临时备份一次
This commit is contained in:
414
docs/7月迭代/基础设施/审批流.md
Normal file
414
docs/7月迭代/基础设施/审批流.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# 基础设施:通用审批流引擎
|
||||
|
||||
> 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核)
|
||||
> 设计:DDD 结构,`internal/domain/approval/`
|
||||
|
||||
---
|
||||
|
||||
## 一、业务规则
|
||||
|
||||
- 审批顺序:**提交人部门领导 → 财务**(固定两级)
|
||||
- 每级只能有一个审批人(通过角色确定,不是指定个人)
|
||||
- 上一级审批通过后,下一级审批人收到站内消息
|
||||
- **驳回**:永久拒绝,流程终止,通知申请人含驳回原因
|
||||
- **退回**:退回给提交人修改,流程终止,提交人可修改后重新提交(新建审批流)
|
||||
- **通过**:最后一级通过后,通知申请人,触发业务回调
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 审批流实例表
|
||||
CREATE TABLE tb_approval_flow (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
flow_no VARCHAR(30) NOT NULL UNIQUE,
|
||||
biz_type VARCHAR(50) NOT NULL, -- 业务类型:recharge | refund
|
||||
biz_id BIGINT NOT NULL,
|
||||
biz_no VARCHAR(50) NOT NULL DEFAULT '', -- 关联业务单号(快照)
|
||||
submitter_id BIGINT NOT NULL,
|
||||
submitter_name VARCHAR(50) NOT NULL DEFAULT '',
|
||||
current_step INT NOT NULL DEFAULT 1, -- 当前步骤(1=部门领导, 2=财务)
|
||||
total_steps INT NOT NULL DEFAULT 2,
|
||||
status INT NOT NULL DEFAULT 1, -- 1=审批中 2=已通过 3=已驳回 4=已退回
|
||||
reject_reason TEXT, -- 驳回/退回原因
|
||||
completed_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 审批步骤记录表
|
||||
CREATE TABLE tb_approval_step_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
flow_id BIGINT NOT NULL,
|
||||
step INT NOT NULL,
|
||||
step_name VARCHAR(50) NOT NULL,
|
||||
approver_id BIGINT,
|
||||
approver_name VARCHAR(50),
|
||||
result INT NOT NULL DEFAULT 0, -- 0=待审批 1=通过 2=驳回 3=退回
|
||||
remark TEXT,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_approval_flow_biz ON tb_approval_flow (biz_type, biz_id);
|
||||
CREATE INDEX idx_approval_step_flow ON tb_approval_step_record (flow_id, step);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Model
|
||||
|
||||
```go
|
||||
// internal/model/approval.go
|
||||
|
||||
const (
|
||||
ApprovalBizTypeRecharge = "recharge"
|
||||
ApprovalBizTypeRefund = "refund"
|
||||
|
||||
ApprovalStatusPending = 1 // 审批中
|
||||
ApprovalStatusApproved = 2 // 已通过
|
||||
ApprovalStatusRejected = 3 // 已驳回(永久拒绝,流程终止)
|
||||
ApprovalStatusReturned = 4 // 已退回(退回给提交人修改,可重新提交)
|
||||
|
||||
ApprovalStepResultPending = 0 // 待审批
|
||||
ApprovalStepResultApproved = 1 // 通过
|
||||
ApprovalStepResultRejected = 2 // 驳回
|
||||
ApprovalStepResultReturned = 3 // 退回
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、领域层(DDD)
|
||||
|
||||
```go
|
||||
// internal/domain/approval/approval_flow.go
|
||||
|
||||
type ApprovalFlowAggregate struct {
|
||||
model.ApprovalFlow
|
||||
Steps []*model.ApprovalStepRecord
|
||||
domainEvents []DomainEvent `gorm:"-"`
|
||||
}
|
||||
|
||||
// CanAdvance 检查当前用户是否可以审批当前步骤
|
||||
func (f *ApprovalFlowAggregate) CanAdvance(approverRoles []string) bool {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return false
|
||||
}
|
||||
if f.CurrentStep == 1 {
|
||||
return contains(approverRoles, "department_leader")
|
||||
}
|
||||
if f.CurrentStep == 2 {
|
||||
return contains(approverRoles, "finance")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Approve 通过当前步骤
|
||||
func (f *ApprovalFlowAggregate) Approve(approverID uint, approverName string, remark string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultApproved
|
||||
step.Remark = &remark
|
||||
step.ApprovedAt = &now
|
||||
|
||||
if f.CurrentStep >= f.TotalSteps {
|
||||
f.Status = model.ApprovalStatusApproved
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalCompletedEvent{FlowID: f.ID, BizType: f.BizType, BizID: f.BizID})
|
||||
} else {
|
||||
f.CurrentStep++
|
||||
f.recordEvent(ApprovalStepAdvancedEvent{
|
||||
FlowID: f.ID,
|
||||
NextStep: f.CurrentStep,
|
||||
BizType: f.BizType,
|
||||
SubmitterID: f.SubmitterID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject 驳回(永久拒绝,流程终止)
|
||||
func (f *ApprovalFlowAggregate) Reject(approverID uint, approverName string, reason string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultRejected
|
||||
step.Remark = &reason
|
||||
step.ApprovedAt = &now
|
||||
|
||||
f.Status = model.ApprovalStatusRejected
|
||||
f.RejectReason = &reason
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalRejectedEvent{
|
||||
FlowID: f.ID,
|
||||
BizType: f.BizType,
|
||||
BizID: f.BizID,
|
||||
SubmitterID: f.SubmitterID,
|
||||
Reason: reason,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return 退回给提交人修改(流程终止,提交人可重新提交)
|
||||
func (f *ApprovalFlowAggregate) Return(approverID uint, approverName string, reason string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultReturned
|
||||
step.Remark = &reason
|
||||
step.ApprovedAt = &now
|
||||
|
||||
f.Status = model.ApprovalStatusReturned
|
||||
f.RejectReason = &reason
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalReturnedEvent{
|
||||
FlowID: f.ID,
|
||||
BizType: f.BizType,
|
||||
BizID: f.BizID,
|
||||
SubmitterID: f.SubmitterID,
|
||||
Reason: reason,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、领域事件
|
||||
|
||||
```go
|
||||
// internal/domain/approval/events.go
|
||||
|
||||
// ApprovalStepAdvancedEvent 步骤推进(通知下一级审批人)
|
||||
type ApprovalStepAdvancedEvent struct {
|
||||
FlowID uint
|
||||
NextStep int
|
||||
BizType string
|
||||
SubmitterID uint
|
||||
}
|
||||
|
||||
// ApprovalCompletedEvent 审批完成(触发业务回调 + 通知申请人)
|
||||
type ApprovalCompletedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
}
|
||||
|
||||
// ApprovalRejectedEvent 驳回(通知申请人,流程永久终止)
|
||||
type ApprovalRejectedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
SubmitterID uint
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ApprovalReturnedEvent 退回给提交人修改(通知申请人,可重新提交)
|
||||
type ApprovalReturnedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
SubmitterID uint
|
||||
Reason string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、接入协议(业务层如何接入审批流)
|
||||
|
||||
任何业务接入审批流需做以下四件事:
|
||||
|
||||
### 6.1 业务表新增字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_xxx ADD COLUMN approval_flow_id BIGINT DEFAULT NULL;
|
||||
```
|
||||
|
||||
### 6.2 提交时创建审批流
|
||||
|
||||
业务提交接口(如 `POST /admin/refund-requests`):
|
||||
|
||||
```go
|
||||
// 1. 创建业务单(status=待审批)
|
||||
bizRecord, _ := s.bizStore.Create(ctx, req)
|
||||
|
||||
// 2. 创建审批流
|
||||
flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{
|
||||
BizType: model.ApprovalBizTypeRefund,
|
||||
BizID: bizRecord.ID,
|
||||
BizNo: bizRecord.RefundNo,
|
||||
SubmitterID: middleware.GetUserID(ctx),
|
||||
SubmitterName: middleware.GetUsername(ctx),
|
||||
})
|
||||
|
||||
// 3. 把 flow_id 写回业务单
|
||||
s.bizStore.UpdateApprovalFlowID(ctx, bizRecord.ID, flow.ID)
|
||||
```
|
||||
|
||||
### 6.3 监听审批事件,更新业务状态
|
||||
|
||||
每个接入方在 `internal/task/` 注册对应的 Asynq 事件处理器:
|
||||
|
||||
```go
|
||||
// internal/task/refund_approval_handler.go
|
||||
|
||||
// OnApprovalCompleted 审批通过 → 退款单状态变"已通过",触发退款
|
||||
func (h *RefundApprovalHandler) OnApprovalCompleted(ctx context.Context, event ApprovalCompletedEvent) error {
|
||||
// 按 biz_type 过滤,只处理退款类型
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.ApproveRefund(ctx, event.BizID)
|
||||
}
|
||||
|
||||
// OnApprovalRejected 审批驳回 → 退款单状态变"已拒绝"
|
||||
func (h *RefundApprovalHandler) OnApprovalRejected(ctx context.Context, event ApprovalRejectedEvent) error {
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.RejectRefund(ctx, event.BizID, event.Reason)
|
||||
}
|
||||
|
||||
// OnApprovalReturned 审批退回 → 退款单状态变"已退回",提交人可修改重提
|
||||
func (h *RefundApprovalHandler) OnApprovalReturned(ctx context.Context, event ApprovalReturnedEvent) error {
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.ReturnRefund(ctx, event.BizID, event.Reason)
|
||||
}
|
||||
```
|
||||
|
||||
充值单同理,注册 `RechargeApprovalHandler`。
|
||||
|
||||
### 6.4 退回后重新提交
|
||||
|
||||
业务层新增重新提交接口(如 `POST /admin/refund-requests/{id}/resubmit`):
|
||||
|
||||
```go
|
||||
// 1. 校验业务单 status=已退回
|
||||
if bizRecord.Status != model.RefundStatusReturned {
|
||||
return errors.New(errors.CodeForbidden, "当前状态不允许重新提交")
|
||||
}
|
||||
|
||||
// 2. 新建审批流(biz_type+biz_id 相同,旧的流程作为历史保留)
|
||||
flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{...})
|
||||
|
||||
// 3. 更新业务单:approval_flow_id = 新 flow.ID,status 回到"待审批"
|
||||
s.bizStore.Resubmit(ctx, bizRecord.ID, flow.ID)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、应用层(用例)
|
||||
|
||||
```go
|
||||
// internal/application/approval/submit_approval.go
|
||||
|
||||
type SubmitApprovalCommand struct {
|
||||
BizType string
|
||||
BizID uint
|
||||
BizNo string
|
||||
SubmitterID uint
|
||||
SubmitterName string
|
||||
}
|
||||
|
||||
type SubmitApprovalUseCase struct {
|
||||
approvalRepo approval.ApprovalRepository
|
||||
notifyPublisher *notification.NotificationPublisher
|
||||
userStore UserStore
|
||||
}
|
||||
|
||||
func (uc *SubmitApprovalUseCase) Execute(ctx context.Context, cmd SubmitApprovalCommand) (*model.ApprovalFlow, error) {
|
||||
flow := &domain_approval.ApprovalFlowAggregate{...}
|
||||
// 初始化两个步骤记录(均为待审批)
|
||||
if err := uc.approvalRepo.Create(ctx, flow); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 通知部门领导
|
||||
leaderIDs := uc.userStore.GetUsersByRole(ctx, "department_leader")
|
||||
uc.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||||
RecipientIDs: leaderIDs,
|
||||
Type: constants.NotifyTypeApprovalPending,
|
||||
Title: "您有一条待审批记录",
|
||||
Body: fmt.Sprintf("「%s」提交了%s申请", cmd.SubmitterName, bizTypeLabel(cmd.BizType)),
|
||||
RefType: "approval",
|
||||
RefID: flow.ID,
|
||||
})
|
||||
return &flow.ApprovalFlow, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、API 设计
|
||||
|
||||
### 8.1 待我审批列表
|
||||
|
||||
```
|
||||
GET /admin/approvals?status=1&biz_type=recharge&page=1&page_size=20
|
||||
```
|
||||
|
||||
### 8.2 审批通过
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/approve
|
||||
{ "remark": "审批通过" }
|
||||
```
|
||||
|
||||
### 8.3 审批驳回(永久拒绝)
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/reject
|
||||
{ "reason": "金额不符,请重新提交" }
|
||||
```
|
||||
|
||||
### 8.4 退回给提交人修改
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/return
|
||||
{ "reason": "请补充支付凭证后重新提交" }
|
||||
```
|
||||
|
||||
退回后,提交人修改业务单,再通过业务接口重新提交(如 `POST /admin/refund-requests/{id}/resubmit`)。
|
||||
|
||||
### 8.5 审批详情
|
||||
|
||||
```
|
||||
GET /admin/approvals/{flow_id}
|
||||
```
|
||||
|
||||
响应包含:flow 基本信息 + steps 列表(每步审批人、结果【通过/驳回/退回】、时间、备注)
|
||||
|
||||
---
|
||||
|
||||
## 九、前端对接
|
||||
|
||||
### 审批详情页操作区
|
||||
|
||||
当前登录用户是当前步骤审批人时,显示三个按钮:
|
||||
|
||||
```
|
||||
[审批通过] [退回修改] [驳回]
|
||||
备注/原因输入框
|
||||
```
|
||||
|
||||
- **审批通过** → `POST /admin/approvals/{id}/approve`
|
||||
- **退回修改** → `POST /admin/approvals/{id}/return`(需填退回原因)
|
||||
- **驳回** → `POST /admin/approvals/{id}/reject`(需填驳回原因)
|
||||
|
||||
### 权限控制
|
||||
|
||||
- `department_leader` 角色:步骤1为"待审批"时显示操作按钮
|
||||
- `finance` 角色:步骤2为"待审批"时显示操作按钮
|
||||
- 其他角色:只读
|
||||
340
docs/7月迭代/基础设施/站内消息.md
Normal file
340
docs/7月迭代/基础设施/站内消息.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# 基础设施:站内消息(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 里加一段即可。
|
||||
216
docs/7月迭代/基础设施/系统配置.md
Normal file
216
docs/7月迭代/基础设施/系统配置.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 基础设施:系统配置(tb_system_config)
|
||||
|
||||
> 被依赖:需求 02(H5流程配置)、需求 09(C端支付限制)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 迁移文件:YYYYMMDD_create_tb_system_config.sql
|
||||
CREATE TABLE tb_system_config (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
config_key VARCHAR(100) NOT NULL, -- 唯一键,格式:module.group.name
|
||||
config_value TEXT NOT NULL DEFAULT '', -- 值(string/number/json字符串)
|
||||
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string | int | bool | json
|
||||
module VARCHAR(50) NOT NULL DEFAULT 'general', -- 所属模块(便于按模块查询)
|
||||
description TEXT, -- 中文说明(前端展示用)
|
||||
is_readonly BOOLEAN NOT NULL DEFAULT FALSE, -- 是否只读(代码内部,不允许后台改)
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_system_config_key UNIQUE (config_key)
|
||||
);
|
||||
|
||||
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)');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Model
|
||||
|
||||
```go
|
||||
// internal/model/system_config.go
|
||||
|
||||
// SystemConfig 系统全局配置模型
|
||||
type SystemConfig struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
ConfigKey string `gorm:"column:config_key;uniqueIndex;not null" json:"config_key"`
|
||||
ConfigValue string `gorm:"column:config_value;type:text;not null;default:''" json:"config_value"`
|
||||
ValueType string `gorm:"column:value_type;type:varchar(20);not null;default:'string'" json:"value_type"`
|
||||
Module string `gorm:"column:module;type:varchar(50);not null;default:'general';index" json:"module"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
IsReadonly bool `gorm:"column:is_readonly;not null;default:false" json:"is_readonly"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0" json:"updater"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (SystemConfig) TableName() string { return "tb_system_config" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Store
|
||||
|
||||
```go
|
||||
// internal/store/postgres/system_config_store.go
|
||||
|
||||
type SystemConfigStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (s *SystemConfigStore) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)
|
||||
func (s *SystemConfigStore) GetByModule(ctx context.Context, module string) ([]model.SystemConfig, error)
|
||||
func (s *SystemConfigStore) UpdateValue(ctx context.Context, key string, value string, updaterID uint) error
|
||||
func (s *SystemConfigStore) BatchGet(ctx context.Context, keys []string) (map[string]*model.SystemConfig, error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、配置读取辅助包
|
||||
|
||||
业务代码不直接操作 Store,通过辅助函数读取,带 Redis 缓存(5分钟TTL):
|
||||
|
||||
```go
|
||||
// pkg/sysconfig/config.go
|
||||
|
||||
// GetString 读取字符串配置,返回默认值
|
||||
func GetString(ctx context.Context, key string, defaultVal string) string
|
||||
|
||||
// GetStringSlice 读取 JSON 数组配置
|
||||
func GetStringSlice(ctx context.Context, key string) ([]string, error)
|
||||
|
||||
// GetBool 读取布尔配置
|
||||
func GetBool(ctx context.Context, key string, defaultVal bool) bool
|
||||
|
||||
// InvalidateCache 更新配置后清缓存(在 UpdateValue 后调用)
|
||||
func InvalidateCache(ctx context.Context, key string)
|
||||
```
|
||||
|
||||
Redis Key:`sys:config:{config_key}`,TTL 5分钟。
|
||||
|
||||
业务代码用法:
|
||||
```go
|
||||
// 读取卡的允许支付方式
|
||||
allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_methods")
|
||||
// 返回 ["alipay","wallet"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、API 设计
|
||||
|
||||
### 6.1 获取配置列表
|
||||
|
||||
```
|
||||
GET /admin/system/config?module=c2b.payment
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"config_key": "c2b.payment.card_allowed_methods",
|
||||
"config_value": "[\"alipay\",\"wallet\"]",
|
||||
"value_type": "json",
|
||||
"description": "卡资产C端允许的支付方式",
|
||||
"is_readonly": false,
|
||||
"updated_at": "2026-07-11T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 更新配置
|
||||
|
||||
```
|
||||
PUT /admin/system/config/{config_key}
|
||||
```
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"config_value": "[\"alipay\",\"wallet\",\"wechat\"]"
|
||||
}
|
||||
```
|
||||
|
||||
权限:仅平台超管可操作。
|
||||
|
||||
### DTO
|
||||
|
||||
```go
|
||||
// internal/model/dto/system_config_dto.go
|
||||
|
||||
// SystemConfigListRequest 配置列表查询请求
|
||||
type SystemConfigListRequest struct {
|
||||
Module string `query:"module" description:"按模块过滤(可选)"`
|
||||
}
|
||||
|
||||
// SystemConfigItem 配置项响应
|
||||
type SystemConfigItem struct {
|
||||
ConfigKey string `json:"config_key"`
|
||||
ConfigValue string `json:"config_value"`
|
||||
ValueType string `json:"value_type" description:"值类型 (string/int/bool/json)"`
|
||||
Module string `json:"module"`
|
||||
Description string `json:"description"`
|
||||
IsReadonly bool `json:"is_readonly"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UpdateSystemConfigRequest 更新配置请求
|
||||
type UpdateSystemConfigRequest struct {
|
||||
ConfigValue string `json:"config_value" validate:"required" description:"新配置值"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、前端对接
|
||||
|
||||
### 页面:系统设置 > 系统配置
|
||||
|
||||
**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。**
|
||||
|
||||
调用流程:
|
||||
1. 进入页面 → `GET /admin/system/config`(不传 module = 返回全部)
|
||||
2. 按 module 分组展示,`is_readonly=true` 的配置只读
|
||||
3. 修改某项 → `PUT /admin/system/config/{config_key}`,body: `{config_value}`
|
||||
4. 修改成功后提示"配置已更新,约5分钟后生效"(Redis 缓存 TTL)
|
||||
|
||||
**C端支付限制配置展示建议**(针对 `c2b.payment` 模块):
|
||||
|
||||
不要让后台用户手动填 JSON,前端渲染成 CheckboxGroup:
|
||||
|
||||
```
|
||||
卡资产允许支付方式:
|
||||
☑ 支付宝 ☑ 钱包 ☐ 微信
|
||||
|
||||
设备允许支付方式:
|
||||
☐ 支付宝 ☑ 钱包 ☑ 微信
|
||||
```
|
||||
|
||||
前端把选中项序列化成 `["alipay","wallet"]` 提交。
|
||||
Reference in New Issue
Block a user