Реализованы сервисы ВМ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,533 @@
from __future__ import annotations
import os
import ssl
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
def postgres_ssl_context() -> ssl.SSLContext:
ca_file = os.environ.get("PG_CA_FILE")
if not ca_file:
raise RuntimeError("PG_CA_FILE is required")
context = ssl.create_default_context(cafile=ca_file)
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
return context
@dataclass(frozen=True)
class LeasedTask:
id: uuid.UUID
task_type: str
user_id: uuid.UUID
lease_token: uuid.UUID
attempt_count: int
@dataclass(frozen=True)
class LeasedWebhook:
id: uuid.UUID
receiver_type: str
event_type: str
external_id: str
lease_token: uuid.UUID
attempt_count: int
@dataclass(frozen=True)
class Profile:
user_id: uuid.UUID
phone: str
identity_status: str
profile_status: str
class Repository:
def __init__(self, database_url: str, pool_size: int = 5) -> None:
self.engine: AsyncEngine = create_async_engine(
database_url,
pool_size=pool_size,
pool_pre_ping=True,
connect_args={"ssl": postgres_ssl_context()},
)
async def close(self) -> None:
await self.engine.dispose()
@asynccontextmanager
async def transaction(self) -> AsyncIterator[AsyncConnection]:
async with self.engine.begin() as connection:
yield connection
async def ping(self) -> bool:
try:
async with self.engine.connect() as connection:
await connection.execute(text("SELECT 1"))
return True
except Exception:
return False
async def claim_tasks(
self, worker_id: str, limit: int, lease_seconds: int
) -> list[LeasedTask]:
sql = text(
"""
WITH candidates AS (
SELECT id
FROM han_app.sync_queue
WHERE status IN ('pending','retry_wait')
AND next_attempt_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY next_attempt_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT :limit
)
UPDATE han_app.sync_queue q
SET status='leased', locked_by=:worker_id,
locked_until=now() + make_interval(secs => :lease_seconds),
lease_token=gen_random_uuid(), updated_at=now()
FROM candidates c
WHERE q.id=c.id
RETURNING q.id, q.task_type, q.entity_id, q.lease_token, q.attempt_count
"""
)
async with self.engine.begin() as connection:
rows = (
await connection.execute(
sql,
{"worker_id": worker_id, "limit": limit, "lease_seconds": lease_seconds},
)
).mappings()
return [
LeasedTask(
row["id"],
row["task_type"],
row["entity_id"],
row["lease_token"],
row["attempt_count"],
)
for row in rows
]
async def load_profile(self, user_id: uuid.UUID) -> Profile | None:
sql = text(
"""
SELECT i.id user_id, i.phone_number phone, i.record_status identity_status,
p.record_status profile_status
FROM han_app.user_identities i
JOIN han_app.client_profiles p ON p.user_id=i.id
WHERE i.id=:user_id
"""
)
async with self.engine.connect() as connection:
row = (await connection.execute(sql, {"user_id": user_id})).mappings().first()
return Profile(**row) if row else None
async def active_mapping(self, user_id: uuid.UUID) -> str | None:
sql = text(
"""
SELECT external_id FROM bitrix_sync.entity_external_mapping
WHERE external_system='bitrix24' AND entity_type='contact'
AND entity_id=:user_id AND status='active'
"""
)
async with self.engine.connect() as connection:
return (await connection.execute(sql, {"user_id": user_id})).scalar_one_or_none()
async def create_workflow(self, task: LeasedTask) -> uuid.UUID:
workflow_id = uuid.uuid4()
sql = text(
"""
INSERT INTO bitrix_sync.workflow_instances
(id, workflow_type, user_id, state, current_step, source_task_id,
deadline_at, created_at, updated_at)
VALUES (:id, :workflow_type, :user_id, 'created', 'load_profile', :task_id,
now() + interval '24 hours', now(), now())
ON CONFLICT (source_task_id) DO UPDATE SET updated_at=now()
RETURNING id
"""
)
async with self.engine.begin() as connection:
return (
await connection.execute(
sql,
{
"id": workflow_id,
"workflow_type": task.task_type,
"user_id": task.user_id,
"task_id": task.id,
},
)
).scalar_one()
async def complete_task(self, task: LeasedTask, workflow_id: uuid.UUID) -> bool:
async with self.engine.begin() as connection:
result = await connection.execute(
text(
"""
UPDATE han_app.sync_queue
SET status='processed', completed_at=now(), locked_by=NULL,
locked_until=NULL, lease_token=NULL, updated_at=now()
WHERE id=:id AND status='leased' AND lease_token=:lease_token
"""
),
{"id": task.id, "lease_token": task.lease_token},
)
if result.rowcount != 1:
return False
await connection.execute(
text(
"""
UPDATE bitrix_sync.workflow_instances
SET state='succeeded', current_step='done', outcome='processed',
completed_at=now(), updated_at=now()
WHERE id=:workflow_id AND state NOT IN ('succeeded','failed','cancelled')
"""
),
{"workflow_id": workflow_id},
)
return True
async def retry_task(
self, task: LeasedTask, safe_code: str, delay_seconds: float
) -> bool:
sql = text(
"""
UPDATE han_app.sync_queue
SET status='retry_wait', attempt_count=attempt_count+1,
next_attempt_at=now() + make_interval(secs => :delay),
last_error_code=:code, last_error_at=now(),
locked_by=NULL, locked_until=NULL, lease_token=NULL, updated_at=now()
WHERE id=:id AND status='leased' AND lease_token=:lease_token
"""
)
async with self.engine.begin() as connection:
result = await connection.execute(
sql,
{
"id": task.id,
"lease_token": task.lease_token,
"code": safe_code[:64],
"delay": delay_seconds,
},
)
return result.rowcount == 1
async def insert_webhook(
self,
receiver_type: str,
event_type: str,
entity_id: str,
source_ip: str,
) -> uuid.UUID:
inbox_id = uuid.uuid4()
async with self.engine.begin() as connection:
existing = await connection.execute(
text(
"""
SELECT id FROM bitrix_sync.webhook_inbox
WHERE receiver_type=:receiver AND external_entity_id=:entity_id
AND status IN ('received','processing','retry_wait')
FOR UPDATE
"""
),
{"receiver": receiver_type, "entity_id": entity_id},
)
if row := existing.first():
await connection.execute(
text(
"""
UPDATE bitrix_sync.webhook_inbox
SET coalesced_count=coalesced_count+1, last_received_at=now()
WHERE id=:id
"""
),
{"id": row[0]},
)
return row[0]
await connection.execute(
text(
"""
INSERT INTO bitrix_sync.webhook_inbox
(id, receiver_type, event_type, external_entity_id, source_ip,
status, coalesced_count, received_at, last_received_at)
VALUES (:id,:receiver,:event,:entity_id,CAST(:source_ip AS inet),
'received',1,now(),now())
"""
),
{
"id": inbox_id,
"receiver": receiver_type,
"event": event_type,
"entity_id": entity_id,
"source_ip": source_ip,
},
)
return inbox_id
async def claim_webhooks(
self, worker_id: str, limit: int, lease_seconds: int
) -> list[LeasedWebhook]:
sql = text(
"""
WITH candidates AS (
SELECT id FROM bitrix_sync.webhook_inbox
WHERE status IN ('received','retry_wait') AND next_attempt_at<=now()
AND (locked_until IS NULL OR locked_until<now())
ORDER BY next_attempt_at,received_at
FOR UPDATE SKIP LOCKED LIMIT :limit
)
UPDATE bitrix_sync.webhook_inbox w
SET status='processing',locked_by=:worker_id,
locked_until=now()+make_interval(secs=>:lease_seconds),
lease_token=gen_random_uuid()
FROM candidates c WHERE w.id=c.id
RETURNING w.id,w.receiver_type,w.event_type,w.external_entity_id,
w.lease_token,w.attempt_count
"""
)
async with self.engine.begin() as connection:
rows = (
await connection.execute(
sql,
{"worker_id": worker_id, "limit": limit, "lease_seconds": lease_seconds},
)
).mappings()
return [
LeasedWebhook(
row["id"],
row["receiver_type"],
row["event_type"],
row["external_entity_id"],
row["lease_token"],
row["attempt_count"],
)
for row in rows
]
async def mapped_user_for_external(self, external_id: str) -> uuid.UUID | None:
async with self.engine.connect() as connection:
return (
await connection.execute(
text(
"""
SELECT entity_id FROM bitrix_sync.entity_external_mapping
WHERE external_system='bitrix24' AND external_entity_type='contact'
AND external_id=:external_id AND status='active'
"""
),
{"external_id": external_id},
)
).scalar_one_or_none()
async def apply_crm_profile(
self,
user_id: uuid.UUID,
external_id: str,
*,
full_name: str | None,
citizenship: str | None,
email: str | None,
source_updated_at: datetime | None,
source: str,
) -> None:
async with self.engine.begin() as connection:
await connection.execute(text("SET LOCAL han.sync_suppress='true'"))
await connection.execute(
text(
"""
UPDATE han_app.client_profiles
SET full_name=:full_name,citizenship=:citizenship,email=:email,updated_at=now()
WHERE user_id=:user_id AND record_status='A'
"""
),
{
"user_id": user_id,
"full_name": full_name,
"citizenship": citizenship,
"email": email,
},
)
await connection.execute(
text(
"""
INSERT INTO bitrix_sync.contact_snapshots
(id,mapping_id,user_id,external_id,full_name_hash,email_hash,
citizenship_hash,source_updated_at,last_applied_source,
last_webhook_received_at,created_at,updated_at)
SELECT gen_random_uuid(),m.id,:user_id,:external_id,
encode(digest(coalesce(:full_name,''),'sha256'),'hex'),
encode(digest(coalesce(:email,''),'sha256'),'hex'),
encode(digest(coalesce(:citizenship,''),'sha256'),'hex'),
:source_updated_at,:source,
CASE WHEN :source='webhook' THEN now() END,now(),now()
FROM bitrix_sync.entity_external_mapping m
WHERE m.entity_id=:user_id AND m.external_id=:external_id AND m.status='active'
ON CONFLICT (mapping_id) DO UPDATE
SET full_name_hash=excluded.full_name_hash,email_hash=excluded.email_hash,
citizenship_hash=excluded.citizenship_hash,
source_updated_at=excluded.source_updated_at,
last_applied_source=excluded.last_applied_source,
last_webhook_received_at=coalesce(
excluded.last_webhook_received_at,
bitrix_sync.contact_snapshots.last_webhook_received_at),
updated_at=now()
"""
),
{
"user_id": user_id,
"external_id": external_id,
"full_name": full_name,
"citizenship": citizenship,
"email": email,
"source_updated_at": source_updated_at,
"source": source,
},
)
async def complete_webhook(self, item: LeasedWebhook) -> bool:
async with self.engine.begin() as connection:
result = await connection.execute(
text(
"""
UPDATE bitrix_sync.webhook_inbox
SET status='processed',processed_at=now(),locked_by=NULL,
locked_until=NULL,lease_token=NULL
WHERE id=:id AND status='processing' AND lease_token=:lease_token
"""
),
{"id": item.id, "lease_token": item.lease_token},
)
return result.rowcount == 1
async def retry_webhook(
self, item: LeasedWebhook, safe_code: str, delay_seconds: float
) -> bool:
async with self.engine.begin() as connection:
result = await connection.execute(
text(
"""
UPDATE bitrix_sync.webhook_inbox
SET status='retry_wait',attempt_count=attempt_count+1,
next_attempt_at=now()+make_interval(secs=>:delay),
safe_error_code=:code,locked_by=NULL,locked_until=NULL,lease_token=NULL
WHERE id=:id AND status='processing' AND lease_token=:lease_token
"""
),
{
"id": item.id,
"lease_token": item.lease_token,
"code": safe_code[:64],
"delay": delay_seconds,
},
)
return result.rowcount == 1
async def pending_rebind_ids(self, limit: int) -> list[uuid.UUID]:
async with self.engine.connect() as connection:
rows = await connection.execute(
text(
"""
SELECT id FROM bitrix_sync.rebind_requests
WHERE status IN ('pending','retry_wait')
ORDER BY requested_at LIMIT :limit
"""
),
{"limit": limit},
)
return list(rows.scalars())
async def reserve_limiter_token(self, refill_per_second: float, burst: int) -> float:
async with self.engine.begin() as connection:
row = (
await connection.execute(
text(
"""
SELECT tokens,capacity,refill_per_second,
extract(epoch FROM now()-updated_at) elapsed,
greatest(0,extract(epoch FROM blocked_until-now())) blocked
FROM bitrix_sync.limiter_coordination
WHERE limiter_key='bitrix24:portal'
FOR UPDATE
"""
)
)
).mappings().first()
if row is None:
await connection.execute(
text(
"""
INSERT INTO bitrix_sync.limiter_coordination
(limiter_key,tokens,capacity,refill_per_second,updated_at,fencing_token)
VALUES ('bitrix24:portal',:tokens,:capacity,:refill,now(),1)
"""
),
{"tokens": max(0, burst - 1), "capacity": burst, "refill": refill_per_second},
)
return 0
blocked = float(row["blocked"] or 0)
tokens = min(
float(burst),
float(row["tokens"]) + float(row["elapsed"] or 0) * refill_per_second,
)
if blocked > 0:
delay = blocked
elif tokens >= 1:
tokens -= 1
delay = 0
else:
delay = (1 - tokens) / refill_per_second
await connection.execute(
text(
"""
UPDATE bitrix_sync.limiter_coordination
SET tokens=:tokens,capacity=:capacity,refill_per_second=:refill,
updated_at=now(),fencing_token=fencing_token+1
WHERE limiter_key='bitrix24:portal'
"""
),
{
"tokens": tokens,
"capacity": burst,
"refill": refill_per_second,
},
)
return delay
async def status(self) -> dict[str, Any]:
queries = {
"queue": "SELECT status, count(*) count FROM han_app.sync_queue GROUP BY status",
"workflows": (
"SELECT state, count(*) count FROM bitrix_sync.workflow_instances GROUP BY state"
),
"commands": (
"SELECT status, count(*) count "
"FROM bitrix_sync.crm_commands GROUP BY status"
),
"webhook_lag_seconds": (
"SELECT coalesce(extract(epoch from now()-min(received_at)),0) "
"FROM bitrix_sync.webhook_inbox WHERE status IN ('received','retry_wait')"
),
"settings_version": (
"SELECT version FROM bitrix_sync.settings_versions "
"WHERE active=true AND validation_status='valid' ORDER BY activated_at DESC LIMIT 1"
),
}
output: dict[str, Any] = {}
async with self.engine.connect() as connection:
for key, sql in queries.items():
result = await connection.execute(text(sql))
if key in {"queue", "workflows", "commands"}:
output[key] = {row.status: row.count for row in result}
else:
output[key] = result.scalar_one_or_none()
output["generated_at"] = datetime.now(UTC).isoformat()
return output