Browse Source

feat(notification): 完善业务关联、已读和通知回收站

fix/mysql57-module-migrations-20261009
Zhang Shun 6 hours ago
parent
commit
cc3f72a56f
  1. 66
      docs/notification-links-development-verification-20261028.md
  2. 180
      docs/notification-links-frontend-handover-20261028.md
  3. 64
      docs/notification-trash-development-verification-20261028.md
  4. 125
      docs/notification-trash-frontend-handover-20261028.md
  5. 434
      docs/openapi-rc.json
  6. 41
      docs/verify_openapi.py
  7. 4
      mydreamjob-server/src/main/java/com/mydreamjob/notification/Notification.java
  8. 31
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchRequest.java
  9. 6
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchResult.java
  10. 6
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationChangeResult.java
  11. 61
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java
  12. 48
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationListResponseDoc.java
  13. 23
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java
  14. 116
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java
  15. 34
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationTrashResponseDoc.java
  16. 2
      mydreamjob-server/src/main/resources/db/migration/V64__notification_recycle_bin.sql
  17. 12
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java
  18. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  19. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  20. 33
      mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java
  21. 183
      mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationAssociationIntegrationTest.java
  22. 34
      mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java
  23. 58
      mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java
  24. 206
      mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashIntegrationTest.java
  25. 26
      mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlIntegrationTest.java
  26. 55
      mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlMigrationTest.java
  27. 10
      mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java
  28. 4
      mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java
  29. 4
      mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java

66
docs/notification-links-development-verification-20261028.md

@ -0,0 +1,66 @@
# 通知业务关联与已读:开发验证记录
日期:2026-10-28。结论范围为本地开发与定向回归,**不是完整发布验收**。
## 1. 身份与边界
- 仓库:`C:\Users\15642\Desktop\zhilai`。
- 分支:`fix/mysql57-module-migrations-20261009`。
- 开始 HEAD:`9fdb72a8e819e2a46397596483dd14b19792f9b1`。
- 原有注销、评分、配置及文档修改保留,不纳入本批。基线记录及全部原始日志在 `D:\Acceptance\qsp-notification-links-20261027\`,文件夹日期为开始执行日期。
- 本批不改前端、线上数据库、迁移、鉴权策略或业务通知接收者;不提交、不推送、不触发 Jenkins、不发布。
## 2. 修改清单与用途(13 文件)
| 文件(仓库相对路径) | 用途 |
|---|---|
| `mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java` | 显式返回 businessType/businessId;通知条目局部序列化保留 null;分页增加 id 倒序保证同刻通知稳定 |
| `mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java` | 为已有列表响应绑定精确的运行时 OpenAPI schema,不改业务返回类型 |
| `mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationListResponseDoc.java` | 新增仅用于文档的响应/分页/条目 schema,采用 OpenAPI 3.1 null 类型联合 |
| `mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationAssociationIntegrationTest.java` | 新增 H2 + 完整认证过滤链,覆盖序列化、历史空字段、已读、跨页、隔离、禁用 Token、分页和运行时契约 |
| `mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java` | 关联保留、不泄露内部键、原始 readAt 幂等、发布事件与创建测试 |
| `mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java` | 事件序列化、消费者落库、关联字段和历史 null 传递 |
| `mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java` | 投递成功/撤回/状态变更通知必须使用投递 ID,不是岗位 ID |
| `mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java` | 邀请/改期/取消/完成/学生响应通知必须使用面试 ID |
| `mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java` | 发送/响应/提醒通知关联 Offer ID |
| `docs/openapi-rc.json` | 仅扩展通知列表响应 schema;无新增路径或方法 |
| `docs/verify_openapi.py` | 校验条目键集、类型/null 联合、业务 ID int64、开放类型及原有已读路径 |
| `docs/notification-links-frontend-handover-20261028.md` | 字段与接口参考、点击/已读流程、全部已查来源映射和联调限制 |
| 本文 | 记录开发验证范围、历史失败、未执行项与后续验收要求 |
## 3. 核查结果与直接相关修复
1. 表已有 business_type/business_id,直接创建、事件发布及消费者均已传递;已核查生产者的关联表在前端说明中。本批无生产者语义修正、数据库迁移或历史回填。
2. 全局 `spring.jackson.default-property-inclusion=non_null` 原本省略 Map 中 null。仅使用 Map 子类 JsonInclude 注解的尝试仍会被全局 Map 内容过滤影响,实际 HTTP 测试抓到此问题;最终使用通知视图专用 serializer 明确输出键,普通 Map 的全局 null 省略行为有回归断言,不修改全局配置。
3. 原“全部已读”遍历页时只有 createdAt 排序。同刻通知的排序不确定,首轮 105 条中先读 1 条,剩余应标记 104 条而实际只变更 103 条。查询增加 id 倒序作为同刻 tie-breaker;回归特意构造 105 条完全相同 createdAt 的通知,检验分页不漏、另一个账号不变、重复操作 marked=0。
4. 通知不存在沿用 HTTP 400 / 9105,初始验收用例误期望 404 已修正为实际既有规则,没有扩大全局异常映射改动。
5. OpenAPI 运行时为 3.1.0,单用 nullable 注解没有生成预期 null 语义;改用 `type: [基础类型, null]`,保留 required 字段存在性,并比较静态及运行时字段类型/format。
6. `MATCH` 的 businessId 是投递 ID;`VERIFICATION` 是认证申请 ID;`REPORT` 是举报单 ID。保留既有类型,不擅自统一为其他业务类型。
## 4. 执行证据与结论口径
定向命令(在 mydreamjob-server 执行,两个付费 Live 开关保持 false):
```powershell
mvn '-Dmaven.repo.local=D:\DevCache\maven' '-Dtest=Notification*Test,ApplicationServiceTest,ApplicationAcceptanceServiceTest,InterviewServiceTest,OfferServiceTest,OfferExpiryWorkerTest,MatchNotificationServiceTest' test
```
覆盖 10 个类,含已有企业接受投递及匹配通知测试。H2 测试使用完整 Spring 认证过滤链及真实持久化,不是 standalone MockMvc;消费者测试使用序列化 round-trip 与模拟持久化,**不是实际 RabbitMQ broker 端到端验收**。
执行静态契约 `python docs/verify_openapi.py`、静态/运行时路径方法集及通知字段比较、9 个契约反向变异和精确文件 `git diff --check`。最终机器结果以验收目录 `verification-summary.json`、`contract-negative.json` 与 `targeted-final2.log` 为准;不是仅凭本文认定通过。
历史证据保留:
- targeted-run1.log:174 项,4 failures;null 过滤和多页漏标被发现,另有既有错误码及 3.1 schema 测试期望不符。
- targeted-run2.log:中途中断,无最终 Maven 结论,不计为成功或产品失败。
- targeted-run3.log:181 项,1 failure;剩余 null 过滤问题。
- targeted-run4.log、targeted-final.log:181/0/0/0,成功;随后进一步补正文 null 的契约和回归,再执行最终 targeted-final2.log。
## 5. 未执行与下一阶段
- 完整 `mvn clean verify`:**NOT EXECUTED**。当前定向验证不替代全量;本轮预检 Docker daemon 不可用,不把缺 Docker 的全量或 MySQL 项记为通过。
- 真实 MySQL 8.4 / 5.7.36:**NOT EXECUTED**;此批无 DDL,但分页和 null 输出仍应补真实库专项。
- 同次成功全量构建 JAR 的真实 HTTP、持久化重启:**NOT EXECUTED**;未用旧 JAR 代替。
- 实际 RabbitMQ 发送/消费、前端点击/跳转/红点联动、线上部署:**NOT EXECUTED**。
不需要新增产品决策才能完成本批代码。后续若要回填历史无关联通知、改变业务类型含义、调整“不存在”的 HTTP 状态或新增详情接口,应另行设计和授权;恢复 Docker 后建议建立 HEAD + 本批 13 文件的隔离基线做全量及真实库/JAR 验收,不把原仓库其他未提交改动混入。

180
docs/notification-links-frontend-handover-20261028.md

@ -0,0 +1,180 @@
# 通知已读与业务关联:前端接入说明
日期:2026-10-28。面向前端联调开发者。本文描述本地后端改动,**尚未提交、推送或发布**,不能据此认为线上已经返回新增字段。
### 交接状态(验收后更新)
- 后端隔离验收已通过:完整 `clean verify` **3900 tests / 0 failures / 0 errors / 2 skipped**(398 suites);两个跳过项仅为付费 DeepSeek Live 测试。
- 同次 JAR 在 MySQL 8.4.11、5.7.36 下各 **42/42** 真实 HTTP / RabbitMQ / 重启检查通过;静态与运行时契约均为 **216 paths / 266 operations**,反向检查 9/9。
- JAR SHA256:`0d74e2c6610e5a2197e4ee1571f136d643f1b5a9dcfde06bfd381c0ec59aedec`。报告目录:`D:\Acceptance\qsp-notification-full-20261028-a1\`。
- **发布尚未授权执行。** 前端可以先完成类型、交互、单测;真实联调须由后端提供已启动的联调地址,或待后续授权发布后确认实际响应。已停止的验收进程/容器不是可直接使用的联调服务。
- 此节及下文实施清单是验收后的文档补充,不是新的后端代码改动。原验收目录中的 13 文件源码快照和哈希保留,不修改既有 PASS 证据。
## 1. 字段与接口参考
统一前缀 `/api/v1`,使用正常登录取得的 access token,发送 `Authorization: Bearer <accessToken>`;refresh token 不能访问业务接口。身份从安全上下文取得,不接受调用方指定通知归属用户。
### 通知列表
`GET /notifications?page=0&size=20`:page 从 0 开始,size 为 1~50。按 createdAt 倒序,同一创建时间按 id 倒序,分页信封不变。
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [{
"id": 28,
"type": "INTERVIEW_INVITED",
"title": "面试邀请",
"content": "你收到一条面试邀请",
"read": false,
"readAt": null,
"createdAt": "2026-10-28T10:00:00",
"businessType": "INTERVIEW",
"businessId": 123
}],
"total": 1,
"page": 0,
"size": 20,
"totalPages": 1
}
}
```
外层 requestId 和 timestamp 沿用统一响应,此处省略。createdAt/readAt 沿用现有 LocalDateTime 格式,不在本批改变时间/时区语义。
| 字段 | 类型 | 含义 |
|---|---|---|
| id | integer / int64 | 通知 ID,用于标记已读 |
| type | string | 通知事件类型,如 INTERVIEW_INVITED,不等于业务类型 |
| title | string | 通知标题 |
| content | string / null | 展示正文,历史内容可空;禁止从文案提取目标 ID |
| read | boolean | 当前已读状态;false 才显示该条红点 |
| readAt | string / null | 已读时间,未读时 null |
| createdAt | string | 创建时间 |
| businessType | string / null | 新增:持久化关联业务类型,开放字符串,不是封闭枚举 |
| businessId | integer / int64 / null | 新增:持久化业务记录 ID,不是通知 ID,也不一定是岗位 ID |
两项业务关联字段在本批返回中保留,历史无关联时明确为 null,不从文案猜测,不执行历史回填。部分旧数据也可能只有一个字段有值,前端须把不完整关联视为不可导航。
项目全局配置是 `non_null`。本批仅在通知条目视图局部保留 null,不改全局配置;因此历史条目会明确含 `businessType: null`、`businessId: null`,readAt 等通知条目中的 null 也会显式输出。新旧客户端均应接受这些附加键。
### 已读和未读数
| 操作 | 请求 | 成功 data |
|---|---|---|
| 单条已读 | `POST /notifications/{id}/read`,无请求体 | `{"message":"marked as read"}` |
| 全部已读 | `POST /notifications/read-all`,无请求体 | `{"marked":3}`(本次实际变更数量) |
| 未读数 | `GET /notifications/unread-count` | `{"unreadCount":3}` |
均返回统一响应 code=200。重复单条已读不重复计数且不重写原 readAt;重复全部已读通常 marked=0。新通知并发到达时,以随后获取的未读数为准,不能保证永久为 0。
现有错误语义保留:未登录或原 Token 对应账号已禁用返回 401;他人通知的已读操作返回 403 / 2002;通知不存在实际返回 **400 / 9105**,不是 404;分页非法返回 400 / 1001。企业账号仍受已有企业认证门禁约束,不在本批放宽。
## 2. 点击和已读接入流程
### 前端需要修改的位置
以下来自只读检查 `D:\Projects\mydreamjob`(HEAD `cbe7382`),仅作为定位线索;前端工程师须以自己的最新分支复核。后端本轮没有修改这些文件。
| 文件 | 当前观察 | 需要完成 |
|---|---|---|
| `src/types/message.ts` | Notification 只有展示字段,type 为 offer/system/promotion | 保留 UI 分类,新增原始事件类型和业务关联字段 |
| `src/api/api.ts` 的 mapBackendNotification | 映射时丢掉业务关联和原始事件类型 | 透传并校验新增字段,content=null 时安全展示 |
| `src/api/api.ts` 的 fetchNotifications | 只取第一页并返回数组,分页信封丢失 | 支持 page/size,保留 total/totalPages,不能只展示默认前 20 条却称为全部通知 |
| `app/(tabs)/chat.tsx` 的 NotificationCard | 使用 View,没有点击处理 | 增加可点击交互、通知全文展示、已读和业务导航 |
| `src/hooks/useMessageData.ts` | 独立聚合通知状态,子请求失败会降级为空数组 | 对接读状态同步;通知失败应呈现可重试状态,不能误报“暂无通知” |
| `app/notifications.tsx`、`src/hooks/useNotifications.ts` | 已有已读/全部已读入口,但状态局部独立 | 与沟通 Tab 同步,处理请求失败和业务跳转 |
| `src/hooks/useNotifications.ts` 的 useUnreadCount | 未读数独立获取 | 已读后刷新并通知所有入口,切账号清理旧结果 |
### 字段映射约定
保留现有 `type: 'offer' | 'system' | 'promotion'` 用于图标,不强行替换为后端大写事件类型,避免破坏现有图标映射。建议给 Notification 增加以下字段(命名可按项目规范,但语义必须保留):
```ts
// Notification 中的增量字段
eventType: string; // 原始 raw.type,例如 INTERVIEW_INVITED
businessType: string | null; // 原始 raw.businessType,不从文案推断
businessId: number | null; // 原始 raw.businessId
readAt: string | null;
createdAt: string;
```
- 通知 id 沿用现有 string 表示,例如 `String(raw.id)`;标记已读用通知 id,业务跳转用 businessId。
- 为兼容尚未发布的新后端,旧响应缺失关联字段时按不可导航处理,禁止从文案补造;发布后联调需确认两个关联键确实存在。
- 导航前检查 `typeof businessId === 'number' && Number.isSafeInteger(businessId) && businessId > 0`。不应对非法值、字符串或超出安全整数范围的数值直接拼路由;不要依靠 parseInt 静默修复脏数据。
- read 使用服务端布尔值;content 为 null 时展示空正文或“暂无详细内容”,不要显示字面量 null。
### 点击、已读与刷新
1. 列表映射须保留原始 type、businessType、businessId;不要只保留 title/content/read,也不要把所有 type 折叠为 system 后丢掉原类型。
2. 点击条目先展示通知全文,再按下表解析关联。只支持明确映射的类型,并校验 businessId 是可用正整数;应用内路由由前端按当前角色和现有页面实现。
3. 用户查看通知即可以通知 id 调用单条已读。通知阅读和关联详情能否访问是两回事:即使目标已删除,也仍可阅读原通知并标记已读。
4. 已读成功后更新列表项并刷新独立的未读总数;失败时保留未读或回滚乐观状态,提供重试,不能静默清除红点。切换账号后禁止旧请求结果写回新账号状态。
5. 关联业务详情仍使用既有面试/投递/Offer 接口并携带当前用户凭据。通知关联不是新的访问授权;禁止凭通知 ID 或 businessId 绕过权限。
6. 未知类型、空/不完整关联、目标已删除/无权限时,保留通知全文,并提示“关联内容不可用”;未登录应走登录恢复,不伪装为内容不存在。
7. “全部已读”由用户主动触发;成功后刷新条目和未读数,不建议仅进入列表就自动把所有页标成已读。
8. 页面回到前台、进入通知 Tab 或读状态变化后,按项目统一刷新机制同步。此批没有新增通知实时推送协议,不能承诺 WebSocket 即时更新。
9. 两个通知入口应复用当前账号的状态或共享失效通知机制,不允许只刷新当前卡片而其他页面继续保留旧红点。可以复用项目已有缓存/事件机制,不要求为此更换全局状态库。
10. 全局红点/数量使用 `/notifications/unread-count`,不能使用第一页的 `items.some(!read)` 代替;第一页全已读不代表后续页无未读。“全部已读”按钮也不能仅依据当前页判断是否可用。
11. 对同一条通知的连续点击去重,全部已读提交时防止重复点击;刷新请求需要账号隔离和旧响应保护。不要在请求失败或超时时直接把未读数硬设为 0。
### 业务目标与现有路由边界
| 关联类型 | 前端行为 |
|---|---|
| APPLICATION、MATCH | ID 均为投递 ID。当前求职者页为 `/my-application-detail?id=<businessId>`,企业页为 `/application-detail?id=<businessId>`;按当前角色选择,仍由详情接口校验权限 |
| OFFER | 当前已有 `/offers/[id]` 双端共用详情,使用 Offer ID;请复核最新实现 |
| INTERVIEW | 使用现有 `GET /interviews/{interviewId}` 读取企业面试详情,展示邀请/时间/最新状态;需要前端补真实业务详情页或详情弹层,并可复用面试列表中的数据与组件 |
| VERIFICATION、REPORT、未知类型、空/不完整关联 | 在没有确认可用且授权的业务页面前,只展示通知全文及不可用提示,不伪造路径 |
**特别注意:当前 `app/interview.tsx` 使用 useAIInterview,是 AI 面试功能,不是企业邀约的业务面试详情页。禁止把 INTERVIEW 通知直接跳到 `/interview` 当作完成。** 面试取消后应展示“已取消”等最新业务状态,不把通知当作仍然有效的邀请。
业务详情可能已删除、已失效或当前用户无权限。此时通知本身仍可阅读、标记已读;详情访问失败应有明确反馈。401 应走登录恢复,403/404 或具体业务不可用码应按该详情接口实际契约处理,不一律吞掉,也不要把业务字段当成额外访问授权。
## 3. 已核查的业务关联表
以下为源码生产者核查,不等于每种事件均已做真实端到端验证;具体测试范围见本批验证记录。
| 生产者 / 事件 | 接收者 | businessType / businessId |
|---|---|---|
| ApplicationService:APPLICATION_APPLIED、APPLICATION_WITHDRAWN、APPLICATION_STATUS | 投递求职者 | APPLICATION / 投递 ID;投递成功文案中的岗位 ID 不能用于此字段 |
| ApplicationAcceptanceService:APPLICATION_ACCEPTED | 投递求职者 | APPLICATION / 投递 ID |
| InterviewService:INTERVIEW_INVITED、INTERVIEW_RESCHEDULED、INTERVIEW_CANCELLED、INTERVIEW_COMPLETED | 面试求职者 | INTERVIEW / 面试 ID,不是投递 ID |
| InterviewService:INTERVIEW_CONFIRMED、INTERVIEW_DECLINED、INTERVIEW_RESCHEDULE_REQUESTED | 所属企业 owner 用户 | INTERVIEW / 面试 ID |
| InterviewScoreService:INTERVIEW_SCORE_SUBMITTED | 评分对应求职者 | INTERVIEW / 面试 ID,不是评分 ID |
| OfferService:OFFER_SENT、OFFER_REMINDER | 候选人 | OFFER / Offer ID |
| OfferService:OFFER_ACCEPTED、OFFER_REJECTED、OFFER_NEGOTIATING、OFFER_EXPIRED、OFFER_WITHDRAWN | 所属企业 owner 用户(按现有逻辑) | OFFER / Offer ID |
| OfferExpiryWorker:OFFER_REMINDER | 候选人 | OFFER / Offer ID |
| OfferExpiryWorker:OFFER_EXPIRED | 候选人及所属企业 owner 用户(两个独立事件) | OFFER / Offer ID |
| MatchNotificationService:MATCH_SUCCEEDED | 候选人及所属企业 owner 用户 | **MATCH / 投递 ID**,不是匹配记录 ID;前端可基于投递详情或现有投递匹配接口处理 |
| CompanyVerificationService:VERIFICATION_APPROVED、VERIFICATION_REJECTED | 提交认证的用户 | VERIFICATION / 企业认证申请 ID,不是 companyId |
| ReportService:REPORT_DECISION | 举报人 | REPORT / 举报单 ID,不是被举报对象 ID |
| 四参 NotificationService.create、历史/纯系统通知 | 调用方指定接收者 | 无明确关联时两个字段均 null,仅展示原文 |
通知消费者将事件中的 businessType/businessId 原样写入;本批不修改事件类型、接收者、发消息时机、幂等键或通知文案。未发现上述已查生产者需要补造关联字段,故未改这些生产业务代码。
## 4. 联调验收与限制
- 面试、投递、Offer、MATCH、未知类型以及历史无关联:字段正确,路由不靠文本推断。
- 点击一条未读 → 单条已读成功 → 本项红点及总未读数更新 → 重新进入仍已读。
- 重复点击及“全部已读”幂等;超过 100 条、相同创建时间的多页通知不遗漏。
- A/B 两账号隔离;禁用原 Token、他人通知、通知不存在均按上文错误规则处理。
- 关联详情目标无权限或已不存在时,通知仍能展示;没有返回内部 userId/idempotencyKey。
- 列表保留分页信封;此批不新增详情、路由、聚合接口,不新增迁移,不回填旧数据。
- 后端本地改动不会自动修复前端点击事件、红点状态或完成线上部署。完整构建、真实 MySQL、JAR 黑盒、前端点击及上线结果分别登记,不互相替代。
### 前端交付验收清单
1. **两个入口一致**:沟通 Tab、独立通知页均可点击;任一入口阅读后,另一入口和全局未读数同步。
2. **持久已读**:未读红点 → 点击 → 已读接口成功 → 红点消失 → 返回/重新登录后仍已读;重复点击不重复扣数。
3. **失败不伪成功**:模拟已读、全部已读、列表或未读数请求失败,显示重试;不会把失败当空列表或把未读数直接清零。
4. **分页正确**:至少 25 条通知、第二页仍有未读;第一页读完时全局提示仍正确,加载更多不重复、不遗漏;全部已读处理所有页。
5. **业务跳转**:投递成功/状态变更/企业接受进入正确投递;Offer 进入正确 Offer;面试邀请/改期/取消展示真实业务面试,不进入 AI 面试。
6. **降级正常**:历史 null、未知类型、单边字段缺失、目标已删除或无权限,仍可展示通知全文并反馈原因。
7. **账号隔离**:A 请求未完成时切 B,A 的列表、已读响应和未读数不能覆盖 B。
8. **不从文案猜 ID**:把标题/正文中的“#编号”改为无关值,跳转仍只由 businessType/businessId 决定。
前端交付请提供:修改文件列表、TypeScript/相关测试结果、实际联调地址与后端版本、关键请求参数与响应字段(去除 Token/验证码)、双入口与跨页操作录屏或截图。尚未拿到运行中的新后端时,仅能登记前端单测/模拟测试通过,不能登记真实接口联调通过。

