Files
wecom_it_smart_desk/docs/03-测试文档/03-功能测试用例/TC-REQ-通用-005-选项选择持久化-v1.0.md
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
2026-08-07 22:31:32 +08:00

613 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 测试用例 - 选项选择持久化
> **REQ 编号**: REQ-通用-005
> **版本**: v1.0
> **日期**: 2026-07-29
> **作者**: 严过关(QA
> **状态**: ✅ 编写完成(35 用例,待工程师静态审查 + AST 后给出最终结论)
> **关联文档**:
> - PRD`docs/01-产品文档/00-产品规划/PRD-REQ-通用-005-选项选择持久化-v1.0.md`
> - 技术方案:`docs/02-技术文档/技术架构/技术方案-REQ-通用-005-选项选择持久化-v1.0.md`
> - 代码真相:`src/backend/app/api/ws.py` + `src/backend/app/api/messages.py` + `src/backend/app/utils/sensitive.py` + `src/backend/app/tasks/h5_ai_task.py` + `src/backend/app/services/ai_service.py` + `src/frontend-agent/src/components/chat/MessageBubble.vue` + `src/frontend-h5/src/stores/conversation.ts`
---
## 一、用例汇总
| 类别 | 用例数 | 覆盖重点 | 关联 PRD |
|------|-------:|---------|---------|
| A. 功能验收(AC-01~AC-10 | 10 | PRD §5 验收标准 | §5 |
| B. 异常场景 | 8 | 历史 UUID 复用 / 多 worker 并发 / Dify 超时 / WS 失败 / 缺字段降级 / 跨卡同名 / 多题最新 / 不更新旧行 | §6 + 工程师报告 §八 |
| C. 敏感词边界 | 5 | 16 位 / 18 位 / 短值 / 中文边界 / 不动业务键 | §4.8 |
| D. H5 UUID 生命周期 | 3 | 首次生成 / 重试复用 / 主动重选 | §4.4 + §4.5 |
| E. 性能压测 | 2 | 20 次并发 / 1000 条 latest 计算 | 技术方案 §9 |
| F. Dify 集成 | 3 | 5 字段注入 / 普通文本无 inputs / 代理 fallback 不拼原值 | §4.6 + 技术方案 §5 |
| G. 转人工快照 | 2 | 每题最新 / 双处注入(tags + conversation_updated.data | §4.7 + 技术方案 §6 |
| H. 回归兼容 | 2 | 普通文本链路 / ai_structured + recommend_event | 技术方案 §9 回归 |
| **总计** | **35** | - | - |
> **优先级分布**P0 = 23 条 / P1 = 9 条 / P2 = 3 条
---
## 二、功能测试用例
### 2.A 功能验收(直接对应 PRD §5 AC-01~AC-10
#### TC-通用-005-001
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-001 |
| **用例标题** | 首次点选后 messages 表新增 1 行 option_select6 字段完整 |
| **关联 AC** | AC-01 |
| **前置条件** | 1) H5 已建立 WS 连接;2) AI 已发出 `ai_structured` 含 options3) `messages` 表初始无 option_select |
| **测试步骤** | 1. 员工点击"网络中断"选项<br>2. H5 发送 6 字段 `option_select`<br>3. 等待 200ms |
| **预期结果** | 1) `messages` 表新增 1 行:`msg_type="option_select"``sender_type="employee"`2) `extra_data` 字段含 `option_value/selected_from_message_id/client_msg_id/question_id/option_id` + `option_label_original`3) `content` 为原始 label4) `GET /conversations/{id}/messages` 返回该行 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_handle_option_select_persists_first_message` |
| **静态审查锚点** | ws.py:374-385(构造 Message + ws.py:365-373extra_data 5+1 字段) |
#### TC-通用-005-002
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-002 |
| **用例标题** | 同一选项重选两次,形成两行 option_select |
| **关联 AC** | AC-02 |
| **前置条件** | 1) 已完成 TC-001 的首次点选;2) 5 秒后再点同选项 |
| **测试步骤** | 1. 员工再次点击同选项(新 UUID<br>2. 等待 200ms |
| **预期结果** | 1) `messages` 表累计 2 行 `option_select``message_id` 互不相同;2) 第一行不被覆盖;3) REST 历史按时间正序返回两行 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_two_reselects_persist_two_rows` |
| **静态审查锚点** | ws.py:386db.add + 不存在 UPDATE 旧行的 SQL |
#### TC-通用-005-003
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-003 |
| **用例标题** | 坐席在线时收到标准 `new_message` 事件 |
| **关联 AC** | AC-03 |
| **前置条件** | 1) 坐席 WebSocket 已连接;2) H5 发起点选 |
| **测试步骤** | 1. 抓取坐席 WS 收到的消息;2. 校验 payload |
| **预期结果** | 1) 收到 `type=="new_message"`2) `data.msg_type=="option_select"`3) `data.message_id` 与 DB 行一致;4) `data.content` 为 mask 后文本 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_ws_broadcasts_new_message_type` |
| **静态审查锚点** | ws.py:407-421broadcast payload |
#### TC-通用-005-004
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-004 |
| **用例标题** | 坐席断线重连后,REST 历史可见相同 message_id |
| **关联 AC** | AC-03 |
| **前置条件** | 1) 已完成 TC-001 的首次点选;2) 坐席 WS 断开;3) 重新调用 GET 历史接口 |
| **测试步骤** | 1. 调用 `GET /api/conversations/{id}/messages`;2. 在返回列表中查找该 option_select |
| **预期结果** | 1) 找到 1 条 `msg_type=="option_select"`2) `message_id` 与 TC-001 落库 ID 完全一致;3) `content` 为 mask 后文本 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_rest_history_returns_same_message_id` |
| **静态审查锚点** | messages.py:158-173REST mask 分支) |
#### TC-通用-005-005
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-005 |
| **用例标题** | 坐席 MessageBubble 渲染"✓ 选项"灰色徽标 |
| **关联 AC** | AC-04 |
| **前置条件** | 1) 坐席前端加载会话;2) REST 返回 1 条 option_select |
| **测试步骤** | 1. 渲染 MessageBubble2. 检查 DOM |
| **预期结果** | 1) 渲染 `option-select-badge` 节点;2) 文案为 `✓ {content}`3) 默认不带 `--latest` 类(灰色) |
| **优先级** | P0 |
| **自动化建议** | ✅ `MessageBubble.spec.ts → option_select renders badge` |
| **静态审查锚点** | MessageBubble.vue:84-94option_select 模板分支) |
#### TC-通用-005-006
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-006 |
| **用例标题** | 同 question_id 仅最新一条高亮,旧记录仍可见 |
| **关联 AC** | AC-04 |
| **前置条件** | 1) 同一 question_id 有 2 条 option_select(不同 message_id |
| **测试步骤** | 1. 渲染该 question 的所有 option_select 徽标;2. 检查 `--latest` 类归属 |
| **预期结果** | 1) 仅 1 个徽标带 `option-select-badge--latest` 与"最新"标签;2) 旧徽标仍显示但无高亮;3) 排序稳定(按 created_at + id 倒序) |
| **优先级** | P0 |
| **自动化建议** | ✅ `MessageBubble.spec.ts → only latest highlighted` |
| **静态审查锚点** | MessageBubble.vue:477-483isLatestSelection + 386-403latestSelectionPerQuestion |
#### TC-通用-005-007
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-007 |
| **用例标题** | H5 首次点选生成合法 UUID v4 |
| **关联 AC** | AC-05 |
| **前置条件** | 1) H5 加载完成;2) pendingOptionSelect 为 null |
| **测试步骤** | 1. 员工首次点击选项;2. 抓取 WS 发送的 `data.client_msg_id` |
| **预期结果** | 1) `client_msg_id` 符合 UUID v4 格式(8-4-4-4-12,第 13 位为 `4`);2) 6 字段全部存在;3) `selected_from_message_id` 与 AI 消息 ID 一致 |
| **优先级** | P0 |
| **自动化建议** | ✅ `conversation.spec.ts → first click generates UUID` |
| **静态审查锚点** | conversation.ts:1567safeRandomUUID 调用) + 1571-15796 字段 payload |
#### TC-通用-005-008
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-008 |
| **用例标题** | 5 秒内同 client_msg_id 重发 3 次,仅落库/广播/Dify 各 1 次 |
| **关联 AC** | AC-06 |
| **前置条件** | 1) H5 已首次点选;2) WS 偶发重连 |
| **测试步骤** | 1. 在 5 秒窗口内用同一 client_msg_id 重发 3 次;2. 统计落库次数、广播次数、Dify 调用次数 |
| **预期结果** | 1) `messages` 表仅 +1 行;2) `ws_manager.broadcast` 仅 1 次;3) `process_h5_ai_reply` 仅 1 次;4) 后续 2 次返回首次 message 不报错 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_idempotency_three_repeats` |
| **静态审查锚点** | ws.py:316-3365 秒窗口幂等查询 + return + ws.py:300-313advisory lock |
#### TC-通用-005-009
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-009 |
| **用例标题** | Dify 收到 feedback_type=option_select 及题目/选项字段 |
| **关联 AC** | AC-07 |
| **前置条件** | 1) H5 发起点选;2) Dify Workflow 已声明 5 个 input variables |
| **测试步骤** | 1. mock 拦截 Dify `/v1/chat-messages` 请求;2. 解析 `payload.inputs` |
| **预期结果** | 1) `payload.inputs.feedback_type=="option_select"`2) 字段含 `question_id``option_id``option_value``option_label`mask 后);3) `query` 字段为 option_label 原值;4) 不影响现有 `user`/`conversation_id` |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_dify_inputs_contains_feedback_context` |
| **静态审查锚点** | ws.py:433-439feedback_context dict + ai_service.py:279inputs 透传) |
#### TC-通用-005-010
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-010 |
| **用例标题** | 员工选择后立即转人工,坐席上下文含各 question_id 最新选择 |
| **关联 AC** | AC-08 |
| **前置条件** | 1) 员工已对 2 个不同 question_id 各选 1 次;2) Dify 返回 `diagnosis_stage=escalating` |
| **测试步骤** | 1. 等待 Dify 触发转人工;2. 坐席 WS 抓取 `conversation_updated`3. 校验 `data.selected_options` |
| **预期结果** | 1) `conversation_updated.data.selected_options` 是 list2) 每项含 `question_id/option_id/label(message_id/selected_at)`3) 每题仅 1 条;4) `label` 为 mask 后文本;5) `conversation.tags.selected_options` 同步写入 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_snapshot_each_question_latest` |
| **静态审查锚点** | h5_ai_task.py:253-318_build_selected_options_snapshot + h5_ai_task.py:620-628tags 注入) + h5_ai_task.py:743-755conversation_updated.data 注入) |
#### TC-通用-005-011
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-011 |
| **用例标题** | 选项含账号/身份证时,中间四位为 `****`DB 原值不变 |
| **关联 AC** | AC-09 |
| **前置条件** | 1) 选项 label 含 16 位数字串 |
| **测试步骤** | 1. H5 点选;2. 抓 WS 广播、REST 历史、Dify inputs、转人工快照;3. 直查 DB |
| **预期结果** | 1) WS/REST/Dify/快照中均为 `前4****后N`2) DB `messages.content` 为原值;3) `question_id`/`option_id`/`client_msg_id` 不被 mask |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_mask_preserves_db_value` |
| **静态审查锚点** | sensitive.py:38-66mask_sensitive_text + ws.py:397,415mask 应用) + messages.py:165mask 应用) |
#### TC-通用-005-012
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-012 |
| **用例标题** | 全链路:AI 发选项→员工点选→坐席实时 ✓→坐席重连→REST 历史仍可见,全程无重复记录 |
| **关联 AC** | AC-10 |
| **前置条件** | 1) AI 已发 ai_structured2) 坐席 WS 已连接 |
| **测试步骤** | 1. H5 点选;2. 坐席收到 new_message3. 坐席 WS 断开 10s 后重连;4. 调用 REST 历史 |
| **预期结果** | 1) 实时收到 type=new_messagemessage_id=X2) REST 返回含相同 message_id=X 的 option_select3) DB 中该 message_id 仅 1 行;4) 整个流程无重复 INSERT、无重复广播 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_e2e_no_duplicate_across_ws_and_rest` |
| **静态审查锚点** | ws.py:407(广播)+ messages.py:163REST mask 分支) + ws.py:316-336(去重) |
---
### 2.B 异常场景
#### TC-通用-005-013
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-013 |
| **用例标题** | 历史 client_msg_id 复用(>5s)应被拒绝并不新增行 |
| **关联 AC** | PRD §6 撤回/重选 + 工程师报告 §八 偏离项 #2 |
| **前置条件** | 1) 已完成首次点选 client_msg_id=A 已落库;2) 时间流逝 >5s |
| **测试步骤** | 1. 员工用同 client_msg_id=A 再次发起(模拟客户端异常复用);2. 等待 200ms |
| **预期结果** | 1) `messages` 表行数不变;2) 服务端日志告警"拒绝历史 client_msg_id 复用"3) 不广播、不调 Dify |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_historical_uuid_reuse_rejected` |
| **静态审查锚点** | ws.py:339-356hist_stmt 检查) |
#### TC-通用-005-014
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-014 |
| **用例标题** | 多 worker 并发同 UUID 20 次,仅 1 次落库/广播/Dify |
| **关联 AC** | PRD §8 + 技术方案 §3.2 |
| **前置条件** | 1) 启动 ≥2 个 worker 模拟;2) 同一 client_msg_id |
| **测试步骤** | 1. 20 个协程同时调用 `_handle_option_select`2. 完成后统计 |
| **预期结果** | 1) DB 仅 1 行;2) 广播 1 次;3) Dify 任务 1 次;4) 其余 19 次命中 5 秒窗口 return |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_concurrent_20_workers_single_uuid` |
| **静态审查锚点** | ws.py:300-313pg_advisory_xact_lock |
#### TC-通用-005-015
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-015 |
| **用例标题** | Dify 30 秒超时,但 option_select 消息已落库,不反向删除 |
| **关联 AC** | 工程师报告 §八 偏离项 #2/3 |
| **前置条件** | 1) H5 已点选;2) mock Dify 30 秒不返回 |
| **测试步骤** | 1. 等待 Dify 超时;2. 走关键词降级;3. 检查 messages 表 |
| **预期结果** | 1) `messages` 表 option_select 行保留;2) AI 回复走关键词降级(不影响 option_select 持久化);3) 后续 REST 仍可见该行 |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_dify_timeout_does_not_delete_option_select` |
| **静态审查锚点** | h5_ai_task.py:1613-1639(超时分支) + ws.py:426-441(异步任务不阻塞 commit |
#### TC-通用-005-016
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-016 |
| **用例标题** | WS 广播失败(ws_manager.broadcast 抛异常),消息已落库且可由 REST 重连读取 |
| **关联 AC** | PRD §8 风险"广播失败" |
| **前置条件** | 1) mock ws_manager.broadcast 抛 RuntimeError |
| **测试步骤** | 1. H5 点选;2. 等待 200ms3. 坐席调用 REST 历史 |
| **预期结果** | 1) `messages` 表有 option_select 行;2) WS 广播异常被吞掉(仅 warning);3) REST 历史可读 |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_ws_broadcast_failure_graceful` |
| **静态审查锚点** | ws.py:422-423try/except ws_err |
#### TC-通用-005-017
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-017 |
| **用例标题** | 缺字段(question_id/option_id/sourceMessageId)降级为普通文本 |
| **关联 AC** | 工程师报告 §八 偏离项 #2 |
| **前置条件** | 1) H5 `sendOptionSelect` 传入空 `questionId=""` |
| **测试步骤** | 1. 观察 H5 行为;2. 后端落库消息类型 |
| **预期结果** | 1) H5 走 `sendMessage({content: optionLabel, msg_type: 'text'})` 降级;2) `messages` 新增 1 行 `msg_type=text`3) 不产生 option_select 行 |
| **优先级** | P1 |
| **自动化建议** | ✅ `conversation.spec.ts → missing question_id falls back to text` |
| **静态审查锚点** | conversation.ts:1538-1544(降级分支) |
#### TC-通用-005-018
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-018 |
| **用例标题** | 跨卡同名 label 不串扰(依靠 question_id+option_id 归属) |
| **关联 AC** | PRD §6 跨卡归属 |
| **前置条件** | 1) 会话有 2 张不同 ai_structuredquestion_id=A 与 question_id=B),均含 label="是" |
| **测试步骤** | 1. H5 在 A 卡选"是"option_id=A_yes);2. H5 在 B 卡选"是"option_id=B_yes);3. 落库 2 行 |
| **预期结果** | 1) 2 行均落库;2) latestSelectionPerQuestion 中 A→{label: "是", messageId: A 行 id}B→{label: "是", messageId: B 行 id}3) 不会互相覆盖 |
| **优先级** | P1 |
| **自动化建议** | ✅ `MessageBubble.spec.ts → cross-card same label no interference` |
| **静态审查锚点** | MessageBubble.vue:387-394(按 question_id 分组) |
#### TC-通用-005-019
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-019 |
| **用例标题** | 多 question_id 各自多次重选,最新选择仅高亮最新一条 |
| **关联 AC** | PRD §6 + AC-04 |
| **前置条件** | 1) question_id=A 选了 3 次(A1/A2/A3);question_id=B 选了 2 次(B1/B2 |
| **测试步骤** | 1. 渲染所有 option_select2. 检查 `--latest` 类归属 |
| **预期结果** | 1) A3 与 B2 各带 `--latest`2) A1/A2/B1 无高亮;3) 排序按 (created_at desc, id desc) |
| **优先级** | P0 |
| **自动化建议** | ✅ `MessageBubble.spec.ts → multi-question latest only` |
| **静态审查锚点** | MessageBubble.vue:392-403latest map 更新条件) |
#### TC-通用-005-020
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-020 |
| **用例标题** | 重选不更新旧行(数据库与 WS 均为追加语义) |
| **关联 AC** | PRD §6 撤回/重选 + §7 非目标 #2 |
| **前置条件** | 1) 第一条 option_select 已落库(行 1 |
| **测试步骤** | 1. SQL:检查行 1 的 `created_at``content``extra_data`2. 员工重选(行 2 |
| **预期结果** | 1) 行 1 内容与时间不变;2) 行 2 是新 INSERT3) 没有 UPDATE 语句 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_reselect_never_updates_old_row`(检查 SQL 日志无 UPDATE |
| **静态审查锚点** | ws.py:374-385(仅 db.add + flush + 全文无 Message.update |
---
### 2.C 敏感词边界
#### TC-通用-005-021
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-021 |
| **用例标题** | 16 位连续数字:保留前 4 + **** + 后 8 |
| **关联 AC** | AC-09 |
| **前置条件** | 1) 单元测试 `mask_sensitive_text` |
| **测试步骤** | 1. 调用 `mask_sensitive_text("6222123456789012")` |
| **预期结果** | 返回 `"6222****56789012"` |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_mask_16_digit` |
| **静态审查锚点** | sensitive.py:28_MASK_PATTERN_16 + 57(替换) |
#### TC-通用-005-022
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-022 |
| **用例标题** | 18 位身份证(数字串)按通用规则:前 4 + **** + 后 10 |
| **关联 AC** | AC-09 |
| **前置条件** | 单元测试 |
| **测试步骤** | 1. `mask_sensitive_text("11010119900101001X")`(含字母 X<br>2. `mask_sensitive_text("110101199001010011")`(纯数字) |
| **预期结果** | 1) 含 X 时不匹配纯数字正则,前 14 位中数字部分应 mask(如 `"1101****0101001X"`);2) 纯数字 18 位按 4+4+10 切分,前 4 + **** + 后 10 |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_mask_18_idcard` |
| **静态审查锚点** | sensitive.py:32_MASK_PATTERN_GENERIC |
#### TC-通用-005-023
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-023 |
| **用例标题** | 短敏感值(<4 位数字)全部替换为 `*` |
| **关联 AC** | AC-09 |
| **前置条件** | 单元测试 |
| **测试步骤** | 1. `mask_sensitive_text("6222")` <br>2. `mask_sensitive_text("123")` <br>3. `mask_sensitive_text("9")` |
| **预期结果** | 1) `"****"`2) `"***"`3) `"*"` |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_mask_short_value` |
| **静态审查锚点** | sensitive.py:35_MASK_PATTERN_SHORT |
#### TC-通用-005-024
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-024 |
| **用例标题** | 中文边界:数字前后为中文应正确切分 |
| **关联 AC** | AC-09 |
| **前置条件** | 单元测试 |
| **测试步骤** | 1. `mask_sensitive_text("账号62221234567中文")` <br>2. `mask_sensitive_text("余额是1234元")` |
| **预期结果** | 1) `"账号6222****4567中文"`(11 位按通用规则:4+4+3);2) `"余额是****元"`(4 位按短规则但通用先匹配为 4+4+0... 实际为 `"余额是****元"` |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_mask_chinese_boundary` |
| **静态审查锚点** | sensitive.py:32(前后 `(?<!\d)`/`(?!\d)` 数字边界断言) |
#### TC-通用-005-025
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-025 |
| **用例标题** | 业务键(question_id/option_id/client_msg_id)不被 mask |
| **关联 AC** | AC-09 |
| **前置条件** | 单元测试 |
| **测试步骤** | 1. `mask_sensitive_text("fault_type_network_down_12345678")`(不应误伤)<br>2. 验证 `question_id="fault_type"` 等保持原值(在 `_build_selected_options_snapshot` 输出中验证) |
| **预期结果** | 1) 含 `_` 分隔的数字串不被破坏(按单词边界);2) snapshot 中 question_id 与 option_id 完整保留 |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_mask_skips_business_keys` |
| **静态审查锚点** | h5_ai_task.py:305-314snapshot 不调用 mask on qid/oid |
---
### 2.D H5 UUID 生命周期
#### TC-通用-005-026
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-026 |
| **用例标题** | 首次点选:pendingOptionSelect 由 null → 含 client_msg_id |
| **关联 AC** | AC-05 |
| **前置条件** | 1) `pendingOptionSelect.value=null`2) H5 调用 `sendOptionSelect` |
| **测试步骤** | 1. 检查发送后 store 状态 |
| **预期结果** | 1) `pendingOptionSelect.value !== null`2) `clientMsgId` 为合法 UUID v43) `attempts=1`4) `sentAt` 为 Date.now() |
| **优先级** | P0 |
| **自动化建议** | ✅ `conversation.spec.ts → first click sets pending` |
| **静态审查锚点** | conversation.ts:1560-1589 |
#### TC-通用-005-027
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-027 |
| **用例标题** | 重试复用:失败重发使用同 client_msg_idattempts+1 |
| **关联 AC** | AC-05 + §4.4 |
| **前置条件** | 1) `pendingOptionSelect.value` 已存在;2) WS send 失败 |
| **测试步骤** | 1. 模拟 WS 未连接,重发同一选项;2. 抓 client_msg_id |
| **预期结果** | 1) 第二次发送复用同一 UUID;2) `attempts=2`;3) 服务端 5 秒窗口去重,DB 仅 1 行 |
| **优先级** | P0 |
| **自动化建议** | ✅ `conversation.spec.ts → retry reuses UUID` |
| **静态审查锚点** | conversation.ts:1561-1564(复用分支) |
#### TC-通用-005-028
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-028 |
| **用例标题** | 主动重选:同 question_id + 时间间隔 >1s 生成新 UUID |
| **关联 AC** | AC-05 |
| **前置条件** | 1) `pendingOptionSelect.value` 已存在;2) 同 question_id 选项再次点击 |
| **测试步骤** | 1. mock `Date.now()` 偏移 2s2. 调用 sendOptionSelect |
| **预期结果** | 1) 新 client_msg_id ≠ 上次;2) DB 累计 +1 行;3) 旧行不被覆盖 |
| **优先级** | P0 |
| **自动化建议** | ✅ `conversation.spec.ts → user reselect generates new UUID` |
| **静态审查锚点** | conversation.ts:1555-1568isUserReselection 判断) |
---
### 2.E 性能压测
#### TC-通用-005-029
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-029 |
| **用例标题** | 20 个协程并发同 UUID:仅 1 次 INSERT / 1 次 broadcast / 1 次 Dify |
| **关联 AC** | PRD §8 + 技术方案 §9 |
| **前置条件** | 1) 集成测试环境;2) mock ws_manager + Dify |
| **测试步骤** | 1. `asyncio.gather(*[_handle_option_select(...) for _ in range(20)])`2. 统计 |
| **预期结果** | 1) DB 行数 += 12) broadcast 计数 = 13) Dify 任务计数 = 14) 其他 19 次命中 5 秒幂等 |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_concurrent_20_single_uuid` |
| **静态审查锚点** | ws.py:300-336lock + 5s 查询) |
#### TC-通用-005-030
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-030 |
| **用例标题** | 1000 条 option_select 的 latestSelectionPerQuestion 计算耗时 |
| **关联 AC** | 技术方案 §9 |
| **前置条件** | 1) 构造 1000 条 mock2) `performance.now()` 测时 |
| **测试步骤** | 1. 构造 1000 条 `msg_type=option_select`2. 计算 `latestSelectionPerQuestion`3. 100 次循环取平均 |
| **预期结果** | 平均耗时 < 10ms(典型 N=1000, Vue computed 单帧计算) |
| **优先级** | P2 |
| **自动化建议** | ✅ `MessageBubble.spec.ts → 1000 rows performance` |
| **静态审查锚点** | MessageBubble.vue:382-405O(N) 单次扫描) |
---
### 2.F Dify 集成
#### TC-通用-005-031
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-031 |
| **用例标题** | option_select 触发:5 字段 inputs 完整透传 |
| **关联 AC** | AC-07 |
| **前置条件** | 1) mock Dify `/v1/chat-messages`2) H5 点选 |
| **测试步骤** | 1. 检查 `payload.inputs` 字段 |
| **预期结果** | 1) `feedback_type=="option_select"`2) `question_id` 与 H5 一致;3) `option_id/option_value` 一致;4) `option_label` 是 mask 后文本 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_dify_inputs_feedback_context` |
| **静态审查锚点** | ws.py:433-439 + ai_service.py:279 |
#### TC-通用-005-032
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-032 |
| **用例标题** | 普通文本消息 inputs 仍为空 dict |
| **关联 AC** | AC-07(普通文本链路不受影响) |
| **前置条件** | 1) H5 发送普通 text 消息 |
| **测试步骤** | 1. mock Dify2. 检查 payload |
| **预期结果** | 1) `payload.inputs == {}`2) `feedback_type` 字段不存在;3) 仅 query / user / response_mode |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_dify_plain_text_empty_inputs` |
| **静态审查锚点** | ai_service.py:279`inputs or {}` + h5_ai_task.py:1609(普通文本传 None |
#### TC-通用-005-033
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-033 |
| **用例标题** | 代理 fallbackdify2openai):feedback_context 放入 metadata,不拼原值 |
| **关联 AC** | 工程师报告 §八 偏离项 #5 |
| **前置条件** | 1) mock 原生 API 失败;2) 走 dify2openai 路径 |
| **测试步骤** | 1. 检查代理 payload |
| **预期结果** | 1) `payload.metadata.feedback_context` 含 5 字段;2) `payload.messages[0].content` 不含 option 原值(仅原 query 内容);3) 不破坏 OpenAI 兼容层 |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_proxy_fallback_metadata` |
| **静态审查锚点** | ai_service.py:476-479metadata 注入) |
---
### 2.G 转人工快照
#### TC-通用-005-034
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-034 |
| **用例标题** | 转人工快照:每 question_id 仅 1 条最新选择,按 (created_at desc, id desc) 取 |
| **关联 AC** | AC-08 |
| **前置条件** | 1) 同一 question_id 选 3 次;2) Dify 返回 escalating |
| **测试步骤** | 1. 触发转人工;2. 校验 snapshot |
| **预期结果** | 1) 该 question_id 仅 1 条;2) message_id 为最后一次的 ID3) `selected_at` 准确;4) label 已 mask |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_snapshot_latest_per_question` |
| **静态审查锚点** | h5_ai_task.py:277-298row_number window function |
#### TC-通用-005-035
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-035 |
| **用例标题** | 转人工快照同时注入:conversation.tags.selected_options + conversation_updated.data.selected_options |
| **关联 AC** | PRD §4.7 + 技术方案 §6 |
| **前置条件** | 1) Dify 触发 escalating |
| **测试步骤** | 1. 检查 conversation.tags2. 抓取坐席 WS 的 conversation_updated |
| **预期结果** | 1) `conversation.tags.selected_options` 写入;2) `conversation.tags.selected_options_updated_at` ISO 时间存在;3) `conversation_updated.data.selected_options` 含相同条目;4) 两处结构一致 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_snapshot_dual_inject` |
| **静态审查锚点** | h5_ai_task.py:620-628tags+ h5_ai_task.py:743-755data 注入) |
---
### 2.H 回归兼容
#### TC-通用-005-036
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-036 |
| **用例标题** | 普通文本消息链路不受 option_select 改造影响 |
| **关联 AC** | 技术方案 §9 回归 |
| **前置条件** | 1) H5 发送普通 text |
| **测试步骤** | 1. 检查落库消息类型;2. 检查 Dify 调用;3. 检查 WS 广播 |
| **预期结果** | 1) `messages.msg_type=="text"`2) Dify inputs 为空;3) WS 仍广播 type=new_message 含 text 消息 |
| **优先级** | P0 |
| **自动化建议** | ✅ `test_plain_text_chain_unaffected` |
| **静态审查锚点** | h5_ai_task.py:1609(普通文本 None+ messages.py:163(仅 option_select 分支 mask |
#### TC-通用-005-037
| 字段 | 内容 |
|------|------|
| **用例编号** | TC-通用-005-037 |
| **用例标题** | ai_structured 推荐卡 + recommend_event 兼容(不删除旧字段) |
| **关联 AC** | PRD §7 非目标 #6 + 技术方案 §9 回归 |
| **前置条件** | 1) Dify 返回 action+options2) 旧客户端与新客户端并存 |
| **测试步骤** | 1. 检查 ai_reply 推送;2. 检查 dynamic_recommend3. 检查 new_message 广播 |
| **预期结果** | 1) `extra_data.action` 仍含旧字段;2) `extra_data.options` 仍含旧字段;3) 不因 option_select 改造而丢失 |
| **优先级** | P1 |
| **自动化建议** | ✅ `test_recommend_event_compat` |
| **静态审查锚点** | h5_ai_task.py:557-590extra_data.action/options 构造未改) |
---
## 三、覆盖矩阵
| PRD/技术方案要点 | 覆盖用例 |
|------------------|----------|
| PRD §4.1 落库 schema | TC-001, TC-002, TC-011 |
| PRD §4.2 WS 标准广播 | TC-003, TC-012, TC-016 |
| PRD §4.3 坐席渲染 | TC-005, TC-006, TC-018, TC-019 |
| PRD §4.4 H5 字段 | TC-007, TC-026~TC-028 |
| PRD §4.5 幂等 | TC-008, TC-013, TC-014, TC-029 |
| PRD §4.6 Dify inputs | TC-009, TC-031~TC-033 |
| PRD §4.7 转人工快照 | TC-010, TC-034, TC-035 |
| PRD §4.8 敏感词 mask | TC-011, TC-021~TC-025 |
| PRD §5 AC-01~AC-10 | TC-001~TC-012(含 AC-10 E2E |
| PRD §6 边界场景 | TC-013~TC-020 |
| PRD §8 风险 | TC-014, TC-016, TC-029 |
---
## 四、自动化落库建议(pytest/vitest 目录)
| 文件 | 覆盖 |
|------|------|
| `src/backend/tests/test_option_select.py` | TC-001~TC-004, TC-008~TC-014, TC-016, TC-020, TC-029, TC-034~TC-035 |
| `src/backend/tests/test_sensitive.py` | TC-021~TC-025 |
| `src/backend/tests/test_dify_inputs.py` | TC-031~TC-033 |
| `src/frontend-h5/src/stores/conversation.spec.ts` | TC-007, TC-017, TC-026~TC-028 |
| `src/frontend-agent/src/components/chat/MessageBubble.spec.ts` | TC-005~TC-006, TC-018~TC-019, TC-030 |
> **注**:自动化测试代码尚未在仓库中提交(`Glob` 未发现 `test_option_select.py``test_sensitive.py`),需工程师在 T02/T03 验收前补齐。