You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

12 KiB

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 时间窗口

撤回截止时间为:

recallDeadline = message.createdAt + 2 minutes

当 capturedNow <= recallDeadline 时允许撤回;当 capturedNow > recallDeadline 时返回冲突。一次请求只捕获一次 capturedNow,所有判断和 recalledAt 均使用该时间。

4.2 权限

  • 请求者必须已登录;
  • 请求者必须是会话成员;
  • 消息必须属于路径中的会话;
  • 只有 senderUserId 对应的原发送者可以撤回;
  • 其他成员、ADMIN 以及不属于该会话的用户均返回既有 403/2002;
  • 不通过不同错误暴露某条消息是否属于其他会话。

4.3 状态机

SENT -> RECALLED
  • SENT 可以在窗口内转为 RECALLED;
  • RECALLED 再次撤回返回同一当前结果,HTTP 200,不重复更新、不重复审计;
  • 窗口外的 SENT 返回 409;
  • 没有其他状态迁移。

5. 数据库方案

编码前从实际主线确定下一可用 Flyway 版本,文件说明建议为:

V<next>__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

约束建议:

CHECK (status IN ('SENT','RECALLED'))

现有 conversation_id, created_at 索引继续用于分页。本版不新增撤回历史表,不保存原正文副本,不新增包含正文的审计表。

迁移必须同时通过 H2 MODE=MySQL 与真实 MySQL 8.x,并保持 ddl-auto=validate。

6. 正文与数据保留

撤回成功时,将 content 覆盖为固定内部占位值:

消息已撤回

响应层也只返回这一固定文本。原正文不得写入:

  • audit_logs;
  • 应用日志; -异常消息;
  • history 表;
  • RabbitMQ; -缓存或导出结果。

数据库备份和存储层面的物理恢复策略属于 G02,不在 N07 内建立正文恢复接口。

7. HTTP 接口

新增一个接口:

POST /conversations/{conversationId}/messages/{messageId}/recall

无请求体。身份仅从 JWT/SecurityContext 获取。

成功响应使用白名单 MessageResponse:

{
  "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. 服务与事务

建议新增:

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. 审计

成功状态变更写:

MESSAGE_RECALLED

details 白名单:

{
  "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。若仅新增一个撤回接口,预计为:

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 版本在编码前按实际主线分配