Поправили заполлнение БД + поправили гонку сообщений при отправке в Битрикс

This commit is contained in:
mi
2026-07-17 11:46:12 +03:00
parent 7457bc5c0b
commit d1a0f3791d
13 changed files with 562 additions and 84 deletions
+82 -24
View File
@@ -40,6 +40,7 @@ from app.db import (
PopularQuestion,
TextResource,
UserIdentity,
UxSession,
)
from app.integrations import (
DependencyFailure,
@@ -62,6 +63,7 @@ from app.schemas import (
encode_cursor,
)
from app.services import (
AuditContext,
DomainError,
SettingsSnapshot,
apply_inbox,
@@ -153,7 +155,7 @@ app = FastAPI(
log = structlog.get_logger()
def client_ip(request: Request) -> str:
def client_ip(request: Request) -> str | None:
"""Trust forwarded client addresses only from configured reverse proxies."""
peer = request.client.host if request.client else ""
try:
@@ -170,7 +172,40 @@ def client_ip(request: Request) -> str:
try:
return str(ip_address(candidate))
except ValueError:
return "unknown"
return None
def request_trace_id(request: Request) -> str:
parts = request.headers.get("traceparent", "").lower().split("-")
if (
len(parts) == 4
and len(parts[0]) == 2
and len(parts[1]) == 32
and len(parts[2]) == 16
and len(parts[3]) == 2
):
try:
int("".join(parts), 16)
if parts[1] != "0" * 32 and parts[2] != "0" * 16:
return parts[1]
except ValueError:
pass
return uuid.uuid4().hex
def user_agent_hash(request: Request) -> str | None:
value = " ".join(request.headers.get("User-Agent", "").split())
return hashlib.sha256(value[:1024].encode()).hexdigest() if value else None
def audit_context(request: Request, ux_session_id: uuid.UUID | None = None) -> AuditContext:
return AuditContext(
request_id=request.state.request_id,
trace_id=request.state.trace_id,
ux_session_id=ux_session_id,
user_agent_hash=request.state.user_agent_hash,
client_ip=client_ip(request),
)
@app.middleware("http")
@@ -181,10 +216,13 @@ async def request_context(request: Request, call_next: Any) -> Response:
except ValueError:
request_id = str(uuid.uuid4())
request.state.request_id = request_id
request.state.trace_id = request_trace_id(request)
request.state.user_agent_hash = user_agent_hash(request)
request.state.started_at = time.monotonic()
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
request_id=request_id,
trace_id=request.state.trace_id,
ux_session_id=request.headers.get("X-Ux-Session-Id"),
method=request.method,
route=request.url.path,
@@ -291,6 +329,26 @@ async def current_user(db: Session, auth: PrincipalDep) -> UserIdentity:
UserDep = Annotated[UserIdentity, Depends(current_user)]
async def required_user_audit_context(
request: Request,
db: Session,
user: UserDep,
x_ux_session_id: Annotated[str, Header(alias="X-Ux-Session-Id")],
) -> AuditContext:
session_id = ux_id(x_ux_session_id)
if session_id is None:
raise DomainError("validation_error", 400, "X-Ux-Session-Id is required")
exists = await db.scalar(
select(UxSession.id).where(UxSession.id == session_id, UxSession.user_id == user.id)
)
if exists is None:
raise DomainError("validation_error", 400, "X-Ux-Session-Id is invalid")
return audit_context(request, session_id)
UserAuditContextDep = Annotated[AuditContext, Depends(required_user_audit_context)]
async def snapshot(db: Session) -> SettingsSnapshot:
return await load_settings(db)
@@ -357,7 +415,7 @@ async def ready(request: Request, db: Session):
try:
await db.execute(text("SELECT 1"))
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
if revision != "0002_pgcrypto_digest":
if revision != "0003_consent_audit":
raise RuntimeError("unexpected database revision")
await load_settings(db)
components["postgres"] = "ok"
@@ -400,7 +458,7 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
await enforce_limit(
request,
"ip",
client_ip(request),
client_ip(request) or "unknown",
"public",
settings.limit("rate_limit.public_endpoints.per_ip"),
fail_closed=False,
@@ -444,7 +502,7 @@ async def content(
await enforce_limit(
request,
"ip",
client_ip(request),
client_ip(request) or "unknown",
"public",
settings.limit("rate_limit.public_endpoints.per_ip"),
fail_closed=False,
@@ -480,7 +538,7 @@ async def content(
async def auth_bootstrap(
body: BootstrapRequest, request: Request, db: Session, auth: PrincipalDep, settings: SnapshotDep
):
return await bootstrap(db, auth, body, settings, request.state.request_id)
return await bootstrap(db, auth, body, settings, audit_context(request))
@app.post("/api/v1/consents", status_code=201, tags=["auth"])
@@ -490,18 +548,16 @@ async def consents(
db: Session,
user: UserDep,
settings: SnapshotDep,
x_ux_session_id: Annotated[str | None, Header()] = None,
context: UserAuditContextDep,
):
return await record_consents(
db, user, body, settings, request.state.request_id, ux_id(x_ux_session_id)
)
return await record_consents(db, user, body, settings, context)
@app.post("/api/v1/analytics/session-start", status_code=201, tags=["analytics"])
async def analytics_session(
body: SessionStartRequest, request: Request, db: Session, user: UserDep
):
return await start_session(db, user, body, request.state.request_id)
return await start_session(db, user, body, audit_context(request))
@app.get("/api/v1/me", tags=["profile"])
@@ -564,7 +620,7 @@ async def document_download(
db: Session,
user: UserDep,
settings: SnapshotDep,
x_ux_session_id: Annotated[str | None, Header()] = None,
context: UserAuditContextDep,
):
await enforce_limit(
request,
@@ -578,11 +634,11 @@ async def document_download(
db.add(
audit(
"document.download_url_issued",
request.state.request_id,
context,
user.id,
"document",
item.id,
ux_id(x_ux_session_id),
metadata={"expires_in_seconds": 300},
)
)
await db.commit()
@@ -595,6 +651,7 @@ async def dialogs_create(
request: Request,
db: Session,
user: UserDep,
context: UserAuditContextDep,
idempotency_key: Annotated[str | None, Header()] = None,
):
key = await required_idempotency(idempotency_key)
@@ -608,7 +665,7 @@ async def dialogs_create(
if cached["fingerprint"] != fingerprint:
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
return JSONResponse(cached["body"], status_code=cached["status"])
body, status = await create_dialog(db, user, request.state.request_id, key)
body, status = await create_dialog(db, user, context, key)
try:
await request.app.state.idempotency.put(
scope,
@@ -735,6 +792,7 @@ async def message_create(
db: Session,
user: UserDep,
business: SnapshotDep,
context: UserAuditContextDep,
idempotency_key: Annotated[str | None, Header()] = None,
):
key = await required_idempotency(idempotency_key)
@@ -772,7 +830,7 @@ async def message_create(
dialog_id,
body,
key,
request.state.request_id,
context,
request.app.state.settings,
request.app.state.safety,
request.app.state.openlines,
@@ -803,6 +861,7 @@ async def attachment_init(
db: Session,
user: UserDep,
settings: SnapshotDep,
context: UserAuditContextDep,
):
await enforce_limit(
request,
@@ -812,9 +871,7 @@ async def attachment_init(
settings.limit("rate_limit.message_send.per_user"),
fail_closed=True,
)
return await init_attachment(
db, user, dialog_id, body, settings, request.app.state.s3, request.state.request_id
)
return await init_attachment(db, user, dialog_id, body, settings, request.app.state.s3, context)
@app.post(
@@ -828,9 +885,10 @@ async def attachment_complete(
request: Request,
db: Session,
user: UserDep,
context: UserAuditContextDep,
):
return await complete_attachment(
db, user, dialog_id, attachment_id, body, request.app.state.s3, request.state.request_id
db, user, dialog_id, attachment_id, body, request.app.state.s3, context
)
@@ -845,7 +903,7 @@ async def attachment_download(
db: Session,
user: UserDep,
settings: SnapshotDep,
x_ux_session_id: Annotated[str | None, Header()] = None,
context: UserAuditContextDep,
):
await enforce_limit(
request,
@@ -871,11 +929,11 @@ async def attachment_download(
db.add(
audit(
"attachment.download_url_issued",
request.state.request_id,
context,
user.id,
"attachment",
item.id,
ux_id(x_ux_session_id),
metadata={"expires_in_seconds": 300},
)
)
await db.commit()
@@ -895,7 +953,7 @@ async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings:
body, status = await apply_inbox(
db,
event,
request.state.request_id,
audit_context(request),
settings,
request.app.state.s3,
request.app.state.http,