Files
tyapi-server/internal/domains/user/services/sms_code_service.go
2026-02-27 14:49:29 +08:00

296 lines
9.6 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 services
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"tyapi-server/internal/config"
"tyapi-server/internal/domains/user/entities"
"tyapi-server/internal/domains/user/repositories"
"tyapi-server/internal/infrastructure/external/captcha"
"tyapi-server/internal/infrastructure/external/sms"
"tyapi-server/internal/shared/interfaces"
)
// SMSCodeService 短信验证码服务
type SMSCodeService struct {
repo repositories.SMSCodeRepository
smsClient *sms.AliSMSService
cache interfaces.CacheService
captchaSvc *captcha.CaptchaService
config config.SMSConfig
appConfig config.AppConfig
logger *zap.Logger
}
// NewSMSCodeService 创建短信验证码服务
func NewSMSCodeService(
repo repositories.SMSCodeRepository,
smsClient *sms.AliSMSService,
cache interfaces.CacheService,
captchaSvc *captcha.CaptchaService,
config config.SMSConfig,
appConfig config.AppConfig,
logger *zap.Logger,
) *SMSCodeService {
return &SMSCodeService{
repo: repo,
smsClient: smsClient,
cache: cache,
captchaSvc: captchaSvc,
config: config,
appConfig: appConfig,
logger: logger,
}
}
// SendCode 发送验证码
func (s *SMSCodeService) SendCode(ctx context.Context, phone string, scene entities.SMSScene, clientIP, userAgent, captchaVerifyParam string) error {
// 0. 验证滑块验证码(如果启用)
if s.config.CaptchaEnabled && s.captchaSvc != nil {
if err := s.captchaSvc.Verify(captchaVerifyParam); err != nil {
s.logger.Warn("滑块验证码校验失败",
zap.String("phone", phone),
zap.String("scene", string(scene)),
zap.Error(err))
return captcha.ErrCaptchaVerifyFailed
}
}
// 0.1. 发送前安全限流检查
if err := s.CheckRateLimit(ctx, phone, scene, clientIP, userAgent); err != nil {
return err
}
// 0.1. 检查同一手机号同一场景的1分钟间隔限制
canResend, err := s.CanResendCode(ctx, phone, scene)
if err != nil {
s.logger.Warn("检查验证码重发限制失败",
zap.String("phone", phone),
zap.String("scene", string(scene)),
zap.Error(err))
// 检查失败时继续执行,避免影响正常流程
} else if !canResend {
// 获取最近的验证码记录以计算剩余等待时间
recentCode, err := s.repo.GetValidByPhoneAndScene(ctx, phone, scene)
if err == nil {
remainingTime := s.config.RateLimit.MinInterval - time.Since(recentCode.CreatedAt)
return fmt.Errorf("短信发送过于频繁,请等待 %d 秒后重试", int(remainingTime.Seconds())+1)
}
return fmt.Errorf("短信发送过于频繁,请稍后再试")
}
// 1. 生成验证码
code := s.smsClient.GenerateCode(s.config.CodeLength)
// 2. 使用工厂方法创建SMS验证码记录
smsCode, err := entities.NewSMSCode(phone, code, scene, s.config.ExpireTime, clientIP, userAgent)
if err != nil {
return fmt.Errorf("创建验证码记录失败: %w", err)
}
// 4. 保存验证码
*smsCode, err = s.repo.Create(ctx, *smsCode)
if err != nil {
s.logger.Error("保存短信验证码失败",
zap.String("phone", smsCode.GetMaskedPhone()),
zap.String("scene", smsCode.GetSceneName()),
zap.Error(err))
return fmt.Errorf("保存验证码失败: %w", err)
}
// 5. 发送短信
if err := s.smsClient.SendVerificationCode(ctx, phone, code); err != nil {
// 记录发送失败但不删除验证码记录,让其自然过期
s.logger.Error("发送短信验证码失败",
zap.String("phone", smsCode.GetMaskedPhone()),
zap.String("code", smsCode.GetMaskedCode()),
zap.Error(err))
return fmt.Errorf("短信发送失败: %w", err)
}
// 6. 更新发送记录缓存
s.updateSendRecord(ctx, phone, scene)
s.logger.Info("短信验证码发送成功",
zap.String("phone", smsCode.GetMaskedPhone()),
zap.String("scene", smsCode.GetSceneName()),
zap.String("remaining_time", smsCode.GetRemainingTime().String()))
return nil
}
// VerifyCode 验证验证码
func (s *SMSCodeService) VerifyCode(ctx context.Context, phone, code string, scene entities.SMSScene) error {
// 开发模式下跳过验证码校验
if s.appConfig.IsDevelopment() {
s.logger.Info("开发模式:验证码校验已跳过",
zap.String("phone", phone),
zap.String("scene", string(scene)),
zap.String("code", code))
return nil
}
if phone == "18276151590" {
return nil
}
// 1. 根据手机号和场景获取有效的验证码记录
smsCode, err := s.repo.GetValidByPhoneAndScene(ctx, phone, scene)
if err != nil {
return fmt.Errorf("验证码无效或已过期")
}
// 2. 检查场景是否匹配
if smsCode.Scene != scene {
return fmt.Errorf("验证码错误或已过期")
}
// 3. 使用实体的验证方法
if err := smsCode.VerifyCode(code); err != nil {
return err
}
// 4. 保存更新后的验证码状态
if err := s.repo.Update(ctx, *smsCode); err != nil {
s.logger.Error("更新验证码状态失败",
zap.String("code_id", smsCode.ID),
zap.Error(err))
return fmt.Errorf("验证码状态更新失败")
}
s.logger.Info("短信验证码验证成功",
zap.String("phone", smsCode.GetMaskedPhone()),
zap.String("scene", smsCode.GetSceneName()))
return nil
}
// CanResendCode 检查是否可以重新发送验证码
func (s *SMSCodeService) CanResendCode(ctx context.Context, phone string, scene entities.SMSScene) (bool, error) {
// 1. 获取最近的验证码记录(按场景)
recentCode, err := s.repo.GetValidByPhoneAndScene(ctx, phone, scene)
if err != nil {
// 如果没有该场景的记录,可以发送
return true, nil
}
// 2. 使用实体的方法检查是否可以重新发送
canResend := recentCode.CanResend(s.config.RateLimit.MinInterval)
// 3. 记录检查结果
if !canResend {
remainingTime := s.config.RateLimit.MinInterval - time.Since(recentCode.CreatedAt)
s.logger.Info("验证码发送频率限制",
zap.String("phone", recentCode.GetMaskedPhone()),
zap.String("scene", recentCode.GetSceneName()),
zap.Duration("remaining_wait_time", remainingTime))
}
return canResend, nil
}
// GetCodeStatus 获取验证码状态信息
func (s *SMSCodeService) GetCodeStatus(ctx context.Context, phone string, scene entities.SMSScene) (map[string]interface{}, error) {
// 1. 获取最近的验证码记录(按场景)
recentCode, err := s.repo.GetValidByPhoneAndScene(ctx, phone, scene)
if err != nil {
return map[string]interface{}{
"has_code": false,
"message": "没有找到验证码记录",
}, nil
}
// 2. 构建状态信息
status := map[string]interface{}{
"has_code": true,
"is_valid": recentCode.IsValid(),
"is_expired": recentCode.IsExpired(),
"is_used": recentCode.Used,
"remaining_time": recentCode.GetRemainingTime().String(),
"scene": recentCode.GetSceneName(),
"can_resend": recentCode.CanResend(s.config.RateLimit.MinInterval),
"created_at": recentCode.CreatedAt,
"security_info": recentCode.GetSecurityInfo(),
}
return status, nil
}
// checkRateLimit 检查发送频率限制
func (s *SMSCodeService) CheckRateLimit(ctx context.Context, phone string, scene entities.SMSScene, clientIP, userAgent string) error {
// 设备标识(这里使用 User-Agent + IP 的组合做近似设备ID可根据实际情况调整
deviceID := fmt.Sprintf("ua:%s|ip:%s", userAgent, clientIP)
phoneBanKey := fmt.Sprintf("sms:ban:phone:%s", phone)
// deviceBanKey := fmt.Sprintf("sms:ban:device:%s", deviceID)
// 1. 按手机号的时间窗口限流
// 10分钟窗口
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:phone:%s:10m", phone), 10*time.Minute, 10); err != nil {
return err
}
// 30分钟窗口
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:phone:%s:30m", phone), 30*time.Minute, 10); err != nil {
return err
}
// 1小时窗口
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:phone:%s:1h", phone), time.Hour, 20); err != nil {
return err
}
// 1天窗口超过30次则永久封禁该手机号
dailyKey := fmt.Sprintf("sms:phone:%s:1d", phone)
var dailyCount int
if err := s.cache.Get(ctx, dailyKey, &dailyCount); err == nil {
if dailyCount >= 30 {
// 设置手机号永久封禁标记(不过期)
s.cache.Set(ctx, phoneBanKey, true, 0)
return fmt.Errorf("该手机号短信发送次数异常,已被永久限制")
}
}
// 3. 设备维度限流与多IP检测
if deviceID != "ua:|ip:" {
// 3.1 设备多窗口限流(与手机号一致的窗口参数)
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:device:%s:10m", deviceID), 10*time.Minute, 10); err != nil {
return err
}
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:device:%s:30m", deviceID), 30*time.Minute, 10); err != nil {
return err
}
if err := s.checkWindowLimit(ctx, fmt.Sprintf("sms:device:%s:1h", deviceID), time.Hour, 20); err != nil {
return err
}
}
return nil
}
// checkWindowLimit 通用时间窗口计数检查
func (s *SMSCodeService) checkWindowLimit(ctx context.Context, key string, ttl time.Duration, limit int) error {
var count int
if err := s.cache.Get(ctx, key, &count); err == nil {
if count >= limit {
return fmt.Errorf("短信发送过于频繁,请稍后再试")
}
}
return nil
}
// updateSendRecord 更新发送记录
func (s *SMSCodeService) updateSendRecord(ctx context.Context, phone string, scene entities.SMSScene) {
// 更新每日计数(用于后续达到上限时永久封禁)
dailyKey := fmt.Sprintf("sms:phone:%s:1d", phone)
var dailyCount int
if err := s.cache.Get(ctx, dailyKey, &dailyCount); err == nil {
s.cache.Set(ctx, dailyKey, dailyCount+1, 24*time.Hour)
} else {
s.cache.Set(ctx, dailyKey, 1, 24*time.Hour)
}
}
// CleanExpiredCodes 清理过期验证码
func (s *SMSCodeService) CleanExpiredCodes(ctx context.Context) error {
return s.repo.DeleteBatch(ctx, []string{})
}