# 需求15/16/18/19/20/21 技术方案

> 状态：原需求来源稿；其中本地审批流、审批页面、操作密码以及基于原退款单 `resubmit` 的方案均已废弃，最终以企微审批方案和标准评审稿为准。
> 评审建议：需求 15/16、需求 18/20/21、需求 19 分三组评审，不在一次会议中混合确认。

---

## 需求15：套餐下架后允许续费

### 业务规则

- 下架套餐（`shelf_status=2`）不可被**新购**
- 下架套餐**可以续费**（已在使用该套餐的客户）
- 续费仅支持**客户自己购买**（不允许代理代购下架套餐给新客户）

```mermaid
flowchart TD
    Start[请求购买套餐] --> Enabled{套餐是否启用?}
    Enabled -->|否| RejectDisabled[拒绝：套餐已禁用]
    Enabled -->|是| Shelf{是否已下架?}
    Shelf -->|否| Allow[允许继续下单]
    Shelf -->|是| Renewal{当前客户资产是否有该套餐历史使用记录?}
    Renewal -->|否| RejectNew[拒绝：下架套餐不可新购]
    Renewal -->|是| Actor{是否客户本人续费?}
    Actor -->|是| Allow
    Actor -->|否| RejectProxy[拒绝：下架套餐不可代购]
```

“客户本人”必须由后端登录主体与资产归属关系判断，不能信任前端传入 `is_renewal=true`。

### 后端

**当前逻辑**：下架套餐在购买时被拦截。

修改：在订单创建校验中由后端根据资产、历史使用记录和登录主体判定“新购/续费”，不能接收或信任前端 `is_renewal`：

```go
// internal/service/order/service.go 或 client_order/service.go
func (s *Service) validatePackageAvailability(
    ctx context.Context,
    pkg *model.Package,
    asset *ResolvedAsset,
    actor *PurchaseActor,
) error {
    if pkg.Status == constants.StatusDisabled { // 0=禁用，定义在 pkg/constants/constants.go
        return errors.New(errors.CodeForbidden, "套餐已禁用")
    }
    if pkg.ShelfStatus == constants.ShelfStatusOff { // 2=下架
        if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) {
            return errors.New(errors.CodeForbidden, "套餐已下架，仅支持资产所有人续费")
        }
    }
    return nil
}
```

**续费资格判断**：查当前资产是否有该套餐的有效历史使用记录，并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格：

```go
func (s *Service) hasRenewalEligibility(ctx context.Context, asset *ResolvedAsset, actor *PurchaseActor, packageID uint) bool {
    return actor.OwnsAsset(asset) &&
        s.packageUsageStore.HasValidHistory(ctx, asset.Type, asset.ID, packageID)
}
```

### 前端

C端资产详情/当前套餐卡片：在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程，并携带资产和当前套餐上下文；后端重新判定资格，前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表，也不额外建设第二个续费套餐列表。

后台代购时：下架套餐的"代购"按钮禁用，tooltip 提示"套餐已下架，不可代购"。

---

## 需求16：代理分销码与佣金提现（已移出7月迭代）

> 状态：已移出本期
> 决策日期：2026-07-14
> 后续处理：作为独立需求重新评审和排期，不纳入本次开发、迁移和发布

本次范围调整包含整个需求 16：

- 代理/员工分销码和推广二维码。
- H5 代理申请、进度查询和退回重提。
- 代理申请接入通用审批及审批后自动开店。
- 店铺发展人关系和代理申请来源字段。
- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。

因此 7 月迭代不创建 `tb_distribution_code`、`tb_agent_application`，不修改 `tb_shop` 发展人字段和提现材料字段，不注册代理申请相关 API，不发布 `agent_application_approval`，也不建设对应前端页面。

通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时，可以复用本期审批流、站内消息、对象存储和 Outbox 能力，但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。

---

## 需求18：多人审批（APR-001~009）

### 依赖

历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md) 和 [站内消息初版](../历史方案/站内消息-初版.md)。

