From cc3f72a56fcf4cd6fd1780297226ef42b8c5895e Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Wed, 28 Oct 2026 14:29:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E5=85=B3=E8=81=94=E3=80=81=E5=B7=B2=E8=AF=BB?= =?UTF-8?q?=E5=92=8C=E9=80=9A=E7=9F=A5=E5=9B=9E=E6=94=B6=E7=AB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...links-development-verification-20261028.md | 66 +++ ...cation-links-frontend-handover-20261028.md | 180 ++++++++ ...trash-development-verification-20261028.md | 64 +++ ...cation-trash-frontend-handover-20261028.md | 125 +++++ docs/openapi-rc.json | 434 +++++++++++++++++- docs/verify_openapi.py | 41 +- .../mydreamjob/notification/Notification.java | 4 + .../NotificationBatchRequest.java | 31 ++ .../notification/NotificationBatchResult.java | 6 + .../NotificationChangeResult.java | 6 + .../notification/NotificationController.java | 61 ++- .../NotificationListResponseDoc.java | 48 ++ .../notification/NotificationRepository.java | 23 +- .../notification/NotificationService.java | 116 ++++- .../NotificationTrashResponseDoc.java | 34 ++ .../V64__notification_recycle_bin.sql | 2 + .../application/ApplicationServiceTest.java | 12 +- .../CompanyResumeDownloadIntegrationTest.java | 4 +- .../UnifiedRegistrationIntegrationTest.java | 2 +- .../interview/InterviewServiceTest.java | 33 +- ...otificationAssociationIntegrationTest.java | 183 ++++++++ .../NotificationConsumerTest.java | 34 +- .../notification/NotificationServiceTest.java | 58 ++- .../NotificationTrashIntegrationTest.java | 206 +++++++++ ...NotificationTrashMySqlIntegrationTest.java | 26 ++ .../NotificationTrashMySqlMigrationTest.java | 55 +++ .../mydreamjob/offer/OfferServiceTest.java | 10 +- .../ProfileImageIntegrationTest.java | 4 +- ...ceOptimizationSecurityIntegrationTest.java | 4 +- 29 files changed, 1811 insertions(+), 61 deletions(-) create mode 100644 docs/notification-links-development-verification-20261028.md create mode 100644 docs/notification-links-frontend-handover-20261028.md create mode 100644 docs/notification-trash-development-verification-20261028.md create mode 100644 docs/notification-trash-frontend-handover-20261028.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchResult.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationChangeResult.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationListResponseDoc.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationTrashResponseDoc.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V64__notification_recycle_bin.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationAssociationIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlMigrationTest.java diff --git a/docs/notification-links-development-verification-20261028.md b/docs/notification-links-development-verification-20261028.md new file mode 100644 index 0000000..12a118b --- /dev/null +++ b/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 验收,不把原仓库其他未提交改动混入。 diff --git a/docs/notification-links-frontend-handover-20261028.md b/docs/notification-links-frontend-handover-20261028.md new file mode 100644 index 0000000..55bf1b9 --- /dev/null +++ b/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 `;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=`,企业页为 `/application-detail?id=`;按当前角色选择,仍由详情接口校验权限 | +| 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/验证码)、双入口与跨页操作录屏或截图。尚未拿到运行中的新后端时,仅能登记前端单测/模拟测试通过,不能登记真实接口联调通过。 diff --git a/docs/notification-trash-development-verification-20261028.md b/docs/notification-trash-development-verification-20261028.md new file mode 100644 index 0000000..0520743 --- /dev/null +++ b/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 文件清单直接发布。 diff --git a/docs/notification-trash-frontend-handover-20261028.md b/docs/notification-trash-frontend-handover-20261028.md new file mode 100644 index 0000000..d28733d --- /dev/null +++ b/docs/notification-trash-frontend-handover-20261028.md @@ -0,0 +1,125 @@ +# 通知第二批:删除、多选与回收站对接 + +日期:2026-10-28。面向前端开发/联调。仅描述本批后端实现,**尚未提交、推送或发布**。第一批“业务关联与已读”交接文档继续适用;本批没有新增历史记录页面、永久删除或自动清空。 + +## 1. 接口与字段 + +前缀 `/api/v1`,全部接口使用正常 access token:`Authorization: Bearer `,身份从安全上下文取得。不得传 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 文件清单直接发布当前工作区**。后续须按第一批依赖 + 第二批改动的完整合并基线重新验收。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index a3a1fd9..0ff4f93 100644 --- a/docs/openapi-rc.json +++ b/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": { diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index a2336fe..f297c10 100644 --- a/docs/verify_openapi.py +++ b/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!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/Notification.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/Notification.java index 1b69b18..b9cad1e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/Notification.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchRequest.java new file mode 100644 index 0000000..0fbf65d --- /dev/null +++ b/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 { + @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; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchResult.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationBatchResult.java new file mode 100644 index 0000000..8297615 --- /dev/null +++ b/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) {} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationChangeResult.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationChangeResult.java new file mode 100644 index 0000000..e9563bb --- /dev/null +++ b/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) {} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java index 2c0a9c3..ca5a837 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java +++ b/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>> list( @RequestParam(defaultValue = "0") int page, @@ -34,8 +37,14 @@ public class NotificationController { HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Page result = notificationService.list(userId, page, size); + return pageResponse(result, false, request); + } + + private ResponseEntity>> pageResponse(Page result, boolean trash, + HttpServletRequest request) { Map 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>> 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> 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> 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> 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> 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>> markRead( diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationListResponseDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationListResponseDoc.java new file mode 100644 index 0000000..17a2da5 --- /dev/null +++ b/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 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java index 35eaf93..04f9644 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java +++ b/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 { Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); - @Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false") + Page findByUserIdAndDeletedAtIsNullOrderByCreatedAtDescIdDesc(Long userId, Pageable pageable); + + Page findByUserIdAndDeletedAtIsNotNullOrderByDeletedAtDescIdDesc(Long userId, Pageable pageable); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT n FROM Notification n WHERE n.id = :id") + Optional 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); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java index cbe54d6..15dd661 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java +++ b/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 list(Long userId, int page, int size) { requireVerifiedIfCompany(userId); + validatePage(page, size); + return notificationRepository.findByUserIdAndDeletedAtIsNullOrderByCreatedAtDescIdDesc(userId, + PageRequest.of(page, size)); + } + + public Page 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 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 ids) { + return changeVisibility(userId, ids, false); + } + + @Transactional + public NotificationBatchResult batchRestore(Long userId, List ids) { + return changeVisibility(userId, ids, true); + } + + private NotificationBatchResult changeVisibility(Long userId, List 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 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 toView(Notification n) { - Map v = new HashMap<>(); + Map 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 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 {} + + /** Map 的全局 content-inclusion 会省略 null,故在本地视图明确写出字段。 */ + private static class NotificationViewSerializer extends com.fasterxml.jackson.databind.JsonSerializer { + @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 entry : value.entrySet()) { + generator.writeFieldName(entry.getKey()); + serializers.defaultSerializeValue(entry.getValue(), generator); + } + generator.writeEndObject(); + } + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationTrashResponseDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationTrashResponseDoc.java new file mode 100644 index 0000000..d644f56 --- /dev/null +++ b/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 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; + } +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V64__notification_recycle_bin.sql b/mydreamjob-server/src/main/resources/db/migration/V64__notification_recycle_bin.sql new file mode 100644 index 0000000..5ac203e --- /dev/null +++ b/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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java index be31952..3c16e5d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/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()); } // ── 企业候选人列表 ───────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index d5955d9..13274e0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/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(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index c461e7a..8f6d0d3 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java +++ b/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()+"}"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java index 5eae1ec..d4d6ee5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java +++ b/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 diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationAssociationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationAssociationIntegrationTest.java new file mode 100644 index 0000000..59a2942 --- /dev/null +++ b/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 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(); 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(); + for (JsonNode item : data.path("items")) { + var keys = new HashSet(); 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(); actual.path("properties").fieldNames().forEachRemaining(actualNames::add); + var expectedNames = new HashSet(); 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java index 01b916f..38de913 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationConsumerTest.java +++ b/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")); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java index 1dd9236..bf5e13b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java +++ b/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()); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashIntegrationTest.java new file mode 100644 index 0000000..9bcf731 --- /dev/null +++ b/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 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(); + 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(); + 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 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();} + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlIntegrationTest.java new file mode 100644 index 0000000..e03697d --- /dev/null +++ b/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"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationTrashMySqlMigrationTest.java new file mode 100644 index 0000000..94007a3 --- /dev/null +++ b/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 rows(Statement sql,String query) throws SQLException { + var result=new ArrayList(); + try(var r=sql.executeQuery(query)) { + while(r.next()) {var fields=new ArrayList();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)); + } + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java index b7e8f8d..a18c510 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java +++ b/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 diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java index fdbcd78..84080c9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java +++ b/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 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 -> { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java index 99993ea..5d048b3 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java +++ b/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"));