# 新增需求 02：企业微信审批接入

> 状态：已合并至标准评审稿，本文保留为实施明细。  
> 评审主文档：`../../7月迭代技术方案-标准评审稿.md`  
> 范围：退款审批、平台员工线下充值审批，以及企业微信模板配置、回调和轮询补偿。

## 一、已确认决策

1. 不建设通用审批流引擎，审批节点、审批人、会签/或签和流程配置全部由企业微信审批模板负责。
2. 本系统只建设企业微信审批接入、状态镜像和审批终态后的业务处理。
3. 退款金额在申请时固定，企微只决定通过或驳回；不允许审批人在终态修改退款金额。
4. 线下充值不再要求操作密码，企微审批通过后自动入账。
5. 企微审批通过后又撤销，不自动冲正已经完成的退款或充值，只记录高等级异常、发送通知并人工处理。
6. 回调是主通道，每 2 分钟查询审批详情作为待审批单兜底。
7. 回调与轮询必须进入同一个状态同步用例，业务终态处理只能执行一次。
8. 企微模板 ID 和控件 ID 都可能因管理员编辑模板而变化，必须使用稳定业务场景码、不可变模板版本和控件映射快照。
9. 本次触碰的退款、线下充值审批逻辑迁移到 Domain/Application，旧业务单级通过、驳回、退回、重新提交和确认入账接口下线，不保留两套审批入口。
10. 系统无法从旧模板 ID 自动发现编辑后生成的新模板 ID；模板变更必须先暂停业务场景，再发布新映射并原子切换，避免继续向旧模板提交。
11. 退款仍为财务人工退款，本系统不调用微信、支付宝等支付渠道退款接口；只有代理钱包支付订单自动回溯原扣款代理主钱包，不向个人客户或资产钱包自动回款。
12. 平台账号与企微成员使用 Web 登录二维码自助绑定，普通运营不手工查找或录入 `userid`；代理账号不绑定企微，统一使用部署配置中的固定企微账号代提交。两类路径都独立保存真实业务提交人。

## 二、系统边界

企业微信负责：

- 审批模板编辑。
- 审批节点和审批人配置。
- 审批中的通过、驳回、撤销和删除。
- 审批意见和审批附件展示。
- 企业微信端待办提醒。

本系统负责：

- 退款和线下充值业务单创建。
- 把本地业务快照和附件提交到企微。
- 保存企微审批单号和状态镜像。
- 接收、解密企微回调并查询审批详情。
- 审批通过后记录人工退款终态、按规则回溯代理主钱包，或执行线下充值入账。
- 审批驳回、撤销、删除后的本地业务状态更新。
- 站内结果通知、异常告警和审计。

本系统不再保存审批节点、审批任务、审批人候选集合或本地审批动作。

## 三、总体流程

```mermaid
sequenceDiagram
    actor User as 业务提交人
    participant API as Refund/Recharge Application
    participant DB as PostgreSQL
    participant Outbox as Outbox
    participant Worker as WeCom Submit Worker
    participant WeCom as 企业微信审批
    participant Callback as 企微回调
    participant Sync as SyncApprovalStatus
    participant Biz as 业务终态用例

    User->>API: 创建退款/线下充值申请
    API->>DB: 保存业务单和企微审批实例(submitting)
    API->>Outbox: 同事务写 SubmissionRequested
    API-->>User: 返回业务单和提交中状态
    Outbox->>Worker: 投递提交任务
    Worker->>WeCom: 按账号类型选择企微身份并applyevent
    WeCom-->>Worker: sp_no
    Worker->>DB: 保存 sp_no，状态改为 pending

    WeCom->>Callback: 加密审批事件
    Callback->>Callback: 验签、解密、保存事件
    Callback->>Sync: 提交状态同步
    Sync->>WeCom: getapprovaldetail
    WeCom-->>Sync: 审批详情
    Sync->>DB: 幂等更新审批状态和详情快照
    Sync->>Outbox: 首次进入终态时写业务处理事件
    Outbox->>Biz: 可靠执行退款/充值终态用例
```

## 四、基础配置

企微基础连接配置使用现有 Viper 统一配置体系，不通过通用 Key-Value 页面展示明文密钥：

```yaml
wecom:
  corp_id: ""
  agent_id: 0
  agent_secret: ""
  callback_token: ""
  callback_encoding_aes_key: ""
  callback_path: "/api/callback/wecom/approval"
  account_binding_redirect_url: "https://后台域名/api/callback/wecom/account-binding"
  agent_approval_creator_userid: ""
  approval_poll_interval: 2m
  template_verify_interval: 10m
  request_timeout: 10s
```

环境变量覆盖敏感项：

```text
JUNHONG_WECOM_CORP_ID
JUNHONG_WECOM_AGENT_ID
JUNHONG_WECOM_AGENT_SECRET
JUNHONG_WECOM_CALLBACK_TOKEN
JUNHONG_WECOM_CALLBACK_ENCODING_AES_KEY
JUNHONG_WECOM_ACCOUNT_BINDING_REDIRECT_URL
JUNHONG_WECOM_AGENT_APPROVAL_CREATOR_USERID
```

