Files
group_fqcd_jr/app/worker/offsite_mail_worker.py

459 lines
18 KiB
Python

"""场外基金收件箱独立 Worker。"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Protocol
from uuid import uuid4
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.contracts import RequestContext
from app.core.offsite_fund_contracts import (
ReceiveRecognizedMailRequest,
RecognizedAttachment,
)
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
from app.model.offsite_fund import OffsiteFundMail, OffsiteMailCursor, OffsiteNotification
from app.service.offsite_document_recognition_adapter import (
OffsiteDocumentRecognitionAdapter,
RecognitionSourceFile,
StructuredRecognitionResult,
)
from app.service.offsite_fund_service import OffsiteFundService
from app.service.offsite_mail_adapter import (
OffsiteImapReceiver,
OffsiteMailStorage,
RawMailMessage,
SavedMailAttachment,
)
logger = logging.getLogger(__name__)
class MailReceiver(Protocol):
last_scanned_uid: str | None
def health_check(self) -> dict[str, object]: ...
def fetch_since(self, last_uid: str | None, *, limit: int) -> tuple[RawMailMessage, ...]: ...
def close(self) -> None: ...
class MailRecognizer(Protocol):
async def recognize(self, source: RecognitionSourceFile) -> StructuredRecognitionResult: ...
class MailService(Protocol):
async def receive_recognized_mail(
self, payload: ReceiveRecognizedMailRequest, context: RequestContext
) -> dict[str, object]: ...
class CursorLease:
def __init__(self, lease_id: str, last_uid: str) -> None:
self.lease_id = lease_id
self.last_uid = last_uid
class OffsiteMailWorker:
"""按 UID 顺序处理邮件,失败时停在当前 UID 并等待补偿。"""
def __init__(
self,
settings: Settings,
*,
receiver: MailReceiver | None = None,
storage: OffsiteMailStorage | None = None,
recognizer: MailRecognizer | None = None,
identity_resolver: Callable[[RequestContext], Awaitable[RequestContext]] | None = None,
session_factory: Callable[[], AsyncSession] = SessionFactory,
service_factory: Callable[[AsyncSession], MailService] | None = None,
) -> None:
self.settings = settings
self.receiver = receiver or OffsiteImapReceiver(settings)
self.storage = storage or OffsiteMailStorage(settings.offsite_mail_storage_dir)
self.recognizer = recognizer or OffsiteDocumentRecognitionAdapter(settings)
self.identity_resolver = identity_resolver
self.session_factory = session_factory
self.service_factory = service_factory or OffsiteFundService
async def run_once(self) -> bool:
if not self.settings.offsite_mail_worker_enabled:
return False
recovered = await self.recover_stale_notifications()
if not self.settings.offsite_imap_enabled:
return recovered
if not self.settings.offsite_worker_user_id:
logger.error("场外 Worker 未配置合法操作用户 ID,拒绝自动写入业务数据")
return recovered
try:
context = await self._resolve_worker_context()
lease = await self._claim_cursor()
if lease is None:
return recovered
return await self._process_batch(lease, context) or recovered
except asyncio.CancelledError:
raise
except Exception:
logger.exception("场外邮件 Worker 执行失败")
return True
async def close(self) -> None:
self.receiver.close()
close = getattr(self.recognizer, "close", None)
if callable(close):
result = close()
if asyncio.iscoroutine(result):
await result
async def recover_stale_notifications(self) -> bool:
now = datetime.now(UTC).replace(tzinfo=None)
cutoff = now - timedelta(
seconds=self.settings.offsite_notification_sending_timeout_seconds
)
async with self.session_factory() as session, session.begin():
activity = func.coalesce(
OffsiteNotification.updated_at, OffsiteNotification.created_at
)
result = await session.execute(
update(OffsiteNotification)
.where(
OffsiteNotification.status == "发送中",
activity < cutoff,
)
.values(
status="发送失败",
failure_reason="发送状态不确定,需人工核验外部邮箱后再决定是否重试",
updated_at=now,
)
)
count = int(getattr(result, "rowcount", 0) or 0)
if count:
session.add(
InteractionAudit(
actor_type="system",
actor_id=None,
target_customer_id=None,
session_id=None,
portal="worker",
action_type="offsite.notification_timeout_recovered",
detail={"count": count},
created_at=now,
)
)
return count > 0
async def _resolve_worker_context(self) -> RequestContext:
identity = RequestContext(
user_id=self.settings.offsite_worker_user_id,
trace_id=f"offsite-worker-{uuid4()}",
portal="worker",
)
resolver = self.identity_resolver
if resolver is None:
from app.service.identity_service import IdentityService
resolver = IdentityService().resolve
context = await resolver(identity)
if not context.roles or not context.permissions:
raise RuntimeError("场外 Worker 操作用户未通过角色和权限校验")
return context
async def _claim_cursor(self) -> CursorLease | None:
for attempt in range(2):
lease_id = str(uuid4())
now = datetime.now(UTC).replace(tzinfo=None)
try:
async with self.session_factory() as session, session.begin():
cursor = await session.scalar(
select(OffsiteMailCursor)
.where(
OffsiteMailCursor.mailbox == self.settings.offsite_mailbox,
OffsiteMailCursor.folder == OffsiteImapReceiver.inbox_name,
)
.with_for_update()
)
if cursor is None:
cursor = OffsiteMailCursor(
mailbox=self.settings.offsite_mailbox,
folder=OffsiteImapReceiver.inbox_name,
last_uid="0",
status="idle",
retry_count=0,
created_at=now,
updated_at=now,
)
session.add(cursor)
await session.flush()
if cursor.status == "blocked":
return None
if cursor.lease_until is not None and cursor.lease_until > now:
return None
if cursor.next_retry_at is not None and cursor.next_retry_at > now:
return None
cursor.status = "processing"
cursor.lease_id = lease_id
cursor.lease_until = now + timedelta(
seconds=self.settings.worker_lease_seconds
)
cursor.updated_at = now
return CursorLease(lease_id, cursor.last_uid)
except IntegrityError:
if attempt == 1:
raise
return None
async def _process_batch(self, lease: CursorLease, context: RequestContext) -> bool:
task = asyncio.current_task()
if task is None:
raise RuntimeError("场外 Worker 无法建立当前任务租约")
heartbeat = asyncio.create_task(self._cursor_heartbeat(lease.lease_id, task))
try:
return await self._process_batch_work(lease, context)
finally:
heartbeat.cancel()
with suppress(asyncio.CancelledError):
await heartbeat
async def _process_batch_work(self, lease: CursorLease, context: RequestContext) -> bool:
try:
health = await asyncio.to_thread(self.receiver.health_check)
if health.get("status") != "ok":
message = str(health.get("message") or health.get("status") or "IMAP不可用")
raise RuntimeError(f"场外 IMAP 健康检查失败:{message}")
messages = await asyncio.to_thread(
self.receiver.fetch_since,
lease.last_uid,
limit=self.settings.offsite_mail_worker_batch_size,
)
current_uid = lease.last_uid
for mail in sorted(messages, key=self._uid_sort_key):
if not self._uid_after(mail.imap_uid, current_uid):
continue
try:
await self._process_message(mail, context)
except asyncio.CancelledError:
raise
except Exception as exc:
await self._record_failure(lease, mail, exc)
return True
current_uid = mail.imap_uid
await self._advance_cursor(lease.lease_id, current_uid, release=False)
scanned_uid = self.receiver.last_scanned_uid
if scanned_uid is not None and self._uid_after(scanned_uid, current_uid):
current_uid = scanned_uid
await self._advance_cursor(lease.lease_id, current_uid, release=True)
return bool(messages) or scanned_uid is not None
except asyncio.CancelledError:
raise
except Exception as exc:
await self._record_cursor_failure(lease, exc)
self.receiver.close()
return True
async def _cursor_heartbeat(self, lease_id: str, task: asyncio.Task[bool]) -> None:
try:
while True:
await asyncio.sleep(self.settings.worker_lease_seconds / 3)
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session_factory() as session, session.begin():
result = await session.execute(
update(OffsiteMailCursor)
.where(OffsiteMailCursor.lease_id == lease_id)
.values(
lease_until=now + timedelta(
seconds=self.settings.worker_lease_seconds
),
updated_at=now,
)
)
if int(getattr(result, "rowcount", 0) or 0) != 1:
task.cancel()
return
except asyncio.CancelledError:
raise
except Exception:
logger.exception("场外 Worker 游标租约续期失败")
task.cancel()
async def _process_message(self, mail: RawMailMessage, context: RequestContext) -> None:
saved = await asyncio.to_thread(self.storage.save, mail)
attachments: list[RecognizedAttachment] = []
for saved_attachment in saved.attachments:
payload = await asyncio.to_thread(Path(saved_attachment.original_file_path).read_bytes)
recognition = await self.recognizer.recognize(
RecognitionSourceFile(
filename=saved_attachment.filename,
media_type=saved_attachment.media_type,
payload=payload,
file_hash=saved_attachment.file_hash,
original_file_path=saved_attachment.original_file_path,
)
)
if self._recognition_failed(recognition):
raise RuntimeError("附件识别失败,已保留原始文件并等待补偿")
attachments.append(
self._recognized_attachment(saved_attachment, recognition)
)
request = ReceiveRecognizedMailRequest(
imap_uid=saved.imap_uid,
message_id=saved.message_id,
sender=saved.sender,
return_path=saved.return_path,
auth_result=saved.auth_result,
eml_path=saved.eml_path,
attachments=tuple(attachments),
)
async with self.session_factory() as session:
service = self.service_factory(session)
result = await service.receive_recognized_mail(request, context)
if result.get("code") != 0:
raise RuntimeError(f"场外业务入库失败:{result.get('message', '未知错误')}")
async def _advance_cursor(
self, lease_id: str, last_uid: str, *, release: bool
) -> None:
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session_factory() as session, session.begin():
cursor = await session.scalar(
select(OffsiteMailCursor)
.where(OffsiteMailCursor.lease_id == lease_id)
.with_for_update()
)
if cursor is None:
return
cursor.last_uid = last_uid
cursor.status = "idle" if release else "processing"
if release:
cursor.retry_count = 0
cursor.last_error = None
cursor.blocked_uid = None
cursor.blocked_message_id = None
cursor.next_retry_at = None
cursor.lease_id = None
cursor.lease_until = None
cursor.updated_at = now
async def _record_failure(
self, lease: CursorLease, mail: RawMailMessage, exc: Exception
) -> None:
now = datetime.now(UTC).replace(tzinfo=None)
message = self._error_message(exc)
async with self.session_factory() as session, session.begin():
cursor = await session.scalar(
select(OffsiteMailCursor)
.where(OffsiteMailCursor.lease_id == lease.lease_id)
.with_for_update()
)
if cursor is None:
return
cursor.retry_count += 1
cursor.status = (
"blocked"
if cursor.retry_count >= self.settings.offsite_max_retry_count
else "failed"
)
cursor.blocked_uid = mail.imap_uid
cursor.blocked_message_id = mail.message_id
cursor.last_error = message
cursor.next_retry_at = (
None
if cursor.status == "blocked"
else now + timedelta(seconds=min(300, 2**cursor.retry_count))
)
cursor.lease_id = None
cursor.lease_until = None
cursor.updated_at = now
await session.execute(
update(OffsiteFundMail)
.where(
OffsiteFundMail.imap_uid == mail.imap_uid,
OffsiteFundMail.message_id == mail.message_id,
)
.values(
retry_count=OffsiteFundMail.retry_count + 1,
last_error=message,
next_retry_at=cursor.next_retry_at,
last_attempt_at=now,
updated_at=now,
)
)
logger.error(
"场外邮件处理失败 uid=%s message_id=%s error=%s",
mail.imap_uid,
mail.message_id,
message,
)
async def _record_cursor_failure(self, lease: CursorLease, exc: Exception) -> None:
message = self._error_message(exc)
now = datetime.now(UTC).replace(tzinfo=None)
async with self.session_factory() as session, session.begin():
cursor = await session.scalar(
select(OffsiteMailCursor)
.where(OffsiteMailCursor.lease_id == lease.lease_id)
.with_for_update()
)
if cursor is None:
return
cursor.retry_count += 1
cursor.status = "failed"
cursor.last_error = message
cursor.next_retry_at = now + timedelta(
seconds=min(300, 2**cursor.retry_count)
)
cursor.lease_id = None
cursor.lease_until = None
cursor.updated_at = now
logger.error("场外邮件批处理失败 error=%s", message)
@staticmethod
def _uid_sort_key(mail: RawMailMessage) -> tuple[int, str]:
return (int(mail.imap_uid) if mail.imap_uid.isdigit() else 2**63 - 1, mail.imap_uid)
@staticmethod
def _uid_after(candidate: str, current: str) -> bool:
if candidate.isdigit() and current.isdigit():
return int(candidate) > int(current)
return candidate > current
@staticmethod
def _recognition_failed(result: object) -> bool:
error = getattr(result, "error_message", None)
ocr_status = getattr(result, "ocr_status", None)
llm_status = getattr(result, "llm_status", None)
failed = {"error", "misconfigured"}
return bool(error and (ocr_status in failed or llm_status in failed))
@staticmethod
def _recognized_attachment(
saved: SavedMailAttachment, result: StructuredRecognitionResult
) -> RecognizedAttachment:
return RecognizedAttachment(
filename=saved.filename,
file_hash=saved.file_hash,
original_file_path=saved.original_file_path,
media_type=saved.media_type,
size_bytes=saved.size_bytes,
document_type=result.document_type,
extracted_fields=result.extracted_fields,
field_confidence=result.field_confidence,
ocr_text=result.ocr_text,
page_evidence=result.page_evidence,
)
@staticmethod
def _error_message(exc: Exception) -> str:
return f"{type(exc).__name__}: {str(exc)[:450]}"