Реализованы сервисы ВМ2 - проверка сообщений и синхронизация с Б24 (деплой еще без перевода в боевой режим)

This commit is contained in:
mi
2026-08-13 18:52:42 +03:00
parent 5100ba9fc3
commit 99605b1c77
144 changed files with 15295 additions and 1120 deletions
@@ -0,0 +1,188 @@
"""Expand module-07 queue and stage canonical Bitrix mapping.
Revision ID: 0011_module07_contract
Revises: 0010_contact_map_dedup
Create Date: 2026-08-06
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0011_module07_contract"
down_revision: str | None = "0010_contact_map_dedup"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Expand first. Existing rows remain readable throughout the migration.
op.execute(
"""
ALTER TABLE han_app.sync_queue
ADD COLUMN IF NOT EXISTS locked_by varchar(128),
ADD COLUMN IF NOT EXISTS locked_until timestamptz,
ADD COLUMN IF NOT EXISTS lease_token uuid,
ADD COLUMN IF NOT EXISTS last_error_code varchar(64),
ADD COLUMN IF NOT EXISTS last_error_at timestamptz,
ADD COLUMN IF NOT EXISTS completed_at timestamptz,
ADD COLUMN IF NOT EXISTS cancel_reason varchar(255);
UPDATE han_app.sync_queue
SET status = CASE status
WHEN 'processing' THEN 'pending'
WHEN 'failed' THEN 'retry_wait'
ELSE status
END
WHERE status IN ('processing', 'failed');
ALTER TABLE han_app.sync_queue
DROP CONSTRAINT IF EXISTS sync_queue_status_check;
ALTER TABLE han_app.sync_queue
ADD CONSTRAINT sync_queue_status_check CHECK (
status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled')
) NOT VALID;
ALTER TABLE han_app.sync_queue
VALIDATE CONSTRAINT sync_queue_status_check;
ALTER TABLE han_app.sync_queue
DROP CONSTRAINT IF EXISTS sync_queue_dedup_key_key;
DROP INDEX IF EXISTS han_app.ix_sync_queue_status_next;
CREATE INDEX IF NOT EXISTS ix_sync_queue_claim
ON han_app.sync_queue(status, next_attempt_at, created_at);
CREATE INDEX IF NOT EXISTS ix_sync_queue_expired_lease
ON han_app.sync_queue(locked_until) WHERE status = 'leased';
CREATE INDEX IF NOT EXISTS ix_sync_queue_entity_history
ON han_app.sync_queue(entity_type, entity_id, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS uq_sync_queue_active_dedup
ON han_app.sync_queue(dedup_key)
WHERE status IN ('pending','leased','retry_wait');
"""
)
# The bitrix-sync migration owns the canonical schema. If that migration
# already ran, copy and verify legacy rows here; otherwise its follow-up
# migration performs the same copy. The legacy table remains until the
# readers have switched and the contract migration is explicitly approved.
op.execute(
"""
DO $$
BEGIN
IF to_regclass('bitrix_sync.entity_external_mapping') IS NOT NULL THEN
INSERT INTO bitrix_sync.entity_external_mapping (
id, entity_type, entity_id, external_system, external_entity_type,
external_id, status, opened_at, created_at, updated_at
)
SELECT id, entity_type, entity_id, 'bitrix24', 'contact',
external_id, 'active', created_at, created_at, created_at
FROM han_app.entity_external_mapping
ON CONFLICT DO NOTHING;
IF EXISTS (
SELECT 1
FROM han_app.entity_external_mapping legacy
LEFT JOIN bitrix_sync.entity_external_mapping canonical
ON canonical.id = legacy.id
AND canonical.entity_type = legacy.entity_type
AND canonical.entity_id = legacy.entity_id
AND canonical.external_id = legacy.external_id
WHERE canonical.id IS NULL
) THEN
RAISE EXCEPTION 'canonical mapping verification failed';
END IF;
END IF;
END $$;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
RETURNS trigger
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = han_app, pg_temp
AS $$
DECLARE
v_user_id uuid;
v_task_type varchar(64);
v_reason varchar(64);
v_dedup varchar(255);
v_source_updated_at timestamptz;
BEGIN
IF current_setting('han.sync_suppress', true) = 'true' THEN
RETURN NEW;
END IF;
IF TG_TABLE_NAME = 'user_identities' THEN
v_user_id := NEW.id;
v_source_updated_at := NEW.updated_at;
IF TG_OP = 'INSERT' THEN
IF NEW.record_status <> 'A' THEN RETURN NEW; END IF;
v_task_type := 'contact.map_or_create';
v_reason := 'identity_created';
ELSIF OLD.record_status = 'A' AND NEW.record_status <> 'A' THEN
v_task_type := 'contact.deactivate';
v_reason := 'identity_deactivated';
ELSIF OLD.record_status <> 'A' AND NEW.record_status = 'A' THEN
v_task_type := 'contact.map_or_create';
v_reason := 'identity_reactivated';
ELSIF NEW.record_status = 'A'
AND NEW.phone_number IS DISTINCT FROM OLD.phone_number THEN
v_task_type := 'contact.update';
v_reason := 'identity_phone_changed';
ELSE
RETURN NEW;
END IF;
ELSE
v_user_id := NEW.user_id;
v_source_updated_at := NEW.updated_at;
IF TG_OP = 'INSERT' THEN
IF NEW.record_status <> 'A' THEN RETURN NEW; END IF;
v_task_type := 'contact.map_or_create';
v_reason := 'profile_created';
ELSIF OLD.record_status = 'A' AND NEW.record_status <> 'A' THEN
v_task_type := 'contact.deactivate';
v_reason := 'profile_deactivated';
ELSIF OLD.record_status <> 'A' AND NEW.record_status = 'A' THEN
v_task_type := 'contact.map_or_create';
v_reason := 'profile_reactivated';
ELSE
RETURN NEW;
END IF;
END IF;
v_dedup := v_task_type || ':' || v_user_id::text;
INSERT INTO han_app.sync_queue (
id, task_type, entity_type, entity_id, dedup_key, payload_json,
status, attempt_count, next_attempt_at, created_at, updated_at
) VALUES (
gen_random_uuid(), v_task_type, 'contact', v_user_id, v_dedup,
jsonb_build_object(
'schema_version', 1,
'user_id', v_user_id,
'reason', v_reason,
'source_updated_at', v_source_updated_at
),
'pending', 0, now(), now(), now()
)
ON CONFLICT (dedup_key)
WHERE status IN ('pending','leased','retry_wait')
DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
CREATE TRIGGER trg_profile_contact_sync
AFTER INSERT OR UPDATE OF record_status
ON han_app.client_profiles
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
"""
)
def downgrade() -> None:
raise RuntimeError("Module-07 staged contract migration is forward-only")
@@ -0,0 +1,74 @@
"""Persist Message Safety v2 evidence and recovery locations.
Revision ID: 0012_safety_v2_checkpoint
Revises: 0011_module07_contract
Create Date: 2026-08-06
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0012_safety_v2_checkpoint"
down_revision: str | None = "0011_module07_contract"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE han_app.messages
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
ALTER TABLE han_app.message_attachments
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024);
ALTER TABLE han_app.message_attachments
DROP CONSTRAINT IF EXISTS message_attachments_scan_status_check;
ALTER TABLE han_app.message_attachments
ADD CONSTRAINT message_attachments_scan_status_check
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
ALTER TABLE han_app.message_attachments
VALIDATE CONSTRAINT message_attachments_scan_status_check;
ALTER TABLE han_app.safety_tasks
ADD COLUMN IF NOT EXISTS poll_location varchar(1024),
ADD COLUMN IF NOT EXISTS processing_mode varchar(16),
ADD COLUMN IF NOT EXISTS config_version bigint,
ADD COLUMN IF NOT EXISTS rules_version varchar(128),
ADD COLUMN IF NOT EXISTS expires_at timestamptz;
UPDATE han_app.safety_tasks
SET poll_location = '/internal/safety/v2/messages/tasks/' || task_id,
expires_at = deadline_at
WHERE poll_location IS NULL OR expires_at IS NULL;
ALTER TABLE han_app.safety_tasks
ALTER COLUMN poll_location SET NOT NULL,
ALTER COLUMN expires_at SET NOT NULL;
"""
)
# Notification uploads use the same safety contract.
op.execute(
"""
ALTER TABLE han_app.client_upload_drafts
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024),
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
ALTER TABLE han_app.client_upload_drafts
DROP CONSTRAINT IF EXISTS client_upload_drafts_scan_status_check;
ALTER TABLE han_app.client_upload_drafts
ADD CONSTRAINT client_upload_drafts_scan_status_check
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
ALTER TABLE han_app.client_upload_drafts
VALIDATE CONSTRAINT client_upload_drafts_scan_status_check;
"""
)
def downgrade() -> None:
raise RuntimeError("Safety v2 checkpoint migration is forward-only")