341 lines
10 KiB
Markdown
341 lines
10 KiB
Markdown
# 基础设施:站内消息(Notification)
|
||
|
||
> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒)
|
||
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
|
||
|
||
---
|
||
|
||
## 一、设计原则
|
||
|
||
- **业务代码不直接写通知**:只发 Asynq 任务,异步处理
|
||
- **不过度抽象**:不用 interface,用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码)
|
||
- **前端轮询**:30秒一次 `/admin/notifications/unread-count`,不用 WebSocket
|
||
|
||
---
|
||
|
||
## 二、数据库
|
||
|
||
```sql
|
||
-- 迁移文件:YYYYMMDD_create_tb_notification.sql
|
||
CREATE TABLE tb_notification (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
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 '', -- 正文(支持简单 HTML)
|
||
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | package
|
||
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);
|
||
CREATE INDEX idx_notification_created ON tb_notification (created_at DESC);
|
||
|
||
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"`
|
||
RecipientID uint `gorm:"column:recipient_id;not null;index" json:"recipient_id"`
|
||
RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin'" 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
|
||
|
||
业务代码调这个发布通知,内部异步入队:
|
||
|
||
```go
|
||
// internal/service/notification/publisher.go
|
||
|
||
// SendPayload 发送通知的参数
|
||
type SendPayload struct {
|
||
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 发布通知(异步,不阻塞业务)
|
||
// 失败只记录日志,不影响业务事务
|
||
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) {
|
||
if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil {
|
||
p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type))
|
||
}
|
||
}
|
||
```
|
||
|
||
业务代码调用示例(审批推进后通知下一审批人):
|
||
|
||
```go
|
||
p.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||
RecipientIDs: []uint{nextApproverID},
|
||
RecipientType: constants.NotifyRecipientAdmin,
|
||
Type: constants.NotifyTypeApprovalPending,
|
||
Title: "您有一条待审批记录",
|
||
Body: fmt.Sprintf("代理「%s」提交了充值申请,请及时审批。", shopName),
|
||
RefType: "approval",
|
||
RefID: approvalFlowID,
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 六、消费侧: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{
|
||
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).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 /admin/notifications/unread-count
|
||
```
|
||
|
||
响应:
|
||
```json
|
||
{ "code": 0, "data": { "count": 5 } }
|
||
```
|
||
|
||
### 7.2 通知列表
|
||
|
||
```
|
||
GET /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 /admin/notifications/{id}/read
|
||
```
|
||
|
||
### 7.4 全部标记已读
|
||
|
||
```
|
||
PUT /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 /admin/notifications/unread-count(30秒一次)
|
||
→ count > 0 时铃铛显示红点 + 数字
|
||
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
|
||
→ 打开时调 GET /admin/notifications?is_read=false
|
||
→ 点击某条通知 → PUT /admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
|
||
```
|
||
|
||
### 跳转逻辑(ref_type)
|
||
|
||
| ref_type | 跳转页面 |
|
||
|----------|---------|
|
||
| `approval` | `/approval/{ref_id}` 审批详情 |
|
||
| `recharge` | `/recharge/{ref_id}` 充值单详情 |
|
||
| `refund` | `/refund/{ref_id}` 退款单详情 |
|
||
| `package` | `/assets/{ref_id}` 资产详情(临期提醒) |
|
||
|
||
### 通知列表页(/notifications)
|
||
|
||
筛选:通知类型(下拉)、已读状态
|
||
操作:全部已读按钮
|
||
列表字段:类型、标题、时间、已读状态
|
||
点击行:跳转关联业务详情
|
||
|
||
---
|
||
|
||
## 九、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 里加一段即可。
|