后台只能查询“是否已配置、最近连通时间、最近错误”以及代理固定代提交成员是否就绪和成员显示名，不能返回 Secret、Token、EncodingAESKey 或原始固定 `userid`。

企微自建应用还必须配置：

- Web 登录回调可信域名与 `account_binding_redirect_url` 域名一致。
- 应用可见范围覆盖需要本人发起审批的平台/超级管理员以及代理固定代提交成员；代理业务账号本身不要求成为企微成员。
- `CorpID`、`AgentID`、用于换取身份的 Access Token 必须属于同一个自建应用配置。

用户提供的 demo 中已经出现完整密钥，正式接入前必须在企业微信后台轮换，并禁止将新值写入代码、Markdown、日志或审计快照。

## 五、模板映射与版本

### 5.1 为什么不能只配置 template_id

企业微信模板编辑后可能生成新的模板 ID；删除并重新增加控件时，控件 ID 也会变化。业务代码如果写死：

```go
templateID = "..."
amountControlID = "Text-..."
```

模板一旦编辑，新审批立即提交失败，历史审批也无法说明当时使用了哪套字段。

因此分为三层：

```text
稳定业务场景 scene_code
    ↓ 当前启用
不可变模板版本 template_version
    ↓ 包含
template_id + control_mapping + template_snapshot
```

### 5.2 稳定业务场景

第一版固定两个场景：

| scene_code | 中文名称 | 业务类型 |
|---|---|---|
| `refund_approval` | 退款审批 | refund |
| `offline_recharge_approval` | 线下充值审批 | agent_recharge |

业务代码只引用 `scene_code`，不直接引用企微模板 ID。

### 5.3 场景运行状态

```sql
CREATE TABLE tb_wecom_approval_scene (
    scene_code                   VARCHAR(64) PRIMARY KEY,
    scene_name                   VARCHAR(255) NOT NULL,
    status                       INT NOT NULL DEFAULT 0,
    current_template_version_id  BIGINT,
    paused_reason                VARCHAR(255) NOT NULL DEFAULT '',
    version                      BIGINT NOT NULL DEFAULT 0,
    updated_by                   BIGINT,
    updated_at                   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

`status`：`0=已暂停, 1=启用, 2=暂停中`。不建立数据库外键，`current_template_version_id` 由领域规则保证引用有效版本。

企微不会根据旧模板 ID 告诉本系统“这个模板已经产生了一个新 ID”。如果旧 ID 仍可访问，仅轮询 `gettemplatedetail` 也无法发现新模板。因此模板编辑采用明确的维护流程：

```text
将 scene_code 改为暂停中，立即阻止新申请和新提交租约
→ 等待已经取得提交租约的 Worker 完成或释放
→ 场景进入已暂停
→ 在企微编辑模板并取得新 template_id
→ 本系统读取新模板并完成控件映射
→ 发布不可变模板版本
→ 同一事务切换 current_template_version_id 并恢复场景
```

场景暂停后，退款或线下充值创建接口在写业务单前直接拒绝新申请，并返回“审批模板维护中”；已取得 `sp_no` 的历史审批继续接收回调和轮询，不受影响。

提交 Worker 调用企微前通过条件更新取得短期 `submit_lease_until`。暂停操作先把场景改为 `暂停中`，此后不再发放新租约；租约全部释放或到期后才显示“已暂停”。这样运营人员看到“已暂停”时，可以确认没有旧模板提交正在进行。

租约覆盖附件上传和 `applyevent` 调用并由 Worker 定时续期；处理结束时使用 `defer` 释放，提交结果未知也必须先持久化状态再释放。暂停流程只认可未过期租约已经全部消失，不能仅按任务进程是否存活判断。

### 5.4 模板版本表

```sql
CREATE TABLE tb_wecom_approval_template_version (
    id                    BIGSERIAL PRIMARY KEY,
    scene_code            VARCHAR(64) NOT NULL,
    version_no            INT NOT NULL,
    template_id           VARCHAR(128) NOT NULL,
    template_name         VARCHAR(255) NOT NULL DEFAULT '',
    status                INT NOT NULL DEFAULT 1,
    control_mapping       JSONB NOT NULL,
    template_snapshot     JSONB NOT NULL,
    template_fingerprint  VARCHAR(64) NOT NULL,
    last_verified_at      TIMESTAMPTZ,
    last_verify_error     TEXT,
    published_by          BIGINT NOT NULL,
    published_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    disabled_at           TIMESTAMPTZ,
    created_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (scene_code, version_no)
);

CREATE UNIQUE INDEX uq_wecom_template_active_scene
    ON tb_wecom_approval_template_version (scene_code)
    WHERE status = 1;
