fix(代理分销注册): 校验手机号/用户名/店铺编号唯一性并支持驳回后重注册
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m57s
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 通过
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-17
|
||||
@@ -0,0 +1,69 @@
|
||||
## 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
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
公开扫码注册接口 `POST /api/c/v1/agent-distribution-registrations` 在创建待审批注册记录前只做格式校验,不校验手机号、用户名、店铺编号是否已被既有账号/店铺占用,也不校验是否已存在其它待审批申请。后果是:重复提交会同时生成多条待审批记录与企业微信审批单;企业微信通过其中一条后建店建号必然撞 `tb_account`/`tb_shop` 唯一索引,整笔审批事务回滚,形成「审批已通过、店铺和账号没建出来」且注册记录永久停留在待审批的漏水场景——即测试库当前 7 条待审批记录中出现手机号、用户名、店铺编号重复(`SHOP20260729114608DDUQ`、`csdp333-1`、`罗洋平` 各重复)所暴露的问题。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 校验位置前移到「创建注册记录与审批实例」的同一写事务内:手机号、用户名、店铺编号与既有未删除账号/店铺冲突时拒绝,返回既有稳定错误码 `1014 手机号已存在`、`1013 用户名已存在`、`1031 店铺编号已存在`。
|
||||
- 手机号、用户名、店铺编号与其它 `status=0` 待审批注册记录冲突时拒绝,返回 `1007 资源冲突` 与可定位中文提示(区分手机号 / 用户名 / 店铺编号)。
|
||||
- 已驳回(`status=2`,含企业微信通过后撤销按驳回处理)与已通过(`status=1`)的历史注册记录 MUST NOT 阻止同一手机号、用户名或店铺编号重新提交:填错资料后重新扫码注册仍可用。
|
||||
- 同一关键字段的并发提交由事务级 advisory lock 串行裁决,保证至多落一条待审批注册记录;锁在事务提交时自动释放。
|
||||
- 审批通过路径在创建店铺与账号前复检同一组关键字段,冲突时返回可定位错误(既有账号/店铺占用)并整体回滚,不再以裸数据库错误收场。
|
||||
- 冲突拒绝发生在注册记录落库前,短信验证码 MUST NOT 被消费,客户修正资料后可直接用同一验证码重试。
|
||||
- 路由描述(`internal/routes/agent_distribution_public.go`)同步新增失败原因;`docs/admin-openapi.yaml` 随之重新生成。
|
||||
|
||||
明确不做:不校验店铺名称重复(`tb_shop` 无 `shop_name` 唯一约束,重名是既有允许行为);不新增数据库唯一索引(待审批唯一性是跨状态条件约束,需在应用事务内串行裁决,且现存重复待审批行会让条件唯一索引迁移失败);不新增错误码;不改变注册记录、审批实例与审批通过建实体的既有结构与语义。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `agent-distribution-withdrawal`: 「分销码与待审批代理注册」需求新增关键字段唯一性门禁(既有账号/店铺、其它待审批申请)、驳回后重新注册不被阻塞、并发提交串行化与审批通过前复检。
|
||||
@@ -0,0 +1,77 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 分销码与待审批代理注册
|
||||
|
||||
系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。
|
||||
|
||||
系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`,不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验与限流规则,并 MUST 保证同一验证码至多被消费一次。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。
|
||||
|
||||
系统 MUST 在创建注册记录与审批实例前校验注册关键字段(手机号、用户名、店铺编号)的唯一性:任一字段与既有未删除账号或店铺冲突时,MUST 按字段返回各自可定位的错误码与提示(手机号已存在、用户名已存在、店铺编号已存在);任一字段与其它待审批(`status=0`)注册记录冲突时,MUST 返回资源冲突错误并指明冲突字段。关键字段冲突时 MUST NOT 创建注册记录或审批实例,MUST NOT 消费短信验证码,客户修正资料后 MUST 能用同一验证码重试。
|
||||
|
||||
已驳回(含企业微信通过后撤销按驳回处理)与已通过的注册记录 MUST NOT 阻止同一手机号、用户名或店铺编号再次提交;状态为终态的历史注册记录只作为历史事实保留。同一关键字段的并发提交 MUST 被串行裁决,同一手机号、用户名或店铺编号至多存在一条待审批注册记录,MUST NOT 出现两条指向同一关键字段的待审批申请。店铺名称不参与唯一性校验。
|
||||
|
||||
分销码无效、分销码所属店铺已停用、上级店铺缺少启用的主账号、短信验证码无效或已被消费时,系统 MUST NOT 创建注册记录或审批实例,且 MUST 按失败原因返回各自可定位的错误码与提示,MUST NOT 统一为同一不可用结果。短信验证码 MUST 在注册记录与审批实例落库成功后才消费;落库前的任何失败 MUST NOT 消费验证码,客户 MUST 能用同一验证码直接重试。分销码所属店铺停用 MUST NOT 级联变更既有下级与既有佣金关系。
|
||||
|
||||
注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;创建店铺与代理账号前 MUST 复检注册关键字段未被既有账号或店铺占用,被占用时 MUST 整体回滚并返回可定位冲突错误,MUST NOT 创建半套实体。最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。
|
||||
|
||||
同一手机号在驳回后再次扫码 MUST 形成新的注册记录与新的审批实例;重复或乱序回调 MUST NOT 重复创建账号、层级或钱包。
|
||||
|
||||
#### Scenario: 扫码注册进入待审批
|
||||
|
||||
- **WHEN** 客户使用有效分销码提交已验证手机号、密码与注册必填资料,且手机号、用户名、店铺编号均未被占用
|
||||
- **THEN** 系统仅创建一条待审批注册记录及企业微信审批实例,不创建店铺、账号或钱包
|
||||
|
||||
#### Scenario: 关键字段与既有账号或店铺重复
|
||||
|
||||
- **WHEN** 客户提交的注册手机号、用户名或店铺编号已存在未删除的账号或店铺
|
||||
- **THEN** 系统不创建注册记录与审批实例,按冲突字段返回对应错误码与提示(手机号已存在 / 用户名已存在 / 店铺编号已存在),且不消费该次短信验证码
|
||||
|
||||
#### Scenario: 关键字段与其它待审批申请重复
|
||||
|
||||
- **WHEN** 客户提交的注册手机号、用户名或店铺编号已存在另一条待审批注册记录
|
||||
- **THEN** 系统不创建新的注册记录与审批实例,返回资源冲突错误并指明冲突字段,且不消费该次短信验证码
|
||||
|
||||
#### Scenario: 驳回后重新注册
|
||||
|
||||
- **WHEN** 同一手机号、用户名或店铺编号的上一次申请已被企业微信驳回(含通过后撤销)
|
||||
- **THEN** 系统允许再次提交并创建新的待审批注册记录与新的审批实例,不复用也不改写既有终态记录
|
||||
|
||||
#### Scenario: 并发重复提交
|
||||
|
||||
- **WHEN** 同一手机号、用户名或店铺编号在并发请求中被同时提交
|
||||
- **THEN** 系统至多创建一条待审批注册记录,其余请求返回资源冲突错误
|
||||
|
||||
#### Scenario: 停用代理码注册
|
||||
|
||||
- **WHEN** 客户使用已停用代理所属店铺的分销码注册
|
||||
- **THEN** 系统拒绝创建注册记录与审批实例,且不产生任何店铺或账号
|
||||
|
||||
#### Scenario: 验证码无效或已消费
|
||||
|
||||
- **WHEN** 请求携带的短信验证码无效、过期或已被消费
|
||||
- **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额
|
||||
|
||||
#### Scenario: 落库前失败不消耗验证码
|
||||
|
||||
- **WHEN** 短信验证码校验通过后,注册记录或审批实例创建失败(含关键字段冲突)
|
||||
- **THEN** 系统保留该验证码,客户可用同一验证码重试,重试得到的仍是同一失败原因而非验证码失效
|
||||
|
||||
#### Scenario: 审批通过建立层级与业务员快照
|
||||
|
||||
- **WHEN** 企业微信最终通过一条待审批注册记录,且该记录的手机号、用户名与店铺编号仍未被占用
|
||||
- **THEN** 系统在同一事务内创建启用店铺与代理账号、写入分销码所属店铺为直接上级、复制上级当时业务员为初始业务员,并标记注册记录已通过
|
||||
|
||||
#### Scenario: 审批通过前关键字段已被占用
|
||||
|
||||
- **WHEN** 企业微信通过一条待审批注册记录,但其手机号、用户名或店铺编号已被并发创建的账号或店铺占用
|
||||
- **THEN** 系统整体回滚本次建店建号,返回可定位的冲突错误,注册记录保持待审批且不产生半套实体
|
||||
|
||||
#### Scenario: 审批驳回不产生实体
|
||||
|
||||
- **WHEN** 企业微信最终驳回一条待审批注册记录
|
||||
- **THEN** 系统不创建店铺、账号、钱包或层级,且同一手机号、用户名或店铺编号可再次扫码形成新的注册记录
|
||||
|
||||
#### Scenario: 重复回调不重复建层级
|
||||
|
||||
- **WHEN** 同一注册审批终态被重复投递
|
||||
- **THEN** 系统至多创建一次店铺、账号、钱包与层级关系
|
||||
@@ -0,0 +1,14 @@
|
||||
## 1. 关键字段唯一性门禁
|
||||
|
||||
- [x] 1.1 `internal/application/distributionwithdrawal/registration.go` 新增三个内部函数并写明中文注释:`lockRegistrationKeyScopes`(按 key 字符串升序对 `phone`/`username`/`shop_code` 取 `pg_advisory_xact_lock(hashtext(:key))`,key 前缀 `agent-distribution-registration:`,注释说明行锁无法覆盖首次并发提交、升序避免死锁环);`ensureRegistrationKeysAvailable`(用 GORM 默认软删除范围查 `tb_account.phone`、`tb_account.username`、`tb_shop.shop_code`,分别返回 `CodePhoneExists`「手机号已被使用」、`CodeUsernameExists`「用户名已存在」、`CodeShopCodeExists`「店铺编号已存在」,查询失败返回 `CodeDatabaseError`);`ensureNoPendingRegistration`(查 `status=0` 且 `phone`/`username`/`shop_code` 任一命中的最早记录,命中后按字段返回 `CodeConflict` 与区分字段的中文提示)。验证:三个函数位于 `registration.go:152-235`,`lockRegistrationKeyScopes` 使用 `slices.Sort(keys)` 排序后逐个 `Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key)`,`ensureRegistrationKeysAvailable` 依次以 `registrationKeyTaken` 校验三个字段并返回三个既有错误码。
|
||||
- [x] 1.2 `Register` 的写事务内按「取串行化点 → 校验既有账号/店铺 → 校验待审批记录 → 创建注册记录 → 创建审批实例 → 回写实例 ID」顺序接入 1.1 的三个函数,使校验与插入同事务;失败整体回滚,事务外既有的验证码消费不执行。同步更新 `Register` 与相关函数的文档注释(冲突失败原因、已驳回/已通过记录不阻塞重新注册、软删除账号/店铺不占用关键字段)。验证:`registration.go:107-120` 事务体开头依次调用三个函数;受控脚手架中三类冲突场景均断言「不落库 + 验证码仍存在」,驳回后重注册场景断言验证码被消费。
|
||||
- [x] 1.3 `internal/application/distributionwithdrawal/registration_approval.go` 的 `applyApproved` 在 `loadEnabledParentShop` 之后、创建店铺与账号之前调用 `ensureRegistrationKeysAvailable`:关键字段已被既有账号或店铺占用时返回 1.1 的可定位错误并整体回滚,注册记录保持待审批,不产生半套店铺/账号/钱包;注释写明该复检只把裸数据库错误换成稳定错误码,审批路径不取 advisory lock 的理由。验证:`registration_approval.go:107-113`;受控脚手架断言冲突审批返回 `1014`、注册记录仍为 `status=0`、无店铺与账号落库。
|
||||
- [x] 1.4 `internal/routes/agent_distribution_public.go` 的 `Description` 增补失败原因(关键字段与既有账号/店铺或其他待审批申请重复时返回各自提示,且不消费验证码);运行 `go run cmd/gendocs/main.go` 重新生成 `docs/admin-openapi.yaml`,确认该路由描述与工作区文件一致且未改写其它路由。验证:`go run cmd/gendocs/main.go` 输出「成功在以下位置生成 OpenAPI 文档」,生成文件 `/api/c/v1/agent-distribution-registrations` 的 `description` 已包含新增失败原因(该文件被 `.gitignore` 忽略,为生成产物)。
|
||||
- [x] 1.5 运行 `gofmt -w` 覆盖全部变更 Go 文件,并运行 `go build ./cmd/api ./cmd/worker`、`go vet ./internal/... ./pkg/...`,确认无错误输出。验证:`gofmt -l` 对四个变更 Go 文件输出为空;`go build ./cmd/api ./cmd/worker` 退出码 0;`go vet ./internal/... ./pkg/...` 退出码 0(唯余 `go` 自身对 `/Users/break/go/pkg/mod/cache/download` 无写权限的 `stat cache` 提示,与本次变更无关)。
|
||||
|
||||
## 2. 验证
|
||||
|
||||
- [x] 2.1 按 ENG-TEST-001 在 `junhong_cmp_test` PostgreSQL + Redis DB 6 以一次性受控脚手架(驱动真实 `RegistrationService` 与真实 `DistributionApprovalHandler`,事后删除且不在仓库留下测试入口)验证提交侧门禁:手机号与既有未删除账号冲突、用户名与既有账号冲突、店铺编号与既有店铺冲突分别返回 `1014`/`1013`/`1031` 与对应提示;软删除账号/店铺占用的关键字段可重新注册;手机号/用户名/店铺编号与既有 `status=0` 记录冲突分别返回 `1007` 与区分字段的中文提示;上一次申请为 `status=2`(驳回)时同一手机号、用户名、店铺编号可成功提交为新记录与新审批实例;所有拒绝场景下注册记录与审批实例均未落库,且失败后同一验证码可直接重试成功;并发发起 N 路同关键字段提交时仅 1 条待审批记录落库、其余返回 `1007`。fixture 只创建与删除本 Change 自己的记录(手机号/用户名/店铺编号带可识别前缀),不重置整库。验证:`go run ./cmd/tmpverify_dupguard`(`.env.local` 显式 `DB_*` = `junhong_cmp_test`、Redis DB 6 显式覆盖)输出 **37 项通过 / 0 项失败**,覆盖上述全部断言;并发 6 路同手机号/用户名/店铺编号提交仅 1 条成功、待审批记录恰 1 条,失败方错误码为 `1007`(串行点后看到待审批记录)与 `1183`(验证码已被胜者消费);三类冲突场景均验证验证码未被消费,驳回后重注册成功且验证码被消费。脚手架驱动的审批渠道为受控桩(真实 `approval.Port.Prepare` 需要调用企业微信,AGENTS.md 禁止对真实外部审批系统做自动验证),审计 Writer 用受控桩以避免在共享测试环境留下审计残留。
|
||||
- [x] 2.2 以同一脚手架验证审批侧:关键字段已被占用的待审批记录在受控 `TerminalDecisionEvent`(通过)下整体回滚,注册记录仍为 `status=0`,错误码为 1.1 的可定位错误,且 `tb_shop`/`tb_account`/`tb_agent_wallet` 无半套实体;对无冲突待审批记录验证通过路径仍正常建店、建号、建钱包与层级(回归)。验证:冲突审批返回 `1014`,注册记录仍为待审批,按店铺编号与用户名查询均为 0 行;无冲突审批成功建店(`parent_id` 为上级、`level` 为上级 +1、生成新的非空分销码)、建代理主账号(归属新店铺、`is_primary`、手机号一致)、建两个钱包,注册记录标记已通过。脚手架清理后按前缀复核残留:注册记录 0 / 店铺 0 / 账号 0 / 钱包 0(经 dbhub 只读查询 `tb_shop`、`tb_account`、`tb_agent_distribution_registration`、`tb_agent_wallet` 确认),脚手架文件已删除。
|
||||
- [x] 2.3 运行 `openspec validate fix-agent-distribution-registration-duplicate-guard --strict`、`openspec doctor --json` 与 `./scripts/context-health.sh`,确认 Change 校验通过、仓库健康;自动化测试按项目决策为 N/A,不新增测试入口。验证:`openspec validate ... --strict` 输出 `Change 'fix-agent-distribution-registration-duplicate-guard' is valid`;`openspec doctor --json` 为 `"healthy": true` 且 `status` 为空;`./scripts/context-health.sh` 输出「Context 健康检查通过」。
|
||||
- [x] 2.4 清理修复前产生的重复待审批记录(维护者指令):测试环境 `tb_agent_distribution_registration` 中 `id` 88~93 六条 `status=0` 记录的关键字段均已与既有店铺或账号冲突(`shop_code` 全部被既有店铺占用),在修复后的门禁下不可能通过审批,已人工标记为 `status=2` 并写入可区分来源的中文驳回原因(88~91 为渠道提交失败、92~93 为企业微信已通过但关键字段被占用无法建店);注册记录与审批实例的既有渠道事实不改写,`id=94` 已由企业微信回调按既有链路驳回。验证:dbhub 只读查询确认七条记录状态均为 `status=2`,无 `status=0` 记录遗留;本次为测试环境数据清理,不改写迁移、不新增审计入口。
|
||||
@@ -9,17 +9,41 @@
|
||||
|
||||
系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`,不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验与限流规则,并 MUST 保证同一验证码至多被消费一次。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。
|
||||
|
||||
系统 MUST 在创建注册记录与审批实例前校验注册关键字段(手机号、用户名、店铺编号)的唯一性:任一字段与既有未删除账号或店铺冲突时,MUST 按字段返回各自可定位的错误码与提示(手机号已存在、用户名已存在、店铺编号已存在);任一字段与其它待审批(`status=0`)注册记录冲突时,MUST 返回资源冲突错误并指明冲突字段。关键字段冲突时 MUST NOT 创建注册记录或审批实例,MUST NOT 消费短信验证码,客户修正资料后 MUST 能用同一验证码重试。
|
||||
|
||||
已驳回(含企业微信通过后撤销按驳回处理)与已通过的注册记录 MUST NOT 阻止同一手机号、用户名或店铺编号再次提交;状态为终态的历史注册记录只作为历史事实保留。同一关键字段的并发提交 MUST 被串行裁决,同一手机号、用户名或店铺编号至多存在一条待审批注册记录,MUST NOT 出现两条指向同一关键字段的待审批申请。店铺名称不参与唯一性校验。
|
||||
|
||||
分销码无效、分销码所属店铺已停用、上级店铺缺少启用的主账号、短信验证码无效或已被消费时,系统 MUST NOT 创建注册记录或审批实例,且 MUST 按失败原因返回各自可定位的错误码与提示,MUST NOT 统一为同一不可用结果。短信验证码 MUST 在注册记录与审批实例落库成功后才消费;落库前的任何失败 MUST NOT 消费验证码,客户 MUST 能用同一验证码直接重试。分销码所属店铺停用 MUST NOT 级联变更既有下级与既有佣金关系。
|
||||
|
||||
注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。
|
||||
注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;创建店铺与代理账号前 MUST 复检注册关键字段未被既有账号或店铺占用,被占用时 MUST 整体回滚并返回可定位冲突错误,MUST NOT 创建半套实体。最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。
|
||||
|
||||
同一手机号在驳回后再次扫码 MUST 形成新的注册记录与新的审批实例;重复或乱序回调 MUST NOT 重复创建账号、层级或钱包。
|
||||
|
||||
#### Scenario: 扫码注册进入待审批
|
||||
|
||||
- **WHEN** 客户使用有效分销码提交已验证手机号、密码与注册必填资料
|
||||
- **WHEN** 客户使用有效分销码提交已验证手机号、密码与注册必填资料,且手机号、用户名、店铺编号均未被占用
|
||||
- **THEN** 系统仅创建一条待审批注册记录及企业微信审批实例,不创建店铺、账号或钱包
|
||||
|
||||
#### Scenario: 关键字段与既有账号或店铺重复
|
||||
|
||||
- **WHEN** 客户提交的注册手机号、用户名或店铺编号已存在未删除的账号或店铺
|
||||
- **THEN** 系统不创建注册记录与审批实例,按冲突字段返回对应错误码与提示(手机号已存在 / 用户名已存在 / 店铺编号已存在),且不消费该次短信验证码
|
||||
|
||||
#### Scenario: 关键字段与其它待审批申请重复
|
||||
|
||||
- **WHEN** 客户提交的注册手机号、用户名或店铺编号已存在另一条待审批注册记录
|
||||
- **THEN** 系统不创建新的注册记录与审批实例,返回资源冲突错误并指明冲突字段,且不消费该次短信验证码
|
||||
|
||||
#### Scenario: 驳回后重新注册
|
||||
|
||||
- **WHEN** 同一手机号、用户名或店铺编号的上一次申请已被企业微信驳回(含通过后撤销)
|
||||
- **THEN** 系统允许再次提交并创建新的待审批注册记录与新的审批实例,不复用也不改写既有终态记录
|
||||
|
||||
#### Scenario: 并发重复提交
|
||||
|
||||
- **WHEN** 同一手机号、用户名或店铺编号在并发请求中被同时提交
|
||||
- **THEN** 系统至多创建一条待审批注册记录,其余请求返回资源冲突错误
|
||||
|
||||
#### Scenario: 停用代理码注册
|
||||
|
||||
- **WHEN** 客户使用已停用代理所属店铺的分销码注册
|
||||
@@ -32,18 +56,23 @@
|
||||
|
||||
#### Scenario: 落库前失败不消耗验证码
|
||||
|
||||
- **WHEN** 短信验证码校验通过后,注册记录或审批实例创建失败
|
||||
- **WHEN** 短信验证码校验通过后,注册记录或审批实例创建失败(含关键字段冲突)
|
||||
- **THEN** 系统保留该验证码,客户可用同一验证码重试,重试得到的仍是同一失败原因而非验证码失效
|
||||
|
||||
#### Scenario: 审批通过建立层级与业务员快照
|
||||
|
||||
- **WHEN** 企业微信最终通过一条待审批注册记录
|
||||
- **WHEN** 企业微信最终通过一条待审批注册记录,且该记录的手机号、用户名与店铺编号仍未被占用
|
||||
- **THEN** 系统在同一事务内创建启用店铺与代理账号、写入分销码所属店铺为直接上级、复制上级当时业务员为初始业务员,并标记注册记录已通过
|
||||
|
||||
#### Scenario: 审批通过前关键字段已被占用
|
||||
|
||||
- **WHEN** 企业微信通过一条待审批注册记录,但其手机号、用户名或店铺编号已被并发创建的账号或店铺占用
|
||||
- **THEN** 系统整体回滚本次建店建号,返回可定位的冲突错误,注册记录保持待审批且不产生半套实体
|
||||
|
||||
#### Scenario: 审批驳回不产生实体
|
||||
|
||||
- **WHEN** 企业微信最终驳回一条待审批注册记录
|
||||
- **THEN** 系统不创建店铺、账号、钱包或层级,且同一手机号可再次扫码形成新的注册记录
|
||||
- **THEN** 系统不创建店铺、账号、钱包或层级,且同一手机号、用户名或店铺编号可再次扫码形成新的注册记录
|
||||
|
||||
#### Scenario: 重复回调不重复建层级
|
||||
|
||||
|
||||
Reference in New Issue
Block a user