Files
junhong_cmp_fiber/internal/service/verification/service.go
邹进 adcd4f5e9d feat: 调整验证码短信文案
改为「您的验证码是XXXXXX,请在5分钟内输入验证。如非本人操作,请忽略本短信。」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:56:22 +09:00

297 lines
10 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"
"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
}