# N07 消息撤回与会话一致性:详细设计 状态:N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收。 文档类型:解释与技术参考。目标读者为产品负责人、后端开发、前端联调和独立验收人员。 ## 1. 目标 N07 为现有站内会话增加有限、可审计、并发安全的消息撤回能力。发送者可在发送后两分钟内撤回自己的消息;会话成员继续看到一条撤回占位记录,但不能再通过列表、详情、缓存或审计取得原正文。 本版同时统一消息响应的白名单映射,保证 `DIRECT` 会话保持既有身份字段语义,`TALENT_INVITATION` 会话继续使用相对身份标识,不重新暴露学生内部 ID。 ## 2. 当前实现事实 - 消息表由 `V9__messaging.sql` 创建,并由 N07 的 `V41__message_recall.sql` 增加 `status`、`recalled_at` 和 `version`。 - `Message` 实体使用字符串状态、撤回时间和 JPA `@Version`;撤回通过条件更新保证并发安全。 - `MessagingService` 的既有 Map 响应和 N07 撤回响应均委托给统一的 `MessageViewMapper`,不直接返回实体。 - `DIRECT` 消息当前返回 `senderUserId`;这是既有兼容语义。 - `TALENT_INVITATION` 消息返回 `senderScope=CURRENT_USER/OTHER_PARTICIPANT`,不返回另一方用户 ID。 - 当前没有附件、引用消息、消息编辑、WebSocket 推送或消息撤回。 - 未读数当前基于最近 500 条消息与 `message_reads` 计算。 - 最新主线迁移为 V40;并行工作存在迁移号冲突风险。 ## 3. 范围 ### 3.1 本版包含 - 发送者撤回本人发送的消息; - `DIRECT` 与 `TALENT_INVITATION` 两种会话; - 两分钟撤回窗口; - 重复撤回幂等; - 数据库条件更新与乐观锁; - 固定撤回占位文本; - 撤回消息不计入未读数; - 白名单消息响应 DTO; - 一条脱敏安全审计; - 自动化、OpenAPI、联调文档和真实 MySQL 黑盒验收。 ### 3.2 本版不包含 - 消息编辑、删除、批量撤回; - 管理员代撤回; - 撤回恢复; - 附件和引用消息; - WebSocket、SSE 或 RabbitMQ 撤回通知; - 离线推送、短信或邮件; - 保存或公开原正文历史; - 内容审核、AI 摘要或聊天风控扩展; - 改变 DIRECT 会话创建的 MATCHED 守卫; - 改变 V42 邀约接受后建会话的语义。 ## 4. 产品规则 ### 4.1 时间窗口 撤回截止时间为: ```text recallDeadline = message.createdAt + 2 minutes ``` 当 `capturedNow <= recallDeadline` 时允许撤回;当 `capturedNow > recallDeadline` 时返回冲突。一次请求只捕获一次 `capturedNow`,所有判断和 `recalledAt` 均使用该时间。 ### 4.2 权限 - 请求者必须已登录; - 请求者必须是会话成员; - 消息必须属于路径中的会话; - 只有 `senderUserId` 对应的原发送者可以撤回; - 其他成员、ADMIN 以及不属于该会话的用户均返回既有 `403/2002`; - 不通过不同错误暴露某条消息是否属于其他会话。 ### 4.3 状态机 ```text SENT -> RECALLED ``` - `SENT` 可以在窗口内转为 `RECALLED`; - `RECALLED` 再次撤回返回同一当前结果,HTTP 200,不重复更新、不重复审计; - 窗口外的 `SENT` 返回 409; - 没有其他状态迁移。 ## 5. 数据库方案 编码前从实际主线确定下一可用 Flyway 版本,文件说明建议为: ```text V__message_recall.sql ``` 不得预先假定 V41 未被其他分支占用,也不得修改 V1–V40。 在 `messages` 增加: | 字段 | 类型 | 约束 | 说明 | |---|---|---|---| | status | VARCHAR(20) | NOT NULL DEFAULT 'SENT' | `SENT` 或 `RECALLED` | | recalled_at | DATETIME | NULL | 实际撤回时间 | | version | BIGINT | NOT NULL DEFAULT 0 | JPA `@Version` | 约束建议: ```sql CHECK (status IN ('SENT','RECALLED')) ``` 现有 `conversation_id, created_at` 索引继续用于分页。本版不新增撤回历史表,不保存原正文副本,不新增包含正文的审计表。 迁移必须同时通过 H2 `MODE=MySQL` 与真实 MySQL 8.x,并保持 `ddl-auto=validate`。 ## 6. 正文与数据保留 撤回成功时,将 `content` 覆盖为固定内部占位值: ```text 消息已撤回 ``` 响应层也只返回这一固定文本。原正文不得写入: - audit_logs; - 应用日志; -异常消息; - history 表; - RabbitMQ; -缓存或导出结果。 数据库备份和存储层面的物理恢复策略属于 G02,不在 N07 内建立正文恢复接口。 ## 7. HTTP 接口 新增一个接口: ```http POST /conversations/{conversationId}/messages/{messageId}/recall ``` 无请求体。身份仅从 JWT/SecurityContext 获取。 成功响应使用白名单 `MessageResponse`: ```json { "code": 200, "data": { "id": 81, "conversationId": 12, "senderUserId": 7, "content": "消息已撤回", "status": "RECALLED", "recalledAt": "2026-09-10T10:01:30", "createdAt": "2026-09-10T10:00:00", "isRead": false } } ``` 对于 `TALENT_INVITATION`,响应使用 `senderScope`,不得返回 `senderUserId`。 ### 7.1 错误语义 | 场景 | HTTP / 业务码 | |---|---| | 未登录 | 401 / 既有未认证码 | | 非成员、非发送者、跨会话探测、ADMIN | 403 / 2002 | | 会话不存在 | 404 / 既有会话不存在码 | | 当前会话内消息不存在 | 404 / 新增消息不存在码或经评审复用现有码 | | 超过撤回窗口 | 409 / 新增撤回窗口错误码 | | 并发状态冲突 | 409 / 新增并发冲突错误码 | ErrorCode 属于高冲突文件。编码前应先核对现有码值,并只新增确有必要的错误码。 ## 8. 响应 DTO 与兼容 新增白名单 DTO,建议: - `MessageResponse`; - 如分页结构尚无类型,可新增 `MessagePageResponse`。 允许字段: - id; - conversationId; - senderUserId,仅 DIRECT; - senderScope,仅 TALENT_INVITATION; - content; - status; - recalledAt; - createdAt; - isRead。 禁止字段: - 对方在 TALENT_INVITATION 中的 userId; - 手机号、邮箱、姓名、简历; - 邀约 candidateKey 或摘要; - 原撤回正文; - IP、User-Agent、Token、密码; - 内部审计内容。 现有发送与列表接口兼容新增 `status` 和可空 `recalledAt`。DIRECT 的 `senderUserId` 不删除;TALENT_INVITATION 继续只提供相对身份。 ## 9. 服务与事务 建议新增: ```java MessageResponse recallMessage( Long currentUserId, Long conversationId, Long messageId, LocalDateTime capturedNow, AuditContext auditContext) ``` 事务步骤: 1. 查询会话并校验成员; 2. 查询消息,校验其属于当前会话; 3. 校验请求者为发送者; 4. 若已撤回,返回当前白名单响应; 5. 校验两分钟窗口; 6. 使用 `id + conversationId + senderUserId + status=SENT + version` 条件更新; 7. 更新 `status=RECALLED`、固定占位正文、`recalledAt=capturedNow`; 8. 条件更新成功后写一条审计; 9. 返回重新读取或由确定字段构造的响应。 只有成功发生 `SENT -> RECALLED` 的请求写审计。幂等重放和失败请求不重复写成功审计。 ## 10. 并发与幂等 - `Message.version` 使用 JPA `@Version`; - Repository 提供条件更新,不能只依赖“先查后改”; - 两个并发撤回请求只能一个执行状态变更并写审计; - 另一请求重新读取后若状态已为 `RECALLED`,按幂等成功返回; - 撤回与列表读取允许并发,读取结果只能是完整 `SENT` 或完整 `RECALLED`,不能返回已撤回状态配原正文; - SQL异常和锁异常不得转为空成功。 ## 11. 未读与列表 - `RECALLED` 消息保留在消息分页中,维持时间线位置; - 撤回占位消息的 `isRead` 可以保留既有记录语义; - `RECALLED` 消息不计入 unreadCount; - 不删除已有 `message_reads`; - `markRead` 不需要为撤回消息新增 read 记录; - 列表、发送响应、撤回响应必须共用同一消息映射器。 现有 `countUnread` 与 `markRead` 存在最多读取 500 条的历史限制。N07 只修正撤回消息口径,不借机重写整个未读架构;容量治理记录到 G03。 ## 12. 审计 成功状态变更写: ```text MESSAGE_RECALLED ``` details 白名单: ```json { "messageId": 81, "conversationId": 12, "conversationType": "DIRECT", "status": "RECALLED", "requestId": "..." } ``` 禁止写入正文、对方用户 ID、手机号、邮箱、简历、IP/UA 明文、Token、密码或邀约标识。 ## 13. 性能与查询 - 消息分页不得因撤回字段产生逐消息额外查询; - 会话类型只读取一次,不能逐消息查询 Conversation; - 已读状态继续批量查询,禁止逐消息 `existsBy...`; - unreadCount 应通过数据库计数或固定批量查询实现,排除 `RECALLED`; - 查询次数不得随消息数量线性增长; - 为真实 MySQL EXPLAIN 保留消息分页和未读计数 SQL。 ## 14. OpenAPI 影响 当前基线为 172 paths / 210 operations。若仅新增一个撤回接口,预计为: ```text 173 paths / 211 operations ``` 实际计数以静态 OpenAPI、运行时 `/api/v1/v3/api-docs` 和校验脚本为准。 OpenAPI 必须断言: - 仅新增一个 N07 path/operation; - 撤回接口 200 响应有非空 schema; - DIRECT 与邀约消息隐私契约明确; - 响应不包含原正文历史或用户隐私字段; - 既有 6 个消息 operations 保持存在。 ## 15. 测试与验收 ### 15.1 单元与服务测试 - 发送后 0 秒、1:59、恰好 2:00 可撤回; - 2:00:00 后最小时间单位不可撤回; - 非发送者、非成员、ADMIN 拒绝; - 消息和路径会话不一致返回 403,避免枚举; - 重复撤回幂等; - 并发撤回仅一次更新和一次审计; - 固定 Clock,全请求只捕获一次; - 审计白名单不含正文。 ### 15.2 HTTP 与隐私测试 - DIRECT 发送、列表和撤回保持 senderUserId; - TALENT_INVITATION 仅返回 senderScope; - 已撤回消息所有接口只返回占位内容; - 完整 JSON 不含原正文、对方 ID、联系方式或凭据; - 未登录 401、越权 403/2002。 ### 15.3 持久化与 MySQL - 迁移在 H2 MySQL 模式和 MySQL 8.x 执行; - `CHECK`、默认状态和 `@Version` 正确; - 撤回后数据库正文为固定占位; - 并发条件更新正确; - `ddl-auto=validate` 通过; - 消息分页和未读 SQL EXPLAIN 无发布阻断。 ### 15.4 回归 - DIRECT MATCHED 守卫不变; - V33 屏蔽发送规则不变; - V42 邀约接受、会话隐私和唯一会话不变; - 消息发送、列表、已读原有功能通过; - 不写 candidate_dimension_scores; - 不发送新增 RabbitMQ 消息。 ## 16. 风险 - 并行分支可能占用迁移号,编码前必须重新确认; - Map 响应迁移为 DTO 时容易意外删除旧字段,必须做兼容 JSON 对照; - 撤回与列表并发可能暴露状态和正文不一致,必须原子更新并统一映射; - 当前未读实现有 500 条上限,不能将其误称为完整历史未读统计; - 数据库备份可能保留历史物理页,本功能只保证业务接口、当前数据库值、日志和审计不暴露原正文。 ## 17. 已确认决策 | 决策 | 结论 | |---|---| | D01 | 撤回窗口为发送后 2 分钟 | | D02 | `capturedNow <= deadline` 允许撤回 | | D03 | DIRECT 与 TALENT_INVITATION 均支持 | | D04 | 仅原发送者可撤回 | | D05 | 使用 conversationId 与 messageId 的 POST 撤回接口 | | D06 | 重复撤回幂等返回 200 | | D07 | 保留固定撤回占位消息 | | D08 | 数据库正文覆盖为固定占位,不保存原正文历史 | | D09 | 撤回消息不计入 unreadCount,不删除已有 read 记录 | | D10 | messages 增加 status、recalled_at、version,无历史表 | | D11 | @Version 与条件更新共同保证并发 | | D12 | 仅成功转换写 MESSAGE_RECALLED 脱敏审计 | | D13 | 一期不新增实时推送或 RabbitMQ 事件 | | D14 | 附件与引用消息不在本版范围 | | D15 | 使用白名单 DTO,保持两种会话的既有身份语义 | | D16 | 401/403/404/409 按认证、归属、存在性和状态区分 | | D17 | 预计新增 1 path / 1 operation | | D18 | Flyway 版本在编码前按实际主线分配 |