Files
danyang-match/docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md
T
WorkBuddy 03a03c7c11 fix(chat): Phase 09 复验 —— 修复 WebSocket 下行死链路 / Unmatch 后可发消息 / Block 未落库 / 聊天 IDOR
对 Phase 09(Text Chat)执行 KEEP-VERIFY/EXTEND 复验:不采信历史 ✅,逐条核对
REST 发送 + WebSocket 接收、幂等、重连、未读计数、非 active Match 禁聊、Unmatch/
Block 即时禁聊、活动双确认会话复用。共发现并修复 4 处真实缺陷 + 1 处规范不符。

D1(Critical)WebSocket 下行整条链路是死代码
  - 无 HandshakeInterceptor,无任一环节把 userId 写入 session attributes,
    extractUserId 恒返回 null;sendToUser/sendToConversation 全仓库零调用点。
  - 新增 ChatHandshakeInterceptor:从 ?token= 或 Authorization: Bearer 解析 JWT,
    校验通过后写 session attr userId;失败 fail-closed 拒绝握手;仅接受 type=access。
  - 重写 ChatWebSocketHandler:按规范 {event,data} 信封推送;未认证连接直接
    close(NOT_ACCEPTABLE);同账号重连由新连接接管、旧连接 close(NORMAL);
    上行仅支持 ping->pong,禁止客户端自订阅。
  - ChatService.sendText 落库+递增未读后推送 message.new;RiskNotice 同推。
  - 移除 WebSocketConfig 的 .withSockJS()(SockJS 改写端点致原生 WS 无法直连)。

D2(Critical)Unmatch 后仍可发消息
  - unmatch 只改 Match,不联动会话;sendText 仅校验 conv.status==active。
  - 新增 ChatService.assertMatchAlive(conv):按 matchId 查 Match,状态非
    {active,getting_to_know} 抛 "匹配已解除,无法发送消息"。sendText 在 conv.status
    校验之后调用。
  - MatchController.unmatch 落库后调 chatService.syncFromMatch 推 conversation.closed。
  - syncFromMatch 重写:alive 恢复 active;非 alive 只推 conversation.closed,
    不改 status(会话仍以灰显态留在列表,属未授权产品决策,登记待决)。

D3(Critical)RelationService.block 调静态 MatchService.unmatch 只改内存态不落库
  - 结果:Block 后 matches.status 仍为 active。
  - 补 matchMapper.updateById(m) 持久化 + chatService.closeConversationForMatch(
    CONVERSATION_STATUS_BLOCKED) 通知对端。RelationshipService.block 同步补会话关闭通知。

D4(Critical 安全)聊天 4 个接口无成员校验 -> IDOR 越权读私聊
  - MessageController.load/markRead、ConversationController.detail/unread 补
    chatService.isMember(conversationId, userId) 校验;非成员返回
    ApiResponse.notFound()(body 级 success=false code=NOT_FOUND,HTTP 200,
    与 MatchController.authorizedMatch 同约定),不区分"不存在"与"无权"以防探测。

D5(Medium)上行订阅/信封不符规范
  - REST API 接口详细设计规范.md 第124-126章要求禁止客户端自订阅,事件名
    message.new/message.read/conversation.closed,信封 {event,data}。已对齐。

测试保真度 + 测试补齐
  - schema-h2.sql 新增 uk_message_client_id(sender_id, client_message_id),
    对齐生产 V004:71;H2 不支持过滤索引故省略 WHERE(NULL 语义等价)。
    否则 service 的 SELECT-then-INSERT 竞态在测试中无 DB 兜底,形成测试盲区。
  - 新增 ChatPhase09IntegrationTest(13 用例):幂等重试不重复投递、推送信封字段、
    未读增减清零、Match unmatched 拒发、Unmatch/Block 端点即时禁聊+收 closed 事件、
    活动双确认复用同一消息体系、未形成 Match 无法建会话、非成员读消息/详情/未读
    被拒且不泄露正文、DB 唯一索引兜底重复 clientMessageId、无幂等键系统消息可共存。
  - 新增 ChatWebSocketHandlerTest(7 用例):userId 路由、信封格式、未认证关连、
    断开不推、重连替换旧连接、上行仅 ping。

