# 测试用例 - 选项选择持久化 > **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_select,6 字段完整 | | **关联 AC** | AC-01 | | **前置条件** | 1) H5 已建立 WS 连接;2) AI 已发出 `ai_structured` 含 options;3) `messages` 表初始无 option_select | | **测试步骤** | 1. 员工点击"网络中断"选项
2. H5 发送 6 字段 `option_select`
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` 为原始 label;4) `GET /conversations/{id}/messages` 返回该行 | | **优先级** | P0 | | **自动化建议** | ✅ `test_handle_option_select_persists_first_message` | | **静态审查锚点** | ws.py:374-385(构造 Message) + ws.py:365-373(extra_data 5+1 字段) | #### TC-通用-005-002 | 字段 | 内容 | |------|------| | **用例编号** | TC-通用-005-002 | | **用例标题** | 同一选项重选两次,形成两行 option_select | | **关联 AC** | AC-02 | | **前置条件** | 1) 已完成 TC-001 的首次点选;2) 5 秒后再点同选项 | | **测试步骤** | 1. 员工再次点击同选项(新 UUID)
2. 等待 200ms | | **预期结果** | 1) `messages` 表累计 2 行 `option_select`,`message_id` 互不相同;2) 第一行不被覆盖;3) REST 历史按时间正序返回两行 | | **优先级** | P0 | | **自动化建议** | ✅ `test_two_reselects_persist_two_rows` | | **静态审查锚点** | ws.py:386(db.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-421(broadcast 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-173(REST mask 分支) | #### TC-通用-005-005 | 字段 | 内容 | |------|------| | **用例编号** | TC-通用-005-005 | | **用例标题** | 坐席 MessageBubble 渲染"✓ 选项"灰色徽标 | | **关联 AC** | AC-04 | | **前置条件** | 1) 坐席前端加载会话;2) REST 返回 1 条 option_select | | **测试步骤** | 1. 渲染 MessageBubble;2. 检查 DOM | | **预期结果** | 1) 渲染 `option-select-badge` 节点;2) 文案为 `✓ {content}`;3) 默认不带 `--latest` 类(灰色) | | **优先级** | P0 | | **自动化建议** | ✅ `MessageBubble.spec.ts → option_select renders badge` | | **静态审查锚点** | MessageBubble.vue:84-94(option_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-483(isLatestSelection) + 386-403(latestSelectionPerQuestion) | #### 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:1567(safeRandomUUID 调用) + 1571-1579(6 字段 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-336(5 秒窗口幂等查询 + return) + ws.py:300-313(advisory 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-439(feedback_context dict) + ai_service.py:279(inputs 透传) | #### 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` 是 list;2) 每项含 `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-628(tags 注入) + h5_ai_task.py:743-755(conversation_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-66(mask_sensitive_text) + ws.py:397,415(mask 应用) + messages.py:165(mask 应用) | #### TC-通用-005-012 | 字段 | 内容 | |------|------| | **用例编号** | TC-通用-005-012 | | **用例标题** | 全链路:AI 发选项→员工点选→坐席实时 ✓→坐席重连→REST 历史仍可见,全程无重复记录 | | **关联 AC** | AC-10 | | **前置条件** | 1) AI 已发 ai_structured;2) 坐席 WS 已连接 | | **测试步骤** | 1. H5 点选;2. 坐席收到 new_message;3. 坐席 WS 断开 10s 后重连;4. 调用 REST 历史 | | **预期结果** | 1) 实时收到 type=new_message,message_id=X;2) REST 返回含相同 message_id=X 的 option_select;3) DB 中该 message_id 仅 1 行;4) 整个流程无重复 INSERT、无重复广播 | | **优先级** | P0 | | **自动化建议** | ✅ `test_e2e_no_duplicate_across_ws_and_rest` | | **静态审查锚点** | ws.py:407(广播)+ messages.py:163(REST 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-356(hist_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-313(pg_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. 等待 200ms;3. 坐席调用 REST 历史 | | **预期结果** | 1) `messages` 表有 option_select 行;2) WS 广播异常被吞掉(仅 warning);3) REST 历史可读 | | **优先级** | P1 | | **自动化建议** | ✅ `test_ws_broadcast_failure_graceful` | | **静态审查锚点** | ws.py:422-423(try/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_structured(question_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_select;2. 检查 `--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-403(latest 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 是新 INSERT;3) 没有 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)
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")`
2. `mask_sensitive_text("123")`
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中文")`
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(前后 `(?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-314(snapshot 不调用 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 v4;3) `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_id(attempts+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()` 偏移 2s;2. 调用 sendOptionSelect | | **预期结果** | 1) 新 client_msg_id ≠ 上次;2) DB 累计 +1 行;3) 旧行不被覆盖 | | **优先级** | P0 | | **自动化建议** | ✅ `conversation.spec.ts → user reselect generates new UUID` | | **静态审查锚点** | conversation.ts:1555-1568(isUserReselection 判断) | --- ### 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 行数 += 1;2) broadcast 计数 = 1;3) Dify 任务计数 = 1;4) 其他 19 次命中 5 秒幂等 | | **优先级** | P1 | | **自动化建议** | ✅ `test_concurrent_20_single_uuid` | | **静态审查锚点** | ws.py:300-336(lock + 5s 查询) | #### TC-通用-005-030 | 字段 | 内容 | |------|------| | **用例编号** | TC-通用-005-030 | | **用例标题** | 1000 条 option_select 的 latestSelectionPerQuestion 计算耗时 | | **关联 AC** | 技术方案 §9 | | **前置条件** | 1) 构造 1000 条 mock;2) `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-405(O(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 Dify;2. 检查 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 | | **用例标题** | 代理 fallback(dify2openai):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-479(metadata 注入) | --- ### 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 为最后一次的 ID;3) `selected_at` 准确;4) label 已 mask | | **优先级** | P0 | | **自动化建议** | ✅ `test_snapshot_latest_per_question` | | **静态审查锚点** | h5_ai_task.py:277-298(row_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.tags;2. 抓取坐席 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-628(tags)+ h5_ai_task.py:743-755(data 注入) | --- ### 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+options;2) 旧客户端与新客户端并存 | | **测试步骤** | 1. 检查 ai_reply 推送;2. 检查 dynamic_recommend;3. 检查 new_message 广播 | | **预期结果** | 1) `extra_data.action` 仍含旧字段;2) `extra_data.options` 仍含旧字段;3) 不因 option_select 改造而丢失 | | **优先级** | P1 | | **自动化建议** | ✅ `test_recommend_event_compat` | | **静态审查锚点** | h5_ai_task.py:557-590(extra_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 验收前补齐。