Commit Graph
96 Commits
Author SHA1 Message Date
wangjianlong_0626 8cb75992e6 Merge remote-tracking branch 'origin/qyqy_develop' into nl-merge-colleague 2026-09-12 14:21:34 +08:00
wangjianlong_0626 451aa4e915 fix(profile): ProfileAssemblyService 漏写 current_customer_id(唯一键失效的另一处)
## 现象(2026-09-12 端到端跑通后查数据时发现)

客户 9102 的画像快照状态**彼此矛盾**:

| version | is_current | current_customer_id |
|---|---|---|
| 2 | `0` | `9102` ← 清旧时没清空 |
| 3 | `1` | `NULL` ← 建新时没写入 |

## 根因

`ProfileAssemblyService._write_snapshot` 把 `current_customer_id` **当成了生成列**:

- 方法 docstring 原文写着"唯一键 `uk_profile_snapshot_current` 建立在**生成列** `current_customer_id` 上"
- 因此两处都不赋值(以为 DB 会自动填)

**但该列不是生成列** —— `alembic/baseline_generated.sql` 与真实库都是**普通可空列 + 唯一键**,
`app/model/profile.py` 的模块 docstring 第 2 条已明确:"当前版本必须由写入方**显式写入**客户 ID
(历史版本写 NULL),才能保证「每个客户最多一条当前快照」"。

后果:唯一键**形同虚设**(多个 NULL 不冲突)⇒ 不变式失效;且旧版本残留的值
一旦与新版本补上的值相同,就会**直接撞唯一键**。

> 这与本线先前修的 `CustomerProfileCandidateService._write_profile_snapshot` 是**同一个缺陷的另一处**
> —— 当时只找到一处,这次是靠真实链路跑出数据后核对才暴露出来。

## 改动

`app/service/profile_assembly_service.py`:

- 旧版本:`is_current = False` 的同时 `current_customer_id = None`
- 新版本:`is_current=True` 的同时 `current_customer_id=customer_id`
- 订正方法 docstring 的错误认知("生成列"→ 普通可空列 + 唯一键),并写明后果

## 已有数据订正

新增 `tools/fix_profile_snapshot_current.py`(**默认 dry-run**、幂等、`--apply` 才提交):

1. 先清空 `is_current=0` 却残留值的行
2. 再补写 `is_current=1` 却是 NULL 的行
3. **顺序要紧**:反过来的话第 2 步会与残留值撞唯一键

本机实测:清空 1 行、补写 1 行,复核两类异常均归零。

## 验证

- `mypy app` → 0 错 / 245 文件
- `pytest tests`(全量)→ `2 failed, 1418 passed, 1 skipped`
  (2 个失败为既有环境项:httpx 把中文序列化成 `\uXXXX`,非本次引入)
- 端到端:真实对话 → 记忆抽取 → `memory_unit` 落库已实测通过(客户 9102
  `preference:risk_level = "低风险"`,候选态)
2026-09-12 14:20:38 +08:00
lzf_0626 c8cdc06e80 Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop 2026-09-12 14:18:06 +08:00
lzf_0626 ffbcc229c2 fix: 删掉 NL 合并后残留的未使用变量 role_ids(ruff F841) 2026-09-12 14:17:42 +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 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
yuancong_0626 a0a489a055 chore: preserve RBAC seed consistency checks 2026-09-12 13:03:16 +08:00
张胜宇 c5adf01603 test: 补端点编号守卫与权限判定的 HTTP 层用例
补两道守卫,覆盖 2026-09-12 那次合并评审暴露出来的两个盲区。

一、`docs/05` §19 端点编号唯一性(新增 `tools/check_docs_endpoint_ids.py`)
- 背景:`tools/check_authoritative_docs.py` 只校验 `docs/` 的**文件名编号**,不校验
  §19 的**端点编号**。两条线各自新增端点时都占了 `A034`/`A035`,合并后 §19 同时
  存在两个 `A034` 与两个 `A035`,而文档守卫照样通过 —— 编号复用会静默遗留。
- 口径要点:**不枚举前缀白名单**,前缀从数据里归纳后连同数量一起输出。同一件事上
  还踩过一次"扫描正则写成 `[AMKCS]`,漏掉 `O`/`R` 两段,把 62 个端点报成 55 个"——
  漏掉的号段一旦被复用,脚本仍会报"重复 0"。所以覆盖报告会打印
   `62 个端点编号 / 6 个号段(A×40、C×7、K×4、M×4、O×3、R×4)`,让漏扫本身可见。
- 只读、不依赖任何环境变量(纯解析文档),可在裸检出环境直接跑。

