## Context 见 `proposal.md` 了解动机。以下为开工核查确认的现状约束,全部来自实际代码与迁移: - 店铺无分销码;`tb_shop` 只有 `ShopStatusDisabled=0` / `ShopStatusEnabled=1`,不存在待审批状态;业务员只是 `tb_shop.business_owner_account_id` 单列,无快照。 - 不存在任何 H5 或后台的店铺注册链路与注册实体;`/api/c/v1` 现有能力为短信验证码、登录与手机号绑定/换绑。 - 短信验证码能力可复用:Redis `verification:code:`、校验即消费、手机号/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`。