袁聪的第二次提交,项目已完整
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user