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)
事务步骤:
- 查询会话并校验成员;
- 查询消息,校验其属于当前会话;
- 校验请求者为发送者;
- 若已撤回,返回当前白名单响应;
- 校验两分钟窗口;
- 使用
id + conversationId + senderUserId + status=SENT + version条件更新; - 更新
status=RECALLED、固定占位正文、recalledAt=capturedNow; - 条件更新成功后写一条审计;
- 返回重新读取或由确定字段构造的响应。
只有成功发生 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 版本在编码前按实际主线分配 |