415 lines
12 KiB
Markdown
415 lines
12 KiB
Markdown
# 基础设施:通用审批流引擎
|
||
|
||
> 被依赖:需求 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为"待审批"时显示操作按钮
|
||
- 其他角色:只读
|