Merge remote-tracking branch 'origin/qyqy_develop' into lzl_qyqy_integration

This commit is contained in:
Windows
2026-09-12 14:16:27 +08:00
24 changed files with 1087 additions and 61 deletions
+20 -6
View File
@@ -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`,
+27
View File
@@ -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,
+16
View File
@@ -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):
"""重新判定规则只接受操作人身份,数值一律取自已落库的识别与查询结果。"""
+1 -1
View File
@@ -68,7 +68,7 @@ class HealthService:
finally:
if client is not None:
try:
await client.aclose()
await client.close()
except Exception:
pass
+7 -3
View File
@@ -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
)
+6 -2
View File
@@ -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
+171 -8
View File
@@ -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 使用英文稳定字段 |
| 固定奶龙基金代码 | 配置或代码白名单 | 先保留白名单,后续配置中心化 |
| 固定南方基金代码 | 配置或代码白名单 | 先保留白名单,后续配置中心化 |
## 四、阶段计划
+9 -9
View File
@@ -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": "具备完整投研分工"},
+33 -19
View File
@@ -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
+109
View File
@@ -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)
+7
View File
@@ -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}
+132
View File
@@ -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())
+123
View File
@@ -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())
+1 -1
View File
@@ -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}>
+109
View File
@@ -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. 启动前端后补做浏览器端到端验收。