# 基础设施：站内消息（Notification）

> 历史状态：初版方案，已被站内通知详细方案替代。
> 替代方案：[站内通知详细方案](../新增需求/03-站内通知详细方案.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
> 被依赖：需求 18/20/21（审批流通知）、需求 22（临期提醒）
> Phase 2 扩展：企业微信推送、短信通知（预留插拔接口）

---

## 一、设计原则

- **业务代码不直接写通知表**：统一通过通知发布器或领域事件异步处理
- **关键领域事件先写 Outbox**：审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq，禁止事务提交后直接入队
- **通知消费必须幂等**：使用稳定的 `event_id` 和接收人唯一约束，Asynq 重试不得重复生成站内消息
- **不过度抽象**：不用 interface，用具体的 `NotificationPublisher`（后续加渠道 = 在 handler 里加代码）
- **前端轮询**：30秒一次 `/api/admin/notifications/unread-count`，不用 WebSocket

```mermaid
sequenceDiagram
    participant Biz as 业务事务
    participant Outbox as Outbox
    participant Relay as Relay
    participant Worker as Notification Worker
    participant DB as tb_notification
    participant Web as 后台前端

    Biz->>Outbox: 同事务写领域事件
    Relay->>Outbox: 拉取待投递事件
    Relay->>Worker: 至少一次投递
    Worker->>DB: 按 event_id + recipient 幂等插入
    Web->>DB: 经 API 轮询未读数/通知列表
    Web->>DB: 经 API 标记已读
```

---

## 二、数据库

```sql
-- 迁移文件：YYYYMMDD_create_tb_notification.sql
CREATE TABLE tb_notification (
    id             BIGSERIAL PRIMARY KEY,
    event_id       VARCHAR(64) NOT NULL,          -- 领域事件ID或调用方请求ID，用于幂等
    recipient_id   BIGINT NOT NULL,          -- 用户ID（admin user 或 agent user）
    recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent
    type           VARCHAR(50) NOT NULL,     -- 通知类型（见常量定义）
    title          VARCHAR(200) NOT NULL,    -- 标题
    body           TEXT NOT NULL DEFAULT '', -- 纯文本正文
    ref_type       VARCHAR(50),              -- 关联业务类型 approval | recharge | refund | iot_card | device
    ref_id         BIGINT,                   -- 关联业务ID
    is_read        BOOLEAN NOT NULL DEFAULT FALSE,
    read_at        TIMESTAMPTZ,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at     TIMESTAMPTZ               -- 过期自动不展示（可选，临期提醒用）
);

CREATE INDEX idx_notification_recipient
    ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC);
CREATE INDEX idx_notification_created   ON tb_notification (created_at DESC);
CREATE UNIQUE INDEX uq_notification_event_recipient
    ON tb_notification (event_id, recipient_id, recipient_type);

COMMENT ON TABLE tb_notification IS '站内消息通知表';
```

---

## 三、常量定义

```go
// pkg/constants/notification.go

// 通知类型
const (
    NotifyTypeApprovalPending  = "approval.pending"   // 待我审批
    NotifyTypeApprovalDone     = "approval.done"       // 审批完成（申请人收）
    NotifyTypeApprovalRejected = "approval.rejected"   // 审批驳回（申请人收，流程终止）
    NotifyTypeApprovalReturned = "approval.returned"   // 审批退回（申请人收，可修改后重新提交）
    NotifyTypePackageExpiring  = "package.expiring"    // 套餐临期
    NotifyTypeSystemAlert      = "system.alert"        // 系统通知
)

// 通知接收者类型
const (
    NotifyRecipientAdmin = "admin"  // 平台用户
    NotifyRecipientAgent = "agent"  // 代理用户（预留）
)
```

---

## 四、Model

```go
// internal/model/notification.go

// Notification 站内消息模型
type Notification struct {
    ID            uint       `gorm:"column:id;primaryKey" json:"id"`
    EventID       string     `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"`
    RecipientID   uint       `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"`
    RecipientType string     `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"`
    Type          string     `gorm:"column:type;type:varchar(50);not null" json:"type"`
    Title         string     `gorm:"column:title;type:varchar(200);not null" json:"title"`
    Body          string     `gorm:"column:body;type:text;not null;default:''" json:"body"`
    RefType       *string    `gorm:"column:ref_type;type:varchar(50)" json:"ref_type,omitempty"`
    RefID         *uint      `gorm:"column:ref_id" json:"ref_id,omitempty"`
    IsRead        bool       `gorm:"column:is_read;not null;default:false" json:"is_read"`
    ReadAt        *time.Time `gorm:"column:read_at" json:"read_at,omitempty"`
    CreatedAt     time.Time  `gorm:"column:created_at;not null" json:"created_at"`
    ExpiresAt     *time.Time `gorm:"column:expires_at" json:"expires_at,omitempty"`
}

