# 公共站内通知功能总结

> 当前状态：任务 2.1～2.4 后台、个人客户通知闭环和后台动态接收人已完成代码交付；受控跳转、保留清理和完整前端契约仍按后续任务实施。

## 本次交付范围

本阶段交付明确后台账号的通知中心纵向闭环：公共 Outbox 事件经现有 `outbox:deliver` Worker 消费后，为指定且仍启用的后台账号幂等写入一条纯文本通知。当前登录账号只能查询自己的未读数、固定分类汇总和筛选分页列表，并可把自己的一条、指定类别或全部通知幂等标记为已读。

本阶段不实现目标跳转解析、前端组件、WebSocket 或具体业务触发规则，也不复制公共 Outbox 与 Relay。

## 事件与幂等契约

- 稳定事件类型：`notification.admin.direct.requested`。
- 个人客户稳定事件类型：`notification.personal_customer.direct.requested`。
- 后台动态接收人事件类型：`notification.admin.dynamic.requested`。
- 载荷版本：`1`。
- 结构化载荷包含后台接收账号 ID、注册通知类型、模板数据、可选受控资源引用和过期时间；调用公共队列时保持 struct/map 载荷，不传预序列化 `[]byte`。
- 当前内置通知类型为 `system.notice`，标题和正文均由代码内固定模板生成，不接受业务载荷传入任意正文；未知字段、HTML、任意 HTTP URL、超长文本和明显敏感内容会被拒绝。
- 个人客户首个受控类型为 `package.expiring`，使用固定纯文本模板，只允许 `package` 或 `asset` 资源引用；`system.notice` 只向后台账号开放。
- 接收账号必须启用且未软删除；不可用账号记录安全日志并跳过，不把通知写入失败反向传播到原业务事务。
- `event_id + recipient_kind + recipient_id` 唯一约束与 `ON CONFLICT DO NOTHING` 共同保证重复或并发消费最多生成一条通知。

后台动态事件使用 `target_kind + target_id` 指定受控目标，当前支持：

- `account`：业务事件携带的稳定真实申请人账号 ID，消费时复核账号仍启用且未删除。
- `platform_role`：批量解析当前启用的平台角色、有效账号角色关系，以及仍启用未删除的超管/平台账号。
- `shop`：复用 UR#96 接缝，只解析目标店铺当前启用主账号和当前仍可用业务员，不读取父级、祖先、创建人或代理数据权限。

解析结果按稳定账号 ID 去重和排序。同一事件对每个接收人独立幂等写入；暂无可用接收人记录 `resolution=no_recipient` 并正常结束，数据库故障继续返回 Worker 错误。已经生成的通知保持原接收人事实，关系后续变化不会转移历史通知。

## 数据与查询

迁移 `000168_create_notification` 新建 `tb_notification`，不使用外键。表内固化通知类别、类型、级别、纯文本标题正文、受控引用、首次已读时间、过期时间和创建时间，并通过 CHECK 约束保护接收人、引用与已读状态一致性。

后台查询始终绑定当前认证账号和 `recipient_kind=account`：

- `GET /api/admin/notifications/unread-count`：返回准确 `count` 与徽标 `display_count`，超过 99 显示 `99+`。
- `GET /api/admin/notifications/unread-summary`：使用单条 PostgreSQL 条件聚合返回 `total/approval/expiry/sync/system` 五个固定未读计数。
- `GET /api/admin/notifications`：只返回未过期通知，支持类别、类型、级别和已读状态 AND 组合筛选，按 `created_at DESC, id DESC` 排序，默认每页 20、最大 50，页码最大 10000。
- `PUT /api/admin/notifications/read-all`：类别为空时更新当前账号全部未过期未读通知，指定有效类别时只更新该类别并返回实际更新数；重复调用返回零更新且保持成功。
- `PUT /api/admin/notifications/:id/read`：仅首次更新当前账号自己的未过期未读通知；不存在、属于别人或已经已读均幂等成功，不泄露通知是否存在，也不覆盖首次 `read_at`。
- `GET /api/admin/notifications/:id/target`：先固定当前账号查询通知，再返回 `target_type/target_id/target_key/available` 结构化白名单目标；不返回 URL，别人通知、不存在通知和过期通知统一返回不可用。

