All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m57s
- 提交写事务内先取事务级 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 通过
70 lines
7.7 KiB
Markdown
70 lines
7.7 KiB
Markdown
## 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
|
||
|
||
无。
|