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

70 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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
无。