topfans/backend/services/userService/service/sms_service.go
2026-05-26 13:23:04 +08:00

262 lines
8.0 KiB
Go

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
}