二、端点权限判定的 HTTP 层用例(新增 `tests/unit/api/test_permission_enforcement.py`)
- 背景:既有用例都通过 `build_request_context` 覆写注入**已经带好权限**的上下文,
  只覆盖"有权限能通",覆盖不到"缺权限必须被拒"。而"权限码在库里根本不存在"这类
  环境数据问题(本次三个新权限码)恰恰只会在这一层暴露:权限判定发生在身份解析之后,
  服务层测试自己构造 `RequestContext`,权限字段由测试塞入,所以全绿也照样漏。
- 覆盖客服二期三个权限码对应的 5 个端点:
  `handover:read`(工单列表/详情)、`memory:candidate:review`(候选列表/审核)、
  `memory:candidate:confirm`(用户确认)。
- 正反双向断言:缺权限 → 必须 403 且错误码为 `AGENT_PERMISSION_DENIED`;
  带权限 → **不能**再是 403(这一条把端点要求的权限码钉住,改动即红)。
- 只替换两处边界:`build_request_context`(跳过 JWT 与身份库)与
  `AuthorizationService` 的审计落库(内存替身);真实路由、真实权限判定与真实 403 信封。
- 已做反向验证:给一个不存在的权限码时,5 个端点全部被拒(403),确认正向断言非空过。

验证(隔离 worktree,基线 `origin/qyqy_develop` = 4d8edb4):
pytest tests/unit tests/contract -> 1294 passed, 2 skipped, 0 failed;
ruff check app tests tools alembic 全通过;mypy app 244 源文件 0 错;
check_authoritative_docs(50 份文档无撞号)与本脚本均通过。
2026-09-12 12:57:16 +08:00
yuancong_0626 a501060e50 merge: merge yc into qyqy_develop 2026-09-12 12:50:14 +08:00
lzf_0626 4d8edb4b7f chore: 号段一致性自检固化为 tools 脚本并纳入门禁;AGENTS.md 更新主分支口径、Agent/工具清单与测试基线 2026-09-12 12:29:20 +08:00
lzf_0626 4cc0ecdea9 fix: 种子改用 UPSERT 建用户且不覆盖密码,解除投顾 FK 导致的跑不通;记录 RBAC 对齐最终状态 2026-09-12 12:22:32 +08:00
yuancong_0626 91f5373cfb 袁聪的第三次提交,API接口完善 2026-09-12 12:20:01 +08:00
Windows 3751891574 Merge branch 'qyqy_develop' of http://47.106.207.27:3000/AI260626/group_fqcd_jr into lzl_qyqy_integration 2026-09-12 12:10:41 +08:00
Windows f094cbeae1 fix: harden advisor market history dual-source sync 2026-09-12 12:10:10 +08:00
lzf_0626 cdb2de3e45 chore: 权限号段根治——9041-9046 并进种子;修正 grant 脚本与种子 9020-9034 的 id 映射冲突 2026-09-12 12:09:55 +08:00
lzf_0626 441364437c merge: 合并 ZSY 的客服 Agent 接入(访客身份、画像候选、转人工工单)—— PR #7 2026-09-12 12:05:25 +08:00
lzf_0626 ade5e0c085 回复 ZSY 的底座扩展确认 v2:逐行核验两句确认;指出 docs/05 端点编号撞车、三个新权限未随代码合并;补权限种子脚本 2026-09-12 11:46:47 +08:00
wangjianlong_0626 8a0cbab636 fix(memory-projection): 订正 outbox 取值口径并接通画像投影链路
背景:memory_sync_outbox 这条链此前**完全没有消费者**,且生产端照 docs/00 §6.4.6
写成大写 MILVUS/NEO4J + 中文「待处理」,而消费端按 target_store 的**值**分派 handler、
且只领 status in {pending, failed} —— 两个条件都不满足,事件任何消费者都领不到、
永久滞留且不报错(唯一键 (event_uuid, target_store) 对大小写无约束,MySQL 也不报错)。
根因是代码与测试都硬编码字面量,所以测试跟着一起错、谁也没拦住。

订正
- profile_generation_service:取值改为全仓一致的小写(milvus/neo4j/upsert/pending)
- 测试改为引用常量并断言消费端契约,不再硬编码(硬编码是本次跑偏的直接原因)
- 新增契约回归测试:断言大写值分派不到 handler、会进死信,谁改回大写立刻红
- 新增 tools/normalize_memory_sync_outbox.py:订正历史脏行(默认 dry-run、幂等)

