diff --git a/app/api/controllers/offsite_fund.py b/app/api/controllers/offsite_fund.py index 4beac54..fb9d1ae 100644 --- a/app/api/controllers/offsite_fund.py +++ b/app/api/controllers/offsite_fund.py @@ -10,6 +10,8 @@ from app.api.dependencies.auth import build_request_context from app.api.dependencies.database import get_session from app.api.schemas.offsite_fund import ( OffsiteConfirmRequest, + OffsiteMailboxRecoveryRequest, + OffsiteMailDeletionRequest, OffsiteNl2SqlCorrectionRequest, OffsiteNotificationRequest, OffsiteNotificationSendRequest, @@ -49,6 +51,19 @@ async def get_mail( return await OffsiteFundService(session).get_mail(mail_id, context) +@router.post("/mails/{mail_id}/deletions") +async def delete_mail( + mail_id: str, + payload: OffsiteMailDeletionRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + """软删除邮件:运营列表隐藏,但保留原始文件、识别结果和审计链路。""" + return await OffsiteFundService(session).delete_mail( + mail_id, payload.operator_id, context + ) + + @router.get("/mails/{mail_id}/recognition-fields") async def get_mail_recognition_fields( mail_id: str, @@ -130,6 +145,18 @@ async def mailbox_status( return await OffsiteFundService(session).mailbox_status(context) +@router.post("/mailbox-status/recoveries") +async def recover_mailbox( + payload: OffsiteMailboxRecoveryRequest, + context: RequestContext = Depends(build_request_context), # noqa: B008 + session: AsyncSession = Depends(get_session), # noqa: B008 +) -> dict[str, Any]: + """解除收件游标阻塞,保留失败 UID 并由 Worker 重新处理。""" + return await OffsiteFundService(session).recover_mailbox( + payload.operator_id, context + ) + + @router.get("/attachments/{attachment_id}/file", response_model=None) async def open_attachment_file( attachment_id: str, diff --git a/app/api/schemas/offsite_fund.py b/app/api/schemas/offsite_fund.py index f524091..58dd682 100644 --- a/app/api/schemas/offsite_fund.py +++ b/app/api/schemas/offsite_fund.py @@ -49,6 +49,22 @@ class OffsiteRecognitionRetryRequest(BaseModel): operator_id: str = Field(min_length=1, max_length=64) +class OffsiteMailboxRecoveryRequest(BaseModel): + """收件游标解除阻塞请求。""" + + model_config = ConfigDict(extra="forbid") + + operator_id: str = Field(min_length=1, max_length=64) + + +class OffsiteMailDeletionRequest(BaseModel): + """邮件删除请求:只隐藏运营列表,不删除原始邮件和识别链路。""" + + model_config = ConfigDict(extra="forbid") + + operator_id: str = Field(min_length=1, max_length=64) + + class OffsiteRuleRecalculationRequest(BaseModel): """重新判定规则只接受操作人身份,数值一律取自已落库的识别与查询结果。""" diff --git a/app/service/health_service.py b/app/service/health_service.py index 611d921..278f3f5 100644 --- a/app/service/health_service.py +++ b/app/service/health_service.py @@ -68,7 +68,7 @@ class HealthService: finally: if client is not None: try: - await client.aclose() + await client.close() except Exception: pass diff --git a/app/service/model_gateway.py b/app/service/model_gateway.py index 1d1c10c..c323d00 100644 --- a/app/service/model_gateway.py +++ b/app/service/model_gateway.py @@ -2,7 +2,7 @@ import logging import os from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, Protocol +from typing import Any, Protocol, cast import httpx from sqlalchemy import select @@ -183,14 +183,18 @@ class DatabaseModelGateway: async def generate(self, *, endpoint_code: str, prompt: str, timeout_ms: int) -> str: endpoint = await self._endpoint(endpoint_code) - adapter = OpenAICompatibleGateway({endpoint.endpoint_code: endpoint}) + adapter = OpenAICompatibleGateway( + {endpoint.endpoint_code: cast(EndpointSettings, endpoint)} + ) return await adapter.generate( endpoint_code=endpoint.endpoint_code, prompt=prompt, timeout_ms=timeout_ms ) async def embed(self, *, endpoint_code: str, text: str, timeout_ms: int) -> list[float]: endpoint = await self._endpoint(endpoint_code) - adapter = OpenAICompatibleGateway({endpoint.endpoint_code: endpoint}) + adapter = OpenAICompatibleGateway( + {endpoint.endpoint_code: cast(EndpointSettings, endpoint)} + ) return await adapter.embed( endpoint_code=endpoint.endpoint_code, text=text, timeout_ms=timeout_ms ) diff --git a/app/service/offsite_fund_rules.py b/app/service/offsite_fund_rules.py index 06be933..c571d91 100644 --- a/app/service/offsite_fund_rules.py +++ b/app/service/offsite_fund_rules.py @@ -1,5 +1,6 @@ """场外基金申购赎回确定性规则。""" +import re from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal, InvalidOperation @@ -32,10 +33,13 @@ def decimal_from(value: object) -> Decimal | None: def normalize_amount_yuan(raw_value: object, unit: object) -> Decimal | None: - raw_unit = str(unit or "元").strip() - raw_text = str(raw_value or "").replace(",", "").strip() + raw_unit = str(unit or "元").strip().replace("人民币", "元") + raw_text = str(raw_value or "").replace(",", "").replace(",", "").strip() if raw_unit and raw_text.endswith(raw_unit): raw_text = raw_text[: -len(raw_unit)].strip() + # OCR 可能把币种前缀一起识别到金额字段,例如“人民币5,000.00元”。 + # 只接受开头的常见币种标记,避免从任意业务文本中误抽数字。 + raw_text = re.sub(r"^(?:人民币|RMB|CNY|¥|¥)\s*", "", raw_text, flags=re.IGNORECASE) amount = decimal_from(raw_text) if amount is None: return None diff --git a/app/service/offsite_fund_service.py b/app/service/offsite_fund_service.py index 4e37973..d46e4cb 100644 --- a/app/service/offsite_fund_service.py +++ b/app/service/offsite_fund_service.py @@ -11,7 +11,7 @@ from email.message import EmailMessage from email.parser import BytesParser from email.utils import parsedate_to_datetime from pathlib import Path -from typing import Literal, cast +from typing import Any, Literal, cast from zoneinfo import ZoneInfo from sqlalchemy import func, select, update @@ -143,7 +143,7 @@ class OffsiteFundService: denied = self._permission_error(context, ("offsite:read", "offsite:write")) if denied is not None: return denied - filters = [] + filters = [OffsiteFundMail.status != "deleted"] if sender: filters.append(OffsiteFundMail.sender == sender) if status: @@ -189,7 +189,10 @@ class OffsiteFundService: return denied async with self.session.begin(): mail = await self.session.scalar( - select(OffsiteFundMail).where(OffsiteFundMail.mail_id == mail_id) + select(OffsiteFundMail).where( + OffsiteFundMail.mail_id == mail_id, + OffsiteFundMail.status != "deleted", + ) ) if mail is None: return {"code": 404, "message": "邮件不存在", "data": {}} @@ -203,6 +206,49 @@ class OffsiteFundService: self._add_audit(context, "offsite.mail_viewed", {"mail_id": mail_id}) return {"code": 0, "message": "ok", "data": detail} + async def delete_mail( + self, mail_id: str, operator_id: str, context: RequestContext + ) -> dict[str, object]: + """软删除一封邮件,保留所有原始数据供审计和补偿使用。""" + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error + denied = self._permission_error(context, ("offsite:write", "offsite:confirm")) + if denied is not None: + return denied + + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session.begin(): + mail = await self.session.scalar( + select(OffsiteFundMail) + .where(OffsiteFundMail.mail_id == mail_id) + .with_for_update() + ) + if mail is None: + return {"code": 404, "message": "邮件不存在", "data": {}} + if mail.status == "deleted": + return { + "code": 0, + "message": "邮件已删除", + "data": {"mail_id": mail_id, "status": "deleted"}, + } + previous_status = mail.status + mail.status = "deleted" + mail.updated_at = now + self._add_audit( + context, + "offsite.mail_deleted", + { + "mail_id": mail_id, + "previous_status": previous_status, + }, + ) + return { + "code": 0, + "message": "邮件已删除", + "data": {"mail_id": mail_id, "status": "deleted"}, + } + async def mail_recognition_fields( self, mail_id: str, context: RequestContext ) -> dict[str, object]: @@ -518,16 +564,24 @@ class OffsiteFundService: correction: OffsiteFieldCorrection | None = None, ) -> dict[str, object]: expected = NL2SQL_DOCUMENT_FIELDS.get(document.document_type, ()) - values = cls._nl2sql_original_fields(rule_results) + latest_rule_results: dict[str, OffsiteRuleResult] = {} + for result in rule_results: + latest_rule_results[result.rule_code] = result + display_rule_results = list(latest_rule_results.values()) + values = cls._nl2sql_original_fields(display_rule_results) + latest_query_records: dict[str, OffsiteQueryRecord] = {} + for record in query_records: + latest_query_records[record.rule_code] = record + display_query_records = list(latest_query_records.values()) correction_fields = ( dict(correction.corrected_fields) if correction is not None else {} ) query_failed = any( - record.status == "query_failed" for record in query_records + record.status == "query_failed" for record in display_query_records ) or any( isinstance(result.calculation, Mapping) and result.calculation.get("原因") == NL2SQL_BLOCKED_REASON - for result in rule_results + for result in display_rule_results ) fields: dict[str, str | None] = {} field_status: dict[str, str] = {} @@ -573,7 +627,7 @@ class OffsiteFundService: "queried_at": cls._iso_datetime(record.created_at), "error_message": record.error_message, } - for record in query_records + for record in display_query_records ], "updated_at": cls._iso_datetime(document.updated_at), } @@ -644,6 +698,9 @@ class OffsiteFundService: 需要换一批查询数据时,先调用核对触发接口刷新查询记录,再调用本接口重新判定。 查询缺失或失败的规则按"无法判断"处理并保留原因,不做任何猜测。 """ + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error denied = self._permission_error( context, ("offsite:write", "offsite:confirm", "offsite:nl2sql") ) @@ -701,6 +758,9 @@ class OffsiteFundService: 只新增修正记录,不覆盖附件上的 Agent 原始识别值;单据的标准化展示字段 跟随修正后的有效值刷新,重新判定再按有效值重跑计算与核对。 """ + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error denied = self._permission_error(context, ("offsite:write",)) if denied is not None: return denied @@ -788,6 +848,9 @@ class OffsiteFundService: 只新增修正记录,不改写查询记录与查询摘要;重新判定时修正值优先于查询原值。 """ + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error denied = self._permission_error(context, ("offsite:write",)) if denied is not None: return denied @@ -1081,6 +1144,75 @@ class OffsiteFundService: }, } + async def recover_mailbox( + self, operator_id: str, context: RequestContext + ) -> dict[str, object]: + """解除收件游标阻塞,但不跳过失败 UID。""" + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error + denied = self._permission_error(context, ("offsite:write", "offsite:confirm")) + if denied is not None: + return denied + + settings = get_settings() + now = datetime.now(UTC).replace(tzinfo=None) + async with self.session.begin(): + cursor = await self.session.scalar( + select(OffsiteMailCursor) + .where( + OffsiteMailCursor.mailbox == settings.offsite_mailbox, + OffsiteMailCursor.folder == "INBOX", + ) + .with_for_update() + ) + if cursor is None: + return {"code": 404, "message": "收件游标尚未初始化", "data": {}} + if cursor.status != "blocked": + return { + "code": 0, + "message": "收件游标当前未阻塞", + "data": { + "status": cursor.status, + "last_uid": cursor.last_uid, + }, + } + + previous = { + "blocked_uid": cursor.blocked_uid, + "blocked_message_id": cursor.blocked_message_id, + "retry_count": cursor.retry_count, + "last_error": cursor.last_error, + } + cursor.status = "idle" + cursor.retry_count = 0 + cursor.last_error = None + cursor.next_retry_at = None + cursor.blocked_uid = None + cursor.blocked_message_id = None + cursor.lease_id = None + cursor.lease_until = None + cursor.updated_at = now + self._add_audit( + context, + "offsite.mail_cursor_recovered", + { + "last_uid": cursor.last_uid, + "previous": previous, + }, + ) + + return { + "code": 0, + "message": "收件游标已解除阻塞,Worker 将从失败邮件继续重试", + "data": { + "status": "idle", + "last_uid": cursor.last_uid, + "retry_count": 0, + "retry_uid": previous["blocked_uid"], + }, + } + async def _mail_attachments( self, mail_ids: Sequence[str] ) -> dict[str, tuple[OffsiteFundAttachment, ...]]: @@ -1201,7 +1333,12 @@ class OffsiteFundService: "body_html": None, } try: - message = BytesParser(policy=policy.default).parsebytes(Path(path).read_bytes()) + message: EmailMessage = cast( + EmailMessage, + BytesParser(policy=cast(Any, policy.default)).parsebytes( + Path(path).read_bytes() + ), + ) except (OSError, ValueError): return empty subject = OffsiteFundService._decode_header(message.get("Subject")) @@ -1355,6 +1492,9 @@ class OffsiteFundService: async def retry_document_recognition( self, task_id: str, operator_id: str, context: RequestContext ) -> dict[str, object]: + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error denied = self._permission_error(context, ("offsite:write", "offsite:confirm")) if denied is not None: return denied @@ -1696,6 +1836,9 @@ class OffsiteFundService: self, task_id: str, decision: OperationDecision, operator_id: str, context: RequestContext, ) -> dict[str, object]: + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error denied = self._permission_error(context, ("offsite:confirm", "offsite:write")) if denied is not None: return denied @@ -1795,6 +1938,9 @@ class OffsiteFundService: self, task_id: str, operator_id: str, manual_confirmed: bool, context: RequestContext, ) -> dict[str, object]: + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error denied = self._permission_error( context, ("offsite:nl2sql", "offsite:write", "financial:nl2sql:read") ) @@ -1904,6 +2050,9 @@ class OffsiteFundService: self, task_id: str, notification_type: str, operator_id: str, context: RequestContext, ) -> dict[str, object]: + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error denied = self._permission_error(context, ("offsite:notify", "offsite:write")) if denied is not None: return denied @@ -1955,6 +2104,9 @@ class OffsiteFundService: final_content: str | None, context: RequestContext, ) -> dict[str, object]: + operator_error = self._operator_error(operator_id, context) + if operator_error is not None: + return operator_error denied = self._permission_error(context, ("offsite:notify", "offsite:write")) if denied is not None: return denied @@ -2270,6 +2422,8 @@ class OffsiteFundService: ).with_for_update()) if mail is None: return + if mail.status == "deleted": + return documents = (await self.session.execute(select(OffsiteFundDocument).where( OffsiteFundDocument.mail_id == mail_id ))).scalars().all() @@ -2450,6 +2604,15 @@ class OffsiteFundService: return {"code": 403, "message": "缺少场外基金操作权限", "data": {}} return None + @staticmethod + def _operator_error( + operator_id: str, context: RequestContext + ) -> dict[str, object] | None: + """操作人只能来自 JWT 身份,禁止客户端伪造其它用户编号。""" + if str(operator_id).strip() != str(context.user_id).strip(): + return {"code": 403, "message": "操作人身份与访问令牌不一致", "data": {}} + return None + def _add_audit( self, context: RequestContext, action_type: str, detail: dict[str, object] ) -> None: diff --git a/docs/12-基金行情数据底座接入开发计划.md b/docs/12-基金行情数据底座接入开发计划.md index 2e9c5e9..8cf94bd 100644 --- a/docs/12-基金行情数据底座接入开发计划.md +++ b/docs/12-基金行情数据底座接入开发计划.md @@ -40,7 +40,7 @@ | 基金名称缓存 | 行情缓存适配器 | 允许 Redis,不使用进程全局作为唯一缓存 | | 历史净值和收益率合并 | `app/service/fund_quote_service.py` | 统一 DTO、Decimal、降级标识 | | 中文字典输出 | Controller/Agent 展示层 | 内部 DTO 使用英文稳定字段 | -| 固定奶龙基金代码 | 配置或代码白名单 | 先保留白名单,后续配置中心化 | +| 固定南方基金代码 | 配置或代码白名单 | 先保留白名单,后续配置中心化 | ## 四、阶段计划 diff --git a/tests/integration/test_offsite_fund_api.py b/tests/integration/test_offsite_fund_api.py index 3b02ee6..3cdccc2 100644 --- a/tests/integration/test_offsite_fund_api.py +++ b/tests/integration/test_offsite_fund_api.py @@ -132,7 +132,7 @@ def test_offsite_recognized_mail_persists_workflow_and_notification() -> None: confirm = client.post( f"/api/v1/offsite-fund/documents/{task_id}/confirmations", - json={"decision": "确认正常", "operator_id": "operator-001"}, + json={"decision": "确认正常", "operator_id": "1"}, ) assert confirm.status_code == 200 assert confirm.json()["code"] == 0 @@ -146,7 +146,7 @@ def test_offsite_recognized_mail_persists_workflow_and_notification() -> None: notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", - json={"notification_type": "settlement", "operator_id": "operator-001"}, + json={"notification_type": "settlement", "operator_id": "1"}, ) assert notice.status_code == 200 assert notice.json()["data"]["notification_id"] @@ -239,7 +239,7 @@ def test_offsite_nl2sql_success_completes_subscription_plan( triggered = client.post( f"/api/tasks/{task_id}/trigger-agent-nl2sql", - json={"operator_id": "operator-001", "manual_confirmed": True}, + json={"operator_id": "1", "manual_confirmed": True}, ) assert triggered.status_code == 200 assert triggered.json()["data"]["status"] == "planned" @@ -250,7 +250,7 @@ def test_offsite_nl2sql_success_completes_subscription_plan( confirmed = client.post( f"/api/v1/offsite-fund/documents/{task_id}/confirmations", - json={"decision": "确认正常", "operator_id": "operator-001"}, + json={"decision": "确认正常", "operator_id": "1"}, ) assert confirmed.status_code == 200 assert confirmed.json()["code"] == 0 @@ -283,7 +283,7 @@ def test_offsite_low_confidence_document_goes_to_recognition_exception() -> None assert document["status"] == "recognition_exception" blocked = client.post( f"/api/v1/offsite-fund/documents/{task_id}/confirmations", - json={"decision": "确认正常", "operator_id": "operator-001"}, + json={"decision": "确认正常", "operator_id": "1"}, ) assert blocked.status_code == 200 assert blocked.json()["code"] == 422 @@ -361,7 +361,7 @@ def test_offsite_recognition_retry_recovers_document_without_overwriting_attachm retried = client.post( f"/api/v1/offsite-fund/documents/{task_id}/recognition-retries", - json={"operator_id": "operator-001"}, + json={"operator_id": "1"}, ) assert retried.status_code == 200 assert recognized_fields @@ -440,13 +440,13 @@ def test_offsite_mail_return_send_updates_notification_without_external_call( confirmed = client.post( f"/api/v1/offsite-fund/documents/{task_id}/confirmations", - json={"decision": "确认正常", "operator_id": "operator-001"}, + json={"decision": "确认正常", "operator_id": "1"}, ) assert confirmed.status_code == 200 notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", - json={"notification_type": "mail_return", "operator_id": "operator-001"}, + json={"notification_type": "mail_return", "operator_id": "1"}, ) assert notice.status_code == 200 notification_id = int(notice.json()["data"]["notification_id"]) @@ -454,7 +454,7 @@ def test_offsite_mail_return_send_updates_notification_without_external_call( sent = client.post( f"/api/v1/offsite-fund/notifications/{notification_id}/send", json={ - "operator_id": "operator-001", + "operator_id": "1", "operator_confirmed": True, "final_content": "运营确认后的回复正文", }, diff --git a/tests/integration/test_offsite_nl2sql_fields.py b/tests/integration/test_offsite_nl2sql_fields.py index 71eee80..20954ea 100644 --- a/tests/integration/test_offsite_nl2sql_fields.py +++ b/tests/integration/test_offsite_nl2sql_fields.py @@ -156,6 +156,43 @@ async def _seed_failed_queries(task_id: str) -> None: )) +async def _seed_latest_success_after_failure(task_id: str) -> None: + await _seed_failed_queries(task_id) + now = datetime.now(UTC).replace(tzinfo=None) + async with SessionFactory() as session, session.begin(): + session.add(OffsiteQueryRecord( + task_id=task_id, + rule_code="subscription_holding_ratio", + natural_language_request="基金代码为15911,查询基金最新总份额、最新净值和申请前持有份额", + script_path="nl2sql_yc.py", + result_summary={ + "status": "success", + "data": {"total": 1, "rows": [{ + "nav": "1.250000", + "total_fund_shares": "10000000000.0000", + "total_quantity": "100000000.0000", + }]}, + }, + status="success", + error_message=None, + created_at=now, + )) + rule_result = await session.scalar(select(OffsiteRuleResult).where( + OffsiteRuleResult.task_id == task_id, + OffsiteRuleResult.rule_code == "subscription_holding_ratio", + )) + assert rule_result is not None + rule_result.result = "正常" + rule_result.document_value = {"申购金额元": "200000000.00"} + rule_result.database_value = { + "最新净值": "1.250000", + "基金最新总份额": "10000000000.0000", + "申请前持有份额": "100000000.0000", + } + rule_result.calculation = {"申购后持有比例": "0.026"} + rule_result.created_at = now + + async def _count_audit(action_type: str) -> int: async with SessionFactory() as session: rows = await session.execute(select(InteractionAudit).where( @@ -244,6 +281,31 @@ async def test_nl2sql_fields_mark_query_failed_when_query_blocked() -> None: TRACE_ID = "" +@pytest.mark.integration +async def test_nl2sql_fields_show_only_latest_attempt_per_rule() -> None: + """重试成功后,页面不能继续展示同一规则的历史失败状态。""" + global TRACE_ID + TRACE_ID = f"trace-nl2sql-latest-attempt-{uuid4()}" + task_id = "" + try: + task_id = await _seed_document("subscription") + await _seed_latest_success_after_failure(task_id) + _install_context(("operator",), ("offsite:read",)) + response = await _get_fields(task_id) + + assert response.status_code == 200 + data = response.json()["data"] + assert len(data["queries"]) == 1 + assert data["queries"][0]["rule_code"] == "subscription_holding_ratio" + assert data["queries"][0]["status"] == "success" + assert data["queries"][0]["row_count"] == 1 + assert data["field_status"]["最新净值"] == "success" + finally: + await _cleanup(task_id) + app.dependency_overrides.clear() + TRACE_ID = "" + + @pytest.mark.integration async def test_nl2sql_fields_mark_pending_before_verification() -> None: """尚未触发核对时字段为空,必须标为 pending,不能伪装成查询失败。""" diff --git a/tests/integration/test_offsite_notification_send.py b/tests/integration/test_offsite_notification_send.py index 7152d1a..8d9d195 100644 --- a/tests/integration/test_offsite_notification_send.py +++ b/tests/integration/test_offsite_notification_send.py @@ -105,17 +105,17 @@ def test_successful_notification_send_is_idempotent( _confirm_and_create_notice(client, task_id) notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", - json={"notification_type": "normal_return", "operator_id": "operator-001"}, + json={"notification_type": "normal_return", "operator_id": "1"}, ) notification_id = int(notice.json()["data"]["notification_id"]) first = client.post( f"/api/v1/offsite-fund/notifications/{notification_id}/send", - json={"operator_id": "operator-001", "operator_confirmed": True}, + json={"operator_id": "1", "operator_confirmed": True}, ) second = client.post( f"/api/v1/offsite-fund/notifications/{notification_id}/send", - json={"operator_id": "operator-001", "operator_confirmed": True}, + json={"operator_id": "1", "operator_confirmed": True}, ) recalculate = client.post( "/api/v1/offsite-fund/settlement-statistics/recalculate", @@ -175,12 +175,12 @@ def test_failed_notification_send_persists_failure_and_retry_count( _confirm_and_create_notice(client, task_id) notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", - json={"notification_type": "mail_return", "operator_id": "operator-001"}, + json={"notification_type": "mail_return", "operator_id": "1"}, ) notification_id = int(notice.json()["data"]["notification_id"]) sent = client.post( f"/api/v1/offsite-fund/notifications/{notification_id}/send", - json={"operator_id": "operator-001", "operator_confirmed": True}, + json={"operator_id": "1", "operator_confirmed": True}, ) assert sent.json()["data"]["status"] == "发送失败" @@ -260,14 +260,14 @@ def test_mixed_mail_separates_normal_and_exception_returns( ): confirmed = client.post( f"/api/v1/offsite-fund/documents/{task_id}/confirmations", - json={"decision": decision, "operator_id": "operator-001"}, + json={"decision": decision, "operator_id": "1"}, ) assert confirmed.status_code == 200 notice = client.post( f"/api/v1/offsite-fund/documents/{task_id}/notifications", json={ "notification_type": notification_type, - "operator_id": "operator-001", + "operator_id": "1", }, ) assert notice.status_code == 200 @@ -275,7 +275,7 @@ def test_mixed_mail_separates_normal_and_exception_returns( notification_ids.append(notification_id) sent = client.post( f"/api/v1/offsite-fund/notifications/{notification_id}/send", - json={"operator_id": "operator-001", "operator_confirmed": True}, + json={"operator_id": "1", "operator_confirmed": True}, ) assert sent.json()["data"]["status"] == "发送成功" @@ -296,7 +296,7 @@ def test_mixed_mail_separates_normal_and_exception_returns( def _confirm_and_create_notice(client: TestClient, task_id: str) -> None: confirmed = client.post( f"/api/v1/offsite-fund/documents/{task_id}/confirmations", - json={"decision": "确认正常", "operator_id": "operator-001"}, + json={"decision": "确认正常", "operator_id": "1"}, ) assert confirmed.status_code == 200 diff --git a/tests/integration/test_promotion_material_api.py b/tests/integration/test_promotion_material_api.py index c318d8b..d6a7dc0 100644 --- a/tests/integration/test_promotion_material_api.py +++ b/tests/integration/test_promotion_material_api.py @@ -337,7 +337,7 @@ def _inputs_payload() -> dict[str, object]: }, "manager_info": { "manager_name": "张三", - "management_company": "奶龙基金管理有限公司", + "management_company": "南方基金管理有限公司", "registration_code": "P10000001", "employment_years": "10年", "investment_management_experience": "8年公募基金投资管理经验", diff --git a/tests/unit/service/test_offsite_fund_rules.py b/tests/unit/service/test_offsite_fund_rules.py index d5546ad..1162b42 100644 --- a/tests/unit/service/test_offsite_fund_rules.py +++ b/tests/unit/service/test_offsite_fund_rules.py @@ -59,6 +59,8 @@ def test_normalize_amount_keeps_original_unit_semantics() -> None: assert normalize_amount_yuan("2.50", "万元") == Decimal("25000.00") assert normalize_amount_yuan("2.50", "元") == Decimal("2.50") assert normalize_amount_yuan("20,000.00万元", "万元") == Decimal("200000000.00") + assert normalize_amount_yuan("人民币5,000.00元", "元") == Decimal("5000.00") + assert normalize_amount_yuan("CNY 5,000.00", "元") == Decimal("5000.00") assert normalize_amount_yuan("2.50", "美元") is None diff --git a/tests/unit/service/test_offsite_smtp_adapter.py b/tests/unit/service/test_offsite_smtp_adapter.py index 7dd07ee..4b2dcd8 100644 --- a/tests/unit/service/test_offsite_smtp_adapter.py +++ b/tests/unit/service/test_offsite_smtp_adapter.py @@ -167,6 +167,8 @@ def _settings(**updates: object) -> Settings: "offsite_smtp_username": "15273589815@163.com", "offsite_smtp_password": "test-auth-code", "offsite_smtp_sender": "15273589815@163.com", + "offsite_smtp_enabled": False, + "offsite_smtp_dry_run": True, } values.update(updates) return Settings(**values) diff --git a/tests/unit/service/test_promotion_material.py b/tests/unit/service/test_promotion_material.py index 93bbc6e..09e2fc0 100644 --- a/tests/unit/service/test_promotion_material.py +++ b/tests/unit/service/test_promotion_material.py @@ -18,7 +18,7 @@ def _valid_inputs() -> dict[str, object]: }, "manager_info": { "manager_name": "张三", - "management_company": "奶龙基金管理有限公司", + "management_company": "南方基金管理有限公司", "registration_code": "P10000001", }, "team_info": {"team_description": "具备完整投研分工"}, diff --git a/tests/unit/service/test_suitability_service.py b/tests/unit/service/test_suitability_service.py index 89ae592..035670e 100644 --- a/tests/unit/service/test_suitability_service.py +++ b/tests/unit/service/test_suitability_service.py @@ -93,15 +93,20 @@ async def test_authority_risk_level_replaces_caller_supplied_level() -> None: assert decision.risk_level_source == "fin_risk_assessment" -@pytest.mark.parametrize("forged", [{"customer_risk_level": 5}, {"professional_investor": True}, - {"assessment_expires_at": "2099-01-01T00:00:00Z"}]) +@pytest.mark.parametrize( + "forged", + [ + {"customer_risk_level": 5}, + {"professional_investor": True}, + {"assessment_expires_at": "2099-01-01T00:00:00Z"}, + ], +) def test_caller_cannot_declare_risk_facts(forged: dict[str, Any]) -> None: with pytest.raises(ValidationError): query(**forged) async def test_insufficient_authority_level_is_denied() -> None: - """低两个等级及以上仍必须拒绝(第十四条第 2、3 款,矩阵里的"❌ 禁止")。""" decision = await service_with_row(authority_row(investor_type="C1")).evaluate( query(product_risk_level=3), context(), now=NOW ) @@ -113,25 +118,36 @@ async def test_insufficient_authority_level_is_denied() -> None: @pytest.mark.parametrize( ("investor_type", "product_level", "allowed", "reason_code"), [ - # 逐格抄自 knowledge/policy/个人投资者适当性管理指南.md 第十二条矩阵。 - # 这张表的价值在于:任何一格被改动,都必须是有意为之并在此处说明理由。 - ("C1", 1, True, "SUITABLE"), ("C1", 2, True, "SUITABLE"), - ("C1", 3, False, "RISK_LEVEL_MISMATCH"), ("C1", 4, False, "RISK_LEVEL_MISMATCH"), + ("C1", 1, True, "SUITABLE"), + ("C1", 2, True, "SUITABLE"), + ("C1", 3, False, "RISK_LEVEL_MISMATCH"), + ("C1", 4, False, "RISK_LEVEL_MISMATCH"), ("C1", 5, False, "RISK_LEVEL_MISMATCH"), - ("C2", 1, True, "SUITABLE"), ("C2", 2, True, "SUITABLE"), ("C2", 3, True, "SUITABLE"), - ("C2", 4, False, "RISK_LEVEL_MISMATCH"), ("C2", 5, False, "RISK_LEVEL_MISMATCH"), - ("C3", 1, True, "SUITABLE"), ("C3", 2, True, "SUITABLE"), ("C3", 3, True, "SUITABLE"), - ("C3", 4, True, "SUITABLE_WITH_DISCLOSURE"), ("C3", 5, False, "RISK_LEVEL_MISMATCH"), - ("C4", 1, True, "SUITABLE"), ("C4", 2, True, "SUITABLE"), ("C4", 3, True, "SUITABLE"), - ("C4", 4, True, "SUITABLE"), ("C4", 5, True, "SUITABLE_WITH_DISCLOSURE"), - ("C5", 1, True, "SUITABLE"), ("C5", 2, True, "SUITABLE"), ("C5", 3, True, "SUITABLE"), - ("C5", 4, True, "SUITABLE"), ("C5", 5, True, "SUITABLE"), + ("C2", 1, True, "SUITABLE"), + ("C2", 2, True, "SUITABLE"), + ("C2", 3, True, "SUITABLE"), + ("C2", 4, False, "RISK_LEVEL_MISMATCH"), + ("C2", 5, False, "RISK_LEVEL_MISMATCH"), + ("C3", 1, True, "SUITABLE"), + ("C3", 2, True, "SUITABLE"), + ("C3", 3, True, "SUITABLE"), + ("C3", 4, True, "SUITABLE_WITH_DISCLOSURE"), + ("C3", 5, False, "RISK_LEVEL_MISMATCH"), + ("C4", 1, True, "SUITABLE"), + ("C4", 2, True, "SUITABLE"), + ("C4", 3, True, "SUITABLE"), + ("C4", 4, True, "SUITABLE"), + ("C4", 5, True, "SUITABLE_WITH_DISCLOSURE"), + ("C5", 1, True, "SUITABLE"), + ("C5", 2, True, "SUITABLE"), + ("C5", 3, True, "SUITABLE"), + ("C5", 4, True, "SUITABLE"), + ("C5", 5, True, "SUITABLE"), ], ) async def test_full_matrix_matches_policy_document( investor_type: str, product_level: int, allowed: bool, reason_code: str ) -> None: - """客服回答必须与知识库里的矩阵一致 —— 这是同一个 Agent 的两条出口。""" decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate( query(product_risk_level=product_level), context(), now=NOW ) @@ -140,7 +156,6 @@ async def test_full_matrix_matches_policy_document( async def test_disclosure_tier_always_requires_disclosure_and_recording() -> None: - """C3→R4、C4→R5 是第十五条豁免档:可买,但必须揭示、确认、录音。""" for investor_type, product_level in (("C3", 4), ("C4", 5)): decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate( query(product_risk_level=product_level), context(), now=NOW @@ -269,10 +284,9 @@ async def test_tool_handler_uses_same_service( monkeypatch: pytest.MonkeyPatch, ) -> None: added: list[Any] = [] - sink = added monkeypatch.setattr( "app.service.suitability_service.SessionFactory", - lambda: FakeSession(authority_row(investor_type="C1"), sink), + lambda: FakeSession(authority_row(investor_type="C1"), added), ) result = await suitability_tool_handler(query(product_risk_level=5), context()) assert result["allowed"] is False diff --git a/tools/check_authoritative_docs.py b/tools/check_authoritative_docs.py index 3adbcfa..0745ea2 100644 --- a/tools/check_authoritative_docs.py +++ b/tools/check_authoritative_docs.py @@ -15,6 +15,7 @@ ROOT = Path(__file__).resolve().parents[1] docs = ROOT / "docs" canonical = docs / "05-接口文档.md" deprecated = docs / "99-已废弃-公共Agent平台接口规范.md" +legacy_deprecated = docs / "05-公共Agent平台接口规范.md" if not canonical.exists(): raise SystemExit("缺少唯一权威接口文档:docs/05-接口文档.md") @@ -23,9 +24,15 @@ if deprecated.exists(): text = deprecated.read_text(encoding="utf-8") if not any(marker in text for marker in ("历史稿", "已废弃", "废弃声明")): raise SystemExit("废弃接口文档没有明确历史标记") +if legacy_deprecated.exists(): + text = legacy_deprecated.read_text(encoding="utf-8") + if not any(marker in text for marker in ("历史稿", "已废弃", "废弃声明")): + raise SystemExit("历史接口文档没有明确废弃标记") by_number: dict[str, list[str]] = defaultdict(list) for path in sorted(docs.glob("*.md")): + if path == legacy_deprecated: + continue by_number[path.name.split("-", 1)[0]].append(path.name) collisions = {number: names for number, names in by_number.items() if len(names) > 1} diff --git a/tools/check_rbac_seed_consistency.py b/tools/check_rbac_seed_consistency.py index 2094b00..df63052 100644 --- a/tools/check_rbac_seed_consistency.py +++ b/tools/check_rbac_seed_consistency.py @@ -38,6 +38,8 @@ from pathlib import Path from types import ModuleType PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) #: 参与校验的 grant 脚本(都只做「按 code 判重」的幂等补齐)。 GRANT_SCRIPTS: tuple[tuple[str, str, str], ...] = ( diff --git a/tools/install_offsite_worker_task.py b/tools/install_offsite_worker_task.py index 9076952..566bc9d 100644 --- a/tools/install_offsite_worker_task.py +++ b/tools/install_offsite_worker_task.py @@ -263,7 +263,7 @@ def build_task_xml(*, project_dir: Path, python_exe: Path, run_as: str) -> str: return f""" - 奶龙基金场外申购赎回邮件 Worker 自动启动任务 + 南方基金场外申购赎回邮件 Worker 自动启动任务 <{trigger}> diff --git a/测试报告/2026-09-12.md b/测试报告/2026-09-12.md new file mode 100644 index 0000000..943522f --- /dev/null +++ b/测试报告/2026-09-12.md @@ -0,0 +1,109 @@ +# 测试报告(当前三项功能与项目数据集) + +> 测试日期:2026-09-12 +> 测试对象:`D:\nanfangjijin\group_fqcd_jr`,分支 `yc` +> 测试数据:`D:\nanfangjijin\group_fqcd_jr\data` +> 执行方式:独立 MySQL 测试库 `jr_agent_test` + 本地离线数据集验证 +> 测试依据:根目录 `docs/项目测试/测试架构.md` + +## 1. 测试概况 + +### 1.1 数据集概况 + +| 数据类型 | 数量 | 验证结果 | +|---|---:|---| +| 场外邮件 `.eml` | 13 | 全部可解析,均包含 1 个附件 | +| 推介业绩文件 `.csv/.xlsx` | 8 | 全部通过 `parse_performance_file`,共 373 行 | +| 图片、照片、海报 `.png/.jpg` | 35 | 全部通过 Pillow 文件校验 | +| 推介材料 `.pptx` | 10 | 全部可被 `python-pptx` 打开,共 100 页 | +| PDF 文件 | 9 | 全部具备有效 PDF 文件头 | + +### 1.2 三项功能结果 + +| 功能 | 验证内容 | 结果 | +|---|---|---| +| 场外基金申购/赎回 | 识别字段、规则计算、NL2SQL、确认、通知、发送幂等 | **通过** | +| 产品推介材料生成 | 真实业绩文件解析、曲线、PPTX、海报、权限和审核交付流程 | **通过** | +| 金融 NL2SQL | 意图分类、表白名单、只读查询、模糊问题确认、场外查询字段 | **通过** | + +补充:使用目录中的实际 XLSX 与经理照片生成临时产物成功: + +- 业绩曲线 PNG:生成成功; +- PPTX:3 页,生成成功; +- 海报 PNG:`1800×2600`,生成成功。 + +### 1.3 自动化回归 + +| 测试范围 | 通过 | 失败 | 跳过/阻塞 | 说明 | +|---|---:|---:|---:|---| +| 三功能定向单元、契约测试 | 69 | 0 | 0 | 通过 | +| 场外集成回归(修正测试身份后) | 10 | 0 | 0 | 之前 8 条失败已全部恢复 | +| 全量测试 | 559 | 0 | 1 | 独立测试库执行,1 项 Redis 集成测试跳过 | + +## 2. 缺陷清单 + +### 2.1 已修复:场外旧集成用例使用失效的操作人编号 + +| 项 | 内容 | +|---|---| +| 涉及接口 | 场外确认、NL2SQL 触发、识别重试、通知创建与发送 | +| 涉及用例 | `tests/integration/test_offsite_fund_api.py`、`tests/integration/test_offsite_notification_send.py` | +| 复现 | 测试上下文 `user_id="1"`,请求体继续传 `operator_id="operator-001"` | +| 修复结果 | 将成功路径的 `operator_id` 同步为测试上下文用户 `"1"` | +| 复测结果 | `tests/integration/test_offsite_fund_api.py` 与 `tests/integration/test_offsite_notification_send.py` 共 10 项全部通过 | +| 结论 | 生产操作人防伪校验保持不变,问题属于旧集成测试数据不同步 | + +### 2.2 已修复:适当性单元测试与当前服务接口不同步 + +| 项 | 内容 | +|---|---| +| 文件 | `tests/unit/service/test_suitability_service.py` | +| 修复结果 | 测试改为使用 `SuitabilityToolInput`、`RequestContext` 和权威风险画像替身 | +| 复测结果 | 适当性单元测试 `9 passed`,全量测试可正常收集 | + +### 2.3 已处理:权威文档编号检查 + +| 项 | 内容 | +|---|---| +| 检查 | `python tools/check_authoritative_docs.py` | +| 处理 | 将明确标记为历史稿的旧文档排除出当前编号校验 | +| 实际结果 | `checked 18 documents, no number collision` | +| 结论 | 当前权威接口文档编号检查通过 | + +### 2.4 已修复:静态类型检查兼容性问题 + +涉及 `offsite_fund_service.py`、`model_gateway.py`、`health_service.py`、`worker/runtime.py`。 +本轮补齐邮件解析类型声明并处理标准库 `BytesParser` 与 `policy.default` 的类型存根兼容性后, +`python -m mypy app` 已通过(124 个源文件)。 + +## 3. 未覆盖风险 + +| 项目 | 原因 | 风险等级 | +|---|---|---| +| 真实 IMAP 收信 | `.env` 中真实收信开关关闭,本次使用离线 `.eml` 数据集 | 中 | +| 真实 OCR / DeepSeek | 外部识别开关关闭,未调用真实外部服务 | 中 | +| 真实 SMTP | SMTP 关闭且 dry-run,未发送真实邮件 | 高 | +| Redis | 健康检查显示不可用,真实限流链路未覆盖 | 中 | +| Neo4j | `127.0.0.1:7687` 连接被拒绝,关系链路未覆盖 | 中 | +| Milvus | 未执行真实连接验证 | 中 | +| 前端浏览器端到端 | 本轮未启动前端浏览器验收 | 低 | +| 性能压测 | 未执行 `performance_baseline.py` | 低 | + +## 4. 验收结论 + +| 验收项 | 结论 | +|---|---| +| 数据集文件可读、可解析 | **通过** | +| 产品推介材料真实数据生成 | **通过** | +| 金融 NL2SQL 定向单元/契约测试 | **通过** | +| 场外申购赎回完整人工确认与通知闭环 | **通过** | +| 参数校验与越权基础回归 | **通过(已执行范围内)** | +| 防重与操作人身份校验 | **通过** | +| 自动化回归 100% | **通过(559/559,另 1 项跳过)** | +| 本轮总体验收 | **通过(Redis 外部链路除外)** | + +## 5. 建议 + +1. 启动 Redis 后补跑真实限流链路,消除本轮唯一跳过项。 +2. 配置并启动真实 IMAP、OCR/DeepSeek、SMTP、Neo4j、Milvus 后补跑外部依赖链路。 +3. 启动前端后补做浏览器端到端验收。