# 7月迭代技术方案（标准评审稿）

> 状态：已评审  
> 最后更新：2026-07-20
> 分支：`Iteration/7-11`  
> 系统：`junhong_cmp_fiber` 及配套后台、代理端、C 端前端  
> 负责人：待指定  
> 评审人：后端、前端、产品、验收负责人、运维  
> 关联需求：7月迭代需求 01～22（需求16已移出）、禅道补充需求 #43/#86/#96/#97/#98，以及新增的数据同步、企微审批、站内通知、全局审计、代理钱包扫码充值
> 说明：本文是唯一技术评审主文档；`独立方案/` 和 `来源材料/` 仅用于实施细节与方案溯源。

---

## 一、评审摘要

### 1.1 背景与目标

当前退款、线下充值审批各自维护，卡实名、流量和网络状态又被轮询、手动刷新及业务操作分别写入，容易出现同一业务动作只更新部分状态。现有账号和资产审计也没有统一模型，无法按人员、资源、请求、资金链路和外部交互追踪。复杂资金和状态业务仍集中在旧 Service 中，配置、批量任务、通知和导出权限缺少统一契约。

本次评审覆盖原需求 01～22（需求16移出）、禅道补充需求 #43/#86/#96/#97/#98 及五项新增技术需求，主要解决以下问题：

- 退款和平台员工线下充值直接接入企业微信审批，本系统只维护业务快照、审批状态镜像和终态处理。
- 将卡实名、流量和网络状态写入收口到统一 DDD 用例，以轮询兜底、业务事件阶梯触发和运营商回调提高同步及时性。
- 一次性切换全局 Audit Event、Integration Log 和多视角审计中心，旧审计表停止新写入。
- 补齐代理后台微信/支付宝扫码充值，在线支付成功后直接幂等入代理主钱包，不进入审批。
- 将信用额度、批量订购、退款和充值中的资金规则收敛为可并发校验的不变量。
- 补齐异步任务、站内消息、动态配置、导出字段权限和 Gateway 限速等基础能力。
- 补齐换货归属继承与资产换货链、店铺业务员、固定余额预警和代理系列套餐批量授权。
- 统一后台、代理端和 C 端的页面状态、异步反馈和异常展示。
- 在不一次性重构旧系统的前提下，按完整用例渐进迁移 DDD。

### 1.2 非目标

本期明确不做：

- 本地通用审批流引擎、审批节点配置、审批人解析、待我审批和本地审批动作页面。
- BPMN 全规范、拖拽流程设计器、部门组织模型和本地直属领导计算。
- 套餐临期企业微信消息推送；本期先完成站内通知和防重记录。
- 全仓 MVC/贫血模型一次性重构。
- 为旧退款和线下充值审批接口建设长期兼容层。
- 后端提供批量导入模板下载接口；CSV 模板由前端静态资源随版本发布。
- 微信/支付宝自动退款，以及基于套餐规则的自动限速。
- 运营商解除实名后自动回滚本地实名状态；第一版只保留防腐层入口和集成记录。
- 禅道草稿 #41“代理查询限制”和 #51“不同品类资产换货”；草稿不进入本期开发、工时和验收。

### 1.3 已确认决策

| 编号 | 决策 | 影响范围 |
|------|------|----------|
| D-01 | 采用触碰式渐进 DDD，以完整用例为迁移单位，不以整个模块为单位重构 | 全局 |
| D-02 | 复杂写进入 Domain；简单写使用 Application 事务脚本；复杂查询走独立 Query 通道 | 全局 |
| D-03 | 不建设本地审批流；节点、审批人、意见和审批附件全部由企微模板及审批详情负责 | 18、20、21 |
| D-04 | 本系统保存提交业务快照、本地业务资料和企微审批详情快照，审批人能够看到审批对象、备注和附件 | 18、20、21 |
| D-05 | 平台员工不建立钱包信用额度，信用额度只属于代理主钱包 | 17、19 |
| D-06 | 批量订购使用 CSV，支付方式整批统一，CSV 不包含支付方式 | 19 |
| D-07 | 角色级导出字段权限本期落地；前端不选择、不提交字段，服务端按代码字段目录与当前账号有效角色授权并集自动解析全部导出列 | 14 |
| D-08 | Gateway 只按 `cardNo` 手动设置固定限速等级或恢复不限速；设备入口先解析当前绑定卡，不做套餐自动限速 | 10 |
| D-09 | 需求16整体移出本期，不创建相关表、API、流程、页面和迁移 | 16 |
| D-10 | 退款金额在发起时固定；非代理钱包退款由财务人工处理，代理钱包仅回退原扣款主钱包；线下充值企微通过后自动入账 | 20、21 |
| D-11 | 采用停机发布，同时切换数据库、API、Worker 和前端，旧审批接口同步下线 | 20、21 |
| D-12 | 企微模板 ID 和控件 ID 均按不可变版本映射；编辑模板前暂停场景，发布新映射后原子切换 | 企微审批 |
| D-13 | 设备批量分配代理与套餐系列拆为两个独立 CSV 命令，文件先直传私有对象存储，业务接口只接收 `file_key` 和唯一目标 | 08 |
| D-14 | 排队套餐使用购买时长快照推算预计最终到期时间；资产详情、临期和导出共用实时 Query，定时任务仅发送通知 | 06、11、22 |
| D-15 | 卡是否需要实名由运营商 `realname_link_type` 决定，`card_category` 不参与实名和复机判断；本期不重新设计现有周期轮询资格 | 01、02、数据同步 |
| D-16 | 数据同步保留轮询兜底，关键业务事件按立即、3 分钟、5 分钟触发；超频不建立退避状态 | 数据同步 |
| D-17 | 企微发起身份按账号类型分流：平台/超级管理员必须扫码绑定并使用本人 `userid`，代理使用部署配置中的固定企微账号代提交；真实业务提交人始终独立进入审批表单、通知和审计 | 企微审批 |
| D-18 | 全系统审计本次一次性切换到 Audit Event + Integration Log，多视角 API 和前端同时发布 | 全局 |
| D-19 | 代理在线充值最低 100 元，支持微信 Native 和支付宝 PreCreate，支付成功直接入主钱包且不审批 | 21、新增充值 |
| D-20 | 资产层只展示一个预计最终到期时间；当前套餐和全部排队主套餐共同参与推算，临期也使用同一结果 | 06、11、22 |
| D-21 | 换货完成时新资产自动继承旧资产店铺；旧资产保留原店铺用于历史查询和权限追踪 | 禅道 #98 |
| D-22 | 店铺可选绑定一个平台业务员；该字段只表达业务归属，不恢复需求16的分销和佣金关系 | 禅道 #96 |
| D-23 | 代理主钱包现金可用余额降至 100 元及以下时，向代理主账号和店铺业务员发送一次站内通知；信用额度不参与预警计算 | 禅道 #97 |
| D-24 | 客户角色只提供新建店铺的默认信用配置；修改角色配置不更新任何已有店铺，已有店铺额度只能单独调整 | 17 |
| D-25 | 代理系列套餐授权复用现有批量接口；前端批量选择，已授权套餐明确标记并置灰 | 禅道 #43 |
| D-26 | 一张退款单只对应一条企微审批；企微拒绝同时终结审批和退款单，原单不可修改或重提，后续仍需退款时必须重新创建退款单 | 20、企微审批 |

### 1.4 本期边界

本期包含企业微信审批、平台账号扫码绑定、代理固定企微账号代提交、回调与轮询补偿；不再保留“先做站内审批、以后切企微”的中间态。套餐临期的企业微信消息推送仍属于后续扩展，本期只实现站内通知及外部渠道可扩展边界。

---

## 二、系统架构与渐进式 DDD

### 2.1 系统上下文

```mermaid
flowchart LR
    Admin[后台管理端] --> API[Junhong API]
    Agent[代理端] --> API
    Client[C端 H5/公众号] --> API
    API --> PG[(PostgreSQL)]
    API --> Redis[(Redis)]
    API --> Gateway[Gateway]
    API --> Outbox[(Outbox)]
    Outbox --> Relay[Outbox Relay]
    Relay --> Asynq[Asynq]
    Asynq --> Worker[Worker]
    Worker --> Notice[站内消息]
    Worker --> Biz[退款/充值/批量任务]
    Worker --> WeCom[企业微信]
    Worker --> Pay[微信/支付宝]
    Worker --> Gateway[Gateway]
    Carrier[运营商回调] --> API
    Pay --> API
    API --> Audit[(Audit/Integration Log)]
```

边界约束：

- API 负责同步校验、数据库事务和返回可追踪状态。
- Worker 负责通知、导出、批量处理及审批后的业务动作。
- Redis/Asynq 不参与核心业务事实的唯一持久化；关键事件先写 Outbox。
- Gateway、运营商回调、微信/支付宝和企业微信均通过 Infrastructure Adapter 接入，外部字段不得直接进入领域模型。

### 2.2 渐进迁移规则

```text
复杂写：Handler -> Application -> Domain -> Repository/Infrastructure
简单写：Handler -> Application -> Store/GORM
复杂查询：Handler -> Query -> GORM/DTO
旧用例未触碰：继续 Handler -> Service -> Store -> Model
```

| 场景 | 选择 | 判断依据 |
|------|------|----------|
| 钱包扣款、授信、换货完成、卡状态应用、退款状态转换 | Domain | 有不变量、状态机、并发或跨表一致性 |
| 店铺业务员、角色默认额度、简单开关、局部 CRUD | Application 事务脚本 | 规则少且不会形成复杂状态 |
| 系列套餐候选、换货链、列表、报表、导出、跨聚合详情 | Query | 只读、字段多、需要 JOIN/聚合/分页 |
| 未被本次用例触碰的旧逻辑 | 保持旧架构 | 避免扩大认知和回归范围 |

站内消息只是幂等写入和已读状态管理，采用轻量 Application/Infrastructure 即可，不为满足目录形式强行创建空洞聚合。

迁移单位必须是一个完整用例。例如修改“钱包扣款”时，应同时迁移该用例的规则、事务、仓储和事件，不要求顺带迁移所有钱包查询或后台 CRUD。

### 2.3 查询侧规则

- Query 可以直接使用 GORM、JOIN、聚合和只读 DTO，不要求经过聚合根。
- Query 不修改状态、不发布领域事件、不承担业务不变量。
- 列表默认分页 20、最大 100；导出和批量详情使用批量查询，禁止 N+1。
- 企微审批摘要、审计多视角、导出字段和套餐临期列表均属于 Query 场景。

### 2.4 跨需求技术约定

| 项目 | 约定 |
|------|------|
| 管理端 API | `/api/admin/*` |
| C 端 API | `/api/c/v1/*` |
| 金额 | 数据库与接口使用分，前端显示元 |
| 时间 | 后端返回 ISO 8601，前端统一本地化显示 |
| 状态 | 生命周期使用 `int`，类型/方式使用 `string` |
| 创建幂等 | 稳定业务键或 `request_id` + 唯一索引 |
| 状态幂等 | `WHERE status = expected` 条件更新 |
| 余额并发 | 版本号或行锁，并在同一事务写资金流水 |
| 异步事件 | 业务事务内写 Outbox，Relay 投递 Asynq，消费者按至少一次设计 |
| 权限 | 后端强校验，前端隐藏按钮不能作为权限边界 |

---

## 三、跨需求基础设施

### 3.1 系统配置

#### 目标

将支付方式等受控配置从代码迁移到数据库，平台超管修改后无需重新部署；H5 实名流程继续由资产自身字段决定，不增加全局默认 Key。

#### 数据模型

`tb_system_config` 关键字段：

| 字段 | 说明 |
|------|------|
| `config_key` | 唯一键，格式 `module.group.name` |
| `config_value` | 字符串形式的配置值 |
| `value_type` | `string/int/bool/json` |
| `module` | 前端分组和查询维度 |
| `description` | 中文说明 |
| `is_readonly` | 只读配置不能从后台修改 |
| `updater/updated_at` | 审计信息 |

唯一约束：`UNIQUE(config_key)`。

首批配置：

| Key | 默认值 | 用途 |
|-----|--------|------|
| `c2b.payment.card_allowed_methods` | `["alipay","wallet"]` | 卡资产 C 端支付方式 |
| `c2b.payment.device_allowed_methods` | `["wechat","wallet"]` | 设备 C 端支付方式 |

#### 更新流程

```mermaid
sequenceDiagram
    actor Admin as 平台超管
    participant Web as 系统配置页
    participant API as Config Application
    participant DB as PostgreSQL
    participant Redis as Redis

    Admin->>Web: 修改受控表单
    Web->>API: PUT /api/admin/system/config/{key}
    API->>API: 按 key 校验允许值
    API->>DB: 更新并记录操作人
    API->>Redis: 删除 sys:config:{key}
    API-->>Web: 返回最新配置
```

- 配置读取缓存 5 分钟，更新后立即失效。
- 不能只按 `value_type` 校验；每个已注册 Key 必须限制枚举和值域。
- 未注册 Key 默认只读，禁止通用接口写入任意配置。
- 配置解析失败、Redis/数据库异常时使用最近一次有效值或代码安全默认值并告警，禁止静默放开未知支付方式。
- 前端使用复选框、单选或开关，不向运营人员暴露 JSON 文本框。

#### API

| 方法 | 路径 | 权限 |
|------|------|------|
| GET | `/api/admin/system/config?module=` | 系统配置查看 |
| PUT | `/api/admin/system/config/{config_key}` | 平台超管 |

### 3.2 站内消息

#### 设计

```mermaid
sequenceDiagram
    participant Biz as 业务事务
    participant Outbox as Outbox
    participant Relay as Relay
    participant Worker as Notification Worker
    participant DB as tb_notification
    participant Web as 前端

    Biz->>Outbox: 同事务写领域事件
    Relay->>Worker: 至少一次投递
    Worker->>DB: event_id + recipient 幂等插入
    Web->>DB: 经 API 轮询和标记已读
```