APR-009（企微审批对接）= Phase 2。

```mermaid
sequenceDiagram
    actor Applicant as 提交人
    participant Biz as 退款/充值业务
    participant Approval as 审批流
    participant Notice as 站内消息
    actor Approver1 as 当前节点审批人
    actor Approver2 as 下一节点审批人

    Applicant->>Biz: 提交申请
    Biz->>Approval: 同事务创建流程实例和首任务
    Approval->>Notice: TaskCreated
    Notice-->>Approver1: 待审批提醒
    Approver1->>Approval: 审批通过
    Approval->>Notice: 下一节点 TaskCreated
    Notice-->>Approver2: 待审批提醒
    Approver2->>Approval: 通过/驳回/退回
    Approval->>Notice: 流程结果事件
    Notice-->>Applicant: 结果和原因
```

### 实现要点

| 编号 | 需求 | 实现 |
|------|------|------|
| APR-001~003 | 充值/退款多级审核 | 见需求20/21；需求16已移出本期 |
| APR-004 | 审核环节：部门领导→财务 | 作为默认流程定义的两个串行节点；系统无部门模型，节点审批人由角色或指定账号配置，禁止按步骤编号或角色名称写死 |
| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` |
| APR-006 | 上一级完成后才提示下一级 | `ProcessInstance` 完成当前任务并激活下一节点，写入 `TaskCreated` Outbox 事件 |
| APR-007 | 通过后通知申请人 | `ProcessApproved` 事件处理器 |
| APR-008 | 驳回/退回后通知申请人含原因 | `ProcessRejected` / `ProcessReturned` 事件处理器 |
| APR-009 | 对接企微 | **Phase 2** |

### 默认流程与可配置边界

- 本迭代可以预置“业务审核 → 财务审核”两个节点，但这只是初始流程定义，不是引擎固定规则。
- 每个节点可配置 `role` 或 `user` 审批人来源，以及 `any` 或 `all` 完成方式。
- 节点可配置是否要求操作密码；仅触发该节点完成的审批人输入，不能按“财务节点”等名称写死。
- 角色审批在节点激活时解析当前启用账号，并将候选审批人快照到任务审批人表。
- 流程定义发布后不可修改；调整节点或审批人配置时创建新版本。
- 已发起实例始终使用发起时保存的流程定义快照。

### 业务表与审批流的关联

充值单和退款单接入审批流，各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义，迁移文件只能创建一次。

本段是历史设计。旧接入协议见 [本地通用审批流 - 业务接入协议](../历史方案/本地通用审批流-已废弃方案.md#十一业务接入协议)。

退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`；发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示；发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。

### 前端技术方案