验证证据
  - 全量回归(离线容器 danyang-match-backend-test,mvn -o test):
    328 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 308,+20)。
  - 负向验证:临时注释 assertMatchAlive + 两处 isMember,恰好 3 个用例变红,
    证明守卫有效;随后还原并 grep 确认无残留。

文档
  - 新增 Phase-09-V11-Recovery-Completion-Report-2026-09-16.md(完整报告)。
  - Phase-09-V11-Completion-Report.md 顶部加状态注记,推翻其 4 条 WebSocket 结论。
  - 计划/STATUS/JIRA_BACKLOG 同步 Phase 09 完成态与测试总数(328)。

Phase 10 未启动,按计划每阶段出报告后停止等待确认。
2026-09-16 16:42:48 +08:00

15 KiB
Raw Permalink Blame History

Phase 09 V1.1 Recovery Completion Report

阶段:Text Chat — KEEP-VERIFY/EXTEND 分支:main 日期:2026-09-16 结论:Phase 09 复验通过并关闭;期间发现并修复 4 处真实缺陷(2 处 Critical、1 处 Critical 安全缺陷、1 处规范不符),并补齐 WebSocket 端到端链路 未进入 Phase 10 —— 按计划每阶段输出报告后停止,等待用户确认


1. 阶段目标(来自客户需求基线增量开发与修复计划)

  • 验证 REST send + WebSocket receive、消息幂等、重连和未读计数。
  • 验证非 active Match 禁聊,Unmatch/Block 后即时禁聊。
  • 让活动双确认产生的 Conversation 复用同一消息体系。

Gate: 未 Mutual 或活动未双确认时不能发消息;正常会话不重复投递。


2. 复验方式

同样不采信历史 ✅,而是把计划的三条各自拆成可执行的代码路径逐条核对,并对每条路径追问"这段代码真的会被调用吗"。 本轮大量缺陷属于死代码 / 缺接线类:组件存在、逻辑看着对,但没有任何调用点。

2.1 复验结论矩阵

计划条目 承载代码 复验前 处置
REST send MessageController.send → ChatService.sendText ✅ 可用 增强(加 Match 活性校验)
WebSocket receive ChatWebSocketHandler + WebSocketConfig ❌ 整条链路是死代码 已修复(缺陷 C1)
消息幂等 sendText 按 clientMessageId 重放返回原消息 ✅ 逻辑正确 补测试锁定"不重复落库 + 不重复投递"
重连 WS handler 会话登记/替换 ❌ 无鉴权、无 userId → 登记表恒空 随 C1 一并修复 + ping/pong 保活
未读计数 incrementUnread / resetUnread / sumUnreadByUser ✅ 正确 补测试
非 active Match 禁聊 sendText 的会话状态校验 ❌ 只校验会话,不校验 Match 已修复(缺陷 C2)
Unmatch 后即时禁聊 MatchController.unmatch ❌ 完全不联动会话 已修复(缺陷 C2)
Block 后即时禁聊 sendText 的拉黑关系双向校验 ✅ 发送被拒 ✅;但 Block 的 Match 落库有缺陷(C3)
活动双确认复用同一消息体系 ConversationController.fromMatch → getOrCreateConversation ✅ 只绑 match_id,与 source 无关 补端到端测试

3. 本轮发现并修复的缺陷

缺陷 C1(Critical)— WebSocket 下行链路整体是死代码

