# PRD：TECH 公共站内通知与受控跳转

Status: 后端代码与契约完成、验证延期

---

## Problem Statement

七月迭代的套餐临期、钱包低余额、企微审批结果和系统异常都需要向系统用户发送消息，但当前代码没有统一的站内通知存储、未读状态、消息中心或受控跳转能力。若每个需求自行建表和接口，会产生不同的防重、接收人、已读和权限规则；若直接把任意 URL 放进消息，又会形成越权跳转和开放重定向风险。

站内通知不承担企业微信审批待办，也不等同于旧轮询告警。业务资金、审批和套餐事务不能因为通知投递暂时失败而回滚，但通知又必须在 Outbox/Asynq 至少一次投递下保持不重不漏。

## Solution

建立一套轻量站内通知写模型：业务事务只可靠发布稳定事件，Notification Worker 根据受控通知类型、模板和接收人生成每人一条通知，并以事件和接收人唯一键防重。后台账号与个人客户使用各自认证上下文查询自己的未读数、分页列表和已读状态，任何接口都不接受前端传入接收人 ID。

通知只保存受控 `ref_type/ref_id/ref_key`，目标解析接口把它转换为白名单 `target_type` 和结构化目标参数，不保存、不返回任意 URL。跳转后的业务详情继续执行原资源权限校验。

## User Stories

1. 作为后台或代理账号，我希望在顶部看到可靠的未读数，并在通知中心查看自己的审批、临期、同步和系统消息。
2. 作为个人客户，我希望看到与自己订单、套餐和资产有关的简化消息，不看到平台运维消息。
3. 作为用户，我希望重复 Worker 投递不会生成重复消息，重复点击已读也不会报错。
4. 作为用户，我希望点击通知只能进入系统允许的业务页面，权限变化后不能借通知越权查看资源。
5. 作为业务开发者，我希望新增消息场景只注册常量、模板、接收人和目标类型，不复制一套通知表和 Handler。
6. 作为运维人员，我希望没有接收人、模板错误和投递失败均可追踪，不会无限重试或影响原业务提交。
7. 作为前端用户，我希望铃铛、抽屉和通知中心的未读状态一致，请求失败时不会把已有未读数闪回零。

## Implementation Decisions

### 架构与可靠投递

- 站内通知是简单写模型，采用 `Application + Query + Infrastructure`，不创建无业务价值的通知聚合根。
- 关键业务在原事务中写 Outbox，提交后由 Relay 投递结构化 Asynq 载荷；Notification Worker 负责解析接收人、渲染模板并幂等写通知。非关键系统告警可直接入队，但必须携带预先生成的稳定 `event_id`。
- 本需求复用七月公共 `tb_outbox_event`、Relay 和处理租约，不在通知模块复制一套 Outbox。调用项目 `EnqueueTask` 时传 struct 或 map，禁止传预序列化 `[]byte`。
- 业务提交只依赖 Outbox 同事务成功，不等待通知表写入；Worker 失败按队列策略重试，不回滚已完成的资金、审批或套餐事务。
- 每个最终接收人独立一行，唯一键为 `event_id + recipient_kind + recipient_id`。同一事件重复消费不重复写，同一事件的不同接收人互不影响已读状态。
- 接收人解析失败分为：暂无可用接收人记 `no_recipient` 并成功结束；数据库/模板等瞬时失败返回任务错误；模板字段永久缺失达到最大重试后进入失败监控，禁止生成残缺正文。

### 数据与常量

- 新建 `tb_notification`，字段至少包括：`id`、`event_id`、`recipient_kind`、`recipient_id`、`category`、`type`、`severity`、`title`、`body`、`ref_type`、`ref_id`、`ref_key`、`is_read`、`read_at`、`expires_at`、`created_at`。
- `recipient_kind` 为类型字段，使用 string：`account`、`personal_customer`。
- `category` 为 string：`approval`、`expiry`、`sync`、`system`；`severity` 为 string：`info`、`warning`、`error`、`critical`。具体 `type` 使用点分业务常量，例如 `package.expiring`、`wallet.low_balance`、`wecom.approval.approved`、`card_sync.failed`。
- 所有常量及中文说明统一放在 `pkg/constants/`；通知类型到类别、默认级别、模板和允许目标的映射使用代码注册表，前端不得自行猜测。
- `title/body` 是发送时的纯文本快照，不保存任意 HTML。正文不得包含密码、操作密码、Token、Secret、完整证件、完整敏感回调、长期对象存储 URL 或企微 `media_id`。
- 唯一索引覆盖 `event_id, recipient_kind, recipient_id`；未读和分类索引均以 `recipient_kind, recipient_id` 开头并包含 `created_at DESC`；过期时间建立部分索引。禁止数据库外键。
- 第一版未读数直接查询 PostgreSQL，不维护 Redis 未读计数，避免通知表与缓存双写不一致。

