迭代方案确认

This commit is contained in:
2026-07-17 16:39:41 +08:00
parent bcf3e31db6
commit d022cc8788
36 changed files with 3104 additions and 6882 deletions

View File

@@ -0,0 +1,457 @@
# 新增需求 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 时不显示数字。
- 199 显示实际数字,超过 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。