# 基础设施：通用审批流引擎

> 被依赖：需求 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为"待审批"时显示操作按钮
- 其他角色：只读