- 历史前端交互见 [前端共性方案历史稿](../历史方案/前端共性方案-历史稿.md#六统一审批交互)；当前已改为企微审批只读页面。
- 退款、充值列表只展示审批摘要；审批详情首屏展示发起时固化的业务关键字段和业务资料，随后展示完整审批人、意见和历史实例。
- 每次通过、驳回、退回分别保存审批意见和可选附件；驳回、退回意见必填，附件不与退款/充值业务凭证混用。
- 时间线必须能查看此前审批人的动作、时间、意见和审批附件；业务资料与审批附件分区展示。
- 所有节点名称和审批人来自接口，不保留“部门领导审批人”“财务审批人”固定字段。
- APR-009 不在 Phase 1 前端中展示不可用入口；企业微信接入完成后再增加来源标识和跳转。

---

## 需求19：批量订购套餐（BPO-001~008）

### 支付方式粒度

评审结论：支付方式按**整批统一**设计：

- 页面选择 `offline` 或 `wallet`，CSV 不再重复填写支付方式。`wallet` 复用现有后台订单支付枚举，在批量任务中表示逐行扣结算代理主钱包，不新增同义枚举 `agent_wallet`。
- 混合支付拆成两个批次，避免一份凭证对应多种支付语义。
- CSV 不包含支付方式列，后端拒绝同一批次混合支付。

### 业务流程

```mermaid
flowchart TD
    Start[员工选择整批支付方式] --> Upload[上传可含不同代理资产的 CSV]
    Upload --> Parse[解析并持久化逐行明细]
    Parse --> Validate[校验资产、套餐、归属和重复行]
    Validate --> Item{处理下一条有效明细}
    Item -->|代理钱包| Lock[锁定钱包并校验可用余额]
    Item -->|线下支付| Voucher[校验整批支付凭证]
    Lock --> Order[单条事务创建订单并扣款]
    Voucher --> OrderOffline[单条事务创建已支付订单]
    Order --> Result[记录订单 ID 和成功状态]
    OrderOffline --> Result
    Result --> More{还有待处理明细?}
    More -->|是| Item
    More -->|否| Summary[汇总任务结果]
    Validate -->|校验失败| Failed[记录结构化失败原因]
    Failed --> More
```

任务允许部分成功。每一行是独立、可重试、可审计的业务单元，不能只保存一段失败 JSON。

Worker 复用系统统一资产解析能力，把资产标识解析为唯一资产类型和资产 ID，再按“资产类型 + 资产 ID”判断重复。一个批次在创建时只选择一个套餐，因此重复键不包含套餐；同一资产使用不同受支持标识仍是重复，首次出现的行正常处理，后续重复行失败并记录首次出现的行号。本期不把重复行解释为购买多份，未来如有多份订购需求，应增加明确数量字段。

统一资产解析当前支持 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN；未命中或无法唯一解析时只失败该行，禁止任意选择资产。明细保存用户原始资产标识、解析后的资产类型/ID和规范标识快照，便于审计和跨标识判重。

本页面入口沿用前端现有可见性规则；后端不新增批量订购权限码、账号类型拦截或任务创建人隔离，能够通过现有后台认证调用接口的主体视为可以使用。该入口决定不能替代逐行的资产归属、套餐授权、成本价和钱包业务校验。

### 数据库变更

```sql
CREATE TABLE tb_bulk_purchase_task (
    id             BIGSERIAL PRIMARY KEY,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at     TIMESTAMPTZ,
    creator        BIGINT NOT NULL DEFAULT 0,
    updater        BIGINT NOT NULL DEFAULT 0,
    task_no        VARCHAR(30) NOT NULL,
    request_id     VARCHAR(64) NOT NULL,
    source_file_key VARCHAR(500) NOT NULL,
    operator_id    BIGINT NOT NULL,
    package_id     BIGINT NOT NULL,
    package_code_snapshot VARCHAR(50) NOT NULL DEFAULT '',
    package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '',
    payment_method VARCHAR(20) NOT NULL,
    voucher_keys   JSONB NOT NULL DEFAULT '[]',
    involved_shop_count INT NOT NULL DEFAULT 0,
    total_amount   BIGINT NOT NULL DEFAULT 0,
    total_count    INT NOT NULL DEFAULT 0,
    success_count  INT NOT NULL DEFAULT 0,
    fail_count     INT NOT NULL DEFAULT 0,
    status         INT NOT NULL DEFAULT 1,
    error_message  TEXT NOT NULL DEFAULT '',
    started_at     TIMESTAMPTZ,
    completed_at   TIMESTAMPTZ
);

CREATE UNIQUE INDEX idx_bulk_purchase_task_no
    ON tb_bulk_purchase_task(task_no)
    WHERE deleted_at IS NULL;

CREATE UNIQUE INDEX idx_bulk_purchase_task_request_id
    ON tb_bulk_purchase_task(request_id)
    WHERE deleted_at IS NULL;

CREATE TABLE tb_bulk_purchase_item (
    id                    BIGSERIAL PRIMARY KEY,
    created_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    task_id               BIGINT NOT NULL,
    row_no                INT NOT NULL,
    asset_type            VARCHAR(20),
    input_asset_identifier VARCHAR(100) NOT NULL,
    resolved_asset_id     BIGINT,
    canonical_identifier  VARCHAR(100) NOT NULL DEFAULT '',
    resolved_shop_id      BIGINT,
    resolved_shop_name    VARCHAR(200) NOT NULL DEFAULT '',
    amount                BIGINT NOT NULL DEFAULT 0,
    status                INT NOT NULL DEFAULT 1,
    order_id              BIGINT,
    failure_code          VARCHAR(50) NOT NULL DEFAULT '',
    failure_reason        VARCHAR(500) NOT NULL DEFAULT '',
    idempotency_key       VARCHAR(100) NOT NULL,
    processed_at          TIMESTAMPTZ
);

CREATE UNIQUE INDEX idx_bulk_purchase_item_task_row
    ON tb_bulk_purchase_item(task_id, row_no);

CREATE UNIQUE INDEX idx_bulk_purchase_item_idempotency
    ON tb_bulk_purchase_item(idempotency_key);

CREATE INDEX idx_bulk_purchase_item_task_status
    ON tb_bulk_purchase_item(task_id, status);
```

状态建议：

- 任务统一复用全局异步任务状态：`1=待处理, 2=处理中, 3=已完成, 4=已失败, 5=已取消`；逐行明细使用 `1=待处理, 2=处理中, 3=成功, 4=失败`。
- 文件有效并完成全部行业务处理后任务为“已完成”；全部成功、部分成功和全部行业务失败由成功数/失败数表达，不建立“部分成功”状态。本期没有取消入口，但保留全局取消状态码。

### 处理与幂等

1. 前端选择单个套餐和整批支付方式，通过对象存储预签名地址直传 CSV 和线下凭证，再由 API 校验 `package_id`、`file_key`、支付方式和 `voucher_keys` 并创建任务，返回 `task_id`；业务接口不接收 `shop_id` 或文件字节。创建接口校验套餐存在以及对象归属、扩展名/Content-Type 和 10MB 大小，不同步解析 CSV；逐行处理时再校验结算代理当前套餐授权、价格和可售规则。
2. Worker 根据 `source_file_key` 下载并解析 UTF-8 CSV（允许 BOM），校验固定表头、未知列、CSV 语法、空文件和 1000 行上限；任一文件级校验失败时任务整体失败且不创建明细订单。文件有效后将每一行先写入明细表，再开始业务处理；不接受 Excel。
3. 同一任务严格按 CSV 行号顺序处理；每行按资产当前归属解析结算代理，同一任务可以依次处理不同代理的钱包。`wallet` 不预占整批或某一代理全部行的金额，当前行余额不足只失败该行并继续后续行；后续金额较小且当时余额足够的行仍可成功，因此行序就是同一代理资金不足时的订购优先级。
4. 每行使用独立事务。代理钱包支付时锁定该行结算代理的主钱包，按有效信用额度校验总可用金额后，在同一事务扣款、创建订单、资金流水并更新明细；无归属、归属异常、无主钱包或余额不足只失败该行。
5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时，已存在成功订单的明细直接跳过。
6. 单行失败不回滚已成功行；失败原因写结构化错误码和用户可见中文原因。
7. 任务汇总从明细表计算，不信任内存计数。

Asynq 载荷只传任务 ID，不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理，确保 Worker 重试期间仍可读取。

单文件上限 1000 行；超过上限由 Worker 将任务标记为整体失败，不创建任何订单。

### API 设计

**前端静态 CSV 模板**：

模板由前端项目随版本发布，后端不提供下载接口。模板只有一列：

```text
资产标识
```

套餐由创建任务请求中的单个 `package_id` 决定。资产标识统一交给系统资产解析能力识别资产类型和 ID；批量模块不维护自己的标识白名单。后端必须校验单列表头并对未知列给出明确错误，不能依赖模板一定来自当前前端版本。

**上传并提交**：

```text
POST /api/admin/bulk-purchases
Content-Type: application/json

{
  "request_id": "01J...",
  "package_id": 1001,
  "payment_method": "offline",
  "file_key": "bulk-purchase/2026/07/xxx.csv",
  "voucher_keys": ["attachment/2026/07/key1"]
}
```

CSV 先调用 `POST /api/admin/storage/upload-url` 并使用独立 `purpose=bulk_purchase` 获取预签名地址后直传；线下凭证使用附件用途直传。`package_id` 为大于 0 的单个套餐 ID；`offline` 时 `voucher_keys` 必填，`wallet` 时必须为空。创建任务前校验套餐存在、对象归属、CSV 类型和 10MB 大小限制；编码、表头、语法、空文件和行数由 Worker 校验。

**查询任务状态**：

```text
GET /api/admin/bulk-purchases/{task_id}
```

**分页查询任务明细**：

```text
GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50
```

### 前端技术方案

- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤，刷新页面后可根据任务 ID 恢复进度。
- 提交前展示所选单个套餐、支付方式、凭证数量和文件名；不展示整批目标代理或单一钱包余额。任务结果按行展示后端解析的结算代理及金额。
- 任务处理中展示总数、已处理数、成功数和失败数，轮询规则复用统一异步任务方案。
- 结果页默认显示失败明细，可切换全部/成功/失败，并可按资产标识搜索。
- 任务状态为已完成后，根据成功数和失败数显示全部成功、部分成功或全部业务行失败摘要；再次上传失败行会创建新任务，不修改旧任务历史。
- 操作员、任务号和处理时间在页面固定展示，便于财务和运营追溯。

### 自动化测试环境隔离

- 已部署测试环境继续使用 Redis DB 6；本地开发和 Agent 自动化测试统一使用 Redis DB 7，禁止自动化测试向 DB 6 投递 Asynq 任务。
- 测试入口必须读取并校验实际 Redis Client 配置，DB 不是 7 时立即失败。普通 Redis Client、Asynq Client 和 Worker Handler 使用同一 DB 配置，不能只隔离普通 Key 而把任务送到 DB 6。
- 不允许对 DB 7 执行 `FLUSHDB`；每次运行使用唯一测试标识，并只清理本次运行实际创建的 Key 和数据。
- 自动化测试直接连接当前真实 S3，使用唯一对象 Key 上传 CSV 和凭证，结束时只删除本次创建的对象；不提供内存对象存储替身。
- PostgreSQL 继续使用现有测试库，不新增 Agent 专用库。测试只创建带唯一运行标识的夹具，并按实际记录 ID 精确清理；禁止清表、`TRUNCATE`、模糊删除和修改既有业务数据，并发钱包场景也遵守相同隔离规则。
- Go HTTP 集成测试是主要自动化入口：使用 Fiber `app.Test` 穿过真实认证和业务链路，捕获待投递任务后直接驱动公开 Worker Handler，不通过 `sleep` 等待测试环境 Worker 抢取任务。
- 实现时同步保留可复用的 Agent 集成测试规范、环境守卫与精确清理 Harness、典型 CSV `testdata` 和完整示例；真实部署环境另提供 `curl` 冒烟模板，但不以 `curl` 代替自动化断言。

---

## 需求20：退款审批

### 依赖

本需求依赖七月统一企业微信审批公共能力，以稳定场景码 `refund_approval` 复用真实提交人、平台本人绑定、代理固定成员代提交、模板版本、附件上传、加密回调、轮询和终态 Outbox。退款模块不再依赖已废弃的本地审批流，也不自行实现审批节点、候选审批人或审批按钮。

### 创建契约与固定金额

- 保留 `POST /api/admin/refunds`，允许超级管理员、平台和代理发起；企业账号不允许发起。
- 请求只接受 `order_id`、`requested_refund_amount`、必填且最多 1000 字的 `refund_reason`、可选且最多 500 字的 `remark`，以及 1～5 个 `{file_key,file_name,file_size}` 附件。
- 不再接受前端提交的 `actual_received_amount` 或 `package_usage_id`。后端读取订单实收金额、支付方式、买卖方、资产和店铺事实并固化快照。
- 申请金额校验 `0 < requested_refund_amount <= 订单实收金额`，提交后固定。企微表单只读展示订单实收金额、可退款区间和本次申请金额，审批人只能同意或拒绝，不能修改金额。
- 退款单、唯一企微审批实例和提交 Outbox 同一 PostgreSQL 事务创建；远程企微异步提交，业务接口返回本地退款和“企微提交中”。场景、模板、平台本人绑定或代理固定成员不可用时，在任何业务事实落库前失败。
- 同一订单存在提交中、审批中、已通过但处理未成功或异常人工处置未完成的退款时禁止新建。已拒绝退款不占活跃名额；撤销、删除和通过后撤销不自动放行。

### 独立状态与数据事实

```text
退款状态：1=待审批 2=已通过 3=已拒绝 4=已退回（仅历史） 5=已撤销/审批已删除
处理状态：0=未触发 1=处理中 2=处理成功 3=处理失败
```

- 企微审批状态独立复用公共枚举：`0=提交中, 1=审批中, 2=已通过, 3=已驳回, 4=已撤销, 5=通过后撤销, 6=已删除, 7=提交失败, 8=提交结果未知`。退款、审批和业务处理状态不得互相覆盖。
- 退款记录保存唯一 `approval_instance_id`、真实提交人显示快照、结构化申请附件、申请备注、处理状态/错误/开始与完成时间，以及可靠 Worker 所需的租约或版本事实。
- `actual_received_amount` 继续作为后端生成的订单实收快照；`actual_refund_amount` 只表示资金已经实际完成的金额。旧 `approved_refund_amount`、`package_usage_id` 和字符串 Key 列表只作历史兼容，新退款不再依赖。
- 非代理钱包在财务已系统外退款并同意企微时写 `actual_refund_amount=申请金额`；代理主钱包只在原钱包回溯与唯一退款流水事务成功后写。驳回、撤销、删除及尚未完成代理回溯时为空；资金完成后即使佣金或套餐失败也保留。
- 佣金记录增加结构化 `invalid_reason`、`invalid_refund_id`、`invalidated_at` 和必要的人工操作者事实；已发放佣金回扣以“退款单 + 佣金记录”唯一防重。

### 企微终态和资金处理

- 企微拒绝把退款状态置为已拒绝，处理状态保持未触发；该审批和退款单均终结。若修正问题后仍需退款，重新调用创建接口生成新退款 ID、退款单号、快照、附件和企微实例，原单不可编辑或重提。
- 微信、支付宝、线下及其他非代理主钱包支付由财务先在系统外退款，再同意企微；企微同意即代表退款完成。本系统不调用渠道退款 API，也不提供 `manual-complete` 二次确认。
- 个人客户资产钱包支付同样走系统外人工退款。本系统不自动回充资产钱包、不创建资产钱包退款流水；现有个人钱包自动回充分支必须从新终态用例移除或隔离。
- 代理主钱包支付在企微同意后只按原订单扣款流水定位原主钱包，增加本次申请金额并写唯一退款流水。余额允许为负，退款自然冲减欠款；缺少可核验原扣款流水时处理失败，禁止根据当前代理关系、买卖方或当前钱包猜测。
- 企微撤销或删除进入异常人工处置，不执行资金动作也不自动放行新退款。通过后撤销且资金尚未执行时阻止后续资金步骤；资金已执行时不自动冲正，保留实际退款金额，记录 `critical` Audit Event 和站内告警。

### 整单终结、可靠性和审计

- 无论申请金额是否等于订单实收金额，企微同意后都以整张订单为边界：订单标记已退款，该订单全部有效套餐和佣金资格终结，该订单不允许再申请未退差额。向客户实际退款的金额仍是本次申请金额。
- 已冻结、解冻中、尚未发放或待人工修正的佣金不移动钱包，直接改为已失效；已发放佣金先从对应佣金钱包全额扣回，再改为已失效，钱包允许为负。佣金状态、钱包余额/版本、回扣流水和统一 Audit Event 同事务。
- 该订单生成的全部有效套餐失效，不按单个套餐使用记录缩小范围。主套餐失效级联其加油包，然后尝试激活下一条待生效主套餐；没有下一条时通过公共卡/设备状态能力停止资产。
- 企微首次终态通过公共业务 Outbox 触发可靠 Asynq Worker，不使用进程内 Goroutine。Worker 通过状态条件和租约领取任务；资金、订单、每条佣金和套餐分别保存持久化幂等事实，局部失败只重试未完成步骤，全部完成后才置处理成功。
- 代理主钱包余额/版本/退款流水与实际退款金额、已发放佣金回扣、订单状态和关键套餐状态分别按最小一致性边界写统一 Audit Event。重复回调、轮询和 Worker 命中已完成事实时不产生第二笔资金、第二次失效或第二条等价审计。

### API、权限与前端

- 保留 `POST /api/admin/refunds`、`GET /api/admin/refunds`、`GET /api/admin/refunds/{id}`。下线并不再注册原 `approve/reject/return/resubmit` 及任何 `manual-complete` 路由。
- 列表默认每页 20、最大 100，按创建时间倒序并以 ID 作并列排序键；筛选参数按 AND 组合。详情和列表同时返回退款状态、`approval` 对象和独立处理状态，历史本地审批使用 `approval.source=legacy`，新企微退款使用 `wecom`。
- 代理退款查询不再按创建账号隔离，改为既有店铺层级数据范围和退款业务权限。代理可见申请资料、业务凭证、真实提交人、审批状态和处理结果，但看不到审批人、内部意见和审批人附件。平台/超级管理员也必须有退款业务查看权限，才可读取完整企微详情。
- 列表、详情、附件下载和导出复用同一权限投影；企微运营权限不能绕过退款业务权限。
- 创建页不提交实收金额或套餐使用记录，明确提示“申请金额可以小于实收金额，但通过后仍按整单终结”。详情固定分为退款业务信息、企微审批信息和业务处理结果，不显示本地审批、金额修改、重提或人工确认按钮。
- 非代理钱包提示财务先系统外退款再同意企微；代理钱包提示同意后系统自动回溯。处理失败只展示脱敏摘要和系统重试状态；通过后撤销使用高风险告警并说明不会自动冲正。

### 测试与发布门禁

- 自动化最高接缝为 Fiber HTTP 与真实认证，经退款 Application/Domain/Query、GORM、现有测试 PostgreSQL、Outbox 和公开 Worker Handler，最终核对钱包、佣金、套餐和统一审计；企微网络边界使用可编程 Adapter 覆盖超时、重复、乱序、撤销、删除、通过后撤销和并发重试。
- 本地/Agent 测试强制 Redis DB 7，已部署测试环境继续使用 DB 6；测试启动前校验普通 Redis、Asynq Client 和 Worker 的实际 DB，禁止向 DB 6 投递、禁止 `FLUSHDB`。PostgreSQL 沿用现有测试库并按唯一运行标识精确清理；对象存储使用真实 S3 和唯一 Key，不做内存替身。
- 真实企微验收是实现完成门禁，不得推迟到 INT-06。至少执行两张独立退款：代理使用固定成员代提交并同意，验证真实附件、加密回调及代理钱包/佣金/套餐/审计；平台账号使用本人绑定发起并拒绝，验证轮询兜底且不触发资金处理。
- 回调按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发查询参数和请求体，后端仍完整验签、解密并核对 CorpID。真实参数只通过安全配置提供。撤销、删除和通过后撤销由可编程 Adapter 稳定覆盖，不强制每次真实企微人工制造。
- 停机发布时先具备真实企微公共能力、模板映射、平台绑定和代理固定成员，再迁移待审批退款并启用终态 Worker；历史终态保留 `legacy`，历史已退回只读。旧状态不一致记录进入对账/人工处置，迁移脚本不得猜测资金、佣金或套餐已完成。

---

## 需求21：充值审核流程

> 本节已按 UR#34 最终评审结论重写。完整可执行契约见 [UR#34 PRD](../../../../.scratch/ur34-agent-recharge/PRD.md)，如有细节差异以该 PRD 和标准评审稿为准。

### 业务分流

- 代理只能为当前登录店铺主钱包创建 `wechat` 或 `alipay` 在线充值，请求不接受 `shop_id`；平台、超级管理员和企业账号不能替代理创建在线支付。
- 平台和超级管理员只能为指定 `shop_id` 创建 `offline` 线下代充值，必须携带固定金额、1～5 个结构化付款凭证和可选备注，并进入真实企业微信审批；代理和企业账号不能创建线下代充值。
- 在线充值金额为 `10000～100000000` 分，线下代充值为 `1～100000000` 分。
- 平台和超级管理员的线下提交人使用本人绑定的企微账号；审批人只能同意或拒绝，不能修改金额。拒绝后原充值单以 `6=已驳回`终结，若要修正资料必须新建充值单和企微审批；不新增 `7=已退回`，也不提供原单重提接口。

### 在线支付与入账

- 继续使用现有支付配置，只按 `wechat` 或 `alipay` 选择当前可用配置，不建设多通道自动路由、优先级或故障转移。
- 微信使用 Native，支付宝使用 `alipay.trade.precreate`。后端把第三方返回的字符串或 HTTPS URL 原样映射为 `qr_content`，前端渲染二维码；后端不生成二维码图片或新增二维码生成接口。
- 创建接口不返回 `expires_at`，前端不展示本地推算的精确倒计时。本地时间不能判定第三方支付单是否失效，支付成功或关闭以回调和后端受控查单为准。
- `request_id` 只防止同一次提交重试。代理每次主动创建或再次拉起支付都使用新 `request_id` 并产生新的充值单和支付单，旧单等待第三方自然收敛，不复用、不主动取消。
- 支付回调或查单先在事务中固化真实收款事实：支付单已支付、充值单 `2=已支付`、`processing_status=1`并可靠写入钱包入账 Outbox；随后 Worker 在独立事务中更新钱包和版本、创建唯一流水、将充值单改为 `3=已完成`和 `processing_status=2`并写资金审计。入账失败使用 `processing_status=3`可靠重试，不回滚支付事实。
- 支付状态继续使用 `0=待支付, 1=已支付, 2=已失败, 3=已退款`，不新增“已关闭”；第三方明确关闭或失效时支付记为已失败、充值记为 `4=已关闭`。有效的迟到成功回调仍必须恢复支付事实并幂等入账。

### 线下审批终态

- 企微同意后按与在线充值相同的钱包入账 Worker 幂等入账；企微拒绝时终结为已驳回，不触发钱包。
- 企微在通过前撤销或删除时关闭充值单；通过后、入账前撤销时终止入账并关闭；已经到账后再撤销时不得自动扣款，保持已完成并写严重 Audit Event、通知财务人工处理。
- 停机发布时，下线旧 `offline-pay` 和本地 `reject` 路由。历史终态只读保留；历史待处理线下单只有在真实提交人已绑定企微且申请资料完整时才迁移为真实企微审批，其余进入异常清单，禁止猜测入账。

### 查询、通知和测试门禁

- `payment_status` 与 `processing_status` 分离；处理状态统一为 `0=未触发, 1=处理中, 2=处理成功, 3=处理失败`，不提供同义字段 `wallet_posting_status`。
- 代理按既有充值业务权限和店铺层级范围查看本店及有权管理的下级店铺记录，不按创建账号隔离；平台和超级管理员也受业务权限与数据范围约束，企业账号不可访问。
- 主钱包实际入账后，必须向目标代理主账号发送“充值到账”站内通知；在线实际提交账号不同于主账号时也接收一条。通知按充值单与接收人防重，失败不回滚资金。
- 本地自动化使用真实 PostgreSQL、Redis DB 7、真实 S3 和可编程支付 Adapter；测试环境 Redis 保持 DB 6，微信/支付宝真实扫码由人工验证。真实企微审批链路是实现完成门禁，回调可经用户中转应用原样转发到本地。
