diff --git a/internal/application/distributionwithdrawal/audit.go b/internal/application/distributionwithdrawal/audit.go index 8f6a5f6..338c55f 100644 --- a/internal/application/distributionwithdrawal/audit.go +++ b/internal/application/distributionwithdrawal/audit.go @@ -17,10 +17,12 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/errors" ) -// VerificationCodeVerifier 是公开扫码注册复用的短信验证码校验接缝。 -// 校验成功即消费验证码,同一验证码不可二次使用。 +// VerificationCodeVerifier 是公开扫码注册复用的短信验证码接缝。 +// CheckCode 只校验不消费;业务事实落库成功后再由 ConsumeCode 原子消费, +// 使落库前的任何失败都不会消费验证码,重试无需重新获取短信验证码。 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 描述分销注册、提现资格与提现审批事实的实际变化。 diff --git a/internal/application/distributionwithdrawal/registration.go b/internal/application/distributionwithdrawal/registration.go index d1cfd3c..68949fb 100644 --- a/internal/application/distributionwithdrawal/registration.go +++ b/internal/application/distributionwithdrawal/registration.go @@ -2,6 +2,7 @@ package distributionwithdrawal import ( "context" + stderrors "errors" "strconv" "strings" @@ -45,7 +46,8 @@ func NewRegistrationService( } // Register 创建待审批注册记录。 -// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库。 +// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。 +// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。 // 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。 func (s *RegistrationService) Register( ctx context.Context, @@ -61,24 +63,19 @@ func (s *RegistrationService) Register( } code = strings.TrimSpace(code) if code == "" { - return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") + return nil, errors.New(errors.CodeInvalidParam, "短信验证码不能为空") } passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败") } - var parent *model.Shop - if err := s.db.WithContext(ctx). - Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled). - First(&parent).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") - } - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败") + parent, err := s.findDistributionParent(ctx, normalized.DistributionCode) + if err != nil { + return nil, err } - // 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果。 - if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil { - return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") + // 只校验不消费:验证码在注册记录与审批实例落库成功后才消费,落库前的失败不消耗验证码。 + if err := s.verifier.CheckCode(ctx, normalized.Phone, code); err != nil { + return nil, verificationFailure(err) } submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID) if err != nil { @@ -132,9 +129,39 @@ func (s *RegistrationService) Register( if err != nil { return nil, err } + // 注册记录与审批实例已落库:此后消费验证码失败只记录日志,不回滚既有事实,也不改变对外成功结果。 + // 消费失败(验证码已过期或已被并发的另一次提交消费)由验证码实现侧记录。 + _ = s.verifier.ConsumeCode(ctx, normalized.Phone, code) 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 解析扫码注册的审批发起身份。 // 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体; // 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。 @@ -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). First(&account).Error; err != nil { if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") + return nil, errors.New(errors.CodeInvalidStatus, "上级店铺未配置启用的主账号,请联系平台处理") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败") } diff --git a/internal/domain/distribution/distribution.go b/internal/domain/distribution/distribution.go index 1db770e..c721b69 100644 --- a/internal/domain/distribution/distribution.go +++ b/internal/domain/distribution/distribution.go @@ -43,9 +43,20 @@ func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, erro input.City = strings.TrimSpace(input.City) input.District = strings.TrimSpace(input.District) input.Address = strings.TrimSpace(input.Address) - if input.DistributionCode == "" || input.Phone == "" || input.Username == "" || - input.ShopName == "" || input.ShopCode == "" { - return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不可用") + if input.DistributionCode == "" { + 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 { return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确") diff --git a/internal/handler/app/agent_distribution.go b/internal/handler/app/agent_distribution.go index 579e591..727d1b3 100644 --- a/internal/handler/app/agent_distribution.go +++ b/internal/handler/app/agent_distribution.go @@ -6,6 +6,7 @@ import ( distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" 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/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -29,7 +30,8 @@ func NewAgentDistributionHandler( // RegisterAgentDistribution 提交代理扫码注册 // 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, "代理分销注册能力尚未配置") @@ -42,7 +44,7 @@ func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置") } 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{ DistributionCode: req.DistributionCode, diff --git a/internal/routes/agent_distribution_public.go b/internal/routes/agent_distribution_public.go index 96f6529..1ee5c4f 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/internal/service/verification/service.go b/internal/service/verification/service.go index e97cb3c..dc3d332 100644 --- a/internal/service/verification/service.go +++ b/internal/service/verification/service.go @@ -118,12 +118,20 @@ func (s *Service) SendCode(ctx context.Context, phone string) error { return nil } -// VerifyCode 验证验证码 -func (s *Service) VerifyCode(ctx context.Context, phone string, code string) error { - codeKey := constants.RedisVerificationCodeKey(phone) +// verificationCodeConsumeScript 原子消费验证码:仅当键值仍等于已校验通过的验证码时删除。 +// 并发的两次提交只有一次能消费成功,验证码不会被重复使用。 +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 获取验证码 - storedCode, err := s.redisClient.Get(ctx, codeKey).Result() + storedCode, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeKey(phone)).Result() if err == redis.Nil { s.logger.Warn("验证码不存在或已过期", 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, "验证码错误") } - // 验证成功,删除验证码(防止重复使用) - err = s.redisClient.Del(ctx, codeKey).Err() + return nil +} + +// 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 { - s.logger.Error("删除验证码失败", + s.logger.Error("消费验证码失败", zap.String("phone", phone), 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("验证码验证成功", zap.String("phone", phone), diff --git a/openspec/specs/agent-distribution-withdrawal/spec.md b/openspec/specs/agent-distribution-withdrawal/spec.md index 56af14c..bf65e46 100644 --- a/openspec/specs/agent-distribution-withdrawal/spec.md +++ b/openspec/specs/agent-distribution-withdrawal/spec.md @@ -7,9 +7,9 @@ 系统 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 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。 @@ -30,6 +30,11 @@ - **WHEN** 请求携带的短信验证码无效、过期或已被消费 - **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额 +#### Scenario: 落库前失败不消耗验证码 + +- **WHEN** 短信验证码校验通过后,注册记录或审批实例创建失败 +- **THEN** 系统保留该验证码,客户可用同一验证码重试,重试得到的仍是同一失败原因而非验证码失效 + #### Scenario: 审批通过建立层级与业务员快照 - **WHEN** 企业微信最终通过一条待审批注册记录