Commit Graph
160 Commits
Author SHA1 Message Date
lzf_0626 01e4e6a687 修掉"Worker 会自己退出"的真缺陷(场外游标续租失败误取消主任务)
## 现象(2026-09-14 实测,非人为停止)
Worker 进程自己退出,退出码 1,日志末尾是
`ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接`
(发生在 `offsite_worker.close()` 的 IMAP `logout()`),
而真正的起点是更上面那句 `asyncio.exceptions.CancelledError`。

## 根因(两处,都是真缺陷)
1. **取消错了对象**:`OffsiteMailWorker._process_batch` 把
   `asyncio.current_task()`(= `__main__.serve()` 的**主循环任务**)交给游标心跳,
   心跳在续租失败(`rowcount != 1`)或续租抛异常时执行 `task.cancel()` ——
   于是"放弃这一批"变成了"**杀掉整个 Worker**"。`CancelledError` 从 `run_once()`
   一路冒到 `serve()`,主循环直接结束。
2. **收尾异常盖掉退出原因**:`serve()` 的 `finally` 里 `await offsite_worker.close()`
   在网络已断时抛 `ConnectionResetError`,把 `CancelledError` 顶掉,
   表现为"关闭流程崩了",看不出真实原因。

## 改法
- `_process_batch` 把"这一批"跑在**独立任务** `batch` 里,心跳只取消 `batch`;
  调用方捕获 `CancelledError` 后区分两种情况:
  **本批被放弃**(`batch.cancelled()` 且当前任务自己没有在取消)→ 记 warning、返回 `False`、
  Worker 继续下一轮;**外层在取消当前任务**(Ctrl+C / 进程关闭)→ 原样上抛,绝不吞掉。
- `serve()` 的 `finally` 里关闭场外 Worker 包 try/except:收尾失败只记日志,
  **不改变退出码与退出原因**。
- 新增 `_current_task_is_cancelling()` 用 `Task.cancelling()` 做这个区分(3.11+)。

## 守卫
`tests/unit/worker/test_offsite_mail_worker.py::test_cursor_lease_loss_abandons_batch_without_cancelling_the_worker`
—— 续租失败(rowcount=0)时断言:批次确实跑起来过、返回 `False`、
**调用方任务没有被取消**。修复前这条用例会挂在"调用方被取消"上。

## 验证
- `pytest tests/unit/worker` → 112 passed
- `pytest tests/unit tests/contract` → 见下方(0 failed)
- `mypy app/worker/offsite_mail_worker.py app/worker/__main__.py` → 0 错(除组员文件里既有的 1 个)
- 重启 Worker 后跑记忆演示链路:候选 → verified → active → **2 秒**收敛,进程稳定

## 文档
`docs/演示用/记忆系统演示文档-2026-09-14.md`:
场景五补"如果发现 Worker 自己退了是怎么回事",问答补"Worker 会不会自己中途退出"。
2026-09-15 08:01:00 +08:00
lzf_0626 076d786bc6 补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题
`svc_handover_ticket` 的 DDL 与状态机在 `docs/02` §7.2 早就定好了
(pending → assigned → processing → resolved → closed,未解决可 cancelled),
但平台**只有 handover:read(只读队列)**:没有任何入口能改状态、assigned_to /
accepted_at / resolved_at / closed_at / resolution 五列**全库 0 非空**,
于是 40 张工单永远停在 pending —— 用户看到的就是"工单全都长一样"。

## 改了什么
后端:
- 新增 `app/service/customer_service_handover_action_service.py`:五个动作
  (分配/接单/解决/关闭/取消),`SELECT ... FOR UPDATE` 锁单后判状态;
  接单允许从 pending 自助接管(同时记受理人);取消不写 closed_at(该列属 closed 状态);
  每次流转写一条 interaction_audit(handover.assigned/accepted/resolved/closed/cancelled);
  非法流转 409、坐席不存在 422、工单不存在 404;回包不含 customer_id/session_id。
- 只读服务保持只读(读侧与写侧是两条边界,单测守着"读侧不许长出写方法"),
  但列表支持 `?status=` 六态筛选、详情补上受理人与流转时间(坐席侧路由信息,非客户数据)。
- `app/api/controllers/admin.py`:五个 action 端点 A049–A053
  (assignments / acceptances / resolutions / closures / cancellations),
  走 `ApiTransactionService.execute_in` —— 幂等记录与业务写入同事务、重复键回放。
