- 提交写事务内先取事务级 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 通过
7.7 KiB
Context
公开扫码注册现在是「格式校验 + 分销码/验证码门禁 + 落库」:RegistrationService.Register(internal/application/distributionwithdrawal/registration.go)先做领域格式校验、bcrypt、分销码定位、验证码只校验不消费、approval.Prepare,随后在单个 GORM 事务内创建 tb_agent_distribution_registration 记录、通用审批实例并回写实例 ID,最后在事务外消费验证码。审批终态由 DistributionApprovalHandler.applyApproved 在同一事务内建 tb_shop、tb_account、角色、钱包与业务员快照。
目标事实的唯一性只在数据库索引层存在:tb_account 的 username/phone 与 tb_shop 的 shop_code 各有 WHERE deleted_at IS NULL 的条件唯一索引(idx_account_username、idx_account_phone、idx_shop_code),tb_agent_distribution_registration 只有普通索引 idx_agent_distribution_registration_phone 与 idx_agent_distribution_registration_parent。因此重复注册不会在提交时被拦下,只会在审批通过建实体时撞唯一索引整笔回滚,注册记录永久停留在 status=0。
工程约束:既有迁移不可修改,新 Schema 变化须新成对迁移(本 Change 不需要 Schema 变化);并发不变量在 Application 闭合;公开接口需要复用既有验证码语义(落库前失败不消费);错误统一由 pkg/errors 稳定错误码承载。
Goals / Non-Goals
Goals:
- 提交时(落库前)拦截手机号、用户名、店铺编号与既有未删除账号/店铺、其它待审批注册记录的冲突,返回可定位错误码与提示。
- 已驳回(含通过后撤销)与已通过的终态记录不阻塞重新注册。
- 同一关键字段的并发提交串行裁决,同一关键字段至多一条待审批注册记录。
- 审批通过建实体前复检同一组关键字段,冲突时给出可定位错误而非裸数据库错误,且不产生半套实体。
- 关键字段冲突时不消费短信验证码,修正资料后可用同一验证码重试。
Non-Goals:
- 不校验店铺名称重复:
tb_shop无shop_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.go、internal/application/wecom/* 一致。锁随提交或回滚自动释放。
tb_account/tb_shop 的既有条件唯一索引仍是最终兜底:串行点只负责让「检查—插入」原子,不负责替代数据库约束。
3. 错误码与提示按冲突对象区分
- 与既有账号/店铺冲突:复用
1014 CodePhoneExists(手机号已被使用)、1013 CodeUsernameExists(用户名已存在)、1031 CodeShopCodeExists(店铺编号已存在),提示与账号、店铺创建链路保持同一措辞。 - 与其它待审批记录冲突:
1007 CodeConflict,提示区分字段(该手机号已有待审批的注册申请,请等待审批结果 / 该用户名… / 该店铺编号…)。 - 校验查询沿用 GORM 默认软删除范围,与
WHERE deleted_at IS NULL的唯一索引生效范围一致:软删除账号/店铺占用的关键字段可被重新注册。
4. 审批通过路径复检同一组关键字段
applyApproved 在 loadEnabledParentShop 之后、创建店铺与账号之前复检关键字段;冲突直接返回 1014/1013/1031,事务回滚,注册记录保持 status=0。这不改变原有「冲突即整体回滚」的结果,只是把不可定位的数据库错误换成稳定错误码,便于定位扫描结果与审计日志。审批路径不取 advisory lock:提交路径的检查与插入已在同一串行点内,且审批的账号/店铺写入与注册记录状态推进在同一事务内提交,因此「提交侧检查」要么看到已提交的账号/店铺、要么看到仍为待审批的冲突记录,两个方向都会拒绝,不存在需要额外串行化的空档。
5. 只校验真正有唯一性事实的三个字段
手机号、用户名、店铺编号分别对应 tb_account.phone、tb_account.username、tb_shop.shop_code。店铺名称、联系人、地区等无唯一性事实,重复提交不构成冲突,保持现状。
Risks / Trade-offs
- advisory lock 的键空间是全局整数:
hashtext有哈希碰撞可能,碰撞只会带来额外阻塞(两个不相关关键字被串行),不会放宽唯一性;单次提交至多取 3 个锁,持锁时间等于事务时长(含 bcrypt 之外的数据库写入),对低频公开注册入口无吞吐风险。 - 首次提交仍可能被并发审批插入的账号抢先:提交串行点只覆盖提交路径;若恰好有一个命中同一关键字段的审批在建实体,提交侧在事务内看到的是「待审批记录仍在(未提交前)」或「账号/店铺已提交」,两种情况都会拒绝,不会落下两条同关键字段的待审批记录。
- 审批通过复检引入了与建实体同事务的额外查询:三次计数查询,代价可忽略;换来的是冲突可定位而不是裸
CodeDatabaseError。 - 测试库既有 7 条重复待审批记录:代码不再产生新的重复记录,但已存在的记录仍可能在审批通过时因唯一索引冲突回滚;这属于修复前的历史数据,需由维护者在测试环境自行处理(不属于本 Change 的代码交付物)。
- 冲突提示排在验证码校验之后:关键字段冲突在写事务内判定,因此请求必须先通过分销码与短信验证码校验才会看到冲突提示(既有失败顺序不变)。两个错误都属真实失败原因且验证码不被消费,客户修正手机号/用户名/店铺编号后可用同一验证码重试。
Migration Plan
无 Schema 变化、无数据迁移。发布即为二进制替换:新提交在关键字段冲突时被拒绝并保留验证码;已存在的待审批记录与审批实例不受影响。回滚即旧二进制,无需数据库回滚。
Open Questions
无。