接通投影链路(此前零消费者)
- 新增 Milvus 集合 user_long_term_memory_v1 及建集合工具(幂等、不覆盖已有集合)
- 新增 MilvusProfileProjection / MilvusProfileVectorClient,并修掉移植带来的两处必炸点:
  customer_id 由「必须 int」放宽为接受数字字符串(本仓所有生产者都写 str,
  不放宽则每个事件必然失败);不可投影的 memory_key 由「整批 raise」改为跳过留痕
  (否则一条 constraint: 记忆毒死该客户整批,而受控词表 13 个键里有 7 个不满足前缀)
- 新增 MemorySyncOutboxWorker(领取/指数退避/死信骨架保留原样)并接入 WorkerRuntime
- milvus → 向量投影;neo4j → 复用主干 ProfileGraphProjectionService(方案 A,
  不引入第二套投影,避免同一事实在图中两种说法、违反主干既有的只投影已确认事实的不变式)
- 生产端从 memory_unit(status=active) 组装 memory_sources,随事件带上确定快照
- 前置移植 conversation_privacy:写外部存储前脱敏手机号/证件号/银行卡等

验证
- 新增 17 个单测;全量 2 failed, 1307 passed, 2 skipped
  (2 个失败为既有环境项:断言请求体中文原文而 httpx 序列化成 \uXXXX,非本次引入)
- mypy app → 0 错(227 文件);audit_schema → 89 张业务表无缺失/意外,未改动表结构
- 真机:真实 embedding(1024 维) + 真实 Milvus 写入并回读通过
- 整合链路(测试记忆 → 生产端组装 → outbox → 消费端投递 → Milvus 回读)通过,
  且 MySQL 已回滚、Milvus 无残留

文档
- 新增 docs/32-记忆投影链路实现说明.md:真实口径、根因、契约与验证证据(供接手)
- AGENTS.md:新增该易错点;新增 Windows 中文输出乱码的正确命令(-X utf8);
  校正测试基线与 mypy 文件数

未做:未改 docs/00 基线、未动数据库迁移、未改投顾线代码、未启动常驻 Worker。
遗留:投顾线两处生产者的 payload 缺 memory_sources,会被消费至死信,待架构师确认是否投影。
2026-09-12 10:45:40 +08:00
张胜宇 e85989b344 merge: integrate latest qyqy_develop (auth login + RBAC read) into ZSY branch
Incremental merge on top of ef701c8, which already integrated the earlier qyqy base bbf623a. qyqy_develop only added commits on top of bbf623a, so this merge is conflict-free. Incoming: account/password login (POST /api/v1/auth/tokens), RBAC read-only query API, rate limit dependency, login test console and user management tools. Additive changes in app/main.py, requirements.txt and pyproject.toml from both sides are all preserved. ZSY side capabilities (visitor tokens, customer service agent, knowledge retrieval, profile projection) are unchanged.
2026-09-12 10:37:53 +08:00
lzf_0626 0358ea519f 投顾演示数据准备脚本:配好测评与归属,方案那步如实报告卡在证据来源
投顾线合并后 21 张 advisor_* 表全空,导致 published 返回 []、投顾 customer_ids 为空、
客户画像因缺测评返回 403。本脚本按依赖顺序补能自己造的那几样:

1. 产品目录:调 tools/import_hq_test_products.py 取真实行情(实测 19 个 ETF/LOF);
2. 客户风险测评:给 9001 补一条 C5、有效期一年(answers 里注明是演示数据);
3. 客户-投顾归属:9001 → 9020(assigned_at 往前留 5 秒,避开 DATETIME(0) 舍入陷阱)。

**方案那步不配** —— 缺的是证据来源不是技术:authoritative_tradable_products 是
fail closed 的(Missing or unverified evidence excludes a product),而造
advisor_product_suitability_reference 必须带 source_url 与 document_sha256,也就是真实的
销售适当性披露 / 基金合同文件(import_product_governance_reference.py 的两个 CSV,
不在仓库里,属环境数据)。脚本**不伪造这两个字段**(证据链红线),只检查并打印真正的解法。

实测:cust_t 的 /users/me/memory-profile 由 403 变 200(测评生效);admin 视角看 9020 的
customer_ids = ["9001"](归属生效);advisor 的 published 仍为 [](符合预期,尚无方案)。

