package service import ( "context" "crypto/rand" "errors" "fmt" "math/big" "time" dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20180501/v2/client" "github.com/alibabacloud-go/darabonba-openapi/v2/client" "github.com/alibabacloud-go/tea/tea" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/services/userService/config" "go.uber.org/zap" ) var smsClient *dysmsapi20170525.Client // InitSMSClient initializes the SMS client as a singleton func InitSMSClient() error { cfg := config.GetSMSConfig() // If credentials are not set, skip initialization if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" { logger.Logger.Warn("SMS credentials not configured, SMS client not initialized") return nil } openapiConfig := &client.Config{ AccessKeyId: tea.String(cfg.AccessKeyID), AccessKeySecret: tea.String(cfg.AccessKeySecret), } openapiConfig.Endpoint = tea.String("dysmsapi.aliyuncs.com") var err error smsClient, err = dysmsapi20170525.NewClient(openapiConfig) if err != nil { return fmt.Errorf("failed to initialize SMS client: %w", err) } logger.Logger.Info("SMS client initialized successfully") return nil } // generateVerifyToken generates a verify token with format "vtf_" + 29 random chars func generateVerifyToken() (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const tokenLen = 29 b := make([]byte, tokenLen) charsLen := big.NewInt(int64(len(charset))) for i := range b { n, err := rand.Int(rand.Reader, charsLen) if err != nil { return "", fmt.Errorf("failed to generate random token: %w", err) } b[i] = charset[n.Int64()] } return "vtf_" + string(b), nil } // maskMobile masks the middle digits of a mobile number // Example: 13812345678 -> 138****5678 func maskMobile(mobile string) string { if len(mobile) < 7 { return mobile } return mobile[:3] + "****" + mobile[len(mobile)-4:] } // SendVerificationCode sends a verification code to the given mobile number func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) { logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile)), zap.String("ip", ip)) // Step 1: Check IP blacklist blacklisted, err := CheckIPBlacklist(ctx, ip) if err != nil { logger.Error("Failed to check IP blacklist", zap.Error(err)) return 0, fmt.Errorf("failed to check IP blacklist: %w", err) } if blacklisted { logger.Warn("IP is blacklisted") return 0, errors.New("IP黑名单") } // Step 2: Check if code was recently sent (TTL check via GetSMSCode) smsData, err := GetSMSCode(ctx, mobile) if err != nil { logger.Error("Failed to get SMS code from Redis", zap.Error(err)) return 0, fmt.Errorf("failed to check recent SMS: %w", err) } if smsData != nil { elapsed := time.Now().Unix() - smsData.CreatedAt if elapsed < 60 { logger.Warn("SMS code recently sent", zap.Int64("elapsed_seconds", elapsed)) return 0, errors.New("发送过于频繁") } } // Step 3: Check mobile hourly limit (10/hour) allowed, err := CheckRateLimitMobile(ctx, mobile) if err != nil { logger.Error("Failed to check mobile rate limit", zap.Error(err)) return 0, fmt.Errorf("failed to check mobile rate limit: %w", err) } if !allowed { logger.Warn("Mobile hourly limit exceeded") return 0, errors.New("当前手机号发送次数超限") } // Step 4: Check IP hourly limit (30/hour) allowed, err = CheckRateLimitIP(ctx, ip) if err != nil { logger.Error("Failed to check IP rate limit", zap.Error(err)) return 0, fmt.Errorf("failed to check IP rate limit: %w", err) } if !allowed { logger.Warn("IP hourly limit exceeded") return 0, errors.New("请求过于频繁") } // Step 5: Generate 6-digit code code, err := GenerateCode() if err != nil { logger.Error("Failed to generate SMS code", zap.Error(err)) return 0, fmt.Errorf("failed to generate code: %w", err) } // Step 6: Call Aliyun SMS API to send (if client is initialized) if smsClient != nil { cfg := config.GetSMSConfig() sendReq := &dysmsapi20170525.SendMessageWithTemplateRequest{ To: tea.String("86" + mobile), // China area code + mobile From: tea.String(cfg.SignName), TemplateCode: tea.String(cfg.TemplateCode), TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)), } _, err := smsClient.SendMessageWithTemplate(sendReq) if err != nil { logger.Error("Failed to send SMS via Aliyun", zap.Error(err)) return 0, fmt.Errorf("failed to send SMS: %w", err) } logger.Info("SMS sent successfully via Aliyun") } else { logger.Warn("SMS client not initialized, skipping actual SMS send") } // Step 7: Save code to Redis with 60s TTL err = SaveSMSCode(ctx, mobile, code, 60*time.Second) if err != nil { logger.Error("Failed to save SMS code to Redis", zap.Error(err)) return 0, fmt.Errorf("failed to save code: %w", err) } // Step 8: Increment mobile and IP counters _, err = IncrMobileCount(ctx, mobile) if err != nil { logger.Error("Failed to increment mobile count", zap.Error(err)) } _, err = IncrIPCount(ctx, ip) if err != nil { logger.Error("Failed to increment IP count", zap.Error(err)) } logger.Info("Verification code sent successfully") return 60, nil } // VerifyCode verifies the SMS code and returns a verify token on success func VerifyCode(ctx context.Context, mobile, code string) (string, error) { logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile))) // Step 1: Get stored code data smsData, err := GetSMSCode(ctx, mobile) if err != nil { logger.Error("Failed to get SMS code from Redis", zap.Error(err)) return "", fmt.Errorf("failed to get SMS code: %w", err) } if smsData == nil { logger.Warn("No SMS code found for mobile") return "", errors.New("验证码不存在或已过期") } // Step 2: Check if already used if smsData.Used { logger.Warn("SMS code already used") return "", errors.New("验证码已使用") } // Step 3: Compare codes if smsData.Code != code { attempts, _ := IncrementAttempts(ctx, mobile) logger.Warn("SMS code mismatch", zap.Int("attempts", attempts)) // Delete code if attempts >= 3 if attempts >= 3 { DeleteSMSCode(ctx, mobile) logger.Info("SMS code deleted due to max attempts") return "", errors.New("验证失败次数过多,请重新获取验证码") } return "", errors.New("验证码错误") } // Step 4: On success - delete code, generate verify_token, save with 300s TTL if err := DeleteSMSCode(ctx, mobile); err != nil { logger.Error("Failed to delete SMS code", zap.Error(err)) } verifyToken, err := generateVerifyToken() if err != nil { logger.Error("Failed to generate verify token", zap.Error(err)) return "", fmt.Errorf("failed to generate verify token: %w", err) } err = SaveVerifyToken(ctx, mobile, verifyToken, 300*time.Second) if err != nil { logger.Error("Failed to save verify token", zap.Error(err)) return "", fmt.Errorf("failed to save verify token: %w", err) } logger.Info("SMS code verified successfully, verify token issued") return verifyToken, nil } // VerifyToken verifies the verify_token during registration (one-time use) func VerifyToken(ctx context.Context, mobile, token string) error { logger := logger.Logger.With(zap.String("mobile", maskMobile(mobile))) // Step 1: Get stored token from Redis storedToken, err := GetVerifyToken(ctx, mobile) if err != nil { logger.Error("Failed to get verify token from Redis", zap.Error(err)) return fmt.Errorf("failed to get verify token: %w", err) } // Step 2: Compare with provided token if storedToken == "" { logger.Warn("No verify token found for mobile") return errors.New("验证令牌不存在或已过期") } if storedToken != token { logger.Warn("Verify token mismatch") return errors.New("验证令牌无效") } // Step 3: If match, delete the token (one-time use) if err := DeleteVerifyToken(ctx, mobile); err != nil { logger.Error("Failed to delete verify token", zap.Error(err)) return fmt.Errorf("failed to delete verify token: %w", err) } logger.Info("Verify token validated and consumed successfully") return nil }