袁聪的第二次提交,项目已完整

This commit is contained in:
2026-09-11 16:57:47 +08:00
parent 5907fcd6d2
commit fd9598efdf
190 changed files with 31513 additions and 680 deletions
+214 -17
View File
@@ -23,8 +23,14 @@ from app.core.offsite_fund_contracts import (
)
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
from app.model.offsite_fund import OffsiteFundMail, OffsiteMailCursor, OffsiteNotification
from app.model.offsite_fund import (
OffsiteFundMail,
OffsiteMailCursor,
OffsiteNotification,
OffsiteRecognitionAttempt,
)
from app.service.offsite_document_recognition_adapter import (
REQUIRED_FIELDS,
OffsiteDocumentRecognitionAdapter,
RecognitionSourceFile,
StructuredRecognitionResult,
@@ -47,6 +53,8 @@ class MailReceiver(Protocol):
def fetch_since(self, last_uid: str | None, *, limit: int) -> tuple[RawMailMessage, ...]: ...
def wait_for_new_mail(self, timeout_seconds: float) -> bool: ...
def close(self) -> None: ...
@@ -94,6 +102,9 @@ class OffsiteMailWorker:
recovered = await self.recover_stale_notifications()
if not self.settings.offsite_imap_enabled:
return recovered
if not self._real_recognition_ready():
logger.error("场外 Worker 拒绝处理:真实 IMAP 不得使用 Mock 识别结果")
return recovered
if not self.settings.offsite_worker_user_id:
logger.error("场外 Worker 未配置合法操作用户 ID,拒绝自动写入业务数据")
return recovered
@@ -117,6 +128,21 @@ class OffsiteMailWorker:
if asyncio.iscoroutine(result):
await result
def _real_recognition_ready(self) -> bool:
if not isinstance(self.recognizer, OffsiteDocumentRecognitionAdapter):
return True
if not self.settings.offsite_ocr_enabled or not self.settings.offsite_deepseek_enabled:
return False
health = self.recognizer.health_check()
ocr = health.get("ocr")
deepseek = health.get("deepseek")
return (
isinstance(ocr, dict)
and ocr.get("status") == "ok"
and isinstance(deepseek, dict)
and deepseek.get("status") == "ok"
)
async def recover_stale_notifications(self) -> bool:
now = datetime.now(UTC).replace(tzinfo=None)
cutoff = now - timedelta(
@@ -232,11 +258,7 @@ class OffsiteMailWorker:
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,
)
messages = await self._fetch_with_idle_compensation(lease.last_uid)
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):
@@ -262,6 +284,35 @@ class OffsiteMailWorker:
self.receiver.close()
return True
async def _fetch_with_idle_compensation(
self, last_uid: str
) -> tuple[RawMailMessage, ...]:
messages = await asyncio.to_thread(
self.receiver.fetch_since,
last_uid,
limit=self.settings.offsite_mail_worker_batch_size,
)
if (
messages
or self.receiver.last_scanned_uid is not None
or not self.settings.offsite_imap_idle_enabled
):
return messages
wait_for_new_mail = getattr(self.receiver, "wait_for_new_mail", None)
if not callable(wait_for_new_mail):
raise RuntimeError("已启用 IMAP IDLE,但收件适配器未提供 IDLE 等待能力")
has_new_mail = await asyncio.to_thread(
wait_for_new_mail,
self.settings.offsite_imap_idle_timeout_seconds,
)
if not has_new_mail:
return ()
return await asyncio.to_thread(
self.receiver.fetch_since,
last_uid,
limit=self.settings.offsite_mail_worker_batch_size,
)
async def _cursor_heartbeat(self, lease_id: str, task: asyncio.Task[bool]) -> None:
try:
while True:
@@ -291,18 +342,9 @@ class OffsiteMailWorker:
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,
)
recognition = await self._recognize_with_retry(
mail, saved_attachment
)
if self._recognition_failed(recognition):
raise RuntimeError("附件识别失败,已保留原始文件并等待补偿")
attachments.append(
self._recognized_attachment(saved_attachment, recognition)
)
@@ -321,6 +363,118 @@ class OffsiteMailWorker:
if result.get("code") != 0:
raise RuntimeError(f"场外业务入库失败:{result.get('message', '未知错误')}")
async def _recognize_with_retry(
self, mail: RawMailMessage, saved_attachment: SavedMailAttachment
) -> StructuredRecognitionResult:
payload = await asyncio.to_thread(Path(saved_attachment.original_file_path).read_bytes)
source = 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,
)
result: StructuredRecognitionResult | None = None
for attempt_no in range(1, 3):
started_at = datetime.now(UTC).replace(tzinfo=None)
error_message: str | None = None
try:
result = await self.recognizer.recognize(source)
except asyncio.CancelledError:
raise
except Exception as exc:
error_message = self._error_message(exc)
result = None
finished_at = datetime.now(UTC).replace(tzinfo=None)
await self._record_recognition_attempt(
mail=mail,
saved_attachment=saved_attachment,
attempt_no=attempt_no,
result=result,
source="automatic",
operator_id=None,
started_at=started_at,
finished_at=finished_at,
error_message=error_message,
)
if result is not None and not self._recognition_requires_retry(result):
return result
if attempt_no == 2:
break
if result is None or self._recognition_failed(result):
raise RuntimeError("附件识别失败,已保留原始文件并等待补偿")
return result
async def _record_recognition_attempt(
self,
*,
mail: RawMailMessage,
saved_attachment: SavedMailAttachment,
attempt_no: int,
result: StructuredRecognitionResult | None,
source: str,
operator_id: str | None,
started_at: datetime,
finished_at: datetime,
error_message: str | None,
task_id: str | None = None,
mail_id: str | None = None,
attachment_id: str | None = None,
) -> None:
fields = result.extracted_fields if result is not None else {}
confidence = (
{key: str(value) for key, value in result.field_confidence.items()}
if result is not None
else {}
)
missing_fields = list(result.missing_fields) if result is not None else []
low_confidence_fields = (
list(result.low_confidence_fields) if result is not None else []
)
if result is None:
status = "error"
document_type = "other"
ocr_status = "error"
llm_status = "error"
page_evidence: dict[str, object] = {}
error_message = error_message or "识别器未返回结果"
else:
status = self._recognition_attempt_status(result)
document_type = result.document_type
ocr_status = result.ocr_status
llm_status = result.llm_status
page_evidence = result.page_evidence
error_message = error_message or result.error_message
async with self.session_factory() as session, session.begin():
session.add(
OffsiteRecognitionAttempt(
task_id=task_id,
mail_id=mail_id,
attachment_id=attachment_id,
imap_uid=mail.imap_uid,
message_id=mail.message_id,
filename=saved_attachment.filename,
file_hash=saved_attachment.file_hash,
original_file_path=saved_attachment.original_file_path,
attempt_no=attempt_no,
source=source,
operator_id=operator_id,
document_type=document_type,
ocr_status=ocr_status,
llm_status=llm_status,
extracted_fields=fields,
field_confidence=confidence,
missing_fields=missing_fields,
low_confidence_fields=low_confidence_fields,
page_evidence=page_evidence,
status=status,
error_message=error_message,
started_at=started_at,
finished_at=finished_at,
created_at=finished_at,
)
)
async def _advance_cursor(
self, lease_id: str, last_uid: str, *, release: bool
) -> None:
@@ -375,6 +529,27 @@ class OffsiteMailWorker:
cursor.lease_id = None
cursor.lease_until = None
cursor.updated_at = now
if cursor.status == "blocked":
# 游标锁死必须留痕:前端"收件箱异常"提示读的就是这条记录。
# 锁死后 _claim_cursor() 会直接返回,因此这里每次锁死只会写一条。
session.add(
InteractionAudit(
actor_type="system",
actor_id=None,
target_customer_id=None,
session_id=None,
portal="worker",
action_type="offsite.mail_cursor_blocked",
detail={
"mailbox": self.settings.offsite_mailbox,
"imap_uid": mail.imap_uid,
"message_id": mail.message_id,
"retry_count": cursor.retry_count,
"error": message,
},
created_at=now,
)
)
await session.execute(
update(OffsiteFundMail)
.where(
@@ -436,6 +611,28 @@ class OffsiteMailWorker:
failed = {"error", "misconfigured"}
return bool(error and (ocr_status in failed or llm_status in failed))
@staticmethod
def _recognition_requires_retry(result: StructuredRecognitionResult) -> bool:
if OffsiteMailWorker._recognition_failed(result):
return True
if result.document_type not in REQUIRED_FIELDS:
return False
fields = result.extracted_fields
required = REQUIRED_FIELDS[result.document_type]
if any(not str(fields.get(name) or "").strip() for name in required):
return True
if not (fields.get("投资者名称") or fields.get("客户标识")):
return True
return bool(result.missing_fields or result.low_confidence_fields)
@staticmethod
def _recognition_attempt_status(result: StructuredRecognitionResult) -> str:
if OffsiteMailWorker._recognition_failed(result):
return "error"
if OffsiteMailWorker._recognition_requires_retry(result):
return "recognition_exception"
return "success"
@staticmethod
def _recognized_attachment(
saved: SavedMailAttachment, result: StructuredRecognitionResult