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
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
18 KiB
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 = 注册记录主键。终态消费以条件更新(注册记录状态 = 待审批)保证至多一次,通过时在同一事务内:
- 校验分销码所属店铺仍存在且启用;
- 创建启用店铺,写入上级店铺与层级(
level = parent.level + 1,上限 7); - 创建代理主账号(写入注册时的密码哈希),创建所需钱包;
- 写入上级当时业务员为初始业务员快照;
- 标记注册记录已通过,记录审计。
事务提交后必须清理上级店铺的下级缓存,否则新下级在下级集合缓存的有效期内不可见。驳回只标记注册记录与审批结果,不创建任何实体。手机号或用户名在通过时已被并发注册占用,属于事务失败,必须整体回滚,不得留下半套实体。
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。
实施决策(与设计文档的差异)
本节记录实施过程中与本文档正文不同的实际决策,仅描述事实,不改变需求语义。
- 公开注册提交不写审计:公开路由注册在个人客户路由
Use()之前,不经认证中间件,链路没有可信的 actor/source(审计入口规则会拒绝),且该动作不在 tasks 1.7 的审计清单内。故移除该「提交」审计动作与写入,而不是伪造操作者身份;注册通过/驳回的审计保留。 - 分销与提现相关审计改为必达:审计与业务事实同事务原子提交,写入使用非吞错的
AppendAndGet并返回错误,不再降级为「只记日志」。原因是失败审计被静默丢弃会让拒绝事实不可追溯(ENG-AUDIT-001)。 - 分销码冲突重试改用 GORM 嵌套事务:PostgreSQL 唯一冲突会中止整个事务,必须回滚到保存点才能重试;而 GORM 在
PrepareStmt: true下会临时置换Statement.ConnPool,裸SavePoint/RollbackTo会破坏事务状态。故以tx.Transaction(...)的嵌套事务实现重试(由 GORM 负责保存点与连接池切换),保持「预检 + 唯一冲突重试 + 上限 5 次」,其他唯一冲突不重试。 - 统一加锁顺序为「申请行 → 尝试行 → 钱包行」:三条写路径(提现终态消费者、重提与释放接缝、退款佣金回扣)统一该顺序,钱包永远最后加锁,避免 A→B、B→A 死锁环;退款回扣因此把「拒绝待审提现的加锁与释放额计算」提到钱包加锁之前。
- 新增两个拒绝类审计动作码:
commission_withdrawal.attempt_reject与withdrawal_qualification.submit_reject,主资源为店铺。原因:创建前拒绝(资格无效、余额不足、越权、存在待审批版本)没有提现单或资料版本可挂,而审计规则要求主资源类型必须等于动作注册的主资源。 - 店铺删除与停用统一走资格失效联动:删除与停用在同一事务内使该店铺全部有效资料资格失效;删除路径的失效必须先于软删除执行(失效写审计时需要店铺行可见)。
- 代理停用/删除时企微通过资格的终态收敛:店铺已停用或不存在时,待审批资料版本直接收敛为已失效并写审计,避免停留在待审批导致该店铺永久无法获得有效资格。
- 分销码存量回填:新增迁移
000217为历史空码店铺生成随机唯一码,并同步列注释;down不清空数据。
待裁决项与处置
以下三项在实施复核中提出,经用户裁决后的最终处置:
- 有效资格下仅变更可选材料(发票、营业执照、门头照)的提交被拒绝:裁决为「作废后重提可行」,保持现状。需要补录发票等可选材料时,由超级管理员填写原因作废当前有效版本,代理重新提交并重新审批通过。资格「通过后长期有效、不可同版本重提」的语义不变。
- 企业主体是否必填法人身份证号:裁决为「必填」。合同签约双方均需登记法人身份证号,实现已是该语义(
legal_person_id_card对企业与个人均必填,DTO 校验与chk_withdrawal_qualification_legal_person双重约束)。 - 重提与释放接缝相对终态消费者的加锁顺序:属前瞻性风险,已由实施决策第 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
- 新增成对迁移(
.up.sql/.down.sql)创建待审批注册记录、提现资格资料版本、提现审批尝试记录三张表及唯一约束与查询索引,并为提现申请追加latest_*、通过时间与异常标记列。迁移编号不预占,实施开始时按当时migrations/目录的最大编号顺延;不修改既有迁移。 - 同一批迁移扩展
tb_wecom_approval_scene的business_typeCHECK 以纳入三个新类型;down在存在新类型场景行时必须拒绝破坏性回滚并给出明确异常。 - 施工时列全审批业务类型注册点:业务类型与字段常量、场景 DTO 枚举与中文描述、场景用例的业务字段白名单/类型校验/中文名、数据库 CHECK、Worker 决策消费者与依赖装配、审批审计资源映射,以及分销注册、资格版本、提现尝试记录的审计资源常量与 registry 条目。
- 单批发布即可:新表与追加列对既有旧代码无影响;注册只创建本地待审批记录,建店只发生在审批通过事务内。
- 运行期前置(非迁移写入):上线前由超级管理员经
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返回服务不可用)。 - 按 ENG-TEST-001 在维护者指定的
junhong_cmp_testPostgreSQL + Redis DB 6 验证:迁移从本地工作区以显式DB_*执行scripts/migrate.sh,只创建、删除本 Change 自己的 fixture,禁止重置整库;仅连接、迁移或实际行为失败时才阻塞对应场景。 - 回滚时先停止新入口;已有注册、资格与提现事实保留,只有维护者确认未产生不可逆业务数据时才执行
down。