⚠️ 顺带发现一处口径不一致:画像 fin_customer_profile.investor_type 是 C2,而新补的测评是
C5 —— 前者决定画像展示、后者决定适当性裁决,两者会同时出现在界面上。演示前需要统一
(要么把测评改成 C2,要么把画像也改成 C5)。
2026-09-12 10:34:28 +08:00
张胜宇 ef701c844c merge: integrate ZSY customer service and profile capabilities 2026-09-11 22:31:51 +08:00
lzf_0626 306a514316 修正登录测试台的探针:投顾那个按钮其实该是客户视角
/api/v1/advisor/recommendations/published 的语义是"**我(客户)自己**已发布的方案" ——
服务端按 `customer_id == 调用者 user_id` 过滤(权限码 `product-recommendation:read:self`
的 `:self` 正对应这一点),所以**投顾调它必然为空**:筛的是 customer_id = 投顾自己的 id。
把它标成 customer 角色,免得再用投顾账号点它、然后怀疑权限。

查证过程中确认了三处数据空缺(投顾合并后那 21 张 advisor_* 表是新建的):
- client_facing_content = 0 行(没有任何投顾方案)
- sys_customer_assignment = 0 行(投顾没绑定任何客户,故 customer_ids 为空)
- fin_risk_assessment 里没有 9001(客户画像的前置"完成开户风险测评问卷"未满足)
2026-09-11 22:28:32 +08:00
lzf_0626 636892c8b1 登录测试台支持投顾:快捷按钮 + 只读探针
- 快捷填充加 advisor_t / abc12345;
- 探针加「已发布投顾方案」(GET /api/v1/advisor/recommendations/published),按 advisor 角色启用。

实测同一接口的对照:投顾令牌 200(data: []),客户令牌 403 —— 权限边界正确。
2026-09-11 21:50:39 +08:00
lzf_0626 5018f11843 增加投顾(advisor)角色;修正登录测试的错误假设;修投顾带入的 2 处文档重号
## 投顾角色

投顾线合并后,bootstrap.py 有 10 处 allowed_roles 引用 advisor,
financial_nl2sql_service.py:272 还硬编码检查 {"advisor","operator","admin","super_admin"},
promotion_material_service.py:164 按 "advisor" in context.roles 走业务分支 ——
但 sys_role 里没有这个角色、sys_permission 里也没有投顾那 16 个权限码(种子只建到 9019)。
表现是所有投顾接口 403,而报错看起来像"权限配错了",实际是角色根本不存在。

- tools/grant_advisor_role.py:建 advisor 角色(id=9004,避开种子的 9001-9003 重建范围)
  + 16 个投顾权限(id 9020-9035)+ 授权(advisor 拿 10 项工作流、admin 补齐 16 项)。
  只增不删、可重复执行、带 --dry-run。
- tools/create_test_user.py:ROLE_IDS 加 advisor。
- 先跑 alembic upgrade head:补 21 张 advisor_* 表,业务表 68 → 89,审计通过。

权限划分:投顾工作流 10 项(read:self / generate:self / review / publish)给 advisor;
治理类 6 项(product-governance:*、profile-governance:*、asset-allocation:backtest)只给 admin。
review/publish 也给 advisor,与既有决策一致(此前已裁定不做双人复核)。

验证:advisor_t 登录 200,roles=['advisor'] data_scope=all 权限 10 项;
用它查 RBAC 清单得 403(没有 audit:read),边界正确。

⚠️ 与种子的冲突:seed_test_rbac.py 是 DELETE 重建语义,其
DELETE FROM sys_permission WHERE id BETWEEN 9001 AND 9099 会清掉本脚本建的权限。
要把投顾权限固化,应并进 seed_test_rbac.py 的 PERMISSIONS 常量。

## 修正登录测试的一个错误假设

test_issued_token_actually_works_on_a_real_endpoint 原本用客户的
/users/me/memory-profile 验证令牌可用,投顾合并后它返回 403。追下去发现**与令牌无关**:
那个接口对客户有业务前置"请先完成开户风险测评问卷",而演示客户 9001 没有测评记录。
是我的测试选错了验证端点,把"业务前置未满足"误判成"令牌坏了"。

- 改用管理员令牌调 /api/v1/admin/roles(需要 audit:read,走完整鉴权链路),
  并补一条反向对照:不带令牌必须 401,否则那个 200 说明不了令牌有效。
- 把那个业务前置单独写成一个用例,让后来者一眼看到条件,而不是反复怀疑令牌。

过程里我先按控制台乱码猜了两次失败原因,都不对;最后把响应抓成 UTF-8 文件才看到真实
消息。教训记下:不要读乱码猜消息。

## 修投顾带入的 2 处文档重号

21-投顾Agent迁移TODO.md → 30-…、22-投顾Agent灰度与回滚操作手册.md → 31-…
(沿用 NL 那次让号的先例:既有文档更早、引用更多;且这两份新文档没有被任何地方引用。)
文档守卫:40 份无编号冲突。