`tb_notification` 关键字段：`event_id`、`recipient_kind`、`recipient_id`、`category`、`type`、`severity`、`title`、`body`、`ref_type`、`ref_id/ref_key`、`is_read`、`read_at`、`expires_at`。

关键约束：

- 唯一索引：`event_id + recipient_kind + recipient_id`，Outbox/Asynq 重试不得重复建消息。
- 未读和分类索引均以 `recipient_kind + recipient_id` 开头，所有 Query 和已读更新从登录上下文固定接收人。
- 正文使用受控纯文本模板，不保存任意 HTML、密钥、操作密码、完整个人信息或长期附件 URL。
- 企微审批待办由企业微信负责，本系统只发送审批结果、通过后撤销异常、模板失效和业务处理结果通知。
- 前端每 30 秒轮询未读数，不使用 WebSocket；页面不可见时暂停，恢复时立即刷新。
- `ref_type/ref_id/ref_key` 只用于受控目标解析，跳转后仍需业务权限校验。

通知类型：

| 类型 | 接收人 |
|------|--------|
| `wecom.approval.approved/rejected/cancelled` | 真实业务提交人账号 |
| `wecom.approval.revoked_after_approved` | 真实业务提交人和财务角色账号 |
| `wecom.template.invalid` | 平台超管 |
| `package.expiring` | 代理/企业相关账号 |
| `card_sync.failed` | 平台运维角色 |
| `carrier_callback.card_not_found` | 平台运维角色 |
| `system.alert` | 平台超管或指定运维角色 |

API：

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/admin/notifications/unread-count` | 未读总数 |
| GET | `/api/admin/notifications/unread-summary` | 分类未读数 |
| GET | `/api/admin/notifications` | 按分类、类型、级别和已读状态分页 |
| PUT | `/api/admin/notifications/{id}/read` | 幂等标记单条已读 |
| PUT | `/api/admin/notifications/read-all` | 全部或按分类已读 |
| GET | `/api/admin/notifications/{id}/target` | 返回受控目标类型，不返回任意 URL |
| GET | `/api/c/v1/notifications/unread-count` | C 端当前客户未读数 |
| GET | `/api/c/v1/notifications` | C 端简化消息列表 |
| PUT | `/api/c/v1/notifications/{id}/read` | C 端幂等已读 |
| PUT | `/api/c/v1/notifications/read-all` | C 端全部已读 |

### 3.3 企业微信审批接入

#### 3.3.1 系统边界

企业微信负责模板、审批节点、审批人、会签/或签、通过、驳回、撤销、审批意见、审批附件和企业微信端待办。本系统不保存本地审批任务，也不提供审批按钮，只负责：

- 创建退款（允许现有平台/超级管理员/代理发起）和平台员工线下充值业务单。
- 保存提交时业务快照和本地业务资料，上传企微附件副本。
- 以稳定场景码映射企微模板版本和控件 ID。
- 保存 `sp_no`、审批状态、审批人/意见/附件详情快照。
- 接收回调并以 `getapprovaldetail` 查询作为状态权威来源。
- 审批终态后幂等执行退款或线下充值业务处理。

Viper 配置包含 `corp_id`、`agent_id`、`agent_secret`、回调 Token/EncodingAESKey、审批回调路径、账号绑定回调 URL、代理代提交固定成员 `agent_approval_creator_userid`、2 分钟审批轮询和 10 分钟模板验证间隔。Secret 和代理代提交身份只允许通过部署配置提供，后台只返回“是否就绪”、固定成员显示名和最近连通结果，不提供在线修改固定 `userid`。Access Token 缓存在 Redis，TTL 使用 `expires_in-300秒` 并通过锁避免并发刷新。

```mermaid
sequenceDiagram
    actor Submitter as 业务提交人
    participant App as Refund/Recharge Application
    participant DB as PostgreSQL
    participant Outbox as Outbox
    participant Worker as WeCom Worker
    participant WeCom as 企业微信
    participant Sync as Approval Sync
    participant Biz as 业务终态用例

    Submitter->>App: 创建退款/线下充值
    App->>DB: 业务单+企微实例(submitting)
    App->>Outbox: SubmissionRequested
    Worker->>WeCom: 按账号类型解析userid并applyevent
    WeCom-->>Worker: sp_no
    WeCom->>Sync: 加密状态回调
    Sync->>WeCom: getapprovaldetail
    Sync->>DB: 保存详情快照并幂等更新状态
    Sync->>Outbox: 首次终态写业务事件
    Outbox->>Biz: 退款/充值终态处理
```

#### 3.3.2 模板版本与暂停切换

业务代码只引用稳定场景码：

| `scene_code` | 业务 |
|--------------|------|
| `refund_approval` | 退款审批 |
| `offline_recharge_approval` | 平台员工线下充值审批 |

`tb_wecom_approval_scene` 保存场景启用、暂停中、已暂停和当前模板版本；`tb_wecom_approval_template_version` 保存不可变的 `template_id`、控件映射、模板快照、fingerprint、验证状态和发布时间。

企微编辑模板可能更换模板 ID，删除再添加控件也会更换控件 ID，因此发布流程必须是：

```text
暂停业务场景并等待提交租约清空
→ 在企微编辑模板
→ 输入新的 template_id 并读取模板
→ 将稳定业务字段映射到当前控件 ID
→ 后端重新调用 gettemplatedetail 校验
→ 原子发布新版本并恢复场景
```

退款至少映射店铺、退款单号、订单实收金额、可退款区间、固定申请金额、原因、备注、附件和真实业务提交人；线下充值至少映射店铺、充值单号、金额、备注、附件和提交人。退款金额在提交后只读，企微审批人只能同意或拒绝。后台每 10 分钟验证启用版本，模板不可访问或 fingerprint 变化时暂停场景并发送系统告警。

场景处于暂停中、已暂停，或当前模板版本失效时，新的退款/线下充值创建请求必须在写入任何业务单、审批实例或 Outbox 之前失败，并返回明确的“审批场景当前不可用”。前端保留用户已经填写的表单，待场景恢复后由用户重新提交；后端不为失败请求保留待补提的孤儿业务单。暂停前已经成功创建的审批实例继续接收回调、执行 2 分钟兜底同步和终态业务处理，不受场景暂停影响。

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

企微 `applyevent.creator_userid` 按当前登录账号类型解析：平台账号和超级管理员必须使用其系统账号扫码绑定的本人企微 `userid`；代理账号发起退款时使用部署配置中的 `agent_approval_creator_userid` 代提交。退款或线下充值的真实业务提交人始终取当前登录账号，并独立保存账号 ID、名称、角色和店铺快照。

约束：

- 平台/超级管理员通过 5 分钟一次性会话扫码绑定；`account_id` 与 `wecom_userid` 均一对一唯一，普通运营不手工录入 `userid`。
- 平台/超级管理员未绑定、绑定失效或成员不可用时，拒绝其新申请并原地提供绑定入口；代理账号不要求绑定企微。
- 代理代提交固定成员必须属于当前 CorpID、处于可用状态并在自建应用可见范围内；未配置或失效时拒绝代理新申请。以上校验都在任何业务单、审批实例或 Outbox 落库前完成。
- 企微模板中的 `submitter` 必填字段始终写入真实业务提交人名称和账号标识；代理审批单不能只展示固定代提交账号。
- 审批实例同时保存真实业务提交人快照、本次实际使用的企微 `userid` 快照及身份来源 `self_binding/agent_proxy`；列表、详情、通知、权限与审计中的“提交人/申请人”均指真实业务提交人。
- 平台账号换绑、解绑以及代理固定成员变更只影响新审批；历史实例保留提交时身份快照，不改写历史记录。
- 后台账号绑定列表只允许查看和强制解绑；代理固定身份只展示就绪状态及成员显示名，不提供在线修改 `userid`。

#### 3.3.4 审批实例与状态同步

`tb_wecom_approval_instance` 的每条记录表示一次独立审批申请，保存：业务类型/ID/编号、场景、模板版本和映射快照、真实业务提交人账号及显示快照、实际企微发起 `userid` 快照、身份来源 `self_binding/agent_proxy`、`sp_no`、提交业务快照、企微详情和审批人快照、业务处理结果、轮询时间、乐观锁版本。退款和线下充值业务表使用 `approval_instance_id` 明确指向唯一审批申请，审批实例以 `(biz_type, biz_id)` 唯一约束保证一张业务单只有一条审批；不使用 `round_no`，也不存在从同一业务单推算“当前轮次”的逻辑。

状态：

| 本地状态 | 企微状态 |
|----------|----------|
| 提交中/提交失败/提交结果未知 | 尚未取得明确审批单结果 |
| 审批中 | `sp_status=1` |
| 已通过 | `sp_status=2` |
| 已驳回 | `sp_status=3` |
| 已撤销 | `sp_status=4` |
| 通过后撤销 | `sp_status=6` |
| 已删除 | `sp_status=7` |

`applyevent` 请求已发送但响应超时必须标记“提交结果未知”，禁止盲目重试产生重复审批。异常恢复同时提供两个受控动作：

- “绑定已有 `sp_no`”：先调用 `getapprovaldetail`，核对企业、模板版本、实例保存的实际企微发起 `userid`、身份来源、业务场景、真实业务提交人字段和提交业务快照；全部匹配后才允许绑定，禁止只校验编号存在。
- “确认企微未创建并重新发送”：保留原未知尝试和 Integration Log，在同一审批申请下记录新的技术提交尝试；它不是一条新的业务审批申请。企微已经拒绝或进入其他明确业务终态后，原业务单不得再次发送；后续确有业务需要时只能重新创建新的业务单和审批申请。

两个动作只允许超级管理员或具备独立“企微审批异常恢复”权限的平台账号执行，并记录操作人、依据、审批申请、技术尝试或绑定 `sp_no`、校验结果和前后状态的高风险 Audit Event。

回调只负责验签、解密、保存 Integration Log 并触发统一 `SyncApprovalStatus`。审批中实例每 2 分钟兜底查询，回调和轮询共用状态同步用例；首次进入终态时同事务写业务 Outbox，`business_processed_at` 保证资金动作只执行一次。

#### 3.3.5 权限边界

企微能力拆分授权，禁止用一个泛化权限同时覆盖配置和异常操作：

| 能力 | 超级管理员 | 平台账号 | 代理账号 |
|------|------------|----------|----------|
| 查询连接、场景和模板版本 | 允许 | 不允许 | 不允许 |
| 暂停/恢复场景、读取/发布模板映射 | 允许 | 不允许 | 不允许 |
| 管理平台账号绑定列表、强制解绑 | 允许 | 不允许 | 不允许 |
| 查询、重新绑定、解绑本人企微 | 允许 | 仅本人 | 不提供绑定能力 |
| 审批运行列表和详情 | 允许 | 仅具备独立“企微审批运营”权限 | 不允许 |
| 立即同步审批详情 | 允许 | 仅具备独立“企微审批运营”权限 | 不允许 |
| 提交未知异常恢复 | 允许 | 仅具备独立“企微审批异常恢复”权限 | 不允许 |
| 业务退款详情中的审批区块 | 按业务查看权限 | 按业务查看权限 | 按现有店铺层级数据范围 |

平台账号的“企微审批运营”权限只允许查看运行记录和触发 `getapprovaldetail` 同步，不包含场景、模板、账号绑定管理或提交未知恢复。所有接口以后端角色与权限校验为准，前端隐藏入口不能替代鉴权。

审批详情按查看主体投影，禁止把平台内部审批资料随业务详情或导出泄露给代理：

- 代理在其店铺层级数据范围内只能看到真实业务提交人、审批状态、状态更新时间、业务处理结果，以及代理自己提交的退款资料和业务凭证。
- 代理不得看到企微审批人名单、内部审批意见和审批人在企微上传的附件。
- 平台账号和超级管理员在具备对应退款查看权限时可查看完整审批详情，包括审批人、意见、时间线和审批附件。
- 审批附件详情、受保护下载解析与导出必须复用同一主体权限投影；不得因持有 `attachment_ref` 或历史导出文件而绕过当前权限。

#### 3.3.6 DDD 边界与 API

```text
domain/wecomapproval      场景、模板版本、审批状态和终态幂等
application/wecomapproval 发布模板、提交审批、回调、同步和异常恢复
domain/wecomidentity      平台系统账号与企微成员一对一绑定
application/wecomidentity 创建绑定会话、完成绑定和解绑
infrastructure/adapter/wecom Token、模板、附件、审批、身份和回调加解密
query/wecomapproval       审批运行列表、详情和业务摘要
```

主要 API：

| 方法 | 路径 | 用途 |
|------|------|------|
| GET | `/api/admin/wecom/status` | 查询连接配置状态 |
| PUT | `/api/admin/wecom/approval-scenes/{scene_code}/status` | 暂停或恢复场景 |
| POST | `/api/admin/wecom/approval-templates/inspect` | 读取企微模板 |
| POST | `/api/admin/wecom/approval-templates/publish` | 发布控件映射版本 |
| GET | `/api/admin/wecom/approval-templates` | 模板版本列表 |
| POST | `/api/admin/wecom/account-binding/sessions` | 当前平台账号创建扫码绑定会话 |
| GET | `/api/admin/wecom/account-binding/sessions/{session_id}` | 查询扫码绑定结果 |
| GET/DELETE | `/api/admin/wecom/account-binding/me` | 查询或解绑自己 |
| GET | `/api/admin/wecom/account-bindings` | 超管查询绑定列表 |
| DELETE | `/api/admin/wecom/account-bindings/{account_id}` | 超管强制解绑 |
| GET | `/api/callback/wecom/account-binding` | Web 登录回调 |
| GET/POST | `/api/callback/wecom/approval` | 企微审批回调校验和事件 |
| GET | `/api/admin/wecom/approvals*` | 审批运行列表和详情 |
| 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` | 确认企微未创建后重新发送同一审批申请 |

### 3.4 卡状态公共写入、事件触发与运营商回调

#### 3.4.1 三条自动通道

轮询、业务事件触发和运营商回调相互隔离，但最终都进入统一的卡状态应用用例：

