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

12 KiB
Raw Blame History

基础设施:通用审批流引擎

被依赖:需求 18多人审批、需求 20退款审批、需求 21充值审核
设计DDD 结构,internal/domain/approval/


一、业务规则

  • 审批顺序:提交人部门领导 → 财务(固定两级)
  • 每级只能有一个审批人(通过角色确定,不是指定个人)
  • 上一级审批通过后,下一级审批人收到站内消息
  • 驳回:永久拒绝,流程终止,通知申请人含驳回原因
  • 退回:退回给提交人修改,流程终止,提交人可修改后重新提交(新建审批流)
  • 通过:最后一级通过后,通知申请人,触发业务回调

二、数据库

-- 审批流实例表
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

// 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

// 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
}

五、领域事件

// 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 业务表新增字段

ALTER TABLE tb_xxx ADD COLUMN approval_flow_id BIGINT DEFAULT NULL;

6.2 提交时创建审批流

业务提交接口(如 POST /admin/refund-requests

// 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 事件处理器:

// 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

// 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)

七、应用层(用例)

// 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为"待审批"时显示操作按钮
  • 其他角色:只读