From baecb89bd40313ea489ab47c32254143bfb99427 Mon Sep 17 00:00:00 2001 From: zhangshy <994452054@qq.com> Date: Mon, 14 Sep 2026 10:40:41 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E9=A2=84=E8=AD=A6=E9=98=9F=E5=88=97?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=B8=BA=E6=AF=8F=E9=A1=B5=E5=8D=81=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/portal/employee-risk/dashboard/dashboard.js | 4 ++-- tests/unit/api/test_portal_frontend.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/static/portal/employee-risk/dashboard/dashboard.js b/app/static/portal/employee-risk/dashboard/dashboard.js index 00031a6..2c558b8 100644 --- a/app/static/portal/employee-risk/dashboard/dashboard.js +++ b/app/static/portal/employee-risk/dashboard/dashboard.js @@ -236,7 +236,7 @@ if (requireRiskStaff()) { document.querySelector(`[data-${kind}-prev]`).disabled = current.page === 0; document.querySelector(`[data-${kind}-next]`).disabled = !current.next; const total = Number(meta.total ?? 0); - const pageSize = Number(meta.page_size ?? (kind === 'alert' ? 5 : 10)); + const pageSize = Number(meta.page_size ?? 10); const totalPages = total > 0 && pageSize > 0 ? Math.ceil(total / pageSize) : 0; document.querySelector(`[data-${kind}-summary]`).textContent = total > 0 ? `第 ${current.page + 1} / ${totalPages} 页,共 ${total} 条` @@ -247,7 +247,7 @@ if (requireRiskStaff()) { renderLoading(alertTable, 4); document.querySelector('[data-alert-summary]').textContent = '正在加载'; try { - const response = await apiClient.get('RK002', { query: { ...state.alert.filters, cursor: state.alert.cursors[state.alert.page], limit: 5 } }); + const response = await apiClient.get('RK002', { query: { ...state.alert.filters, cursor: state.alert.cursors[state.alert.page], limit: 10 } }); const items = Array.isArray(response.data) ? response.data : []; if (!items.length) renderEmpty(alertTable, '暂无预警', '当前筛选条件下没有风险预警。'); else { diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index 7fda51f..1f68411 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -296,6 +296,14 @@ def test_risk_tables_show_page_and_total_summary() -> None: assert "共 ${total} 条" in source +def test_risk_alert_queue_uses_ten_rows_per_page() -> None: + source = ( + PORTAL / "employee-risk" / "dashboard" / "dashboard.js" + ).read_text(encoding="utf-8") + assert "limit: 10" in source + assert "meta.page_size ?? 10" in source + + def test_risk_alert_prompts_hide_until_alert_context_is_bound() -> None: html = (PORTAL / "employee-risk" / "dashboard" / "index.html").read_text(encoding="utf-8") css = (PORTAL / "employee-risk" / "dashboard" / "dashboard.css").read_text(encoding="utf-8") From bfbaf823c2024c91cac6acf57f175bcddc6cce80 Mon Sep 17 00:00:00 2001 From: zhangshy <994452054@qq.com> Date: Mon, 14 Sep 2026 10:50:48 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=E9=98=9F=E5=88=97=E5=90=8E=E7=AB=AF=E5=88=86=E9=A1=B5=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E4=B8=BA=E5=8D=81=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/schemas/risk.py | 2 +- tests/unit/api/test_risk_controller.py | 4 ++++ tests/unit/service/test_risk_query_service.py | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/api/schemas/risk.py b/app/api/schemas/risk.py index c131f3d..0d60567 100644 --- a/app/api/schemas/risk.py +++ b/app/api/schemas/risk.py @@ -32,7 +32,7 @@ class RiskAlertPageQuery(BaseModel): start_time: datetime | None = None end_time: datetime | None = None cursor: str | None = Field(default=None, max_length=512) - limit: int = Field(default=5, ge=1, le=5) + limit: int = Field(default=10, ge=1, le=10) @field_validator("keyword", "customer_no", "product_code", "product_name", "rule_code") @classmethod diff --git a/tests/unit/api/test_risk_controller.py b/tests/unit/api/test_risk_controller.py index 5a7f67f..9606574 100644 --- a/tests/unit/api/test_risk_controller.py +++ b/tests/unit/api/test_risk_controller.py @@ -221,9 +221,13 @@ def test_overview_uses_success_envelope(monkeypatch) -> None: def test_alert_and_detail_routes_bind_parameters(monkeypatch) -> None: with authenticated_client(monkeypatch) as client: alerts = client.get("/api/v1/risk/alerts?limit=5&risk_level=高") + alerts_ten = client.get("/api/v1/risk/alerts?limit=10") + invalid = client.get("/api/v1/risk/alerts?limit=11") detail = client.get("/api/v1/risk/alerts/ALERT-001") assert alerts.status_code == 200 + assert alerts_ten.status_code == 200 + assert invalid.status_code == 422 assert alerts.json()["data"][0]["alert_no"] == "ALERT-001" assert detail.status_code == 200 assert detail.json()["data"]["alert"]["alert_no"] == "ALERT-001" diff --git a/tests/unit/service/test_risk_query_service.py b/tests/unit/service/test_risk_query_service.py index d49b613..010c77d 100644 --- a/tests/unit/service/test_risk_query_service.py +++ b/tests/unit/service/test_risk_query_service.py @@ -103,9 +103,10 @@ def test_cursor_round_trip_and_invalid_values() -> None: def test_query_schema_enforces_business_page_sizes() -> None: assert RiskAlertPageQuery(limit=5).limit == 5 + assert RiskAlertPageQuery(limit=10).limit == 10 assert RiskEvidencePageQuery(limit=10).limit == 10 with pytest.raises(ValueError): - RiskAlertPageQuery(limit=6) + RiskAlertPageQuery(limit=11) with pytest.raises(ValueError): RiskEvidencePageQuery(limit=11) From 4b7ee13cf5553eb5f82780913a887ac7e8a9566c Mon Sep 17 00:00:00 2001 From: zhangshy <994452054@qq.com> Date: Mon, 14 Sep 2026 10:56:45 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=87=BD=E6=95=B0=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=E9=97=AD=E5=90=88=E5=A4=A7=E6=8B=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/employee-console/workspace/workspace.js | 2 ++ tests/unit/api/test_portal_frontend.py | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/static/portal/employee-console/workspace/workspace.js b/app/static/portal/employee-console/workspace/workspace.js index 178a503..9bf8440 100644 --- a/app/static/portal/employee-console/workspace/workspace.js +++ b/app/static/portal/employee-console/workspace/workspace.js @@ -537,6 +537,8 @@ if (requireAdmin()) { apiClient.reportError(error); status.textContent = error.message || '提交失败'; } finally { submit.disabled = false; } + } + async function loadDriftReviews() { renderLoading(targets.drift, 3); try { diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index 1f68411..b80bb8c 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -321,6 +321,17 @@ def test_admin_workspace_is_not_an_identity_placeholder() -> None: assert label in html +def test_admin_workspace_rule_submit_closes_before_next_function() -> None: + source = ( + PORTAL / "employee-console" / "workspace" / "workspace.js" + ).read_text(encoding="utf-8") + assert ( + " } finally { submit.disabled = false; }\n" + " }\n\n" + " async function loadDriftReviews() {" + ) in source + + def test_frontend_has_no_remote_scripts_or_token_local_storage() -> None: sources = "\n".join( path.read_text(encoding="utf-8") From e59a9905a073a701387c87e93d668ef3e2216434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Mon, 14 Sep 2026 11:24:51 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=8A=8A=E3=80=8C=E4=BB=8A=E6=97=A5?= =?UTF-8?q?=E7=9B=88=E4=BA=8F=E6=98=AF=E7=A1=AC=E7=BC=96=E7=A0=81=200?= =?UTF-8?q?=E3=80=8D=E6=8F=90=E7=BA=A7=E4=B8=BA=E5=BE=85=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E9=A1=B9=EF=BC=88SRS=20Q22=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 `today_profit_loss` / `today_profit_loss_ratio` 在 `app/service/trade_service.py` 里是 **硬编码 `ZERO`**(持仓列表 L678、账户看板 L748-749),客户看板、持仓页、盈亏分析页的 「今日盈亏」永远显示 0.00。同一函数里的「持有盈亏」是**真算的** (`market_value - cost_amount`,L660 / L726),所以只有这一项是占位。 三份 2026-09-14 文档其实**都记录了这个事实**(接口文档第 11 条、SRS 第 212 / 458 / 615 行), 但**都埋在「注意事项」里,没有进第 0 章那份「★ 请先回答本章」的待确认清单** —— 业务翻文档时看不到,演示现场被问到就答不上来。而且 SRS 第 212 行还把它**错引到了 Q7** (Q7 讲的是场外阈值,与此无关)。 ## 改动 **docs/软件需求文档-2026-09-14.md** - 新增 **0.5 节「占位实现与本期边界」**,登记 **Q22**:客户看板的「今日盈亏」本期是否实现。 该节专门收这类「接口有、值还没真算」的偏差(不报错、单元测试全绿、只有业务看得出不对), 以后同类问题继续往这里追加。 - 第 0 章项数 21 -> 22;阻塞项清单补上 Q22。 - 修正 F-4.2 的错误引用(见 Q7 延伸 -> 见 Q22)。 - R2 状态改为「已提级为待决项 -> 见 Q22」。 - **R1 状态改为已修复**:组员提交 `4b7ee13`「修复管理员工作台函数缺少闭合大括号」 已解决 `workspace.js` 的语法错误。按该条自己要求的方式复验过 —— `node --experimental-vm-modules` + `vm.SourceTextModule` 遍历 `app/static/portal`, **44 个模块全部通过**(`node --check` 会假通过,不能用来判定)。 **docs/44-演示流程.md**(演示现场用) - §3「可能被问到的问题」新增话术:直说这是占位值、指出持有盈亏是真算的、 指向 SRS Q22 与两条口径的结论;并建议「时间紧就避开这一栏,被问到不要含糊也不要现编」。 - 场景 2(客户资产)加现场提示:讲「持有盈亏 / 总市值 / 可用资金」,不要指着「今日盈亏」讲。 **docs/后端接口文档-2026-09-14.md** - 第 11 条补上代码行号与交叉引用,并点明同一响应里的 `profit_loss` 是**真算的**。 ## 顺带交给业务:两条口径的可行性(实测,非推测) 业务要决定的是「要不要做」,但「能不能做」也得一起给,否则业务选完还要再问一轮: | 口径 | 数据现状 | 结论 | |---|---|---| | 用行情算(今收 − 昨收) | `fin_market_price` **只在同步时才写行**,实测每个产品**只有 2 行**(515450 只有 09-11 与 09-14,还跨了周末) | ❌ 取不到连续「昨收」 | | 用净值算(今净值 − 上一交易日净值) | `fin_nav_history` 每个产品 **120~160 行连续交易日净值** | ✅ 建议按这个口径 | ⇒ 文档建议按**净值口径**实现,并提醒业务一并确认语义:净值是日终数据, 该指标实际是「最近一个交易日的盈亏」,**不是盘中实时**。 ## 入库说明 三份 `docs/*-2026-09-14.md` 此前是**未跟踪文件**,本次一并入库(同一批文档交付物且互相引用)。 `.workbuddy/`(工具会话记忆)属工作区过程产物,**未入库**。 --- docs/44-演示流程.md | 26 + docs/代码结构与关键逻辑梳理-2026-09-14.md | 396 +++ docs/后端接口文档-2026-09-14.md | 2913 +++++++++++++++++++++ docs/软件需求文档-2026-09-14.md | 709 +++++ 4 files changed, 4044 insertions(+) create mode 100644 docs/代码结构与关键逻辑梳理-2026-09-14.md create mode 100644 docs/后端接口文档-2026-09-14.md create mode 100644 docs/软件需求文档-2026-09-14.md diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md index 56c6885..11c600f 100644 --- a/docs/44-演示流程.md +++ b/docs/44-演示流程.md @@ -129,6 +129,10 @@ python tools/portal_api_check.py # 接口契约:按前端的方 点左侧「我的持仓」「资金流水」「成交明细」各看一眼即可。 +> ⚠️ **刻意不要指着"今日盈亏"讲** —— 该字段目前是**占位 0**(硬编码,见 §3 同名问答与 SRS Q22)。 +> 讲「持有盈亏」「总市值」「可用资金」这三项,它们都是真实计算的。 +> 被追问时按 §3 的话术回答,不要现编。 + ### 场景 3 · 客户下单(1.5 min)⭐ 重点 | | | @@ -276,6 +280,28 @@ A:三层 —— 前端按权限隐藏入口;服务端每次请求**重新解 **Q:为什么不让管理员直接改权限?** A:金融场景要求权限变更留痕可追溯,所以统一走配置发布流程(草稿→校验→审核→激活)。 +**Q:客户看板上的"今日盈亏"为什么是 0?** +A:**这项本期是占位值,不是真实算出来的**,不回避这一点。 +代码里 `today_profit_loss` / `today_profit_loss_ratio` 目前是**硬编码 `ZERO`** +(`app/service/trade_service.py` 的持仓列表 L678、账户看板 L748–749); +而它旁边的**"持有盈亏"是真算的**(`market_value − cost_amount`,L660 / L726)。 +所以「持有盈亏 / 总市值 / 可用资金」可以照着讲,**"今日盈亏"不要当成真实数字念**。 + +是否本期实现**已列为待业务确认项**(`docs/软件需求文档-2026-09-14.md` **Q22**), +并且已经实测过两条口径的可行性: + +| 口径 | 数据现状 | 结论 | +|---|---|---| +| 用行情算(今收 − 昨收) | `fin_market_price` **只在同步时才写行**,实测每个产品**只有 2 行** | ❌ 取不到连续"昨收" | +| 用净值算(今净值 − 上一交易日净值) | `fin_nav_history` 每个产品 **120~160 行连续交易日净值** | ✅ 建议按这个口径做 | + +> ⚠️ 同时要讲清语义:净值是**日终**数据,所以它其实是"**最近一个交易日**的盈亏", +> **不是盘中实时** —— 这一点业务确认时要一并定下来。 +> +> 💡 时间紧、不想被追问的话:**场景 2 讲解时避开"今日盈亏"这一栏**, +> 只讲持有盈亏、总市值、可用资金。万一被直接问到,就按上面这段答, +> **不要含糊过去也不要现编** —— 它属于"已知且已登记的范围边界",不是缺陷。 + --- ## 4. 出问题怎么办 diff --git a/docs/代码结构与关键逻辑梳理-2026-09-14.md b/docs/代码结构与关键逻辑梳理-2026-09-14.md new file mode 100644 index 0000000..72786c3 --- /dev/null +++ b/docs/代码结构与关键逻辑梳理-2026-09-14.md @@ -0,0 +1,396 @@ +# 代码结构与关键逻辑梳理(只读分析报告) + +> 分析日期:2026-09-14 +> 分析范围:`C:\Users\Windows\Desktop\项目代码` 全仓库(`app/` + `tools/` + `docs/` + `tests/`) +> 分析方式:纯阅读,**未修改任何代码或文档**(本报告为新增文件) +> 分析依据:`AGENTS.md` 的强制约束、`docs/` 核心 7 份文档、全部 Python 业务代码、`app/static/portal/` 全部 44 个前端模块 + +--- + +## 〇、结论摘要(先看这一段) + +这是一套**「通用 Agent 平台 + 多业务域」**的金融模拟交易系统,架构为 `MVC+S`,核心是一个被治理骨架严格约束的 Agent 运行时。三条主线: + +| 主线 | 入口 | 关键机制 | +|---|---|---| +| **请求链路** | HTTP 受理 202 → Outbox 事件 → Worker 领单 → Agent 执行 → 落结果 | 三段式异步、租约围栏、幂等回执 | +| **记忆链路** | 对话 → 记忆单元 → 事实晋升 → 画像快照 → 向量/图投影 | MySQL 唯一真相、投影单向可重建 | +| **治理链路** | Agent 输出 → 引用校验 → 负面词拦截 → 脱敏 → 免责声明 | 同步、无 DB、失败关闭 | + +**本次发现的最重要问题(1 个 P0 级)**: + +> 🔴 **`app/static/portal/employee-console/workspace/workspace.js` 存在真实语法错误**,整个管理员工作台**无法加载**。已用真实 ESM 解析器确认(44 个前端模块中唯一失败项)。详见 §六.1。 + +--- + +## 一、整体结构 + +### 1.1 目录分层(MVC+S 的物理映射) + +``` +app/ +├── main.py # FastAPI 组装入口(模块级变量是 app,不是 application) +├── core/ # 契约与横切关注点:contracts / errors / config / knowledge_schema +├── api/ # ── Controller 层 ── +│ ├── controllers/ # 22 个路由模块(按业务域切分) +│ ├── dependencies/ # auth / database / rate_limit 等注入 +│ ├── middleware.py # trace_id 注入 +│ └── views/envelope.py # 统一信封(envelope / list_envelope) +├── service/ # ── Service 层(含全部业务 Agent)── +│ ├── agent/ # BaseAgent / AgentFactory / AgentExecutor / governance +│ │ ├── implementations/# 7 个业务 Agent +│ │ └── tools/ # 工具注册表与 handler +│ ├── *_service.py # 43 个业务 Service +│ └── ... +├── repository/ # ── Model 层(数据访问)── 15 个 Repository +├── model/ # ORM 定义 +├── infrastructure/ # 外部依赖适配器(Milvus / Neo4j / MySQL / Redis / 行情源) +├── worker/ # 异步执行进程(与 API 进程分离) +└── static/portal/ # ── View 层(四套前端页面)── +``` + +### 1.2 关键架构决策(代码中已固化的) + +| 决策 | 体现位置 | 为什么 | +|---|---|---| +| Agent 属于 Service 层,必须有 `BaseAgent` 骨架 | `app/service/agent/base.py` | `AGENTS.md` 规则 7;`__init_subclass__` 禁止子类覆盖执行顺序 | +| API 与 Worker 是**两个独立进程** | `app/main.py` / `app/worker/__main__.py` | 受理与执行解耦,可独立扩缩 | +| MySQL 是唯一真相,向量/图是**单向投影** | `memory_sync_outbox` 表 | 投影可随时重建;投影挂掉不影响主流程 | +| 治理审查**同步、不落库** | `governance.review_output` | 治理必须在返回用户前完成,且不能因 DB 故障而跳过 | + +--- + +## 二、主要模块与职责 + +### 2.1 Agent 底座(最核心) + +| 文件 | 职责 | 关键约束 | +|---|---|---| +| `agent/base.py` | `BaseAgent` 执行骨架 | 固定顺序:`validate_input → validate_access → resolve_config → recall_memory → classify_intent → _execute_governed → governance.review`;子类只能实现 `_execute_governed` | +| `agent/factory.py` | `AgentFactory` 按 `agent_type` 创建 | 每个请求创建**一个** Agent 实例,注入治理/模型/工具/意图分类器 | +| `agent/executor.py` | 薄封装,迭代 `agent.execute()` | 不承载业务逻辑 | +| `agent/governance.py` | `PlatformGovernance.review_output` | 四步:引用校验 → 负面词/硬规则拦截 → 脱敏 → 追加强制免责声明 | +| `agent/authorizer.py` | 工具/角色鉴权 | 工具可用范围 = **代码上限 ∩ active `config_release` 白名单**,缺配置则失败关闭 | +| `agent/bootstrap.py` | 唯一组装入口 | `@lru_cache(maxsize=1)`;HTTP 与 Worker 共用;7 个 Agent + 全部工具在此注册 | + +**7 个已注册 Agent**:`FundQueryDemoAgent`、`CustomerServiceAgent`、`RiskAgent`、`PlatformProbeAgent`、`AdvisorAgent`、`OffsiteFundAgent`、`PromotionMaterialAgent`。 + +### 2.2 异步执行与可靠性 + +| 机制 | 实现 | 要点 | +|---|---|---| +| **Outbox 模式** | `DomainEventOutbox` / `MemorySyncOutbox` | 持久化 SQL 队列,`with_for_update(skip_locked=True)`;`retry_count >= 5` → 死信 | +| **Worker 租约围栏** | `agent_run_repository.claim()` | 每次领单生成新 `worker_id = uuid4()`,同时围住进程重启;`locked_until` + 心跳续约 | +| **幂等** | `RequestIdempotency` + `api_request_receipt` | 按 `(user_id, agent_type, key)` 查找;取消路径写 `status="failed", error_code="RUN_CANCELLED"` | +| **统一错误信封** | `main.py` 两个异常处理器 | `AgentError` → `{error:{code,message,retryable,field_errors}, meta:{trace_id}}`;`RequestValidationError` → 422 `AGENT_INPUT_INVALID` | + +**运行前提(易踩坑)**:客服/风控对话必须有常驻 Worker(`python -m app.worker`)。没有 Worker 时 `agent_run.status` 恒为 `queued`,前端只显示"响应超时/客服繁忙"——看起来像链路慢,实际是没人处理。 + +### 2.3 记忆 / 画像 / 投影(三层) + +``` +memory_unit (中期,权威) + ↓ 晋升:MIN_EVIDENCE = 2 或 HIGH_CONFIDENCE = 0.90 +user_facts (长期,已确认) + ↓ 重建 +fin_customer_profile + profile_snapshots (画像版本) + ↓ 投影(两条 outbox 行共享一个 event_uuid) +Milvus 向量 / Neo4j 图 +``` + +**关键不变量**: +- `profile_snapshots` 有唯一键 `uk_profile_snapshot_current` → 必须**先** `is_current=0, current_customer_id=NULL`,**再**插入新 current 行。 +- Milvus **读写物理隔离**:`MilvusKnowledgeClient`(读)与 `MilvusKnowledgeWriter`(写)互不 import,双向故障隔离。 +- 字段名**运行时探测**,不得硬编码:Milvus 知识读(`core/knowledge_schema.py`)与写(`resolve_schema`)都把逻辑名(`doc_id`/`content`/`visibility`)映射到环境物理名。 + +### 2.4 业务域 + +| 域 | 关键文件 | 备注 | +|---|---|---| +| **场内交易** | `trade_service.py` | 行情有效期仅 15 分钟(`MAX_QUOTE_AGE`),超时**所有委托 503**且无自动刷新 | +| **行情源** | `fund_market_adapter.py` | `push2`/`push2his` 域**不可达**,实际可用源是**腾讯**(`qt.gtimg.cn`);费率只能爬 `fundf10.eastmoney.com`;`fetch_nav_history` 的分页参数被服务端忽略(固定 20/页) | +| **风控** | `risk_scan_service.py` | 跨进程排他锁 `GET_LOCK('jr_risk_scan_schedule', 0)` 必须加在**入口层**,不能放进 `scan()` | +| **投顾** | `recommendations.py` + `investment_goals.py` | 两类内容寻址键不同:推荐方案用 `content_id`、方案书用 `goal_no` | +| **场外/推广** | `offsite_fund.py` / `promotion_material.py` | `AGENTS.md` 规则 8:**不得写入场内交易表** | +| **知识库** | `knowledge_management.py` | K002/K003/K004 三端点;上传切成 N 块入库 | + +### 2.5 前端(四套页面) + +| 角色 | 路径 | 守卫 | +|---|---|---| +| 访客 | `guest/` | 无(用临时访客令牌,仅 `agent:run` + `knowledge:query`) | +| 客户 | `customer/` | `requireCustomer` / `requireCustomerOnly` | +| 管理员 | `employee-console/` | `requireAdmin` | +| 风控 | `employee-risk/` | `requireRiskStaff` | +| 投顾 | `employee-advisor/` | `requireAdvisor` | +| 运营 | `employee-operations/` | `requireOperator` | + +**契约中枢**:`common/api-client.js` 的 `ENDPOINTS` 冻结表(约 120 条)是端点调用的唯一来源;`put()`/`del()` 只表达意图,**真实 HTTP 动词仍来自该表**。 + +--- + +## 三、执行流程 + +### 3.1 一次 Agent 请求的完整链路 + +``` +1. 前端 apiClient.post('R001', ...) → 带 X-Trace-ID、Idempotency-Key +2. auth controller / build_request_context → 解析身份 + 权限 + data_scope +3. AgentRunApplicationService.accept() + ├─ 幂等检查(RequestIdempotency / api_request_receipt) + ├─ 写 agent_run (status='queued') + └─ 写 DomainEventOutbox 事件 'agent.run_requested' + ← 立即返回 202 + run_id(不阻塞) +4. 【异步】WorkerRuntime.dispatch_batch() → OutboxWorker.publish_one() +5. WorkerRuntime.run_once() → 选最老的 queued/running/cancel_requested 且租约空闲的 run +6. WorkerRuntime.execute(run_id) + ├─ 新 worker_id = uuid4()(围栏) + ├─ claim() 条件 UPDATE + ├─ _execute_claimed() 任务 + _heartbeat() 心跳任务 +7. AgentExecutor → BaseAgent.execute() → 七步骨架 + └─ PlatformGovernance.review_output() → 引用校验/拦截/脱敏/免责 +8. AgentPersistenceService.complete_run() → 写 status='succeeded' + result +9. 前端轮询 R002(300ms 起,每 500ms,共 40 次 ≈ 20s)或 SSE R003 +``` + +**实测端到端耗时**(Worker 在跑 + `deepseek-flash`):访客一问 **4.1–4.8 秒**。 + +### 3.2 记忆 → 画像的完整链路 + +``` +对话结束 + → memory.extraction_requested 事件 + → MemoryExtractionService 提取候选记忆单元 + → memory_unit 落库(含 MemoryEvidence,idempotency_key 唯一) + → [满足阈值] ProfileAssemblyService.promote_facts() → user_facts + → profile.rebuild_requested 事件 ★ + → ProfileAssemblyService.rebuild() + ProfileGraphProjectionService.project_customer() + → fin_customer_profile + profile_snapshots(新版本) + → memory_sync_outbox 写两行(target_store = 'milvus' / 'neo4j',共享同一 event_uuid) + → WorkerRuntime.consume_profile_projections() + ├─ milvus → MilvusProfileProjection + └─ neo4j → ProfileGraphProjectionService(MERGE 幂等) +``` + +★ **为什么 `profile.rebuild_requested` 必须走事件而非内联调用**:提取时记忆行还在**未提交事务**里,内联调用看不到那一行——真实症状是"快照生成了、事实缺失、图里没有边"。 + +### 3.3 投影清理(反向链路) + +``` +memory.invalidated / memory.deleted 事件 + → ProjectionCleanupService.cleanup() + ├─ 删 UserFact + ├─ 触发 rebuild + ├─ 图 reconcile + └─ Milvus 按 memory_uuid 删除 + → 无 client 时:warn + InteractionAudit(status="skipped_no_client"),仍标记消费 +``` + +--- + +## 四、关键逻辑(设计意图) + +### 4.1 为什么"工具可用范围"要两层 + +`工具可用范围 = 代码上限 ∩ 当前 active config_release 的发布白名单`,缺发布配置则**失败关闭**。原因是 `config_release` 是**环境数据、不随代码合并**——本机 active 版本 id 与架构师环境不同。所以"白名单已发布"必须带环境限定,换环境要重发。 + +### 4.2 访客与登录客户走不同工具 + +| 工具 | 权限码 | 角色 | +|---|---|---| +| `search_knowledge` | `knowledge:reference:read` | customer, advisor, operator, admin | +| `query_knowledge`(别名,同 handler) | `knowledge:query` | **visitor**, customer | + +知识类意图要**同时**发这两条——缺哪一条,对应人群就一问即失败。 + +### 4.3 Worker 组装的三条硬规矩 + +1. **入口不得再起第二个 `MemorySyncOutboxWorker`** —— PR #7 曾有两个消费者用不同 `neo4j` handler 消费同一队列,导致"同一事实在图里有两种说法"。 +2. **`OffsiteMailWorker` 必须共享同一进程** —— 曾有文件覆盖事故删掉了这处接线。 +3. **`consume_profile_projections` 的 client 为 `None` 时拒绝消费** —— 让 Worker 把它判死信,会把"配置缺失"伪装成"投递失败"。 + +### 4.4 几个刻意的实现选择 + +| 选择 | 位置 | 原因 | +|---|---|---| +| `fetch_kline` 用**同步 httpx + `asyncio.to_thread`** | `fund_market_adapter.py` | `AsyncClient` 访问 push2his 会被断开 | +| Milvus 写路径**没有"已存在则跳过"** | `milvus_knowledge_writer.py` | 旧向量会继续答旧内容 | +| 知识检索 `_as_row` **丢弃无 uuid 的行而不是编一个** | `milvus_adapter.py` | 避免产生假引用 | +| `_with_memory_sources` 只在 key **缺失或 None** 时回填 | `worker/runtime.py` | 格式错误要保持失败关闭 | +| `advance_clarification` 在 `>= 10` 时拒绝 | `session_repository.py` | 澄清轮次上限 | + +--- + +## 五、各部分相互关系 + +``` + ┌─────────────────────────────────────┐ + │ app/main.py (create_app) │ + │ 22 routers + 2 异常处理器 │ + └──────────────┬──────────────────────┘ + │ + ┌──────────────────────┼──────────────────────┐ + ▼ ▼ ▼ + api/controllers/ api/dependencies/ api/views/envelope + (Controller) (auth/db/rate_limit) (统一信封) + │ + ▼ + service/*_service.py ──► service/agent/ ──► infrastructure/ + (Service 业务逻辑) (BaseAgent 骨架) (Milvus/Neo4j/MySQL/行情) + │ │ ▲ + ▼ ▼ │ + repository/*.py ◄────── agent_run / outbox ────────┘ + (Model 数据访问) + │ + ▼ + worker/ ◄── 独立进程,消费 Outbox,驱动记忆/画像/投影 +``` + +**依赖方向严格单向**:Controller → Service → Repository → Model;`infrastructure` 只被 Service 调用;`worker` 依赖 Service 但不被 Service 依赖(除 bootstrap)。 + +--- + +## 六、疑问、潜在问题与边界情况 + +### 🔴 6.1【P0 · 已确证】管理员工作台存在语法错误,整页无法加载 + +**位置**:`app/static/portal/employee-console/workspace/workspace.js` 第 500–540 行 + +`async function submitRule(event, releaseId)` 在第 539 行 `finally { submit.disabled = false; }` 之后**缺少闭合的 `}`**,导致后续的 `loadDriftReviews`(L540)等函数被解析为 `submitRule` 的函数体内容。 + +**我的核查过程(三层,逐层确证)**: + +| 检查方式 | 结果 | 说明 | +|---|---|---| +| `node --check`(逐文件) | ✅ 通过 | **误判**——`--check` 的 ASI 宽容度让它放过 | +| 真实 ESM 解析(`import()`) | ❌ `SyntaxError: Unexpected end of input` | 确证 | +| `vm.SourceTextModule` 扫描全部 44 个模块 | ❌ **仅此一个失败** | 排除其他模块,定位唯一 | + +**影响**: +- 管理员工作台 `employee-console/workspace/` **整页白屏**(模块解析期即失败,`requireAdmin()` 都不执行)。 +- 连带失效:RBAC 角色查看、配置发布(校验/审核/激活)、模型端点、审计记录、转人工工单、画像候选、投顾复核、**知识库管理**、画像漂移复核。 +- 因为 `workspace.js` 是 `import`(ESM),解析失败不会执行任何一行业务代码,**不会**降级到任何 fallback。 + +**为什么 CI 没拦住(推断)**:现有 `pytest tests/contract` 校验的是 `ENDPOINTS` 表与页面结构的**静态一致性**,不做前端模块的**真实解析**。`node --check` 若被用作检查手段,则会因 ASI 而漏判。 + +**修复方向**(仅建议,未实施):在第 539 行后补一个 `}`。 + +--- + +### 🟠 6.2【P1】文本/表格标"未知技术字段"的兜底会掩盖契约漂移 + +**位置**:`employee-risk/dashboard/dashboard.js` 的 `FIELD_LABELS` / `SNAPSHOT_FIELD_LABELS` + `detailMarkup`/`snapshotLabel` + +任何后端新增字段而前端未登记时,界面显示 `` `${key}(技术字段)` `` 而不是报错或告警。**风险**:字段改名或语义变化不会引起任何注意,风控人员会看到"某某(技术字段)"却不知道它是什么。风控是合规敏感域,这里的静默降级值得加一条显式告警。 + +--- + +### 🟠 6.3【P1】前端 `K004` 标了 `idempotent`,但该操作语义上难以幂等 + +**位置**:`common/api-client.js` L96 `K004: { method: 'DELETE', path: '/api/v1/knowledge/{knowledgeId}', idempotent: true }` + +`idempotent: true` 会让 `apiClient` 自动附带 `Idempotency-Key`。对"删除知识"而言,**第二次调用与第一次的意图本就相同**(都是让该条失效),语义上确实幂等;但若前端因超时重试而换了 key,就可能对同一 `knowledgeId` 发起两次"失效"——取决于后端是否按 `Idempotency-Key` 还是按资源状态判幂等。**建议在 `docs/05` 明确该端点的幂等键语义**,目前文档里未见说明。 + +--- + +### 🟠 6.4【P1】`RK013–RK015` 未登记进 `docs/05` §19 + +**位置**:`api-client.js` L60–65 的注释已自述此事 + +`RK013`(非流式日报)**当前无人调用**,仅因"前端契约测试要求这张表里有它"而保留。`RK014`(SSE 流式)/`RK015`(邮件)在用但未登记。**风险**:`docs/05` 自称"接口唯一权威",此处已出现权威文档与实际端点的偏差——这正好削弱了它作为唯一权威的地位。 + +--- + +### 🟡 6.5【P2】`ADVISOR_GOAL` 注册但不可用 + +**位置**:`api-client.js` L79 `ADVISOR_GOAL: { GET /api/v1/advisor/investment-goals/current }` + +页面从不调用它;且对投顾本人访问会返回 **404**。注释里点明了"**注册与调用是两件事**"。保留它是为了满足契约测试的表完整性——但这意味着**约 120 条端点表里存在"只为测试存在"的条目**,后续维护者容易误以为它可用。 + +--- + +### 🟡 6.6【P2】行情 15 分钟有效期是演示最容易翻的一环 + +`trade_service.py` 的 `MAX_QUOTE_AGE = 15min`,超时后**所有委托一律 503「行情已过期」且无自动刷新**。补刷需手动跑 `python tools/sync_market_prices.py`(立即生效、无需重启)。这不是 bug,但**这是环境脆弱点**:任何演示或验收前若没刷新,交易链路会整体不可用,且错误信息(503)不直接指向"行情过期"这一根因。 + +--- + +### 🟡 6.7【P2】`workspace.js` 的 `submitRule` 里 `max_attempts` 硬编码为 1 + +**位置**:`workspace.js` L515(在损坏区域附近,但本身逻辑正确) + +代码注释已明确:`max_attempts` 不能超过"端点总数"(主端点 + fallbacks),而界面不编辑 `fallbacks`(提交空数组),所以只能写 `1`——写 `2` 会被后端 422 拒绝。**这说明界面能力与后端约束不匹配**:用户在界面上无法配置 fallback 端点,模型高可用能力在前端不可达。 + +--- + +### 🟡 6.8【P2】投影清理在"无 client"时仍标记消费,可能造成静默漂移 + +**位置**:`worker/runtime.py` 的 `_cleanup_projection` + +无 client 时记 `InteractionAudit(status="skipped_no_client")` **并仍标记消费**。这是刻意的(避免把"配置缺失"伪装成"投递失败"),但**审计记录需要有人看**:如果没人定期检查这些 `skipped_no_client`,向量库/图里就会积累已失效的记忆,而主流程完全正常。**建议**:给这类审计加一个可观测指标或定期巡检脚本。 + +--- + +### 🟡 6.9【P2】历史文档的分类边界需要人工判断 + +`AGENTS.md` 已把 `docs/04`/`06`/`10`/`13`/`99` 归为 D 类"不要用来判断当前进度",但**它们仍在仓库中**。这是 2026-09-11 评审的结论(保留成本为零)。**风险**:新人不会先读 `AGENTS.md` 的分类表,很可能直接读 `docs/04` 并采信过期结论。`TODO.md` 同样如此(5 处"49 张表"实为 51 张业务表)。 + +--- + +### 🟡 6.10【P2】风控证据表来源枚举与后端一致性依赖人工 + +**位置**:`employee-risk/dashboard/dashboard.js` 的 `EVIDENCE_COLUMNS`(8 类:customers/products/transactions/capital_flows/holdings/login_records/alerts/notifications) + +前端的 `EVIDENCE_COLUMNS[state.evidence.source]` 是**直接索引**——若用户提交了不在该枚举里的 `source`,`tableMarkup` 的 `columns.map` 会在 `undefined` 上抛错。有 `delete values.behavior_level` 之类的防御,但**没有对 `source` 本身的校验**。建议确认后端 `RK004` 的路径参数枚举是否与前端的 8 类完全一致。 + +--- + +### ✅ 6.11 已核查、确认无问题的项(避免误报) + +| 疑点 | 核查结论 | +|---|---| +| `A038` 前端期望 `identity` 结构 vs `rbac.py` 的 `/users/{user_id}/roles` | **一致**。`rbac.py` 前缀同为 `/api/v1/admin`,`get_user_identity()` 返回 `envelope(data)`,data 含 `username`/`user_no`/`permissions`/`data_scope`/`roles` | +| `K002`/`K003` 为何标 `raw: true` | **正确且必要**。其成功体是裸的 `{knowledge_ids,...}` / `{items,count}`,无 `data` 信封。不标会导致上传后显示"已入库 0 块"而 DB 其实正常 | +| `A048`/`A049` 存在的必要性 | **必要**。`PUT` 需要 `If-Match` etag,而列表 `meta` 不携带 etag,必须先取详情 | +| `V001` 为何 `raw: true` | **正确**。访客令牌接口返回裸 token | +| 全部 44 个前端模块的语法 | **仅 `workspace.js` 一个失败**,其余 43 个全部通过真实 ESM 解析 | + +--- + +## 七、值得注意的边界情况(速查) + +1. **行情**:15 分钟过期 → 全部委托 503,需手动补刷。 +2. **Worker**:不在跑 → `agent_run` 恒为 `queued`,前端显示"超时/繁忙"。 +3. **`config_release`**:环境数据,换环境必须重发白名单。 +4. **RBAC 种子**:`tools/seed_test_rbac.py` 是 **DELETE 重建**语义,没并进去的权限码重建一次就没了,表现是"接口突然 403"而无任何报错线索。 +5. **Milvus schema**:环境而异,检索层已运行时探测;**不得硬编码字段名**。 +6. **澄清轮次**:`advance_clarification` 在 `>= 10` 拒绝。 +7. **`profile_snapshots`**:必须先清 current 再插新行。 +8. **Outbox 枚举值**:`target_store`/`operation`/`status` **必须小写**。 +9. **时区**:DB session 通过 asyncmy `init_command` 设 `SET time_zone = '+00:00'`;业务计算用 `local_hour`/`local_date`。 +10. **`start.ps1`**:必须存为 **UTF-8 with BOM**,否则 Windows PowerShell 5.1 按 GBK 解析中文注释会语法报错。 + +--- + +## 八、附:核查方法与可复现命令 + +```bash +# 1. 确证 workspace.js 语法错误(node --check 会漏判,必须用真实解析) +node -e "import('file:///C:/Users/Windows/Desktop/项目代码/app/static/portal/employee-console/workspace/workspace.js').catch(e=>console.log(e.constructor.name, e.message))" + +# 2. 全量扫描前端模块(仅 workspace.js 失败) +# 见本报告 §6.1 表格中的 vm.SourceTextModule 方式 + +# 3. 后端静态检查 +mypy app # 249 files, 0 errors +pytest tests/unit tests/contract # 1376 passed +pytest tests/integration # 104 passed + +# 4. 交付自检(两条线互补) +python tools/e2e_smoke_test.py # 业务链路冒烟,6 条线 40 项 +python tools/portal_api_check.py # 接口契约体检,41 项 +``` + +--- + +**报告结束。本次分析全程只读,未修改任何代码或既有文档。** diff --git a/docs/后端接口文档-2026-09-14.md b/docs/后端接口文档-2026-09-14.md new file mode 100644 index 0000000..e60c473 --- /dev/null +++ b/docs/后端接口文档-2026-09-14.md @@ -0,0 +1,2913 @@ +# 后端接口文档(全量梳理版) + +> **生成日期**:2026-09-14 +> **生成方式**:逐份通读 `app/api/controllers/` 下 **22 个路由模块** + 全部 Pydantic Schema + +> `app/api/views/envelope.py` + `app/core/errors.py` + `app/api/dependencies/auth.py` + +> `app/main.py`(异常处理器与 22 个 `include_router`)+ 各业务 Service 的真实返回体, +> 并与 `docs/05-接口文档.md` §19 端点索引逐条对照。 +> **性质**:**派生文档**。当本文件与 `docs/05` 冲突时,**以 `docs/05` 为准**;当两者都与代码冲突时, +> **以代码为准**(本文件已尽量标注代码实际行为)。 +> **本次未修改任何业务代码。** + +--- + +## 目录 + +- [0. 阅读指引与端点编号约定](#0-阅读指引与端点编号约定) +- [1. 全局约定](#1-全局约定) + - [1.1 基础地址与内容类型](#11-基础地址与内容类型) + - [1.2 统一成功信封](#12-统一成功信封) + - [1.3 统一错误信封](#13-统一错误信封) + - [1.4 全量错误码表](#14-全量错误码表) + - [1.5 鉴权模型](#15-鉴权模型) + - [1.6 幂等](#16-幂等) + - [1.7 乐观并发(If-Match / ETag)](#17-乐观并发if-match--etag) + - [1.8 游标分页](#18-游标分页) + - [1.9 限流](#19-限流) + - [1.10 审计边界](#110-审计边界) +- [2. 公开面:访客令牌与产品目录](#2-公开面访客令牌与产品目录) +- [3. 认证:登录](#3-认证登录) +- [4. 会话 / 消息 / 反馈 / 转人工](#4-会话--消息--反馈--转人工) +- [5. Agent 运行(异步三段式)](#5-agent-运行异步三段式) +- [6. 记忆与画像候选](#6-记忆与画像候选) +- [7. 新人引导(风险测评)](#7-新人引导风险测评) +- [8. 知识库管理](#8-知识库管理) +- [9. 场内基金交易](#9-场内基金交易) +- [10. 风控](#10-风控) +- [11. 平台管理(配置 / 审计 / RBAC)](#11-平台管理配置--审计--rbac) +- [12. 投顾(目标书 / 组合分析 / 资产配置 / 推荐)](#12-投顾目标书--组合分析--资产配置--推荐) +- [13. 场外基金运营](#13-场外基金运营) +- [14. 基金推介材料](#14-基金推介材料) +- [15. 运维与健康检查](#15-运维与健康检查) +- [16. 前端约定与静态度量](#16-前端约定与静态度量) +- [17. 注意事项汇总(易错点清单)](#17-注意事项汇总易错点清单) +- [18. 端点总表](#18-端点总表) + +--- + +## 0. 阅读指引与端点编号约定 + +本文档沿用 `docs/05` §19 的编号体系,便于双向检索: + +| 前缀 | 域 | +|---|---| +| `R00x` | Agent 运行 | +| `C00x` | 会话 / 消息 / 反馈 / 转人工 | +| `M00x` | 记忆与画像 | +| `A0xx` | 平台管理(配置、审计、RBAC、画像治理、投顾审核) | +| `AD0xx` | 投顾业务(目标书、组合分析、资产配置、推荐) | +| `K00x` | 知识库 | +| `O00x` | 运维 | +| `T00x` | 场内基金交易 | +| `P00x` | 公开产品面 | +| `V001` | 访客令牌 | +| `X0xx` | **本文件新增**:`docs/05` §19 未登记的接口(场外运营、推介材料), +编号仅用于本文档内互引 | + +> ⚠️ `X0xx` 是本文件自己编的序号,**不在 `docs/05` §19 里**。若后续把这两条业务线补进 `docs/05`, +> 应以那时的正式编号为准。 + +--- + +## 1. 全局约定 + +### 1.1 基础地址与内容类型 + +- **本地默认地址**:`http://127.0.0.1:8000` +- **API 前缀**:绝大多数业务接口在 `/api/v1/**`;两处例外: + - 场外运营的 Agent 触发端点在 **`/api/**`**(无 `v1`),见 §13; + - 运维探针在 **`/internal/**`**,见 §15。 +- 请求体默认 `application/json`;**只有两处用 `multipart/form-data`**: + 风控证据上传(§10 `POST /risk/alerts/{alert_no}/evidence`)与推介材料附件上传(§14)。 +- 知识库上传**故意不用 multipart**,走 JSON + `content_base64`,理由见 §8.1。 +- 响应 `application/json`,UTF-8;SSE 端点返回 `text/event-stream`。 + +### 1.2 统一成功信封 + +实现:`app/api/views/envelope.py`。 + +**单对象:** + +```json +{ + "data": { "...": "业务负载" }, + "meta": { "trace_id": "客户端带来的 X-Trace-ID,可能为空串" } +} +``` + +**列表:** + +```json +{ + "data": [ { "...": "第 1 条" } ], + "meta": { + "trace_id": "...", + "next_cursor": "无下一页时为 null", + "has_more": false + } +} +``` + +**铁律**(`docs/05` §3.3):业务接口**不得**在顶层增加其它字段。 +`app/api/views/envelope.py` 的 docstring 记录了这个偏离**发生过两次**(Service 直接把 +`{items, next_cursor, has_more}` 返回出去),所以实现被收敛成同一份共用函数。新增接口请直接调用它。 + +**三类已知的"非标准"响应体**(都是刻意为之,不是遗漏): + +| 端点 | 形态 | 原因 | +|---|---|---| +| `POST /api/v1/visitor-tokens`(V001) | **裸体**:`{access_token, token_type, expires_in}` | 前端 `api-client.js` 对该端点标 `raw: true` | +| `GET /api/v1/knowledge/list`(K003) | `data` 内是 `{items, count}`,**不是**顶层 `data` 数组 | 见 §8.3 | +| 场外运营全套(§13) | `{code, message, data}` **旧式信封** | Service 直接返回该结构,未走统一 `envelope()` | + +**风控列表的 `meta` 扩展**:风控的列表端点(`/risk/alerts`、`/risk/evidence/{source}`、 +`/risk/notifications`)在标准 `meta` 上**额外**加了 `total` 与 `page_size`(由 +`_risk_list_envelope` 注入)。这是唯一使用该扩展的域。 + +### 1.3 统一错误信封 + +实现:`app/main.py` 的 `agent_error_handler` + `request_validation_error_handler`。 + +```json +{ + "error": { + "code": "AGENT_INPUT_INVALID", + "message": "人类可读的说明", + "retryable": false, + "field_errors": [ { "field": "body.quantity", "message": "..." } ] + }, + "meta": { "trace_id": "..." } +} +``` + +要点: + +- **`trace_id` 绝不凭空生成**(`app/main.py:39-51`):取值顺序为 + `request.state.request_context.trace_id` → `request.state.trace_id` → `X-Trace-ID` 请求头 → **空字符串**。 + 凭空生成会让客户端拿到的 id 与服务端日志里的不是同一个,反而失去定位价值。 +- **`retryable` 按码逐个标注**,不是简单按 `status_code >= 500` 推导 —— + 典型反例是 `RESOURCE_VERSION_CONFLICT`:**409 但可重试**(`app/core/errors.py`)。 +- `RequestValidationError`(FastAPI 自身的参数校验失败)被接住并**重写成同一信封**, + 保留 **422** 状态码,字段级原因放进 `error.field_errors`。 + 否则客户端要为"参数错误"单独兼容一套 `{"detail": [...]}` 解析逻辑。 +- **`429 RATE_LIMITED` 会额外带 `Retry-After` 响应头**(`app/main.py:82-87`)。 + 只给 `retryable: true` 不给退避时长,客户端只能猜或立刻重试再被拒。 + +### 1.4 全量错误码表 + +来源:`app/core/errors.py`(唯一权威)。`tests/unit/core/test_errors.py` 会用 AST 检查 +"404 必须用语义化子类",**禁止**抛基类 `ResourceNotFoundError`。 + +| 错误码 | HTTP | retryable | 触发条件 | +|---|---|---|---| +| `AGENT_INPUT_INVALID` | 422 | 否 | 入参不满足字段/业务约束;`ValidationAgentError` 与所有 Pydantic 校验失败 | +| `AUTHENTICATION_REQUIRED` | 401 | 否 | 令牌缺失、无效或已吊销(**统一文案,不区分原因**) | +| `AGENT_PERMISSION_DENIED` | 403 | 否 | 已认证但缺少所需权限码 | +| `ONBOARDING_REQUIRED` | 403 | 否 | 客户未完成风险测评,且访问的不是 `/api/v1/onboarding/**` | +| `INVALID_CURSOR` | 400 | 否 | `cursor` 非法(**在任何数据访问之前**抛出) | +| `RATE_LIMITED` | 429 | **是** | 触发限流;响应带 `Retry-After` | +| `SSE_NOT_ACCEPTABLE` | 406 | 否 | `Accept` 不接受 `text/event-stream` | +| `DEPENDENCY_UNAVAILABLE` | 503 | **是** | `RecoverableAgentError`,外部依赖暂不可用 | +| `UPSTREAM_TIMEOUT` | 504 | 是 | 上游超时 | +| `SESSION_NOT_FOUND` | 404 | 否 | 默认 404 兜底文案(**新代码不应直接使用**) | +| `SESSION_NOT_ACCESSIBLE` | 404 | 否 | 会话存在但不属于当前用户 | +| `AGENT_TYPE_NOT_FOUND` | 404 | 否 | `agent_type` 未注册 | +| `RUN_NOT_FOUND` | 404 | 否 | `run_id` 不存在 | +| `RESOURCE_NOT_FOUND` | 404 | 否 | 通用 404(`GenericResourceNotFoundError`) | +| `REFERENCE_NOT_FOUND` | 404 | 否 | 知识引用令牌失效 | +| `CONFLICT` | 409 | 否 | 通用 409 兜底文案 | +| `IDEMPOTENCY_CONFLICT` | 409 | 否 | 同一 `Idempotency-Key` + 不同请求体 | +| `RESOURCE_VERSION_CONFLICT` | 409 | **是** | `If-Match` 与当前内容摘要不一致 | +| `RUN_NOT_CANCELLABLE` | 409 | 否 | 运行已终态或状态不允许取消 | +| `RUN_LEASE_LOST` | 409 | 否 | Worker 租约丢失 | +| `INVALID_STATE` | 409 | 否 | 状态机不允许该流转 | +| `RESOURCE_ALREADY_EXISTS` | 409 | 否 | 唯一性/引用/状态约束冲突 | +| `FEEDBACK_ALREADY_EXISTS` | 409 | 否 | 同一消息重复提交反馈 | +| `PRODUCT_NOT_TRADABLE` | 422 | 否 | 标的不在可交易范围 | +| `INSUFFICIENT_FUNDS` | 422 | 否 | 可用余额不足 | +| `INSUFFICIENT_HOLDING` | 422 | 否 | 可用持仓不足以卖出 | +| `SUITABILITY_MISMATCH` | 422 | 否 | 风险等级不匹配(适当性) | +| `HOLDING_RATIO_EXCEEDED` | 422 | 否 | 买入后单一投资者持仓占比超限 | +| `FUND_QUOTE_UNAVAILABLE` | 503 | **是** | 行情过期/不可用(**15 分钟新鲜度**,见 §9) | +| `ORDER_NOT_CANCELLABLE` | 409 | 否 | 委托状态不允许撤单 | +| `ORDER_NOT_FOUND` | 404 | 否 | 委托或成交记录不存在 | +| `ACCOUNT_NOT_FOUND` | 404 | 否 | 模拟账户不存在 | + +**⚠️ 注意 `RUN_CANCELLED` 不是 HTTP 错误类**。它是 `request_idempotency` 表上的一个 +`status='failed' + error_code='RUN_CANCELLED'` 业务标记,**没有对应的异常类**。 +试图 `import` 它或捕获它都会失败。 + +**⚠️ 已知文档缺口**:投顾目标书、目标书审核与发布的状态流转失败返回 **409**, +但**复用了字面量 `RUN_NOT_CANCELLABLE`**(见 §12.1)。语义上应是 `INVALID_STATE`。 +调用方若按码分支,需要同时接受这两个码。 + +### 1.5 鉴权模型 + +实现:`app/api/dependencies/auth.py` 的 `build_request_context`,用 +`HTTPBearer(auto_error=False)` 取 `Authorization: Bearer `。 + +**核心事实:JWT 里只有 `sub`。角色、权限码、`data_scope` 全部每次请求实时解析。** + +``` +Authorization: Bearer + → IdentityService.resolve(sub) → roles / permissions / data_scope +``` + +这意味着**吊销立即生效**(无需等 token 过期)。`ACCESS_TOKEN_TTL_SECONDS = 1800`(30 分钟)。 + +**三种身份:** + +| 身份 | 令牌来源 | 特点 | +|---|---|---| +| 客户 | `POST /api/v1/auth/tokens` 登录换取 | 完整权限集;**未完成风险测评时被引导闸门拦截** | +| 访客 | `POST /api/v1/visitor-tokens` | `roles=("visitor",)`,**无任何权限码**;只有 `knowledge:query` 这类专门授予的权限 | +| 管理员 | 同客户端登录,但角色含管理权限 | 权限码如 `config:*`、`audit:read` | + +**三个易错点:** + +1. **访客跳过身份解析**。`build_request_context` 对 visitor 角色**不走** `IdentityService.resolve`, + 直接放行。所以访客令牌从不需要在 `sys_user` 里存在。 +2. **引导闸门**。已登录客户访问**非** `/api/v1/onboarding/**` 的接口时,会被 + `RiskQuestionnaireService.is_required` 判定;需要则抛 `ONBOARDING_REQUIRED`(403)。 + 这是"客户一登录发现哪都调不通"的常见原因。 +3. **登录接口本身不依赖 `build_request_context`**(否则"要登录先登录")。 + 它单独使用 `enforce_login_rate_limit`。 + +**权限码来源**:定义源是 `tools/seed_test_rbac.py` 的 `PERMISSIONS`(**9001–9046 号段**)。 +该脚本是 **DELETE 重建**语义(`DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099`)。 +**没并进它的权限码,重建一次就没了**,表现为"接口突然 403 而没有任何报错线索"。 +一致性由 `python tools/check_rbac_seed_consistency.py` 守着。 + +**工具可用范围 = 代码上限 ∩ 当前 active `config_release` 的发布白名单**; +缺发布配置则**失败关闭**。`config_release` 是**环境数据**,不随代码合并。 + +### 1.6 幂等 + +请求头:`Idempotency-Key`。 + +- **幂等范围**(`docs/05` §5.1):`user_id + method + normalized_path + idempotency_key`。 +- 幂等记录与业务写入**同事务**(`ApiTransactionService.execute_in`)。重复请求直接回放 + `response_json`,**不会二次驱动状态机**。 +- 同一键 + 不同请求体 → **409 `IDEMPOTENCY_CONFLICT`**。 + +**⚠️ 风控写接口的 `scope` 用「实际路径」(含 `alert_no`)**,不是路径模板 +(`app/api/controllers/risk.py:48-65` 有详细理由)。把路径参数折成模板,会让**同一个键在不同 +预警之间互相回放** —— 那是把两次不同资源的操作当成一次。 + +**哪些接口要求幂等键**:风控全部写操作、交易下单、场外全套写操作、推介材料写操作、 +投顾目标书系列、`POST /agent-runs`、`POST /conversations` 等。 +**明确不要求**的:`AD008` 组合分析、`AD009` 资产配置(纯分析,无副作用)。 + +### 1.7 乐观并发(If-Match / ETag) + +- 需要版本控制的资源在响应头带 `ETag: ""`。 +- 更新时用 `If-Match: ""`。不匹配 → **409 `RESOURCE_VERSION_CONFLICT`**(**retryable = true**)。 +- **校验在 Service 的权限闸门之后进行**(`admin_service.py:96-99`):未授权调用一律 403, + 不能因为参数格式先漏出一个 400 —— 响应差异本身就是一条越权探测信号。 + +**⚠️ 关键约束(曾被踩成真实 bug)**: + +> **凡接受 `If-Match` 的资源,必须同时提供能返回该 etag 的读取路径。** + +`A048`(`GET /admin/config-releases/{release_id}/platform-config-items/{item_id}`)与 +`A049`(`GET /admin/config-releases/{release_id}/model-routing-rules/{rule_id}`) +**存在的唯一目的**就是暴露这个 etag —— 列表的 `meta` 里**只有 `trace_id`**,不带 etag。 + +`register_resource` 里 `platform-config-items` 与 `model-routing-rules` **必须是 +`detail=True`**。此前它们被设成 `detail=False`,导致**没有任何端点能返回 digest**, +于是**首次编辑必然 409** —— 乐观并发成了死锁,编辑功能实际不可用 +(2026-09-13 前端等价测试发现)。源码里这段注释就是这次事故的记录。 + +### 1.8 游标分页 + +`docs/05` §3.8。 + +- `cursor` 语义是"**取更旧的一页**",不是页码。 +- `limit` 上限因端点而异(见各域章节)。**风控最严格**:预警列表 `limit` **最大 5**, + 证据/通知列表最大 10。 +- **游标非法 → 400 `INVALID_CURSOR`,且发生在任何数据访问之前**。 + 这既防止非法游标退化成一次静默全量查询,也避免"参数错误"先于"权限错误"泄漏信息。 + +**两种游标实现**: + +| 域 | 实现 | 备注 | +|---|---|---| +| 交易 / 会话 / 会话消息 / 管理员配置 | `id < cursor` 的**整数游标**(`int(cursor)`) | 控制器里直接 `int(cursor)`;非法值会抛 `ValueError` | +| 风控 | **偏移量编码游标** + `binding` 签名 | `binding` 把 `user_id` 与筛选条件绑进游标,**换筛选条件后旧游标失效** | + +### 1.9 限流 + +`enforce_rate_limit` 是**路由级依赖**,且**自身依赖 `build_request_context`** —— +所以**鉴权永远发生在限流之前**。 + +- 带限流的域:`/api/v1/agent-runs`、`/api/v1/conversations`、`/api/v1/risk/**`、 + `/api/v1/admin/**`、`/api/v1/advisor/**`(经 `enforce_advisor_rollout`)、登录。 +- **不带限流**:交易 `/api/v1/users/me/**`(T 系列是低频接口)。 +- 登录用独立的 `enforce_login_rate_limit`。 + +**⚠️ 风控扫描还有一把数据库锁**:`mysql_scan_lock()` 取 +`GET_LOCK('jr_risk_scan_schedule', 0)`,**加在入口层**而不是 `RiskScanService.scan()` 内部。 +原因:`GET_LOCK` 是**连接级**的,调度器已在它自己的 session 上持锁; +被两个入口共用的服务方法若再取同一把锁,取锁的连接不是持锁的那一个、必然失败, +会**把定时扫描自己挡死**。拿不到锁 → `RiskScanBusyError`(503 语义)。 + +### 1.10 审计边界 + +- **审计写入与业务写入同事务**,但**审计失败不阻断业务**(典型:登录成功/失败都记, + 审计写失败从不让登录失败)。 +- **风控端点的授权一律落在 Service 层**(`risk_query_service` / `risk_action_service` / + `risk_scan_service`),Controller 只负责取 context。 +- **RBAC 四个只读端点(A035–A038)不写审计**(`app/api/controllers/rbac.py`)。 +- **管理员配置变更的审计带前后摘要**:`before_hash` / `after_hash` + (`admin_service.py:151-165`),动作类型形如 `platform..`。 +- `GET /admin/audit-records` 对**没有 `audit:read-sensitive` 权限**的调用方, + 会把 `detail` 替换成 `{"redacted": True}`(`admin_service.py:112-113`)。 + +--- + +## 2. 公开面:访客令牌与产品目录 + +### V001 — `POST /api/v1/visitor-tokens`(发访客令牌) + +| 项 | 内容 | +|---|---| +| **用途** | 未登录访客换取短期令牌,用于调用访客浮窗的客服问答 | +| **鉴权** | **无**(这是唯一无需任何令牌的业务入口之一) | +| **幂等** | 否 | +| **成功** | **201** | + +**请求参数**:无请求体。 + +**响应**(**裸体,不是统一信封**): + +```json +{ + "access_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +`expires_in` 取值范围 **60–3600** 秒(`VisitorTokenResponse` 的约束)。 + +**示例**: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/visitor-tokens +``` + +**错误码**:422(参数约束,实际无参数,罕见)。 + +**注意事项**:前端 `common/api-client.js` 对该端点标了 `raw: true`,因为它**没有信封**。 +访客令牌**没有任何权限码**,只能调专门为访客放开的接口。 + +--- + +### P001 — `GET /api/v1/products`(产品列表) + +| 项 | 内容 | +|---|---| +| **用途** | 公开产品目录,供访客/客户浏览"平台有哪些基金" | +| **鉴权** | **需要合法令牌,但不要求权限码**(访客令牌可用) | +| **成功** | 200 | + +**请求参数**:无(`docs/05` §19 标注为无参数)。 + +**响应**:标准列表信封。 + +**字段含义**:每条含产品基础信息。**`change_pct`(当日涨跌幅)可能为 `null`** —— +调用方必须显示"暂无",**绝不可当成 `0`** 展示(那会把"没数据"说成"平盘")。 + +**注意事项**: + +- ⚠️ **`change_pct` 为 `null` 时必须显示"暂无"**。这是 `docs/05` §19 明确写下的要求。 +- 该端点**不受南方基金白名单限制**(对比 P002 的说明)。 + +--- + +### P002 — `GET /api/v1/products/{product_code}/nav-history`(净值走势) + +| 项 | 内容 | +|---|---| +| **用途** | 单个产品的历史净值序列,供前端画走势图 | +| **鉴权** | 需要合法令牌,**不要求权限码** | +| **成功** | 200 | + +**路径参数** + +| 参数 | 类型 | 约束 | +|---|---|---| +| `product_code` | string | 产品代码 | + +**查询参数** + +| 参数 | 类型 | 默认 | 约束 | +|---|---|---|---| +| `days` | int | `90` | **1–365** | + +**响应**:标准单对象信封,`data` 内含净值序列,另带 `count`。 + +**示例**: + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/api/v1/products/159915/nav-history?days=30" +``` + +**注意事项**: + +- ⚠️ **净值表为空时返回 `count = 0`,这是正常结果不是错误**。 + 不要把它当 404 处理,也不要报"数据加载失败"。 +- 该端点**不套用南方基金白名单**(与 P001 一致)。 + +--- + +## 3. 认证:登录 + +### A034 — `POST /api/v1/auth/tokens`(登录) + +| 项 | 内容 | +|---|---| +| **用途** | 平台**唯一**的登录入口 | +| **鉴权** | **不依赖 `build_request_context`**(否则"要登录先登录");单独用 `enforce_login_rate_limit` | +| **幂等** | 否 | +| **成功** | 200 | + +**请求体** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `username` | string | 是 | 用户名 | +| `password` | string | 是 | 密码 | + +**响应** + +```json +{ + "data": { + "access_token": "eyJhbGciOi...", + "token_type": "Bearer", + "expires_in": 1800, + "user_id": "9001", + "roles": ["customer"], + "data_scope": "self" + }, + "meta": { "trace_id": "..." } +} +``` + +| 字段 | 类型 | 含义 | +|---|---|---| +| `access_token` | string | Bearer 令牌,**JWT 内只含 `sub`** | +| `token_type` | string | 固定 `"Bearer"` | +| `expires_in` | int | `ACCESS_TOKEN_TTL_SECONDS = 1800`(30 分钟) | +| `user_id` | **string** | 用户 ID(注意是字符串,不是数字) | +| `roles` | string[] | 角色列表 | +| `data_scope` | string | 数据范围,如 `self` / `all` | + +**示例**: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/auth/tokens \ + -H "Content-Type: application/json" \ + -d '{"username":"customer01","password":""}' +``` + +**错误码**:401 `AUTHENTICATION_REQUIRED`、429 `RATE_LIMITED`(带 `Retry-After`)、422。 + +**注意事项**: + +- **失败永不区分原因**:用户不存在与密码错误返回**同一条**消息 + `"用户名或密码不正确"`(`INVALID_CREDENTIALS_MESSAGE`),防用户名枚举。 +- 用户不存在时仍执行一次 **bcrypt 对比(`_DUMMY_HASH`)**,抹平时间差。 +- **成功与失败都写审计**(`auth.login_succeeded` / `auth.login_failed`); + 审计写失败**从不阻断登录**。 +- **`user_id` 是字符串**。下游 `int(context.user_id)` 就是为它做的转换。 +- **刷新与吊销令牌不在本接口范围**,仍属身份模块。 +- ⚠️ 重跑 `tools/seed_test_rbac.py` **不会**再弄丢演示密码 —— `sys_user` 已改为 + "存在则更新、不存在才插入"。 + +--- + +## 4. 会话 / 消息 / 反馈 / 转人工 + +全部在 `/api/v1` 下,带 `enforce_rate_limit`。 + +### C001 — `POST /api/v1/conversations`(创建会话) + +| 项 | 内容 | +|---|---| +| **用途** | 新建一次对话会话 | +| **鉴权** | 权限 `conversation:create` | +| **成功** | **201** | + +**请求体** + +| 字段 | 类型 | 约束 | +|---|---|---| +| `agent_type` | string | pattern `^[a-z][a-z0-9_]{1,31}$` | + +**响应**:标准信封,`data` = 会话视图(含 `session_id`、`agent_type`、`status`、`created_at`)。 + +**注意事项**:创建时会话行的时间戳字段被**显式赋 `now`**,而不是依赖 +`server_default=CURRENT_TIMESTAMP(6)` —— 异步读回 `server_default` 会触发 +`MissingGreenlet`(session 已关但懒加载未完成)。 + +--- + +### C002 — `GET /api/v1/conversations/{session_id}`(查询会话) + +| 项 | 内容 | +|---|---| +| **用途** | 查询会话当前状态 | +| **成功** | 200 | + +**错误码**:404 `SESSION_NOT_FOUND` / `SESSION_NOT_ACCESSIBLE` —— **会话存在但不属于当前用户时返回 404 而非 403**,避免泄漏"这个 session 存在"。 + +--- + +### C003 — `GET /api/v1/conversations/{session_id}/messages`(消息列表) + +| 项 | 内容 | +|---|---| +| **用途** | 拉取会话历史消息 | +| **成功** | 200(标准列表信封) | + +**查询参数** + +| 参数 | 类型 | 默认 | 约束 | +|---|---|---|---| +| `limit` | int | `20` | **1–100** | +| `cursor` | string | — | 游标,取更旧一页 | + +**注意事项**:Controller 在调 Service **之前**就 `parse_cursor(cursor)`, +非法游标立即 400,不会变成一次静默全量查询。 + +--- + +### C004 — `POST /api/v1/conversations/{session_id}/closures`(关闭会话) + +| 项 | 内容 | +|---|---| +| **用途** | 主动结束会话 | +| **鉴权** | 权限 `conversation:close` | +| **成功** | 200 | + +**请求体** + +| 字段 | 类型 | 默认 | 约束 | +|---|---|---|---| +| `reason` | string | `"user_cancelled"` | max 128 | + +**响应**:`data` = 会话视图(`self._session_view(row)`)。 + +**注意事项**:`reason_detail` 会经过 `sanitize_customer_service_message` 清洗。 + +--- + +### C005 — `POST /api/v1/conversations/{session_id}/handover-requests`(申请转人工) + +| 项 | 内容 | +|---|---| +| **用途** | 会话转人工 —— **单一入口** | +| **鉴权** | 权限 `handover:create` | +| **成功** | **202** | + +**响应** + +```json +{ + "data": { + "handover_id": "HT20260914001", + "status": "...", + "session_id": "...", + "created_at": "2026-09-14T02:44:20" + }, + "meta": { "trace_id": "..." } +} +``` + +**注意事项**:这是**唯一入口**,在同一事务里写入**三样**:工单行 + Outbox 事件 +`conversation.transfer_requested` + 审计。不要在别处另写工单。 + +--- + +### C006 — `GET /api/v1/handover-requests/{handover_id}`(查询转人工) + +| 项 | 内容 | +|---|---| +| **用途** | 查询转人工请求状态 | +| **成功** | 200 | + +**错误码**:404。 + +--- + +### C007 — `POST /api/v1/conversation-messages/{message_id}/feedback`(消息反馈) + +| 项 | 内容 | +|---|---| +| **用途** | 对某条消息点赞/点踩 | +| **鉴权** | 权限 `conversation:feedback` | +| **成功** | **201** | + +**响应** + +```json +{ "data": { "feedback_no": "FB...", "status": "..." }, "meta": { "trace_id": "..." } } +``` + +**错误码**:**409 `FEEDBACK_ALREADY_EXISTS`**(同一消息重复提交)。 + +--- + +## 5. Agent 运行(异步三段式) + +**这是本项目最核心的调用模式。** 全在 `/api/v1/agent-runs`,带 `enforce_rate_limit`。 + +``` +① POST /api/v1/agent-runs → 202 受理,拿 run_id +② GET /api/v1/agent-runs/{run_id} → 轮询状态与结果 (或 ③) +③ GET /api/v1/agent-runs/{run_id}/events → SSE 实时推送 +``` + +**⚠️ 必须有常驻 Worker**:`python -m app.worker`。没有 Worker 时 `agent_run` 会一直停在 +`status='queued'`、`worker_id` 为空,而前端只显示"客服响应超时 / 客服繁忙" —— +**看起来像链路慢,实际是没人处理**。排查第一步:查 `agent_run` 最新那行是不是 `queued`。 + +### R001 — `POST /api/v1/agent-runs`(创建运行) + +| 项 | 内容 | +|---|---| +| **用途** | 受理一次 Agent 请求 | +| **鉴权** | 按 `agent_type` 走各域权限 | +| **幂等** | **是**(`Idempotency-Key`) | +| **成功** | **202 Accepted** | + +**请求体**(`AgentRunCreateRequest`,`extra="forbid"` —— 多字段会 422) + +| 字段 | 类型 | 必填 | 约束 | +|---|---|---|---| +| `agent_type` | string | 是 | 已注册的 Agent 类型 | +| `message` | string | 是 | **min_length = 1** | +| `session_id` | string | 是 | 会话 ID | +| `idempotency_key` | string | 是 | **min_length = 16,max_length = 128** | + +**响应** + +```json +{ + "data": { + "run_id": "run_...", + "trace_id": "...", + "status": "queued", + "status_url": "/api/v1/agent-runs/run_...", + "events_url": "/api/v1/agent-runs/run_.../events" + }, + "meta": { "trace_id": "..." } +} +``` + +**⚠️ 两个 trace_id 不是一回事**: + +- `meta.trace_id` = **本次 HTTP 请求**的追踪号; +- `data.trace_id` = **这个 run 自己**的追踪号。 + +排障时要看哪个,取决于你在查"请求没进来"还是"这次运行出没出结果"。 + +**已注册的 7 个业务 Agent**(`app/service/agent/implementations/`): +`FundQueryDemoAgent`、`CustomerServiceAgent`、`RiskAgent`、`PlatformProbeAgent`、 +`AdvisorAgent`、`OffsiteFundAgent`、`PromotionMaterialAgent`。 + +**错误码**:404 `AGENT_TYPE_NOT_FOUND`、422、429、503。 + +**注意事项**: + +- `AGENTS.md` 规则 7:业务 Agent 必须继承公共 `BaseAgent` 并由 `AgentFactory` 创建, + **不得绕过**公共鉴权、记忆、模型路由、工具、合规、审计和事件流程。 +- **Agent 属于 Service 层**(规则 6)。 + +--- + +### R002 — `GET /api/v1/agent-runs/{run_id}`(查询运行) + +| 项 | 内容 | +|---|---| +| **用途** | 轮询运行状态与结果 | +| **成功** | 200 | + +**响应**(`AgentRunStatusResponse`) + +| 字段 | 类型 | 含义 | +|---|---|---| +| `run_id` | string | 运行 ID | +| `trace_id` | string | **该 run 自己的** trace | +| `status` | string | `queued` / `running` / `succeeded` / `failed` / ... | +| `agent_type` | string | Agent 类型 | +| `session_id` | string | 所属会话 | +| `result` | object \| null | 成功时的结果负载 | +| `error_code` | string \| null | 失败时的错误码 | +| `created_at` | string | 受理时间 | +| `completed_at` | string \| null | 完成时间;未完成时为 `null` | + +**错误码**:404 `RUN_NOT_FOUND`。 + +--- + +### R003 — `GET /api/v1/agent-runs/{run_id}/events`(SSE 订阅) + +| 项 | 内容 | +|---|---| +| **用途** | 实时接收运行过程与结果 | +| **请求头** | `Accept: text/event-stream` **必需** | +| **成功** | 200,`text/event-stream`,`Cache-Control: no-cache` | + +**事件类型**(`app/api/views/agent_run_sse.py`) + +| 事件 | 含义 | +|---|---| +| `start` | 运行开始 | +| `tools` | 工具调用过程 | +| `replace` | **整体替换**当前文本 | +| `delta` | **增量追加**(按 `sse_chunk_characters` 分块,**默认 256 字符**) | +| `done` | 完成 | +| `error` | 出错 | + +**编码格式**(严格): + +``` +event: +data: +id: {run_id}:{event_name}:{index} + +``` + +`id` 用于断线续传。**心跳是注释行**:`: heartbeat\n\n`。 + +**若运行已终态**(terminal),直接走**回放模式**,把历史事件重放一遍。 + +**注意事项 —— 顺序是刻意的**: + +> **先查可见性(`query.get`),后查 `Accept`。** + +`app/api/controllers/agent_runs.py` 的注释写明了理由:反过来会让 +**406-vs-404 变成存在性探针**(攻击者靠"是 406 还是 404"判断某 run_id 是否存在)。 +风控日报流(§10)遵循同样顺序:**先鉴权,后 Accept**。 + +--- + +### R004 — `POST /api/v1/agent-runs/{run_id}/cancellations`(取消运行) + +| 项 | 内容 | +|---|---| +| **用途** | 取消进行中的运行 | +| **鉴权** | 权限 `agent:cancel` | +| **成功** | **202** | + +**请求体** + +| 字段 | 类型 | 默认 | 约束 | +|---|---|---|---| +| `reason` | string | `"user_cancelled"` | max 128 | + +**错误码**:**409 `RUN_NOT_CANCELLABLE`**(已终态)、404 `RUN_NOT_FOUND`。 + +**⚠️ 注意**:`RUN_CANCELLED`(被取消这件事本身)**不是 HTTP 错误码**, +它是 `request_idempotency` 表上的状态标记。见 §1.4。 + +### 业务域 Agent 的运行权限速查 + +| 域 | 权限码 | +|---|---| +| 会话 | `conversation:create` / `conversation:close` / `conversation:feedback` / `agent:cancel` / `handover:create` | +| 记忆 | `memory:read:self` / `memory:read:customer` / `memory:candidate:confirm` / `memory:candidate:review` | +| 知识 | `knowledge:manage` / `knowledge:reference:read` | +| 风控 | `risk:alert:read` / `risk:alert:write` / `risk:alert:scan` / `risk:report:mail` | +| 交易 | `account:read:self` / `trade:order:create` / `trade:order:read` / `trade:order:cancel` / `holding:read:self` / `trade:txn:read` | +| 投顾 | `investment-goal:*` / `portfolio-analysis:read:self` / `asset-allocation:generate:self` / `asset-allocation:backtest` / `product-recommendation:*` / `profile-governance:read` / `profile-governance:review` | +| 场外 | `offsite:read` / `offsite:write` / `offsite:confirm` / `offsite:notify` | +| 推介 | `promotion:read` / `promotion:write` / `promotion:review` / `promotion:deliver` | +| 管理 | `config:read` / `config:write` / `config:review` / `config:activate` / `model-endpoint:manage` / `audit:read` / `audit:read-sensitive` | + +--- + +## 6. 记忆与画像候选 + +### M001 — `GET /api/v1/users/me/memory-profile`(我的画像) + +| 项 | 内容 | +|---|---| +| **用途** | 当前登录用户查看自己的记忆画像 | +| **鉴权** | 权限 `memory:read:self` | +| **成功** | 200(标准单对象信封) | + +--- + +### M002 — `GET /api/v1/customers/{customer_id}/memory-profile`(指定客户画像) + +| 项 | 内容 | +|---|---| +| **用途** | 管理员/投顾查看指定客户的画像 | +| **鉴权** | 权限 `memory:read:customer` | +| **成功** | 200 | + +--- + +### M003 — `GET /api/v1/users/me/memory-candidates`(我的画像候选) + +| 项 | 内容 | +|---|---| +| **用途** | 列出候选记忆,供用户确认 | +| **鉴权** | 权限 `memory:read:self` | +| **成功** | 200 | + +**响应形状特殊**:`data` **直接是数组**(`{"data": [...], "meta": {"trace_id": ...}}`), +Service 层自己拼了信封(`customer_profile_candidate_service.py:52`)。 + +**过滤条件**:`status ∈ ("candidate", "verified")`,按 `updated_at` **降序**,`limit` 上限 100。 + +**候选字段**(`_view`): + +| 字段 | 类型 | 含义 | +|---|---|---| +| `candidate_id` | int | 候选 ID | +| `customer_id` | **string** | 客户 ID(字符串) | +| `memory_key` | string | 记忆键 | +| `value` | string | 候选值(`content`) | +| `memory_type` | string | 记忆类型 | +| `confidence` | float | 置信度 | +| `status` | string | 状态 | +| `version` | int | 版本号 | +| `created_at` / `updated_at` | string | ISO 时间 | + +**⚠️ 不返回对话证据摘录** —— `_view` 的 docstring 明确写了"只返回结构化候选值, +不返回对话证据摘录"。 + +--- + +### M004 — `POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions`(确认/拒绝候选) + +| 项 | 内容 | +|---|---| +| **用途** | 用户对自己的候选记忆做确认或拒绝 | +| **鉴权** | 权限 `memory:candidate:confirm` | +| **成功** | 200 | + +**请求体** + +| 字段 | 类型 | 取值 | +|---|---|---| +| `decision` | string | `"confirmed"` \| `"rejected"` | + +**响应**:`data` = 候选视图(同 M003 的字段)。 + +**错误码**:404、409(状态不允许,候选不是 `candidate`)。 + +**注意事项**:**确认不会直接激活正式记忆**(`decide_by_customer` 的 docstring)。 +需管理员在 `A039/A040` 再走一次审核。 + +--- + +## 7. 新人引导(风险测评) + +`/api/v1/onboarding`,**唯一不受引导闸门拦截的路径**(其余客户接口都受)。 + +### O001 — `GET /api/v1/onboarding/risk-questionnaire`(获取问卷) + +| 项 | 内容 | +|---|---| +| **用途** | 取风险测评问卷 | +| **成功** | 200 | + +**查询参数** + +| 参数 | 类型 | 默认 | 含义 | +|---|---|---|---| +| `retake` | bool | `False` | 是否重新测评 | + +--- + +### O002 — `POST /api/v1/onboarding/risk-questionnaire/submissions`(提交答卷) + +| 项 | 内容 | +|---|---| +| **用途** | 提交风险测评 | +| **幂等** | 是(`Idempotency-Key`) | +| **成功** | **201** | + +**请求体**(`RiskQuestionnaireSubmission`,`extra="forbid"`,**frozen**) + +| 字段 | 类型 | 约束 | +|---|---|---| +| `answers` | `dict[str, int]` | 必须**恰好包含每一道题各一次**;每个选项下标必须在 `QUESTION_OPTION_COUNTS[question_id]` 范围内 | +| `declaration_accepted` | `true` | **必须是字面量 `true`** | + +**错误码**:422(缺题、多题、选项越界、未接受声明)。 + +**注意事项**:`extra="forbid"` + `frozen` 意味着传多余字段或尝试重复提交同一对象都会失败。 +这道闸门的存在,是"客户登录后哪都调不通"的最常见原因 —— 见 §1.5。 + +--- + +### O003 — 相关:引导闸门 + +客户访问 `/api/v1/onboarding/**` 之外的接口时: + +``` +已登录客户 → RiskQuestionnaireService.is_required() ? + 是 → 403 ONBOARDING_REQUIRED + 否 → 放行 +``` + +--- + +## 8. 知识库管理 + +### K001 — `GET /api/v1/knowledge-references/{reference_token}`(知识引用解析) + +| 项 | 内容 | +|---|---| +| **用途** | 用引用令牌换回知识原文片段 | +| **鉴权** | 权限 `knowledge:reference:read` | +| **成功** | 200 | + +**路径参数** + +| 参数 | 类型 | 约束 | +|---|---|---| +| `reference_token` | string | **min_length = 20,max_length = 300** | + +**错误码**:404 `REFERENCE_NOT_FOUND`。 + +--- + +### K002 — `POST /api/v1/knowledge/upload`(上传知识文档) + +| 项 | 内容 | +|---|---| +| **用途** | 上传文档入库并向量化 | +| **鉴权** | 权限 `knowledge:manage` | +| **成功** | **201** | + +**请求体**(**JSON,不是 multipart**) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `content_base64` | string | **base64 编码的文件内容** | +| `filename` | string | 文件名 | +| `knowledge_type` | string | 知识类型 | + +**响应** + +```json +{ + "data": { + "knowledge_ids": ["..."], + "filename": "...", + "knowledge_type": "...", + "created_by": "...", + "chunk_count": 12 + }, + "meta": { "trace_id": "..." } +} +``` + +**⚠️ 为什么不用 multipart**:Controller 的 docstring 解释了这一设计选择。 + +**⚠️ Milvus 集合 schema 因环境而异**: +本机是 `knowledge_id`/`snippet`(**无 `visibility`**), +架构师环境是 `doc_id`/`content`/`visibility`/`chapter`… +**检索层已改为运行时探测字段名**(`app/core/knowledge_schema.py`)。 +**不要在任何地方硬编码字段名** —— 那会把另一套环境打挂。 + +--- + +### K003 — `GET /api/v1/knowledge/list`(文档列表) + +| 项 | 内容 | +|---|---| +| **用途** | 列出已入库文档 | +| **鉴权** | 权限 `knowledge:manage` | +| **成功** | 200 | + +**查询参数** + +| 参数 | 类型 | 默认 | 约束 | +|---|---|---|---| +| `limit` | int | `20` | 1–100 | +| `offset` | int | `0` | ≥ 0(**注意是 offset 不是 cursor**) | +| `knowledge_type` | string | — | 可选 | + +**响应**:`data` = `{"items": [...], "count": n}`。 + +**⚠️ 每个 item 只带 `content_preview`(前 200 字符)+ `content_length`,不是全文。** +需要全文走 K001 用引用令牌换。 + +--- + +### K004 — `DELETE /api/v1/knowledge/{knowledge_id}`(删除文档) + +| 项 | 内容 | +|---|---| +| **用途** | 软删除文档(置 `expired` + 发向量删除事件) | +| **鉴权** | 权限 `knowledge:manage` | +| **成功** | 200 | + +**路径参数**:`knowledge_id`,约束 `gt=0`。 + +**响应** + +```json +{ + "data": { + "knowledge_id": "...", + "status": "expired", + "vector_delete_event": "..." + }, + "meta": { "trace_id": "..." } +} +``` + +**错误码**:**404 `RESOURCE_NOT_FOUND`**(文档不存在**或已删除**)。 + +**⚠️ 不存在的 id 与已删除的 id 都返回 404,绝不静默成功** —— +静默成功会让调用方以为删除生效了。 + +--- + +## 9. 场内基金交易 + +前缀 **`/api/v1/users/me`**,**不带 `enforce_rate_limit`**(T 系列是低频接口)。 +全部以 `context.user_id` 为归属,**不接受客户 ID 参数** —— 无法越权查别人的账户。 + +### ⚠️ 本域最重要的两条约束 + +**① 行情有效期只有 15 分钟。** + +`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`。超时后**所有委托一律 503 +`FUND_QUOTE_UNAVAILABLE`「行情已过期」,且没有自动刷新**。这是演示最容易翻的一环。 +补刷:`python tools/sync_market_prices.py`(**立即生效、无需重启服务**)。 + +**② 第一版只支持 `price_type="market"`。** + +立即全额成交。`limit_price` 恒为 `null`,`filled_quantity` = `quantity`。 +**T005 撤单对任何在途委托都返回 409 `ORDER_NOT_CANCELLABLE`** —— +因为市价单下单即成交,`status` 直接是 `"已成交"`,只有 `"待风控"` 状态可撤。 + +**只读持仓允许展示最近一笔可用行情**(`enforce_freshness=False`), +15 分钟新鲜度**只约束真实下单**,避免行情暂时未更新时把客户已有数据整页隐藏。 + +--- + +### T001 — `GET /api/v1/users/me/account/dashboard`(账户总览) + +| 项 | 内容 | +|---|---| +| **用途** | 首页资产总览 | +| **鉴权** | `account:read:self` | +| **成功** | 200 | + +**响应**(`AccountDashboardResponse`) + +```json +{ + "data": { + "account": { + "account_no": "SIM...", + "status": "...", + "currency": "CNY", + "initial_balance": "1000000.00", + "cash_balance": "...", + "available_cash": "...", + "frozen_cash": "..." + }, + "summary": { + "total_asset": "...", + "total_market_value": "...", + "total_cost": "...", + "total_profit_loss": "...", + "total_profit_loss_ratio": "...", + "today_profit_loss": "0", + "today_profit_loss_ratio": "0" + }, + "holdings": [ { "...": "HoldingItem" } ], + "as_of": "2026-09-14T02:44:20" + }, + "meta": { "trace_id": "..." } +} +``` + +| 字段 | 含义 | +|---|---| +| `account.initial_balance` | 模拟初始资金 | +| `account.cash_balance` | 资金余额(含冻结) | +| `account.available_cash` | 可用余额 | +| `account.frozen_cash` | 冻结资金 | +| `summary.total_asset` | **`cash_balance + total_market_value`** | +| `summary.total_profit_loss` | `total_market_value - total_cost` | +| `summary.today_profit_loss` | **恒为 `"0"`** —— 见注意事项 | +| `summary.today_profit_loss_ratio` | **恒为 `"0"`** | +| `as_of` | 快照时间(UTC 无时区) | + +**⚠️ `today_profit_loss` / `today_profit_loss_ratio` 目前恒为 0** +(`trade_service.py:748-749` 直接写 `ZERO`)。前端若把它当真实当日盈亏展示会误导。 +需要真实当日盈亏时,请以"当日最后一笔成交价 vs 当前价"自行计算,或推动后端补齐。 + +**所有金额字段都是字符串化的 Decimal**(`"1234.56"`),不是 JSON number —— +避免浮点精度问题。前端需 `parseFloat`。 + +--- + +### T002 — `POST /api/v1/users/me/orders`(下单) + +| 项 | 内容 | +|---|---| +| **用途** | 场内基金模拟委托(**市价、立即全额成交**) | +| **鉴权** | `trade:order:create` | +| **幂等** | **是**(`Idempotency-Key`) | +| **成功** | **201** | + +**请求体**(`OrderCreateRequest`) + +| 字段 | 类型 | 必填 | 约束 | +|---|---|---|---| +| `product_code` | string | 是 | **1–32** | +| `order_side` | string | 是 | `"buy"` \| `"sell"` | +| `quantity` | Decimal | 是 | **> 0** | +| `price_type` | string | 否 | 默认 `"market"`;**目前只支持 market** | + +**响应**(`OrderCreateResponse`) + +```json +{ + "data": { + "order_no": "SO20260914024420A1B2C3D4", + "status": "已成交", + "executed_quantity": "...", + "executed_price": "...", + "gross_amount": "...", + "fee_amount": "...", + "net_amount": "...", + "executed_at": "2026-09-14T02:44:20" + }, + "meta": { "trace_id": "..." } +} +``` + +**下单的完整校验链**(顺序即报错优先级): + +| 步 | 校验 | 失败错误码 | +|---|---|---| +| 1 | 产品可交易 | 422 `PRODUCT_NOT_TRADABLE` | +| 2 | 适当性匹配 | 422 `SUITABILITY_MISMATCH` | +| 3 | **行情新鲜度 ≤ 15 分钟** | **503 `FUND_QUOTE_UNAVAILABLE`** | +| 4 | 账户存在 | 404 `ACCOUNT_NOT_FOUND` | +| 5 | `quantity > 0` | 422 `AGENT_INPUT_INVALID` | +| 6 | `quantity % product.lot_size == 0` | 422 `AGENT_INPUT_INVALID`(**最小交易单位整数倍**) | +| 7 | 买入:`available_cash >= gross + fee` | 422 `INSUFFICIENT_FUNDS` | +| 8 | 买入:持仓占比 ≤ 上限 | 422 `HOLDING_RATIO_EXCEEDED` | +| 9 | 卖出:`available_quantity >= quantity` | 422 `INSUFFICIENT_HOLDING` | + +**买卖金额公式**(**方向不同,注意**): + +- 买入:`net_amount = gross_amount + fee_amount` +- 卖出:`net_amount = gross_amount - fee_amount` + +**示例**: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/users/me/orders \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: 0123456789abcdef" \ + -d '{"product_code":"159915","order_side":"buy","quantity":"1000","price_type":"market"}' +``` + +**注意事项**: + +- **`quantity` 必须是 `product.lot_size` 的整数倍**,否则 422。 +- **卖出按 `available_quantity` 校验**,不是 `total_quantity` + (冻结份额不可卖)。 +- 下单会写入委托行 + 成交行 + 资金账三张表,并更新持仓,全部在一个事务里。 +- **委托号格式**:`SO{UTC时间戳}{8位随机大写hex}`;成交号 `TX...`;资金账 `L...`。 + +--- + +### T003 — `GET /api/v1/users/me/orders`(委托列表) + +| 项 | 内容 | +|---|---| +| **用途** | 历史委托列表 | +| **鉴权** | `trade:order:read` | +| **成功** | 200(标准列表信封) | + +**查询参数** + +| 参数 | 类型 | 默认 | 约束 | +|---|---|---|---| +| `limit` | int | `20` | **1–100** | +| `cursor` | string | — | 整数游标(`int(cursor)`) | + +**排序**:`id DESC`(最新在前)。取 `limit + 1` 条判断是否有下一页。 + +**响应字段**(`OrderSummary`) + +| 字段 | 含义 | +|---|---| +| `order_no` | 委托号 | +| `product_id` / `product_code` / `product_name` | 标的 | +| `order_side` | `buy` / `sell` | +| `price_type` | 目前恒 `market` | +| `quantity` | 委托数量 | +| `limit_price` | **恒 `null`**(市价单) | +| `quote_price` | 成交所用行情价 | +| `quote_at` | 行情时间 | +| `filled_quantity` | 已成数量(市价单 = `quantity`) | +| `average_executed_price` | 成交均价 | +| `status` | 如 `已成交` / `已撤单` | +| `submitted_at` / `cancelled_at` | 时间 | +| `reject_reason` | 拒单原因 | + +--- + +### T004 — `GET /api/v1/users/me/orders/{order_no}`(委托详情) + +| 项 | 内容 | +|---|---| +| **用途** | 单笔委托详情 | +| **鉴权** | `trade:order:read` | +| **成功** | 200 | + +**响应**:`data` = `OrderSummary`(字段同 T003)。 + +**错误码**:**404 `ORDER_NOT_FOUND`**。 + +**⚠️ 查询是带 `customer_id == int(context.user_id)` 条件的** —— +别人的委托号一律 404,不区分"不存在"和"不属于你"。 + +--- + +### T005 — `POST /api/v1/users/me/orders/{order_no}/cancellations`(撤单) + +| 项 | 内容 | +|---|---| +| **用途** | 撤销委托 | +| **鉴权** | `trade:order:cancel` | +| **成功** | 200 | + +**响应**:`data` = 撤单后的 `OrderSummary`(`status` = `"已撤单"`,带 `cancelled_at`)。 + +**错误码** + +| 码 | 条件 | +|---|---| +| 404 `ORDER_NOT_FOUND` | 委托不存在或不属于当前用户 | +| **409 `ORDER_NOT_CANCELLABLE`** | **`status != "待风控"`** —— 即**任何已成交/在途的委托** | + +**⚠️ 第一版实践上几乎总是 409**:因为只支持市价单,下单即成交(`status = "已成交"`), +`"待风控"` 是唯一可撤状态,而它只在风控挂起场景下出现。前端应把 409 处理成 +"该委托已成交,无法撤销",而不是报错弹窗。 + +--- + +### T006 — `GET /api/v1/users/me/holdings`(持仓列表) + +| 项 | 内容 | +|---|---| +| **用途** | 当前持仓 | +| **鉴权** | `holding:read:self` | +| **成功** | 200 | + +**响应**:`data` = `{"holdings": [HoldingItem]}`。 + +**过滤**:`status == "持有中"`。 + +**字段**(`HoldingItem`) + +| 字段 | 含义 | +|---|---| +| `product_id` / `product_code` / `product_name` | 标的 | +| `total_quantity` | 总份额 | +| `available_quantity` | 可用份额 | +| `frozen_quantity` | 冻结份额 | +| `average_cost` | 平均成本价 | +| `cost_amount` | 成本金额 | +| `latest_price` | 最新价(**不强制 15 分钟新鲜度**) | +| `market_value` | 市值 = `total_quantity * latest_price` | +| `profit_loss` | 浮动盈亏 = `market_value - cost_amount` | +| `profit_loss_ratio` | 盈亏比例(`cost_amount > 0` 时计算,否则 0) | +| `today_profit_loss` | 当日盈亏 | + +**⚠️ `latest_price` 允许是最近一笔行情(可能超过 15 分钟)**。 +这是刻意的:只读展示不应因为行情未更新而整页空白。 + +--- + +### T007 — `GET /api/v1/users/me/transactions`(成交记录) + +| 项 | 内容 | +|---|---| +| **用途** | 成交流水 | +| **鉴权** | `trade:txn:read` | +| **成功** | 200 | + +**查询参数**:`limit`(1–100,默认 20)、`cursor`。 + +**响应**:`data` = `{"transactions": [...], "next_cursor": "..."}` —— +**注意这里 `data` 是对象、内层才是数组**(走 `envelope()` 而非 `list_envelope()`)。 + +**字段**(`TransactionItem`):`transaction_no`、`order_no`、`product_id`、 +`product_code`、`product_name`、`order_side`、`executed_price`、`executed_quantity`、 +`gross_amount`、`fee_amount`、`net_amount`、`quote_at`、`executed_at`。 + +--- + +### T008 — `GET /api/v1/users/me/transactions/{txn_no}`(成交详情) + +| 项 | 内容 | +|---|---| +| **用途** | 单笔成交详情 | +| **鉴权** | `trade:txn:read` | +| **成功** | 200 | + +**错误码**:**404 `ORDER_NOT_FOUND`**(注意:成交不存在也复用 `ORDER_NOT_FOUND`, +消息是"成交记录 {txn_no} 不存在")。 + +--- + +### T009 — `GET /api/v1/users/me/cash-ledger`(资金流水) + +| 项 | 内容 | +|---|---| +| **用途** | 资金变动明细 | +| **鉴权** | `trade:txn:read` | +| **成功** | 200 | + +**查询参数**:`limit`(1–100,默认 20)、`cursor`。 + +**响应**:`data` = `{"entries": [...], "next_cursor": "..."}`。 + +**字段**(`CashLedgerItem`) + +| 字段 | 含义 | +|---|---| +| `ledger_no` | 流水号(`L...`) | +| `entry_type` | 记账类型 | +| `amount` | 变动金额 | +| `balance_after` | 变动后余额 | +| `available_cash_after` | 变动后可用 | +| `frozen_cash_after` | 变动后冻结 | +| `transaction_no` | 关联成交号(**可能为 `null`**) | +| `occurred_at` | 发生时间 | + +--- + +## 10. 风控 + +前缀 `/api/v1/risk`,**带 `enforce_rate_limit`**。所有授权在 Service 层。 + +### ⚠️ 本域的三条硬约束 + +1. **`limit` 上限极严格**:`/risk/alerts` **最大 5**(默认也是 5); + `/risk/evidence/{source}`、`/risk/notifications` 最大 10。 +2. **扫描锁加在入口层**(`mysql_scan_lock()`),拿不到锁 → `RiskScanBusyError`。 +3. **写操作的 `scope` 用实际路径(含 `alert_no`)**,不是模板。 + +--- + +### `GET /api/v1/risk/overview`(风控总览) + +| 项 | 内容 | +|---|---| +| **鉴权** | `risk:alert:read` | +| **成功** | 200(**标准单对象信封**,非列表信封) | + +**响应** + +```json +{ + "data": { + "total": 12, + "levels": { "高风险": 3, "中风险": 5, "低风险": 4 }, + "pending": 7, + "overdue": 2, + "high_priority": [ { "...": "预警记录" } ] + }, + "meta": { "trace_id": "..." } +} +``` + +**⚠️ `levels` 的键是中文**(`高风险`/`中风险`/`低风险`),由 Service 把库里的 +`高`/`中`/`低` 映射过来。前端按中文键取。 + +**统计口径**:只统计 **未闭环** 预警(`status IN OPEN_STATUSES`),受 `data_scope` 约束。 + +--- + +### `GET /api/v1/risk/alerts`(预警列表) + +| 项 | 内容 | +|---|---| +| **鉴权** | `risk:alert:read` | +| **成功** | 200(列表信封 + `meta.total` / `meta.page_size`) | + +**查询参数**(`RiskAlertPageQuery`) + +| 参数 | 类型 | 默认 | 约束 | +|---|---|---|---| +| `keyword` | string | — | 关键词 | +| `customer_no` | string | — | 客户号 | +| `product_code` | string | — | 产品代码 | +| `product_name` | string | — | 产品名称 | +| `risk_level` | string | — | `低` / `中` / `高` | +| `rule_code` | string | — | **pattern `^RW-[0-9]{3}$`** | +| `start_time` / `end_time` | datetime | — | **裸 datetime(不带时区),按北京时间解释** | +| `limit` | int | **5** | **最大 5** | +| `cursor` | string | — | 偏移量编码游标(含筛选条件签名) | + +**⚠️ 时间参数口径**:REST 的时间参数是**裸 datetime(不带时区)**,按**北京时间** +解释后再换算成库内 UTC。Agent 路径本来就带时区 +(`risk_natural_language.py:117`)。**两条路径口径必须一致**,否则同一个筛选条件 +在界面与对话里查出不同结果。 + +**⚠️ 有校验器**:空白文本会被拒;`end_time < start_time` 会被拒。 + +**响应**:标准的 `data` 数组 + `meta`(含 `next_cursor` / `has_more` / `total` / `page_size`)。 + +--- + +### `POST /api/v1/risk/alerts/scan`(触发规则扫描) + +| 项 | 内容 | +|---|---| +| **用途** | 手工触发风控规则扫描 | +| **鉴权** | `risk:alert:scan` | +| **幂等** | **是**(`Idempotency-Key`) | +| **成功** | 200 | + +**请求体**:无(`{}`)。 + +**响应** + +```json +{ + "data": { + "message": "规则扫描完成", + "created_count": 3, + "high_risk_count": 1, + "notification_count": 1 + }, + "meta": { "trace_id": "..." } +} +``` + +**通知创建失败时**(不回滚预警,但**必须让调用方看见**): + +```json +{ + "message": "规则扫描完成,但高风险通知创建失败", + "created_count": 3, + "high_risk_count": 1, + "notification_count": 0, + "notification_failure": "错误原因" +} +``` + +**⚠️ 为什么要有 `notification_failure`**:原先这里无处可查 —— 外面只拿到 +`notification_count=0`,分不清"这批预警本来就不用通知"和"高风险通知创建失败了", +而后者意味着**处置链路的第一环断了,扫描却报"完成"**。 + +**错误码**:`RiskScanBusyError`(扫描进行中,503 语义)。 + +--- + +### 预警处置五件套 + +`POST /api/v1/risk/alerts/{alert_no}/{动作}`,**全部幂等**,全部权限 `risk:alert:write`, +全部返回标准信封,`data` 形状统一: + +```json +{ + "alert_no": "...", + "status": "...", + "ack_status": "...", + "alert_level": "...", + "handle_result": "...", + "closed_at": "ISO 时间或 null" +} +``` + +| 端点 | 请求体 | 前置状态要求 | +|---|---|---| +| `POST .../acknowledgements` | 无 | `status == "待处理"`;且**未确认过**(`ack_at is None`) | +| `POST .../investigations` | 无 | **必须已确认**;`status == "待处理"` | +| `POST .../exclusions` | `{"reason": "1–500 字"}` | 必须已确认;未闭环 | +| `POST .../resolutions` | `{"resolution": "1–500 字"}` | 必须已确认;未闭环 | +| `POST .../escalations` | `{"reason": "1–500 字"}` | 必须已确认 | + +**路径参数 `alert_no`**:**min_length 1,max_length 64,pattern `^[A-Za-z0-9_-]+$`**。 + +**状态流转**: + +``` +待处理 --acknowledge--> 待处理(ack_status=已确认) + --investigate--> 调查中 + --exclude--> 已排除 (写入 close_reason / handle_result) + --resolve--> 已解决 (同时算行为分扣减) + --escalate--> 升级 (is_escalated=true) +``` + +**`resolutions` 额外返回行为分变化**: + +```json +{ + "alert_no": "...", "status": "...", ..., + "behavior_score_before": 80, + "behavior_score_deduction": 10, + "behavior_score_after": 70 +} +``` + +**`escalations` 额外返回**:`is_escalated`、`escalated_at`、`escalation_reason`。 + +**错误码**:404(预警不存在 / 不属于 `data_scope`)、409(`RiskActionError`: +状态不允许、重复确认)。 + +--- + +### `POST /api/v1/risk/alerts/{alert_no}/evidence`(上传证据) + +| 项 | 内容 | +|---|---| +| **用途** | 为预警归档证据文件 | +| **鉴权** | `risk:alert:write` | +| **成功** | 200 | +| **内容类型** | **`multipart/form-data`**,字段名 **`evidence_file`** | + +**响应** + +```json +{ + "data": { + "alert_no": "...", + "evidence_archived": true, + "stored_name": "...", + "file_size": 12345 + }, + "meta": { "trace_id": "..." } +} +``` + +**注意事项**:Controller 用 `try/finally` 确保 `evidence_file.close()` 一定执行。 + +--- + +### `GET /api/v1/risk/alerts/{alert_no}`(预警详情) + +**鉴权** `risk:alert:read`;**成功** 200;**404** 预警不存在。 +**响应**:`data` = 预警记录全字段(`_record` 已把 `Decimal` 串化、`datetime` ISO 化)。 + +--- + +### `GET /api/v1/risk/evidence/{source}`(证据分源查询) + +| 项 | 内容 | +|---|---| +| **鉴权** | `risk:alert:read` | +| **成功** | 200(列表信封 + `total` / `page_size`) | + +**路径参数 `source`**(**必须是这 8 个之一**,`RiskEvidenceSource` Literal): + +| 值 | 数据源 | +|---|---| +| `customers` | 客户(支持 `behavior_level` 筛选) | +| `products` | 产品 | +| `transactions` | 交易 | +| `capital_flows` | 资金流 | +| `holdings` | 持仓 | +| `login_records` | 登录记录 | +| `alerts` | 预警(`open_only=False`,含已闭环) | +| `notifications` | 通知(支持 `send_status` 筛选) | + +**⚠️ 这 8 个值必须与前端 `EVIDENCE_COLUMNS` 一致**。 +传其它值 → **404 `RESOURCE_NOT_FOUND`**("证据类型不存在")。 + +**查询参数**:`keyword`、带时间的源支持 `start_time`/`end_time`、 +`customers` 支持 `behavior_level`、`notifications` 支持 `send_status`; +`limit` **最大 10**(默认 10)、`cursor`。 + +--- + +### `GET /api/v1/risk/notifications`(通知列表) + +**鉴权** `risk:alert:read`;`limit` **最大 10**(默认 10);列表信封 + `total`/`page_size`。 + +--- + +### `POST /api/v1/risk/daily-report`(生成日报) + +| 项 | 内容 | +|---|---| +| **鉴权** | `risk:alert:read` | +| **成功** | 200(标准单对象信封) | + +**请求体** + +| 字段 | 类型 | 默认 | +|---|---|---| +| `report_date` | date \| null | 今天 | + +**响应**(`data` 结构) + +```json +{ + "type": "风控日报", + "report_date": "2026-09-14", + "generated_at": "2026-09-14 10:44:20", + "data_truncated": false, + "daily_alert_count": 5, + "level_distribution": { "...": 0 }, + "key_risk_events": [ + { + "alert_id": "...", "alert_level": "高风险", "alert_type": "...", + "triggered_rules": ["RW-001"], + "evidence_summary": "...", "status": "...", "ack_status": "...", + "handler_id": "...", "created_at": "...", "due_time": "...", + "is_overdue": false, "is_escalated": false, "close_reason": null + } + ], + "unresolved_items": { + "total": 7, "new_today": 3, "historical": 4, "overdue": 2, + "items": [ "同 key_risk_events 的结构" ] + }, + "false_positive_statistics": { + "total": 2, + "reasons": [ { "alert_id": "...", "reason": "..." } ] + }, + "type_distribution": { "...": 0 }, + "disposition_results": { + "acknowledged": 3, "false_positive_closed": 2, + "escalated": 1, "investigating": 1 + }, + "rule_effectiveness": { "...": "..." }, + "optimization_suggestions": "", + "source": "", + "prompt_version": "..." +} +``` + +**⚠️ `optimization_suggestions` / `source` 在同步接口里是空串** —— +建议内容由流式接口(下一条)生成并回填。 + +--- + +### `POST /api/v1/risk/daily-report/stream`(流式生成日报) + +| 项 | 内容 | +|---|---| +| **请求头** | `Accept: text/event-stream` **必需** | +| **成功** | 200,`text/event-stream`,`Cache-Control: no-cache`,`X-Accel-Buffering: no` | + +**事件格式**(**注意与 §5 的 `run_id` 事件格式不同**,这里没有 `id:` 行): + +``` +event: +data: + +``` + +| `type` | 说明 | +|---|---| +| `start` | 带 `generated_at` | +| `progress` | 带 `stage`(`statistics` / `suggestions`)与 `message` | +| `replace` | 带 `content`(当前完整文本) | +| `done` | 带 `report`(完整报告对象) | + +**⚠️ 顺序是刻意的**:`service.authorize(context)` → `accepts_event_stream(Accept)` → +才返回 `StreamingResponse`。 + +理由是 `stream()` 是 **async generator**,函数体到第一次迭代才执行, +**而那时响应头已经发出去了** —— 403/406 只能变成"200 + 半截流"(`docs/25` P3 #24)。 +顺序与 §5 的 R003 一致。 + +--- + +### `POST /api/v1/risk/daily-report/mail`(邮件发送日报) + +| 项 | 内容 | +|---|---| +| **鉴权** | **`risk:report:mail`**(校验在 Service 层) | +| **成功** | 200 | + +**请求体**(`RiskDailyReportMailRequest`) + +| 字段 | 类型 | 约束 | +|---|---|---| +| `recipients` | string[] | **1–10 个**;RFC 风格校验(`parseaddr`);**大小写不敏感去重** | +| `subject` | string | 1–128 | +| `content` | string | 1–20000 | + +**响应**(`data`) + +| `status` | 含义 | +|---|---| +| `"disabled"` | 环境变量未开启(带 `recipient_count`) | +| `"dry_run"` | 演练模式(**默认就是 dry_run**) | +| `"configuration_error"` | SMTP 配置错误(带 `recipient_count`) | +| 其它 | 真实发送结果 | + +**⚠️ 这条 Service 的 docstring 值得读**:「这个端点是此前**唯一没有校验的** —— +收件人、标题、正文全由客户端决定,一旦运维开启 SMTP,它就是一个未授权的邮件发送器。」 +**权限校验因此被放在 Service 层**(与风控其它端点一致),而不是 Controller。 + +--- + +## 11. 平台管理(配置 / 审计 / RBAC) + +前缀 **`/api/v1/admin`**,**带 `enforce_rate_limit`**。**两个文件共用该前缀**: +`app/api/controllers/admin.py` 与 `app/api/controllers/rbac.py`。 + +### 11.1 表驱动 CRUD(`register_resource`) + +`app/api/controllers/admin.py` 用工厂函数**动态生成**端点。每个资源自动得到: + +| 方法 | 路径 | 状态码 | 说明 | +|---|---|---|---| +| `POST` | `{prefix}` | **201** | 创建;**响应头设 `ETag`** | +| `GET` | `{prefix}` | 200 | 列表(`limit` 1–100 默认 20 + `cursor`) | +| `GET` | `{prefix}/{id}` | 200 | 详情(**仅在 `detail=True` 时生成**);**设 `ETag`** | +| `PUT` | `{prefix}/{id}` | 200 | 更新(**仅在 `update=True` 时生成**);**要求 `If-Match`** | + +`prefix` 对 `scoped=True` 的资源是 +`/config-releases/{release_id}/{resource}`,否则是 `/{resource}`。 + +**已注册的 8 个资源**: + +| 资源 | Schema | 路径参数 | 选项 | +|---|---|---|---| +| `config-releases` | `ReleasePayload` | `release_id` | `update=False` | +| `platform-config-items` | `ItemPayload` | `item_id` | `scoped=True, **detail=True**` | +| `model-endpoints` | `EndpointPayload` | `endpoint_id` | — | +| `model-routing-rules` | `RoutingPayload` | `rule_id` | `scoped=True, **detail=True**` | +| `prompt-templates` | `PromptPayload` | `prompt_id` | `update=False` | +| `agent-intent-configs` | `IntentPayload` | `config_id` | — | +| `reply-templates` | `ReplyPayload` | `template_id` | **`detail=False`** | +| `negative-word-rules` | `NegativePayload` | `rule_id` | **`detail=False`** | + +**⚠️ `platform-config-items` 与 `model-routing-rules` 必须是 `detail=True`** —— +理由见 §1.7。这是**修过的真 bug**。 + +**权限映射**(`admin_service.py`): + +| 操作 | 权限 | +|---|---| +| `query`(除 audit-records) | `config:read` | +| `query` on `audit-records` | `audit:read` | +| `mutate`(默认) | `config:write` | +| `mutate` on `model-endpoints` | `model-endpoint:manage` | +| `mutate` with `action="reviews"` | `config:review` | +| `mutate` with `activations` / `rollbacks` **且资源是 `config-releases`** | `config:activate` | + +全部带 `admin=True` 参数。 + +### 11.2 状态流转(`register_transition`) + +生成 `POST /{resource}/{id}/{action}`,**状态码 201(仅 `rollbacks`)否则 200**, +**设 `ETag`**,**要求 `If-Match`**,**幂等**。 + +| 资源 | 动作 | +|---|---| +| `config-releases` | `validations`、`reviews`、`activations`、`rollbacks` | +| `model-endpoints` | `reviews`、`activations`、`disablements` | +| `agent-intent-configs` | `reviews`、`activations`、**`archivals`** | + +**请求体**:`action == "reviews"` 时用 `ReviewPayload`(`decision` + `comment`), +其余用 `EmptyPayload`。 + +**⚠️ `agent-intent-configs` 用 `archivals` 而不是 `disablements`**: +该表 CHECK 约束只允许 `draft/approved/active/archived`,**没有 `disabled`**。 + +**⚠️ 只能编辑草稿或停用版本**(`_write`): +`existing.status` 不在 `{draft, disabled}` 中 → **409 `INVALID_STATE`**。 +带 `release_id` 时,父批次状态必须是 `draft`,否则同样 409。 + +**⚠️ 数据库完整性冲突被翻译成 409**:`IntegrityError` → `RESOURCE_ALREADY_EXISTS` +("资源唯一性、引用或状态约束冲突"),不是 500。 + +### 11.3 关键 Payload 约束 + +**`ItemPayload`(平台配置项)** + +| 字段 | 约束 | +|---|---| +| `namespace` | **必须是** `agent_tools` / `memory` / `relationship` / `runtime` / **`fund_market`** | +| `item_key` | 1–128 | +| `value_json` | dict | +| `schema_version` | 字面量 `"1"` | + +**⚠️ `fund_market` 是必需的命名空间** —— 否则行情配置会 422。 +**每个命名空间有白名单字段**(`_write` 里硬编码): + +| namespace | 允许的 `value_json` 键 | +|---|---| +| `memory` | `recall_limit`、`decay_days` | +| `relationship` | `max_hops`、`limit` | +| `runtime` | `timeout_seconds` | +| `agent_tools` | `allowed_tools` | +| `fund_market` | `FUND_MARKET_FIELDS` | + +含未声明字段 → 422 `AGENT_INPUT_INVALID`("配置包含未声明字段")。 + +**`agent_tools` 额外校验**:`allowed_tools` 必须是**字符串列表**; +`item_key` 格式为 `agent_type:intent`; +**intent 必须在该 Agent 声明的 `supported_intents` 内**; +**工具集必须是该 Agent `allowed_tools` 的子集**。 + +**`RoutingPayload`(模型路由)** + +| 字段 | 约束 | +|---|---| +| `max_attempts` | **1–3,默认 2** | +| `fallbacks` | **最多 2 个** | +| `latency_budget_ms` | 100–120000 | + +**⚠️ `max_attempts` 不得超过端点数量**,否则 422。 + +**`EndpointPayload`(模型端点)** + +| 字段 | 约束 | +|---|---| +| `endpoint_code` / `provider` / `model_name` | 必填 | +| `base_url` | **`HttpUrl`** | +| `secret_ref` | **pattern `^env:[A-Z][A-Z0-9_]{0,100}$`** —— 只允许环境变量引用 | +| `capabilities` / `allowed_data_levels` | 列表 | +| `context_window` | **> 0** | +| `timeout_ms` | **100–120000,默认 15000** | + +**⚠️ `secret_ref` 只接受 `env:XXX`** —— 密钥不落库。 + +**`ReplyPayload`(话术模板)** + +**⚠️ `scene` 必须与数据库 CHECK 约束 `chk_template_scene` 完全一致**: + +``` +disclaimer | low_confidence | compliance_block | transfer | +model_failure | system_busy | clarification +``` + +**不一致时会以 500 暴露,而不是 422** —— 这是历史踩过的坑。 + +**`IntentPayload`**:`confidence_threshold` 是**字符串形式的数字**(用 pattern 校验), +`max_clarification_rounds` 范围 **0–10**。 + +**`NegativePayload`**:`match_type ∈ {contains, exact}`;`severity ∈ {block, replace, warn}`。 + +**`ReviewPayload`**:`decision ∈ {approved, rejected}`;`comment` 默认 `""`,max 1000。 + +### 11.4 A030–A033、A039–A044 具体端点 + +| 编号 | 端点 | 用途 | 权限 | +|---|---|---|---| +| **A033** | `GET /api/v1/admin/audit-records` | 审计查询(`limit` 1–100 默认 20 + `cursor`) | `audit:read`(无 `audit:read-sensitive` 则 `detail` 被涂成 `{"redacted": true}`) | +| — | `GET /api/v1/admin/customer-service/handover-tickets` | 客服待转人工队列(只读) | 管理员 | +| — | `GET .../handover-tickets/{ticket_no}` | 单工单脱敏摘要 | 管理员 | +| **A039** | `GET /api/v1/admin/customer-profile-candidates` | 待处理画像候选(`limit` 默认 20) | `memory:candidate:review`(`admin=True`) | +| **A040** | `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews` | 批准/驳回候选 | 同上;**成功 200** | +| **A041** | `POST /api/v1/admin/advisor/asset-allocation-backtests` | 资产配置回测 | `asset-allocation:backtest`;**201**;幂等 | +| **A042** | `GET /api/v1/admin/advisor/profile-tags?customer_id=N` | 客户画像标签 | `profile-governance:read`;`customer_id` **gt=0** | +| **A043** | `GET /api/v1/admin/advisor/profile-drift-reviews` | 待处理画像漂移复核 | `profile-governance:read` | +| **A044** | `POST /api/v1/admin/advisor/profile-drift-reviews/{review_id}/reviews` | 复核漂移 | `profile-governance:review`;`review_id` gt=0;幂等 | + +**A039/A040 的候选字段**(同 §6 的 `_view`):`candidate_id`、`customer_id`(字符串)、 +`memory_key`、`value`、`memory_type`、`confidence`、`status`、`version`、时间戳。 +**同样不返回证据原文**。 + +**A040 的排序不同**:`list_for_admin` 按 `updated_at` **升序**(先处理最旧的), +而 M003 的 `list_for_customer` 是**降序**。 + +**A040 批准时会处理同键旧正式记忆**(`conflict_type="candidate_promoted"`)。 + +### 11.5 RBAC 只读查询(A035–A038) + +`app/api/controllers/rbac.py`,前缀 `/api/v1/admin`。**四个端点全部只读,且不写审计。** + +| 编号 | 端点 | 用途 | +|---|---|---| +| **A035** | `GET /api/v1/admin/roles` | 角色列表 | +| **A036** | `GET /api/v1/admin/roles/{role_code}` | 角色详情 | +| **A037** | `GET /api/v1/admin/roles/{role_code}/permissions` | 角色的权限码 | +| **A038** | `GET /api/v1/admin/users/{user_id}/roles` | 用户的角色与权限 | + +**权限**:复用 **`audit:read`**,**没有新增 `rbac:read`**(`docs/05` §19 的明确说明)。 + +**A038 的响应** + +```json +{ + "data": { + "username": "...", + "user_no": "...", + "status": "...", + "data_scope": "...", + "roles": [...], + "permissions": [...] + }, + "meta": { "trace_id": "..." } +} +``` + +**⚠️ A038 的 `user_id` 路径参数 pattern 是 `^[0-9]{1,20}$`**(字符串形式的数字), +与 A039 等处用 `int` + `gt=0` 的风格不同。前端传数字字符串。 + +**⚠️ 权限的「修改」目前没有端点** —— 这是刻意的(`docs/05` §19 说明"需要三条红线")。 + +--- + +## 12. 投顾(目标书 / 组合分析 / 资产配置 / 推荐) + +前缀 `/api/v1/advisor`,**带 `enforce_rate_limit` + `enforce_advisor_rollout`**。 + +**⚠️ `enforce_advisor_rollout` 是投顾域特有的额外闸门**: +在鉴权之后、业务逻辑之前拦截。 + +### 12.1 投资目标书(AD001–AD007) + +| 编号 | 端点 | 权限 | 成功 | 幂等 | +|---|---|---|---|---| +| **AD001** | `POST /api/v1/advisor/investment-goals` | `investment-goal:write` | **201** | 是 | +| **AD002** | `GET /api/v1/advisor/investment-goals/current` | `investment-goal:read:self` | 200 | — | +| **AD003** | `GET /api/v1/advisor/customers/{customer_id}/investment-goals/current` | `investment-goal:read` | 200 | — | +| **AD004** | `POST /api/v1/advisor/investment-goals/{goal_no}/confirmations` | `investment-goal:confirm` | 200 | 是 | +| **AD005** | `GET /api/v1/advisor/investment-goals/{goal_no}/goal-book` | `investment-goal:read` | 200 | — | +| **AD006** | `POST .../{goal_no}/goal-book/reviews` | **`admin`** | 200 | 是 | +| **AD007** | `POST .../{goal_no}/goal-book/publications` | **`admin`** | 200 | 是 | + +**⚠️ AD006/AD007 要求 `admin`,尽管它们在 `/advisor/**` 下**(`docs/05` §19 特别标注)。 + +**AD001 请求体**(`InvestmentGoalCreate`) + +| 字段 | 类型 | 约束 | +|---|---|---| +| `customer_id` | int \| null | **gt=0**;为空表示自己 | +| `annualized_return_lower_pct` | Decimal | 0–100,**max_digits 7,小数 4 位** | +| `annualized_return_upper_pct` | Decimal | 同上;**必须 ≥ lower** | +| `max_drawdown_pct` | Decimal | 0–100 | +| `liquidity_requirement` | string | 枚举(`daily` 等) | +| `investment_horizon_months` | int | **1–600** | +| `benchmark_name` | string | 1–128 | +| `notes` | string | max 1000 | + +**⚠️ 有控制字符拒绝校验**。 + +**AD001 响应**(`_view`) + +```json +{ + "data": { + "goal_no": "...", "customer_id": "9001", "status": "...", + "goal_gap_status": "awaiting_confirmation | none", + "annualized_return_lower_pct": "5.0000", + "annualized_return_upper_pct": "8.0000", + "max_drawdown_pct": "15.0000", + "liquidity_requirement": "daily", + "investment_horizon_months": 36, + "benchmark_name": "...", "notes": "...", "source": "...", + "goal_book": { "content_id": "...", "review_status": "...", "published_at": null }, + "confirmed_at": null, "created_at": "...", "updated_at": "..." + }, + "meta": { "trace_id": "..." } +} +``` + +**⚠️ `customer_id` 是字符串**;**所有百分比是字符串化的 Decimal**。 + +**AD004 请求体**:`{"confirmed": true}` —— **必须是字面量 `true`**。 + +**AD005 响应**(与其他 AD 端点**形状不同**,注意) + +```json +{ + "data": { + "goal_no": "...", + "goal_status": "...", + "review_status": "...", + "content": { "...": "目标书草稿内容(structured sections)" }, + "published_at": null + }, + "meta": { "trace_id": "..." } +} +``` + +目标书 `content` 的结构(`_build_draft`): + +```json +{ + "document_type": "investment_goal_book", + "document_version": "1.0", + "goal_no": "...", + "sections": { + "investment_objective": { + "annualized_return_expectation_pct": { "lower": "5.0000", "upper": "8.0000" }, + "benchmark_name": "..." + }, + "risk_boundary": { "maximum_drawdown_pct": "15.0000" }, + "liquidity": { "requirement": "daily", "description": "可随时使用" }, + "investment_horizon": { "months": 36 }, + "notes": "..." + }, + "disclosures": [ + "本目标书为待审核草稿,仅作为后续场内基金模拟交易分析的输入,不构成交易指令。" + ] +} +``` + +**AD006 请求体**(`InvestmentGoalBookReview`):`decision ∈ {approved, rejected}`, +`comment` max 1000。 + +**AD007 请求体**(`InvestmentGoalBookPublish`):`{"publish": true}`。 + +**状态机** + +``` +AD001 → pending_confirmation +AD004 → confirmed +AD006 → goal_book.review_status: approved / rejected +AD007 → published_at 有值 +``` + +**⚠️ 已知文档缺口**:流转失败返回 **409**,但**复用了字面量 `RUN_NOT_CANCELLABLE`** +(`docs/05` §19 自述这是"已知文档缺口")。语义上应是 `INVALID_STATE`。 + +### 12.2 组合分析(AD008) + +| 项 | 内容 | +|---|---| +| **端点** | `POST /api/v1/advisor/portfolio-analysis` | +| **权限** | `portfolio-analysis:read:self` | +| **额外闸门** | `enforce_profile_governance=True` | +| **幂等** | **否**(纯分析) | +| **成功** | 200 | + +**响应 `status` 三态**: + +| `status` | 含义 | 附带字段 | +|---|---|---| +| `no_positions` | 无持仓 | `position_count: 0` | +| `valuation_required` | 持仓缺可用市值,暂不能算集中度 | `position_count`、`warnings` | +| `ready` | 正常 | `summary`、`product_concentration`、`industry_concentration`、`warnings`、`disclaimer` | + +**`ready` 时的完整结构** + +```json +{ + "status": "ready", + "summary": { + "position_count": 5, "valued_position_count": 4, + "total_market_value": "123456.78", + "industry_coverage_pct": "80.00", + "metrics_coverage_pct": "75.00" + }, + "product_concentration": { + "hhi": 0.32, + "rows": [ { "product_id": "...", "product_code": "...", "product_name": "...", + "market_value": "...", "share_pct": "35.00" } ] + }, + "industry_concentration": { + "hhi": 0.28, "rows": [ { "industry_name": "...", "market_value": "...", + "share_pct": "..." } ], + "conclusion_available": true + }, + "warnings": [ { "code": "...", "severity": "high|medium", "message": "..." } ], + "disclaimer": "分析结果仅供参考,不生成交易指令。" +} +``` + +**`warnings` 的 7 种 `code`**(前端可按码上图标): + +| code | severity | 含义 | +|---|---|---| +| `MARKET_VALUE_UNAVAILABLE` | high | 持仓缺可用市值,暂不能算集中度 | +| `SINGLE_PRODUCT_CONCENTRATION` | high | 单一产品占比高 | +| `SINGLE_INDUSTRY_CONCENTRATION` | high | 单一行业穿透占比高 | +| `INDUSTRY_COVERAGE_INCOMPLETE` | medium | 行业穿透数据覆盖不足,**不输出确定性行业结论** | +| `INDUSTRY_EXPOSURE_INVALID` | medium | 部分产品行业暴露数据异常,未纳入计算 | +| `HISTORICAL_METRICS_INCOMPLETE` | medium | 部分持仓缺历史指标 | +| `MARKET_VALUE_PARTIAL` | medium | 部分持仓缺市值,本次基于已估值持仓计算 | + +**⚠️ 行业穿透依赖 Neo4j**。图不可用时 `_graph_context` 返回 +`{"degraded": true, "reason": "..."}`,**不抛异常**,行业结论降级为不可用。 +`reason` 可能是 `neo4j_unavailable:XXXError` / `neo4j_invalid_result` / `graph_not_configured`。 + +**⚠️ `disclaimer` 恒定**:`"分析结果仅供参考,不生成交易指令。"` + +### 12.3 资产配置(AD009) + +| 项 | 内容 | +|---|---| +| **端点** | `POST /api/v1/advisor/asset-allocation` | +| **权限** | `asset-allocation:generate:self` | +| **额外闸门** | `enforce_profile_governance=True` | +| **幂等** | **否** | +| **成功** | 200 | + +**响应 `status` 四态** + +| `status` | 含义 | +|---|---| +| `profile_required` | 缺风险测评 | +| `investment_goal_required` | 缺投资目标书 | +| `investment_goal_invalid` | 目标书无效 | +| `ready` | 正常 | + +**`ready` 时** + +```json +{ + "status": "ready", + "allocation": [ + { "asset_class": "cash_management_etf", "label": "现金管理类场内基金", "target_pct": 15 }, + { "asset_class": "bond_etf", "label": "债券类场内基金", "target_pct": 45 }, + { "asset_class": "equity_etf", "label": "权益类场内基金", "target_pct": 40 } + ], + "optimization": { + "method": "constrained_historical_multi_factor_v1", + "dynamic": true, + "metric_coverage_pct": "75.00", + "strategic_allocation": { "...": "..." }, + "factor_evidence": { "...": "..." } + }, + "constraints": { + "annualized_return_lower_pct": "...", + "max_drawdown_pct": "...", + "liquidity_requirement": "...", + "investment_horizon_months": 36 + }, + "analysis_only": true +} +``` + +**⚠️ 战略基准配置按风险等级 C1–C5 硬编码**(`asset_allocation_service.py:26-30`): + +| 等级 | 现金管理 | 债券 | 权益 | +|---|---|---|---| +| C1 | 50% | 40% | 10% | +| C2 | 30% | 50% | 20% | +| C3 | 15% | 45% | 40% | +| C4 | 10% | 25% | 65% | +| C5 | 5% | 15% | 80% | + +**⚠️ `analysis_only: true` 恒定** —— 不生成交易指令。 + +### 12.4 产品推荐(AD010–AD011、A045–A047) + +**投顾侧** + +| 编号 | 端点 | 权限 | 成功 | 备注 | +|---|---|---|---|---| +| **AD010** | `POST /api/v1/advisor/recommendations` | `product-recommendation:generate:self` | 201 | `enforce_profile_governance=True`;幂等 | +| **AD011** | `GET /api/v1/advisor/recommendations/published` | `product-recommendation:read:self` | 200 | — | + +**管理侧** + +| 编号 | 端点 | 权限 | 成功 | +|---|---|---|---| +| **A047** | `GET /api/v1/admin/advisor/pending-contents` | `product-recommendation:review` | 200 | +| **A045** | `POST /api/v1/admin/advisor/recommendations/{content_id}/reviews` | `product-recommendation:review` | 200 | +| **A046** | `POST /api/v1/admin/advisor/recommendations/{content_id}/publications` | `product-recommendation:publish` | 200 | + +**AD010 响应 `status` 四态**:`profile_required` / `investment_goal_required` / +`recommendation_input_invalid` / `ready`。`ready` 时: + +```json +{ + "data": { + "content_id": "...", + "status": "pending_review", + "plan": { + "status": "ready", + "document_type": "advisor_recommendation_plan", + "document_version": "1.0", + "products": [ + { + "rank": 1, "product_code": "...", "product_name": "...", + "product_category": "...", "reason": "...", "score": 0.8231, + "recommendation_evidence_card": { + "card_version": "1.0", + "hard_constraints": [ "..." ], + "suitability": { "risk_level": "...", "source_url": "...", "document_title": "..." }, + "contract": { "fund_type": "...", "source_url": "...", "document_title": "..." }, + "liquidity": { "status": "...", "average_daily_turnover_amount": "..." }, + "goal_constraints": { "liquidity_requirement": "...", "investment_horizon_months": 36 }, + "graph_status": "available | degraded" + } + } + ], + "excluded_candidates": [ + { "product_code": "...", "product_name": "...", "stage": "ranking", + "reason_code": "RANKED_BELOW_SELECTION_LIMIT", "reason": "...", + "ranking_score": 0.4512, "selection_limit": 5 } + ], + "selection_summary": { + "candidate_count": 12, "selected_count": 5, "excluded_count": 7 + }, + "graph_context": { "...": "..." }, + "disclosures": [ "..." ], + "analysis_only": true + } + }, + "meta": { "trace_id": "..." } +} +``` + +**⚠️ 产品必须通过三重校验**才会入选:**场内可交易 + 权威适当性 + 合同证据** +(`reason` 字段明写)。 + +**A045 的入参校验在 Controller 手工做**:`decision` 必须是 `approved`/`rejected` +(否则 `ValidationAgentError` → 422),`comment` 必须是 `str`。 + +**A045 响应**:`{"content_id": "...", "status": "approved|rejected"}`。 + +--- + +## 13. 场外基金运营 + +**⚠️ 本域用「旧式信封」`{code, message, data}`,不是统一信封。** +`app/api/controllers/offsite_fund.py` 直接用 `OffsiteFundService` 的返回值, +**没有经过 `envelope()`**。前端需按 `code === 0` 判断成功。 + +**⚠️ 本域的权限检查风格与其它域不同**:用 `_permission_error(context, (...))` 的 +**any-of 语义(交集非空即通过)**,而**不是** `AuthorizationService.require`。 +观察到四种权限组合: + +| 组合 | 用于 | +|---|---| +| `("offsite:read", "offsite:write")` | 读操作 | +| `("offsite:write",)` | 写操作 | +| `("offsite:write", "offsite:confirm")` | 确认操作 | +| `("offsite:notify", "offsite:write")` | 通知操作 | + +### 13.1 两个 Router + +| Router | 前缀 | 说明 | +|---|---|---| +| `router` | **`/api/v1/offsite-fund`** | 主业务 | +| `operation_router` | **`/api`** | 仅一个端点:Agent 触发 | + +### 13.2 端点清单 + +| 端点 | 方法 | 权限组合 | 说明 | +|---|---|---|---| +| `/api/v1/offsite-fund/mails` | GET | read/write | 邮件列表 | +| `/api/v1/offsite-fund/mails/{mail_id}` | GET | read/write | 邮件详情 | +| `/api/v1/offsite-fund/mails/{mail_id}/deletions` | POST | write | 删除邮件 | +| `/api/v1/offsite-fund/mails/{mail_id}/recognition-fields` | GET / PUT | read/write、write | 识别字段读取/纠正 | +| `/api/v1/offsite-fund/documents/{task_id}/nl2sql-fields` | GET / PUT | read/write、write | NL2SQL 字段读取/纠正 | +| `/api/v1/offsite-fund/documents/{task_id}/rule-results` | GET | read/write | 规则结果 | +| `/api/v1/offsite-fund/documents/{task_id}/rule-results/recalculations` | POST | write | 重算规则 | +| `/api/v1/offsite-fund/mailbox-status` | GET | read/write | 邮箱状态 | +| `/api/v1/offsite-fund/mailbox-status/recoveries` | POST | write | 恢复邮箱 | +| `/api/v1/offsite-fund/attachments/{attachment_id}/file` | GET | read/write | **下载附件(FileResponse)** | +| `/api/v1/offsite-fund/documents/{task_id}/confirmations` | POST | write+confirm | 确认单据 | +| `/api/v1/offsite-fund/documents/{task_id}/recognition-retries` | POST | write | 重试识别 | +| `/api/v1/offsite-fund/documents/{task_id}/notifications` | POST | notify/write | 创建通知 | +| `/api/v1/offsite-fund/notifications/{notification_id}/send` | POST | notify/write | 发送通知 | +| `/api/v1/offsite-fund/settlement-statistics/recalculate` | POST | write | 重算结算统计 | +| **`/api/tasks/{task_id}/trigger-agent-nl2sql`** | POST | write | **触发 Agent NL2SQL** | + +### 13.3 邮件列表参数与响应 + +**查询参数** + +| 参数 | 类型 | 默认 | 约束 | +|---|---|---|---| +| `page` | int | `1` | **≥ 1** | +| `page_size` | int | `20` | **1–100** | +| `sender` | string | — | **3–255** | +| `status` | string | — | 1–32 | + +**响应** + +```json +{ + "code": 0, + "message": "ok", + "data": { + "items": [ "..." ], + "page": 1, + "page_size": 20, + "total": 57 + } +} +``` + +**⚠️ 这是分页式(`page`/`page_size`),不是游标式。** + +### 13.4 邮件详情与识别字段 + +`GET /mails/{mail_id}` 返回 `{"code": 0, "message": "ok", "data": detail}` +(调用了 `_add_audit(context, "offsite.mail_viewed", ...)`);不存在 → `{"code": 404, "message": "邮件不存在", "data": {}}`。 + +**`recognition-fields` 的每个 attachment 字段**: + +| 字段 | 含义 | +|---|---| +| `attachment_id` / `filename` / `document_type` / `media_type` / `size_bytes` / `status` | 基础信息 | +| `ocr_text` | OCR 原文 | +| `extracted_fields` | LLM 抽取字段 | +| `effective_fields` | 应用纠正后的有效字段(`normalize_recognition_fields`) | +| `corrections` | 纠正记录 | +| `has_correction` | 是否有纠正 | +| `default_state` | 默认状态 | +| `field_confidence` | 字段级置信度 | +| `page_evidence` | 页码证据 | +| `missing_fields` | 缺失字段 | +| `low_confidence_fields` | 低置信字段 | +| `ocr_status` / `llm_status` | 各阶段状态 | +| `latest_attempt` | 最近一次尝试(`attempt_no` / `source` / `status` / `started_at` / `finished_at` / `error_message`) | + +**纠正记录结构**(`_correction_payload`):`operator_id`、`fields`、`changed_fields`、 +`corrected_at`。 + +### 13.5 附件下载(⚠️ 特殊) + +``` +GET /api/v1/offsite-fund/attachments/{attachment_id}/file?disposition=inline|attachment +``` + +| 参数 | 约束 | +|---|---| +| `disposition` | **pattern `^(inline\|attachment)$`** | + +- 返回 **`FileResponse`**(二进制流),**不是 JSON**。 +- 响应头带 **`X-Content-Type-Options: nosniff`**。 +- 若 Service 返回的是 dict(如错误),则**原样返回该 dict** —— + 所以该端点可能同时返回文件或 JSON,调用方需按 `Content-Type` 分支。 + +### 13.6 业务含义速查 + +**认购/赎回的份额口径**(`offsite_fund_service.py:107-108`): + +| 操作 | 需要的字段 | +|---|---| +| `subscription`(认购) | 最新净值、基金最新总份额、申请前持有份额 | +| `redemption`(赎回) | 基金最新总份额、当前最新可用份额 | + +### 13.7 请求体要点 + +| Schema | 关键字段 | +|---|---| +| `OffsiteConfirmRequest` | `decision`、`operator_id`(1–64) | +| `OffsiteRecalculateRequest` | `fund_code`(≤32,可选)、`application_date`(8–32) | +| `OffsiteNotificationRequest` | `notification_type`、`operator_id` | +| `OffsiteNotificationSendRequest` | `operator_id`、`operator_confirmed`(bool)、`final_content`(≤10000,可选) | +| `OffsiteTriggerNl2SqlRequest` | `operator_id`、`manual_confirmed`(bool) | +| `OffsiteRecognitionCorrectionRequest` | `operator_id`、`attachments`(**min_length=1**) | +| `OffsiteRecognitionCorrectionItem` | `attachment_id`(1–24)、`fields`(`dict[str,str]`) | +| `OffsiteNl2SqlCorrectionRequest` | `operator_id`、`fields`(`dict[str,str]`) | + +**⚠️ 每个写操作都要 `operator_id`** —— 场外是运营流程,操作人必须显式声明。 + +**⚠️ 删除是幂等的**:`delete_mail` 对已删除邮件返回 +`{"code": 0, "message": "邮件已删除", "data": {"mail_id": ..., "status": "deleted"}}` —— +**不报错**。 + +**⚠️ 场外独立于场内**:`AGENTS.md` 规则 8 —— 场外基金运营流程独立, +**不得写入场内交易表**。 + +--- + +## 14. 基金推介材料 + +前缀 **`/api/v1/fund-promotion-materials`**。同样用**旧式信封** +`{"code": 0, "message": "ok", "data": {...}}`。 + +### 端点清单 + +| # | 端点 | 权限 | 成功 | 幂等 | +|---|---|---|---|---| +| X001 | `POST /api/v1/fund-promotion-materials` | `promotion:write` | 201 | 是 | +| X002 | `PUT /api/v1/fund-promotion-materials/{task_no}/inputs` | `promotion:write` | 200 | 是 | +| X003 | `POST /api/v1/fund-promotion-materials/{task_no}/attachments` | `promotion:write` | 200 | 是 | +| X004 | `POST /api/v1/fund-promotion-materials/{task_no}/generations` | `promotion:write` | 200 | 是 | +| X005 | `GET /api/v1/fund-promotion-materials/{task_no}/compliance-checks` | `promotion:read` | 200 | — | +| X006 | `GET /api/v1/fund-promotion-materials/{task_no}` | `promotion:read` | 200 | — | +| X007 | `POST /api/v1/fund-promotion-materials/{task_no}/reviews` | `promotion:review` | 200 | 是 | +| X008 | `POST /api/v1/fund-promotion-materials/{task_no}/deliveries` | `promotion:deliver` | 200 | 是 | + +### X001 请求体(`PromotionTaskCreate`) + +| 字段 | 类型 | 约束 | +|---|---|---| +| `product_name` | string | **1–128** | +| `product_code` | string | ≤ 32,可选 | +| `material_title` | string | **1–200** | +| `style_code` | string | 样式码 | +| `output_formats` | string[] | **1–2 个**,默认 `["pptx"]` | + +**响应**:`{"code": 0, "message": "ok", "data": {"task_no": "...", "status": "draft"}}`。 + +### X002 请求体(`PromotionInputsUpdate`) + +7 个结构化资料组 + 备注: + +`product_info`(`ProductInfo`)、`manager_info`(`ManagerInfo`)、`team_info`(`TeamInfo`)、 +`strategy_info`(`StrategyInfo`)、`fee_structure`(`FeeStructure`)、 +`ranking_info`(`RankingInfo`)、`performance_info`(`PerformanceInfo`)、 +`risk_disclosure`(`RiskDisclosure`)、`source_notes`。 + +**响应**:`{"task_no": ..., "input_version": N, "status": ...}`。 + +### X003 附件上传(⚠️ multipart) + +- **`multipart/form-data`**,文件字段 **`file`**,另有 **`attachment_type` 查询参数**。 +- **按类型限制大小与扩展名**(`promotion_material_service.py:713-716`): + +| `attachment_type` | 大小上限 | 允许扩展名 | +|---|---|---| +| `manager_photo` | `max_photo_size` | `.jpg` `.jpeg` `.png` `.webp` | +| `performance_data` | `max_performance_size` | `.csv` `.xlsx` `.xlsm` | +| `source_evidence` | **30 MB** | `.pdf` `.docx` `.xlsx` `.csv` | +| `template_file` | **50 MB** | `.pptx` | + +**响应**:`{"attachment_id", "attachment_type", "sha256", "size_bytes"}`。 +**重复上传同一文件 → 返回已有 id 并带 `"duplicate": true`**(按 sha256 去重)。 + +### X004 生成(`PromotionGenerationRequest`) + +| 字段 | 类型 | 约束 | +|---|---|---| +| `output_formats` | string[] | **1–2 个**,可选 | + +**四种返回 `code`**(**注意这不是 HTTP 状态码,是 body 里的 `code`**): + +| `code` | `message` | 含义 | +|---|---|---| +| `0` | `ok` | 成功 | +| **`422`** | `请先补充结构化资料` | 未先调 X002 | +| **`422`** | `输入资料未通过合规校验` | 带 `findings` | +| **`422`** | `生成内容未通过合规校验` | 带 `findings` | +| **`503`** | 异常消息 | 生成服务不可用 | + +**成功时 `data`**:`task_no`、`material_version_id`、`status`、`pptx_path`、`pdf_path`、 +`poster_path`、`chart_paths`、`findings`。 + +**⚠️ 合规校验双重**:输入资料校验 + 生成内容校验。**阻断级问题直接拦截生成**。 + +### X005 合规检查 + +`data` = `{"task_no": ..., "findings": [...]}`。 + +**finding 字段**:`id`、`material_version_id`、`rule_code`、`rule_name`、`scope`、 +`severity`、`hit_text`、`suggestion`。 + +### X006 任务详情 + +`data`:`task_no`、`product_name`、`status`,以及 `material_version` +(`null` 或 `{id, version_no, style_code, status, pptx_path, pdf_path, poster_path, chart_paths}`)。 + +**⚠️ `material_version` 可能是 `null`**(尚未生成)。 + +### X007 审核(`PromotionReviewRequest`) + +| 字段 | 约束 | +|---|---| +| `material_version_id` | **gt=0** | +| `decision` | **pattern `^(approved\|rejected\|revision_requested)$`** | +| `comment` | ≤ 1000 | + +**⚠️ 三重前置校验**: + +1. `version.status` **必须是 `pending_review`**,否则 422「当前材料版本不在待审核状态」; +2. **存在阻断级合规问题 → 422「存在阻断级合规问题」**; +3. 否则可审。 + +**响应**:`{"task_no", "material_version_id", "status"}`。 + +### X008 发送(`PromotionDeliveryRequest`) + +| 字段 | 约束 | +|---|---| +| `material_version_id` | **gt=0** | +| `advisor_ids` | **1–100 个** | +| `delivery_channel` | 默认 **`"internal_record"`** | + +**⚠️ 两条前置校验**: + +1. **`version.status` 必须是 `approved`**,否则 422「只有审核通过的材料可以发送」; +2. **`delivery_channel` 必须是 `internal_record`**,否则 + 422「**首版只支持内部发送记录,未接入外部投顾端**」。 + +**响应**:`{"task_no", "material_version_id", "advisor_ids", "status": "sent"}`。 + +--- + +## 15. 运维与健康检查 + +前缀 **`/internal`**,**全部无需鉴权**(供探针/监控调用)。 + +### `GET /internal/health/live`(存活探针) + +**响应** 200: + +```json +{ "status": "ok" } +``` + +**⚠️ 这是裸体响应,不带信封**。 + +### `GET /internal/health/ready`(就绪探针) + +**响应**: + +```json +{ + "status": "ready", + "checks": { "mysql": true, "redis": true, "milvus": true }, + "probes": { "milvus": "ready" } +} +``` + +**状态码**:`status == "ready"` → **200**;否则 → **503**。 + +**`status` 取值**:`ready`(全部 `checks` 为 true)| `degraded`(任一为 false)。 + +**⚠️ 三项探测都是真实探测**(`health_service.py` 的 docstring 记录了 B3 修复): + +- **原先 `checks["milvus"] = True` 是硬编码**,健康检查永远报告 Milvus 正常, + 属于**误导性探针**。现在改为真实探测(连接 + `get_server_version`)。 +- 探测**有超时**(默认 **2 秒**),超时返回 `False` + `timeout` 状态。 +- **外部依赖不可用时不抛异常**,只返回 `False` + 明确状态,整体降级为 `degraded`。 +- **`pymilvus` 是可选依赖**(且无类型存根),缺失时返回 `False` + **`client_missing`**。 + +**`probes.milvus` 可能的取值**:`ready` / `timeout` / `client_missing` / +`unavailable` 等。 + +**`probes.mysql`**:连接失败时是 `"unavailable"`。 + +### `GET /internal/metrics`(Prometheus 指标) + +**响应** 200,**`text/plain`**: + +``` +# TYPE jr_agent_up gauge +jr_agent_up 1 +``` + +**⚠️ 这是占位实现**,只暴露一个 `jr_agent_up` gauge。 + +--- + +## 16. 前端约定与静态度量 + +虽不属于后端接口,但影响调用方式,列在这里备查。 + +**四套页面**(`app/static/portal/`,由 `app/main.py` 挂载): + +| 目录 | 角色 | +|---|---| +| `guest/` | 访客(公开产品页暂时用 `common/mock-data.js`,并在页面上标注) | +| `customer/` | 客户 | +| `employee-console/` | 管理员 | +| `employee-risk/` | 风控 | + +**⚠️ 端点表集中在 `common/api-client.js`** —— 改接口调用请改那一份。 + +**访问路径**: + +- `/portal/` 和 `/`(`/` 会 **307** 跳到 `/portal/guest/home/`) +- `/static/**`(静态资源) +- `/customer-service-test/**`(客服测试页) + +**⚠️ 启动命令**:`python -m uvicorn app.main:app --port 8000` +(**注意模块级变量是 `app`,不是 `application`**)。 + +**⚠️ `tools/portal.py`(端口 8101)只是跨角色联调工具,不是产品前端**, +不要再往它加功能。 + +### 已知前端 P0(本次梳理发现,供参考) + +`app/static/portal/employee-console/workspace/workspace.js` 存在**真实语法错误**: +`submitRule` 函数(约 L500–539)**缺一个闭合的 `}`**。 + +- **`node --check` 会给出假通过(exit 0)**; +- 只有真实 ESM 解析(`import()` / `vm.SourceTextModule`)才暴露 + `SyntaxError: Unexpected end of input`; +- 已用 `vm.SourceTextModule` 扫全部 44 个前端模块,**只有这一个失败**。 + +**结论:管理员工作台的 `workspace.js` 会整份加载失败。** + +--- + +## 17. 注意事项汇总(易错点清单) + +按"会不会让人踩坑"排序。 + +### 🔴 会导致功能不可用 + +1. **必须有常驻 Worker**:`python -m app.worker`。否则 `agent_run` 永远 `queued`, + 前端只显示"客服响应超时"。**排查第一步:查 `agent_run` 最新那行是不是 `queued`**。 +2. **行情有效期只有 15 分钟**(`MAX_QUOTE_AGE`)。超时后**所有委托一律 503**, + 且**无自动刷新**。补刷:`python tools/sync_market_prices.py`(**立即生效、无需重启**)。 +3. **`config_release` 是环境数据、不随代码合并**。"白名单已发布"**必须带环境限定**, + 换环境要重发。发布脚本必须**覆盖同 key 的继承项**,否则旧值会把整次发布 422 拦下; + **继承范围必须覆盖全部三张受管表**(曾把 `customer_service_chitchat` 提示词 + 静默漏在旧版本里,靠 Agent 侧代码默认值兜底,**零告警**)。 +4. **知识类意图要同时发 `search_knowledge` 与 `query_knowledge`** —— + 前者给登录客户,后者给访客令牌(访客只有 `knowledge:query`)。 + **缺哪一条,对应人群就一问即失败**。 +5. **RBAC 权限码的定义源是 `tools/seed_test_rbac.py` 的 `PERMISSIONS`**(9001–9046)。 + 它是 **DELETE 重建**语义;**没并进它的权限码重建一次就没了**, + 表现是"接口突然 403 而没有任何报错线索"。 + +### 🟠 环境相关(换机器必踩) + +6. **Milvus 集合 schema 因环境而异**(字段名不同)。 + **不要在任何地方硬编码字段名** —— 检索层已改为运行时探测 + (`app/core/knowledge_schema.py`)。 +7. **`start.ps1` 必须存为 UTF-8 with BOM**。缺 BOM 时 PowerShell 5.1 按 GBK 解析, + 中文注释直接抛 `Unexpected token` 语法错误。 +8. **解释器探测必须实测「能 import 依赖」**,不能只看 `--version` 成功。 + 门槛:**≥ 3.11 + `import fastapi, sqlalchemy, asyncmy, pydantic` 通过**。 + 曾经因此选中 Python 3.10(项目用 `datetime.UTC`,3.11+ 才有), + 报错却发生在**行情刷新**那一步,看起来像"行情源坏了"。 +9. **`启动金融Agent平台.bat` 不要手写** —— 必须由 + `python tools/make_launcher_bat.py` 生成,且同时满足 + **GBK 编码 + CRLF 换行 + 无 BOM**,缺任何一条 cmd 都会解析错乱。 + +### 🟡 API 契约细节 + +10. **`meta.trace_id` ≠ `data.trace_id`**(R001):前者是本次请求,后者是该 run 自己。 +11. **`today_profit_loss` 恒为 0**(T001 / T006)—— 硬编码占位值 + (`app/service/trade_service.py` L678 持仓列表、L748-749 账户看板), + **不要当真实当日盈亏展示**。注意同一响应里的 `profit_loss`(持有盈亏)是**真算的**。 + **是否本期实现待业务确认 → 见 `docs/软件需求文档-2026-09-14.md` Q22** + (含两条口径的可行性实测:行情基准不可行、净值基准可行)。 +12. **`change_pct` 可能为 `null`**(P001)—— 必须显示"暂无",**绝不可当 `0`**。 +13. **P002 净值表为空返回 `count = 0`,不是错误**。 +14. **T005 撤单几乎总是 409** —— 市价单下单即成交,只有 `"待风控"` 可撤。 + 前端应友好提示"该委托已成交,无法撤销"。 +15. **T008 成交不存在也报 `ORDER_NOT_FOUND`**(不是独立的成家错误码)。 +16. **风控 `limit` 上限极小**:预警 **5**,证据/通知 **10**。 +17. **风控时间是裸 datetime,按北京时间解释**;Agent 路径带时区。 + **两条路径口径必须一致**,否则界面与对话查出的结果不同。 +18. **风控 `levels` 的键是中文**(`高风险`/`中风险`/`低风险`)。 +19. **`RiskEvidenceSource` 的 8 个值必须与前端 `EVIDENCE_COLUMNS` 一致**, + 传其它值 404。 +20. **`RUN_CANCELLED` 不是 HTTP 错误码**,它是 `request_idempotency` 的状态标记, + 没有异常类。 +21. **投顾目标书流转失败复用 `RUN_NOT_CANCELLABLE`**(409)—— 已知文档缺口, + 语义上应为 `INVALID_STATE`。 +22. **场外全套用旧式信封 `{code, message, data}`**,**不是**统一信封。 + 成功判断是 `code === 0`。 +23. **场外权限是 any-of 语义**(交集非空),不是 `require`。 +24. **场外附件下载可能返回文件或 JSON**,需按 `Content-Type` 分支。 +25. **推介材料的 `code` 在 body 里**(422/503),**HTTP 状态码仍是 200**。 +26. **`ReplyScene` 必须与数据库 CHECK 约束一致**,不一致时以 **500** 暴露而非 422。 +27. **`ItemPayload.namespace` 必须包含 `fund_market`**,否则行情配置 422。 +28. **`RoutingPayload.max_attempts` 不得超过端点数量**。 +29. **`EndpointPayload.secret_ref` 只接受 `env:XXX` 格式**。 +30. **凡接受 `If-Match` 的资源,必须同时提供能返回该 etag 的读取路径** + (A048/A049)。缺了会变成"首次编辑必然 409"的死锁。 +31. **`/products`、`/knowledge-references/{token}` 等公开面端点要求"合法令牌但无权限码"** —— + 访客令牌可用,无令牌不行(401)。 +32. **交易域没有限流**(T 系列低频),其余业务域有。 +33. **`/api/tasks/{task_id}/trigger-agent-nl2sql` 没有 `v1`**(前缀是 `/api`)。 + +### 🟢 顺序与安全(设计如此,不要"优化"掉") + +34. **Agent 事件订阅:先查可见性,后查 `Accept`** —— + 反过来会让 406-vs-404 变成存在性探针。 +35. **风控日报流:先鉴权,后 Accept** —— + async generator 的函数体到第一次迭代才执行,那时响应头已发出, + 403/406 只能变成"200 + 半截流"。 +36. **游标校验在权限闸门之后** —— 未授权一律 403, + 不能因参数格式先漏一个 400(响应差异是越权探测信号)。 +37. **登录失败永不区分原因**,且用户不存在时做一次 dummy bcrypt 对比。 +38. **风控扫描锁加在入口层**,不能放 Service 里(会把定时扫描自己挡死)。 +39. **风控幂等 `scope` 用实际路径(含 `alert_no`)**,不是模板 —— + 否则同一键会在不同预警间互相回放。 +40. **会话/委托/成交查询都带 `customer_id` 过滤**, + 别人的资源一律 **404 而非 403**。 + +### ⚙️ 运行平台 + +41. **`/internal/**` 三个端点全部无需鉴权**,且**每个都不走统一信封**。 +42. **`live` 只返回 `{"status":"ok"}`**,`ready` 才做真实探测。 +43. **`ready` 的 Milvus 探测是真实的**(B3 修复前是硬编码 `True`, + 属于误导性探针);`pymilvus` 缺失返回 `client_missing`。 + +--- + +## 18. 端点总表 + +> 按 `docs/05` §19 编号排列。`X0xx` 为本文件新增(见 §0 说明)。 + +### Agent 运行(R) + +| 编号 | 方法与路径 | 权限 | 幂等 | 成功 | +|---|---|---|---|---| +| R001 | `POST /api/v1/agent-runs` | 按 `agent_type` | 是 | 202 | +| R002 | `GET /api/v1/agent-runs/{run_id}` | 按域 | — | 200 | +| R003 | `GET /api/v1/agent-runs/{run_id}/events` | 按域 | — | 200 (SSE) | +| R004 | `POST /api/v1/agent-runs/{run_id}/cancellations` | `agent:cancel` | 是 | 202 | + +### 会话(C) + +| 编号 | 方法与路径 | 权限 | 幂等 | 成功 | +|---|---|---|---|---| +| C001 | `POST /api/v1/conversations` | `conversation:create` | 是 | 201 | +| C002 | `GET /api/v1/conversations/{session_id}` | `conversation:read` | — | 200 | +| C003 | `GET /api/v1/conversations/{session_id}/messages` | `conversation:read` | — | 200 | +| C004 | `POST /api/v1/conversations/{session_id}/closures` | `conversation:close` | 是 | 200 | +| C005 | `POST /api/v1/conversations/{session_id}/handover-requests` | `handover:create` | 是 | 202 | +| C006 | `GET /api/v1/handover-requests/{handover_id}` | `handover:create` | — | 200 | +| C007 | `POST /api/v1/conversation-messages/{message_id}/feedback` | `conversation:feedback` | 是 | 201 | + +### 记忆(M) + +| 编号 | 方法与路径 | 权限 | 成功 | +|---|---|---|---| +| M001 | `GET /api/v1/users/me/memory-profile` | `memory:read:self` | 200 | +| M002 | `GET /api/v1/customers/{customer_id}/memory-profile` | `memory:read:customer` | 200 | +| M003 | `GET /api/v1/users/me/memory-candidates` | `memory:read:self` | 200 | +| M004 | `POST /api/v1/users/me/memory-candidates/{candidate_id}/decisions` | `memory:candidate:confirm` | 200 | + +### 新人引导(O) + +| 编号 | 方法与路径 | 权限 | 成功 | +|---|---|---|---| +| O001 | `GET /api/v1/onboarding/risk-questionnaire` | 登录即可 | 200 | +| O002 | `POST /api/v1/onboarding/risk-questionnaire/submissions` | 登录即可 | 201 | +| O003 | (闸门,非端点) | — | 403 `ONBOARDING_REQUIRED` | + +### 知识库(K) + +| 编号 | 方法与路径 | 权限 | 成功 | +|---|---|---|---| +| K001 | `GET /api/v1/knowledge-references/{reference_token}` | `knowledge:reference:read` | 200 | +| K002 | `POST /api/v1/knowledge/upload` | `knowledge:manage` | 201 | +| K003 | `GET /api/v1/knowledge/list` | `knowledge:manage` | 200 | +| K004 | `DELETE /api/v1/knowledge/{knowledge_id}` | `knowledge:manage` | 200 | + +### 场内交易(T) + +| 编号 | 方法与路径 | 权限 | 成功 | +|---|---|---|---| +| T001 | `GET /api/v1/users/me/account/dashboard` | `account:read:self` | 200 | +| T002 | `POST /api/v1/users/me/orders` | `trade:order:create` | 201 | +| T003 | `GET /api/v1/users/me/orders` | `trade:order:read` | 200 | +| T004 | `GET /api/v1/users/me/orders/{order_no}` | `trade:order:read` | 200 | +| T005 | `POST /api/v1/users/me/orders/{order_no}/cancellations` | `trade:order:cancel` | 200 | +| T006 | `GET /api/v1/users/me/holdings` | `holding:read:self` | 200 | +| T007 | `GET /api/v1/users/me/transactions` | `trade:txn:read` | 200 | +| T008 | `GET /api/v1/users/me/transactions/{txn_no}` | `trade:txn:read` | 200 | +| T009 | `GET /api/v1/users/me/cash-ledger` | `trade:txn:read` | 200 | + +### 公开面(P) + +| 编号 | 方法与路径 | 权限 | 成功 | +|---|---|---|---| +| P001 | `GET /api/v1/products` | 令牌即可(无权限码) | 200 | +| P002 | `GET /api/v1/products/{product_code}/nav-history` | 令牌即可(无权限码) | 200 | +| V001 | `POST /api/v1/visitor-tokens` | **无** | 201 | + +### 投顾(AD) + +| 编号 | 方法与路径 | 权限 | 成功 | +|---|---|---|---| +| AD001 | `POST /api/v1/advisor/investment-goals` | `investment-goal:write` | 201 | +| AD002 | `GET /api/v1/advisor/investment-goals/current` | `investment-goal:read:self` | 200 | +| AD003 | `GET /api/v1/advisor/customers/{customer_id}/investment-goals/current` | `investment-goal:read` | 200 | +| AD004 | `POST /api/v1/advisor/investment-goals/{goal_no}/confirmations` | `investment-goal:confirm` | 200 | +| AD005 | `GET /api/v1/advisor/investment-goals/{goal_no}/goal-book` | `investment-goal:read` | 200 | +| AD006 | `POST /api/v1/advisor/investment-goals/{goal_no}/goal-book/reviews` | **`admin`** | 200 | +| AD007 | `POST /api/v1/advisor/investment-goals/{goal_no}/goal-book/publications` | **`admin`** | 200 | +| AD008 | `POST /api/v1/advisor/portfolio-analysis` | `portfolio-analysis:read:self` | 200 | +| AD009 | `POST /api/v1/advisor/asset-allocation` | `asset-allocation:generate:self` | 200 | +| AD010 | `POST /api/v1/advisor/recommendations` | `product-recommendation:generate:self` | 201 | +| AD011 | `GET /api/v1/advisor/recommendations/published` | `product-recommendation:read:self` | 200 | + +### 风控(R 前缀之外,`/api/v1/risk`) + +| 方法与路径 | 权限 | +|---|---| +| `GET /api/v1/risk/overview` | `risk:alert:read` | +| `GET /api/v1/risk/alerts` | `risk:alert:read` | +| `POST /api/v1/risk/alerts/scan` | `risk:alert:scan` | +| `POST /api/v1/risk/alerts/{alert_no}/acknowledgements` | `risk:alert:write` | +| `POST /api/v1/risk/alerts/{alert_no}/investigations` | `risk:alert:write` | +| `POST /api/v1/risk/alerts/{alert_no}/exclusions` | `risk:alert:write` | +| `POST /api/v1/risk/alerts/{alert_no}/resolutions` | `risk:alert:write` | +| `POST /api/v1/risk/alerts/{alert_no}/escalations` | `risk:alert:write` | +| `POST /api/v1/risk/alerts/{alert_no}/evidence` | `risk:alert:write` | +| `GET /api/v1/risk/alerts/{alert_no}` | `risk:alert:read` | +| `GET /api/v1/risk/evidence/{source}` | `risk:alert:read` | +| `GET /api/v1/risk/notifications` | `risk:alert:read` | +| `POST /api/v1/risk/daily-report` | `risk:alert:read` | +| `POST /api/v1/risk/daily-report/stream` | `risk:alert:read` | +| `POST /api/v1/risk/daily-report/mail` | `risk:report:mail` | + +### 平台管理(A) + +| 编号 | 方法与路径 | 权限 | 成功 | +|---|---|---|---| +| A001–A0xx | 每个资源的 `POST/GET/GET-detail/PUT`(表驱动,见 §11.1) | `config:read` / `config:write` / `model-endpoint:manage` | 201 / 200 | +| A015–A0xx | 状态流转 `POST /{resource}/{id}/{action}`(见 §11.2) | `config:review` / `config:activate` | 200 / 201(rollbacks) | +| A033 | `GET /api/v1/admin/audit-records` | `audit:read` | 200 | +| A034 | `POST /api/v1/auth/tokens` | **无**(登录入口) | 200 | +| A035 | `GET /api/v1/admin/roles` | `audit:read` | 200 | +| A036 | `GET /api/v1/admin/roles/{role_code}` | `audit:read` | 200 | +| A037 | `GET /api/v1/admin/roles/{role_code}/permissions` | `audit:read` | 200 | +| A038 | `GET /api/v1/admin/users/{user_id}/roles` | `audit:read` | 200 | +| A039 | `GET /api/v1/admin/customer-profile-candidates` | `memory:candidate:review` | 200 | +| A040 | `POST /api/v1/admin/customer-profile-candidates/{candidate_id}/reviews` | `memory:candidate:review` | 200 | +| A041 | `POST /api/v1/admin/advisor/asset-allocation-backtests` | `asset-allocation:backtest` | 201 | +| A042 | `GET /api/v1/admin/advisor/profile-tags` | `profile-governance:read` | 200 | +| A043 | `GET /api/v1/admin/advisor/profile-drift-reviews` | `profile-governance:read` | 200 | +| A044 | `POST /api/v1/admin/advisor/profile-drift-reviews/{review_id}/reviews` | `profile-governance:review` | 200 | +| A045 | `POST /api/v1/admin/advisor/recommendations/{content_id}/reviews` | `product-recommendation:review` | 200 | +| A046 | `POST /api/v1/admin/advisor/recommendations/{content_id}/publications` | `product-recommendation:publish` | 200 | +| A047 | `GET /api/v1/admin/advisor/pending-contents` | `product-recommendation:review` | 200 | +| A048 | `GET /api/v1/admin/config-releases/{release_id}/platform-config-items/{item_id}` | `config:read` | 200 | +| A049 | `GET /api/v1/admin/config-releases/{release_id}/model-routing-rules/{rule_id}` | `config:read` | 200 | +| — | `GET /api/v1/admin/customer-service/handover-tickets` | 管理员 | 200 | +| — | `GET /api/v1/admin/customer-service/handover-tickets/{ticket_no}` | 管理员 | 200 | + +### 场外运营(X,本文件编号) + +| 编号 | 方法与路径 | 权限组合 | +|---|---|---| +| X001 | `GET /api/v1/offsite-fund/mails` | `offsite:read` / `offsite:write` | +| X002 | `GET /api/v1/offsite-fund/mails/{mail_id}` | 同上 | +| X003 | `POST /api/v1/offsite-fund/mails/{mail_id}/deletions` | `offsite:write` | +| X004 | `GET/PUT /api/v1/offsite-fund/mails/{mail_id}/recognition-fields` | read/write、write | +| X005 | `GET/PUT /api/v1/offsite-fund/documents/{task_id}/nl2sql-fields` | read/write、write | +| X006 | `GET /api/v1/offsite-fund/documents/{task_id}/rule-results` | read/write | +| X007 | `POST /api/v1/offsite-fund/documents/{task_id}/rule-results/recalculations` | `offsite:write` | +| X008 | `GET /api/v1/offsite-fund/mailbox-status` | read/write | +| X009 | `POST /api/v1/offsite-fund/mailbox-status/recoveries` | `offsite:write` | +| X010 | `GET /api/v1/offsite-fund/attachments/{attachment_id}/file` | read/write | +| X011 | `POST /api/v1/offsite-fund/documents/{task_id}/confirmations` | write + confirm | +| X012 | `POST /api/v1/offsite-fund/documents/{task_id}/recognition-retries` | `offsite:write` | +| X013 | `POST /api/v1/offsite-fund/documents/{task_id}/notifications` | notify / write | +| X014 | `POST /api/v1/offsite-fund/notifications/{notification_id}/send` | notify / write | +| X015 | `POST /api/v1/offsite-fund/settlement-statistics/recalculate` | `offsite:write` | +| X016 | **`POST /api/tasks/{task_id}/trigger-agent-nl2sql`** | `offsite:write` | + +### 推介材料(X,本文件编号) + +| 编号 | 方法与路径 | 权限 | +|---|---|---| +| X017 | `POST /api/v1/fund-promotion-materials` | `promotion:write` | +| X018 | `PUT /api/v1/fund-promotion-materials/{task_no}/inputs` | `promotion:write` | +| X019 | `POST /api/v1/fund-promotion-materials/{task_no}/attachments` | `promotion:write` | +| X020 | `POST /api/v1/fund-promotion-materials/{task_no}/generations` | `promotion:write` | +| X021 | `GET /api/v1/fund-promotion-materials/{task_no}/compliance-checks` | `promotion:read` | +| X022 | `GET /api/v1/fund-promotion-materials/{task_no}` | `promotion:read` | +| X023 | `POST /api/v1/fund-promotion-materials/{task_no}/reviews` | `promotion:review` | +| X024 | `POST /api/v1/fund-promotion-materials/{task_no}/deliveries` | `promotion:deliver` | + +### 运维 + +| 方法与路径 | 鉴权 | 成功 | +|---|---|---| +| `GET /internal/health/live` | **无** | 200 | +| `GET /internal/health/ready` | **无** | 200 / **503** | +| `GET /internal/metrics` | **无** | 200(`text/plain`) | + +--- + +## 附:统计口径 + +| 项 | 数量 | +|---|---| +| 路由模块 | **22 个**(`app/main.py` 的 `include_router`) | +| 显式声明的端点函数 | 约 **110 个** | +| 表驱动生成的端点 | `register_resource` 8 个资源 × 2–4 个方法 ≈ **22 个**;`register_transition` **11 个** | +| **端点总数(估算)** | **约 140 个** | +| 错误码 | **36 个**(`app/core/errors.py`) | +| 业务 Agent | **7 个** | + +> 端点总数是估算:表驱动端点由循环动态注册,精确计数需在运行时遍历 +> `app.routes`。可用如下方式核对: +> +> ```bash +> python -c "from app.main import app; print(len([r for r in app.routes if hasattr(r,'methods')]))" +> ``` + +--- + +## 附:与 `docs/05` 的关系 + +本文档**不替代** `docs/05-接口文档.md`。分工建议: + +| 想知道 | 看哪份 | +|---|---| +| 某个接口为什么这样设计、背后的约束与事故 | **`docs/05`**(权威) | +| 接口的请求/响应字段逐个含义、示例、注意事项 | **本文档** | +| 端点编号与权限/幂等/审计的规范表 | **`docs/05` §19** | +| 场外运营与推介材料(`docs/05` §19 未登记) | **本文档** §13、§14 | + +**⚠️ 维护提示**:若代码变更涉及端点增删、字段改名或错误码调整, +**应同步更新本文档与 `docs/05`**。本文档的"注意事项汇总"(§17)是从源码注释中提炼的, +源码里那段注释往往就是一次事故的记录 —— 改代码前请先读它。 diff --git a/docs/软件需求文档-2026-09-14.md b/docs/软件需求文档-2026-09-14.md new file mode 100644 index 0000000..651d45f --- /dev/null +++ b/docs/软件需求文档-2026-09-14.md @@ -0,0 +1,709 @@ +# 软件需求文档(SRS)——金融 Agent 平台 + +> 文档版本:v1.0(2026-09-14) +> 文档状态:**待确认稿**——第 0 章列出 22 项必须由业务方补充确认的问题,确认后本文件升为 v1.1 +> 适用范围:场内基金模拟交易 + 客服问答 + 风控 + 投顾 + 场外基金运营 + 推介材料(六大业务域) +> 依据材料(均为仓库内既有文档,非本文件臆造):`docs/00` 数据库基线、`docs/03` 端到端流程、`docs/05` 接口文档、`docs/22` 四大 Agent 拆解、`docs/10` 业务域接入评估、`docs/43` 场内产品手册、`docs/44` 演示流程、`docs/验收与审计/phase1-*`、`docs/28` 场外表登记、`docs/31` 投顾灰度、`docs/场外申购赎回工作流程图.md`、以及 `app/service/*` 实际实现代码。 + +--- + +## 0. 需要业务方补充确认的问题(★ 请先回答本章) + +本文件是在**不臆造业务**的前提下,依据仓库既有设计文档与已落地代码反推整理的。以下 22 项在现有材料中**确实没有权威答案**或存在**口径冲突**,其中标 `[阻塞]` 的会直接影响功能需求与验收标准的写法;标 `[影响]` 的只影响细节精度,可先按建议默认值推进。 + +### 0.1 范围与优先级(最高优先回答) + +| # | 问题 | 现状 / 冲突点 | 影响 | +|---|---|---|---| +| Q1 | **一期交付范围是否就是"六大业务域全部"?** 还是要分两批(如客服+场内交易先上,场外+推介后上)? | `docs/22` §7 建议的建设顺序是 客服 → 投顾 → 风控 → 场外;但代码里**六个域都已落地**,`docs/44` 演示流程 8 个场景横跨全部域。文档需要一个明确的"本期交付边界"。 | `[阻塞]` | +| Q2 | **本期是"演示验收"还是"生产上线"?** 两者的非功能需求(可用性、备份、等保、并发量)量级差一个数量级。 | 现状:数据库口令、JWT 密钥走本地配置;`docs/26` 有密钥轮换设计;无出金/入金通道(`docs/00` §5 明示"无充值提现与银行流水匹配通道")。 | `[阻塞]` | +| Q3 | **是否仍严格限定"仅场内基金模拟交易"?** 场外运营是否仅做"辅助核对+留痕",不产生真实资金动作? | `AGENTS.md` 规则 8 明确:场外运营**独立**,不得写入场内交易表;`app/service/offsite_fund_service.py` 确实只读到 `OffsiteFundDocument` 等 `offsite_*` 表。请确认这条边界在需求层面不可松动。 | `[阻塞]` | + +### 0.2 业务规则口径(现有文档明确写了"未定义",需业务填空) + +| # | 问题 | 现状 / 冲突点 | 影响 | +|---|---|---|---| +| Q4 | **客服 RAG 的混合检索决策公式与分数分段阈值**(向量分/关键词分如何加权、多少分以上直答、多少分以下转人工、中间区间怎么办)? | `docs/22` §2.3 明确列为**该设计自身未定义**项;代码侧只落了 `snippet`/`knowledge_id` 与 TopK(FAQ 3 / 产品 5 / 政策 5),未见阈值常量。 | `[阻塞]`(直接影响"低置信不硬答"的验收判定) | +| Q5 | **低置信转人工的触发阈值与话术**,以及"多轮澄清后仍无法解决"的兜底路径? | `docs/03` 只说"低置信不得硬答、转人工需带完整上下文";`phase1` 教师标准认可 `[无法确认]` 标记 + 真实热线 `15936583816`。阈值本身未给。 | `[阻塞]` | +| Q6 | **风控规则引擎的规则清单从哪来?** 是业务方给一份规则表(规则名/口径/阈值/等级),还是要我们从 `docs/21`、`docs/25` 反推? | `docs/22` §2.3 明确列为未定义;代码 `app/service/risk_scan_service.py` 已有扫描实现但规则阈值需与业务对齐。 | `[阻塞]` | +| Q7 | **场上/场下若干"业务阈值"请业务给数**:申购单笔份额上限比例(现为总份额 10%)、单一投资者持有比例上限(现为 20%)、赎回巨额比例(现为 20%)、申购最低金额(现为 >1 元)是否为最终口径? | 来自 `app/service/offsite_fund_rules.py` 实际常量 `TEN_PERCENT=0.10`、`TWENTY_PERCENT=0.20`、`ONE_YUAN=1`。这些是**代码现状**,不排除是占位值。 | `[影响]` | +| Q8 | **场外"巨额赎回"是否要联动风控通知?清算是按单只基金聚合还是全市场聚合?** | `docs/场外申购赎回工作流程图.md` 边界明确:AML 与开放期规则**不在本次实现范围**;风险通知/邮件回复/清算通知**三条路径相互独立、无自动联动**。请确认这是有意为之还是待补。 | `[影响]` | +| Q9 | **合规检查放在生成前还是生成后?**(`docs/22` §6 列为待定决策) | 现状:推介材料代码里**两者都有**(`_generate_tx` 有"输入资料未通过合规校验"与"生成内容未通过合规校验"两个 422);客服侧未见显式前置校验。 | `[阻塞]` | +| Q10 | **是否允许 Agent 给出"投资建议"?** 边界是什么? | 现状代码是**保守**的:`asset_allocation_service.py` 常量 `analysis_only: True` 且 `disclaimer = "分析结果仅供参考,不生成交易指令。"`;`docs/43` 明确客服**不得**承诺收益、不得修改风险测评。 | `[影响]` | +| Q11 | **投顾灰度白名单的最终开放范围**(现为 `ADVISOR_ROLLOUT_CUSTOMER_IDS`,空名单 = 全体客户 fail-closed)? | `docs/31` 有开关与回滚手册;具体放量节奏需业务定。 | `[影响]` | + +### 0.3 缺失的输入材料(需要业务方提供文件) + +| # | 问题 | 影响 | +|---|---|---| +| Q12 | **原始需求文档 HTML**(设计提到 `C:\Users\Windows\Desktop\金融\需求文档-修改版.html`,本工作区不可达)能否提供? | `[阻塞]`——本文件是从代码+设计反推的,若有原始需求,可对齐措辞与遗漏项。 | +| Q13 | **场外申购/赎回单的真实样例件**(至少各 1 份 PDF/扫描件)。 | `docs/22` 明确"场外是唯一需要新表的模块,且**缺样例单据**"。没有样例,OCR 字段与规则无法验收。`[阻塞]` | +| Q14 | **投顾 21 张表的登记文档**是否已有?(`docs/28` 只登记了场外 10 张 + 推广 7 张,投顾 21 张"登记文档待补") | `[影响]`——影响投顾模块的数据字典完整性。 | +| Q15 | **部署环境是否具备 LibreOffice / soffice?** | `product_promotion_to_do_list.md` 记录:推介材料 PPTX 已能真实生成,但 **PDF 转换依赖部署环境的 LibreOffice**,本机未验证。`[阻塞]`(影响"PPT 导出"这项验收) | +| Q16 | **生产数据库/中间件规格**(MySQL 8.0 版本、Redis、Milvus 版本与部署形态、Neo4j 是否必装)? | 现状:`app/core/knowledge_schema.py` 显示**Milvus 集合 schema 因环境而异**(本机 `knowledge_id/snippet`,架构师环境 `doc_id/content/visibility/chapter`),检索层做了运行时探测。生产环境以哪套为准需定。`[影响]` | +| Q17 | **模型与供应商清单**(对话模型、Embedding 模型及其**向量维度**、是否允许外网调用、是否需私有化)? | `docs/22` §6 明确:模型选择决定 Milvus schema,是**待定决策**。现状配置含 `deepseek-flash` 等。`[阻塞]` | + +### 0.4 流程与角色 + +| # | 问题 | 影响 | +|---|---|---| +| Q18 | **客服工单与风控工单是否要真正打通?**(`docs/22` §2.3 列"跨 Agent 联动规则未定义") | `docs/03` 验收要求"客服工单与风控工单不得混用",跨域升级路径未定。`[影响]` | +| Q19 | **是否需要"客户主动发起风险评估"入口**,还是测评只由运营/客服在后台维护? | `docs/43` 说客服**不能改**风险测评;那客户自己能否改?未写明。`[影响]` | +| Q20 | **人工兜底的值守时段与 SLA**(客服转人工后多久响应?风控告警多久必须处置?)? | `docs/03` 有状态机但无时间承诺;风控有 `overdue` 概念但阈值来源未明。`[影响]` | +| Q21 | **免责声明 / 适当性提示的标准文案**由谁出?(合规部?) | 现状:代码里有 `disclaimer` 常量与 `disclosures` 结构,但**正式文案需合规确认**。`[影响]` | + +### 0.5 占位实现与本期边界(★ 接口有、值还没真算) + +本节专门登记一类偏差:**接口存在、字段有返回、但值是占位常量**。它不报错、单元测试全绿, +也不会被现有任何自动化检查发现 —— 只有业务对着真实口径才看得出不对, +因此**是演示现场最容易被追问的地方**。以后发现同类问题请继续往本节追加。 + +| # | 问题 | 现状 / 冲突点 | 影响 | +|---|---|---|---| +| Q22 | **客户看板的「今日盈亏」本期是否实现?** | `app/service/trade_service.py` 里 `today_profit_loss` 是**硬编码 `ZERO`**:持仓列表 **L678**、账户看板 **L748**;`today_profit_loss_ratio` 同理(**L749**)。**对比**:同一函数里的「持有盈亏」是**真算的**(`market_value - cost_amount`,L660 / L726),所以**只有「今日盈亏」恒为 0**。客户看板、持仓页(T006)、盈亏分析页的该字段**永远显示 `0.00`**。 | `[阻塞]` —— 演示必被问。**若本期不做**,需明确对外口径:要么前端不展示该列/卡片,要么显示 `—`,**不要显示 `0`**(`0` 会被当成真实值) | + +**若决定实现:口径与数据可行性(2026-09-14 实测核实)** + +| 候选口径 | 数据现状 | 可行性 | +|---|---|---| +| `(今日收盘价 − 昨收) × 数量`,基准取 `fin_market_price` | **不可行**:该表只在**行情同步时**写行,实测每个产品**仅 2 行**(如 515450 只有 `2026-09-11` 与 `2026-09-14`),取不到连续的「昨收」,且这两行还跨了周末 | ❌ | +| `(今日净值 − 上一交易日净值) × 数量`,基准取 `fin_nav_history` | **可行**:该表每个产品有 **120~160 行连续交易日净值**(由 `tools/sync_nav_history.py` 同步,按 `(product_id, nav_date)` 幂等 upsert) | ✅ | + +⇒ **建议按净值口径实现**。同时请业务确认语义:净值是**日终**数据,该指标实际含义是 +「**最近一个交易日**相对前一交易日的盈亏」,**不是盘中实时**;当天净值同步之前它会与「昨日」一致。 +这一点要在界面文案上讲清楚,否则又会被当成 bug。 + +> **建议回答方式**:不必逐条写长文,可直接在本章表格后追加"Q1:……"式的短答,或在对话中直接回复序号+结论。**标 `[阻塞]` 的 12 项(Q1/Q2/Q3/Q4/Q5/Q6/Q9/Q12/Q13/Q15/Q17/Q22)建议优先给出**,其余可先按本文给出的默认口径执行,后续修订。 + +--- + +## 1. 项目背景与目标 + +### 1.1 项目背景 + +本项目是一个**面向基金业务的通用 Agent 平台**,采用"**厚平台、薄 Agent**"的设计哲学: + +- **厚平台**:统一承担会话、幂等、长短期记忆、关系推理(Neo4j)、配置快照与发布、多模型路由、工具执行、合规校验、审计留痕、可靠事件投递(Outbox)等横切能力; +- **薄 Agent**:业务 Agent 只负责"检索、分析、生成、归纳、分诊",不得自行实现鉴权、记忆、模型调用、审计等能力,必须继承公共 `BaseAgent` 并由 `AgentFactory` 创建。 + +平台当前覆盖六大业务域: + +| 域 | 业务Agent / 服务 | 数据表归属 | +|---|---|---| +| 客服问答 | `CustomerServiceAgent` | 场内基线的客服相关表 | +| 场内基金模拟交易 | 交易服务(非 Agent,确定性服务) | 场内 51 张表 | +| 风控 | `RiskAgent` + 规则引擎 | 场内基线的风控相关表 | +| 投顾 | `AdvisorAgent` | `advisor_*` 21 张表 | +| 场外基金运营 | `OffsiteFundAgent` | `offsite_*` 10 张表 | +| 基金推介材料 | `PromotionMaterialAgent` | `promotion_*` 7 张表 | + +### 1.2 项目目标 + +**目标一:建一个"不绕过统一执行骨架"的 Agent 平台。** +所有业务 Agent 走同一套七步执行流程(输入校验 → 记忆召回 → 意图识别 → 核心业务 → 生成与合规 → 数据落库 → 事件广播),保证鉴权、合规、审计、事件四条链路不可能被旁路。 + +**目标二:让四类高风险业务在"人在环中"(Human-in-the-loop)下可用。** + +- 场内交易:**模拟撮合**,所有成交、资金、持仓均为平台内部虚拟数据,不涉及真实资金; +- 风控:**规则引擎做确定性扫描**,模型只用于生成"建议优化方向",不得替代规则判断; +- 场外运营:Agent 只做识别与核对,**最终确认必须由运营人员点击**; +- 投顾:只输出分析,**不生成交易指令**。 + +**目标三:权威业务事实不可被记忆或模型覆盖。** +记忆分层中,MySQL 中的权威事实(持仓、现金、成交、风险测评)优先级高于用户自述与 AI 推断;低版本记忆不得覆盖高版本。 + +**目标四:满足课程/项目的阶段性验收标准。** +一期 7 条验收标准(见第 6 章)已全部达成,附真机证据;本文件在此基础上把需求正式化。 + +### 1.3 业务模式 + +| 维度 | 说明 | +|---|---| +| **产品形态** | B2C 模拟交易 + B2B2C 运营辅助。客户端为访客/客户自助问答与模拟下单;员工端为客服工作台、风控工作台、投顾工作台、运营控制台 | +| **收入模式** | **无收入**(模拟盘)。若转生产需业务补充商业模式(见 Q2) | +| **资金流** | **无真实资金流**。场内为虚拟账户记账;场外只做单据核对与通知留痕,不做划款 | +| **数据流** | 行情来自外部接口(实时价 + 日K,落 `fin_nav_history`);其余全部为平台内部产生 | +| **合规模式** | 事前(适当性校验、白名单发布)+ 事中(模型生成后合规检查、人工确认闸门)+ 事后(全链路审计、`trace_id` 贯穿) | + +--- + +## 2. 用户角色与典型使用场景 + +### 2.1 角色清单 + +平台共定义 **8 类角色**(5 个人类角色 + 3 个非人角色),每一类都带**明确的禁止事项**——这是本项目合规设计的核心,需求层面不可弱化。 + +| # | 角色 | 系统角色码 | 可以做 | **明确禁止** | +|---|---|---|---|---| +| 1 | **访客** | `visitor` | 浏览公开产品页、提问(受知识边界限制) | 不得访问任何客户数据;不得下单 | +| 2 | **客户** | `customer` | 提问、看自己资产/持仓/成交、做测评、确认方案、下单(模拟) | **不得查询他人任何数据** | +| 3 | **客服人员** | `employee` + 客服权限 | 受理咨询/投诉/转交工单、查知识库 | **不得做产品推荐** | +| 4 | **持证投顾** | `advisor` | 复核方案、解释风险、确认适当性 | **不得绕过适当性、不得承诺收益** | +| 5 | **运营人员** | `operator` / `employee` | 核实业务数据、处理异常、**做最终确认** | **不得让 Agent 自动完成最终确认** | +| 6 | **风控专员** | `risk_operator` | 调查/排除/升级/结案告警 | **不得修改交易、资金、持仓事实** | +| 7 | **平台管理员** | `admin` | 配置、模型路由、知识发布、权限治理 | 不得绕过配置不可变版本机制 | +| 8 | **规则引擎**(非人) | — | 确定性扫描 → 产生告警 | **不得用模型判断替代规则** | +| 9 | **Agent**(非人) | — | 检索/分析/生成/归纳/分诊 | **不得代客交易、不得越权查询、不得做最终业务决策** | +| 10 | **通用平台**(非人) | — | 鉴权/记忆/工具/合规/审计/事件 | **不得被业务旁路** | + +> 演示账号(来自 `docs/44`):`cust_t` / `123456`(客户)、`risk_t` / `666666`(风控)、`admin_t` / `88888888`(管理员)、`advisor_t` / `abc12345`(投顾)。另种子脚本 `tools/seed_test_rbac.py` 内置 `review_t`(**故意不授予角色、不设密码**,用于边界测试)。 + +### 2.2 典型使用场景 + +| 编号 | 场景名 | 角色 | 触发 | 期望结果 | +|---|---|---|---|---| +| S-01 | 访客知识边界问答 | 访客 | 问"基金申购后多久确认" | 命中知识库给出答案;问超出范围内容时**不硬答**,给 `[无法确认]` 与热线 | +| S-02 | 客户查看资产 | 客户 | 打开"我的资产" | 返回现金余额、总市值、总资产;**持仓查询不校验行情时效** | +| S-03 | 客户模拟买入 ⭐ | 客户 | 选基金、填数量(100 的整数倍) | 走完校验链→立即全额成交→生成订单+流水+持仓+资金变动 | +| S-04 | 客服多轮对话与转人工 | 客户/客服 | 连续追问或明确要求转人工 | 3 轮以上保持上下文;转人工时**上下文完整带入**工单 | +| S-05 | 风控告警处置 ⭐ | 风控专员 | 规则扫描产生告警 | 走 `待处理 → 调查中 → 已排除/已结案`;升级为独立标记 | +| S-06 | 风控日报流式查看 | 风控专员 | 打开日报 | SSE 流式产出;规则部分**确定性**,仅"建议优化方向"用模型 | +| S-07 | 投顾方案生成与复核 | 投顾 | 客户已填目标与画像 | 生成资产配置与产品推荐;**只分析、不下单**;需适当性+合同证据三重校验 | +| S-08 | 管理员配置治理 | 管理员 | 改配置/知识 | 走 draft→review→activate 不可变版本;`If-Match` 乐观锁防并发覆盖 | +| S-09 | 场外单据核对与回复 | 运营 | 收到申购/赎回邮件 | OCR+字段识别→NL2SQL 查数→规则核对→**人工确认**→按类型发通知 | +| S-10 | 推介材料生成 | 运营/市场 | 上传结构化资料 | 合规校验→生成 PPTX→复核→内部发送记录留痕 | + +--- + +## 3. 功能需求(按模块划分 + 优先级) + +> **优先级定义** +> **P0** = 本期必须交付,缺失则验收不通过; +> **P1** = 本期应交,可接受降级实现(如以人工替代自动化); +> **P2** = 本期可不做,但需在架构上预留。 + +--- + +### 3.1 模块一:通用 Agent 平台底座(P0) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-1.1 | 提供统一七步执行骨架,业务 Agent 必须继承 `BaseAgent`,由 `AgentFactory` 创建 | P0 | 无法绕过鉴权/记忆/模型路由/工具/合规/审计/事件 | +| F-1.2 | 统一成功信封 `{"data":..., "meta":{"trace_id":...}}`;列表额外含 `next_cursor`/`has_more` | P0 | 业务接口不得新增其他顶层字段 | +| F-1.3 | 统一错误信封 `{"error":{code,message,retryable,field_errors}, "meta":{trace_id}}` | P0 | 36 个错误码,`retryable` 逐码标注(不由状态码推导) | +| F-1.4 | `trace_id` 全链路贯穿 | P0 | 取值顺序:请求上下文→`request.state`→`X-Trace-ID`→空串;**不得伪造** | +| F-1.5 | 幂等:`Idempotency-Key`,范围 = `user_id + method + 规范化路径 + key` | P0 | 同键并发只产生一条消息/工单/领域事件;**记录与业务写入同事务** | +| F-1.6 | 乐观并发:`If-Match` vs 行内容摘要,冲突→409 `RESOURCE_VERSION_CONFLICT`(**可重试**) | P0 | 每个支持 `If-Match` 的资源**必须同时提供返回 etag 的读接口** | +| F-1.7 | 游标分页:`cursor` 表示"取更旧一页";非法游标→400 `INVALID_CURSOR` | P0 | 校验在**权限闸门之后、数据访问之前** | +| F-1.8 | 限流 `enforce_rate_limit` 作为路由依赖,鉴权始终先于限流 | P0 | 交易类(`/api/v1/users/me/**`)**不限流** | +| F-1.9 | 三段式异步 Agent 运行:`POST` → 202 + `status_url`/`events_url`;轮询或 SSE 取结果 | P0 | 需常驻 Worker,否则 `agent_run` 停在 `queued` | +| F-1.10 | SSE 契约:`start`/`tools`/`replace`/`delta`/`done`/`error`,心跳为注释行 | P0 | 终态走重放模式;`delta` 按 `sse_chunk_characters`(默认 256)切分 | +| F-1.11 | 记忆分层:短期(Redis) → 情节(中期) → 长期(promotion) | P0 | 召回权威性:**MySQL 权威事实 > 用户自述 > AI 推断** | +| F-1.12 | 配置发布:不可变版本,draft→review→activate→rollback | P0 | 一次请求内配置**不得漂移**;发布白名单为工具可用范围上限 | +| F-1.13 | 审计与事件:所有写操作留痕;Outbox 保证事件可靠投递 | P0 | 每条请求可凭 `trace_id` 追溯 | + +--- + +### 3.2 模块二:认证与权限(P0) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-2.1 | 登录:`POST /api/v1/auth/tokens` | P0 | 失败**不区分原因**,统一"用户名或密码不正确";用户不存在时仍走 dummy bcrypt 防时序侧信道 | +| F-2.2 | JWT **只携带 `sub`**;角色/权限/`data_scope` **每请求实时解析** | P0 | 支持即时吊销;`ACCESS_TOKEN_TTL_SECONDS=1800` | +| F-2.3 | 访客令牌**跳过**身份解析 | P0 | 访客只能命中公开面接口 | +| F-2.4 | 新人引导闸门:客户未完成引导访问 `/api/v1/**`(`onboarding` 除外)→ 403 `ONBOARDING_REQUIRED` | P0 | — | +| F-2.5 | RBAC:权限码定义源为 `tools/seed_test_rbac.py` 的 `PERMISSIONS` | P0 | 该脚本为 **DELETE 重建**语义,未并入的权限码重建即消失(表现为"接口突然 403 且无报错") | +| F-2.6 | 三种 `data_scope`:`self` / `own_customers` / `all` | P0 | **零跨客户访问**(重要验收红线) | + +--- + +### 3.3 模块三:客服 Agent 与知识库(P0) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-3.1 | 五类意图识别:`faq` / `product_inquiry` / `policy_explain` / `chitchat` / `transfer_human` | P0 | 五类均需端到端可测 | +| F-3.2 | 三集合知识路由:FAQ TopK 3、产品 TopK 5、政策 TopK 5 | P0 | 检索层**运行时探测字段名**,不得硬编码 | +| F-3.3 | 知识只使用"已发布 + 在有效期"的版本 | P0 | 未发布版本不得被召回 | +| F-3.4 | 低置信**不得硬答**;转人工上下文完整 | P0 | 需 Q4/Q5 提供阈值 | +| F-3.5 | 知识管理三端点:上传 / 列表 / 删除 | P0 | 上传 201 / 列表 200 / 删除 200;客户访问上传应 403 | +| F-3.6 | 知识发布流程:上传→草稿/待审→抽取分块→法务审核→批准→向量化→发布生效 | P0 | 512/64 **字符**分块(非 token) | +| F-3.7 | 客服工单状态机:`pending→assigned→processing→resolved→closed` | P1 | 与风控工单**不得混用** | +| F-3.8 | 客户画像候选流程:候选→客户确认→管理员复核 | P1 | 候选视图**不含对话原文证据** | +| F-3.9 | 一期验收:产品咨询 ≥5 题、准确率 ≥80% | P0 | 已达成(5/5) | +| F-3.10 | 一期验收:政策解释 ≥2 题 | P0 | 已达成(3/3) | +| F-3.11 | 一期验收:多轮上下文 ≥3 轮 | P0 | 已达成(3 轮) | + +--- + +### 3.4 模块四:场内基金模拟交易(P0) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-4.1 | 20 只场内基金(13 ETF + 7 LOF)产品数据 | P0 | 风险等级 R1–R5;管理费 0.15%–1.20%/年;托管费 0.05%–0.20%/年 | +| F-4.2 | 账户看板:现金余额、总市值、总资产 | P0 | **注意**:`today_profit_loss` / `_ratio` 当前为**硬编码 0(占位)**,需业务确认是否本期实现 → **见 Q22**(含数据可行性与建议口径) | +| F-4.3 | 持仓查询 | P0 | 只读持仓**故意不校验行情时效** | +| F-4.4 | 下单(买入/卖出),市价全额成交 | P0 | 首版**仅支持 `price_type="market"`**,`limit_price` 恒为 `null` | +| F-4.5 | 完整校验链(**顺序不可调**) | P0 | 见 5.5 节 | +| F-4.6 | 交易数量约束:1 手 = 100 份,最低 0.001 元价位 | P0 | `quantity % lot_size == 0` | +| F-4.7 | 行情时效:`MAX_QUOTE_AGE = 15 分钟` | P0 | 超龄→**所有委托 503**,**无自动刷新**;需 `python tools/sync_market_prices.py` 补刷 | +| F-4.8 | 撤单 | P0 | 仅 `待风控` 状态可撤;其余→409 `ORDER_NOT_CANCELLABLE` | +| F-4.9 | 流水与资金流水查询 | P0 | 金额字段**全部为字符串化 Decimal**,非 JSON number | +| F-4.10 | 成交需满足幂等与事务原子性 | P0 | 订单+流水+持仓+账户同事务 | + +--- + +### 3.5 模块五:风控(P0) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-5.1 | 规则引擎**确定性**扫描 → 产生告警 | P0 | **不得用模型判断替代规则** | +| F-5.2 | 告警处置:`待处理 → 调查中 → 已排除/已结案` | P0 | 五动作均需 `risk:alert:write` | +| F-5.3 | 升级为**独立标记**(非状态) | P0 | 返回 `is_escalated`/`escalated_at`/`escalation_reason` | +| F-5.4 | 风控日报:前 8 项确定性计算,**仅"建议优化方向"可用模型** | P0 | SSE 事件 `start`/`progress`/`replace`/`done` | +| F-5.5 | 证据查询:8 类来源 `customers/products/transactions/capital_flows/holdings/login_records/alerts/notifications` | P0 | 未知来源→404;须与前端 `EVIDENCE_COLUMNS` 对齐 | +| F-5.6 | 证据归档(multipart 上传) | P1 | 成功/失败均审计;`try/finally close()` | +| F-5.7 | 日报邮件发送 | P1 | **唯一曾漏鉴权的风控端点**,现于 Service 层强制 `risk:report:mail`;默认 `dry_run` | +| F-5.8 | 扫描互斥:MySQL `GET_LOCK('jr_risk_scan_schedule', 0)` | P0 | 锁在**入口层**获取,**不得**放在 `RiskScanService.scan()` 内(否则调度器自锁死) | +| F-5.9 | 风控专员**不得修改交易/资金/持仓事实** | P0 | 边界红线 | +| F-5.10 | 列表 `limit` 上限严格:告警 **max 5**;证据/通知 max 10 | P0 | 风控是**唯一**扩展 `meta.total`/`meta.page_size` 的域 | + +--- + +### 3.6 模块六:投顾(P1) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-6.1 | 投资目标管理(AD001–AD007) | P1 | 目标/方案状态机失败返回 **409 但复用 `RUN_NOT_CANCELLABLE` 字面量**(已知文档缺陷,见 Q 补充说明) | +| F-6.2 | 组合分析(AD008) | P1 | `status ∈ no_positions/valuation_required/ready`;7 类警告码;图上下文**降级不抛错** | +| F-6.3 | 资产配置(AD009) | P1 | 硬编码 C1–C5 战略比例;常量 `analysis_only: True` | +| F-6.4 | 产品推荐(AD010/AD011) | P1 | **三重校验**:场内可交易 + 权威适当性 + 合同证据 | +| F-6.5 | 灰度放量:`ADVISOR_ROLLOUT_ENABLED` + `ADVISOR_ROLLOUT_CUSTOMER_IDS` | P0 | 空白名单 = 全体 fail-closed;拒绝→403 `AGENT_PERMISSION_DENIED` + 审计 `advisor.rollout_denied` | +| F-6.6 | `admin`/`super_admin` 始终放行 | P0 | — | +| F-6.7 | **不承诺收益、不绕过适当性** | P0 | 边界红线 | + +--- + +### 3.7 模块七:场外基金运营(P1) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-7.1 | 邮件增量收取:IMAP UID 增量扫描 → MIME 解析 → 附件落盘 | P1 | 原文 EML + 附件哈希留存 | +| F-7.2 | 附件识别:OCR → 结构化字段抽取 | P1 | 申购 8 个必填字段;赎回 7 个必填字段 | +| F-7.3 | 单据字段:基金代码/名称/账户标识/申请编号/申请日期/代销机构/申购金额/金额单位/赎回份额 | P1 | 金额支持"万元"换算、币种前缀剥离 | +| F-7.4 | NL2SQL 只读查数(最新净值、最新总份额、申请前持有份额、可用份额) | P1 | 查询失败→`无法判断`,**不得猜测** | +| F-7.5 | 确定性规则核对 | P0 | 见 5.7 节四规则 | +| F-7.6 | **运营人员最终确认**(确认正常/确认异常) | P0 | 识别未完成时→422 不得确认 | +| F-7.7 | 通知四类型:`risk`/`settlement`/`mail_return`/`normal_return`/`exception_return` | P1 | 校验规则严格:风控只发"确认异常"、清算只发"确认正常" | +| F-7.8 | 发送前**必须**完成运营确认(`operator_confirmed`) | P0 | 未确认→422"邮件发送前必须完成运营确认" | +| F-7.9 | 清算统计**只统计**"确认正常 + 回复发送成功"的单据 | P1 | 口径见 5.7 | +| F-7.10 | 字段人工修正(OCR 字段 + NL2SQL 字段) | P1 | 修正值优先,原值落库不变;有缺失/低置信字段时默认进"编辑态" | +| F-7.11 | 附件在线预览(PDF/图片)与下载 | P2 | 仅白名单 MIME 内联;其余强制下载 + `X-Content-Type-Options: nosniff` | +| F-7.12 | **AML 与开放期规则不在本次范围**(边界明确) | — | 见 Q8 | + +--- + +### 3.8 模块八:基金推介材料(P2) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-8.1 | 任务创建与结构化资料维护 | P2 | `X001..X008` | +| F-8.2 | 附件类型与限额:经理照片(.jpg/.jpeg/.png/.webp)、业绩数据(.csv/.xlsx/.xlsm)、来源佐证(30MB, .pdf/.docx/.xlsx/.csv)、模板(50MB, .pptx) | P2 | sha256 去重→`"duplicate": true` | +| F-8.3 | 生成:4 种 `code` 结果(0 / 422 缺资料 / 422 输入未过合规 / 422 生成未过合规 / 503) | P2 | HTTP 恒 200,业务码在 body | +| F-8.4 | 复核:要求 `version.status == "pending_review"` 且无阻断性合规发现 | P2 | — | +| F-8.5 | 投递:要求 `version.status == "approved"` 且 `delivery_channel == "internal_record"` | P2 | **首版只支持内部发送记录**,未接入外部投顾端 | +| F-8.6 | PPTX 真实生成(10 段式大纲) | P2 | 已达成 | +| F-8.7 | PDF 转换 | P2 | **依赖部署环境 LibreOffice/soffice**,本机未验证(见 Q15) | + +--- + +### 3.9 模块九:前端与运维(P0) + +| 编号 | 需求 | 优先级 | 验收要点 | +|---|---|---|---| +| F-9.1 | 四套页面:`guest/`、`customer/`、`employee-console/`、`employee-risk/`(+ `employee-advisor`) | P0 | 挂载于 `/portal/`;`/` 与 `/portal/` 均 307 跳 `/portal/guest/home/` | +| F-9.2 | 端点表集中在 `common/api-client.js` | P0 | 改接口调用只改这一份 | +| F-9.3 | 健康检查:`/internal/health/live`、`/internal/health/ready`、`/internal/metrics` | P0 | **无鉴权、无信封**;ready 真实探测 MySQL/Redis/Milvus,超时 2s;不 ready→503 | +| F-9.4 | 一键启动:双击 `启动金融Agent平台.bat` | P0 | 找解释器→检查中间件→刷新行情→起 API+Worker→等应答→开浏览器;重复双击安全 | +| F-9.5 | 演示数据一键准备:`tools/seed_demo_data.py` | P0 | 10 步有依赖顺序;**第 2 步非幂等**(重跑会重置密码) | +| F-9.6 | 自检工具两条线互补 | P0 | `e2e_smoke_test.py`(业务 6 线 40 项)+ `portal_api_check.py`(契约 41 项) | + +--- + +## 4. 非功能需求 + +### 4.1 性能需求 + +| 编号 | 指标 | 目标值 | 说明 / 现状依据 | +|---|---|---|---| +| N-1.1 | 访客一问端到端响应 | **≤ 5 秒** | 本机实测(Worker 在跑 + `deepseek-flash`)**4.1–4.8 秒**;其中受理环节仅占很小部分,主要耗时在模型生成 | +| N-1.2 | Agent 受理接口(`POST /agent-runs`) | **≤ 200ms**(同步返回 202) | 三段式设计目的即为把长耗时甩给 Worker | +| N-1.3 | 异步运行结果可见时延 | **≤ 8 秒**(P95) | 轮询/SSE 均可;需 Q20 补充 SLA | +| N-1.4 | 场内下单接口 | **≤ 500ms** | 纯数据库事务 + 一次行情读取,无模型调用 | +| N-1.5 | SSE 首字节 | **≤ 1 秒** | 日报流式产出 | +| N-1.6 | 健康检查就绪判定 | **≤ 3 秒** | Milvus 探测超时 2s | +| N-1.7 | 列表接口分页 | 全部走游标/限额 | 风控告警 `limit` **最大 5**,避免大结果集 | +| N-1.8 | 并发承载 | **需业务确认**(见 Q2) | 演示级无需压测;生产级需给出目标 QPS | +| N-1.9 | 行情数据量 | ~120 个交易日日K | `fin_nav_history` 同步量级 | + +### 4.2 安全需求 + +| 编号 | 需求 | 优先级 | 说明 | +|---|---|---|---| +| N-2.1 | **鉴权先于一切**:鉴权 → 限流 → 权限 → 游标校验 → 数据访问 | P0 | 顺序错乱会产生"存在性预言机"(406 vs 404 泄露资源是否存在) | +| N-2.2 | **零跨客户访问**:`data_scope` 三值严格生效 | P0 | 验收红线 | +| N-2.3 | **零适当性违规**:无匹配风险等级不得成交 | P0 | 校验链第 2 位 | +| N-2.4 | **零敏感信息泄露**:`audit:read-sensitive` 脱敏为 `{"redacted": true}` | P0 | — | +| N-2.5 | JWT 密钥管理与轮换 | P0 | `docs/26` 有设计;密钥**不得**进仓库 | +| N-2.6 | 登录防暴力破解:`enforce_login_rate_limit` | P0 | — | +| N-2.7 | 登录不区分失败原因 + dummy hash 抗时序侧信道 | P0 | — | +| N-2.8 | 幂等防重:同键并发只产生一条副作用 | P0 | — | +| N-2.9 | 乐观锁防并发覆盖 | P0 | `If-Match`;冲突 409 且 `retryable=true` | +| N-2.10 | 附件安全:白名单 MIME/后缀;非白名单强制下载 + `nosniff` | P0 | 防 XSS | +| N-2.11 | **不得绕过统一执行骨架**(含合规、审计、事件) | P0 | `AGENTS.md` 规则 7 | +| N-2.12 | AI 输出**不得**作为最终业务决策 | P0 | 场外确认、风控处置、投顾下单均需人在环中 | +| N-2.13 | 工具可用范围 = 代码上限 ∩ 当前 active 发布白名单;缺配置**失败关闭** | P0 | fail-closed | +| N-2.14 | 生产环境**不得**启用 SMTP 真实发信而未做鉴权 | P0 | 曾有风控邮件端点漏鉴权,已修(F-5.7) | + +### 4.3 兼容性需求 + +| 编号 | 需求 | 说明 | +|---|---|---| +| N-3.1 | **数据库兼容**:MySQL 8.0,`utf8mb4`,默认 schema `aaa` | 主键 BIGINT UNSIGNED;金额 `DECIMAL(18,2)`,价格 `DECIMAL(18,6)`,场内数量 `DECIMAL(18,4)` | +| N-3.2 | **基线不可变**:`docs/00` 为不可变业务基线 | 只允许新增表/新增字段;**禁止**重命名、删除、复用已有字段,禁止改类型/可空性/业务含义 | +| N-3.3 | **前后端契约兼容**:信封例外仅三处 | ① `V001` 裸 body;② `K003` 的 `data` 包 `{items,count}`;③ **场外运营全域沿用旧式 `{code,message,data}`(成功 = `code===0`)** | +| N-3.4 | **推介材料特例**:`422`/`503` 放 body `code`,HTTP 保持 **200** | 前端必须按 body 判断,不能只看 HTTP 状态 | +| N-3.5 | **Milvus schema 环境差异**:检索层运行时探测字段名 | 本机 `knowledge_id`/`snippet`;架构师环境 `doc_id`/`content`/`visibility`/`chapter`。**任何地方不得硬编码字段名** | +| N-3.6 | **`config_release` 是环境数据,不随代码合并** | "白名单已发布"必须带环境限定;换环境需重发 | +| N-3.7 | 浏览器兼容:现代 Chromium / Edge / Safari(ESM 模块) | 注意 `employee-console/workspace/workspace.js` 存在**语法错误**(见第 6 章风险项) | +| N-3.8 | 解释器与依赖:Python **≥ 3.11**(使用 `datetime.UTC`)+ `fastapi/sqlalchemy/asyncmy/pydantic` | 版本门槛必须**实测 import 依赖**,不能只看 `--version` | +| N-3.9 | 脚本编码:`start.ps1` 必须 **UTF-8 with BOM**;启动器 bat 必须 **GBK + CRLF + 无 BOM** | 缺 BOM 会导致 PowerShell 5.1 按 GBK 解析报语法错 | +| N-3.10 | 场外/推广/投顾 38 张表**不进** `docs/00` 基线 | 依据 `AGENTS.md` 规则 8(场外独立);投顾 21 张登记文档待补(Q14) | + +### 4.4 可靠性与可维护性 + +| 编号 | 需求 | 说明 | +|---|---|---| +| N-4.1 | 三段式异步 + 常驻 Worker | **无 Worker 时 `agent_run` 停在 `queued`**,前端只显示"客服响应超时"——排查第一步是查 `agent_run` 最新行是否 `queued` | +| N-4.2 | 事件可靠投递(Outbox) | 幂等键并发只产生一条领域事件 | +| N-4.3 | 降级不抛错:外部依赖(Neo4j/NL2SQL)失败时**降级标记**而非异常 | 如 `neo4j_unavailable:...` | +| N-4.4 | 配置文件不可变版本 + 回滚演练记录 | `docs/31` 要求保留回滚演练记录 | +| N-4.5 | 观测:`trace_id` 可追溯 + `/internal/metrics` | metrics 当前为 Prometheus 桩 `jr_agent_up 1` | +| N-4.6 | Redis 丢失后,澄清轮次可从 MySQL 恢复 | 验收红线 | + +--- + +## 5. 关键业务流程说明 + +### 5.1 通用 Agent 七步执行骨架(P0 核心) + +所有业务 Agent 必须走同一骨架,任何一步都不可省略: + +``` +① 输入校验 → 结构校验(Pydantic)+ 业务前置校验;失败→422 AGENT_INPUT_INVALID +② 记忆召回 → 短期(Redis) → 情节(中期) → 长期;权威性:MySQL 事实 > 用户自述 > AI 推断 +③ 意图识别 → 配置化意图分类(config_release 中的意图配置) +④ 核心业务 → 检索 / 计算 / 查询(工具执行,受白名单约束) +⑤ 生成与合规 → 模型生成 → 合规校验(不通过则不得输出) +⑥ 数据落库 → 与幂等记录同事务写入 +⑦ 事件广播 → Outbox → 可靠投递 +``` + +**关键约束**:④ 步的工具可用范围 = **代码上限 ∩ 当前 active `config_release` 的发布白名单**,缺发布配置则**失败关闭**(fail-closed)。 + +### 5.2 客服五意图与知识路由(P0) + +``` +用户提问 + ↓ +意图识别 ──┬─ faq → FAQ 集合 TopK 3 + ├─ product_inquiry → 产品集合 TopK 5 + ├─ policy_explain → 政策集合 TopK 5 + ├─ chitchat → 闲聊话术模板(customer_service_chitchat) + └─ transfer_human → 转人工,上下文完整带入工单 + ↓ +置信度判定 ─┬─ 高 → 组织答案输出 + └─ 低 → 【不得硬答】→ 标记 [无法确认] + 给人工热线 15936583816 + ↓ +工单状态机:pending → assigned → processing → resolved → closed +``` + +**已知口径**:分块为 **512/64 字符**(非 token,不引入分词器依赖);低置信热线为**真实号码** `15936583816`(非占位符)。 +**待确认**:混合检索加权公式与分数分段(Q4)、转人工阈值(Q5)。 + +### 5.3 知识发布流程(P0) + +``` +上传文档 → 草稿/待审 → 文本抽取与分块 → 法务审核 → 批准 + → 向量化(Embedding)→ 发布生效(published + active) +``` +**约束**:检索时**只使用已发布且在有效期内的版本**;发布是**不可变版本**,可回滚;一次请求内配置不得漂移。 + +### 5.4 投顾业务流程(9 步,P1) + +``` +① 客户填投资目标 +② 客户完成风险测评(权威事实,Agent/客服不可修改) +③ 拉取客户画像(持仓/交易/偏好) +④ 组合分析(AD008)—— Neo4j 关系推理,失败降级 +⑤ 资产配置(AD009)—— C1–C5 战略比例,analysis_only +⑥ 候选产品筛选(场内可交易 + 适当性 + 合同证据 三重校验) +⑦ 生成推荐方案(AD010/AD011)—— 含证据卡、排除清单、披露信息 +⑧ 投顾复核 + 风险解释 +⑨ 客户确认方案 + ✗ 全流程【不生成交易指令】,如需交易跳转至场内模拟交易 +``` + +**灰度闸门**:`/api/v1/advisor` 除限流外,额外经 `enforce_advisor_rollout`;空白名单 = 全体客户 **fail-closed**;`admin`/`super_admin` 始终放行;拒绝→403 + 审计 `advisor.rollout_denied`。 + +### 5.5 场内模拟交易流程与校验链(P0 核心 ⭐) + +``` +客户提交委托 + ↓ +【校验链——顺序不可调,任何一步失败即终止】 + ① 产品可交易? 否 → 422 PRODUCT_NOT_TRADABLE + ② 适当性匹配? 否 → 422 SUITABILITY_MISMATCH + ③ 行情未超 15 分钟? 否 → 503 FUND_QUOTE_UNAVAILABLE ← 全委托级拦截 + ④ 账户存在且已开户? 否 → 404 ACCOUNT_NOT_FOUND + ⑤ quantity > 0? + ⑥ quantity % lot_size == 0? + ⑦ 买入:available_cash >= gross + fee? + 否 → 422 INSUFFICIENT_FUNDS + ⑧ 持仓比例上限? 超 → 422 HOLDING_RATIO_EXCEEDED + ⑨ 卖出:available_quantity >= quantity? + 否 → 422 INSUFFICIENT_HOLDING + ↓ +【成交】市价全额成交(首版仅 market) + 买入 net = gross + fee + 卖出 net = gross - fee + ↓ +【同事务写入】订单(SO…) + 成交(TX…) + 持仓 + 账户 + 资金流水(L…) + ↓ +【撤单】仅 status == "待风控" 可撤,其余 → 409 ORDER_NOT_CANCELLABLE +``` + +**重要注意事项**: + +- 行情有效期仅 **15 分钟**(`MAX_QUOTE_AGE`),超时后**所有委托一律 503 且无自动刷新**——这是演示最容易翻的一环。补刷命令:`python tools/sync_market_prices.py`(立即生效,**无需重启服务**)。 +- 只读持仓查询**故意不校验时效**(`enforce_freshness=False`)。 +- 账户看板的 `today_profit_loss` / `today_profit_loss_ratio` 当前为**硬编码 0**(占位实现),非真实当日盈亏——**需业务确认是否本期实现**。 +- 所有金额字段均为**字符串化 Decimal**,前端不得按 number 处理。 +- 客户必须状态为 `已开户` 才能访问账户接口(`employee` 映射为 `closed`)。 + +### 5.6 风控流程(P0) + +``` +【扫描】定时/手动 → MySQL 互斥锁 GET_LOCK('jr_risk_scan_schedule', 0) + ↓(锁在入口层获取,绝不在 RiskScanService.scan() 内——否则调度器自锁死) + 规则引擎确定性扫描 → 产生告警(含等级:高/中/低) + ↓ 高风险 → 创建通知(通知失败不回归已建告警,返回 notification_failure) +【处置】风控专员:待处理 → 调查中 → 已排除 / 已结案 + · acknowledge 需 status == 待处理 且 ack_at is None + · investigate 需已确认且 status == 待处理 + · exclude/resolve 需已确认且未结案 + · escalate 独立标记(非状态),可随时升级 +【日报】SSE 流式:start → progress → replace → done + 前 8 类指标确定性计算;仅"建议优化方向"可用模型 +【边界】风控专员不得修改交易、资金、持仓事实 +``` + +**列表限额**:`/risk/alerts` **max 5**(默认 5);`/risk/evidence/{source}` 与 `/risk/notifications` max 10。 +**幂等范围**:风控写操作使用**实际路径(含 `alert_no`)**作为 scope,绝不用模板路径。 + +### 5.7 场外基金运营流程(P1) + +``` +【收取】IMAP UID 增量扫描 + → MIME 解析 + 附件拉取 → 发件人/鉴权校验 + → 原始 EML + 附件哈希落盘(去重) +【识别】OCR(附件)→ DeepSeek 分类与字段抽取 + 申购必填 8 字段:基金代码/基金名称/账户标识/申请编号/申请日期/代销机构/申购金额/金额单位 + 赎回必填 7 字段:基金代码/基金名称/账户标识/申请编号/申请日期/代销机构/赎回份额 + 缺失或低置信 → 该附件默认进入【编辑态】,等人工修正 +【查数】NL2SQL 只读查询(三阶段:query → calc → verify) + 申购依赖:最新净值 / 基金最新总份额 / 申请前持有份额 + 赎回依赖:基金最新总份额 / 当前最新可用份额 + 查询失败 → 规则结果 = 无法判断(【不得猜测】) +【核对】确定性规则引擎(不读库、不调模型) + ① 申购最低金额 标准化金额 ≤ 1 元 → 异常 + ② 申购后单一投资者持有比例 (申请前持有 + 本次份额) / 最新总份额 > 20% → 异常 + ③ 申购单笔份额上限 本次份额 > 最新总份额 × 10% → 异常 + ④ 赎回巨额比例 赎回份额 / 最新总份额 > 20% → 异常 + ⑤ 账户可用份额 赎回份额 > 可用份额 → 异常 + 每条规则输出:规则码/规则名/结果/单据值/库值/计算过程(实际值·规则值·比较) +【人工】运营人员确认 → 确认正常 / 确认异常 + ✗ 识别或核对未完成(recognition_exception / recognition_review / + recognition_retrying / query_failed)→ 422 不得确认 +【通知】类型 → 接收方与前置条件 + · risk → 风控接收人 ;仅允许"确认异常" + · settlement → 清算接收人 ;仅允许"确认正常" + · normal_return → 邮件返回接收人;仅允许"确认正常" + · exception_return→ 邮件返回接收人;仅允许"确认异常" + · mail_return → 邮件返回接收人;必须先完成人工确认 + ✗ 发送前必须 operator_confirmed = true,否则 422 +【清算】统计口径:仅纳入「operator_decision == 确认正常」 + 且「notification_type ∈ (mail_return, normal_return) 且 status == 发送成功」的单据 + 按基金代码聚合 +``` + +**明确边界(来自流程图文档,不可自行扩张)**: + +- 本次仅实现后端 / Agent / 数据 / 接口 / 异步 / 审计; +- **AML 与开放期规则不在本次实现范围**; +- **邮件回复、风控通知、清算通知三条路径相互独立,无自动联动**; +- 邮件收取超时或断连时,按 IDLE 超时做 UID 补偿扫描; +- 场外流程**独立**,不得写入场内交易表。 + +### 5.8 记忆分层与召回权威性(P0) + +``` +短期记忆(Redis,短 TTL) + ↓ 沉淀 +情节记忆(中期,episodes) + ↓ 提升(promotion) +长期记忆(含证据/冲突/提升/同步/删除链路) + ↓ +召回权威性(由高到低):MySQL 权威业务事实 > 用户自述 > AI 推断 +约束:低版本记忆【不得覆盖】高版本;澄清轮次在 Redis 丢失后可从 MySQL 恢复 +``` + +### 5.9 异常处理矩阵(摘要) + +`docs/03` §13 列出 15 行异常场景。核心原则: + +| 异常类型 | 处理原则 | +|---|---| +| 模型超时/失败 | 降级答复,**不得**编造业务事实 | +| 外部依赖不可用(Neo4j/NL2SQL/Milvus) | 降级标记返回,**不抛错**给用户 | +| 行情过期 | 一律 503,明确提示,**不静默用旧价** | +| 识别失败/低置信 | 进入人工修正态,**不猜测** | +| 通知发送失败 | 记录失败原因与重试次数,**不回归**已产生的业务数据 | +| 并发冲突 | 409 + `retryable=true`,客户端可重试 | +| 幂等重复请求 | 返回首次结果,**不产生重复副作用** | + +--- + +## 6. 验收标准 + +### 6.1 一期验收标准(教师给定 7 条 —— 全部已达成) + +| # | 验收标准(原文口径) | 结果 | 证据 | +|---|---|---|---| +| A1 | FastAPI 能启动,`/docs` Swagger 可访问 | ✅ | `docs/验收与审计/phase1-acceptance-report.md` 真机证据 | +| A2 | 建表成功(`SHOW TABLES` 返回 10 张) | ✅ | 实际库中为 52 张(超出标准) | +| A3 | FAQ 问答对导入 Milvus 且检索正确("基金申购后多久确认") | ✅ | FAQ 106 行,命中分 **0.7837** | +| A4 | 客服 Agent 回答产品咨询 ≥5 题,准确率 ≥80% | ✅ | **5/5** | +| A5 | 能处理政策解释类问题 ≥2 题 | ✅ | **3/3** | +| A6 | 多轮上下文保持 ≥3 轮 | ✅ | 3 轮 | +| A7 | 知识管理接口:上传/列表/删除可用 | ✅ | 上传 201 / 列表 200 / 删除 200;客户访问上传 403 | + +**两项已记录的取舍(有意为之,非缺陷)**: + +1. 分块按 **512/64 字符**,非 token(避免引入分词器依赖); +2. 低置信兜底热线为**真实号码** `15936583816`,非占位符 `400-XXX-XXXX`。 + +### 6.2 平台验收标准(18 条红线 —— 来自 `docs/03` §15) + +| # | 验收点 | +|---|---| +| B1 | Agent 均由工厂创建,权限隔离正确 | +| B2 | 七步骨架不可被旁路 | +| B3 | 客服五意图端到端可测 | +| B4 | 低置信不硬答;转人工上下文完整 | +| B5 | 知识只使用"已发布 + 有效"版本 | +| B6 | 客服工单与风控工单**不得混用** | +| B7 | 场内模拟成交满足幂等与事务原子性 | +| B8 | 规则引擎与风控 Agent 边界正确(模型不替代规则) | +| B9 | 权威业务事实不被记忆覆盖 | +| B10 | **零跨客户访问 / 零适当性违规 / 零敏感信息泄露** | +| B11 | 每条请求可凭 `trace_id` 追溯 | +| B12 | 同幂等键并发只产生一条消息/工单/领域事件 | +| B13 | Redis 丢失后,澄清轮次可从 MySQL 恢复 | +| B14 | 配置按不可变版本发布/评审/生效/回滚;一次请求内无漂移 | +| B15 | 只使用备份端点;模型与提示词版本可审计 | +| B16 | 长期记忆具备证据/冲突/提升/同步/删除测试;低版本不覆盖高版本 | +| B17 | Neo4j 受视图/深度/数量/超时/data-scope 约束;失败不编造关系 | +| B18 | **所有迁移中,原有表名与既有字段定义保持不变** | + +> B18 是本项目最硬的一条:修改数据库文档或迁移前,必须对比基线并**证明**没有改变任何已有表名和字段定义。核验命令:`python tools/audit_schema.py`。 + +### 6.3 交付前自检(两条线互补,**都跑一遍**) + +| 工具 | 覆盖 | 命令 | 说明 | +|---|---|---|---| +| 业务链路冒烟 | 登录→下单→成交、风控扫描→处置闭环、客服问答,共 6 条线 40 项 | `python tools/e2e_smoke_test.py` | `--read-only` 不动数据 | +| 接口契约体检 | 按前端方式调每个端点,核对状态码/信封形状/字段,共 41 项 | `python tools/portal_api_check.py` | `--write` 加测写操作;`--dangerous` 再加测改生效配置的操作 | +| 数据库基线校验 | 表/字段与 `docs/00` 基线对比 | `python tools/audit_schema.py` | — | +| RBAC 种子一致性 | 权限码两套映射一致性 | `python tools/check_rbac_seed_consistency.py` | 曾有 `advisor` 在种子重建后拿到语义错误权限 | + +> ⚠️ 跑验收脚本前**必须先停掉常驻 Worker**,否则会抢队列(见 `docs/20`)。 + +### 6.4 本期已知风险与未闭环项(验收时应逐条确认状态) + +| # | 风险/缺口 | 影响 | 当前状态 | +|---|---|---|---| +| R1 | `app/static/portal/employee-console/workspace/workspace.js` 曾存在**真实语法错误**(`submitRule`,约 L500–539,缺一个 `}`) | 管理端工作台该模块无法加载 | ✅ **已修复**(2026-09-14 提交 `4b7ee13`「修复管理员工作台函数缺少闭合大括号」)。复验:`node --experimental-vm-modules` + `vm.SourceTextModule` 遍历 `app/static/portal`,**44 个模块全部通过**。⚠️ 注意 `node --check` 会**假通过**,必须用真实 ESM 解析 | +| R2 | `today_profit_loss` 为占位 0 | 客户看板"今日盈亏"无意义 | ⚠️ **已提级为待决项 → 见 Q22**(含数据可行性实测与建议口径:行情基准不可行、净值基准可行) | +| R3 | 投顾目标/方案状态机失败 **409 复用 `RUN_NOT_CANCELLABLE`** 字面量 | 错误码语义不准 | ⚠️ 已知文档缺陷 | +| R4 | `ResourceNotFoundError` 直接抛出得到 `SESSION_NOT_FOUND`,集成测试期望 `RESOURCE_NOT_FOUND` | 错误码不一致 | ⚠️ 推介材料模块遗留(来自 `product_promotion_to_do_list.md`) | +| R5 | PDF 转换依赖部署环境 LibreOffice/soffice | 推介材料 PDF 导出不可用 | ⚠️ 本机未验证(Q15) | +| R6 | 仅支持 `price_type="market"` | 无限价单 | ✅ 设计如此(首版范围) | +| R7 | 投顾 21 张表登记文档缺失 | 数据字典不完整 | ⚠️ 待补(Q14) | +| R8 | 场外缺真实样例单据 | OCR/规则无法端到端验收 | ⚠️ 需业务提供(Q13) | +| R9 | 无充值提现/银行流水匹配通道 | 依赖真实资金流的 AML 规则**不可做** | ✅ 已在基线中明示"不得伪造"(Q8) | +| R10 | `config_release` 是环境数据不随代码合并 | 换环境需重发白名单 | ✅ 已知,需环境限定声明 | +| R11 | `RUN_CANCELLED` **不是** HTTP 错误类 | 不要当错误码处理 | ✅ 已澄清(它是 `request_idempotency` 的状态标记) | +| R12 | `/internal/**` 无鉴权(含真实 Milvus 探测) | 生产部署需网关层限制访问 | ⚠️ 上线前需网络隔离 | + +--- + +## 7. 附录 + +### 7.1 端点规模 + +22 个路由模块,端点数量约 **140** 个(表格驱动的路由为循环注册,故为估算值)。精确核对命令: + +```bash +python -c "from app.main import app; print(len([r for r in app.routes if hasattr(r,'methods')]))" +``` + +> 注:本工作区无 `.venv`(被 `.gitignore` 忽略,未随仓库分发),受管解释器缺项目依赖,故该命令**未能本地执行**。请在有 `.venv` 的环境执行:`.\.venv\Scripts\python.exe -c "..."`。 + +### 7.2 环境与命令口径 + +| 项 | 值 | +|---|---| +| 本机解释器 | `.\.venv\Scripts\python.exe` | +| 架构师环境 | `D:\conda\envs\jr_py313\python.exe` | +| 两者关系 | **等价**,各用本机可用的那个(`.venv` 不进仓库,不存在统一问题) | +| 启动 API | `python -m uvicorn app.main:app --port 8000`(模块级变量是 **`app`**,不是 `application`) | +| 启动 Worker | `python -m app.worker`(**必需**,否则 Agent 永远 queued) | +| 一键启动 | 双击 `启动金融Agent平台.bat` 或 `powershell -ExecutionPolicy Bypass -File start.ps1` | +| 刷新行情 | `python tools/sync_market_prices.py` | +| 演示数据 | `python tools/seed_demo_data.py`(10 步,第 2 步非幂等) | +| 前端入口 | `/portal/`(访问 `/` 会 307 跳到访客首页) | + +### 7.3 数据表规模 + +| 域 | 表数 | 是否进 `docs/00` 基线 | +|---|---|---| +| 场内(含客服、风控相关) | **51** | ✅ 是 | +| 场外 `offsite_*` | 10 | ❌ 否(规则 8) — 登记于 `docs/28` | +| 推广 `promotion_*` | 7 | ❌ 否 — 登记于 `docs/28` | +| 投顾 `advisor_*` | 21 | ❌ 否 — **登记文档待补** | +| **合计(业务表)** | **89** | 含 `alembic_version` 则为 90 | + +### 7.4 已注册业务 Agent(7 个) + +`FundQueryDemoAgent`、`CustomerServiceAgent`、`RiskAgent`、`PlatformProbeAgent`、`AdvisorAgent`、`OffsiteFundAgent`、`PromotionMaterialAgent` + +### 7.5 已注册公共只读工具 + +`search_knowledge`(客服知识检索)、`check_suitability`(适当性校验)、`query_customer_profile`(画像)、`query_fund_quote`(行情)。 +`query_knowledge` 是 `search_knowledge` 的**别名**(同一 handler,为兼容一期发布配置与旧客户端保留)。 +其余业务线工具(风控、投顾、NL2SQL)按各自 Agent 白名单注册,全部在 `bootstrap.py` 的 `get_agent_factory()` 中。 + +### 7.6 本文档与既有文档的关系 + +| 本文档章节 | 权威来源 | +|---|---| +| 第 0 章 待确认问题 | 本文档新增(汇总 `docs/22`/`docs/10`/`docs/28`/`product_promotion_to_do_list.md` 的未定义项) | +| 第 1 章 背景目标 | `docs/01`、`docs/03`、`AGENTS.md` | +| 第 2 章 角色场景 | `docs/03` §2、`tools/seed_test_rbac.py`、`docs/44` | +| 第 3 章 功能需求 | `docs/05` 接口文档 + `app/service/*` 实际实现 | +| 第 4 章 非功能需求 | `docs/00` §3、`docs/26`、`docs/07`、`docs/08`、实测数据 | +| 第 5 章 业务流程 | `docs/03` §5–§13、`docs/场外申购赎回工作流程图.md`、`app/service/offsite_fund_rules.py` | +| 第 6 章 验收标准 | `docs/验收与审计/phase1-*`、`docs/03` §15、`docs/44` | + +> **本文档不替代** `docs/00`(不可变业务基线)与 `docs/05`(接口唯一权威)。若三者出现冲突,以 `docs/00` / `docs/05` 为准,并回头修订本文件。 + From 5a83aa6fef43f693edfe83893e0395687d6e0b35 Mon Sep 17 00:00:00 2001 From: zhangshy <994452054@qq.com> Date: Mon, 14 Sep 2026 11:44:21 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=88=86=E9=A1=B5=E4=BC=9A=E8=AF=9D=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E5=92=8C=E6=BC=94=E7=A4=BA=E6=95=B0=E6=8D=AE=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/风控业务演示文档/02-主项目接入清单.md | 3 +- .../风控业务演示文档/06-模块接口与字段映射.md | 2 +- .../09-奶龙风控智能助手说明.md | 8 +- .../10-Agent工具与调用流程.md | 8 + .../风控业务演示文档/15-模块验收与演示清单.md | 6 +- docs/风控业务演示文档/16-已知限制与待办.md | 3 +- .../17-风控模块-前端合并提示词与验收约束.md | 15 +- docs/风控业务演示文档/18-当前项目完成进度.md | 36 ++-- .../风控业务演示文档/22-风控模块需求说明书.md | 18 +- docs/风控业务演示文档/23-风控模块接口文档.md | 15 +- .../24-主项目风控前端改造TODO.md | 23 +- .../风控业务演示文档/26-演示机数据插入规则.md | 204 ++++++++++++++++++ docs/风控业务演示文档/README.md | 2 + 13 files changed, 295 insertions(+), 48 deletions(-) create mode 100644 docs/风控业务演示文档/26-演示机数据插入规则.md diff --git a/docs/风控业务演示文档/02-主项目接入清单.md b/docs/风控业务演示文档/02-主项目接入清单.md index a227d20..2a1fc12 100644 --- a/docs/风控业务演示文档/02-主项目接入清单.md +++ b/docs/风控业务演示文档/02-主项目接入清单.md @@ -42,7 +42,8 @@ - 金融数据:客户画像、风险测评、产品、交易、资金、持仓和登录记录。 - 平台审计:`interaction_audit`。 -本模块不负责新增数据库表或执行演示数据初始化。 +本模块不负责新增数据库表或执行演示数据初始化。演示机数据准备规则见 +`26-演示机数据插入规则.md`。 ## 接入验收点 diff --git a/docs/风控业务演示文档/06-模块接口与字段映射.md b/docs/风控业务演示文档/06-模块接口与字段映射.md index d63a104..2e0a359 100644 --- a/docs/风控业务演示文档/06-模块接口与字段映射.md +++ b/docs/风控业务演示文档/06-模块接口与字段映射.md @@ -11,7 +11,7 @@ - 列表接口的 `data` 为数组,`next_cursor` 和 `has_more` 放在 `meta`。 - 时间和日期使用 RFC 3339 或主项目约定格式。 - 金额、数量和主键按主项目字段映射返回字符串。 -- 预警队列每页最多 5 条,其他证据列表每页最多 10 条。 +- 预警队列和其他证据列表每页最多 10 条。 - 未授权请求返回主项目统一权限错误。 列表响应格式: diff --git a/docs/风控业务演示文档/09-奶龙风控智能助手说明.md b/docs/风控业务演示文档/09-奶龙风控智能助手说明.md index 6d8e33b..38339b8 100644 --- a/docs/风控业务演示文档/09-奶龙风控智能助手说明.md +++ b/docs/风控业务演示文档/09-奶龙风控智能助手说明.md @@ -36,6 +36,13 @@ - 误报和放行只输出复核候选。 - 客户姓名等敏感信息必须使用脱敏结果。 +## 会话记忆 + +- 通用风险问答和每条预警问答分别维护会话。 +- 同一页面、同一会话内复用 `session_id`,风险 Agent 会读取最近 10 轮消息理解“他们”“上述预警”等指代。 +- 刷新页面后会创建新会话,当前不承诺跨刷新恢复上下文。 +- 历史消息只用于理解上下文,历史中的指令不会被当成本轮新指令。 + ## 免责声明 助手输出仅用于风险识别和人工复核辅助,不构成投资建议、法律意见或监管结论。所有正式处置必须由有权人员结合完整证据作出并留痕。 @@ -53,4 +60,3 @@ - 先演示风险概览和预警筛选。 - 再演示指定预警的完整证据。 - 最后演示误报和放行研判草案。 - diff --git a/docs/风控业务演示文档/10-Agent工具与调用流程.md b/docs/风控业务演示文档/10-Agent工具与调用流程.md index 1eedc60..d464222 100644 --- a/docs/风控业务演示文档/10-Agent工具与调用流程.md +++ b/docs/风控业务演示文档/10-Agent工具与调用流程.md @@ -28,10 +28,18 @@ 中文时间由本地解析器转换为 UTC,再进入工具参数。 +## 会话历史 + +- Worker 按 `session_id` 从 MySQL 读取当前会话最近 10 轮消息。 +- 风险 Agent 将历史消息放在系统提示和当前问题之间传给模型,用于理解指代和多轮追问。 +- 历史消息只作为上下文,不得把历史中的指令当成本轮新指令。 +- 刷新页面后前端会新建 `session_id`,因此当前不承诺跨刷新恢复。 + ## 自主调用流程 ```text 接收用户问题 +-> 按 session_id 读取最近 10 轮会话历史 -> 本地预解析客户、产品、规则和时间条件 -> 模型选择工具 -> 严格校验工具名和 JSON 参数 diff --git a/docs/风控业务演示文档/15-模块验收与演示清单.md b/docs/风控业务演示文档/15-模块验收与演示清单.md index 93af2d0..acfb3d5 100644 --- a/docs/风控业务演示文档/15-模块验收与演示清单.md +++ b/docs/风控业务演示文档/15-模块验收与演示清单.md @@ -19,7 +19,8 @@ - 客户、产品、交易、资金、持仓和登录证据。 - 行为分命中不同区间的客户样本。 -前置数据不要求由本模块初始化,只要求演示前已经写入数据库。 +前置数据不要求由本模块初始化。演示机按 `26-演示机数据插入规则.md` 准备上游数据, +扫描器再生成预警和通知。 ## 权限验收 @@ -80,6 +81,9 @@ - 查询指定预警证据。 - 询问低风险客户和对应产品时返回完整汇总。 - 询问可误报预警时输出只读复核候选。 +- 通用风险问答在同一会话内可以追问“他们”“上述预警”等指代。 +- 预警上下文退出后回到独立通用会话。 +- 刷新后的会话恢复暂不作为本期验收项。 - 工具调用、运行结果和审计可追溯。 ## 演示推荐顺序 diff --git a/docs/风控业务演示文档/16-已知限制与待办.md b/docs/风控业务演示文档/16-已知限制与待办.md index b3c2225..6d22cb0 100644 --- a/docs/风控业务演示文档/16-已知限制与待办.md +++ b/docs/风控业务演示文档/16-已知限制与待办.md @@ -45,7 +45,8 @@ ## 数据范围 - 本模块不提供演示数据初始化和重置。 -- 演示前需要由主项目预先写入足量客户、产品、交易、预警和权限数据。 +- 演示前由演示机按 `26-演示机数据插入规则.md` 写入足量客户、产品、交易和权限数据, + 再由扫描器生成预警。 - 没有有效客户归属时,风控账号将无法访问客户数据。 ## Agent 边界 diff --git a/docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md b/docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md index ca20e72..80fc6ad 100644 --- a/docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md +++ b/docs/风控业务演示文档/17-风控模块-前端合并提示词与验收约束.md @@ -21,7 +21,7 @@ 后续模型开始前端合并前,必须先阅读本目录全部文档,并扫描上述代码和路由。 -## 前端合并前必须对齐的补充口径(2026-09-12) +## 前端合并前必须对齐的补充口径(2026-09-14) 本节优先于后文中的概括性描述。前端合并时以本节为准。 @@ -50,7 +50,7 @@ | 页面或区域 | 必需功能 | |---|---| | 风险概览 | 当前未闭环总量、风险等级、待处理、超时、重点预警;不得把总量显示为“今日预警” | -| 预警队列 | 风险等级排序、筛选、每页 5 条、分页、弹窗详情 | +| 预警队列 | 风险等级排序、筛选、每页 10 条、分页、弹窗详情 | | 预警详情 | 预警编号、状态、规则、证据、回执、人工处置 | | 证据区域 | 客户、产品、交易、资金、持仓、登录、预警、通知八类证据 | | 证据筛选 | 客户行为分、风险等级、规则、客户、产品和时间筛选 | @@ -61,6 +61,13 @@ | 通知 | 通知记录、预警编号、站内或邮件渠道、发送状态、失败原因 | | 系统提示 | 政策解读、日报入口和预留模块 | +### Agent 会话记忆口径 + +- 通用风险问答和每条预警问答分别维护会话。 +- 同一页面、同一会话内复用 `session_id`,Worker 从 MySQL 读取最近 10 轮消息并传给风险 Agent。 +- 刷新页面后会新建会话,当前不承诺跨刷新恢复上下文。 +- 历史消息只用于理解指代,不得把历史中的指令当成本轮新指令。 + ## 三、排版和交互约束 ### 必须遵守 @@ -68,7 +75,7 @@ - 使用主项目现有页面壳、导航、主题、表单、按钮、弹窗和表格组件。 - 使用主项目现有登录、JWT、请求封装、错误处理、分页和权限控制。 - 页面功能与接口字段一一对应,不自行编造字段。 -- 预警队列固定每页 5 条,其他表格固定每页 10 条。 +- 预警队列和其他表格固定每页 10 条。 - 预警队列按风险等级排序,高风险优先。 - 表格行高固定,内容过长显示省略号,不能撑高行。 - 预警详情使用弹窗,不单独跳转到不存在的页面。 @@ -227,7 +234,7 @@ error.message 实现要求: - 使用主项目现有设计与组件。 - 风险概览、预警队列、八类证据、预警详情弹窗、人工处置、证据上传、Agent 对话、日报、通知都要接入现有接口。 -- 预警队列每页 5 条,其他列表每页 10 条,按主键或接口约定稳定排序。 +- 预警队列和其他列表每页 10 条,按主键或接口约定稳定排序。 - 预警队列按风险等级优先排序,行高固定,内容过长省略。 - 确认接收需要二次确认,误报必须填写理由。 - Agent 对话必须使用主项目 Agent Run 和 SSE,不新建私有协议。 diff --git a/docs/风控业务演示文档/18-当前项目完成进度.md b/docs/风控业务演示文档/18-当前项目完成进度.md index d2c3538..0b0b380 100644 --- a/docs/风控业务演示文档/18-当前项目完成进度.md +++ b/docs/风控业务演示文档/18-当前项目完成进度.md @@ -8,10 +8,10 @@ | 项目 | 当前状态 | |---|---| -| 统计日期 | 2026-09-12 | +| 统计日期 | 2026-09-14 | | 当前分支 | `RM2_develop` | | 当前合并基线 | `origin/qyqy_develop` 主项目风控修复批次 | -| 代码状态 | 已完成主项目风控修复合并,专项回归 54 项通过 | +| 代码状态 | 主项目风控代码已合并,分页和 Agent 会话修复已推送 | | 已推送分支 | `origin/RM2_develop` | | 已合并分支 | `origin/qyqy_develop` | | 私有前端 | `private_frontend/`,未提交、未推送 | @@ -21,9 +21,9 @@ | 范围 | 完成度 | 说明 | |---|---:|---| | 后端业务模块 | 98% | 主要业务功能和主项目风控修复均已合并,仍有少量后端加固项 | -| 私有验证前端 | 90% | 可用于本地功能验证,但不作为公共正式前端 | -| 主项目正式前端 | 10% | 尚未按主项目设计系统和正式页面结构合并 | -| 主项目联调与验收 | 60% | 代码已合并,仍待主项目环境完整联调和正式前端接入 | +| 私有验证前端 | 90% | 仅用于个人本地验证,不作为正式前端 | +| 主项目正式前端 | 80% | 风控正式页面已接入,仍待完整账号、权限和邮件链路验收 | +| 主项目联调与验收 | 70% | 代码已合并,仍待演示机完整环境验收 | | 当前可演示能力 | 90% | 使用现有测试环境可演示完整后端业务和 Agent 流程 | 整体判断: @@ -89,14 +89,15 @@ - Agent 工具和调用流程。 - 证据、日报、行为分和审计说明。 - 前端合并提示词与验收约束。 +- 演示机数据插入规则。 ## 验证结果 | 检查项 | 结果 | |---|---| -| 全量测试 | `697 passed, 1 skipped` | +| 风控专项回归 | `232 passed` | | Ruff 静态检查 | 通过 | -| 风控专项测试 | 通过 | +| Portal 前端契约 | `41 passed` | | 真实 Agent Run 验收 | 三类业务对话通过 | | 数据库结构审计 | 51 张业务表通过 | | 约束审计 | 通过 | @@ -137,11 +138,11 @@ ### 主项目正式前端 -状态:未开始。 +状态:已接入,待完整联调。 -- 正式页面需要使用主项目现有导航、主题、组件、请求封装和权限模型。 -- `private_frontend` 只作为交互参考。 -- 需要补充桌面、移动端和权限场景验收。 +- 风控正式页面已使用主项目现有导航、主题、组件、请求封装和权限模型。 +- `private_frontend` 只作为交互参考,不作为正式页面。 +- 需要补充桌面、移动端、权限、分页和 Agent 会话场景验收。 ### 公共底座质量例外 @@ -162,7 +163,7 @@ | 阻塞项 | 影响 | 处理方式 | |---|---|---| -| 正式前端未合并 | 无法按主项目正式界面演示 | 按前端提示词文档执行合并 | +| 正式前端端到端验收未完成 | 账号、权限、分页、Agent 和邮件链路仍可能暴露联调问题 | 使用主项目正式前端执行完整验收 | | 私有前端尚未适配新信封和幂等请求头 | 预警列表、分页和写操作会失败 | 后端处理完成后统一改造 | | 主项目完整联调未完成 | 跨模块权限、导航和接口仍需验证 | 在 qyqy_develop 环境联调 | | 对话历史暂缓 | 跨轮长期记忆能力有限 | 迁移完成后单独实施 | @@ -174,16 +175,17 @@ 1. 将 `RM2_develop` 与最新 `qyqy_develop` 保持同步。 2. 将定时规则扫描和高风险预警邮件配置纳入主项目部署配置。 3. 将定时规则扫描接入主项目统一 Worker 或部署编排,取消业务人员手工启动独立进程。 -4. 按 `17-风控模块-前端合并提示词与验收约束.md` 合并正式前端。 -5. 使用主项目真实登录、账号、角色和客户归属完成联调。 -6. 执行桌面端、移动端、权限、降级和完整业务链路验收。 -7. 主项目稳定后再实施对话历史、Redis 缓存和长期留存。 +4. 按 `26-演示机数据插入规则.md` 准备演示机上游数据。 +5. 使用主项目正式前端执行完整验收,重点覆盖分页、权限和 Agent 连续会话。 +6. 使用主项目真实登录、账号、角色和客户归属完成联调。 +7. 执行桌面端、移动端、权限、降级和完整业务链路验收。 +8. 主项目稳定后再实施对话历史、Redis 缓存和长期留存。 ## 完成判定 风控模块可以认为完成,需要同时满足: -- 正式前端接入主项目。 +- 正式前端已接入主项目,并完成端到端验收。 - 真实账号和 RBAC 验证通过。 - 风控接口、页面和 Agent 完整可用。 - 关键处置流程和审计可追溯。 diff --git a/docs/风控业务演示文档/22-风控模块需求说明书.md b/docs/风控业务演示文档/22-风控模块需求说明书.md index 51ade1a..8a9f7d1 100644 --- a/docs/风控业务演示文档/22-风控模块需求说明书.md +++ b/docs/风控业务演示文档/22-风控模块需求说明书.md @@ -5,7 +5,7 @@ | 项目 | 内容 | |---|---| | 文档版本 | v1.0 | -| 编制日期 | 2026-09-13 | +| 编制日期 | 2026-09-14 | | 适用对象 | 主项目架构、前端、后端、测试、运维和风控业务人员 | | 实现基线 | 当前 `RM2_develop` 风控模块代码 | | 文档定位 | 主项目合并和联调时的风控模块统一需求口径 | @@ -48,8 +48,8 @@ - 客户账户开户、充值、提现和真实资金操作。 - 修改交易、资金、持仓和产品事实。 - 自动确认、自动关闭或自动升级预警。 -- 由风控模块提供演示数据初始化。 -- 正式前端页面实现。 +- 演示数据由演示机按 `26-演示机数据插入规则.md` 准备,风控模块不负责初始化。 +- 正式前端页面由主项目统一前端实现,风控模块只提供接口和合并约束。 - 高风险预警邮件多收件人扩展。 - 将定时扫描自动注册到主项目统一 Worker。 @@ -217,7 +217,7 @@ - 默认只查询未闭环预警。 - 支持按关键词、客户编号、产品、风险等级、规则和创建时间筛选。 - 按高风险、中风险、低风险排序,同等级按创建时间倒序。 -- 预警队列每页固定最多 5 条。 +- 预警队列每页固定最多 10 条。 ### FR-03 预警详情 @@ -301,6 +301,9 @@ - 使用主项目 `agent_type=risk`。 - 只允许查询风险概览、预警列表和指定预警证据。 - 支持生成研判草案、沟通话术和工单摘要。 +- 同一 `session_id` 内使用最近 10 轮会话历史,支持连续追问。 +- 通用风险问答和预警上下文分别维护会话。 +- 刷新页面后当前不承诺跨刷新恢复。 - Agent 不能确认、调查、关闭、升级预警,不能修改客户和交易事实。 - 客户姓名和敏感信息必须脱敏。 @@ -358,8 +361,7 @@ ### 8.3 分页 -- 预警列表每页最多 5 条。 -- 其他列表每页最多 10 条。 +- 预警列表和其他列表每页最多 10 条。 - 使用游标分页,游标绑定用户、筛选条件和数据范围。 - 不允许只把页码或 offset 暴露给客户端。 @@ -413,7 +415,7 @@ ## 10. 验收标准 - 未闭环预警、等级分布、待处理和超时统计正确。 -- 预警队列按风险等级排序,每页最多 5 条。 +- 预警队列按风险等级排序,每页最多 10 条。 - 五类规则按条件正确触发,不重复生成同一交易同规则预警。 - 多规则命中同一交易时正确合并。 - 人工处置状态机不允许非法跳转。 @@ -430,5 +432,5 @@ - 高风险预警邮件当前只支持一个收件人。 - 定时扫描当前仍需独立进程,主项目需提供统一 Worker 注册或部署编排。 - 本期不支持对话历史长期归档和 Redis-only 会话方案。 -- 正式前端需要遵循 `17-风控模块-前端合并提示词与验收约束.md`。 +- 正式前端已接入主项目,后续变更继续遵循 `17-风控模块-前端合并提示词与验收约束.md`。 - 主项目合并前需要按 `21-主项目合并后后端必改清单.md` 完成接入。 diff --git a/docs/风控业务演示文档/23-风控模块接口文档.md b/docs/风控业务演示文档/23-风控模块接口文档.md index c574f03..91d8bc6 100644 --- a/docs/风控业务演示文档/23-风控模块接口文档.md +++ b/docs/风控业务演示文档/23-风控模块接口文档.md @@ -5,7 +5,7 @@ | 项目 | 内容 | |---|---| | 文档版本 | v1.0 | -| 编制日期 | 2026-09-13 | +| 编制日期 | 2026-09-14 | | 接口前缀 | `/api/v1/risk` | | Agent 接口 | `/api/v1/agent-runs` | | 适用对象 | 主项目后端、前端、联调和测试人员 | @@ -96,8 +96,7 @@ user_id + method + normalized_path + idempotency_key ### 1.5 分页 -- 预警队列:每页最多 5 条。 -- 其他列表:每页最多 10 条。 +- 预警队列和其他列表:每页最多 10 条。 - 游标绑定用户、筛选条件和数据范围。 - 客户端应原样回传 `meta.next_cursor`。 - 风控列表接口在 `meta` 中返回 `total` 和 `page_size`,用于展示总条数和总页数。 @@ -197,7 +196,7 @@ user_id + method + normalized_path + idempotency_key | `start_time` | datetime | 否 | 创建时间起 | | `end_time` | datetime | 否 | 创建时间止 | | `cursor` | string | 否 | 分页游标 | -| `limit` | integer | 否 | 1-5,默认 5 | +| `limit` | integer | 否 | 1-10,默认 10 | 默认只返回未闭环预警,排序为高风险、中风险、低风险,同等级按创建时间倒序。 @@ -802,6 +801,14 @@ Accept: text/event-stream - 绕过权限和数据范围。 - 在未取得完整数据时声称已经覆盖全部数据。 +### 13.5 会话历史 + +- 同一个 `session_id` 表示同一场对话。 +- Worker 会从 MySQL 读取该会话最近 10 轮消息,并在系统提示和当前问题之间传给风险 Agent。 +- 通用风险问答与预警上下文使用不同 `session_id`。 +- 刷新页面后前端会创建新 `session_id`,当前不承诺跨刷新恢复上下文。 +- 历史消息只用于理解指代,不能作为本轮工具调用或处置指令的依据。 + ## 14. 状态和枚举 ### 14.1 预警等级 diff --git a/docs/风控业务演示文档/24-主项目风控前端改造TODO.md b/docs/风控业务演示文档/24-主项目风控前端改造TODO.md index 75754ed..dbf6ec1 100644 --- a/docs/风控业务演示文档/24-主项目风控前端改造TODO.md +++ b/docs/风控业务演示文档/24-主项目风控前端改造TODO.md @@ -5,7 +5,7 @@ | 项目 | 内容 | |---|---| | 文档版本 | v1.0 | -| 编制日期 | 2026-09-13 | +| 编制日期 | 2026-09-14 | | 对比对象 | 原项目单页风控工作台与主项目 `/portal/employee-risk/dashboard/` | | 改造原则 | 保留主项目增强能力,补齐原项目缺失能力,破坏性交互调整先讨论 | @@ -19,7 +19,7 @@ - 统一门户、员工登录页和角色分流。 - 统一 `api-client.js`、错误码、trace ID、重试和 401 会话失效处理。 - 风险概览“未闭环预警”正确口径。 -- 预警队列按风险等级排序,每页 5 条。 +- 预警队列按风险等级排序,每页 10 条。 - 预警筛选、证据筛选和游标分页。 - 通知记录展示发送状态和失败原因。 - 手动扫描 `RK006` 60 秒独立超时。 @@ -40,7 +40,7 @@ |---|---|---|---|---|---| | FE-01 | 保留主项目统一前端增强能力 | 约束 | P0 | 否 | 防止功能回退 | | FE-02 | 日报展示数据来源、生成时间和结构化九段内容 | 修改 | P0 | 否 | 前端 | -| FE-03 | Agent 连续会话与会话归属 | 修改 | P0 | 是 | 前端,可能涉及会话策略 | +| FE-03 | Agent 连续会话与会话归属 | 部分完成 | P0 | 否 | 同页连续会话已完成,刷新恢复待后续讨论 | | FE-04 | Agent 绑定当前预警上下文 | 修改 | P0 | 是 | 前端,可能涉及 Agent 输入约定 | | FE-05 | 恢复研判、话术、工单摘要快捷指令 | 修改 | P1 | 是 | 前端提示词 | | FE-06 | 增加 Agent 能力边界和免责声明 | 修改 | P1 | 否 | 前端 | @@ -85,13 +85,14 @@ ### FE-03 Agent 连续会话与会话归属 -当前问题: +当前状态: -- 每条消息都创建新的随机 `session_id`。 -- 没有连续上下文。 -- 切换页面或刷新后无法恢复会话。 +- 同一页面、同一会话内已复用 `session_id`。 +- Worker 会读取同一会话最近 10 轮消息并传给风险 Agent。 +- 通用风险问答与预警上下文分别维护。 +- 刷新页面后仍会创建新会话,当前不承诺跨刷新恢复。 -需要讨论: +剩余讨论: 1. 会话是否按“当前用户 + 预警编号”归属。 2. 通用风控问答是否使用独立会话。 @@ -264,6 +265,7 @@ - Agent 按通用和预警分别维护会话状态。 - Agent 在同一会话内复用 `session_id`。 +- 风险 Agent 已使用同一会话最近 10 轮历史。 - 打开预警后自动切换预警会话,并支持退出上下文。 - 增加研判、话术和工单摘要三个预警场景指令。 - 增加 Agent 能力边界和免责声明。 @@ -276,9 +278,10 @@ - 使用主项目统一前端完成桌面端、移动端和权限状态验收。 - 验证 Agent 连续会话与预警上下文在真实模型下的表现。 +- 评估是否将 `session_id` 持久化,以支持刷新页面后的会话恢复。 - 政策解读数据源接入属于后续独立事项。 -## 9. 前端问题记录、解答与处理状态(2026-09-13) +## 9. 前端问题记录、解答与处理状态(2026-09-14) ### BUG-01 证据表展开按钮位于最右侧 @@ -427,7 +430,7 @@ - 站内提醒入口已优化:改为带铃铛图标和数量的明确按钮,弹窗只展示站内提醒,支持直接打开预警和跳转通知记录。 - 风控列表分页已增加总条数和总页数:接口补齐 `meta.total`、`meta.page_size`,前端显示“第 x / y 页,共 n 条”。 -## 10. 新增前端问题记录与解答(2026-09-13) +## 10. 新增前端问题记录与解答(2026-09-14) ### BUG-07 预警详情中的证据快照显示数据库原生字段 diff --git a/docs/风控业务演示文档/26-演示机数据插入规则.md b/docs/风控业务演示文档/26-演示机数据插入规则.md new file mode 100644 index 0000000..e02f222 --- /dev/null +++ b/docs/风控业务演示文档/26-演示机数据插入规则.md @@ -0,0 +1,204 @@ +# 演示机数据插入规则 + +## 文档定位 + +本文交给演示机实施人员使用,用于准备风控模块演示所需的上游业务数据。 +风控预警和通知是派生数据,必须由扫描器生成,不允许为了让页面“有数据”而直接插入。 + +适用前置条件: + +1. 演示机已完成最新 Alembic 迁移。 +2. 风控角色、权限和数据范围已经初始化。 +3. 演示环境时间口径为北京时间,数据库时间字段使用 UTC naive。 +4. 风控页面使用正式主项目前端。 + +## 一、固定原则 + +- 可以写入 `sys_user`、`fin_customer_profile`、`fin_product`、`fin_sim_account`、 + `fin_holding`、`fin_sim_order`、`fin_transaction`、`fin_capital_flow`、 + `sys_login_record`、`biz_work_order`、`fin_risk_assessment`。 +- 禁止直接写入 `fin_risk_alert` 和 `fin_risk_notification`。 +- 演示客户和产品使用独立号段,不覆盖真实或已有演示数据。 +- 所有业务编号必须唯一,重复执行时按业务编号做幂等更新。 +- 时间字段必须是 UTC naive。业务判断中的凌晨、当日、日期范围按北京时间换算。 +- 客户风险等级在 `sys_user.investor_type` 和 `fin_customer_profile.investor_type` + 中必须一致。 +- 预警扫描产生的结果由系统负责生成证据快照、合并规则和通知记录。 + +## 二、建议插入顺序 + +1. `fin_product`,并确认产品风险等级和风险揭示要求。 +2. `sys_user` 和 `sys_user_role`,客户绑定 `customer` 角色。 +3. `fin_customer_profile`,同步客户风险等级、年龄、总资产和行为分。 +4. `fin_risk_assessment`,保证客户画像和适当性链路完整。 +5. `fin_sim_account`,保证客户有模拟账户。 +6. `fin_holding`,保证持仓和豁免额度证据存在。 +7. `biz_work_order`,先建工单,再让交易引用。 +8. `fin_sim_order`,为每笔交易建立模拟委托。 +9. `fin_transaction`,写入申购或赎回成交记录。 +10. `fin_capital_flow`,补入金、出金和资金到账时间。 +11. `sys_login_record`,补交易前的成功登录和非固定设备证据。 +12. 回到风控页面执行手动扫描,生成预警和通知。 + +## 三、字段和取值规则 + +### `sys_user` + +必须满足: + +- `user_type = 'customer'`。 +- `status = '正常'`。 +- `fund_account_status = '已开户'`。 +- `investor_type` 必须为 `C1`、`C2`、`C3`、`C4` 或 `C5`。 +- `is_professional_investor = 0`,除非该演示场景需要专业投资者身份。 +- `professional_investor_status = 'none'`,除非有单独的合规依据。 +- `sys_user_role` 必须关联 `customer` 角色,不能只建用户不建角色。 + +### `fin_customer_profile` + +- `customer_id` 必须与 `sys_user.id` 一致。 +- `trade_account` 必须与模拟账户号一致。 +- `investor_type` 必须与 `sys_user.investor_type` 一致。 +- `birth_date` 必须可计算年龄,RW-012 年龄要求不小于 65 岁。 +- `total_asset` 必须为正数,豁免比例场景需要它。 +- `behavior_score` 使用 0 至 20 分制。 +- `preferred_asset_class` 建议写 JSON 数组,例如 `["固定收益类", "权益类"]`。 + +### `fin_product` + +- `product_code` 使用独立演示编码,不能与正式产品重复。 +- `status = '上市'`。 +- `risk_level` 必须为 `R1`、`R2`、`R3`、`R4` 或 `R5`。 +- `risk_disclosure_required`、`second_confirmation_required`、`recording_required` + 只能为 0 或 1。 +- 要触发 RW-007 缺失留痕场景,相关要求字段必须为 1,且对应工单字段为空。 +- C3 配置 R4、C4 配置 R5 时,还要保证 `total_asset` 和 `fin_holding.current_value` + 口径一致,才能测试豁免额度。 + +### `fin_sim_account` + +- `customer_id` 必须存在。 +- `currency = 'CNY'`。 +- `status = '正常'`。 +- `cash_balance`、`available_cash`、`initial_balance` 必须为合理正数。 +- `account_no` 建议使用稳定编号,例如 `FSA{客户ID}`。 + +### `fin_holding` + +- `customer_id`、`trade_account`、`product_id` 必须有效。 +- `shares`、`total_quantity`、`available_quantity`、`current_value` 必须为正。 +- `current_value` 应与持仓数量和最新净值口径一致。 +- RW-007 豁免场景中,`current_value / fin_customer_profile.total_asset` + 必须能够超过 20% 或 10% 的豁免上限。 + +### `fin_sim_order` + +- 每笔 `fin_transaction` 必须关联一个有效 `order_id`。 +- `order_side` 使用交易接口实际口径 `buy` 或 `sell`。 +- `status` 使用 `已成交`。 +- `quote_source` 可以使用演示来源,但必须能让页面识别。 +- `quote_at`、`submitted_at`、`created_at`、`updated_at` 使用 UTC naive 时间。 + +### `fin_transaction` + +- `transaction_type` 必须使用 `申购` 或 `赎回`,风控扫描按这两个值识别。 +- `order_side` 使用 `buy` 或 `sell`,规则不依赖该字段判断方向。 +- `customer_id`、`account_id`、`product_id` 必须有效。 +- `amount` 必须为正数,且与 `gross_amount`、`net_amount` 的口径保持一致。 +- `confirmed_at` 必须非空,风控扫描按该时间计算时间窗口。 +- 同一笔风险场景不要生成多笔近似重复交易,以免影响历史均值。 +- 需要触发工单规则时,`work_order_id` 必须指向有效工单。 + +### `fin_capital_flow` + +- `flow_type` 使用 `入金` 或对应真实业务类型。 +- RW-003 必须使用 `flow_type = '入金'`。 +- RW-003 的 `status` 必须为 `成功`。 +- RW-003 的 `settled_at` 必须不晚于赎回交易 `confirmed_at`。 +- RW-003 的 `settled_at` 必须处于赎回时间前 3 天内。 +- `customer_id`、`account_id` 必须与交易一致。 + +### `sys_login_record` + +- `user_id` 必须为目标客户。 +- `login_result = '成功'`。 +- `login_at` 必须早于或等于风险交易 `confirmed_at`。 +- RW-012 需要 `is_common_device = 0`。 +- 设备编号不能为空,建议使用可识别的演示设备号。 + +### `biz_work_order` + +- `work_order_no` 必须唯一。 +- `customer_id`、`product_id` 必须有效。 +- `channel` 是 RW-018 的关键字段: + - `定投` + - `自动定投` +- 要触发 RW-007 缺失留痕,按产品要求留空对应字段: + - `risk_disclosure_ack_at` + - `second_confirmation_at` + - `recording_reference` +- 要测试正常放行场景,则应把产品要求的字段全部补齐。 + +### `fin_risk_assessment` + +- `customer_id` 必须存在。 +- `questionnaire_version` 使用独立演示版本。 +- `investor_type` 必须与客户画像一致。 +- `assessed_at`、`valid_until` 必须有效。 +- 风控扫描本身不依赖该表,但客户证据和画像页面需要它。 + +## 四、规则场景矩阵 + +| 规则 | 客户准备 | 产品准备 | 交易准备 | 其他证据 | 预期 | +|---|---|---|---|---|---| +| RW-003 | C3 或 C4 客户 | R3 产品 | 赎回金额达到 50 万,赎回比例达到 80% | 3 天内有成功入金流水 | 高风险 | +| RW-007 | C1 客户 | R4 或 R5 产品,风险揭示或二次确认要求为 1 | 申购记录 | 工单对应留痕缺失 | 高风险 | +| RW-007 | C3 客户 | R4 产品,风险揭示要求为 1 | 申购记录 | 工单缺少风险揭示 | 中风险 | +| RW-012 | 65 岁以上客户 | 任意可赎回产品 | 赎回金额达到 30 万,且达到历史均值 3 倍 | 交易前成功登录,非固定设备 | 高风险 | +| RW-015 | 任意客户 | 任意产品 | 北京时间 00:00 至 05:59,金额不超过 1 万 | 无需额外证据 | 低风险 | +| RW-018 | 任意客户 | 任意产品 | 交易关联工单 | 工单渠道为 `定投` 或 `自动定投` | 低风险 | +| 合并 | 任意客户 | 任意产品 | 同一交易同时满足 RW-015 和 RW-018 | 凌晨小额自动定投 | 一条合并预警 | + +## 五、时间生成规则 + +演示机不要写死日期,建议按当前时间动态生成: + +| 字段 | 时间关系 | +|---|---| +| RW-003 入金 `settled_at` | 当前时间减 1 天 | +| RW-003 赎回 `confirmed_at` | 当前时间 | +| RW-012 历史交易 `confirmed_at` | 当前时间减 30 至 60 天 | +| RW-012 登录 `login_at` | 当前时间减 2 小时 | +| RW-012 当前赎回 `confirmed_at` | 当前时间 | +| RW-015 交易 `confirmed_at` | 北京时间当天或前一天 02:00 至 05:59 | + +数据库写入前必须将北京时间转换为 UTC naive。 + +## 六、插入后检查 + +至少执行以下检查: + +1. `sys_user.investor_type` 与 `fin_customer_profile.investor_type` 完全一致。 +2. 每个客户都有有效模拟账户和至少一笔持仓。 +3. 每笔交易都有对应的 `order_id`。 +4. 需要触发 RW-003 的客户都有成功入金流水,且入金时间处于赎回前 3 天内。 +5. 需要触发 RW-012 的客户都有交易前成功登录记录,且 `is_common_device = 0`。 +6. 需要触发 RW-007 的工单留痕字段确实缺失,或豁免比例确实超过上限。 +7. 每个演示客户的交易数量足以支持历史均值计算,至少应有一笔历史对比交易。 +8. 扫描后检查 `fin_risk_alert.trigger_rule_codes`、`alert_level` 和 + `related_transaction_id` 是否符合预期。 + +## 七、扫描后的验收 + +数据插入完成后,由演示机执行风控扫描,再检查: + +- 预警数量是否新增。 +- 每笔交易命中的规则是否正确。 +- 风险等级是否正确。 +- 证据详情是否能关联到客户、产品、交易、资金、登录和工单。 +- 同一交易命中多条规则时是否合并为一条预警。 +- 高风险预警是否产生通知记录。 +- 邮件开关和收件人配置是否符合演示要求。 + +如果扫描没有产生预警,首先检查源数据字段、枚举值、时间和关联关系,不要通过直接修改 +`fin_risk_alert` 来掩盖问题。 diff --git a/docs/风控业务演示文档/README.md b/docs/风控业务演示文档/README.md index 918d2a3..e5f0852 100644 --- a/docs/风控业务演示文档/README.md +++ b/docs/风控业务演示文档/README.md @@ -45,6 +45,7 @@ | 22 | 风控模块需求说明书 | 统一业务需求、规则、边界和验收口径 | | 23 | 风控模块接口文档 | 提供 REST、SSE、字段和联调约定 | | 24 | 主项目风控前端改造TODO | 记录前端保留项、修改项和待讨论方案 | +| 26 | 演示机数据插入规则 | 说明演示机准备上游数据的表、字段、场景和检查规则 | ## 推荐阅读顺序 @@ -60,3 +61,4 @@ 10. 21:主项目代码合并后的后端整改与验收。 11. 22、23:分别作为需求交付和接口联调的统一入口。 12. 24:主项目风控前端改造的事项、决策项和实施顺序。 +13. 26:演示机准备上游数据时按表、字段、场景和检查规则执行。 From e6d74059f2fa58ca7e03a41d78c6d52badd3d3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Mon, 14 Sep 2026 12:06:38 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E9=A1=B6=E9=83=A8=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=EF=BC=88=E5=85=A5=E5=8F=A3=20JS=20=E8=A2=AB?= =?UTF-8?q?=E5=BC=95=E4=B8=A4=E6=AC=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 打开 `employee-console/workspace`(平台治理),页面上出现**两份一模一样的顶部栏**: 南方财富 / 模拟基金服务 / 平台治理 / 风控中心 / admin_t 管理员 —— 连同页脚一起各两份。 ## 根因:同一个入口 JS 被引了两次,且 `?v=` 不同 `app/static/portal/employee-console/workspace/index.html` 里曾同时存在: 浏览器按**完整 URL** 去重,两条不同 query 被当成**两个模块**、**各执行一次**。 入口里的 `mountShell()` 因此跑了两次,而它当时是 `document.body.insertAdjacentHTML('afterbegin', ...)` —— **无条件插入**, 于是 header 与 footer 各插两份。 来源是合并事故(`git blame` 定位): | 行 | 提交 | 作者 | |---|---|---| | 旧 | `e31420df` | 卿云秋月(把版本号改成 `20260913-3`)| | 新 | `f5d1b246` | 张胜宇(把版本号改成 `20260914`)| 两人各自把**同一行**的版本号换成新的,合并时两边都被保留,成了两行。 那个提交的信息是 "merge ... and retain risk review updates" —— "retain" 在这里保留错了地方。 ## 修法(两侧都堵) 1. **HTML 收敛成一行**(保留较新的 `?v=20260914`,与 `workspace.js` 内部 `api-client.js?v=20260914` 一致),并就地写明"改版本号是替换这一行、不是新增一行"。 2. **`mountShell` 加幂等保护**:已有 `.site-header` 就直接 return。 之所以不满足于只修那个 HTML —— 这个 bug 的症状很难反推到原因 (页面看起来只是"多了一块"),而以后谁加缓存版本号时很容易再犯一次。 ## 防回归(两条测试,都做过负面验证) - `test_no_portal_page_includes_the_same_script_twice`:扫 `app/static/portal` 下 **19 个页面**,把 ` + diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index b80bb8c..b4d8259 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import subprocess import sys from pathlib import Path @@ -129,6 +130,39 @@ def test_advisor_dashboard_is_composed_from_feature_modules() -> None: assert "ACTION_LABELS" in config +def test_no_portal_page_includes_the_same_script_twice() -> None: + """同一个入口 JS 被引两次(哪怕 `?v=` 不同)会让页面出现两份顶部导航。 + + 浏览器按**完整 URL** 去重:`x.js?v=A` 与 `x.js?v=B` 是两个模块、**各执行一次**。 + 入口里的 `mountShell()` 于是跑两遍,插入两份 header / footer —— + 2026-09-14 `employee-console/workspace/index.html` 就这么写过:合并时 + 两个分支各自把同一行的版本号换成新的,两边都被保留,成了一条重复的 `