项 内容
位置 chat/websocket/ChatWebSocketHandler、chat/config/WebSocketConfig
症状 1 全局没有任何 HandshakeInterceptor,也没有任何代码往 session.getAttributes() 写 userId,而 extractUserId() 只从 attributes 读 → 恒为 null → sessions 映射永远为空。
症状 2 sendToUser / sendToConversation 在生产代码里零调用点:ChatService.sendText 落库后从不推送。
合并后果 "REST send + WebSocket receive" 不存在:消息发出去后对方只能靠重进会话才能看到,实时接收完全缺失。同时未认证连接可随意建立(违反规范第 126 章)。
修复 1 新增 ChatHandshakeInterceptor:从 ?token=(或 Authorization: Bearer)解析 JWT,校验通过后把 userId 写入 session attributes;校验失败拒绝握手。仅接受 type=access 的用户 token,operator_access 一律拒绝(AGENTS 第 7 条)。
修复 2 ChatWebSocketHandler 重写:按规范第 125 章输出 {event, data} 信封,提供 sendEvent(userId, event, data);无 userId 的连接直接 close(NOT_ACCEPTABLE);同账号重连时旧连接被替换并关闭(避免重复投递);新增 ping/pong 保活。
修复 3 ChatService.sendText 落库并递增未读后,向对方推送 message.new。幂等重放分支在上方 return,走不到推送,因此正常会话天然不会重复投递。

关于 SockJS:WebSocketConfig 原本调用 .withSockJS(),会把端点改写为 SockJS 协议族,原生 WebSocket 客户端(小程序 wx.connectSocket、规范第 124 章的 /wss/chat)无法直连,与本类注释自述的 /ws/chat?token= 自相矛盾。两端都是原生客户端,故移除 SockJS。

缺陷 C2(Critical)— Unmatch 后仍可继续发消息

项 内容
位置 match/controller/MatchController#unmatch、chat/service/ChatService#sendText
症状 unmatch 只把 matches.status 改成 unmatched 就结束,从不联动会话;而 sendText 只校验 conversation.status == active,会话仍是 active → 解除匹配后双方仍能正常发消息,直接违反 Gate「未 Mutual 不能发消息」。
另一层 ChatService.syncFromMatch —— 本该承担这个联动 —— 零调用点。
修复 1 sendText 增加 assertMatchAlive(conv):按 matchId 回查 Match,状态不在 {active, getting_to_know} 即拒绝。校验不依赖会话状态是否漂移,属独立防线。
修复 2 MatchController.unmatch 落库后调用 chatService.syncFromMatch(...),向双方推送 conversation.closed,客户端可立即把会话置灰。

缺陷 C3(Critical)— Block 的"自动 Unmatch"实际是空操作

项 内容
位置 match/service/RelationService#block
症状 调用 MatchService.unmatch(m, userId) —— 但 MatchService 是 private 构造的静态工具类,unmatch 只改传入对象的内存态、不落库,且此处没有后续 updateById。于是 Block 之后 Match 在库里仍是 active,"自动 Unmatch"完全没发生;用户侧仍会看到双方互相匹配。
附带 Block 也从不通知会话,对方界面不会即时停用。
修复 补 matchMapper.updateById(m) 持久化;并调用 chatService.closeConversationForMatch(matchId, "blocked") 通知双方。

对比:legacy 的 RelationshipService.block 反而是正确的(它内联改字段并 updateById 落库),canonical 路径却漏了 —— 典型"两套实现、新的那套漏了一步"。

缺陷 C4(Critical,安全)— 聊天接口越权(IDOR)

