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

212 lines
15 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.
# 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. 阶段目标(来自客户需求基线增量开发与修复计划)
- [x] 验证 REST send + WebSocket receive、消息幂等、重连和未读计数。
- [x] 验证非 active Match 禁聊,Unmatch/Block 后即时禁聊。
- [x] 让活动双确认产生的 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`(两者之间存在竞态窗口),
而是生产库的唯一索引:
```sql
-- 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 应一并复核该降级策略是否符合风控要求。