fix(代理分销注册): 验证码改为落库成功后消费并细化失败原因
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m54s

公开扫码注册原先在审批准备前就消费短信验证码,落库前的任何失败都会烧掉验证码,
客户重试只能得到统一的“分销码不可用”,掩盖了真实失败原因。

- 验证码校验与消费拆分为 CheckCode 与 ConsumeCode,注册记录与审批实例落库成功后才原子消费;
  校验不消费、消费一次性,落库前失败时同一验证码可直接重试
- 分销码无效、所属店铺已停用、上级店铺缺少启用的主账号、验证码错误分别返回各自错误码与提示
- 注册必填字段缺失与请求参数校验失败提示定位到具体字段
- 同步公开接口描述与 agent-distribution-withdrawal 主 Spec 行为契约
This commit is contained in:
2026-09-17 18:01:47 +08:00
parent 398a5e4282
commit e8ab1f471e
7 changed files with 112 additions and 33 deletions

View File

@@ -17,10 +17,12 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
) )
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码校验接缝。 // VerificationCodeVerifier 是公开扫码注册复用的短信验证码接缝。
// 校验成功即消费验证码,同一验证码不可二次使用。 // CheckCode 只校验不消费;业务事实落库成功后再由 ConsumeCode 原子消费,
// 使落库前的任何失败都不会消费验证码,重试无需重新获取短信验证码。
type VerificationCodeVerifier interface { type VerificationCodeVerifier interface {
VerifyCode(ctx context.Context, phone string, code string) error CheckCode(ctx context.Context, phone string, code string) error
ConsumeCode(ctx context.Context, phone string, code string) error
} }
// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。 // AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。

View File

@@ -2,6 +2,7 @@ package distributionwithdrawal
import ( import (
"context" "context"
stderrors "errors"
"strconv" "strconv"
"strings" "strings"
@@ -45,7 +46,8 @@ func NewRegistrationService(
} }
// Register 创建待审批注册记录。 // Register 创建待审批注册记录。
// 无效分销码停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库 // 分销码无效、上级店铺停用上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示
// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。
// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。 // 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。
func (s *RegistrationService) Register( func (s *RegistrationService) Register(
ctx context.Context, ctx context.Context,
@@ -61,24 +63,19 @@ func (s *RegistrationService) Register(
} }
code = strings.TrimSpace(code) code = strings.TrimSpace(code)
if code == "" { if code == "" {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") return nil, errors.New(errors.CodeInvalidParam, "短信验证码不能为空")
} }
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost) passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败") return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
} }
var parent *model.Shop parent, err := s.findDistributionParent(ctx, normalized.DistributionCode)
if err := s.db.WithContext(ctx). if err != nil {
Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled). return nil, err
First(&parent).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
} }
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败") // 只校验不消费:验证码在注册记录与审批实例落库成功后才消费,落库前的失败不消耗验证码。
} if err := s.verifier.CheckCode(ctx, normalized.Phone, code); err != nil {
// 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果。 return nil, verificationFailure(err)
if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
} }
submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID) submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID)
if err != nil { if err != nil {
@@ -132,9 +129,39 @@ func (s *RegistrationService) Register(
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 注册记录与审批实例已落库:此后消费验证码失败只记录日志,不回滚既有事实,也不改变对外成功结果。
// 消费失败(验证码已过期或已被并发的另一次提交消费)由验证码实现侧记录。
_ = s.verifier.ConsumeCode(ctx, normalized.Phone, code)
return result, nil return result, nil
} }
// findDistributionParent 按分销码定位上级店铺;未命中与已停用返回各自的可定位错误。
// 软删除店铺不参与匹配,与店铺唯一索引的生效范围一致。
func (s *RegistrationService) findDistributionParent(ctx context.Context, distributionCode string) (*model.Shop, error) {
var parent model.Shop
if err := s.db.WithContext(ctx).
Where("distribution_code = ?", distributionCode).
First(&parent).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码无效")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
}
if parent.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "分销码所属店铺已停用")
}
return &parent, nil
}
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
func verificationFailure(err error) error {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
}
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。 // resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体; // 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。 // 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
@@ -144,7 +171,7 @@ func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled). Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
First(&account).Error; err != nil { First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") return nil, errors.New(errors.CodeInvalidStatus, "上级店铺未配置启用的主账号,请联系平台处理")
} }
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败") return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
} }

