From 560775156cd15a71ad657fd165f775722d71919d Mon Sep 17 00:00:00 2001 From: Windows Date: Wed, 16 Sep 2026 18:17:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E6=8A=95?= =?UTF-8?q?=E9=A1=BE=20Agent=20=E9=9C=80=E6=B1=82=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=8E=E5=8A=9F=E8=83=BD=E6=9E=B6=E6=9E=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据仓库既有设计与已落地代码反推整理,内容与当前实现一致(非前瞻设计)。 - docs/46-投顾Agent需求文档.md · 背景与三个真实断点(交付无落点 / 文案不可读 / 客户无入口) · 目标 G1-G5 与 4 条非目标(不真实下单、不预测收益、不自动投资、不做场外) · 角色场景 SC-01~SC-05、两条主链路流程图 · 功能需求 FR-01~FR-21(含口径、优先级、验收标准) · 业务规则:候选池硬约束 fail-closed、5 条合规红线、状态机、FM-03 熔断 · 非功能需求、异常处理表、权限矩阵、数据需求、验收清单、11 项待确认问题 - docs/47-投顾Agent功能架构文档.md · 五层架构总览、后端模块划分与 3 条设计约束、数据模型与关系 · 接口清单(投顾 8 / 客户 3 / 依赖能力 3 组) · 三张时序图(生成推荐、审核发布、申报受理) · 外部依赖降级矩阵、前端模块结构与 2 条硬约定、权限灰度、配置清单、已知约束 --- docs/46-投顾Agent需求文档.md | 314 +++++++++++++++++++++++++++++++ docs/47-投顾Agent功能架构文档.md | 284 ++++++++++++++++++++++++++++ 2 files changed, 598 insertions(+) create mode 100644 docs/46-投顾Agent需求文档.md create mode 100644 docs/47-投顾Agent功能架构文档.md diff --git a/docs/46-投顾Agent需求文档.md b/docs/46-投顾Agent需求文档.md new file mode 100644 index 0000000..a258552 --- /dev/null +++ b/docs/46-投顾Agent需求文档.md @@ -0,0 +1,314 @@ +# 投顾 Agent 需求文档(PRD) + +> 文档版本:v1.0(2026-09-16) +> 文档状态:**已实现反推稿** —— 本章所记需求均已在本仓库落地并本地验证,非前瞻设计 +> 适用范围:投顾 Agent(投顾工作台 + 客户端「我的投顾方案」+ 客户主动申报) +> 依据材料(均为仓库内既有文档与代码,非本文件臆造): +> · 设计:`docs/01`(通用 Agent 平台)、`docs/03`(端到端流程)、`docs/05`(接口文档)、 +> `docs/22`(四大 Agent 拆解)、`docs/30`(投顾迁移 TODO)、`docs/31`(投顾灰度与回滚)、 +> `docs/43`(场内产品手册)、`docs/44`(演示流程) +> · 代码:`app/service/product_recommendation_service.py`、`advisor_service_request_service.py`、 +> `advisor_reason_service.py`、`app/api/controllers/recommendations.py`、`advisor_service_requests.py`、 +> `app/static/portal/employee-advisor/dashboard/*`、`app/static/portal/customer/advisor-plans/*` + +--- + +## 1. 背景与目标 + +### 1.1 问题陈述 + +投顾业务的真实痛点在于「**选品可解释 + 交付可追溯**」: + +- 投顾给客户荐品,必须能说清**为什么是这几只**(适当性匹配、期限/流动性匹配、证据可核查); +- 方案从生成到到达客户手里,必须**留痕**、且**未经审核不得对客户可见**; +- 客户侧长期是单向接收,缺少「主动提出需求」的入口。 + +此前系统里推荐能力存在,但存在三处断点: + +| 断点 | 表现 | +|---|---| +| 交付无落点 | 投顾「发送给客户」只改数据状态,客户端没有任何页面/接口能读到已发布方案 | +| 文案不可读 | 每只产品的「推荐依据」是同一句套话,客户看不出差异 | +| 客户无入口 | 客户只能被动等方案,无法主动申报 | + +### 1.2 目标(G) + +| 编号 | 目标 | 衡量方式 | +|---|---|---| +| G1 | 推荐**可解释**:每只入选产品都能给出基于真实参数的依据 | 方案里每只产品都有独立依据,且含风险等级/匹配关系 | +| G2 | 交付**可追溯**:审核与发布分离,未发布对客户不可见 | 状态机 + `published_at` 双条件;客户接口只返回已发布 | +| G3 | 呈现**可感知**:客户能看到走势图、指标与组合构成 | 客户端可展示净值折线图、环形配置图、区间收益与最大回撤 | +| G4 | 客户**能发起**:客户可主动申报,投顾后台受理后出方案 | 申报→受理→生成草稿→审核→发送 全链路打通 | +| G5 | 合规**不越线**:不得承诺收益,证据缺失即整只排除 | 硬约束 fail-closed + 文案合规守卫 | + +### 1.3 非目标(本期明确不做) + +| 非目标 | 说明 | +|---|---| +| 真实下单 | 推荐方案**不产生交易指令**,客户仍需在交易页自行下单 | +| 收益预测 | 不预测未来收益,只呈现历史区间收益与最大回撤 | +| 自动投资 | 无自动调仓、无定投、无代客操作 | +| 场外基金 | 投顾范围限定场内基金,场外走独立模块(见 `docs/22`) | + +--- + +## 2. 角色与场景 + +### 2.1 角色定义 + +| 角色 | 角色码 | 入口 | 核心诉求 | +|---|---|---|---| +| 投顾 | `advisor` | `/portal/employee-advisor/dashboard/` | 代客出方案、审核发布、处理客户申报 | +| 客户 | `customer` | `/portal/customer/advisor-plans/` | 接收方案、主动申报、查看申报进度 | +| 管理员 | `admin` / `super_admin` | 管理面 | 审核、发布、故障处置(不受灰度限制) | + +### 2.2 核心场景 + +| 编号 | 场景 | 触发方 | +|---|---|---| +| SC-01 | 投顾选择名下客户 → 生成推荐方案 → 审核通过 → 发送给客户 | 投顾 | +| SC-02 | 投顾查看历史方案记录(含草稿)→ 抽查内容详情 | 投顾 | +| SC-03 | 客户登录 → 在「我的投顾方案」查看已收到的方案 | 客户 | +| SC-04 | 客户提交投顾方案申报 → 投顾受理 → 自动出草稿 | 客户→投顾 | +| SC-05 | 投顾受理后审核、发布,客户看到方案变为「已发送方案」 | 投顾→客户 | + +--- + +## 3. 业务流程 + +### 3.1 主链路 A:投顾主动出方案 + +```mermaid +flowchart LR + A[投顾选择客户] --> B[合规熔断校验 FM-03] + B -- 测评失效 --> X[熔断拦截 转人工] + B -- 通过 --> C[生成推荐方案] + C --> D{候选池硬约束} + D -- 某产品缺证据 --> E[整只排除 fail-closed] + D -- 通过 --> F[排序取 TopN] + E --> F + F --> G[LLM 生成推荐依据] + G --> H[落库 pending_review] + H --> I[投顾审核通过] + I --> J[发布给客户] + J --> K[客户可见] +``` + +### 3.2 主链路 B:客户主动申报 + +```mermaid +flowchart LR + A[客户填写申报单] --> B[服务端校验风险测评有效性] + B -- 未测评/已过期 --> X[拒绝 并提示原因] + B -- 通过 --> C[落库 pending] + C --> D[投顾工作台出现待办] + D --> E{投顾受理 or 驳回} + E -- 驳回 --> F[填理由 客户可见] + E -- 受理 --> G[自动跑推荐逻辑 出草稿] + G --> H[投顾审核 + 发送] + H --> I[申报单变 已发送方案] +``` + +--- + +## 4. 功能需求 + +优先级:**P0** 本期必须 / **P1** 重要 / **P2** 增强。 + +### 4.1 客户与画像 + +| 编号 | 功能 | 口径 | 优先级 | 验收标准 | +|---|---|---|---|---| +| FR-01 | 投顾查看名下客户 | 范围 = 本人 + `sys_customer_assignment` 归属客户 | P0 | 非归属客户不可见,越权返回 403 | +| FR-02 | 风险测评(客户自助) | `ONB001` 取题 / `ONB002` 提交;结果落服务端权威画像 | P0 | 测评结果由服务端权威返回,前端不得自行计算风险等级 | +| FR-03 | 熔断前置校验 | 测评超过 12 个月或缺失 → FM-03 拦截 | P0 | 前端展示熔断横幅,接口返回 `blocked_by_fuse` | + +### 4.2 组合诊断与配置 + +| 编号 | 功能 | 口径 | 优先级 | 验收标准 | +|---|---|---|---|---| +| FR-04 | 组合分析 | 集中度、行业穿透与流动性 | P1 | 返回可展示的诊断结论 | +| FR-05 | 资产配置 | 按风评与目标给出配比与偏离 | P1 | 目标缺失时返回 `investment_goal_required` | +| FR-06 | 画像评分 | 本地演示引擎计算(后端无对应接口,明确标注"规则模拟") | P2 | 结果区必须带演示标识 | +| FR-07 | 调仓建议 | 同上,本地引擎 | P2 | 结果区必须带演示标识 | + +> FR-06/FR-07 由本地演示引擎计算,前端会明确标注;**不得**被误认为真实后端结论。 + +### 4.3 推荐方案生成(核心) + +| 编号 | 功能 | 口径 | 优先级 | 验收标准 | +|---|---|---|---|---| +| FR-08 | 生成推荐方案 | `POST /api/v1/advisor/recommendations`,需 16-128 位 ASCII 幂等键 | P0 | 同键重试返回同一方案,不重复建单 | +| FR-09 | 候选池硬约束 | 产品须同时具备:场内可交易 + 销售机构**已验证**适当性证据 + 基金合同快照;缺任一即整只排除 | P0 | 排除原因可查(前端「排除原因」提示词) | +| FR-10 | 排序与入选 | 按适当性等级、目标期限、目标流动性筛选后综合打分取 TopN(默认 3) | P0 | 返回 `selection_summary`:候选/入选/排除数 | +| FR-11 | 推荐依据生成 | 规则文案兜底;配置大模型时改写为面向客户的说明 | P0 | 文案必须含真实参数;命中收益承诺词即丢弃并回退 | +| FR-12 | 审核 | `reviews` 接口,decision ∈ {approved, rejected} | P0 | 未审核不得发布 | +| FR-13 | 发布 | `publications` 接口,置 `review_status` 与 `published_at` | P0 | 发布后客户可见;重复发布幂等 | + +### 4.4 历史记录与交付 + +| 编号 | 功能 | 口径 | 优先级 | 验收标准 | +|---|---|---|---|---| +| FR-14 | 历史方案记录 | 本人生成的全部状态方案 | P0 | 状态标签与后端状态一致,不使用枚举兜底文案 | +| FR-15 | 客户接收方案 | `GET /api/v1/users/me/advisor-contents`:只看**本人**、已审核且 `published_at` 非空 | P0 | 未发布的方案对客户不可见 | +| FR-16 | 方案可视化 | 净值折线图(带坐标轴/网格)、组合业绩(等权合成 + 最大回撤)、资产配置环形图 | P1 | 数据缺失显示「暂无数据」,不显示 0 | + +### 4.5 客户主动申报 + +| 编号 | 功能 | 口径 | 优先级 | 验收标准 | +|---|---|---|---|---| +| FR-17 | 客户提交申报 | 金额(万元)/ 投资期限 / 风险偏好 / 备注 | P0 | 服务端校验取值与风评有效性 | +| FR-18 | 我的申报 | 只看自己的申报单与状态 | P0 | 状态含 待受理/已受理/已发送/已驳回 | +| FR-19 | 投顾申报队列 | 本人 + 归属客户的申报,待受理排前 | P0 | 非归属客户的申报不可处理 | +| FR-20 | 受理并生成方案 | 受理即调用现有推荐逻辑自动出**待审核草稿** | P0 | 生成失败给出人话原因,并保持待受理 | +| FR-21 | 驳回 | 需填理由(可选),客户可见 | P1 | 驳回后申报单不可再被受理 | + +--- + +## 5. 业务规则 + +### 5.1 候选池硬约束(fail-closed) + +产品必须同时满足三项,缺一即排除: + +1. **场内可交易**; +2. **适当性证据**:销售机构的**已验证**适当性证据,带可核查来源链接与 `document_sha256`; +3. **合同快照**:基金合同快照,同样带来源与摘要。 + +按目标继续过滤: + +| 维度 | 规则 | +|---|---| +| 风险等级 | 客户风评等级对应的禁投风险级直接排除 | +| 投资期限 | 短于目标期限下限的产品排除 | +| 流动性 | 不符合目标流动性要求(如 30 日内可用)的产品排除 | + +### 5.2 合规红线(必须遵守,代码强制) + +| # | 红线 | +|---|---| +| 1 | 不得出现收益承诺与绝对化表述(保本/保证收益/稳赚/无风险/收益承诺…) | +| 2 | 未审核发布的方案对客户不可见 | +| 3 | 推荐不产生交易指令,仅作分析参考 | +| 4 | 客户不得自行修改风险测评结果(服务端权威) | +| 5 | 全流程留痕(审核、发布、受理、驳回均记录操作人与时间) | + +### 5.3 状态机 + +**推荐方案** + +```mermaid +stateDiagram-v2 + [*] --> pending_review: 生成方案 + pending_review --> approved: 审核通过 + pending_review --> rejected: 驳回 + approved --> 已发布: 发送给客户 + rejected --> [*] + 已发布 --> [*] +``` + +> 「已发布」不是独立状态字段,而是 `review_status ∈ {approved, published}` **且** `published_at` 非空 —— 避免两处状态各写各的而对不上。 + +**客户申报单** + +```mermaid +stateDiagram-v2 + [*] --> pending: 客户提交 + pending --> accepted: 投顾受理(自动生成方案草稿) + pending --> rejected: 投顾驳回 + accepted --> 已发送: 关联方案发布后推导 +``` + +> `已发送(delivered)` 同样**不落库**,由关联方案的 `published_at` 推导。 + +### 5.4 熔断 FM-03 + +| 条件 | 结果 | +|---|---| +| 无风险测评 | 拦截,提示先完成测评 | +| 测评超过 12 个月 | 拦截(服务端判 `valid_until`,缺失时按 12 个月兜底) | +| 熔断生效时 | 客户仅可办理赎回,流程转人工 | + +--- + +## 6. 非功能需求 + +| 类别 | 要求 | +|---|---| +| 幂等 | 写接口必须带 16-128 位 ASCII `Idempotency-Key`;同键重试返回首次结果 | +| 降级 | 图谱(Neo4j)、大模型、行情任一不可用,业务**继续可用**并明确标注降级 | +| 留痕 | 审核/发布/受理/驳回写入审计;演示口径留存 ≥ 20 年 | +| 限流 | 按「用户 + 方法 + 路由」计数,超限 429;Redis 不可用时降级放行 | +| 灰度 | `ADVISOR_ROLLOUT_ENABLED` 开启后仅白名单客户与管理员可访问投顾业务 | +| 前端一致性 | 同一页面内同一模块的 `?v=` 版本号必须一致(否则双实例) | + +--- + +## 7. 异常处理 + +| 场景 | 返回 | 前端呈现 | 可恢复动作 | +|---|---|---|---| +| 客户无风险测评 | `profile_required` | 结果区提示 | 先做风险测评 | +| 无已确认投资目标 | `investment_goal_required` | 结果区提示 | 先录入并确认目标 | +| 推荐数量越界 | 422 `RECOMMENDATION_PARAM_INVALID` | 结果区提示 | 改为 1-3 | +| 权限不足 | 403 | 「当前账户暂不可访问 / 权限不足」+ toast | 换有权限的账号 | +| 未开户(资金类) | 404 | 「客户未开户」 | 先建模拟账户 | +| 行情/净值缺失 | 503 / 空数据 | 「暂无净值数据」 | 跑 `tools/sync_nav_history.py` | +| 大模型不可用 | 无异常 | 依据回退为规则文案 | 关闭开关或换 key | +| 熔断 | `blocked_by_fuse` | 熔断横幅 | 转人工 | + +--- + +## 8. 权限矩阵 + +| 能力 | 客户 | 投顾 | 管理员 | +|---|---|---|---| +| 提交/查看**自己的**申报 | ✅ `advisor-request:write:self` / `read:self` | — | — | +| 查看**自己的**已发布方案 | ✅ `product-recommendation:read:self` | — | — | +| 生成/查看/删除推荐方案 | — | ✅ | ✅ | +| 审核 / 发布方案 | — | ✅(自助) | ✅ | +| 查看客户申报队列 | — | ✅ `advisor-request:read` | ✅ | +| 受理 / 驳回申报 | — | ✅ `advisor-request:review` | ✅ | +| 灰度限制 | — | 受白名单限制 | 不受限 | + +> 权限码定义源:`tools/seed_test_rbac.py`;投顾角色的授权工具:`tools/grant_advisor_role.py`。 + +--- + +## 9. 数据需求 + +| 表 | 用途 | 关键字段 | +|---|---|---| +| `client_facing_content` | 方案与方案书 | `content_type` / `customer_id` / `review_status` / `published_at` / `draft_content` | +| `advisor_service_request` | 客户申报单 | `request_no`(唯一) / `customer_id` / `amount_wan` / `horizon` / `risk_preference` / `status` / `result_content_id` | +| `sys_customer_assignment` | 投顾-客户归属 | 决定投顾可见范围 | +| `fin_product` / `fin_market_price` / `fin_nav_history` | 产品、行情、净值 | 图表与指标的数据源 | +| `api_request_receipt` | 幂等 | `user_id` / `scope_hash` / `idempotency_key` / `request_hash` | +| `interaction_audit` | 审计留痕 | 操作人、动作、明细 | + +--- + +## 10. 验收清单 + +| # | 验收项 | 方法 | +|---|---|---| +| 1 | 投顾生成方案 → 结果区出现卡片与图表 | 工作台执行「生成推荐方案」 | +| 2 | 审核通过后发布 → 客户可见 | 客户页出现该方案 | +| 3 | 未发布方案对客户不可见 | 客户页不出现草稿 | +| 4 | 同幂等键重试不重复建单 | 连续两次提交同键 | +| 5 | 客户申报 → 投顾队列出现 → 受理后自动出草稿 | 两端对照 | +| 6 | 文案命中收益承诺词被丢弃 | 单测 `test_advisor_reason_service.py` | +| 7 | 大模型不可用时回退规则文案 | 关闭开关后重生成 | +| 8 | 越权客户不可见/不可处理 | 换非归属客户验证 403 | + +--- + +## 11. 待确认问题 + +| # | 问题 | 影响 | +|---|---|---| +| Q1 | 适当性证据与合同快照的**真实数据来源**何时接入?当前候选为 0(证据缺失 fail-closed) | 阻塞:决定演示用真实方案还是本地引擎 | +| Q2 | 推荐依据是否允许出现历史业绩之外的表述(如基金经理风格)? | 影响文案提示词 | +| Q3 | 客户申报是否需要额度/频率限制(防刷)? | 影响 FR-17 | +| Q4 | 申报单与投资目标的关系:受理后是否自动创建投资目标? | 影响 FR-20 之后流程 | +| Q5 | 灰度白名单的最终放量范围? | 影响验收范围 | diff --git a/docs/47-投顾Agent功能架构文档.md b/docs/47-投顾Agent功能架构文档.md new file mode 100644 index 0000000..ed54f5a --- /dev/null +++ b/docs/47-投顾Agent功能架构文档.md @@ -0,0 +1,284 @@ +# 投顾 Agent 功能架构文档 + +> 文档版本:v1.0(2026-09-16) +> 适用范围:投顾 Agent 的后端服务、前端模块、数据模型、接口与降级策略 +> 配套文档:`docs/46-投顾Agent需求文档.md`(需求与验收) +> 说明:本文描述的是**仓库当前已落地的实现**,不是前瞻设计 + +--- + +## 1. 架构总览 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 客户端门户 /portal/customer/ │ +│ advisor-plans(我的投顾方案 / 主动申报 / 申报进度) │ +│ dashboard · holdings · orders · cash-ledger · risk-questionnaire│ +└─────────────────────────────────────────────────────────────────┘ + │ HTTPS + Bearer JWT +┌─────────────────────────────────────────────────────────────────┐ +│ 投顾工作台 /portal/employee-advisor/dashboard/ │ +│ dashboard.js(壳/引导/指标) │ +│ ├ actions-module 七项操作 + 熔断闸门 + 结果渲染 │ +│ ├ assistant-module 自然语言意图路由 │ +│ ├ history-module 历史方案记录(只读 + 详情弹窗) │ +│ ├ service-request-module 客户申报队列(受理/驳回) │ +│ ├ published-module 已发布交付物 │ +│ ├ customer-module 客户选择 │ +│ └ advisor-engine / advisor-config 本地演示引擎与常量 │ +└─────────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────────┐ +│ FastAPI 入口层 app/api/controllers/ │ +│ recommendations.py 推荐(生成/历史/已发布/审核/发布/删除)│ +│ advisor_service_requests.py 客户申报 + 投顾受理 │ +│ public_platform.py 公开产品与净值(P001/P002) │ +│ onboarding.py / trading.py 风险测评 / 场内模拟交易 │ +└─────────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────────┐ +│ 服务层 app/service/ │ +│ ProductRecommendationService 选品 + 排序 + 审核发布(核心) │ +│ AdvisorServiceRequestService 客户申报与受理 │ +│ AdvisorReasonService 推荐依据的大模型增强(可选) │ +│ SuitabilityService 服务端权威风险画像 │ +│ InvestmentGoalService 投资目标 │ +│ MarketPriceSyncService 行情同步 │ +│ AuthorizationService / IdentityService 权限与身份 │ +└─────────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────────┐ +│ 存储与依赖 │ +│ MySQL(业务主库) · Redis(限流/缓存) · Milvus(知识向量) │ +│ Neo4j(图谱,可降级) · 外部行情/净值源 · DeepSeek(可选) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 后端模块划分 + +| 层 | 文件 | 职责 | +|---|---|---| +| 控制器 | `app/api/controllers/recommendations.py` | 三个 router:`advisor`(投顾)、`admin`(平台管理员)、`client`(客户自助) | +| 控制器 | `app/api/controllers/advisor_service_requests.py` | 客户申报(`/users/me`)+ 投顾受理(`/advisor`) | +| 服务 | `product_recommendation_service.py` | 硬约束过滤、排序、审核、发布、历史;**唯一选品入口** | +| 服务 | `advisor_service_request_service.py` | 申报创建/查询、队列、受理(联动生成草稿)、驳回 | +| 服务 | `advisor_reason_service.py` | 推荐依据的大模型增强(合规守卫 + 失败回退) | +| 仓储 | `advisor_product_repository.py` | 权威候选产品(场内可交易 + 适当性证据 + 合同快照) | +| 模型 | `advisor_service_request.py` | 申报单 ORM 模型与状态常量 | +| 模型 | `investment_goal.py` | `ClientFacingContent`(方案/方案书) | + +**设计约束(踩过坑换来的)** + +- 客户自助路由**不挂投顾灰度闸门**(`enforce_advisor_rollout`)—— 那是投顾业务灰度,客户查看自己的方案不该被它拦。 +- 归属范围**只有一处实现**:`ProductRecommendationService._visible_customer_ids`(本人 + `sys_customer_assignment`),历史、已发布、申报队列全部复用它。 +- 写操作统一走 `ApiTransactionService.execute`(16-128 位 ASCII 幂等键)。 + +--- + +## 3. 数据模型 + +| 表 | 说明 | 关键字段 | +|---|---|---| +| `client_facing_content` | 方案与方案书 | `content_type`(`advisor_recommendation_plan` / `investment_goal_book`)、`customer_id`、`review_status`、`published_at`、`draft_content` | +| `advisor_service_request` | 客户申报单 | `request_no`(唯一)、`customer_id`、`amount_wan`、`horizon`、`risk_preference`、`note`、`status`、`handled_by/at`、`advisor_note`、`result_content_id` | +| `sys_customer_assignment` | 投顾-客户归属 | 决定可见范围 | +| `fin_product` / `fin_market_price` / `fin_nav_history` | 产品 / 行情 / 历史净值 | 图表与指标数据源 | +| `api_request_receipt` | 幂等 | `user_id` + `scope_hash` + `idempotency_key` + `request_hash` | +| `interaction_audit` | 审计留痕 | 操作人、动作、明细、时间 | + +**关系要点** + +``` +advisor_service_request.result_content_id ──▶ client_facing_content.id +client_facing_content.customer_id ──▶ sys_user.id(客户) +sys_customer_assignment ──▶ 投顾可见客户集合 +``` + +> 「已发布 / 已发送」**不落库**:由 `published_at` 是否为空推导,避免两处状态不一致。 + +--- + +## 4. 接口清单 + +### 4.1 投顾侧(`/api/v1/advisor`,挂限流 + 灰度) + +| 方法 | 路径 | 权限 | 端点 ID | 幂等 | +|---|---|---|---|---| +| POST | `/advisor/recommendations` | `product-recommendation:generate:customer` | `ADVISOR_RECOMMEND` | ✅ | +| GET | `/advisor/recommendations/history` | `product-recommendation:history` | `ADVISOR_HISTORY` | — | +| GET | `/advisor/recommendations/published` | `product-recommendation:read:self` | `ADVISOR_PUBLISHED` | — | +| DELETE | `/advisor/recommendations/{contentId}` | `product-recommendation:delete` | `ADVISOR_DELETE_RECOMMENDATION` | ✅ | +| POST | `/advisor/recommendations/{contentId}/reviews` | `product-recommendation:review` | — | ✅ | +| POST | `/advisor/recommendations/{contentId}/publications` | `product-recommendation:publish` | — | ✅ | +| GET | `/advisor/service-requests` | `advisor-request:read` | `ADVISOR_SERVICE_REQUESTS` | — | +| POST | `/advisor/service-requests/{requestNo}/reviews` | `advisor-request:review` | `ADVISOR_SERVICE_REQUEST_REVIEW` | ✅ | + +### 4.2 客户侧(`/api/v1/users/me`,只挂限流) + +| 方法 | 路径 | 权限 | 端点 ID | +|---|---|---|---| +| GET | `/users/me/advisor-contents` | `product-recommendation:read:self` | `MY_ADVISOR_CONTENTS` | +| GET | `/users/me/advisor-requests` | `advisor-request:read:self` | `MY_ADVISOR_REQUESTS` | +| POST | `/users/me/advisor-requests` | `advisor-request:write:self` | `ADVISOR_REQUEST_CREATE` | + +### 4.3 依赖的既有能力 + +| 端点 | 用途 | +|---|---| +| `ONB001` / `ONB002` | 风险测评取题与提交 | +| `T001`–`T009` | 账户看板、委托、持仓、成交、资金明细 | +| `P001` / `P002` | 产品列表(含最新行情)与历史净值序列(图表数据源) | + +--- + +## 5. 关键时序 + +### 5.1 生成推荐方案 + +```mermaid +sequenceDiagram + participant A as 投顾前端 + participant C as recommendations.py + participant S as ProductRecommendationService + participant R as AdvisorProductRepository + participant G as 图谱 Neo4j + participant L as DeepSeek 可选 + participant DB as MySQL + + A->>C: POST /advisor/recommendations (Idempotency-Key) + C->>S: generate(payload, context, key) + S->>S: 权限 + 熔断 + 目标校验 + S->>R: 权威候选产品 + R-->>S: 候选(含硬约束证据) + S->>G: 组合行业上下文 + G-->>S: 正常 / 降级 degraded + S->>S: 过滤 + 排序取 TopN + S->>L: 真实参数 → 推荐依据 + L-->>S: 文案 / 失败(回退规则文案) + S->>DB: 落库 pending_review + S-->>A: 返回结果(信封,含 content_id) +``` + +### 5.2 审核与发布 + +```mermaid +sequenceDiagram + participant A as 投顾/管理员 + participant C as recommendations.py + participant S as ProductRecommendationService + participant DB as MySQL + + A->>C: POST .../reviews {decision: approved} + C->>S: review(content_id, decision, context, key) + S->>DB: review_status = approved(幂等) + A->>C: POST .../publications + C->>S: publish(content_id, context, key) + S->>DB: review_status + published_at + S-->>A: 发布成功 + Note over DB: 此后客户接口才能读到该方案 +``` + +### 5.3 客户申报受理 + +```mermaid +sequenceDiagram + participant U as 客户 + participant C1 as advisor_service_requests.py + participant S as AdvisorServiceRequestService + participant P as ProductRecommendationService + participant A as 投顾 + participant DB as MySQL + + U->>C1: POST /users/me/advisor-requests + S->>S: 校验风评有效性(FM-03) + S->>DB: 落库 pending + A->>C1: GET /advisor/service-requests + A->>C1: POST .../reviews {decision: accepted} + S->>P: generate(customer_id) 自动生成草稿 + P-->>S: content_id + S->>DB: status=accepted + result_content_id + S-->>A: 返回草稿编号(待审核) +``` + +--- + +## 6. 外部依赖与降级矩阵 + +| 依赖 | 用途 | 不可用时 | 表现 | +|---|---|---|---| +| MySQL | 主存储 | 不可用即故障 | 无降级 | +| Redis | 限流 | 自动降级为放行 | 不限流 | +| Neo4j | 组合行业图谱 | 降级 | 方案标记 `graph_status=degraded`,业务继续 | +| 行情源(腾讯) | 最新价 / 涨跌幅 | 无行情 | 相关指标显示「暂无行情」 | +| 净值源(东财) | 历史净值 | 无数据 | 折线图显示「暂无净值数据」 | +| DeepSeek | 推荐依据改写 | 回退 | 使用规则文案,`reason_source=rule` | + +> 行情与净值**没有周期性刷新**:演示前需跑 `tools/sync_market_prices.py` 与 `tools/sync_nav_history.py`(后者全量一次会被超时终止,建议按 `--codes` 分块)。 + +--- + +## 7. 前端模块结构 + +**投顾工作台**(`employee-advisor/dashboard/`) + +| 模块 | 职责 | +|---|---| +| `dashboard.js` | 壳、引导、指标卡、在线探测与占位 | +| `actions-module.js` | 七项操作的表单/请求/熔断/结果渲染 | +| `assistant-module.js` | 自然语言意图路由(复用 `actions` 的 resolve) | +| `history-module.js` | 历史方案记录(只读 + 详情弹窗) | +| `service-request-module.js` | 客户申报队列(受理/驳回) | +| `published-module.js` | 已发布交付物 | +| `customer-module.js` | 客户选择 | +| `advisor-engine.js` / `advisor-config.js` | 本地演示引擎与常量 | + +**客户端**(`customer/advisor-plans/` + `common/`) + +| 文件 | 职责 | +|---|---| +| `advisor-plans.js` | 已收到方案列表、申报表单、我的申报 | +| `common/advisor-plan-view.js` | **投顾与客户共用**的方案可视化渲染(折线图/环形图/指标) | +| `common/advisor-plan.css` | 上述组件的样式(含涨跌色变量) | + +**两条硬约定** + +1. 同一页面内,同一模块的 `?v=` 版本号必须一致 —— 不同版本会被浏览器当成两个模块,导致双实例。 +2. 共享模块**不 import `api-client.js`**,apiClient 由调用方传入(依赖注入),否则两端版本不同会多出实例。 + +--- + +## 8. 权限与灰度 + +| 项 | 说明 | +|---|---| +| 权限码定义源 | `tools/seed_test_rbac.py`(投顾 9071-9074 为客户申报相关) | +| 投顾授权工具 | `tools/grant_advisor_role.py`(权限码必须与种子一致,由 `check_rbac_seed_consistency.py` 校验) | +| 客户授权 | 由种子 `CUSTOMER_PERMISSIONS` 决定;既有环境需按同名补建并授权 | +| 灰度 | `ADVISOR_ROLLOUT_ENABLED` + `ADVISOR_ROLLOUT_CUSTOMER_IDS`;管理员不受限 | +| 幂等 | 所有写接口要求 16-128 位 ASCII `Idempotency-Key` | + +--- + +## 9. 配置清单 + +| 配置 | 说明 | +|---|---| +| `ADVISOR_ROLLOUT_ENABLED` / `ADVISOR_ROLLOUT_CUSTOMER_IDS` | 投顾灰度 | +| `ADVISOR_REASON_LLM_ENABLED` | 推荐依据是否启用大模型 | +| `ADVISOR_REASON_LLM_BASE_URL` / `_MODEL` / `_TIMEOUT_SECONDS` | 模型地址、模型名、超时 | +| `DEEPSEEK_API_KEY` | 模型密钥(`.env` 已被 `.gitignore` 忽略,不得入库) | +| `NEO4J_URI` / `NEO4J_PASSWORD` | 图谱,留空即降级 | + +--- + +## 10. 已知约束与演进方向 + +| 约束 | 说明 | 演进建议 | +|---|---|---| +| 候选池可能为空 | 本环境适当性证据与合同快照暂无数据,fail-closed 导致候选为 0 | 接入治理侧证据导入(`tools/import_product_governance_reference.py`) | +| 本地演示引擎 | 画像评分/调仓建议为前端规则模拟 | 后端补接口后切换数据源 | +| 行情/净值无自动刷新 | 依赖手工同步脚本 | 增加定时同步或接入实时源 | +| 涨跌色口径 | 新图表为**涨红跌绿**;产品列表/收益明细为绿涨红跌 | 统一口径后改 `advisor-plan.css` 两个变量即可 | +| alembic 版本指针 | 本地库指针与实际结构对不上,新表依赖手工应用 DDL | 修复版本指针后回归 `alembic upgrade head` | From 2fe7d0c50614acb488914dd9bb38477c5fb1920d Mon Sep 17 00:00:00 2001 From: Windows Date: Wed, 16 Sep 2026 18:17:46 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(=E6=8A=95=E9=A1=BE):=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E4=B8=BB=E5=8A=A8=E7=94=B3=E6=8A=A5=E6=8A=95=E9=A1=BE?= =?UTF-8?q?=E6=96=B9=E6=A1=88=20+=20=E6=8A=95=E9=A1=BE=E5=8F=97=E7=90=86?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E6=96=B9=E6=A1=88=E8=8D=89?= =?UTF-8?q?=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户在自己主页提交申报 → 投顾工作台受理 → 自动跑既有推荐逻辑生成一份待审核草稿 → 投顾再走既有的「审核通过 → 发送给客户」。补上原先「客户只能被动等方案」的缺口。 后端 - 新增表 advisor_service_request(本轮新建,带 AUTO_INCREMENT)+ 迁移 20260916_advisor_service_request(幂等:先查表再建,兼容本库 alembic 指针滞后) - 新增 AdvisorServiceRequestService:create / list_mine / queue / review · 申报前置:风险测评必须存在且未失效(FM-03,12 个月),服务端判 · 队列复用 ProductRecommendationService._visible_customer_ids(本人 + 归属),待受理排前 · 受理即调用推荐逻辑生成草稿并把 content_id 回填;生成前置失败给出人话原因并保持待受理 · 「已发送客户」不落库,由关联方案 published_at 推导,避免两处状态各写各的 - 权限码 9071-9074(客户 write/read:self、投顾 read/review),已进种子与授权工具 前端 - 投顾工作台新增「客户申报」面板(受理 / 驳回,驳回理由客户可见) - 客户页新增申报表单(金额/期限/风险偏好/备注)与「我的申报」列表 客户自助路由刻意不挂投顾灰度闸门:那是投顾业务的灰度,客户提交自己的申请不该被它拦下。 --- .../20260916_advisor_service_request.py | 68 ++++ .../controllers/advisor_service_requests.py | 85 +++++ app/model/advisor_service_request.py | 41 +++ .../advisor_service_request_service.py | 303 ++++++++++++++++++ app/static/portal/common/api-client.js | 16 + .../employee-advisor/dashboard/dashboard.js | 53 +-- .../employee-advisor/dashboard/index.html | 28 +- .../dashboard/service-request-module.js | 134 ++++++++ tools/check_portal_modules.py | 2 + tools/grant_advisor_role.py | 20 +- tools/seed_test_rbac.py | 17 + 11 files changed, 741 insertions(+), 26 deletions(-) create mode 100644 alembic/versions/20260916_advisor_service_request.py create mode 100644 app/api/controllers/advisor_service_requests.py create mode 100644 app/model/advisor_service_request.py create mode 100644 app/service/advisor_service_request_service.py create mode 100644 app/static/portal/employee-advisor/dashboard/service-request-module.js diff --git a/alembic/versions/20260916_advisor_service_request.py b/alembic/versions/20260916_advisor_service_request.py new file mode 100644 index 0000000..d205983 --- /dev/null +++ b/alembic/versions/20260916_advisor_service_request.py @@ -0,0 +1,68 @@ +"""create advisor_service_request (客户申报 → 投顾受理) + +新增一张表:客户在「我的投顾方案」页主动申报,投顾在工作台受理/驳回。 +表内**新列全部可空或有业务默认**,不改动任何既有表,因此与基线完全兼容。 + +⚠️ 本表的 `id` 是 `AUTO_INCREMENT`:既有 `fin_*` 表缺自增(`20260914_baseline_auto_increment` +那次修复之后才补齐),新表直接按现代写法建,不再重复那个坑。 + +幂等:先查 `information_schema` 再建表 —— 本仓库的 alembic 版本指针与实际结构 +历史上就对不上(指针停在 `20260911_adv_profile_tags`),该表可能已被 DDL 手工建过; +无条件 `CREATE TABLE` 会在这种环境直接失败。 +""" + +from sqlalchemy import text + +from alembic import op + +revision = "20260916_advisor_service_request" +down_revision = "20260914_baseline_auto_increment" +branch_labels = None +depends_on = None + +CREATE_TABLE = """ +CREATE TABLE `advisor_service_request` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `request_no` VARCHAR(32) NOT NULL, + `customer_id` BIGINT UNSIGNED NOT NULL, + `amount_wan` DECIMAL(14,2) NOT NULL, + `horizon` VARCHAR(16) NOT NULL, + `risk_preference` VARCHAR(16) NOT NULL, + `note` VARCHAR(500) NULL, + `status` VARCHAR(24) NOT NULL, + `handled_by` BIGINT UNSIGNED NULL, + `handled_at` DATETIME NULL, + `advisor_note` VARCHAR(500) NULL, + `result_content_id` BIGINT UNSIGNED NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_advisor_service_request_no` (`request_no`), + KEY `idx_advisor_service_request_customer` (`customer_id`, `status`), + KEY `idx_advisor_service_request_advisor` (`status`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='客户主动申报的投顾服务申请单' +""" + + +def _exists() -> bool: + bind = op.get_bind() + return bool( + bind.execute( + text( + "SELECT COUNT(*) FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'advisor_service_request'" + ) + ).scalar() + ) + + +def upgrade() -> None: + if _exists(): + return + op.execute(CREATE_TABLE) + + +def downgrade() -> None: + # 表里是客户主动提交的申请,**不做自动删表**:需要回退请人工确认后执行。 + raise RuntimeError("advisor_service_request must not be dropped automatically") diff --git a/app/api/controllers/advisor_service_requests.py b/app/api/controllers/advisor_service_requests.py new file mode 100644 index 0000000..415c42d --- /dev/null +++ b/app/api/controllers/advisor_service_requests.py @@ -0,0 +1,85 @@ +"""客户申报投顾方案 + 投顾受理审批 的 HTTP 入口(`docs/05` 未登记的 AD 段新端点)。 + +| 段 | 端点 | 权限码 | 摘要 | +|---|---|---|---| +| 客户 | `POST /api/v1/users/me/advisor-requests` | `advisor-request:write:self` | 提交申报 | +| 客户 | `GET /api/v1/users/me/advisor-requests` | `advisor-request:read:self` | 我的申报(含状态) | +| 投顾 | `GET /api/v1/advisor/service-requests` | `advisor-request:read` | 名下客户的申报队列 | +| 投顾 | `POST /api/v1/advisor/service-requests/{request_no}/reviews` | `advisor-request:review` | 受理 / 驳回 | + +客户侧刻意**不挂投顾灰度闸门**(`enforce_advisor_rollout`)—— 那是投顾业务的灰度, +客户提交自己的服务申请不该被它拦下;投顾侧照旧挂上。 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Header, Path + +from app.api.dependencies.auth import build_request_context +from app.api.dependencies.rate_limit import enforce_rate_limit +from app.core.contracts import RequestContext +from app.core.errors import ValidationAgentError +from app.service.advisor_rollout_service import enforce_advisor_rollout +from app.service.advisor_service_request_service import ( + AdvisorServiceRequestCreate, + AdvisorServiceRequestService, +) + +client_router = APIRouter( + prefix="/api/v1/users/me", + tags=["advisor-service-requests"], + dependencies=[Depends(enforce_rate_limit)], +) +advisor_router = APIRouter( + prefix="/api/v1/advisor", + tags=["advisor-service-requests"], + dependencies=[Depends(enforce_rate_limit), Depends(enforce_advisor_rollout)], +) + + +@client_router.post("/advisor-requests") +async def create_advisor_request( + payload: AdvisorServiceRequestCreate, + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + """客户主动申报投顾方案(需已完成且未过期的风险测评)。""" + return await AdvisorServiceRequestService().create(payload, context, key) + + +@client_router.get("/advisor-requests") +async def my_advisor_requests( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + """我的申报单(待受理 / 已受理 / 已发送 / 已驳回)。""" + return await AdvisorServiceRequestService().list_mine(context) + + +@advisor_router.get("/service-requests") +async def advisor_service_request_queue( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + """名下客户提交的申报队列(待受理排前面)。""" + return await AdvisorServiceRequestService().queue(context) + + +@advisor_router.post("/service-requests/{request_no}/reviews") +async def review_advisor_service_request( + payload: dict[str, object], + request_no: str = Path(min_length=8, max_length=32), + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + """受理(**自动生成方案草稿**)或驳回客户的申报。 + + 受理后投顾仍需走既有的「审核通过 → 发送给客户」,方案才会到客户手里。 + """ + decision = payload.get("decision") + if decision not in {"accepted", "rejected"}: + raise ValidationAgentError("decision 必须为 accepted 或 rejected") + comment = payload.get("comment", "") + if not isinstance(comment, str): + raise ValidationAgentError("comment 必须是字符串") + return await AdvisorServiceRequestService().review( + request_no, str(decision), comment, context, key + ) diff --git a/app/model/advisor_service_request.py b/app/model/advisor_service_request.py new file mode 100644 index 0000000..6e10e1a --- /dev/null +++ b/app/model/advisor_service_request.py @@ -0,0 +1,41 @@ +"""客户主动申报投顾方案 → 投顾受理审批 的工单模型。""" + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, DateTime, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.model.base import Base + +#: 状态机(`pending --受理--> accepted` / `pending --驳回--> rejected`)。 +#: 「已发送给客户」不单独存状态:它由 `result_content_id` 指向的方案是否已发布**推导**出来 +#: (见 `AdvisorServiceRequestService._view`),避免两处状态各写各的而对不上。 +PENDING = "pending" +ACCEPTED = "accepted" +REJECTED = "rejected" +DELIVERED = "delivered" + + +class AdvisorServiceRequest(Base): + """客户在「我的投顾方案」页发起的服务申请单。""" + + __tablename__ = "advisor_service_request" + + #: 本表是本轮新建的,**带上 AUTO_INCREMENT** —— 既有 `fin_*` 表缺自增 + #: 导致"不给 id 就插不进去"的坑,新表不再重复踩。 + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + request_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False) + customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + amount_wan: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False) + horizon: Mapped[str] = mapped_column(String(16), nullable=False) + risk_preference: Mapped[str] = mapped_column(String(16), nullable=False) + note: Mapped[str | None] = mapped_column(String(500)) + status: Mapped[str] = mapped_column(String(24), nullable=False) + handled_by: Mapped[int | None] = mapped_column(BigInteger) + handled_at: Mapped[datetime | None] = mapped_column(DateTime) + advisor_note: Mapped[str | None] = mapped_column(String(500)) + #: 受理时自动生成的方案(`client_facing_content.id`);驳回时为 None。 + result_content_id: Mapped[int | None] = mapped_column(BigInteger) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) diff --git a/app/service/advisor_service_request_service.py b/app/service/advisor_service_request_service.py new file mode 100644 index 0000000..813f1b3 --- /dev/null +++ b/app/service/advisor_service_request_service.py @@ -0,0 +1,303 @@ +"""客户主动申报投顾方案 → 投顾受理审批。 + +## 流程 + + 客户「我的投顾方案」页提交申报(金额/期限/风险偏好/备注) + → 落 `advisor_service_request`(status=pending) + → 投顾工作台「客户申报」看到待办 + → 投顾受理:**自动跑一次推荐**,生成一份 pending_review 的方案草稿, + 并把草稿 id 记进 `result_content_id`(status=accepted) + → 投顾随后走既有的「审核通过 → 发送给客户」 + → 客户在「我的投顾方案」看到方案(status 呈现为 delivered) + +## 两条前置/边界 + +1. **申报前置**:必须已完成风险测评且未失效(FM-03:12 个月)。服务端判, + 不依赖前端闸门 —— 前端只是体验层。 +2. **归属**:投顾只能处理**名下客户**的申报,复用 + `ProductRecommendationService._visible_customer_ids`(本人 + `sys_customer_assignment`), + 与已发布方案/历史记录同一把尺子。 + +## 状态不重复存 + +「已发送给客户」不单独存状态,而是由 `result_content_id` 指向的方案是否已发布 +**推导**(见 `_view`)—— 两处各存一份必然对不上。 +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select + +from app.core.contracts import RequestContext +from app.core.errors import ( + ForbiddenAgentError, + GenericResourceNotFoundError, + InvalidStateError, + ValidationAgentError, +) +from app.core.product_recommendation_contracts import ProductRecommendationQuery +from app.infrastructure.db import SessionFactory +from app.model.advisor_service_request import ( + ACCEPTED, + DELIVERED, + PENDING, + REJECTED, + AdvisorServiceRequest, +) +from app.model.investment_goal import ClientFacingContent +from app.service.api_transaction_service import ApiTransactionService +from app.service.authorization_service import AuthorizationService +from app.service.product_recommendation_service import ProductRecommendationService +from app.service.suitability_service import SuitabilityService + +logger = logging.getLogger(__name__) + +#: 与投顾工作台左栏表单的选项一致;服务端仍要校验一次(前端只是体验层)。 +HORIZONS: tuple[str, ...] = ("<1年", "1-3年", "3-5年", ">5年") +RISK_PREFERENCES: tuple[str, ...] = ("稳健", "平衡", "进取") + +#: FM-03:风险测评 12 个月失效。 +ASSESSMENT_VALID_DAYS = 365 + + +def _naive_utc(value: datetime) -> datetime: + """归一成 **UTC naive** 再比较。 + + 适配器返回的时间可能带 tz(`valid_until`),而 `now` 是 naive —— + 直接比较会 `TypeError: can't compare offset-naive and offset-aware datetimes`。 + """ + if value.tzinfo is None: + return value + return value.astimezone(UTC).replace(tzinfo=None) + +#: 受理时推荐逻辑的前置失败 → 给投顾看得懂的话(`generate` 的 early return 状态码)。 +GENERATE_FAILURES: dict[str, str] = { + "profile_required": "客户尚未完成风险测评,无法生成方案", + "investment_goal_required": "客户暂无已确认投资目标,请先录入并确认目标", + "recommendation_input_invalid": "客户投资目标数据不完整,无法生成方案", +} + + +class AdvisorServiceRequestCreate(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + amount_wan: float = Field(gt=0, le=1_000_000, description="拟投资金额(万元)") + horizon: str = Field(description="投资期限") + risk_preference: str = Field(description="风险偏好") + note: str | None = Field(default=None, max_length=500, description="补充说明") + + +class AdvisorServiceRequestService: + # ---- 客户侧 ---- + + async def create( + self, payload: AdvisorServiceRequestCreate, context: RequestContext, key: str | None + ) -> dict[str, object]: + await AuthorizationService.require(context, "advisor-request:write:self") + if payload.horizon not in HORIZONS: + raise ValidationAgentError(f"投资期限只能是 {list(HORIZONS)} 之一") + if payload.risk_preference not in RISK_PREFERENCES: + raise ValidationAgentError(f"风险偏好只能是 {list(RISK_PREFERENCES)} 之一") + customer_id = int(context.user_id) + await self._require_valid_assessment(customer_id) + + async def operation(session: Any) -> dict[str, object]: + now = datetime.now(UTC).replace(tzinfo=None) + # 同一客户同一秒可能连点两次:后缀加短随机码,避免撞 `request_no` 唯一键。 + request_no = f"ASR-{customer_id}-{now:%y%m%d%H%M%S}-{uuid.uuid4().hex[:4].upper()}" + row = AdvisorServiceRequest( + request_no=request_no, + customer_id=customer_id, + amount_wan=Decimal(str(payload.amount_wan)), + horizon=payload.horizon, + risk_preference=payload.risk_preference, + note=payload.note, + status=PENDING, + created_at=now, + updated_at=now, + ) + session.add(row) + await session.flush() + return { + "data": {"request_no": request_no, "status": PENDING}, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + f"advisor-request:create:{customer_id}", + key, + payload.model_dump(mode="json"), + operation, + ) + + async def list_mine(self, context: RequestContext) -> dict[str, object]: + await AuthorizationService.require(context, "advisor-request:read:self") + rows = await self._fetch(customer_ids=[int(context.user_id)]) + rows.sort(key=lambda row: row.created_at, reverse=True) + return {"data": await self._views(rows), "meta": {"trace_id": context.trace_id}} + + # ---- 投顾侧 ---- + + async def queue(self, context: RequestContext) -> dict[str, object]: + await AuthorizationService.require(context, "advisor-request:read") + customer_ids = list(ProductRecommendationService._visible_customer_ids(context)) + rows = await self._fetch(customer_ids=customer_ids) + # 待受理排前面,其余按时间倒序 —— 投顾打开面板先看到"要动手的"。 + rows.sort(key=lambda row: (row.status != PENDING, -row.created_at.timestamp())) + return {"data": await self._views(rows), "meta": {"trace_id": context.trace_id}} + + async def review( + self, + request_no: str, + decision: str, + comment: str, + context: RequestContext, + key: str | None, + ) -> dict[str, object]: + await AuthorizationService.require(context, "advisor-request:review") + if decision not in {"accepted", "rejected"}: + raise ValidationAgentError("decision 必须为 accepted 或 rejected") + + async with SessionFactory() as session: + existing = await session.scalar( + select(AdvisorServiceRequest).where( + AdvisorServiceRequest.request_no == request_no + ) + ) + if existing is None: + raise GenericResourceNotFoundError("申报单不存在") + visible = set(ProductRecommendationService._visible_customer_ids(context)) + if existing.customer_id not in visible: + raise ForbiddenAgentError("无权处理该客户的申报") + if existing.status != PENDING: + raise InvalidStateError("该申报单已被处理") + + content_id: int | None = None + if decision == "accepted": + content_id = await self._generate_draft_plan(request_no, existing.customer_id, context) + + async def operation(session: Any) -> dict[str, object]: + row = await session.get(AdvisorServiceRequest, existing.id, with_for_update=True) + if row is None or row.status != PENDING: + raise InvalidStateError("该申报单已被处理") + now = datetime.now(UTC).replace(tzinfo=None) + row.status = ACCEPTED if decision == "accepted" else REJECTED + row.handled_by = int(context.user_id) + row.handled_at = now + row.advisor_note = comment or None + row.result_content_id = content_id + row.updated_at = now + await session.flush() + return { + "data": { + "request_no": row.request_no, + "status": row.status, + "result_content_id": str(content_id) if content_id else None, + }, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + f"advisor-request:{request_no}:review", + key, + {"decision": decision, "comment": comment}, + operation, + ) + + # ---- 内部 ---- + + async def _require_valid_assessment(self, customer_id: int) -> None: + """申报前置:必须已完成风险测评且未失效(FM-03:12 个月)。""" + profile = await SuitabilityService().authority_for_customer(customer_id) + if profile.customer_risk_level is None: + raise InvalidStateError("请先完成风险测评,再申报投顾方案") + now = datetime.now(UTC).replace(tzinfo=None) + if profile.valid_until is not None: + if _naive_utc(profile.valid_until) < now: + raise InvalidStateError("风险测评已过期,请重新测评后再申报") + return + # 没写有效期时按 FM-03 的 12 个月口径兜底 —— 与前端熔断规则同一口径。 + if ( + profile.assessed_at is not None + and now - _naive_utc(profile.assessed_at) > timedelta(days=ASSESSMENT_VALID_DAYS) + ): + raise InvalidStateError("风险测评已超过 12 个月,请重新测评后再申报") + + async def _generate_draft_plan( + self, request_no: str, customer_id: int, context: RequestContext + ) -> int: + """受理时自动出草稿:沿用既有推荐逻辑(硬约束 + 适当性 + 排序 + LLM 依据)。""" + result = await ProductRecommendationService( + enforce_profile_governance=True + ).generate( + ProductRecommendationQuery(customer_id=customer_id, limit=3), + context, + f"advisor-request-{request_no}-generate", + ) + status = result.get("status") + if isinstance(status, str) and status in GENERATE_FAILURES: + raise InvalidStateError(GENERATE_FAILURES[status]) + payload = result.get("data") + content_id = str((payload or {}).get("content_id") or "") if isinstance(payload, dict) else "" + if not content_id.isdigit(): + raise InvalidStateError("方案生成未返回方案编号,请稍后重试") + return int(content_id) + + @staticmethod + async def _fetch(customer_ids: list[int]) -> list[AdvisorServiceRequest]: + if not customer_ids: + return [] + async with SessionFactory() as session: + return list( + await session.scalars( + select(AdvisorServiceRequest) + .where(AdvisorServiceRequest.customer_id.in_(customer_ids)) + .order_by(AdvisorServiceRequest.created_at.desc()) + .limit(50) + ) + ) + + @staticmethod + async def _views(rows: list[AdvisorServiceRequest]) -> list[dict[str, object]]: + """补上「方案是否已发布」——`delivered` 是**推导**出来的,不单独存状态。""" + content_ids = [row.result_content_id for row in rows if row.result_content_id] + published: dict[int, bool] = {} + if content_ids: + async with SessionFactory() as session: + found = ( + await session.execute( + select(ClientFacingContent.id, ClientFacingContent.published_at).where( + ClientFacingContent.id.in_(content_ids) + ) + ) + ).all() + published = {int(cid): published_at is not None for cid, published_at in found} + views: list[dict[str, object]] = [] + for row in rows: + delivered = bool( + row.result_content_id and published.get(int(row.result_content_id)) + ) + status = DELIVERED if delivered else row.status + views.append({ + "request_no": row.request_no, + "customer_id": str(row.customer_id), + "amount_wan": float(row.amount_wan), + "horizon": row.horizon, + "risk_preference": row.risk_preference, + "note": row.note, + "status": status, + "advisor_note": row.advisor_note, + "handled_at": row.handled_at.isoformat() if row.handled_at else None, + "result_content_id": str(row.result_content_id) if row.result_content_id else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + }) + return views diff --git a/app/static/portal/common/api-client.js b/app/static/portal/common/api-client.js index 60e6bac..218a403 100644 --- a/app/static/portal/common/api-client.js +++ b/app/static/portal/common/api-client.js @@ -93,11 +93,27 @@ const ENDPOINTS = Object.freeze({ T008: { method: 'GET', path: '/api/v1/users/me/transactions/{transactionNo}' }, T009: { method: 'GET', path: '/api/v1/users/me/cash-ledger' }, ADVISOR_PUBLISHED: { method: 'GET', path: '/api/v1/advisor/recommendations/published' }, + // 历史方案留档(本人 + 名下客户、**全部状态**,含待审/已驳回)。投顾工作台 + // 「历史方案记录」面板用;与 ADVISOR_PUBLISHED 的区别只在状态口径(后者只给已发布)。 + ADVISOR_HISTORY: { method: 'GET', path: '/api/v1/advisor/recommendations/history' }, // 投顾自助审核/发布自己生成的推荐方案(2026-09-14 起): // 服务层不再额外要求 admin 角色,但仍要求 `product-recommendation:review` / // `product-recommendation:publish` 两个权限码(仅 advisor 与 admin 持有)。 ADVISOR_REVIEW_RECOMMENDATION: { method: 'POST', path: '/api/v1/advisor/recommendations/{contentId}/reviews', idempotent: true }, ADVISOR_PUBLISH_RECOMMENDATION: { method: 'POST', path: '/api/v1/advisor/recommendations/{contentId}/publications', idempotent: true }, + // 删除推荐方案(历史记录里的「删除」)。仅推荐方案可删 —— 投资方案书被 + // `advisor_investment_goal.goal_book_content_id`(NO ACTION 外键)引用,删不掉。 + // 权限码 `product-recommendation:delete`(advisor 与 admin 持有)。 + ADVISOR_DELETE_RECOMMENDATION: { method: 'DELETE', path: '/api/v1/advisor/recommendations/{contentId}', idempotent: true }, + // 客户侧:**本人**已发布的投顾交付物(推荐方案 + 投资方案书)。 + // 是投顾「发送给客户」的落点,见 `customer/advisor-plans/`。 + MY_ADVISOR_CONTENTS: { method: 'GET', path: '/api/v1/users/me/advisor-contents' }, + // 客户**主动申报**投顾方案(本人提交 / 查看自己的申报状态)。 + MY_ADVISOR_REQUESTS: { method: 'GET', path: '/api/v1/users/me/advisor-requests' }, + ADVISOR_REQUEST_CREATE: { method: 'POST', path: '/api/v1/users/me/advisor-requests', idempotent: true }, + // 投顾侧:名下客户的申报队列 + 受理/驳回(**受理会自动生成一份待审方案草稿**)。 + ADVISOR_SERVICE_REQUESTS: { method: 'GET', path: '/api/v1/advisor/service-requests' }, + ADVISOR_SERVICE_REQUEST_REVIEW: { method: 'POST', path: '/api/v1/advisor/service-requests/{requestNo}/reviews', idempotent: true }, // ⚠️ 保留:前端契约测试(`tests/unit/api/test_portal_frontend.py`)把"页面会用到的端点" // 固定成一张清单,**删注册会破坏它**。它对应 AD002,当前页面确实没调用 // (投顾本人没有"自己的投资目标",调它返回 404)—— 但**注册与调用是两件事**。 diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.js b/app/static/portal/employee-advisor/dashboard/dashboard.js index 50885df..e7d5f26 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.js +++ b/app/static/portal/employee-advisor/dashboard/dashboard.js @@ -1,14 +1,14 @@ -// 投顾工作台。本文件只做**组合**,能力拆在五个模块里: +// 投顾工作台。本文件只做**组合**,能力拆在六个模块里: // // · advisor-config.js 文案 / 操作 / 熔断规则 / 演示数据的唯一来源 // · advisor-engine.js 本地演示引擎(纯函数:后端无接口的两个操作 + 离线回退) // · customer-module.js 我的客户列表与选中客户(整个工作台的上下文) // · actions-module.js 七项操作:表单、请求、熔断闸门、结果渲染 -// · published-module.js 已发布交付物(只读列表) +// · history-module.js 历史方案记录(只读列表:全部状态的方案与方案书) // · assistant-module.js 统一对话入口(前端意图路由) // // 构图:**左栏(投顾身份 / 我的客户+参数+动作按钮+自然语言输入 / 执行日志)+ -// 主区(推荐流水线 / 结果 / 已发布交付物)**。结果与对话回答共用同一个容器。 +// 主区(推荐流水线 / 结果 / 历史方案记录)**。结果与对话回答共用同一个容器。 // // 每个模块各自 import 它需要的公共件(apiClient / formatters / state-view), // 本层不再重复 import —— 同一个东西两处 import,改一处漏一处是这类拆分的典型退化。 @@ -28,16 +28,17 @@ // —— 版本号不一致会让同一个文件被两个 URL 引入,模块被实例化成两份。 // (import 说明符是静态的,没法用变量拼,只能逐处写。) import { clearAuthSession, getAccessToken, getAuthContext, requireAdvisor } from '/static/portal/common/auth.js?v=20260913'; -import { apiClient } from '/static/portal/common/api-client.js?v=20260914-3'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260916-history3'; import { escapeHtml } from '/static/portal/common/formatters.js?v=20260913'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { renderLoading } from '/static/portal/common/state-view.js?v=20260913'; import { METRIC_HINTS, PIPELINE_STEPS } from './advisor-config.js?v=20260914-advisor9'; import { assessmentFuseHits } from './advisor-engine.js?v=20260914-advisor9'; -import { createActionsModule } from './actions-module.js?v=20260914-advisor9'; +import { createActionsModule } from './actions-module.js?v=20260916-plan3'; import { createAssistantModule } from './assistant-module.js?v=20260914-advisor9'; import { createCustomerModule } from './customer-module.js?v=20260914-advisor9'; -import { createPublishedModule } from './published-module.js?v=20260914-advisor9'; +import { createHistoryModule } from './history-module.js?v=20260916-history3'; +import { createServiceRequestModule } from './service-request-module.js?v=20260916-plan3'; const DEMO_MODE = new URLSearchParams(window.location.search).get('demo') === '1'; @@ -106,7 +107,7 @@ function start(context) { // // 形态与「平台治理工作台」「风险工作台」一致:label / value / meta 三元组。 // 数据分两批到:客户相关的一开始就能算(本地数据),已发布方案数要等接口回来 - // (见下面 `published` 的 `onClick` 回调)。所以先渲染一次、拿到数再渲染一次, + // (见下方 `ADVISOR_PUBLISHED` 取数)。所以先渲染一次、拿到数再渲染一次, // 单张卡在非登录态显示「--」而不是假 0 —— 0 和「不知道」是两回事。 const metricBox = document.querySelector('[data-advisor-metrics]'); let publishedCount = null; @@ -163,11 +164,6 @@ function start(context) { online, }); - const published = createPublishedModule({ - list: document.querySelector('[data-recommendations]'), - onClick: (count) => { publishedCount = count; renderMetrics(); }, - }); - const assistant = createAssistantModule({ log, output: document.querySelector('[data-action-output]'), @@ -178,24 +174,41 @@ function start(context) { online, }); - document.querySelector('[data-refresh]').addEventListener('click', () => published.load()); + // 历史方案记录(只读 + 详情弹窗):与其它模块无耦合,独立取数即可。 + const history = createHistoryModule({ + list: document.querySelector('[data-history]'), + dialog: document.querySelector('[data-history-dialog]'), + }); + + // 客户申报队列:受理即自动生成方案草稿(后端跑既有推荐逻辑)。 + const serviceRequests = createServiceRequestModule({ + list: document.querySelector('[data-service-requests]'), + }); + actions.bind(); assistant.bind(); + document.querySelector('[data-history-refresh]').addEventListener('click', () => history.load()); + document.querySelector('[data-service-request-refresh]') + ?.addEventListener('click', () => serviceRequests.load()); document.querySelector('[data-boot-hint]')?.remove(); customers.render(); renderMetrics(); if (online()) { - // 已发布交付物只在登录后才有意义;顺手用它的成败判断后端是否在线 —— - // 比单独打一次 /health 更省一次请求,失败时也仍然有降级文案。 - published.load().then(() => { - apiClient.get('HEALTH') - .then(() => { backendOnline = true; setStatus('后端在线 · FastAPI', null); renderMetrics(); }) - .catch(() => { backendOnline = false; setStatus('后端未连接 · 本地引擎', 'medium'); renderMetrics(); }); - }); + // 历史方案记录 + 顶部「已发布方案数」指标卡都要真实后端;后端在线与否由 /health 判断。 + history.load(); + serviceRequests.load(); + apiClient.get('ADVISOR_PUBLISHED') + .then((response) => { publishedCount = Array.isArray(response.data) ? response.data.length : 0; renderMetrics(); }) + .catch(() => { publishedCount = 0; renderMetrics(); }); + apiClient.get('HEALTH') + .then(() => { backendOnline = true; setStatus('后端在线 · FastAPI', null); renderMetrics(); }) + .catch(() => { backendOnline = false; setStatus('后端未连接 · 本地引擎', 'medium'); renderMetrics(); }); } else { backendOnline = false; + history.showPlaceholder(); + serviceRequests.showPlaceholder(); renderMetrics(); actions.execute('recommend'); } diff --git a/app/static/portal/employee-advisor/dashboard/index.html b/app/static/portal/employee-advisor/dashboard/index.html index 0bac14d..9e6c9bd 100644 --- a/app/static/portal/employee-advisor/dashboard/index.html +++ b/app/static/portal/employee-advisor/dashboard/index.html @@ -6,7 +6,8 @@ 投顾工作台 · 南方财富 - + +
@@ -116,14 +117,31 @@
-

