# 通知已读与业务关联:前端接入说明 日期: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/验证码)、双入口与跨页操作录屏或截图。尚未拿到运行中的新后端时,仅能登记前端单测/模拟测试通过,不能登记真实接口联调通过。