验证:ruff 干净 / mypy 228 文件 0 错 / 文档守卫 40 份无冲突 /
unit+contract 1207 passed(0 failed)/ integration 99 passed / 业务表 89 张。
2026-09-11 21:49:46 +08:00
lzf_0626 c4a73b735e Merge remote-tracking branch 'origin/qyqy_develop' into qyqy_develop
# Conflicts:
#	app/main.py
2026-09-11 21:41:04 +08:00
lzf_0626 e29fce4012 新增登录测试台:一个记事本级别的前端,用来在浏览器里验证登录
tools/login_console.py —— 浏览器打开 http://127.0.0.1:8099 即可测。

与 chat_console.py 的关键差别:**它走真实登录接口**(POST /api/v1/auth/tokens)拿令牌,
而不是自己签。chat_console 是当初还没有登录接口时的权宜做法,这个测的是真链路。

页面能做的:
- 三个快捷填充按钮(客户 cust_t/123456、员工 risk_t/666666、管理员 admin_t/88888888);
- 登录后显示用户、角色标签、data_scope、令牌有效期(令牌只显示前 24 字符);
- 按角色列出可调的**只读**接口按钮(我的画像 / 风控概览 / 预警列表 / 角色清单 /
  管理视角看某人的身份),一眼核对"登录给的 roles"与"接口实际放行"是否一致;
- 把真实状态码与响应 JSON 原样摊在页面上,并写明 401 涵盖三种原因、429 是登录限流。