- 权限:新增 `handover:write`(9069,只授 admin),已并进种子
  `tools/seed_test_rbac.py`;配套幂等脚本 `tools/grant_handover_write_permission.py`。

前端(管理员工作台 · 转人工工单页):
- 按状态给按钮(待处理→分配/直接接单、已分配→接单、处理中→解决、已解决→关闭、
  未解决都可取消),加了状态筛选与"刷新";摘要弹窗补上受理人与四个时间点、处置结论。
- api-client 注册五个端点;workspace.js 的 api-client 引用与页面自身的 ?v= 一并升版,
  避免浏览器拿旧缓存(旧缓存里没有这些端点)。

冒烟与测试:
- `tools/e2e_smoke_test.py`:B 段建的测试工单由 F 段走完 分配→接单→解决→关闭 收尾
  —— 既不再把测试件堆在 pending 队列里(此前每次冒烟攒一张),又让每次冒烟都覆盖一遍状态机。
  总数 40 → 44 项,实测 44/44 全绿。
- 新增单测 24 条(状态机合法/非法路径、越权、坐席不存在、审计、视图不泄漏客户标识)
  与一条真机集成用例(HTTP 十步 + 数据库侧审计证据 + 自动清理)。
- 读侧那条"详情不得返回 assigned_to"的旧断言按新口径更新,并写清为什么。

## 验证
- `pytest tests/unit tests/contract` → 1489 passed, 2 skipped, 0 failed
- 新增集成用例通过;`tests/integration` 全量跑时
  `test_memory_extraction` / `test_run_cancellation_mysql` 两条偶发红 —— 单独跑都通过,
  是 AGENTS.md 已登记的"常驻 Worker 抢队列"(跑验收前须先停 Worker)
- `tools/portal_api_check.py` → 41 项通过 39、失败 0
- `tools/e2e_smoke_test.py` → 44/44 全通过
- `python tools/check_rbac_seed_consistency.py` → 通过(种子 63 条权限)
- 真机 HTTP 实测:分配→接单→解决→关闭四步 200 且时间戳齐全;取消路径 200 且 closed_at 为空;
  同键重发回放不二次推进;对已关闭工单再分配 409;风控账号处置 403

## 文档
`docs/44-演示流程.md`(场景 4/8 + 命令 + 44 项)、`docs/演示用/后端接口文档`(新增 §11.4b 与
A049–A053)、`docs/演示用/全功能流程-大白话版.md`(工单页签改"读写"+ 已知偏差)、
`AGENTS.md`(9066-9069 号段演进 + 冒烟 44 项)
2026-09-15 00:41:59 +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 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 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 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 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 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 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
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 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 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
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
张胜宇 f5d1b24618 merge qyqy_develop and retain risk review updates 2026-09-14 01:41:02 +08:00
yuancong_0626 abbfb6eddd 合并yy并同步远程qyqy_develop 2026-09-14 01:11:07 +08:00
yuancong_0626 7a49a1c2c8 袁聪的前端调修 2026-09-14 01:07:43 +08:00
lzf_0626 9a5259d787 feat(market): 存下行情源给的涨跌幅,产品列表与排行页不再显示"暂无"
## 问题

产品列表与排行页的「最近涨跌」全是"暂无"。原因是它只能由**两个交易日的收盘价**
现算,而 `fin_market_price` 每只产品每天只有一行 —— 库里头一天只有一天数据时
根本算不出来。

## 但行情源本来就给了这个数

腾讯行情接口的第 32 位就是**当日涨跌幅**(适配器此前只解析了开高低收量额,没取它)。
所以不必等第二个交易日去算,把源给的数存下来即可。

## 改动

- **迁移** `20260913_market_price_change_pct`:给 `fin_market_price` **新增一个可空列**
  `change_pct DECIMAL(10,4)`。只加列、不改任何已有字段(AGENTS.md 规则 2 允许),
  可空且不回填,既有读写方全部不受影响。
- **适配器**:`_parse_tencent` 增解析位置 4(昨收)与位置 32(涨跌幅)。
- **同步服务**:写入 `change_pct`;降级路径(净值兜底)没有这个数就写 **NULL**。
- **接口**:`_resolve_change_pct` **优先用存下来的值**;迁移前的历史行没有该列的值,
  才回退到"今日收盘 vs 昨收"现算。仍为 `null` 时前端显示"暂无" ——
  **不得当成 0**(`formatPercent(null)` 会渲染成 `+0.00%`,那等于说"今天平盘")。

