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{}) }