# 基础设施：通用审批流领域

> 历史状态：已废弃，仅保留用于追溯本地审批引擎方案；当前采用企业微信审批。
> 替代方案：[企业微信审批独立稿](../新增需求/02-企业微信审批接入.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
> 被依赖：需求 18（多人审批）、需求 20（退款审批）、需求 21（充值审核）
> 范围变更：需求16已于2026-07-14移出本期，代理申请不在第一版接入范围
> 架构：DDD，`internal/domain/approval/` + `internal/application/approval/`
> 核心原则：流程结构、审批人和审批规则通过已发布的流程定义决定，禁止写死在业务代码中。

---

## 一、背景与目标

当前系统没有“部门”组织模型，只有账号、角色以及账号与角色的关联。因此审批引擎不能假设“提交人的部门领导”，也不能在代码中固定“第一步部门领导、第二步财务”。

审批流领域只认识以下概念：

- 业务类型和业务单号
- 流程定义及版本
- 流程实例
- 审批节点和流转
- 审批任务和候选审批人
- 业务快照和业务资料
- 审批动作、状态和操作日志
- 领域事件和业务回调

退款、充值等业务负责说明“什么业务需要审批”，审批领域负责说明“流程怎么走、谁可以审批、什么时候完成”。

### 整体运行视图

```mermaid
flowchart LR
    BizUI[退款/充值/待办前端] --> Handler[Approval Handler]
    Handler --> App[Application UseCase]
    App --> Definition[ProcessDefinition 聚合]
    App --> Instance[ProcessInstance 聚合]
    App --> Resolver[审批人解析器]
    App --> Attachment[审批附件管理端口]
    App --> Repo[(GORM Repository)]
    App --> Outbox[(Outbox)]
    Relay[Outbox Relay] --> Outbox
    Relay --> Asynq[Asynq]
    Asynq --> Notice[站内消息消费者]
    Asynq --> Adapter[退款/充值业务处理器]
    Attachment --> Storage[(私有对象存储)]
```

审批事务只负责形成可靠的审批事实。通知和审批后的业务动作异步执行，并依靠稳定事件 ID 和业务幂等键承受重复投递。

### 第一版支持

- 流程定义草稿、发布、停用和版本管理
- 串行审批节点
- 按角色选择审批人
- 直接指定一个或多个审批账号
- 或签：任意一人通过，当前节点完成
- 会签：所有人通过，当前节点完成
- 通过、驳回、退回修改
- 每次审批动作的审批意见和审批附件
- 流程定义快照、审批人快照和操作日志
- 并发控制、请求幂等
- Outbox + Asynq 可靠事件投递
- 退款、充值业务通过适配器接入
- 受控的审批动作业务字段；第一版用于退款金额确认和线下充值操作密码校验

### 第一版不支持

- 条件表达式和复杂分支
- 循环、子流程和 BPMN 全规范
- 任意节点回退、动态跳转和加签
- 超时自动通过或自动拒绝
- 运行中修改流程定义或审批人

数据结构保留 `nodes + transitions`，但第一版发布校验只允许 `start → approval... → end` 的无环串行流程。

---

## 二、DDD 边界

```mermaid
flowchart TB
    H[Handler] --> A[Application UseCase]
    A --> PD[ProcessDefinition 聚合]
    A --> PI[ProcessInstance 聚合]
    PI --> T[ApprovalTask 实体]
    A --> AR[ApproverResolver 领域端口]
    A --> AT[ApprovalAttachmentStore 应用端口]
    A --> PR[Repository 端口]
    A --> OR[OutboxRepository 端口]
    Infra[Infrastructure] --> PR
    Infra --> OR
    Infra --> AR
    Infra --> AT
    Infra --> BA[BusinessApprovalHandler 应用端口]
    Infra --> GORM[GORM Repository]
    Infra --> Queue[Outbox Relay + Asynq]
```

### 2.1 ProcessDefinition 聚合

负责流程定义的合法性和版本规则：

- 草稿可以修改。
- 发布时校验节点、连线、审批人配置和无环约束。
- 已发布版本不可修改。
- 修改流程必须基于旧版本创建新版本。
- 停用只影响新流程发起，不影响已运行实例。

```mermaid
stateDiagram-v2
    [*] --> Draft: 新建定义
    Draft --> Draft: 修改节点/审批人
    Draft --> Published: 发布校验通过
    Published --> Disabled: 停用
    Published --> Draft: 基于当前版本复制新草稿
    Disabled --> Draft: 基于历史版本复制新草稿
    Published --> [*]
    Disabled --> [*]
```

`Published → Draft` 表示创建一个新版本草稿，不是把已发布记录原地改回草稿。

### 2.2 ProcessInstance 聚合

负责运行期业务不变量：

- 只有运行中的流程可以审批。
- 只有待审批任务的候选审批人可以操作。
- 同一个任务只能完成一次。
- 或签任意一人通过后，其他候选人的待处理资格取消。
- 会签全部通过后才推进下一节点。
- 任意有效审批人驳回或退回时，流程终止，并取消当前节点其他待处理任务。
- 流程推进必须使用实例保存的定义快照，不能读取后来发布的新版本。
- 每个审批人的意见属于其唯一审批动作，提交后不可修改；会签和或签都不能用后续意见覆盖先前记录。
- 审批附件必须与对应操作日志同时落库，不能出现任务已完成但附件关联缺失的半成品。
- 发起时固化的业务快照是审批详情的权威展示数据；实时业务详情仅作为受原业务数据范围保护的补充。

### 2.3 Application UseCase

Application 只负责：

- 加载流程定义、实例和账号数据。
- 开启事务并调用聚合方法。
- 调用审批人解析器并激活下一节点。
- 在审批事务内调用受控的业务动作适配器，校验退款金额或充值操作密码等业务字段。
- 保存聚合、审批日志和 Outbox 事件。
- 编排退款、充值等业务与审批领域的接入。

状态是否允许转换、节点是否完成等判断必须位于 Domain。

---

## 三、流程定义

### 3.1 定义示例

```json
{
  "code": "refund_approval",
  "version": 2,
  "start_node_id": "start",
  "nodes": [
    {
      "id": "start",
      "type": "start",
      "name": "开始"
    },
    {
      "id": "business_review",
      "type": "approval",
      "name": "业务审核",
      "approver": {
        "type": "role",
        "role_id": 12,
        "approval_mode": "any"
      }
    },
    {
      "id": "finance_review",
      "type": "approval",
      "name": "财务审核",
      "approver": {
        "type": "user",
        "user_ids": [101, 102],
        "approval_mode": "all"
      },
      "action_config": {
        "require_operation_password": true
      }
    },
    {
      "id": "end",
      "type": "end",
      "name": "结束"
    }
  ],
  "transitions": [
    {"from": "start", "to": "business_review"},
    {"from": "business_review", "to": "finance_review"},
    {"from": "finance_review", "to": "end"}
  ]
}
```

### 3.2 审批人配置

第一版支持两种审批人来源：

```go
// ApproverType 审批人来源类型
const (
    ApproverTypeRole = "role" // 按角色解析
    ApproverTypeUser = "user" // 直接指定账号
)

// ApprovalMode 节点完成方式
const (
    ApprovalModeAny = "any" // 或签，任意一人通过
    ApprovalModeAll = "all" // 会签，全部人员通过
)
```

所有常量实际定义在 `pkg/constants/approval.go`，状态值使用 `int`，类型和方式使用 `string`。

角色审批配置存储稳定的 `role_id`，同时在定义和任务中保存角色名称快照。禁止依赖可修改的角色名称执行权限判断。

`action_config` 是节点定义快照的一部分。第一版只支持 `require_operation_password`：配置后，仅触发节点完成的审批人输入密码；或签由实际通过者输入，会签由最后一位完成会签者输入。密码只参与本次内存校验，不进入业务表、审批日志、Outbox、访问日志或错误日志。

### 3.3 审批人解析时机

审批人在**节点激活时**解析：

1. `role`：查询该角色下当前启用的账号。
2. `user`：校验配置中的账号存在且启用。
3. 将解析结果写入 `tb_approval_task_assignee`，形成运行期快照。
4. 没有可用审批人时，节点激活失败，当前事务回滚。

角色成员后续变化不影响已经激活的任务。后续节点尚未激活时，使用激活时最新的角色成员。

后台审批场景只允许配置启用的平台角色以及超级管理员/平台用户账号。客户角色、代理账号和企业账号不能成为后台审批任务候选人，除非后续业务明确扩展审批主体范围。

### 3.4 业务绑定

业务类型通过绑定表选择流程定义，业务代码不得写死具体步骤：

```text
refund   → refund_approval 的当前已发布版本
recharge → recharge_approval 的当前已发布版本
```

发起流程时解析当前已发布版本，并将完整 `definition_json` 保存到流程实例快照。

### 3.5 运行时节点推进

```mermaid
flowchart TD
    Start[读取实例中的定义快照] --> Node{当前节点类型}
    Node -->|start| Next[查唯一下一节点]
    Node -->|approval| Resolve[解析角色或指定账号]
    Resolve --> Empty{存在可用审批人?}
    Empty -->|否| Fail[事务失败并返回配置错误]
    Empty -->|是| Task[创建任务和审批人快照]
    Task --> Wait[等待审批]
    Wait --> Decision{节点审批结果}
    Decision -->|等待| Wait
    Decision -->|通过| Next
    Decision -->|驳回| Rejected[流程已驳回]
    Decision -->|退回| Returned[流程已退回]
    Next --> EndCheck{下一节点是否 end?}
    EndCheck -->|否| Node
    EndCheck -->|是| Approved[流程已通过]
```

第一版发布校验保证每个非结束节点只有一个后继节点，因此运行期不执行条件表达式，也不会出现多分支选择。

---

## 四、领域状态机

### 4.1 流程实例状态

```text
1=审批中
2=已通过
3=已驳回
4=已退回
```

允许的状态转换：

```mermaid
stateDiagram-v2
    [*] --> Running: 发起成功
    Running --> Approved: 到达结束节点
    Running --> Rejected: 审批人驳回
    Running --> Returned: 审批人退回修改
    Approved --> [*]
    Rejected --> [*]
    Returned --> [*]
```

已通过、已驳回、已退回都是终态。退回后重新提交必须创建新的流程实例，旧实例永久保留。

### 4.2 审批任务状态

```text
1=待审批
2=已通过
3=已驳回
4=已退回
5=已取消
```

任务只在节点激活时创建，不预先创建后续节点的“待审批”任务。

### 4.3 审批人处理状态

```text
1=待处理
2=已通过
3=已驳回
4=已退回
5=已取消
```

- 或签：任意审批人通过后，任务通过，其余待处理记录变为已取消。
- 会签：全部审批人通过后，任务通过。
- 驳回或退回：任务和流程立即进入对应终态，其余待处理记录变为已取消。

```mermaid
stateDiagram-v2
    [*] --> Pending
    Pending --> Approved: 节点规则满足
    Pending --> Rejected: 任一有效审批人驳回
    Pending --> Returned: 任一有效审批人退回
    Pending --> Cancelled: 同节点已由他人完成/流程终止
    Approved --> [*]
    Rejected --> [*]
    Returned --> [*]
    Cancelled --> [*]
```

### 4.4 审批意见与附件

“审批建议”“审批备注”统一建模为 `ApprovalOpinion`，它属于某个审批人执行的一次通过、驳回或退回动作，不属于流程实例的可修改公共备注：

- 通过：审批意见可选，最多 1000 字。
- 驳回、退回：审批意见必填，去除首尾空白后至少 1 字，最多 1000 字。
- 意见按纯文本保存和展示，不接受 HTML、Markdown 或富文本。
- 审批动作提交成功后，意见和附件不可编辑、覆盖或删除；需要纠正时只能由后续审批动作形成新的审计记录。
- 第一版不建设独立评论区或聊天式追加备注，避免绕开审批动作权限和审计语义。

审批附件属于本次审批意见的补充材料：

- 每次动作可选上传 0~5 个附件。
- 附件与审批意见、任务状态、操作日志在同一数据库事务内建立关联。
- 退款凭证、充值凭证等仍属于业务单资料；审批附件不能替代业务必填凭证，也不能反向修改业务资料。
- 或签、会签场景下，每个审批人拥有各自独立的意见和附件，不能把多人意见合并覆盖到任务级字段。

---

## 五、数据库设计

禁止建立数据库外键，关联通过 ID 维护。

### 5.1 流程定义表

```sql
CREATE TABLE tb_approval_process_definition (
    id              BIGSERIAL PRIMARY KEY,
    process_code    VARCHAR(50) NOT NULL,
    process_name    VARCHAR(100) NOT NULL,
    version         INT NOT NULL,
    status          INT NOT NULL DEFAULT 1, -- 1=草稿 2=已发布 3=已停用
    definition_json JSONB NOT NULL,
    published_at    TIMESTAMPTZ,
    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(),
    deleted_at      TIMESTAMPTZ
);

CREATE UNIQUE INDEX uq_approval_definition_code_version
    ON tb_approval_process_definition (process_code, version)
    WHERE deleted_at IS NULL;
```

### 5.2 业务流程绑定表

```sql
CREATE TABLE tb_approval_process_binding (
    id           BIGSERIAL PRIMARY KEY,
    biz_type     VARCHAR(50) NOT NULL,
    process_code VARCHAR(50) NOT NULL,
    status       INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用
    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()
);

CREATE UNIQUE INDEX uq_approval_binding_biz_type
    ON tb_approval_process_binding (biz_type);
```

### 5.3 流程实例表

```sql
CREATE TABLE tb_approval_process_instance (
    id                   BIGSERIAL PRIMARY KEY,
    flow_no              VARCHAR(30) NOT NULL,
    process_code         VARCHAR(50) NOT NULL,
    process_version      INT NOT NULL,
    definition_snapshot  JSONB NOT NULL,
    business_snapshot    JSONB NOT NULL DEFAULT '{}',
    biz_type             VARCHAR(50) NOT NULL,
    biz_id               BIGINT NOT NULL,
    biz_no               VARCHAR(50) NOT NULL DEFAULT '',
    submitter_type       VARCHAR(30) NOT NULL,
    submitter_id         BIGINT,
    submitter_name       VARCHAR(50) NOT NULL DEFAULT '',
    current_node_id      VARCHAR(64) NOT NULL,
    current_node_name    VARCHAR(100) NOT NULL DEFAULT '',
    status               INT NOT NULL DEFAULT 1,
    version              BIGINT NOT NULL DEFAULT 1,
    finish_reason        TEXT,
    completed_at         TIMESTAMPTZ,
    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()
);

CREATE UNIQUE INDEX uq_approval_instance_flow_no
    ON tb_approval_process_instance (flow_no);
CREATE INDEX idx_approval_instance_biz
    ON tb_approval_process_instance (biz_type, biz_id, created_at DESC);
CREATE UNIQUE INDEX uq_approval_instance_active_biz
    ON tb_approval_process_instance (biz_type, biz_id)
    WHERE status = 1;
```

`business_snapshot` 固化业务标题、业务单号、提交人、审批关键字段和业务资料元数据。退款申请凭证、充值凭证等在其中标记为 `business_materials`；审批人动作附件仍只写入 `tb_approval_operation_attachment`，两类文件不能混用。

第一版业务快照最低字段：

| 业务 | 字段和资料 |
|------|------------|
| 退款 | 退款单号、订单号、资产类型和标识、代理店铺、支付方式、订单实收、申请退款金额、退款原因、退款申请凭证 |
| 员工线下充值 | 充值单号、目标代理店铺、充值金额、支付方式、提交人、备注、支付凭证 |

`submitter_type` 第一版支持 `admin_account`、`shop_account`。外部申请人属于需求16后续扩展，本期不引入 `external_applicant` 语义。

### 5.4 审批任务表

```sql
CREATE TABLE tb_approval_task (
    id                  BIGSERIAL PRIMARY KEY,
    process_instance_id BIGINT NOT NULL,
    node_id             VARCHAR(64) NOT NULL,
    node_name           VARCHAR(100) NOT NULL,
    node_sequence       INT NOT NULL,
    approver_type       VARCHAR(20) NOT NULL,
    approver_config     JSONB NOT NULL,
    approval_mode       VARCHAR(20) NOT NULL,
    status              INT NOT NULL DEFAULT 1,
    completed_by        BIGINT,
    completed_by_name   VARCHAR(50),
    completed_at        TIMESTAMPTZ,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX uq_approval_task_instance_node
    ON tb_approval_task (process_instance_id, node_id);
CREATE INDEX idx_approval_task_status
    ON tb_approval_task (status, created_at DESC);
```

任务表只保存节点结果。`completed_by` 表示触发节点完成的最后操作人，不代表节点只有一个审批人；人类审批意见统一保存在审批人记录和不可变操作日志中。

### 5.5 任务审批人表

```sql
CREATE TABLE tb_approval_task_assignee (
    id                  BIGSERIAL PRIMARY KEY,
    task_id             BIGINT NOT NULL,
    assignee_id         BIGINT NOT NULL,
    assignee_name       VARCHAR(50) NOT NULL DEFAULT '',
    source_type         VARCHAR(20) NOT NULL,
    source_id           BIGINT,
    source_name         VARCHAR(100) NOT NULL DEFAULT '',
    status              INT NOT NULL DEFAULT 1,
    comment             TEXT NOT NULL DEFAULT '',
    operation_log_id    BIGINT,
    operated_at         TIMESTAMPTZ,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX uq_approval_task_assignee
    ON tb_approval_task_assignee (task_id, assignee_id);
CREATE INDEX idx_approval_assignee_pending
    ON tb_approval_task_assignee (assignee_id, status, created_at DESC);
```

### 5.6 操作日志表

```sql
CREATE TABLE tb_approval_operation_log (
    id                  BIGSERIAL PRIMARY KEY,
    request_id          VARCHAR(64) NOT NULL,
    process_instance_id BIGINT NOT NULL,
    task_id             BIGINT,
    node_id             VARCHAR(64),
    operator_type       VARCHAR(30) NOT NULL,
    operator_id         BIGINT,
    operator_name       VARCHAR(50) NOT NULL DEFAULT '',
    action              VARCHAR(30) NOT NULL,
    request_digest      CHAR(64) NOT NULL,
    before_status       INT NOT NULL,
    after_status        INT NOT NULL,
    comment             TEXT NOT NULL DEFAULT '',
    business_data       JSONB NOT NULL DEFAULT '{}',
    client_ip           VARCHAR(64) NOT NULL DEFAULT '',
    user_agent          VARCHAR(500) NOT NULL DEFAULT '',
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX uq_approval_operation_request_id
    ON tb_approval_operation_log (request_id);
CREATE INDEX idx_approval_operation_instance
    ON tb_approval_operation_log (process_instance_id, created_at);
```

操作日志只允许新增，禁止更新或删除。相同 `request_id` 仅在 `task_id`、操作人、动作和规范化请求摘要 SHA-256 均一致时返回第一次操作结果；任一字段不一致返回冲突，不能把误复用的请求 ID 当成成功重试。

### 5.7 审批操作附件表

```sql
CREATE TABLE tb_approval_operation_attachment (
    id                   BIGSERIAL PRIMARY KEY,
    operation_log_id     BIGINT NOT NULL,
    file_key             VARCHAR(500) NOT NULL,
    file_name_snapshot   VARCHAR(255) NOT NULL,
    content_type_snapshot VARCHAR(100) NOT NULL,
    size_bytes           BIGINT NOT NULL,
    object_etag          VARCHAR(128) NOT NULL,
    attached_by          BIGINT NOT NULL,
    created_at           TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX uq_approval_attachment_operation_file
    ON tb_approval_operation_attachment(operation_log_id, file_key);
CREATE INDEX idx_approval_attachment_operation
    ON tb_approval_operation_attachment(operation_log_id);
```

附件表不建立数据库外键。`operation_log_id` 对应不可变操作日志；审批人记录通过 `operation_log_id` 定位该次操作的意见和附件。

附件提交规则：

1. 前端生成本次动作 `request_id` 后，通过现有上传能力申请 `purpose=approval_attachment` 的一次性上传凭证；上传记录必须固化上传人账号、用途、`request_id`、临时 Key 和过期时间，临时 Key 位于 `attachments/` 前缀。
2. 审批动作提交 `attachments=[{file_key,file_name}]`；每个临时 Key 最长 500，必须以 `attachments/` 开头且不能重复。`file_name` 清理后长度为 1~255，只作为显示快照，不作为对象定位依据。
3. Application 在状态转换前先校验临时 Key 的上传记录属于当前账号、用途为 `approval_attachment` 且 `request_id` 一致，再调用对象存储 `Stat/HeadObject` 确认文件存在，并读取对象大小、Content-Type 和 ETag；后端同时校验文件扩展名与 Content-Type 组合，不能只信任请求体声明。
4. 第一版每个文件最大 20MB，只允许 `.jpg/.jpeg/.png/.pdf/.doc/.docx/.xls/.xlsx`；拒绝可执行文件、脚本和普通压缩包。扩展名与 MIME 白名单定义在 `pkg/constants/approval.go`，不由前端决定。
5. 校验通过后，后端按 `request_id` 将临时对象服务端复制到 `approval-attachments/{request_id}/` 审计前缀，并返回最终 Key、元数据和 ETag。该前缀不提供前端上传 URL，避免附件提交后被覆盖。
6. 文件名只作为展示快照，必须去除路径和控制字符；下载时使用安全的 `Content-Disposition`。
7. 数据库事务只保存最终审计 Key。事务提交后异步删除临时对象；事务失败产生的未引用审计对象和普通临时对象由统一生命周期清理。

### 5.8 Outbox 表

`tb_outbox_event` 是通用基础设施表，不只服务审批流：

```sql
CREATE TABLE tb_outbox_event (
    id             BIGSERIAL PRIMARY KEY,
    event_id       VARCHAR(64) NOT NULL,
    aggregate_type VARCHAR(50) NOT NULL,
    aggregate_id   VARCHAR(64) NOT NULL,
    event_type     VARCHAR(100) NOT NULL,
    payload        JSONB NOT NULL,
    status         INT NOT NULL DEFAULT 1, -- 1=待发布 2=已发布 3=发布失败
    retry_count    INT NOT NULL DEFAULT 0,
    available_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at   TIMESTAMPTZ,
    last_error     TEXT,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX uq_outbox_event_id ON tb_outbox_event (event_id);
CREATE INDEX idx_outbox_pending
    ON tb_outbox_event (status, available_at)
    WHERE status IN (1, 3);
```

---

## 六、核心扩展接口

### 6.1 审批人解析器

```go
// ApproverResolver 根据节点配置解析候选审批人
type ApproverResolver interface {
    Resolve(ctx context.Context, rule ApproverRule) ([]Approver, error)
}
```

Infrastructure 提供：

- `RoleApproverResolver`
- `UserApproverResolver`
- `ApproverResolverRegistry`

注册表只是按 `approver.type` 选择策略，不承担流程推进逻辑。

### 6.2 业务审批处理器

```go
// BusinessApprovalHandler 处理审批结果对应的业务动作
type BusinessApprovalHandler interface {
    BusinessType() string
    BuildSnapshot(ctx context.Context, bizID uint) (BusinessSnapshot, error)
    OnApproved(ctx context.Context, event ProcessApprovedEvent) error
    OnRejected(ctx context.Context, event ProcessRejectedEvent) error
    OnReturned(ctx context.Context, event ProcessReturnedEvent) error
}
```

`BuildSnapshot` 在业务单创建、重新提交并启动流程的同一事务前读取业务事实，返回标题、编号、提交人、关键字段和业务资料元数据；写入实例后不随业务表后续变化而变化。退款和充值分别实现处理器，并注册到 `BusinessApprovalHandlerRegistry`。异步处理器必须使用状态条件更新或业务幂等键，允许 Asynq 重试。

### 6.3 审批动作业务扩展

少数业务需要在审批动作中确认业务字段，例如退款最终审批需要确认实际退款金额。审批引擎不解析这些字段，只提供受控扩展端口：

```go
// BusinessApprovalActionAdapter 处理审批动作中的业务字段。
type BusinessApprovalActionAdapter interface {
    BusinessType() string
    ResolveActionForm(ctx context.Context, action ActionContext) ([]ActionField, error)
    ApplyBeforeApprove(ctx context.Context, tx Transaction, action ActionContext, fields map[string]any) (map[string]any, error)
}
```

`Transaction` 是 Application 层的事务上下文抽象，由现有 GORM `TxManager` 实现；领域对象不直接依赖 GORM。

- `ResolveActionForm` 返回当前任务允许提交的字段定义，前端按接口渲染，不能按节点名称写死。
- `ApplyBeforeApprove` 在审批状态转换的同一事务中校验并保存业务字段，返回标准化结果。
- 标准化结果写入 `tb_approval_operation_log.business_data`，便于审计和幂等重放。
- 没有注册适配器的业务拒绝非空 `business_fields`，不能静默忽略未知字段。
- 第一版退款和员工线下充值实现该端口：节点通过 `action_config` 声明 `approved_refund_amount` 或 `operation_password`。字段只在当前操作会完成该节点时返回。
- 金额必须大于 0，且不能超过申请退款金额和订单实收金额。
- 会签节点的退款金额由最后一位完成会签的审批人确认；如需让指定人员确定金额，应在流程定义中增加其专属的最终决策节点，不能由第一位会签人预先锁定。
- `operation_password` 由现有 `OperationPasswordService` 校验，只用于本次请求，不写业务表、操作日志、Outbox、错误日志或访问日志；适配器返回的标准化审计数据必须排除敏感字段。

`ProcessApproved` 消费者只读取已经持久化的业务字段执行后续退款，不能在异步阶段重新接受审批人输入。

### 6.4 审批附件存储端口

```go
// ApprovalAttachmentStore 校验临时对象并固化为不可覆盖的审计附件。
type ApprovalAttachmentStore interface {
    PrepareImmutable(ctx context.Context, requestID string, refs []AttachmentRef) ([]PreparedAttachment, error)
}
```

Infrastructure 使用对象存储 `HeadObject + CopyObject` 实现，并要求复制目标 Key 对同一 `request_id + source_key` 幂等：目标已存在时必须校验 ETag 一致并直接复用，禁止覆盖为不同内容。输入中的文件名只作显示候选值；大小、Content-Type 和 ETag 来自对象存储。该端口不生成下载 URL，下载授权由审批实例查询用例单独处理。

---

## 七、领域事件与事务

核心事件：

```text
ProcessStarted
TaskCreated
TaskApproved
TaskRejected
TaskReturned
ProcessApproved
ProcessRejected
ProcessReturned
```

任务结果事件只携带 `operation_log_id` 和必要的意见摘要，不携带对象存储签名 URL。附件 Key 保留在审批附件表；站内消息第一版只提示“包含附件”，用户进入有权限的审批详情后再获取下载 URL。

审批动作先执行事务外预检：

```text
1. 按 request_id 查询既有操作日志；只有任务、操作人、动作和规范化请求摘要均一致时才返回原结果，否则返回冲突
2. 规范化并校验审批意见
3. 调用 ApprovalAttachmentStore 检查附件并复制到不可覆盖的审计前缀
4. 形成仅供本次请求使用的最终 Key 和附件元数据快照
```

对象存储检查不得在持有流程实例行锁的数据库事务中执行。预检通过后，审批数据库事务必须同时完成：

```text
1. 锁定或按 version 加载流程实例
2. 校验 request_id 幂等及请求摘要
3. 再次校验任务仍属于当前账号且状态未变化
4. 调用业务动作适配器校验并保存扩展字段（如有）
5. 聚合执行状态转换
6. 更新任务和审批人状态，保存该审批人的意见
7. 创建下一节点任务和审批人快照（如需推进）
8. 使用预检快照写操作日志和审批附件记录，并回写审批人的 operation_log_id
9. 写 Outbox 事件
10. 提交事务
```

对象一旦写入审批附件记录即视为不可变引用，未引用文件清理任务不得删除它；如果预检后任务状态发生变化，数据库事务回滚，固化对象按未引用审计对象处理。

Outbox Relay 提交 Asynq 后，消费者负责：

- 创建站内消息
- 通知下一节点候选审批人
- 通知有站内账号的申请人审批结果；外部申请人由业务投影提供结果查询
- 调用退款、充值等业务处理器
- 后续扩展企业微信通知

通知失败不能阻塞审批，但事件不能丢失。业务处理失败由 Asynq 重试并保留错误日志。

### 7.1 发起流程时序

```mermaid
sequenceDiagram
    actor User as 提交人
    participant Biz as 退款/充值 Application
    participant Approval as StartProcessUseCase
    participant DB as PostgreSQL
    participant Relay as Outbox Relay
    participant Worker as Asynq Worker

    User->>Biz: 提交业务申请
    Biz->>DB: 开启同一数据库事务
    Biz->>DB: 创建业务单
    Biz->>Approval: 发起审批(biz_type, biz_id)
    Approval->>DB: 读取已发布定义和业务绑定
    Approval->>DB: 创建实例、首任务、审批人快照、操作日志
    Approval->>DB: 写 ProcessStarted/TaskCreated Outbox
    Biz->>DB: 回写 approval_instance_id
    DB-->>Biz: 提交成功
    Biz-->>User: 返回业务单和审批摘要
    Relay->>DB: 拉取未投递事件
    Relay->>Worker: 投递通知任务
```

业务单和首个审批任务必须同事务创建。任何一步失败都不能留下“有业务单但没有审批任务”的半成品。

### 7.2 审批与业务回调时序

```mermaid
sequenceDiagram
    actor Approver as 审批人
    participant API as Approval API
    participant Domain as ProcessInstance
    participant DB as PostgreSQL
    participant Relay as Outbox Relay
    participant Worker as Asynq Worker
    participant Biz as 退款/充值处理器

    Approver->>API: approve/reject/return(task_id, request_id)
    API->>DB: 开启事务并加载实例/任务/审批人
    API->>Domain: 执行状态转换
    Domain-->>API: 节点等待/推进/流程终态
    API->>DB: 保存状态、日志、下一任务和 Outbox
    DB-->>API: 提交成功
    API-->>Approver: 返回最新审批摘要
    Relay->>Worker: 至少一次投递领域事件
    Worker->>Biz: 按 biz_type 调业务处理器
    Biz-->>Worker: 幂等成功或可重试错误
```

审批接口成功只证明审批事实已提交，不证明退款到账或充值入账已经完成。关联业务接口必须返回独立的业务处理状态。

---

## 八、并发与幂等

### 8.1 流程并发

流程实例使用 `version` 乐观锁：

```sql
UPDATE tb_approval_process_instance
SET current_node_id = ?,
    current_node_name = ?,
    status = ?,
    version = version + 1,
    updated_at = NOW()
WHERE id = ?
  AND status = 1
  AND version = ?;
```

受影响行数为 0 时，返回“审批状态已变化，请刷新后重试”。

### 8.2 任务幂等

任务只能从待审批状态更新：

```sql
UPDATE tb_approval_task_assignee
SET status = ?, operated_at = NOW()
WHERE task_id = ?
  AND assignee_id = ?
  AND status = 1;
```

所有审批动作必须携带 `request_id`，并由操作日志唯一索引防止重复请求。

---

## 九、应用用例

```text
internal/application/approval/
├── create_definition.go
├── update_definition.go
├── publish_definition.go
├── disable_definition.go
├── bind_business_process.go
├── start_process.go
├── approve_task.go
├── reject_task.go
├── return_task.go
├── list_my_tasks.go
└── get_process_detail.go
```

### 9.1 发起流程

```text
1. 根据 biz_type 查询启用的流程绑定
2. 查询 process_code 当前已发布版本
3. 校验业务单不存在运行中的审批实例
4. 由业务处理器构建业务快照，创建定义快照和流程实例
5. 从 start 节点找到首个 approval 节点
6. 解析角色或指定账号
7. 创建审批任务和审批人快照
8. 保存业务单、流程实例、任务、操作日志和 Outbox
```

业务单创建与流程创建必须共享同一个事务，禁止先创建业务单后忽略审批流创建失败。

### 9.2 审批通过

```text
1. 根据 task_id 加载流程实例、任务和当前审批人记录
2. 校验任务属于当前账号且仍为待处理
3. 聚合执行 Approve
4. 或签/会签策略判断节点是否完成
5. 节点未完成：保存当前审批结果并等待其他人
6. 节点完成：根据定义快照激活下一节点
7. 到达 end：流程变为已通过
8. 保存操作日志和 Outbox 事件
```

### 9.3 驳回和退回

- 驳回：当前流程永久终止，业务进入已拒绝状态。
- 退回：当前流程永久终止，业务进入可编辑状态。
- 重新提交：业务创建新的审批实例，旧实例仅用于历史追溯。

---

## 十、API 设计

### 10.1 流程定义管理

```text
POST /api/admin/approval-definitions
PUT  /api/admin/approval-definitions/{id}
POST /api/admin/approval-definitions/{id}/publish
POST /api/admin/approval-definitions/{id}/disable
GET  /api/admin/approval-definitions
GET  /api/admin/approval-definitions/{id}
PUT  /api/admin/approval-bindings/{biz_type}
```

只有草稿可以修改。发布接口必须完成完整定义校验。

### 10.2 运行时接口

```text
GET  /api/admin/approval-tasks?status=1&biz_type=refund&page=1&page_size=20
POST /api/admin/approval-tasks/{task_id}/approve
POST /api/admin/approval-tasks/{task_id}/reject
POST /api/admin/approval-tasks/{task_id}/return
GET  /api/admin/approval-instances/{instance_id}
GET  /api/admin/approval-instances/by-business?biz_type=refund&biz_id=123
POST /api/admin/approval-instances/{instance_id}/attachment-download-urls
POST /api/admin/approval-instances/{instance_id}/business-material-download-urls
```

审批动作请求：

```json
{
  "request_id": "0190f6c9-2a4e-7f19-b8ab-ec11d63e9970",
  "comment": "同意",
  "attachments": [
    {
      "file_key": "attachments/2026/07/13/0190f6c9-evidence.pdf",
      "file_name": "退款核对说明.pdf"
    }
  ],
  "business_fields": {
    "approved_refund_amount": 10000
  }
}
```

`attachments` 可省略，最多 5 个；后端先校验临时对象的上传人、用途和 `request_id`，再以 `file_key` 定位对象，文件类型和大小从对象存储读取，`file_name` 经清理后作为显示快照。`business_fields` 可省略；仅当前节点 `action_config` 声明且本次操作会完成节点的字段允许提交。其他业务或其他节点传入未声明字段时，后端返回参数错误。

意见规则由三个动作 DTO 分别校验：`approve` 的 `comment` 可为空，`reject/return` 的 `comment` 必填。后端必须统一去除首尾空白并限制最多 1000 字，不能只依赖前端校验。

附件下载请求使用附件 ID，不接受客户端直接提交任意 `file_key`：

```json
{
  "attachment_ids": [9001, 9002]
}
```

单次支持 1~50 个附件 ID。后端先校验当前账号有权查看该流程实例，并确认所有附件都属于该实例的操作日志，再调用对象存储生成短期下载 URL。

业务资料下载请求只接受 `business_snapshot.business_materials` 中的 `file_id`，不接受任意 `file_key`。后端先校验流程详情权限，再从实例快照映射到原业务资料对象并生成短期 URL；业务资料不会被伪装成审批附件。

审批接口操作 `task_id`，不再直接操作 `flow_id`。

### 10.3 详情响应

详情必须动态返回节点和审批人，不使用固定的“部门领导审批人”“财务审批人”字段。`action_form` 只在当前账号拥有对应审批动作时返回：

```json
{
  "instance_id": 1001,
  "process_name": "退款审批",
  "process_version": 2,
  "status": 1,
  "current_node_name": "财务审核",
  "business_snapshot": {
    "title": "退款申请",
    "biz_no": "RF202607140001",
    "submitter_name": "张三",
    "fields": [
      {"key": "asset_identifier", "label": "资产标识", "value": "8986..."},
      {"key": "requested_refund_amount", "label": "申请退款金额", "value": 10000}
    ],
    "business_materials": [
      {"file_id": "refund-voucher-1", "file_name": "退款凭证.pdf", "content_type": "application/pdf", "size_bytes": 245760}
    ]
  },
  "available_actions": ["approve", "reject", "return"],
  "action_form": {
    "fields": [
      {
        "key": "approved_refund_amount",
        "type": "money",
        "required": false,
        "default_value": 10000,
        "label": "实际退款金额"
      }
    ]
  },
  "tasks": [
    {
      "node_id": "business_review",
      "node_name": "业务审核",
      "approval_mode": "any",
      "status": 2,
      "current_user_assignee_status": 2,
      "assignees": [
        {
          "account_id": 88,
          "account_name": "zhangsan",
          "status": 2,
          "comment": "同意",
          "attachments": [
            {
              "attachment_id": 9001,
              "file_name": "退款核对说明.pdf",
              "content_type": "application/pdf",
              "size_bytes": 245760
            }
          ]
        }
      ]
    }
  ]
}
```

### 10.4 权限控制

- 流程定义创建、修改、发布、停用和业务绑定：仅流程管理员或系统管理员。
- 审批任务操作：当前账号必须存在于 `tb_approval_task_assignee` 且状态为待处理。
- 待我审批列表：按当前账号 ID 查询任务审批人表，不根据前端传入角色判断。
- 流程详情：有站内账号的申请人、当前审批候选人、业务管理员和系统管理员可以查看。上述人员可查看本实例的业务快照、业务资料、审批意见和审批附件；跳转到实时业务详情时仍受现有店铺/企业数据范围过滤。
- 审批附件下载：继承流程详情权限，并逐个校验附件归属；响应只返回短期签名 URL，不暴露 Bucket、公网永久地址或未授权 `file_key`。
- 角色或账号配置在发布时校验存在性；节点激活时再次校验账号启用状态。
- 所有权限校验在后端完成，前端隐藏按钮不能作为授权依据。
- `available_actions` 由后端根据当前账号、实例状态和审批人快照计算，前端不得用角色名称自行拼装。

---

## 十一、业务接入协议

### 11.1 业务表关联

退款单和充值单保留当前审批实例 ID：

```sql
ALTER TABLE tb_xxx ADD COLUMN approval_instance_id BIGINT;
```

历史审批通过 `biz_type + biz_id` 查询全部流程实例。业务表只保存当前实例 ID，重新提交时更新为新实例。

### 11.2 业务状态归属

- 审批领域只维护流程和任务状态。
- 退款、充值领域维护各自业务状态。
- `ProcessApproved/Rejected/Returned` 通过 Outbox 可靠投递。
- 业务处理器消费事件后，使用条件更新改变业务状态。
- 审批通过不等同于业务处理成功；代理钱包回退和充值入账具备各自处理状态、业务幂等键和失败重试。第三方/线下退款本期由财务人工确认，不接入支付渠道自动退款。
- 审批意见和审批附件的权威记录属于审批操作日志。业务表可以为列表、H5 或历史兼容快照最终驳回/退回原因，但不能反向覆盖审批日志，也不重复保存全部附件。
- 退款的资金边界：仅代理钱包支付订单在审批通过后自动回退到原扣款代理主钱包；个人/客户资产钱包不纳入本期自动回退，微信、支付宝和线下退款均由财务在系统外完成后人工确认。

### 11.3 默认流程

本迭代预置两个流程定义：

- `refund_approval`：退款审批
- `recharge_approval`：员工线下代充值审批

默认节点可配置为两个串行审批节点，但审批节点名称、角色 ID、指定账号和审批方式必须来自流程定义，禁止在代码中使用固定步骤编号或固定角色名称。

### 11.4 停机切换与旧接口下线

本次发布明确采用停机切换，不建设新旧审批接口兼容门面：

1. 维护窗口内停止退款、线下充值和审批写操作。
2. 完成迁移后同时发布 API、Worker/Relay 和前端。
3. 初始化并发布两个默认流程定义，绑定 `refund` 和 `recharge`。
4. 退款按业务单 ID 直接审批接口、`POST /api/admin/agent-recharges/{id}/offline-pay`、`POST /api/admin/agent-recharges/{id}/reject` 在新版本中不再注册。
5. 所有审批动作统一调用 `/api/admin/approval-tasks/{task_id}/*`。
6. 验证流程发起、任务审批、结果通知和业务处理状态后再开放系统。

停机发布降低了同时维护两套状态转换语义的风险，也避免旧接口绕过任务审批人快照、请求幂等和操作日志。

### 11.5 存量审批数据切换

停机期间必须处理存量单据，不能在旧接口下线后留下无法审批的业务记录：

1. 已通过、已拒绝、已退回等历史终态记录不伪造流程实例，`approval_instance_id` 保持为空；查询接口返回 `approval_source=legacy`，页面只读展示原业务状态、旧审批字段和审计日志。
2. `status=1` 的待审批退款单，使用发布后的 `refund_approval` 从首节点创建流程实例、任务和审批人快照，并回写 `approval_instance_id`。
3. 仅对符合“平台员工创建 + `payment_method=offline` + 尚未入账”的存量充值记录创建 `recharge_approval` 实例；代理在线充值不进入审批流。
4. 回填使用一次性 Application 命令执行，不用裸 SQL 拼装任务；命令按 `biz_type + biz_id` 幂等，已有关联实例时跳过。
5. 开放访问前必须确认所有需要继续审批的存量业务记录都已关联运行中的流程实例，不允许前端回退到旧审批按钮。

历史终态记录没有完整节点、候选人和操作意见时，宁可明确显示“历史审批记录”，也不能根据角色名称或当前账号关系反推并生成虚假的审批时间线。

---

## 十二、前端技术方案

历史跨需求规则见 [前端共性方案历史稿](./前端共性方案-历史稿.md)，本节只保留旧审批流设计。

### 12.1 页面与路由

| 页面 | 建议路由 | 说明 |
|------|----------|------|
| 待我审批 | `/approvals/tasks` | 默认仅查询当前账号待处理任务，支持业务类型和时间筛选 |
| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、流程时间线、候选人状态和审批意见 |
| 流程定义列表 | `/settings/approval-flows` | 草稿、已发布、已停用、当前业务绑定 |
| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点有序编辑、角色/账号选择、或签/会签 |

### 12.2 流程定义编辑器

第一版不做拖拽流程画布。前端使用有序节点列表：

```text
开始
  ↓
[业务审核] 审批人=角色:运营主管 方式=或签
  ↓
[财务审核] 审批人=账号:张三/李四 方式=会签
  ↓
结束
```

- 开始和结束节点固定且只读。
- 审批节点支持新增、删除、上移、下移。
- 角色选择器提交稳定的 `role_id`，账号选择器提交 `user_ids`。
- 发布前先做前端基础校验，再以发布接口的后端校验结果为准。
- 已发布版本只读；修改操作创建新版本草稿。

### 12.3 待办与详情

- 待办列表按 `task_id` 操作，行点击进入 `instance_id` 详情。
- 详情首屏显示业务标题、单号、提交人、关键字段和业务资料；审批人可在不跳转业务页面的前提下判断审批对象。
- 时间线按 `tasks[]` 顺序动态渲染，不固定节点数量和名称。
- 会签节点展示“已完成人数/总人数”；或签节点展示实际完成人和被取消候选人。
- 每位已操作审批人分别展示动作、审批意见和附件；多人意见不得合并成一段任务级备注。
- 通过意见可选，驳回和退回意见必填；附件最多 5 个，上传完成前禁止提交。
- 附件使用权限校验后的短期下载 URL，页面过期后重新获取，不缓存永久地址。
- 业务资料和审批附件使用不同下载接口和区域展示；前者来自流程发起快照，后者属于具体审批动作。
- 操作成功后重新请求实例详情和待办列表，不做乐观推进。
- 返回并发冲突时提示“审批状态已变化，已为你刷新”，随后重新加载详情。
- 驳回和退回意见必填规则以后端 DTO 为准，提交期间三个操作按钮统一锁定。

### 12.4 通知跳转

`approval.pending` 通知的 `ref_id` 使用审批实例 ID，跳转审批详情。页面加载后再由 `available_actions` 判断当前账号是否可操作，不能因通知到达过就默认拥有审批权限。

---

## 十三、发布、停用与回滚

1. 进入维护模式并确认相关写入口已经停止。
2. 执行增量迁移，创建定义、实例、任务、审批人、操作日志、审批附件和 Outbox 表，并为退款、充值两个业务表增加审批关联字段。
3. 同时发布 API、Worker/Relay 和前端，旧审批路由不再注册。
4. 创建并发布默认流程定义，配置真实角色 ID 或账号 ID，完成退款、充值两个业务绑定。
5. 执行存量待审批退款和员工线下充值回填命令，并核对不存在应审批但 `approval_instance_id` 为空的记录。
6. 人工验证退款、线下充值的发起、审批、退回重提、历史记录展示和业务处理状态。
7. 验证通过后解除维护模式。

开放访问前失败时，回滚应用版本和可逆迁移；开放后不得删除已发布定义、历史实例、任务、日志和 Outbox。正在处理的业务事件通过管理查询确认后再决定重试或人工处理。

---

## 十四、可观测性与风险

### 14.1 必备查询维度

- `process_instance_id`、`task_id`、`request_id`、`operation_log_id`、`attachment_id`、`event_id`。
- `biz_type + biz_id` 对应的全部历史实例。
- 当前节点、候选审批人和每个审批人的操作状态。
- Outbox 投递次数、下次重试时间和最后错误。

### 14.2 关键风险

| 风险 | 触发条件 | 处理 |
|------|----------|------|
| 节点无审批人 | 角色无人或账号被停用 | 节点激活事务失败，记录流程配置错误，不创建空任务 |
| 两人同时完成节点 | 或签/会签临界并发 | 实例 version + 审批人条件更新，冲突方刷新 |
| 审批成功但业务未完成 | Worker 或业务处理器失败 | 业务处理状态独立展示，Asynq 重试，禁止回滚审批事实 |
| 重复事件 | Relay 或 Worker 至少一次投递 | `event_id`、业务幂等键和状态条件更新 |
| 附件越权下载 | 用户猜测或获得其他流程的附件 ID | 按流程详情权限校验，并验证附件、操作日志和流程实例的完整归属链 |
| 临时附件误用 | 猜测或获得他人临时 `file_key` | 上传记录绑定上传人、用途和 `request_id`，提交时三者必须一致 |
| 非法或孤立附件 | 上传恶意文件，或上传后未提交审批 | 服务端读取对象元数据执行白名单/大小校验；未引用对象按生命周期清理 |
| 旧路由误保留 | 发布包仍注册业务单审批或直接入账接口 | 停机验收中逐个验证旧路由为不可用，审批写操作只允许任务级 API |

---

## 十五、设计模式边界

| 能力 | 使用方式 |
|------|---------|
| 状态机 | `ProcessInstance` 聚合维护流程和任务状态转换 |
| 策略模式 | 角色/指定账号解析、或签/会签判断 |
| 适配器模式 | 退款、充值业务处理器和后续业务接入 |
| 领域事件 | 表达任务创建、流程通过、驳回、退回等业务事实 |
| Outbox Pattern | 保证领域事件不会因 Asynq 短暂故障丢失 |
| 命令模式 | Application Command DTO 即可，不额外建立命令类体系 |
| 责任链模式 | 第一版不使用，避免把校验拆成大量小 Handler |
| 条件规则引擎 | 第一版不实现，出现真实条件分支需求后再扩展 |

审批流的核心不是堆叠设计模式，而是保持流程定义、实例状态、审批任务和业务回调之间的边界稳定。
