fix(代理分销注册): 校验手机号/用户名/店铺编号唯一性并支持驳回后重注册
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:
2026-09-17 19:02:14 +08:00
parent 62419d4b17
commit 6333f4ad13
11 changed files with 333 additions and 9 deletions

View File

@@ -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) {

View File

@@ -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

View File

@@ -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, "代理分销注册能力尚未配置")

View File

@@ -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"`

View File

@@ -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),