## 契约测试同步

两处契约断言因结构演进需要跟进 —— 它们的作用正是拦住这类变更:

- `test_fund_readonly_contract.py`:`fin_market_price` 列数 **13 → 14**
- `test_advisor_migration_contract.py`:迁移 head 更新为新版本
  (核心断言仍是"链收敛到唯一 head",此处只是钉住末端)

验证:实测 20 只产品**全部有真实涨跌幅**(如 515450 −0.43%、159511 +1.23%);
unit+contract **1397 passed**;integration **110 passed**;ruff 通过;
mypy 251 文件 0 错;`audit_schema` 与 `migration_state_check` 均通过;e2e 冒烟 40/40。
2026-09-14 00:30:06 +08:00
lzf_0626 373bcb2a68 fix(test): 补 FakeSession.scalar 桩,修复合并进来的 15 个红灯用例
组员的 `2a3146e`("增加风控列表总数")在 `fund_query_repository.fetch_page` 里新增了
一次 `await self.session.scalar(select(func.count())...)` 来返回 `total`,但
`tests/unit/repository/test_fund_query_repository.py` 的 `FakeSession` 只有 `execute`,
于是该文件**15 个用例全部**挂在:

    AttributeError: 'FakeSession' object has no attribute 'scalar'

## 改动

给 `FakeSession` 加 `scalar`:

- **返回值取 `len(_rows)`**。这些用例没有一处断言 `total`(只校验 `has_more` /
  `next_offset` 与生成的 SQL),返回非负整数即可;`_rows` 是带 `limit+1` 的当页结果,
  所以它**不是真实总数** —— 将来若要断言 total,需给这个桩加显式参数。
- **不写入 `statements`**。`last_sql` 取的是 `statements[-1]`,而有用例断言的是
  **分页查询**的 SQL(LIMIT / ORDER BY / 过滤条件)。第一版实现把 count 语句也
  append 了进去,`last_sql` 随即指向 count 语句,4 个用例的断言集体落空
  (实测:15 failed → 4 failed → 0)。count 语句改记在 `count_statements` 里备查。

验证:`test_fund_query_repository.py` 18 passed;unit+contract **1397 passed**;
ruff 通过;e2e 冒烟 40/40。
2026-09-14 00:02:35 +08:00
zhangshy 2a3146e050 增加风控列表总数并优化站内提醒 2026-09-13 23:51:45 +08:00
zhangshy 38fe6f3689 合并主项目最新改动并解决风控前端冲突 2026-09-13 23:22:12 +08:00
zhangshy 4f75d32ea1 优化风控详情展示与前端交互 2026-09-13 23:19:52 +08:00
张胜宇 3325232e2a feat(portal): complete advisor workspace operations 2026-09-13 23:04:35 +08:00
zhangshy 522c9b19fe Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop 2026-09-13 21:09:09 +08:00
zhangshy 09c80575b7 延长风控手动扫描前端超时 2026-09-13 21:08:57 +08:00
lzf_0626 fef4c826ff fix(worker): 知识写入 Milvus 必须探测字段名并补齐必填字段
背景:走 `POST /api/v1/knowledge/upload` 灌了 22 块场内基金知识,MySQL 全部写入成功、
向量事件也全部投递,却全部同步失败(重试 3 次进死信),客服检索不到新知识。
逐层定位出三个真问题,都在写入侧:

1) **字段名硬编码**。检索侧早已按 AGENTS.md 改用运行时探测
   (`app/core/knowledge_schema.py`:`doc_id`↔`knowledge_id`、`content`↔`snippet`),
   写侧却一直硬编码 `knowledge_id` / `snippet`。本机集合实际是
   `doc_id`/`content`/`chapter`/`visibility`/…(架构师那套 schema),于是
   `Attempt to insert an unexpected field knowledge_id` 整条失败。
   现在两侧共用 `resolve_schema` 的同一份映射表,调用方只用**逻辑**字段名;
   集合没有的字段(如另一套环境无 `intent`)跳过而不是报错。