```

`status`：`0=已停用, 1=启用, 2=失效`。

发布新版本时，在一个事务内停用旧版本并创建新版本。旧审批实例继续引用旧版本记录，不更新历史快照。

### 5.5 控件映射

稳定业务字段映射到当前模板控件：

```json
{
  "shop_name": {
    "control": "Text",
    "control_id": "Text-xxx",
    "required": true
  },
  "business_no": {
    "control": "Text",
    "control_id": "Text-yyy",
    "required": true
  },
  "amount": {
    "control": "Money",
    "control_id": "Money-zzz",
    "required": true
  },
  "attachment": {
    "control": "File",
    "control_id": "File-aaa",
    "required": true
  }
}
```

退款场景必填业务字段：

```text
shop_name, refund_no, order_no, asset_identifier,
requested_refund_amount, refund_reason, attachment, submitter
```

线下充值场景必填业务字段：

```text
shop_name, recharge_no, amount, remark, attachment, submitter
```

模板发布时必须调用 `gettemplatedetail` 校验：

- `template_id` 可访问。
- 映射的每个控件 ID 确实存在。
- 控件类型与业务字段要求一致。
- 必填业务字段全部完成映射。
- 同一个控件不能绑定两个业务字段。

### 5.6 模板失效检测

后台任务每 10 分钟验证所有启用模板：

1. 调用 `gettemplatedetail`。
2. 使用控件 ID、类型和名称生成 SHA-256 fingerprint。
3. 模板不可访问或 fingerprint 变化时，将版本标记为 `status=2`。
4. 模板失效时同时暂停对应场景，禁止创建和提交新审批，但不影响已提交审批的回调和状态查询。
5. 发送站内系统告警，提示管理员发布新的模板映射版本。

该检测只能发现“当前模板 ID 已失效或内容发生变化”，不能自动发现企微生成的新模板 ID，所以不能替代上述暂停和发布流程。

提交审批前，如果 `last_verified_at` 超过验证间隔，Worker 先同步验证一次。验证失败不调用 `applyevent`。

## 六、平台账号绑定、代理固定代提交与真实业务提交人

企微发起身份按账号类型解析：平台账号和超级管理员必须有本人企微 `userid`；代理账号发起退款时使用部署配置中的固定企微成员 `agent_approval_creator_userid`。两类路径都把当前登录账号作为真实业务提交人写入业务快照和模板 `submitter` 字段。

### 6.1 绑定流程

```mermaid
sequenceDiagram
    actor User as 已登录平台/超级管理员
    participant FE as 管理后台
    participant API as WeComIdentity Application
    participant Redis as Redis绑定会话
    participant Login as 企业微信Web登录
    participant WeCom as 企业微信API
    participant DB as PostgreSQL

    User->>FE: 点击绑定企业微信
    FE->>API: 创建绑定会话
    API->>Redis: 保存一次性state，TTL 5分钟
    API-->>FE: 返回企微登录URL和session_id
    FE->>Login: 新窗口打开企微二维码
    User->>Login: 使用企业微信扫码确认
    Login->>API: 回调code + state
    API->>Redis: 原子消费state
    API->>WeCom: auth/getuserinfo(code)
    WeCom-->>API: userid
    API->>DB: 校验唯一性并保存绑定
    API-->>FE: postMessage通知绑定结果
```

后端构造官方 Web 登录地址：

```text
https://login.work.weixin.qq.com/wwlogin/sso/login
  ?login_type=CorpApp
  &appid={CorpID}
  &agentid={AgentID}
  &redirect_uri={URLEncode后的回调地址}
  &state={一次性随机值}
```

回调取得的 `code` 只能使用一次且 5 分钟过期。后端使用同一自建应用的 Access Token 调用：

```http
GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo
    ?access_token={access_token}
    &code={code}
```

返回 `userid` 才允许绑定；返回 `openid` 表示不是本企业成员，直接拒绝。企微身份接口不返回 `corp_id`，企业归属由登录 URL 中的 `CorpID` 和用于换取身份的自建应用 Access Token 共同限定。

第一版使用官方登录页面新窗口，不引入前端 SDK。回调成功页使用固定后台 Origin 的 `window.opener.postMessage` 通知原页面并关闭窗口；窗口通信失败时，原页面通过 `session_id` 轮询会话状态兜底。

### 6.2 绑定会话

绑定会话只存 Redis，不建长期数据库表。`state` 与前端查询会话分开保存，避免回调消费 `state` 后无法查询结果：

```text
key: RedisWecomAccountBindingStateKey(state)
ttl: 5分钟
value:
  session_id

key: RedisWecomAccountBindingSessionKey(session_id)
ttl: 10分钟
value:
  target_account_id
  initiator_account_id
  allowed_origin
  status
  error_code
  created_at
