14 KiB
新增需求 03:站内通知详细方案
状态:已合并至标准评审稿,本文保留为实施明细。
评审主文档:../7月迭代技术方案-标准评审稿.md
范围:后台账号、代理账号、企业账号和个人客户的站内通知。
一、定位
站内通知只负责“用户登录本系统后可以看到的消息”,不承担企业微信审批流程,也不直接发送企业微信消息。
第一版消息来源:
| 来源 | 接收人 | 示例 |
|---|---|---|
| 企微审批结果 | 业务申请人、相关平台角色 | 退款审批通过、线下充值审批驳回 |
| 套餐临期 | 代理账号、企业业务员、个人客户 | 套餐剩余 15/7/3 天 |
| 数据同步异常 | 平台运维角色 | 模板失效、回调找不到资产、连续同步失败 |
| 系统配置异常 | 平台超管 | 企微配置不可用、支付配置失效 |
企微审批中的“待我审批”由企业微信自身提醒,不在本系统重复创建审批待办。
二、设计原则
- 业务事务不直接写
tb_notification,关键事件通过 Outbox 可靠投递。 - 一条事件向多个接收人发送时,每个接收人保存一条独立通知。
- 使用
event_id + recipient_kind + recipient_id保证重复消费不生成重复消息。 - 通知正文是发送时快照,使用受控纯文本模板,不保存任意 HTML。
- 通知只能跳转到受控业务类型,前端不接受后端返回任意 URL。
- 通知已读状态只属于当前接收人,任何查询和更新都必须带接收人条件。
- 过期通知不进入列表和未读数,但历史数据按保留策略清理,不由用户删除。
- 站内通知是简单写模型,使用 Application + Query + Infrastructure,不为形式强行创建领域聚合。
- 后续新增短信、企微消息等外部渠道时使用独立 Delivery,不在站内通知 Handler 中追加外部调用。
三、流程
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;不能在重试时重新生成。
四、数据模型
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 |
业务编号或资产标识快照,用于列表展示和资源删除后的追溯 |
不在通知表保存接收人名称、店铺名称等实时关联字段;正文已经是发送时快照,查询时也不需要联表才能展示。
五、通知类型
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"
)
类别映射由后端常量维护,前端只按返回值展示,不自行猜测:
func NotificationCategory(notifyType string) string
六、发布命令
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 通过代码内模板注册表渲染:
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 未读总数
GET /api/admin/notifications/unread-count
GET /api/c/v1/notifications/unread-count
响应:
{
"code": 0,
"data": {
"count": 12,
"display_count": "12"
}
}
超过 99 时 display_count 返回 99+,前端也必须兼容直接按 count 计算。
查询条件:
recipient_kind = ?
AND recipient_id = ?
AND is_read = FALSE
AND (expires_at IS NULL OR expires_at > NOW())
第一版直接查询 PostgreSQL,不额外维护 Redis 未读计数,避免数据库与缓存双写不一致。
8.2 分类未读数
GET /api/admin/notifications/unread-summary
{
"code": 0,
"data": {
"total": 12,
"categories": {
"approval": 3,
"expiry": 5,
"sync": 2,
"system": 2
}
}
}
个人客户第一版只返回总数,不开放运维类分类。
8.3 通知列表
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
响应项:
{
"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 单条标记已读
PUT /api/admin/notifications/{id}/read
PUT /api/c/v1/notifications/{id}/read
条件更新:
UPDATE tb_notification
SET is_read = TRUE, read_at = NOW()
WHERE id = ?
AND recipient_kind = ?
AND recipient_id = ?
AND is_read = FALSE;
记录不存在、无权访问或已经已读时统一返回成功,保持幂等且不泄露其他用户通知是否存在。
8.5 批量标记已读
PUT /api/admin/notifications/read-all
PUT /api/c/v1/notifications/read-all
{
"category": "approval"
}
category 为空表示当前接收人的全部未过期通知。接口返回本次实际更新数量。
8.6 点击并读取跳转信息
前端通常可直接使用列表中的 ref_type/ref_id。为了处理业务被删除、权限变化等情况,允许:
GET /api/admin/notifications/{id}/target
后端先校验通知属于当前用户,再返回受控目标:
{
"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 顶部铃铛
登录成功/布局挂载
→ 立即请求 unread-count
→ 每 30 秒轮询
→ 页面不可见时暂停
→ 恢复可见时立即刷新
- 未读为 0 时不显示数字。
- 1~99 显示实际数字,超过 99 显示
99+。 - 请求失败保留上一次数字,不闪回 0。
- 铃铛区域固定宽度,数字变化不得造成导航布局位移。
10.2 通知抽屉
点击铃铛打开右侧抽屉:
- 顶部显示“全部、审批、临期、系统”分段控件。
- 默认加载最近 10 条,提供“查看全部消息”。
- 未读消息使用左侧状态点,不用整行高饱和背景。
- 点击消息先本地进入已读视觉状态,再并行调用标记已读和目标解析。
- 标记已读失败时,下次未读轮询恢复服务端真实状态。
10.3 通知中心
后台路由:/notifications
页面结构:
分类 Tab + 已读状态筛选 + 严重级别筛选
通知时间线列表
全部已读按钮
服务端分页
错误和严重通知显示级别图标;普通通知不使用警告色。
个人客户使用简化通知列表,只显示套餐临期、订单和资产相关消息,不显示平台运维消息。
十一、权限与数据安全
- Notification Query 从登录 Context 固定注入接收人,不接收前端传入
recipient_id。 - 后台账号只能查看自己的消息,平台超管也不能通过通知接口查看其他人的消息。
- 运维需要排查通知投递时,使用全局审计/运维查询接口,不复用用户通知列表接口。
- 正文禁止包含操作密码、支付密钥、身份证完整号码、长期对象存储 URL 和完整企微回调正文。
- 附件只提示“包含附件”,用户进入有权限的业务详情后获取短期下载 URL。
- 所有已读接口不区分“通知不存在”和“通知不属于当前用户”。
十二、保留与清理
| 通知类型 | 展示期限 | 数据保留期限 |
|---|---|---|
| 套餐临期 | 到期后自动不展示 | 180 天 |
| 审批结果 | 不自动过期 | 365 天 |
| 同步异常 | 30 天 | 180 天 |
| 系统告警 | 由事件指定 | 365 天 |
每日低峰期使用现有数据清理任务按批次删除超过保留期限的数据。清理不修改业务审计和领域流水。
十三、外部渠道扩展
未来如果需要企微消息或短信,新增独立投递表:
tb_notification_delivery
notification_event_id
recipient
channel
status
attempts
provider_message_id
last_error
站内通知写入和外部渠道发送分别消费同一业务事件,互不影响。不能在 NotificationHandler 写入站内消息后直接循环调用企微或短信接口,否则某个外部渠道故障会拖慢所有站内消息。
十四、代码结构
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 查询读取模型。
十五、人工验证
- 同一事件重复消费不会为同一接收人生成重复通知。
- 同一事件的两个接收人各自生成一条通知,已读状态互不影响。
- 用户无法读取或标记其他用户通知。
- 过期通知不进入未读数和列表。
- 全部已读只影响当前用户和指定分类。
- 业务权限变化后,通知仍可展示,但目标接口重新校验权限。
- 前端隐藏页签时暂停轮询,恢复后立即刷新。
- 企微审批待办不重复生成站内待办,审批结果可以正常通知申请人。
- 外部消息渠道失败不影响站内通知生成。
- 通知正文、日志和 API 不包含密钥、操作密码或长期附件 URL。