实现上不动 app/ 一个字节:内层用 create_app(),本进程只做两件事 —— 提供静态页、
把 /api/* 同源转发(httpx.ASGITransport 进程内调用,不起第二个服务)。同源转发避开 CORS,
也免得浏览器直连主服务;只监听 127.0.0.1,私钥始终留在服务端。

实测:页面 200;通过代理用 admin_t 登录拿到真实 JWT(sub=9003);错密码 401。

顺带说明一条边界变化:docs/24 当初拒绝"给底座加 dev token 端点",理由是那种端点等于把
**任意身份**开放给任何能访问服务的人。现在有了密码校验,浏览器拿令牌不再等于
"谁都能冒充任何人",那条顾虑已消除 —— 所以这个页面不是绕过安全设计,而是设计补齐后的正常用法。
2026-09-11 21:26:45 +08:00
lzf_0626 edd8c53c3a 修正 create_test_user 里写反的理由:sys_user_role 有唯一键,先清后插是为了支持改角色 2026-09-11 21:17:49 +08:00
Windows bbf623a464 merge: integrate advisor capabilities on latest qyqy base 2026-09-11 21:03:58 +08:00
lzf_0626 b1429aa364 登录接口配套:加人工具 + 给组员的转交文档(docs/29)
- tools/create_test_user.py:一条命令建"可登录的测试账号"(用户 + 角色 + bcrypt 密码),
  并用 IdentityService.resolve 打印**真实解析结果**。sys_user / sys_user_role 没有 ORM
  模型、全靠裸 SQL,手写容易漏必填字段;更要紧的是 assigned_at 那个静默陷阱(见下)。
  重复执行同一 --id 是覆盖语义,改角色也用它。
- tools/set_user_password.py:hash_password 改从 auth_service 取,消除第二份实现。
- app/service/auth_service.py:新增 hash_password,与 verify_password 放在一起,
  让"写密码"和"校验密码"永远同一套算法。
- docs/29-Agent组员登录接口使用说明.md:给组员的转交文档(接口契约、加人步骤、
  前端接入示例、常见问题、当前边界)。

文档里专门写清三条最容易踩的:

1. 登录用 username 而不是用户 id —— 演示账号是 cust_t / risk_t / admin_t,
   不是 9001/9002/9003。这条不写明,联调时一定有人按 id 试。
2. sys_user_role.assigned_at 的 DATETIME(0) 毫秒舍入陷阱:落在未来会让账号
   "登录成功但 roles=()",**不报错**。create_test_user 统一往前留 5 秒。
3. roles / data_scope 只用于前端分流界面,不是权限凭证 —— 鉴权每次请求查库解析,
   所以权限变更立即生效,前端也不该拿它们做安全判断。

另:create_test_user 的 ON DUPLICATE KEY UPDATE 用 MySQL 8.0.19+ 的 `AS new` 别名语法,
避开已弃用的 VALUES()(实测本机 8.0.27 会打弃用警告)。

验证:文档守卫 38 份无编号冲突 / ruff 干净 / mypy 183 文件 0 错 /
登录集成测试 10 passed。
2026-09-11 20:50:41 +08:00
lzf_0626 01ee68034d 新增登录接口:账号密码换访问令牌(POST /api/v1/auth/tokens)
背景:客户 / 员工 / 管理员三种身份此前无法区分。但区分逻辑其实早就完备 ——
bootstrap.py 里各 Agent 的 allowed_roles 一直是分开的(CustomerServiceAgent 只要
customer、RiskAgent 要 risk_operator/admin、PlatformProbeAgent 只要 admin),
唯独缺"怎么证明你是谁";sys_user.password_hash 字段也一直存在,只是全是占位符
(种子写 'x'、worker 身份写 !worker-only-no-password-login!),从没写过真实密码。

实现:
- app/service/auth_service.py:bcrypt 校验 + 签发只含 sub 的 JWT + 审计。令牌里只放 sub
  是刻意的:角色/权限/数据范围由 IdentityService 每次请求查库解析
  (identity_repository.load_context),权限变更因此立即生效,现有鉴权链路一行未改。
- app/api/controllers/auth.py + app/api/schemas/auth.py:POST /api/v1/auth/tokens,
  响应含 roles/data_scope 供前端决定进哪个界面(鉴权仍以库里实时数据为准)。
- tools/set_user_password.py:设密码(客户 123456 / 员工 666666 / 管理员 88888888)。
  ⚠️ 脚本与文档均标注"仅限演示环境",这三种弱口令上线前必须更换。
- pyproject / requirements 加 bcrypt(cryptography 只用于 JWT,不提供密码哈希)。

安全约定(逐条有实现与测试):失败不区分原因 —— 用户不存在/密码错/账号停用返回同一条
401,否则接口就成了账号枚举器;用户不存在时也跑一次 bcrypt 以抹掉时序差异;
成功与失败都写 interaction_audit(actor_id 可空正是为失败场景准备的);绝不记录密码。

过程中踩到一个自己挖的坑:给登录路由挂了通用的 enforce_rate_limit,而它声明依赖
build_request_context ⇒ 变成"要登录先登录",所有登录都 401。改为新增
enforce_login_rate_limit:按客户端 IP 独立限流(60 秒 10 次)、不依赖认证上下文。
集成测试据此调整:注入恒放行替身隔离跨用例的计数累积,同时保留一个恒超限用例验证闸门
确实会拦 —— 不能因为加了替身就把这道防线测丢。

接口登记:docs/05 §19 加 A034;并更新 §11 —— 那里原写"JWT 签发、刷新、注销由统一身份
认证模块负责,Agent 平台不重复实现",现注明平台只做登录这一步,刷新/注销仍归该模块。

验证:ruff 干净 / mypy 183 文件 0 错 / 文档守卫 37 份无重号(此前因 docs/21 重号失败)/
unit+contract 1140 passed / integration 90 passed。
2026-09-11 20:43:21 +08:00
lzf_0626 62501747a6 新增 sys_user 认证现状探查(判断"账号密码登录"可行性)
tools/probe_auth_state.py:只读统计 sys_user 的 status / user_type 分布与 password_hash
的格式特征(只输出前缀与长度,不输出完整值)。

结论(docs/evidence/auth-state.json):库里 5 个用户,password_hash 全是占位符 ——
4 个是 'x'(tools/seed_test_rbac.py 写入),1 个是 '!worker-only-no-password-login!'
(alembic 迁移写入的哨兵值,名字本身就表明"不用于密码登录")。
has_real_password_hash = false。

⇒ 在本项目做"账号密码换令牌"不是"加个端点"的事:没有密码可校验,等于要新建一套密码体系
(选算法 + 加依赖 + 定义写入流程 + 改上游数据),而 password_hash 是基线已有字段
(规则 4:不得改变既有业务含义)。docs/05 §11 也已明确该职责属统一身份认证模块。

顺带记录一个数据质量问题:user_type 取值不统一(employee / 员工 两种写法并存)。
2026-09-11 20:30:27 +08:00
lzf_0626 f09ea9e988 Merge origin/NL_develop:客服画像出口、知识管理三端点、合规语境与知识向量链路
NL 线(含其并入的袁聪场外/推广域)。唯一冲突是 .gitignore —— 双方都往同一区域加了
.workdir/,取对方版本(他的更完整,含 .tmp/ 与说明),顺带修掉我之前用
Add-Content -Encoding utf8 造成的编码混合(read 工具当时报 invalid UTF-8)。

合并后修的问题 —— 都不是"改别人业务逻辑",是让门禁能绿:

1. 缺运行依赖 python-docx。document_parser.py 解析 .docx 用它,但 requirements.txt 与
   pyproject.toml 都没声明 —— 别人环境跑知识入库会直接
   ModuleNotFoundError: No module named 'docx'。已补声明。
2. ruff 7 项:其中 tests/conftest.py 的 F821 Undefined name 'Path'(他的 tmp_path 修复
   写了字符串注解 "Path" 却漏 import,运行时不求值所以没炸,但 mypy/ruff 会抓)、
   tools/publish_customer_service_config.py 的 F841 inherited_keys 死变量(他改同 key
   覆盖、换成 inherited_only 后忘删旧的)、3 处 E501,另 2 项 ruff --fix 自动修复。
3. 合规基线种子未跑:integration 的 test_compliance_seed_mysql 4 个用例要求
   agent_negative_word 有 7 条 active 且已复核、agent_reply_template 覆盖 6 场景。
   跑 tools/seed_compliance_baseline.py(11 条 active 规则 / 6 个场景模板)后 80 passed。

验证:ruff 干净 / mypy 180 文件 0 错 / unit+contract 1140 passed /
integration 80 passed / 表数 68(alembic 已在 20260911_merge_risk_heads)。

唯一失败 tests/unit/repository/test_fund_readonly_contract.py 是双方一致的既有缺陷:
它断言 Base.metadata 里的 fin_* 表集合,而实测为空集 —— 即该测试依赖别的测试先导入模型的
副作用,单独跑必失败。NL 方也明确"不修不报",此处照办,仅记录。
2026-09-11 20:22:59 +08:00
lzf_0626 9957ddc191 Merge pull request 'NL_develop 评审答复与画像白名单补发脚本' (#6) from qyqy_develop_1 into qyqy_develop
合并 qyqy_develop_1 第四轮工作:NL_develop 评审答复、画像工具白名单补发脚本、docs/08 §8。
2026-09-11 19:58:29 +08:00
Windows fbb1171a45 feat: add quote sync command 2026-09-11 19:51:51 +08:00
lzf_0626 6c09cdecbd 新增补发画像工具白名单的脚本(dry-run + 合并前防呆)
tools/publish_profile_tool_whitelist.py:把 query_customer_profile 加进
customer_service:faq,供 NL_develop 合并后在本环境补发配置。

关键设计都来自这轮评审核实到的事实:

1. 继承走 ConfigReleaseService.effective_snapshot(),一次拿全三张受管表
   (platform_config_item / prompt_template_version / model_routing_rule)。
   既有的两个 agent_tools 发布脚本都绕开它、自己写 SQL 只查第一张 —— 用它们发版
   会把 201 里那条客服闲聊提示词一起清掉,而 Agent 侧有兜底、功能看着正常、没有告警
   (这个事故在本项目真实发生过一次)。
2. 同 key 覆盖:继承项里的 customer_service:faq 必须被本次新值覆盖,否则旧值
   ["search_knowledge"] 会被 admin 端校验拦下、整次发布失败。
3. 提示词搬运重分配 version(唯一键含 version),并按 PromptPayload 的 9 个字段挑字段、
   归一化 input_schema/output_schema —— 漏带就是静默丢失。
4. 前置防呆:admin 端校验「配置 ⊆ 代码 allowed_tools」(admin_service.py:219-220),
   合并前跑必然 422,而报错只有"配置超出 Agent 工具上限"。脚本先查代码上限:
   dry-run 给警告后继续预览,正式跑直接中止。
5. JSON 列归一化:SELECT * 读出来的 JSON 列可能是字符串,不解析会静默走进
   "生效版本里没有该 key"的分支(本脚本第一版就是这么错的,诊断打印才定位到)。

实测:--dry-run 打印 9 条配置项 + 1 条提示词(v2→v3 重分配)与唯一变化;
正式跑因代码尚未合并而中止,未产生任何写入。
2026-09-11 19:51:44 +08:00
Windows b6429e0e9c feat: add advisory product comparison tool 2026-09-11 19:20:32 +08:00
qyqy 7677aeaee1 merge: 并入同事的场外申购/推广/行情/NL2SQL 线(11 提交、334 文件)
冲突仅 3 个文件,全部取并集(双方都没有需要丢弃的改动):
- app/main.py:import 双方路由(我方 knowledge_management + 同事的 offsite_fund/
  promotion_material);include_router 段本已自动合并
- app/service/agent/bootstrap.py:import 与工具注册均取并集
  (query_customer_profile + query_financial_data 都注册)
- tests/integration/test_config_release_mysql.py:outbox 清理同时保留
  架构师的 event_type 限定(防误删其它域 outbox 行)与同事新增的 peer_release_id

同事这轮带入:11 个 alembic 迁移(建 offsite_* / promotion_* 等表)、
场外申购与推广素材 Agent、financial NL2SQL 工具。
注意:本库尚无 offsite_*/promotion_* 表,跑相关测试前需要执行 alembic upgrade。