```mermaid
flowchart LR
    Polling[现有周期轮询] --> Request[RequestCardObservation]
    Event[关键业务埋点] --> Series[立即/3分钟/5分钟]
    Series --> Request
    Manual[现有手动刷新] --> Request
    Request --> Gateway[Gateway Adapter]
    Gateway --> Apply[ApplyCardObservation]
    Callback[运营商实名回调] --> ACL[Carrier ACL]
    ACL --> Apply
    Apply --> Card[卡状态聚合]
    Apply --> Outbox[套餐/停复机等事件]
    Request --> Integration[Integration Log]
    ACL --> Integration
```

- 现有周期轮询继续兜底，其 PostgreSQL 配置、Redis 分片队列、同步类型间隔、卡级开关、失败重排、并发控制和监控保持现状；本期只把查询成功后的状态应用收口到公共用例。
- 业务事件不是新接口，而是在查询资产、获取实名链接、停复机、支付、套餐激活、切卡、重启等用例成功边界埋点。
- 每个事件序列默认创建立即、3 分钟、5 分钟三个无自动重试任务；达到预期状态后剩余任务提前完成。
- 现有手动刷新接口保持响应契约并直接同步，不再额外创建阶梯任务。
- 事件阶梯任务遇到 Gateway 超频只记录当前 `rate_limited`，不为事件序列维护 `blocked_until` 或 30/60/120 秒退避；后续阶梯任务和现有周期轮询保持各自原计划。

#### 3.4.2 实名判断与状态应用

行业卡不能统一跳过实名。运营商 `realname_link_type` 决定实名要求和链接方式：

| 值 | 规则 |
|----|------|
| `none` | 不需要实名，不创建实名查询任务 |
| `template` | 需要实名，按模板生成链接 |
| `gateway` | 需要实名，通过 Gateway 获取链接 |

`card_category` 只用于分类和展示，不参与实名、复机、套餐激活和轮询资格。资产 `realname_policy` 只决定实名与购买顺序；`realname_link_type=none` 时有效策略统一视为 `none`。

`ApplyCardObservation` 是唯一允许写入实名、流量和网络状态的入口：加载并锁定卡聚合，应用标准观测值，状态变化时同事务保存领域事件和 Audit Event，外部调用始终写 Integration Log。旧轮询 Handler、手动实名修改和刷新 Service 必须改为调用该用例，不保留双实现。

#### 3.4.3 事件序列请求协调

本期不引入活跃/不活跃卡、不增加活跃状态字段、不调整周期轮询间隔或 QPS，也不合并现有多套轮询配置。以下协调规则只作用于业务事件产生的 `0/3/5` 观测序列：

重复控制拆为：

- 同场景、同资源、同同步类型合并未结束的 `0/3/5` 序列。
- 单卡、运营商、接口类型只在一次 Gateway 请求执行期间互斥。
- 运营商最小请求间隔按接入和接口类型配置，默认兜底 10 秒，不使用统一五分钟冷却。

#### 3.4.4 运营商实名回调防腐层

移动、联通、电信分别实现报文 Translator，把 JSON/XML/表单、状态码和成功应答转换为标准实名观测。业务结论百分百信任，不验证来源真实性，也不反查 Gateway。

ICCID 必须按原始长度精确路由：19 位只查 `iccid_19`，20 位只查 `iccid_20`，禁止截断、补位或跨列降级。发布前检查 `iccid_19` 重复并建立未删除数据范围内的部分唯一索引。解除实名回调第一版只写 Integration Log 并返回约定成功报文，不修改卡实名状态。

本需求不新增同步按钮、显式同步 API、活跃状态或下次轮询字段。资产详情只增加“查看同步轨迹”，跳转 `/operations/audit?tab=integrations&resource_type=iot_card&resource_key=...`；现有手动刷新和轮询监控页面保持原契约。

### 3.5 全局多视角审计

#### 3.5.1 四类记录边界

| 类型 | 权威存储 | 用途 |
|------|----------|------|
| Access Log | 日志文件/平台 | HTTP 调试、性能和 request_id |
| Audit Event | `tb_audit_event` | 谁对哪些资源做了什么及结果 |
| Domain Ledger | 钱包流水、订单、退款、充值、企微实例等业务表 | 领域事实 |
| Integration Log | `tb_integration_log` | Gateway、运营商、企微、支付渠道的外部交互 |

`tb_audit_event` 保存不可变 `event_id`、操作注册编码、操作者快照、来源、结果、风险、前后数据、`request_id/correlation_id/parent_event_id`、请求摘要和内容哈希。`tb_audit_event_resource` 支持一个事件关联主要、影响和引用资源，解决退款同时影响订单、钱包、流水和资产的问题。

`tb_integration_log` 保存 provider、方向、operation、外部单号、资源、触发来源/场景/序列/尝试、结果、脱敏请求响应摘要、耗时和关联链路。数据同步的合并、限流、提前完成即使没有真正请求上游，也必须写可解释结果。

#### 3.5.2 写入可靠性和脱敏

- 钱包余额、人工退款结果、代理钱包回溯、线下充值入账、角色权限和关键配置变更必须与业务事务同事务写 Audit Event；审计失败则业务回滚。
- 外部轮询和系统任务写 Integration Log；状态变化、连续失败、人工触发或高风险异常再追加 Audit Event。
- 失败和拒绝在业务回滚后用独立短事务记录，不使用裸 goroutine。
- 密码、操作密码、Token、Secret、企微密钥、支付私钥、验证码、完整身份证、签名 URL 和 `media_id` 永不进入审计。
- Access Log 请求体和响应体使用同一递归脱敏；登录、Token、回调和文件路由只记录字段摘要/哈希。

#### 3.5.3 多视角 API 与前端

主要 Query：

```text
GET /api/admin/audit/events
GET /api/admin/audit/events/{event_id}
GET /api/admin/audit/actors*                 人员行为
GET /api/admin/audit/resources/search
GET /api/admin/audit/resources/{type}/{id}/timeline
GET /api/admin/audit/requests/{request_id}/timeline
GET /api/admin/audit/correlations/{correlation_id}/timeline
GET /api/admin/audit/risks/*
GET /api/admin/audit/integrations*
GET /api/admin/audit/finance/timeline
POST /api/admin/audit/exports
```

前端 `/operations/audit` 使用工作台式 Tab：全局事件、人员行为、资源轨迹、请求/业务链路、资金审计、风险事件、外部集成。资源搜索先返回候选资源再打开时间线；Gateway 同步序列按立即、3 分钟、5 分钟连续展示。敏感字段默认脱敏，查看完整敏感值的动作本身再次审计。

本需求在一次停机发布中全局切换：新功能和现有敏感写操作全部改用统一 Audit Writer，旧账号/资产/轮询日志表停止新写入，不做双写；历史数据通过 Query `UNION ALL` 只读投影到新审计页面。

保留策略：资金、权限、审批和关键配置 Audit Event 保留 5 年，普通业务审计 2 年，Integration Log 默认 180 天，Gateway 无变化成功记录 30 天，Access Log 30 天；达到单表维护阈值后按月分区和归档。

---

## 四、统一前端技术方案

当前仓库不包含前端源码。本节定义框架无关的页面、状态和接口契约；实际目录、状态库和组件名称以各前端仓库现状为准。

### 4.1 页面与模块

| 模块/页面 | 建议路由 | 主要能力 |
|-----------|----------|----------|
| 企微审批运行 | `/operations/wecom-approvals` | 状态、业务单号、模板版本、异常恢复和详情抽屉 |
| 企微配置 | `/system/wecom` | 连接状态、代理固定代提交账号状态、模板映射版本、平台账号绑定和异常审批 |
| 账号企微绑定 | 当前平台用户个人中心 | 扫码绑定、查看成员名称、重新绑定和解绑；代理不展示绑定入口 |
| 站内消息 | `/notifications` | 未读数、列表、已读和受控业务跳转 |
| 全局审计 | `/operations/audit` | 全局、人员、资源、链路、资金、风险和外部集成多视角 |
| 系统配置 | `/settings/system-config` | 受控单选、复选和开关 |
| 店铺管理 | 现有店铺创建、编辑、列表和详情 | 平台业务员绑定、业务员筛选和信用额度来源展示 |
| 代理资金概况 | 现有资金页面 | 固定 100 元余额预警、角色默认额度和店铺单独调额 |
| 代理系列授权 | 现有系列授权创建和详情页 | 套餐候选批量选择、已授权标记和不可重复选择 |
| 资产详情 | 现有卡/设备详情页 | 预计最终到期时间、换货前代/后代标识和受控跳转 |
| 异步任务 | 复用各业务页面 | 导入、批量订购、导出进度和失败明细 |

全局状态只保存登录用户、权限和通知未读数。列表、详情和表单草稿保留在页面或模块级状态，避免复制服务端状态后长期失真。

顶部铃铛固定宽度显示 `0/1～99/99+`，点击打开最近 10 条通知抽屉，按全部、审批、临期、同步/系统分类；通知中心提供分类、已读和严重级别筛选及全部已读。点击通知先幂等标记已读，再解析受控目标，未知目标只展示正文不跳转。

### 4.2 通用显示规则

- 前端使用 `status` 判断逻辑，使用后端返回的 `status_name` 展示中文。
- 金额接口使用分，金额组件显示元；转换结果必须是整数。
- 时间按后端 ISO 8601 和现有时区工具展示。
- 无权限和资源不存在使用统一页面语义，避免泄露资源存在性。
- 操作成功后重新请求服务端详情，不在本地自行推进审批或资金状态。

### 4.3 异步任务交互

```mermaid
stateDiagram-v2
    [*] --> Editing
    Editing --> Submitting: 提交文件和参数
    Submitting --> Processing: 创建任务成功
    Submitting --> Editing: 参数或上传失败
    Processing --> Processing: 轮询进度
    Processing --> Completed: 业务处理完成
    Processing --> Failed: 任务失败
    Completed --> [*]
    Failed --> Editing: 修正后创建新任务
```

- 创建成功后立即请求一次详情，再按 2、3、5 秒退避，最大间隔 10 秒。
- 页面不可见时暂停轮询，恢复可见时立即刷新。
- 网络错误不等于业务失败，展示“状态获取失败，点击重试”。
- 全局异步任务状态固定为 `1=待处理, 2=处理中, 3=已完成, 4=已失败, 5=已取消`；各业务不得另占状态码或定义“部分成功”状态。
- 必须展示总数、成功数和失败数；全部成功、部分成功和全部业务项失败是完成任务的结果摘要，由计数推导。
- 页面刷新后根据任务 ID 恢复进度。
- 批量文件模板由前端静态资源提供；设备批量分配和批量订购均只接受 CSV，但各自使用独立表头契约。后端仍严格校验表头、编码、文件大小和内容。

### 4.4 企业微信审批交互

- 删除本系统待我审批、流程节点配置、审批人配置、通过/驳回/退回按钮，审批操作全部在企业微信完成。
- 退款和线下充值创建成功后先显示“正在提交企业微信审批”，取得 `sp_no` 后显示“企业微信审批中”。
- 平台/超级管理员的业务详情企微区块展示审批单号、状态、模板版本、真实业务提交人、审批人、意见、审批附件、状态时间线和本地业务处理结果；代理视图只展示真实业务提交人、审批状态、状态时间和业务处理结果，不返回平台内部审批人、意见或审批附件。
- “立即同步”只调用 `getapprovaldetail`，不提供任何本地审批动作。
- 通过后撤销且资金动作已执行时使用高风险异常提示，明确说明不会自动冲正。
- 平台/超级管理员未绑定企微时，创建页原地展示“绑定企业微信”按钮，绑定成功后继续当前表单；代理不展示绑定入口，后端使用固定企微账号代提交。
- 代理固定代提交账号未配置、失效或不在应用可见范围时，代理创建页展示后端返回的场景不可用原因并保留当前表单。
- 模板发布使用业务字段与企微控件的可视化映射，不向运营暴露 JSON；场景暂停时创建页提前展示维护原因。
- `/system/wecom` 仅超级管理员可访问；平台账号只在个人中心维护本人绑定，具备“企微审批运营”权限时才显示审批运行页；代理只在自己有权查看的退款详情中查看审批区块。

### 4.5 审批与业务处理状态

退款和充值详情必须分别展示审批状态、企微状态更新时间、业务处理状态，以及失败摘要和“系统重试中/联系管理员”提示。

`approval_source` 展示规则：

| 值 | 前端行为 |
|----|----------|
| `none` | 不显示审批区域 |
| `wecom` | 展示只读企微详情和本地业务处理结果 |
| `legacy` | 只读展示历史摘要，不伪造节点，不提供审批按钮 |

### 4.6 权限与敏感数据

- 企微连接、平台账号绑定与代理固定代提交账号状态、导出字段、审计视角和数据范围均以后端为准。
- 审批附件的详情展示、下载解析和导出沿用审批详情主体权限：代理提交的业务凭证可按业务权限访问，平台内部审批附件仅平台/超级管理员按权限访问。
- 通知跳转使用前端受控 `ref_type` 路由表，不接受任意 URL。
- 退款凭证、充值凭证、身份证、营业执照和审批附件均走对象存储，不保存永久公开地址。
- 角色导出字段配置只控制列，不扩大已有店铺或企业数据范围。
- 审计前后数据和外部交互摘要默认脱敏；敏感查看权限和审计导出权限分开控制。

---

## 五、需求 01～15、22 专项方案

### 5.1 简单改动汇总

