Files
junhong_cmp_fiber/internal/service/verification/service.go
break e8ab1f471e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m54s
fix(代理分销注册): 验证码改为落库成功后消费并细化失败原因
公开扫码注册原先在审批准备前就消费短信验证码,落库前的任何失败都会烧掉验证码,
客户重试只能得到统一的“分销码不可用”,掩盖了真实失败原因。

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

214 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package verification 提供验证码管理的业务逻辑服务
// 包含短信验证码生成、发送、验证等功能
package verification
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/sms"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// Service 验证码服务
type Service struct {
redisClient *redis.Client
smsClient *sms.Client
logger *zap.Logger
}
// NewService 创建验证码服务实例
func NewService(redisClient *redis.Client, smsClient *sms.Client, logger *zap.Logger) *Service {
return &Service{
redisClient: redisClient,
smsClient: smsClient,
logger: logger,
}
}
// SendCode 发送验证码
func (s *Service) SendCode(ctx context.Context, phone string) error {
// 检查短信服务是否可用
if s.smsClient == nil {
s.logger.Error("短信服务未配置", zap.String("phone", phone))
return errors.New(errors.CodeServiceUnavailable)
}
// 检查发送频率限制
limitKey := constants.RedisVerificationCodeLimitKey(phone)
exists, err := s.redisClient.Exists(ctx, limitKey).Result()
if err != nil {
s.logger.Error("检查验证码发送频率限制失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "检查验证码发送频率限制失败")
}
if exists > 0 {
s.logger.Warn("验证码发送过于频繁",
zap.String("phone", phone),
)
return errors.New(errors.CodeTooManyRequests, "验证码发送过于频繁,请稍后再试")
}
// 生成随机验证码
code, err := s.generateCode()
if err != nil {
s.logger.Error("生成验证码失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "生成验证码失败")
}
// 构造短信内容
cfg := config.Get()
content := fmt.Sprintf("您的验证码是%s%d分钟内有效", code, int(constants.VerificationCodeExpiration.Minutes()))
// 发送短信
_, err = s.smsClient.SendMessage(ctx, content, []string{phone})
if err != nil {
s.logger.Error("发送验证码短信失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "发送验证码短信失败")
}
// 存储验证码到 Redis
codeKey := constants.RedisVerificationCodeKey(phone)
err = s.redisClient.Set(ctx, codeKey, code, constants.VerificationCodeExpiration).Err()
if err != nil {
s.logger.Error("存储验证码失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "存储验证码失败")
}
// 设置发送频率限制
err = s.redisClient.Set(ctx, limitKey, "1", constants.VerificationCodeRateLimit).Err()
if err != nil {
s.logger.Error("设置验证码发送频率限制失败",
zap.String("phone", phone),
zap.Error(err),
)
// 这个错误不影响主流程,只记录日志
}
s.logger.Info("验证码发送成功",
zap.String("phone", phone),
)
// 避免在日志中暴露验证码(仅在开发环境下记录)
if cfg.Logging.Development {
s.logger.Debug("验证码内容(仅开发环境)",
zap.String("phone", phone),
zap.String("code", code),
)
}
return nil
}
// 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, constants.RedisVerificationCodeKey(phone)).Result()
if err == redis.Nil {
s.logger.Warn("验证码不存在或已过期",
zap.String("phone", phone),
)
return errors.New(errors.CodeInvalidParam, "验证码不存在或已过期")
}
if err != nil {
s.logger.Error("获取验证码失败",
zap.String("phone", phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "获取验证码失败")
}
// 验证码比对
if storedCode != code {
s.logger.Warn("验证码错误",
zap.String("phone", phone),
)
return errors.New(errors.CodeInvalidParam, "验证码错误")
}
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("消费验证码失败",
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),
)
return nil
}
// generateCode 生成随机验证码
func (s *Service) generateCode() (string, error) {
// 生成 6 位数字验证码
const digits = "0123456789"
code := make([]byte, constants.VerificationCodeLength)
for i := range code {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
if err != nil {
return "", err
}
code[i] = digits[num.Int64()]
}
return string(code), nil
}