// Package verification 提供验证码管理的业务逻辑服务 // 包含短信验证码生成、发送、验证等功能 package verification import ( "context" "crypto/rand" "fmt" "math/big" "strconv" "time" "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 `) // verificationFailureWriteTimeout 是失败计数写入的独立超时:写入与请求生命周期解耦,避免客户端断开绕过计数。 const verificationFailureWriteTimeout = 5 * time.Second // verificationFailureCountScript 原子累加手机号维度的校验失败计数,并在同一步刷新计数窗口。 // INCR 与 PEXPIRE 必须在同一脚本内完成:拆成 SETNX 再 INCR 会让并发提交在两次调用之间丢失过期时间, // 计数键永久驻留即等于手机号被永久锁定;同时 INCR 本身原子,并发提交不会丢失一次失败计数。 // 每次失败都刷新窗口(滑动窗口):锁定期间的提交在比对前即被拒绝且不再计数,因此锁定不会续期, // 锁定上限为一个窗口,到期自动恢复。 var verificationFailureCountScript = redis.NewScript(` local count = redis.call('INCR', KEYS[1]) redis.call('PEXPIRE', KEYS[1], ARGV[1]) return count `) // verificationFailureCount 读取手机号当前窗口内的校验失败计数;键不存在表示窗口内尚未失败。 func (s *Service) verificationFailureCount(ctx context.Context, phone string) (int64, error) { value, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeFailKey(phone)).Result() if err == redis.Nil { return 0, nil } if err != nil { return 0, err } count, err := strconv.ParseInt(value, 10, 64) if err != nil { // 计数键被外部写入非数字值:按窗口内无计数处理并记录,不作为限流依据。 return 0, fmt.Errorf("校验失败计数取值非法: %w", err) } return count, nil } // recordVerificationFailure 累加一次校验失败;计数写入失败只记录并放行,不改变本次校验结果。 func (s *Service) recordVerificationFailure(ctx context.Context, phone string) { // 计数写入不随请求上下文取消而丢失:客户端提前断开不应成为绕过失败次数限制的通道。 countCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), verificationFailureWriteTimeout) defer cancel() _, err := verificationFailureCountScript.Run( countCtx, s.redisClient, []string{constants.RedisVerificationCodeFailKey(phone)}, constants.VerificationCodeFailureWindow.Milliseconds(), ).Int64() if err != nil { s.logger.Error("累加验证码校验失败计数失败,按放行处理", zap.String("phone", phone), zap.Error(err), ) } } // clearVerificationFailures 在校验成功后清零手机号的失败计数;清零失败只记录,不影响校验成功结果。 func (s *Service) clearVerificationFailures(ctx context.Context, phone string) { if err := s.redisClient.Del(ctx, constants.RedisVerificationCodeFailKey(phone)).Err(); err != nil { s.logger.Error("清零验证码校验失败计数失败,按成功放行", zap.String("phone", phone), zap.Error(err), ) } } // CheckCode 校验验证码但不消费。 // 供消费必须晚于业务事实落库的链路复用;不消费时验证码仍受原有 5 分钟有效期约束。 // 失败次数限制在验证码比对之前生效:窗口内失败达到上限后,锁定期间的校验一律拒绝(即使验证码正确), // 锁定随窗口到期自动解除;窗口内校验成功清零计数;校验失败不消费验证码。 // 计数读写故障一律放行并记录,MUST NOT 因限流计数故障阻断注册、绑定、换绑、换证与登录的成功路径。 func (s *Service) CheckCode(ctx context.Context, phone string, code string) error { failureCount, err := s.verificationFailureCount(ctx, phone) if err != nil { s.logger.Error("读取验证码校验失败计数失败,按放行处理", zap.String("phone", phone), zap.Error(err), ) } if failureCount >= constants.VerificationCodeMaxFailures { s.logger.Warn("验证码校验失败次数达到上限,窗口内拒绝校验", zap.String("phone", phone), ) // 提示不含验证码正确性、剩余失败次数与内部键名。 return errors.New(errors.CodeTooManyRequests, constants.VerificationCodeLockedMessage) } // 从 Redis 获取验证码 storedCode, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeKey(phone)).Result() if err == redis.Nil { s.logger.Warn("验证码不存在或已过期", zap.String("phone", phone), ) // 已无可用验证码的提交同样是校验失败:计入失败次数,但不产生任何业务事实。 s.recordVerificationFailure(ctx, 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), ) s.recordVerificationFailure(ctx, phone) return errors.New(errors.CodeInvalidParam, "验证码错误") } s.clearVerificationFailures(ctx, phone) 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 }