Merge remote-tracking branch 'origin/qyqy_develop' into lzl_qyqy_integration
This commit is contained in:
@@ -20,7 +20,8 @@
|
||||
> **⭐ 第 0 步先读这个**:`docs/superpowers/handoff/2026-09-11-交接文档-客服Agent与RAG收尾.md`
|
||||
> —— 客服 Agent + RAG 这条线的交接文档(**含合并完成后的第二次更新**):环境口径、交付内容与
|
||||
> **可复现验证证据**、合并后修掉的 3 个真机故障、**已知问题清单(逐条标注当前状态)**、Git/PR 状态。
|
||||
> 当前工作分支是 **`NL_develop`**(个人分支 → PR 合回 `qyqy_develop`),**不要再用 `6516ccb`**。
|
||||
> **主集成分支是 `qyqy_develop`**(ZSY 的客服接入线已由 PR #7 合入,见 `docs/36`);
|
||||
> 客服/RAG 那条线的个人分支是 **`NL_develop`**(个人分支 → PR 合回 `qyqy_develop`),**不要再用 `6516ccb`**。
|
||||
> 它是对"当前状态"最准确的一份,读完它再读下面这些。
|
||||
>
|
||||
> **⚠️ 文档现状(2026-09-11 第二次修订)**:本文件原先声明"已删除 5 份编号文档",
|
||||
@@ -78,9 +79,20 @@
|
||||
场外/推广那 17 张逐表登记见 `docs/28-场外与推广域数据表登记.md`;
|
||||
**投顾那 21 张的登记文档待补**(按同样口径另立一份)。
|
||||
核验命令:`python tools/audit_schema.py`(若报 `unexpected` 先分清是"库里多表"还是"迁移没进来")。
|
||||
- 已注册业务 Agent:`FundQueryDemoAgent`、`CustomerServiceAgent`、`RiskAgent`、`PlatformProbeAgent`(见 `app/service/agent/bootstrap.py`)。
|
||||
- 已注册业务 Agent(**7 个**,见 `app/service/agent/implementations/` 与 `app/service/agent/`):
|
||||
`FundQueryDemoAgent`、`CustomerServiceAgent`、`RiskAgent`、`PlatformProbeAgent`、
|
||||
**`AdvisorAgent`**、**`OffsiteFundAgent`**、**`PromotionMaterialAgent`**。
|
||||
- 已注册公共只读工具:`search_knowledge`(客服知识检索)、`check_suitability`、`query_customer_profile`(画像)、`query_fund_quote`;
|
||||
**`query_knowledge` 是 `search_knowledge` 的别名**(同一 handler,为兼容一期发布配置与旧客户端保留,见 `bootstrap.py`);
|
||||
其余业务线工具(风控、投顾、NL2SQL)按各自 Agent 白名单注册,全部在 `bootstrap.py` 的 `get_agent_factory()` 里。
|
||||
**工具可用范围 = 代码上限 ∩ 当前 active `config_release` 的发布白名单**,缺发布配置则失败关闭。
|
||||
- ⚠️ **RBAC 权限码的定义源是 `tools/seed_test_rbac.py` 的 `PERMISSIONS`**(9001-9046 号段):
|
||||
那个脚本是 **DELETE 重建**语义(`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099`),
|
||||
**没并进它的权限码重建一次就没了**,表现是"接口突然 403"而没有任何报错线索。
|
||||
`tools/grant_*.py` 只补种子里缺的,且 id 必须与种子**逐条一致** ——
|
||||
一致性由 `python tools/check_rbac_seed_consistency.py` 及其单测守着
|
||||
(2026-09-12 曾因两套 id→code 映射并存,让 `advisor` 在种子重建后静默拿到语义错误的权限)。
|
||||
另注:`sys_user` 已改为「存在则更新、不存在才插入」,故重跑种子**不会**再弄丢演示密码。
|
||||
- ⚠️ **`config_release` 是环境数据,不随代码合并**:本机 active 版本 id 与架构师环境**不同**
|
||||
(本机是我方发布的客服白名单;他那边还有风控的 9 条白名单)。**"白名单已发布"必须带环境限定**,换环境要重发。
|
||||
发布脚本 `tools/publish_customer_service_config.py`(**同 key 的继承项必须被本次定义覆盖**,否则旧值会被子集校验 422 拦下整次发布)。
|
||||
@@ -93,10 +105,12 @@
|
||||
健康检查与部分检索链路会指向本地 **Milvus Lite 文件**。团队/生产环境请**保持该变量为空**。
|
||||
对应的 `milvus-lite` 属**本地开发依赖**,应放在 `pyproject.toml` 的
|
||||
`optional-dependencies`,**不要进主 `dependencies`**。
|
||||
- 测试基线(2026-09-11 架构师环境实测):`ruff` 干净 / `mypy app` **228 个文件 0 错** /
|
||||
`pytest tests/unit tests/contract` → **1207 passed, 2 skipped, 0 failed** /
|
||||
`pytest tests/integration` → **99 passed**。完整口径与联调清单见
|
||||
`docs/32-平台侧交接与联调准备.md`。
|
||||
- 测试基线(**2026-09-12 合并 PR #7 之后实测**):`ruff` 干净 / `mypy app` **244 个文件 0 错** /
|
||||
`pytest tests/unit tests/contract` → **1276 passed, 2 skipped, 0 failed**
|
||||
(**用例数会随开发增减,判断健康看"0 failed"而不是看绝对值**;出现数量级差异再按下面那条对版本) /
|
||||
`pytest tests/integration` → 99 passed(**合并前口径,合并后未整套复跑**,已复跑的是
|
||||
`test_auth_login_mysql.py` + `test_rbac_read_mysql.py` → 19 passed)。完整口径与联调清单见
|
||||
`docs/32-平台侧交接与联调准备.md`;本次合并的逐项证据见 `docs/36-PR7合并记录与权限号段修正.md`。
|
||||
- ⚠️ **mypy 与测试数必须带环境读**:出现"一边上百个错、另一边 0 错"时先对版本,别当代码质量问题。
|
||||
已知根因是某一侧的虚拟环境没满足 `pyproject.toml` 的 `sqlalchemy>=2.0,<3` / `mypy>=1.14,<2`。
|
||||
**不要装 `sqlalchemy2-stubs`** —— 那是给 SQLAlchemy 1.4 的,2.0 自带 `py.typed`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""重新判定规则只接受操作人身份,数值一律取自已落库的识别与查询结果。"""
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class HealthService:
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
await client.aclose()
|
||||
await client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| 基金名称缓存 | 行情缓存适配器 | 允许 Redis,不使用进程全局作为唯一缓存 |
|
||||
| 历史净值和收益率合并 | `app/service/fund_quote_service.py` | 统一 DTO、Decimal、降级标识 |
|
||||
| 中文字典输出 | Controller/Agent 展示层 | 内部 DTO 使用英文稳定字段 |
|
||||
| 固定奶龙基金代码 | 配置或代码白名单 | 先保留白名单,后续配置中心化 |
|
||||
| 固定南方基金代码 | 配置或代码白名单 | 先保留白名单,后续配置中心化 |
|
||||
|
||||
## 四、阶段计划
|
||||
|
||||
|
||||
@@ -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": "运营确认后的回复正文",
|
||||
},
|
||||
|
||||
@@ -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,不能伪装成查询失败。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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年公募基金投资管理经验",
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""端点权限判定的 HTTP 层用例(不连数据库)。
|
||||
|
||||
## 为什么单开这一组
|
||||
|
||||
既有用例(含 `tests/integration/test_customer_service_handover_admin_mysql.py`)都通过
|
||||
`app.dependency_overrides[build_request_context]` 注入**已经带好权限**的上下文,
|
||||
所以只覆盖了"有权限能通",**覆盖不到"缺权限必须被拒"**。
|
||||
|
||||
而 2026-09-12 出过的那类事故(客服二期三个权限码 `memory:candidate:confirm` /
|
||||
`memory:candidate:review` / `handover:read` 没随代码合并进环境)恰好只会在这一层暴露:
|
||||
|
||||
权限判定发生在身份解析之后(`app/api/dependencies/auth.py` → `IdentityService.resolve`),
|
||||
而服务层测试是自己构造 `RequestContext` 的、权限字段由测试塞进去,
|
||||
所以"权限码在库里根本不存在"这类问题,单元/集成测试全绿也照样漏。
|
||||
|
||||
## 替换了什么、保留了哪些真实部分
|
||||
|
||||
- **保留**:真实路由、真实 `build_request_context` 依赖注入位、真实
|
||||
`AuthorizationService.require` 判定与 `ForbiddenAgentError` → 403 信封。
|
||||
- **替换**(只替换两处边界):
|
||||
1. `build_request_context` → 直接返回指定权限集的上下文(跳过 JWT 与身份库查询);
|
||||
2. `AuthorizationService` 的审计落库 → 内存替身(拒绝时会写一条 `permission.denied`)。
|
||||
|
||||
## 为什么正反两个方向都要断言
|
||||
|
||||
- **反向**(权限集里没有该权限 → 必须 403):防"端点忘了做权限校验"。
|
||||
- **正向**(把该权限放进去 → **不能**再是 403):把"端点要求的权限码"钉住 ——
|
||||
若有人改了端点要的权限码而没同步这里,正向会立刻变红。
|
||||
正向之后的下游(MySQL / Redis)在单元环境不可用,返回 5xx 属正常;
|
||||
本用例只关心"不再因权限被拒",因此断言 `status_code != 403` 而不是 `== 200`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.core.contracts import RequestContext
|
||||
from app.main import create_app
|
||||
from app.service import authorization_service
|
||||
|
||||
PERMISSION_DENIED_CODE = "AGENT_PERMISSION_DENIED"
|
||||
|
||||
#: (HTTP 方法, 路径, 该端点要求的权限码, 角色, 请求体)
|
||||
CASES: tuple[tuple[str, str, str, tuple[str, ...], dict[str, Any] | None], ...] = (
|
||||
(
|
||||
"GET",
|
||||
"/api/v1/admin/customer-service/handover-tickets",
|
||||
"handover:read",
|
||||
("admin",),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/v1/admin/customer-service/handover-tickets/T-0001",
|
||||
"handover:read",
|
||||
("admin",),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/v1/admin/customer-profile-candidates",
|
||||
"memory:candidate:review",
|
||||
("admin",),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/api/v1/admin/customer-profile-candidates/1/reviews",
|
||||
"memory:candidate:review",
|
||||
("admin",),
|
||||
{"decision": "approved"},
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/api/v1/users/me/memory-candidates/1/decisions",
|
||||
"memory:candidate:confirm",
|
||||
("customer",),
|
||||
{"decision": "confirmed"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""只承载"拒绝时写一条审计"这一步,不碰数据库。"""
|
||||
|
||||
def add(self, _instance: object) -> None:
|
||||
return None
|
||||
|
||||
def begin(self) -> _FakeSession:
|
||||
return self
|
||||
|
||||
async def __aenter__(self) -> _FakeSession:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _FakeSessionFactory:
|
||||
def __call__(self) -> _FakeSession:
|
||||
return _FakeSession()
|
||||
|
||||
|
||||
def _contains_code(payload: Any, code: str) -> bool:
|
||||
"""在响应 JSON 里递归找错误码,不依赖信封的具体层级。"""
|
||||
if isinstance(payload, dict):
|
||||
return any(
|
||||
(isinstance(value, str) and value == code) or _contains_code(value, code)
|
||||
for value in payload.values()
|
||||
)
|
||||
if isinstance(payload, list):
|
||||
return any(_contains_code(item, code) for item in payload)
|
||||
return False
|
||||
|
||||
|
||||
def _request(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
permissions: tuple[str, ...],
|
||||
roles: tuple[str, ...],
|
||||
body: dict[str, Any] | None,
|
||||
) -> Any:
|
||||
app = create_app()
|
||||
|
||||
async def override_context() -> RequestContext:
|
||||
return RequestContext(
|
||||
user_id="9003",
|
||||
trace_id="permission-enforcement-trace",
|
||||
roles=roles,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
app.dependency_overrides[build_request_context] = override_context
|
||||
monkeypatch.setattr(authorization_service, "SessionFactory", _FakeSessionFactory())
|
||||
try:
|
||||
# 正向用例会走到下游(MySQL / Redis),单元环境不可用会抛异常;
|
||||
# 这里让它变成 5xx 响应,用例只关心"不再因权限被拒"。
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
return client.request(method, path, json=body)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("method", "path", "permission", "roles", "body"), CASES)
|
||||
def test_endpoint_denies_request_without_required_permission(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: str,
|
||||
path: str,
|
||||
permission: str,
|
||||
roles: tuple[str, ...],
|
||||
body: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""反向:权限集里没有该权限时,必须 403 且错误码是 `AGENT_PERMISSION_DENIED`。"""
|
||||
response = _request(
|
||||
monkeypatch,
|
||||
method=method,
|
||||
path=path,
|
||||
permissions=(),
|
||||
roles=roles,
|
||||
body=body,
|
||||
)
|
||||
|
||||
assert response.status_code == 403, (
|
||||
f"{method} {path} 缺少 {permission} 时预期 403,实际 {response.status_code}:"
|
||||
f"{response.text[:200]}"
|
||||
)
|
||||
assert _contains_code(response.json(), PERMISSION_DENIED_CODE), response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("method", "path", "permission", "roles", "body"), CASES)
|
||||
def test_endpoint_accepts_request_with_required_permission(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
method: str,
|
||||
path: str,
|
||||
permission: str,
|
||||
roles: tuple[str, ...],
|
||||
body: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""正向:放入该权限后不能再是 403 —— 同时把端点要求的权限码钉住。"""
|
||||
response = _request(
|
||||
monkeypatch,
|
||||
method=method,
|
||||
path=path,
|
||||
permissions=(permission,),
|
||||
roles=roles,
|
||||
body=body,
|
||||
)
|
||||
|
||||
assert response.status_code != 403, (
|
||||
f"{method} {path} 带上 {permission} 后仍被拒;"
|
||||
f"端点要求的权限码可能已改动:{response.text[:200]}"
|
||||
)
|
||||
assert not _contains_code(response.json(), PERMISSION_DENIED_CODE), response.text
|
||||
|
||||
|
||||
def test_cases_cover_every_phase2_permission_code() -> None:
|
||||
"""这组用例必须覆盖客服二期新增的三个权限码,缺一个就失去意义。"""
|
||||
covered = {case[2] for case in CASES}
|
||||
assert covered == {
|
||||
"handover:read",
|
||||
"memory:candidate:confirm",
|
||||
"memory:candidate:review",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "具备完整投研分工"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""`docs/05-接口文档.md` §19 端点编号唯一性(只读,不连库)。
|
||||
|
||||
规则与动机见 `tools/check_docs_endpoint_ids.py` 的模块说明:`check_authoritative_docs.py`
|
||||
只校验 `docs/` 的**文件名编号**,不校验 §19 的**端点编号** —— 2026-09-12 两条线各自
|
||||
新增端点时都占了 `A034`/`A035`,合并后同时存在两个 `A034` 与两个 `A035` 却仍然通过守卫。
|
||||
|
||||
这里额外守住两件事:
|
||||
|
||||
1. **不能"空扫通过"**:真实文档必须真的扫到足量编号,否则脚本坏掉了也会显示"无重复";
|
||||
2. **覆盖范围要可见**:扫描不得依赖前缀白名单 —— 同一件事上曾因正则写成 `[AMKCS]`
|
||||
而漏掉 `O`/`R` 两段,把 62 个端点报成 55 个;漏掉的号段一旦被复用,脚本仍会报
|
||||
"重复 0"。所以覆盖报告必须列出每个号段的数量。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
#: 真实 §19 的规模下限:远小于实际数量,只为拦住"扫到 0 条也算通过"。
|
||||
MIN_REAL_ENDPOINTS = 40
|
||||
|
||||
|
||||
def _load_tool_module() -> ModuleType:
|
||||
path = PROJECT_ROOT / "tools" / "check_docs_endpoint_ids.py"
|
||||
spec = importlib.util.spec_from_file_location("check_docs_endpoint_ids", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _write_doc(tmp_path: Path, body: str) -> Path:
|
||||
doc = tmp_path / "05-接口文档.md"
|
||||
doc.write_text(
|
||||
"# 接口文档\n\n## 19. 接口总目录\n\n"
|
||||
"| 编号 | 方法与路径 | 权限 | 幂等 | 成功状态 | 审计 |\n"
|
||||
"|---|---|---|---|---|---|\n"
|
||||
f"{body}\n\n## 20. 变更流程\n\n占位。\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
def test_real_document_has_no_duplicate_endpoint_ids() -> None:
|
||||
module = _load_tool_module()
|
||||
assert module.collect_findings() == []
|
||||
|
||||
|
||||
def test_real_document_actually_scans_enough_endpoints() -> None:
|
||||
"""防止脚本坏掉后"什么都没扫到"却报通过。"""
|
||||
module = _load_tool_module()
|
||||
ids, _ = module.collect_rows(module.read_section())
|
||||
assert len(ids) >= MIN_REAL_ENDPOINTS
|
||||
assert len(set(ids)) == len(ids)
|
||||
|
||||
|
||||
def test_duplicate_endpoint_id_is_detected(tmp_path: Path) -> None:
|
||||
module = _load_tool_module()
|
||||
doc = _write_doc(
|
||||
tmp_path,
|
||||
"| A034 | `POST /api/v1/auth/tokens` | 公开 | 否 | `200` | 否 |\n"
|
||||
"| A034 | `GET /api/v1/admin/customer-profile-candidates` | `x:y` | 否 | `200` | 否 |\n",
|
||||
)
|
||||
findings = module.collect_findings(doc)
|
||||
assert any("A034" in item and "重复" in item for item in findings)
|
||||
|
||||
|
||||
def test_unrecognized_first_column_is_reported(tmp_path: Path) -> None:
|
||||
module = _load_tool_module()
|
||||
doc = _write_doc(tmp_path, "| A034(临时) | `GET /x` | `a:b` | 否 | `200` | 否 |\n")
|
||||
findings = module.collect_findings(doc)
|
||||
assert any("无法识别" in item for item in findings)
|
||||
|
||||
|
||||
def test_coverage_report_lists_every_prefix_segment(tmp_path: Path) -> None:
|
||||
"""覆盖报告必须暴露各号段数量 —— 漏扫一个前缀就会在这里显形。"""
|
||||
module = _load_tool_module()
|
||||
doc = _write_doc(
|
||||
tmp_path,
|
||||
"| A001 | `GET /a1` | `a:b` | 否 | `200` | 否 |\n"
|
||||
"| A002 | `GET /a2` | `a:b` | 否 | `200` | 否 |\n"
|
||||
"| M003 | `GET /m3` | `a:b` | 否 | `200` | 否 |\n"
|
||||
"| O001 | `GET /o1` | `a:b` | 否 | `200` | 否 |\n"
|
||||
"| R001 | `GET /r1` | `a:b` | 否 | `200` | 否 |\n",
|
||||
)
|
||||
report = module.describe_coverage(doc)
|
||||
for expected in ("5 个端点编号", "4 个号段", "A×2", "M×1", "O×1", "R×1"):
|
||||
assert expected in report, report
|
||||
|
||||
|
||||
def test_missing_section_raises(tmp_path: Path) -> None:
|
||||
module = _load_tool_module()
|
||||
doc = tmp_path / "05-接口文档.md"
|
||||
doc.write_text("# 接口文档\n\n## 1. 概述\n\n没有 §19。\n", encoding="utf-8")
|
||||
with pytest.raises(module.DocumentStructureError):
|
||||
module.collect_findings(doc)
|
||||
|
||||
|
||||
def test_empty_checklist_prepares_no_false_success(tmp_path: Path) -> None:
|
||||
"""§19 存在但一张表都没有时,必须报结构异常,而不是"无重复"。"""
|
||||
module = _load_tool_module()
|
||||
doc = _write_doc(tmp_path, "本节没有表格。")
|
||||
findings = module.collect_findings(doc)
|
||||
assert any("没有扫描到任何端点编号" in item for item in findings)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""RBAC 权限号段一致性(只读,不连库)。
|
||||
|
||||
规则与动机见 `tools/check_rbac_seed_consistency.py` 的模块说明:权限码的定义源是
|
||||
`tools/seed_test_rbac.py` 的 `PERMISSIONS`,各 `grant_*.py` 只能补种子里缺的,
|
||||
且 id 必须与种子逐条一致 —— 2026-09-12 曾因两套 id→code 映射并存,
|
||||
让 `advisor` 角色在种子重建后静默拿到语义错误的权限。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _load_tool_module() -> ModuleType:
|
||||
path = PROJECT_ROOT / "tools" / "check_rbac_seed_consistency.py"
|
||||
spec = importlib.util.spec_from_file_location("check_rbac_seed_consistency", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_permission_ids_are_consistent_between_seed_and_grant_scripts() -> None:
|
||||
problems = _load_tool_module().collect_findings()
|
||||
assert problems == [], "权限号段不一致:\n" + "\n".join(problems)
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""校验 `docs/05-接口文档.md` §19「接口总目录」的端点编号唯一性(只读,不连数据库)。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
`tools/check_authoritative_docs.py` 只校验 `docs/` 的**文件名编号**与权威性声明,
|
||||
**不校验 §19 里每个端点的编号**。2026-09-12 出过一次真实事故:两条线各自新增端点时
|
||||
都占用了 `A034`/`A035`,合并后 §19 同时存在两个 `A034` 与两个 `A035`,
|
||||
而文档守卫照样通过 —— 编号复用比"编号不够"更麻烦,而且**静默遗留**。
|
||||
|
||||
## 口径(含覆盖自检)
|
||||
|
||||
- 扫描范围:§19 章节内的**所有**表格行首列;
|
||||
- **不枚举前缀白名单**,前缀从数据里归纳后打印出来。同一件事上还踩过一次
|
||||
"扫描正则写成 `[AMKCS]` 就漏掉 `O`/`R` 两段,把 62 个端点报成 55 个" ——
|
||||
一旦被漏掉的号段将来被复用,脚本仍会报"重复 0"。所以这里把
|
||||
**"扫到哪几个号段、各多少条、共计多少"** 一并输出,让覆盖范围本身可核对;
|
||||
- 首列不是 `XXX###` 形状的行会被显式列出来(而不是静默跳过);
|
||||
- 只读:不修改任何文件。
|
||||
|
||||
用法:
|
||||
|
||||
python tools/check_docs_endpoint_ids.py
|
||||
|
||||
退出码:0 = 无重复;1 = 有重复或文档结构异常。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
INTERFACE_DOC = PROJECT_ROOT / "docs" / "05-接口文档.md"
|
||||
|
||||
#: §19 的起止标题(`: \\s*` 之间允许一个空格,避免依赖标题里的具体文字)。
|
||||
SECTION_START = re.compile(r"^##\s*19\.")
|
||||
SECTION_END = re.compile(r"^##\s*20\.")
|
||||
|
||||
#: 表格行:只取首列,容忍单元格两侧空白。
|
||||
TABLE_ROW = re.compile(r"^\|(.+)\|\s*$")
|
||||
#: Markdown 分隔行:| --- | :---: | 之类。
|
||||
SEPARATOR = re.compile(r"^[\s\-:|]+$")
|
||||
#: 端点编号形状:1~4 个大写字母 + 3~4 位数字(A034 / M003 / R001)。
|
||||
ENDPOINT_ID = re.compile(r"^[A-Z]{1,4}\d{3,4}$")
|
||||
|
||||
|
||||
class DocumentStructureError(RuntimeError):
|
||||
"""`docs/05` 的 §19 结构不符合预期,无法安全校验。"""
|
||||
|
||||
|
||||
def read_section(path: Path = INTERFACE_DOC) -> list[str]:
|
||||
"""返回 §19 的正文行(不含起止标题)。"""
|
||||
if not path.exists():
|
||||
raise DocumentStructureError(f"缺少接口权威文档:{path}")
|
||||
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
start = next((i for i, line in enumerate(lines) if SECTION_START.match(line)), None)
|
||||
if start is None:
|
||||
raise DocumentStructureError("接口文档里找不到 §19 章节标题")
|
||||
end = next(
|
||||
(i for i in range(start + 1, len(lines)) if SECTION_END.match(lines[i])),
|
||||
len(lines),
|
||||
)
|
||||
return lines[start + 1 : end]
|
||||
|
||||
|
||||
def collect_rows(lines: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""返回 `(首列编号列表, 无法识别的首列列表)`。
|
||||
|
||||
表头行、分隔行、非表格行都会被跳过;非空但形状不对的首列会被归类为
|
||||
"无法识别",以便显式暴露而不是静默漏扫。
|
||||
"""
|
||||
ids: list[str] = []
|
||||
unrecognized: list[str] = []
|
||||
for line in lines:
|
||||
match = TABLE_ROW.match(line.strip())
|
||||
if match is None:
|
||||
continue
|
||||
first_cell = match.group(1).split("|", 1)[0].strip()
|
||||
if not first_cell or SEPARATOR.match(first_cell):
|
||||
continue
|
||||
if first_cell == "编号": # 表头
|
||||
continue
|
||||
if ENDPOINT_ID.match(first_cell):
|
||||
ids.append(first_cell)
|
||||
else:
|
||||
unrecognized.append(first_cell)
|
||||
return ids, unrecognized
|
||||
|
||||
|
||||
def collect_findings(path: Path = INTERFACE_DOC) -> list[str]:
|
||||
"""返回问题列表;空列表表示一致。"""
|
||||
lines = read_section(path)
|
||||
ids, unrecognized = collect_rows(lines)
|
||||
|
||||
problems: list[str] = []
|
||||
if not ids:
|
||||
problems.append("§19 没有扫描到任何端点编号,文档结构可能已变化,请人工确认")
|
||||
|
||||
counter = Counter(ids)
|
||||
for endpoint_id in sorted(code for code, count in counter.items() if count > 1):
|
||||
problems.append(f"端点编号 {endpoint_id} 重复 {counter[endpoint_id]} 次")
|
||||
|
||||
for cell in sorted(set(unrecognized)):
|
||||
problems.append(f"§19 首列无法识别为端点编号:{cell!r}")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def describe_coverage(path: Path = INTERFACE_DOC) -> str:
|
||||
"""返回覆盖范围报告 —— 让"扫到了什么"可见,而不是只报"没问题"。"""
|
||||
ids, _ = collect_rows(read_section(path))
|
||||
prefixes = Counter(re.match(r"[A-Z]+", code).group() for code in ids)
|
||||
detail = "、".join(f"{prefix}×{count}" for prefix, count in sorted(prefixes.items()))
|
||||
return f"§19 覆盖:{len(ids)} 个端点编号 / {len(prefixes)} 个号段({detail})"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
problems = collect_findings()
|
||||
print(describe_coverage())
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f" ✗ {problem}")
|
||||
print(f"docs/05 §19 端点编号校验失败:{len(problems)} 处问题")
|
||||
return 1
|
||||
print("docs/05 §19 端点编号无重复")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,123 @@
|
||||
"""校验 RBAC 权限号段的一致性(只读,不连数据库)。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
权限码的**定义源**是 `tools/seed_test_rbac.py` 的 `PERMISSIONS` 常量 —— 因为那个脚本是
|
||||
**DELETE 重建**语义:
|
||||
|
||||
```
|
||||
DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099
|
||||
```
|
||||
|
||||
任何没并进 `PERMISSIONS` 的权限码,重建一次就没了,表现是"接口突然 403",
|
||||
而且**没有任何报错线索**。
|
||||
|
||||
2026-09-12 出过一次真实事故:库里有一批 `9020-9035` 是 `grant_advisor_role.py` 用**旧号段**
|
||||
建的,而投顾线把 `9020-9034` 写进了种子 —— 两套 **id→code 映射不同**。种子的清理只清
|
||||
`role_id 9001-9003` 的角色绑定,`advisor`(9004) 的绑定**不在清理范围内**,于是重建后
|
||||
advisor 会静默拿到**语义完全错误**的权限组合(例如 id 9020 从 `asset-allocation:generate:self`
|
||||
变成 `investment-goal:write:self`),全程不报错。
|
||||
|
||||
本脚本把这条约束变成可自动检查的四条:
|
||||
|
||||
1. 种子内 id 不重复;
|
||||
2. 每个 `grant_*.py` 声明的 `(id, code)` 都能在种子里找到**完全一致**的一条;
|
||||
3. `CUSTOMER_PERMISSIONS` 引用的 id 都存在;
|
||||
4. 各 `grant_*.py` 之间不抢同一个 id。
|
||||
|
||||
`tests/unit/tools/test_rbac_seed_consistency.py` 会调用它,所以这四条是纳入门禁的。
|
||||
|
||||
用法:python tools/check_rbac_seed_consistency.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
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], ...] = (
|
||||
("tools/grant_advisor_role.py", "ADVISOR_PERMISSIONS", "投顾"),
|
||||
("tools/grant_customer_service_phase2_permissions.py", "PHASE2_PERMISSIONS", "客服二期"),
|
||||
)
|
||||
|
||||
|
||||
def _load(name: str, relative: str) -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, PROJECT_ROOT / relative)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"无法加载 {relative}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def collect_findings() -> list[str]:
|
||||
"""返回问题列表;空列表表示一致。"""
|
||||
problems: list[str] = []
|
||||
|
||||
seed = _load("_rbac_seed", "tools/seed_test_rbac.py")
|
||||
seed_map: dict[int, str] = {}
|
||||
for row in seed.PERMISSIONS:
|
||||
permission_id, code = int(row[0]), str(row[1])
|
||||
if permission_id in seed_map:
|
||||
problems.append(
|
||||
f"种子里 id {permission_id} 重复:{seed_map[permission_id]} 与 {code}"
|
||||
)
|
||||
seed_map[permission_id] = code
|
||||
|
||||
claimed: dict[int, str] = {}
|
||||
for relative, attribute, label in GRANT_SCRIPTS:
|
||||
module = _load(f"_rbac_{attribute.lower()}", relative)
|
||||
for row in getattr(module, attribute):
|
||||
permission_id, code = int(row[0]), str(row[1])
|
||||
seeded = seed_map.get(permission_id)
|
||||
if seeded is None:
|
||||
problems.append(f"{label}({relative}):id {permission_id}({code})不在种子里")
|
||||
elif seeded != code:
|
||||
problems.append(
|
||||
f"{label}({relative}):id {permission_id} 与种子冲突 —— "
|
||||
f"种子={seeded},脚本={code}"
|
||||
)
|
||||
if permission_id in claimed and claimed[permission_id] != code:
|
||||
problems.append(
|
||||
f"id {permission_id} 被两个脚本抢用:{claimed[permission_id]} 与 {code}"
|
||||
)
|
||||
claimed[permission_id] = code
|
||||
|
||||
for permission_id in seed.CUSTOMER_PERMISSIONS:
|
||||
if int(permission_id) not in seed_map:
|
||||
problems.append(f"CUSTOMER_PERMISSIONS 引用了不存在的 id {permission_id}")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
||||
|
||||
seed = _load("_rbac_seed_report", "tools/seed_test_rbac.py")
|
||||
print(f"种子权限 {len(seed.PERMISSIONS)} 条;grant 脚本 {len(GRANT_SCRIPTS)} 个")
|
||||
|
||||
problems = collect_findings()
|
||||
if problems:
|
||||
print("\n[失败] 号段不一致:")
|
||||
for item in problems:
|
||||
print(f" - {item}")
|
||||
print(
|
||||
"\n处置:权限码的定义以 tools/seed_test_rbac.py 的 PERMISSIONS 为准;"
|
||||
"grant 脚本只补种子里缺的,且 id 必须与种子一致。"
|
||||
)
|
||||
return 1
|
||||
|
||||
print("一致性检查通过:种子内 id 唯一,各 grant 脚本与种子逐条一致。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -263,7 +263,7 @@ def build_task_xml(*, project_dir: Path, python_exe: Path, run_as: str) -> str:
|
||||
return f"""<?xml version="1.0" encoding="UTF-16"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>奶龙基金场外申购赎回邮件 Worker 自动启动任务</Description>
|
||||
<Description>南方基金场外申购赎回邮件 Worker 自动启动任务</Description>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<{trigger}>
|
||||
|
||||
@@ -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. 启动前端后补做浏览器端到端验收。
|
||||
Reference in New Issue
Block a user