lzf_0626
|
42a330c4c9
|
docs/44 演示流程按 2026-09-14 实测同步:今日盈亏可讲、投顾自助审核、管理员八页签、权限数与已知空数据
|
2026-09-15 00:14:20 +08:00 |
|
lzf_0626
|
776b504ee6
|
新增《全平台功能流程 · 大白话版》:从登入讲到六门户全部流程
- 读者是第一次接触平台的人:全文不用"链路/编排/收敛"这类词,讲"点一下之后发生了什么"
- 结构:三个角色(浏览器/API/Worker)→ 访客 → 登入与角色落地 → 客户 8 页 → 风控 →
投顾 → 运营三条线 → 管理员 8 个页签 → 底座 9 件事 → 一条主线时间线 → 常见疑问 → 速查
- 每节都标了文件/接口依据,并写清"已知空数据"与"已知小偏差"(通知类型下拉未接线、
NL2SQL 错误信息列恒为 --、推广页无 form 导致 required 不生效、场外默认 dry-run 等)
- 顺带纠正三处易误传的说法:幂等键只防"同一次请求重发"(连点仍会下两笔)、
客户侧前端权限门是装饰性的(后端 403 才是拦截)、FM-03 熔断目前只在前端拦
- 风控演示数据那 6 行 RISKDEMO 场外申购/赎回:代码注释与今日盈亏说明改口径为
"风控异常交易演示的触发材料,刻意保留",不再当脏数据;今日盈亏按 transaction_type 语义排除它们
|
2026-09-15 00:10:23 +08:00 |
|
lzf_0626
|
9dd2802d67
|
客户看板「今日盈亏」不再是硬编码 0:按行情/净值基准真实计算(含当日买卖)
- 口径:今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额(不含费用,与「持有盈亏」同口径)
- 昨日持仓数量由当日成交反推,不需要新表新字段
- 基准优先场内行情最近两个交易日收盘价(与同页 latest_price/market_value 同源、客户可核对),
行情只有一天时回退基金净值(15911/159991-159995 的行情本身就来自净值序列)
- 只认 transaction_type ∈ {买入,卖出}:演示库里混进的风控场外申购/赎回(RISKDEMO-*)
会把「昨日持仓数量」抬到 76 万份、今日盈亏从 -21.22 变成 -1612.72
- 接口字段与前端一行未改:HoldingItem.today_profit_loss / PortfolioSummary 两个字段数值变真
- 新增 tools/check_today_profit_loss.py:纯 SQL 独立复算并与接口逐只比对(实测 9001 = -132.70 / -0.2528%)
- 新增 6 条单测;pytest tests/unit tests/contract → 1466 passed, 0 failed
|
2026-09-15 00:03:23 +08:00 |
|
lzf_0626
|
48b43cfad5
|
投顾推荐空候选池:把"为什么没有产品"讲清楚,而不是一句"没有通过校验的产品"
"当前没有通过适当性与证据校验的产品。" 这句话对运营毫无信息量:既不知道是谁的问题,
也不知道下一步该做什么。现在空态会说明证据门口径(每个产品都要求 ①销售机构的**已验证**
适当性证据 ②基金合同快照,且都要带可核查的来源链接与文档 sha256,缺一即整只排除,
fail closed),并给出本轮候选/入选/排除的数量,以及两条可执行路径:
- 真实方案:先由产品治理线导入适当性证据与合同快照
( ools/import_product_governance_reference.py,CSV 需带 source_url 与 document_sha256);
- 呈现效果:切到页面顶部「本地演示数据」,那里的方案会标注"规则模拟、不构成真实推荐"。
同时说明本环境为空的原因:dvisor_product_suitability_reference 与
dvisor_product_contract_snapshot **均为 0 行**(26 个上市产品因此全部被证据门排除,
candidate_count=0,所以连"排除原因"也没有可展示的条目)。
|
2026-09-14 23:34:36 +08:00 |
|
lzf_0626
|
467d2b5169
|
投顾可自助审核/发布自己生成的推荐方案(原先只有管理员能推进草案)
## 现象与根因
投顾工作台生成推荐方案后,草案停在 `pending_review` 且投顾无法推进:
- 服务层 `review` / `publish` 都带 **`admin=True` 角色闸门**
(`product_recommendation_service.py:286/317`),即使投顾角色**已经持有**
`product-recommendation:review` / `:publish` 两个权限码也一律 403;
- 审核/发布端点只注册在 **admin 路由**下(`/api/v1/admin/advisor/...`),
投顾侧根本没有对应入口;
- 投顾工作台也没有审核/发布按钮(`published-module.js` 原注释即写着
"发布动作要求管理员,投顾侧只读")。
于是业务上"让投顾自己审核"完全做不到,必须切管理员账号。
## 修法(三处配套,安全边界保留)
1. `app/service/product_recommendation_service.py`
- `review` / `publish` 去掉 `admin=True`,**只按权限码判定**
(`product-recommendation:review` / `:publish`,目前仅 advisor 与 admin 持有);
- `reviewer_user_id` 照旧如实落库,审计可追;
- 注释写明:若要回到"四眼原则/管理员专属",把 `admin=True` 加回即可。
2. `app/api/controllers/recommendations.py`
- 新增投顾侧路由 `POST /api/v1/advisor/recommendations/{id}/reviews`
与 `.../publications`(与 admin 路由调用同一服务方法)。
3. 前端
- `common/api-client.js`:注册 `ADVISOR_REVIEW_RECOMMENDATION` /
`ADVISOR_PUBLISH_RECOMMENDATION`;
- `employee-advisor/dashboard/actions-module.js`:结果区在拿到 `content_id` 后
给出「审核通过 / 驳回 / 发布给客户」按钮(结果区是 `innerHTML` 重建的,
所以每次渲染后重新绑定);审核通过后就地换成「发布给客户」;
- `published-module.js`:监听 `advisor:published-refresh`,发布成功后列表自动刷新。
## 未放宽的部分(有意保留)
- **管理面复核队列** `GET /api/v1/admin/advisor/pending-contents` 仍为
`admin=True` 专属 —— `tests/integration/test_advisor_review_queue_mysql.py`
里"投顾读不到该队列"的断言**未改动**;
- 客户/风控/运营角色不持有这两个权限码,因此不受影响。
## 验证(真实 HTTP,9020 身份)
```
① 生成推荐方案(客户 9001)→ content_id=19, pending_review
② 投顾自助审核通过 → HTTP 200 status=approved (改前 403)
③ 投顾自助发布 → HTTP 200 status=published
④ 已发布列表 → 含 id=19 ✅
```
新增回归测试 `test_advisor_can_review_and_publish_own_recommendation`
(客户缺测评/目标时 `pytest.skip` 并说明是数据前置,不误判为权限失败)。
## 门禁
- `pytest tests/unit tests/contract` → 1458 passed;
- `pytest tests/integration` → 111 passed + 1 例
`test_worker_runtime_mysql::...repeat[False]` 失败,**经复跑确认是 AGENTS.md 记载的
"常驻 Worker 抢队列",停掉常驻 Worker 后该用例 2 passed**,与本次改动无关;
- `ruff` 干净;三个 JS 文件 `node --check` 通过。
|
2026-09-14 23:27:44 +08:00 |
|
lzf_0626
|
5da2fb5790
|
投顾代客三项的权限码缺失:整片 403(与 9057-9059 同一个坑的第二批)
## 现象
投顾工作台(`advisor_t`,9020)一选客户就整片失败,页面只显示
「请求失败,请检查权限或稍后重试」。API 访问日志给出真相:
```
POST /api/v1/advisor/recommendations 403
POST /api/v1/advisor/asset-allocation 403
POST /api/v1/advisor/portfolio-analysis 403
GET /api/v1/advisor/recommendations/published 200 ← 只有它不需要代客权限
```
## 根因:三个 `*:customer` 权限码在库里根本不存在
三个服务在"代客"(`customer_id != context.user_id`)时要求的是**动态拼出来的
`:customer` 变体**:
- `product_recommendation_service.py:71` → `product-recommendation:generate:customer`
- `asset_allocation_service.py:57` → `asset-allocation:generate:customer`
- `portfolio_analysis_service.py:46` → `portfolio-analysis:read:customer`
而 `sys_permission` 里 `:customer` 后缀**只有 4 个**(`memory:read:customer`、
`investment-goal:{read,write,confirm}:customer`)—— 这三个从来没登记过。
`AuthorizationService.require()` 第一步就查不到该码 ⇒ 直接 403。
这与种子里 **9057-9059 的注释是同一个坑**("动态拼出来的权限码,对账工具抓不到字面量,
于是投顾查/建/确认客户目标全部 403"),前人修了 investment-goal 那三个,这三个漏了。
## 修法(按仓库纪律:种子定义权限码 → grant 脚本绑定角色)
1. `tools/seed_test_rbac.py`:新增 **9066-9068** 三个码,`data_scope=own_customers`
(`require_customer_scope` 只放行 `all`,或 `own_customers` 且客户确在
`context.customer_ids` = 归属客户内 —— 这正是投顾该有的最小权限),
并写明三处调用点,避免后人再漏;
2. `tools/grant_advisor_role.py`:把三个码加入 `ADVISOR_GRANTED_CODES`
(admin 因 `ADMIN_PERMISSIONS = 全部种子权限` 自动获得)。
已执行:`seed_test_rbac.py` → `grant_advisor_role.py`
(advisor 新增 3 项,共 31 项;admin 62 项)。
## 验证(真实 HTTP,9020 身份)
| 客户 | 推荐方案 | 资产配置 | 组合分析 |
|---|---|---|---|
| 9101(演示客户) | 200 `profile_required` | 200 `profile_required` | 200 `no_positions` |
| **9001(真实客户)** | **200 已生成方案**(content_id=5, pending_review) | **200 ready**(含配置比例) | **200 ready**(7 个持仓,市值 10698.60) |
**403 全部消失**;对真实客户三项均返回真实结果。
## 同时补的归属数据
`sys_customer_assignment` 原有 9020→9001 一行;工作台把演示客户的 id
(9101-9104)当真实 `customer_id` 发给后端,而 `own_customers` 要求客户在投顾名下,
因此用 `tools/assign_customer_scope.py` 补了 4 行(9020 → 9101/9102/9103/9104),
回验 `customer_ids = ('9001','9101','9102','9103','9104')`。
## 仍未解决(属**数据**缺口,不是权限)
1. **9101-9104 在库里没有任何数据**:`advisor-config.js:126` 注释指向的
`_seed_demo_customers.py` 在仓库、git 历史与桌面上**都不存在**(从未提交),
所以这四位没有账号 / 风险测评 / 投资目标 / 持仓 —— 只能返回 `profile_required`。
2. **推荐候选恒为 0**:`AdvisorProductRepository.authoritative_tradable_products`
要求**已验证**的适当性证据与合同快照(`review_status='verified'` 且
`source_url` / `document_sha256` 非空,fail closed),而
`advisor_product_suitability_reference` / `advisor_product_contract_snapshot`
均为 0 行。这需要产品治理线提供可核查证据,**不应伪造**。
## 门禁
`tools/check_rbac_seed_consistency.py` 通过(种子内 id 唯一、各 grant 脚本与种子逐条一致);
`pytest tests/unit tests/contract` 全绿。
|
2026-09-14 22:56:42 +08:00 |
|
lzf_0626
|
d8e47df20c
|
推介材料:勾选「展示排名」后无法填写来源 → 必然合规阻断(死锁)的修复
## 现象(你的截图)
任务 `PM-20260914-0007` 的合规检查结果为:
```
业绩排名来源不满足要求
补充三年期以上公开评价数据来源 · block
```
输入快照里的实证:
```json
ranking = {"enabled": true, "ranking_text": null, "public_source": null,
"institution_name": null, "evaluation_period_years": null}
```
## 根因:勾选框有了,填来源的地方没有
`promotion_compliance.py:82-91` 的规则是:
```python
if ranking.get("enabled"):
years = _years(ranking.get("evaluation_period_years"))
if years < 3 or not ranking.get("institution_name") or not ranking.get("public_source"):
→ block "performance.ranking_source_invalid"
```
而前端**只有**一个复选框 `performance_info.ranking.enabled`:
- 表单里**没有** `institution_name` / `public_source` / `evaluation_period_years` /
`ranking_text` 四个输入(实测枚举全部 `data-promo-field` 只有那一个 ranking 字段);
- `inputsPayload()` 也只提交 `ranking: { enabled }`。
⇒ 运营一旦勾选「展示排名」,来源四项永远是 null,规则必然阻断,
而界面上**无处可填** —— 要么取消勾选,要么永远生成不了。这是**死锁**,不是数据问题。
## 修法(前后端契约字段本就有,只补界面与提交)
1. `promotion/index.html`:在展示选项上方新增一组输入(`data-ranking-source`):
评价期间(年,需 ≥3)/ 评价机构 / 公开来源 / 排名文本;
2. `promotion/promotion.js`:`inputsPayload()` 的 `ranking` 补上这四个字段
(契约 `RankingInfo` 本就定义了 `institution_name` / `evaluation_period_years`
/ `ranking_text` / `public_source`,`evaluation_period_years` 是字符串,如 "3年")。
## 验证
- 用**真实任务 0007 的输入**跑 `PromotionComplianceChecker.check_inputs()`:
- 现状:3 条阻断(`history_short` + **`ranking_source_invalid`** + `data_attachment_missing`);
- 把来源填全后:**`ranking_source_invalid` 消失**(其余两条由"未上传业绩文件"引起,上传即解);
- 反例:评价期间改成 2 年 → 仍拦;3 年但缺公开来源 → 仍拦(规则没有被放松);
- 从源文件抽出 `inputsPayload()` 用桩真实调用:四个来源字段都进 payload;未勾选时 `enabled=false`;
- `index.html` 标签净增 +1 `<div>` / +1 `</div>`(结构平衡);`node --check` 通过;
- `pytest tests/unit tests/contract` → 1458 passed, 2 skipped, 0 failed。
## 附带说明
你看到的是"2 条"是因为**点了两次生成**:每次生成都会把该次的 findings 落库,
页面"读取合规结果"会把历史记录一并列出(不是一次调用重复产出)。
|
2026-09-14 22:45:57 +08:00 |
|
lzf_0626
|
e6147bb6ec
|
推介材料:中文文件名让上传请求头非法 → 两个附件都"网络连接失败"(服务端一条记录都没有)
## 现象
上传 `业绩数据1.xlsx` 与 `基金经理1号王建龙.png`(扩展名都在白名单内):
页面提示 `业绩数据文件上传失败:网络连接失败;基金经理照片上传失败:网络连接失败`,
而库里、盘上、审计里**都没有任何上传痕迹**。
## 排查与根因
1. API 是健康的(`/portal/` 200、129 条路由在、8000 正常监听);
2. `api_client` 里 "网络连接失败" 的触发条件是 **`fetch` 抛了非超时的异常**(超时会显示"请求超时");
3. **`api_request_receipt` 里没有任何上传记录** —— 同一时段的建任务/存资料/生成三条都有 receipt
(连"生成未通过合规校验"这种业务失败也落 receipt)⇒ **上传请求根本没到应用**;
4. `promotion.js` 给上传传的幂等键是 `` `${taskNo}-${type}-${file.name}-${file.size}` `` ——
**把中文文件名拼进了 HTTP 头 `Idempotency-Key`**;
5. HTTP 头值只能由 ≤0xFF 的码点组成:实测同一请求用标准客户端发送时抛
`UnicodeEncodeError: 'ascii' codec can't encode characters in position 34-45`;
浏览器更严格,`fetch` 在**构造请求头时直接抛 `TypeError`**,请求一个字节都没发出去,
却被 `api-client.js` 的 catch 包装成"网络连接失败"——**"参数非法"伪装成了"网络故障"**。
6. 反证:换成纯 ASCII 的幂等键,同两个文件、同一端点**立刻 200 成功**
(attachment_id=67/68,`performance_summary` 正常返回)。
## 修法
1. `employee-operations/promotion/promotion.js`
- 新增 `asciiOnly()` / `uploadKey()`:幂等键改为 `任务号-类型-字节数-最后修改时间`
(**刻意不用文件名** —— 中文名即非法头值),纯 ASCII 且同一文件重传得到同一键(幂等回放);
2. `common/api-client.js`
- 对幂等键做**前置校验**(平台规范:16-128 位可打印 ASCII),不满足时抛
`IDEMPOTENCY_KEY_INVALID` 并**带上端点与键值** —— 下一次这类问题 10 秒可定位,
不必再从"网络故障"倒推。
## 验证
- 从源文件抽出 `asciiOnly`/`uploadKey` 用 Node 断言:中文文件名 → 键全 ASCII 且通过校验、
同一文件两次同键、不同文件不同键、任务号含中文也被转义、旧写法会被拦下(全部通过);
- `node --check` 两个文件通过;
- 真实 HTTP(带令牌、真实文件)验证:ASCII 键 → **HTTP 200**,中文键 → 客户端抛 `UnicodeEncodeError`;
- 该账号的两个附件已成功入库(id=67/68),业绩摘要 = `as_of_date 2025-12-31 /
history_months 23 / product_return 17.1% / max_drawdown -1.15%`;
- `pytest tests/unit tests/contract` 全绿。
|
2026-09-14 22:36:49 +08:00 |
|
lzf_0626
|
84bf51c0d0
|
NL2SQL:5 位产品代码被丢弃导致"查询成功但数据错"(演示产品 15911 正是 5 位)
## 现象
通用自然语言查询里问「查询15911的净值」,返回的是**别的产品**的数据:
```
问:查询15911的净值 → 返回 511810 货币ETF南方 的净值
问:查询基金代码15911最近30天净值 → 同上
```
而且 `status = success`、`message = 查询成功` —— **不报错,只是数据是错的**。
## 根因
`financial_nl2sql_service._filters()` 只认 6 位数字:
```python
re.findall(r"(?<!\d)\d{6}(?!\d)", question) # 15911 只有 5 位 ⇒ 被静默丢弃
```
过滤条件为空 ⇒ 生成的 SQL **没有产品过滤**(`WHERE 1=1 ... GROUP BY ... LIMIT 50`)
⇒ 返回一批无关于问题的产品净值。现库实测:**6 位代码 25 个、5 位代码 1 个**
(后者就是演示产品 `15911`)—— 所以这个缺陷精确地打在了演示产品上。
`tests/unit/service/test_financial_nl2sql_golden_cases.py` 里 30 条黄金用例**全是 6 位代码**
(159511/588890/159948),因此从未暴露。
## 修法
`app/service/financial_nl2sql_service.py`:
- 产品代码模式放宽为 **5–6 位**(`(?<!\d)\d{5,6}(?!\d)`);
- 5 位数字更容易与"金额/数量/天数"撞车(6 位不会),因此加单位守卫:
紧跟 `元/万元/万/亿/份/股/手/天/日/周/个月/年/次/笔` 的一律**不当**产品代码
(例:「申购金额50000元」里的 50000);
- 抽成 `_product_codes()` 便于单测。
## 验证
- `query("查询15911的净值")` → SQL 含 `p.product_code = :filter_0`、参数 `15911`、
返回行 `{"product_code": "15911", "product_name": "15911 自定义产品", "nav": "1.074596"}`;
三种问法("查询15911的净值"/"查询基金代码 15911 最近 30 天的净值"/"15911 的最新净值是多少")全部命中 15911;
- 新增 5 条单测:5 位代码成为过滤条件、6 位仍正常、金额不当代码、
年份/天数不被误认、多代码并存;
- `pytest tests/unit tests/contract` → **1458 passed, 2 skipped, 0 failed**;
- `ruff` 干净。
## 说明(未改,供你决定)
问「最新净值」时返回的是该产品的 50 行净值明细(SQL 无 `ORDER BY`),
不是"最新的那一条"。要的话我可以补成按日期倒序、单点取值。
|
2026-09-14 22:32:26 +08:00 |
|
lzf_0626
|
5b97475950
|
场外前端:单据核对区显示不出字段 —— 邮件详情的单据只是摘要,须与识别接口按 task_id 合并
## 现象
"单据核对与运营动作"里的单据信息全是 `--`:
```
20260914-001-A01
subscription · -- · --
申请日期 -- 申购金额 / 赎回份额 -- / --
机构 -- 基金代码 --
```
而 OCR 识别字段里这些值都**有**(基金代码 15911 / 申请日期 2026-09-11 /
申购金额 5000.00 / 机构 星澜财富服务中心)。所以不是"没保存",是**渲染时取错了数据源**。
## 根因:同一个单据,两个接口给的不是同一份字段
| 接口 | `attachments[].documents[]` 的字段 |
|---|---|
| `GET /mails/{id}`(`_mail_detail`) | **只有摘要 4 个键**:`task_id` / `document_type` / `status` / `operator_decision` |
| `GET /mails/{id}/recognition-fields`(`_recognition_payload`) | **全部标准化字段**:`fund_code` / `fund_name` / `application_no` / `application_date` / `agency` / `subscription_amount_yuan` … |
`renderDocuments()` 渲染单据 kv 用的是 `state.documents`,而 `loadMail()` 只从
**邮件详情**那一份构建它 ⇒ 标准化字段根本不在对象里,只能显示 `--`。
前端其实**已经有** `syncDocumentsFromRecognition()` 想做这件事,但 `loadMail()` 里
是「先 `syncDocumentsFromRecognition(...)`、紧接着又用邮件详情重建 `state.documents`」
—— 合并结果被后一行覆盖掉了(这也是保存识别字段后那次同步没生效的原因)。
## 修法
1. `loadMail()`:构建完 `state.documents`(邮件侧摘要)之后**再调用一次**
`syncDocumentsFromRecognition(state.recognition)`,把识别侧的标准字段合并进来;
2. `syncDocumentsFromRecognition()` 由"整段替换"改为**逐条按 `task_id` 合并**:
- 两侧都有 → 识别侧字段优先,摘要里独有的键(`status` / `operator_decision`)保留;
- 只有摘要侧 → 原样保留(不因为识别接口没提到就丢单据);
- 只有识别侧 → 也补进来(例如刚生成、摘要还没刷新);
- 识别接口无 attachments(请求失败等)→ 早退,**不清空**已有单据。
## 验证
`node --check offsite.js` 通过;并用 Node **从源文件抽出该函数**(不另抄一份逻辑)灌入
线上两个接口的真实载荷形态,6+4+3+1 项断言全部通过:
- 摘要 + 识别侧全字段 → `fund_code=15911` / `application_date=2026-09-11` /
`agency=星澜财富服务中心` / `subscription_amount_yuan=5000.0000`,且
`status=planned`、`operator_decision=未处理` 未被覆盖,`attachment_id` 已补上;
- 识别接口返回空 → 邮件侧摘要仍在;
- 邮件侧独有单据保留、识别侧独有单据出现。
前端相关测试:`tests/unit/api/test_portal_frontend.py` → 45 passed, 1 failed
(仍是组员在改的投顾页,与本次无关);`pytest tests/unit/api -k "offsite or operations"`
→ 1 passed。
## 说明:为什么没改后端
也可以让 `_mail_detail` 直接带上标准化字段,但那会改动已被文档化的接口载荷形状;
而前端本来就有合并函数、意图明确(`save-ocr` 路径早就在调用它),
因此按"恢复原有意图 + 按 task_id 正确合并"来修,零接口契约风险。
|
2026-09-14 22:23:49 +08:00 |
|
lzf_0626
|
ae7a89f33d
|
投顾工作台:修推荐结果渲染(幂等响应形状归一)+ 对齐门户版式规范
投顾页「生成推荐方案」拿不到产品的根因:该端点标了 idempotent,浏览器必带
Idempotency-Key,而后端此时返回的是 {data:{content_id, status, plan:{...}}, meta}
—— 真正的文档嵌在 data.plan 里。此前前端只处理了不带键时的裸文档形状。
- api-client:ADVISOR_RECOMMEND 撤掉 raw(带幂等键时确为信封,需正常解包);
ADVISOR_ALLOCATION / ADVISOR_ANALYSIS 保留 raw(不标幂等,返回裸文档)
- actions-module:新增 normalizeRecommend(),把「信封 + plan 嵌套」与「裸文档」
两种响应归一成一种形状;结果卡片显示方案编号与待审核状态
- 投顾页:hero 大图 + 4 张概览指标卡 + 左栏/主区两栏构图
- 投顾页:页头常显「退出登录」按钮;新增风评预警数、快捷问句按钮
- 投顾页:动作名统一为「生成推荐方案」(对齐软件需求文档 5.4 ⑦)
- 管理台「待审投顾内容」空态文案同步改名
- 前端模块相对 import 加 ?v= 版本号:改文件内容而 URL 不变会被浏览器缓存挡住
|
2026-09-14 22:17:39 +08:00 |
|
lzf_0626
|
1d6c32f6b3
|
场外核对:桥接"单据账户标识 → 平台交易账号";保存识别字段后自动重跑核对;运营角色补推介材料权限
## 一、根因:核对查不到不是"缺数据",是**账户口径没桥接**
实测(客户 10002 的单据):
```
生成的 SQL: JOIN fin_holding h ... WHERE h.trade_account = '10002' ← 单据上的账户标识
结果: 0 行 → 「申请前持有份额」= null → 规则判「无法判断」→ 单据状态 query_failed
```
而库里 `fin_holding.trade_account` 存的是**平台交易账号** `FSA{客户号:06d}`
(与 `fin_sim_account.account_no` 同值,见 `tools/seed_sim_account_demo.py:226`),
客户 10002 **一直持有 15911 共 1000 份**。所以页面上"单据核对与运营动作"是空的,
看起来像缺数据,实为**单据印的账户标识与平台账号是两个口径,中间少了一步换算**。
修法:新增 `OffsiteFundService._platform_account()`,在**单据字段落库的两个入口**
(`_create_document` 新建、`_apply_recognition_fields` 保存/重试)做换算,规则:
1. 已是库里的 `account_no` → 原样;
2. 纯数字且命中 `customer_id` → 用该客户的 `account_no`;
3. 其余原样返回并留 warning —— **失败关闭,不猜不造**(不凭空生成 `FSA099999`)。
一处修好,后端查询、NL2SQL 页面的自然语言、页面展示三处口径一致。
附件上的 OCR **原始值不受影响**(`extracted_fields` 仍是单据上印的 `10002`)。
端到端证据(同一张单据,只走服务层):
| 阶段 | document.account_identifier | 规则数据 | 单据状态 |
|---|---|---|---|
| 修复前 | `10002` | `{}` → 无法判断 | `query_failed` |
| 保存识别字段后 | **`FSA010002`** | — | `query_failed` |
| 触发核对后 | `FSA010002` | **申请前持有份额 1000.0000** → **正常** | **planned** |
## 二、前端:保存识别字段后自动重跑核对
`employee-operations/offsite/offsite.js`:原先 `save-ocr` 只保存 + 重载页面,
**不触发核对**,于是运营改完字段点保存,那两个区块要么停留在上一次核对的状态、
要么整块是空的,必须再手动点一次"重新核对并判定规则"——看起来像"保存没生效"。
现在:保存 == 运营已人工确认该单据内容,因此保存成功后**自动对该附件关联的单据**
执行「触发 NL2SQL → 拉取返回字段 → 重新判定规则」,并在提示里区分"已重新核对"
与"核对未全部成功"。
顺带把这段逻辑抽成 `recalculateDocument(taskId)`,与面板上的"重新核对并判定规则"
按钮**走同一条路径** —— 两处各写一份正是"保存后不刷新"这类不一致的来源。
## 三、运营(operator)角色权限
`tools/grant_operator_role.py` 的授权清单补齐:`promotion:write/read/review/deliver`
(`promotion_material_service.py` 的八处 `_require` 恰好只用这四个码,缺任一都会 403,
例如只给 write 会在查看详情 read 那一步被拒)+ `agent:run`。
已实际执行并**从身份侧验证**(`IdentityRepository.load_context`):
9005 / 9006 现在各 7 项权限,四个推介材料码齐全。
- NL2SQL 全库与它相关的权限码**只有 `financial:nl2sql:read`**(`offsite:nl2sql` 在
`sys_permission` 里并不存在,是 `offsite_fund_service` 里 any-of 校验的死值),
该码运营早已有,本次无需新增。
- 可持续性已核实:`sys_role_permission` / `sys_user_role` **没有外键**,
重跑 `seed_test_rbac.py`(DELETE 重建 9001-9099 号段权限)**不会**删掉运营的绑定;
且本工具按**权限码查 id**、不写死 id,天然抗号段变动。
## 四、10001 / 10002 的 15911 持仓
复核结论:**各 1000 份,且三处口径一致**(`fin_holding.market_value` = 数量 × 最新净值、
净值历史 120 条、`fin_product.current_nav` 与净值最新一条一致、账户可用资金正常)。
`fin_holding` 的唯一键是 `(customer_id, product_id)`,所以**不能**再插一行
`trade_account='10002'` 的"同一个持仓"—— 那会把持仓重复计数,是错的。
需要改数量就用 `python tools/seed_custom_holdings.py --quantity N`(默认就是这两个客户 + 15911)。
## 五、验证与回归
- 新增 `tests/unit/service/test_offsite_account_bridge.py`(5 条:账号原样 / 客户号换算 /
认不出原样返回 / 不凭空造账号 / 空值不查库);
- `pytest tests/unit/service -k offsite` → 34 passed;
- 场外集成测试 4 个文件 → 27 passed;
- `ruff` 干净;`mypy app` 仍只有组员新代码里那 3 个既有错(与本次无关);
- 前端 `node --check offsite.js` 通过。
|
2026-09-14 22:16:29 +08:00 |
|
lzf_0626
|
292acd2e2c
|
新增「补客户归属」工具,并订正一处因迁移而过期的注释
## 新增 tools/assign_customer_scope.py
`sys_customer_assignment` 是**全平台**的"谁负责哪个客户"口径,不是记忆私有:
记忆召回(app/core/memory_scope.py)、`memory:read:customer` /
`investment-goal:*:customer` 的 `own_customers` 授权判定、投顾"已发布方案"的可见客户
都读它。所以"给某人补归属"是一句**授权动作**,不该用一次性 SQL 随手写进库。
本工具把它做成幂等、可复跑、默认只预览(--apply 才写),并**当场用真实身份链路回验**
(IdentityRepository.load_context → customer_memory_scope),还会在目标账号缺少
`memory:read:customer` 时明确提示"归属关系不等于授权"。
内置两个已踩过的坑:
1. `assigned_at` 不能写"当前时间" —— MySQL DATETIME(0) 四舍五入到秒可能落进未来,
于是 `assigned_at <= now` 判定"尚未生效",归属拿不到**且不报错**;统一往前留 5 秒;
2. `employee_role` 是 NOT NULL 且参与唯一键 `(customer_id, employee_role)`,
故从 RBAC 反查员工真实角色码,不靠手填。
`id` 在 9901+ 段分配(避开业务号段)。另外修掉一个写入期 bug:校验查询已经开了隐式事务,
再 `async with session.begin()` 会抛 `InvalidRequestError: A transaction is already begun`。
用法:
python tools/assign_customer_scope.py # 预览:9002(风控) ← 9001
python tools/assign_customer_scope.py --apply
python tools/assign_customer_scope.py --show # 只读:每个员工的实际可见范围
## 订正 tools/seed_advisor_demo.py 的过期注释
该文件原写"`sys_customer_assignment.id` 没有自增(EXTRA 为空),必须自己给" ——
这条**已被 20260914_baseline_auto_increment 迁移改变**(实测 EXTRA 现为 auto_increment)。
注释改为记录现状,并说明仍然显式给 id 的理由(保持号段约定 + 让种子可复现,
自增会让同一份种子在不同环境落到不同 id)。
## 本轮实际执行
- 补入一行归属:员工 9002(risk_operator)→ 客户 9001(id=9902,幂等复跑正确跳过);
- 回验:风控 9002 的记忆可读范围从 `()` 变为 `(9001,)`,召回从 0 条变为 2 条;
投顾 9020 仍 2 条;
- 该动作**只影响记忆召回**:9002 其余权限都是 `data_scope='all'`,
`scope_from_context()` 见 all 直接放行、不看 customer_ids。
|
2026-09-14 21:46:29 +08:00 |
|
lzf_0626
|
c4f4c33fd6
|
记忆召回补一道能力码闸门:归属关系不等于授权
上一版把员工召回改成"按 sys_customer_assignment 归属"时,只看了**归属关系**,
没校验**能力码** —— 这留下一个我自己带出来的口子:
- 平台读他人画像/记忆的正式路径(customer_profile_service、public_platform_service)
校验的是 `memory:read:customer`(sys_permission id=9010,data_scope='own_customers')
+ `customer_ids`;
- 而召回是**另一条**把客户记忆送进模型上下文的路径,只按归属放行,等于让
"有归属行但没有该权限"的账号(例如 operator:只有 financial:nl2sql:read
+ offsite:write)凭空获得读他人客户记忆的能力 —— 它上一版之前是读不到的。
现改为**两个条件都满足才放行**(缺一即失败关闭,并在日志里分开点名"缺能力码"
与"归属未维护",避免运维在错误的表上找问题):
① `memory:read:customer` 在 context.permissions 里;
② 客户落在 context.customer_ids(sys_customer_assignment 生效行)内。
演示账号不受影响:9002(risk_operator)、9020(advisor)、9003(admin) 三个角色都持有该权限码,
真实身份链路复验 9020 仍是"修复前 0 条 → 修复后 2 条"。
测试:tests/unit/core/test_memory_scope.py +3 条(缺能力码读不到/能力码与归属是"与"关系/
现有用例补上能力码),tests/unit/service/test_agent_governance.py 同步。
`pytest tests/unit tests/contract` → 1448 passed, 2 skipped, 1 failed
(唯一失败仍是组员正在改的投顾页面)。
|
2026-09-14 21:40:05 +08:00 |
|
lzf_0626
|
89b15f32cb
|
忽略 .env 自动备份:它含真实密钥却不在 .gitignore 里,git add -A 会把它带进版本库
|
2026-09-14 21:33:48 +08:00 |
|
lzf_0626
|
9eebf9627f
|
记忆召回:按 sys_customer_assignment 归属定范围,不再把员工号当客户号
## 修的是什么
`governance.recall()` 把 `int(context.user_id)` 当客户号用。后果有两个,
方向相反但都致命:
1. **员工身份(风控/投顾/运营/管理员/system)恒空** —— 员工不是客户,
那是个不存在的客户号;日志只说 "empty",看不出是"设计如此"还是"记忆坏了"。
2. **越权陷阱** —— 员工号与客户号同号段(演示数据里客户 9001-9020、
员工 9002/9020 并存)。`int(user_id)` 一旦与真实客户号重合,就会把
**陌生客户的长期记忆读进来并注入提示词**,且不报错、看起来正常。
同一个问题在代码里还有另外两处**各自判断**、口径互不一致:
`BaseAgent.recall_memory()` 要求"每条记忆 customer_id == context.user_id"
(否则抛"越过客户范围"),`review_output()` 的引用校验只认同一条件。
## 怎么修的
新增 `app/core/memory_scope.py` 作为**唯一判定口径**,三处共用:
- 客户身份(customer / authenticated_user):**只读自己**,分配表里有别行也不读别人;
- 员工身份:**只读 `sys_customer_assignment` 分配给自己**的客户
(`context.customer_ids`,由 `IdentityRepository.load_context()` 读入);
归属未维护 ⇒ **失败关闭**,并在日志里点名"归属未维护",与"库里确实没有记忆"区分开;
- 访客:无(上游已拦)。
细节约定:
- 归属客户按客户号**升序**召回、单次上限 `MAX_RECALL_CUSTOMERS=10`
—— 升序是为了确定性(同一身份每次取同一批,不随数据库返回顺序漂移),
上限是为了别把成百上千条他人记忆塞进一个提示词;
- 跨客户合并后按置信度降序、`(客户号, uuid)` 兜底排序,最多 10 条;
- 员工同时持有多个归属客户的记忆时,`memory_context_text()` **逐行标注客户号**
并把提示词改成"多个客户的长期事实" —— 否则模型会把 A 客户的事实当成 B 客户的。
单一客户时保持原格式(客户身份的提示词与改动前逐字相同);
- 引用校验与范围守卫都改用同一口径:员工引用**归属客户**的记忆不再被判成伪造引用;
引用**非归属客户**的记忆即便被塞进 memories 也照样拦下。
## 验证(真实身份链路 + 生产召回装配)
`IdentityRepository.load_context` → `PlatformGovernance.recall`(含 Milvus 语义通道):
- 身份展开:roles=('advisor',)、customer_ids=('9001',)(sys_customer_assignment
里唯一那行 9020→9001)、可读范围 (9001,);
- **修复前** `recall(int(user_id)=9020)` → **0 条**;
- **修复后** `recall(按归属)` → **2 条**(客户9001:进取型 / 约三年);
- 边界:客户身份 9001 可读范围 (9001,);无归属员工 9002 = ()(失败关闭,
且**没有**把 9002 当客户号);未分配时的 9020 = ()。
测试:`pytest tests/unit tests/contract` → **1445 passed, 2 skipped, 1 failed**
(1432 + 新增 13;唯一失败是组员正在改的投顾页面,与记忆链路无关)。
新增用例:`tests/unit/core/test_memory_scope.py`(8 条,含"员工号不得被当成客户号"
的反例断言)、`tests/unit/service/test_agent_governance.py`(+5 条:归属召回/
无归属失败关闭且不碰数据库/客户只读自己/引用校验/越界守卫)。
## 遗留(已在 AGENTS.md 与文档里写明,未自行实施)
风控扫描这条线**仍读不到记忆**:它是唯一消费召回内容的地方
(`risk_agent.py:224`),而扫描上下文是 user_id="0"/roles=("system",) 且无归属行。
根因是**顺序问题**:召回发生在 handle() 之前,上下文里没有"本次目标客户"这个概念。
出路有两条:① 给风控专员补 sys_customer_assignment 行(运维动作,立即可用);
② 在 RequestContext 加显式的 target_customer_id 并校验它落在归属集合内
(推荐,但属跨线协议改动,等确认)。
文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md 新增 §五(含 §5.4 遗留说明)、
AGENTS.md 新增"记忆可读范围只有一个判定口径"易错点,并按 2026-09-14 复测更新测试基线。
|
2026-09-14 21:33:16 +08:00 |
|
lzf_0626
|
0c642133d4
|
修复长期记忆向量链路:投影入队 + 集合名三侧同源 + 召回按客户过滤
语义召回"恒空"的根因分四层,本提交修掉投递层与读取层(另两层——重试计数
门禁、episode 不投 rebuild 事件——已在前两个提交修复)。
R2 投递层:dispatch_profile_rebuild 只做图投影,没有任何 Milvus 投递,
长期记忆的向量从未被写入过(实测客户 9001 在 memory_sync_outbox 里 0 行)。
新增 _enqueue_memory_vector_projection(),在画像重建后投
MemorySyncOutbox(target_store="milvus")。走事件而不是同步写,是为了拿
Outbox 的重试/退避/死信,且不把 embedding 的网络等待拖进事务。
⚠️ payload 必须带 version(正整数):适配器 _coerce_profile_version 缺它
直接抛 ValueError(实测踩到,事件立刻 failed)。
R4 读取层:写的集合与读的集合不是同一个 ——
写(MilvusProfileProjection)用 "user_long_term_memory_v1",
读(bootstrap.get_vector_memory_adapter)与删(projection_cleanup_service)
却用 settings.milvus_collection = "jr_memory",而该集合从未被创建。
⇒ 召回:MilvusClient 构造不校验集合存在,适配器"构造成功"但每次 search
抛异常被 VectorMemoryAdapter 吞成 degraded → 召回恒
degraded_reasons=('milvus_unavailable',)、向量命中恒 0 条;
⇒ 清理:jr_memory 不在集合列表 → 走 vector_collection_absent 分支 →
报清理成功但一个向量都没删,陈旧向量永久留存。
修法:PROFILE_COLLECTION 成为唯一常量,读/删两侧直接引用它;
并删除 Settings.milvus_collection 配置项、清掉 .env.example 的
MILVUS_COLLECTION —— 写侧从来没读过它,一个只在契约一侧生效的配置项
比没有配置项更危险(Settings 的 extra="ignore" 会让其他环境残留的该
变量被安全忽略)。
顺带:
- 语义检索把客户过滤下推到 Milvus(filter="customer_id == N")。此前不带
过滤,别家客户的命中会白占 limit 名额,稀释本客户的召回条数。
- upsert 在 sources 为空时先返回,不再无条件 load_collection ——
"本来就没有可写内容"不该被记成投递失败(10001/10002 那两条事件即如此
重试 5 次进死信)。
回归守卫:tests/unit/infrastructure/test_memory_vector_collection_consistency.py
断言读侧与删侧用的都是 PROFILE_COLLECTION,且被删掉的配置项不得回归。
这个缺陷能活下来,正是因为两侧单测全绿而接缝无人守。
验证(走生产装配、进程内调用,未重启你正在跑的 API 窗口):
读侧集合打印 user_long_term_memory_v1(修前为 jr_memory);
召回 degraded=False / reasons=() / sources 含 milvus,且排序随 query 语义
变化(投资期限→horizon 0.288 > risk_level 0.201;风险偏好→risk_level 0.287);
query="进取型" 双通道合并且 vector_score=0.9987;query=None 走 mysql 全量。
pytest tests/unit tests/contract → 1432 passed, 2 skipped, 1 failed
(唯一失败是同事正在改的投顾页面,与记忆链路无关)。
文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md(新增,四层根因+证据)、
docs/37-记忆投影链路实现说明.md(补集合名三侧契约)。
|
2026-09-14 21:26:03 +08:00 |
|
lzf_0626
|
3a1065ca1e
|
修复片段链路:重复聚合拖垮重试预算 + 从不投画像重建事件
## 结论先说:真正的根因和最初两个假设都不是同一个
你原来的判断是「第二条召回恒空、第三条传导断链」,并猜第三条是
「`record_evidence` 返回 False 时该不该补写重建事件」。查完库发现:
1. **`record_evidence` 返回 False 时数据零净变化**(幂等命中直接 return;
并发冲突把刚加的计数减回来),所以那个判断点解释不了画像停更;
2. **`memory_extraction_worker` 是有写重建事件的**(`if recorded:`),
233 条 `profile.rebuild_requested` 全 published;
3. 真正断在两处,都在 **`episode_worker`** 这条片段链路上。
## 根因一:`_touch_retry` 把「重复聚合」记成「抽取失败」(P0,已修)
`episode_worker._persist` 在 `content_hash` 命中(同一段会话被重复聚合)时调
`_touch_retry` 做 `retry_count += 1`。但 `retry_count` 的语义是**抽取失败次数**
(由 `_mark_failed` 累加),而"分片逻辑每轮重新看到同一段会话"根本不是失败。
后果是实测出来的:
episodes 待提取片段:retry_count=1405(40 条,全是客户 9001)
consume_pending 逐条件筛:
仅 status in (待提取,失败) -> 40
+ retry_count < max_retry(3) -> 0 ← 一个都不剩
+ promoted_to_ltm IS FALSE -> 40
这 40 条片段**永远不可能被选中** ⇒ 新记忆进不来 ⇒ 画像停在旧值。
(另外还观察到一次运行里它从 1405 涨到 1407 —— 常驻 Worker 每轮都在继续推高。)
**修法**:重复聚合不再触碰 `retry_count`,连 `flush` 都不做(内容没变就只是看到)。
## 根因二:片段链路从不投「画像重建」事件(已修)
`memory_extraction_worker` 写完证据会投 `profile.rebuild_requested`;
而 `episode_worker` 写完证据直接 `_mark_done` 就结束了 —— **完全没有这一步**。
所以即使片段被成功抽取,画像也不会重建。
**修法**:`episode_worker` 也捕获 `recorded` 并在为真时投同样的事件
(`trigger="episode_extraction"`)。只在 `recorded=True` 时投:幂等命中时证据与计数
都没有净变化,投一次是白跑。
## 数据修复
代码修好不会让已写进库的脏计数自己恢复 —— 那 40 条片段仍然超预算。
用 `tools` 级别的临时脚本把**确实被污染的行**重置(条件收紧为三者交集):
extraction_status IN ('待提取','失败') AND promoted_to_ltm = 0 AND retry_count >= 3
-> 重置 40 行;之后可被 consume_pending 选中的片段从 0 恢复到 40
## 实测
- **重试预算修复**:`consume_episodes` 从"领不到任何片段"变为能领到;
分两批消费完 40 条(全部 `no_fact` —— 那些片段摘要里确实没有用户陈述,
属正常结果),待提取 40 → 0
- **重建事件修复**:那 40 条全是 `no_fact`,走不到 `if recorded:`,所以**
实测不到**。为不留下"改了但没验证",另造了一条含用户陈述的片段:
consume_episodes -> extracted=[191]
9001 的 rebuild 事件 3 -> 4(增量 1)
memory_evidence 8 -> 9
另外注意到基线在我造数据前已经由 1 涨到 3 —— 说明常驻 Worker 也在这期间
投过事件,修复在真实链路里同样生效。
- `pytest tests/unit tests/contract` -> 1428 passed / 2 skipped / 1 failed
(剩下的 1 个是投顾工作台页面被替换所致,与本次无关)
## 测试
两个既有用例断言的正是被修掉的旧行为,已更新,并把第二个改造成**防回归守卫**
`test_repeated_aggregation_never_bumps_retry_count` —— 它守着
"`retry_count` 被重复聚合推高到 `max_retry` 之上会导致片段永久滞留"这个 P0。
`tests/unit/worker/test_episode_worker.py` 16 passed。
## 未处理
「召回恒空」(员工身份下 `recall` 取的是自己作为客户的记忆)**本次没动**。
它需要给 `AgentRequest` 加 `target_customer_id` 并配套越权校验,属接口契约变更;
排查报告给的建议是保持现状、员工侧走 `query_customer_profile` 工具。
要按"支持目标客户维度"做,请确认,我再单独一提交。
|
2026-09-14 21:09:03 +08:00 |
|
lzf_0626
|
2c5fe9cdef
|
把风控扫描演示数据接进一键准备(第 12 步),并让编排支持给步骤传参
## 改动(`tools/seed_demo_data.py`)
1. **`STEPS` 增加第四个字段「额外命令行参数」**。这不是可有可无的装饰:
`seed_risk_demo_data.py` **不带参数时只打印计划、不连数据库**(默认是干跑模式),
不给它传 `--apply` 的话,接进编排只会打印一句"确认无误后使用 --apply 正式写入"
然后退出码 0 —— **看起来像成功,实际什么都没准备**。这类"假成功"最难查,
所以参数必须能传得进去。
2. **新增第 12 步「风控扫描演示数据」**,带 `--apply --dry-run-scan`:先写入上游
业务数据,再跑一次规则干跑并把结果回滚。净效果是「写入上游数据 + 顺带验证
5 个场景都真的命中预期规则」。
3. `run_step` / `--list` / 主循环相应支持第四项;执行时把参数**打印出来**,
这样手跑时一眼能看出这一步到底会不会落库。
## 为什么放第 12 步,而不是插在「风控预警样本」旁边
避免**改动后续步骤的编号**。文档与肌肉记忆里已经有 `--only 4,10`、`--from 4`
这类用法,插队会让它们全部错位。而且它本身是独立号段(客户 12001-12005、
产品 159991-159995),放最后不影响任何既有步骤。
## 第 6 步与第 12 步不是重复(docstring 已写明)
- 第 6 步 `seed_risk_alert_demo_data.py`:**直接摆好**三条现成预警,
保证风控页面一打开就有东西可看;
- 第 12 步 `seed_risk_demo_data.py`:**只造上游**,刻意不写 `fin_risk_alert`,
让风控扫描器按规则生成 —— 演示覆盖的是**规则 → 证据 → 通知**完整链路,
而不是"预先摆好的假预警"。
## 实测
- `--list` -> **12 步**,第 12 步末尾显示 `[--apply --dry-run-scan]`
- `--only 12 --dry-run` -> 只打印、不执行(并且打印出了参数)
- `--only 12` -> **真跑通**,退出码 0:
[12/12] 风控扫描演示数据 用时 3.8s 退出码 0
[命中] 客户 12001:RW-003 (高)
[命中] 客户 12002:RW-007 (高)
[命中] 客户 12003:RW-007 (中)
[命中] 客户 12004:RW-012 (高)
[命中] 客户 12005:RW-015、RW-018 (低)
- `ruff check tools/seed_demo_data.py` -> All checks passed
|
2026-09-14 20:50:55 +08:00 |
|
lzf_0626
|
e00c4ad51d
|
风控演示数据脚本:造上游业务数据,让风控扫描器自己生成预警
## 来源
组员通过腾讯会议发来的 `seed_risk_demo_data.py`,按约定放入 `tools/`。
**脚本内容是组员写的**,我只做了两件事:放进仓库、把 docstring 里的用法示例
统一成实际文件名(组员原文写的是 `seed_risk_scan_demo_data.py`,与文件名不符,
已改成 `tools/seed_risk_demo_data.py`),**未改动任何逻辑**。
## 它做什么
固定号段:客户 **12001–12005**、产品 **159991–159995**(独立号段,不覆盖人工数据
与其他演示数据)。写入客户 / 画像 / 产品 / 账户 / 持仓 / 交易 / 资金流水 / 登录 /
工单,**刻意不写 `fin_risk_alert` 与 `fin_risk_notification`** —— 预警必须由风控
扫描器按规则生成,这样演示才能真实覆盖规则、证据、通知三条链路。
五个场景各自瞄准一条规则:
| 客户 | 场景 | 预期规则 |
|---|---|---|
| 12001 | 大额快进快出(3 天内入金 80 万后赎回 75 万,比例 93.75%) | RW-003 |
| 12002 | 高风险适当性错配(缺风险揭示 + 二次确认) | RW-007 |
| 12003 | 中风险适当性错配(等级差 1 且缺风险揭示) | RW-007 |
| 12004 | 72 岁客户赎回 75 万,达历史均值 3 倍且用非常用设备 | RW-012 |
| 12005 | 凌晨 2:15 小额自动定投(关联有效工单,预期合并预警) | RW-015、RW-018 |
模式:`--check`(只读核对)、默认 `--dry-run`(只打印计划、**不连数据库**)、
`--apply`(写入)、`--dry-run-scan`(跑规则引擎后回滚)。
## 我跑了什么、核到什么
按约定的命令 `--apply --dry-run-scan`:
[命中] 客户 12001:RW-003 (高)
[命中] 客户 12002:RW-007 (高)
[命中] 客户 12003:RW-007 (中)
[命中] 客户 12004:RW-012 (高)
[命中] 客户 12005:RW-015、RW-018 (低)
规则干跑完成,共命中 5 条演示预警,已回滚,不会写库。
**"已回滚"这句话我没有只信它的打印**,而是直接查了库:
- 上游数据确实写入:客户 5 / 产品 5 / 账户 5 / 持仓 5 / 委托 6 / 成交 6 /
资金流水 1 / 登录 1 / 工单 3,演示产品行情 5 行(`source=custom_seed`);
- **`fin_risk_alert` = 0、`fin_risk_notification` = 0** —— 回滚确实生效。
跑之前还先确认了它承诺回滚是**成立**的:`RiskRuleEngine.refresh_alerts()`
(`risk_scan_service.py:101`)到 565 行之间**没有 `commit()`**,全文件仅有的
`commit/rollback` 在 `:566-568`,属于另一个方法(正式扫描 `scan()`)。
否则 `dry_run_scan` 的 `session.rollback()` 根本回滚不掉,那就是"假干跑"。
## 复用与兼容
脚本 import 了 `tools/seed_custom_holdings.py` 的 `ensure_user` / `ensure_account` /
`ensure_holding` / `ensure_product` / `ensure_nav_history` / `ensure_market_price`,
我逐个核对了签名,**全部匹配**(组员看过那个脚本)。
它自己的 `_next_id` 用 `SELECT MAX(id)+1` 发号:单线程脚本里没问题,且
`sys_user` 与 `fin_product` 本来就没有 AUTO_INCREMENT(被外键引用,迁移
`20260914_baseline_auto_increment` 已说明为何排除)。**这不是缺陷**,不改。
## 实测
- `ruff check tools/seed_risk_demo_data.py` -> All checks passed
- `--apply --dry-run-scan` -> 5/5 场景命中预期规则,退出码 0
- `pytest tests/unit tests/contract` -> **1428 passed, 2 skipped, 1 failed**
(那个失败是投顾工作台页面被替换所致,与本次无关)
|
2026-09-14 20:48:18 +08:00 |
|
lzf_0626
|
e85e958389
|
排障表补一条:所有页面一起卡 → Milvus 抖动 + 同步调用阻塞事件循环
## 为什么补这一条
把审查报告的 P1–P3 共 45 项按 8 个演示场景筛完,**"会砸演示"的是 0 条**
(P0 四条已全修)。剩下的分两类:37 项演示路径根本走不到,8 项是"边缘"
(平时无感、特定条件才暴露)。
这 8 项里只有 **P1-2** 属于"一处慢、全平台卡":检索层用的是**同步** Milvus 客户端,
单次 `search` 卡 3 秒会让整个 API 进程停摆 3 秒,连 Worker 心跳一起停。
它同时也是**最容易被误判**的一个 —— 现象是"客服、风控、看板一起变慢",
而排障表原有的 6 条全是**单点**故障(503 行情 / Worker 没起 / 浏览器缓存 /
知识库没同步 / MySQL 没起 / 风控样本用完),**没有一条对得上"全场同时变慢"**。
没有这一条,现场会往"服务挂了"的方向查,而它其实是外部依赖抖动、不需要改代码、
也不需要重启。
所以这条的价值不在改代码,而在**把一个边缘隐患变成一眼能认出的现象**:
演示版本选择不修 P1-2(Milvus 正常时完全无感),代价就是必须知道它长什么样。
## 依据(都是会话内实际核过的)
- 客服链路确认:`app/service/knowledge_tool.py:29` 的 `search_knowledge` 工具
→ `get_knowledge_search_service().search(...)`;
而 `knowledge_search_service.py:207-213 / 224 / 229` 调的是**同步** `MilvusClient`
(`app/infrastructure/vector_memory.py` 的 `def search(...)`,非 `async def`)。
- 对照:项目在其它 10+ 处已用 `asyncio.to_thread`(如
`app/infrastructure/fund_market_adapter.py:273/382`),说明异步规范已建立,
这 4 处是遗漏 —— 但**不是演示阻塞项**,本次不改。
- "风控日报会不会砸"(P1-6,报告标了【确定】)也核过了:`_distribution` 的
`counts` 键是字段**值**,两个调用点用的 `risk_level`(中文高/中/低)与
`alert_type` 都是字符串,`str(key) == key` 恒成立,**触发不了** —— 场景 6 安全。
## 改动范围
只改 `docs/44-演示流程.md` 的 §4 排障表,**新增一行**,未动其它内容。
|
2026-09-14 20:41:34 +08:00 |
|
lzf_0626
|
36c7a9d8d2
|
文档:审查报告入库 + 全量校对补注
## 新入库(`docs/演示用/`)
- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`
## 全量校对(32 个既有文档 + `AGENTS.md`)
跨 39 个文件、**1125 insertions / 148 deletions**。
⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":
- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
(两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
(那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
**重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
而不是查密码。
这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。
**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
|
2026-09-14 20:36:00 +08:00 |
|
lzf_0626
|
c0e5c80929
|
记忆系统:recall 结果接入 prompt + 可观测性(既有改动,代为提交)
## 说明
**这批改动不是本次会话写的**,它们在会话开始前就已在工作区里、一直未提交。
我做的是**验证**它确实成立,然后按你的指示代为提交。
出处:`docs/演示用/记忆系统排查报告-2026-09-14.md` 与同目录
`记忆系统修复文档-2026-09-14.md`(两份都在本次一并入库)。
排查报告的结论是「记忆系统没有坏」——库里有真实数据、170 条抽取事件全部消费成功;
真正的问题是「观测不到」+「召回结果没人消费」。
## 改动内容(按两份文档的编号)
- **F1 `RecalledMemory.content` 断头路**:`base.py` 新增 `memory_context_text()`,
`risk_agent._agent_system_prompt` 接收并注入记忆段。无记忆时返回空串,
因此 prompt 逐字不变 —— 这也是它能安全接线的理由。
- **F3 `governance.recall` 员工身份恒空**:补一条明确的语义日志。
员工身份下召回的是"该用户自身作为客户"的记忆,恒为空属预期,
但此前没有任何提示,运维看到 `count=0` 只会以为记忆坏了。
- **F4 可观测性**:`GET /api/v1/users/me/memories`(`stored` / `recalled` /
`downstream` / `pending_events` 四段)+ 抽取与召回的 6 处日志 +
三个只读探针 `tools/probe_memory_state.py`、`probe_memory_detail.py`、
`probe_agent_types.py`。
**未实施**(文档明确留作待决,我也不代为决定):F2 `known` 引用校验永不触发
(需架构确认 memory 类 `source_references` 由业务填还是底座统一附加)、
F5 客服是否读写长期记忆(涉脱敏与复核,需产品+合规)。
## 我做的验证(会话内实测,非照录文档)
- 新接口 `GET /users/me/memories` 以 `cust_t` 调用 -> **HTTP 200**:
stored: total=2, by_status={'active': 2}
recalled: count=2, degraded=False
两条记忆:preference:horizon='约三年'(0.95)、preference:risk_level='稳健型'(0.98)
与排查报告 §〇 列出的那两条**完全吻合**。
- `pytest tests/unit tests/contract` 全绿(这批改动没有破坏既有测试)。
## 未验证的部分
`memory_context_text()` 接进 prompt 后的**端到端效果没有实测** —— 文档自己说明了
原因:当前 `risk` Agent 的召回恒空(员工身份不是客户),所以接线后行为不变,
要用测试替身才能验证注入。我没有为此编造证据。
|
2026-09-14 20:35:46 +08:00 |
|
lzf_0626
|
c868f01e67
|
修复 §19 端点编号检查的误报:按表格块识别,而不是"整章所有表格行"
## 问题
`test_real_document_has_no_duplicate_endpoint_ids` 失败,报 7 处"首列无法识别":
`**场外基金**`、`**场外运营**`、`**客户入驻**`、`**推广材料**`、`**访客令牌**`、
`**账户与交易**`、`段`。
查下去发现**文档没错、工具错了**:§19 里除端点总目录外,还有一张
「段 / 前缀 / 挂载来源 / 权限口径」的**说明表**(`docs/05` 行 1207 起)。它的首列是
分组名、表头是 `段` 而不是 `编号`。工具早期实现把**整章所有表格行**都当端点表扫,
于是那张说明表的 6 个分组名 + 1 个表头被报成结构异常。
**误报比漏报更伤**:这个脚本存在的意义是抓"编号被复用"(2026-09-12 真的发生过
`A034`/`A035` 各出现两次),可一旦它平时就在报噪声,真出问题时会被当成噪声忽略。
## 改法
`collect_rows` 改为**按表格块**识别:
- 表格块的第一行是表头;**只有表头首列是 `编号`** 的表格才是端点总目录;
- 其它表格整体跳过并**计数** —— 跳过几张表会打印出来,所以"忽略了什么"是可见的,
不是静默的;
- 端点表**内部**形状不对的首列**仍然报出来** —— 那才是真正的结构异常。
于是"§19 覆盖"这行现在长这样:
§19 覆盖:93 个端点编号 / 9 个号段(A×49、AD×11、C×7、K×4、M×4、O×3、P×2、R×4、T×9);跳过 1 张非端点表
跳过的表被显式写出来,覆盖范围仍然可核对 —— 这是原实现"不枚举前缀白名单、
把扫到的号段一并打印"那套自检思路的延续。
## 测试
`test_real_document_actually_scans_enough_endpoints` 因 `collect_rows` 返回值从
2 元组变 3 元组而需要同步;顺便**加了一条断言守住新行为**:
`skipped >= 1`,即"§19 的说明表确实被识别为独立表格块并跳过" —— 否则那几个分组名
会重新变成误报,而这条测试会先失败。
## 实测
- `python tools/check_docs_endpoint_ids.py` -> **exit 0**
「93 个端点编号 / 9 个号段;跳过 1 张非端点表 / 端点编号无重复」
- `pytest tests/unit tests/contract` -> **1428 passed, 2 skipped, 1 failed**
(剩下的 1 个是投顾工作台 `index.html` 被整体替换成自包含静态页所致,
属产品决策,见前述说明)
- `ruff check` -> All checks passed
|
2026-09-14 20:29:54 +08:00 |
|
lzf_0626
|
6b2e2edcda
|
P0-2:恢复基线要求的 AUTO_INCREMENT,消除手工发号的并发主键冲突
## 问题
`trade_service._next_id` 用 `SELECT MAX(id)+1` 发主键。两个事务读到同一个 MAX、
算出同一个 id,后写的那笔 `flush()` 撞 `Duplicate entry ... for key 'PRIMARY'`
-> 该客户下单直接 **500**。`submit_order` 一次要发 **3 个 id**
(订单 / 成交 / 资金流水),冲突面是单表的三倍。
## 这是"修正偏差",不是"改基线"
`docs/00-新数据库基线设计.md` 第 41 行:
| 主键 | 统一 `BIGINT UNSIGNED AUTO_INCREMENT`,业务编号另设唯一键 |
**基线本来就要求 AUTO_INCREMENT**,是生成的 DDL 漏了 —— `_next_id` 自己的
docstring 也写着"与 docs/00 设计稿存在偏差"。所以本迁移**不违反** AGENTS.md
规则 4(禁止改类型/可空性/业务含义):类型仍是 `BIGINT UNSIGNED`、仍是 `NOT NULL`、
`id` 的业务含义不变,只是补回一个列属性;已有行 id 不变,显式给 id 依然合法。
## 迁移 `20260914_baseline_auto_increment`
- **15 张表**恢复 AUTO_INCREMENT(硬编码表名 —— 迁移必须确定性,动态查
`information_schema` 会让同一份迁移在不同环境产生不同结果)。
- **有意排除 3 张**(`EXCLUDED_BECAUSE_FOREIGN_KEY`):`fin_product`(被 10 张
`advisor_product_*` 引用)、`fin_risk_assessment`、`sys_user`(被 19+ 张引用)。
MySQL 拒绝 `MODIFY` 被外键引用的列:
(1833, "Cannot change column 'id': used in a foreign key constraint ...")
改它们必须先 DROP FOREIGN KEY -> MODIFY -> 重建外键,那是另一件事(涉及 30+ 个
外键的重建与一致性验证),不该塞进这条"恢复基线属性"的迁移。且这三张表**写入
频率很低、没有任何代码用 `SELECT MAX(id)+1` 给它们发号** —— 排除它们不影响
本迁移的目标。
- `downgrade()` 可回滚(只是去掉属性、不丢数据),但注释里写明:**回滚会把 P0-2
的并发冲突带回来**。
⚠️ 迁移执行中踩到过"部分生效":MySQL DDL 非事务性,第一次跑到 `fin_product`
才报错,**前 7 张已经改完**。修正列表后重跑即收敛(对已是 AUTO_INCREMENT 的列
再 `MODIFY` 是无害的)。这一点也说明**迁移必须逐表可重入**。
## 代码
`trade_service.py` 删除 `_next_id` 方法及 4 处调用(`FundSimOrder` /
`FundTransaction` / `FundCashLedger` / `FundHolding`),改由 InnoDB 分配;
顺带清掉因此不再使用的 `Any` 与 `func` import(全仓 grep 确认它们只服务于
`_next_id`)。测试对 `_next_id` 零依赖(已 grep 确认)。
`test_advisor_migration_contract.py` 里那个"钉住末端版本"的断言按它自己的注释
要求同步更新到新 head。
## 实测
- `alembic upgrade head` -> `current = 20260914_baseline_auto_increment`,
复核状态:**15 张已生效、3 张按设计排除**
- **并发下单实测**(2 个客户 × 3 笔 = 6 笔真并发;刻意用**不同客户**,
因为 P0-3 的行锁已经把同一客户串行化了,不同客户才会真正并发进入发号路径):
成功 6 / 主键冲突 0 / 其它失败 0
=> P0-2 已解决
- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
(2 个既有失败与本次无关)
- `ruff check` -> All checks passed
|
2026-09-14 20:27:35 +08:00 |
|
lzf_0626
|
a337cca31a
|
修复 T002 下单的两个 P0:无幂等保护、读账户/持仓无行锁
## P0-1 下单没有幂等保护(实测复现过重复扣款)
`app/api/controllers/trading.py` 的 T002 此前**没有任何幂等保护** —— 全仓 7 个
controller 共 29 处声明了 `Idempotency-Key`,**唯独 trading.py 一处都没有**,
而 `docs/05` §5.2 要求写端点必须带。
**修复前实测**(同一 key 连发两次):
两个不同 order_no:SO...FD48A488 / SO...2FB182C0
委托 +2、可用现金 -910.50(2×455.25)、510300 持仓 3500 -> 3700
⇒ 重复扣款 + 重复建仓
**改法**:接 `ApiTransactionService.execute_in` —— 业务写入与幂等回执**同一个事务**
(`docs/05` §5.2),同键同 body 的第二次请求直接回放上次响应。
- `trade_service.submit_order` 新增 `commit: bool = True`:包装层调用时传
`commit=False`,由 `execute_in` 统一提交。**不做这一步就会"内层提交外层事务"**,
幂等回执与业务写入分处两个事务,回执写失败时业务已落库,重放失去意义。
- 响应经 `model_dump(mode="json")` 统一 JSON 化后再 `model_validate` 还原 ——
保证"首次"与"重放"两条路径返回**同一形状**,且对外结构不变
(金额仍是字符串化 Decimal)。
**修复后实测**(同一 key 连发两次):
两次 order_no 完全相同:SO202609141216512C7166D4
两次响应逐字段相同(真正的回放)
可用现金 -455.25(单次)、510300 持仓 +100(单次)
T003 核对:43 条委托里只多出 1 条
## P0-3 下单读账户/持仓无行锁(并发可扣穿余额)
`trade_service.py` 全文 **零 `with_for_update`**,而项目其它 15 个 service 共 38 处
用了它 —— 规范早已建立,这里是遗漏。
**改法**:`_load_account` / `_load_holding` 增加 `for_update` 开关(默认关,
只读路径不加锁、不牺牲并发),`submit_order` 以 `for_update=True` 调用。
**加锁顺序固定「账户 → 持仓」**:并发事务按同一顺序取锁才不会成环,
这一点写在两处 docstring 里,改顺序前必须先想清楚。
**实测**(真并发 2 笔,各 45520 元,合计 91040 > 余额 49103):
第 1 笔:201 成交 SO...59568811
第 2 笔:422 INSUFFICIENT_FUNDS「可用余额 3578.91 不足」
成交 1/2,最终余额 3578.91 >= 0
**关键证据**:被拒那笔读到的是 **3578.91(已扣减后)**而不是初始的 49103.46 ——
证明两个事务被行锁串行化了。无锁时两笔都会读到 49103.46 而双双通过,
余额会变成 -41936。
## 实测汇总
- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
(那 2 个是既有的:投顾页面被替换、docs/05 §19 分组行,均与本提交无关)
- `ruff check` -> All checks passed
- 两次实测的完整证据见上;两份验证脚本在 %TEMP%(未入库)
## 顺带修正一个我自己脚本的 bug
验证脚本里用 `GET /users/me/orders?limit=200` 读委托数,而 T003 的 `limit` 上限是
**100**(`Query(le=100)`)→ 422 → 读到 0 条,一度让我误判"委托没增加"。
改用 `limit=100` 后确认委托确实只 +1。
|
2026-09-14 20:19:55 +08:00 |
|
lzf_0626
|
e4cd336afa
|
修复权限拒绝变 500、访客令牌无限流,并让两处测试跟上代码
## 1. 权限不足从 500 修回 403(14 个测试失败里的 11 个)
`app/service/authorization_service.py` 的 `_deny` 构造 `InteractionAudit(...)` 时
**漏了 `created_at`**(该列 NOT NULL 且无默认值),而 `raise ForbiddenAgentError`
写在 `session.commit()` **之后** —— commit 必抛 IntegrityError
(1048: Column 'created_at' cannot be null),于是**永远走不到 raise**:
预期 403,实际 500:Internal Server Error
**⇒ 任何「权限不足」的请求都变成 500**,破坏 docs/05 §3.6 的错误码契约。
全仓 100+ 处 `InteractionAudit(...)` 都跟着 `created_at=now`,只有这一处没有
(由 2026-09-14 的 `857c106`「投顾工作台:三接口支持按客户出方案」引入)。
修两处:
- 补 `created_at`;
- **并把审计写入失败与 403 解耦**(try/except + `logger.warning(exc_info=True)`):
安全判定不该依赖审计表是否可写 —— 拒绝就是拒绝。但也绝不静默,审计缺失是合规问题。
## 2. 访客令牌端点补限流(P0-4)
`app/api/controllers/visitor_tokens.py` 此前**零认证、零限流**,可以不限量铸造
有效 JWT;每个都能调 `/api/v1/agent-runs` 触发 LLM 调用,而 agent-runs 的限流
按 `user_id` 计、访客 `sub` 每次都是新随机值 ⇒ **限流被天然绕过**。
把 `rate_limit.py` 的登录闸门抽成通用的 `_enforce_ip_rate_limit(...)`,
新增 `enforce_visitor_token_rate_limit` 挂到该端点的 router 上。
⚠️ 刻意**用独立计数器前缀**(`visitor-token` vs `login`)而不是直接复用登录闸门:
共用会让两者互相挤占配额 —— 正常访客刷几次页面就把别人挡在登录外。
阈值 30 次/分钟(比登录的 10 次宽松,因为访客进站/刷新会正常签发)。
## 3. 测试跟上代码(2 个)
- `test_product_recommendation_service.py`:monkeypatch 打在 `current_for_agent` 上,
而 `generate()` 现在走 `current_for_customer(customer_id, context)` —— 补丁不生效,
真实方法被执行并命中鉴权抛 403。改为按新签名打补丁。
- `test_authorization_service.py` 等 3 个:随第 1 项一起恢复。
## 实测
- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
(修复前 **14 failed** / 1426 passed)
- `_deny`:权限不足恢复 **403**(原为 500)
- 访客限流:连发 40 次 -> `{201: 30, 429: 10}`,首次被拒返回
`RATE_LIMITED「访客令牌签发过于频繁:每 60 秒最多 30 次」`
(修复前:连发 15 次全部 201、无限流)
- `ruff check` -> All checks passed
## 剩余 2 个失败(需产品决策,未擅自处理)
1. `test_advisor_workspace_registers_documented_operation_endpoints` ——
`employee-advisor/dashboard/index.html` 已被**整个替换**为一个自包含静态页
(`data-page-node-id` 属性、内联全部 CSS/JS、硬编码 `API="http://127.0.0.1:8000"`、
自带「离线本地引擎」),**不引用 `api-client.js` / `app-shell.js`、不调 `mountShell`**。
测试断言的是旧页面措辞("组合分析"),新页面写的是"生成推荐方案"。
是接受替换后的页面(改测试断言),还是恢复挂平台壳的版本,属产品决策。
2. `test_docs_endpoint_ids` —— `docs/05` §19 表里被插入了分组标题行
(`**场外基金**`、`**账户与交易**`),而检查工具要求首列是端点编号。
|
2026-09-14 20:14:50 +08:00 |
|
lzf_0626
|
e9539ae1be
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-14 18:24:24 +08:00 |
|
lzf_0626
|
857c106faf
|
投顾工作台:三接口支持按客户出方案 + 动作按所选客户分流
后端(向后兼容,customer_id 缺省即原行为):
- 三个请求契约新增可选 customer_id(推荐/资产配置/持仓诊断)
- AuthorizationService 新增 require_customer_scope(权限码 + 数据范围)
- 推荐/资产配置/持仓诊断服务支持指定被分析客户
- InvestmentGoalService 新增 current_for_customer
前端(employee-advisor/dashboard/index.html):
- 删除「本人」虚拟条目,客户列表改为 4 位真实客户
- 动作与自然语言入口均按所选客户带 customer_id 调真实后端
- 新增风评超期熔断闸门(FM-03,流水线停在 ② 画像)
- 统一对话入口从硬编码占位改为自然语言意图路由
|
2026-09-14 18:22:56 +08:00 |
|
yuancong_0626
|
14e027f781
|
合并yy与远程qyqy_develop并保留业务优先级
|
2026-09-14 18:16:33 +08:00 |
|
yuancong_0626
|
2548c39c6b
|
袁聪的最后一次完善更新
|
2026-09-14 18:13:10 +08:00 |
|
lzf_0626
|
484cccc145
|
记录三个演示文档的移动,并把 .workbuddy/ 加进 .gitignore
## 文档移动(docs/ -> docs/演示用/)
`代码结构与关键逻辑梳理-2026-09-14.md`、`后端接口文档-2026-09-14.md`、
`软件需求文档-2026-09-14.md` 移入 `docs/演示用/`。
git 识别为 **rename**(状态 `R`),三份文档的历史完整保留,不是"删掉再新建"。
## .workbuddy/ 加入 .gitignore
它与文件里已有的 `.agents/`、`skills-lock.json` 属同一类 —— AI 编码助手的工具产物,
不是项目内容。放在同一节下,避免以后 `git add -A` 时误提交。
## 未包含(有意留下)
`docs/23-记忆分层与画像设计.md` 的工作区改动**没有**一并提交:那不是我改的,
也不在这次确认的范围内,留在工作区由作者决定何时提交。
(暂存时被 `git add docs` 一并带上,已 `git restore --staged` 退回。)
|
2026-09-14 12:09:23 +08:00 |
|
lzf_0626
|
3df6671ce3
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-14 12:07:18 +08:00 |
|
lzf_0626
|
e6d74059f2
|
修复员工工作台顶部导航重复(入口 JS 被引两次)
## 现象
打开 `employee-console/workspace`(平台治理),页面上出现**两份一模一样的顶部栏**:
南方财富 / 模拟基金服务 / 平台治理 / 风控中心 / admin_t 管理员 —— 连同页脚一起各两份。
## 根因:同一个入口 JS 被引了两次,且 `?v=` 不同
`app/static/portal/employee-console/workspace/index.html` 里曾同时存在:
<script type="module" src=".../workspace.js?v=20260913-3"></script>
<script type="module" src=".../workspace.js?v=20260914"></script>
浏览器按**完整 URL** 去重,两条不同 query 被当成**两个模块**、**各执行一次**。
入口里的 `mountShell()` 因此跑了两次,而它当时是
`document.body.insertAdjacentHTML('afterbegin', ...)` —— **无条件插入**,
于是 header 与 footer 各插两份。
来源是合并事故(`git blame` 定位):
| 行 | 提交 | 作者 |
|---|---|---|
| 旧 | `e31420df` | 卿云秋月(把版本号改成 `20260913-3`)|
| 新 | `f5d1b246` | 张胜宇(把版本号改成 `20260914`)|
两人各自把**同一行**的版本号换成新的,合并时两边都被保留,成了两行。
那个提交的信息是 "merge ... and retain risk review updates" ——
"retain" 在这里保留错了地方。
## 修法(两侧都堵)
1. **HTML 收敛成一行**(保留较新的 `?v=20260914`,与 `workspace.js` 内部
`api-client.js?v=20260914` 一致),并就地写明"改版本号是替换这一行、不是新增一行"。
2. **`mountShell` 加幂等保护**:已有 `.site-header` 就直接 return。
之所以不满足于只修那个 HTML —— 这个 bug 的症状很难反推到原因
(页面看起来只是"多了一块"),而以后谁加缓存版本号时很容易再犯一次。
## 防回归(两条测试,都做过负面验证)
- `test_no_portal_page_includes_the_same_script_twice`:扫 `app/static/portal` 下
**19 个页面**,把 `<script src>` 去掉 query 后比对,同一入口出现多次即失败。
负面验证:把重复行临时放回去,测试**精确报出**
`employee-console\workspace\index.html: ['/static/portal/employee-console/workspace/workspace.js']`,
恢复后通过。
- `test_mount_shell_is_idempotent`:断言 `app-shell.js` 里有那句幂等判断。
全站扫描确认**只有这一处**,不是批量问题。
## 实测
- `GET /portal/employee-console/workspace/` -> 200,页面里 `workspace.js` **只出现一次**
(`?v=20260914`);静态 HTML 中 `site-header` 出现 **0 次**(确认由 JS 注入,
所以 JS 执行一次就只插一份)
- `pytest tests/unit/api/test_portal_frontend.py` -> **43 passed**(41 + 新增 2 条)
- `ruff check` -> All checks passed
## 一点说明
这次是"改同一个版本号"的合并冲突处理失误,属于**流程问题**而非个人疏忽:
两边都想把缓存版本号推新,冲突解决时很容易两边都留下。
测试补上之后,这类错误会在 `pytest tests/unit` 里当场暴露。
|
2026-09-14 12:06:38 +08:00 |
|
zhangshy
|
8409a2c96e
|
合并主项目最新文档并保留风控文档更新
|
2026-09-14 11:44:45 +08:00 |
|
zhangshy
|
5a83aa6fef
|
更新风控文档分页会话记忆和演示数据规则
|
2026-09-14 11:44:21 +08:00 |
|
lzf_0626
|
e59a9905a0
|
把「今日盈亏是硬编码 0」提级为待业务确认项(SRS Q22)
## 问题
`today_profit_loss` / `today_profit_loss_ratio` 在 `app/service/trade_service.py` 里是
**硬编码 `ZERO`**(持仓列表 L678、账户看板 L748-749),客户看板、持仓页、盈亏分析页的
「今日盈亏」永远显示 0.00。同一函数里的「持有盈亏」是**真算的**
(`market_value - cost_amount`,L660 / L726),所以只有这一项是占位。
三份 2026-09-14 文档其实**都记录了这个事实**(接口文档第 11 条、SRS 第 212 / 458 / 615 行),
但**都埋在「注意事项」里,没有进第 0 章那份「★ 请先回答本章」的待确认清单** ——
业务翻文档时看不到,演示现场被问到就答不上来。而且 SRS 第 212 行还把它**错引到了 Q7**
(Q7 讲的是场外阈值,与此无关)。
## 改动
**docs/软件需求文档-2026-09-14.md**
- 新增 **0.5 节「占位实现与本期边界」**,登记 **Q22**:客户看板的「今日盈亏」本期是否实现。
该节专门收这类「接口有、值还没真算」的偏差(不报错、单元测试全绿、只有业务看得出不对),
以后同类问题继续往这里追加。
- 第 0 章项数 21 -> 22;阻塞项清单补上 Q22。
- 修正 F-4.2 的错误引用(见 Q7 延伸 -> 见 Q22)。
- R2 状态改为「已提级为待决项 -> 见 Q22」。
- **R1 状态改为已修复**:组员提交 `4b7ee13`「修复管理员工作台函数缺少闭合大括号」
已解决 `workspace.js` 的语法错误。按该条自己要求的方式复验过 ——
`node --experimental-vm-modules` + `vm.SourceTextModule` 遍历 `app/static/portal`,
**44 个模块全部通过**(`node --check` 会假通过,不能用来判定)。
**docs/44-演示流程.md**(演示现场用)
- §3「可能被问到的问题」新增话术:直说这是占位值、指出持有盈亏是真算的、
指向 SRS Q22 与两条口径的结论;并建议「时间紧就避开这一栏,被问到不要含糊也不要现编」。
- 场景 2(客户资产)加现场提示:讲「持有盈亏 / 总市值 / 可用资金」,不要指着「今日盈亏」讲。
**docs/后端接口文档-2026-09-14.md**
- 第 11 条补上代码行号与交叉引用,并点明同一响应里的 `profit_loss` 是**真算的**。
## 顺带交给业务:两条口径的可行性(实测,非推测)
业务要决定的是「要不要做」,但「能不能做」也得一起给,否则业务选完还要再问一轮:
| 口径 | 数据现状 | 结论 |
|---|---|---|
| 用行情算(今收 − 昨收) | `fin_market_price` **只在同步时才写行**,实测每个产品**只有 2 行**(515450 只有 09-11 与 09-14,还跨了周末) | ❌ 取不到连续「昨收」 |
| 用净值算(今净值 − 上一交易日净值) | `fin_nav_history` 每个产品 **120~160 行连续交易日净值** | ✅ 建议按这个口径 |
⇒ 文档建议按**净值口径**实现,并提醒业务一并确认语义:净值是日终数据,
该指标实际是「最近一个交易日的盈亏」,**不是盘中实时**。
## 入库说明
三份 `docs/*-2026-09-14.md` 此前是**未跟踪文件**,本次一并入库(同一批文档交付物且互相引用)。
`.workbuddy/`(工具会话记忆)属工作区过程产物,**未入库**。
|
2026-09-14 11:24:51 +08:00 |
|
zhangshy
|
4b7ee13cf5
|
修复管理员工作台函数缺少闭合大括号
|
2026-09-14 10:56:45 +08:00 |
|
zhangshy
|
bfbaf823c2
|
同步预警队列后端分页上限为十条
|
2026-09-14 10:50:48 +08:00 |
|
zhangshy
|
baecb89bd4
|
预警队列调整为每页十条
|
2026-09-14 10:40:41 +08:00 |
|
zhangshy
|
54055b4328
|
合并主项目最新改动并保留风险问答记忆修复
|
2026-09-14 10:08:37 +08:00 |
|
zhangshy
|
20773453bd
|
修复通用风险问答会话历史丢失
|
2026-09-14 10:08:05 +08:00 |
|
lzf_0626
|
eb89ffd0b4
|
按需造数据:给指定客户建持仓,并保证产品有可查的最新净值
## 需求
产品 15911、客户 10001 / 10002;最终要能查到这两户持有 15911 的持仓,
以及 15911 的最新净值。
## tools/seed_custom_holdings.py(新)
参数化脚本,不写死这次的代码与账号。手工写 SQL 会连踩一串坑,每一条都在本仓库真实发生过:
1. `fin_*` 的 `id` 列**没有 AUTO_INCREMENT**,不显式给 id 就报
`Field 'id' doesn't have a default value`;
2. `fin_holding.shares` / `current_value` 文档标注为"生成列",实测是普通 NOT NULL 列,
不写插不进去;
3. 只建 `sys_user` 不绑角色 -> 登录成功但查持仓 403;
4. 只建用户不设密码 -> `password_hash` 是占位符,**根本登不进来**;
5. 净值散在三处(`fin_product.current_nav`、`fin_nav_history` 最新一条、
`fin_market_price.close_price`),只写一处就会"产品页有净值、持仓页市值为空";
6. 往 `fin_market_price` 写"今天"的编造行情会**盖住真实行情** —— 下单按 `trade_date DESC`
取,编造行排在前面,过了 `MAX_QUOTE_AGE`(15 分钟)整个产品就一律 503
(周末尤其明显)。
脚本把这些一次做对,并保证上面第 5 条的三处严格一致(持仓市值恒等于 数量 x 最新净值)。
## 开户风险测评:走接口,不插表
这是这次最关键的发现。数据都建好之后,客户能登录,但访问 T001 / T006 一律 403,
报的是 `AGENT_PERMISSION_DENIED`「请先完成开户风险测评问卷」——
那是合规前置(`app/api/dependencies/auth.py` 的 `OnboardingRequiredError`),
不是 RBAC 没配好,两者表象一模一样,很容易查错方向。
`RiskQuestionnaireService.is_required` 只查 `fin_risk_assessment.valid_until > now`,
所以插一行就能过检查。**没有那样做**:插表不会生成画像快照、画像标签和记忆同步事件,
而投顾分析、适当性检查、Agent 对话都要读那些。改走真实接口
`POST /api/v1/onboarding/risk-questionnaire/submissions`,让业务链路自己把关联数据建全。
内置 13 题答案按 `_SCORE_RULES` 折总分 46 -> C4 成长型(产品是 R3,C4 有余量)。
等级**不从接口读**:提交返回里故意没有它(`_SCORE_RULES` 是服务端机密,注释写明
never included in customer responses),所以从库里读回并打印。实测读到 C4 / 46 分,
与手算完全吻合。
## 实测(接口层,不只是查库)
- P001 访客产品列表:21 个产品,★ 命中 15911,`current_nav=1.000000`
- P002 净值走势:30 个点,最近 3 条 2026-09-10 / 09-11 / 09-14
- 10001 / 10002 登录 -> T001 资产看板 200:总资产 101000.00、总市值 1000.00
- 10001 / 10002 -> T006 持仓 200:15911 数量 1000、最新价 1.000000、市值 1000.00
- 幂等复核:第二次执行不重复建记录、不覆盖持仓数量、不重复提交测评、不重置密码
- `ruff check app tests tools alembic hq.py` -> All checks passed
## 已知限制
产品代码 `15911` 是 **5 位**,而系统现有 20 个产品**全是 6 位**,且下单要求行情落在
`FundQuoteRuntimeConfig.allowed_codes`(六位数字)白名单里。
所以它**能建、能查持仓、能查净值,但不能下单**。
若是笔误,用 `--product-code 159911` 重跑即可。
|
2026-09-14 10:03:07 +08:00 |
|
lzf_0626
|
e096ffab22
|
修复投顾工作台白板:补上模块拆分时漏掉的 import
## 问题(合并进来的故障,不是本次会话改坏的)
合并 `origin/qyqy_develop`(f5d1b24 / 3134fe5)后 `pytest` 红了一条:
`test_advisor_dashboard_is_composed_from_feature_modules`。
查下去发现是**拆分做了一半**:
- 新建了 `advisor-config.js` / `actions-module.js` / `published-module.js`
- 把 `CONTENT_TYPE_LABELS`、`actionLabels`、`resultMessages` 从 `dashboard.js` 删掉了
- **但没有在 `dashboard.js` 里 import 它们**,`dashboard.js` 仍是拆分前的内联版本,
第 39 行还在用 `CONTENT_TYPE_LABELS`
后果不只是测试红:投顾工作台一打开就 `ReferenceError: CONTENT_TYPE_LABELS is not defined`,
页面渲染不出来;同时那两个新模块是**死代码**(没有任何地方 import 它们)。
`index.html` 是单入口(只加载 `dashboard.js`),所以模块必须由它 import。
## 修法:把重构接完,而不是把测试改掉
- `dashboard.js` 变成薄组合层:挂 shell、取 DOM、组合两个模块,其余逻辑不再内联
- `advisor-config.js` 收拢 `GOAL_STATUS_LABELS` / `BOOK_STATUS_LABELS`(原来内联在 dashboard.js)
- `actions-module.js` 接管「目标确认与方案书」
- `published-module.js` 直接可用
⚠️ 关键点:`bind()` 会给**所有** `[data-action]` 按钮挂 `open()`,而 `actions-module.js`
原先不认识 `goal-status` —— 直接接线会让它掉到最后一行的兜底分支、被当成
「资产配置」发出去(点"目标确认与方案书"却收到一份配置建议)。
所以把「目标确认与方案书」一并做进 `open()` 的分支里,并在两处留了注释说明这个约束。
「目标确认与方案书」这条功能本身要保留:此前工作台只有 4 个"生成草案"操作 + 1 个只读列表,
而确认目标与查看方案书这两个端点**有接口没入口**,导致目标永远停在 `pending_confirmation`、
方案书永远停在 `pending`(实测客户 9001 正是如此)。
## 防回归:tools/check_portal_modules.py(新)
上面那个 bug **不能靠现有断言发现** —— 那些测试断言的是"某个字符串在文件里出现",
而这里的问题是"定义搬走了、使用处还在",浏览器里才炸,Python 测试全绿。
新检查做四件事:`node --check` 按 ES module 解析语法、相对 import 的目标文件存在、
import 的名字在目标文件里真有 `export`、**用到的全大写常量必须有来源**。
第 4 条是抓这个 bug 的关键。写的时候踩了两次坑,都已修正并记录在文件里:
1. 第一版用 `(?<![\w.$])` 排除属性访问、却把**模板字符串整体**当字符串剔除了 ——
而 `CONTENT_TYPE_LABELS[row.content_type]` 恰好写在模板字符串里,
于是漏报、检查全绿。现在只剔除单双引号字符串,模板字符串保留(`${}` 里是真代码)。
2. 用负面验证确认它真的有效:把 `published-module.js` 的 import 拿掉后,
检查精确报出 `使用了 'CONTENT_TYPE_LABELS',但既没 import 也没在本文件声明`(exit 1);
恢复后 exit 0。没有这一步,这个检查就是个摆设。
同时接进测试:`test_portal_feature_modules_have_consistent_imports` 调用它,
保证以后每次 `pytest tests/unit` 都会执行。
## 实测
- `pytest tests/unit tests/contract` -> **1400 passed, 2 skipped, 0 failed**
(合并后未修时是 1399 passed + 1 failed)
- `pytest tests/unit/api/test_portal_frontend.py` -> 39 passed(38 + 新增 1 条)
- `python tools/check_portal_modules.py` -> 全部通过;负面验证 exit 1
- `ruff check app tests tools alembic hq.py` -> All checks passed
|
2026-09-14 02:07:44 +08:00 |
|
lzf_0626
|
d95ef09627
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-14 02:00:59 +08:00 |
|
lzf_0626
|
c5bfc2c998
|
钉住 .bat 的换行:新增 .gitattributes(*.bat text eol=crlf)
本机 core.autocrlf=true,git 在 add 时会把工作区的 CRLF 静默存成 LF;
换个 autocrlf=false 的环境 clone 出来就是 LF,而 cmd 对 LF-only 的 .bat
会行边界错乱(把 echo 的说明文字当命令执行,实测报
"AT 命令已弃用"、"']' 不是内部或外部命令",甚至真的去跑脚本)。
.gitattributes 里钉 `*.bat text eol=crlf`:仓库统一按 LF 存储、检出时转回 CRLF,
不依赖任何人的本地 git 配置。
验证方式(不是"我觉得会转"):删掉工作区的 bat 再 `git checkout --` 取回来,
实测 2090 字节、62 个 CRLF、无裸 LF,与写文件时的形态一致。
仓库里目前只有 `启动平台.bat` 一个批处理,影响面仅此。
|
2026-09-14 02:00:41 +08:00 |
|
lzf_0626
|
404f8f6aa5
|
固化接口体检脚本,并交付双击即用的启动 bat
## tools/portal_api_check.py(新)
把 2026-09-13 那轮"照着接口内容把前端测一遍"的验证固化成可重复跑的脚本。
当时靠这套验证查出 6 个真缺陷,但它们只存在于一次会话里,下次改动没人会重跑:
- `K002`/`K003` 是裸信封,前端按 `payload.data` 取 -> 知识库页显示"已入库 0 块 / 为空"
- 委托/成交详情页照着建表字段写,而接口返回视图没带那些列 -> 5 行永远显示 `--`
- 配置项/路由规则的 `PUT` 要 `If-Match`,却没有端点能返回该 digest -> 首次编辑必然 409
- 回复模板 `scene` 只校验长度不校验枚举 -> 非法值撞数据库 CHECK、冒成 500
- 路由规则表单固定 `max_attempts=2` 且 `fallbacks` 为空 -> 后端必然 422
- 模型端点手填 ID -> 未激活的 ID 直接 422
与 `e2e_smoke_test.py` 分工互补:后者测**业务链路通不通**,本脚本测**接口契约对不对**
(状态码、信封形状、字段名与前端期望是否一致)。
三档,默认只跑第一档:
- 默认只读 41 项(不动数据)
- `--write` 加测写操作:配置项 ETag 链路、`max_attempts` 越界、知识库上传+失效、
回复模板非法枚举、敏感词、推广物料全链路
- `--dangerous` 再加测会改生效配置的操作(激活配置版本会整版本替换,默认不跑,
脚本内注明了恢复办法)
实测:只读 39/39、写入档 54/54 全绿。
## 桌面一键启动(双击即用)
桌面 `启动金融Agent平台.bat` + 仓库 `启动平台.bat`,由
`tools/make_launcher_bat.py` 生成,只负责"双击"这一层,启动逻辑复用 `start.ps1`
(不重复实现,避免两边漂移)。参数可透传:`-Port 8100` / `-NoBrowser` / `-SkipPriceSync`。
不要手写这个 bat:必须同时满足 **GBK 编码 + CRLF 换行 + 无 BOM**。生成器自己
读回来校验这三条。踩过的坑:用 `write_bytes` 直接落盘时没转 CRLF,cmd 对 LF-only
批处理会行边界错乱,把 `echo` 的说明文字当命令执行(实测报
`AT 命令已弃用`、`']' 不是内部或外部命令`)。
## start.ps1 的三处修复
1. **解释器探测选错环境(真机双击失败的主因)**
原先只验证 `--version` 成功就选中,结果挑到一个 Python 3.10 环境:
本项目用了 `datetime.UTC`(3.11+)且依赖 `asyncmy`,启动当场 ImportError ——
而报错发生在**行情刷新**那一步,看起来像"行情源坏了"。
现在实测两项:**版本 >= 3.11** + `import fastapi, sqlalchemy, asyncmy, pydantic` 通过,
并在跳过时打印具体原因。
2. **`Select-Object -First 1` 掐断 native 管道**
`& $exe -c ... 2>&1 | Select-Object -First 1` 拿到首个对象后停掉上游管道,
等于把进程掐了、`$LASTEXITCODE` 变脏,于是**每个候选都被误判成"无法执行"**
(连装好的 jr_py313 也被跳过)。改为先整体接住输出、再在结果上取行。
3. **幂等 + 就绪探测 + Docker 自动拉起**
重复双击不再起第二个 API/Worker(API 按端口、Worker 按进程判断);
起完等 `/internal/health/ready` 真正应答才报成功;Milvus 不可达时尝试拉起
Docker Desktop 并最多等 60 秒(它不常驻,是"知识检索静默降级"最常见的原因)。
## 实测
- `启动平台.bat` 重复执行:解释器正确选中 jr_py313、依赖检查全 OK、行情已刷新、
两个服务识别为已在跑并跳过、就绪探测 1 秒
- `-Port 8199` 冷启动:新起 API 窗口,8199 的 `/internal/health/ready` 与 `/portal/` 均 200,
测试后已清理
- `ruff check app tests tools alembic hq.py` -> All checks passed
- `start.ps1` 语法解析通过(BOM 已保留)、bat 三项编码约束校验通过
文档同步:`AGENTS.md`(启动方式、bat 生成器、解释器探测两个坑)、
`docs/44-演示流程.md` §0.3/§0.4(双击启动、两条自检线的分工)。
|
2026-09-14 01:59:46 +08:00 |
|
张胜宇
|
3134fe5dd0
|
Merge branch 'qyqy_develop' of http://47.106.207.27:3000/AI260626/group_fqcd_jr into qyqy_develop
|
2026-09-14 01:45:47 +08:00 |
|
lzf_0626
|
3cbfe10d62
|
fix(portal): 补齐运营三个新页面的同名 css 入口,修复合并进来的红灯契约测试
合并组员的运营工作台提交后,`test_every_portal_page_has_local_js_and_css_entry`
红了:门户每个页面都要有与目录同名的 `js`/`css` 入口,而新加的
`nl2sql` / `offsite` / `promotion` 三个页面**只有 js**,样式统一引 `operator-workspace.css`。
## 改动
给三个页面补上同名样式入口,并在各自 `index.html` 里引用(放在共用的
`operator-workspace.css` 之后,便于页面覆盖):
- `employee-operations/nl2sql/nl2sql.css`
- `employee-operations/offsite/offsite.css`
- `employee-operations/promotion/promotion.css`
三个文件当前**没有规则**,只写了用途说明 —— 它们的价值是把"页面专属样式"的位置
**确定下来**:这个约定的意义正在于此,否则将来只会继续往共用文件里堆。
⚠️ 只在每个 `index.html` 的 `<head>` 里**加了一行 link**,未改动组员的其它内容。
验证:`tests/unit/api/test_portal_frontend.py` 37 passed;
unit+contract **1398 passed**;integration **110 passed**;ruff 通过;
mypy 251 文件 0 错;三个页面与三个 css 均 200;e2e 冒烟 **40/40**。
|
2026-09-14 01:42:21 +08:00 |
|
张胜宇
|
f5d1b24618
|
merge qyqy_develop and retain risk review updates
|
2026-09-14 01:41:02 +08:00 |
|