```

- 只有已登录且启用的超级管理员、平台用户可以为自己创建绑定会话；代理账号不提供绑定会话。
- `state` 使用密码学安全随机数，回调时通过 Lua 原子读取并删除；后续成功或失败结果写入独立的 `session_id` 会话。
- 回调不接受前端传入的 `account_id`，绑定目标只能来自服务端会话。
- 同一账号再次创建会话时，旧会话立即失效。
- `allowed_origin` 从服务端后台域名配置生成，不能接受请求参数覆盖。
- `session_id` 仅用于当前登录用户查询结果，不能作为绑定凭证。

### 6.3 绑定数据

```sql
CREATE TABLE tb_account_wecom_mapping (
    id             BIGSERIAL PRIMARY KEY,
    account_id     BIGINT NOT NULL UNIQUE,
    wecom_userid   VARCHAR(128) NOT NULL UNIQUE,
    display_name   VARCHAR(255) NOT NULL DEFAULT '',
    corp_id        VARCHAR(64) NOT NULL,
    agent_id       BIGINT NOT NULL,
    bind_source    VARCHAR(32) NOT NULL DEFAULT 'self_scan',
    bound_by       BIGINT NOT NULL,
    status         INT NOT NULL DEFAULT 1,
    verified_at    TIMESTAMPTZ,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

- 平台/超级管理员创建退款或线下充值时必须存在启用绑定，否则拒绝创建并返回可直接拉起绑定窗口的错误状态；代理创建退款不检查个人绑定，改用固定代提交身份。
- `account_id` 与 `wecom_userid` 都是一对一唯一；企微成员已绑定其他账号时拒绝覆盖，必须先由超级管理员解除旧绑定。
- 重新绑定同一账号必须再次扫码，成功后在事务内替换旧身份并记录前后值审计。
- 自助解绑不影响历史审批；历史实例继续使用提交时保存的 `creator_wecom_userid` 快照，新审批在重新绑定前禁止创建。
- `display_name` 通过读取成员接口获取；权限不足时允许为空，但不影响以 `userid` 发起审批。
- 企微审批详情中的审批人 `userid` 原样保存为快照；能匹配本地账号时额外返回本地账号 ID。
- 用户离职或映射失效不修改历史审批人快照。

普通运营界面不提供手工录入 `userid`。超级管理员仅能查看、强制解绑并要求员工重新扫码，不提供直接改写成员 ID 的入口。

代理固定代提交身份只通过部署配置维护。其 `userid` 必须属于当前 CorpID、处于启用状态且在自建应用可见范围内；后台只返回是否就绪及成员显示名，不返回或修改原始 `userid`。固定身份未配置或失效时，代理新申请必须在业务单、审批实例和 Outbox 落库前拒绝。固定身份变更只影响新审批，历史实例保留原发起身份快照。

### 6.4 权限边界

- 连接状态、场景状态、模板版本、模板读取/发布、场景暂停/恢复仅超级管理员可访问。
- 平台账号只能查询、重新绑定和解绑自己的企微身份；绑定列表和强制解绑仅超级管理员可访问。
- 审批运行列表、详情和“立即同步”仅超级管理员或具备独立“企微审批运营”权限的平台账号访问。
- “企微审批运营”不包含场景/模板维护、账号绑定管理和提交结果未知恢复。
- 提交结果未知恢复仅超级管理员或具备独立“企微审批异常恢复”权限的平台账号访问。
- 代理不访问企微配置、绑定和审批运行接口，只能按现有店铺层级数据范围查看退款详情中的只读审批摘要。
- 所有接口均执行后端鉴权，前端隐藏按钮或路由不构成权限控制。

审批内容继续按主体最小化投影：代理只读取真实业务提交人、审批状态、状态时间、业务处理结果，以及其业务范围内的退款资料和业务凭证；企微审批人、内部意见和审批人上传附件只允许具备对应业务查看权限的平台账号或超级管理员读取。审批详情、附件解析和导出必须共享同一权限判定，历史导出中的稳定附件引用也要在每次访问时重新校验当前权限。

## 七、审批实例

```sql
CREATE TABLE tb_wecom_approval_instance (
    id                         BIGSERIAL PRIMARY KEY,
    biz_type                   VARCHAR(64) NOT NULL,
    biz_id                     BIGINT NOT NULL,
    biz_no                     VARCHAR(64) NOT NULL DEFAULT '',
    scene_code                 VARCHAR(64) NOT NULL,
    template_version_id        BIGINT NOT NULL,
    template_id_snapshot       VARCHAR(128) NOT NULL,
    control_mapping_snapshot   JSONB NOT NULL,
    creator_account_id         BIGINT NOT NULL,
    creator_wecom_userid       VARCHAR(128) NOT NULL,
    creator_identity_source    VARCHAR(32) NOT NULL,
    sp_no                      VARCHAR(128),
    status                     INT NOT NULL DEFAULT 0,
    submitted_snapshot         JSONB NOT NULL,
    approval_detail_snapshot   JSONB,
    approver_snapshot          JSONB,
    apply_error                TEXT,
    submit_lease_until         TIMESTAMPTZ,
    callback_at                TIMESTAMPTZ,
    last_polled_at             TIMESTAMPTZ,
    status_changed_at          TIMESTAMPTZ,
    business_processed_at      TIMESTAMPTZ,
    business_process_result    VARCHAR(20),
    business_process_error     TEXT,
    version                    BIGINT NOT NULL DEFAULT 0,
    created_at                 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at                 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (biz_type, biz_id)
);

CREATE UNIQUE INDEX uq_wecom_approval_sp_no
    ON tb_wecom_approval_instance (sp_no)
    WHERE sp_no IS NOT NULL;
```

内部状态：

| status | 含义 | 对应企微状态 |
|---:|---|---|
| 0 | 提交中 | 尚未取得 sp_no |
| 1 | 审批中 | `sp_status=1` |
| 2 | 已通过 | `sp_status=2` |
| 3 | 已驳回 | `sp_status=3` |
| 4 | 已撤销 | `sp_status=4` |
| 5 | 通过后撤销 | `sp_status=6` |
| 6 | 已删除 | `sp_status=7` |
| 7 | 提交失败 | 明确未创建审批 |
| 8 | 提交结果未知 | 请求超时，无法确认是否创建 |

每条实例表示一张业务单唯一的审批申请，不使用 `round_no`。业务表通过 `approval_instance_id` 明确引用该申请，`(biz_type, biz_id)` 唯一约束保证退款或线下充值业务单不能产生第二条审批申请。`creator_account_id` 是真实业务提交人；`creator_wecom_userid` 是本次实际调用企微的成员快照，`creator_identity_source` 固定为 `self_binding` 或 `agent_proxy`。`submitted_snapshot` 保存提交时的业务展示数据和本地附件 Key，不保存临时 `media_id`、签名 URL 或敏感密钥。

## 八、DDD 设计

```text
internal/
├── domain/wecomapproval/
│   ├── approval.go             审批状态机和终态幂等规则
│   ├── scene.go                场景暂停、启用和版本切换不变量
│   ├── template.go             不可变模板版本和控件映射
│   ├── events.go               审批通过/驳回/异常事件
│   └── repository.go
├── application/wecomapproval/
│   ├── change_scene_status.go
│   ├── publish_template_version.go
│   ├── submit_approval.go
│   ├── sync_approval_status.go
│   ├── handle_callback.go
│   ├── poll_pending.go
│   └── recover_unknown_submission.go
├── domain/wecomidentity/
│   ├── binding.go              账号与企微成员一对一绑定规则
│   └── repository.go
├── application/wecomidentity/
│   ├── create_binding_session.go
│   ├── complete_binding.go
│   ├── get_my_binding.go
│   └── unbind_account.go
├── domain/refund/
│   ├── refund.go               退款金额和状态不变量
│   └── events.go
├── application/refund/
│   ├── create_refund.go
│   ├── complete_wecom_approved.go
│   └── reject_from_wecom.go
├── application/recharge/
│   ├── create_offline_recharge.go
│   ├── complete_wecom_approved.go
│   └── reject_from_wecom.go
└── infrastructure/adapter/wecom/
    ├── client.go
    ├── token_provider.go
    ├── web_login.go
    ├── identity.go
    ├── approval.go
    ├── media.go
    └── callback_crypto.go
```

退款和充值的旧 Service 不再作为审批业务入口。列表查询进入 `internal/query/refund`、`internal/query/recharge` 和 `internal/query/wecomapproval`。

## 九、审批提交

### 9.1 为什么异步提交

本地业务单与企微远程审批无法放在同一个数据库事务中，因此采用本地事务 + Outbox：

```text
业务单创建成功
审批实例 status=0
Outbox: WeComApprovalSubmissionRequested
```

Worker 处理：

1. 仅在场景启用且租约为空或已过期时，通过条件更新取得提交租约。
2. 加载不可变模板版本和提交快照。
3. 确认实例引用的模板版本有效且最近验证成功。
4. 从对象存储下载本地附件到临时文件。
5. 逐个调用 `media/upload` 获取临时 `media_id`。
6. 按控件映射构建 `apply_data`。
7. 使用模板审批人配置，固定 `use_template_approver=1`。
8. 按实例身份来源使用平台账号绑定的本人 `userid` 或代理固定代提交 `userid` 调用 `applyevent`；不得在 Worker 执行时重新按账号当前状态选择身份。
9. 保存 `sp_no`，状态变为审批中并释放租约。

附件的本地对象存储 Key 是权威记录；企微 `media_id` 只是提交期间使用的临时值。

### 9.2 提交结果未知

`applyevent` 网络超时后可能已经在企微创建审批，因此不能无脑重试，否则可能产生重复审批：

- 收到明确 `errcode != 0`：状态改为提交失败，可修正配置后重新提交。
- 建连失败且确认请求未发送：允许自动重试。
- 请求已发送但响应超时/连接中断：状态改为提交结果未知，不自动再次调用 `applyevent`。
- 提交结果未知进入异常页面，由管理员在企微核对后选择“确认未创建并重新发送”或“绑定已有 sp_no”。重新发送只是同一审批申请的新技术尝试，不创建新的业务审批申请；旧尝试继续保留在 Integration Log。

## 十、回调和轮询

### 10.1 回调路由

```http
GET  /api/callback/wecom/approval
POST /api/callback/wecom/approval
```

- GET 按企微协议完成 URL 校验。
- POST 使用 SHA1 签名校验和 AES-CBC 解密。
- 解密后的 `receiveID` 必须等于配置的 CorpID。
- 回调原始密文、解密结果摘要和处理状态写 Integration Log；不写文件系统。
- 回调快速保存事件并返回 `success`，实际状态以 `getapprovaldetail` 为准。

### 10.2 轮询补偿

每 2 分钟查询：

```sql
status = 1
AND (last_polled_at IS NULL OR last_polled_at <= NOW() - INTERVAL '2 minutes')
```

单批限制 100 条，按 `last_polled_at` 排序。回调和轮询都调用 `SyncApprovalStatus`。

### 10.3 状态同步幂等

```text
1. 根据 sp_no 加载审批实例
2. 调 getapprovaldetail
3. 保存审批详情和审批人快照
4. 使用 version 乐观锁更新状态
5. 状态没有变化时结束
6. 首次进入终态时在同一事务写对应业务终态 Outbox 事件
7. 业务 Worker 调用对应终态用例，失败按错误类型重试
8. business_processed_at 非空时不得重复执行资金动作
```

## 十一、业务状态处理

### 11.1 退款

创建退款时：

- `requested_refund_amount` 已完成合法性校验并固定。
- `approved_refund_amount` 不再由审批接口输入；企微通过时写为申请金额。
- 退款凭证必须先保存本地对象存储，再上传企微副本。
- 财务在系统外完成人工退款并通过企微审批确认结果；本系统不请求任何支付渠道退款 API。

退款状态在现有枚举基础上追加：

| status | 含义 |
|---:|---|
| 1 | 待审批 |
| 2 | 已通过/人工退款已确认 |
| 3 | 已拒绝 |
| 4 | 已退回，仅保留历史兼容，新企微审批不再产生 |
| 5 | 已撤销/审批已删除 |

不得将历史 `4=已退回` 改写为撤销，避免旧数据语义变化。

企微状态处理：

| 企微状态 | 本地退款动作 |
|---|---|
| 通过 | 记录人工退款完成；代理钱包支付订单回溯原扣款代理主钱包；更新订单状态，随后异步佣金回扣和资产处理 |
| 驳回 | 退款状态改为已拒绝，保存审批详情摘要 |
| 撤销/删除 | 非正常业务结果，记录异常、停止自动业务处理并进入人工处置；不开放正常重新提交入口 |
| 通过后撤销 | 已退款则不冲正，记录严重异常并通知财务；尚未执行则阻止退款 |

原 `POST /api/admin/refunds/{id}/approve`、`reject`、`return`、`resubmit` 全部下线。

一张退款单只产生一条审批申请，正常业务结果只有同意或拒绝，任一结果产生后审批和退款单即同时完结。企微拒绝时保存拒绝原因和审批详情，原退款单进入不可编辑、不可重提的已拒绝终态。业务人员纠正问题后若仍需退款，必须重新调用 `POST /api/admin/refunds`，系统生成新的退款 ID、退款单号、业务快照、提交人快照和企微审批申请；新单不继承旧审批节点、意见、附件、状态或企微发起身份。旧单只作为历史与审计事实保留。创建校验不把已拒绝退款视为活跃退款，但存在其他审批中或处理未完成的退款时仍拒绝新建。撤销、删除和通过后撤销属于外部异常防御，不作为自动放行新退款的依据。

审批通过后的业务 Worker 处理失败时，审批实例保持“已通过”，`business_process_result=failed`，由任务重试；前端明确区分“审批已通过”和“退款终态处理失败”。只有退款终态事务成功后才写 `business_processed_at`。

现有 `internal/service/refund/service.go` 中 `BuyerTypePersonal -> refundAssetWalletPayment` 与本次确认规则冲突，迁移退款用例时删除该分支及其专用退款回款逻辑。个人客户或资产钱包相关订单只记录人工退款结果，不自动增加资产钱包余额；代理钱包支付订单继续按原扣款流水定位并回溯原代理主钱包。

### 11.2 平台员工线下充值

创建线下充值后立即创建企微审批实例，不再暴露本地“确认入账”和“驳回”按钮。

| 企微状态 | 本地充值动作 |
|---|---|
| 通过 | 无操作密码，自动增加代理主钱包余额并写钱包流水 |
| 驳回 | 状态改为已驳回，保存企微审批摘要 |
| 撤销/删除 | 状态改为已关闭，可重新创建充值申请 |
| 通过后撤销 | 已入账不自动扣回，记录严重异常并通知财务 |

原 `POST /api/admin/agent-recharges/{id}/offline-pay` 和 `reject` 下线。

线上微信/支付宝充值不进入企微审批，保持支付回调流程。

审批通过后的入账任务失败时按相同规则重试；只有钱包余额和流水事务成功后才写 `business_processed_at`。若审批先变为通过后撤销且入账任务尚未成功，后续入账任务检测当前状态后终止，不再加钱。

## 十二、API 设计

### 12.1 基础配置状态

```http
GET /api/admin/wecom/status
```

返回是否配置、Token 最近获取时间、回调最近成功时间、最近错误、代理固定代提交成员是否就绪及成员显示名，不返回任何密钥或原始固定 `userid`。

### 12.2 审批场景状态

```http
GET /api/admin/wecom/approval-scenes
PUT /api/admin/wecom/approval-scenes/{scene_code}/status
```

```json
{
  "status": 0,
  "reason": "企微模板编辑中"
}
```

暂停和恢复必须写高等级审计。请求暂停后可能先返回 `status=2`，前端轮询场景列表，只有进入 `status=0` 才允许提示运营人员开始编辑企微模板。已有启用版本的场景只允许在暂停状态发布新版本；首次初始化没有当前版本时可直接发布。

### 12.3 读取企微模板

```http
POST /api/admin/wecom/approval-templates/inspect
```

```json
{
  "scene_code": "refund_approval",
  "template_id": "企微模板ID"
}
```

返回模板名称和可映射控件列表。

### 12.4 发布模板映射

```http
POST /api/admin/wecom/approval-templates/publish
```

```json
{
  "scene_code": "refund_approval",
  "template_id": "企微模板ID",
  "control_mapping": {
    "shop_name": "Text-xxx",
    "refund_no": "Text-yyy",
    "requested_refund_amount": "Money-zzz",
    "attachment": "File-aaa"
  }
}
```

后端重新读取模板并校验，不能信任前端提交的控件类型和名称。发布成功后在同一事务内停用旧版本、写入新版本、把尚未取得 `sp_no` 且仍为 `status=0` 的实例改绑到新版本、更新场景当前版本并恢复场景；任一步失败都保持暂停，不允许部分切换。

### 12.5 模板版本列表

```http
GET /api/admin/wecom/approval-templates?scene_code=refund_approval
```

### 12.6 账号绑定

```http
GET    /api/admin/wecom/account-binding/me
POST   /api/admin/wecom/account-binding/sessions
GET    /api/admin/wecom/account-binding/sessions/{session_id}
DELETE /api/admin/wecom/account-binding/me

GET    /api/admin/wecom/account-bindings?page=1&page_size=20&binding_status=
DELETE /api/admin/wecom/account-bindings/{account_id}

GET    /api/callback/wecom/account-binding?code={code}&state={state}
```

创建会话返回：

```json
{
  "session_id": "01J...",
  "login_url": "https://login.work.weixin.qq.com/wwlogin/sso/login?...",
  "expires_at": "2026-07-15T15:05:00+08:00"
}
```

列表接口不返回 Secret 或完整企微配置。普通平台用户只能查询和解绑自己的绑定；绑定列表和强制解绑仅超级管理员可用，强制解绑必须记录高等级审计。

### 12.7 审批记录

```http
GET /api/admin/wecom/approvals
    ?biz_type=refund
    &status=1
    &sp_no=
    &biz_no=
    &page=1&page_size=20

GET /api/admin/wecom/approvals/{id}
POST /api/admin/wecom/approvals/{id}/sync
POST /api/admin/wecom/approvals/{id}/bind-sp-no
POST /api/admin/wecom/approvals/{id}/confirm-not-created-and-resend
```

`bind-sp-no` 请求包含 `sp_no` 和必填恢复原因；后端调用 `getapprovaldetail` 完成企业、模板、发起身份、业务场景、真实业务提交人和业务快照核对后才能绑定。`confirm-not-created-and-resend` 请求包含必填恢复原因，只在操作人已在企微确认未创建审批时重新发送同一审批申请。两者仅用于提交结果未知的人工恢复，均必须二次确认并写高等级审计日志。

### 12.8 业务详情响应

退款和充值详情统一增加：

```json
{
  "approval": {
    "source": "wecom",
    "instance_id": 123,
    "sp_no": "202607150001",
    "status": 2,
    "status_name": "已通过",
    "template_name": "退款审批",
    "creator_name": "张三",
    "approvers": [],
    "submitted_at": "2026-07-15T10:00:00+08:00",
    "status_changed_at": "2026-07-15T10:05:00+08:00",
    "business_process_result": "success"
  }
}
```

## 十三、前端方案

### 13.1 审批入口变化

- 删除本系统待审批任务、审批节点配置、审批人配置和审批动作页面。
- 退款和线下充值列表不再显示“通过、驳回、退回、确认入账”按钮。
- 创建成功后展示“正在提交企业微信审批”；取得 `sp_no` 后展示“企业微信审批中”。
- 审批操作全部在企业微信完成。

### 13.2 业务详情

退款和充值详情增加只读“企业微信审批”区块。平台/超级管理员在具备业务查看权限时展示：

- 审批单号。
- 当前状态和状态更新时间。
- 模板名称和模板版本。
- 真实业务提交人；代理代提交时可只读标识“企微由固定账号代提交”，但不能把固定账号展示为业务申请人。
- 审批人、审批结果、意见和时间线。
- 业务处理结果。
- “立即同步”图标按钮，仅触发 `getapprovaldetail`，不提供审批按钮。

代理退款详情只展示真实业务提交人、审批状态、状态时间和业务处理结果，以及其原有业务权限允许查看的退款资料和业务凭证；接口不向代理返回审批人、内部审批意见和审批人上传附件。

通过后撤销且业务已执行时使用红色异常状态条，明确显示“资金动作未自动冲正，需人工处理”。

### 13.3 企微配置页

路由：`/system/wecom`

Tab：

1. **连接状态**：只显示配置状态和最近连通结果。
2. **审批模板**：按业务场景展示当前版本、模板 ID、验证状态和历史版本。
3. **账号绑定**：查看平台/超级管理员绑定状态、企微名称和最近验证时间；不允许编辑 userid；代理固定代提交账号只在连接状态中展示就绪状态和成员显示名。
4. **异常审批**：提交未知、模板失效、终态业务处理失败、通过后撤销。

模板发布交互：

```text
暂停业务场景并等待状态变为“已暂停”
→ 在企微完成模板编辑并取得新 template_id
→ 选择业务场景
→ 输入新 template_id
→ 点击“读取模板”
→ 左侧显示业务字段，右侧下拉选择企微控件
→ 后端校验
→ 发布新版本并自动恢复场景
```

不允许运营人员直接编辑 `control_mapping` JSON。
场景处于暂停状态时必须在退款和线下充值创建页明确显示维护原因，不能等异步提交后才暴露错误。

账号绑定交互：

- 平台/超级管理员个人中心显示“未绑定/已绑定/已失效”、企微成员名称和最近验证时间；代理个人中心不展示企微绑定能力。
- 点击“绑定企业微信”后打开官方企微二维码窗口，原页面显示 5 分钟倒计时。
- 绑定成功后窗口自动关闭，原页面刷新绑定状态；失败时显示企微返回的可操作原因。
- 平台/超级管理员创建退款或线下充值时发现未绑定，页面原地展示“绑定企业微信”按钮，绑定成功后继续填写，不要求用户先去配置页；代理创建退款不检查个人绑定。
- 管理员的账号绑定列表只提供筛选、查看和强制解绑；不提供 userid 输入框。

### 13.4 审批运行页

列表路由：`/operations/wecom-approvals`

详情路由：`/operations/wecom-approvals/{id}`，复用同一页面并打开详情抽屉，供业务详情和站内通知稳定跳转。

页面用于运行监控，不提供审批动作：

- 按业务类型、企微状态、提交状态和业务处理结果筛选。
- 查询 `sp_no`、业务单号、真实业务提交人、企微发起身份来源和模板版本。
- 查看企微审批详情快照和本地业务处理结果。
- 对审批中记录执行“立即同步”。
- 对提交结果未知记录执行“绑定已有 sp_no”或“确认未创建并重新发送”；后者仍属于同一审批申请的技术恢复。
- 对业务处理失败记录查看错误和任务重试状态。

## 十四、Token、日志和限流

- Access Token 缓存在 Redis，TTL 使用 `expires_in - 300秒`。
- 使用 Redis 锁避免多个进程同时刷新 Token。
- `auth/getuserinfo`、读取成员、`gettemplatedetail`、`applyevent`、`getapprovaldetail` 和 `media/upload` 分别记录接口名称、耗时、errcode、errmsg 和 request_id。
- 日志禁止记录 access_token、Secret、EncodingAESKey、解密密钥和完整附件内容。
- 绑定、换绑、自助解绑、管理员强制解绑写统一审计日志；Access Token 获取和企微身份查询写 Integration Log。
- `applyevent` 不做网络层盲目重试；详情查询允许指数退避重试。
- 审批轮询与回调同步共享企微 API 并发限制。

## 十五、存量数据和发布

停机发布：

1. 在企微自建应用配置 Web 登录可信域名和平台员工可见范围。
2. 创建企微模板版本、账号绑定和审批实例表。
3. 发布企微身份 Adapter、账号绑定回调、审批回调、轮询 Worker 和业务终态用例。
4. 要求会发起审批的平台/超级管理员完成扫码绑定，并验证代理固定代提交成员可用。
5. 发布并验证退款、线下充值两个模板映射版本。
6. 下线本地审批动作路由和前端按钮。
7. 存量已通过/已拒绝记录保留原业务审批快照，展示 `approval.source=legacy`。
8. 存量待审批记录按创建人类型处理：代理创建的退款使用固定代提交成员；平台/超级管理员创建的退款和线下充值仅在创建人已绑定企微时提交；未绑定或创建人缺失的记录进入迁移待处理列表。
9. 提交失败的存量记录进入异常审批页面，不允许继续走旧接口处理。

## 十六、人工验证

1. 场景进入暂停中后，新退款或充值申请和新提交租约被阻止；进入已暂停时没有旧模板提交仍在执行，历史审批仍可正常同步。
2. 编辑企微模板导致 template_id 变化后，旧模板版本仍可展示，发布新映射时原子切换并恢复场景。
3. 删除再新增控件后，旧 control ID 不会被继续使用。
4. 退款审批通过后按申请金额确认人工退款结果，重复回调和轮询不会重复处理退款终态。
5. 线下充值审批通过后无需操作密码自动入账，重复终态不会重复加钱。
6. 驳回、撤销、删除状态正确同步。
7. 通过后撤销不自动冲正，异常页面、站内消息和审计记录完整。
8. 回调失效时，2 分钟轮询可以推进终态。
9. 回调和轮询同时到达时，只有一个处理器执行业务动作。
10. 附件始终保留本地对象存储 Key，企微 media_id 失效不影响历史资料下载。
11. Secret、Token、EncodingAESKey 和附件内容不出现在 API、日志和审计详情中。
12. 已登录平台/超级管理员扫码后自动获得本人企微 userid，不需要人工查询或录入成员 ID；代理不展示绑定入口并使用固定账号代提交。
13. 绑定 `state` 过期、重复回调、非企业成员扫码时均不会产生绑定。
14. 同一企微 userid 尝试绑定第二个系统账号时被拒绝，原绑定不被覆盖。
15. 自助解绑和管理员强制解绑不影响历史审批快照，但会阻止该账号发起新审批。
16. 同一份退款分别由平台账号和代理账号创建时，前者使用本人绑定身份，后者使用固定代提交身份；两份审批单都正确展示真实业务提交人，通知和审计不会把固定账号当成代理本人。