2) **非 nullable 必填字段没给**。改完名字后报
   `Insert missed an field chapter to collection without set nullable==true`:
   集合里存在、调用方没提供的 VARCHAR 字段必须补值。VARCHAR 的判据用
   `params.max_length`,不必 import pymilvus 的枚举。

3) **补值不能一律空串**:`visibility` 留空会被检索侧
   `visibility == "public"` 的过滤整行排除。这条最隐蔽 —— `query` 查得到、`search`
   查不到,表现为「入库成功但客服永远答不出新知识」,比写入直接失败更难定位。
   现在按 `FIELD_DEFAULTS` 给有语义的字段默认值。

Worker 侧把 `fields` 的键改成逻辑名(`snippet` → `content`),上限表随之改名。

**测试**:原来的桩没有 `describe_collection`,所以这条路径从未覆盖到真机 schema
(这正是缺陷长期存在的原因)。现在桩提供两套真实 schema 并参数化,另加
「跳过集合没有的字段」「探测不出必要字段必须失败关闭」两个用例。

**验证**:22 块重新同步后 `fin_product_collection` 236 行、`visibility` 全为 `public`;
问「南方沪深300ETF 的起投金额是多少」命中新块 `score=0.7932`(≥ `HIGH_SCORE` 0.75),
客服从「转人工」变为直接作答,端到端 3.34 秒。

**另**:新增 `docs/43-场内基金产品手册(知识库入库版).md` —— 从 `docs/42` 抽出
**客户可见**的纯净内容(去掉全部内部决策备注)供入库;`docs/42` 保留为草稿与决策记录。
2026-09-13 20:08:31 +08:00
张胜宇 aa4b9bf367 Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop 2026-09-13 19:26:31 +08:00
张胜宇 e3c316aafb Enforce trading authorization and suitability checks 2026-09-13 19:24:59 +08:00
lzf_0626 f72a545c39 refactor: 品牌统一为「南方财富」(项目方定)
背景:品牌名此前三处不一致 —— 后端 Agent 自称「奶龙基金」(customer_service_rules
的防诈骗/转人工话术、risk_agent 与 risk_analysis_service 的系统提示词)、前端全站
「南方财富」、闲聊提示词与知识素材「南方科技」。docs/36 已把它登记为"上报项目方后
待定",现按项目方决定统一为「南方财富」。

改动:
- app/core/customer_service_rules.py:P0 防诈骗话术与 P2 转人工话术里的品牌名
- app/service/agent/implementations/customer_service.py:COMPANY 常量,
  以及那处引用实测样本的注释(改为不绑定具体品牌名,免得下次改名又过时)
- app/service/agent/implementations/risk_agent.py:docstring、自我介绍、system prompt
- app/service/risk_analysis_service.py:SYSTEM_PROMPT
- app/worker/risk_scan_scheduler.py:--help 描述
- app/static/index.html(旧联调页 4 处)、portal/employee-risk/dashboard/index.html
- tests/unit/api/test_customer_service_test_page.py:同步断言(它断言的正是页面里的品牌名)
- tools/publish_chitchat_prompt.py:SYSTEM_PROMPT 改品牌;并修掉"存在即跳过"的检查
  —— 原来只判当前版本有没有这一行,于是改了文案也发不出去(脚本打印"无需发布"直接
  退出),没有任何提示。改为比对 system_prompt/user_prompt_template 内容。

闲聊提示词已重发为 release 308 / v5,生效内容为"你是南方财富的智能客服助手…"。

刻意未动:
- fin_product.fund_manager = "南方基金" —— 它被 market_quote_sync_service 与
  product_history_sync_service 当过滤条件使用,改名会让同步链路查不到产品
- knowledge_search_service.py 注释里引用的知识库实际标题「南方科技有限公司…」
- docs/客服docs 下的历史素材与 docs/ 下的过程记录(属历史留痕)

⚠️ Milvus 里的知识条目仍写「奶龙基金」(RAG-*/NF-*)与「南方科技」(PROD-*),
属知识数据,需重灌才能统一;本轮不动。

同时:
- docs/40 把品牌条目标为已处理,并补上知识库缺口的现状
- 新增 docs/42-场内基金知识条目草稿.md:按 fin_product 的 20 只产品生成,
  含通用交易规则与产品清单;费率等缺失字段一律标"以交易页面为准",未编造数字。
  **该文件是草稿,未入库**,待审核后走 POST /api/v1/knowledge/documents 灌库。
