feat(代理分销提现): 落地扫码注册、提现资料资格与企微终审提现
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

AUG26-008。

- 迁移 000214–000217:tb_shop 全局唯一且不可修改的随机分销码(含存量回填)、
  tb_agent_distribution_registration 待审批注册记录、tb_withdrawal_qualification 资料版本、
  tb_commission_withdrawal_request_attempt 审批尝试记录,以及提现申请的 latest_*/异常标记列;
  不修改既有迁移,down 在存在本 Change 业务事实或新类型场景行时拒绝破坏性回滚。
- 公开接口 POST /api/c/v1/agent-distribution-registrations:无认证,复用既有短信验证码校验、
  消费与限流;无效分销码、停用上级、验证码无效或已消费统一返回「分销码不可用」且不落库,
  审批通过前不创建店铺、账号或钱包。
- 审批通过才在同一事务内建启用店铺、代理主账号、钱包、上级层级与业务员快照,驳回不建实体,
  重复回调不重复建实体,提交后清理上级下级缓存。
- 提现资料资格按不可变版本保存,替换合同或法人身份证即新增版本并同事务失效旧有效版本;
  超管作废原因必填;代理停用与店铺删除联动失效。
- 提现每次提交或重提新增不可变审批尝试记录并冻结金额;企业微信通过仅一次从冻结扣减、
  保持状态 2 并写 paid_at(不使用状态 4),驳回/cancelled/deleted 仅一次释放,
  通过后撤销不回滚、不重新冻结、只写正交异常标记;加锁顺序统一为申请→尝试→钱包。
- 本地人工终审对已关联审批实例的申请返回状态冲突,approval_instance_id 为空的存量申请保持既有行为,
  不新增任何配置开关。
- 补齐审批业务类型注册点全集:业务类型与场景字段常量、场景 DTO 两处枚举与中文描述、
  场景字段白名单/合法类型/中文名、数据库 CHECK、Worker 决策消费者与装配、审批审计资源映射,
  以及三个新审计资源与 13 个审计动作;失败/拒绝审计改为必达。
- 新增后台路由与 OpenAPI:资格提交/查询/作废、提现申请/重提/详情、店铺详情返回只读分销码。
- 归档本 Change:主 Spec 新增 agent-distribution-withdrawal 能力(5 个 Requirement)。

验证(junhong_cmp_test + Redis DB 6,显式 DB_*,未重置整库):
- 迁移 up → version 217 且 dirty=false → down 3 → up 回 217,fixture 复核残留为 0。
- 受控状态机脚手架 227 项通过 / 0 项失败,覆盖 18 组场景(幂等与乱序回调、资金冻结/释放/重提、
  退款回扣 × 在途提现并发、负向场景拒绝审计与 14 个动作码审计真实落库)。
- gofmt 空、go build/go vet 通过、gendocs 与工作区逐字节一致、context-health 通过、
  openspec validate --strict 通过、doctor healthy;自动化测试按项目决策为 N/A。

运行期前置(未完成,非代码交付物):由超管经 PUT /api/admin/wecom/scenes/{business_type} 为
agent_distribution_approval、withdrawal_qualification_approval、commission_withdrawal_approval
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
This commit is contained in:
2026-09-14 09:45:13 +08:00
parent 315a7de3e4
commit 575d056f54
68 changed files with 5759 additions and 309 deletions

View File

@@ -1,32 +0,0 @@
## Context
现有佣金提现有申请和钱包事实;本 Change 以资格及审批实例补齐其前置条件,不以外部线下打款作为完成条件。
## Decisions
- 分销码由店铺唯一约束保护;扫码注册与审批结果使用稳定审批实例幂等消费。
- 资格申请与附件版本分表/快照,替换、作废和代理停用以状态失效,不覆盖历史审批。
- 提现创建在事务内锁定佣金钱包并写冻结和审批提交;通过仅一次扣减/确认,驳回仅一次释放。
## 业务动作契约
### 分销码与扫码注册
- 代理店铺创建事务生成全局唯一、不可修改的随机 `distribution_code`;二维码只包含 H5 注册入口及该码。唯一冲突重试生成,不允许人工指定或编辑。
- `POST /api/c/v1/agent-distribution-registrations`:提交 `distribution_code`、短信已验证手机号、密码及既有注册必填资料。服务锁定上级店铺,校验其代理启用;失效码/停用代理统一返回“分销码不可用”。成功仅创建待审批代理/店铺和企业微信审批实例,不建立下级归属。
- 企业微信通过消费者幂等启用新代理/店铺,并在同一事务写直接上级店铺和上级当前业务员快照;驳回不启用。重复回调不重复创建层级或账号。
### 提现资格资料
- `POST /shops/:shop_id/withdrawal-qualifications`:仅本人代理店铺。请求合同、法人身份证正反面附件;企业必须提供统一社会信用代码,个人必须提供法人身份证号;可选营业执照、门头照;企业可选发票,发票抬头和统一社会信用代码必须与合同主体一致。创建新资料版本并提交企业微信,替换合同或身份证立即使旧有效资格失效。
- 超级管理员作废资格必须填写原因;代理停用自动失效全部有效资格。合同与身份证审批通过后长期有效,直至替换、作废或停用;历史版本、附件、审批实例永不覆盖。
### 提现申请与审批
- 既有 `POST /shops/:shop_id/withdrawal-requests` 增加资格有效校验。锁定该店铺佣金钱包后冻结可提现余额、申请金额、手续费/实际到账金额、收款信息和可选发票快照;余额不足、资格无效或非本人代理均不创建申请或冻结。
- 企业微信通过时以申请/审批实例条件更新一次确认到账;驳回时仅一次释放冻结。代理可在资格仍有效时修改被驳回申请的金额、收款信息和申请级发票并创建新审批实例;资料资格不因提现驳回失效。
- 新业务禁用本地 `approve/reject` 终审;提交/回调未知复用既有审批恢复,不允许通过重提制造第二笔冻结。每次资格、冻结、审批终态和重提均记录审计。
## Migration Plan
新增成对迁移及唯一/状态索引;隔离库验证码唯一和停用、资格失效、提现冻结/重提、重复审批回调及 up/down/up。

View File

@@ -1,25 +0,0 @@
## Scope
- 迭代编号:`AUG26-008`
## Why
代理下级归属和提现资料/资金缺少企业微信终审及失效边界,无法可靠追溯。
## What Changes
- 代理店铺唯一分销码和审批后下级注册。
- 合同、法人身份证为核心的提现资格审批与失效。
- 提现余额/资料快照、企微终审和驳回释放。
## Capabilities
### New Capabilities
- `agent-distribution-withdrawal`: 分销注册、资料资格与提现审批。
### Modified Capabilities
- 无。
## Impact
影响代理店铺、H5 注册、附件、佣金钱包、企业微信审批和 Schema。

View File