| 需求 | 业务规则 | 后端/数据 | 前端与验收 |
|------|----------|-----------|------------|
| 需求 01 行业卡复机 | 是否要求实名只看运营商 `realname_link_type`；`none` 可未实名复机，`template/gateway` 仍需实名 | 删除按 `card_category=industry` 放行或跳过实名的逻辑，复用卡状态领域规则 | 验证同为行业卡但不同运营商实名能力时行为不同，卡类别不再影响结果 |
| 需求 03 联系电话搜索 | 店铺联系电话 11 位精确查询 | 店铺列表 Query 增加 `contact_phone` | 增加搜索框；空参数不影响原查询，非法号码返回参数错误 |
| 需求 04 退款中禁止换货 | 企微审批中、历史已退回、企微已通过但退款终态未完成的资产禁止换货；已拒绝、已撤销或退款完成后放行 | 创建换货前批量校验活跃退款，返回“该资产存在退款申请” | 前端直接展示后端错误，不自行判断退款状态 |
| 需求 06/11 套餐到期时间 | 资产层只展示当前生效及排队主套餐全部接续后的预计最终到期时间；当前套餐自身到期时间只保留在套餐明细 | 资产详情 Query 按队列顺序使用购买时长快照推演；无套餐返回 `null`，等待未知实名激活时返回不可预计状态 | 文案使用“预计套餐到期时间”；高亮和临期统一使用最终剩余天数，不维护第二套资产汇总字段 |
| 需求 07 实名筛选 | 卡按自身实名状态；设备任一有效绑定卡实名即视为设备实名 | 设备增加 `real_name_status` 快照；实名变化、绑定、解绑、换卡均刷新旧/新设备 | 卡和设备列表增加全部/已实名/未实名筛选 |
| 需求 12 换货显示与搜索 | 卡的新旧资产标识统一快照 ICCID；设备维持设备号；历史数据不回填 | 创建快照逻辑修正；列表增加 `old_asset_keyword/new_asset_keyword`，支持 ICCID、接入号、虚拟号 | 搜索框拆为旧资产和新资产；验收大结果集查询性能 |
| 需求 13 列表字段 | 提交人写业务快照；企微审批摘要动态读取 | 换货、退款、充值增加 `submitter_name`；按本页企微实例批量查询状态，平台视角可查询审批人摘要，代理视角摘要固定为空，禁止 N+1 | 展示企微状态和处理状态；当前审批人仅平台/超级管理员按业务权限展示 |
| 需求 15 下架套餐续费 | 禁用套餐始终不可购买；下架套餐只允许资产所有人基于有效历史使用记录续费，禁止代理代购 | 复用统一套餐可售策略；后端根据资产和登录主体判定续费资格 | C 端当前套餐旁展示“续费”，复用购买流程；新购入口隐藏，后台代购禁用 |

### 5.2 需求 02：H5 实名与充值顺序配置

#### 规则

| 资产视角 | 策略来源 |
|----------|----------|
| 独立卡 | 卡的 `realname_policy` |
| 设备 | 设备的 `realname_policy` |
| 设备下的卡 | 设备的 `realname_policy`，卡自身值不参与 H5 流程 |

资产顺序策略：`none`、`before_order`、`after_order`，默认 `after_order`。运营商能力先于资产顺序策略：`realname_link_type=none` 时有效策略固定为 `none`；其他值下如果资产策略为 `none`，属于待修复冲突数据，禁止运行时静默放行。

```mermaid
flowchart TD
    Login[用户进入 H5] --> Resolve[解析资产视角]
    Resolve --> Policy{realname_policy}
    Policy -->|none| Pay[直接进入充值/购买]
    Policy -->|before_order| Realname[先实名]
    Realname --> Pay
    Policy -->|after_order| PayFirst[先充值/购买]
    PayFirst --> Prompt[完成后提示实名]
```

现有字段和单条接口继续使用：

```text
PATCH /api/admin/assets/{identifier}/realname-mode
```

新增批量接口：

```text
POST /api/admin/iot-cards/batch-update-realname-policy
POST /api/admin/devices/batch-update-realname-policy
```

- 单次最多 500 条；事务内全成全败，任一资产不合法则返回明确失败原因并不更新任何记录。
- 设备下的卡在后台修改卡策略时，前端提示“实际 H5 流程由设备策略决定”。
- C 端只使用初始化接口返回的有效策略，不自行判断资产类型；本期不增加全局默认配置，新建卡/设备继续使用模型默认 `after_order`。

### 5.3 需求 05：套餐分配生效条件

完整生命周期：

```text
套餐默认规则
  -> 代理分配可选覆盖
  -> 客户购买时计算有效值并写入 PackageUsage 快照
  -> 套餐激活、排队接续和最后到期时间只读取快照
```

覆盖的含义是“该代理未来购买此套餐时采用的规则”，不是修改套餐本身。客户购买后，`PackageUsage` 同时快照 `expiry_base`、周期类型和时长；之后修改套餐或分配配置都不影响已经购买的套餐。旧记录缺少快照时仅兼容回退到当前套餐值，不能把回退逻辑用于新订单。

数据变更：

| 表 | 字段 | 说明 |
|----|------|------|
| `tb_shop_package_allocation` | `expiry_base_override` nullable | `NULL` 表示跟随套餐默认 |
| `tb_package_usage` | `expiry_base_snapshot`、`calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot` | 新订单写入有效值和时长；用于激活和排队到期推算 |

API：

```text
POST  /api/admin/shop-package-allocations
PATCH /api/admin/shop-package-allocations/{id}/expiry-base
```

- 修改分配覆盖值只影响后续新订单，不修改已经形成的使用记录快照。
- 前端提供“跟随套餐默认、购买即生效、实名即生效”三选一。
- PATCH 必须能区分“字段缺失”和“显式恢复默认”；实现时使用可识别 JSON `null` 的 DTO，不能用普通指针静默混淆。

示例：套餐默认“实名即生效”，代理 A 覆盖为“购买即生效”。代理 A 的客户购买后，使用记录写入 `from_purchase`；后来将套餐默认改为“实名即生效”或删除代理覆盖，已购买记录的计时方式和预计到期时间均不变化。

### 5.4 需求 08：设备批量分配

“分配代理”和“分配套餐系列”是两个业务命令，不允许一个任务同时修改两个字段。两者只接受单列 UTF-8 CSV，复用任务轮询和失败明细基础设施，但使用两个独立创建接口且每个任务只携带一个目标值。

```mermaid
sequenceDiagram
    actor User as 平台或代理
    participant Web as 设备管理页
    participant API as Batch Allocation API
    participant DB as PostgreSQL
    participant Worker as Asynq Worker

    User->>Web: 选择一种操作并上传 CSV 到对象存储
    Web->>API: 提交 file_key 和 shop_id 或 series_id
    API->>DB: 保存任务和文件 Key
    API-->>Web: task_id
    Worker->>DB: 分批查询、条件更新、记录失败
    Web->>API: GET /api/admin/devices/batch-allocation/{id}
```

任务字段：任务号、源文件 Key、`operation_type=assign_shop|assign_series`、目标 ID、操作人、总数、成功数、失败数、状态、失败明细、开始/完成时间。

处理规则：

- 设备号去重后批量查询，禁止逐行查询。
- CSV 固定一列“设备号”，支持虚拟号或 IMEI 精确匹配；不接受 Excel、multipart 文件字节、号段或模糊搜索。
- 前端复用受控预签名上传，业务接口只接收 `file_key` 和对应目标 ID；Asynq 载荷只传结构化 `task_id`。
- `assign_shop`：已属于其他代理的设备失败并提示先回收；已属于目标代理的按幂等成功。
- `assign_shop`：平台只能分配平台库存设备；代理只能把自己名下设备分配给直属下级。
- `assign_series`：已属于目标套餐系列的按幂等成功；不修改 `shop_id`。
- `assign_series`：代理只能操作自己名下设备并选择自己当前有效授权的系列。
- 临时本地路径和文件字节不得进入 Asynq 载荷。
- 模板由前端提供，后端不实现下载接口。
- 限制：文件最大 10MB、最多 1000 行、Worker 每批 200 条、失败明细最多保存 1000 条。
- 前端提供两个独立入口和两个表单：批量分配代理、批量分配套餐系列。

### 5.5 需求 09：C 端支付方式限制

卡和设备的允许支付方式读取系统配置，钱包始终允许。初始默认值：

| 资产 | 默认允许方式 |
|------|--------------|
| 卡 | 支付宝、钱包 |
| 设备 | 微信、钱包 |

- C 端支付页只展示接口返回的允许方式。
- 创建订单时 `payment_method` 必填，后端校验后固化为订单不可变快照；强充在创建流程中立即按该方式拉起渠道，钱包不能用于给自身强充。
- 后续支付接口不允许重新选择或替换 `payment_method`，只能执行订单快照方式，并在真正支付前按最新资产配置再次校验。需要换方式时先取消待支付订单再重新创建。
- 普通资产钱包充值只展示并接受资产允许集合与 `wechat|alipay` 的交集，不能把 `wallet` 当成充值渠道。
- 配置异常时使用上述安全默认值并记录错误，不得放开全部方式。
- 后台配置使用复选框，不直接编辑 JSON。

### 5.6 需求 10：Gateway 限速

本期只提供统一的**手动卡限速**能力，不在套餐上配置固定限速，也不因套餐激活、到期、切卡或停机自动调用 Gateway。

限速最终对象始终是卡 ICCID：

```mermaid
flowchart TD
    Trigger[卡详情手动选择固定档位] --> ICCID[校验 IoT 卡 ICCID 与数据权限]
    ICCID --> Gateway[SetCardSpeedTier cardNo, code]
    Gateway --> Audit[记录操作审计和 Gateway 结果]
```

#### 5.6.1 CMP 业务契约

数据和契约：

- 后台接口只接收固定整数档位 `code=-1..8`，前端只能从固定档位中选择，禁止输入任意速率。
- 卡专用接口：`PUT /api/admin/iot-cards/{iccid}/speed-tier`。设备没有限速入口，也不得通过设备当前绑定卡间接限速。
- Gateway 端口只有 `SetCardSpeedTier(cardNo, code)`，`cardNo` 必须是经过本地权限校验的卡 ICCID，不得传设备 ID 或 IMEI。
- “恢复不限速”和“限到 0kbps”是两个不同业务等级，必须分别映射为上游 `code=-1` 和 `code=0`，禁止继续用数值 `0` 表示取消限速。
- 上游是否支持具体运营商与账户由 Gateway 返回结果确认；本系统只校验固定档位并原样发送，不自行推算运营商策略。
- 不新增 `tb_package.speed_limit_kbps`、`SpeedLimitApplyRequested` Outbox 或自动补偿 Worker。本期也不宣称能展示 Gateway 当前实际限速，除非上游另提供查询接口。
- 仅卡详情提供固定档位选择及恢复不限速入口。每次操作记录卡 ICCID、实际发送的 `code`、操作人、Integration Log、请求结果和错误摘要。

CMP 固定档位如下，接口直接提交表中的整数 `code`。

| `speed_level` | 展示文案 | 当前上游默认 `code` |
|---------------|----------|----------------------|
| `unlimited` | 恢复不限速 | `-1` |
| `zero_kbps` | 限到 0kbps | `0` |
| `limit_128_kbps` | 128Kbps | `1` |
| `limit_512_kbps` | 512Kbps | `2` |
| `limit_1_mbps` | 1Mbps | `3` |
| `limit_2_mbps` | 2Mbps | `4` |
| `limit_10_mbps` | 10Mbps | `5` |
| `limit_20_mbps` | 20Mbps | `6` |
| `limit_50_mbps` | 50Mbps | `7` |
| `limit_100_mbps` | 100Mbps | `8` |

#### 5.6.2 Gateway 上游接口文档

上游正式环境地址为 `https://open.whjhft.com/openapi`，限速接口为 `POST /flow-card/speedLimit`。统一 Gateway 客户端发送时使用 `params` 包装业务参数：

```json
{
  "params": {
    "cardNo": "89861124221081232235",
    "code": "-1"
  }
}
```

以下为上游提供的原始 OpenAPI 文档。文档中的成功响应 `example` 实际是错误页面文案，不是有效 JSON；实现和自动化测试不得据此构造成功响应，响应字段以 schema 及真实环境联调结果为准。

```yaml
openapi: 3.0.1
info:
  title: ''
  version: 1.0.0
paths:
  /flow-card/speedLimit:
    post:
      summary: 流量卡限速接口
      deprecated: false
      description: |
        只有广电接口和电信接口存在限速 联通是通过通信计划调整 不同账户相同速率的编码不一致 移动无限速
        | 字段名 | 类型 | 必填 | 说明 |
        |--------|------|------|------|
        | cardNo | string | 是 | 流量卡ICCID号码 |
        | code | string | 是 | 限速档位，取值见下表 |

        **code 档位对照表：**

        | code | 速率 |
        |------|------|
        | -1 | 限速恢复（取消限速） |
        | 0 | 0kbps |
        | 1 | 128Kbps |
        | 2 | 512Kbps |
        | 3 | 1Mbps |
        | 4 | 2Mbps |
        | 5 | 10Mbps |
        | 6 | 20Mbps |
        | 7 | 50Mbps |
        | 8 | 100Mbps |
      tags:
        - 流量卡
        - flow-card
      parameters: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties: {}
            example:
              params:
                cardNo: '89861124221081232235'
                code: '-1'
        required: true
      responses:
        '200':
          description: ''
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: integer
                    description: 200成功
                  msg:
                    type: string
                  data:
                    type: object
                    properties:
                      appliedSpeed:
                        type: string
                        description: 实际限速值
                      channelRawValue:
                        type: string
                        description: 对应运营商过去的值
                      iccid:
                        type: string
                    required:
                      - appliedSpeed
                      - channelRawValue
                      - iccid
                    x-apifox-orders:
                      - appliedSpeed
                      - channelRawValue
                      - iccid
                required:
                  - code
                  - msg
                  - data
                x-apifox-orders:
                  - code
                  - msg
                  - data
              example: 抱歉，您访问的页面不存在。
          headers: {}
          x-apifox-name: 成功
          x-apifox-ordering: 0
      security: []
      x-apifox-folder: 流量卡
      x-apifox-status: developing
      x-run-in-apifox: https://app.apifox.com/web/project/6930706/apis/api-487496525-run
components:
  schemas: {}
  responses: {}
  securitySchemes: {}
servers:
  - url: https://open.whjhft.com/openapi
    description: 正式环境
security: []
```

### 5.7 需求 14：统一导出与字段权限

复用现有 `tb_export_task + DataSource + Asynq`，新增或扩展以下场景：

| Scene | 内容 |
|-------|------|
| `iot_card` | 增加套餐名称、已用流量、剩余流量 |
| `agent_wallet_transaction` | 代理主钱包流水 |
| `package` | 套餐基础、流量、价格、状态和审计字段 |
| `refund` | 退款业务字段、动态审批摘要和凭证 |
| `exchange` | 换货新旧资产、收货和物流字段 |
| `agent_recharge` | 充值、余额、状态、审批摘要和凭证，不含支付通道 |
| `expiring_asset` | 临期资产、套餐、到期和流量字段 |

统一创建接口：

```text
POST /api/admin/export-tasks
body: scene + format + query
```

字段权限：

```text
resolved_fields = 角色授权字段并集
                ∩ 场景支持字段
```

新增 `tb_role_export_field_permission(role_id, scene, field_key)`，三列唯一。超级管理员拥有代码目录内全部字段；普通账号从当前有效角色的授权并集计算。数据行范围继续使用现有权限，字段授权不能扩大菜单、接口、场景或数据行范围。前端不得提交 `fields`，也不提供导出字段选择器；账号获授权的代码目录字段全部导出。

API：

```text
GET /api/admin/export-fields?scene={scene}
GET /api/admin/roles/{role_id}/export-fields
PUT /api/admin/roles/{role_id}/export-fields
```

- 创建任务时快照最终字段和表头，Worker 不能因后续角色变化扩大字段。
- 权限解析失败时拒绝导出，不能回退到全字段。
- 普通账号解析成功但最终字段为空时不创建任务；查询当前账号字段时返回空数组，前端据此禁用导出入口。
- 不存在 `default_selected`、`required` 或后端自动补列。角色配置是代码目录内字段能否导出的权威来源；密码、密钥、Token、对象存储 Key、企微 `media_id` 等永久禁止导出的字段不得注册进代码目录。
- Count 和 Fetch 必须使用相同权限及查询条件。
- 退款和充值审批摘要按本批实例批量查询，禁止 N+1。
- 退款/充值业务凭证和企微审批附件可按字段权限导出受保护的稳定业务链接，但字段权限不能突破审批主体可见性：代理可导出其有权查看的业务凭证，不能导出或解析平台内部企微审批附件；不得输出对象 Key、企微 `media_id`、当前预签名 URL 或公开 Bucket URL。平台审批摘要仍可保留附件数量。
- `scene=iot_card` 支持按预计最终到期时间筛选 30 天内资产；该导出条件独立于页面 15 天临期定义，复用需求 06/11/22 的最终到期 Query。

受保护附件访问流程：

```text
导出单元格中的后台前端绝对 URL
→ /export-attachments/{attachment_ref}
→ 未登录时进入后台登录并保存当前站内返回地址
→ 登录成功返回附件落地页
→ 前端携带 Bearer Token 请求 GET /api/admin/attachments/{attachment_ref}/download-url
→ 后端按附件所关联退款单、充值单或审批业务重新校验当前查看权限、数据范围和附件种类可见性
→ 返回短期 download_url、expires_at、file_name
→ 前端在当前页跳转短期地址，浏览器打开或下载私有文件
```

- `attachment_ref` 是不可变、不可枚举的稳定引用；业务附件被替换时旧引用不得改指新文件。
- 附件仍保存在私有 Bucket。稳定的是后台前端落地页地址，不是对象存储地址；每次访问都重新鉴权并生成短期地址。
- 无权与引用不存在对外使用统一错误，避免探测；角色或数据范围在导出后被收回时，旧导出文件中的链接也不能继续下载。
- 企微审批附件必须先保存到本地私有对象存储并建立稳定引用，不能依赖临时 `media_id`。历史确无本地附件事实时输出空；匹配记录仍处于应有附件但尚未本地化的异常状态时，导出任务明确失败。
- 前端附件落地页必须覆盖登录恢复、加载中、无权限/不存在、对象文件不可用、解析失败和成功跳转状态；不得把后端 API URL直接写进导出文件，因为浏览器无法为普通文件链接自动附加后台 Bearer Token。

### 5.8 需求 22：套餐临期提醒

临期定义：按 `Asia/Shanghai` 自然日计算，资产当前生效主套餐与全部排队主套餐连续接续后的**预计最终剩余天数**为 `0～15` 天。已过期资产不按 0 天计入临期；没有生效套餐且队首仍等待无法确定时间的实名激活时，不伪造到期日期，也不进入临期。需求 06、11、22 共用同一个最终到期 Query。

| 剩余天数 | 展示颜色 | 通知节点 |
|----------|----------|----------|
| 8～15 天 | 粉红色 | 15 天 |
| 4～7 天 | 紫色 | 7 天 |
| 0～3 天 | 红色 | 3 天 |

```mermaid
flowchart TD
    Schedule[每日定时任务] --> Query[计算预计最终到期时间]
    Query --> Predictable{可以推算?}
    Predictable -->|否| Skip[不临期、不通知]
    Predictable -->|是| Days[计算最终剩余自然日]
    Days --> Node{存在最近未发送阈值?}
    Node -->|是| Notify[按使用记录+节点+接收人幂等通知]
    Node -->|否| End[结束]
```

后端：

- 卡/设备列表和详情增加 `estimated_final_expires_at`、`days_until_final_expiry`、`expiry_estimate_status`、`is_expiring`。
- 列表、详情、临期页、代理首页和 C 端均实时 SQL 计算，不读取每日任务快照，也不需要前端轮询临期状态。
- 新增 `GET /api/admin/expiring-assets`，支持资产、套餐、店铺、到期范围和剩余天数筛选。
- 临期独立列表固定将 `0～3` 天资产置顶，再按预计最终到期时间升序；普通卡/设备列表只高亮，不改变原排序。
- 代理首页增加临期卡/设备数量。
- C 端 `GET /api/c/v1/asset/info` 增加临期字段并提供续费入口。
- 临期导出复用 `scene=expiring_asset`。
- `tb_expiry_push_record` 从本期起按 `package_usage_id + recipient + channel + node` 防重；漏跑后只补发当前最近且尚未发送的阈值，避免一次补发多条旧通知。
- 本期只发送站内通知，不发送企业微信临期消息；店铺业务员通过系统账号接收站内通知。

### 5.9 禅道补充需求：换货、业务员、余额预警和系列授权

#### 5.9.1 #98 换货新资产归属

- 新资产允许处于平台库存或已经属于旧资产店铺；如果已经属于其他店铺则拒绝换货。
- 换货完成事务内先将新资产 `shop_id`、租户标签和资产分配记录切换为旧资产店铺，再迁移客户绑定、钱包/套餐资料并更新新旧资产状态。
- 旧资产保留原 `shop_id`，状态改为已换货，确保原店铺仍可查看历史订单、退款和换货链。
- 前端选择新资产后展示“完成后将归属：{旧资产店铺}”，不增加人工选择目标店铺的控件。
- 换货完成是跨资产、客户绑定、钱包、套餐和状态的一致性用例，迁移到 `exchange` Domain；旧 Service 不再保留第二套完成逻辑。

#### 5.9.2 #86 资产详情换货标识

资产详情 Query 基于已完成换货单返回：

```json
{
  "exchange_trace": {
    "previous_asset": null,
    "next_asset": {
      "asset_type": "iot_card",
      "asset_id": 2002,
      "identifier": "89860...",
      "exchange_no": "EXC202607170001"
    }
  }
}
```

- 存在 `previous_asset` 时显示“换货新资产”；存在 `next_asset` 时显示“已换出旧资产”。链路中间资产可以同时存在前代和后代。
- 关联资产仍需通过后端数据权限校验；无权限时只显示标识，不返回可跳转的资产 ID。
- 复用 `tb_exchange_order`，不新建换货关系表；补充旧资产查询已完成换货和新资产查询索引。

#### 5.9.3 #96 店铺业务员

- `tb_shop` 增加可空 `business_owner_account_id`，只允许绑定启用的平台账号。
- 创建、编辑、列表、详情和筛选接口返回业务员 ID、账号名和手机号摘要。
- 业务员仅表示店铺业务归属和通知接收关系，不参与店铺层级、数据权限、佣金或分销关系计算。
- 业务员停用或删除后保留店铺字段和历史审计，通知时跳过不可用账号；运营可重新绑定。

#### 5.9.4 #97 固定 100 元钱包余额预警

预警阈值固定为 `10000` 分，不提供系统配置、角色配置或店铺配置：

```text
cash_available = balance - frozen_balance
变动前 cash_available > 10000
变动后 cash_available <= 10000
=> 发送一次低余额站内通知
```

- 信用额度不参与余额预警，避免授信掩盖代理现金不足。
- 接收人是店铺主账号和可用的店铺业务员；站内通知使用稳定业务键防重。
- 余额回升到 100 元以上后重新布防，再次跌破时可重新通知；持续低于阈值的连续扣款不重复提醒。
- 资金概况只展示“现金余额不足 100 元”状态，不提供阈值编辑控件。
- 钱包聚合在余额变更后产生低余额领域事件，事务内写 Outbox；通知失败不能回滚资金事务。

#### 5.9.5 #43 代理系列套餐批量授权

不新增套餐候选 API。首次授权使用现有 `GET /api/admin/packages?series_id=...` 选择套餐，并由 `POST /api/admin/shop-series-grants` 在同一事务创建系列授权和至少一条套餐授权；不允许创建没有套餐的空系列授权。

后续管理并行读取现有套餐列表和 `GET /api/admin/shop-series-grants/{id}`，由前端按 `package_id` 合并：已授权项置灰，未授权项可多选，存量已授权但当前不再可售/可见的项目仍通过授权详情只读展示。

保留 `PUT /api/admin/shop-series-grants/{id}/packages` 及已交付的 `packages:[{package_id,cost_price,remove}]` 契约：`remove=true` 表示软删除，未设置或为 `false` 时按当前授权状态新增或改价。单次最多 100 个套餐、事务内全成全败；不得删除 `remove` 字段、要求必填顶层 `operation_type` 或改变既有响应结构。

页面按调用视角分别标注上级当前成本价、目标代理授权成本价和建议零售价；平台视角的上级成本才是公司成本。所有系列、套餐、价格和直属下级权限由后端重新校验，不能依赖前端置灰。

---

## 六、需求16移出记录与需求17～21方案

### 6.1 需求 16：代理分销码与佣金提现（已移出本期）

> 状态：2026-07-14 移出 7 月迭代

本期不建设分销码、H5 代理申请、代理申请审批、自动开店、分销发展关系和佣金提现材料。禅道 #96 新增的店铺业务员字段只用于业务归属、筛选和通知，不产生分销层级或佣金关系。本需求后续独立立项时重新评审数据模型、H5 安全、审批接入、开店幂等、提现材料和工时。

本期审批流仅接入退款和平台员工线下充值；迁移、API、前端、发布及工时均不包含需求 16。

### 6.2 需求 17：代理主钱包信用额度

平台员工不是结算和负债主体，不建立员工钱包或信用额度。客户角色可以保存新建店铺的默认信用模板，但不能成为债务主体；真正参与资金计算的信用额度始终写入代理主钱包。

角色默认规则：

- 新建店铺时读取 `default_role_id` 的默认信用配置并初始化主钱包。
- 修改角色默认信用配置只影响以后新建的店铺，不更新任何已有店铺。
- 已有店铺通过独立资金接口直接修改实际额度，之后也不跟随角色变化。
- 店铺后续增加其他角色不改变钱包信用额度，避免多角色组合影响资金事实。
- 代理账号不能调整自己或任何下级代理的实际信用额度；现有店铺管理权限和数据范围不推导信用额度修改权。
- 实际额度只能由超级管理员，或具备独立信用额度管理权限的平台账号修改。角色默认模板也只允许超级管理员或具备相应角色管理权限的平台账号配置。
- 信用额度不设置产品层固定上限；接口仍须使用分为单位的 `int64` 安全范围并拒绝负数和算术溢出。

#### 不变量

```text
effective_credit = credit_enabled ? credit_limit : 0
available = balance - frozen_balance + effective_credit
available >= 0
```

- 信用额度只作用于代理主钱包，不作用于佣金钱包和资产钱包。
- 余额允许为负，但不能突破可用额度边界。
- 关闭信用时额度必须为 0；开启信用时额度必须大于 0。
- 存在欠款或冻结金额导致修改后可用金额小于 0 时，禁止降低额度或关闭信用。
- 扣款、冻结、解冻、充值、退款回充、调额、版本和资金流水必须维护同一不变量。

#### 数据约束

`tb_agent_wallet` 增加：

```text
credit_enabled BOOLEAN NOT NULL DEFAULT FALSE
credit_limit   BIGINT  NOT NULL DEFAULT 0
```

`tb_role` 增加：

```text
default_credit_enabled BOOLEAN NOT NULL DEFAULT FALSE
default_credit_limit   BIGINT  NOT NULL DEFAULT 0
```

角色字段仅允许客户角色使用。平台角色固定关闭；运行时扣款不 JOIN 角色表。

数据库 CHECK 至少保证：

```sql
CHECK (frozen_balance >= 0),
CHECK (credit_limit >= 0),
CHECK (
    (credit_enabled AND credit_limit > 0)
    OR (NOT credit_enabled AND credit_limit = 0)
),
CHECK (
    balance - frozen_balance
    + CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0
)
```

迁移前必须核对生产库现有约束名和历史异常数据，不能仅依赖 `DROP CONSTRAINT IF EXISTS`。

#### 用例与 API

| 用例 | 规则 |
|------|------|
| 修改角色默认额度 | 只更新角色模板和审计，不扫描、不修改任何已有店铺钱包 |
| 创建店铺 | 在创建事务中读取默认角色模板并初始化代理主钱包实际额度 |
| 修改额度 | 拒绝代理账号；校验平台独立权限、加载主钱包、校验新可用金额、按 `version` 条件更新、写信用变更审计 |
| 钱包扣款 | 使用有效额度计算可用金额，同事务更新余额、版本和资金流水 |
| 查询/导出 | Query 返回 `credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount` |

