29 changed files with 1811 additions and 61 deletions
@ -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 验收,不把原仓库其他未提交改动混入。 |
||||
@ -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/验证码)、双入口与跨页操作录屏或截图。尚未拿到运行中的新后端时,仅能登记前端单测/模拟测试通过,不能登记真实接口联调通过。 |
||||
@ -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 文件清单直接发布。 |
||||
@ -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 文件清单直接发布当前工作区**。后续须按第一批依赖 + 第二批改动的完整合并基线重新验收。 |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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) {} |
||||
@ -0,0 +1,6 @@ |
|||||
|
package com.mydreamjob.notification; |
||||
|
|
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
|
||||
|
public record NotificationChangeResult( |
||||
|
@Schema(description = "本次是否实际改变状态;重复删除/恢复为 false") boolean changed) {} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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();} |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue