Files
break 6333f4ad13
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m57s
fix(代理分销注册): 校验手机号/用户名/店铺编号唯一性并支持驳回后重注册
- 提交写事务内先取事务级 advisory lock,再校验既有未删除账号/店铺与其它待审批申请:
  手机号 1014、用户名 1013、店铺编号 1031、待审批占用 1007,冲突不落库且不消费短信验证码
- 已驳回(含通过后撤销)与已通过的终态记录不阻塞重新注册,形成新记录与新审批实例
- 并发同关键字段提交串行裁决,同一关键字段至多一条待审批记录
- 审批通过建店建号前复检关键字段,冲突返回可定位错误并整体回滚,不再以裸数据库错误收场
- 归档 Change fix-agent-distribution-registration-duplicate-guard 并同步主 Spec

验证:junhong_cmp_test + Redis DB 6 受控脚手架 37 项通过 / 0 项失败(含 6 路并发仅 1 条落库、
审批冲突回滚与无冲突建店回归),清理后 fixture 残留 0;gofmt/go build/go vet 全绿;
openspec validate --all 35 项通过、doctor healthy、context-health 通过
2026-09-17 19:02:14 +08:00

7.7 KiB
Raw Blame History

Context

公开扫码注册现在是「格式校验 + 分销码/验证码门禁 + 落库」:RegistrationService.Registerinternal/application/distributionwithdrawal/registration.go先做领域格式校验、bcrypt、分销码定位、验证码只校验不消费、approval.Prepare,随后在单个 GORM 事务内创建 tb_agent_distribution_registration 记录、通用审批实例并回写实例 ID最后在事务外消费验证码。审批终态由 DistributionApprovalHandler.applyApproved 在同一事务内建 tb_shoptb_account、角色、钱包与业务员快照。

目标事实的唯一性只在数据库索引层存在:tb_accountusername/phonetb_shopshop_code 各有 WHERE deleted_at IS NULL 的条件唯一索引(idx_account_usernameidx_account_phoneidx_shop_codetb_agent_distribution_registration 只有普通索引 idx_agent_distribution_registration_phoneidx_agent_distribution_registration_parent。因此重复注册不会在提交时被拦下,只会在审批通过建实体时撞唯一索引整笔回滚,注册记录永久停留在 status=0

工程约束:既有迁移不可修改,新 Schema 变化须新成对迁移(本 Change 不需要 Schema 变化);并发不变量在 Application 闭合;公开接口需要复用既有验证码语义(落库前失败不消费);错误统一由 pkg/errors 稳定错误码承载。

Goals / Non-Goals

Goals:

  • 提交时(落库前)拦截手机号、用户名、店铺编号与既有未删除账号/店铺、其它待审批注册记录的冲突,返回可定位错误码与提示。
  • 已驳回(含通过后撤销)与已通过的终态记录不阻塞重新注册。
  • 同一关键字段的并发提交串行裁决,同一关键字段至多一条待审批注册记录。
  • 审批通过建实体前复检同一组关键字段,冲突时给出可定位错误而非裸数据库错误,且不产生半套实体。
  • 关键字段冲突时不消费短信验证码,修正资料后可用同一验证码重试。

Non-Goals:

  • 不校验店铺名称重复:tb_shopshop_name 唯一约束,重名是既有允许行为。
  • 不新增/修改错误码,不新增数据库唯一索引与迁移。
  • 不改动注册记录、审批实例、审批通过建实体的字段与状态语义;不改动分销码生成、验证码校验/消费与限流规则。
  • 不清理测试库既有的重复待审批记录(既有业务数据,不由代码或迁移改写)。
  • 不引入审批通过失败后的自动驳回/重试状态机。

Decisions

1. 校验落在创建注册记录与审批实例的同一个写事务内

Register 的事务体顺序改为:取关键字段串行化点 → 校验既有账号/店铺冲突 → 校验其它待审批记录冲突 → 创建注册记录 → 创建审批实例 → 回写实例 ID。校验与插入同事务意味着「检查通过」与「记录落库」之间没有可观测空档失败即回滚事务外既有的 ConsumeCode 不执行,因此验证码不被消费(与既有「落库前失败不消耗验证码」一致)。

放在事务外先做一次无锁预检可以更早返回错误,但会与 Prepare 之后的写事务重复同一判断,且预检结果在写事务内仍需复核;本接口是低频公开写入口,不做冗余预检。

2. 串行化用事务级 advisory lock而不是条件唯一索引

待审批唯一性是「同关键字段、status=0」的跨状态条件约束:目标行在首次提交时并不存在,行锁无法覆盖「首次并发提交」;同时条件唯一索引要求现存数据无冲突,而测试库当前就有 7 条重复待审批记录,建索引会让迁移失败并要求人工清理业务数据。因此按 key 字符串升序取 pg_advisory_xact_lock(hashtext(key))key 形如 agent-distribution-registration:phone:<手机号>username、shop_code 同理),升序保证并发提交不会形成 A→B / B→A 死锁环。该手法与仓库既有 internal/service/client_auth/*internal/application/systemconfig/update.gointernal/application/wecom/* 一致。锁随提交或回滚自动释放。

tb_account/tb_shop 的既有条件唯一索引仍是最终兜底:串行点只负责让「检查—插入」原子,不负责替代数据库约束。

3. 错误码与提示按冲突对象区分

  • 与既有账号/店铺冲突:复用 1014 CodePhoneExists(手机号已被使用)、1013 CodeUsernameExists(用户名已存在)、1031 CodeShopCodeExists(店铺编号已存在),提示与账号、店铺创建链路保持同一措辞。
  • 与其它待审批记录冲突:1007 CodeConflict,提示区分字段(该手机号已有待审批的注册申请,请等待审批结果 / 该用户名… / 该店铺编号…)。
  • 校验查询沿用 GORM 默认软删除范围,与 WHERE deleted_at IS NULL 的唯一索引生效范围一致:软删除账号/店铺占用的关键字段可被重新注册。

4. 审批通过路径复检同一组关键字段

applyApprovedloadEnabledParentShop 之后、创建店铺与账号之前复检关键字段;冲突直接返回 1014/1013/1031,事务回滚,注册记录保持 status=0。这不改变原有「冲突即整体回滚」的结果,只是把不可定位的数据库错误换成稳定错误码,便于定位扫描结果与审计日志。审批路径不取 advisory lock提交路径的检查与插入已在同一串行点内且审批的账号/店铺写入与注册记录状态推进在同一事务内提交,因此「提交侧检查」要么看到已提交的账号/店铺、要么看到仍为待审批的冲突记录,两个方向都会拒绝,不存在需要额外串行化的空档。

5. 只校验真正有唯一性事实的三个字段

手机号、用户名、店铺编号分别对应 tb_account.phonetb_account.usernametb_shop.shop_code。店铺名称、联系人、地区等无唯一性事实,重复提交不构成冲突,保持现状。

Risks / Trade-offs

  • advisory lock 的键空间是全局整数hashtext 有哈希碰撞可能,碰撞只会带来额外阻塞(两个不相关关键字被串行),不会放宽唯一性;单次提交至多取 3 个锁,持锁时间等于事务时长(含 bcrypt 之外的数据库写入),对低频公开注册入口无吞吐风险。
  • 首次提交仍可能被并发审批插入的账号抢先:提交串行点只覆盖提交路径;若恰好有一个命中同一关键字段的审批在建实体,提交侧在事务内看到的是「待审批记录仍在(未提交前)」或「账号/店铺已提交」,两种情况都会拒绝,不会落下两条同关键字段的待审批记录。
  • 审批通过复检引入了与建实体同事务的额外查询:三次计数查询,代价可忽略;换来的是冲突可定位而不是裸 CodeDatabaseError
  • 测试库既有 7 条重复待审批记录:代码不再产生新的重复记录,但已存在的记录仍可能在审批通过时因唯一索引冲突回滚;这属于修复前的历史数据,需由维护者在测试环境自行处理(不属于本 Change 的代码交付物)。
  • 冲突提示排在验证码校验之后:关键字段冲突在写事务内判定,因此请求必须先通过分销码与短信验证码校验才会看到冲突提示(既有失败顺序不变)。两个错误都属真实失败原因且验证码不被消费,客户修正手机号/用户名/店铺编号后可用同一验证码重试。

Migration Plan

无 Schema 变化、无数据迁移。发布即为二进制替换:新提交在关键字段冲突时被拒绝并保留验证码;已存在的待审批记录与审批实例不受影响。回滚即旧二进制,无需数据库回滚。

Open Questions

无。