View File

@@ -43,9 +43,20 @@ func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, erro
input.City = strings.TrimSpace(input.City) input.City = strings.TrimSpace(input.City)
input.District = strings.TrimSpace(input.District) input.District = strings.TrimSpace(input.District)
input.Address = strings.TrimSpace(input.Address) input.Address = strings.TrimSpace(input.Address)
if input.DistributionCode == "" || input.Phone == "" || input.Username == "" || if input.DistributionCode == "" {
input.ShopName == "" || input.ShopCode == "" { return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不能为空")
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不可用") }
if input.Phone == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号不能为空")
}
if input.Username == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名不能为空")
}
if input.ShopName == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺名称不能为空")
}
if input.ShopCode == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺编号不能为空")
} }
if len(input.Phone) != 11 { if len(input.Phone) != 11 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确") return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确")

View File

@@ -6,6 +6,7 @@ import (
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -29,7 +30,8 @@ func NewAgentDistributionHandler(
// RegisterAgentDistribution 提交代理扫码注册 // RegisterAgentDistribution 提交代理扫码注册
// POST /api/c/v1/agent-distribution-registrations // POST /api/c/v1/agent-distribution-registrations
// 无需认证、JWT、角色或权限只创建待审批注册记录不返回任何账号凭证。 // 无需认证、JWT、角色或权限只创建待审批注册记录不返回任何账号凭证。
// 无效分销码停用上级、验证码无效或已消费统一返回“分销码不可用”且不落库 // 分销码无效、上级店铺停用上级店铺缺少启用的主账号、短信验证码无效分别返回各自提示且不落库
// 短信验证码在注册记录落库成功后消费,落库前的失败不消耗验证码。
func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error { func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error {
if h.service == nil { if h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置") return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
@@ -42,7 +44,7 @@ func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error
return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置") return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置")
} }
if err := h.validator.Struct(&req); err != nil { if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "注册参数不合法") return errors.New(errors.CodeInvalidParam, validation.Message("注册参数不合法", &req, err))
} }
result, err := h.service.Register(c.UserContext(), distributiondomain.RegistrationInput{ result, err := h.service.Register(c.UserContext(), distributiondomain.RegistrationInput{
DistributionCode: req.DistributionCode, DistributionCode: req.DistributionCode,

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{ Register(router, doc, basePath, "POST", "/agent-distribution-registrations", handler.RegisterAgentDistribution, RouteSpec{
Summary: "代理扫码注册", Summary: "代理扫码注册",
Description: "公开接口,无需认证。请求必须携带有效分销码、短信已验证手机号与密码;无效分销码、分销码所属店铺已停用、验证码无效或已被消费统一返回“分销码不可用”,且不创建注册记录、店铺或账号。通过后仍需企业微信终审才会创建店铺与代理账号。", Description: "公开接口,无需认证。请求必须携带有效分销码、短信已验证手机号与密码;分销码无效、分销码所属店铺已停用、上级店铺缺少启用的主账号、短信验证码无效或已被消费分别返回各自提示,且不创建注册记录、店铺或账号。短信验证码只在注册记录落库成功后消费,落库前的失败不消耗验证码,可用同一验证码直接重试。通过后仍需企业微信终审才会创建店铺与代理账号。",
Tags: []string{"个人客户 - 代理分销注册"}, Tags: []string{"个人客户 - 代理分销注册"},
Auth: false, Auth: false,
Input: new(dto.CreateAgentDistributionRegistrationReq), Input: new(dto.CreateAgentDistributionRegistrationReq),

View File

@@ -118,12 +118,20 @@ func (s *Service) SendCode(ctx context.Context, phone string) error {
return nil return nil
} }
// VerifyCode 验证验证码 // verificationCodeConsumeScript 原子消费验证码:仅当键值仍等于已校验通过的验证码时删除。
func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error { // 并发的两次提交只有一次能消费成功,验证码不会被重复使用。
codeKey := constants.RedisVerificationCodeKey(phone) var verificationCodeConsumeScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`)
// CheckCode 校验验证码但不消费。
// 供消费必须晚于业务事实落库的链路复用;不消费时验证码仍受原有 5 分钟有效期约束。
func (s *Service) CheckCode(ctx context.Context, phone string, code string) error {
// 从 Redis 获取验证码 // 从 Redis 获取验证码
storedCode, err := s.redisClient.Get(ctx, codeKey).Result() storedCode, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeKey(phone)).Result()
if err == redis.Nil { if err == redis.Nil {
s.logger.Warn("验证码不存在或已过期", s.logger.Warn("验证码不存在或已过期",
zap.String("phone", phone), zap.String("phone", phone),
@@ -146,15 +154,39 @@ func (s *Service) VerifyCode(ctx context.Context, phone string, code string) err
return errors.New(errors.CodeInvalidParam, "验证码错误") return errors.New(errors.CodeInvalidParam, "验证码错误")
} }
// 验证成功,删除验证码(防止重复使用) return nil
err = s.redisClient.Del(ctx, codeKey).Err() }
// ConsumeCode 消费已校验通过的验证码;验证码已失效或已被其它请求消费时返回错误并记录日志。
func (s *Service) ConsumeCode(ctx context.Context, phone string, code string) error {
consumed, err := verificationCodeConsumeScript.Run(
ctx, s.redisClient, []string{constants.RedisVerificationCodeKey(phone)}, code,
).Int64()
if err != nil { if err != nil {
s.logger.Error("删除验证码失败", s.logger.Error("消费验证码失败",
zap.String("phone", phone), zap.String("phone", phone),
zap.Error(err), zap.Error(err),
) )
// 这个错误不影响主流程,只记录日志 return errors.Wrap(errors.CodeInternalError, err, "消费验证码失败")
} }
if consumed == 0 {
s.logger.Warn("验证码已失效或已被消费",
zap.String("phone", phone),
)
return errors.New(errors.CodeInvalidParam, "验证码已失效或已被消费")
}
return nil
}
// VerifyCode 验证并消费验证码,校验成功即消费。
// 仅适用于失败后必须重新获取验证码的链路;失败可能晚于消费的链路改用 CheckCode 与 ConsumeCode。
func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error {
if err := s.CheckCode(ctx, phone, code); err != nil {
return err
}
// 消费失败不影响主流程ConsumeCode 内部已记录日志,与既有链路保持一致。
_ = s.ConsumeCode(ctx, phone, code)
s.logger.Info("验证码验证成功", s.logger.Info("验证码验证成功",
zap.String("phone", phone), zap.String("phone", phone),

View File

@@ -7,9 +7,9 @@
系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。 系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。
系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验、消费与限流规则。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。 系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验与限流规则,并 MUST 保证同一验证码至多被消费一次。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。
无效分销码、分销码所属店铺已停用、验证码无效或已被消费时,系统 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 NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。
@@ -30,6 +30,11 @@
- **WHEN** 请求携带的短信验证码无效、过期或已被消费 - **WHEN** 请求携带的短信验证码无效、过期或已被消费
- **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额 - **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额
#### Scenario: 落库前失败不消耗验证码
- **WHEN** 短信验证码校验通过后,注册记录或审批实例创建失败
- **THEN** 系统保留该验证码,客户可用同一验证码重试,重试得到的仍是同一失败原因而非验证码失效
#### Scenario: 审批通过建立层级与业务员快照 #### Scenario: 审批通过建立层级与业务员快照
- **WHEN** 企业微信最终通过一条待审批注册记录 - **WHEN** 企业微信最终通过一条待审批注册记录