# 新增需求 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。