```text
POST /api/admin/shops                         创建代理时可初始化信用配置
PUT  /api/admin/roles/{id}/default-credit    修改新建店铺默认信用模板
PUT  /api/admin/shops/{id}/credit-limit      独立调整信用额度
GET  /api/admin/shops/fund-summary           返回信用和可用金额
```

角色页面显示“新建代理默认信用额度”，并明确提示“修改后不会影响已有店铺”。店铺资金页面独立显示和修改实际信用额度。前端只展示接口返回的可用金额，不自行重新计算；额度调整弹框显示修改前后金额预览，并发冲突时刷新最新钱包版本。

资金概况中的 `is_in_debt` 表示 `balance < 0`，`debt_amount = max(-balance, 0)`；冻结金额只影响现金可用金额和总可用金额，不直接记为欠款。额度调整只改变资金边界，不伪造一条金额为零的钱包交易流水；变更前后值进入全局 Audit Event。

### 6.3 需求 18：多人审批业务映射

多人审批由企业微信模板负责，本系统不解析审批角色、账号、部门或会签规则：

| 需求 | 方案 |
|------|------|
| 多级、会签、或签 | 在企微模板中配置，本系统使用 `use_template_approver=1` |
| “部门领导→财务” | 可以作为企微模板中的组织规则，本系统不建立部门模型也不推导审批人 |
| 当前节点待办 | 由企业微信自身提醒，本系统不重复生成站内待审批任务 |
| 通过/驳回/撤销通知 | 状态同步后向真实业务提交人生成站内结果通知 |
| 意见和附件 | 从 `getapprovaldetail` 保存审批详情快照并在业务详情只读展示 |
| 历史版本 | 本地保存模板 ID、控件映射和提交快照，历史实例不受新模板影响 |

平台/超级管理员必须扫码绑定并使用本人企微 `userid` 发起；代理退款使用部署配置中的固定企微成员代提交。两类路径都把真实业务提交人写入模板 `submitter` 字段和本地快照，通知与审计均以真实业务提交人为准。

### 6.4 需求 19：批量订购套餐

#### 规则和流程

- 创建批次时选择单个 `package_id` 和整批支付方式 `offline|wallet`；CSV 不包含套餐或支付方式。`wallet` 复用现有后台订单支付枚举，在本批次中表示逐行扣结算代理主钱包，不新增同义枚举 `agent_wallet`。
- 混合支付必须拆成不同批次。
- 批次不选择代理、不接收 `shop_id`；同一 CSV 可以包含不同代理的资产。Worker 逐行以资产当前归属解析结算代理，套餐授权、成本价、钱包和数据权限均以该行解析结果为准。
- 不新增批量订购后端权限码、账号类型拦截或任务创建人隔离。页面是否展示入口沿用前端现有可见性规则；能够通过现有后台认证调用接口的主体视为可以使用。此决定不跳过逐行资产、结算代理、套餐授权、成本价或钱包校验。
- CSV 固定 UTF-8（允许 BOM），只有一列表头 `资产标识`，不接受 Excel。Worker 复用系统统一资产解析能力识别资产类型和资产 ID，不在批量用例维护标识白名单；当前统一能力支持 ICCID、卡 `virtual_no`、MSISDN、设备 `virtual_no`、IMEI 和 SN。
- Worker 按统一解析得到的“资产类型 + 资产 ID”识别重复行。一个批次只有一个套餐，因此重复键不包含套餐；同一资产即使使用不同受支持标识仍属于重复，首次出现的行正常处理，后续重复行失败并返回首次行号。本期不通过复制相同行表达购买多份。
- 资产标识未命中或无法唯一解析时只失败对应行，禁止任意选择资产。明细保留用户原始资产标识、解析后的资产类型/ID和规范标识快照，便于审计和跨标识判重。
- 任务允许部分成功，每行是独立、可重试、可审计的业务单元。
- 文件最大 10MB、最多 1000 行；失败明细最多保存 1000 条。创建接口只校验对象存在、属于当前上传主体、扩展名/Content-Type 和 10MB 大小，不同步解析文件；Worker 校验 UTF-8（允许 BOM）、固定表头、未知列、CSV 语法、空文件和 1000 行上限。任一文件级校验失败时任务整体失败且不创建订单；资产、套餐、归属、钱包和重复行等业务错误才进入逐行失败并允许部分成功。

```mermaid
flowchart TD
    Submit[选择单个套餐和支付方式，CSV及凭证直传私有对象存储] --> Task[提交package_id、file_key和voucher_keys创建任务]
    Task --> Parse[解析并持久化逐行明细]
    Parse --> Item{处理下一行}
    Item -->|钱包| Wallet[锁钱包并校验有效可用金额]
    Item -->|线下| Voucher[校验整批凭证快照]
    Wallet --> Order[单行事务：订单+扣款+流水+明细]
    Voucher --> OfflineOrder[单行事务：已支付订单+明细]
    Order --> More{还有明细?}
    OfflineOrder --> More
    More -->|是| Item
    More -->|否| Summary[从明细汇总任务结果]
```

#### 数据模型

| 表 | 关键字段 |
|----|----------|
| `tb_bulk_purchase_task` | 任务号、`request_id`、文件 Key、所选套餐 ID/编码/名称快照、支付方式、凭证快照、涉及代理数、金额和数量汇总、状态、处理租约 |
| `tb_bulk_purchase_item` | 行号、解析后的资产类型、用户原始资产标识、解析后资产 ID、规范标识快照、结算代理快照、金额、状态、订单 ID、错误码、错误原因、幂等键 |

任务复用全局异步任务状态：`1=待处理, 2=处理中, 3=已完成, 4=已失败, 5=已取消`。文件有效且逐行处理结束时任务为已完成，部分成功只通过 `success_count` 与 `fail_count` 表达；本期没有取消入口，但保留状态码 5。逐行明细不是独立任务，使用 `1=待处理, 2=处理中, 3=成功, 4=失败`。

必须具备：

- 创建接口使用 `request_id` 唯一约束，重复提交返回原任务。
- Worker 通过状态条件和处理租约领取任务/明细，防止并发消费者重复执行。
- 行幂等键：`bulk_purchase:{task_id}:{row_no}`。
- 钱包可用金额统一使用 `credit_enabled` 后的有效信用额度，禁止直接无条件加 `credit_limit`。
- 钱包余额、版本、订单、资金流水和明细成功状态在同一事务。
- 钱包支付逐行扣该资产当前所属代理的主钱包；资产无代理归属、归属异常或该代理没有有效主钱包时只将该行记为失败。同一批次可依次锁定不同代理钱包，不存在整批共享钱包。
- `wallet` 批次严格按 CSV 行号顺序逐行结算，不预占整批或某一代理全部行的金额。当前行余额不足只失败该行并继续处理后续行；后续金额较小的行若当时余额足够仍可成功。因此，同一代理资金不足时，CSV 行序就是订购优先级。
- 单行失败不回滚其他成功行，任务统计从明细表重新聚合。
- 批量订购必须复用需求 15 的套餐可售策略，不能绕过下架续费限制。
- 任务详情和明细复用现有后台认证及公共数据范围，不新增基于账号类型、权限码或任务创建人的过滤。

API：

```text
POST /api/admin/storage/upload-url
POST /api/admin/bulk-purchases
GET  /api/admin/bulk-purchases/{task_id}
GET  /api/admin/bulk-purchases/{task_id}/items
```

CSV 先使用 `purpose=bulk_purchase` 获取预签名地址并直传私有对象存储；线下凭证使用附件用途直传。`POST /api/admin/bulk-purchases` 只接收 JSON：`request_id`、单个 `package_id`、`payment_method`、`file_key`、`voucher_keys`，不接收 `shop_id`、multipart 或文件字节。创建任务前校验套餐存在以及对象归属、类型和 10MB 大小并立即返回任务；逐行按结算代理的当前套餐授权、价格和可售规则再次校验。Worker 按稳定 `file_key` 下载并执行文件级解析校验。

线下凭证仅作为本批次业务资料和审计快照，本期不校验跨批次唯一性或建立财务核销规则。

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

- 已部署测试环境的 API/Worker 使用 Redis DB 6；本地开发和 Agent 自动化测试统一使用 Redis DB 7。自动化测试禁止向 DB 6 写入普通 Redis Key 或 Asynq 任务。
- 测试入口必须在启动前校验实际生效的 Redis DB，只有 DB 7 才允许继续；不能只依赖调用方记得覆盖环境变量。当前 Redis 客户端、Asynq Client 和 Worker Server均从同一 `Redis.DB` 配置取值，测试必须保持三者一致。
- DB 7 也可能被多个本地进程共用，测试不得执行 `FLUSHDB`；测试数据、业务键和清理由每次运行的唯一标识及实际创建 ID 限定。
- 自动化测试直接使用当前可调用的真实 S3，不建立内存对象存储替身；每次运行使用唯一对象 Key，并只删除本次创建的测试对象。真实上传、下载和删除失败都必须作为测试失败暴露。
- PostgreSQL 继续使用现有测试库，不新增专用数据库。测试夹具必须带唯一运行标识并按实际创建记录 ID 精确清理，禁止 `TRUNCATE`、清整表、模糊条件删除或修改既有业务数据；并发钱包测试也在该测试库内使用隔离夹具执行。
- 自动化以 Go HTTP 集成测试为主：通过 Fiber `app.Test` 穿过真实路由、认证、Handler、Application/Domain、GORM 和统一响应；测试接缝捕获待投递任务后直接调用公开 Worker Handler，不依赖 `sleep` 等待后台 Worker，生产仍使用真实 Asynq。
- 本需求实现时必须沉淀可供后续 Agent 复用的集成测试规范、环境守卫与资源清理 Harness、典型 CSV `testdata` 和完整示例；另提供真实部署环境的 `curl` 冒烟模板，但 `curl` 不替代 Go 自动化测试。

### 6.5 需求 20：退款审批

退款创建只接收 `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 <= 订单实收金额`，提交后固定。企微展示订单实收金额、可退款区间和申请金额，审批人只能同意或拒绝，不能修改金额。退款凭证先保存本地对象存储，再上传企微副本；本地对象 Key 是权威资料，企微 `media_id` 只是临时审批副本。

退款状态在历史枚举基础上追加 `5=已撤销/审批已删除`，保留历史 `4=已退回` 语义但新企微审批不再产生退回状态。独立保存业务处理结果，避免“企微已通过但代理钱包回溯或资产处理失败”被展示为全部完成。

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

```mermaid
sequenceDiagram
    actor User as 平台员工/财务
    participant Refund as Refund Application
    participant WeCom as 企业微信审批
    participant Sync as WeCom Approval Sync
    participant DB as PostgreSQL
    participant Worker as Refund Terminal Worker

    User->>Refund: 创建退款申请
    Refund->>DB: 保存退款单、业务快照和企微实例
    Refund-->>WeCom: 异步提交申请和附件
    User->>WeCom: 非代理钱包先人工退款；审批只能同意或拒绝
    WeCom-->>Sync: 回调/轮询同步终态
    Sync->>DB: 保存审批详情并写终态Outbox
    DB-->>Worker: 首次通过时处理业务终态
    Worker->>DB: 资金确认、订单/佣金/套餐整单终结
```

关键规则：

- 无论申请金额是否等于订单实收金额，企微同意后的业务处理都按整张订单终结：订单标记已退款、该订单产生的全部有效套餐失效、全部佣金失效，且该订单不能再申请剩余差额。实际向客户退款的金额仍为本次申请金额。
- 微信、支付宝、线下及其他非代理主钱包支付由财务在系统外人工退款后再通过企微；企微通过即表示人工退款已确认。本系统不调用渠道退款 API，也不提供本地 `manual-complete` 二次确认。
- 个人客户资产钱包支付同样由财务系统外退款，本系统不自动回充资产钱包、不写资产钱包退款流水；现有 `BuyerTypePersonal` 自动资产钱包退款分支在迁移时删除或隔离。
- 代理主钱包支付订单在企微通过后必须按原扣款流水定位原代理主钱包，幂等回溯并写唯一退款流水；可自然冲减负余额。缺失原扣款流水时处理失败，禁止按当前店铺关系或买卖方猜测钱包。
- `actual_refund_amount` 只表示资金已经完成：非代理钱包在企微同意时写申请金额；代理主钱包在余额和退款流水事务成功时写申请金额。驳回、撤销、删除及代理钱包尚未回溯时为空；资金完成但佣金或套餐失败时保留。旧 `approved_refund_amount` 仅作历史兼容，不再是新业务概念。
- 已冻结、解冻中、未发放或待人工修正的佣金直接失效；已发放佣金先从对应佣金钱包全额扣回，再失效，钱包允许为负。佣金保存结构化 `invalid_reason`、`invalid_refund_id`、`invalidated_at`，每条已发放佣金以退款单和佣金记录组成防重键；状态、钱包、回扣流水和 Audit Event 同事务。
- 该订单产生的全部有效套餐失效；主套餐失效级联加油包，随后尝试激活下一条待生效主套餐，没有下一条时通过公共卡/设备状态能力停止资产。
- 驳回时本地状态改为已拒绝，审批和该退款单同时终结；原退款单不可编辑、不可再次提交。业务人员纠正驳回原因后仍需退款时，重新走 `POST /api/admin/refunds` 创建新退款单。撤销/删除时进入异常状态和人工处置，同样不开放原单重新提交。
- 通过后撤销且资金已执行时不自动冲正，记录 `critical` 审计、站内告警并人工处理；资金尚未执行时终止后续任务。
- 原退款业务单级 `approve/reject/return` 路由下线，不存在本地审批动作 API。
- 原进程内佣金和套餐 Goroutine 改为 Outbox + 可靠 Worker。资金、佣金、套餐、资产状态各自保存持久化幂等事实，局部失败只重试未完成步骤；全部完成后才把处理状态置为成功。