2026-09-13 19:17:56 +08:00
张胜宇 e38ece32bf 前端提交 2026-09-13 15:56:54 +08:00
zhangshy 001ba065df Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop 2026-09-12 16:12:02 +08:00
zhangshy eb4e895e4e 修复高风险预警邮件通知并补充测试 2026-09-12 15:56:50 +08:00
张胜宇 ebc3fe4cbe feat(§T): 账户看板 + 场内模拟交易 9 端点(用户自助首版)
新增 §T 用户自助段(docs/05 §19 新号段 7 个 = A×40/C×7/K×4/M×4/O×3/R×4/T×9):

- T001 GET /api/v1/users/me/account/dashboard  — 账户/资金/持仓/盈亏汇总

- T002 POST /api/v1/users/me/orders  — 委托提交(首版 market 立即全额成交)

- T003 / T004 / T005  委托列表/详情/撤单

- T006 GET /api/v1/users/me/holdings  — 持仓列表(含市值/盈亏/当日盈亏)

- T007 / T008  成交记录列表/详情

- T009 GET /api/v1/users/me/cash-ledger  — 资金账本

要点(与 docs/00 §6.6 一致):

- 首版市价委托立即全额成交,不实现撮合队列/部分成交;T005 撤单首版对任何在场委托返回 ORDER_NOT_CANCELLABLE (409)

- 价格来源复用 base FundQuoteService;service 层不二次封装(满足 AGENTS 第 2 条)

- 首版风控 3 条硬性:产品可交易、客户适当性、持仓比例上限(fin_market_price 缺失或过期 → 拒绝买入)

- 数据库零修改:10 张 fin_* 表全部 docs/00 既定,本批 PR 改列类型与可空性均 0;底座实际偏差(id 无 AUTO_INCREMENT、所谓'生成列'是普通 NOT NULL)由 service _next_id / 业务派生值补偿

注册 API:9 端点均注册进 app.main;user=9001(cust)'s id 写账

权限码(tools/seed_test_rbac.py 同步登记 + CUSTOMER 全量):

  9047 account:read:self

  9048 trade:order:create

  9049 trade:order:read

  9050 trade:order:cancel

  9051 holding:read:self

  9052 trade:txn:read

错误码(app/core/errors.py + docs/05 §3.6 + tests/unit/core/test_errors.py DOCUMENTED 三方同步):

  404 ACCOUNT_NOT_FOUND / ORDER_NOT_FOUND

  409 ORDER_NOT_CANCELLABLE

  422 INSUFFICIENT_FUNDS / INSUFFICIENT_HOLDING / HOLDING_RATIO_EXCEEDED / SUITABILITY_MISMATCH / PRODUCT_NOT_TRADABLE

  503 FUND_QUOTE_UNAVAILABLE(可重试)

新增:app/api/controllers/trading.py / app/api/schemas/trading.py / app/service/trade_service.py / tools/seed_sim_account_demo.py / tests/unit/service/test_trade_service.py(unit×8) / tests/contract/test_trading_endpoint_contract.py(contract×11)

修改:app/main.py(挂载 controller) / app/core/errors.py(10 新异常类) / tools/seed_test_rbac.py / docs/05-接口文档.md(§19 T001-T009 + §3.6 9 新码) / tests/unit/core/test_errors.py(DOCUMENTED 同步)

门禁:pytest tests/unit tests/contract 1313 passed (+19 新增) / ruff all clean / 三道守卫全过
2026-09-12 15:50:37 +08:00
zhangshy 164d55a05a 修复合规问句被误判为收益承诺 2026-09-12 15:05:03 +08:00
Windows 026fec7514 Merge remote-tracking branch 'origin/qyqy_develop' into lzl_qyqy_integration 2026-09-12 14:41:13 +08:00
Windows 9c6f839a6e feat: add advisor demo environment bootstrap 2026-09-12 14:40:53 +08:00
lzf_0626 c8cdc06e80 Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop 2026-09-12 14:18:06 +08:00
Windows 050eedac68 Merge remote-tracking branch 'origin/qyqy_develop' into lzl_qyqy_integration 2026-09-12 14:16:27 +08:00
Windows 54fadb3a99 Merge branch 'qyqy_develop' of http://47.106.207.27:3000/AI260626/group_fqcd_jr into lzl_qyqy_integration
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-09-12 14:15:43 +08:00
wangjianlong_0626 58c28ef4a0 fix(memory): 记忆抽取容忍"单元素数组"形状,空结果不再被判为无效 JSON
## 现象(2026-09-12 跑真实 Worker 时发现)

