You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

15 KiB

通知已读与业务关联:前端接入说明

日期:2026-10-28。面向前端联调开发者。本文描述本地后端改动,尚未提交、推送或发布,不能据此认为线上已经返回新增字段。

交接状态(验收后更新)

  • 后端隔离验收已通过:完整 clean verify 3900 tests / 0 failures / 0 errors / 2 skipped(398 suites);两个跳过项仅为付费 DeepSeek Live 测试。
  • 同次 JAR 在 MySQL 8.4.11、5.7.36 下各 42/42 真实 HTTP / RabbitMQ / 重启检查通过;静态与运行时契约均为 216 paths / 266 operations,反向检查 9/9。
  • JAR SHA256:0d74e2c6610e5a2197e4ee1571f136d643f1b5a9dcfde06bfd381c0ec59aedec。报告目录:D:\Acceptance\qsp-notification-full-20261028-a1\。
  • 发布尚未授权执行。 前端可以先完成类型、交互、单测;真实联调须由后端提供已启动的联调地址,或待后续授权发布后确认实际响应。已停止的验收进程/容器不是可直接使用的联调服务。
  • 此节及下文实施清单是验收后的文档补充,不是新的后端代码改动。原验收目录中的 13 文件源码快照和哈希保留,不修改既有 PASS 证据。

1. 字段与接口参考

统一前缀 /api/v1,使用正常登录取得的 access token,发送 Authorization: Bearer <accessToken>;refresh token 不能访问业务接口。身份从安全上下文取得,不接受调用方指定通知归属用户。

通知列表

GET /notifications?page=0&size=20:page 从 0 开始,size 为 1~50。按 createdAt 倒序,同一创建时间按 id 倒序,分页信封不变。

