对 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 未启动,按计划每阶段出报告后停止等待确认。
15 KiB
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. 已知限制(本轮登记,未处理)
- 业务拒绝仍映射为 HTTP 500。
sendText用IllegalStateException表达"未 Mutual / 已拉黑 / 非成员",而GlobalExceptionHandler的兜底把它映射成 500 内部错误,而非 4xx。前端目前靠canSendMessage=false规避,不依赖该状态码,故本轮未动(改动涉及全平台错误码契约,超出本阶段授权)。 Conversation.status的关闭语义未定 —— 见第 4 节。- WebSocket 为单实例内存实现:多实例部署需 Redis Pub/Sub(原注释已标注属于 Phase 22+)。当前不构成缺陷。
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/pongchat/config/WebSocketConfig.java— 注册拦截器;移除 SockJSchat/service/ChatService.java— 注入 WS handler;assertMatchAlive;落库后推message.new;syncFromMatch推conversation.closed;新增closeConversationForMatch、isMemberchat/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 应一并复核该降级策略是否符合风控要求。