forked from baskbuddy/zhangshun
28 changed files with 2656 additions and 50 deletions
@ -0,0 +1,368 @@ |
|||
# 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<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` | |
|||
|
|||
约束建议: |
|||
|
|||
```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 版本在编码前按实际主线分配 | |
|||
@ -0,0 +1,293 @@ |
|||
# N07 消息撤回:实施指令 |
|||
|
|||
状态:N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收。 |
|||
|
|||
文档类型:操作指南。用于指导实现者在不扩大范围的前提下完成 N07。 |
|||
|
|||
## 1. 开始前 |
|||
|
|||
项目根目录: |
|||
|
|||
```text |
|||
C:\Users\15642\Desktop\zhilai |
|||
``` |
|||
|
|||
先完整阅读: |
|||
|
|||
- `docs/message-recall-design.md`; |
|||
- `docs/frontend-integration.md`; |
|||
- `docs/enterprise-talent-invitation-api.md`; |
|||
- `docs/report-block-api.md`; |
|||
- `docs/phase1-scope-and-acceptance.md`。 |
|||
|
|||
重点代码: |
|||
|
|||
- `messaging/Message.java`; |
|||
- `messaging/MessageRepository.java`; |
|||
- `messaging/MessagingService.java`; |
|||
- `messaging/MessagingController.java`; |
|||
- `messaging/Conversation.java`; |
|||
- `messaging/ConversationMemberRepository.java`; |
|||
- `talentinvitation/`; |
|||
- `safety/BlockRelationRepository.java`; |
|||
- `audit/SecurityAuditService.java`; |
|||
- `config/SecurityConfig.java`; |
|||
- `common/exception/ErrorCode.java`; |
|||
- `common/exception/GlobalExceptionHandler.java`。 |
|||
|
|||
先执行: |
|||
|
|||
```powershell |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git log -5 --oneline |
|||
git diff --check |
|||
``` |
|||
|
|||
如果暂存区非空,停止并报告。保护所有历史未跟踪文件,不得 reset、restore、checkout、clean 或 `git add -A`。 |
|||
|
|||
## 2. 迁移号分配 |
|||
|
|||
编码前列出迁移: |
|||
|
|||
```powershell |
|||
Get-ChildItem mydreamjob-server/src/main/resources/db/migration -File | |
|||
Sort-Object Name | |
|||
Select-Object -ExpandProperty Name |
|||
``` |
|||
|
|||
同时检查其他待合并分支是否已经占用下一版本。只有负责人确认后才创建: |
|||
|
|||
```text |
|||
V<next>__message_recall.sql |
|||
``` |
|||
|
|||
禁止修改 V1–V40,禁止擅自假定 V41 可用。 |
|||
|
|||
迁移只允许为 `messages` 增加: |
|||
|
|||
- `status VARCHAR(20) NOT NULL DEFAULT 'SENT'`; |
|||
- `recalled_at DATETIME NULL`; |
|||
- `version BIGINT NOT NULL DEFAULT 0`; |
|||
- `status` 的受控 CHECK。 |
|||
|
|||
不新增历史表,不保存正文副本。 |
|||
|
|||
## 3. 实体与 Repository |
|||
|
|||
修改 `Message`: |
|||
|
|||
- 新增字符串状态或受控枚举; |
|||
- 新增 `recalledAt`; |
|||
- 新增 `@Version Long version`; |
|||
- 发送创建状态固定为 `SENT`。 |
|||
|
|||
Repository 增加条件更新,条件至少包括: |
|||
|
|||
- messageId; |
|||
- conversationId; |
|||
- senderUserId; |
|||
- status=`SENT`; |
|||
- expectedVersion。 |
|||
|
|||
更新内容: |
|||
|
|||
- status=`RECALLED`; |
|||
- content=`消息已撤回`; |
|||
- recalledAt=capturedNow; |
|||
- version 原子递增。 |
|||
|
|||
不得只用 `findById -> set -> save` 作为并发保证。 |
|||
|
|||
消息分页和未读查询要排除撤回消息的未读贡献,并采用批量查询,禁止逐消息查 Conversation 或 read 状态。 |
|||
|
|||
## 4. DTO 与映射 |
|||
|
|||
在 `messaging/dto/` 新增白名单 DTO: |
|||
|
|||
- `MessageResponse`; |
|||
- 必要时新增 `MessagePageResponse`。 |
|||
|
|||
把发送、列表、撤回响应收敛到同一个映射器。 |
|||
|
|||
DIRECT 兼容保留 `senderUserId`;TALENT_INVITATION 只返回 `senderScope`。撤回状态下任何路径都只能返回固定占位正文。 |
|||
|
|||
不得直接返回 Message Entity,不得继续在 Controller 拼 Map。 |
|||
|
|||
## 5. 服务实现 |
|||
|
|||
为 `MessagingService` 注入 `Clock`,生产默认系统时区。撤回入口只捕获一次 `capturedNow`。 |
|||
|
|||
实现顺序: |
|||
|
|||
1. requireConversation; |
|||
2. assertMember; |
|||
3. 查询消息并防止跨会话枚举; |
|||
4. 校验发送者; |
|||
5. 已撤回则幂等返回; |
|||
6. 校验 `capturedNow <= createdAt.plusMinutes(2)`; |
|||
7. 执行条件更新; |
|||
8. 失败时重新读取并区分幂等完成与真实冲突; |
|||
9. 成功后写一次脱敏审计; |
|||
10. 返回白名单响应。 |
|||
|
|||
不得把原正文写入日志、异常或审计。 |
|||
|
|||
## 6. Controller 与接口 |
|||
|
|||
新增: |
|||
|
|||
```http |
|||
POST /conversations/{conversationId}/messages/{messageId}/recall |
|||
``` |
|||
|
|||
无 requestBody,不接收 userId。身份从 SecurityContext/JWT subject 获取。 |
|||
|
|||
Controller 只做参数接收、身份解析和响应包装,不包含撤回规则。 |
|||
|
|||
## 7. ErrorCode |
|||
|
|||
先搜索现有消息错误码。只在没有可准确复用的代码时新增: |
|||
|
|||
- MESSAGE_NOT_FOUND; |
|||
- MESSAGE_RECALL_WINDOW_EXPIRED; |
|||
- MESSAGE_RECALL_CONCURRENT_UPDATE。 |
|||
|
|||
保持: |
|||
|
|||
- 未登录 401; |
|||
- 非成员、非发送者和跨会话探测 403/2002; |
|||
- 超时和并发冲突 409。 |
|||
|
|||
不得改变已有错误码含义。ErrorCode 和 GlobalExceptionHandler 修改前应与集成负责人确认。 |
|||
|
|||
## 8. 审计 |
|||
|
|||
成功状态转换写一条 `MESSAGE_RECALLED`。 |
|||
|
|||
details 只允许: |
|||
|
|||
- messageId; |
|||
- conversationId; |
|||
- conversationType; |
|||
- status; |
|||
- requestId。 |
|||
|
|||
幂等重放、超时、越权和并发失败不写成功审计。禁止记录正文、对方 ID、联系方式、IP/UA 明文或凭据。 |
|||
|
|||
## 9. 必须新增的测试 |
|||
|
|||
建议测试类: |
|||
|
|||
- `MessageRecallServiceTest`; |
|||
- `MessageRecallControllerTest`; |
|||
- `MessageRecallPersistenceIntegrationTest`; |
|||
- `MessageRecallPrivacyIntegrationTest`。 |
|||
|
|||
必须覆盖: |
|||
|
|||
1. 窗口内撤回; |
|||
2. 1:59 与恰好 2:00 成功; |
|||
3. 超过 2:00 的最小时间单位失败; |
|||
4. 重复撤回 HTTP 200 且不重复审计; |
|||
5. 非发送者、非成员、ADMIN、匿名; |
|||
6. messageId 属于另一 conversationId 时 403/2002; |
|||
7. 两个并发请求只有一次条件更新和审计; |
|||
8. 撤回后数据库正文为固定占位; |
|||
9. 列表、撤回响应均不返回原正文; |
|||
10. DIRECT 保留 senderUserId; |
|||
11. TALENT_INVITATION 不返回 senderUserId、createdBy 或学生 ID; |
|||
12. 撤回消息不计 unreadCount; |
|||
13. 已有 message_reads 不删除; |
|||
14. markRead 不为撤回消息产生新记录; |
|||
15. 消息分页无逐消息查询; |
|||
16. 审计 details 白名单; |
|||
17. V33 屏蔽发送回归; |
|||
18. V42 邀约会话隐私回归; |
|||
19. DIRECT MATCHED 守卫回归; |
|||
20. `candidate_dimension_scores` 不变。 |
|||
|
|||
完整 JSON 禁止出现: |
|||
|
|||
```text |
|||
originalContent |
|||
previousContent |
|||
studentUserId |
|||
candidateId |
|||
phone |
|||
email |
|||
resumeUrl |
|||
candidateKey |
|||
candidateKeyDigest |
|||
ipAddress |
|||
userAgent |
|||
password |
|||
token |
|||
secret |
|||
``` |
|||
|
|||
## 10. OpenAPI 与文档 |
|||
|
|||
更新: |
|||
|
|||
- `docs/messaging-api.md`,不存在则新增; |
|||
- `docs/frontend-integration.md`; |
|||
- `docs/backend-development-roadmap.md`; |
|||
- `docs/phase1-scope-and-acceptance.md`; |
|||
- `docs/openapi-rc.json`; |
|||
- `docs/verify_openapi.py`; |
|||
- 本设计和实施指令状态。 |
|||
|
|||
目标预计为 173 paths / 211 operations,以实际静态和运行时结果为准。 |
|||
|
|||
校验脚本必须递归检查消息响应 schema,不得只检查计数。 |
|||
|
|||
## 11. 自动化验证 |
|||
|
|||
依次执行: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server |
|||
mvn -Dtest="*MessageRecall*Test,*Messaging*Test,*TalentInvitation*Test" test |
|||
mvn test |
|||
mvn clean verify |
|||
python ..\docs\verify_openapi.py |
|||
git diff --check |
|||
``` |
|||
|
|||
记录真实 Tests run、Failures、Errors、Skipped 和 OpenAPI 计数。禁止删除测试或修改期望语义掩盖回归。 |
|||
|
|||
## 12. 真实 MySQL/Docker 验收 |
|||
|
|||
自动化完成后由负责人授权执行: |
|||
|
|||
- Flyway 新迁移 success=1; |
|||
- `ddl-auto=validate` 通过; |
|||
- 窗口边界与并发撤回; |
|||
- 重复撤回只一条审计; |
|||
- 数据库正文不可恢复为业务明文; |
|||
- 权限矩阵; |
|||
- DIRECT 与邀约完整 JSON 脱敏; |
|||
- unreadCount 与 read 记录; |
|||
- 消息分页和未读 SQL EXPLAIN; |
|||
- RabbitMQ 无新增消息; |
|||
- 运行时 OpenAPI 与静态文件一致。 |
|||
|
|||
未执行真库验收不得标记冻结。 |
|||
|
|||
## 13. Git 边界 |
|||
|
|||
本轮实现期间: |
|||
|
|||
- 不操作 Docker,除非负责人明确要求黑盒; |
|||
- 不暂存验收脚本、日志、请求体或 `.env`; |
|||
- 不执行 `git add -A`; |
|||
- 不提交、不推送,直到独立验收完成; |
|||
- 不删除历史未跟踪文件; |
|||
- 不进入 N08。 |
|||
|
|||
完成代码和自动化后,状态只能写为: |
|||
|
|||
```text |
|||
N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收。 |
|||
``` |
|||
@ -0,0 +1,33 @@ |
|||
# 站内消息接口 |
|||
|
|||
## 消息撤回 |
|||
|
|||
`POST /conversations/{conversationId}/messages/{messageId}/recall` |
|||
|
|||
当前登录用户从 JWT subject 解析。请求无 body,只有会话成员中的原发送者可在消息创建后两分钟内撤回;截止瞬间仍允许撤回。会话不存在返回 404/8305,跨会话、非成员或非发送者返回 403/2002,窗口结束返回 409/8307,并发状态冲突返回 409/8308。 |
|||
|
|||
成功响应为白名单 `MessageResponse`: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"data": { |
|||
"id": 81, |
|||
"conversationId": 12, |
|||
"senderUserId": 7, |
|||
"content": "消息已撤回", |
|||
"status": "RECALLED", |
|||
"recalledAt": "2026-09-10T10:02:00", |
|||
"createdAt": "2026-09-10T10:00:00", |
|||
"isRead": false |
|||
} |
|||
} |
|||
``` |
|||
|
|||
撤回会在同一事务中将数据库正文覆盖为固定占位文本 `消息已撤回`,并将状态从 `SENT` 更新为 `RECALLED`。重复请求幂等返回当前撤回结果,不重复写成功审计。原正文不写入审计、异常、缓存、消息队列或历史表。 |
|||
|
|||
`TALENT_INVITATION` 会话继续使用 `senderScope=CURRENT_USER` 或 `OTHER_PARTICIPANT`,不返回对方的 `senderUserId`;`DIRECT` 会话保留既有 `senderUserId` 兼容字段。撤回消息仍保留在时间线中,但不计入未读数,也不会因标记已读新增 `message_reads`。 |
|||
|
|||
## 既有消息接口兼容 |
|||
|
|||
原有会话列表、会话详情、发送消息、消息列表和标记已读接口路径不变。消息列表和发送响应增加 `status`,撤回消息的 `content` 统一为占位文本,其他既有字段语义保持不变。 |
|||
@ -0,0 +1,87 @@ |
|||
# N07 消息撤回与会话一致性:独立代码复验与真实 MySQL/Docker 验收 |
|||
|
|||
日期:2026-09-10 |
|||
分支:`feat/message-recall` |
|||
结论:**PASS(真实 MySQL/Docker 黑盒已完成;可进入精确暂存检查点)** |
|||
|
|||
## 1. Git 基线与边界 |
|||
|
|||
- 当前分支:`feat/message-recall`。 |
|||
- HEAD:`dbd27e6 feat: enhance candidate scoring explanations`。 |
|||
- 验收开始及结束时暂存区均为空;既有 N07 修改、历史脚本、日志、JSON、SQL、xlsx 和 `.env` 均保留。 |
|||
- `git diff --check`:通过;迁移目录没有 V1–V40 修改。 |
|||
- N07 唯一新增迁移:`V41__message_recall.sql`。 |
|||
- 未执行 `git add -A`、commit、push、reset、restore、checkout、clean;未删除或移动历史文件。 |
|||
|
|||
## 2. 自动化独立复验 |
|||
|
|||
| 命令 | Tests run | Failures | Errors | Skipped | 结果 | |
|||
|---|---:|---:|---:|---:|---| |
|||
| N07 定向测试 | 30 | 0 | 0 | 0 | BUILD SUCCESS | |
|||
| 消息与邀约关联回归 | 80 | 0 | 0 | 0 | BUILD SUCCESS | |
|||
| `mvn clean verify` | 1601 | 0 | 0 | 0 | BUILD SUCCESS | |
|||
|
|||
`python docs\\verify_openapi.py` 通过,静态 OpenAPI 为 173 paths / 211 operations。 |
|||
|
|||
## 3. 代码审查 |
|||
|
|||
- 状态机:PASS。V41 CHECK 仅允许 SENT/RECALLED;条件更新包含 messageId、conversationId、senderUserId、SENT、expectedVersion;正文固定占位、recalledAt 非空、version 仅递增一次;重复撤回幂等且不重复成功审计。 |
|||
- 权限与归属:PASS。真实 HTTP 中原发送者成功,另一成员/非成员/ADMIN/跨会话为 403/2002,匿名为 401,会话/消息不存在分别为 404/8305、404/8306,超时为 409/8307。 |
|||
- 并发与事务:PASS。H2 独立事务测试以及真实 HTTP 两个并发请求均无 500;同消息最终 version=1、恰好一条成功审计;不同消息并发各自成功。 |
|||
- 依赖注入:PASS。生产目录不存在 `messageViewMapper == null`、`objectMapper == null`、`new MessageViewMapper()`、`new ObjectMapper()` 兜底。 |
|||
- 审计隐私:PASS。真实 MySQL `MESSAGE_RECALLED` 行 `ip_address`/`user_agent` 均为 NULL,details 为合法 JSON 且字段严格为 `messageId,conversationId,conversationType,status,requestId`。 |
|||
- DTO/查询结构:PASS。撤回及分页路径使用白名单 DTO;会话与成员固定查询、消息分页固定查询、message_reads 一次批量查询,无逐消息 exists/find。 |
|||
- 副作用:PASS。目标消息仅发生 SENT→RECALLED、占位正文、recalledAt、version+1;message_reads、conversation_members、candidate_dimension_scores、notifications、风险表内容和 version 均未改变。 |
|||
|
|||
## 4. Docker、MySQL、Flyway 与启动 |
|||
|
|||
- Docker Engine:Server 29.6.2;MySQL 8.4.11、Redis 7.4、RabbitMQ 3.13 容器均 healthy。未重建、删除容器或 volume。 |
|||
- 真实 Flyway 查询:最新三条为 V41 `message recall` success=1、V40 `enterprise talent invitations` success=1、V39 `enterprise talent discovery` success=1;无失败迁移、无 N07 之外迁移。 |
|||
- `SHOW CREATE TABLE messages`:`status` 默认 SENT、`recalled_at` 可空、`version` 默认 0,CHECK 仅允许 SENT/RECALLED,既有外键与 `idx_messages_conversation_created` 保留;无消息历史表。 |
|||
- 使用 `mvn clean verify` 生成的 JAR、dev 配置并保持 `ddl-auto=validate` 启动,日志出现 `Started MyDreamjobApplication`,无 Flyway/schema-validation 错误。 |
|||
- `GET /api/v1/health`:HTTP 200,status=UP。 |
|||
- 本轮启动的应用 PID 4412(后续边界复验重启为 PID 21168)均在验收结束后正常停止;8080 已恢复空闲。Docker 未停止。 |
|||
|
|||
## 5. 真实 HTTP 主链路与权限 |
|||
|
|||
- DIRECT:真实发送、列表、撤回、重复撤回全部 200;撤回后 status=RECALLED、正文为固定占位、recalledAt 非空、version 0→1,重复请求不再增加 version 或审计;senderUserId 按既有语义保留。 |
|||
- TALENT_INVITATION:真实发送、撤回、列表全部 200;仅返回 senderScope,不返回 senderUserId、学生 ID或 candidateKey;会话详情和会话列表中的 TALENT_INVITATION 项不返回 createdBy;撤回后正文为固定占位。 |
|||
- 权限配对结果:原发送者 `200/200`;另一成员 `403/2002`;非成员 `403/2002`;ADMIN `403/2002`;匿名 `401/401`;跨会话 `403/2002`;不存在会话 `404/8305`;不存在消息 `404/8306`;超时 `409/8307`。 |
|||
- 递归 JSON 脱敏:DIRECT 撤回/列表、TALENT_INVITATION 撤回/列表及会话详情中的 TALENT_INVITATION 项均未发现原正文、senderUserId(TALENT)、studentUserId、candidateKey、联系方式、IP/UA、凭据等禁止字段。 |
|||
|
|||
## 6. 并发、时间与 message_reads |
|||
|
|||
- 真实 MySQL HTTP 并发:同一消息两请求均 200,最终 version=1、单条审计;两条不同消息并发均 200、各 version=1、各一条审计。 |
|||
- 时间:H2 正式测试覆盖 0 秒、1:59、恰好 2:00 成功及超过 2:00 最小单位失败;真实 MySQL HTTP 119 秒返回 200/200、120 秒返回 409/8307(数据库状态分别为 RECALLED/SENT),创建后立即撤回也成功。边界表达式保持 `capturedNow <= createdAt.plusMinutes(2)`。 |
|||
- 已有 `message_reads` 行在撤回后仍存在(精确查询=1);撤回消息不计入 unreadCount(精确查询=0),消息仍保留时间线,未新增第二条 read 行。 |
|||
|
|||
## 7. 查询预算与 EXPLAIN |
|||
|
|||
- 使用真实 MySQL `performance_schema.events_statements_summary_by_digest` 测量正式 HTTP 列表路径,夹具规模 1、20、50 条消息:每次均为 `messages=2、message_reads=1、conversations=1、conversation_members=1`,查询数不随消息量线性增长。 |
|||
- 真实 EXPLAIN FORMAT=JSON:消息分页使用 `idx_messages_conversation_created`、access=ref、无 filesort/temporary;message_reads 使用 reader 索引、成员校验使用 `uk_conv_members`;条件撤回使用 PRIMARY、access=range;未读查询使用消息索引和 `uk_message_reads`。未新增索引。 |
|||
- 说明:该 EXPLAIN 为当前验收数据的代表性计划;更大生产数据量的容量/延迟压测仍需后续授权,不以固定查询数替代性能结论。 |
|||
|
|||
## 8. RabbitMQ 与副作用 |
|||
|
|||
RabbitMQ `ping` 成功;`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` 均真实存在,ready=0、unacked=0。N07 撤回未产生新增消息、通知、短信或邮件。 |
|||
|
|||
目标消息撤回前后稳定摘要证明:除目标消息允许的四项状态变化和一条白名单 `MESSAGE_RECALLED` 审计外,message_reads、会话、成员、评分快照、通知、风险治理表内容与 version 不变。 |
|||
|
|||
## 9. OpenAPI |
|||
|
|||
- 静态:173 paths / 211 operations,`verify_openapi.py` 通过。 |
|||
- 运行时:`GET /api/v1/v3/api-docs` 返回 173 paths / 211 operations,与静态计数一致;撤回无 requestBody,200 响应引用 `ApiResponseMessageResponse`,分页响应引用 `ApiResponseMessagePageResponse`。 |
|||
- 未新增编辑、删除、批量撤回、恢复接口;TALENT_INVITATION 的条件性 senderScope 脱敏由真实 JSON 验证。 |
|||
|
|||
## 10. PASS/FAIL/SKIP 与剩余风险 |
|||
|
|||
- PASS:自动化、V41 迁移与 schema validation、启动/健康、权限、DIRECT/TALENT_INVITATION 脱敏、并发、审计隐私、message_reads/unread、只读副作用、查询预算、EXPLAIN、RabbitMQ、静态/运行时 OpenAPI。 |
|||
- FAIL:0。 |
|||
- SKIP:0 个核心验收项。生产级更大规模容量压测和持续运行监控不属于本轮黑盒范围,需独立性能验收,但不构成当前 N07 功能阻断。 |
|||
- 不允许把本报告扩展为对 V1–V40 其他业务的全面生产保证;仅确认 N07 变更边界。 |
|||
|
|||
## 11. 最终判定 |
|||
|
|||
**PASS:N07 独立代码复验和真实 MySQL/Docker 黑盒全部通过,可以进入精确暂存检查点。** |
|||
|
|||
本报告不包含数据库密码、JWT、测试账号密码、手机号、邮箱或原消息正文。验收数据仅使用本轮唯一前缀并已按精确 ID 清理;历史验收文件未删除或移动。 |
|||
@ -0,0 +1,32 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.messaging.dto.MessageResponse; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.Objects; |
|||
|
|||
/** 集中处理 DIRECT 与 TALENT_INVITATION 消息的白名单映射。 */ |
|||
@Component |
|||
public class MessageViewMapper { |
|||
|
|||
public static final String RECALLED_PLACEHOLDER = "消息已撤回"; |
|||
|
|||
public MessageResponse toResponse(Message message, Long currentUserId, boolean talentInvitation) { |
|||
if (message == null) return null; |
|||
boolean recalled = "RECALLED".equals(message.getStatus()); |
|||
MessageResponse.MessageResponseBuilder builder = MessageResponse.builder() |
|||
.id(message.getId()) |
|||
.conversationId(message.getConversationId()) |
|||
.content(recalled ? RECALLED_PLACEHOLDER : message.getContent()) |
|||
.status(message.getStatus() == null ? "SENT" : message.getStatus()) |
|||
.recalledAt(message.getRecalledAt()) |
|||
.createdAt(message.getCreatedAt()); |
|||
if (talentInvitation) { |
|||
builder.senderScope(Objects.equals(message.getSenderUserId(), currentUserId) |
|||
? "CURRENT_USER" : "OTHER_PARTICIPANT"); |
|||
} else { |
|||
builder.senderUserId(message.getSenderUserId()); |
|||
} |
|||
return builder.build(); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.mydreamjob.messaging.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** 消息分页白名单响应,保持既有分页字段兼容。 */ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Schema(name = "MessagePageResponse", description = "脱敏消息分页响应") |
|||
public class MessagePageResponse { |
|||
|
|||
private List<MessageResponse> items; |
|||
private long total; |
|||
private int page; |
|||
private int size; |
|||
private int totalPages; |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package com.mydreamjob.messaging.dto; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonInclude; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** 消息白名单响应;不直接暴露 Message 实体。 */ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
@Schema(name = "MessageResponse", description = "脱敏消息响应") |
|||
public class MessageResponse { |
|||
|
|||
private Long id; |
|||
private Long conversationId; |
|||
/** 仅 DIRECT 会话返回;TALENT_INVITATION 使用 senderScope。 */ |
|||
private Long senderUserId; |
|||
private String senderScope; |
|||
private String content; |
|||
private String status; |
|||
private LocalDateTime recalledAt; |
|||
private LocalDateTime createdAt; |
|||
private Boolean isRead; |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
ALTER TABLE messages ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'SENT'; |
|||
ALTER TABLE messages ADD COLUMN recalled_at DATETIME NULL; |
|||
ALTER TABLE messages ADD COLUMN version BIGINT NOT NULL DEFAULT 0; |
|||
|
|||
ALTER TABLE messages |
|||
ADD CONSTRAINT chk_messages_status CHECK (status IN ('SENT', 'RECALLED')); |
|||
@ -0,0 +1,215 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.messaging.dto.MessagePageResponse; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.context.TestConfiguration; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Import; |
|||
import org.springframework.context.annotation.Primary; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.datasource.AbstractDataSource; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import javax.sql.DataSource; |
|||
import java.lang.reflect.Proxy; |
|||
import java.sql.Connection; |
|||
import java.sql.Statement; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertFalse; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
|
|||
/** |
|||
* 真实 MessagingService + Repository 的消息列表查询预算证据。 |
|||
* 只统计夹具准备完成后 listMessages 的 JDBC 查询,不以 Mockito verify 替代 SQL 计数。 |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
@Import(MessageListQueryBudgetIntegrationTest.CountingDataSourceConfiguration.class) |
|||
class MessageListQueryBudgetIntegrationTest { |
|||
|
|||
@Autowired private MessagingService messagingService; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private ConversationRepository conversationRepository; |
|||
@Autowired private ConversationMemberRepository memberRepository; |
|||
@Autowired private MessageRepository messageRepository; |
|||
@Autowired private MessageReadRepository readRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@Autowired private CountingDataSource countingDataSource; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private final List<Long> userIds = new ArrayList<>(); |
|||
private final List<Long> conversationIds = new ArrayList<>(); |
|||
|
|||
@AfterEach |
|||
void cleanup() { |
|||
if (!conversationIds.isEmpty()) { |
|||
String marks = placeholders(conversationIds.size()); |
|||
jdbcTemplate.update("DELETE FROM message_reads WHERE message_id IN " |
|||
+ "(SELECT id FROM messages WHERE conversation_id IN (" + marks + "))", |
|||
conversationIds.toArray()); |
|||
jdbcTemplate.update("DELETE FROM messages WHERE conversation_id IN (" + marks + ")", |
|||
conversationIds.toArray()); |
|||
jdbcTemplate.update("DELETE FROM conversation_members WHERE conversation_id IN (" + marks + ")", |
|||
conversationIds.toArray()); |
|||
jdbcTemplate.update("DELETE FROM conversations WHERE id IN (" + marks + ")", |
|||
conversationIds.toArray()); |
|||
} |
|||
if (!userIds.isEmpty()) { |
|||
String marks = placeholders(userIds.size()); |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (" + marks + ")", userIds.toArray()); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id IN (" + marks + ")", userIds.toArray()); |
|||
} |
|||
userIds.clear(); |
|||
conversationIds.clear(); |
|||
} |
|||
|
|||
@Test |
|||
void listMessagesUsesFixedBatchQueriesForOneTwentyAndFiftyMessages() { |
|||
List<Integer> measuredStatements = new ArrayList<>(); |
|||
for (int count : List.of(1, 20, 50)) { |
|||
long sender = user("budget-sender-" + count); |
|||
long receiver = user("budget-receiver-" + count); |
|||
long conversation = conversation(sender, receiver, count % 2 == 0 ? "TALENT_INVITATION" : "DIRECT"); |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
List<Message> messages = new ArrayList<>(); |
|||
for (int i = 0; i < count; i++) { |
|||
boolean recalled = i % 7 == 0; |
|||
messages.add(Message.builder() |
|||
.conversationId(conversation) |
|||
.senderUserId(sender) |
|||
.content(recalled ? MessageViewMapper.RECALLED_PLACEHOLDER : "budget-body-" + i) |
|||
.status(recalled ? "RECALLED" : "SENT") |
|||
.version(recalled ? 1L : 0L) |
|||
.recalledAt(recalled ? created.plusSeconds(i) : null) |
|||
.createdAt(created.plusSeconds(i)) |
|||
.build()); |
|||
} |
|||
messageRepository.saveAllAndFlush(messages); |
|||
|
|||
countingDataSource.reset(); |
|||
MessagePageResponse response = messagingService.listMessages(conversation, receiver, 0, 50); |
|||
int statements = countingDataSource.messageStatements(); |
|||
measuredStatements.add(statements); |
|||
assertTrue(statements <= 5, |
|||
"message list must stay within the fixed conversation/member/page/read budget (count=" + count + ")"); |
|||
assertEquals(count, response.getItems().size()); |
|||
assertEquals(count, response.getTotal()); |
|||
assertTrue(response.getItems().stream().allMatch(item -> item.getConversationId().equals(conversation))); |
|||
assertFalse(response.getItems().stream().anyMatch(item -> item.getContent() != null |
|||
&& item.getContent().startsWith("budget-body-") |
|||
&& item.getStatus().equals("RECALLED")), |
|||
"recalled messages must expose only the placeholder"); |
|||
} |
|||
assertTrue(measuredStatements.stream().max(Integer::compareTo).orElse(0) |
|||
- measuredStatements.stream().min(Integer::compareTo).orElse(0) <= 1, |
|||
"pagination count query optimization may add one statement, but must not scale with item count: " |
|||
+ measuredStatements); |
|||
} |
|||
|
|||
private long user(String nickname) { |
|||
User saved = userRepository.saveAndFlush(User.builder() |
|||
.phone("139" + Math.abs(System.nanoTime() % 1_000_000_000L)) |
|||
.nickname(nickname).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) |
|||
.active(true).verified(true).build()); |
|||
userIds.add(saved.getId()); |
|||
return saved.getId(); |
|||
} |
|||
|
|||
private long conversation(long sender, long receiver, String type) { |
|||
Conversation saved = conversationRepository.saveAndFlush(Conversation.builder() |
|||
.type(type).createdBy(sender).title("N07 query budget").build()); |
|||
conversationIds.add(saved.getId()); |
|||
memberRepository.save(ConversationMember.builder().conversationId(saved.getId()).userId(sender).build()); |
|||
memberRepository.saveAndFlush(ConversationMember.builder().conversationId(saved.getId()).userId(receiver).build()); |
|||
return saved.getId(); |
|||
} |
|||
|
|||
private static String placeholders(int size) { |
|||
return String.join(",", java.util.Collections.nCopies(size, "?")); |
|||
} |
|||
|
|||
@TestConfiguration |
|||
static class CountingDataSourceConfiguration { |
|||
@Bean |
|||
@Primary |
|||
CountingDataSource dataSource(DataSourceProperties properties) { |
|||
return new CountingDataSource(properties.initializeDataSourceBuilder().build()); |
|||
} |
|||
} |
|||
|
|||
static final class CountingDataSource extends AbstractDataSource { |
|||
private final DataSource delegate; |
|||
private final AtomicInteger messageStatements = new AtomicInteger(); |
|||
|
|||
CountingDataSource(DataSource delegate) { |
|||
this.delegate = delegate; |
|||
} |
|||
|
|||
void reset() { |
|||
messageStatements.set(0); |
|||
} |
|||
|
|||
int messageStatements() { |
|||
return messageStatements.get(); |
|||
} |
|||
|
|||
@Override |
|||
public Connection getConnection() throws java.sql.SQLException { |
|||
return wrap(delegate.getConnection()); |
|||
} |
|||
|
|||
@Override |
|||
public Connection getConnection(String username, String password) throws java.sql.SQLException { |
|||
return wrap(delegate.getConnection(username, password)); |
|||
} |
|||
|
|||
private Connection wrap(Connection connection) { |
|||
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), |
|||
new Class<?>[]{Connection.class}, (proxy, method, args) -> { |
|||
Object value = method.invoke(connection, args); |
|||
if (value instanceof Statement statement) { |
|||
String sql = args != null && args.length > 0 && args[0] instanceof String s ? s : ""; |
|||
return wrapStatement(statement, sql); |
|||
} |
|||
return value; |
|||
}); |
|||
} |
|||
|
|||
private Object wrapStatement(Statement statement, String preparedSql) { |
|||
Class<?> iface = statement instanceof java.sql.PreparedStatement |
|||
? java.sql.PreparedStatement.class : Statement.class; |
|||
return Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface}, |
|||
(proxy, method, args) -> { |
|||
String sql = preparedSql; |
|||
if (sql.isBlank() && args != null && args.length > 0 && args[0] instanceof String s) sql = s; |
|||
if (method.getName().startsWith("execute") && isMessageQuery(sql)) { |
|||
messageStatements.incrementAndGet(); |
|||
} |
|||
return method.invoke(statement, args); |
|||
}); |
|||
} |
|||
|
|||
private boolean isMessageQuery(String sql) { |
|||
String normalized = sql.toLowerCase(java.util.Locale.ROOT); |
|||
return normalized.contains(" from conversations") |
|||
|| normalized.contains(" from conversation_members") |
|||
|| normalized.contains(" from messages") |
|||
|| normalized.contains(" from message_reads") |
|||
|| normalized.contains(" from message_reads"); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,174 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.audit.AuditLog; |
|||
import com.mydreamjob.audit.AuditLogRepository; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.messaging.dto.MessageResponse; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.transaction.PlatformTransactionManager; |
|||
import org.springframework.transaction.support.TransactionTemplate; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.Future; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
/** 真实事务竞争测试:不 Mock 撤回服务、仓库或审计。 */ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class MessageRecallConcurrencyIntegrationTest { |
|||
|
|||
@Autowired private MessagingService messagingService; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private ConversationRepository conversationRepository; |
|||
@Autowired private ConversationMemberRepository memberRepository; |
|||
@Autowired private MessageRepository messageRepository; |
|||
@Autowired private AuditLogRepository auditLogRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@Autowired private PlatformTransactionManager transactionManager; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private final List<Long> messages = new ArrayList<>(); |
|||
private Long conversation; |
|||
private Long sender; |
|||
private Long receiver; |
|||
|
|||
@AfterEach |
|||
void cleanup() { |
|||
if (!messages.isEmpty()) { |
|||
String marks = String.join(",", messages.stream().map(id -> "?").toList()); |
|||
jdbcTemplate.update("DELETE FROM audit_logs WHERE action='MESSAGE_RECALLED' AND entity_id IN (" + marks + ")", |
|||
messages.toArray()); |
|||
jdbcTemplate.update("DELETE FROM message_reads WHERE message_id IN (" + marks + ")", messages.toArray()); |
|||
jdbcTemplate.update("DELETE FROM messages WHERE id IN (" + marks + ")", messages.toArray()); |
|||
} |
|||
if (conversation != null) { |
|||
memberRepository.deleteAll(memberRepository.findByConversationId(conversation)); |
|||
conversationRepository.deleteById(conversation); |
|||
} |
|||
if (sender != null) userRepository.deleteById(sender); |
|||
if (receiver != null) userRepository.deleteById(receiver); |
|||
messages.clear(); |
|||
conversation = sender = receiver = null; |
|||
} |
|||
|
|||
private void fixture(int messageCount) { |
|||
sender = userRepository.save(user("concurrent-sender")).getId(); |
|||
receiver = userRepository.save(user("concurrent-receiver")).getId(); |
|||
conversation = conversationRepository.save(Conversation.builder().type("DIRECT").createdBy(sender).build()).getId(); |
|||
memberRepository.save(ConversationMember.builder().conversationId(conversation).userId(sender).build()); |
|||
memberRepository.save(ConversationMember.builder().conversationId(conversation).userId(receiver).build()); |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
for (int i = 0; i < messageCount; i++) { |
|||
Message saved = messageRepository.saveAndFlush(Message.builder() |
|||
.conversationId(conversation).senderUserId(sender) |
|||
.content("N07_CONCURRENT_ORIGINAL_" + i).status("SENT").version(0L) |
|||
.createdAt(created).build()); |
|||
jdbcTemplate.update("UPDATE messages SET created_at=? WHERE id=?", created, saved.getId()); |
|||
messages.add(saved.getId()); |
|||
} |
|||
} |
|||
|
|||
private User user(String name) { |
|||
return User.builder().phone("139" + Math.abs(System.nanoTime() % 1_000_000_000L)) |
|||
.nickname(name).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) |
|||
.active(true).verified(true).build(); |
|||
} |
|||
|
|||
private List<Attempt> runConcurrently(List<Long> messageIds) throws Exception { |
|||
ExecutorService pool = Executors.newFixedThreadPool(messageIds.size()); |
|||
CountDownLatch ready = new CountDownLatch(messageIds.size()); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
TransactionTemplate tx = new TransactionTemplate(transactionManager); |
|||
try { |
|||
List<Future<Attempt>> futures = messageIds.stream().map(id -> pool.submit(() -> { |
|||
ready.countDown(); |
|||
assertTrue(start.await(5, TimeUnit.SECONDS)); |
|||
try { |
|||
MessageResponse response = tx.execute(status -> messagingService.recallMessage( |
|||
sender, conversation, id, null, LocalDateTime.of(2026, 9, 10, 10, 1))); |
|||
return new Attempt(response, null); |
|||
} catch (Throwable failure) { |
|||
return new Attempt(null, failure); |
|||
} |
|||
})).toList(); |
|||
assertTrue(ready.await(5, TimeUnit.SECONDS)); |
|||
start.countDown(); |
|||
List<Attempt> attempts = new ArrayList<>(); |
|||
for (Future<Attempt> future : futures) attempts.add(future.get(10, TimeUnit.SECONDS)); |
|||
return attempts; |
|||
} finally { |
|||
pool.shutdownNow(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void concurrentRecallOnlyTransitionsAndAuditsOnce() throws Exception { |
|||
fixture(1); |
|||
List<Attempt> attempts = runConcurrently(List.of(messages.get(0), messages.get(0))); |
|||
|
|||
assertEquals(2, attempts.size()); |
|||
for (Attempt attempt : attempts) { |
|||
if (attempt.failure() != null) { |
|||
Integer code = findBusinessCode(attempt.failure()); |
|||
assertEquals(ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE.getCode(), code, |
|||
"并发失败只能是既定 8308,而不能泄漏 500"); |
|||
} else { |
|||
assertEquals("RECALLED", attempt.response().getStatus()); |
|||
} |
|||
} |
|||
Message persisted = messageRepository.findById(messages.get(0)).orElseThrow(); |
|||
assertEquals("RECALLED", persisted.getStatus()); |
|||
assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, persisted.getContent()); |
|||
assertNotNull(persisted.getRecalledAt()); |
|||
assertEquals(1L, persisted.getVersion()); |
|||
assertEquals(1, auditLogRepository.findAll().stream() |
|||
.filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && messages.get(0).equals(a.getEntityId())).count()); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentRecallOfDifferentMessagesDoesNotBlockOrCrossUpdate() throws Exception { |
|||
fixture(2); |
|||
List<Attempt> attempts = runConcurrently(List.of(messages.get(0), messages.get(1))); |
|||
|
|||
assertEquals(2, attempts.size()); |
|||
assertTrue(attempts.stream().allMatch(a -> a.failure() == null), |
|||
"不同消息的并发撤回不得产生异常"); |
|||
for (Long messageId : messages) { |
|||
Message persisted = messageRepository.findById(messageId).orElseThrow(); |
|||
assertEquals("RECALLED", persisted.getStatus()); |
|||
assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, persisted.getContent()); |
|||
assertEquals(1L, persisted.getVersion()); |
|||
} |
|||
assertEquals(2, auditLogRepository.findAll().stream() |
|||
.filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && messages.contains(a.getEntityId())).count()); |
|||
} |
|||
|
|||
private Integer findBusinessCode(Throwable failure) { |
|||
Throwable current = failure; |
|||
while (current != null) { |
|||
if (current instanceof BusinessException business) return business.getErrorCodeInt(); |
|||
current = current.getCause(); |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
private record Attempt(MessageResponse response, Throwable failure) { } |
|||
} |
|||
@ -0,0 +1,91 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.messaging.dto.MessageResponse; |
|||
import com.mydreamjob.auth.JwtTokenProvider; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.Map; |
|||
|
|||
import static org.mockito.ArgumentMatchers.*; |
|||
import static org.mockito.Mockito.*; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class MessageRecallControllerTest { |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JwtTokenProvider jwtTokenProvider; |
|||
@MockBean private MessagingService messagingService; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUpRedis() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
} |
|||
|
|||
private String token(Long userId) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT")); |
|||
} |
|||
|
|||
@Test |
|||
void recallReturnsWhitelistedMessageResponse() throws Exception { |
|||
MessageResponse response = MessageResponse.builder() |
|||
.id(81L).conversationId(12L).senderUserId(7L) |
|||
.content(MessageViewMapper.RECALLED_PLACEHOLDER).status("RECALLED") |
|||
.recalledAt(LocalDateTime.of(2026, 9, 10, 10, 2)) |
|||
.createdAt(LocalDateTime.of(2026, 9, 10, 10, 0)).isRead(false).build(); |
|||
when(messagingService.recallMessage(eq(7L), eq(12L), eq(81L), any())).thenReturn(response); |
|||
|
|||
mockMvc.perform(post("/conversations/12/messages/81/recall") |
|||
.header("Authorization", token(7L)) |
|||
.header("X-Request-Id", "recall-1")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.code").value(200)) |
|||
.andExpect(jsonPath("$.data.content").value(MessageViewMapper.RECALLED_PLACEHOLDER)) |
|||
.andExpect(jsonPath("$.data.status").value("RECALLED")) |
|||
.andExpect(jsonPath("$.data.senderUserId").value(7)); |
|||
verify(messagingService).recallMessage(eq(7L), eq(12L), eq(81L), any()); |
|||
} |
|||
|
|||
@Test |
|||
void recallWithoutTokenReturns401() throws Exception { |
|||
mockMvc.perform(post("/conversations/12/messages/81/recall")) |
|||
.andExpect(status().isUnauthorized()); |
|||
verifyNoInteractions(messagingService); |
|||
} |
|||
|
|||
@Test |
|||
void recallWindowErrorMapsTo409WithoutInternalContent() throws Exception { |
|||
when(messagingService.recallMessage(eq(7L), eq(12L), eq(81L), any())) |
|||
.thenThrow(new BusinessException(ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED)); |
|||
|
|||
String body = mockMvc.perform(post("/conversations/12/messages/81/recall") |
|||
.header("Authorization", token(7L))) |
|||
.andExpect(status().isConflict()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED.getCode())) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
org.junit.jupiter.api.Assertions.assertFalse(body.contains("original")); |
|||
org.junit.jupiter.api.Assertions.assertFalse(body.contains("password")); |
|||
} |
|||
} |
|||
@ -0,0 +1,272 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.audit.AuditLogRepository; |
|||
import com.mydreamjob.auth.JwtTokenProvider; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
/** 真实 Service、Repository 和 Security 链路的消息撤回 HTTP 脱敏验证。 */ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class MessageRecallHttpIntegrationTest { |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private JwtTokenProvider jwtTokenProvider; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private ConversationRepository conversationRepository; |
|||
@Autowired private ConversationMemberRepository memberRepository; |
|||
@Autowired private MessageRepository messageRepository; |
|||
@Autowired private AuditLogRepository auditLogRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
@MockBean private Clock clock; |
|||
|
|||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); |
|||
private static final LocalDateTime CAPTURED_NOW = LocalDateTime.of(2026, 9, 10, 10, 1); |
|||
private static final LocalDateTime FIXTURE_CREATED_AT = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
|
|||
private static final AtomicLong PHONE = new AtomicLong(7400); |
|||
private final List<Long> messageIds = new ArrayList<>(); |
|||
private final List<Long> conversationIds = new ArrayList<>(); |
|||
private final List<Long> userIds = new ArrayList<>(); |
|||
|
|||
@org.junit.jupiter.api.BeforeEach |
|||
void fixApplicationClock() { |
|||
when(clock.instant()).thenReturn(CAPTURED_NOW.atZone(BUSINESS_ZONE).toInstant()); |
|||
when(clock.getZone()).thenReturn(BUSINESS_ZONE); |
|||
} |
|||
|
|||
@AfterEach |
|||
void cleanup() { |
|||
if (!messageIds.isEmpty()) { |
|||
String marks = String.join(",", messageIds.stream().map(id -> "?").toList()); |
|||
jdbcTemplate.update("DELETE FROM audit_logs WHERE action='MESSAGE_RECALLED' AND entity_id IN (" + marks + ")", |
|||
messageIds.toArray()); |
|||
jdbcTemplate.update("DELETE FROM message_reads WHERE message_id IN (" + marks + ")", messageIds.toArray()); |
|||
jdbcTemplate.update("DELETE FROM messages WHERE id IN (" + marks + ")", messageIds.toArray()); |
|||
} |
|||
if (!conversationIds.isEmpty()) { |
|||
String marks = String.join(",", conversationIds.stream().map(id -> "?").toList()); |
|||
jdbcTemplate.update("DELETE FROM conversation_members WHERE conversation_id IN (" + marks + ")", |
|||
conversationIds.toArray()); |
|||
jdbcTemplate.update("DELETE FROM conversations WHERE id IN (" + marks + ")", conversationIds.toArray()); |
|||
} |
|||
if (!userIds.isEmpty()) { |
|||
String marks = String.join(",", userIds.stream().map(id -> "?").toList()); |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (" + marks + ")", userIds.toArray()); |
|||
jdbcTemplate.update("DELETE FROM users WHERE id IN (" + marks + ")", userIds.toArray()); |
|||
} |
|||
messageIds.clear(); |
|||
conversationIds.clear(); |
|||
userIds.clear(); |
|||
} |
|||
|
|||
private Long user(String label, User.Role role) { |
|||
User saved = userRepository.saveAndFlush(User.builder() |
|||
.phone("139" + String.format("%08d", PHONE.incrementAndGet())) |
|||
.nickname(label).role(role).roles(Set.of(role)) |
|||
.active(true).verified(true).build()); |
|||
userIds.add(saved.getId()); |
|||
return saved.getId(); |
|||
} |
|||
|
|||
private Long conversation(Long sender, Long receiver, String type) { |
|||
Long id = conversationRepository.saveAndFlush(Conversation.builder() |
|||
.type(type).createdBy(sender).title("N07 HTTP test").build()).getId(); |
|||
conversationIds.add(id); |
|||
memberRepository.save(ConversationMember.builder().conversationId(id).userId(sender).build()); |
|||
memberRepository.save(ConversationMember.builder().conversationId(id).userId(receiver).build()); |
|||
return id; |
|||
} |
|||
|
|||
private Long message(Long conversation, Long sender, String body) { |
|||
LocalDateTime created = FIXTURE_CREATED_AT; |
|||
Message saved = messageRepository.saveAndFlush(Message.builder() |
|||
.conversationId(conversation).senderUserId(sender).content(body) |
|||
.status("SENT").version(0L).createdAt(created).build()); |
|||
jdbcTemplate.update("UPDATE messages SET created_at=? WHERE id=?", created, saved.getId()); |
|||
messageIds.add(saved.getId()); |
|||
return saved.getId(); |
|||
} |
|||
|
|||
private String token(Long userId, User.Role role) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", |
|||
java.util.Map.of("roles", role.name())); |
|||
} |
|||
|
|||
@Test |
|||
void directRecallAndListUsePlaceholderAndKeepExistingSenderId() throws Exception { |
|||
Long sender = user("direct-sender", User.Role.STUDENT); |
|||
Long receiver = user("direct-receiver", User.Role.STUDENT); |
|||
Long conversation = conversation(sender, receiver, "DIRECT"); |
|||
Long message = message(conversation, sender, "N07_PRIVATE_ORIGINAL_BODY"); |
|||
|
|||
String recalled = mockMvc.perform(post("/conversations/{conversationId}/messages/{messageId}/recall", |
|||
conversation, message) |
|||
.header("Authorization", token(sender, User.Role.STUDENT)) |
|||
.header("X-Request-Id", "n07-http-direct")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.senderUserId").value(sender)) |
|||
.andExpect(jsonPath("$.data.content").value(MessageViewMapper.RECALLED_PLACEHOLDER)) |
|||
.andExpect(jsonPath("$.data.status").value("RECALLED")) |
|||
.andExpect(jsonPath("$.data.recalledAt").exists()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(recalled); |
|||
|
|||
String listed = mockMvc.perform(get("/conversations/{conversationId}/messages", conversation) |
|||
.header("Authorization", token(receiver, User.Role.STUDENT))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.items[0].content").value(MessageViewMapper.RECALLED_PLACEHOLDER)) |
|||
.andExpect(jsonPath("$.data.items[0].status").value("RECALLED")) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(listed); |
|||
} |
|||
|
|||
@Test |
|||
void talentInvitationRecallUsesRelativeIdentityAcrossResponses() throws Exception { |
|||
Long sender = user("talent-sender", User.Role.COMPANY); |
|||
Long receiver = user("talent-receiver", User.Role.STUDENT); |
|||
Long conversation = conversation(sender, receiver, "TALENT_INVITATION"); |
|||
Long message = message(conversation, sender, "N07_PRIVATE_ORIGINAL_BODY"); |
|||
|
|||
String recalled = mockMvc.perform(post("/conversations/{conversationId}/messages/{messageId}/recall", |
|||
conversation, message) |
|||
.header("Authorization", token(sender, User.Role.COMPANY))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.senderScope").value("CURRENT_USER")) |
|||
.andExpect(jsonPath("$.data.senderUserId").doesNotExist()) |
|||
.andExpect(jsonPath("$.data.content").value(MessageViewMapper.RECALLED_PLACEHOLDER)) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(recalled); |
|||
|
|||
String listed = mockMvc.perform(get("/conversations/{conversationId}/messages", conversation) |
|||
.header("Authorization", token(receiver, User.Role.STUDENT))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.items[0].senderScope").value("OTHER_PARTICIPANT")) |
|||
.andExpect(jsonPath("$.data.items[0].senderUserId").doesNotExist()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(listed); |
|||
|
|||
String conversationView = mockMvc.perform(get("/conversations/{conversationId}", conversation) |
|||
.header("Authorization", token(receiver, User.Role.STUDENT))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.createdBy").doesNotExist()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(conversationView); |
|||
} |
|||
|
|||
@Test |
|||
void realHttpPermissionAndErrorContractIsPreserved() throws Exception { |
|||
Long sender = user("permission-sender", User.Role.STUDENT); |
|||
Long receiver = user("permission-receiver", User.Role.STUDENT); |
|||
Long admin = user("permission-admin", User.Role.ADMIN); |
|||
Long conversation = conversation(sender, receiver, "DIRECT"); |
|||
memberRepository.saveAndFlush(ConversationMember.builder().conversationId(conversation).userId(admin).build()); |
|||
Long message = message(conversation, sender, "N07_PRIVATE_ORIGINAL_BODY"); |
|||
|
|||
mockMvc.perform(post("/conversations/{conversationId}/messages/{messageId}/recall", conversation, message) |
|||
.header("Authorization", token(receiver, User.Role.STUDENT))) |
|||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); |
|||
mockMvc.perform(post("/conversations/{conversationId}/messages/{messageId}/recall", conversation, message) |
|||
.header("Authorization", token(admin, User.Role.ADMIN))) |
|||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); |
|||
mockMvc.perform(post("/conversations/{conversationId}/messages/{messageId}/recall", conversation, message)) |
|||
.andExpect(status().isUnauthorized()); |
|||
mockMvc.perform(post("/conversations/{conversationId}/messages/{messageId}/recall", conversation, 999999L) |
|||
.header("Authorization", token(sender, User.Role.STUDENT))) |
|||
.andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value(8306)); |
|||
} |
|||
|
|||
@Test |
|||
void realHttpCoversNonMemberCrossConversationMissingConversationAndExpiredWindow() throws Exception { |
|||
Long sender = user("matrix-sender", User.Role.STUDENT); |
|||
Long receiver = user("matrix-receiver", User.Role.STUDENT); |
|||
Long nonMember = user("matrix-non-member", User.Role.STUDENT); |
|||
Long primaryConversation = conversation(sender, receiver, "DIRECT"); |
|||
Long otherConversation = conversation(sender, receiver, "DIRECT"); |
|||
Long message = message(primaryConversation, sender, "N07_PRIVATE_ORIGINAL_BODY"); |
|||
|
|||
String nonMemberBody = mockMvc.perform(post( |
|||
"/conversations/{conversationId}/messages/{messageId}/recall", |
|||
primaryConversation, message) |
|||
.header("Authorization", token(nonMember, User.Role.STUDENT))) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(2002)) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(nonMemberBody); |
|||
|
|||
String crossConversationBody = mockMvc.perform(post( |
|||
"/conversations/{conversationId}/messages/{messageId}/recall", |
|||
otherConversation, message) |
|||
.header("Authorization", token(sender, User.Role.STUDENT))) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(2002)) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(crossConversationBody); |
|||
|
|||
String missingConversationBody = mockMvc.perform(post( |
|||
"/conversations/{conversationId}/messages/{messageId}/recall", |
|||
999999L, message) |
|||
.header("Authorization", token(sender, User.Role.STUDENT))) |
|||
.andExpect(status().isNotFound()) |
|||
.andExpect(jsonPath("$.code").value(8305)) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(missingConversationBody); |
|||
|
|||
LocalDateTime expiredNow = FIXTURE_CREATED_AT.plusMinutes(2).plusNanos(1); |
|||
when(clock.instant()).thenReturn(expiredNow.atZone(BUSINESS_ZONE).toInstant()); |
|||
String expiredBody = mockMvc.perform(post( |
|||
"/conversations/{conversationId}/messages/{messageId}/recall", |
|||
primaryConversation, message) |
|||
.header("Authorization", token(sender, User.Role.STUDENT))) |
|||
.andExpect(status().isConflict()) |
|||
.andExpect(jsonPath("$.code").value(8307)) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertSafe(expiredBody); |
|||
|
|||
Message unchanged = messageRepository.findById(message).orElseThrow(); |
|||
assertEquals("SENT", unchanged.getStatus()); |
|||
assertEquals("N07_PRIVATE_ORIGINAL_BODY", unchanged.getContent()); |
|||
assertEquals(0L, unchanged.getVersion()); |
|||
assertEquals(0, auditLogRepository.findAll().stream() |
|||
.filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())) |
|||
.count()); |
|||
} |
|||
|
|||
private void assertSafe(String json) { |
|||
assertFalse(json.contains("N07_PRIVATE_ORIGINAL_BODY")); |
|||
for (String forbidden : List.of("originalContent", "previousContent", "studentUserId", "candidateId", |
|||
"phone", "email", "resumeUrl", "candidateKey", "candidateKeyDigest", "ipAddress", "userAgent", |
|||
"password", "token", "secret")) { |
|||
assertFalse(json.contains(forbidden), "response contains forbidden field: " + forbidden); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,355 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditLog; |
|||
import com.mydreamjob.audit.AuditLogRepository; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.messaging.dto.MessageResponse; |
|||
import com.mydreamjob.safety.BlockRelationRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import jakarta.persistence.EntityManager; |
|||
import jakarta.persistence.EntityManagerFactory; |
|||
import jakarta.persistence.OptimisticLockException; |
|||
import org.springframework.dao.DataAccessException; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class MessageRecallPersistenceIntegrationTest { |
|||
|
|||
@Autowired private MessagingService messagingService; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private ConversationRepository conversationRepository; |
|||
@Autowired private ConversationMemberRepository memberRepository; |
|||
@Autowired private MessageRepository messageRepository; |
|||
@Autowired private MessageReadRepository readRepository; |
|||
@Autowired private AuditLogRepository auditLogRepository; |
|||
@Autowired private BlockRelationRepository blockRelationRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@Autowired private EntityManager entityManager; |
|||
@Autowired private EntityManagerFactory entityManagerFactory; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private Long sender; |
|||
private Long receiver; |
|||
private Long conversation; |
|||
private Long message; |
|||
private Long candidateScoreStudent; |
|||
|
|||
@AfterEach |
|||
void cleanup() { |
|||
if (candidateScoreStudent != null) { |
|||
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id=?", candidateScoreStudent); |
|||
candidateScoreStudent = null; |
|||
} |
|||
if (message != null) readRepository.deleteAll(); |
|||
messageRepository.deleteAll(); |
|||
memberRepository.deleteAll(); |
|||
blockRelationRepository.deleteAll(); |
|||
conversationRepository.deleteAll(); |
|||
auditLogRepository.deleteAll(); |
|||
if (sender != null || receiver != null) { |
|||
if (sender != null) { |
|||
jdbcTemplate.update("DELETE FROM scoring_risk_actions WHERE operator_user_id=?", sender); |
|||
jdbcTemplate.update("DELETE FROM scoring_risk_events WHERE subject_user_id=?", sender); |
|||
jdbcTemplate.update("DELETE FROM notifications WHERE user_id=?", sender); |
|||
} |
|||
if (receiver != null) { |
|||
jdbcTemplate.update("DELETE FROM scoring_risk_actions WHERE operator_user_id=?", receiver); |
|||
jdbcTemplate.update("DELETE FROM scoring_risk_events WHERE subject_user_id=?", receiver); |
|||
jdbcTemplate.update("DELETE FROM notifications WHERE user_id=?", receiver); |
|||
} |
|||
} |
|||
userRepository.deleteAll(); |
|||
} |
|||
|
|||
private void fixture(LocalDateTime createdAt) { |
|||
sender = userRepository.save(user("sender")).getId(); |
|||
receiver = userRepository.save(user("receiver")).getId(); |
|||
conversation = conversationRepository.save(Conversation.builder().type("DIRECT").createdBy(sender).build()).getId(); |
|||
memberRepository.save(ConversationMember.builder().conversationId(conversation).userId(sender).build()); |
|||
memberRepository.save(ConversationMember.builder().conversationId(conversation).userId(receiver).build()); |
|||
Message saved = messageRepository.saveAndFlush(Message.builder().conversationId(conversation).senderUserId(sender) |
|||
.content("secret-body").status("SENT").version(0L).createdAt(createdAt).build()); |
|||
message = saved.getId(); |
|||
// Auditing may refresh @CreatedDate on update; force the deterministic boundary in the fixture only.
|
|||
jdbcTemplate.update("UPDATE messages SET created_at=? WHERE id=?", createdAt, message); |
|||
entityManager.clear(); |
|||
} |
|||
|
|||
private User user(String name) { |
|||
return User.builder().phone("139" + Math.abs(System.nanoTime() % 1_000_000_000L)) |
|||
.nickname(name).role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)) |
|||
.active(true).verified(true).build(); |
|||
} |
|||
|
|||
@Test |
|||
void migrationFieldsAndConditionalUpdatePersistRecall() { |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
LocalDateTime recalledAt = created.plusMinutes(2); |
|||
fixture(created); |
|||
|
|||
MessageResponse response = messagingService.recallMessage(sender, conversation, message, null, recalledAt); |
|||
Message persisted = messageRepository.findById(message).orElseThrow(); |
|||
|
|||
assertEquals("RECALLED", persisted.getStatus()); |
|||
assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, persisted.getContent()); |
|||
assertEquals(recalledAt, persisted.getRecalledAt()); |
|||
assertEquals(1L, persisted.getVersion()); |
|||
assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, response.getContent()); |
|||
assertEquals(1, auditLogRepository.findAll().stream() |
|||
.filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())).count()); |
|||
assertFalse(auditLogRepository.findAll().stream() |
|||
.filter(a -> "MESSAGE_RECALLED".equals(a.getAction())) |
|||
.anyMatch(a -> a.getDetails() != null && a.getDetails().contains("secret-body"))); |
|||
} |
|||
|
|||
@Test |
|||
void messageRecallAuditStoresRequestIdButNotIpOrUserAgent() throws Exception { |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
fixture(created); |
|||
AuditContext context = AuditContext.builder() |
|||
.requestId("recall-audit-1") |
|||
.ipAddress("127.0.0.66") |
|||
.userAgent("N07-secret-agent") |
|||
.build(); |
|||
|
|||
messagingService.recallMessage(sender, conversation, message, context, created.plusMinutes(1)); |
|||
|
|||
AuditLog audit = auditLogRepository.findAll().stream() |
|||
.filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())) |
|||
.findFirst().orElseThrow(); |
|||
assertEquals("recall-audit-1", audit.getRequestId()); |
|||
assertNull(audit.getIpAddress()); |
|||
assertNull(audit.getUserAgent()); |
|||
JsonNode details = objectMapper.readTree(audit.getDetails()); |
|||
java.util.Set<String> fields = new java.util.HashSet<>(); |
|||
details.fieldNames().forEachRemaining(fields::add); |
|||
assertEquals(java.util.Set.of("messageId", "conversationId", "conversationType", "status", "requestId"), fields); |
|||
assertEquals(message.longValue(), details.get("messageId").longValue()); |
|||
assertEquals(conversation.longValue(), details.get("conversationId").longValue()); |
|||
assertEquals("DIRECT", details.get("conversationType").textValue()); |
|||
assertEquals("RECALLED", details.get("status").textValue()); |
|||
assertEquals("recall-audit-1", details.get("requestId").textValue()); |
|||
assertFalse(audit.getDetails().contains("secret-body")); |
|||
assertFalse(audit.getDetails().contains("127.0.0.66")); |
|||
assertFalse(audit.getDetails().contains("N07-secret-agent")); |
|||
} |
|||
|
|||
@Test |
|||
void v41DefaultsAndStatusCheckAreEnforced() { |
|||
fixture(LocalDateTime.of(2026, 9, 10, 10, 0)); |
|||
jdbcTemplate.update( |
|||
"INSERT INTO messages(conversation_id, sender_user_id, content, created_at) VALUES (?, ?, ?, ?)", |
|||
conversation, sender, "legacy-body", LocalDateTime.of(2026, 9, 10, 10, 0)); |
|||
Long legacyMessage = jdbcTemplate.queryForObject("SELECT MAX(id) FROM messages WHERE conversation_id=?", |
|||
Long.class, conversation); |
|||
java.util.Map<String, Object> row = jdbcTemplate.queryForMap( |
|||
"SELECT status, recalled_at, version FROM messages WHERE id=?", legacyMessage); |
|||
assertEquals("SENT", row.get("STATUS")); |
|||
assertNull(row.get("RECALLED_AT")); |
|||
assertEquals(0L, ((Number) row.get("VERSION")).longValue()); |
|||
|
|||
Long finalLegacyMessage = legacyMessage; |
|||
assertThrows(DataAccessException.class, () -> jdbcTemplate.update( |
|||
"UPDATE messages SET status='DELETED' WHERE id=?", finalLegacyMessage)); |
|||
} |
|||
|
|||
@Test |
|||
void optimisticVersionConflictCannotSilentlyOverwrite() { |
|||
fixture(LocalDateTime.of(2026, 9, 10, 10, 0)); |
|||
EntityManager first = entityManagerFactory.createEntityManager(); |
|||
EntityManager second = entityManagerFactory.createEntityManager(); |
|||
jakarta.persistence.EntityTransaction tx1 = first.getTransaction(); |
|||
jakarta.persistence.EntityTransaction tx2 = second.getTransaction(); |
|||
try { |
|||
Message firstMessage = first.find(Message.class, message); |
|||
Message secondMessage = second.find(Message.class, message); |
|||
tx1.begin(); |
|||
firstMessage.setContent("first-update"); |
|||
tx1.commit(); |
|||
|
|||
tx2.begin(); |
|||
secondMessage.setContent("stale-update"); |
|||
RuntimeException conflict = assertThrows(RuntimeException.class, tx2::commit); |
|||
assertTrue(hasCause(conflict, OptimisticLockException.class), |
|||
"stale update must be reported as an optimistic lock conflict"); |
|||
if (tx2.isActive()) tx2.rollback(); |
|||
assertEquals("first-update", jdbcTemplate.queryForObject( |
|||
"SELECT content FROM messages WHERE id=?", String.class, message)); |
|||
} finally { |
|||
if (tx1.isActive()) tx1.rollback(); |
|||
if (tx2.isActive()) tx2.rollback(); |
|||
first.close(); |
|||
second.close(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
@Transactional |
|||
void conditionalRecallRejectsWrongOwnerConversationVersionAndStatus() { |
|||
fixture(LocalDateTime.of(2026, 9, 10, 10, 0)); |
|||
LocalDateTime recalledAt = LocalDateTime.of(2026, 9, 10, 10, 1); |
|||
assertEquals(0, messageRepository.recallIfCurrent(message, conversation, 999L, 0L, |
|||
MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); |
|||
assertEquals(0, messageRepository.recallIfCurrent(message, 999L, sender, 0L, |
|||
MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); |
|||
assertEquals(0, messageRepository.recallIfCurrent(message, conversation, sender, 1L, |
|||
MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); |
|||
assertEquals(1, messageRepository.recallIfCurrent(message, conversation, sender, 0L, |
|||
MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); |
|||
assertEquals(0, messageRepository.recallIfCurrent(message, conversation, sender, 0L, |
|||
MessageViewMapper.RECALLED_PLACEHOLDER, recalledAt)); |
|||
} |
|||
|
|||
private boolean hasCause(Throwable error, Class<? extends Throwable> type) { |
|||
Throwable current = error; |
|||
while (current != null) { |
|||
if (type.isInstance(current)) return true; |
|||
current = current.getCause(); |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
@Test |
|||
void existingMessageReadRowsRemainAfterRecall() { |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
fixture(created); |
|||
readRepository.saveAndFlush(MessageRead.builder().messageId(message).readerUserId(receiver).build()); |
|||
|
|||
messagingService.recallMessage(sender, conversation, message, null, created.plusMinutes(1)); |
|||
|
|||
assertEquals(1, readRepository.findByMessageIdInAndReaderUserId(java.util.List.of(message), receiver).size()); |
|||
messagingService.markRead(conversation, receiver); |
|||
assertEquals(1, readRepository.findByMessageIdInAndReaderUserId(java.util.List.of(message), receiver).size()); |
|||
assertEquals(1, messagingService.listMessages(conversation, receiver, 0, 20) |
|||
.getItems().stream().filter(item -> Boolean.TRUE.equals(item.getIsRead())).count()); |
|||
} |
|||
|
|||
@Test |
|||
void expiredRecallDoesNotMutateDatabase() { |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
fixture(created); |
|||
assertEquals(created, messageRepository.findById(message).orElseThrow().getCreatedAt()); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> messagingService.recallMessage(sender, conversation, message, null, created.plusMinutes(2).plusNanos(1))); |
|||
assertEquals(ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED.getCode(), ex.getErrorCodeInt()); |
|||
Message persisted = messageRepository.findById(message).orElseThrow(); |
|||
assertEquals("SENT", persisted.getStatus()); |
|||
assertEquals("secret-body", persisted.getContent()); |
|||
assertEquals(0L, persisted.getVersion()); |
|||
assertEquals(0, auditLogRepository.findAll().stream() |
|||
.filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())).count()); |
|||
} |
|||
|
|||
@Test |
|||
void recalledMessagesAreNotUnreadOrMarkedRead() { |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
fixture(created); |
|||
messagingService.recallMessage(sender, conversation, message, null, created.plusMinutes(1)); |
|||
var messages = messagingService.listMessages(conversation, receiver, 0, 20); |
|||
assertEquals(1, messages.getTotal()); |
|||
assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, messages.getItems().get(0).getContent()); |
|||
assertEquals("RECALLED", messages.getItems().get(0).getStatus()); |
|||
messagingService.markRead(conversation, receiver); |
|||
assertEquals(0, readRepository.findByMessageIdInAndReaderUserId(java.util.List.of(message), receiver).size()); |
|||
} |
|||
|
|||
@Test |
|||
void messageRecallDoesNotModifyCandidateDimensionScores() { |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
fixture(created); |
|||
candidateScoreStudent = receiver; |
|||
jdbcTemplate.update("INSERT INTO candidate_dimension_scores " |
|||
+ "(student_user_id, dimension, period, score, status, weight, rules_version, calculated_at) " |
|||
+ "VALUES (?, 'ACTIVITY', '2026-09', 73.25, 'OK', 12.50, 'candidate-scoring-v0.2', ?)", |
|||
candidateScoreStudent, created); |
|||
java.util.Map<String, Object> before = jdbcTemplate.queryForMap( |
|||
"SELECT id, student_user_id, dimension, score, status, weight, rules_version, calculated_at, period, version " |
|||
+ "FROM candidate_dimension_scores WHERE student_user_id=? AND dimension='ACTIVITY'", |
|||
candidateScoreStudent); |
|||
|
|||
messagingService.recallMessage(sender, conversation, message, null, created.plusMinutes(1)); |
|||
|
|||
java.util.Map<String, Object> after = jdbcTemplate.queryForMap( |
|||
"SELECT id, student_user_id, dimension, score, status, weight, rules_version, calculated_at, period, version " |
|||
+ "FROM candidate_dimension_scores WHERE student_user_id=? AND dimension='ACTIVITY'", |
|||
candidateScoreStudent); |
|||
assertEquals(before, after, "message recall must not modify candidate_dimension_scores"); |
|||
} |
|||
|
|||
@Test |
|||
void messageRecallDoesNotModifyNotificationsOrRiskGovernanceData() throws Exception { |
|||
LocalDateTime created = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
fixture(created); |
|||
jdbcTemplate.update("INSERT INTO notifications(user_id, type, title, content, is_read, created_at) " |
|||
+ "VALUES (?, 'SYSTEM', 'N07 baseline', 'unchanged', 0, ?)", |
|||
receiver, created); |
|||
Long ruleId = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM scoring_rule_configs ORDER BY id LIMIT 1", Long.class); |
|||
jdbcTemplate.update("INSERT INTO scoring_risk_events(rule_config_id, subject_user_id, source_audit_log_id, " |
|||
+ "source_action, severity, window_started_at, window_ended_at, occurrence_count, status, " |
|||
+ "detected_at, version, created_at, updated_at) " |
|||
+ "VALUES (?, ?, 987654321, 'REPORT_RATE_LIMITED', 'LOW', ?, ?, 1, 'OPEN', ?, 0, ?, ?)", |
|||
ruleId, sender, created.minusMinutes(1), created, created, created, created); |
|||
Long riskEventId = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM scoring_risk_events WHERE subject_user_id=?", Long.class, sender); |
|||
jdbcTemplate.update("INSERT INTO scoring_risk_actions(risk_event_id, operator_user_id, action, reason_code, created_at) " |
|||
+ "VALUES (?, ?, 'ACKNOWLEDGE', 'MONITORING', ?)", |
|||
riskEventId, sender, created); |
|||
|
|||
java.util.List<java.util.Map<String, Object>> notificationsBefore = stableRows("notifications"); |
|||
java.util.List<java.util.Map<String, Object>> riskEventsBefore = stableRows("scoring_risk_events"); |
|||
java.util.List<java.util.Map<String, Object>> riskActionsBefore = stableRows("scoring_risk_actions"); |
|||
java.util.List<java.util.Map<String, Object>> riskRulesBefore = stableRows("scoring_rule_configs"); |
|||
java.util.List<java.util.Map<String, Object>> checkpointsBefore = stableRows("scoring_risk_scan_checkpoints"); |
|||
long auditsBefore = auditLogRepository.count(); |
|||
|
|||
messagingService.recallMessage(sender, conversation, message, |
|||
AuditContext.builder().requestId("n07-side-effect-check").build(), created.plusMinutes(1)); |
|||
|
|||
assertEquals(notificationsBefore, stableRows("notifications")); |
|||
assertEquals(riskEventsBefore, stableRows("scoring_risk_events")); |
|||
assertEquals(riskActionsBefore, stableRows("scoring_risk_actions")); |
|||
assertEquals(riskRulesBefore, stableRows("scoring_rule_configs")); |
|||
assertEquals(checkpointsBefore, stableRows("scoring_risk_scan_checkpoints")); |
|||
assertEquals(auditsBefore + 1, auditLogRepository.count()); |
|||
|
|||
AuditLog recallAudit = auditLogRepository.findAll().stream() |
|||
.filter(a -> "MESSAGE_RECALLED".equals(a.getAction()) && message.equals(a.getEntityId())) |
|||
.findFirst().orElseThrow(); |
|||
JsonNode details = objectMapper.readTree(recallAudit.getDetails()); |
|||
java.util.Set<String> fields = new java.util.HashSet<>(); |
|||
details.fieldNames().forEachRemaining(fields::add); |
|||
assertEquals(java.util.Set.of("messageId", "conversationId", "conversationType", "status", "requestId"), fields); |
|||
assertNull(recallAudit.getIpAddress()); |
|||
assertNull(recallAudit.getUserAgent()); |
|||
} |
|||
|
|||
private java.util.List<java.util.Map<String, Object>> stableRows(String table) { |
|||
java.util.Set<String> allowed = java.util.Set.of( |
|||
"notifications", "scoring_risk_events", "scoring_risk_actions", |
|||
"scoring_rule_configs", "scoring_risk_scan_checkpoints"); |
|||
if (!allowed.contains(table)) throw new IllegalArgumentException("unsupported snapshot table"); |
|||
return jdbcTemplate.queryForList("SELECT * FROM " + table + " ORDER BY id"); |
|||
} |
|||
} |
|||
@ -0,0 +1,221 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.messaging.dto.MessageResponse; |
|||
import com.mydreamjob.safety.BlockRelationRepository; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.Instant; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.*; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class MessageRecallServiceTest { |
|||
|
|||
@Mock private ConversationRepository conversationRepository; |
|||
@Mock private ConversationMemberRepository memberRepository; |
|||
@Mock private MessageRepository messageRepository; |
|||
@Mock private MessageReadRepository readRepository; |
|||
@Mock private UserRepository userRepository; |
|||
@Mock private CompanyRepository companyRepository; |
|||
@Mock private ApplicationMatchRecordRepository applicationMatchRecordRepository; |
|||
@Mock private BlockRelationRepository blockRelationRepository; |
|||
@Mock private com.mydreamjob.audit.SecurityAuditService securityAuditService; |
|||
@Mock private AuditService auditService; |
|||
@Mock private Clock clock; |
|||
@org.mockito.Spy private MessageViewMapper messageViewMapper = new MessageViewMapper(); |
|||
@org.mockito.Spy private com.fasterxml.jackson.databind.ObjectMapper objectMapper = |
|||
new com.fasterxml.jackson.databind.ObjectMapper(); |
|||
@InjectMocks private MessagingService messagingService; |
|||
|
|||
private static final Long USER = 7L; |
|||
private static final Long OTHER = 8L; |
|||
private static final Long CONVERSATION = 12L; |
|||
private static final Long MESSAGE = 81L; |
|||
private static final LocalDateTime CREATED = LocalDateTime.of(2026, 9, 10, 10, 0); |
|||
private static final LocalDateTime AT_DEADLINE = CREATED.plusMinutes(2); |
|||
private static final AuditContext CONTEXT = AuditContext.builder().requestId("req-1").build(); |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
when(conversationRepository.findById(CONVERSATION)) |
|||
.thenReturn(Optional.of(Conversation.builder().id(CONVERSATION).type("DIRECT").build())); |
|||
lenient().when(memberRepository.existsByConversationIdAndUserId(CONVERSATION, USER)).thenReturn(true); |
|||
lenient().when(memberRepository.existsByConversationIdAndUserId(CONVERSATION, OTHER)).thenReturn(true); |
|||
lenient().when(readRepository.existsByMessageIdAndReaderUserId(MESSAGE, USER)).thenReturn(false); |
|||
} |
|||
|
|||
private Message sentMessage() { |
|||
return Message.builder().id(MESSAGE).conversationId(CONVERSATION).senderUserId(USER) |
|||
.content("private original").status("SENT").version(0L).createdAt(CREATED).build(); |
|||
} |
|||
|
|||
@Test |
|||
void recallWithinWindow_overwritesContentAndAuditsOnce() { |
|||
Message message = sentMessage(); |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of(message)); |
|||
when(messageRepository.recallIfCurrent(eq(MESSAGE), eq(CONVERSATION), eq(USER), eq(0L), |
|||
eq(MessageViewMapper.RECALLED_PLACEHOLDER), eq(AT_DEADLINE))).thenAnswer(invocation -> { |
|||
message.setStatus("RECALLED"); |
|||
message.setContent(MessageViewMapper.RECALLED_PLACEHOLDER); |
|||
message.setRecalledAt(AT_DEADLINE); |
|||
message.setVersion(1L); |
|||
return 1; |
|||
}); |
|||
|
|||
MessageResponse response = messagingService.recallMessage(USER, CONVERSATION, MESSAGE, |
|||
CONTEXT, AT_DEADLINE); |
|||
|
|||
assertEquals("RECALLED", response.getStatus()); |
|||
assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, response.getContent()); |
|||
assertEquals(AT_DEADLINE, response.getRecalledAt()); |
|||
verify(messageRepository).recallIfCurrent(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), eq(AT_DEADLINE)); |
|||
verify(auditService).log(eq(USER), eq("MESSAGE_RECALLED"), eq("MESSAGE"), eq(MESSAGE), |
|||
contains("\"conversationId\":12"), eq(CONTEXT)); |
|||
} |
|||
|
|||
@Test |
|||
void exactTwoMinutesIsStillAllowed() { |
|||
Message message = sentMessage(); |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of(message)); |
|||
when(messageRepository.recallIfCurrent(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), eq(AT_DEADLINE))) |
|||
.thenReturn(1); |
|||
assertDoesNotThrow(() -> messagingService.recallMessage(USER, CONVERSATION, MESSAGE, CONTEXT, AT_DEADLINE)); |
|||
} |
|||
|
|||
@Test |
|||
void recallAtOneMinuteFiftyNineSecondsSucceeds() { |
|||
Message message = sentMessage(); |
|||
LocalDateTime atOneMinuteFiftyNineSeconds = CREATED.plusMinutes(1).plusSeconds(59); |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of(message)); |
|||
when(messageRepository.recallIfCurrent(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), |
|||
eq(atOneMinuteFiftyNineSeconds))).thenAnswer(invocation -> { |
|||
message.setStatus("RECALLED"); |
|||
message.setContent(MessageViewMapper.RECALLED_PLACEHOLDER); |
|||
message.setRecalledAt(atOneMinuteFiftyNineSeconds); |
|||
message.setVersion(1L); |
|||
return 1; |
|||
}); |
|||
|
|||
MessageResponse response = messagingService.recallMessage(USER, CONVERSATION, MESSAGE, |
|||
CONTEXT, atOneMinuteFiftyNineSeconds); |
|||
|
|||
assertEquals("RECALLED", response.getStatus()); |
|||
verify(messageRepository).recallIfCurrent(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), |
|||
eq(atOneMinuteFiftyNineSeconds)); |
|||
} |
|||
|
|||
@Test |
|||
void afterTwoMinutesReturnsConflictWithoutMutationOrAudit() { |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of(sentMessage())); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> messagingService.recallMessage(USER, CONVERSATION, MESSAGE, CONTEXT, AT_DEADLINE.plusNanos(1))); |
|||
assertEquals(ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED.getCode(), ex.getErrorCodeInt()); |
|||
verify(messageRepository, never()).recallIfCurrent(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), any()); |
|||
verifyNoInteractions(auditService); |
|||
} |
|||
|
|||
@Test |
|||
void nonSenderAndNonMemberAreForbidden() { |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of(sentMessage())); |
|||
BusinessException sender = assertThrows(BusinessException.class, |
|||
() -> messagingService.recallMessage(OTHER, CONVERSATION, MESSAGE, CONTEXT, CREATED)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), sender.getErrorCodeInt()); |
|||
|
|||
when(memberRepository.existsByConversationIdAndUserId(CONVERSATION, USER)).thenReturn(false); |
|||
BusinessException member = assertThrows(BusinessException.class, |
|||
() -> messagingService.recallMessage(USER, CONVERSATION, MESSAGE, CONTEXT, CREATED)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), member.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void adminCannotRecallEvenWhenMemberAndSenderChecksWouldOtherwisePass() { |
|||
User admin = User.builder().id(USER).role(User.Role.ADMIN).roles(java.util.Set.of(User.Role.ADMIN)).build(); |
|||
when(userRepository.findById(USER)).thenReturn(Optional.of(admin)); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> messagingService.recallMessage(USER, CONVERSATION, MESSAGE, CONTEXT, CREATED)); |
|||
|
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
verify(messageRepository, never()).findById(anyLong()); |
|||
verifyNoInteractions(auditService); |
|||
} |
|||
|
|||
@Test |
|||
void crossConversationProbeIsForbidden() { |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of( |
|||
Message.builder().id(MESSAGE).conversationId(999L).senderUserId(USER).content("x") |
|||
.status("SENT").createdAt(CREATED).version(0L).build())); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> messagingService.recallMessage(USER, CONVERSATION, MESSAGE, CONTEXT, CREATED)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void repeatedRecallIsIdempotentAndDoesNotAuditAgain() { |
|||
Message message = sentMessage(); |
|||
message.setStatus("RECALLED"); |
|||
message.setContent(MessageViewMapper.RECALLED_PLACEHOLDER); |
|||
message.setRecalledAt(CREATED.plusMinutes(1)); |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of(message)); |
|||
|
|||
MessageResponse response = messagingService.recallMessage(USER, CONVERSATION, MESSAGE, CONTEXT, AT_DEADLINE); |
|||
|
|||
assertEquals("RECALLED", response.getStatus()); |
|||
assertEquals(MessageViewMapper.RECALLED_PLACEHOLDER, response.getContent()); |
|||
verify(messageRepository, never()).recallIfCurrent(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), any()); |
|||
verifyNoInteractions(auditService); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentUpdateReturningRecalledRowIsIdempotent() { |
|||
Message message = sentMessage(); |
|||
Message latest = sentMessage(); |
|||
latest.setStatus("RECALLED"); |
|||
latest.setContent(MessageViewMapper.RECALLED_PLACEHOLDER); |
|||
latest.setRecalledAt(AT_DEADLINE); |
|||
latest.setVersion(1L); |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of(message)); |
|||
when(messageRepository.findCurrentByIdForRecall(MESSAGE)).thenReturn(Optional.of(latest)); |
|||
when(messageRepository.recallIfCurrent(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), any())) |
|||
.thenReturn(0); |
|||
|
|||
MessageResponse response = messagingService.recallMessage(USER, CONVERSATION, MESSAGE, CONTEXT, CREATED); |
|||
|
|||
assertEquals("RECALLED", response.getStatus()); |
|||
verifyNoInteractions(auditService); |
|||
} |
|||
|
|||
@Test |
|||
void talentInvitationResponseUsesRelativeSenderScope() { |
|||
Conversation invitation = Conversation.builder().id(CONVERSATION).type("TALENT_INVITATION").build(); |
|||
when(conversationRepository.findById(CONVERSATION)).thenReturn(Optional.of(invitation)); |
|||
Message message = sentMessage(); |
|||
message.setStatus("RECALLED"); |
|||
when(messageRepository.findById(MESSAGE)).thenReturn(Optional.of(message)); |
|||
|
|||
MessageResponse response = messagingService.recallMessage(USER, CONVERSATION, MESSAGE, CONTEXT, AT_DEADLINE); |
|||
|
|||
assertNull(response.getSenderUserId()); |
|||
assertEquals("CURRENT_USER", response.getSenderScope()); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue