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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user