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

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
@@ -0,0 +1,284 @@
"""邮件 OCR 识别字段查询接口的集成测试。
覆盖:返回附件识别原文、结构化字段、置信度、缺失字段和最近一次识别尝试,
没有识别尝试时仍能返回附件自身字段,邮件不存在、越权的处理,
以及查询动作必须留下审计记录。
统一在单事件循环内调用应用(httpx.ASGITransport),避免连接池里的连接被
跨事件循环复用。
"""
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory, engine
from app.main import app
from app.model.audit import InteractionAudit
from app.model.offsite_fund import (
OffsiteFundAttachment,
OffsiteFundDocument,
OffsiteFundMail,
OffsiteRecognitionAttempt,
)
RECOGNITION_PATH = "/api/v1/offsite-fund/mails/{mail_id}/recognition-fields"
TRACE_ID = ""
@pytest.fixture(autouse=True)
async def _dispose_engine_after_test() -> AsyncIterator[None]:
"""用例结束后释放连接池,避免连接绑定在已关闭的事件循环上。"""
yield
await engine.dispose()
async def _override_session() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
def _install_context(roles: tuple[str, ...], permissions: tuple[str, ...]) -> None:
async def override_context() -> RequestContext:
return RequestContext(
user_id="1",
trace_id=TRACE_ID,
roles=roles,
permissions=permissions,
data_scope="all",
)
app.dependency_overrides[build_request_context] = override_context
app.dependency_overrides[get_session] = _override_session
async def _get_recognition(mail_id: str) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get(RECOGNITION_PATH.format(mail_id=mail_id))
async def _seed_mail(*, with_attempt: bool = True) -> tuple[str, str]:
"""写入一封带附件、单据和识别尝试的测试邮件,返回 (mail_id, task_id)。"""
mail_id = f"M{uuid4().hex[:12]}"
attachment_id = f"{mail_id}-A01"
task_id = f"{mail_id}-A01"
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
session.add(OffsiteFundMail(
mail_id=mail_id,
imap_uid=f"uid-{mail_id}",
message_id=f"<{mail_id}@integration.local>",
received_date=date(2026, 9, 10),
sender="15008108550@163.com",
return_path="15008108550@163.com",
auth_result={"spf": "pass", "dkim": "pass"},
original_eml_path="mock/offsite.eml",
status="recognized",
retry_count=0,
created_at=now,
updated_at=now,
))
session.add(OffsiteFundAttachment(
attachment_id=attachment_id,
mail_id=mail_id,
filename="申购申请单.pdf",
file_hash=uuid4().hex,
media_type="application/pdf",
size_bytes=2048,
document_type="subscription",
original_file_path="mock/申购申请单.pdf",
ocr_text="基金代码 159511 申购金额 10000",
extracted_fields={"基金代码": "159511", "基金名称": "测试基金"},
field_confidence={"基金代码": "0.95", "基金名称": "0.62"},
page_evidence={"基金代码": "layout_1_block_1"},
status="recognized",
created_at=now,
))
session.add(OffsiteFundDocument(
task_id=task_id,
mail_id=mail_id,
attachment_id=attachment_id,
document_type="subscription",
fund_code="159511",
fund_name="测试基金",
application_no=f"SUB-{uuid4().hex[:8]}",
application_date=date(2026, 9, 10),
raw_application_date="2026年09月10日",
agency="测试代销",
status="planned",
operator_decision="未处理",
created_at=now,
updated_at=now,
))
if with_attempt:
session.add(OffsiteRecognitionAttempt(
task_id=task_id,
mail_id=mail_id,
attachment_id=attachment_id,
imap_uid=f"uid-{mail_id}",
message_id=f"<{mail_id}@integration.local>",
filename="申购申请单.pdf",
file_hash=uuid4().hex,
original_file_path="mock/申购申请单.pdf",
attempt_no=1,
source="worker",
operator_id=None,
document_type="subscription",
ocr_status="success",
llm_status="success",
extracted_fields={"基金代码": "159511", "基金名称": "测试基金"},
field_confidence={"基金代码": "0.95", "基金名称": "0.62"},
missing_fields=["代销机构"],
low_confidence_fields=["基金名称"],
page_evidence={"基金代码": "layout_1_block_1"},
status="recognition_exception",
error_message=None,
started_at=now,
finished_at=now,
created_at=now,
))
return mail_id, task_id
async def _count_audit(action_type: str) -> int:
async with SessionFactory() as session:
rows = await session.execute(select(InteractionAudit).where(
InteractionAudit.action_type == action_type,
InteractionAudit.detail["trace_id"].as_string() == TRACE_ID,
))
return len(rows.scalars().all())
async def _cleanup(mail_id: str, task_id: str) -> None:
async with SessionFactory() as session, session.begin():
if TRACE_ID:
await session.execute(delete(InteractionAudit).where(
InteractionAudit.detail["trace_id"].as_string() == TRACE_ID
))
if mail_id:
await session.execute(
delete(OffsiteRecognitionAttempt).where(
OffsiteRecognitionAttempt.mail_id == mail_id
)
)
await session.execute(
delete(OffsiteFundDocument).where(OffsiteFundDocument.mail_id == mail_id)
)
await session.execute(
delete(OffsiteFundAttachment).where(
OffsiteFundAttachment.mail_id == mail_id
)
)
await session.execute(
delete(OffsiteFundMail).where(OffsiteFundMail.mail_id == mail_id)
)
@pytest.mark.integration
async def test_recognition_fields_return_attachment_and_attempt_detail() -> None:
global TRACE_ID
TRACE_ID = f"trace-recognition-{uuid4()}"
mail_id = ""
task_id = ""
try:
mail_id, task_id = await _seed_mail()
_install_context(("operator",), ("offsite:read",))
response = await _get_recognition(mail_id)
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
data = body["data"]
assert data["mail_id"] == mail_id
assert len(data["attachments"]) == 1
attachment = data["attachments"][0]
assert attachment["document_type"] == "subscription"
assert attachment["ocr_text"] == "基金代码 159511 申购金额 10000"
assert attachment["extracted_fields"]["基金代码"] == "159511"
assert attachment["field_confidence"]["基金名称"] == "0.62"
assert attachment["missing_fields"] == ["代销机构"]
assert attachment["low_confidence_fields"] == ["基金名称"]
assert attachment["ocr_status"] == "success"
assert attachment["llm_status"] == "success"
assert attachment["latest_attempt"]["attempt_no"] == 1
assert attachment["latest_attempt"]["source"] == "worker"
assert attachment["documents"][0]["task_id"] == task_id
assert attachment["documents"][0]["fund_code"] == "159511"
assert attachment["documents"][0]["application_date"] == "2026-09-10"
assert await _count_audit("offsite.mail_recognition_viewed") == 1
finally:
await _cleanup(mail_id, task_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_recognition_fields_without_attempt_keeps_attachment_fields() -> None:
"""识别尝试缺失时不能报错,仍要返回附件自身的识别字段。"""
global TRACE_ID
TRACE_ID = f"trace-recognition-{uuid4()}"
mail_id = ""
task_id = ""
try:
mail_id, task_id = await _seed_mail(with_attempt=False)
_install_context(("operator",), ("offsite:read",))
response = await _get_recognition(mail_id)
assert response.status_code == 200
attachment = response.json()["data"]["attachments"][0]
assert attachment["extracted_fields"]["基金代码"] == "159511"
assert attachment["missing_fields"] == []
assert attachment["low_confidence_fields"] == []
assert attachment["ocr_status"] is None
assert attachment["latest_attempt"] is None
finally:
await _cleanup(mail_id, task_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_recognition_fields_unknown_mail_returns_not_found() -> None:
global TRACE_ID
TRACE_ID = f"trace-recognition-{uuid4()}"
try:
_install_context(("operator",), ("offsite:read",))
response = await _get_recognition("M000000000000")
assert response.status_code == 200
assert response.json()["code"] == 404
assert response.json()["message"] == "邮件不存在"
finally:
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_recognition_fields_requires_offsite_permission() -> None:
global TRACE_ID
TRACE_ID = f"trace-recognition-{uuid4()}"
mail_id = ""
task_id = ""
try:
mail_id, task_id = await _seed_mail()
_install_context(("operator",), ())
missing_permission = await _get_recognition(mail_id)
_install_context(("customer",), ("offsite:read",))
wrong_role = await _get_recognition(mail_id)
assert missing_permission.json()["code"] == 403
assert wrong_role.json()["code"] == 403
finally:
await _cleanup(mail_id, task_id)
app.dependency_overrides.clear()
TRACE_ID = ""