@@ -1,26 +0,0 @@
## Purpose
使代理下级注册和佣金提现均以企业微信审批、资格有效性和不可变业务快照为准,避免代理层级或资金事实因资料变更、重复回调而漂移。
## ADDED Requirements
### Requirement: 分销码与下级代理注册
系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码。代理扫码后以手机号短信验证、设置密码并创建待企业微信审批的下级代理和店铺;仅审批通过时启用,并将新店铺设为分销码所属店铺的直接下级,复制上级当时业务员为初始业务员。代理停用后其码立即不可注册,既有下级和佣金关系不级联变更。
#### Scenario: 停用代理码注册
- **WHEN** 客户使用已停用代理所属店铺的分销码注册
- **THEN** 系统拒绝创建下级代理或店铺
### Requirement: 提现资料资格
代理首次提现前 SHALL 提交企业微信资料资格申请;合同和法人身份证正反面必填,企业代理填写统一社会信用代码、个人代理填写法人身份证号。合同资格主体必须填写统一社会信用代码或身份证号;营业执照、门头照可选,发票仅企业可选且其抬头/统一社会信用代码必须与合同主体一致。审批通过且资料未过期才有效;合同和身份证通过后长期有效,直到代理替换资料、超级管理员作废或代理停用。
#### Scenario: 资料替换后提现
- **WHEN** 有效资格的代理替换合同或法人身份证资料
- **THEN** 原资格失效,代理必须重新审批通过后才可提现
### Requirement: 提现冻结与企业微信终审
提现申请 SHALL 冻结可提现余额、金额、手续费、收款信息和可选发票快照,并创建企业微信审批。本地不得人工通过或驳回;企业微信通过即视为已到账,驳回时释放本申请冻结余额。驳回后代理可修改金额、收款信息和本次发票重新提交,每次新建审批实例;资料资格保持有效。发票为申请级材料,若上传必须按当时有效合同主体校验并冻结。
#### Scenario: 提现审批驳回
- **WHEN** 企业微信最终驳回一笔提现申请
- **THEN** 系统仅一次释放其冻结佣金余额,保留审批快照,并允许在资格仍有效时修改后重提

View File

@@ -1,13 +0,0 @@
## 1. 分销与资格
- [ ] 1.1 追踪代理/店铺创建、H5 短信注册、佣金提现、附件、企微审批和钱包冻结链路。
- [ ] 1.2 新增分销码、资格申请/资料版本、提现审批快照的成对迁移、模型、状态与唯一约束。
- [ ] 1.3 实现分销注册、停用门禁、审批后下级归属/业务员快照及审计。
- [ ] 1.4 实现资格提交、主体/附件/发票校验、失效和企微回调。
## 2. 提现
- [ ] 2.1 实现有效资格校验、钱包锁定冻结、申请快照、企微终审、驳回释放和新实例重提。
- [ ] 2.2 注册后台/H5 路由及 OpenAPI保障附件和数据范围。
## 3. 验证
- [ ] 3.1 隔离库验证迁移、分销码停用、资格替换、冻结/释放和重复回调。
- [ ] 3.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-agent-distribution-withdrawal-qualification --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。

View File