func (Notification) TableName() string { return "tb_notification" }
```

---

## 五、发布侧：NotificationPublisher

非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器，而由 `TaskCreated`、`ProcessApproved` 等事件处理器转换为通知载荷。

领域事件通知使用领域事件自身的 `event_id`；非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。

```go
// internal/infrastructure/messaging/notification_publisher.go

// SendPayload 发送通知的参数
type SendPayload struct {
    EventID       string   // 领域事件ID或调用方请求ID，同一次重试必须保持不变
    RecipientIDs  []uint   // 接收人ID列表
    RecipientType string   // admin | agent
    Type          string   // 通知类型常量
    Title         string   // 标题
    Body          string   // 正文
    RefType       string   // 关联业务类型（可选）
    RefID         uint     // 关联业务ID（可选）
    ExpiresAt     *time.Time // 过期时间（可选）
}

// NotificationPublisher 通知发布器（非接口，简单具体实现）
type NotificationPublisher struct {
    queueClient *queue.Client
    logger      *zap.Logger
}

// Publish 发布通知（异步）
// 返回错误供调用方或 Asynq Handler 决定重试，禁止吞掉关键通知错误。
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error {
    if payload.EventID == "" {
        return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空")
    }
    if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil {
        p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type))
        return err
    }
    return nil
}
```

审批事件处理器调用示例（节点激活后通知候选审批人）：

```go
return h.notifyPublisher.Publish(ctx, notification.SendPayload{
    EventID:       event.EventID,
    RecipientIDs:  []uint{nextApproverID},
    RecipientType: constants.NotifyRecipientAdmin,
    Type:          constants.NotifyTypeApprovalPending,
    Title:         "您有一条待审批记录",
    Body:          fmt.Sprintf("代理「%s」提交了充值申请，请及时审批。", shopName),
    RefType:       "approval",
    RefID:         processInstanceID,
})
```

审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用，事件仍保留在数据库，由 Relay 重试；通知处理失败则由 Asynq 重试当前任务。

---

## 六、消费侧：Asynq Task Handler

```go
// internal/task/notification_handler.go

