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

18 KiB
Raw Blame History

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_approvalbusiness_id = 注册记录主键。终态消费以条件更新(注册记录状态 = 待审批)保证至多一次,通过时在同一事务内:

  1. 校验分销码所属店铺仍存在且启用;
  2. 创建启用店铺,写入上级店铺与层级(level = parent.level + 1,上限 7
  3. 创建代理主账号(写入注册时的密码哈希),创建所需钱包;
  4. 写入上级当时业务员为初始业务员快照;
  5. 标记注册记录已通过,记录审计。

事务提交后必须清理上级店铺的下级缓存,否则新下级在下级集合缓存的有效期内不可见。驳回只标记注册记录与审批结果,不创建任何实体。手机号或用户名在通过时已被并发注册占用,属于事务失败,必须整体回滚,不得留下半套实体。

4. 资格:不可变资料版本 + 独立审批业务类型

业务类型 withdrawal_qualification_approvalbusiness_id = 资料版本主键。资格事实按版本不可变保存:主体类型(企业/个人)、签约主体代码、法人身份证号、合同与法人身份证正反面附件、可选营业执照与门头照、可选发票抬头与统一社会信用代码、审批实例、状态与失效原因。

替换合同或法人身份证 = 新增版本 + 新审批实例,并在同一事务内使旧有效版本失效。作废由超级管理员执行且必须填写原因;代理停用联动使全部有效版本失效。资格与注册资料都不引入同行重提状态机——重审即新版本,驳回后重新提交即新记录。

附件映射受企业微信单张审批单 6 个附件上限约束:必填 3 项(合同、法人身份证正面、反面)+ 可选 3 项(营业执照、门头照、发票),因此每个附件字段只允许单个对象键,不得支持多文件追加。

5. 提现审批尝试记录与列表投影

业务类型 commission_withdrawal_approvalbusiness_id = 提现审批尝试记录主键。尝试记录保存 request_idattempt_no、金额/手续费/费率/实际到账快照、收款信息、申请级发票快照、提交人、审批实例与释放时间,创建后不可修改。

提现申请行新增 latest_attempt_idlatest_approval_instance_id,仅作为列表投影与门禁判定使用,不是历史事实来源;请求行上的金额与状态是最近一次尝试的镜像,冻结与释放金额一律取尝试记录。

每次提交或重提:事务内锁定佣金钱包、为新的尝试冻结金额、写尝试记录、创建审批实例、回填 latest_*、写钱包流水与审计。驳回后重提先释放旧未结算尝试的冻结,再按新金额冻结,全程单事务;不允许通过重提制造第二笔冻结。

6. 终态映射

企业微信决策 提现请求状态 资金动作 时间与标记
approved 保持 WithdrawalStatusApproved=2 仅一次从冻结余额扣减 paid_atprocessed_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,其 WHEREstatus = 待审核 条件;实现只返回该写入的错误、未校验影响行数,因此条件更新在此不作为已校验的守卫,并发下的最终一致性依赖既有的状态判定与唯一约束。存量从未关联审批实例的提现申请保持既有兼容处理;其他审批场景的本地历史语义不变。

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_rejectwithdrawal_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,不使用状态 4paid_at 只补充到账时间,不改变既有聚合条件。
  • [重提造成第二笔冻结] → 重提在单事务内先释放旧未结算冻结再冻结新金额,且以尝试记录为资金事实。
  • [存量提现兼容] → 本地终审仅对已关联审批实例的申请拒绝,approval_instance_id 为空的记录走既有路径。
  • [重提与审批核心唯一约束] → 新业务类型以自身业务记录主键作为 business_id,共享唯一约束不变,既有两个审批场景语义不变。
  • [财务审计时间线未纳入新业务类型] → 仅缺少业务跳转、不报错;沿用既有登记,作为后续 Change 待办,不属本期范围。

Migration Plan

  1. 新增成对迁移(.up.sql/.down.sql)创建待审批注册记录、提现资格资料版本、提现审批尝试记录三张表及唯一约束与查询索引,并为提现申请追加 latest_*、通过时间与异常标记列。迁移编号不预占,实施开始时按当时 migrations/ 目录的最大编号顺延;不修改既有迁移。
  2. 同一批迁移扩展 tb_wecom_approval_scenebusiness_type CHECK 以纳入三个新类型;down 在存在新类型场景行时必须拒绝破坏性回滚并给出明确异常。
  3. 施工时列全审批业务类型注册点:业务类型与字段常量、场景 DTO 枚举与中文描述、场景用例的业务字段白名单/类型校验/中文名、数据库 CHECK、Worker 决策消费者与依赖装配、审批审计资源映射,以及分销注册、资格版本、提现尝试记录的审计资源常量与 registry 条目。
  4. 单批发布即可:新表与追加列对既有旧代码无影响;注册只创建本地待审批记录,建店只发生在审批通过事务内。
  5. 运行期前置(非迁移写入):上线前由超级管理员经 PUT /api/admin/wecom/scenes/{business_type}agent_distribution_approvalwithdrawal_qualification_approvalcommission_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