目标注册表覆盖退款、代理充值、企微审批、物联网卡、设备、临期资产列表、店铺资金概况、外部集成和系统配置。退款、卡、设备、店铺、外部集成和系统配置会复核当前数据权限与资源存在性；尚未交付下游业务表的代理充值和企微审批目标先失败关闭为 `available=false`。`card_sync` 统一映射到外部集成目标，不指向不存在的同步执行页面。未知引用仅展示正文。

个人客户接口固定绑定认证上下文中的 `customer_id`，请求 DTO 不包含接收人字段：

- `GET /api/c/v1/notifications/unread-count`：返回当前客户可见业务通知的准确未读数和 `display_count`。
- `GET /api/c/v1/notifications`：提供默认 20、最大 50 的简化分页并固定倒序，不暴露后台筛选或分类汇总参数。
- `PUT /api/c/v1/notifications/read-all`：幂等更新当前客户可见的全部未过期未读业务通知并返回实际更新数。
- `PUT /api/c/v1/notifications/:id/read`：幂等更新当前客户的一条可见通知，跨客户 ID 与不存在 ID 使用相同成功语义。

C 端查询和更新同时限制 `recipient_kind=personal_customer`、当前客户、未过期、业务类别及开放类型白名单；`sync/system` 和未对 C 端开放的类型不会进入结果，也不能被 C 端已读接口修改。

## 审计与安全边界

- `tb_notification` 是通知投递和已读状态的权威事实；通知不能替代资金、审批、套餐等业务 Domain Ledger。
- 本阶段无外部系统调用，因此不写 Integration Log；可靠输入继续使用公共 Outbox。
- 按测试环境 Change 的临时决定，本阶段不接入 Audit Event Writer。普通列表、未读数和已读进入 Access Log；生产发布前由任务 6.5 重新评审通知失败与系统告警治理。
- 用户接口不接受 `recipient_id`，后台账号权限也不能查看或修改其他接收人的通知。

## 验证与发布

- 已执行 `gofmt`、`git diff --check`，并通过 `go build ./cmd/api ./cmd/worker ./cmd/gendocs`。
- 按本次测试环境里程碑豁免，未新增或运行 `_test.go`，也未执行真实 PostgreSQL、Redis/Asynq 或 HTTP 集成验证；这些证据统一延期到任务 6.1 和 6.3，当前状态不得表述为生产验收通过。
- `000168` 的 down 迁移只允许空表回滚；一旦产生通知事实，必须停止生产者并前向修复，不允许降级删表清除事实。

通知展示与保留策略由代码统一执行：审批结果不自动过期、保留 365 天；套餐临期必须携带业务到期时间并保留 180 天；同步异常默认最多展示 30 天、保留 180 天；系统告警默认展示 30 天且最长 365 天、保留 365 天。

Worker 每天 02:15 调度 `notification:cleanup`，按类别、创建时间和通知主键，每批最多 500 条、每类每次最多 20 批执行 PostgreSQL CTE 删除。任务可中断重跑，只删除 `tb_notification`，不级联业务资源、Outbox、Integration Log 或审计事实。

无接收人以 `resolution=no_recipient` 正常结束。接收人解析、模板和展示策略失败只记录事件 ID、通知类型、失败类别及安全目标标识，不记录模板数据、回调、Token、Secret 或 URL；瞬时错误由 Asynq 有限重试，残缺正文不会入库。统一 Audit Event 管理性写入按本 Change 冻结到任务 6.5。

## 后续任务

- 2.7～2.8：前端契约、OpenAPI/中文文档最终收口和测试环境发布检查。

后台与 C 端的完整交互、目标白名单、示例和验收矩阵见 [前端联调与验收契约](前端联调与验收契约.md)。

测试环境发布顺序、运行门禁、恢复策略和下游事件接入方式见 [发布与下游接入清单](发布与下游接入清单.md)。
- 2.7～2.8：前端契约、OpenAPI/文档最终生成、Worker/模板与测试环境发布检查。