项 内容
位置 MessageController.load / markRead、ConversationController.detail / unread
症状 四个接口完全没有会话成员校验。任意已登录用户只要拿到(或枚举)conversationId,就能读取他人完整聊天记录、看到对方昵称与最后一条消息、甚至改写他人已读状态。SecurityConfig 只有 /** → authenticated(),没有对象级授权。
影响 直接违反 AGENTS 第 7 条(普通工作人员不能查看私人聊天)与隐私要求。
修复 ChatService.isMember(convId, userId) + 四个接口前置校验;非成员按既有约定返回 success=false / code=NOT_FOUND(与 MatchController.authorizedMatch 同一模式,不区分"不存在"与"无权",避免被用来探测会话是否存在)。

缺陷 C5(Medium)— 上行订阅与事件信封不符规范

项 内容
规范第 126 章 "不允许客户端自行订阅任意 conversationId"、事件名清单 message.new / message.read / conversation.closed / match.created / notification.new / account.status_changed(第 124 章)、信封 {event, data}(第 125 章)。
修复前 上行帧只被 log.debug 记一行,订阅语义未表态;事件信封为自定义 type + payload。
修复 上行只接受 ping(回 pong),其余忽略并有注释说明规范依据;下行统一 {event, data},事件名用规范清单。

4. 刻意不改的一处(+ 登记为待决项)

会话 status 是否应在 Unmatch/Block 后置为非 active —— 保持现状。

ConversationMapper.findActiveByUser 过滤 status = 'active'。一旦把 status 改成 unmatched/blocked,会话会从"我的消息"列表消失。 但 conversation-item 组件恰恰要在列表里渲染"会话已停用 / 会话已停用(已拉黑)"的灰显态,AGENTS 也要求 Block 后保留历史。

因此本轮把"关闭语义"交给事件 + 发送时校验承载,而不是 status:

  • 发送权限 → assertMatchAlive + 拉黑关系双向校验(与 status 无关,无法绕过);
  • 实时表现 → 推送 conversation.closed,客户端立即灰显;
  • 读接口 → toConversationDTO 已用 matchAlive && !blocked 算出 canSendMessage=false 与 relationStatus。

列表可见性是否应在关闭后收回,是未被本阶段授权的产品决策,故保持现状并在本节显式登记,请用户裁决。


5. 验证证据

项目 结果
Backend 全量回归(离线容器,mvn -o test) 328 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 308,+20)
新增测试 ChatPhase09IntegrationTest(13 用例,真实 H2 + 真实 SQL + 真实 HTTP 层)、ChatWebSocketHandlerTest(7 用例,纯单元)
负向验证 同时还原 assertMatchAlive 与消息读接口的成员校验后,恰好 3 个用例变红(outsiderCannotReadMessages、sendRejectedWhenMatchNotAlive、unmatchEndpointImmediatelyDisablesChat)✔ 守卫有效
定向复跑 ChatPhase09IntegrationTest + P0FixVerificationTest:21 tests / 0 failures / 0 errors

新增/增强测试覆盖:

用例 断言要点
幂等重试不重复投递 同 clientMessageId 返回同一条;库内只 1 行;未读只 +1;只推一次
推送信封 sendEvent(peer, "message.new", ...),data.conversationId + data.message.{messageId,content,senderId,sentAt}
未读随发送递增、标记已读清零 发送方不计未读
Match 已 unmatched 时拒发 刻意只改 Match、不改会话,证明校验不依赖会话状态
Unmatch 端点即时禁聊 调 MatchController.unmatch 后双方均拒发 + 收到 conversation.closed
Block 即时禁聊 + Match 落库为 unmatched 直接锁死 C3 回归
活动双确认复用同一消息体系 确认 → from-match 建会话 → 同一套发送/推送/未读
未形成可聊 Match 无法建会话 不存在 / 已失效的 Match 均拒绝(Gate 第二分句)
非成员读消息/详情/未读被拒 越权响应不泄露聊天正文;成员侧不受影响
WS handler(7 例) userId 路由、{event,data} 信封、未认证连接被关、断开后不推、重连替换旧连接、上行只支持 ping
幂等由 DB 唯一索引兜底(2 例) 绕过 service 幂等查询直接插重复 clientMessageId → 被唯一索引拒绝;无幂等键的系统消息仍可多条共存

5.1 测试保真度补强(延续 Phase 08 的方法论)

"正常会话不重复投递"在并发下的真正保障不是 service 里的 SELECT-then-INSERT(两者之间存在竞态窗口), 而是生产库的唯一索引:

-- V004__add_messaging_and_recommendation_tables.sql:71
CREATE UNIQUE INDEX uk_message_client_id ON messages(sender_id, client_message_id)
    WHERE client_message_id IS NOT NULL;

而测试库 schema-h2.sql 里没有这条索引 —— 与 Phase 08 的 chk_match_source 属同一类保真度缺口: 约束只存在于生产库,测试环境无从验证。本轮已补齐:

  • H2 不支持过滤索引,故省略 WHERE;但 H2 的唯一索引遵循 SQL 标准(多个 NULL 互不冲突), 语义与生产的部分索引等价 —— 未带幂等键的系统消息(RiskNotice)仍可多条存在,已由测试守住。
  • 新增两条测试:绕过 service 层直接落库的重复 clientMessageId 必须被索引拒绝; 无幂等键的系统消息不得被误伤。

6. 已知限制(本轮登记,未处理)

  1. 业务拒绝仍映射为 HTTP 500。 sendText 用 IllegalStateException 表达"未 Mutual / 已拉黑 / 非成员",而 GlobalExceptionHandler 的兜底把它映射成 500 内部错误,而非 4xx。前端目前靠 canSendMessage=false 规避,不依赖该状态码,故本轮未动(改动涉及全平台错误码契约,超出本阶段授权)。
  2. Conversation.status 的关闭语义未定 —— 见第 4 节。
  3. WebSocket 为单实例内存实现:多实例部署需 Redis Pub/Sub(原注释已标注属于 Phase 22+)。当前不构成缺陷。
  4. MatchService.unmatch 未持久化这一"静态工具方法只改内存态"的隐患仍存在于方法签名本身(调用方必须自己 updateById)。本轮只在 canonical 调用点补上,未改设计。

7. Gate 判定

Gate 条款 判定 依据
未 Mutual 时不能发消息 ✅ 无 Match → 无会话;且 assertMatchAlive 独立拒发
活动未双确认时不能发消息 ✅ 无 Match → 无会话 → 无成员 → 拒发;getOrCreateConversation 对不存在/失效 Match 直接拒建
正常会话不重复投递 ✅ 相同 clientMessageId 重放返回原消息且不推送;测试断言只推 1 次
REST send + WebSocket receive ✅ 握手鉴权 + 落库后推送 message.new,端到端链路打通

Phase 09:通过并关闭。


8. 变更清单

主代码

  • chat/websocket/ChatHandshakeInterceptor.java — 新增:握手 Token 鉴权 + userId 绑定(fail-closed)
  • chat/websocket/ChatWebSocketHandler.java — 重写:{event,data} 信封、sendEvent 下行 API、未认证连接关闭、重连替换、ping/pong
  • chat/config/WebSocketConfig.java — 注册拦截器;移除 SockJS
  • chat/service/ChatService.java — 注入 WS handler;assertMatchAlive;落库后推 message.new;syncFromMatch 推 conversation.closed;新增 closeConversationForMatch、isMember
  • chat/controller/MessageController.java — load/markRead 补成员校验
  • chat/controller/ConversationController.java — detail/unread 补成员校验
  • match/controller/MatchController.java — unmatch 联动会话关闭
  • match/service/RelationService.java — Block 补 Match 落库 + 会话关闭通知

测试

  • chat/ChatPhase09IntegrationTest.java — 新增 13 用例
  • chat/websocket/ChatWebSocketHandlerTest.java — 新增 7 用例
  • backend/src/test/resources/schema-h2.sql — 补生产消息唯一索引 uk_message_client_id

9. 下一阶段

不自动进入 Phase 10。 按计划要求,每阶段输出报告后停止,等待用户确认。

Phase 10(Image & Chat Safety — KEEP-VERIFY)待办预告:

  • 验证图片私有访问、类型/大小校验、审核和水印
  • 验证风险识别、举报入口和违规内容处理
  • 检查日志不记录聊天正文、身份证、手机号或签名 URL

附:ChatSafetyService 的频控为 Redis fail-open(Redis 不可用时放行),Phase 10 应一并复核该降级策略是否符合风控要求。