// HandleNotification 处理通知发送任务
func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.Task) error {
    var payload notification.SendPayload
    if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
        return fmt.Errorf("反序列化通知载荷失败: %w", err)
    }

    // 批量写入 tb_notification
    records := make([]model.Notification, 0, len(payload.RecipientIDs))
    for _, uid := range payload.RecipientIDs {
        ref_type := (*string)(nil)
        ref_id := (*uint)(nil)
        if payload.RefType != "" {
            ref_type = &payload.RefType
        }
        if payload.RefID != 0 {
            ref_id = &payload.RefID
        }
        records = append(records, model.Notification{
            EventID:        payload.EventID,
            RecipientID:   uid,
            RecipientType: payload.RecipientType,
            Type:          payload.Type,
            Title:         payload.Title,
            Body:          payload.Body,
            RefType:       ref_type,
            RefID:         ref_id,
            ExpiresAt:     payload.ExpiresAt,
        })
    }

    if err := h.db.WithContext(ctx).
        Clauses(clause.OnConflict{DoNothing: true}).
        CreateInBatches(records, 100).Error; err != nil {
        return fmt.Errorf("批量写入通知失败: %w", err)
    }

    // Phase 2：在此处加企微/短信调用
    // for _, sender := range h.extraSenders { sender.Send(ctx, payload) }

    return nil
}
```

---

## 七、API 设计

### 7.1 未读数量（前端轮询用，30秒一次）

```
GET /api/admin/notifications/unread-count
```

响应：
```json
{ "code": 0, "data": { "count": 5 } }
```

### 7.2 通知列表

```
GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
```

请求参数：
```go
type NotificationListRequest struct {
    IsRead *bool  `query:"is_read" description:"是否已读（不传=全部）"`
    Type   string `query:"type"    description:"通知类型过滤（可选）"`
    Page   int    `query:"page"    description:"页码"`
    PageSize int  `query:"page_size" description:"每页数量（最大50）"`
}
```

响应：
```json
{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 1,
        "type": "approval.pending",
        "title": "您有一条待审批记录",
        "body": "代理「XX店」提交了充值申请，请及时审批。",
        "ref_type": "approval",
        "ref_id": 42,
        "is_read": false,
        "created_at": "2026-07-11T10:00:00Z"
      }
    ],
    "total": 3,
    "page": 1,
    "page_size": 20
  }
}
```

### 7.3 标记已读

```
PUT /api/admin/notifications/{id}/read
```

### 7.4 全部标记已读

```
PUT /api/admin/notifications/read-all
```

可传 `type` 过滤（只把某类型全部已读）：
```json
{ "type": "approval.pending" }
```

### DTO

```go
// internal/model/dto/notification_dto.go

type NotificationListRequest struct {
    IsRead   *bool  `query:"is_read"`
    Type     string `query:"type"`
    Page     int    `query:"page" default:"1"`
    PageSize int    `query:"page_size" default:"20"`
}

type NotificationItem struct {
    ID        uint       `json:"id"`
    Type      string     `json:"type" description:"通知类型"`
    Title     string     `json:"title"`
    Body      string     `json:"body"`
    RefType   *string    `json:"ref_type,omitempty"`
    RefID     *uint      `json:"ref_id,omitempty"`
    IsRead    bool       `json:"is_read"`
    ReadAt    *time.Time `json:"read_at,omitempty"`
    CreatedAt time.Time  `json:"created_at"`
}

type UnreadCountResponse struct {
    Count int64 `json:"count"`
}

type ReadAllRequest struct {
    Type string `json:"type" description:"通知类型（为空则全部已读）"`
}
```

---

## 八、前端对接

### 顶部导航栏铃铛

```
组件挂载 → 轮询 GET /api/admin/notifications/unread-count（30秒一次）
→ count > 0 时铃铛显示红点 + 数字
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
→ 打开时调 GET /api/admin/notifications?is_read=false
→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
```

### 跳转逻辑（ref_type）

| ref_type | 跳转页面 |
|----------|---------|
| `approval` | `/approvals/instances/{ref_id}` 审批详情 |
| `recharge` | `/agent-recharges/{ref_id}` 充值单详情 |
| `refund`   | `/refunds/{ref_id}` 退款单详情 |
| `iot_card` | `/iot-cards/{ref_id}` IoT卡详情（临期提醒） |
| `device`   | `/devices/{ref_id}` 设备详情（临期提醒） |

### 通知列表页（/notifications）

筛选：通知类型（下拉）、已读状态
操作：全部已读按钮
列表字段：类型、标题、时间、已读状态
点击行：跳转关联业务详情

前端页面不可见时暂停未读数轮询，恢复可见时立即刷新。通知正文按纯文本渲染；未来需要富文本时使用受控模板和统一净化，禁止直接渲染业务方提交的 HTML。

---

## 九、Phase 2 扩展预留

当需要接入企微通知时，只需在 `HandleNotification` 里追加：

```go
// 企微通知（Phase 2）
if h.wecomClient != nil {
    for _, uid := range payload.RecipientIDs {
        wecomOpenID := h.userStore.GetWecomOpenID(ctx, uid)
        h.wecomClient.SendMessage(wecomOpenID, payload.Title, payload.Body)
    }
}
```

业务代码零修改，Handler 里加一段即可。