一张退款单只对应一条企微审批申请。企微同意或拒绝后，该审批申请和退款单均形成不可变终态；本期下线既有 `POST /api/admin/refunds/{id}/resubmit`，不提供任何原退款单编辑或重提接口。拒绝后再次退款属于新的业务事实：前端重新进入退款创建流程，用户根据拒绝原因重新填写金额、凭证和原因，后端生成新的退款 ID、退款单号、业务快照、提交人快照和企微审批申请。新旧退款单只因指向同一订单或资产而具有关联，不继承审批节点、意见、附件、状态或企微发起身份；已拒绝退款不计入该订单或资产的活跃退款，但仍须阻止与其他活跃退款并存。企微意外返回撤销、删除或通过后撤销时只进入异常处置，不作为创建新退款的自动放行依据。

代理退款查询不再按创建账号隔离，改为既有店铺层级和退款业务权限范围；代理可看业务凭证、真实提交人、审批状态和处理结果，但看不到审批人、内部意见和审批人附件。平台和超级管理员也必须具备退款业务查看权限才可读取完整审批详情。列表、详情、附件下载和导出复用同一权限投影。

前端只读展示退款状态、企微审批状态和业务处理结果，不显示本地审批、金额修改、重提或人工退款确认按钮。详情固定分为退款业务信息、企微审批信息和业务处理结果；处理失败展示脱敏错误摘要及系统重试状态。

本需求实现完成必须同时通过两类门禁：可编程 WeCom Adapter 的可重复自动化，以及真实企微验收。真实企微至少完成“代理固定成员代提交并同意”和“平台本人绑定提交并拒绝”两张独立退款，覆盖真实附件、`applyevent`、加密回调、轮询兜底及本地资金/佣金/套餐/审计核对。回调按“企业微信 → 用户提供的中转应用 → 本地服务”原样转发，后端仍完整验签解密；真实参数只通过安全配置提供。撤销、删除和通过后撤销由 Adapter 自动化稳定覆盖，不强制每次真实企微人工制造。

### 6.6 需求 21：代理在线充值与员工线下充值审批

本需求分为完全隔离的两条路径：代理在线扫码充值不审批；平台员工线下代充值走企业微信审批。

#### 代理在线扫码充值

- 代理只能为当前店铺主钱包充值，最低 `10000` 分（100 元）。
- 支持微信 Native 和支付宝 `alipay.trade.precreate`，创建本地充值单和 `tb_payment(order_type=agent_recharge)` 后再预下单。
- 每次主动创建都生成新的充值单和支付单；`request_id` 只防同一次 HTTP 提交重试，不按金额或已有待支付单复用。后端原样返回第三方 `qr_content`，前端使用二维码组件渲染，不生成后端图片文件。
- 不返回 `expires_at`，也不展示本地推算的精确倒计时；支付是否成功、关闭或失效以第三方回调和受控查单结果为准。前端每 3 秒轮询的轻量接口只读取本地状态，不直接触发第三方查单。
- 微信/支付宝回调按支付单类型分发，校验渠道、支付配置、金额、第三方交易号和业务单关联。
- 支付确认和钱包入账分成两个可靠事务：第一阶段固化支付单已支付、充值单已支付、处理状态和入账 Outbox；第二阶段由 Worker 原子更新代理主钱包、版本、唯一流水、充值完成状态和资金 Audit Event。重复回调或任务不重复加钱。
- 支付成功但钱包事务失败时保持已支付并由可靠任务补齐；第三方明确关闭或失效时支付单沿用 `2=已失败`、充值单改为已关闭，不新增支付“已关闭”状态。已关闭后收到可核验的迟到成功回调仍按真实收款事实幂等入账。
- 钱包实际入账成功后向目标代理店铺主账号发送站内到账通知；在线真实提交人不是主账号时也接收，按充值单和接收人防重。

```text
GET  /api/admin/agent-recharges/payment-methods
POST /api/admin/agent-recharges
GET  /api/admin/agent-recharges/{id}/payment-status
```

代理在线充值详情固定 `approval_source=none`。前端每 3 秒轮询本地轻量支付状态，页面不可见时暂停；响应分别返回支付状态和统一 `processing_status`，不提供同义字段 `wallet_posting_status`。

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

创建线下充值后立即创建企微审批实例，不再暴露本地确认入账和驳回按钮，也不再校验操作密码。充值状态沿用现有 `1～6`：

| 状态 | 语义 |
|------|------|
| 1 | 待支付/线下待审批 |
| 2 | 已支付或审批通过后正在入账 |
| 3 | 已完成 |
| 4 | 已关闭 |
| 5 | 已退款 |
| 6 | 已驳回 |

充值处理状态：`0=未触发, 1=处理中, 2=成功, 3=失败`。

```mermaid
sequenceDiagram
    actor Staff as 平台员工
    participant Recharge as Recharge Application
    participant WeCom as 企业微信审批
    participant Sync as WeCom Approval Sync
    participant DB as PostgreSQL
    participant Worker as Recharge Worker

    Staff->>Recharge: 创建线下充值
    Recharge->>DB: 保存充值单、业务快照和企微实例
    Recharge-->>WeCom: 异步提交审批
    WeCom-->>Sync: 回调/轮询同步终态
    Sync->>DB: 首次通过时写入账Outbox
    DB-->>Worker: RechargeApproved
    Worker->>DB: 同事务更新钱包、版本、流水和充值单
```

关键规则：

- 企微通过后自动增加代理主钱包余额并写钱包流水，无操作密码。
- 线下金额只要求大于 0且不超过 100 万元；目标店铺、固定金额和 1～5 个结构化付款凭证必填，备注选填且最多 500 字。企微只能同意或拒绝，不能修改金额。
- Worker 使用 `recharge:{recharge_no}` 幂等键和处理租约。
- 钱包余额、版本、充值单和资金流水必须在同一事务更新。
- 若资金流水已存在但充值单状态未完成，重试只补齐状态，不再次入账。
- 驳回改为已驳回并终结原单；不增加已退回状态或原单重提接口，修正后必须重新创建。撤销/删除改为已关闭，可重新创建充值申请。
- 通过后撤销且已经入账时不自动扣回，记录严重异常并通知财务；尚未入账时终止任务。
- 钱包实际入账成功后向目标代理店铺主账号发送站内到账通知；企微审批结果另行通知真实业务提交人。

旧接口在停机发布后不再注册：

```text
POST /api/admin/agent-recharges/{id}/offline-pay
POST /api/admin/agent-recharges/{id}/reject
```

---

## 七、数据迁移、发布与回滚

### 7.1 迁移前检查

- 核对钱包现有 CHECK 约束名、负余额、冻结金额和异常数据。
- 检查同一设备多条 `is_current=true` 绑定并先修复。
- 检查未删除卡 `iccid_19` 重复，解决冲突后才能建立部分唯一索引。
- 检查 `realname_link_type!=none` 但资产 `realname_policy=none` 的冲突数据并明确修正结果。
- 记录现有轮询配置、队列深度、卡级开关和监控基线，发布后验证调度行为未被公共写入改造改变。
- 核对存量退款的支付方式和买家类型；仅将代理钱包订单接入自动回退，个人资产钱包订单不得误入该处理器。
- 统计待审批退款、平台员工线下充值和历史终态记录数量。
- 轮换用户 demo 中泄露的企微 Secret、Token 和 EncodingAESKey，配置可信域名、应用可见范围和回调地址。
- 发布并验证退款、线下充值企微模板控件映射，验证代理固定代提交成员可用，并要求会发起审批的平台/超级管理员完成扫码绑定。
- 验证微信 Native、支付宝 PreCreate 配置和回调地址，确认代理充值支付渠道可用。
- 盘点旧账号、资产、轮询日志的写入口和查询入口，确认统一审计切换清单。
- 停机窗口内由业务使用超级管理员配置普通角色导出字段并抽样验证；不运行默认授权迁移，永久禁止导出的字段不进入代码目录。

### 7.2 迁移清单

| 类型 | 表/对象 | 变更 |
|------|---------|------|
| 新建 | `tb_system_config` | 受控动态配置 |
| 新建 | `tb_notification` | 分类、级别、受控跳转和接收人幂等 |
| 新建 | `tb_wecom_approval_scene`、`tb_wecom_approval_template_version` | 稳定场景和不可变模板映射版本 |
| 新建 | `tb_wecom_approval_instance`、`tb_account_wecom_mapping` | 企微审批镜像、平台账号扫码绑定、真实业务提交人和企微发起身份快照 |
| 新建 | `tb_audit_event`、`tb_audit_event_resource` | 全局不可变业务审计和多资源关联 |
| 新建 | `tb_integration_log` | Gateway、运营商、企微和支付渠道交互 |
| 新建/确认 | `tb_outbox_event` | 可靠事件投递 |
| 新建 | `tb_device_batch_allocation_task` | 设备批量分配任务 |
| 新建 | `tb_role_export_field_permission` | 角色导出字段权限 |
| 新建 | `tb_expiry_push_record` | 15/7/3 天临期通知防重 |
| 新建 | `tb_bulk_purchase_task`、`tb_bulk_purchase_item` | 批量订购任务和逐行明细 |
| 修改 | `tb_device` | 实名状态快照 |
| 修改 | 现有实名/流量/网络轮询 Handler | 保持调度不变，只将查询结果交给公共卡状态写入用例 |
| 修改 | `tb_iot_card.iccid_19` | 未删除数据范围 Partial Unique Index |
| 修改 | `tb_shop_package_allocation`、`tb_package_usage` | 生效条件、周期和时长快照 |
| 修改 | `tb_exchange_order` | 提交人快照、资产标识快照和新旧资产查询索引 |
| 修改 | `tb_iot_card`、`tb_device` | 换货完成时新资产继承旧资产店铺和租户标签 |
| 修改 | `tb_shop` | 可空平台业务员字段 `business_owner_account_id` |
| 修改 | `tb_role` | 新建店铺默认信用开关和额度模板 |
| 修改 | `tb_agent_wallet` | 实际信用开关、额度和 CHECK 约束 |
| 修改 | `tb_refund_request` | 提交人、唯一企微审批申请引用、撤销状态和终态处理结果 |
| 修改 | `tb_agent_recharge_record` | 提交人、企微实例、支付状态和入账处理结果 |
| 修改 | `tb_payment` | 支持 `order_type=agent_recharge` 和创建时支付配置快照 |

迁移必须拆成可回滚的增量文件；同一表和同一索引只由一个迁移创建。旧账号、资产和轮询日志表保留只读，不回填新表、不再产生新写入。

### 7.3 发布顺序

```mermaid
flowchart LR
    M[进入维护模式并停止写入] --> DB[执行增量迁移]
    DB --> Deploy[同时发布 API、Relay/Worker、前端]
    Deploy --> WeCom[发布企微模板映射、验证代理固定账号并完成平台账号绑定]
    WeCom --> Backfill[提交存量待审批单到企微]
    Backfill --> Verify[人工验证核心链路]
    Verify --> Open[解除维护模式]
```

详细步骤：

1. 停止订单、退款、充值、资产状态和配置相关写入，暂停旧 Worker。
2. 创建企微、审计、通知、批量任务、权限和同步调度所需表/索引，并增加业务关联字段。
3. 同时发布 API、Relay/Worker 和前端，统一切换 Audit Writer、Integration Log 和 Access Log 脱敏。
4. 发布两个企微模板映射版本，验证连接、代理固定代提交账号、平台账号扫码绑定、回调和状态查询。
5. 使用一次性 Application 命令为存量待审批记录创建企微实例：代理创建的退款使用固定代提交账号；平台/超级管理员创建的记录仅在创建人已绑定企微时提交，未绑定或真实创建人缺失的记录进入迁移待处理列表。
6. 历史终态记录保留 `approval_source=legacy`，不得伪造企微实例或审批时间线。
7. 验证数据同步三通道、19/20 位 ICCID 回调、代理微信/支付宝扫码充值和统一审计查询。
8. 确认旧退款审批、充值 `offline-pay/reject`、旧审计写入口和重复卡状态写逻辑不可访问。
9. 完成人工验证后恢复 Worker 并解除维护模式。

### 7.4 回滚边界

- 开放访问前验证失败：可以回滚应用版本和可逆迁移。
- 已提交到企业微信的审批无法通过回滚本地版本撤销；必须继续保留实例并同步终态。
- 已形成的企微实例、审计、Integration Log、资金流水、Outbox 和通知不得清表回滚。
- Worker 暂停时保留 Outbox，恢复后继续消费。
- 信用额度启用并产生负余额后，不能删除字段或直接关闭信用；必须先处理欠款或继续保留新资金逻辑。
- 已部分成功的批量任务不能整体回滚；通过明细和资金流水逐条追踪。
- 支付渠道已经成功但钱包尚未入账的充值单必须由恢复任务完成，不能要求代理重新支付。

---

## 八、风险与已确认约束

本轮评审结论已经收口，以下约束直接进入实施，不再要求实施人员自行猜测：

