diff --git a/internal/application/distributionwithdrawal/registration.go b/internal/application/distributionwithdrawal/registration.go index 68949fb..6b534fe 100644 --- a/internal/application/distributionwithdrawal/registration.go +++ b/internal/application/distributionwithdrawal/registration.go @@ -3,6 +3,7 @@ package distributionwithdrawal import ( "context" stderrors "errors" + "slices" "strconv" "strings" @@ -48,7 +49,9 @@ func NewRegistrationService( // Register 创建待审批注册记录。 // 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。 // 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。 -// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。 +// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误码; +// 与其它待审批注册记录冲突时返回资源冲突错误并指明冲突字段。 +// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 advisory lock 串行裁决。 func (s *RegistrationService) Register( ctx context.Context, input distributiondomain.RegistrationInput, @@ -104,6 +107,17 @@ func (s *RegistrationService) Register( } result := &RegistrationResult{} err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 关键字段门禁与注册记录插入必须同处一个串行化区间:先取 advisory lock, + // 再在同一事务内校验并落库,避免并发提交落下两条指向同一手机号/用户名/店铺编号的待审批申请。 + if err := lockRegistrationKeyScopes(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil { + return err + } + if err := ensureRegistrationKeysAvailable(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil { + return err + } + if err := ensureNoPendingRegistration(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil { + return err + } if err := tx.WithContext(ctx).Create(registration).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建待审批注册记录失败") } @@ -135,6 +149,90 @@ func (s *RegistrationService) Register( return result, nil } +// registrationKeyScopePrefix 是注册关键字段串行化点的键前缀,与其它用例的 advisory lock 键空间隔离。 +const registrationKeyScopePrefix = "agent-distribution-registration:" + +// lockRegistrationKeyScopes 在事务内为注册关键字段(手机号、用户名、店铺编号)取稳定串行化点。 +// 目标关键字段的待审批记录可能尚不存在,行锁无法覆盖「首次并发提交」, +// 因此按 key 字符串升序取事务级 advisory lock;升序保证并发提交不会形成 A→B / B→A 死锁环。 +// 锁随本次事务提交或回滚自动释放。 +func lockRegistrationKeyScopes(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error { + keys := []string{ + registrationKeyScopePrefix + "phone:" + phone, + registrationKeyScopePrefix + "username:" + username, + registrationKeyScopePrefix + "shop_code:" + shopCode, + } + slices.Sort(keys) + for _, key := range keys { + if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定注册关键字段串行化点失败") + } + } + return nil +} + +// ensureRegistrationKeysAvailable 校验注册关键字段未被既有账号或店铺占用。 +// 手机号与用户名对应 tb_account 的条件唯一索引,店铺编号对应 tb_shop 的条件唯一索引; +// 查询沿用 GORM 默认软删除范围,软删除账号或店铺占用的关键字段可被重新注册。 +func ensureRegistrationKeysAvailable(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error { + if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "phone", phone); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "校验注册手机号失败") + } else if exists { + return errors.New(errors.CodePhoneExists, "手机号已被使用") + } + if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "username", username); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "校验注册用户名失败") + } else if exists { + return errors.New(errors.CodeUsernameExists, "用户名已存在") + } + if exists, err := registrationKeyTaken(ctx, tx, &model.Shop{}, "shop_code", shopCode); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "校验注册店铺编号失败") + } else if exists { + return errors.New(errors.CodeShopCodeExists, "店铺编号已存在") + } + return nil +} + +// registrationKeyTaken 判断目标表(默认软删除范围)是否已存在占用该关键字段的记录。 +func registrationKeyTaken(ctx context.Context, tx *gorm.DB, target any, column, value string) (bool, error) { + var count int64 + if err := tx.WithContext(ctx).Model(target).Where(column+" = ?", value).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// ensureNoPendingRegistration 校验关键字段没有正在等待审批的注册申请。 +// 已通过或已驳回的终态记录不阻塞重新注册:资料填错后重新扫码必须能形成新的申请与新审批实例。 +func ensureNoPendingRegistration(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error { + var pending model.AgentDistributionRegistration + err := tx.WithContext(ctx). + Where("status = ? AND (phone = ? OR username = ? OR shop_code = ?)", + constants.AgentDistributionRegistrationStatusPending, phone, username, shopCode). + Order("id ASC").First(&pending).Error + switch { + case err == nil: + return pendingKeyConflict(&pending, phone, username) + case stderrors.Is(err, gorm.ErrRecordNotFound): + return nil + default: + return errors.Wrap(errors.CodeDatabaseError, err, "校验待审批注册申请失败") + } +} + +// pendingKeyConflict 把命中的待审批记录映射为指明冲突字段的冲突错误。 +// 查询条件保证三个关键字段至少一个命中,店铺编号作为兜底分支。 +func pendingKeyConflict(pending *model.AgentDistributionRegistration, phone, username string) error { + switch { + case pending.Phone == phone: + return errors.New(errors.CodeConflict, "该手机号已有待审批的注册申请,请等待审批结果") + case pending.Username == username: + return errors.New(errors.CodeConflict, "该用户名已有待审批的注册申请,请等待审批结果") + default: + return errors.New(errors.CodeConflict, "该店铺编号已有待审批的注册申请,请等待审批结果") + } +} + // findDistributionParent 按分销码定位上级店铺;未命中与已停用返回各自的可定位错误。 // 软删除店铺不参与匹配,与店铺唯一索引的生效范围一致。 func (s *RegistrationService) findDistributionParent(ctx context.Context, distributionCode string) (*model.Shop, error) { diff --git a/internal/application/distributionwithdrawal/registration_approval.go b/internal/application/distributionwithdrawal/registration_approval.go index d87c36c..23b1a37 100644 --- a/internal/application/distributionwithdrawal/registration_approval.go +++ b/internal/application/distributionwithdrawal/registration_approval.go @@ -89,7 +89,10 @@ func (h *DistributionApprovalHandler) Handle(ctx context.Context, event approval } // applyApproved 在同一事务内建立店铺、账号、钱包、层级与业务员快照。 -// 上级店铺必须仍然存在且启用;手机号或用户名已被并发注册占用时整体回滚,不留半套实体。 +// 上级店铺必须仍然存在且启用;手机号、用户名或店铺编号已被既有账号/店铺占用时整体回滚,不留半套实体。 +// 审批路径不取提交侧的关键字段 advisory lock:提交侧的校验与插入同处一个串行化区间, +// 且账号/店铺写入与注册记录状态推进同事务提交,因此提交侧只会看到「已提交的账号/店铺」或「仍待审批的冲突记录」, +// 两种情况都会拒绝。 func (h *DistributionApprovalHandler) applyApproved( ctx context.Context, tx *gorm.DB, @@ -104,6 +107,11 @@ func (h *DistributionApprovalHandler) applyApproved( if level > constants.ShopMaxLevel { return errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级") } + // 提交时的关键字段门禁可能已被此后的并发事实占用(平台手工建店、历史待审批记录): + // 此处复检把裸唯一索引错误换成可定位错误码,仍整体回滚,注册记录保持待审批。 + if err := ensureRegistrationKeysAvailable(ctx, tx, registration.Phone, registration.Username, registration.ShopCode); err != nil { + return err + } role, err := loadEnabledCustomerRole(ctx, tx) if err != nil { return err diff --git a/internal/handler/app/agent_distribution.go b/internal/handler/app/agent_distribution.go index 727d1b3..19d6c62 100644 --- a/internal/handler/app/agent_distribution.go +++ b/internal/handler/app/agent_distribution.go @@ -31,7 +31,8 @@ func NewAgentDistributionHandler( // POST /api/c/v1/agent-distribution-registrations // 无需认证、JWT、角色或权限;只创建待审批注册记录,不返回任何账号凭证。 // 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自提示且不落库; -// 短信验证码在注册记录落库成功后消费,落库前的失败不消耗验证码。 +// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误,与其它待审批申请冲突时返回资源冲突; +// 短信验证码在注册记录落库成功后消费,落库前的失败(含关键字段冲突)不消耗验证码。 func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error { if h.service == nil { return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置") diff --git a/internal/model/agent_distribution.go b/internal/model/agent_distribution.go index 5cbcc4d..1b47a27 100644 --- a/internal/model/agent_distribution.go +++ b/internal/model/agent_distribution.go @@ -4,6 +4,7 @@ import "time" // AgentDistributionRegistration 是代理扫码注册的待审批记录。 // 审批通过前不创建店铺、账号、钱包或上下级归属;同一手机号驳回后再次扫码是新记录。 +// 同一手机号、用户名或店铺编号至多存在一条待审批记录,终态(已通过/已驳回)记录不阻塞重新注册。 type AgentDistributionRegistration struct { ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` DistributionCode string `gorm:"column:distribution_code;type:varchar(32);not null;comment:上级店铺分销码快照" json:"distribution_code"` diff --git a/internal/routes/agent_distribution_public.go b/internal/routes/agent_distribution_public.go index 1ee5c4f..0f5e817 100644 --- a/internal/routes/agent_distribution_public.go +++ b/internal/routes/agent_distribution_public.go @@ -16,7 +16,7 @@ func registerAgentDistributionPublicRoutes(router fiber.Router, handler *app.Age } Register(router, doc, basePath, "POST", "/agent-distribution-registrations", handler.RegisterAgentDistribution, RouteSpec{ Summary: "代理扫码注册", - Description: "公开接口,无需认证。请求必须携带有效分销码、短信已验证手机号与密码;分销码无效、分销码所属店铺已停用、上级店铺缺少启用的主账号、短信验证码无效或已被消费分别返回各自提示,且不创建注册记录、店铺或账号。短信验证码只在注册记录落库成功后消费,落库前的失败不消耗验证码,可用同一验证码直接重试。通过后仍需企业微信终审才会创建店铺与代理账号。", + Description: "公开接口,无需认证。请求必须携带有效分销码、短信已验证手机号与密码;分销码无效、分销码所属店铺已停用、上级店铺缺少启用的主账号、短信验证码无效或已被消费分别返回各自提示,且不创建注册记录、店铺或账号。手机号、用户名或店铺编号已被既有账号或店铺占用时按其字段返回已存在提示,已有其它待审批注册申请占用同一字段时返回资源冲突提示(已驳回或已通过的历史申请不阻塞重新注册)。短信验证码只在注册记录落库成功后消费,落库前的失败不消耗验证码,可用同一验证码直接重试。通过后仍需企业微信终审才会创建店铺与代理账号。", Tags: []string{"个人客户 - 代理分销注册"}, Auth: false, Input: new(dto.CreateAgentDistributionRegistrationReq), diff --git a/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/.openspec.yaml b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/.openspec.yaml new file mode 100644 index 0000000..d28e909 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-17 diff --git a/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/design.md b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/design.md new file mode 100644 index 0000000..8619f8a --- /dev/null +++ b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/design.md @@ -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 + +无。 diff --git a/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/proposal.md b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/proposal.md new file mode 100644 index 0000000..c9b1a5d --- /dev/null +++ b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/proposal.md @@ -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`: 「分销码与待审批代理注册」需求新增关键字段唯一性门禁(既有账号/店铺、其它待审批申请)、驳回后重新注册不被阻塞、并发提交串行化与审批通过前复检。 diff --git a/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/specs/agent-distribution-withdrawal/spec.md b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/specs/agent-distribution-withdrawal/spec.md new file mode 100644 index 0000000..21efebc --- /dev/null +++ b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/specs/agent-distribution-withdrawal/spec.md @@ -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** 系统至多创建一次店铺、账号、钱包与层级关系 diff --git a/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/tasks.md b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/tasks.md new file mode 100644 index 0000000..b748b51 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-fix-agent-distribution-registration-duplicate-guard/tasks.md @@ -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` 记录遗留;本次为测试环境数据清理,不改写迁移、不新增审计入口。 diff --git a/openspec/specs/agent-distribution-withdrawal/spec.md b/openspec/specs/agent-distribution-withdrawal/spec.md index bf65e46..4873074 100644 --- a/openspec/specs/agent-distribution-withdrawal/spec.md +++ b/openspec/specs/agent-distribution-withdrawal/spec.md @@ -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: 重复回调不重复建层级