64
docs/notification-trash-development-verification-20261028.md

@ -0,0 +1,64 @@
# 通知第二批开发验证记录:删除、多选与回收站
日期:2026-10-28。状态:**开发验证通过,尚非完整发布验收**。仅后端、测试和必要文档;未改前端,未提交、推送、触发 Jenkins 或发布,未修改线上数据库。
## 1. 实现范围
- 通知以 `deleted_at` 软删除;支持单条/批量移入回收站、单条/批量恢复和回收站分页。不存在永久删除、清空、自动过期清理或历史操作页面。
- 删除只影响当前用户的通知,不取消面试,不删除投递/Offer,也不影响对方收到的通知。
- 原正常列表不再展示软删除通知,条目仍保持第一批九个字段;回收站增加 `deletedAt`,按删除时间、ID 倒序。
- 原未读通知删除后不计数;恢复保留 read/readAt,原未读重新计数。全部已读只处理正常列表。
- 批量请求原始长度 1~100,ID 必须为 JSON 正整数,去重后返回 requested 和实际 changed 数量。任何越权或不存在 ID 导致整批失败,无部分成功。
- 单条及批量操作使用行锁,批量按 ID 升序加锁;单条已读也使用行锁,避免并发删除被陈旧实体覆盖而复活。全部已读条件更新只写 read/readAt,排除回收站。
- 原个人数据导出仍按原归属全量查询,软删除并不等于物理清除。账号注销清理范围不因本模块改变。
## 2. 文件范围
本批共 21 文件;相对第一批已验收的 13 文件,重叠 5 文件,合并后共 **29 文件**。准确路径和 SHA256 以证据目录清单为准。
| 文件组 | 用途 |
|---|---|
| Notification.java / NotificationRepository.java | 删除时间、正常/回收站分页、排除删除项的计数、行锁与条件已读更新 |
| NotificationService.java / NotificationController.java | 五个新接口、批量原子性和权限、删除/恢复幂等、保持原通知关联视图 |
| NotificationBatchRequest.java / NotificationBatchResult.java / NotificationChangeResult.java | 严格正整数数组和数量上限、去重数量/实际变化结果 |
| NotificationTrashResponseDoc.java | 回收站分页及 deletedAt 的运行时契约 |
| V64__notification_recycle_bin.sql | 新增可空 deleted_at 与用户/删除时间/ID 索引,不改旧迁移或回填历史数据 |
| NotificationServiceTest.java | 适配正常列表过滤、加锁已读、条件全部已读 |
| NotificationTrashIntegrationTest.java | JWT 过滤链下 HTTP 语义、参数、权限、幂等、分页、批量原子性与并发 |
| NotificationTrashMySqlIntegrationTest.java | 相同套件在真实 MySQL 上执行,可指定 8.4 或 5.7.36 |
| NotificationTrashMySqlMigrationTest.java | 双 MySQL V63→V64、旧数据与历史校验和、幂等及 validate |
| CompanyResumeDownloadIntegrationTest.java / UnifiedRegistrationIntegrationTest.java / ProfileImageIntegrationTest.java / WorkExperienceOptimizationSecurityIntegrationTest.java | 同步五个新认证操作带来的现有契约计数,未跳过或削弱权限断言 |
| docs/openapi-rc.json / docs/verify_openapi.py | 221 paths / 271 operations,新增结构、认证、批量约束及无永久删除约束 |
| 本记录 / notification-trash-frontend-handover-20261028.md | 验证边界及前端对接 |
## 3. 已执行及证据
证据目录:`D:\Acceptance\qsp-notification-trash-20261028-a1`。
| 验证 | 实际结果 |
|---|---|
| 最终 9 类定向测试 | **117 tests / 0 failures / 0 errors / 0 skipped**,Maven 退出码 0 |
| 真实 MySQL 8.4 通知套件 | **22/22**,完整认证过滤链 + MockMvc,不冒充实际端口 JAR 黑盒 |
| 真实 MySQL 5.7.36 通知套件 | **22/22**,同上;Hibernate 对 5.7 支持范围有警告,实际专项无失败 |
| V63→V64 真实双库迁移 | **2/2**:旧通知字段及 read/readAt 不变,deleted_at 初始 NULL,索引正确,历史 checksum 不变,二次迁移 0、validate 成功 |
| 静态与 Spring 测试运行时契约 | **221 paths / 271 operations**,路径方法集一致,五个新操作身份/鉴权及四个请求/响应模型一致 |
| 静态校验器反向变异 | **10/10 拒绝**:缺鉴权、错误 operationId、空数组、超限、字符串/零 ID、必填约束、缺删除时间及正常列表内部字段泄露 |
| git diff --check | 本批与合并通知文件范围检查,无空白错误 |
关键日志:`targeted-final.log`、`mysql84-run1.log`、`mysql57-run1.log`、`migration-contract-run1.log`。最终定向 XML 归档在 `final-test-xml/`,双库通知套件各自保存 `mysql84-tests.xml` / `mysql57-tests.xml`,不会以后一轮覆盖冒充两轮证据。
计数修正后实际通过:企业简历下载 19 项、统一身份注册 13 项、头像 18 项、AI 权限 9 项。禁止将本批定向通过当作这些模块的完整发布验收。
开发过程中静态契约补丁首次生成使用系统输出编码,导致新标签中文损坏;静态校验已拒绝该结果。改用 UTF-8 重新生成后通过,最终契约与运行时一致。没有通过修改全局序列化、关闭鉴权或跳过测试来消除失败。
## 4. 未执行及发布边界
以下均为 **NOT EXECUTED**:本批合并基线的完整 `mvn clean verify`、同次打包 JAR 的真实端口 HTTP 黑盒、仅重启 JAR 的持久化、前端左滑/长按/跳转实机联调、线上部署。
第一批 `D:\Acceptance\qsp-notification-full-20261028-a1` 的全量/JAR 结果仅覆盖第一批冻结源码。本次新增 V64 和五个接口,不能复用第一批 JAR 或将旧 PASS 视为合并基线也通过。
本批不操作已有业务容器;专项使用 Testcontainers 创建的临时隔离数据库,自动停止清理。没有删除已有数据卷或验收报告。旧迁移内容、原仓库 HEAD/暂存区及非本批起始文件哈希保持不变,详情见机器摘要。
交接文档:`docs/notification-trash-frontend-handover-20261028.md`,结构为“接口与字段 → 左滑/多选/回收站流程 → 权限与错误处理 → 联调验收”。没有需要新增授权才能完成的本批编码问题;若后续需要永久删除或自动保留期限,应另立需求,不能自行增加。
下一步:以 `combined-whitelist.json` 的 **29 文件合并基线**建立独立副本,完整构建成功后锁定同次 JAR,验证删除/恢复、权限、并发、契约和重启,再决定发布。当前工作区不能按第一批旧 13 文件清单直接发布。

