Files
junhong_cmp_fiber/docs/7月迭代/基础设施/审批流.md
2026-07-13 12:01:18 +09:00

415 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 基础设施:通用审批流引擎
> 被依赖:需求 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.IDstatus 回到"待审批"
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为"待审批"时显示操作按钮
- 其他角色:只读