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