### 接收人规则

- Notification Worker 只接受稳定用户 ID，不按用户名、手机号等可变文本投递。具体用户由业务事件携带；角色类接收人在消费时批量解析当前启用账号。
- 审批结果发送给申请人；通过后撤销且资金已执行发送给申请人和当前可用财务角色账号；企微模板/系统配置异常发送给当前可用平台超管或指定运维角色。
- 套餐临期和钱包低余额的店铺接收人为当前启用的店铺主账号及当前仍可用的店铺业务员；去重后逐账号写通知。上级代理可查看下级数据不代表自动成为通知接收人。
- 个人套餐/订单/资产通知使用 `recipient_kind=personal_customer` 和客户 ID。个人客户接口不得返回 `sync/system` 运维消息。
- 账号停用、软删除或业务员关系失效时跳过；已经生成的历史通知仍按原接收人可读，不因后续关系变化转移给其他人。

### API 契约

- 后台、平台、代理和企业账号统一使用当前认证的 `/api/admin`：
  - `GET /api/admin/notifications/unread-count`
  - `GET /api/admin/notifications/unread-summary`
  - `GET /api/admin/notifications`
  - `PUT /api/admin/notifications/read-all`
  - `PUT /api/admin/notifications/{id}/read`
  - `GET /api/admin/notifications/{id}/target`
- 个人客户使用：
  - `GET /api/c/v1/notifications/unread-count`
  - `GET /api/c/v1/notifications`
  - `PUT /api/c/v1/notifications/read-all`
  - `PUT /api/c/v1/notifications/{id}/read`
- 静态 `/read-all` 路由必须先于 `/{id}` 动态路由注册。后台分类汇总第一版返回 `total` 与四个固定类别；C 端第一版只提供总未读数。
- `unread-count` 返回 `count:int64` 和 `display_count:string`；0 返回 `"0"`，1～99 返回十进制文本，超过 99 返回 `"99+"`。
- 列表过滤为 `category`、`type`、`severity`、`is_read`、`page`、`page_size`；固定按 `created_at DESC, id DESC`，默认每页 20，最大 50。过期通知不进入列表和未读统计。
- 通知项返回 `id/category/type/severity/title/body/ref_type/ref_id/ref_key/is_read/read_at/created_at`，使用统一响应外层和 ISO 8601 时间。
- 单条已读执行带接收人的条件更新。通知不存在、属于别人或已读均幂等返回成功，不泄露通知是否存在；首次成功写同一 `read_at`，重复请求不覆盖。
- `read-all` 的可选 `category` 为空时只更新当前接收人的全部未过期未读通知，返回实际更新数量；非法类别返回参数错误。
- `/target` 先固定当前接收人查询通知，再通过后端白名单返回 `target_type`、`target_id/target_key` 和 `available`；不得返回 URL。目标资源不存在或当前无权访问时 `available=false`，不得泄露更多资源信息。

### 受控目标

- 第一版白名单至少覆盖本期实际场景：退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。
- `card_sync` 不指向不存在的独立同步执行页；平台运维消息解析为统一审计中心外部集成目标，并携带受控资源或 Integration Log 标识。
- 前端维护 `target_type -> route builder` 白名单；未知类型和 `available=false` 只展示消息正文，不跳转。拥有通知不等于拥有目标资源权限。

### 前端交互