| 编号 | 已确认结论 |
|------|------------|
| C-01 | 限速使用固定语义化 `speed_level`；前端不得输入任意速率或渠道 `code`；恢复不限速与限到 0kbps 严格区分，上游编码由适配器按 Gateway 账户和运营商映射。 |
| C-02 | 实名策略批量单次最多 500 条，事务内全成全败。 |
| C-03 | 设备批量分配文件最大 10MB、最多 1000 行、每批 200 条、失败明细最多 1000 条，且一任务只做一种分配操作。 |
| C-04 | 批量订购线下凭证仅保存业务资料快照，不校验跨批次唯一性或财务核销。 |
| C-05 | 套餐导出固定采用实际列举的 25 个稳定字段 Key。 |
| C-06 | 钱包流水套餐名称优先使用订单项不可变快照，历史缺失时才读取 `metadata`；禁止关联可修改的当前套餐名称。 |
| C-07 | 临期按 `Asia/Shanghai` 自然日计算，`0～15` 天为临期；漏跑后仅补发当前最近且未发送的阈值通知。 |
| C-08 | 本系统不校验线下充值审批操作密码；审批人与流程规则由企微模板决定。 |
| C-09 | 退款、充值业务资料以本地对象存储 Key 为权威，企微 `media_id` 仅作为提交副本且不进入日志审计。 |
| C-10 | 企微 `applyevent` 请求已发送但结果未知时禁止盲目重试，必须人工核对后恢复。 |
| C-11 | 第三方退款本期人工处理；不增加渠道退款号、渠道结果或自动退款适配器。代理钱包退款仅回退原扣款代理主钱包。 |
| C-12 | H5 不增加全局实名策略默认值，新建卡/设备继续默认 `after_order`。 |
| C-13 | 行业卡实名由 `realname_link_type` 决定；19/20 位 ICCID 精确查对应列，不截断、不补位、不跨列降级。 |
| C-14 | 事件同步固定立即、3 分钟、5 分钟三次；Gateway 超频不退避，不删除后续任务。 |
| C-15 | 平台/超级管理员企微账号只允许扫码绑定且一个 `userid` 不得绑定多个系统账号；代理审批使用部署配置中的固定企微账号代提交，真实业务提交人始终独立保存和展示。 |
| C-16 | 代理在线充值最低 100 元，支付成功直接入主钱包，不因金额大进入审批。 |
| C-17 | 全局审计本次停止旧表新写入；历史只读投影，不双写、不在线回填。 |
| C-18 | 角色默认信用配置只作用于以后新建的店铺；修改角色不更新已有店铺。 |
| C-19 | 钱包预警阈值固定 100 元，按 `balance - frozen_balance` 判断，不包含信用额度。 |
| C-20 | 临期只发站内通知；3 天内使用红色，且只在临期独立列表置顶。 |
| C-21 | 换货新资产继承旧资产店铺；旧资产保留原归属，已属于其他店铺的新资产不得换入。 |

Gateway 上游已确认使用 `POST /flow-card/speedLimit`，请求核心参数为 `cardNo + code` 并由统一客户端包装在 `params` 中。业务层始终只传语义化 `speed_level`；`code=-1` 表示恢复不限速，`code=0` 表示限到 0kbps，二者禁止混用。不同 Gateway 账户相同速率编码可能不同，实施前必须完成账户/运营商映射和真实环境联调。

### 8.1 主要运行风险

| 风险 | 触发条件 | 应对 |
|------|----------|------|
| 企微模板失效 | 模板或控件 ID 编辑后变化 | 场景暂停、模板版本、定期验证和原子切换 |
| 企微提交重复 | `applyevent` 超时后盲目重试 | 提交结果未知状态和人工绑定 `sp_no` |
| 审批回调漏失 | 网络或企微回调异常 | 审批中实例每 2 分钟查询详情兜底 |
| 平台账号绑定冲突 | 同一企微成员绑定多个平台账号 | `userid` 唯一约束、扫码会话和强制解绑审计 |
| 代理固定代提交账号失效 | 成员离职、停用或移出应用可见范围 | 周期验证、提交前就绪校验、暂停代理新申请和系统告警 |
| 重复代理钱包回退或入账 | Outbox/Asynq 至少一次投递 | 稳定业务幂等键、资金流水唯一业务号、处理租约 |
| 通过后撤销 | 企微通过且资金已执行后撤销 | 不自动冲正，critical 审计、通知和人工处理 |
| 支付重复回调 | 微信/支付宝多次通知 | 支付单状态条件、钱包流水唯一键和业务幂等键 |
| 同步请求放大 | 查询、轮询和业务事件同时请求同一卡 | 事件序列按同场景合并、单次请求互斥、运营商最小间隔；现有周期轮询调度保持不变 |
| ICCID 错配 | 20 位截断或 19 位补位命中错误卡 | 双列精确路由和 `iccid_19` 部分唯一索引 |
| 审计数据量过大 | 高频轮询和全局写操作持续增长 | 时间索引、分批清理、Integration Log 短保留和后续月分区 |
| 批量重复下单 | 重复提交或并发 Worker | 创建请求幂等、任务/明细条件领取、行级唯一键 |
| 限速对象错误 | 设备无当前卡或错误把 IMEI 当卡号 | 统一解析当前卡 ICCID；无卡拒绝调用并记录审计 |
| 导出敏感字段泄露 | 权限缺失或 Worker 回退全字段 | 服务端字段交集、任务快照、失败关闭 |
| 临期重复通知 | 定时任务重试 | 稳定事件 ID 和接收人维度唯一约束 |
| 余额预警刷屏 | 钱包持续低于 100 元并发生多次扣款 | 仅跨越阈值时发送，回升后才重新布防 |
| 换货归属越权 | 新资产已属于其他店铺或关联资产无查看权限 | 事务内归属校验；关联跳转继续执行后端数据权限 |
| 角色额度误改存量 | 管理员修改角色默认额度 | API 只更新角色模板，不批量更新钱包；页面明确影响范围 |

---

## 九、可观测性与人工验收

### 9.1 日志和查询维度

统一记录：`request_id`、`correlation_id`、`event_id`、`integration_id`、`task_id`、`wecom_approval_instance_id`、`sp_no`、`biz_type`、`biz_id`、`operator_id`。

关键监控：

- Outbox 待投递数、最早积压时间和失败次数。
- 企微 Token 刷新、模板失效、提交结果未知、回调失败、待审批轮询积压和业务处理失败。
- 平台账号扫码绑定成功/失败/冲突、未绑定平台申请人数、代理固定代提交账号验证结果和提交失败数量。
- 现有轮询队列/配置基线、各同步类型 QPS、Gateway 超频、连续失败和事件序列完成率。
- Audit Event/Integration Log 写入失败、增长速度、清理积压和敏感读取次数。
- 微信/支付宝预下单、回调失败、已支付未入账和重复回调数量。
- 批量任务成功/失败/部分成功数量。
- 钱包版本冲突、可用金额不足和数据库约束失败。
- Gateway cardNo、目标限速、请求结果和错误摘要。
- 导出字段快照、数据范围和文件生成失败。

### 9.2 人工验收矩阵

| 范围 | 必测场景 |
|------|----------|
| 企微审批 | 模板发布/失效、平台账号扫码绑定、代理固定账号有效/失效、两类发起身份与真实业务提交人展示、提交、回调、2分钟轮询、驳回/撤销/删除、提交未知恢复、意见附件和业务快照 |
| 数据同步 | 现有轮询调度回归、0/3/5 阶梯、同场景合并、单次请求互斥、事件超频不退避、19/20位回调、解除实名忽略、旧写入口收口 |
| 全局审计 | 关键事务失败回滚、人员/资源/请求/资金/风险/集成视角、历史投影、脱敏、旧表停止新写入 |
| 钱包 | 普通余额扣款、信用扣款、额度降低失败、并发版本冲突、负余额回充 |
| 角色默认额度 | 新建店铺继承默认值、修改角色不影响已有店铺、店铺独立调额 |
| 余额预警 | 现金余额跨越 100 元阈值、持续低余额不重复、回升后再次跌破、代理和业务员接收通知 |
| 批量订购 | 钱包/线下两种支付、部分成功、重复提交、Worker 重试、失败明细、模板错误 |
| 退款 | 金额发起时固定、财务人工退款企微确认、代理钱包回退原主钱包、个人资产钱包不误入、通过后撤销不冲正 |
| 充值 | 微信/支付宝最低100元扫码、重复回调不重复入账、已支付恢复、在线不审批、线下企微通过自动入账且无操作密码 |
| 导出 | 普通角色字段并集、超级管理员全代码目录、权限为空、行权限、附件受保护链接、审批摘要和附件批量查询 |
| 限速 | 单卡、设备当前卡、无/多当前卡、固定档位、恢复不限速、限到0kbps、运营商能力、Gateway 失败/结果未知和审计记录 |
| 临期 | 当前与排队套餐最终到期推算、等待实名不可预计、15/7/3 天、3天红色和临期页置顶、漏跑补发、列表/详情/C端一致 |
| 换货补充 | 新资产继承店铺、其他店铺资产拒绝、旧资产保留归属、前代/后代标识和受控跳转 |
| 系列授权 | 首次和后续批量选择、已授权置灰、重复提交幂等、三类价格含义正确 |
| 发布 | 存量退款和充值回填、历史 `legacy`、旧路由不可访问、Worker 暂停后恢复 |

验收同时使用自动化测试、真实接口调用、PostgreSQL 数据核对、日志检查和页面操作；资金、状态机、权限、异步幂等和第三方 Adapter 必须有可重复的自动化公共行为测试，真实第三方联调与人工验收不能被测试替代。

---

## 十、实施顺序

```text
1. 增量迁移、事务管理、Outbox、统一审计模型和 Access Log 脱敏
2. 站内通知与审计多视角 Query/前端
3. 卡状态公共写入、现有轮询接入、事件阶梯和运营商回调防腐层
4. 企微连接、模板版本、平台账号扫码绑定、代理固定代提交、提交/回调/轮询
5. 退款和平台员工线下充值接入企微，代理微信/支付宝扫码充值
6. 钱包信用额度、业务员和余额预警、批量订购、导出权限、设备批量分配、系列套餐批量授权、限速和临期提醒
7. 换货归属与换货链、其他查询和显示修复、存量回填及停机发布演练
```

每个复杂用例按“迁移 → Application/Domain → Infrastructure → Handler → 前端 → 人工验收”形成完整闭环，不在同一任务中顺带重构未触碰模块。

---

## 十一、工时估算与排期

### 11.1 估算口径

- 1 人日按 8 小时计算。
- 当前固定投入为 1 名后端和 1 名前端，两人并行开发，正常目标为 12～15 个工作日，风险上限为 16 个工作日。
- 包含本期企业微信审批、数据同步重构、全局审计一次性切换、代理微信/支付宝扫码充值及原 7 月迭代范围。
- 不包含套餐临期企业微信消息推送、自动第三方退款、运营商解除实名回滚和需求16。
- 包含后端、前端、第三方联调、人工接口验证、PostgreSQL 数据核对、存量回填和停机发布；不单独预留长期缓冲。
- 假设对象存储、ExportTask、Asynq、钱包、退款、充值、微信支付和支付宝 SDK 基础代码可以复用，但企微和支付渠道仍需要真实环境联调。
- 采用快速交付口径：优先复用现有 Store、支付、Asynq、导出和页面组件，DDD 只迁移本次触碰的复杂写用例，不追加非必要重构。
- 全局审计仍一次性切换写入口，但第一版页面和 Query 只实现评审稿列出的核心视角，不增加额外分析报表。
- AI 用于代码生成、批量迁移和文档核对；验收优先覆盖资金、企微、回调、同步和权限主链路。

### 11.2 人日估算

| 工作包 | 后端人日 | 前端人日 | 快速交付说明 |
|--------|----------|----------|--------------|
| 迁移、Outbox、全局审计和站内通知 | 2 | 1.5～2 | 复用现有日志、列表和抽屉组件，先完成核心多视角 |
| 数据同步领域收口、事件触发和运营商回调 | 2～3 | 0.5～1 | 现有轮询调度不改，前端只补审计轨迹跳转 |
| 企微模板、平台绑定、代理固定代提交、提交/回调/轮询 | 2～3 | 1.5～2 | 复用企微官方页面和现有业务详情布局 |
| 退款、线下充值终态和代理扫码充值 | 2～3 | 1.5～2 | 复用现有钱包、支付回调和充值页面 |
| 信用、业务员预警、换货、系列授权、批量、导出、限速和临期 | 2.5～3.5 | 2.5～3 | 系列授权和换货链已有后端基础，只补增量能力 |
| 联调、数据核对、回填和停机发布 | 1～1.5 | 0.5～1 | 随开发持续联调，最后集中验证核心链路 |
| **合计投入** | **约 12～16** | **约 8～12** | **总投入约 19～28 人日，两人并行正常 12～15 个工作日，风险上限 16 天** |

排期基线按 **14 个工作日**，正常范围为 **12～15 个工作日**。如换货归属需要补齐超出预期的租户标签表、真实企微/支付配置无法按时提供、生产 ICCID 或审计旧入口存在大量异常，风险上限为第 16 个工作日，不在开发阶段扩展额外重构范围。

### 11.3 当前团队排期

当前团队固定为 **1 后端 + 1 前端**，两人从第一天开始并行，正常预计 **12～15 个工作日**，风险上限 **16 个工作日**。推荐节奏：

```text
第 1～2 天：迁移、Outbox、审计/通知骨架，前端同步搭建页面框架
第 3～5 天：数据同步写入收口、事件阶梯、回调防腐层和审计外部集成视角
第 4～7 天：企微模板、平台账号扫码绑定、代理固定代提交、审批提交/回调/轮询及前端配置页面
第 6～9 天：退款、线下充值终态、代理微信/支付宝扫码充值
第 8～12 天：信用、业务员预警、换货归属与标识、系列批量授权、批量、导出、限速和临期
第 12～14 天：全链路联调、数据核对、旧入口清理和存量回填演练
第 15 天：问题修复和停机发布；换货租户标签或历史数据超预期时使用第 16 天风险缓冲
```

---

## 十二、评审结论记录

| 评审项 | 结论 | 调整项 | 负责人 |
|--------|------|--------|--------|
| 本期范围与移出项 | 待评审 |  |  |
| 渐进式 DDD 边界 | 待评审 |  |  |
| 数据同步三通道与运营商回调 | 待评审 |  |  |
| 企业微信审批、模板版本、平台账号绑定与代理固定代提交 | 待评审 |  |  |
| 全局多视角审计与历史投影 | 待评审 |  |  |
| 站内通知与受控跳转 | 待评审 |  |  |
| 资金与信用额度 | 待评审 |  |  |
| 代理微信/支付宝扫码充值 | 待评审 |  |  |
| 批量任务和导出权限 | 待评审 |  |  |
| Gateway 限速和临期提醒 | 待评审 |  |  |
| 前端交互和权限 | 待评审 |  |  |
| 停机发布和回滚 | 待评审 |  |  |

评审通过条件：第八章约束全部纳入实施任务；除真实第三方联调结果和不同 Gateway 账户/运营商的档位映射配置外，不存在需要实施人员自行猜测的数据模型、接口、状态语义或资金规则。