边界核对:同事的场外代码未写入场内交易表(fin_sim_order/fin_capital_flow/fin_cash_ledger),
符合 AGENTS.md 规则 8。
2026-09-11 18:56:26 +08:00
Windows 930dd59d67 feat: complete advisory market data refresh pipeline 2026-09-11 18:39:57 +08:00
Windows 95633188f1 test: extend advisor e2e fixtures 2026-09-11 17:59:47 +08:00
张胜宇 a769130658 feat: add profile candidate review workflow 2026-09-11 17:47:06 +08:00
yuancong_0626 3f7c5ca74f merge: 同步 origin/qyqy_develop(风控扫描、知识检索、客服 Agent、协商等)
冲突处理:均为双方各自新增,按并集保留——
- .gitignore:本地 data/logs 忽略项 + 远端 .dsh-drop/
- app/core/config.py:offsite/promotion 与 risk_scan 配置项并存
- app/service/agent/bootstrap.py:场外/推介/风控/NL2SQL/知识检索 工具与 Agent 全部注册
- app/worker/__main__.py:场外邮件 Worker 接线 + runtime 关系服务/投影清理注入并存

收尾:新增 20260911_merge_risk_heads 收敛迁移双 head;按 docs/21 生成 config/jwt/dev 开发密钥。
2026-09-11 17:32:47 +08:00
yuancong_0626 61861cdef6 袁聪merge:合并分支 2026-09-11 17:26:18 +08:00
Windows 3e4f21388c test: complete advisor full-stack acceptance record 2026-09-11 17:19:40 +08:00
yuancong_0626 fd9598efdf 袁聪的第二次提交,项目已完整 2026-09-11 16:57:47 +08:00
lzf_0626 927a6fecc0 评审 NL_develop 交付说明:三个环境前提必须先对齐
产出 docs/NL_develop交付说明-评审意见.md(可直接转给组员),以及一个只读探查工具
tools/probe_knowledge_collections.py(Milvus 集合 schema 与行数)。