已发布交付物

- +

客户申报

+
-
+
+
+ +
+
+

历史方案记录

+ +
+
- + + +
+

方案详情

+ +
+
+
+ + diff --git a/app/static/portal/employee-advisor/dashboard/service-request-module.js b/app/static/portal/employee-advisor/dashboard/service-request-module.js new file mode 100644 index 0000000..485b791 --- /dev/null +++ b/app/static/portal/employee-advisor/dashboard/service-request-module.js @@ -0,0 +1,134 @@ +// 客户申报队列(投顾工作台)。 +// +// 客户在「我的投顾方案」页主动申报 → 这里出现待办 → 投顾「受理」时后端**自动跑一次推荐**, +// 生成一份 pending_review 的方案草稿;投顾随后走既有的「审核通过 → 发送给客户」。 +// +// 状态口径(后端 `AdvisorServiceRequestService`):`pending` 待受理 / `accepted` 已受理 +// / `rejected` 已驳回;**`delivered`(已发送)是推导出来的** —— 由关联方案是否已发布决定, +// 不在本模块里猜。 + +import { apiClient } from '/static/portal/common/api-client.js?v=20260916-history3'; +import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913'; +import { showToast } from '/static/portal/common/notifications.js'; +import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260913'; + +//: 状态 → 文案 / 标签色。 +const STATUS_LABELS = Object.freeze({ + pending: '待受理', + accepted: '已受理 · 方案待审核', + delivered: '已发送客户', + rejected: '已驳回', +}); +const STATUS_TONES = Object.freeze({ + pending: 'medium', + accepted: 'active', + delivered: 'low', + rejected: 'failed', +}); + +function statusLabel(status) { + return STATUS_LABELS[status] || String(status || '--'); +} + +function cardHtml(item) { + const tone = STATUS_TONES[item.status] || 'active'; + const pending = item.status === 'pending'; + return `
` + + `