`episode_worker` 反复报 `模型记忆抽取输出不是有效 JSON` 并重试到失败:

```
pydantic_core.ValidationError: Input should be a valid dictionary or instance of _ExtractionPayload
  input_value=[{'memory_key': None, 'value': None, 'memory_type': None, 'confidence': 0}]
  input_type=list
```

## 根因

契约是**对象** `{...}`,而模型在 episode 抽取路径会返回**单元素数组** `[{...}]`。
`_parse` 直接 `json.loads` 后交给 pydantic,数组自然过不了 `model_validate`,
于是被归入"输出不是有效 JSON"这一条 —— 但它其实是**合法的空结果**
(`_validate` 已能把"三字段为 null 且 confidence=0"正确识别为"无持久事实",返回 None)。

后果:这类 episode 白跑一遍模型调用、重试到 `retry_count` 上限后判失败,
**该片段的记忆永远抽不出来**。

## 修法

`_parse` 里只对"**恰好一个对象**的数组"做归一化:

- `[{...}]` → 取 `{...}`(空结果照常返回 None;有事实照常解析)
- 多元素数组、元素非对象、空数组 → **不猜**,仍交给校验失败关闭
  (多元素时无法判断哪个是答案,猜错会把错误记忆写进库,比失败更糟)

## 测试

`tests/unit/service/test_memory_extraction_service.py` 追加 2 个用例:

- `test_single_element_array_is_normalized`:数组包空结果 → None;数组包有事实 → 正常解析
- `test_multi_element_or_non_dict_array_still_fails_closed`:多元素 / 非对象元素 / 空数组 → 仍失败

## 验证

- `pytest tests/unit/service/test_memory_extraction_service.py tests/unit/worker/test_memory_extraction_worker.py` → 28 passed
- `mypy app` → 0 错 / 245 文件