- 登录布局挂载后立即请求未读数，每 30 秒刷新；页面不可见时暂停，恢复可见时立即刷新。失败保留上次成功数值并提供静默重试，不闪回 0。
- 顶部铃铛固定宽度；0 时不显示徽标，1～99 显示数字，超过 99 显示 `99+`。点击打开最近 10 条抽屉，支持全部、审批、临期、同步/系统分类及进入 `/notifications`。
- 通知中心支持类别、类型、严重级别、已读状态和服务端分页，并提供当前筛选类别的全部已读。普通消息使用中性色，错误/严重消息才使用警告视觉。
- 点击通知先进入已读视觉状态并调用已读接口，再解析受控目标；已读调用失败时以下次服务端刷新为准。目标解析或跳转失败不把通知恢复为未读。
- C 端使用简化消息列表，只展示与当前客户有关的审批结果、套餐、订单和资产消息。

### 审计、保留与发布

- 普通通知读取和已读只进入 Access Log；通知模板/接收人解析失败、系统告警生成和管理性排查进入统一 Audit/Integration Log。不得使用用户通知列表作为管理员查看他人消息的入口。
- 套餐临期展示至到期并保留数据 180 天；审批结果不自动过期并保留 365 天；同步异常展示 30 天、保留 180 天；系统告警按事件指定展示期限、最长保留 365 天。
- 低峰清理任务按主键/时间分批删除超出数据保留期限的通知；用户不提供删除接口，清理不修改业务审计和领域流水。
- 未来短信或企微消息使用独立 Delivery 消费同一业务事件；不得在 Notification Handler/Worker 写完站内消息后同步循环调用外部渠道。
- 新增管理端和 C 端 Handler 后同步注册路由和两个 OpenAPI 文档生成器；公共通知能力先于 UR#33、UR#97 和企微结果通知启用。

## 公共能力发布依赖

- 实现阶段复用公共 Outbox 与异步任务，不自行复制表或状态机；最终 08 号发布票必须阻塞于公共基础 12 号票和全局审计 19 号票。
- 每个下游通知生产者仍须登记事件类型、载荷版本、消费者幂等键、接收人、失败明细和通知策略；“通知基础完成”不代表业务生产者已经接入。

## Testing Decisions

- Application/Worker 测试覆盖重复事件、多个接收人、接收人去重、停用/删除接收人、无接收人、模板字段缺失、Outbox/Asynq 重试和过期时间。
- PostgreSQL 集成测试验证唯一索引、未读/分类查询、固定排序、最大分页、过期排除、单条/批量条件更新和并发重复消费。
- HTTP 集成测试穿过真实后台/C 端认证、Handler、Query、GORM 和统一响应；验证前端无法传入或篡改 `recipient_id`，管理员也不能从用户接口查看别人通知。
- 越权测试对“别人通知 ID”“已删除通知”“无权目标资源”返回相同安全语义；重复已读保持成功且 `read_at` 不变。
- 目标解析契约测试覆盖全部白名单、未知 `ref_type`、目标删除、权限变化和审计外部集成目标，证明响应中不存在任意 URL。
- 前端测试/人工验收覆盖 0、1、99、100 条徽标，30 秒刷新、隐藏页暂停、失败保留旧数、抽屉最近 10 条、筛选、全部已读和点击顺序。
- 使用开发 PostgreSQL/Redis 与测试 Outbox Relay/Asynq Worker 验证至少一次投递；不得给真实用户生成测试通知。
- 生成 OpenAPI 并核对静态 `read-all` 路由未被动态 ID 路由吞掉。

## Out of Scope

- 不建设 WebSocket/SSE 推送，第一版使用 30 秒未读轮询。
- 不在本系统复制企业微信“待我审批”待办，不发送套餐临期企业微信消息。
- 不实现短信、企微消息或邮件 Delivery，只保留独立扩展边界。
- 不允许管理员从通知接口查看、修改或删除其他用户消息。
- 不保存富文本 HTML、任意 URL、永久附件链接或外部回调原文。
- 不用 Redis 维护未读数，不让用户自行删除通知。
- 不在本需求实现各业务场景的触发规则；UR#33、UR#97 和企微审批需求分别负责发布业务事件。

## Further Notes

- 当前仓库没有通知模型、接口和消息中心，只有轮询告警等运维模型，不能把后者改名充当业务通知。
- 前端仓库不在当前工作区；本 Spec 的路由、状态和错误交互是跨仓契约，实际组件目录以对应前端仓库为准。
- 公共通知和公共 Outbox 是多个单需求的依赖，但不把这些单需求合并成一份整轮迭代 PRD。