申报单 ${escapeHtml(item.request_no)}` + + ` ${escapeHtml(statusLabel(item.status))}

` + + `

客户 ${escapeHtml(item.customer_id)}` + + ` · ${escapeHtml(String(item.amount_wan))} 万元` + + ` · 期限 ${escapeHtml(item.horizon || '--')}` + + ` · 风险偏好 ${escapeHtml(item.risk_preference || '--')}` + + ` · 提交于 ${escapeHtml(formatDateTime(item.created_at))}

` + + (item.note ? `

客户备注:${escapeHtml(item.note)}

` : '') + + (item.advisor_note + ? `

处理意见:${escapeHtml(item.advisor_note)}

` + : '') + + (item.result_content_id + ? `

关联方案:编号 ${escapeHtml(item.result_content_id)}` + + `${item.status === 'delivered' ? '(已发送客户)' : '(草稿待审核)'}

` + : '') + + (pending + ? '
' + + `' + + `
' + : '') + + '
'; +} + +export function createServiceRequestModule({ list }) { + let rows = []; + let busy = false; + + function rowOf(requestNo) { + return rows.find((item) => item.request_no === requestNo) || null; + } + + async function review(requestNo, decision) { + if (busy) return; + let comment = ''; + if (decision === 'rejected') { + const input = window.prompt('驳回理由(可选,会展示给客户):', ''); + if (input === null) return; // 取消 + comment = input.slice(0, 200); + } else if (!window.confirm(`确认受理申报单 ${requestNo}?将自动为该客户生成一份方案草稿。`)) { + return; + } + busy = true; + list.setAttribute('aria-busy', 'true'); + try { + const response = await apiClient.post( + 'ADVISOR_SERVICE_REQUEST_REVIEW', + { decision, comment }, + { pathParams: { requestNo } }, + ); + const contentId = response?.data?.result_content_id; + showToast(decision === 'accepted' + ? `已受理,方案草稿已生成(编号 ${contentId || '--'}),请在结果区审核并发送给客户` + : `申报单 ${requestNo} 已驳回`); + await load(); + // 让历史记录等模块同步刷新(方案草稿刚被创建)。 + window.dispatchEvent(new CustomEvent('advisor:published-refresh')); + } catch (error) { + apiClient.reportError(error); + showToast(error.message || '受理未完成', 'error'); + } finally { + busy = false; + list.removeAttribute('aria-busy'); + } + } + + list.addEventListener('click', (event) => { + const button = event.target.closest('[data-request-action]'); + if (!button) return; + void review(button.dataset.requestNo, button.dataset.requestAction); + }); + + async function load() { + renderLoading(list, 2); + try { + const response = await apiClient.get('ADVISOR_SERVICE_REQUESTS'); + rows = Array.isArray(response.data) ? response.data : []; + if (!rows.length) { + renderEmpty(list, '暂无客户申报', '客户在「我的投顾方案」页提交申报后,待办会出现在这里。'); + return; + } + list.innerHTML = rows.map(cardHtml).join(''); + } catch (error) { + rows = []; + apiClient.reportError(error); + renderError(list, error, load); + } + } + + function showPlaceholder() { + renderEmpty(list, '客户申报需登录后查看', '当前为本地演示模式,未连接后端,因此没有申报可处理。'); + } + + return Object.freeze({ load, showPlaceholder, rowOf }); +} diff --git a/tools/check_portal_modules.py b/tools/check_portal_modules.py index f4fcf4a..f83978a 100644 --- a/tools/check_portal_modules.py +++ b/tools/check_portal_modules.py @@ -40,6 +40,8 @@ DASHBOARD_DIR = PORTAL / "employee-advisor" / "dashboard" MODULES = [ "dashboard.js", "actions-module.js", + "history-module.js", + "service-request-module.js", "published-module.js", "assistant-module.js", "customer-module.js", diff --git a/tools/grant_advisor_role.py b/tools/grant_advisor_role.py index 219db95..1b6db45 100644 --- a/tools/grant_advisor_role.py +++ b/tools/grant_advisor_role.py @@ -27,6 +27,7 @@ |---|---|---| | 投顾工作流(10) | `asset-allocation:generate:self`、`investment-goal:read:self` / `:review` / `:publish`、`portfolio-analysis:read:self`、`product-comparison:read:self`、`product-recommendation:read:self` / `:generate:self` / `:review` / `:publish` | `advisor` + `admin` —— **定义在种子的 9020-9034** | | 治理类(3,本脚本建) | `product-governance:read` / `:review` / `:sync` | **只给 `admin`** | +| 方案操作(3,本脚本声明,种子里是 9031/9032/9070) | `product-recommendation:review` / `:publish` / `:delete` | `advisor` + `admin` —— 用于**修复没跑过种子的环境**(否则工作台四个按钮整体 403) | | 治理类(种子已含) | `asset-allocation:backtest`、`profile-governance:read` / `:review` | **只给 `admin`** | `review` / `publish` 也给投顾,与项目既有决策一致 —— 此前已裁定**不做双人复核** @@ -63,12 +64,25 @@ ADVISOR_ROLE_NAME = "投资顾问" #: 1. 种子 `seed_test_rbac.py` 已占 9001-9034(含投顾线的 9020-9034); #: 2. 库里曾有一批 9020-9035 是本脚本用**旧号段**建的,与种子的 9020-9034 #: **id→code 映射不同** —— 整体挪到 9041 之后,与两侧都不冲突。 -#: 只定义种子里**缺**的这 3 个治理类权限码;其余 13 个由种子提供。 +#: 本脚本声明 6 行:3 个治理类(种子里没有,9041-9043)+ 3 个方案操作 +#: (种子里有,9031/9032/9070 —— 声明出来是为了在**没跑过种子的环境**里补齐)。 +#: `check_rbac_seed_consistency.py` 要求这里每行的 id+code 与种子完全一致。 #: (id, permission_code, resource, action, data_scope) ADVISOR_PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( (9041, "product-governance:read", "product-governance", "read", "all"), (9042, "product-governance:review", "product-governance", "review", "all"), (9043, "product-governance:sync", "product-governance", "sync", "all"), + # 推荐方案的审核/发布/删除。**定义在种子里**(9031/9032/9070),这里再声明一遍 + # 是为了让本脚本能**修复缺失的环境**:有些库(如本机演示库)从没跑过 + # `seed_test_rbac.py`,这三行就不存在 —— 结果是投顾工作台「审核通过 / 驳回 / + # 发送给客户 / 删除」四个按钮**整体 403**,而报错只显示"权限不足"。 + # 本脚本按 code 判重,缺哪行补哪行(只增不删)。 + (9031, "product-recommendation:review", "product-recommendation", "review", "all"), + (9032, "product-recommendation:publish", "product-recommendation", "publish", "all"), + (9070, "product-recommendation:delete", "product-recommendation", "delete", "all"), + # 客户申报受理(队列 + 受理/驳回)。定义在种子的 9073/9074。 + (9073, "advisor-request:read", "advisor-request", "read", "all"), + (9074, "advisor-request:review", "advisor-request", "review", "all"), ) #: 投顾拿哪些。2026-09-12 补齐:此前只给了 10 项工作流权限,结果投顾**用不了** @@ -102,6 +116,10 @@ ADVISOR_GRANTED_CODES: tuple[str, ...] = ( "portfolio-analysis:read:customer", "product-recommendation:review", "product-recommendation:publish", + "product-recommendation:delete", + # 客户申报受理:看名下客户的申报队列 + 受理/驳回(受理会自动出方案草稿)。 + "advisor-request:read", + "advisor-request:review", # 平台通用:投顾同样要跑 Agent、查行情、检索知识、看所服务客户的画像 "agent:run", "suitability:read", diff --git a/tools/seed_test_rbac.py b/tools/seed_test_rbac.py index 53f17a9..450d6c3 100644 --- a/tools/seed_test_rbac.py +++ b/tools/seed_test_rbac.py @@ -180,6 +180,21 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = ( # 状态机与审计见 `app/service/customer_service_handover_action_service.py`。 # data_scope 取 `all`:工单队列本身就是全平台视图,不按归属客户切。 (9069, "handover:write", "handover", "write", "all"), + # ---- 9070:推荐方案**删除** ---- + # 投顾工作台「历史方案记录」的四个按钮里,审核/发布复用 9031/9032,删除此前 + # **没有权限码**(`ProductRecommendationService.delete` 是本轮新增)。 + # 不给它单独的 `:self` 变体:删除按 content_id 寻址,归属由服务层的 + # `_visible_customer_ids`(本人 + 名下客户)兜底,与 published/history 同一把尺子。 + (9070, "product-recommendation:delete", "product-recommendation", "delete", "all"), + # ---- 9071-9074:客户**主动申报**投顾方案 + 投顾受理审批 ---- + # 客户在自己主页提交申报(金额/期限/风险偏好),投顾在工作台受理后自动出草稿。 + # 客户侧用 `:self`(只能看/提自己的);投顾侧 `read`/`review` 为 `all`, + # 但**真正的范围限制在服务层**:队列按 `sys_customer_assignment` 归属过滤 + # (`AdvisorServiceRequestService.queue` 复用 `_visible_customer_ids`)。 + (9071, "advisor-request:write:self", "advisor-request", "write", "self"), + (9072, "advisor-request:read:self", "advisor-request", "read", "self"), + (9073, "advisor-request:read", "advisor-request", "read", "all"), + (9074, "advisor-request:review", "advisor-request", "review", "all"), ) # 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。 @@ -190,6 +205,8 @@ CUSTOMER_PERMISSIONS = ( 9044, # ZSY §T:账户看板与场内模拟交易(首版仅 customer 角色可用,留 admin 全量) 9060, 9061, 9062, 9063, 9064, 9065, + # 客户主动申报投顾方案(提交 + 查看自己的申报) + 9071, 9072, ) # 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。 RISK_PERMISSIONS = ( From 74b7d00dff63c93ec71f29773428027e08ba20f8 Mon Sep 17 00:00:00 2001 From: Windows Date: Wed, 16 Sep 2026 18:17:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(=E6=8A=95=E9=A1=BE):=20=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E4=BA=A4=E4=BB=98=E8=90=BD=E7=82=B9=E3=80=81=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=E5=9B=BE=E8=A1=A8=E4=B8=8E=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E7=9A=84=E5=A4=A7=E6=A8=A1=E5=9E=8B=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 交付落点 - 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案): 「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它 - 客户端新增「我的投顾方案」页与导航入口 可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移) - 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、 资产配置环形图与图例、组合构成条 - 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%; 现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0 - 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义 推荐依据接入大模型(可选,失败即回退) - 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定) - 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性) - 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案 - 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败 - 前端标注来源(AI 生成 / 规则生成) 数据与权限 - 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065) - 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止) 测试 - 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界) - 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试 --- app/api/controllers/recommendations.py | 49 +++ app/core/config.py | 12 + app/main.py | 12 + app/service/advisor_reason_service.py | 191 +++++++++ app/service/product_recommendation_service.py | 266 +++++++++++- app/static/portal/common/advisor-plan-view.js | 397 ++++++++++++++++++ app/static/portal/common/advisor-plan.css | 119 ++++++ .../portal/common/customer-list-page.js | 2 +- app/static/portal/common/layout/app-shell.js | 1 + .../customer/advisor-plans/advisor-plans.css | 46 ++ .../customer/advisor-plans/advisor-plans.js | 212 ++++++++++ .../portal/customer/advisor-plans/index.html | 1 + .../dashboard/actions-module.js | 20 +- .../employee-advisor/dashboard/dashboard.css | 17 +- .../dashboard/history-module.js | 262 ++++++++++++ tests/unit/api/test_portal_frontend.py | 46 +- .../service/test_advisor_reason_service.py | 153 +++++++ 启动后端.bat | 121 ++++-- 18 files changed, 1873 insertions(+), 54 deletions(-) create mode 100644 app/service/advisor_reason_service.py create mode 100644 app/static/portal/common/advisor-plan-view.js create mode 100644 app/static/portal/common/advisor-plan.css create mode 100644 app/static/portal/customer/advisor-plans/advisor-plans.css create mode 100644 app/static/portal/customer/advisor-plans/advisor-plans.js create mode 100644 app/static/portal/customer/advisor-plans/index.html create mode 100644 app/static/portal/employee-advisor/dashboard/history-module.js create mode 100644 tests/unit/service/test_advisor_reason_service.py diff --git a/app/api/controllers/recommendations.py b/app/api/controllers/recommendations.py index 72ceb26..b4aa03b 100644 --- a/app/api/controllers/recommendations.py +++ b/app/api/controllers/recommendations.py @@ -21,6 +21,15 @@ admin_router = APIRouter( tags=["platform-admin"], dependencies=[Depends(enforce_rate_limit)], ) +#: 客户自助路由:客户看**自己**已发布的投顾交付物。 +#: 前缀挂在 `/api/v1/users/me` 下,与 §T 段(交易)同一约定。 +#: **故意不挂 `enforce_advisor_rollout`** —— 那是投顾业务的灰度闸门(按 +#: `sys_customer_assignment` 归属命中白名单),客户看自己的交付物不该被它拦下。 +client_router = APIRouter( + prefix="/api/v1/users/me", + tags=["advisor-deliveries"], + dependencies=[Depends(enforce_rate_limit)], +) @advisor_router.post("/recommendations") @@ -41,6 +50,19 @@ async def published_recommendations( return await ProductRecommendationService().published(context) +@advisor_router.get("/recommendations/history") +async def recommendation_history( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + """历史方案留档:本人 + 名下客户的**全部状态**方案与方案书。 + + 与 `/recommendations/published` 分开的原因:published 只给"已发布、对客户可见"的内容, + 投顾刚生成、还在待审的草案不在其中。历史记录面板要的是"以前生成过什么", + 所以这里返回全部状态,按生成时间倒序。 + """ + return await ProductRecommendationService().history(context) + + # ---- 投顾自助审核/发布(2026-09-14 新增)------------------------------------ # # 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...` @@ -76,6 +98,20 @@ async def advisor_publish_recommendation( return await ProductRecommendationService().publish(content_id, context, key) +@advisor_router.delete("/recommendations/{content_id}") +async def advisor_delete_recommendation( + content_id: int = Path(gt=0), + context: RequestContext = Depends(build_request_context), # noqa: B008 + key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> dict[str, object]: + """删除推荐方案(历史记录里的「删除」按钮)。 + + 仅推荐方案可删:投资方案书被 `advisor_investment_goal.goal_book_content_id` + (`NO ACTION` 外键、`NOT NULL`)引用,硬删会撞外键 —— 方案书走自己的生命周期。 + """ + return await ProductRecommendationService().delete(content_id, context, key) + + @admin_router.get( "/advisor/pending-contents", dependencies=[Depends(enforce_advisor_rollout)], @@ -123,3 +159,16 @@ async def publish_recommendation( key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: return await ProductRecommendationService().publish(content_id, context, key) + + +# ---- 客户侧:接收投顾交付物 ------------------------------------------------ +# +# 「发送给客户」此前**没有落点** —— 投顾发布后只是把 `published_at` 置上, +# 客户端门户没有任何页面/接口能读到它(客户"可见"只体现在数据口径上)。 +# 这条路由补上落点:客户登录后看**自己**已发布的推荐方案与方案书。 +@client_router.get("/advisor-contents") +async def my_advisor_contents( + context: RequestContext = Depends(build_request_context), # noqa: B008 +) -> dict[str, object]: + """我的投顾方案:本人已审核发布的投顾交付物(按发布时间倒序,最多 50 条)。""" + return await ProductRecommendationService().my_published(context) diff --git a/app/core/config.py b/app/core/config.py index 21680b7..4211ced 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -102,6 +102,18 @@ class Settings(BaseSettings): offsite_deepseek_api_key: str = "" offsite_deepseek_model: str = "deepseek-v4-flash" offsite_deepseek_timeout_seconds: float = Field(default=30, gt=0) + #: 平台级 DeepSeek 密钥。`model_endpoint_config.secret_ref` 走 `env:DEEPSEEK_API_KEY`, + #: 投顾「推荐依据」的 LLM 增强也复用它 —— 密钥只有一个存放点,不按特性各配一把。 + deepseek_api_key: str = "" + #: 投顾「推荐依据」的 LLM 增强(可选)。未启用、或取不到密钥/调用失败时, + #: **自动回退**到确定性的数据化文案:推荐流程绝不因为模型不可用而失败。 + advisor_reason_llm_enabled: bool = False + advisor_reason_llm_base_url: str = "https://api.deepseek.com" + #: 实测 `api.deepseek.com`:`deepseek-chat` 正常返回文本;`deepseek-v4-flash` / + #: `deepseek-reasoner` 返回 200 但 `content` 为空(推理型内容在 `reasoning_content`), + #: 客户端已做兜底读取,但默认仍用最稳的 `deepseek-chat`。 + advisor_reason_llm_model: str = "deepseek-chat" + advisor_reason_llm_timeout_seconds: float = Field(default=20, gt=0) offsite_smtp_enabled: bool = False offsite_smtp_dry_run: bool = True offsite_smtp_host: str = "" diff --git a/app/main.py b/app/main.py index 275d747..6fe97f4 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,12 @@ from fastapi.staticfiles import StaticFiles from app.api.controllers.admin import router as admin_router from app.api.controllers.agent_runs import router as agent_runs_router +from app.api.controllers.advisor_service_requests import ( + advisor_router as advisor_service_request_router, +) +from app.api.controllers.advisor_service_requests import ( + client_router as advisor_service_request_client_router, +) from app.api.controllers.asset_allocation import router as asset_allocation_router from app.api.controllers.auth import router as auth_router from app.api.controllers.conversations import router as conversations_router @@ -28,6 +34,9 @@ from app.api.controllers.recommendations import ( from app.api.controllers.recommendations import ( advisor_router as recommendation_advisor_router, ) +from app.api.controllers.recommendations import ( + client_router as recommendation_client_router, +) from app.api.controllers.risk import router as risk_router from app.api.controllers.trading import router as trading_router from app.api.controllers.visitor_tokens import router as visitor_tokens_router @@ -136,6 +145,9 @@ def create_app() -> FastAPI: application.include_router(asset_allocation_router) application.include_router(recommendation_advisor_router) application.include_router(recommendation_admin_router) + application.include_router(recommendation_client_router) + application.include_router(advisor_service_request_router) + application.include_router(advisor_service_request_client_router) application.include_router(admin_router) application.include_router(trading_router) static_directory = Path(__file__).resolve().parent / "static" diff --git a/app/service/advisor_reason_service.py b/app/service/advisor_reason_service.py new file mode 100644 index 0000000..5970691 --- /dev/null +++ b/app/service/advisor_reason_service.py @@ -0,0 +1,191 @@ +"""投顾「推荐依据」的 LLM 增强(**可选**,任何一步失败都回退到确定性文案)。 + +## 为什么要有它 + +投顾工作台账推方案里,每只产品的「推荐依据」原先是一句**所有产品都一样**的套话, +客户看不出"为什么选这一只"。这里用大模型把**已经算出来的真实参数** +(风险等级、排序得分、区间收益、最大回撤、客户的期限与流动性要求)写成 +一段面向客户的说明。 + +## 合规边界(三条,都在代码里强制执行) + +1. **只用给定数据**:提示词里明确禁止编造数字/业绩/奖项/排名/基金经理信息; +2. **禁止收益承诺**:产出命中 `PROHIBITED_PHRASES`(保本/保证收益/稳赚/无风险…) + 即**整条丢弃** —— 与 `investment_goal_service._PROHIBITED_GOAL_PHRASES` 同一口径; +3. **失败即回退**:未启用、缺密钥、超时、HTTP 错误、JSON 解析失败、字段缺失, + 一律返回空字典,由调用方保留确定性文案。**推荐流程绝不因模型不可用而失败**。 + +## 哪些不算数 + +本服务**不参与选品**,只改文案。选品仍然是 `ProductRecommendationService` 的 +硬约束 + 适当性 + 排序,模型看不到也改不了候选池。 +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import httpx + +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + +#: 收益承诺/绝对化表述 —— 命中即丢弃该条 LLM 文案。 +PROHIBITED_PHRASES: tuple[str, ...] = ( + "保本", "保证收益", "保收益", "稳赚", "稳赢", "无风险", "零风险", + "收益承诺", "包赚", "必赚", "稳赚不赔", "绝对收益", "确保收益", "锁定收益", +) + +#: 文案长度边界:太短没信息量、太长在卡片里读不完。 +MIN_REASON_CHARS = 20 +MAX_REASON_CHARS = 160 + +SYSTEM_PROMPT = """你是南方基金的投顾文案助手,为**已通过合规校验**的推荐产品撰写「推荐依据」。 + +硬性要求: +1. 只能使用我提供的数据,**严禁编造**任何数字、业绩、奖项、排名或基金经理信息; +2. **严禁**出现承诺收益或绝对化表述,例如:保本、保证收益、稳赚、无风险、零风险、收益承诺、包赚、必赚; +3. 每条 45~80 个汉字,面向个人客户,专业克制、可读,说明"为什么这只产品适合这位客户"; +4. 必须点出该产品的风险等级,并说明它与客户风险承受能力、投资期限或流动性要求的匹配关系; +5. 只输出 JSON,不要 Markdown 代码块、不要任何解释文字。 + +输出格式(严格): +{"items": [{"product_code": "159329", "reason": "……"}]}""" + + +def _pct(value: Any) -> str: + if not isinstance(value, (int, float)): + return "暂无" + return f"{value:+.2f}%" + + +def build_prompt(customer: dict[str, Any], products: list[dict[str, Any]]) -> str: + """把客户约束与每只产品的**真实参数**摊平成提示词。""" + lines = [ + "【客户约束】", + f"- 风险承受等级:{customer.get('risk_level') or '未知'}", + f"- 投资期限:{customer.get('horizon_months') or '未知'} 个月", + f"- 流动性要求:{customer.get('liquidity') or '未知'}", + "", + "【待写依据的产品】", + ] + for product in products: + lines.extend([ + f"- product_code={product.get('product_code')}", + f" 名称:{product.get('product_name')}({product.get('product_category')})", + f" 风险等级:{product.get('risk_level')}", + f" 排序得分:{product.get('score')}(0~1,越高表示与客户越匹配)", + f" 近 20 个交易日区间收益:{_pct(product.get('return_20d_pct'))}", + f" 近 60 个交易日区间收益:{_pct(product.get('return_60d_pct'))}", + f" 近 60 个交易日最大回撤:{_pct(product.get('max_drawdown_60d_pct'))}", + f" 系统当前给出的依据(可改写得更易读,但事实不得改变):{product.get('rule_reason')}", + ]) + lines.append("") + lines.append("请为上面每一只产品各写一条 reason,product_code 必须原样返回。") + return "\n".join(lines) + + +def _strip_code_fence(raw: str) -> str: + text = raw.strip() + if text.startswith("```"): + text = text.split("\n", 1)[-1] if "\n" in text else text + text = text.rsplit("```", 1)[0] + return text.strip() + + +def parse_items(raw: str) -> dict[str, str]: + """从模型输出里解出 `{product_code: reason}`;结构不符一律返回空字典。""" + text = _strip_code_fence(raw) + start = text.find("{") + end = text.rfind("}") + if start == -1 or end <= start: + return {} + try: + payload = json.loads(text[start : end + 1]) + except (ValueError, TypeError): + return {} + items = payload.get("items") if isinstance(payload, dict) else None + if not isinstance(items, list): + return {} + parsed: dict[str, str] = {} + for item in items: + if not isinstance(item, dict): + continue + code = item.get("product_code") + reason = item.get("reason") + if isinstance(code, str) and isinstance(reason, str) and reason.strip(): + parsed[code.strip()] = reason.strip() + return parsed + + +def is_compliant(text: str) -> bool: + """合规守卫:长度合理 + 不含收益承诺类表述。""" + if not (MIN_REASON_CHARS <= len(text) <= MAX_REASON_CHARS): + return False + return not any(phrase in text for phrase in PROHIBITED_PHRASES) + + +class AdvisorReasonService: + """调用 OpenAI-compatible `/chat/completions` 生成推荐依据;失败返回空字典。""" + + def __init__(self, client: httpx.AsyncClient | None = None) -> None: + self.client = client + + async def enhance( + self, *, customer: dict[str, Any], products: list[dict[str, Any]] + ) -> dict[str, str]: + settings = get_settings() + if not settings.advisor_reason_llm_enabled or not products: + return {} + api_key = settings.deepseek_api_key + if not api_key: + logger.warning("推荐依据 LLM 已启用但缺少 DEEPSEEK_API_KEY,回退到规则文案") + return {} + url = settings.advisor_reason_llm_base_url.rstrip("/") + "/chat/completions" + payload = { + "model": settings.advisor_reason_llm_model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_prompt(customer, products)}, + ], + "temperature": 0, + "max_tokens": 1500, + } + owns_client = self.client is None + client = self.client or httpx.AsyncClient() + try: + response = await client.post( + url, + headers={"Authorization": f"Bearer {api_key}", + "Content-Type": "application/json"}, + json=payload, + timeout=httpx.Timeout(settings.advisor_reason_llm_timeout_seconds), + ) + response.raise_for_status() + body: Any = response.json() + message = (body.get("choices") or [{}])[0].get("message") or {} + # 推理型模型把正文放在 `reasoning_content`,`content` 可能为空 —— 兜底读一次。 + content = message.get("content") or message.get("reasoning_content") or "" + parsed = parse_items(str(content)) + except Exception: # noqa: BLE001 — 模型不可用绝不能影响推荐主流程 + logger.warning("推荐依据 LLM 调用失败,回退到规则文案", exc_info=True) + return {} + finally: + if owns_client: + await client.aclose() + + allowed_codes = {str(product.get("product_code")) for product in products} + accepted: dict[str, str] = {} + for code, reason in parsed.items(): + if code not in allowed_codes: + continue + if not is_compliant(reason): + logger.warning( + "推荐依据 LLM 文案未通过合规守卫,丢弃(product_code=%s)", code + ) + continue + accepted[code] = reason + return accepted diff --git a/app/service/product_recommendation_service.py b/app/service/product_recommendation_service.py index 2b3ce83..f8ecb10 100644 --- a/app/service/product_recommendation_service.py +++ b/app/service/product_recommendation_service.py @@ -1,5 +1,6 @@ """Constraint-first recommendations for the exchange-traded simulation domain.""" +import logging from collections.abc import Callable from datetime import UTC, datetime from typing import Any @@ -8,19 +9,28 @@ from sqlalchemy import select from app.core.config import get_settings from app.core.contracts import RequestContext -from app.core.errors import GenericResourceNotFoundError, InvalidStateError +from app.core.errors import ( + ForbiddenAgentError, + GenericResourceNotFoundError, + InvalidStateError, +) from app.core.product_recommendation_contracts import ProductRecommendationQuery from app.infrastructure.db import SessionFactory from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver from app.model.audit import InteractionAudit +from app.model.fund import FundNavHistory from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent from app.repository.advisor_product_repository import ( AdvisorProductRepository, AuthoritativeProductCandidate, ) +from app.service.advisor_reason_service import AdvisorReasonService from app.service.api_transaction_service import ApiTransactionService from app.service.authorization_service import AuthorizationService from app.service.investment_goal_service import InvestmentGoalService +# 流动性要求的中文文案与投资目标线**同源**(`investment_goal_service._LIQUIDITY_LABELS`): +# 这里直接复用,避免两处各翻一遍、日后口径漂移。 +from app.service.investment_goal_service import _LIQUIDITY_LABELS from app.service.product_governance_monitor_service import SALES_INSTITUTION from app.service.profile_governance_service import ProfileGovernanceService from app.service.relationship_service import RelationshipService @@ -31,6 +41,8 @@ from app.service.suitability_service import SuitabilityService #: 推荐方案用 `content_id`,方案书用 `goal_no`。 BOOK_CONTENT_TYPE = "investment_goal_book" +logger = logging.getLogger(__name__) + class ProductRecommendationService: CONTENT_TYPE = "advisor_recommendation_plan" @@ -95,6 +107,12 @@ class ProductRecommendationService: self._view(item, index, goal, graph_context) for index, item in enumerate(selected, start=1) ] + # 「推荐依据」的 LLM 增强:把**已经算出来的真实参数**交给模型,写成客户读得懂的说明。 + # 未启用 / 缺密钥 / 调用失败 / 未过合规守卫时,保留 `_view` 的确定性文案。 + # 注意:模型只改文案,**不参与选品**(候选池与排序在它之前就已固定)。 + products = await self._enhance_reasons( + products, selected, goal, authority.customer_risk_level + ) plan = { "document_type": "advisor_recommendation_plan", "document_version": "1.0", @@ -228,13 +246,24 @@ class ProductRecommendationService: candidate, score = item product = candidate.product contract = candidate.contract + # 推荐依据要给出**这一只**的真实参数,而不是每只都同一句套话。 + # 全部取自本次计算过程(硬约束、适当性等级、目标的期限/流动性、排序得分), + # 不做任何收益承诺 —— 文案里不出现"稳赚/保本"这类词。 + liquidity_raw = str(goal.get("liquidity_requirement") or "") + liquidity_label = _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw or "--") + reason = ( + "已通过三项硬约束:场内可交易 / 权威适当性证据 / 基金合同快照;" + f"风险等级 {candidate.suitability.risk_level},与客户风险承受力匹配;" + f"按投资期限 {goal.get('investment_horizon_months')} 个月、" + f"流动性要求「{liquidity_label}」筛入;" + f"综合排序得分 {round(score, 4)}。" + ) return { "rank": rank, "product_code": product.product_code, "product_name": product.product_name, "product_category": product.product_category, - "reason": "该产品已通过场内可交易、权威适当性和合同证据校验," - "并与已确认投资目标的期限和流动性要求相匹配。", + "reason": reason, "score": round(score, 4), "recommendation_evidence_card": { "card_version": "1.0", @@ -270,6 +299,80 @@ class ProductRecommendationService: }, } + async def _performance_snapshot( + self, product_ids: list[int] + ) -> dict[int, dict[str, float | None]]: + """每只产品的近 20/60 个交易日区间收益与近 60 日最大回撤(`fin_nav_history`)。 + + 这些数字是「推荐依据」的**事实来源**:既给确定性文案,也给大模型当输入, + 避免模型自己编业绩。表里没数据时对应值为 None(文案与提示词都显示「暂无」)。 + """ + if not product_ids: + return {} + snapshot: dict[int, dict[str, float | None]] = { + pid: {"return_20d_pct": None, "return_60d_pct": None, "max_drawdown_60d_pct": None} + for pid in product_ids + } + async with self.session_factory() as session: + rows = ( + await session.execute( + select(FundNavHistory.product_id, FundNavHistory.nav) + .where(FundNavHistory.product_id.in_(product_ids)) + .order_by(FundNavHistory.product_id, FundNavHistory.nav_date.asc()) + ) + ).all() + grouped: dict[int, list[float]] = {} + for product_id, nav in rows: + grouped.setdefault(int(product_id), []).append(float(nav)) + for product_id, values in grouped.items(): + snapshot[int(product_id)] = { + "return_20d_pct": _interval_return(values, 20), + "return_60d_pct": _interval_return(values, 60), + "max_drawdown_60d_pct": _max_drawdown_pct(values[-61:]), + } + return snapshot + + async def _enhance_reasons( + self, + products: list[dict[str, object]], + selected: list[tuple[AuthoritativeProductCandidate, float]], + goal: dict[str, object], + customer_risk_level: int, + ) -> list[dict[str, object]]: + """给每只产品标 `reason_source`(llm / rule),并在可用时替换成 LLM 文案。""" + snapshot: dict[int, dict[str, float | None]] = {} + try: + snapshot = await self._performance_snapshot( + [int(candidate.product.id) for candidate, _ in selected] + ) + except Exception: # noqa: BLE001 — 读不到历史不该影响出方案 + logger.warning("推荐依据:历史净值读取失败,按「暂无」处理", exc_info=True) + + prompt_products: list[dict[str, object]] = [] + for index, product in enumerate(products): + product_id = int(selected[index][0].product.id) if index < len(selected) else None + facts = snapshot.get(product_id, {}) if product_id is not None else {} + product["performance"] = facts + prompt_products.append({**product, **facts}) + + liquidity_raw = str(goal.get("liquidity_requirement") or "") + generated = await AdvisorReasonService().enhance( + customer={ + "risk_level": customer_risk_level, + "horizon_months": goal.get("investment_horizon_months"), + "liquidity": _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw), + }, + products=prompt_products, + ) + for product in products: + code = str(product.get("product_code")) + if code in generated: + product["reason"] = generated[code] + product["reason_source"] = "llm" + else: + product["reason_source"] = "rule" + return products + async def _graph_context(self, customer_id: int) -> dict[str, object]: if self.relationship_service is None: return {"degraded": True, "reason": "graph_not_configured"} @@ -349,6 +452,63 @@ class ProductRecommendationService: operation, ) + async def delete( + self, content_id: int, context: RequestContext, key: str | None + ) -> dict[str, object]: + """删除推荐方案(投顾工作台「历史方案记录」里的「删除」)。 + + ## 为什么只允许删推荐方案 + + 投资方案书(`BOOK_CONTENT_TYPE`)被 `advisor_investment_goal.goal_book_content_id` + 以 `NO ACTION` 外键引用,且该列 **`NOT NULL`** —— 硬删方案书必然撞外键(1451)。 + 方案书有自己的 `goal_no` 生命周期,不走这里。 + + ## 归属 + + 复用 `_visible_customer_ids`(本人 + 名下归属客户),与 `published` / `history` + 同一把尺子:不是自己能看的客户,方案也删不得。 + """ + await AuthorizationService.require(context, "product-recommendation:delete") + + async def operation(session: Any) -> dict[str, object]: + content = await session.get(ClientFacingContent, content_id, with_for_update=True) + if content is None or content.content_type != self.CONTENT_TYPE: + raise GenericResourceNotFoundError("推荐方案不存在") + if content.customer_id not in self._visible_customer_ids(context): + raise ForbiddenAgentError("无权操作该客户的方案") + now = datetime.now(UTC).replace(tzinfo=None) + await session.delete(content) + # 删除也留痕:`interaction_audit` 与 `client_facing_content` 无外键, + # 方案没了审计仍在(合规要求「删了什么、谁删的」可追)。 + session.add( + InteractionAudit( + actor_type="user", + actor_id=int(context.user_id), + target_customer_id=content.customer_id, + portal=context.portal, + action_type="advisor.recommendation_deleted", + detail={ + "content_id": str(content_id), + "content_type": self.CONTENT_TYPE, + "trace_id": context.trace_id, + }, + created_at=now, + ) + ) + await session.flush() + return { + "data": {"content_id": str(content_id), "status": "deleted"}, + "meta": {"trace_id": context.trace_id}, + } + + return await ApiTransactionService().execute( + context, + f"advisor:recommendations:{content_id}:delete", + key, + {"delete": True}, + operation, + ) + @staticmethod def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]: """可查看的客户 id:本人 + 名下归属客户。 @@ -395,6 +555,83 @@ class ProductRecommendationService: "meta": {"trace_id": context.trace_id}, } + async def my_published(self, context: RequestContext) -> dict[str, object]: + """客户视角:**自己**已被审核发布的投顾交付物(`/api/v1/users/me/advisor-contents`)。 + + 与 `published()`(投顾侧)的区别在**范围**:这里只看 `customer_id == 自己`, + 不掺 `sys_customer_assignment` —— 那是投顾的归属概念,客户没有归属客户。 + 只返回 `published_at` 非空的:投顾「发送给客户」之前,客户看不到。 + """ + await AuthorizationService.require(context, "product-recommendation:read:self") + customer_id = int(context.user_id) + async with self.session_factory() as session: + rows = list( + await session.scalars( + select(ClientFacingContent) + .where( + ClientFacingContent.customer_id == customer_id, + ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES), + ClientFacingContent.review_status.in_(self.PUBLISHED_STATES), + ClientFacingContent.published_at.is_not(None), + ) + .order_by(ClientFacingContent.published_at.desc()) + .limit(50) + ) + ) + return { + "data": [ + { + "content_id": str(row.id), + "customer_id": str(row.customer_id), + "content_type": row.content_type, + "plan": row.draft_content, + "published_at": row.published_at.isoformat() if row.published_at else None, + } + for row in rows + ], + "meta": {"trace_id": context.trace_id}, + } + + async def history(self, context: RequestContext) -> dict[str, object]: + """投顾本人的方案留档:本人 + 名下归属客户的**全部状态**方案与方案书。 + + 与 `published()` 的唯一差别是**状态口径**:`published` 只给已发布(供客户看), + `history` 给全部(含 `pending_review`/`pending` 待审、`rejected` 已驳回), + 供投顾在工作台回看"以前生成过什么"。归属过滤复用 `_visible_customer_ids`, + 与 `published` 保持同一把尺子。 + """ + await AuthorizationService.require(context, "product-recommendation:read:self") + customer_ids = self._visible_customer_ids(context) + if not customer_ids: + return {"data": [], "meta": {"trace_id": context.trace_id}} + async with self.session_factory() as session: + rows = list( + await session.scalars( + select(ClientFacingContent) + .where( + ClientFacingContent.customer_id.in_(customer_ids), + ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES), + ) + .order_by(ClientFacingContent.created_at.desc()) + .limit(50) + ) + ) + return { + "data": [ + { + "content_id": str(row.id), + "customer_id": str(row.customer_id), + "content_type": row.content_type, + "review_status": row.review_status, + "plan": row.draft_content, + "created_at": row.created_at.isoformat() if row.created_at else None, + "reviewed_at": row.reviewed_at.isoformat() if row.reviewed_at else None, + "published_at": row.published_at.isoformat() if row.published_at else None, + } + for row in rows + ], + "meta": {"trace_id": context.trace_id}, + } async def pending_reviews(self, context: RequestContext) -> dict[str, object]: """管理面复核队列:待审核的推荐方案与投资方案书。 @@ -468,3 +705,26 @@ async def product_recommendation_tool( return await ProductRecommendationService(enforce_profile_governance=True).generate( arguments, context, None ) + + +def _interval_return(values: list[float], trading_days: int) -> float | None: + """近 N 个交易日的区间收益(%);数据不足或基准为 0 时返回 None。""" + if len(values) < 2: + return None + base = values[max(0, len(values) - 1 - trading_days)] + if not base: + return None + return (values[-1] - base) / base * 100 + + +def _max_drawdown_pct(values: list[float]) -> float | None: + """区间最大回撤(%,负值);数据不足返回 None。""" + if len(values) < 2: + return None + peak = values[0] + worst = 0.0 + for value in values: + peak = max(peak, value) + if peak: + worst = min(worst, (value - peak) / peak * 100) + return worst diff --git a/app/static/portal/common/advisor-plan-view.js b/app/static/portal/common/advisor-plan-view.js new file mode 100644 index 0000000..bd44aca --- /dev/null +++ b/app/static/portal/common/advisor-plan-view.js @@ -0,0 +1,397 @@ +// 投顾推荐方案的「可视化」渲染 —— 投顾工作台结果区与客户「我的投顾方案」页**共用**。 +// +// 为什么单独抽一个模块:这两处展示的是同一份 `advisor_recommendation_plan`, +// 各写一套必然漂移(改了一边忘了另一边)。本模块保持**纯渲染 + 一次注解式取数**: +// · `renderPlanProducts(products)` —— 同步产出骨架(只用方案自带字段); +// · `hydratePlanView(root, apiClient, products)` —— 取行情/净值后填图表与指标。 +// +// ⚠️ 不 import `api-client.js`:投顾页与客户页引入的 `apiClient` 版本不同, +// 在共享模块里再 import 一份会多出一个实例(`ENDPOINTS` 注册表也就有了两份)。 +// 所以 apiClient 由调用方当参数传进来(依赖注入)。 +// +// 数据来源(都已在 `api-client.js` 注册): +// · P001 `GET /api/v1/products` → 最新净值/当日涨跌/基金经理/费率 +// · P002 `GET /api/v1/products/{code}/nav-history` → 近 N 个交易日净值序列(画折线) +// +// 颜色口径:**涨红跌绿**(A 股习惯)。样式里用 `--plan-up` / `--plan-down` 两个变量 +// 集中定义(`common/advisor-plan.css`),要与产品列表页的“绿涨红跌”一致时对调即可。 + +import { escapeHtml } from '/static/portal/common/formatters.js'; + +//: 从净值序列里取最近多少个交易日画走势。 +const SPARK_DAYS = 180; + +//: 卡片上展示的区间涨跌(交易日数)。 +const INTERVALS = Object.freeze([ + ['近 20 日', 20], + ['近 60 日', 60], +]); + +//: 环形图配色(最多 6 色循环)。用固定色值而不是主题变量:环形切片需要彼此可区分。 +const SLICE_COLORS = Object.freeze([ + '#2d6f67', '#c99a2e', '#4a7fb5', '#a6573f', '#7a6bab', '#5f8f52', +]); + +/** + * ⚠️ 这里必须显式挡 `null` / `undefined` / `''`。 + * 直接 `Number(null)` 会得到 **0** —— 于是"没有数据"被渲染成 `0.00%`(曾真出过这个 bug: + * 近 20/60 日与管理费全显示 0.00%,看起来像"真的是 0")。`Number('')` 同理。 + */ +function num(value) { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** 涨跌幅文本;拿不到就 `--`,绝不渲染成 `+0.00%`(会把"不知道"说成"平盘")。 */ +function pctText(value) { + const parsed = num(value); + if (parsed === null) return '--'; + return `${parsed > 0 ? '+' : ''}${parsed.toFixed(2)}%`; +} + +/** 涨跌配色类;`null` 用中性色。 */ +function pctClass(value) { + const parsed = num(value); + if (parsed === null) return 'advisor-plan__value--flat'; + if (parsed > 0) return 'advisor-plan__value--up'; + if (parsed < 0) return 'advisor-plan__value--down'; + return 'advisor-plan__value--flat'; +} + +function navValues(points) { + return points.map((point) => num(point.nav)).filter((value) => value !== null); +} + +function intervalChange(values, tradingDays) { + if (values.length < 2) return null; + const baseIndex = Math.max(0, values.length - 1 - tradingDays); + const base = values[baseIndex]; + if (!base) return null; + return ((values[values.length - 1] - base) / base) * 100; +} + +function metric(label, value, className = '') { + return `
${escapeHtml(label)}
` + + `
${escapeHtml(value)}
`; +} + +// ---- 折线图(带坐标轴/网格;内联 SVG,不引图表库) ---- + +function shortDate(iso) { + return String(iso || '').slice(5); +} + +/** + * 通用折线图。`series` 是 `[{date, value}]`;`formatter` 决定 y 轴刻度文本。 + * 缺数据(<2 点)返回空串,由调用方给"暂无数据"文案。 + */ +function lineChartSvg(series, { formatter, ariaLabel }) { + if (series.length < 2) return ''; + const width = 640; + const height = 220; + const left = 58; + const right = 16; + const top = 14; + const bottom = 30; + const values = series.map((point) => point.value); + let min = Math.min(...values); + let max = Math.max(...values); + if (min === max) { + min -= 0.01; + max += 0.01; + } + const padding = (max - min) * 0.08; + min -= padding; + max += padding; + const xAt = (index) => left + (index * (width - left - right)) / (series.length - 1); + const yAt = (value) => top + (1 - (value - min) / (max - min)) * (height - top - bottom); + + const rows = 4; + const grid = Array.from({ length: rows + 1 }, (_, index) => { + const value = min + ((max - min) * index) / rows; + const y = yAt(value).toFixed(1); + return `` + + `` + + `${escapeHtml(formatter(value))}`; + }).join(''); + + const labelIndexes = [0, Math.floor((series.length - 1) / 2), series.length - 1]; + const xLabels = labelIndexes.map((index, position) => { + const anchor = position === 0 ? 'start' : (position === 2 ? 'end' : 'middle'); + return `` + + `${escapeHtml(shortDate(series[index].date))}`; + }).join(''); + + const coords = series + .map((point, index) => `${xAt(index).toFixed(1)},${yAt(point.value).toFixed(1)}`) + .join(' '); + const rising = values[values.length - 1] >= values[0]; + return `` + + `${grid}` + + `${xLabels}` + + ``; +} + +/** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */ +function normalizedPct(points) { + const cleaned = points + .map((point) => ({ date: String(point.nav_date), value: num(point.nav) })) + .filter((point) => point.value !== null); + if (cleaned.length < 2) return []; + const base = cleaned[0].value; + if (!base) return []; + return cleaned.map((point) => ({ date: point.date, value: (point.value / base - 1) * 100 })); +} + +/** 等权组合:各基金按共同交易日对齐后取均值。 */ +function equalWeightSeries(navsByCode) { + const series = Object.values(navsByCode) + .map((points) => normalizedPct(points)) + .filter((pts) => pts.length >= 2); + if (series.length < 2) return []; + let common = new Set(series[0].map((point) => point.date)); + series.slice(1).forEach((pts) => { + const dates = new Set(pts.map((point) => point.date)); + common = new Set([...common].filter((date) => dates.has(date))); + }); + const dates = [...common].sort(); + if (dates.length < 2) return []; + return dates.map((date) => ({ + date, + value: series.reduce((sum, pts) => { + const hit = pts.find((point) => point.date === date); + return sum + (hit ? hit.value : 0); + }, 0) / series.length, + })); +} + +function maxDrawdownPct(series) { + let peak = -Infinity; + let worst = 0; + series.forEach((point) => { + peak = Math.max(peak, point.value); + worst = Math.min(worst, point.value - peak); + }); + return worst; +} + +// ---- 环形图(资产配置比例) ---- + +function donutSvg(entries) { + const total = entries.reduce((sum, entry) => sum + entry.weight, 0) || 1; + const radius = 54; + const circumference = 2 * Math.PI * radius; + let offset = 0; + const slices = entries.map((entry, index) => { + const length = (entry.weight / total) * circumference; + const slice = ``; + offset += length; + return slice; + }).join(''); + return `` + + slices + + '等权组合' + + `${entries.length} 只` + + ''; +} + +// ---- 方案骨架 ---- + +function riskOf(product) { + const evidence = product.recommendation_evidence_card || {}; + return ((evidence.suitability || {}).risk_level) || product.risk_level || '--'; +} + +function cardHtml(product) { + const code = String(product.product_code || ''); + return '
' + + '
' + + `