125
docs/notification-trash-frontend-handover-20261028.md

@ -0,0 +1,125 @@
# 通知第二批:删除、多选与回收站对接
日期:2026-10-28。面向前端开发/联调。仅描述本批后端实现,**尚未提交、推送或发布**。第一批“业务关联与已读”交接文档继续适用;本批没有新增历史记录页面、永久删除或自动清空。
## 1. 接口与字段
前缀 `/api/v1`,全部接口使用正常 access token:`Authorization: Bearer <accessToken>`,身份从安全上下文取得。不得传 userId 来指定通知归属;企业仍受已有认证门禁限制。
| 操作 | 方法与路径 | 请求 | 成功 data |
|---|---|---|---|
| 正常列表(已有) | `GET /notifications?page=0&size=20` | page≥0,size=1~50 | 原分页信封,仅正常通知 |
| 回收站 | `GET /notifications/trash?page=0&size=20` | 同上 | 回收站分页信封 |
| 单条移入回收站 | `DELETE /notifications/{id}` | 无请求体,正整数通知 ID | `{"changed":true}` |
| 单条恢复 | `POST /notifications/{id}/restore` | 无请求体,正整数通知 ID | `{"changed":true}` |
| 批量移入回收站 | `POST /notifications/batch-delete` | `{"ids":[28,29]}` | `{"requested":2,"changed":2}` |
| 批量恢复 | `POST /notifications/batch-restore` | `{"ids":[28,29]}` | `{"requested":2,"changed":2}` |
均为统一响应 code=200,data 外仍有 message、requestId、timestamp。重复删除已删除通知、恢复正常通知均成功但 changed=false;批量 changed 是实际改变状态的条数,requested 是去重后的 ID 数。
### 批量约束
- ids 必须为非空数组,原始数组长度 1~100。超过 100 条即拒绝,不会先去重再绕过上限。
- 只接受 JSON 正整数,不能是字符串、浮点数、布尔值、null、0、负数或超出 int64 范围的值。
- 重复 ID 去重,例如 `[28,28]` → requested=1,最多改变一次。
- **整批原子操作**:任意 ID 不存在、归属其他账号,整批失败,无部分成功。已经处于目标状态的本人通知是幂等项,不构成失败。
- 前端现有通知 id 若存为 string,发送批量请求前必须严格校验并转为安全整数;不能把 string 数组直接发给后端,也不能用 parseInt 静默截断。
```ts
function batchIds(ids: string[]): number[] {
if (ids.length < 1 || ids.length > 100) throw new Error('一次请选择 1~100 条通知');
return [...new Set(ids.map(id => {
if (!/^[1-9]\d*$/.test(id)) throw new Error('通知 ID 无效');
const value = Number(id);
if (!Number.isSafeInteger(value)) throw new Error('通知 ID 超出安全整数范围');
return value;
}))];
}
```
### 回收站响应
```json
{
"code": 200,
"message": "ok",
"data": {
"items": [{
"id": 28,
"type": "INTERVIEW_INVITED",
"title": "面试邀请",
"content": "你收到一条面试邀请",
"read": false,
"readAt": null,
"createdAt": "2026-10-27T10:00:00",
"businessType": "INTERVIEW",
"businessId": 123,
"deletedAt": "2026-10-28T10:00:00"
}],
"total": 1,
"page": 0,
"size": 20,
"totalPages": 1
}
}
```
- 正常列表仍为第一批九个条目字段,不额外返回 deletedAt;回收站在原字段上增加非空 deletedAt。
- 正常列表按原 createdAt/id 倒序;回收站按 deletedAt/id 倒序。恢复后保留原 createdAt,回到正常列表的原时间位置,不保证出现在第一条。
- 删除/恢复不改 type、正文、业务关联、read 或 readAt,也不创建新通知;重复删除不刷新 deletedAt。
## 2. 左滑、多选与回收站流程
### 正常通知列表
1. **左滑**:露出“删除”按钮,点击后才发送 DELETE。不要一次滑动就直接执行。
2. 成功后从正常列表移除该项,并刷新分页总数和全局未读数。失败保留或回滚该项,显示可重试反馈。
3. **长按**:进入多选模式,显示选择数、“取消”“删除所选”,可以提供“全选当前已加载项”。
4. 单次最多选择 100 条。已加载更多时应明确限制或提示分批处理,禁止静默截断,也不要后台拆成多批却声称整次操作原子成功。
5. 批量删除前确认所选数量。成功后退出或更新选择状态;失败不应清空选择,也不能只移除一部分记录。
6. 正常点击继续走第一批“通知全文/业务详情 + 标记已读”,与左滑、长按手势区分,避免误触。
### 回收站
1. 提供独立回收站入口和分页列表,支持单条恢复、长按多选及恢复所选。
2. 回收站不提供“再次删除”“永久删除”“清空回收站”,也不自动过期清理。
3. 回收站中可以查看通知内容,但不要因浏览而调用标记已读。恢复后再按正常通知流程阅读。
4. 恢复成功后刷新正常列表、回收站、两个通知入口和全局未读数;恢复失败保留条目并提示重试。
### 红点与已读规则
- 未读通知移入回收站后,不再计入 `/notifications/unread-count`。
- 恢复时保留原 read/readAt:原未读通知重新计数;原已读通知仍已读,不增加未读数。
- 原 `POST /notifications/read-all` **只处理正常列表**,不会修改回收站中的通知。
- 对回收站里的通知调用 `POST /notifications/{id}/read` 会拒绝,不得把它当作已读成功。
- 不要直接用 changed 扣减未读数:删除项可能混合已读与未读。成功后以未读数接口为准,并保护账号切换和旧响应覆盖。
## 3. 权限、错误与业务边界
| 场景 | 行为 |
|---|---|
| 未登录、过期或禁用账号原 Token | 401,走登录恢复 |
| 参数或 JSON ID 格式非法 | 400,通常 code=1001,修正请求后重试 |
| 通知不存在 | 沿用既有 HTTP 400 / code=9105 |
| 对回收站条目标记已读 | HTTP 400 / code=9105,提示先恢复,不假装已读 |
| 删除/恢复其他账号的通知 | 403 / code=2002;批量整批失败 |
| 未认证企业 | 沿用企业认证门禁,按具体错误码展示,不一律解释成通知归属问题 |
| 网络超时,是否成功未知 | 先刷新状态或安全重试;删除/恢复幂等,不能宣称已成功 |
删除仅是**当前用户自己的通知软删除**,不取消面试、不删除投递/Offer、不影响企业或对方收到的通知。业务详情的访问权限仍由各业务接口校验;业务目标已失效也不妨碍删除或恢复通知。
软删除不是物理清除数据;个人数据导出及账号注销清理继续遵循现有范围和规则。本模块不增加永久删除能力或历史操作记录页面。
## 4. 联调验收
- 未读删除 → 红点计数减少 → 回收站保留 read=false → 恢复后重新计数。
- 已读删除/恢复后 readAt 不变,未读数不增加;重复操作 changed=false 或 0。
- “全部已读”只影响正常列表;回收站原未读恢复后仍未读。
- 正常列表和回收站分别分页;相同删除时间下顺序稳定,无重复/漏项。
- 1 条、100 条、重复 ID、101 条、非法 ID 和空选择均正确处理。
- 批量混入他人或不存在 ID,整批失败,无部分消失或部分恢复。
- A/B 账号隔离、切账号时旧响应不回写;未认证企业、禁用账号不放宽。
- 并发点击已读与删除,不会让通知意外复活;并发批量删除幂等。
- 前端只提交明确选择的 ID,不进行永久删除、不取消关联业务。
后端定向/迁移测试、完整构建、同次 JAR 黑盒、前端实机手势和线上部署分别登记,不互相替代。第一批验收快照保存在 `D:\Acceptance\qsp-notification-full-20261028-a1\`;当前工作区已叠加第二批,**不能再用第一批旧 13 文件清单直接发布当前工作区**。后续须按第一批依赖 + 第二批改动的完整合并基线重新验收。

434
docs/openapi-rc.json

@ -4897,7 +4897,7 @@
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseMapStringObject"
"$ref": "#/components/schemas/NotificationListResponseDoc"
}
}
}
@ -4905,6 +4905,205 @@
}
}
},
"/notifications/{id}": {
"delete": {
"tags": [
"通知"
],
"summary": "通知移入回收站",
"description": "仅本人通知软删除,幂等;不删除面试/投递/Offer,不改变已读状态;不存在400/9105、他人通知403/2002。",
"operationId": "deleteNotification",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseNotificationChangeResult"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/notifications/{id}/restore": {
"post": {
"tags": [
"通知"
],
"summary": "恢复通知",
"description": "仅本人通知,幂等;保留删除前read/readAt,原未读恢复后重新计数;不存在400/9105、他人通知403/2002。",
"operationId": "restoreNotification",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseNotificationChangeResult"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/notifications/trash": {
"get": {
"tags": [
"通知"
],
"summary": "通知回收站",
"description": "仅当前用户软删除通知,按deletedAt/id倒序分页;不计入未读数;无永久删除或自动清空。",
"operationId": "listNotificationTrash",
"parameters": [
{
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"default": 0
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"default": 20
}
}
],
"responses": {
"200": {
"description": "回收站分页",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/NotificationTrashResponseDoc"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/notifications/batch-delete": {
"post": {
"tags": [
"通知"
],
"summary": "批量通知移入回收站",
"description": "最多100个正整数ID,重复项去重;整批原子操作,任意不存在400/9105或越权403/2002均无部分成功;仅当前用户软删除,无永久删除。",
"operationId": "batchDeleteNotifications",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationBatchRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseNotificationBatchResult"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/notifications/batch-restore": {
"post": {
"tags": [
"通知"
],
"summary": "批量恢复通知",
"description": "最多100个正整数ID,重复项去重;整批原子操作,任意不存在400/9105或越权403/2002均无部分成功;保留原已读状态。",
"operationId": "batchRestoreNotifications",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationBatchRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseNotificationBatchResult"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/notifications/read-all": {
"post": {
"tags": [
@ -13027,6 +13226,239 @@
},
"components": {
"schemas": {
"ApiResponseNotificationChangeResult": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/NotificationChangeResult"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"NotificationChangeResult": {
"type": "object",
"properties": {
"changed": {
"type": "boolean",
"description": "本次是否实际改变状态;重复删除/恢复为 false"
}
}
},
"NotificationTrashResponseDoc": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/NotificationTrashPageDoc"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"NotificationTrashPageDoc": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationTrashItemDoc"
}
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer",
"format": "int32"
},
"size": {
"type": "integer",
"format": "int32"
},
"totalPages": {
"type": "integer",
"format": "int32"
}
}
},
"NotificationTrashItemDoc": {
"type": "object",
"description": "回收站条目保留原通知信息和已读状态;关联业务不随通知删除",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"type": {
"type": "string"
},
"title": {
"type": "string"
},
"content": {
"type": [
"string",
"null"
]
},
"read": {
"type": "boolean"
},
"readAt": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"businessType": {
"type": [
"string",
"null"
],
"description": "持久化业务类型,非封闭枚举;历史或系统通知可为 null。已核查 APPLICATION/INTERVIEW/OFFER/MATCH/VERIFICATION/REPORT"
},
"businessId": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "持久化关联业务记录ID,不是通知ID;APPLICATION 和 MATCH 均为投递ID,INTERVIEW 为面试ID,OFFER 为Offer ID;无关联时 null"
},
"deletedAt": {
"type": "string",
"format": "date-time",
"description": "本次移入回收站的时间;重复删除不刷新"
}
},
"required": [
"businessId",
"businessType",
"deletedAt"
]
},
"NotificationBatchRequest": {
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": {
"type": "integer",
"format": "int64",
"maxItems": 100,
"minItems": 1,
"minimum": 1
},
"maxItems": 100,
"minItems": 1
}
},
"required": [
"ids"
]
},
"ApiResponseNotificationBatchResult": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/NotificationBatchResult"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"NotificationBatchResult": {
"type": "object",
"description": "整批原子操作;requested 为去重后的ID数,changed 为本次状态实际改变的条数",
"properties": {
"requested": {
"type": "integer",
"format": "int32"
},
"changed": {
"type": "integer",
"format": "int32"
}
}
},
"NotificationListResponseDoc": {
"type": "object",
"properties": {
"code": {"type": "integer", "format": "int32"},
"message": {"type": "string"},
"data": {"$ref": "#/components/schemas/NotificationPageDoc"},
"requestId": {"type": "string"},
"timestamp": {"type": "string", "format": "date-time"}
}
},
"NotificationPageDoc": {
"type": "object",
"properties": {
"items": {"type": "array", "items": {"$ref": "#/components/schemas/NotificationItemDoc"}},
"total": {"type": "integer", "format": "int64"},
"page": {"type": "integer", "format": "int32"},
"size": {"type": "integer", "format": "int32"},
"totalPages": {"type": "integer", "format": "int32"}
}
},
"NotificationItemDoc": {
"type": "object",
"description": "通知展示与业务关联;未知类型/空关联不推断目标,详情仍受各业务接口权限校验",
"required": ["businessType", "businessId"],
"properties": {
"id": {"type": "integer", "format": "int64"},
"type": {"type": "string"},
"title": {"type": "string"},
"content": {"type": ["string", "null"]},
"read": {"type": "boolean"},
"readAt": {"type": ["string", "null"], "format": "date-time"},
"createdAt": {"type": "string", "format": "date-time"},
"businessType": {"type": ["string", "null"], "description": "持久化业务类型,非封闭枚举;历史或系统通知可为 null。已核查 APPLICATION/INTERVIEW/OFFER/MATCH/VERIFICATION/REPORT"},
"businessId": {"type": ["integer", "null"], "format": "int64", "description": "持久化关联业务记录ID,不是通知ID;APPLICATION 和 MATCH 均为投递ID,INTERVIEW 为面试ID,OFFER 为Offer ID;无关联时 null"}
}
},
"ProfileImageView": {
"type": "object",
"properties": {

41
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 216, f"Expected 216 paths, got {len(d['paths'])}"
assert len(d['paths']) == 221, f"Expected 221 paths, got {len(d['paths'])}"
paths = d['paths']
@ -743,7 +743,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 266, f"Expected 266 operations, got {operations}"
assert operations == 271, f"Expected 271 operations, got {operations}"
# 个人头像/企业 Logo 独立写入;文件上传不接受客户端 userId 或 URL。
for _route, _upload, _delete in (
@ -1057,5 +1057,42 @@ _attachment = resolve_local_ref(d, _message['properties']['attachment']['$ref'])
assert set(_attachment['properties']) == {'fileName','contentType','size','sha256','width','height','available','downloadPath','previewPath','thumbnailPath'}
assert _attachment['properties']['available']['type'] == 'boolean'
print(' Chat image/file multipart, authenticated binary reads and typed metadata verified')
_notification_content = paths['/notifications']['get']['responses']['200']['content']
_notification_envelope = resolve_local_ref(d, next(iter(_notification_content.values()))['schema']['$ref'])
_notification_page = resolve_local_ref(d, _notification_envelope['properties']['data']['$ref'])
assert set(_notification_page['properties']) == {'items', 'total', 'page', 'size', 'totalPages'}
_notification_item = resolve_local_ref(d, _notification_page['properties']['items']['items']['$ref'])
assert set(_notification_item['properties']) == {'id', 'type', 'title', 'content', 'read', 'readAt', 'createdAt', 'businessType', 'businessId'}
assert {'businessType', 'businessId'} <= set(_notification_item['required'])
for _field, _type in [('businessType', 'string'), ('businessId', 'integer')]:
assert set(_notification_item['properties'][_field]['type']) == {_type, 'null'}
assert _notification_item['properties']['businessId']['format'] == 'int64'
assert 'enum' not in _notification_item['properties']['businessType'], 'Unknown persisted business types must remain compatible'
assert _notification_item['properties']['read']['type'] == 'boolean'
for _path, _method in [('/notifications/{id}/read', 'post'), ('/notifications/read-all', 'post'), ('/notifications/unread-count', 'get')]:
assert _method in paths[_path]
print(' Notification association fields: typed, nullable, no internal fields; existing read operations preserved')
_trash_methods = {'/notifications/{id}': ('delete', 'deleteNotification'),
'/notifications/{id}/restore': ('post', 'restoreNotification'),
'/notifications/trash': ('get', 'listNotificationTrash'),
'/notifications/batch-delete': ('post', 'batchDeleteNotifications'),
'/notifications/batch-restore': ('post', 'batchRestoreNotifications')}
for _path, (_method, _operation) in _trash_methods.items():
_op = paths[_path][_method]
assert _op['operationId'] == _operation
assert _op['security'] == [{'bearerAuth': []}]
assert '200' in _op['responses']
assert not any('/notifications/' in p and ('purge' in p or 'clear' in p) for p in paths)
_batch = d['components']['schemas']['NotificationBatchRequest']
assert _batch['required'] == ['ids']
assert _batch['properties']['ids']['minItems'] == 1 and _batch['properties']['ids']['maxItems'] == 100
assert _batch['properties']['ids']['items']['type'] == 'integer'
assert _batch['properties']['ids']['items']['format'] == 'int64'
assert _batch['properties']['ids']['items']['minimum'] == 1
_trash_item = d['components']['schemas']['NotificationTrashItemDoc']
assert set(_trash_item['properties']) == set(_notification_item['properties']) | {'deletedAt'}
assert _trash_item['properties']['deletedAt']['format'] == 'date-time'
assert 'deletedAt' in _trash_item['required']
print(' Notification recycle bin: five authenticated operations, bounded batch IDs, deletion time; no permanent deletion')
print("Valid JSON: True")
print("All checks passed!")

4
mydreamjob-server/src/main/java/com/mydreamjob/notification/Notification.java

@ -50,6 +50,10 @@ public class Notification {
@Column(name = "read_at")
private LocalDateTime readAt;
/** null 为正常收件箱;非 null 为当前用户回收站,保留原已读状态。 */
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

31
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchRequest.java

@ -0,0 +1,31 @@
package com.mydreamjob.notification;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import java.io.IOException;
import java.util.List;
public record NotificationBatchRequest(
@NotEmpty @Size(min = 1, max = 100)
@io.swagger.v3.oas.annotations.media.ArraySchema(minItems = 1, maxItems = 100,
arraySchema = @Schema(requiredMode = Schema.RequiredMode.REQUIRED,
description = "1~100个通知ID;仅正整数,不接受小数、字符串、null;重复项去重"),
schema = @Schema(implementation = Long.class, type = "integer", format = "int64", minimum = "1"))
@JsonDeserialize(contentUsing = StrictIdDeserializer.class)
List<@NotNull @Positive Long> ids) {
public static class StrictIdDeserializer extends JsonDeserializer<Long> {
@Override public Long deserialize(JsonParser parser, DeserializationContext context) throws IOException {
if (!parser.hasToken(JsonToken.VALUE_NUMBER_INT))
throw JsonMappingException.from(parser, "notification id must be an integer");
long id = parser.getLongValue();
if (id <= 0) throw JsonMappingException.from(parser, "notification id must be positive");
return id;
}
}
}

6
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchResult.java

@ -0,0 +1,6 @@
package com.mydreamjob.notification;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "整批原子操作;requested 为去重后的ID数,changed 为本次状态实际改变的条数")
public record NotificationBatchResult(int requested, int changed) {}

6
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationChangeResult.java

@ -0,0 +1,6 @@
package com.mydreamjob.notification;
import io.swagger.v3.oas.annotations.media.Schema;
public record NotificationChangeResult(
@Schema(description = "本次是否实际改变状态;重复删除/恢复为 false") boolean changed) {}

61
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java

@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
@ -27,6 +28,8 @@ public class NotificationController {
private final NotificationService notificationService;
@Operation(summary = "通知列表", description = "当前用户的通知,分页,按时间倒序")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "当前用户通知;关联字段允许 null,不授予关联业务访问权限",
content = @io.swagger.v3.oas.annotations.media.Content(schema = @io.swagger.v3.oas.annotations.media.Schema(implementation = NotificationListResponseDoc.class)))
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> list(
@RequestParam(defaultValue = "0") int page,
@ -34,8 +37,14 @@ public class NotificationController {
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
Page<Notification> result = notificationService.list(userId, page, size);
return pageResponse(result, false, request);
}
private ResponseEntity<ApiResponse<Map<String, Object>>> pageResponse(Page<Notification> result, boolean trash,
HttpServletRequest request) {
Map<String, Object> data = new HashMap<>();
data.put("items", result.getContent().stream().map(notificationService::toView).collect(Collectors.toList()));
data.put("items", result.getContent().stream()
.map(n -> trash ? notificationService.toTrashView(n) : notificationService.toView(n)).collect(Collectors.toList()));
data.put("total", result.getTotalElements());
data.put("page", result.getNumber());
data.put("size", result.getSize());
@ -43,6 +52,56 @@ public class NotificationController {
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(operationId = "listNotificationTrash", summary = "通知回收站",
description = "仅当前用户软删除通知,按deletedAt/id倒序分页;不计入未读数;无永久删除或自动清空。")
@io.swagger.v3.oas.annotations.security.SecurityRequirement(name = "bearerAuth")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "回收站分页",
content = @io.swagger.v3.oas.annotations.media.Content(schema = @io.swagger.v3.oas.annotations.media.Schema(implementation = NotificationTrashResponseDoc.class)))
@GetMapping("/trash")
public ResponseEntity<ApiResponse<Map<String, Object>>> trash(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
return pageResponse(notificationService.trash(currentUserAccessor.requireUserId(), page, size), true, request);
}
@Operation(operationId = "deleteNotification", summary = "通知移入回收站",
description = "仅本人通知软删除,幂等;不删除面试/投递/Offer,不改变已读状态;不存在400/9105、他人通知403/2002。")
@io.swagger.v3.oas.annotations.security.SecurityRequirement(name = "bearerAuth")
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<NotificationChangeResult>> delete(@PathVariable Long id, HttpServletRequest request) {
return ResponseEntity.ok(ApiResponse.ok(new NotificationChangeResult(
notificationService.delete(currentUserAccessor.requireUserId(), id)), requestIdAccessor.get(request)));
}
@Operation(operationId = "restoreNotification", summary = "恢复通知",
description = "仅本人通知,幂等;保留删除前read/readAt,原未读恢复后重新计数;不存在400/9105、他人通知403/2002。")
@io.swagger.v3.oas.annotations.security.SecurityRequirement(name = "bearerAuth")
@PostMapping("/{id}/restore")
public ResponseEntity<ApiResponse<NotificationChangeResult>> restore(@PathVariable Long id, HttpServletRequest request) {
return ResponseEntity.ok(ApiResponse.ok(new NotificationChangeResult(
notificationService.restore(currentUserAccessor.requireUserId(), id)), requestIdAccessor.get(request)));
}
@Operation(operationId = "batchDeleteNotifications", summary = "批量通知移入回收站",
description = "最多100个正整数ID,重复项去重;整批原子操作,任意不存在400/9105或越权403/2002均无部分成功;仅当前用户软删除,无永久删除。")
@io.swagger.v3.oas.annotations.security.SecurityRequirement(name = "bearerAuth")
@PostMapping("/batch-delete")
public ResponseEntity<ApiResponse<NotificationBatchResult>> batchDelete(@Valid @RequestBody NotificationBatchRequest body,
HttpServletRequest request) {
return ResponseEntity.ok(ApiResponse.ok(notificationService.batchDelete(currentUserAccessor.requireUserId(), body.ids()),
requestIdAccessor.get(request)));
}
@Operation(operationId = "batchRestoreNotifications", summary = "批量恢复通知",
description = "最多100个正整数ID,重复项去重;整批原子操作,任意不存在400/9105或越权403/2002均无部分成功;保留原已读状态。")
@io.swagger.v3.oas.annotations.security.SecurityRequirement(name = "bearerAuth")
@PostMapping("/batch-restore")
public ResponseEntity<ApiResponse<NotificationBatchResult>> batchRestore(@Valid @RequestBody NotificationBatchRequest body,
HttpServletRequest request) {
return ResponseEntity.ok(ApiResponse.ok(notificationService.batchRestore(currentUserAccessor.requireUserId(), body.ids()),
requestIdAccessor.get(request)));
}
@Operation(summary = "标记已读", description = "标记单条通知已读(幂等)")
@PostMapping("/{id}/read")
public ResponseEntity<ApiResponse<Map<String, Object>>> markRead(

48
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationListResponseDoc.java

@ -0,0 +1,48 @@
package com.mydreamjob.notification;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
/** 仅描述现有 Map 响应,不改变序列化、业务返回类型或全局配置。 */
@Data
@Schema(name = "NotificationListResponseDoc")
public class NotificationListResponseDoc {
private int code;
private String message;
private NotificationPageDoc data;
private String requestId;
private Instant timestamp;
@Data
@Schema(name = "NotificationPageDoc")
public static class NotificationPageDoc {
private List<NotificationItemDoc> items;
private long total;
private int page;
private int size;
private int totalPages;
}
@Data
@Schema(name = "NotificationItemDoc", description = "通知展示与业务关联;未知类型/空关联不推断目标,详情仍受各业务接口权限校验")
public static class NotificationItemDoc {
private Long id;
private String type;
private String title;
@Schema(types = {"string", "null"})
private String content;
private Boolean read;
@Schema(types = {"string", "null"}, format = "date-time")
private LocalDateTime readAt;
private LocalDateTime createdAt;
@Schema(types = {"string", "null"}, requiredMode = Schema.RequiredMode.REQUIRED,
description = "持久化业务类型,非封闭枚举;历史或系统通知可为 null。已核查 APPLICATION/INTERVIEW/OFFER/MATCH/VERIFICATION/REPORT")
private String businessType;
@Schema(types = {"integer", "null"}, format = "int64", requiredMode = Schema.RequiredMode.REQUIRED,
description = "持久化关联业务记录ID,不是通知ID;APPLICATION 和 MATCH 均为投递ID,INTERVIEW 为面试ID,OFFER 为Offer ID;无关联时 null")
private Long businessId;
}
}

23
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java

@ -6,17 +6,36 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import jakarta.persistence.LockModeType;
import java.time.LocalDateTime;
import java.util.Optional;
@Repository
public interface NotificationRepository extends JpaRepository<Notification, Long> {
Page<Notification> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
@Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false")
Page<Notification> findByUserIdAndDeletedAtIsNullOrderByCreatedAtDescIdDesc(Long userId, Pageable pageable);
Page<Notification> findByUserIdAndDeletedAtIsNotNullOrderByDeletedAtDescIdDesc(Long userId, Pageable pageable);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT n FROM Notification n WHERE n.id = :id")
Optional<Notification> findLockedById(@Param("id") Long id);
/** 条件更新只写已读字段,回收站不受影响,不用 offset 遍历变化中的结果集。 */
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("UPDATE Notification n SET n.read = true, n.readAt = :now"
+ " WHERE n.userId = :userId AND n.deletedAt IS NULL AND n.read = false")
int markAllActiveRead(@Param("userId") Long userId, @Param("now") LocalDateTime now);
@Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false AND n.deletedAt IS NULL")
long countUnreadByUserId(@Param("userId") Long userId);
/** V34 聚合:按用户 + 通知类型统计未读数(用于 REPORT_DECISION 未读计数)。 */
@Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false AND n.type = :type")
@Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false AND n.deletedAt IS NULL AND n.type = :type")
long countUnreadByUserIdAndType(@Param("userId") Long userId, @Param("type") String type);
boolean existsByIdempotencyKey(String idempotencyKey);

116
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java

@ -16,7 +16,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.TreeSet;
@Slf4j
@Service
@ -30,6 +33,7 @@ public class NotificationService {
private final CompanyRepository companyRepository;
private static final int MAX_PAGE_SIZE = 50;
public static final int MAX_BATCH_SIZE = 100;
/** 创建通知并触发推送 */
@Transactional
@ -81,17 +85,30 @@ public class NotificationService {
/** 分页查询用户通知 */
public Page<Notification> list(Long userId, int page, int size) {
requireVerifiedIfCompany(userId);
validatePage(page, size);
return notificationRepository.findByUserIdAndDeletedAtIsNullOrderByCreatedAtDescIdDesc(userId,
PageRequest.of(page, size));
}
public Page<Notification> trash(Long userId, int page, int size) {
requireVerifiedIfCompany(userId);
validatePage(page, size);
return notificationRepository.findByUserIdAndDeletedAtIsNotNullOrderByDeletedAtDescIdDesc(userId,
PageRequest.of(page, size));
}
private void validatePage(int page, int size) {
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size));
}
/** 标记单条已读(幂等:已读也返回成功) */
@Transactional
public void markRead(Long userId, Long notificationId) {
requireVerifiedIfCompany(userId);
Notification n = notificationRepository.findById(notificationId)
Notification n = notificationRepository.findLockedById(notificationId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND));
if (!n.getUserId().equals(userId)) throw new BusinessException(ErrorCode.FORBIDDEN);
if (n.getDeletedAt() != null) throw new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND);
if (!Boolean.TRUE.equals(n.getRead())) {
n.setRead(true);
n.setReadAt(LocalDateTime.now());
@ -103,27 +120,53 @@ public class NotificationService {
@Transactional
public int markAllRead(Long userId) {
requireVerifiedIfCompany(userId);
int count = 0;
int pageIdx = 0;
boolean hasMore = true;
while (hasMore) {
Page<Notification> unread = notificationRepository
.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(pageIdx, 100));
if (unread.isEmpty()) { hasMore = false; break; }
boolean anyUpdated = false;
for (Notification n : unread.getContent()) {
if (!Boolean.TRUE.equals(n.getRead())) {
n.setRead(true);
n.setReadAt(LocalDateTime.now());
notificationRepository.save(n);
count++;
anyUpdated = true;
}
return notificationRepository.markAllActiveRead(userId, LocalDateTime.now());
}
@Transactional
public boolean delete(Long userId, Long id) {
return changeVisibility(userId, Collections.singletonList(id), false).changed() == 1;
}
@Transactional
public boolean restore(Long userId, Long id) {
return changeVisibility(userId, Collections.singletonList(id), true).changed() == 1;
}
@Transactional
public NotificationBatchResult batchDelete(Long userId, List<Long> ids) {
return changeVisibility(userId, ids, false);
}
@Transactional
public NotificationBatchResult batchRestore(Long userId, List<Long> ids) {
return changeVisibility(userId, ids, true);
}
private NotificationBatchResult changeVisibility(Long userId, List<Long> ids, boolean restore) {
requireVerifiedIfCompany(userId);
if (ids == null || ids.isEmpty() || ids.size() > MAX_BATCH_SIZE
|| ids.stream().anyMatch(id -> id == null || id <= 0))
throw new BusinessException(ErrorCode.BAD_REQUEST);
// 去重并统一锁顺序;整批存在性/归属全部确认后才修改,错误不产生部分成功。
var ordered = new TreeSet<>(ids);
List<Notification> locked = new ArrayList<>(ordered.size());
for (Long id : ordered) {
Notification n = notificationRepository.findLockedById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND));
if (!n.getUserId().equals(userId)) throw new BusinessException(ErrorCode.FORBIDDEN);
locked.add(n);
}
int changed = 0;
var now = LocalDateTime.now();
for (Notification n : locked) {
if (restore ? n.getDeletedAt() != null : n.getDeletedAt() == null) {
n.setDeletedAt(restore ? null : now);
notificationRepository.save(n);
changed++;
}
hasMore = !unread.isLast();
pageIdx++;
}
return count;
return new NotificationBatchResult(ordered.size(), changed);
}
/** 未读数 */
@ -139,7 +182,7 @@ public class NotificationService {
}
public Map<String, Object> toView(Notification n) {
Map<String, Object> v = new HashMap<>();
Map<String, Object> v = new NotificationView();
v.put("id", n.getId());
v.put("type", n.getType());
v.put("title", n.getTitle());
@ -147,6 +190,33 @@ public class NotificationService {
v.put("read", n.getRead());
v.put("readAt", n.getReadAt());
v.put("createdAt", n.getCreatedAt());
// 历史空关联原样返回;业务 ID 不能从通知文案推断。
v.put("businessType", n.getBusinessType());
v.put("businessId", n.getBusinessId());
return v;
}
public Map<String, Object> toTrashView(Notification n) {
var view = toView(n);
view.put("deletedAt", n.getDeletedAt());
return view;
}
/** 只在通知视图保留 null;不改变全局 non_null 策略。 */
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = NotificationViewSerializer.class)
private static class NotificationView extends HashMap<String, Object> {}
/** Map 的全局 content-inclusion 会省略 null,故在本地视图明确写出字段。 */
private static class NotificationViewSerializer extends com.fasterxml.jackson.databind.JsonSerializer<NotificationView> {
@Override
public void serialize(NotificationView value, com.fasterxml.jackson.core.JsonGenerator generator,
com.fasterxml.jackson.databind.SerializerProvider serializers) throws java.io.IOException {
generator.writeStartObject();
for (Map.Entry<String, Object> entry : value.entrySet()) {
generator.writeFieldName(entry.getKey());
serializers.defaultSerializeValue(entry.getValue(), generator);
}
generator.writeEndObject();
}
}
}

34
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationTrashResponseDoc.java

@ -0,0 +1,34 @@
package com.mydreamjob.notification;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
/** 回收站文档视图;正常通知列表仍保持第一批九个条目字段。 */
@Data
public class NotificationTrashResponseDoc {
private int code;
private String message;
private NotificationTrashPageDoc data;
private String requestId;
private Instant timestamp;
@Data
public static class NotificationTrashPageDoc {
private List<NotificationTrashItemDoc> items;
private long total;
private int page;
private int size;
private int totalPages;
}
@Data @EqualsAndHashCode(callSuper = true)
@Schema(description = "回收站条目保留原通知信息和已读状态;关联业务不随通知删除")
public static class NotificationTrashItemDoc extends NotificationListResponseDoc.NotificationItemDoc {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "本次移入回收站的时间;重复删除不刷新")
private LocalDateTime deletedAt;
}
}

2
mydreamjob-server/src/main/resources/db/migration/V64__notification_recycle_bin.sql

@ -0,0 +1,2 @@
ALTER TABLE notifications ADD COLUMN deleted_at DATETIME NULL;
CREATE INDEX idx_notifications_user_deleted_id ON notifications(user_id, deleted_at, id);

12
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java

@ -79,6 +79,8 @@ class ApplicationServiceTest {
Application result = applicationService.withdraw(STUDENT_ID, APP_ID);
assertEquals("WITHDRAWN", result.getStatus());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("APPLICATION_WITHDRAWN"), any(), any(),
eq("APPLICATION"), eq(APP_ID), any());
}
@Test void withdraw_wrongStudent_shouldReturn403() {
@ -160,6 +162,8 @@ class ApplicationServiceTest {
applicationService.updateStatus(COMPANY_ID, APP_ID, req);
verify(historyRepository, times(1)).save(any());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("APPLICATION_STATUS"), any(), any(),
eq("APPLICATION"), eq(APP_ID), any());
}
// ── 原有测试兼容 ─────────────────────────────────────────
@ -168,11 +172,17 @@ class ApplicationServiceTest {
mockStudentRole();
ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID);
when(jobService.getJobForApplication(JOB_ID)).thenReturn(publishedJob);
when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(applicationRepository.save(any())).thenAnswer(inv -> {
Application saved = inv.getArgument(0);
saved.setId(APP_ID);
return saved;
});
when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Application result = applicationService.apply(STUDENT_ID, req);
assertEquals("SUBMITTED", result.getStatus());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("APPLICATION_APPLIED"), any(), any(),
eq("APPLICATION"), eq(APP_ID), any());
}
// ── 企业候选人列表 ─────────────────────────────────────────

4
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -503,8 +503,8 @@ class CompanyResumeDownloadIntegrationTest {
}
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("203 paths 包含独立头像/Logo").isEqualTo(203);
assertThat(opCount - moduleOperations).as("248 operations 包含独立头像/Logo").isEqualTo(248);
assertThat(pathCount - modulePaths).as("非模块路径包含头像/Logo及通知回收站").isEqualTo(208);
assertThat(opCount - moduleOperations).as("非模块操作包含头像/Logo及通知回收站").isEqualTo(253);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java

@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
}
}
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁与头像/Logo写入操作均受保护").isEqualTo(221);
assertThat(denied - moduleDenied).as("既有身份门禁、头像/Logo和通知回收站操作均受保护").isEqualTo(226);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
}

33
mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java

@ -149,7 +149,11 @@ class InterviewServiceTest {
.thenReturn(Optional.of(Application.builder().id(APPLICATION_ID).jobId(JOB_ID)
.studentUserId(STUDENT_ID).status("SUBMITTED").build()));
when(jobService.getJob(JOB_ID)).thenReturn(Job.builder().id(JOB_ID).companyId(COMPANY_ID).build());
when(interviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(interviewRepository.save(any())).thenAnswer(inv -> {
Interview saved = inv.getArgument(0);
saved.setId(INTERVIEW_ID);
return saved;
});
when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Interview interview = interviewService.createInterview(COMPANY_USER_ID, APPLICATION_ID, buildCreateReq(), ctx);
@ -158,7 +162,7 @@ class InterviewServiceTest {
assertEquals(COMPANY_USER_ID, interview.getCompanyUserId());
assertEquals(STUDENT_ID, interview.getStudentUserId());
verify(historyRepository).save(any());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_INVITED"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_INVITED"), any(), any(), eq("INTERVIEW"), eq(INTERVIEW_ID), any());
}
@Test
@ -320,7 +324,7 @@ class InterviewServiceTest {
assertEquals("CONFIRMED", interview.getStatus());
verify(historyRepository).save(any());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_CONFIRMED"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_CONFIRMED"), any(), any(), eq("INTERVIEW"), eq(INTERVIEW_ID), any());
}
@Test
@ -334,7 +338,7 @@ class InterviewServiceTest {
Interview interview = interviewService.respondToInterview(STUDENT_ID, INTERVIEW_ID, respondReq("DECLINE"), ctx);
assertEquals("DECLINED", interview.getStatus());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_DECLINED"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_DECLINED"), any(), any(), eq("INTERVIEW"), eq(INTERVIEW_ID), any());
}
@Test
@ -349,7 +353,7 @@ class InterviewServiceTest {
respondReq("REQUEST_RESCHEDULE"), ctx);
assertEquals("RESCHEDULE_REQUESTED", interview.getStatus());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_RESCHEDULE_REQUESTED"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("INTERVIEW_RESCHEDULE_REQUESTED"), any(), any(), eq("INTERVIEW"), eq(INTERVIEW_ID), any());
}
@Test
@ -393,7 +397,7 @@ class InterviewServiceTest {
Interview interview = interviewService.cancelInterview(COMPANY_USER_ID, INTERVIEW_ID, req, ctx);
assertEquals("CANCELLED", interview.getStatus());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_CANCELLED"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_CANCELLED"), any(), any(), eq("INTERVIEW"), eq(INTERVIEW_ID), any());
}
@Test
@ -449,7 +453,7 @@ class InterviewServiceTest {
Interview result = interviewService.completeInterview(COMPANY_USER_ID, INTERVIEW_ID, null, ctx);
assertEquals("COMPLETED", result.getStatus());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_COMPLETED"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_COMPLETED"), any(), any(), eq("INTERVIEW"), eq(INTERVIEW_ID), any());
}
@Test
@ -514,6 +518,21 @@ class InterviewServiceTest {
assertEquals(ErrorCode.INVALID_INTERVIEW_STATUS_TRANSITION.getCode(), ex.getErrorCodeInt());
}
@Test
void update_time_shouldAssociateNotificationWithInterviewNotApplication() {
mockCompanyUser(COMPANY_USER_ID);
mockCompany(COMPANY_USER_ID, COMPANY_ID);
when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED")));
when(interviewRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
InterviewUpdateRequest req = new InterviewUpdateRequest();
req.setStartAt(LocalDateTime.now().plusDays(2));
req.setEndAt(req.getStartAt().plusHours(1));
interviewService.updateInterview(COMPANY_USER_ID, INTERVIEW_ID, req, ctx);
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("INTERVIEW_RESCHEDULED"), any(), any(),
eq("INTERVIEW"), eq(INTERVIEW_ID), any());
}
// ── 时间线 ────────────────────────────────────────────────
@Test

183
mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationAssociationIntegrationTest.java

@ -0,0 +1,183 @@
package com.mydreamjob.notification;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.*;
import com.mydreamjob.audit.AuditContext;
import org.junit.jupiter.api.*;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Clock;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** 完整过滤链 + H2 持久化;不冒充真实 MySQL 或 JAR 黑盒。 */
@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:notification_associations;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class NotificationAssociationIntegrationTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper json;
@Autowired UserRepository users;
@Autowired AuthSessionService sessions;
@Autowired Clock clock;
@Autowired NotificationRepository notifications;
@Autowired NotificationService service;
@MockBean RedisTemplate<String, Object> redis;
static final AtomicLong SEQ = new AtomicLong(78000000);
User a, b;
String at, bt;
User user() {
return users.saveAndFlush(User.builder().phone("166" + SEQ.incrementAndGet()).nickname("合成通知测试")
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build());
}
@BeforeEach void setup() {
a = user(); b = user();
at = sessions.createSession(a, clock.instant(), new AuditContext()).accessToken();
bt = sessions.createSession(b, clock.instant(), new AuditContext()).accessToken();
}
Notification notification(User owner, String kind, Long businessId) {
return notifications.saveAndFlush(Notification.builder().userId(owner.getId()).type("TEST_NOTICE")
.title("通知").content("文案 #999 不用于推断关联").businessType(kind).businessId(businessId).build());
}
JsonNode getData(String path, String token) throws Exception {
return json.readTree(mvc.perform(get(path).header("Authorization", "Bearer " + token))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()).path("data");
}
void read(long id, String token) throws Exception {
mvc.perform(post("/notifications/" + id + "/read").header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
long count(String token) throws Exception { return getData("/notifications/unread-count", token).path("unreadCount").asLong(); }
@Test void listSerializesKnownUnknownAndLegacyAssociationsAndNoInternalFields() throws Exception {
var unrelated = new HashMap<String, Object>(); unrelated.put("omitted", null);
assertFalse(json.readTree(json.writeValueAsBytes(unrelated)).has("omitted"),
"global non_null policy must not change");
for (String kind : List.of("INTERVIEW", "APPLICATION", "OFFER", "MATCH", "FUTURE_TYPE")) notification(a, kind, 123L);
var legacy = notification(a, null, null);
legacy.setContent(null); notifications.saveAndFlush(legacy);
notification(b, "INTERVIEW", 456L);
var data = getData("/notifications", at);
assertEquals(6, data.path("total").asInt());
var kinds = new HashSet<String>();
for (JsonNode item : data.path("items")) {
var keys = new HashSet<String>(); item.fieldNames().forEachRemaining(keys::add);
assertEquals(Set.of("id", "type", "title", "content", "read", "readAt", "createdAt", "businessType", "businessId"), keys);
if (item.path("id").asLong() == legacy.getId()) {
assertTrue(item.has("businessType") && item.get("businessType").isNull());
assertTrue(item.has("businessId") && item.get("businessId").isNull());
assertTrue(item.has("content") && item.get("content").isNull());
} else {
kinds.add(item.path("businessType").asText());
assertTrue(item.path("businessId").isIntegralNumber());
assertEquals(123L, item.path("businessId").asLong());
}
}
assertEquals(Set.of("INTERVIEW", "APPLICATION", "OFFER", "MATCH", "FUTURE_TYPE"), kinds);
assertEquals(1, getData("/notifications", bt).path("total").asInt());
}
@Test void readingLegacyNotificationIsDurableAndIdempotent() throws Exception {
var n = notification(a, null, null);
assertEquals(1, count(at));
read(n.getId(), at);
var first = notifications.findById(n.getId()).orElseThrow();
assertTrue(first.getRead()); assertNotNull(first.getReadAt());
read(n.getId(), at);
assertEquals(first.getReadAt(), notifications.findById(n.getId()).orElseThrow().getReadAt());
assertEquals(0, count(at));
var item = getData("/notifications", at).path("items").get(0);
assertTrue(item.path("read").asBoolean()); assertFalse(item.path("readAt").isNull());
}
@Test void markAllAcrossMultiplePagesOnlyAffectsOwnerAndIsIdempotent() throws Exception {
var sameTime = java.time.LocalDateTime.of(2026, 10, 1, 12, 0);
for (int i = 0; i < 105; i++) {
var n = Notification.builder().userId(a.getId()).type("TEST_NOTICE").title("同刻通知")
.businessType("APPLICATION").businessId((long) i + 1).createdAt(sameTime).build();
notifications.saveAndFlush(n);
}
var other = notification(b, "OFFER", 999L);
read(getData("/notifications", at).path("items").get(0).path("id").asLong(), at);
mvc.perform(post("/notifications/read-all").header("Authorization", "Bearer " + at))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.marked").value(104));
assertEquals(0, count(at)); assertEquals(1, count(bt));
assertFalse(notifications.findById(other.getId()).orElseThrow().getRead());
mvc.perform(post("/notifications/read-all").header("Authorization", "Bearer " + at))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.marked").value(0));
}
@Test void crossAccountAndMissingReadHaveNoSideEffects() throws Exception {
var other = notification(b, "INTERVIEW", 456L);
mvc.perform(post("/notifications/" + other.getId() + "/read").header("Authorization", "Bearer " + at))
.andExpect(status().isForbidden());
mvc.perform(post("/notifications/9223372036854775807/read").header("Authorization", "Bearer " + at))
.andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(9105));
assertFalse(notifications.findById(other.getId()).orElseThrow().getRead());
assertEquals(1, count(bt));
}
@Test void anonymousAndDisabledOriginalTokenCannotAccessAnyNotificationOperation() throws Exception {
var n = notification(a, "APPLICATION", 123L);
a.setActive(false); users.saveAndFlush(a);
for (String token : List.of("", at)) {
String header = token.isEmpty() ? "" : "Bearer " + token;
for (String path : List.of("/notifications", "/notifications/unread-count"))
mvc.perform(get(path).header("Authorization", header)).andExpect(status().isUnauthorized());
for (String path : List.of("/notifications/" + n.getId() + "/read", "/notifications/read-all"))
mvc.perform(post(path).header("Authorization", header)).andExpect(status().isUnauthorized());
}
assertFalse(notifications.findById(n.getId()).orElseThrow().getRead());
}
@Test void paginationAndValidationStayCompatible() throws Exception {
for (int i = 0; i < 3; i++) notification(a, "INTERVIEW", (long) i + 1);
var data = getData("/notifications?page=1&size=2", at);
assertEquals(3, data.path("total").asInt()); assertEquals(1, data.path("page").asInt());
assertEquals(2, data.path("size").asInt()); assertEquals(2, data.path("totalPages").asInt());
assertEquals(1, data.path("items").size());
for (String query : List.of("page=-1", "size=0", "size=51"))
mvc.perform(get("/notifications?" + query).header("Authorization", "Bearer " + at))
.andExpect(status().isBadRequest());
}
JsonNode resolve(JsonNode doc, JsonNode schema) {
return schema.has("$ref") ? doc.at(schema.path("$ref").asText().substring(1)) : schema;
}
JsonNode itemSchema(JsonNode doc) {
var content = doc.path("paths").path("/notifications").path("get").path("responses").path("200").path("content");
var envelope = resolve(doc, content.elements().next().path("schema"));
var data = resolve(doc, envelope.path("properties").path("data"));
return resolve(doc, data.path("properties").path("items").path("items"));
}
@Test void runtimeAndStaticNotificationContractAgree() throws Exception {
JsonNode runtime = json.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk())
.andReturn().getResponse().getContentAsByteArray());
JsonNode contract = json.readTree(java.nio.file.Path.of("../docs/openapi-rc.json").toFile());
java.nio.file.Files.write(java.nio.file.Path.of("target/notification-runtime-openapi.json"), json.writeValueAsBytes(runtime));
var actual = itemSchema(runtime); var expected = itemSchema(contract);
var actualNames = new HashSet<String>(); actual.path("properties").fieldNames().forEachRemaining(actualNames::add);
var expectedNames = new HashSet<String>(); expected.path("properties").fieldNames().forEachRemaining(expectedNames::add);
assertEquals(expectedNames, actualNames); assertEquals(9, actualNames.size());
for (String name : actualNames) {
assertEquals(expected.path("properties").path(name).path("type"), actual.path("properties").path(name).path("type"), name);
assertEquals(expected.path("properties").path(name).path("format"), actual.path("properties").path(name).path("format"), name);
}
for (String name : List.of("businessType", "businessId")) {
assertTrue(actual.path("properties").path(name).path("type").toString().contains("null"));
assertTrue(expected.path("properties").path(name).path("type").toString().contains("null"));
assertTrue(actual.path("required").toString().contains('"' + name + '"'));
}
assertEquals(contract.path("paths").size(), runtime.path("paths").size());
}
}

34
mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java

@ -43,7 +43,12 @@ class NotificationConsumerTest {
when(pushProvider.send(any(), any(), any())).thenReturn(true);
consumer.handle(event);
verify(notificationRepository, times(1)).save(any());
var saved = org.mockito.ArgumentCaptor.forClass(Notification.class);
verify(notificationRepository, times(1)).save(saved.capture());
assertEquals(event.getUserId(), saved.getValue().getUserId());
assertEquals(event.getBusinessType(), saved.getValue().getBusinessType());
assertEquals(event.getBusinessId(), saved.getValue().getBusinessId());
assertFalse(saved.getValue().getRead());
}
@Test void handle_duplicateEvent_shouldSkip() {
@ -53,6 +58,33 @@ class NotificationConsumerTest {
verify(notificationRepository, never()).save(any());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"INTERVIEW", "APPLICATION", "OFFER", "MATCH", "FUTURE_TYPE"})
void handle_associationSurvivesEventSerializationAndPersistence(String kind) throws Exception {
event.setBusinessType(kind);
event.setBusinessId(987L);
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var roundTrip = mapper.readValue(mapper.writeValueAsBytes(event), NotificationEvent.class);
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
consumer.handle(roundTrip);
var saved = org.mockito.ArgumentCaptor.forClass(Notification.class);
verify(notificationRepository).save(saved.capture());
var service = new NotificationService(notificationRepository, pushProvider, null, null, null);
assertEquals(kind, service.toView(saved.getValue()).get("businessType"));
assertEquals(987L, service.toView(saved.getValue()).get("businessId"));
}
@Test void handle_legacyNullAssociationDoesNotParseContent() {
event.setBusinessType(null); event.setBusinessId(null);
event.setContent("面试 #999");
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
consumer.handle(event);
var saved = org.mockito.ArgumentCaptor.forClass(Notification.class);
verify(notificationRepository).save(saved.capture());
assertNull(saved.getValue().getBusinessType());
assertNull(saved.getValue().getBusinessId());
}
@Test void handle_saveThrowsDataIntegrity_shouldNotCrash() {
when(notificationRepository.existsByIdempotencyKey("key-1")).thenReturn(false);
when(notificationRepository.save(any())).thenThrow(new DataIntegrityViolationException("dup"));

58
mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java

@ -27,6 +27,7 @@ class NotificationServiceTest {
@Mock private PushProvider pushProvider;
@Mock private UserRepository userRepository;
@Mock private CompanyRepository companyRepository;
@Mock private NotificationPublisher notificationPublisher;
@InjectMocks private NotificationService notificationService;
private static final Long USER_A = 100L;
@ -51,7 +52,7 @@ class NotificationServiceTest {
}
@Test void list_shouldReturnPaged() {
when(notificationRepository.findByUserIdOrderByCreatedAtDesc(eq(USER_A), any()))
when(notificationRepository.findByUserIdAndDeletedAtIsNullOrderByCreatedAtDescIdDesc(eq(USER_A), any()))
.thenReturn(new PageImpl<>(List.of(notif)));
var r = notificationService.list(USER_A, 0, 20);
@ -65,29 +66,33 @@ class NotificationServiceTest {
}
@Test void markRead_shouldSucceed() {
when(notificationRepository.findById(NOTIF_ID)).thenReturn(Optional.of(notif));
when(notificationRepository.findLockedById(NOTIF_ID)).thenReturn(Optional.of(notif));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.markRead(USER_A, NOTIF_ID);
assertTrue(notif.getRead());
assertNotNull(notif.getReadAt());
}
@Test void markRead_alreadyRead_shouldBeIdempotent() {
notif.setRead(true);
when(notificationRepository.findById(NOTIF_ID)).thenReturn(Optional.of(notif));
var readAt = java.time.LocalDateTime.of(2026, 10, 1, 12, 0);
notif.setReadAt(readAt);
when(notificationRepository.findLockedById(NOTIF_ID)).thenReturn(Optional.of(notif));
notificationService.markRead(USER_A, NOTIF_ID);
verify(notificationRepository, never()).save(any());
assertEquals(readAt, notif.getReadAt());
}
@Test void markRead_otherUser_shouldReturn403() {
when(notificationRepository.findById(NOTIF_ID)).thenReturn(Optional.of(notif));
when(notificationRepository.findLockedById(NOTIF_ID)).thenReturn(Optional.of(notif));
assertThrows(BusinessException.class, () -> notificationService.markRead(USER_B, NOTIF_ID));
}
@Test void markRead_notFound_shouldReturn404() {
when(notificationRepository.findById(999L)).thenReturn(Optional.empty());
when(notificationRepository.findLockedById(999L)).thenReturn(Optional.empty());
BusinessException ex = assertThrows(BusinessException.class,
() -> notificationService.markRead(USER_A, 999L));
@ -101,13 +106,44 @@ class NotificationServiceTest {
}
@Test void markAllRead_shouldMarkUnread() {
notif.setRead(false);
when(notificationRepository.findByUserIdOrderByCreatedAtDesc(eq(USER_A), any()))
.thenReturn(new PageImpl<>(List.of(notif)));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(notificationRepository.markAllActiveRead(eq(USER_A), any())).thenReturn(1);
int marked = notificationService.markAllRead(USER_A);
assertEquals(1, marked);
assertTrue(notif.getRead());
verify(notificationRepository).markAllActiveRead(eq(USER_A), any());
}
@Test void view_preservesAssociationsWithoutLeakingInternalFields() {
for (String type : List.of("INTERVIEW", "APPLICATION", "OFFER", "MATCH", "FUTURE_TYPE")) {
notif.setBusinessType(type);
notif.setBusinessId(123L);
var view = notificationService.toView(notif);
assertEquals(type, view.get("businessType"));
assertEquals(123L, view.get("businessId"));
assertEquals(Set.of("id", "type", "title", "content", "read", "readAt", "createdAt",
"businessType", "businessId"), view.keySet());
}
}
@Test void view_legacyAssociationRemainsExplicitNull() {
notif.setContent("面试 #999 / 岗位 #182");
var view = notificationService.toView(notif);
assertTrue(view.containsKey("businessType"));
assertTrue(view.containsKey("businessId"));
assertNull(view.get("businessType"));
assertNull(view.get("businessId"));
}
@Test void publishAndCreate_preserveAssociation() {
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var n = notificationService.create(USER_A, "INTERVIEW_INVITED", "邀请", "内容",
"INTERVIEW", 987L, "notification-link-create");
assertEquals("INTERVIEW", notificationService.toView(n).get("businessType"));
assertEquals(987L, notificationService.toView(n).get("businessId"));
notificationService.publishEvent(USER_A, "INTERVIEW_INVITED", "邀请", "内容",
"INTERVIEW", 987L, "notification-link-event");
var event = org.mockito.ArgumentCaptor.forClass(NotificationEvent.class);
verify(notificationPublisher).publish(event.capture());
assertEquals("INTERVIEW", event.getValue().getBusinessType());
assertEquals(987L, event.getValue().getBusinessId());
}
}

206
mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashIntegrationTest.java

@ -0,0 +1,206 @@
package com.mydreamjob.notification;
import com.fasterxml.jackson.databind.JsonNode;
import com.mydreamjob.auth.User;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.audit.AuditContext;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** 复用第一批完整过滤链回归;新增回收站行为与并发测试,不使用真实用户数据。 */
class NotificationTrashIntegrationTest extends NotificationAssociationIntegrationTest {
@Autowired CompanyRepository companies;
@Test void runtimeExposesOnlySoftDeleteAndRestoreOperations() throws Exception {
var doc=json.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk())
.andReturn().getResponse().getContentAsByteArray());
java.nio.file.Files.write(java.nio.file.Path.of("target/notification-trash-runtime-openapi.json"),json.writeValueAsBytes(doc));
for(var entry:Map.of("/notifications/{id}","delete","/notifications/{id}/restore","post",
"/notifications/trash","get","/notifications/batch-delete","post","/notifications/batch-restore","post").entrySet()) {
assertTrue(doc.path("paths").path(entry.getKey()).has(entry.getValue()));
assertEquals("bearerAuth",doc.path("paths").path(entry.getKey()).path(entry.getValue())
.path("security").get(0).fieldNames().next());
}
assertFalse(doc.path("paths").has("/notifications/purge"));
assertFalse(doc.path("paths").has("/notifications/trash/clear"));
var ids=doc.path("components").path("schemas").path("NotificationBatchRequest").path("properties").path("ids");
assertEquals(1,ids.path("minItems").asInt()); assertEquals(100,ids.path("maxItems").asInt());
assertEquals("integer",ids.path("items").path("type").asText());
assertEquals("int64",ids.path("items").path("format").asText());
assertEquals(1,ids.path("items").path("minimum").asInt());
}
JsonNode change(MockHttpServletRequestBuilder request, String token) throws Exception {
return json.readTree(mvc.perform(request.header("Authorization", "Bearer " + token))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()).path("data");
}
MockHttpServletRequestBuilder batch(String route, List<Long> ids) throws Exception {
return post("/notifications/" + route).contentType("application/json")
.content(json.writeValueAsBytes(Map.of("ids", ids)));
}
@Test void deletingUnreadHidesItAndRepeatedDeletePreservesDeletedAt() throws Exception {
var n=notification(a,"INTERVIEW",123L);
assertTrue(change(delete("/notifications/"+n.getId()),at).path("changed").asBoolean());
var deleted=notifications.findById(n.getId()).orElseThrow();
assertNotNull(deleted.getDeletedAt()); assertFalse(deleted.getRead()); assertNull(deleted.getReadAt());
assertEquals(0,count(at)); assertEquals(0,getData("/notifications",at).path("total").asInt());
var trash=getData("/notifications/trash",at);
assertEquals(1,trash.path("total").asInt()); assertEquals(10,trash.path("items").get(0).size());
assertEquals(123L,trash.path("items").get(0).path("businessId").asLong());
assertFalse(change(delete("/notifications/"+n.getId()),at).path("changed").asBoolean());
assertEquals(deleted.getDeletedAt(),notifications.findById(n.getId()).orElseThrow().getDeletedAt());
// 软删除不等于物理清除,既有个人数据导出使用的全量归属查询仍可读到本人的行。
assertEquals(1,notifications.findByUserIdOrderByCreatedAtDesc(a.getId(),org.springframework.data.domain.PageRequest.of(0,20)).getTotalElements());
}
@Test void restorePreservesOriginalUnreadStateAndIsIdempotent() throws Exception {
var n=notification(a,null,null);
change(delete("/notifications/"+n.getId()),at);
assertTrue(change(post("/notifications/"+n.getId()+"/restore"),at).path("changed").asBoolean());
assertEquals(1,count(at)); assertEquals(0,getData("/notifications/trash",at).path("total").asInt());
assertNull(notifications.findById(n.getId()).orElseThrow().getDeletedAt());
assertFalse(change(post("/notifications/"+n.getId()+"/restore"),at).path("changed").asBoolean());
}
@Test void recycleBinIsExcludedFromBothReadOperationsAndRestoredReadStateIsUnchanged() throws Exception {
var unread=notification(a,"APPLICATION",123L); var read=notification(a,"OFFER",456L);
read(read.getId(),at); var readAt=notifications.findById(read.getId()).orElseThrow().getReadAt();
change(batch("batch-delete",List.of(unread.getId(),read.getId())),at);
mvc.perform(post("/notifications/"+unread.getId()+"/read").header("Authorization","Bearer "+at))
.andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(9105));
assertEquals(0,change(post("/notifications/read-all"),at).path("marked").asInt());
assertFalse(notifications.findById(unread.getId()).orElseThrow().getRead());
assertEquals(2,change(batch("batch-restore",List.of(unread.getId(),read.getId())),at).path("changed").asInt());
assertEquals(1,count(at)); assertEquals(readAt,notifications.findById(read.getId()).orElseThrow().getReadAt());
}
@Test void batchIsAtomicForForeignOrMissingIdsIncludingRestore() throws Exception {
var own=notification(a,"APPLICATION",123L); var foreign=notification(b,"OFFER",456L);
for (long bad : List.of(foreign.getId(),Long.MAX_VALUE)) {
mvc.perform(batch("batch-delete",List.of(own.getId(),bad)).header("Authorization","Bearer "+at))
.andExpect(status().is(bad==Long.MAX_VALUE?400:403));
assertNull(notifications.findById(own.getId()).orElseThrow().getDeletedAt());
}
change(delete("/notifications/"+own.getId()),at);
for (long bad : List.of(foreign.getId(),Long.MAX_VALUE)) {
mvc.perform(batch("batch-restore",List.of(own.getId(),bad)).header("Authorization","Bearer "+at))
.andExpect(status().is(bad==Long.MAX_VALUE?400:403));
assertNotNull(notifications.findById(own.getId()).orElseThrow().getDeletedAt());
}
assertEquals(1,count(bt)); assertEquals(0,count(at));
}
@Test void batchRejectsInvalidIdsAndStrictlyRejectsNumericCoercion() throws Exception {
var own=notification(a,"APPLICATION",123L);
for (String body : List.of("{}","{\"ids\":null}","{\"ids\":[]}","{\"ids\":[null]}",
"{\"ids\":[0]}","{\"ids\":[-1]}","{\"ids\":[true]}","{\"ids\":[1.5]}",
"{\"ids\":[\""+own.getId()+"\"]}","{\"ids\":[9223372036854775808]}")) {
for (String route : List.of("batch-delete","batch-restore"))
mvc.perform(post("/notifications/"+route).contentType("application/json").content(body)
.header("Authorization","Bearer "+at)).andExpect(status().isBadRequest());
}
mvc.perform(batch("batch-delete",Collections.nCopies(101,own.getId())).header("Authorization","Bearer "+at))
.andExpect(status().isBadRequest());
assertNull(notifications.findById(own.getId()).orElseThrow().getDeletedAt());
}
@Test void batchDeduplicatesAndReturnsActualChanges() throws Exception {
var n=notification(a,"MATCH",123L);
var result=change(batch("batch-delete",List.of(n.getId(),n.getId())),at);
assertEquals(1,result.path("requested").asInt()); assertEquals(1,result.path("changed").asInt());
assertEquals(0,change(batch("batch-delete",List.of(n.getId())),at).path("changed").asInt());
assertEquals(1,change(batch("batch-restore",List.of(n.getId(),n.getId())),at).path("changed").asInt());
assertEquals(0,change(batch("batch-restore",List.of(n.getId())),at).path("changed").asInt());
}
@Test void batchOf100AndTrashPaginationHaveStableOrder() throws Exception {
var ids=new ArrayList<Long>();
for(int i=0;i<100;i++)ids.add(notification(a,"APPLICATION",(long)i+1).getId());
assertEquals(100,change(batch("batch-delete",ids),at).path("changed").asInt());
var first=getData("/notifications/trash?size=50",at); var second=getData("/notifications/trash?page=1&size=50",at);
assertEquals(100,first.path("total").asInt()); assertEquals(2,first.path("totalPages").asInt());
var actual=new ArrayList<Long>();
first.path("items").forEach(n->actual.add(n.path("id").asLong())); second.path("items").forEach(n->actual.add(n.path("id").asLong()));
ids.sort(Comparator.reverseOrder()); assertEquals(ids,actual);
assertEquals(100,change(batch("batch-restore",ids),at).path("changed").asInt()); assertEquals(100,count(at));
}
@Test void trashPaginationRejectsInvalidQueries() throws Exception {
for(String query:List.of("page=-1","size=0","size=51"))
mvc.perform(get("/notifications/trash?"+query).header("Authorization","Bearer "+at)).andExpect(status().isBadRequest());
}
List<MockHttpServletRequestBuilder> requests(long id) throws Exception {
return List.of(get("/notifications/trash"),delete("/notifications/"+id),post("/notifications/"+id+"/restore"),
batch("batch-delete",List.of(id)),batch("batch-restore",List.of(id)));
}
@Test void anonymousAndDisabledOriginalTokenCannotUseAnyNewOperation() throws Exception {
var n=notification(a,"INTERVIEW",123L); a.setActive(false); users.saveAndFlush(a);
for(String token:List.of("",at))for(var req:requests(n.getId()))
mvc.perform(req.header("Authorization",token.isEmpty()?"":"Bearer "+token)).andExpect(status().isUnauthorized());
assertNull(notifications.findById(n.getId()).orElseThrow().getDeletedAt());
}
@Test void everyMutationRejectsOtherAccountAndNoTrashLeaks() throws Exception {
var foreign=notification(b,"OFFER",123L);
for(var req:requests(foreign.getId()).subList(1,5))
mvc.perform(req.header("Authorization","Bearer "+at)).andExpect(status().isForbidden());
change(delete("/notifications/"+foreign.getId()),bt);
assertEquals(0,getData("/notifications/trash?userId="+b.getId(),at).path("total").asInt());
assertEquals(1,getData("/notifications/trash",bt).path("total").asInt());
}
@Test void unverifiedCompanyGuardAppliesToNewOperations() throws Exception {
a.setRole(User.Role.COMPANY); a.setRoles(Set.of(User.Role.COMPANY)); users.saveAndFlush(a);
companies.saveAndFlush(Company.builder().ownerUserId(a.getId()).name("未认证合成企业").verified(false).build());
String token=sessions.createSession(a,clock.instant(),new AuditContext()).accessToken();
var n=notification(a,"VERIFICATION",123L);
for(var req:requests(n.getId())) mvc.perform(req.header("Authorization","Bearer "+token)).andExpect(status().isForbidden());
assertNull(notifications.findById(n.getId()).orElseThrow().getDeletedAt());
}
@Test void deletedReportDecisionIsExcludedFromTypedUnreadCount() {
var n=notification(a,"REPORT",123L); n.setType("REPORT_DECISION"); notifications.saveAndFlush(n);
assertEquals(1,notifications.countUnreadByUserIdAndType(a.getId(),"REPORT_DECISION"));
service.delete(a.getId(),n.getId()); assertEquals(0,notifications.countUnreadByUserIdAndType(a.getId(),"REPORT_DECISION"));
service.restore(a.getId(),n.getId()); assertEquals(1,notifications.countUnreadByUserIdAndType(a.getId(),"REPORT_DECISION"));
}
@Test void concurrentReadAndDeleteNeverResurrectsTheNotification() throws Exception {
var pool=Executors.newFixedThreadPool(2);
try {
for(int i=0;i<8;i++) {
var n=notification(a,"APPLICATION",123L); var go=new CountDownLatch(1);
var delete=pool.submit(()->{go.await();return service.delete(a.getId(),n.getId());});
var read=pool.submit(()->{go.await();try {service.markRead(a.getId(),n.getId());return 200;}
catch(BusinessException ex){return ex.getErrorCodeInt();}});
go.countDown(); assertTrue(delete.get(30,TimeUnit.SECONDS));
assertTrue(Set.of(200,9105).contains(read.get(30,TimeUnit.SECONDS)));
var result=notifications.findById(n.getId()).orElseThrow(); assertNotNull(result.getDeletedAt());
assertEquals(Boolean.TRUE.equals(result.getRead()),result.getReadAt()!=null);
}
assertEquals(0,count(at));
} finally {pool.shutdownNow();}
}
@Test void overlappingBatchRequestsUseConsistentLockOrderAndRemainIdempotent() throws Exception {
var x=notification(a,"APPLICATION",123L);var y=notification(a,"OFFER",456L);
var go=new CountDownLatch(1); var pool=Executors.newFixedThreadPool(2);
try {
var one=pool.submit(()->{go.await();return service.batchDelete(a.getId(),List.of(x.getId(),y.getId()));});
var two=pool.submit(()->{go.await();return service.batchDelete(a.getId(),List.of(y.getId(),x.getId()));});
go.countDown();assertEquals(2,one.get(30,TimeUnit.SECONDS).changed()+two.get(30,TimeUnit.SECONDS).changed());
assertEquals(2,getData("/notifications/trash",at).path("total").asInt());
} finally {pool.shutdownNow();}
}
}

26
mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlIntegrationTest.java

@ -0,0 +1,26 @@
package com.mydreamjob.notification;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/** 相同 HTTP/权限/批量原子性/并发套件在真实 MySQL 执行;版本由专项命令选择。 */
@Testcontainers
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class NotificationTrashMySqlIntegrationTest extends NotificationTrashIntegrationTest {
@Container static final MySQLContainer<?> MYSQL=new MySQLContainer<>(System.getProperty("notification.mysql.image","mysql:8.4"))
.withDatabaseName("notification_trash_http").withUsername("notification_test").withPassword("synthetic-only")
.withCommand("--max-allowed-packet=33554432","--character-set-server=utf8mb4","--collation-server=utf8mb4_unicode_ci")
.withUrlParam("characterEncoding","UTF-8")
.withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024));
@DynamicPropertySource static void mysql(DynamicPropertyRegistry r) {
r.add("spring.datasource.url",MYSQL::getJdbcUrl);
r.add("spring.datasource.username",MYSQL::getUsername);
r.add("spring.datasource.password",MYSQL::getPassword);
r.add("spring.datasource.driver-class-name",()->"com.mysql.cj.jdbc.Driver");
r.add("spring.jpa.properties.hibernate.dialect",()->"org.hibernate.dialect.MySQLDialect");
}
}

55
mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlMigrationTest.java

@ -0,0 +1,55 @@
package com.mydreamjob.notification;
import com.mydreamjob.config.MySql57MigrationConfiguration;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.MySQLContainer;
import java.sql.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** V63→V64:真实双 MySQL,不重置历史迁移、不回填或删除通知。 */
class NotificationTrashMySqlMigrationTest {
@Test void mysql84() throws Exception { verify("mysql:8.4"); }
@Test void mysql57() throws Exception { verify("mysql:5.7.36"); }
Flyway flyway(MySQLContainer<?> db,String target) {
var config=Flyway.configure().dataSource(db.getJdbcUrl(),db.getUsername(),db.getPassword())
.locations("classpath:db/migration").target(target);
MySql57MigrationConfiguration.configure(config);return config.load();
}
List<String> rows(Statement sql,String query) throws SQLException {
var result=new ArrayList<String>();
try(var r=sql.executeQuery(query)) {
while(r.next()) {var fields=new ArrayList<String>();for(int i=1;i<=r.getMetaData().getColumnCount();i++)fields.add(r.getString(i));
result.add(String.join("|",fields));}
}
return result;
}
void verify(String image) throws Exception {
try(var db=new MySQLContainer<>(image).withDatabaseName("notification_trash_migration")
.withUsername("notification_test").withPassword("synthetic-only")
.withCommand("--max-allowed-packet=33554432","--character-set-server=utf8mb4","--collation-server=utf8mb4_unicode_ci")
.withUrlParam("characterEncoding","UTF-8")
.withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024))) {
db.start();flyway(db,"63").migrate();
try(var connection=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword());var sql=connection.createStatement()) {
sql.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(99401,'19900994001','STUDENT',1,1)");
sql.execute("INSERT INTO notifications(id,user_id,type,title,content,is_read,read_at,business_type,business_id) VALUES"
+"(99401,99401,'INTERVIEW_INVITED','合成邀请',NULL,0,NULL,'INTERVIEW',123),"
+"(99402,99401,'LEGACY','历史通知','原文',1,'2026-10-01 12:00:00',NULL,NULL)");
String oldQuery="SELECT id,user_id,type,title,content,is_read,read_at,business_type,business_id FROM notifications ORDER BY id";
String historyQuery="SELECT version,checksum,success FROM flyway_schema_history WHERE version<>'64' ORDER BY installed_rank";
var old=rows(sql,oldQuery);var history=rows(sql,historyQuery);
assertEquals(1,flyway(db,"64").migrate().migrationsExecuted);
assertEquals(old,rows(sql,oldQuery));assertEquals(history,rows(sql,historyQuery));
assertEquals(List.of("2"),rows(sql,"SELECT COUNT(*) FROM notifications WHERE deleted_at IS NULL"));
assertEquals(List.of("datetime|YES"),rows(sql,"SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='notifications' AND column_name='deleted_at'"));
assertEquals(List.of("user_id","deleted_at","id"),rows(sql,"SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='notifications' AND index_name='idx_notifications_user_deleted_id' ORDER BY seq_in_index"));
sql.execute("UPDATE notifications SET deleted_at='2026-10-28 12:00:00' WHERE id=99401");
assertEquals(List.of("1"),rows(sql,"SELECT COUNT(*) FROM notifications WHERE deleted_at IS NOT NULL"));
assertEquals(0,flyway(db,"64").migrate().migrationsExecuted);flyway(db,"64").validate();
assertEquals(old,rows(sql,oldQuery));assertEquals(history,rows(sql,historyQuery));
}
}
}
}

10
mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java

@ -360,7 +360,7 @@ class OfferServiceTest {
Offer offer = offerService.transitionStatus(COMPANY_USER_ID, OFFER_ID, statusReq("SENT"), ctx);
assertEquals("SENT", offer.getStatus());
verify(historyRepository).save(any());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("OFFER_SENT"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("OFFER_SENT"), any(), any(), eq("OFFER"), eq(OFFER_ID), any());
}
@Test
@ -371,7 +371,7 @@ class OfferServiceTest {
stubSaveAndFlush();
Offer offer = offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("ACCEPTED"), ctx);
assertEquals("ACCEPTED", offer.getStatus());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_ACCEPTED"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_ACCEPTED"), any(), any(), eq("OFFER"), eq(OFFER_ID), any());
}
@Test
@ -383,7 +383,7 @@ class OfferServiceTest {
Offer offer = offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("REJECTED"), ctx);
assertEquals("REJECTED", offer.getStatus());
assertNull(offer.getActiveKey());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_REJECTED"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_REJECTED"), any(), any(), eq("OFFER"), eq(OFFER_ID), any());
}
@Test
@ -394,7 +394,7 @@ class OfferServiceTest {
stubSaveAndFlush();
Offer offer = offerService.transitionStatus(STUDENT_ID, OFFER_ID, statusReq("NEGOTIATING"), ctx);
assertEquals("NEGOTIATING", offer.getStatus());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_NEGOTIATING"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(COMPANY_USER_ID), eq("OFFER_NEGOTIATING"), any(), any(), eq("OFFER"), eq(OFFER_ID), any());
}
@Test
@ -524,7 +524,7 @@ class OfferServiceTest {
OfferReminder reminder = offerService.remind(COMPANY_USER_ID, OFFER_ID, ctx);
assertEquals(OfferReminder.TYPE_MANUAL, reminder.getReminderType());
assertEquals(OfferReminder.STATUS_SENT, reminder.getStatus());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("OFFER_REMINDER"), any(), any(), any(), any(), any());
verify(notificationService).publishEvent(eq(STUDENT_ID), eq("OFFER_REMINDER"), any(), any(), eq("OFFER"), eq(OFFER_ID), any());
}
@Test

4
mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java

@ -236,11 +236,11 @@ class ProfileImageIntegrationTest {
var response = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse();
var runtime = json.readTree(response.getContentAsByteArray()).path("paths");
var contract = json.readTree(java.nio.file.Files.readString(java.nio.file.Path.of("../docs/openapi-rc.json"))).path("paths");
assertEquals(216, runtime.size());
assertEquals(221, runtime.size());
Set<String> expected = new TreeSet<>(), actual = new TreeSet<>();
contract.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> expected.add(m + " " + p.getKey())));
runtime.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> actual.add(m + " " + p.getKey())));
assertEquals(266, actual.size()); assertEquals(expected, actual);
assertEquals(271, actual.size()); assertEquals(expected, actual);
assertEquals("uploadAvatar", runtime.path("/auth/me/avatar").path("post").path("operationId").asText());
for (String route : List.of("/auth/me/avatar", "/companies/me/logo", "/profile-images/{imageId}")) {
contract.path(route).fields().forEachRemaining(method -> {

4
mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java

@ -140,8 +140,8 @@ class WorkExperienceOptimizationSecurityIntegrationTest {
var response=http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1/v3/api-docs")).GET().build(),HttpResponse.BodyHandlers.ofString());
assertEquals(200,response.statusCode());
var runtime=mapper.readTree(response.body());var contract=mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertEquals(216,runtime.path("paths").size());
assertEquals(operations(contract),operations(runtime));assertEquals(266,operations(runtime).size());
assertEquals(221,runtime.path("paths").size());
assertEquals(operations(contract),operations(runtime));assertEquals(271,operations(runtime).size());
var path=runtime.path("paths").path("/profile/me/work-experience-optimization").path("post");
assertTrue(path.path("requestBody").path("required").asBoolean());
assertTrue(path.path("security").toString().contains("bearerAuth"));

Loading…
Cancel
Save