Files
break 575d056f54
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(代理分销提现): 落地扫码注册、提现资料资格与企微终审提现
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
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
2026-09-14 09:45:13 +08:00

160 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`