Files

212 lines
15 KiB
Markdown
Raw Permalink Normal View 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. 阶段目标(来自客户需求基线增量开发与修复计划)
- [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 应一并复核该降级策略是否符合风控要求。