lzf_0626
|
ed59e93b53
|
管理员配置发布:点「内容」后在视口里看不到变化(面板渲染在 20 条列表下方),改为渲染后滚动定位 + 标题用发布编号 + 空列表给显式空态
|
2026-09-15 00:21:24 +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
|
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
|
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
|
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
|
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
|
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 |
|
zhangshy
|
20773453bd
|
修复通用风险问答会话历史丢失
|
2026-09-14 10:08:05 +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 |
|
张胜宇
|
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 |
|
lzf_0626
|
4c1e84b5a2
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-14 01:35:54 +08:00 |
|
lzf_0626
|
eded5896cd
|
fix(admin): 回复模板的 scene 加枚举校验,非法值不再冒成 500
## 问题
`agent_reply_template.scene` 有数据库 CHECK 约束 `chk_template_scene`,只允许
`disclaimer` / `low_confidence` / `compliance_block` / `transfer` / `model_failure` /
`system_busy` / `clarification` 七个值。
而 `ReplyPayload.scene` 只校验长度(`min_length=1, max_length=32`)——
传一个不在列内的场景会**穿过接口校验、撞上数据库约束**,最终以
`500` 冒出:
(3819, "Check constraint 'chk_template_scene' is violated.")
那本该是一次 `422` 参数校验失败。**500 与 422 的差别不只是状态码**:
前者会让调用方以为服务端故障、触发重试与告警,而实际是自己参数错了。
## 改动
`ReplyPayload.scene` 改为 `Literal[...]`(新增 `ReplyScene` 类型别名),
取值与 `chk_template_scene` **逐字对齐**,并在注释里写明这个对齐关系与本次事故。
实测:非法 scene → `422 AGENT_INPUT_INVALID`(不再是 500);合法 scene → `201`。
## 发现方式
这一处是**按接口逐条调用、逐个核对返回**时暴露的 —— 只看代码很难注意到
"schema 的宽松校验"与"数据库的严格约束"之间那道缝。同类风险仍存在:
凡是**表上有 CHECK 而 schema 只做长度校验**的字段,都有同样的 500 风险。
验证:unit+contract 1397 passed;integration 110 passed;ruff 通过;
mypy 251 文件 0 错;e2e 冒烟 40/40。
|
2026-09-14 01:35:34 +08:00 |
|
yuancong_0626
|
295be972d2
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-14 01:23:32 +08:00 |
|
lzf_0626
|
daf73a2865
|
fix(portal): 照接口逐条核对前端,修掉 5 处「照着表结构写、看着像对」的缺陷
做法:把前端 `api-client.js` 注册的端点**按前端完全相同的方式**(同样的路径、参数、
身份)逐个调用,再拿真实返回去核对前端 render 用到的字段。
**这类问题纯读代码看不出来** —— 只有把真实返回和期望字段摆在一起才会暴露。
## 1. 知识库功能实际是坏的(K002 / K003 是裸信封)
`K002` 的成功体是 `{knowledge_ids, filename, chunk_count}`、`K003` 是 `{items, count}`,
**都没有 `data` 信封**。而 `request()` 默认取 `payload.data`(undefined),于是:
- 上传后前端显示「已入库 **0** 块」,而库里其实切了 23 块;
- 列表永远显示「知识库为空」。
端点表里标 `raw: true` 后(与 `V001` 同一做法)两者都正常。
实测:上传 4805 字符的产品手册 → 切 23 块并出现在列表里。
## 2. 委托/成交详情页有 5 行永远显示「--」
前端按 `fin_sim_order` / `fin_transaction` 的**建表字段**写了 `quote_source`、
`nav`、`fee_rate_snapshot`、`confirmed_at`、`auto_confirmed` ——
但这些字段**接口的返回视图没有带**(表里有、返回里没有)。已按实际返回重写字段表,
并在注释里写明"以接口返回为准,不要照表写"。
## 3. 配置项与路由规则的**编辑功能不可能成功**(接口缺口)
`PUT` 硬性要求 `If-Match`,校验的是该行内容的 digest;而这两个资源是 `detail=False`
—— **没有任何端点能返回这个 digest**(列表的 `meta` 只有 trace_id)。
乐观并发在"读不到版本"的前提下等于死锁:**首次编辑必然 409**。
(配置发布能用,是因为它有详情端点 `A003`。)
- 新增详情端点 `A048` / `A049`(`detail=True`),已登记 `docs/05` §19;
- 前端编辑前先 GET 详情取 etag,再带 `If-Match` 提交。
- 实测:编辑配置项与路由规则均 200;**不带 `If-Match` 仍返回 409**,
说明乐观并发没有被削弱。
## 4. 路由规则表单**必然提交失败**
前端固定写 `max_attempts: 2` 且 `fallbacks: []`,而后端要求
`max_attempts ≤ 端点总数`(主 + 兜底)→ 422「重试次数超过端点数量」。
改为 `1` 并注明约束。
## 5. 主端点手填 ID 会 422
后端对不存在/未激活的 `primary_endpoint_id` 直接 422「模型端点不存在或未激活」。
把输入框改成**下拉**,只列 `status='active'` 的端点(数据复用已有的端点列表)。
## 顺带
- `apiClient` 增加 `del()` / `put()`:发出的方法一直由端点表决定,所以 `post('K004')`
也能发 DELETE —— 语义太绕,现在意图与行为一致。
- 清理了测试期间上传的 31 条知识残留(客服会检索到它们),库内恢复到 23 条产品手册。
## 关于"逐条核对"的方法论
前两轮跑出来的 9 个和 5 个"失败"里,**多数是我测试脚本自己的假设错了**,不是前端问题:
`T001` 是 `{account, summary}` 嵌套、`RK002` 的字段叫 `risk_level`、
`RK002/RK004/RK005` 的 limit 上限是 5/10/10(前端传的正是 5/10/10)、
`AD011/A002/A047` 的 data 是裸 list。每一处都回到前端源码确认后才下结论 ——
**先把"我以为"改成"代码里写的"**,否则报告出去的就是假 bug。
验证:unit+contract **1397 passed**;integration **110 passed**;ruff 通过;
mypy 251 文件 0 错;§19 现 93 个端点无重复;e2e 冒烟 **40/40**。
前端等价测试:只读 31 项全绿、写操作(含 ETag 链路)9 项 8 绿 1 项因测试数据过短。
|
2026-09-14 01:19:22 +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
|
e31420df29
|
feat(portal): 补齐四个前端缺口——客户详情、知识库管理、配置项与路由编辑
先审计了全部 **141 个后端端点**:前端注册 68 个,注册的**全部有效**(没有一个打不通)。
本提交补的是其中真正影响可用性的四类。
## 1. 客户端:委托详情(T004)与成交详情(T008)
两个接口后端一直存在,但订单页 / 成交明细页**只接了列表(T003 / T007)** ——
客户点不进任何一条记录,看不到成交价、费用构成、确认时间与行情来源。
改为**行内展开**(点「详情」在原行下方展开,再点收起),不跳页:
- 复用新加的公共样式 `.list-detail`(`common/customer-list.css`),两页共用而不各写一份
- 详情取不到时**不整页报错**:列表本身是好的,只恢复按钮并记一次错误
## 2. 管理员:知识库管理(K002 / K003 / K004)
客服的**全部回答都来自已入库的知识**,而此前**没有任何页面能管理知识库** ——
只能靠命令行脚本 `tools/seed_knowledge_demo.py` 灌数据,管理员既看不到也改不了。
新增「知识库」标签页:文档列表 + 上传(.txt/.md/.docx)+ 失效。两处要点:
- **K003 的成功体是裸的** `{items, count}`、**没有 `data` 信封** ——
`request()` 仍会去取 `payload.data`(那是 undefined),所以列表要两面都兜,
否则永远显示"知识库为空"、而库里其实有数据;
- 上传是 **JSON + base64**,不是 multipart(一期契约如此,见 `knowledge_management.py`)。
## 3. 管理员:配置项与模型路由(A001 / A008–A010 / A018–A020)
此前只能对**已存在**的版本走"校验→审核→激活",**既不能新建版本、也不能往里加配置项**
—— 新建的版本永远是空的、校验必然失败;模型路由规则同样既看不到也改不了。
- 新增「新建配置版本」表单(版本号 / 标题 / 变更说明)
- 每个版本加「内容」按钮(**与状态无关**:草稿阶段就要能加,否则版本永远空)→
展开该版本的**配置项**与**模型路由规则**,两者都支持新增与编辑
- 配置项的「值」按 JSON 输入并在前端校验:与其让后端 422,不如就地拦住并说清哪里不对
- `fallbacks` 暂不在界面编辑(提交空数组),需要时用接口补
这些端点**都已在 `docs/05` §19 有编号**,直接复用,无需新增编号。
## 4. 两个"死端点"查证后**保留**
初查发现 `RK013`(风控日报非流式,已被 RK014 流式取代)与 `ADVISOR_GOAL`
(投顾自己的目标;投顾是员工、没有目标 → 永远 404)注册了却无人调用,一度删除。
但 `tests/unit/api/test_portal_frontend.py` 立刻失败 —— 它把"页面会用到的端点"
固定成一张清单,**注册与调用是两件事**。已恢复注册,并就地注明它们当前无人调用、
但受契约保护。
顺带发现:**`RK013`–`RK015`(风控日报)也不在 §19**,与投顾 AD 段原先的情况相同,
属文档缺口(未在本提交内补)。
## 辅助改动
`apiClient` 增加 `del()` 与 `put()`:真正发出的方法一直由端点表里的 `method` 决定,
所以 `post('K004')` 也能发出 DELETE —— 但读代码的人会以为发的是 POST。
现在意图与行为一致。
验证:unit+contract **1397 passed**(含前端契约 37);integration **110 passed**;
ruff 通过;mypy 251 文件 0 错;e2e 冒烟 **40/40**;相关页面与静态资源全部 200。
|
2026-09-14 00:51:13 +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
|
de55c5c60c
|
feat(advisor): 登记 17 个投顾端点并补管理员复核入口(A047 待审队列)
## 1. docs/05 §19 补登 17 个投顾端点
这批端点此前**只存在于代码中**,§19 一条都没登记;而 §12 写的入口
`/api/v1/advisory-plans/**` 与实际路径 `/api/v1/advisor/**` 也不符(已修正)。
- **A041–A046**:管理员治理(配置回测、画像标签与漂移复核、推荐方案审核与发布)
- **AD001–AD011**:投顾自用。**新开 `AD` 号段**的理由:它与 A 段是两个不同的权限面
—— A 段是 `/api/v1/admin/**` 管理面,AD 段是 `/api/v1/advisor/**` 投顾自用;
混在一个号段里,"这条到底谁能调"就得逐条去读权限列。
- 另加 AD 段说明块:`investment-goal` 的两套权限码(`...:self` / `...:customer`)、
AD006/AD007 虽在投顾路径下却要求 `admin`、灰度开关 `enforce_advisor_rollout` 前置、
幂等范围(AD008/AD009 无幂等头)、以及 404/409 的失败口径。
§19 现为 **90 个端点 / 9 个号段**,无重复。
## 2. 管理员复核入口 + A047 待审队列
**发现一个让审核链路不可达的缺口**:`review` / `publish` 都要求调用方先拿到键
(推荐方案是 `content_id`、方案书是 `goal_no`),而此前**没有任何端点能列出待审内容**
—— 管理员拿不到键,投顾生成的东西就永远停在待审状态。
- 新增 `GET /api/v1/admin/advisor/pending-contents`(编号 **A047**):一次返回两类待审内容。
两类内容的"待审"取值不同(推荐方案 `pending_review`、方案书 `pending`),
只判其中一个会整类漏掉,所以用 `PENDING_STATES` 一并匹配。
- **为方案书一并查出 `goal_no`** —— 它的审核/发布端点(AD006/AD007)按 `goal_no` 寻址,
只给 `content_id` 的话管理员拿到列表也调不动。已由 integration 测试守住这一点。
- 管理员工作台新增「投顾复核」标签页:列出待审内容,支持审核通过 / 驳回 / 发布;
前端按 `content_type` 自动选择端点、寻址键与载荷
(方案书发布要 `{publish: true}`,推荐方案发布不读 body)。
- 发布前校验状态:未审核通过不允许发布,与 `publish_book` 的 `IllegalState` 一致。
## 3. ⚠️ 同时发现:投顾的三个分析功能对投顾本人不可用
`ProductRecommendationQuery` **没有 `customer_id`** 字段,而 `generate` 用的是
`int(context.user_id)`(`product_recommendation_service.py:69`)—— 即**把投顾自己**
当成了服务对象。投顾是员工、没有风险测评与持仓,于是实测:
POST /api/v1/advisor/recommendations → {"status": "profile_required"}
POST /api/v1/advisor/asset-allocation → {"status": "profile_required"}
**组合分析、资产配置、生成推荐草案这三个功能,投顾调用必然拿不到结果。**
这是"投顾功能很奇怪"的直接来源之一。修它要改接口契约(加 `customer_id`、
并确定"投顾能对哪些客户生成"的权限口径),属产品决策,未在本提交内改动。
验证:unit+contract **1397 passed**;新增 integration 用例 2 passed;ruff 通过;
mypy 251 文件 0 错;A047 实测管理员 200(带出方案书的 `goal_no`)、投顾 403。
|
2026-09-14 00:17:46 +08:00 |
|
lzf_0626
|
7c3a832104
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-13 23:56:44 +08:00 |
|
lzf_0626
|
421fed8569
|
feat(advisor): 补全投顾流程入口(确认目标 → 查看方案书),并修复页面缺缓存版本
## 1. 投顾流程此前是断的
投顾工作台只有 4 个「生成草案」操作 + 1 个只读列表,而**确认目标**与**查看方案书**
这两个端点虽然存在却**没有入口** —— 于是目标永远停在 `pending_confirmation`、
方案书永远停在 `pending`。
实测客户 9001 正是如此:`confirmed_at: null`、方案书 `review_status=pending`。
而「录入客户目标」成功后的提示是"已提交,等待确认与审核",**没有下一步可点**。
改动:
- 新增操作卡「目标确认与方案书」:输入客户 ID → 查询目标 → 显示目标状态、
方案书状态、收益区间 / 回撤 / 期限 / 基准 / 确认时间 → **确认目标** / **查看方案书**。
- **录入目标后自动跳到目标状态页**,把确认按钮摆在眼前,而不是停在"已提交"。
- `ADVISOR_CONFIRM_GOAL` / `ADVISOR_GOAL_BOOK` 补登记进 `api-client.js`
(`ADVISOR_CUSTOMER_GOAL` 早已注册却从未被使用)。
- 404「当前投资目标不存在」按**正常情况**处理(给下一步提示),不当故障报警。
⚠️ **审核与发布不在此范围**:`review_book` / `publish_book` 都要求 `admin=True`
(见 `investment_goal_service`),那是管理员的动作,投顾侧到"确认 + 看方案书"为止。
方案书仍会停在 `pending`,除非管理员侧也补上入口。
## 2. 9 个页面的 script 标签缺缓存版本参数
投顾工作台的 script 是 `dashboard.js`(**没有 `?v=`**),另有 8 个页面同样如此
(客户的持仓/下单/流水/盈亏/测评/成交明细、管理员工作台、运营工作台)。
浏览器会一直用缓存的旧 JS —— **改了代码也看不到效果**,这是"功能很奇怪"的一部分。
已统一补上 `?v=20260913`,投顾页的 js/css 提到 `-2`。
## 3. 过程中我损坏过 8 个文件,已恢复
第一次批量补版本参数时我用了 PowerShell `Get-Content -Raw` + `Set-Content`:
**PS 5.1 默认按 ANSI/GBK 读**,把 UTF-8 中文读成乱码再写回
(`角色与权限` → `瑙掕壊鏉冮檺`)并写入 BOM。`tests/unit/api/test_portal_frontend.py`
的断言当场抓到了它(找不到「角色与权限」)。
已 `git checkout` 恢复全部 8 个文件,改用 Python 重做(显式 UTF-8、不写 BOM、
保持原换行),现在每个文件的改动都是 **1 增 1 删**。
验证:`tests/unit/api/test_portal_frontend.py` 35 passed;unit+contract **1395 passed**;
ruff 通过;mypy 251 文件 0 错;确认目标实测 200
(`pending_confirmation` → `confirmed`,写入 `confirmed_at`),
重复确认被状态机以 409 拒绝。
|
2026-09-13 23:56:27 +08:00 |
|
zhangshy
|
2f3138113f
|
Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop
|
2026-09-13 23:52:17 +08:00 |
|
zhangshy
|
2a3146e050
|
增加风控列表总数并优化站内提醒
|
2026-09-13 23:51:45 +08:00 |
|
lzf_0626
|
3ada1f6c87
|
Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
|
2026-09-13 23:38:57 +08:00 |
|
lzf_0626
|
7208713c17
|
feat(portal): 接入历史净值,产品详情页恢复真实走势图
`fin_nav_history` 一直是**空表**,所以产品详情页画不出走势图 —— 此前那条曲线是
前端 `mock-data.js` 里编的 12 个点位,接入公开产品接口时把它去掉了
(走势图最容易被当成真实业绩),页面改为显示"尚未接入"。本次补上完整链路。
## 1. 取数(app/infrastructure/fund_market_adapter.py)
新增 `fetch_nav_history()`:东方财富历史净值接口(`api.fund.eastmoney.com/f10/lsjz`)
分页取序列。
- **为什么不复用 `fetch_kline`**:它走 `push2his.eastmoney.com`,
该域名在本环境实测连接被拒(`RemoteProtocolError`);
- **为什么不复用 `hq.get_southern_fund_nav_history`**:那个函数校验**南方基金白名单**,
而 `fin_product` 里有非南方基金的产品(510300 是华泰柏瑞的),调它会直接 `ValueError`。
⚠️ 实现里踩到一个坑:接口**会忽略请求中的 `pageSize`**(实测固定每次返回 20 条)。
最初按硬编码的 30 判断"是否最后一页",于是 `len(items) < 30` 永远成立、只取到第一页 ——
走势图看上去"有数据",其实只有最近 20 天,而且毫无报错。
改为按首屏**实际条数** + `TotalCount` 推算页数后,同样区间取到 124 条。
## 2. 落库(tools/sync_nav_history.py,新增)
写入 `fin_nav_history`,按 `(product_id, nav_date)` 幂等 upsert。
实测:20 只产品 / **2513 行** / 2026-03-17 ~ 09-13;重跑**新写入 0 行**。
## 3. 接口(P002)
`GET /api/v1/products/{product_code}/nav-history`,编号 **P002**,已登记 `docs/05` §19。
鉴权口径与 P001 相同(要求有效令牌、不校验权限码,访客令牌可用);`days` 有界 1–365。
**表为空时返回 `count=0` 与空数组,而不是报错** —— 调用方据此显示"尚未接入",
**不得回退到编造曲线**。产品不存在或未上市 → `404`(否则前端分不清"没有数据"
和"没有这只产品")。
## 4. 前端
详情页按序列画 SVG 折线,期数标题改为动态("近 N 个交易日")。
表为空时仍显示"尚未接入"占位,并补上此前缺失的 `.detail-chart__empty` 样式。
`product-detail.js` / `.css` / `index.html` 的缓存版本参数一并 bump 到 `-7`。
## 5. 演示数据
`tools/seed_demo_data.py` 增加第 5 步「历史净值」(现 **11 步**),
否则换台机器演示时走势图又会是空的。
验证:P002 实测 515450 / 510300 各 120 个净值点;ruff 通过;mypy 251 文件 0 错;
unit+contract 1391 passed;integration 108 passed;e2e 冒烟 40/40。
|
2026-09-13 23:38:13 +08:00 |
|
zhangshy
|
38fe6f3689
|
合并主项目最新改动并解决风控前端冲突
|
2026-09-13 23:22:12 +08:00 |
|
zhangshy
|
4f75d32ea1
|
优化风控详情展示与前端交互
|
2026-09-13 23:19:52 +08:00 |
|
张胜宇
|
5929eb151b
|
merge: integrate latest qyqy_develop changes
|
2026-09-13 23:10:46 +08:00 |
|