迭代计划准备
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,35 @@
|
||||
# 基础设施:站内消息(Notification)
|
||||
|
||||
> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒)
|
||||
> 状态:待评审
|
||||
> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒)
|
||||
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
- **业务代码不直接写通知**:只发 Asynq 任务,异步处理
|
||||
- **业务代码不直接写通知表**:统一通过通知发布器或领域事件异步处理
|
||||
- **关键领域事件先写 Outbox**:审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq,禁止事务提交后直接入队
|
||||
- **通知消费必须幂等**:使用稳定的 `event_id` 和接收人唯一约束,Asynq 重试不得重复生成站内消息
|
||||
- **不过度抽象**:不用 interface,用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码)
|
||||
- **前端轮询**:30秒一次 `/admin/notifications/unread-count`,不用 WebSocket
|
||||
- **前端轮询**: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 标记已读
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -19,12 +39,13 @@
|
||||
-- 迁移文件: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 '', -- 正文(支持简单 HTML)
|
||||
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | package
|
||||
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,
|
||||
@@ -32,8 +53,11 @@ CREATE TABLE tb_notification (
|
||||
expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_recipient ON tb_notification (recipient_id, recipient_type, is_read);
|
||||
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 '站内消息通知表';
|
||||
```
|
||||
@@ -72,8 +96,9 @@ const (
|
||||
// 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"`
|
||||
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"`
|
||||
@@ -92,13 +117,16 @@ func (Notification) TableName() string { return "tb_notification" }
|
||||
|
||||
## 五、发布侧:NotificationPublisher
|
||||
|
||||
业务代码调这个发布通知,内部异步入队:
|
||||
非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated`、`ProcessApproved` 等事件处理器转换为通知载荷。
|
||||
|
||||
领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。
|
||||
|
||||
```go
|
||||
// internal/service/notification/publisher.go
|
||||
// internal/infrastructure/messaging/notification_publisher.go
|
||||
|
||||
// SendPayload 发送通知的参数
|
||||
type SendPayload struct {
|
||||
EventID string // 领域事件ID或调用方请求ID,同一次重试必须保持不变
|
||||
RecipientIDs []uint // 接收人ID列表
|
||||
RecipientType string // admin | agent
|
||||
Type string // 通知类型常量
|
||||
@@ -115,29 +143,37 @@ type NotificationPublisher struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Publish 发布通知(异步,不阻塞业务)
|
||||
// 失败只记录日志,不影响业务事务
|
||||
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) {
|
||||
// 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
|
||||
p.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||||
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: approvalFlowID,
|
||||
RefID: processInstanceID,
|
||||
})
|
||||
```
|
||||
|
||||
审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。
|
||||
|
||||
---
|
||||
|
||||
## 六、消费侧:Asynq Task Handler
|
||||
@@ -164,6 +200,7 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T
|
||||
ref_id = &payload.RefID
|
||||
}
|
||||
records = append(records, model.Notification{
|
||||
EventID: payload.EventID,
|
||||
RecipientID: uid,
|
||||
RecipientType: payload.RecipientType,
|
||||
Type: payload.Type,
|
||||
@@ -175,7 +212,9 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).CreateInBatches(records, 100).Error; err != nil {
|
||||
if err := h.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
CreateInBatches(records, 100).Error; err != nil {
|
||||
return fmt.Errorf("批量写入通知失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -193,7 +232,7 @@ func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.T
|
||||
### 7.1 未读数量(前端轮询用,30秒一次)
|
||||
|
||||
```
|
||||
GET /admin/notifications/unread-count
|
||||
GET /api/admin/notifications/unread-count
|
||||
```
|
||||
|
||||
响应:
|
||||
@@ -204,7 +243,7 @@ GET /admin/notifications/unread-count
|
||||
### 7.2 通知列表
|
||||
|
||||
```
|
||||
GET /admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
|
||||
GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
|
||||
```
|
||||
|
||||
请求参数:
|
||||
@@ -244,13 +283,13 @@ type NotificationListRequest struct {
|
||||
### 7.3 标记已读
|
||||
|
||||
```
|
||||
PUT /admin/notifications/{id}/read
|
||||
PUT /api/admin/notifications/{id}/read
|
||||
```
|
||||
|
||||
### 7.4 全部标记已读
|
||||
|
||||
```
|
||||
PUT /admin/notifications/read-all
|
||||
PUT /api/admin/notifications/read-all
|
||||
```
|
||||
|
||||
可传 `type` 过滤(只把某类型全部已读):
|
||||
@@ -298,29 +337,32 @@ type ReadAllRequest struct {
|
||||
### 顶部导航栏铃铛
|
||||
|
||||
```
|
||||
组件挂载 → 轮询 GET /admin/notifications/unread-count(30秒一次)
|
||||
组件挂载 → 轮询 GET /api/admin/notifications/unread-count(30秒一次)
|
||||
→ count > 0 时铃铛显示红点 + 数字
|
||||
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
|
||||
→ 打开时调 GET /admin/notifications?is_read=false
|
||||
→ 点击某条通知 → PUT /admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
|
||||
→ 打开时调 GET /api/admin/notifications?is_read=false
|
||||
→ 点击某条通知 → PUT /api/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}` 资产详情(临期提醒) |
|
||||
| `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 扩展预留
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 基础设施:系统配置(tb_system_config)
|
||||
|
||||
> 被依赖:需求 02(H5流程配置)、需求 09(C端支付限制)
|
||||
> 状态:待评审
|
||||
> 被依赖:需求 09(C端支付限制)
|
||||
|
||||
---
|
||||
|
||||
@@ -8,6 +9,25 @@
|
||||
|
||||
将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Admin as 平台超管
|
||||
participant Web as 后台配置页
|
||||
participant API as SystemConfig Application
|
||||
participant DB as PostgreSQL
|
||||
participant Redis as Redis
|
||||
participant Biz as 业务读取方
|
||||
|
||||
Admin->>Web: 修改受控配置表单
|
||||
Web->>API: PUT /api/admin/system/config/{config_key}
|
||||
API->>API: 按配置 key 注册规则校验类型和值域
|
||||
API->>DB: 更新值并写审计日志
|
||||
API->>Redis: 删除对应缓存
|
||||
API-->>Web: 返回最新配置和更新时间
|
||||
Biz->>Redis: 下次读取缓存未命中
|
||||
Biz->>DB: 读取最新配置并回填缓存
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
@@ -35,14 +55,11 @@ COMMENT ON TABLE tb_system_config IS '系统全局配置表';
|
||||
INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES
|
||||
-- C端支付限制(需求9)
|
||||
('c2b.payment.card_allowed_methods', '["alipay","wallet"]', 'json', 'c2b.payment', '卡资产C端允许的支付方式(alipay/wechat/wallet)'),
|
||||
('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式'),
|
||||
|
||||
-- H5流程顺序(需求2,per-asset 覆盖,此处是全局默认)
|
||||
-- 注意:per-asset 的 realname_policy 已在 iot_card.realname_policy 字段存储
|
||||
-- 此处仅存"H5绑定手机后的默认流程"
|
||||
('h5.flow.default_realname_policy', 'after_order', 'string', 'h5.flow', 'H5新用户默认实名策略(none/before_order/after_order)');
|
||||
('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式');
|
||||
```
|
||||
|
||||
需求02不写入全局默认实名策略。H5 始终读取卡或设备自身的 `realname_policy`;新建资产使用模型默认 `after_order`,避免全局 Key 与资产字段产生两套优先级。
|
||||
|
||||
---
|
||||
|
||||
## 三、Model
|
||||
@@ -123,7 +140,7 @@ allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_met
|
||||
### 6.1 获取配置列表
|
||||
|
||||
```
|
||||
GET /admin/system/config?module=c2b.payment
|
||||
GET /api/admin/system/config?module=c2b.payment
|
||||
```
|
||||
|
||||
响应:
|
||||
@@ -148,7 +165,7 @@ GET /admin/system/config?module=c2b.payment
|
||||
### 6.2 更新配置
|
||||
|
||||
```
|
||||
PUT /admin/system/config/{config_key}
|
||||
PUT /api/admin/system/config/{config_key}
|
||||
```
|
||||
|
||||
请求体:
|
||||
@@ -187,6 +204,8 @@ type UpdateSystemConfigRequest struct {
|
||||
}
|
||||
```
|
||||
|
||||
更新接口不能只校验 `value_type`。Application 需要按 `config_key` 注册允许值,例如支付方式只能来自 `alipay/wechat/wallet`,实名策略只能来自 `none/before_order/after_order`。未知 key 默认只读,禁止通过通用页面写入任意系统配置。
|
||||
|
||||
---
|
||||
|
||||
## 七、前端对接
|
||||
@@ -196,10 +215,10 @@ type UpdateSystemConfigRequest struct {
|
||||
**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。**
|
||||
|
||||
调用流程:
|
||||
1. 进入页面 → `GET /admin/system/config`(不传 module = 返回全部)
|
||||
1. 进入页面 → `GET /api/admin/system/config`(不传 module = 返回全部)
|
||||
2. 按 module 分组展示,`is_readonly=true` 的配置只读
|
||||
3. 修改某项 → `PUT /admin/system/config/{config_key}`,body: `{config_value}`
|
||||
4. 修改成功后提示"配置已更新,约5分钟后生效"(Redis 缓存 TTL)
|
||||
3. 修改某项 → `PUT /api/admin/system/config/{config_key}`,body: `{config_value}`
|
||||
4. 更新成功后后端立即删除该 key 的缓存,前端提示“配置已更新”并刷新当前值
|
||||
|
||||
**C端支付限制配置展示建议**(针对 `c2b.payment` 模块):
|
||||
|
||||
@@ -214,3 +233,5 @@ type UpdateSystemConfigRequest struct {
|
||||
```
|
||||
|
||||
前端把选中项序列化成 `["alipay","wallet"]` 提交。
|
||||
|
||||
通用 Key-Value 页面只作为管理壳层,已知业务配置必须使用受控组件(单选、复选或开关),不向运营人员暴露 JSON 文本框。
|
||||
|
||||
Reference in New Issue
Block a user