458 lines
14 KiB
Markdown
458 lines
14 KiB
Markdown
# 新增需求 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。
|