核查中发现的硬矛盾,都有可复核的证据:

1. 配置库不是同一个。对方说 active=216、合并前生效版本是 186;我这边实测 active=201,
   最近 5 条 id 就是 201/198/197/196/195 —— release.id 自增,同库不可能一边 216 一边 201。
   所以他以为已发布的 customer_service:faq=[search_knowledge, query_customer_profile]
   在这边并不存在,而他的画像出口复用的正是这个 key ⇒ 合并后被 ToolExecutor 失败关闭。
   同理他说的 fund_query_demo:fund_quote 缺失,我这边是存在的。

2. Milvus 也不是同一个。对方说现库字段是 knowledge_id/snippet、无 visibility、行数
   106/177/73;我这边实测是 doc_id/content/chapter/section/.../visibility 共 15 个字段、
   行数 125/297/214,且与 knowledge_search_service.py 的 OUTPUT_FIELDS 逐字一致。
   他这次的字段映射改动(doc_id→knowledge_id)在这边会直接报 field knowledge_id not
   exist,把客服知识检索整条打挂 —— 比他自述的"关闭 visibility 隔离"严重得多。
   建议不是 A/B/C 三选一,而是第四种:运行时探测字段名,两套 schema 都能跑。

3. 解释器不同。他用 .venv(项目里不存在,那是他机器上的 gitignore 目录),约定是
   D:\conda\envs\jr_py313。所以"mypy 151→181"跑不到本基线 —— 这边是 138 文件 0 错。

另指出:docs/26-JWT密钥管理与轮换.md 会与已存在的 docs/21-JWT密钥管理与轮换.md 重复,
建议并入 21;驳回删除 docs/04/06/10/13/99。

四项待裁决的答复:同意 agent_type 方案(要求补审计);字段映射改为运行时探测;
驳回删除编号文档;26 并入 21。
2026-09-11 16:51:52 +08:00
lzf_0626 5bbf8481ef Merge pull request '信封补齐、适当性矩阵修正与 Worker 失败原因落库' (#5) from qyqy_develop_1 into qyqy_develop
合并 qyqy_develop_1 第三轮工作:meta 信封补齐与公共化、适当性按第十二条匹配矩阵、Worker 失败原因区分固定文案与异常消息。
2026-09-11 16:42:29 +08:00