# 新增需求 03:站内通知详细方案 > 状态:已合并至标准评审稿,本文保留为实施明细。 > 评审主文档:`../7月迭代技术方案-标准评审稿.md` > 范围:后台账号、代理账号、企业账号和个人客户的站内通知。 ## 一、定位 站内通知只负责“用户登录本系统后可以看到的消息”,不承担企业微信审批流程,也不直接发送企业微信消息。 第一版消息来源: | 来源 | 接收人 | 示例 | |---|---|---| | 企微审批结果 | 业务申请人、相关平台角色 | 退款审批通过、线下充值审批驳回 | | 套餐临期 | 代理账号、企业业务员、个人客户 | 套餐剩余 15/7/3 天 | | 数据同步异常 | 平台运维角色 | 模板失效、回调找不到资产、连续同步失败 | | 系统配置异常 | 平台超管 | 企微配置不可用、支付配置失效 | 企微审批中的“待我审批”由企业微信自身提醒,不在本系统重复创建审批待办。 ## 二、设计原则 1. 业务事务不直接写 `tb_notification`,关键事件通过 Outbox 可靠投递。 2. 一条事件向多个接收人发送时,每个接收人保存一条独立通知。 3. 使用 `event_id + recipient_kind + recipient_id` 保证重复消费不生成重复消息。 4. 通知正文是发送时快照,使用受控纯文本模板,不保存任意 HTML。 5. 通知只能跳转到受控业务类型,前端不接受后端返回任意 URL。 6. 通知已读状态只属于当前接收人,任何查询和更新都必须带接收人条件。 7. 过期通知不进入列表和未读数,但历史数据按保留策略清理,不由用户删除。 8. 站内通知是简单写模型,使用 Application + Query + Infrastructure,不为形式强行创建领域聚合。 9. 后续新增短信、企微消息等外部渠道时使用独立 Delivery,不在站内通知 Handler 中追加外部调用。 ## 三、流程 ```mermaid sequenceDiagram participant Biz as 业务 Application participant DB as 业务数据库 participant Outbox as Outbox participant Relay as Outbox Relay participant Worker as Notification Worker participant Notice as tb_notification participant Web as 前端 Biz->>DB: 提交业务状态 Biz->>Outbox: 同事务写通知事件 Relay->>Worker: 至少一次投递 Worker->>Worker: 解析接收人和受控模板 Worker->>Notice: 幂等批量插入 Web->>Notice: 查询未读数/列表 Web->>Notice: 当前接收人标记已读 ``` 非关键系统告警允许直接入 Asynq,但必须携带稳定 `event_id`;不能在重试时重新生成。 ## 四、数据模型 ```sql CREATE TABLE tb_notification ( id BIGSERIAL PRIMARY KEY, event_id VARCHAR(64) NOT NULL, recipient_kind VARCHAR(32) NOT NULL, recipient_id BIGINT NOT NULL, category VARCHAR(32) NOT NULL, type VARCHAR(64) NOT NULL, severity VARCHAR(16) NOT NULL DEFAULT 'info', title VARCHAR(200) NOT NULL, body TEXT NOT NULL DEFAULT '', ref_type VARCHAR(64), ref_id BIGINT, ref_key VARCHAR(128), is_read BOOLEAN NOT NULL DEFAULT FALSE, read_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (event_id, recipient_kind, recipient_id) ); CREATE INDEX idx_notification_recipient_unread ON tb_notification (recipient_kind, recipient_id, is_read, created_at DESC); CREATE INDEX idx_notification_recipient_category ON tb_notification (recipient_kind, recipient_id, category, created_at DESC); CREATE INDEX idx_notification_expired ON tb_notification (expires_at) WHERE expires_at IS NOT NULL; ``` 字段说明: | 字段 | 说明 | |---|---| | `recipient_kind` | `account` 或 `personal_customer` | | `category` | `approval / expiry / sync / system` | | `type` | 具体通知类型常量 | | `severity` | `info / warning / error / critical` | | `ref_type` | 受控业务引用类型 | | `ref_id` | 业务主键,可为空 | | `ref_key` | 业务编号或资产标识快照,用于列表展示和资源删除后的追溯 | 不在通知表保存接收人名称、店铺名称等实时关联字段;正文已经是发送时快照,查询时也不需要联表才能展示。 ## 五、通知类型 ```go const ( NotifyTypeWeComApprovalApproved = "wecom.approval.approved" NotifyTypeWeComApprovalRejected = "wecom.approval.rejected" NotifyTypeWeComApprovalCancelled = "wecom.approval.cancelled" NotifyTypeWeComApprovalRevoked = "wecom.approval.revoked_after_approved" NotifyTypeWeComTemplateInvalid = "wecom.template.invalid" NotifyTypePackageExpiring = "package.expiring" NotifyTypeCardSyncFailed = "card_sync.failed" NotifyTypeCarrierCallbackCardNotFound = "carrier_callback.card_not_found" NotifyTypeSystemAlert = "system.alert" ) ``` 类别映射由后端常量维护,前端只按返回值展示,不自行猜测: ```go func NotificationCategory(notifyType string) string ``` ## 六、发布命令 ```go type Recipient struct { Kind string ID uint } type PublishNotificationCommand struct { EventID string Recipients []Recipient Type string Severity string TemplateData map[string]any RefType string RefID uint RefKey string ExpiresAt *time.Time } ``` Application 只提交通知类型和受控模板数据,不直接提交最终 HTML。Notification Worker 通过代码内模板注册表渲染: ```go type TemplateRenderer func(data map[string]any) (title string, body string, err error) var notificationTemplates = map[string]TemplateRenderer{ constants.NotifyTypeWeComApprovalApproved: renderWeComApprovalApproved, constants.NotifyTypePackageExpiring: renderPackageExpiring, } ``` 模板字段不足时任务失败并重试,禁止生成标题为空或只有业务编号的残缺通知。 ## 七、接收人解析 业务 Application 在发布事件前确定稳定业务接收人: | 场景 | 接收人来源 | |---|---| | 退款/充值审批结果 | 申请人账号 ID | | 通过后撤销 | 申请人 + 财务角色当前启用账号 | | 企微模板失效 | 平台超管角色账号 | | 数据同步连续失败 | 平台运维角色账号 | | 代理套餐临期 | 资产所属代理及配置的业务员账号 | | 企业套餐临期 | 企业关联业务员账号 | | 个人客户套餐临期 | 资产当前绑定的个人客户 ID | 角色接收人应在事件消费时批量解析当前启用账号,具体用户接收人直接使用事件快照中的 ID。 不存在接收人时记录消费结果 `no_recipient`,不能无限重试。 ## 八、API 后台账号、代理账号和企业账号统一使用 `/api/admin`,后端从登录上下文取得当前账号 ID。 ### 8.1 未读总数 ```http GET /api/admin/notifications/unread-count GET /api/c/v1/notifications/unread-count ``` 响应: ```json { "code": 0, "data": { "count": 12, "display_count": "12" } } ``` 超过 99 时 `display_count` 返回 `99+`,前端也必须兼容直接按 count 计算。 查询条件: ```sql recipient_kind = ? AND recipient_id = ? AND is_read = FALSE AND (expires_at IS NULL OR expires_at > NOW()) ``` 第一版直接查询 PostgreSQL,不额外维护 Redis 未读计数,避免数据库与缓存双写不一致。 ### 8.2 分类未读数 ```http GET /api/admin/notifications/unread-summary ``` ```json { "code": 0, "data": { "total": 12, "categories": { "approval": 3, "expiry": 5, "sync": 2, "system": 2 } } } ``` 个人客户第一版只返回总数,不开放运维类分类。 ### 8.3 通知列表 ```http GET /api/admin/notifications ?category=approval &type= &severity= &is_read=false &page=1&page_size=20 GET /api/c/v1/notifications?page=1&page_size=20 ``` 响应项: ```json { "id": 1001, "category": "approval", "type": "wecom.approval.approved", "severity": "info", "title": "退款审批已通过", "body": "退款单 RF202607150001 已通过企业微信审批。", "ref_type": "refund", "ref_id": 42, "ref_key": "RF202607150001", "is_read": false, "read_at": null, "created_at": "2026-07-15T10:00:00+08:00" } ``` 列表固定按 `created_at DESC, id DESC` 排序并服务端分页,最大 `page_size=50`。 ### 8.4 单条标记已读 ```http PUT /api/admin/notifications/{id}/read PUT /api/c/v1/notifications/{id}/read ``` 条件更新: ```sql UPDATE tb_notification SET is_read = TRUE, read_at = NOW() WHERE id = ? AND recipient_kind = ? AND recipient_id = ? AND is_read = FALSE; ``` 记录不存在、无权访问或已经已读时统一返回成功,保持幂等且不泄露其他用户通知是否存在。 ### 8.5 批量标记已读 ```http PUT /api/admin/notifications/read-all PUT /api/c/v1/notifications/read-all ``` ```json { "category": "approval" } ``` `category` 为空表示当前接收人的全部未过期通知。接口返回本次实际更新数量。 ### 8.6 点击并读取跳转信息 前端通常可直接使用列表中的 `ref_type/ref_id`。为了处理业务被删除、权限变化等情况,允许: ```http GET /api/admin/notifications/{id}/target ``` 后端先校验通知属于当前用户,再返回受控目标: ```json { "target_type": "refund_detail", "target_id": 42, "available": true } ``` 该接口不返回任意 URL。 ## 九、受控跳转 | ref_type | target_type | 后台页面 | |---|---|---| | `refund` | `refund_detail` | `/refunds/{id}` | | `agent_recharge` | `agent_recharge_detail` | `/agent-recharges/{id}` | | `wecom_approval` | `wecom_approval_detail` | `/operations/wecom-approvals/{id}` | | `iot_card` | `iot_card_detail` | `/iot-cards/{id}` | | `device` | `device_detail` | `/devices/{id}` | | `card_sync` | `card_sync_execution` | `/operations/card-sync?execution_id={id}` | | `system_config` | `system_config` | `/system/config` | 前端维护 `target_type -> route` 映射。未知类型只展示通知,不执行跳转。 跳转目标页面仍按原业务权限重新校验,拥有通知不等于永久拥有业务数据访问权。 ## 十、前端方案 ### 10.1 顶部铃铛 ```text 登录成功/布局挂载 → 立即请求 unread-count → 每 30 秒轮询 → 页面不可见时暂停 → 恢复可见时立即刷新 ``` - 未读为 0 时不显示数字。 - 1~99 显示实际数字,超过 99 显示 `99+`。 - 请求失败保留上一次数字,不闪回 0。 - 铃铛区域固定宽度,数字变化不得造成导航布局位移。 ### 10.2 通知抽屉 点击铃铛打开右侧抽屉: - 顶部显示“全部、审批、临期、系统”分段控件。 - 默认加载最近 10 条,提供“查看全部消息”。 - 未读消息使用左侧状态点,不用整行高饱和背景。 - 点击消息先本地进入已读视觉状态,再并行调用标记已读和目标解析。 - 标记已读失败时,下次未读轮询恢复服务端真实状态。 ### 10.3 通知中心 后台路由:`/notifications` 页面结构: ```text 分类 Tab + 已读状态筛选 + 严重级别筛选 通知时间线列表 全部已读按钮 服务端分页 ``` 错误和严重通知显示级别图标;普通通知不使用警告色。 个人客户使用简化通知列表,只显示套餐临期、订单和资产相关消息,不显示平台运维消息。 ## 十一、权限与数据安全 - Notification Query 从登录 Context 固定注入接收人,不接收前端传入 `recipient_id`。 - 后台账号只能查看自己的消息,平台超管也不能通过通知接口查看其他人的消息。 - 运维需要排查通知投递时,使用全局审计/运维查询接口,不复用用户通知列表接口。 - 正文禁止包含操作密码、支付密钥、身份证完整号码、长期对象存储 URL 和完整企微回调正文。 - 附件只提示“包含附件”,用户进入有权限的业务详情后获取短期下载 URL。 - 所有已读接口不区分“通知不存在”和“通知不属于当前用户”。 ## 十二、保留与清理 | 通知类型 | 展示期限 | 数据保留期限 | |---|---:|---:| | 套餐临期 | 到期后自动不展示 | 180 天 | | 审批结果 | 不自动过期 | 365 天 | | 同步异常 | 30 天 | 180 天 | | 系统告警 | 由事件指定 | 365 天 | 每日低峰期使用现有数据清理任务按批次删除超过保留期限的数据。清理不修改业务审计和领域流水。 ## 十三、外部渠道扩展 未来如果需要企微消息或短信,新增独立投递表: ```text tb_notification_delivery notification_event_id recipient channel status attempts provider_message_id last_error ``` 站内通知写入和外部渠道发送分别消费同一业务事件,互不影响。不能在 `NotificationHandler` 写入站内消息后直接循环调用企微或短信接口,否则某个外部渠道故障会拖慢所有站内消息。 ## 十四、代码结构 ```text internal/ ├── application/notification/ │ ├── publish_notification.go │ ├── mark_read.go │ └── mark_all_read.go ├── query/notification/ │ ├── list_notifications.go │ ├── unread_count.go │ └── resolve_target.go ├── infrastructure/messaging/ │ └── notification_event_handler.go ├── infrastructure/persistence/ │ └── notification_repository.go ├── handler/admin/notification.go └── handler/app/client_notification.go ``` 通知没有复杂状态机,不创建空洞的 `domain/notification` 聚合。Application 负责写操作和幂等,Query 直接使用 GORM 查询读取模型。 ## 十五、人工验证 1. 同一事件重复消费不会为同一接收人生成重复通知。 2. 同一事件的两个接收人各自生成一条通知,已读状态互不影响。 3. 用户无法读取或标记其他用户通知。 4. 过期通知不进入未读数和列表。 5. 全部已读只影响当前用户和指定分类。 6. 业务权限变化后,通知仍可展示,但目标接口重新校验权限。 7. 前端隐藏页签时暂停轮询,恢复后立即刷新。 8. 企微审批待办不重复生成站内待办,审批结果可以正常通知申请人。 9. 外部消息渠道失败不影响站内通知生成。 10. 通知正文、日志和 API 不包含密钥、操作密码或长期附件 URL。