${escapeHtml(product.product_name || '--')}

` + + `

${escapeHtml(code)}` + + ` · ${escapeHtml(product.product_category || '--')}

` + + '
' + + `${escapeHtml(riskOf(product))}` + + `评分 ${escapeHtml(String(product.score ?? '--'))}` + + '
' + + `
` + + '

走势加载中…

' + + `
` + + metric('风险等级', riskOf(product)) + metric('AI 评分', String(product.score ?? '--')) + + '
' + + `
    ` + // 标注文案来源:模型写的必须让人一眼看出来(与风控页"大模型生成"的标注惯例一致)。 + + '

    推荐依据' + + (product.reason_source === 'llm' + ? 'AI 生成' + : '规则生成') + + `${escapeHtml(product.reason || '--')}

    ` + + '
    '; +} + +/** 同步骨架:只用方案自带字段,行情/净值随后由 `hydratePlanView` 补。 */ +export function renderPlanProducts(products) { + if (!products.length) return ''; + const codes = products.map((product) => String(product.product_code || '')).join(','); + const entries = products.map((product) => ({ + label: product.product_name || product.product_code || '--', + code: String(product.product_code || ''), + weight: 1, + })); + const legend = entries.map((entry, index) => { + const share = Math.round((entry.weight / entries.length) * 100); + return `
  • ` + + `${escapeHtml(entry.label)}` + + `${escapeHtml(entry.code)}` + + `${share}%
  • `; + }).join(''); + return `
    ` + + '
    ' + + '

    组合业绩(等权合成)

    ' + + '
    ' + + '
    ' + + '

    组合走势加载中…

    ' + + '
    ' + + '
    ' + + '

    资产配置比例(等权)

    ' + + '
    ' + + `
    ${donutSvg(entries)}
    ` + + `
      ${legend}
    ` + + '
    ' + + `
    ${products.map(cardHtml).join('')}
    ` + + '
    '; +} + +// ---- 注解式补水 ---- + +async function fetchNav(apiClient, code) { + try { + const response = await apiClient.get('P002', { + pathParams: { productCode: code }, + query: { days: SPARK_DAYS }, + }); + const points = response?.data?.points; + return Array.isArray(points) ? points : []; + } catch { + // 单只基金取不到净值不该拖垮整块:留空,卡片显示“暂无净值数据”。 + return []; + } +} + +function fillCardProducts(products) { + return new Map(products.map((product) => [String(product.product_code || ''), product])); +} + +function enrichCard(block, code, product, facts, points) { + const chartNode = block.querySelector(`[data-plan-chart="${code}"]`); + const metricsNode = block.querySelector(`[data-plan-metrics="${code}"]`); + const highlightsNode = block.querySelector(`[data-plan-highlights="${code}"]`); + const values = navValues(points); + + if (chartNode) { + const series = points + .map((point) => ({ date: String(point.nav_date), value: num(point.nav) })) + .filter((point) => point.value !== null); + chartNode.innerHTML = series.length >= 2 + ? lineChartSvg(series, { + formatter: (value) => value.toFixed(3), + ariaLabel: `${product?.product_name || code} 近 ${series.length} 个交易日净值走势`, + }) + : '

    暂无净值数据' + + '(可运行 tools/sync_nav_history.py 同步)

    '; + } + + if (metricsNode) { + const latestNav = values.length ? values[values.length - 1] : num(facts?.current_nav); + const rows = [ + metric('最新净值', latestNav === null ? '--' : latestNav.toFixed(4)), + metric('当日涨跌', pctText(facts?.change_pct), pctClass(facts?.change_pct)), + ]; + INTERVALS.forEach(([label, days]) => { + const change = intervalChange(values, days); + rows.push(metric(label, pctText(change), pctClass(change))); + }); + rows.push(metric('基金经理', facts?.fund_manager || '--')); + const fee = num(facts?.management_fee_rate); + // `management_fee_rate` 在库里可能是 NULL 或 0(未维护)——显示成「0.00%/年」 + // 会被读成"这只基金不要管理费",与事实相反,所以按"没数据"处理。 + rows.push(metric('管理费', fee === null || fee <= 0 ? '--' : `${(fee * 100).toFixed(2)}%/年`)); + metricsNode.innerHTML = rows.join(''); + } + + if (highlightsNode) { + const items = []; + const c60 = intervalChange(values, 60); + const c20 = intervalChange(values, 20); + if (c60 !== null) items.push(`近 60 个交易日区间收益 ${pctText(c60)}`); + if (c20 !== null) items.push(`近 20 日区间收益 ${pctText(c20)}`); + if (values.length >= 2) { + items.push(`区间最大回撤 ${pctText(Math.min(0, maxDrawdownPct( + values.map((value) => ({ date: '', value })), + )))}`); + } + items.push(`风险等级 ${riskOf(product)},已按你的风险测评与投资期限做匹配校验`); + if (facts?.fund_manager) items.push(`基金经理 ${facts.fund_manager}`); + highlightsNode.innerHTML = items.map((text) => `
  • ${escapeHtml(text)}
  • `).join(''); + } +} + +function fillCombo(block, navsByCode) { + const chartNode = block.querySelector('[data-plan-combo]'); + const statsNode = block.querySelector('[data-plan-combo-stats]'); + const series = equalWeightSeries(navsByCode); + if (!chartNode) return; + if (series.length < 2) { + chartNode.innerHTML = '

    成分基金净值不足,' + + '暂无法合成组合走势。

    '; + return; + } + const total = series[series.length - 1].value; + const drawdown = maxDrawdownPct(series); + chartNode.innerHTML = lineChartSvg(series, { + formatter: (value) => `${value.toFixed(1)}%`, + ariaLabel: `等权组合近 ${series.length} 个交易日累计收益走势`, + }); + if (statsNode) { + statsNode.innerHTML = metric('区间收益', pctText(total), pctClass(total)) + + metric('最大回撤', pctText(drawdown), pctClass(drawdown)) + + metric('成分基金', `${Object.keys(navsByCode).length} 只`) + + metric('区间', `${series[0].date} ~ ${series[series.length - 1].date}`); + } +} + +/** + * 注解式补水:取 P001(一次,含全部产品)+ P002(每只一次), + * 填组合走势、卡片折线图、指标与看点。任何一步失败都只降级为「暂无数据」,不抛错。 + */ +export async function hydratePlanView(root, apiClient, products = []) { + const block = root?.querySelector?.('[data-plan-view]'); + if (!block || typeof apiClient?.get !== 'function') return; + const codes = String(block.dataset.codes || '').split(',').filter(Boolean); + if (!codes.length) return; + const byCode = fillCardProducts(products); + + const productsByCode = new Map(); + try { + const response = await apiClient.get('P001'); + (response?.data?.products || []).forEach((item) => { + productsByCode.set(String(item.product_code), item); + }); + } catch { + // 产品清单取不到:仍能画净值走势,只是基金经理/费率显示 `--`。 + } + + const navs = await Promise.all(codes.map((code) => fetchNav(apiClient, code))); + const navsByCode = {}; + codes.forEach((code, index) => { navsByCode[code] = navs[index] || []; }); + + fillCombo(block, navsByCode); + codes.forEach((code, index) => { + enrichCard(block, code, byCode.get(code), productsByCode.get(code), navs[index] || []); + }); +} diff --git a/app/static/portal/common/advisor-plan.css b/app/static/portal/common/advisor-plan.css new file mode 100644 index 0000000..cc3304e --- /dev/null +++ b/app/static/portal/common/advisor-plan.css @@ -0,0 +1,119 @@ +/* 推荐方案「可视化」样式 —— 投顾工作台结果区与客户「我的投顾方案」页共用。 + * + * 颜色口径:**涨红跌绿**(A 股习惯)。 + * ⚠️ 仓库里 `formatters.js` 的 `value--positive` 是**绿涨红跌**(产品列表页/收益明细沿用)。 + * 若要与那几页统一,把下面两个变量对调即可(只需改这一处)。 + */ +.advisor-plan { + --plan-up: var(--danger); /* 涨 → 红 */ + --plan-down: var(--success); /* 跌 → 绿 */ + display: grid; + gap: var(--space-4); + margin-bottom: var(--space-3); +} + +/* ---- 区块(组合业绩 / 资产配置比例) ---- */ +.advisor-plan__block { + padding: var(--space-3) var(--space-4); + display: grid; + gap: var(--space-3); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-md); +} +.advisor-plan__block h4 { margin: 0; font-size: var(--fs-body); } + +/* ---- 组合统计(区间收益 / 最大回撤 / 成分 / 区间) ---- */ +.advisor-plan__stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); + gap: var(--space-2); +} + +/* ---- 配置环形图 + 图例 ---- */ +.advisor-plan__config { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; } +.advisor-plan__donut-wrap { flex: 0 0 auto; width: 140px; } +.advisor-plan__donut { width: 140px; height: 140px; display: block; } +.advisor-plan__slice { fill: none; stroke-width: 24; } +.advisor-plan__donut-title { fill: var(--ink); font-size: 13px; font-weight: 700; } +.advisor-plan__donut-sub { fill: var(--muted); font-size: 11px; } +.advisor-plan__legend { margin: 0; padding: 0; flex: 1 1 240px; display: grid; gap: var(--space-1); list-style: none; } +.advisor-plan__legend li { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto auto; align-items: center; gap: var(--space-2); font-size: var(--fs-small); } +.advisor-plan__legend i { width: 10px; height: 10px; border-radius: 2px; } +.advisor-plan__legend-name { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.advisor-plan__legend-code { color: var(--muted); } +.advisor-plan__legend b { color: var(--ink-soft); } + +/* ---- 折线图 ---- */ +.advisor-plan__chart { display: grid; gap: var(--space-1); } +.advisor-plan__chart-svg { width: 100%; height: auto; display: block; } +.advisor-plan__chart-grid line { stroke: var(--line); stroke-width: 1; } +.advisor-plan__axis text, .advisor-plan__chart-grid text { fill: var(--muted); font-size: 11px; } +.advisor-plan__line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } +.advisor-plan__line--up { stroke: var(--plan-up); } +.advisor-plan__line--down { stroke: var(--plan-down); } +.advisor-plan__chart-pending, .advisor-plan__chart-empty { margin: 0; color: var(--muted); font-size: var(--fs-small); } + +/* ---- 产品卡片 ---- */ +.advisor-plan__grid { display: grid; gap: var(--space-3); } +.advisor-plan__card { + padding: var(--space-4); + display: grid; + gap: var(--space-3); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-md); +} +.advisor-plan__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; } +.advisor-plan__name { margin: 0; font-size: var(--fs-title); font-weight: 700; } +.advisor-plan__sub { margin: var(--space-1) 0 0; color: var(--muted); font-size: var(--fs-small); } +.advisor-plan__badges { display: inline-flex; align-items: center; gap: var(--space-2); } +.advisor-plan__risk { + padding: 2px var(--space-2); + color: var(--danger); + background: var(--danger-soft); + border-radius: var(--radius-sm); + font-size: var(--fs-small); + font-weight: 700; +} +.advisor-plan__score { + padding: 2px var(--space-2); + color: var(--brand-dark); + background: var(--brand-soft); + border-radius: var(--radius-sm); + font-size: var(--fs-small); + font-weight: 700; +} + +/* ---- 指标 ---- */ +.advisor-plan__metrics { + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + gap: var(--space-2); +} +.advisor-plan__metric { padding: var(--space-2); background: var(--surface-soft); border-radius: var(--radius-sm); } +.advisor-plan__metric dt { color: var(--muted); font-size: var(--fs-small); } +.advisor-plan__metric dd { margin: var(--space-1) 0 0; font-weight: 700; } +.advisor-plan__value--up { color: var(--plan-up); } +.advisor-plan__value--down { color: var(--plan-down); } +.advisor-plan__value--flat { color: var(--ink-soft); } + +/* ---- 看点 ---- */ +.advisor-plan__highlights { margin: 0; padding-left: 1.1em; display: grid; gap: var(--space-1); color: var(--ink-soft); font-size: var(--fs-small); line-height: 1.6; } +.advisor-plan__highlights:empty { display: none; } + +.advisor-plan__reason { margin: 0; color: var(--ink-soft); font-size: var(--fs-small); line-height: 1.7; } +.advisor-plan__reason strong { display: block; margin-bottom: var(--space-1); color: var(--ink); } +/* 文案来源标注:AI 生成的必须可辨识。 */ +.advisor-plan__tag { + margin-left: var(--space-2); + padding: 1px var(--space-2); + color: var(--brand-dark); + background: var(--brand-soft); + border-radius: var(--radius-sm); + font-size: 11px; + font-weight: 600; + vertical-align: middle; +} +.advisor-plan__tag--rule { color: var(--muted); background: var(--surface-soft); } diff --git a/app/static/portal/common/customer-list-page.js b/app/static/portal/common/customer-list-page.js index 9b7c4a5..8cf2b84 100644 --- a/app/static/portal/common/customer-list-page.js +++ b/app/static/portal/common/customer-list-page.js @@ -1,4 +1,4 @@ -import { apiClient } from '/static/portal/common/api-client.js?v=20260913'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260916-plan1'; import { requireCustomer } from '/static/portal/common/auth.js?v=20260913'; import { mountShell } from '/static/portal/common/layout/app-shell.js'; import { renderError, renderLoading } from '/static/portal/common/state-view.js'; diff --git a/app/static/portal/common/layout/app-shell.js b/app/static/portal/common/layout/app-shell.js index 3d3fb98..6d38203 100644 --- a/app/static/portal/common/layout/app-shell.js +++ b/app/static/portal/common/layout/app-shell.js @@ -15,6 +15,7 @@ const PUBLIC_LINKS = [ const CUSTOMER_LINKS = [ ['dashboard', '资产总览', '/portal/customer/dashboard/'], + ['advisor-plans', '我的投顾方案', '/portal/customer/advisor-plans/'], ['holdings', '我的持仓', '/portal/customer/holdings/'], ['profit-loss', '收益明细', '/portal/customer/profit-loss/'], ['orders', '交易记录', '/portal/customer/orders/'], diff --git a/app/static/portal/customer/advisor-plans/advisor-plans.css b/app/static/portal/customer/advisor-plans/advisor-plans.css new file mode 100644 index 0000000..de0a82c --- /dev/null +++ b/app/static/portal/customer/advisor-plans/advisor-plans.css @@ -0,0 +1,46 @@ +/* 「我的投顾方案」页面样式。 + * + * 只写本页自己的东西(交付物卡片列表);表格沿用 common/base.css 的 `.data-table`。 + * 取色/取间距一律用 tokens.css 的变量,不写死色值与 px。 + */ +.advisor-plan-card { + padding: var(--space-4); + display: grid; + gap: var(--space-2); + border-bottom: 1px solid var(--line); +} +.advisor-plan-card:last-child { border-bottom: 0; } +.advisor-plan-card__title { margin: 0; font-size: var(--fs-title); font-weight: 700; } +.advisor-plan-card__no { color: var(--muted); font-size: var(--fs-small); font-weight: 500; } +.advisor-plan-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; } +.advisor-plan-card .button--quiet { justify-self: start; } +.advisor-plan-card__detail { margin-top: var(--space-3); display: grid; gap: var(--space-2); } +.advisor-plan-card__note { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.65; } +.advisor-plan-card__json { + margin: 0; + padding: var(--space-3); + max-height: 320px; + overflow: auto; + color: var(--ink-soft); + background: var(--surface-soft); + border-radius: var(--radius-sm); + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; +} +.advisor-plan-card .data-table-wrap { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); } + +/* ---- 申报表单(`.form-field` / `.form-alert` 来自 base.css,这里只排布) ---- */ +.advisor-request-form { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); + align-items: end; +} +.advisor-request-form__wide { grid-column: 1 / -1; } +.advisor-request-form .button { justify-self: start; } +.advisor-request-form .form-alert { grid-column: 1 / -1; } +@media (max-width: 900px) { + .advisor-request-form { grid-template-columns: 1fr; } + .advisor-request-form__wide { grid-column: auto; } +} diff --git a/app/static/portal/customer/advisor-plans/advisor-plans.js b/app/static/portal/customer/advisor-plans/advisor-plans.js new file mode 100644 index 0000000..c4d07f1 --- /dev/null +++ b/app/static/portal/customer/advisor-plans/advisor-plans.js @@ -0,0 +1,212 @@ +// 「我的投顾方案」——客户侧的接收页。 +// +// 现状背景:投顾工作台的「发送给客户」原先**没有落点**(只把 `published_at` 置上), +// 客户端门户没有任何页面/接口能读到已发布交付物。本页 + `MY_ADVISOR_CONTENTS` +// 端点是那条链路的接收端:投顾发布 → 客户登录这里就能看到。 +// +// 数据口径(后端 `ProductRecommendationService.my_published`): +// **本人**(`customer_id == 登录客户`)、`review_status ∈ {approved, published}` 且 +// `published_at` 非空的两类内容 —— 推荐方案与投资方案书。 +// +// 推荐明细的**可视化**(走势图/指标/组合构成)与投顾工作台结果区共用 +// `common/advisor-plan-view.js`:同一份方案在两处渲染成同一个样子。 +// `apiClient` 用页面统一的那份(`?v=20260916-plan1`,与 `customer-list-page.js` 一致), +// 由调用方传给 `hydratePlanView`(共享模块内不 import,避免多出一个实例)。 + +import { setupCustomerListPage } from '/static/portal/common/customer-list-page.js'; +import { requireCustomer } from '/static/portal/common/auth.js?v=20260913'; +import { apiClient } from '/static/portal/common/api-client.js?v=20260916-plan1'; +import { showToast } from '/static/portal/common/notifications.js'; +import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js'; +import { renderEmpty } from '/static/portal/common/state-view.js'; +import { + hydratePlanView, + renderPlanProducts, +} from '/static/portal/common/advisor-plan-view.js?v=20260916-plan3'; + +//: 内容类型文案。与投顾侧 `advisor-config.js` 的 `CONTENT_TYPE_LABELS` 同源口径。 +const TYPE_LABELS = Object.freeze({ + advisor_recommendation_plan: '产品推荐方案', + investment_goal_book: '投资目标方案书', +}); + +function typeLabel(type) { + return TYPE_LABELS[type] || '投顾方案'; +} + +function detailHtml(item) { + const plan = item.plan && typeof item.plan === 'object' ? item.plan : {}; + const products = Array.isArray(plan.products) ? plan.products : []; + let html = ''; + + if (products.length) { + html += renderPlanProducts(products); + const summary = plan.selection_summary; + if (summary) { + html += '

    ' + + `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:` + + `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、` + + `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。

    `; + } + } else { + // 方案书等结构不固定的内容:原样呈现,不臆造字段。 + html += `
    ${escapeHtml(JSON.stringify(plan, null, 2))}
    `; + } + + (Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => { + html += `

    ${escapeHtml(String(text))}

    `; + }); + return html; +} + +function cardHtml(item) { + const plan = item.plan && typeof item.plan === 'object' ? item.plan : {}; + const products = Array.isArray(plan.products) ? plan.products : []; + const names = products.map((product) => product.product_name || product.product_code || '--').slice(0, 5); + return '
    ' + + `

    ${escapeHtml(typeLabel(item.content_type))}` + + ` 编号 ${escapeHtml(item.content_id)}

    ` + + `

    发布于 ${escapeHtml(formatDateTime(item.published_at))}

    ` + + (names.length + ? `

    推荐产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}

    ` + : '') + + `` + + '' + + '
    '; +} + +setupCustomerListPage({ + active: 'advisor-plans', + endpointId: 'MY_ADVISOR_CONTENTS', + // 后端不做游标分页(一次最多 50 条),恒返回空游标 ⇒ 「下一页」按钮自动禁用。 + getNextCursor: () => null, + render(container, items) { + if (!items.length) { + renderEmpty(container, '暂无投顾方案', '投顾审核通过并发送给你的方案会显示在这里。'); + return; + } + const byId = new Map(items.map((item) => [String(item.content_id), item])); + container.innerHTML = items.map(cardHtml).join(''); + container.querySelectorAll('[data-plan-detail]').forEach((button) => { + button.addEventListener('click', () => { + const body = button.parentElement.querySelector('[data-plan-body]'); + const item = byId.get(String(button.dataset.planDetail)); + if (!body || !item) return; + if (!body.hidden) { + body.hidden = true; + button.textContent = '查看详情'; + return; + } + body.innerHTML = detailHtml(item); + body.hidden = false; + button.textContent = '收起'; + // 走势图/组合曲线/指标是异步补的;失败只降级为"暂无净值数据"。 + const plan = item.plan && typeof item.plan === 'object' ? item.plan : {}; + void hydratePlanView(body, apiClient, Array.isArray(plan.products) ? plan.products : []); + }); + }); + }, +}); + +// ---- 主动申报投顾方案 ------------------------------------------------------ +// +// 客户在这里提交申报(金额/期限/风险偏好/备注)→ 后端落 `advisor_service_request` +// (status=pending)→ 投顾工作台「客户申报」受理后**自动出方案草稿** → +// 投顾审核通过并发送,方案才会出现在上面的「已收到的方案」里。 +// +// 服务端会再校验一次风险测评有效性(FM-03),前端不做前置拦截: +// 拦截只是体验层,真正的边界在服务端。 + +//: 申报单状态 → 文案 / 标签色。`delivered` 由后端**推导**(关联方案已发布)。 +const REQUEST_STATUS_LABELS = Object.freeze({ + pending: '待受理', + accepted: '已受理 · 方案待审核', + delivered: '已发送方案', + rejected: '已驳回', +}); +const REQUEST_STATUS_TONES = Object.freeze({ + pending: 'medium', + accepted: 'active', + delivered: 'low', + rejected: 'failed', +}); + +function requestCardHtml(item) { + const label = REQUEST_STATUS_LABELS[item.status] || item.status; + const tone = REQUEST_STATUS_TONES[item.status] || 'neutral'; + return '
    ' + + `

    申报单 ${escapeHtml(item.request_no)}` + + ` ${escapeHtml(label)}

    ` + + `

    ${escapeHtml(String(item.amount_wan))} 万元` + + ` · 期限 ${escapeHtml(item.horizon || '--')}` + + ` · 风险偏好 ${escapeHtml(item.risk_preference || '--')}` + + ` · 提交于 ${escapeHtml(formatDateTime(item.created_at))}

    ` + + (item.note ? `

    备注:${escapeHtml(item.note)}

    ` : '') + + (item.advisor_note + ? `

    投顾意见:${escapeHtml(item.advisor_note)}

    ` + : '') + + '
    '; +} + +function initRequestSection() { + const form = document.querySelector('[data-request-form]'); + const listNode = document.querySelector('[data-request-list]'); + const alertNode = document.querySelector('[data-request-alert]'); + const submit = document.querySelector('[data-request-submit]'); + if (!form || !listNode) return; + + function showAlert(message, tone = 'error') { + if (!alertNode) return; + alertNode.textContent = message || ''; + alertNode.classList.toggle('form-alert--visible', Boolean(message)); + alertNode.classList.toggle('form-alert--info', tone === 'info'); + } + + async function loadRequests() { + try { + const response = await apiClient.get('MY_ADVISOR_REQUESTS'); + const rows = Array.isArray(response.data) ? response.data : []; + listNode.innerHTML = rows.length + ? rows.map(requestCardHtml).join('') + : '

    还没有提交过申报。填好上面的表单点「提交申报」即可。

    '; + } catch (error) { + apiClient.reportError(error); + listNode.innerHTML = '

    申报记录加载失败,请稍后刷新。

    '; + } + } + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + showAlert(''); + const amount = Number(form.querySelector('[data-request-amount]').value); + if (!Number.isFinite(amount) || amount <= 0) { + showAlert('请填写大于 0 的投资金额'); + return; + } + const note = form.querySelector('[data-request-note]').value.trim(); + submit.disabled = true; + submit.textContent = '提交中…'; + try { + await apiClient.post('ADVISOR_REQUEST_CREATE', { + amount_wan: amount, + horizon: form.querySelector('[data-request-horizon]').value, + risk_preference: form.querySelector('[data-request-risk]').value, + note: note || null, + }); + showToast('申报已提交,投顾受理后会为你出具方案'); + form.querySelector('[data-request-note]').value = ''; + await loadRequests(); + } catch (error) { + apiClient.reportError(error); + // 后端的业务拒绝(例如"请先完成风险测评")要原样给客户看到,否则不知道卡在哪。 + showAlert(error.message || '提交失败,请稍后重试'); + } finally { + submit.disabled = false; + submit.textContent = '提交申报'; + } + }); + + void loadRequests(); +} + +if (requireCustomer()) initRequestSection(); diff --git a/app/static/portal/customer/advisor-plans/index.html b/app/static/portal/customer/advisor-plans/index.html new file mode 100644 index 0000000..c833aab --- /dev/null +++ b/app/static/portal/customer/advisor-plans/index.html @@ -0,0 +1 @@ +我的投顾方案 · 南方财富

    我的投顾方案

    可在下方申报投顾方案(需已完成且在有效期内的风险测评);投顾受理并发送后,方案会出现在「已收到的方案」里。

    申报投顾方案

    我的申报

    已收到的方案

    diff --git a/app/static/portal/employee-advisor/dashboard/actions-module.js b/app/static/portal/employee-advisor/dashboard/actions-module.js index 44bb4e1..a5bcc71 100644 --- a/app/static/portal/employee-advisor/dashboard/actions-module.js +++ b/app/static/portal/employee-advisor/dashboard/actions-module.js @@ -13,6 +13,12 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5'; import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913'; +// 推荐方案的可视化卡片(走势图 / 指标 / 组合构成)。与客户「我的投顾方案」页**共用**, +// 避免两处各写一套后漂移。取数用的是本模块的 apiClient(依赖注入,模块内不 import)。 +import { + hydratePlanView, + renderPlanProducts, +} from '/static/portal/common/advisor-plan-view.js?v=20260916-plan3'; import { ACTION_DESCRIPTIONS, ACTION_ENDPOINTS, @@ -256,15 +262,9 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon 'info', ); } else { - html += table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => { - const evidence = product.recommendation_evidence_card || {}; - return [ - `${escapeHtml(product.product_name)}${escapeHtml(product.product_code)} · ${escapeHtml(product.product_category)}`, - escapeHtml((evidence.suitability || {}).risk_level || '--'), - escapeHtml(String(product.score ?? '--')), - escapeHtml(product.reason || '--'), - ]; - })); + // 可视化卡片:每只基金一张卡(走势图 + 净值/区间涨跌/基金经理/费率/评分 + 推荐依据), + // 外加组合构成条。行情与净值是**异步**补进去的(见 show() 里的 hydratePlanView)。 + html += renderPlanProducts(products); } (data.disclosures || []).forEach((text) => { html += disclaimer(text); }); return html; @@ -399,6 +399,8 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon animate(blockedIndex, () => { output.innerHTML = markupFor(action, data, source); bindRecommendReviewActions(); + // 结果里若有推荐卡片骨架,异步补走势图、组合曲线与指标(失败只降级为"暂无数据")。 + void hydratePlanView(output, apiClient, (data && data.products) || []); }); } diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.css b/app/static/portal/employee-advisor/dashboard/dashboard.css index 4926872..f5b17ab 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.css +++ b/app/static/portal/employee-advisor/dashboard/dashboard.css @@ -3,7 +3,7 @@ * 分层:common/base.css(设计令牌 + 通用组件)→ common/operations.css(工作台共享层) * → 本文件(只写投顾页自己的东西)。 * - * 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 已发布)**。 + * 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 历史方案记录)**。 * 约定: * · 只用 tokens.css 的变量取色/取间距,不写死色值与 px 间距; * · 类名用 BEM(`block__element--modifier`),状态用修饰类而不是内联样式; @@ -169,12 +169,25 @@ .advisor-inline-form .form-field { flex: 1 1 180px; } .advisor-output .form-alert { margin: var(--space-3) 0; } -/* ---- 已发布交付物 ---- */ +/* ---- 历史方案记录(卡片形态与结果区一致) ---- */ .advisor-card { padding: var(--space-4); display: grid; gap: var(--space-2); border-bottom: 1px solid var(--line); } .advisor-card:last-child { border-bottom: 0; } .advisor-card__title { margin: 0; font-size: 16px; font-weight: 680; } .advisor-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; } .advisor-card__content { margin: var(--space-2) 0 0; padding: var(--space-3); color: var(--ink-soft); background: var(--canvas); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.65; white-space: pre-wrap; } +/* 标题里的状态标签:`operations.css` 只给了 `.status-tag--*` 的配色,这里补布局。 */ +.advisor-card__title .status-tag { margin-left: var(--space-2); padding: 1px var(--space-2); border-radius: var(--radius-sm); font-size: var(--fs-small); font-weight: 600; vertical-align: middle; } +/* 卡片可点开详情:给出可点击的视觉与键盘焦点反馈。 */ +.advisor-card--clickable { cursor: pointer; transition: background 160ms ease; } +.advisor-card--clickable:hover { background: var(--surface-soft); } +.advisor-card--clickable:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; } +.advisor-card__hint { margin: var(--space-2) 0 0; color: var(--brand-dark); font-size: var(--fs-small); font-weight: 600; } +/* 四个操作按钮:换行排列,状态不适用时禁用(口径与后端状态机一致)。 */ +.advisor-card__actions { margin-top: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-2); } +.advisor-card__actions .button[disabled] { opacity: .5; cursor: not-allowed; } + +/* 详情弹窗:表格沿用结果区的边框样式(`.advisor-output .data-table-wrap` 只作用于结果区)。 */ +.advisor-history-dialog .data-table-wrap { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); } /* ---- 执行日志 ---- */ .log-item { font-size: var(--fs-small); line-height: 1.6; overflow-wrap: anywhere; } diff --git a/app/static/portal/employee-advisor/dashboard/history-module.js b/app/static/portal/employee-advisor/dashboard/history-module.js new file mode 100644 index 0000000..d582fc2 --- /dev/null +++ b/app/static/portal/employee-advisor/dashboard/history-module.js @@ -0,0 +1,262 @@ +// 历史方案记录(列表 + 详情弹窗 + 四个操作按钮)。 +// +// 与已移除的「已发布交付物」面板不同:这里列**全部状态**的方案与方案书 +// (含 `pending_review` / `pending` 待审、`rejected` 已驳回),用于回看 +// 「以前生成过什么」。数据口径见后端 `GET /api/v1/advisor/recommendations/history`。 +// +// 推荐方案(`advisor_recommendation_plan`)卡片带四个操作:审核通过 / 驳回 / +// 发送给客户 / 删除 —— 对应 ADVISOR_REVIEW_RECOMMENDATION、ADVISOR_PUBLISH_RECOMMENDATION +// 与 ADVISOR_DELETE_RECOMMENDATION 三个端点。**投资方案书不挂这四个按钮**: +// 它按 `goal_no` 走自己的确认/审核/发布链路,且被 `advisor_investment_goal` 外键引用,删不掉。 +// +// 刷新时机:本模块自己操作后会 `load()`;此外 `actions-module` 在结果区审核/发布成功后 +// 会派发 `advisor:published-refresh`,这里一并监听(事件名沿用旧约定)。 + +import { apiClient } from '/static/portal/common/api-client.js?v=20260916-history3'; +import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913'; +import { showToast } from '/static/portal/common/notifications.js'; +import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260913'; +import { BOOK_STATUS_LABELS, CONTENT_TYPE_LABELS } from './advisor-config.js?v=20260914-advisor9'; + +//: 只有推荐方案有下面这四个动作。 +const PLAN_CONTENT_TYPE = 'advisor_recommendation_plan'; + +//: 动作 → 按钮文案(顺序即展示顺序)。 +const PLAN_ACTIONS = Object.freeze([ + ['approve', '审核通过'], + ['reject', '驳回'], + ['publish', '发送给客户'], + ['delete', '删除'], +]); + +//: 两套状态表之外的补充:推荐方案的「待审核」态(`generate` 置 `pending_review`), +//: 以及方案书下架后置的 `draft`(既非 `pending` 也非 `approved`,故 `BOOK_STATUS_LABELS` +//: 里没有)。其余状态两套共用 `BOOK_STATUS_LABELS`。 +const EXTRA_STATUS_LABELS = Object.freeze({ + pending_review: '待审核', + draft: '草稿', +}); + +//: 状态 → 标签色(对齐 common/operations.css 的 `.status-tag--*` 修饰类)。 +const STATUS_TONES = Object.freeze({ + pending: 'medium', + pending_review: 'medium', + draft: 'medium', + approved: 'low', + published: 'active', + rejected: 'failed', +}); + +//: 动作禁用口径 —— 与后端状态机一致(`ProductRecommendationService.review/publish`): +//: 审核只对 `pending_review`;发布只对已审核通过且**尚未发布**的。 +function actionDisabled(action, row) { + if (action === 'approve' || action === 'reject') return row.review_status !== 'pending_review'; + if (action === 'publish') return row.review_status !== 'approved' || Boolean(row.published_at); + return false; +} + +function statusLabel(status) { + return EXTRA_STATUS_LABELS[status] || BOOK_STATUS_LABELS[status] || String(status || '--'); +} + +function typeLabel(type) { + return CONTENT_TYPE_LABELS[type] || '交付物'; +} + +function actionButtons(row) { + return '
    ' + + PLAN_ACTIONS.map(([action, label]) => { + const disabled = actionDisabled(action, row) ? ' disabled' : ''; + return ``; + }).join('') + + '
    '; +} + +//: 表格标记与 `actions-module.js` 的 `table()` 一致(同一页两套写法会漂移)。 +function table(headers, rows) { + if (!rows.length) return ''; + return '
    ' + + headers.map((head) => ``).join('') + + '' + + rows.map((cells) => `${cells.map((cell) => ``).join('')}`).join('') + + '
    ${escapeHtml(head)}
    ${cell}
    '; +} + +// ---- 详情 ---- + +function detailFacts(row) { + const facts = [ + ['方案编号', `#${row.content_id}`], + ['类型', typeLabel(row.content_type)], + ['状态', statusLabel(row.review_status)], + ['客户', `客户 ${row.customer_id || '--'}`], + ['生成时间', formatDateTime(row.created_at)], + ]; + if (row.reviewed_at) facts.push(['审核时间', formatDateTime(row.reviewed_at)]); + if (row.published_at) facts.push(['发布时间', formatDateTime(row.published_at)]); + return `
    ${facts + .map(([label, value]) => `
    ${escapeHtml(label)}
    ${escapeHtml(value)}
    `) + .join('')}
    `; +} + +function renderDetail(row) { + const plan = row.plan && typeof row.plan === 'object' ? row.plan : {}; + const products = Array.isArray(plan.products) ? plan.products : []; + let html = detailFacts(row); + + if (products.length) { + html += '

    推荐产品

    ' + + table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => { + const evidence = product.recommendation_evidence_card || {}; + return [ + `${escapeHtml(product.product_name || '--')}` + + `${escapeHtml(product.product_code || '')}` + + ` · ${escapeHtml(product.product_category || '')}`, + escapeHtml((evidence.suitability || {}).risk_level || '--'), + escapeHtml(String(product.score ?? '--')), + escapeHtml(product.reason || '--'), + ]; + })); + const summary = plan.selection_summary; + if (summary) { + html += '

    ' + + `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:` + + `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、` + + `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。

    `; + } + } else { + // 方案书等非推荐类交付物:结构不固定,原样呈现,避免臆造字段。 + html += '

    方案内容

    ' + + `
    ${escapeHtml(JSON.stringify(plan, null, 2))}
    `; + } + + if (plan.review_comment) { + html += '

    审核意见

    ' + + `

    ${escapeHtml(String(plan.review_comment))}

    `; + } + (Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => { + html += `

    ${escapeHtml(String(text))}

    `; + }); + return html; +} + +export function createHistoryModule({ list, dialog }) { + let currentRows = []; + let busy = false; + + function rowAt(node) { + const card = node.closest('[data-history-index]'); + if (!card) return null; + return currentRows[Number(card.dataset.historyIndex)] || null; + } + + function openDetail(row) { + if (!dialog || !row) return; + const titleNode = dialog.querySelector('[data-history-dialog-title]'); + const bodyNode = dialog.querySelector('[data-history-dialog-body]'); + if (titleNode) titleNode.textContent = `${typeLabel(row.content_type)} · 编号 ${row.content_id}`; + if (bodyNode) bodyNode.innerHTML = renderDetail(row); + if (typeof dialog.showModal === 'function') dialog.showModal(); + else dialog.setAttribute('open', ''); + } + + async function runAction(action, row) { + if (busy || !row) return; + const contentId = row.content_id; + if (action === 'delete' && !window.confirm(`确认删除方案 #${contentId}?删除后不可恢复。`)) return; + busy = true; + list.setAttribute('aria-busy', 'true'); + try { + if (action === 'approve' || action === 'reject') { + await apiClient.post( + 'ADVISOR_REVIEW_RECOMMENDATION', + { decision: action === 'approve' ? 'approved' : 'rejected', comment: '' }, + { pathParams: { contentId } }, + ); + showToast(action === 'approve' ? `方案 #${contentId} 已审核通过` : `方案 #${contentId} 已驳回`); + } else if (action === 'publish') { + await apiClient.post('ADVISOR_PUBLISH_RECOMMENDATION', undefined, { pathParams: { contentId } }); + showToast(`方案 #${contentId} 已发送给客户`); + } else if (action === 'delete') { + await apiClient.del('ADVISOR_DELETE_RECOMMENDATION', { pathParams: { contentId } }); + showToast(`方案 #${contentId} 已删除`); + } + await load(); + } catch (error) { + apiClient.reportError(error); + showToast(error.message || '操作未完成', 'error'); + } finally { + busy = false; + list.removeAttribute('aria-busy'); + } + } + + list.addEventListener('click', (event) => { + const button = event.target.closest('[data-history-action]'); + if (button) { + const row = rowAt(button); + if (row) runAction(button.dataset.historyAction, row); + return; + } + const row = rowAt(event.target); + if (row) openDetail(row); + }); + list.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + // 按钮自身会派发 click,别再当成「卡片激活」重复处理。 + if (event.target.closest('[data-history-action]')) return; + const row = rowAt(event.target); + if (!row) return; + event.preventDefault(); + openDetail(row); + }); + if (dialog) { + dialog.querySelector('[data-history-dialog-close]')?.addEventListener('click', () => dialog.close()); + // 点遮罩(事件目标就是 dialog 本身)关闭。 + dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); }); + } + + async function load() { + renderLoading(list, 3); + try { + const response = await apiClient.get('ADVISOR_HISTORY'); + const rows = Array.isArray(response.data) ? response.data : []; + currentRows = rows; + if (!rows.length) { + renderEmpty(list, '暂无历史方案', '当前账号名下的客户还没有生成过方案或方案书;生成并保存一份推荐方案后,这里会留档。'); + return; + } + list.innerHTML = rows.map((row, index) => { + const tone = STATUS_TONES[row.review_status] || 'active'; + const products = Array.isArray(row.plan?.products) ? row.plan.products : []; + const names = products.map((item) => item.product_name || item.product_code || '--').slice(0, 5); + return `
    ' + + `

    ${escapeHtml(typeLabel(row.content_type))}` + + ` · 编号 ${escapeHtml(row.content_id)}` + + ` ${escapeHtml(statusLabel(row.review_status))}

    ` + + `

    客户 ${escapeHtml(row.customer_id || '--')}` + + ` · 生成于 ${escapeHtml(formatDateTime(row.created_at))}` + + ` · 最近更新 ${escapeHtml(formatDateTime(row.published_at || row.reviewed_at || row.created_at))}

    ` + + (names.length ? `

    产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}

    ` : '') + + (row.content_type === PLAN_CONTENT_TYPE ? actionButtons(row) : '') + + '

    点击卡片查看详情 ›

    ' + + '
    '; + }).join(''); + } catch (error) { + currentRows = []; + apiClient.reportError(error); + renderError(list, error, load); + } + } + + // 演示模式(未登录)没有后端可查,给一句说明而不是空白。 + function showPlaceholder() { + renderEmpty(list, '历史方案需先登录', '当前为本地演示模式,未连接后端,因此没有历史记录可回放。'); + } + + window.addEventListener('advisor:published-refresh', load); + + return Object.freeze({ load, showPlaceholder }); +} diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index 17d64fc..ce3503d 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -39,6 +39,7 @@ async def test_portal_root_redirects_to_public_home() -> None: "/portal/customer/orders/", "/portal/customer/transactions/", "/portal/customer/cash-ledger/", + "/portal/customer/advisor-plans/", "/portal/customer/risk-questionnaire/", "/portal/employee-console/login/", "/portal/employee-console/workspace/", @@ -172,20 +173,59 @@ def test_advisor_workspace_registers_documented_operation_endpoints() -> None: encoding="utf-8" ) for endpoint_id in ( - "ADVISOR_PUBLISHED", "ADVISOR_GOAL", "ADVISOR_ANALYSIS", + "ADVISOR_PUBLISHED", "ADVISOR_HISTORY", "ADVISOR_GOAL", "ADVISOR_ANALYSIS", "ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL", + "ADVISOR_REVIEW_RECOMMENDATION", "ADVISOR_PUBLISH_RECOMMENDATION", + "ADVISOR_DELETE_RECOMMENDATION", ): assert f"{endpoint_id}:" in source - for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标"): + for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标", "历史方案记录"): assert label in dashboard +def test_advisor_plan_view_is_shared_by_both_surfaces() -> None: + """推荐方案的可视化渲染必须**只有一份**,投顾工作台与客户页共用。 + + 两处都是同一份 `advisor_recommendation_plan`,各写一套必然漂移 + (改了一边忘了另一边)。这条测试同时守住「共享模块存在」与「两处都在用它」。 + """ + view = (PORTAL / "common" / "advisor-plan-view.js").read_text(encoding="utf-8") + assert "export function renderPlanProducts" in view + assert "export async function hydratePlanView" in view + assert "P002" in view + for page in ( + "employee-advisor/dashboard/actions-module.js", + "customer/advisor-plans/advisor-plans.js", + ): + source = (PORTAL / page).read_text(encoding="utf-8") + assert "advisor-plan-view.js" in source, page + + +def test_advisor_history_module_exposes_plan_actions() -> None: + """历史方案记录里,推荐方案卡片要带审核/驳回/发送/删除四个操作。 + + 四个动作对应三个端点(审核与驳回共用一个 reviews 端点,用 `decision` 区分)。 + 少了 `ADVISOR_DELETE_RECOMMENDATION` 就只剩"能看不能删"。 + """ + source = ( + PORTAL / "employee-advisor" / "dashboard" / "history-module.js" + ).read_text(encoding="utf-8") + for label in ("审核通过", "驳回", "发送给客户", "删除"): + assert label in source + for endpoint in ( + "ADVISOR_REVIEW_RECOMMENDATION", + "ADVISOR_PUBLISH_RECOMMENDATION", + "ADVISOR_DELETE_RECOMMENDATION", + ): + assert endpoint in source + + def test_advisor_dashboard_is_composed_from_feature_modules() -> None: source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text( encoding="utf-8" ) assert "./actions-module.js" in source - assert "./published-module.js" in source + assert "./history-module.js" in source config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text( encoding="utf-8" ) diff --git a/tests/unit/service/test_advisor_reason_service.py b/tests/unit/service/test_advisor_reason_service.py new file mode 100644 index 0000000..5bbb9e5 --- /dev/null +++ b/tests/unit/service/test_advisor_reason_service.py @@ -0,0 +1,153 @@ +"""推荐依据 LLM 增强的守卫测试。 + +重点不是"能不能调通模型",而是**三条合规边界**: +1. 收益承诺类表述必须被丢弃; +2. 未启用 / 缺密钥 / 调用失败一律回退(返回空字典),不得影响推荐主流程; +3. 只接受自己请求过的 product_code(模型返回别的代码不得采纳)。 +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.service import advisor_reason_service as module +from app.service.advisor_reason_service import ( + PROHIBITED_PHRASES, + AdvisorReasonService, + build_prompt, + is_compliant, + parse_items, +) + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self): + return self._payload + + +class _FakeClient: + def __init__(self, response=None, error=None): + self.response = response + self.error = error + self.calls = 0 + + async def post(self, *args, **kwargs): + self.calls += 1 + if self.error is not None: + raise self.error + return self.response + + +def _settings(**overrides): + values = { + "advisor_reason_llm_enabled": True, + "advisor_reason_llm_base_url": "https://example.invalid", + "advisor_reason_llm_model": "deepseek-chat", + "advisor_reason_llm_timeout_seconds": 5.0, + "deepseek_api_key": "sk-test", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_is_compliant_rejects_every_prohibited_phrase() -> None: + for phrase in PROHIBITED_PHRASES: + text = f"该产品风险等级 R3,与您的风险承受能力匹配,{phrase},可长期持有。" + assert not is_compliant(text), phrase + + +def test_is_compliant_rejects_out_of_range_length() -> None: + assert not is_compliant("太短") + assert not is_compliant("风" * 200) + + +def test_is_compliant_accepts_grounded_text() -> None: + text = ( + "该产品为 R3 中等风险,与您的风险承受能力匹配;近 60 个交易日最大回撤 -8.71%," + "建议作为组合的一部分配置。" + ) + assert is_compliant(text) + + +def test_parse_items_tolerates_code_fence_and_noise() -> None: + raw = '结果如下:\n```json\n{"items":[{"product_code":"159329","reason":"x"}]}\n```' + assert parse_items(raw) == {"159329": "x"} + + +def test_parse_items_returns_empty_on_bad_shape() -> None: + assert parse_items("not json at all") == {} + assert parse_items('{"items": "oops"}') == {} + + +def test_build_prompt_carries_real_numbers() -> None: + prompt = build_prompt( + {"risk_level": 4, "horizon_months": 60, "liquidity": "30 日后可使用"}, + [{ + "product_code": "159329", "product_name": "沙特ETF南方", + "product_category": "ETF", "risk_level": "R5", "score": 0.96, + "return_20d_pct": -0.7126, "return_60d_pct": -1.8213, + "max_drawdown_60d_pct": -4.9219, "rule_reason": "规则文案", + }], + ) + assert "-0.71%" in prompt + assert "-4.92%" in prompt + assert "30 日后可使用" in prompt + + +@pytest.mark.asyncio +async def test_enhance_drops_non_compliant_items(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "get_settings", lambda: _settings()) + client = _FakeClient(_FakeResponse({"choices": [{"message": {"content": ( + '{"items":[' + '{"product_code":"159329","reason":"该产品风险等级 R5,与您的风险承受能力匹配,' + '建议作为组合分散配置的一部分。"},' + '{"product_code":"159382","reason":"这只保证收益,稳赚,放心买。"},' + '{"product_code":"999999","reason":"该产品风险等级 R3,与您的风险承受能力匹配,' + '建议作为组合的一部分配置。"}' + ']}' + )}}]})) + accepted = await AdvisorReasonService(client=client).enhance( + customer={}, products=[{"product_code": "159329"}, {"product_code": "159382"}] + ) + # 命中收益承诺的 159382 被丢弃;未请求过的 999999 也不采纳。 + assert set(accepted) == {"159329"} + + +@pytest.mark.asyncio +async def test_enhance_returns_empty_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + module, "get_settings", lambda: _settings(advisor_reason_llm_enabled=False) + ) + client = _FakeClient(_FakeResponse({"choices": []})) + result = await AdvisorReasonService(client=client).enhance( + customer={}, products=[{"product_code": "159329"}] + ) + assert result == {} + assert client.calls == 0 # 未启用时**根本不发请求** + + +@pytest.mark.asyncio +async def test_enhance_returns_empty_without_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "get_settings", lambda: _settings(deepseek_api_key="")) + client = _FakeClient(_FakeResponse({"choices": []})) + assert await AdvisorReasonService(client=client).enhance( + customer={}, products=[{"product_code": "159329"}] + ) == {} + assert client.calls == 0 + + +@pytest.mark.asyncio +async def test_enhance_returns_empty_on_upstream_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "get_settings", lambda: _settings()) + client = _FakeClient(error=RuntimeError("boom")) + assert await AdvisorReasonService(client=client).enhance( + customer={}, products=[{"product_code": "159329"}] + ) == {} diff --git a/启动后端.bat b/启动后端.bat index 2fdf6e5..d4aefed 100644 --- a/启动后端.bat +++ b/启动后端.bat @@ -1,66 +1,115 @@ @echo off chcp 936 >nul -setlocal -title ½ðÈÚ Agent ƽ̨ ¡¤ ºó¶˷þÎñ(API) +setlocal EnableDelayedExpansion +title ???? Agent ?? ?? ??????(API) rem ============================================================ -rem ˫»÷±¾ÎļþÆô¶¯ºó¶Ë API£¨FastAPI + uvicorn£¬127.0.0.1:8000£©¡£ -rem ֻÆð½ӿÚÓëǰ¶ËҳÃ棻Agent Worker£¨¿ͷþ¶Ի°/֪ʶÏòÁ¿ͬ²½/ -rem ·ç¿ØɨÃ裩²»º¬ÔÚÄڣ¬ÐèҪʱÇëÁíÍâÆô¶¯¡£ -rem PROJ ×Զ¯ȡ±¾ÎļþËùÔÚĿ¼£¨ÏîĿ¸ù£©£¬²ֿ⸴ÖƵ½ÄͼÄÜÅܡ£ -rem ¿Éѡ²ÎÊý£ºÆô¶¯ºó¶Ë.bat 8100 ָ¶¨¶˿ڣ¨ĬÈÏ 8000£© +rem ?????????????? API??FastAPI + uvicorn??127.0.0.1:8000???? +rem ????????????—±Agent Worker????????/?????????/ +rem ?????Ñh???????????????????????? +rem PROJ ???????????????????????????????????????? +rem ???????????????.bat 8100 ?????????? 8000?? +rem +rem ??????????? start.ps1 ??????¡ê??? +rem 1) ????????? .venv +rem 2) ???? conda ???? jr_py313 +rem 3) py -3.13 ????? +rem 4) PATH ??? python????? 3.11+?? +rem ?????? import fastapi/sqlalchemy/asyncmy/pydantic????????? +rem ????????·Ú???????????????????? rem ============================================================ set "PROJ=%~dp0" set "HOST=127.0.0.1" set "PORT=%~1" if "%PORT%"=="" set "PORT=8000" -set "PY=%PROJ%.venv\Scripts\python.exe" +set "PY=" -echo ============================================================ -echo ½ðÈÚ Agent ƽ̨ ¡¤ ºó¶˷þÎñ -echo ============================================================ -echo. +rem ---------- 1) ????????? / conda ??????????? exe ¡¤????---------- +for %%C in ( + "%PROJ%.venv\Scripts\python.exe" + "D:\conda\envs\jr_py313\python.exe" + "%USERPROFILE%\miniconda3\envs\jr_py313\python.exe" + "%USERPROFILE%\anaconda3\envs\jr_py313\python.exe" +) do ( + if not defined PY ( + if exist %%C ( + %%C -c "import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1 + if !errorlevel!==0 set "PY=%%~C" + ) + ) +) -if not exist "%PY%" ( - echo [´íÎó] ÕҲ»µ½ÐéÄ⻷¾³½âÊÍÆ÷£º - echo %PY% - echo. - echo ÇëÏÈÔÚÏîĿ¸ùִÐÐһ´λ·¾³°²װ£º - echo py -3.13 -m venv .venv - echo .venv\Scripts\python.exe -m pip install -e . - echo. - pause - exit /b 1 +rem ---------- 2) py -3.13 ??????????????? exe ¡¤????---------- +if not defined PY ( + for /f "delims=" %%X in ('py -3.13 -c "import sys;print(sys.executable)" 2^>nul') do ( + set "PY=%%X" + ) + if defined PY ( + "!PY!" -c "import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1 + if not !errorlevel!==0 set "PY=" + ) +) + +rem ---------- 3) PATH ??? python?????????? exe ¡¤????§µ?? >=3.11??---------- +if not defined PY ( + for /f "delims=" %%X in ('python -c "import sys;print(sys.executable)" 2^>nul') do ( + set "PY=%%X" + ) + if defined PY ( + "!PY!" -c "import sys;v=sys.version_info;assert (v[0],v[1])>=(3,11);import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1 + if not !errorlevel!==0 set "PY=" + ) +) + +if not defined PY ( + echo [????] ??????????§Ò?????? Python ?????? + echo ??? 3.11+ ????? fastapi / sqlalchemy / asyncmy / pydantic?? + echo. + echo ??????????????????????????§µ??? + echo py -3.13 -m venv .venv + echo .venv\Scripts\python.exe -m pip install -e . + echo. + echo ??????? conda ???? jr_py313???????????????¦Ë?????? + echo D:\conda\envs\jr_py313\python.exe + echo %USERPROFILE%\miniconda3\envs\jr_py313\python.exe + echo %USERPROFILE%\anaconda3\envs\jr_py313\python.exe + echo. + pause + exit /b 1 ) if not exist "%PROJ%app\main.py" ( - echo [´íÎó] δÕҵ½ app\main.py£¬±¾ .bat ±ØÐë·ÅÔÚÏîĿ¸ùĿ¼¡£ - echo µ±ǰ PROJ = %PROJ% - echo. - pause - exit /b 1 + echo [????] ¦Ä??? app\main.py???? .bat ???????????????? + echo ??? PROJ = %PROJ% + echo. + pause + exit /b 1 ) cd /d "%PROJ%" -echo ÏîĿĿ¼ : %PROJ% -echo ½âÊÍÆ÷ : %PY% -echo ¼àÌýµØַ : http://%HOST%:%PORT% -echo ǰ¶ËÈë¿Ú : http://%HOST%:%PORT%/portal/ -echo ½¡¿µ¼ì²é : http://%HOST%:%PORT%/health +echo ============================================================ +echo ???? Agent ?? ?? ?????? +echo ============================================================ echo. -echo Ìáʾ£ºÇëȷÈÏ MySQL(127.0.0.1:3306) ÒÑÆô¶¯¡£ -echo °´ Ctrl+C ¿Éֹͣ·þÎñ¡£ +echo ????? : %PROJ% +echo ?????? : !PY! +echo ??????? : http://%HOST%:%PORT% +echo ?????? : http://%HOST%:%PORT%/portal/ +echo ??????? : http://%HOST%:%PORT%/health +echo. +echo ?????????? MySQL(127.0.0.1:3306) ??????? +echo ?? Ctrl+C ???????? echo ============================================================ echo. -"%PY%" -m uvicorn app.main:app --host %HOST% --port %PORT% --log-level info +"!PY!" -m uvicorn app.main:app --host %HOST% --port %PORT% --log-level info set "RC=%ERRORLEVEL%" echo. echo ============================================================ -echo ·þÎñÒÑÍ˳ö£¨Í˳öÂë %RC%£©¡£ +echo ???????????????? %RC%???? echo ============================================================ pause endlocal