> 说明:`memory_extraction_service.py` 属主干线代码。此处是从**实际运行日志**里发现的
> 健壮性缺陷,改动限定在输出形状归一化,不改变抽取契约与校验规则。
2026-09-12 14:11:42 +08:00
wangjianlong_0626 f7b35006ff Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague 2026-09-12 14:06:18 +08:00
yuancong_0626 fedbf5a3ef merge: sync latest origin/qyqy_develop before push 2026-09-12 13:16:57 +08:00
wangjianlong_0626 cbca2cccbb Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague 2026-09-12 13:15:33 +08:00
wangjianlong_0626 57677f6554 merge: 合并主干 qyqy_develop(PR #7 之后)并对齐两套投影实现
共同祖先 bbf623a;主干 54 个提交、118 个文件;本线 25 个文件;9 个冲突文件。
主干这次把 **ZSY 的整条投影实现合进来了(PR #7)**,而本线此前的提交正是
移植并修正同一套代码 —— 因此冲突的本质是"同一功能两份实现并存",取舍错了会把
已修好的缺陷又带回来。逐项取舍与理由见 `docs/39-主干合并对策记录.md`。

## 取舍(9 个冲突)

取本线:
- `app/infrastructure/milvus_profile_projection.py` —— 主干是 ZSY 原版,含两处必炸点:
  ① `customer_id` 要求 int 而本仓所有生产者都写 `str` ⇒ 每个事件必然失败;
  ② 不可投影的 `memory_key` 直接 raise ⇒ 一条 `constraint:` 记忆毒死整客户整批。
  本线版已放宽为「接受纯数字字符串」与「跳过并留痕」。
- `memory_sync_outbox_worker.py` / `conversation_privacy.py` / `risk_questionnaire.py`
  —— 代码逐行一致,仅注释与说明文字详略不同(`risk_questionnaire.py` 两边**独立做了
  完全相同的修复**,都改成 re-export `app.model.profile`)。
- 两个投影测试文件 —— 本线是他那份的**超集**(4→10、4→5 例,包含他全部用例)。

两边合并:
- `app/worker/runtime.py`:`__init__` 两边各加一个参数,都要。
- `app/service/agent/implementations/customer_service.py`:import 取并集;
  `COMPANY` 取主干的「奶龙基金责任有限公司」("奶龙"是本项目实际品牌名,主干多处出现),
  `HOTLINE`/`SERVICE_HOURS` **取本线的修复**(主干仍是占位符 `400-XXX-XXXX`,
  本线已改为引用 `customer_service_rules` 的唯一来源 —— 这是 A1 缺陷修复,
  否则同一客服给客户两个不同号码)。
- `AGENTS.md`:表数/Agent 清单取主干(90/89、7 个 Agent),本线的
  `-X utf8` 与两条 outbox 易错点保留,测试基线按合并后实测重算。

## 消费端只保留一套(本次最重要的一处)

合并后曾出现**两套消费者读同一个 `memory_sync_outbox`**:`__main__.py`(PR #7)
与 `runtime.consume_profile_projections()`(本线),而**两者的 neo4j handler 不同**
—— 前者用 ZSY 的 `Neo4jProfileProjection`(按客户各建私有节点),
后者用主干 `ProfileGraphProjectionService`(共享 tag 节点、只投影已确认事实)。
同一事件被谁领到结果不定,等于"同一事实在图里有两种说法",正是**方案 A 要避免的状态**。

现只保留 runtime 那一套(带 `memory_sources` 兜底、neo4j 复用主干服务),
删除 `__main__.py` 的重复接线;装配入口职责仍在该文件(注入 `relationships` /
`projection_cleaner`),Milvus 客户端由 `bootstrap` 工厂惰性构造、缺配置时显式降级。

副作用:`app/infrastructure/neo4j_profile_projection.py` 不再被生产代码引用,成为
**死代码**(本线未删,属架构师线,其单测仍在)—— 待架构师决定删或明确分工。

## 顺带修掉的 3 个继承缺陷(主干同样存在,PR #7 后未整套复跑故未发现)

1. `tools/seed_test_rbac.py` **少建 `review_t`(9004)账号** —— 两个集成测试都依赖它
   ("账号存在但无权限应返回 200 空集而非 404"、`PLACEHOLDER_ACCOUNTS`)。
   同时把用户↔角色绑定从 `zip(..., strict=True)` 改为**显式配对表**:原写法隐含
   "USERS 与 ROLES 一一对应",一加不绑角色的账号就 ValueError、整个种子跑不完
   (commit 在最后,外部表现是"什么都没发生")。
2. `CustomerProfileCandidateService._write_profile_snapshot` **漏写 `current_customer_id`**
   —— 该列不是生成列而是普通可空列 + 唯一键 `uk_profile_snapshot_current`,
   不写则唯一键形同虚设(多个 NULL 不冲突),且旧当前版本也没清该列、补写就会撞键。
   现旧值置 None、新值显式写入(与 `ProfileGenerationService._clear_current` 一致)。
3. 集成测试前置未记录 —— 13 个登录/RBAC 用例因 401 而红,实为"测试账号不存在",
   跑 `seed_test_rbac.py` + `set_user_password.py` 后转绿;已在 `AGENTS.md` 记明,
   避免被误判成代码缺陷。

## 文档

- 新增 `docs/39-主干合并对策记录.md`(逐文件取舍 + 理由 + 遗留)
- `docs/37` 订正一处过时说法:曾写 `current_customer_id` 无人使用且故意不映射,
  实际 `app/model/profile.py` 已映射且有人使用(详见该文档 §6.2 的订正块)
- 文档编号:主干已占 29–36,本线两份文档让号至 `docs/37`、`docs/38`

## 验证(合并后实测)

- `pytest tests`(全量)→ `2 failed, 1396 passed, 2 skipped`
- `pytest tests/integration` → `102 passed, 1 skipped`(修上述 1、2 后从 15 failed 归零)
- `mypy app` → `Success: no issues found in 245 source files`
- `tools/audit_schema.py` → 89 张业务表无缺失/意外(未改动任何表结构)
- `tools/check_authoritative_docs.py` → 52 份文档无编号冲突
- `tools/check_rbac_seed_consistency.py` → 通过

那 2 个失败是既有环境项(`test_offsite_document_recognition_adapter.py` 断言请求体
中文原文而 httpx 序列化成 `\uXXXX`),与本次合并无关。
2026-09-12 13:14:57 +08:00