{
  "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。

点击、已读与刷新

  1. 列表映射须保留原始 type、businessType、businessId;不要只保留 title/content/read,也不要把所有 type 折叠为 system 后丢掉原类型。

  2. 点击条目先展示通知全文,再按下表解析关联。只支持明确映射的类型,并校验 businessId 是可用正整数;应用内路由由前端按当前角色和现有页面实现。

  3. 用户查看通知即可以通知 id 调用单条已读。通知阅读和关联详情能否访问是两回事:即使目标已删除,也仍可阅读原通知并标记已读。

  4. 已读成功后更新列表项并刷新独立的未读总数;失败时保留未读或回滚乐观状态,提供重试,不能静默清除红点。切换账号后禁止旧请求结果写回新账号状态。

  5. 关联业务详情仍使用既有面试/投递/Offer 接口并携带当前用户凭据。通知关联不是新的访问授权;禁止凭通知 ID 或 businessId 绕过权限。

  6. 未知类型、空/不完整关联、目标已删除/无权限时,保留通知全文,并提示“关联内容不可用”;未登录应走登录恢复,不伪装为内容不存在。

  7. “全部已读”由用户主动触发;成功后刷新条目和未读数,不建议仅进入列表就自动把所有页标成已读。

  8. 页面回到前台、进入通知 Tab 或读状态变化后,按项目统一刷新机制同步。此批没有新增通知实时推送协议,不能承诺 WebSocket 即时更新。

  9. 两个通知入口应复用当前账号的状态或共享失效通知机制,不允许只刷新当前卡片而其他页面继续保留旧红点。可以复用项目已有缓存/事件机制,不要求为此更换全局状态库。

  10. 全局红点/数量使用 /notifications/unread-count,不能使用第一页的 items.some(!read) 代替;第一页全已读不代表后续页无未读。“全部已读”按钮也不能仅依据当前页判断是否可用。

  11. 对同一条通知的连续点击去重,全部已读提交时防止重复点击;刷新请求需要账号隔离和旧响应保护。不要在请求失败或超时时直接把未读数硬设为 0。

业务目标与现有路由边界

关联类型 前端行为
APPLICATION、MATCH ID 均为投递 ID。当前求职者页为 /my-application-detail?id=<businessId>,企业页为 /application-detail?id=<businessId>;按当前角色选择,仍由详情接口校验权限
OFFER 当前已有 /offers/[id] 双端共用详情,使用 Offer ID;请复核最新实现
INTERVIEW 使用现有 GET /interviews/{interviewId} 读取企业面试详情,展示邀请/时间/最新状态;需要前端补真实业务详情页或详情弹层,并可复用面试列表中的数据与组件
VERIFICATION、REPORT、未知类型、空/不完整关联 在没有确认可用且授权的业务页面前,只展示通知全文及不可用提示,不伪造路径

特别注意:当前 app/interview.tsx 使用 useAIInterview,是 AI 面试功能,不是企业邀约的业务面试详情页。禁止把 INTERVIEW 通知直接跳到 /interview 当作完成。 面试取消后应展示“已取消”等最新业务状态,不把通知当作仍然有效的邀请。

业务详情可能已删除、已失效或当前用户无权限。此时通知本身仍可阅读、标记已读;详情访问失败应有明确反馈。401 应走登录恢复,403/404 或具体业务不可用码应按该详情接口实际契约处理,不一律吞掉,也不要把业务字段当成额外访问授权。

3. 已核查的业务关联表

以下为源码生产者核查,不等于每种事件均已做真实端到端验证;具体测试范围见本批验证记录。

生产者 / 事件 接收者 businessType / businessId
ApplicationService:APPLICATION_APPLIED、APPLICATION_WITHDRAWN、APPLICATION_STATUS 投递求职者 APPLICATION / 投递 ID;投递成功文案中的岗位 ID 不能用于此字段
ApplicationAcceptanceService:APPLICATION_ACCEPTED 投递求职者 APPLICATION / 投递 ID
InterviewService:INTERVIEW_INVITED、INTERVIEW_RESCHEDULED、INTERVIEW_CANCELLED、INTERVIEW_COMPLETED 面试求职者 INTERVIEW / 面试 ID,不是投递 ID
InterviewService:INTERVIEW_CONFIRMED、INTERVIEW_DECLINED、INTERVIEW_RESCHEDULE_REQUESTED 所属企业 owner 用户 INTERVIEW / 面试 ID
InterviewScoreService:INTERVIEW_SCORE_SUBMITTED 评分对应求职者 INTERVIEW / 面试 ID,不是评分 ID
OfferService:OFFER_SENT、OFFER_REMINDER 候选人 OFFER / Offer ID
OfferService:OFFER_ACCEPTED、OFFER_REJECTED、OFFER_NEGOTIATING、OFFER_EXPIRED、OFFER_WITHDRAWN 所属企业 owner 用户(按现有逻辑) OFFER / Offer ID
OfferExpiryWorker:OFFER_REMINDER 候选人 OFFER / Offer ID
OfferExpiryWorker:OFFER_EXPIRED 候选人及所属企业 owner 用户(两个独立事件) OFFER / Offer ID
MatchNotificationService:MATCH_SUCCEEDED 候选人及所属企业 owner 用户 MATCH / 投递 ID,不是匹配记录 ID;前端可基于投递详情或现有投递匹配接口处理
CompanyVerificationService:VERIFICATION_APPROVED、VERIFICATION_REJECTED 提交认证的用户 VERIFICATION / 企业认证申请 ID,不是 companyId
ReportService:REPORT_DECISION 举报人 REPORT / 举报单 ID,不是被举报对象 ID
四参 NotificationService.create、历史/纯系统通知 调用方指定接收者 无明确关联时两个字段均 null,仅展示原文

通知消费者将事件中的 businessType/businessId 原样写入;本批不修改事件类型、接收者、发消息时机、幂等键或通知文案。未发现上述已查生产者需要补造关联字段,故未改这些生产业务代码。

4. 联调验收与限制

  • 面试、投递、Offer、MATCH、未知类型以及历史无关联:字段正确,路由不靠文本推断。
  • 点击一条未读 → 单条已读成功 → 本项红点及总未读数更新 → 重新进入仍已读。
  • 重复点击及“全部已读”幂等;超过 100 条、相同创建时间的多页通知不遗漏。
  • A/B 两账号隔离;禁用原 Token、他人通知、通知不存在均按上文错误规则处理。
  • 关联详情目标无权限或已不存在时,通知仍能展示;没有返回内部 userId/idempotencyKey。
  • 列表保留分页信封;此批不新增详情、路由、聚合接口,不新增迁移,不回填旧数据。
  • 后端本地改动不会自动修复前端点击事件、红点状态或完成线上部署。完整构建、真实 MySQL、JAR 黑盒、前端点击及上线结果分别登记,不互相替代。

前端交付验收清单

  1. 两个入口一致:沟通 Tab、独立通知页均可点击;任一入口阅读后,另一入口和全局未读数同步。
  2. 持久已读:未读红点 → 点击 → 已读接口成功 → 红点消失 → 返回/重新登录后仍已读;重复点击不重复扣数。
  3. 失败不伪成功:模拟已读、全部已读、列表或未读数请求失败,显示重试;不会把失败当空列表或把未读数直接清零。
  4. 分页正确:至少 25 条通知、第二页仍有未读;第一页读完时全局提示仍正确,加载更多不重复、不遗漏;全部已读处理所有页。
  5. 业务跳转:投递成功/状态变更/企业接受进入正确投递;Offer 进入正确 Offer;面试邀请/改期/取消展示真实业务面试,不进入 AI 面试。
  6. 降级正常:历史 null、未知类型、单边字段缺失、目标已删除或无权限,仍可展示通知全文并反馈原因。
  7. 账号隔离:A 请求未完成时切 B,A 的列表、已读响应和未读数不能覆盖 B。
  8. 不从文案猜 ID:把标题/正文中的“#编号”改为无关值,跳转仍只由 businessType/businessId 决定。

前端交付请提供:修改文件列表、TypeScript/相关测试结果、实际联调地址与后端版本、关键请求参数与响应字段(去除 Token/验证码)、双入口与跨页操作录屏或截图。尚未拿到运行中的新后端时,仅能登记前端单测/模拟测试通过,不能登记真实接口联调通过。