## 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 无。