@@ -0,0 +1,159 @@
## Context
`proposal.md` 了解动机。以下为开工核查确认的现状约束,全部来自实际代码与迁移:
- 店铺无分销码;`tb_shop` 只有 `ShopStatusDisabled=0` / `ShopStatusEnabled=1`,不存在待审批状态;业务员只是 `tb_shop.business_owner_account_id` 单列,无快照。
- 不存在任何 H5 或后台的店铺注册链路与注册实体;`/api/c/v1` 现有能力为短信验证码、登录与手机号绑定/换绑。
- 短信验证码能力可复用Redis `verification:code:<phone>`、校验即消费、手机号/IP/日三档限流。
- 佣金提现已有完整的本地冻结链路(申请、`frozen_balance`、钱包流水、审计同事务)与本地人工终审路由,但完全未接入通用审批。
- 合同、法人身份证、营业执照、门头照、发票与统一社会信用代码等资格事实在全库不存在。
- 通用审批实例的共享唯一约束为 `uq_approval_instance_business(business_type, business_id)`,且创建路径无冲突重试或复用分支;因此同一业务类型下 `business_id` 不可复用。
- `tb_commission_withdrawal_request` 无审批关联列;资金汇总只按 `status=2` 统计已通过提现、按 `status=1` 统计提现中。
- 企业微信单张审批单最多 6 个附件。
- 店铺启停只在后台登录与代理开放接口鉴权两处门禁;停用无任何级联失效先例。
## Goals / Non-Goals
**Goals:**
- 分销注册、资格审批、提现审批三者的本地事实与企业微信终态一一对应,可幂等重放、可回放历史。
- 提现的冻结、释放、扣减在单一事务内闭合,并为后续佣金回溯提供稳定释放接缝。
- 分销码与注册审批的落库形态不污染既有店铺层级、账号唯一性与数据范围。
**Non-Goals:**
- 不实现 H5 页面、二维码渲染与前端交互,只交付公开注册后端接口。
- 不实现 AUG26-012 佣金回溯与佣金明细导出。
- 不重构既有店铺创建、账号、钱包与提现列表的对外契约。
- 不新增本地人工终审配置开关,不改变其他审批场景的本地历史语义。
- 不把财务审计时间线(`internal/query/audit/finance.go`)纳入新业务类型。
- 不在本 Change 内接入或验证真实企业微信、短信、对象存储与支付。
## Decisions
### 1. 分销码由唯一索引保护,建店事务内生成
代理店铺创建时生成全局唯一、不可修改的随机 `distribution_code`,以条件唯一索引兜底;冲突时重试生成,不提供人工指定或编辑入口。二维码只编码 H5 注册入口与该码,渲染与页面不在本 Change。
既有店铺创建点共两处,都必须生成分销码:`internal/application/shop/create.go` 的建店事务,以及批量导入脚本 `scripts/migration/lib/shop_sql.py` 生成的单事务 SQL该脚本不经 Go 事务,只能靠生成期随机码 + 唯一索引兜底,整批失败即回滚)。漏掉后者会使导入店铺永久无码。
既有 `shop_code` 语义与唯一索引不变;分销码是新增的正交事实。
### 2. 注册落库形态:独立待审批注册记录,不预先建店铺与账号
采用独立注册记录,而不是“先建 `status=2` 的店铺、审批通过再改状态”。理由:店铺状态枚举只有 0/1用 0 表达待审批会与停用语义冲突并触发既有登录门禁;`parent_id` 为空的待审批店铺会以“一级代理”污染层级列表与下级缓存;且 `tb_account` 的手机号唯一索引会让被驳回的首次注册长期占用手机号,导致同手机号无法再次注册。
注册记录保存分销码、上级店铺、手机号、密码哈希bcrypt审批通过时直接写入新建账号注册记录不保存明文、状态、审批实例与时间戳。审批通过前不创建店铺、账号、钱包或层级。同一手机号驳回后再次扫码即新记录、新审批实例。
公开接口 `POST /api/c/v1/agent-distribution-registrations` 无认证要求,路由必须注册在个人客户路由的 `Use()` 之前Fiber 顺序匹配)。接口复用既有验证码校验与消费、限流规则;锁定上级店铺并校验其启用状态,无效码与停用上级统一返回“分销码不可用”。资格申请、资格作废、提现申请与后台查询仍走既有认证与数据范围校验,不公开。
### 3. 注册审批通过事务:实体与层级一次建成
业务类型 `agent_distribution_approval``business_id` = 注册记录主键。终态消费以条件更新(注册记录状态 = 待审批)保证至多一次,通过时在同一事务内:
1. 校验分销码所属店铺仍存在且启用;
2. 创建启用店铺,写入上级店铺与层级(`level = parent.level + 1`,上限 7
3. 创建代理主账号(写入注册时的密码哈希),创建所需钱包;
4. 写入上级当时业务员为初始业务员快照;
5. 标记注册记录已通过,记录审计。
事务提交后必须清理上级店铺的下级缓存,否则新下级在下级集合缓存的有效期内不可见。驳回只标记注册记录与审批结果,不创建任何实体。手机号或用户名在通过时已被并发注册占用,属于事务失败,必须整体回滚,不得留下半套实体。
### 4. 资格:不可变资料版本 + 独立审批业务类型
业务类型 `withdrawal_qualification_approval``business_id` = 资料版本主键。资格事实按版本不可变保存:主体类型(企业/个人)、签约主体代码、法人身份证号、合同与法人身份证正反面附件、可选营业执照与门头照、可选发票抬头与统一社会信用代码、审批实例、状态与失效原因。
替换合同或法人身份证 = 新增版本 + 新审批实例,并在同一事务内使旧有效版本失效。作废由超级管理员执行且必须填写原因;代理停用联动使全部有效版本失效。资格与注册资料都不引入同行重提状态机——重审即新版本,驳回后重新提交即新记录。
附件映射受企业微信单张审批单 6 个附件上限约束:必填 3 项(合同、法人身份证正面、反面)+ 可选 3 项(营业执照、门头照、发票),因此每个附件字段只允许单个对象键,不得支持多文件追加。
### 5. 提现审批尝试记录与列表投影
业务类型 `commission_withdrawal_approval``business_id` = 提现审批尝试记录主键。尝试记录保存 `request_id``attempt_no`、金额/手续费/费率/实际到账快照、收款信息、申请级发票快照、提交人、审批实例与释放时间,创建后不可修改。
提现申请行新增 `latest_attempt_id``latest_approval_instance_id`,仅作为列表投影与门禁判定使用,不是历史事实来源;请求行上的金额与状态是最近一次尝试的镜像,冻结与释放金额一律取尝试记录。
每次提交或重提:事务内锁定佣金钱包、为新的尝试冻结金额、写尝试记录、创建审批实例、回填 `latest_*`、写钱包流水与审计。驳回后重提先释放旧未结算尝试的冻结,再按新金额冻结,全程单事务;不允许通过重提制造第二笔冻结。
### 6. 终态映射
| 企业微信决策 | 提现请求状态 | 资金动作 | 时间与标记 |
| --- | --- | --- | --- |
| approved | 保持 `WithdrawalStatusApproved`=2 | 仅一次从冻结余额扣减 | 写 `paid_at``processed_at` |
| rejected | `WithdrawalStatusRejected`=3 | 仅一次释放本次尝试冻结 | 写 `processed_at` 与尝试记录释放时间 |
| cancelled | 同 rejected | 同 rejected | 同 rejected |
| deleted | 同 rejected | 同 rejected | 同 rejected |
| revoked_after_approved | 保持 2 | 不回滚、不重新冻结 | 写正交异常标记与原因,禁止自动重提 |
通过不得使用 `WithdrawalStatusPaid`=4既有资金汇总只统计 `status=2`,写 4 会使已通过提现从汇总口径中消失。资金动作全部以“尝试记录/请求行状态条件更新 + 影响行数为 1”为幂等守卫重复、乱序与未知结果由既有交付租约与查询恢复收敛。
### 7. 本地人工终审不新增开关
不新增 `approval.legacy_withdrawal_manual_enabled` 或任何等价开关。本地通过/驳回入口以既有的普通读取(`GetByID`,带数据范围过滤,不加 `FOR UPDATE`)取出申请行,只要该行已关联审批实例(`approval_instance_id` 非空)即直接返回稳定状态冲突并落失败审计,不依赖任何开关。随后的状态写入沿用既有 `UpdateStatusWithTx`,其 `WHERE``status = 待审核` 条件;实现只返回该写入的错误、未校验影响行数,因此**条件更新在此不作为已校验的守卫**,并发下的最终一致性依赖既有的状态判定与唯一约束。存量从未关联审批实例的提现申请保持既有兼容处理;其他审批场景的本地历史语义不变。
### 8. 为后续佣金回溯提供释放接缝
佣金回溯处理待审提现时,必须先幂等释放每个未结算尝试的冻结余额,再扣减佣金余额。原因:佣金钱包约束要求冻结余额不超过余额(负值按 0 计),先扣减会使冻结违约。`attempt.amount` 是稳定冻结事实,`attempt.released_at` 是稳定释放事实,二者构成回溯可重放的幂等依据。本 Change 只提供事实与顺序约定,不实现 AUG26-012。
## 实施决策(与设计文档的差异)
本节记录实施过程中与本文档正文不同的实际决策,仅描述事实,不改变需求语义。
1. **公开注册提交不写审计**:公开路由注册在个人客户路由 `Use()` 之前,不经认证中间件,链路没有可信的 actor/source审计入口规则会拒绝且该动作不在 tasks 1.7 的审计清单内。故移除该「提交」审计动作与写入,而不是伪造操作者身份;注册通过/驳回的审计保留。
2. **分销与提现相关审计改为必达**:审计与业务事实同事务原子提交,写入使用非吞错的 `AppendAndGet` 并返回错误不再降级为「只记日志」。原因是失败审计被静默丢弃会让拒绝事实不可追溯ENG-AUDIT-001
3. **分销码冲突重试改用 GORM 嵌套事务**PostgreSQL 唯一冲突会中止整个事务,必须回滚到保存点才能重试;而 GORM 在 `PrepareStmt: true` 下会临时置换 `Statement.ConnPool`,裸 `SavePoint`/`RollbackTo` 会破坏事务状态。故以 `tx.Transaction(...)` 的嵌套事务实现重试(由 GORM 负责保存点与连接池切换),保持「预检 + 唯一冲突重试 + 上限 5 次」,其他唯一冲突不重试。
4. **统一加锁顺序为「申请行 → 尝试行 → 钱包行」**:三条写路径(提现终态消费者、重提与释放接缝、退款佣金回扣)统一该顺序,钱包永远最后加锁,避免 A→B、B→A 死锁环;退款回扣因此把「拒绝待审提现的加锁与释放额计算」提到钱包加锁之前。
5. **新增两个拒绝类审计动作码**`commission_withdrawal.attempt_reject``withdrawal_qualification.submit_reject`,主资源为**店铺**。原因:创建前拒绝(资格无效、余额不足、越权、存在待审批版本)没有提现单或资料版本可挂,而审计规则要求主资源类型必须等于动作注册的主资源。
6. **店铺删除与停用统一走资格失效联动**:删除与停用在同一事务内使该店铺全部有效资料资格失效;删除路径的失效必须先于软删除执行(失效写审计时需要店铺行可见)。
7. **代理停用/删除时企微通过资格的终态收敛**:店铺已停用或不存在时,待审批资料版本直接收敛为已失效并写审计,避免停留在待审批导致该店铺永久无法获得有效资格。
8. **分销码存量回填**:新增迁移 `000217` 为历史空码店铺生成随机唯一码,并同步列注释;`down` 不清空数据。
## 待裁决项与处置
以下三项在实施复核中提出,经用户裁决后的最终处置:
1. **有效资格下仅变更可选材料(发票、营业执照、门头照)的提交被拒绝**:裁决为「作废后重提可行」,保持现状。需要补录发票等可选材料时,由超级管理员填写原因作废当前有效版本,代理重新提交并重新审批通过。资格「通过后长期有效、不可同版本重提」的语义不变。
2. **企业主体是否必填法人身份证号**:裁决为「必填」。合同签约双方均需登记法人身份证号,实现已是该语义(`legal_person_id_card` 对企业与个人均必填DTO 校验与 `chk_withdrawal_qualification_legal_person` 双重约束)。
3. **重提与释放接缝相对终态消费者的加锁顺序**:属前瞻性风险,已由实施决策第 4 条「统一加锁顺序为申请行 → 尝试行 → 钱包行」解决,不再作为待办。
## 业务动作契约
### 分销注册(公开)
- `POST /api/c/v1/agent-distribution-registrations`:无认证。请求包含 `distribution_code`、手机号、短信验证码、密码与既有注册必填资料。成功返回注册记录标识与待审批状态,不返回任何账号凭证。无效码、停用上级、验证码无效或已消费统一返回“分销码不可用”,不创建记录。
### 提现资格(需认证)
- `POST /api/admin/shops/:shop_id/withdrawal-qualifications`:仅本人代理店铺。请求合同与法人身份证正反面附件;企业必填统一社会信用代码,个人必填法人身份证号;可选营业执照、门头照;企业可选发票且抬头与统一社会信用代码必须与合同主体一致。成功创建新资料版本与审批实例,替换合同或身份证时同一事务失效旧有效版本。
- `POST /api/admin/withdrawal-qualifications/:id/void`:仅超级管理员,`reason` 必填。成功后该版本失效且原因可查询。
- 查询接口沿用既有认证与数据范围校验,仅返回本人或数据范围内店铺的资格与附件对象键引用。
### 提现申请与审批(需认证)
- `POST /api/admin/shops/:shop_id/withdrawal-requests`:仅本人代理店铺,新增有效资格校验。事务内锁定佣金钱包、冻结可提现余额、写审批尝试记录与新审批实例、回填 `latest_*`。余额不足、资格无效或非本人代理均不创建申请或冻结。
- `PUT /api/admin/shops/:shop_id/withdrawal-requests/:id`:仅本人代理店铺,仅已驳回申请可修改金额、收款信息与本次发票后重提;新增尝试记录与新审批实例,历史快照不覆盖。
- 本地通过/驳回入口保留但受终态门禁限制;新申请不可由本地人工终审。
## Risks / Trade-offs
- [企业微信回调重复、乱序、未知或通过后撤销] → 以审批实例、尝试记录、请求行状态条件更新与交付租约幂等消费,复用既有查询恢复;通过后撤销不回滚、不重新冻结、转正交异常标记并禁止自动重提。
- [公开注册接口被滥用] → 复用既有验证码校验/消费与三档限流;无效码与停用上级统一结果避免枚举;注册阶段不创建任何账号或店铺。
- [注册审批通过时并发占用手机号或用户名] → 通过事务整体回滚,不留半套实体;重复回调由注册记录状态条件更新拦截。
- [层级与业务员快照漂移] → 通过事务写入上级与业务员快照;审批通过后清理上级下级缓存,避免缓存窗口内新下级不可见。
- [附件超出企微上限] → 资格只有 6 个单对象键附件字段(合同、法人身份证正反面、营业执照、门头照、发票),结构上不可能超过企微单张审批单 6 个附件上限,无需运行时计数校验;渠道侧仍按既有规则兜底。
- [资金汇总口径漂移] → 通过保持 `status=2`,不使用状态 4`paid_at` 只补充到账时间,不改变既有聚合条件。
- [重提造成第二笔冻结] → 重提在单事务内先释放旧未结算冻结再冻结新金额,且以尝试记录为资金事实。
- [存量提现兼容] → 本地终审仅对已关联审批实例的申请拒绝,`approval_instance_id` 为空的记录走既有路径。
- [重提与审批核心唯一约束] → 新业务类型以自身业务记录主键作为 `business_id`,共享唯一约束不变,既有两个审批场景语义不变。
- [财务审计时间线未纳入新业务类型] → 仅缺少业务跳转、不报错;沿用既有登记,作为后续 Change 待办,不属本期范围。
## Migration Plan
1. 新增成对迁移(`.up.sql`/`.down.sql`)创建待审批注册记录、提现资格资料版本、提现审批尝试记录三张表及唯一约束与查询索引,并为提现申请追加 `latest_*`、通过时间与异常标记列。迁移编号不预占,实施开始时按当时 `migrations/` 目录的最大编号顺延;不修改既有迁移。
2. 同一批迁移扩展 `tb_wecom_approval_scene``business_type` CHECK 以纳入三个新类型;`down` 在存在新类型场景行时必须拒绝破坏性回滚并给出明确异常。
3. 施工时列全审批业务类型注册点:业务类型与字段常量、场景 DTO 枚举与中文描述、场景用例的业务字段白名单/类型校验/中文名、数据库 CHECK、Worker 决策消费者与依赖装配、审批审计资源映射,以及分销注册、资格版本、提现尝试记录的审计资源常量与 registry 条目。
4. 单批发布即可:新表与追加列对既有旧代码无影响;注册只创建本地待审批记录,建店只发生在审批通过事务内。
5. 运行期前置(非迁移写入):上线前由超级管理员经 `PUT /api/admin/wecom/scenes/{business_type}``agent_distribution_approval``withdrawal_qualification_approval``commission_withdrawal_approval` 三个业务类型配置启用场景与模板控件映射;验收证据为场景列表接口 `GET /api/admin/wecom/scenes` 返回这三个业务类型的启用记录及其 `control_mapping`(各场景可映射字段清单可经 `GET /api/admin/wecom/scenes/{business_type}/fields` 核对)。迁移不写入模板;未配置时相应提交按既有规则失败关闭(`Prepare` 返回服务不可用)。
6. 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证:迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh`,只创建、删除本 Change 自己的 fixture禁止重置整库仅连接、迁移或实际行为失败时才阻塞对应场景。
7. 回滚时先停止新入口;已有注册、资格与提现事实保留,只有维护者确认未产生不可逆业务数据时才执行 `down`

View File

@@ -0,0 +1,29 @@
## Scope
- 迭代编号:`AUG26-008`
## Why
代理下级归属和提现资料/资金缺少企业微信终审及失效边界,无法可靠追溯。
## What Changes
- 代理店铺唯一分销码;公开扫码注册接口只创建待审批注册记录,审批通过后才在单一事务内建店铺、代理账号、钱包、上级层级与业务员快照。
- 合同、法人身份证为核心的提现资格审批与失效;资格替换即新资料版本与新审批实例,不引入同行重提状态机。
- 提现增加审批尝试记录快照、企微终审、驳回释放与重提;企业微信通过保持 `WithdrawalStatusApproved` 并以 `paid_at` 记录到账时间。
- 已关联审批实例的提现不再接受本地人工终审;`approval_instance_id` 为空的存量记录保持既有兼容处理。
- 交付公开注册后端接口H5 页面、二维码渲染与前端交互不在本 Change。
## Capabilities
### New Capabilities
- `agent-distribution-withdrawal`: 分销注册、资料资格与提现审批。
### Modified Capabilities
- 无。
## Impact
影响代理店铺、个人客户公开接口面、附件、佣金钱包、企业微信审批与 Schema。
新增待审批注册记录、提现资格资料版本、提现审批尝试记录三张表,以及 `agent_distribution_approval``withdrawal_qualification_approval``commission_withdrawal_approval` 三个审批业务类型;既有店铺、账号、钱包与提现列表的对外契约与兼容语义不变。

View File

@@ -0,0 +1,157 @@
## Purpose
使代理下级注册和佣金提现均以企业微信审批、资格有效性和不可变业务快照为准,避免代理层级或资金事实因资料变更、重复回调而漂移。
## ADDED Requirements
### Requirement: 分销码与待审批代理注册
系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。
系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验、消费与限流规则。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。
无效分销码、分销码所属店铺已停用、验证码无效或已被消费时,系统 MUST NOT 创建注册记录或审批实例,且 MUST 对外返回统一不可用结果。分销码所属店铺停用 MUST NOT 级联变更既有下级与既有佣金关系。
注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。
同一手机号在驳回后再次扫码 MUST 形成新的注册记录与新的审批实例;重复或乱序回调 MUST NOT 重复创建账号、层级或钱包。
#### Scenario: 扫码注册进入待审批
- **WHEN** 客户使用有效分销码提交已验证手机号、密码与注册必填资料
- **THEN** 系统仅创建一条待审批注册记录及企业微信审批实例,不创建店铺、账号或钱包
#### Scenario: 停用代理码注册
- **WHEN** 客户使用已停用代理所属店铺的分销码注册
- **THEN** 系统拒绝创建注册记录与审批实例,且不产生任何店铺或账号
#### Scenario: 验证码无效或已消费
- **WHEN** 请求携带的短信验证码无效、过期或已被消费
- **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额
#### Scenario: 审批通过建立层级与业务员快照
- **WHEN** 企业微信最终通过一条待审批注册记录
- **THEN** 系统在同一事务内创建启用店铺与代理账号、写入分销码所属店铺为直接上级、复制上级当时业务员为初始业务员,并标记注册记录已通过
#### Scenario: 审批驳回不产生实体
- **WHEN** 企业微信最终驳回一条待审批注册记录
- **THEN** 系统不创建店铺、账号、钱包或层级,且同一手机号可再次扫码形成新的注册记录
#### Scenario: 重复回调不重复建层级
- **WHEN** 同一注册审批终态被重复投递
- **THEN** 系统至多创建一次店铺、账号、钱包与层级关系
### Requirement: 提现资料资格
代理首次提现前 SHALL 提交企业微信资料资格申请;合同和法人身份证正反面必填,企业代理填写统一社会信用代码、个人代理填写法人身份证号。合同资格主体 MUST 填写统一社会信用代码或身份证号;营业执照、门头照可选,发票仅企业可选且其抬头/统一社会信用代码 MUST 与合同主体一致。审批通过且资料未过期才有效;合同和身份证通过后长期有效,直到代理替换资料、超级管理员作废或代理停用。
系统 SHALL 以不可变的资料版本保存资格事实与附件引用,历史版本、附件与审批实例 MUST NOT 被覆盖。审批 MUST 使用业务类型 `withdrawal_qualification_approval`,其业务标识 MUST 为资料版本主键。代理替换合同或法人身份证 MUST 新增资料版本并创建新的审批实例,同时在同一事务内使旧有效版本失效;代理 MUST 重新审批通过后才可提现。系统 MUST NOT 为资格资料提供同一版本的重新提交或重提状态机。
超级管理员作废资格 MUST 填写原因;代理停用 MUST 自动使该店铺全部有效资格失效。
资格申请、资格作废与后台查询 MUST 沿用既有认证、自身店铺或数据范围校验MUST NOT 公开。
#### Scenario: 资料替换后提现
- **WHEN** 有效资格的代理替换合同或法人身份证资料
- **THEN** 旧有效版本立即失效,新版本进入企业微信审批,代理重新审批通过后才可提现
#### Scenario: 历史版本不被覆盖
- **WHEN** 同一店铺存在多个资料版本
- **THEN** 每个版本保留各自的附件引用、审批实例与审批结果,历史版本不被改写
#### Scenario: 超级管理员作废资格
- **WHEN** 超级管理员填写原因后作废某有效资格
- **THEN** 该资格失效且原因可查询,代理提现被拒绝直至重新审批通过
#### Scenario: 代理停用使资格失效
- **WHEN** 代理店铺被停用
- **THEN** 该店铺全部有效资格失效,历史版本与审批结果保留
#### Scenario: 未认证或越权访问资格接口
- **WHEN** 未认证请求,或请求的店铺超出当前账号数据范围
- **THEN** 系统拒绝访问且不披露资格是否存在
### Requirement: 提现冻结与企业微信终审
提现申请 SHALL 先校验申请店铺存在有效资料资格,再冻结可提现余额、金额、手续费、收款信息和可选发票快照,并创建企业微信审批。余额不足、资格无效或非本人代理时,系统 MUST NOT 创建提现申请、审批实例或任何冻结。发票为申请级材料,若上传 MUST 按当时有效合同主体校验并冻结至该次审批快照。本地 MUST NOT 提供人工通过或驳回终审。审批 MUST 使用业务类型 `commission_withdrawal_approval`
每次提交或重提 MUST 新增一条不可变的审批尝试记录冻结该次金额、手续费、实际到账、收款信息与申请级发票快照并为其创建独立的审批实例提现申请行只保存最新审批实例标识用于列表投影MUST NOT 作为历史事实来源。企业微信通过即视为已到账:系统 MUST 保持 `WithdrawalStatusApproved`2并在同一事务内仅一次从冻结余额扣减同时写入到账时间MUST NOT 使用已到账状态值 4。
企业微信驳回时,系统 MUST 仅一次释放本次尝试的冻结余额并记录释放时间;`cancelled``deleted` MUST 按驳回同等处理。驳回后代理可修改金额、收款信息和本次发票重新提交,每次新建审批尝试记录与审批实例;资料资格保持有效。企业微信对已通过申请撤销时,系统 MUST NOT 回滚已到账金额、MUST NOT 重新冻结MUST 保持通过状态并写入正交的异常标记与原因供详情展示与人工处理MUST NOT 自动重提。
重复、乱序或结果未知的回调 MUST NOT 造成重复扣减、重复释放或第二笔冻结;结果未知时申请保持在途并由既有查询恢复机制收敛。
#### Scenario: 资格无效或余额不足不创建提现
- **WHEN** 代理店铺不存在有效资料资格、资格已失效,或可提现余额不足
- **THEN** 系统拒绝创建提现申请,不创建审批实例且不冻结任何余额
#### Scenario: 提现审批通过
- **WHEN** 企业微信对一笔待审提现返回最终通过且该结果首次被消费
- **THEN** 系统仅一次从冻结余额扣减,申请保持已通过状态并记录到账时间
#### Scenario: 提现审批驳回
- **WHEN** 企业微信最终驳回一笔提现申请
- **THEN** 系统仅一次释放该次尝试的冻结余额并记录释放时间,保留审批快照,并允许在资格仍有效时修改后重提
#### Scenario: 驳回后重提
- **WHEN** 代理修改金额、收款信息或本次发票后重新提交被驳回的提现申请
- **THEN** 系统新增审批尝试记录并创建新的审批实例,历史尝试、快照与审批结果不被覆盖
#### Scenario: 企微取消或删除
- **WHEN** 企业微信对待审提现返回取消或删除
- **THEN** 系统按驳回同等处理,仅一次释放冻结余额并记录释放时间
#### Scenario: 通过后撤销
- **WHEN** 企业微信对已通过的提现申请返回通过后撤销
- **THEN** 系统不回滚已到账金额、不重新冻结、不自动重提,仅写入异常标记与原因供详情展示
#### Scenario: 重复回调不重复扣减
- **WHEN** 同一审批终态被重复投递或乱序到达
- **THEN** 系统至多扣减或释放一次,不产生第二笔冻结
### Requirement: 提现本地人工终审边界
系统 SHALL 拒绝本地人工通过与驳回已关联审批实例的提现申请;该判定 MUST 以申请记录中是否已关联审批实例为准MUST NOT 依赖任何新增配置开关。本地接口拒绝时 MUST 返回稳定的状态冲突错误且 MUST NOT 改变申请状态、余额或流水。
`approval_instance_id` 为空的存量提现申请 MUST 保持既有本地人工处理行为不变;其他审批场景的本地历史语义 MUST NOT 被本能力改变。
#### Scenario: 新提现申请拒绝本地终审
- **WHEN** 平台账号对已关联企业微信审批实例的提现申请调用本地通过或驳回接口
- **THEN** 系统返回状态冲突、申请状态与钱包余额不变
#### Scenario: 存量提现保持兼容
- **WHEN** 本地通过或驳回接口作用于从未关联审批实例的存量提现申请
- **THEN** 系统按既有规则完成扣减或释放,行为与本次变更前一致
### Requirement: 待审提现与佣金回溯的释放接缝
系统 SHALL 保证后续佣金回溯处理待审提现时,每个未结算审批尝试的冻结余额可被幂等释放:释放金额 MUST 取该尝试的金额事实,释放完成 MUST 写入该尝试的释放时间事实,且释放 MUST 先于同一店铺佣金余额扣减完成。已释放的尝试 MUST NOT 被重复释放,重复处理 MUST NOT 重复增加可提现余额。
#### Scenario: 回溯先释放后扣减
- **WHEN** 佣金回溯处理存在待审提现的店铺
- **THEN** 系统先释放每个未结算尝试的冻结余额并记录释放时间,再扣减佣金余额
#### Scenario: 重复释放不重复入账
- **WHEN** 同一未结算尝试的释放被重复执行
- **THEN** 系统至多释放一次,冻结余额与可提现余额不被重复调整

View File

@@ -0,0 +1,34 @@
## 1. 分销与注册
- [x] 1.1 为 `tb_shop` 新增全局唯一、不可修改的随机 `distribution_code`(新成对迁移 + 条件唯一索引,编号按实施时最大编号顺延);在 `internal/application/shop/create.go` 建店事务内生成并处理唯一冲突重试;同步 `scripts/migration/lib/shop_sql.py` 使批量导入店铺同样生成分销码;不提供人工指定或编辑入口。
- [x] 1.2 新增 `tb_agent_distribution_registration`(分销码、上级店铺、手机号、密码哈希、状态、审批实例与时间戳)及模型、状态常量、审计资源常量与 registry 条目;不创建 `status=2` 的店铺或账号;同一手机号驳回后再次扫码为新记录。
- [x] 1.3 实现公开接口 `POST /api/c/v1/agent-distribution-registrations`:经 `internal/routes.Register` 注册,且在 `internal/routes/personal.go``Use()` 之前;复用既有验证码校验、消费与限流;锁定上级店铺并校验启用,无效码、停用上级、验证码无效或已消费统一返回“分销码不可用”且不落库;新 Handler 同步 `pkg/openapi/handlers.go``cmd/api/docs.go``cmd/gendocs/main.go` 与 bootstrap 装配。
- [x] 1.4 注册 `agent_distribution_approval` 决策消费者:通过时同一事务建启用店铺、代理主账号、所需钱包、上级层级与上级业务员快照,标记注册记录并写审计,提交后清理上级店铺下级缓存;驳回仅标记不建实体;条件更新保证重复回调不重复建账号、层级或钱包。
- [x] 1.5 新增 `tb_withdrawal_qualification`(主体类型、签约主体代码、法人身份证号、合同与身份证正反面附件、可选营业执照/门头照/发票及其主体代码、状态与失效原因、审批实例)与模型、常量;附件每项单对象键,总数不超过企微 6 附件上限。
- [x] 1.6 实现资格提交、替换、作废与停用失效:`POST /api/admin/shops/:shop_id/withdrawal-qualifications`(仅本人代理店铺)、超管作废 `POST /api/admin/withdrawal-qualifications/:id/void`(原因必填)、代理停用联动失效;替换合同或身份证时同一事务新增版本并使旧有效版本失效;注册 `withdrawal_qualification_approval` 消费者,驳回不影响既有有效版本。
- [x] 1.7 为分销码生成、注册通过/驳回、资格提交/替换/作废/停用失效补齐事务内审计;日志与审计不记录密码、完整证件号或附件内容。
## 2. 提现
- [x] 2.1 新增 `tb_commission_withdrawal_request_attempt``request_id``attempt_no`、金额/手续费/费率/实际到账快照、收款信息、申请级发票快照、提交人、审批实例、释放时间)及唯一约束与索引;为 `tb_commission_withdrawal_request` 追加 `latest_attempt_id``latest_approval_instance_id`、通过时间与异常标记/原因列。
- [x] 2.2 改造 `CreateWithdrawalRequest`:增加有效资格校验;事务内锁定佣金钱包、冻结金额、写尝试记录、创建审批实例(`business_id` = 尝试记录主键)、回填 `latest_*`、写钱包流水与审计;余额不足、资格无效或非本人代理均不创建申请或冻结。
- [x] 2.3 实现驳回后重提:仅已驳回申请可由本人代理修改金额、收款信息与本次发票;事务内先释放旧未结算尝试的冻结,再按新金额冻结并新增尝试记录与新审批实例,历史快照与审批结果不被覆盖,不产生第二笔冻结。
- [x] 2.4 注册 `commission_withdrawal_approval` 终态消费者:通过仅一次从冻结余额扣减、保持 `WithdrawalStatusApproved`=2 并写 `paid_at`;驳回与 `cancelled`/`deleted` 仅一次释放本次尝试冻结并写释放时间;`revoked_after_approved` 不回滚、不重新冻结、不自动重提,写正交异常标记与原因供详情展示;重复、乱序与未知结果由既有交付租约与查询恢复收敛。
- [x] 2.5 本地人工终审边界:`internal/service/commission_withdrawal` 的通过/驳回在申请已关联审批实例时拒绝并返回状态冲突,不新增任何配置开关;`approval_instance_id` 为空的存量申请保持既有兼容处理;错误交全局 ErrorHandler。
- [x] 2.6 注册资格、提现申请与提现详情的后台路由及 OpenAPI保障自身店铺与数据范围校验公开接口不得承载资格、提现或后台查询操作。
## 3. 审批业务类型注册点
- [x] 3.1 `pkg/constants/approval.go`:新增 `agent_distribution_approval``withdrawal_qualification_approval``commission_withdrawal_approval` 三个业务类型常量与各场景业务字段常量,注释写明 `business_id` 取值语义。
- [x] 3.2 `internal/model/dto/wecom_scene_dto.go`:两处 `business_type` enum 与中文 description 同步新增三个类型ENG-DTO-001
- [x] 3.3 `internal/application/wecom/scene.go``sceneBusinessFields` 白名单、`validApprovalBusinessType`、业务类型中文名三处新增。
- [x] 3.4 新成对迁移扩展 `chk_wecom_approval_scene_business` 纳入三个新类型;`down` 在存在新类型场景行时拒绝破坏性回滚并报明确异常;不修改既有迁移,编号按实施时最大编号顺延。
- [x] 3.5 `cmd/worker/main.go`:新增三个决策 handler 的依赖装配并注册到 `BusinessDecisionHandler` dispatcher缺注册时按既有规则返回服务不可用
- [x] 3.6 `internal/infrastructure/audit/approval.go``approvalBusinessResource` 新增三个业务类型的资源映射(缺映射会 fail-closed`pkg/constants/audit.go``internal/infrastructure/audit/registry.go` 新增分销注册记录、资格资料版本、提现审批尝试记录三个审计资源(提现单资源已存在,无需新增)。
- [ ] 3.7 运行期前置:上线前由超级管理员经 `PUT /api/admin/wecom/scenes/{business_type}` 为三个业务类型配置启用场景与模板控件映射;迁移不写入模板,未配置时相应提交失败关闭。**待维护者执行**:配置 `agent_distribution_approval``withdrawal_qualification_approval``commission_withdrawal_approval` 的启用场景与控件映射(资格 6 个附件控件对应 `file_list`,必填合同/法人身份证正反面、可选营业执照/门头照/发票);验收证据为 `GET /api/admin/wecom/scenes` 返回三类型启用记录及 `control_mapping`,字段清单经 `GET /api/admin/wecom/scenes/{business_type}/fields` 核对。本项为运行期配置数据,不是代码交付物,故未勾选。
## 4. 验证
- [x] 4.1 按 ENG-TEST-001 在 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证:迁移 `up/down/up`;从本地工作区以显式 `DB_*` 参数执行 `scripts/migrate.sh`;仅创建、删除本 Change 自己的 fixture禁止重置整库。验证以显式 `DB_*``junhong_cmp_test`)执行 `./scripts/migrate.sh up`214/215/216/217 全部成功)→ `version` 为 217 且 `dirty=false``down 3` → 再 `up` 回 217`down` 守卫按设计拒绝破坏性回滚(存在 `tb_agent_distribution_registration` 行时 000214 拒绝、存在新类型场景行时 000216 拒绝),因此整库回退只能到 `000214`,发布说明写明该边界。验证期间只创建、删除本 Change 自己的 fixture`shop_code LIKE 'AUG26008%'`、三张新表、13 个新增动作码审计与资源行),复核残留:新表与审计残留均为 0`tb_shop` 无空分销码且全局唯一;未重置整库。
- [x] 4.2 受控验证本地状态机:以 Redis 预置验证码DB 6`approval.Port.Prepare` 与受控 `TerminalDecisionEvent` 驱动业务消费者,验证分销码唯一与停用门禁、注册通过/驳回与重复回调、资格替换与作废、提现冻结/重提/通过/驳回/取消/删除/通过后撤销、本地终审拒绝与存量兼容、待审提现释放接缝不调用企业微信、短信、对象存储或支付网络。验证临时受控脚手架Redis DB 6 显式覆盖,`.env.local` 的 DB 7 不适用)驱动真实业务消费者,结果 **227 项通过 / 0 项失败**,分 18 组:分销码唯一/随机/不可修改/冲突重试与非事务句柄守卫;公开注册验证码无效与已消费、无效码、停用上级统一拒绝且不落库;注册通过建店+账号+钱包+层级+业务员快照且新码≠上级码并可续开下级,重复回调不重复建实体,驳回不建实体且同手机号可再注册;资格提交/替换失效/驳回不改写历史/超管作废/停用与删店联动失效;提现冻结/通过(状态保持 2 且写 `paid_at`,不使用 4/驳回/cancelled/deleted/重提单笔冻结/通过后撤销不回滚;释放接缝先释放后扣减、重复释放不重复入账;本地终审拒绝新申请与存量兼容;退款佣金回扣 × 在途企微提现(修复前复现 8 项失败);统一加锁序下 6 轮并发无死锁;负向场景拒绝审计与 14 个动作码审计真实落库、恰一个主要资源、失败/拒绝审计不再被吞(`auditfailure` 日志 0 行);数据范围与脱敏投影。脚手架为一次性产物,按清理要求已删除,未在仓库留下测试入口(自动化测试按项目决策为 N/A
- [x] 4.3 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-agent-distribution-withdrawal-qualification --strict``openspec doctor --json``./scripts/context-health.sh`;自动化测试按项目决策为 N/A。验证`gofmt -l` 对全部变更 Go 文件输出为空;`go build ./cmd/api ./cmd/worker` 无错误;`go vet ./internal/... ./pkg/...` 无输出;`go run cmd/gendocs/main.go` 重新生成 `docs/admin-openapi.yaml`,与工作区文件逐字节一致,新增路由与 6 值业务类型枚举齐全,既有路由未被改写或删除;`./scripts/context-health.sh` 输出「Context 健康检查通过」;`openspec validate add-agent-distribution-withdrawal-qualification --strict` 输出 `Change 'add-agent-distribution-withdrawal-qualification' is valid``openspec doctor --json``"healthy": true`

View File

@@ -0,0 +1,157 @@
# agent-distribution-withdrawal Specification
## Purpose
使代理下级注册和佣金提现均以企业微信审批、资格有效性和不可变业务快照为准,避免代理层级或资金事实因资料变更、重复回调而漂移。
## Requirements
### Requirement: 分销码与待审批代理注册
系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。
系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验、消费与限流规则。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。
无效分销码、分销码所属店铺已停用、验证码无效或已被消费时,系统 MUST NOT 创建注册记录或审批实例,且 MUST 对外返回统一不可用结果。分销码所属店铺停用 MUST NOT 级联变更既有下级与既有佣金关系。
注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。
同一手机号在驳回后再次扫码 MUST 形成新的注册记录与新的审批实例;重复或乱序回调 MUST NOT 重复创建账号、层级或钱包。
#### Scenario: 扫码注册进入待审批
- **WHEN** 客户使用有效分销码提交已验证手机号、密码与注册必填资料
- **THEN** 系统仅创建一条待审批注册记录及企业微信审批实例,不创建店铺、账号或钱包
#### Scenario: 停用代理码注册
- **WHEN** 客户使用已停用代理所属店铺的分销码注册
- **THEN** 系统拒绝创建注册记录与审批实例,且不产生任何店铺或账号
#### Scenario: 验证码无效或已消费
- **WHEN** 请求携带的短信验证码无效、过期或已被消费
- **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额
#### Scenario: 审批通过建立层级与业务员快照
- **WHEN** 企业微信最终通过一条待审批注册记录
- **THEN** 系统在同一事务内创建启用店铺与代理账号、写入分销码所属店铺为直接上级、复制上级当时业务员为初始业务员,并标记注册记录已通过
#### Scenario: 审批驳回不产生实体
- **WHEN** 企业微信最终驳回一条待审批注册记录
- **THEN** 系统不创建店铺、账号、钱包或层级,且同一手机号可再次扫码形成新的注册记录
#### Scenario: 重复回调不重复建层级
- **WHEN** 同一注册审批终态被重复投递
- **THEN** 系统至多创建一次店铺、账号、钱包与层级关系
### Requirement: 提现资料资格
代理首次提现前 SHALL 提交企业微信资料资格申请;合同和法人身份证正反面必填,企业代理填写统一社会信用代码、个人代理填写法人身份证号。合同资格主体 MUST 填写统一社会信用代码或身份证号;营业执照、门头照可选,发票仅企业可选且其抬头/统一社会信用代码 MUST 与合同主体一致。审批通过且资料未过期才有效;合同和身份证通过后长期有效,直到代理替换资料、超级管理员作废或代理停用。
系统 SHALL 以不可变的资料版本保存资格事实与附件引用,历史版本、附件与审批实例 MUST NOT 被覆盖。审批 MUST 使用业务类型 `withdrawal_qualification_approval`,其业务标识 MUST 为资料版本主键。代理替换合同或法人身份证 MUST 新增资料版本并创建新的审批实例,同时在同一事务内使旧有效版本失效;代理 MUST 重新审批通过后才可提现。系统 MUST NOT 为资格资料提供同一版本的重新提交或重提状态机。
超级管理员作废资格 MUST 填写原因;代理停用 MUST 自动使该店铺全部有效资格失效。
资格申请、资格作废与后台查询 MUST 沿用既有认证、自身店铺或数据范围校验MUST NOT 公开。
#### Scenario: 资料替换后提现
- **WHEN** 有效资格的代理替换合同或法人身份证资料
- **THEN** 旧有效版本立即失效,新版本进入企业微信审批,代理重新审批通过后才可提现
#### Scenario: 历史版本不被覆盖
- **WHEN** 同一店铺存在多个资料版本
- **THEN** 每个版本保留各自的附件引用、审批实例与审批结果,历史版本不被改写
#### Scenario: 超级管理员作废资格
- **WHEN** 超级管理员填写原因后作废某有效资格
- **THEN** 该资格失效且原因可查询,代理提现被拒绝直至重新审批通过
#### Scenario: 代理停用使资格失效
- **WHEN** 代理店铺被停用
- **THEN** 该店铺全部有效资格失效,历史版本与审批结果保留
#### Scenario: 未认证或越权访问资格接口
- **WHEN** 未认证请求,或请求的店铺超出当前账号数据范围
- **THEN** 系统拒绝访问且不披露资格是否存在
### Requirement: 提现冻结与企业微信终审
提现申请 SHALL 先校验申请店铺存在有效资料资格,再冻结可提现余额、金额、手续费、收款信息和可选发票快照,并创建企业微信审批。余额不足、资格无效或非本人代理时,系统 MUST NOT 创建提现申请、审批实例或任何冻结。发票为申请级材料,若上传 MUST 按当时有效合同主体校验并冻结至该次审批快照。本地 MUST NOT 提供人工通过或驳回终审。审批 MUST 使用业务类型 `commission_withdrawal_approval`
每次提交或重提 MUST 新增一条不可变的审批尝试记录冻结该次金额、手续费、实际到账、收款信息与申请级发票快照并为其创建独立的审批实例提现申请行只保存最新审批实例标识用于列表投影MUST NOT 作为历史事实来源。企业微信通过即视为已到账:系统 MUST 保持 `WithdrawalStatusApproved`2并在同一事务内仅一次从冻结余额扣减同时写入到账时间MUST NOT 使用已到账状态值 4。
企业微信驳回时,系统 MUST 仅一次释放本次尝试的冻结余额并记录释放时间;`cancelled``deleted` MUST 按驳回同等处理。驳回后代理可修改金额、收款信息和本次发票重新提交,每次新建审批尝试记录与审批实例;资料资格保持有效。企业微信对已通过申请撤销时,系统 MUST NOT 回滚已到账金额、MUST NOT 重新冻结MUST 保持通过状态并写入正交的异常标记与原因供详情展示与人工处理MUST NOT 自动重提。
重复、乱序或结果未知的回调 MUST NOT 造成重复扣减、重复释放或第二笔冻结;结果未知时申请保持在途并由既有查询恢复机制收敛。
#### Scenario: 资格无效或余额不足不创建提现
- **WHEN** 代理店铺不存在有效资料资格、资格已失效,或可提现余额不足
- **THEN** 系统拒绝创建提现申请,不创建审批实例且不冻结任何余额
#### Scenario: 提现审批通过
- **WHEN** 企业微信对一笔待审提现返回最终通过且该结果首次被消费
- **THEN** 系统仅一次从冻结余额扣减,申请保持已通过状态并记录到账时间
#### Scenario: 提现审批驳回
- **WHEN** 企业微信最终驳回一笔提现申请
- **THEN** 系统仅一次释放该次尝试的冻结余额并记录释放时间,保留审批快照,并允许在资格仍有效时修改后重提
#### Scenario: 驳回后重提
- **WHEN** 代理修改金额、收款信息或本次发票后重新提交被驳回的提现申请
- **THEN** 系统新增审批尝试记录并创建新的审批实例,历史尝试、快照与审批结果不被覆盖
#### Scenario: 企微取消或删除
- **WHEN** 企业微信对待审提现返回取消或删除
- **THEN** 系统按驳回同等处理,仅一次释放冻结余额并记录释放时间
#### Scenario: 通过后撤销
- **WHEN** 企业微信对已通过的提现申请返回通过后撤销
- **THEN** 系统不回滚已到账金额、不重新冻结、不自动重提,仅写入异常标记与原因供详情展示
#### Scenario: 重复回调不重复扣减
- **WHEN** 同一审批终态被重复投递或乱序到达
- **THEN** 系统至多扣减或释放一次,不产生第二笔冻结
### Requirement: 提现本地人工终审边界
系统 SHALL 拒绝本地人工通过与驳回已关联审批实例的提现申请;该判定 MUST 以申请记录中是否已关联审批实例为准MUST NOT 依赖任何新增配置开关。本地接口拒绝时 MUST 返回稳定的状态冲突错误且 MUST NOT 改变申请状态、余额或流水。
`approval_instance_id` 为空的存量提现申请 MUST 保持既有本地人工处理行为不变;其他审批场景的本地历史语义 MUST NOT 被本能力改变。
#### Scenario: 新提现申请拒绝本地终审
- **WHEN** 平台账号对已关联企业微信审批实例的提现申请调用本地通过或驳回接口
- **THEN** 系统返回状态冲突、申请状态与钱包余额不变
#### Scenario: 存量提现保持兼容
- **WHEN** 本地通过或驳回接口作用于从未关联审批实例的存量提现申请
- **THEN** 系统按既有规则完成扣减或释放,行为与本次变更前一致
### Requirement: 待审提现与佣金回溯的释放接缝
系统 SHALL 保证后续佣金回溯处理待审提现时,每个未结算审批尝试的冻结余额可被幂等释放:释放金额 MUST 取该尝试的金额事实,释放完成 MUST 写入该尝试的释放时间事实,且释放 MUST 先于同一店铺佣金余额扣减完成。已释放的尝试 MUST NOT 被重复释放,重复处理 MUST NOT 重复增加可提现余额。
#### Scenario: 回溯先释放后扣减
- **WHEN** 佣金回溯处理存在待审提现的店铺
- **THEN** 系统先释放每个未结算尝试的冻结余额并记录释放时间,再扣减佣金余额
#### Scenario: 重复释放不重复入账
- **WHEN** 同一未结算尝试的释放被重复执行
- **THEN** 系统至多释放一次,冻结余额与可提现余额不被重复调整