package service import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "regexp" "sort" "strings" "time" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/applog" "github.com/google/uuid" "gorm.io/gorm" ) const ( smsPurposeLogin = model.LoginSMSPurposeLogin smsMaxVerifyAttempts = 5 smsDefaultTTLSeconds = 300 smsDefaultCooldown = 60 smsDefaultDailyLimit = 10 aliyunDysmsEndpoint = "https://dysmsapi.aliyuncs.com/" aliyunDysmsAPIVersion = "2017-05-25" ) var phoneRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`) // SMSConfig 对应 WORKER_SMS_* 环境变量。 type SMSConfig struct { Provider string AccessKeyID string AccessKeySecret string SignName string TemplateCode string CodeTTLSeconds int CooldownSeconds int DailyLimit int } // SendLoginCodeResult 是发送验证码接口的返回。 type SendLoginCodeResult struct { Sent bool `json:"sent"` PhoneMasked string `json:"phoneMasked"` ExpiresInSeconds int `json:"expiresInSeconds"` } // LoginSMSService 负责短信验证码的签发、校验与渠道调用。 type LoginSMSService struct { db *gorm.DB cfg SMSConfig now func() time.Time } func NewLoginSMSService(db *gorm.DB, cfg SMSConfig) *LoginSMSService { if cfg.CodeTTLSeconds < 60 { cfg.CodeTTLSeconds = smsDefaultTTLSeconds } if cfg.CooldownSeconds < 10 { cfg.CooldownSeconds = smsDefaultCooldown } if cfg.DailyLimit < 1 { cfg.DailyLimit = smsDefaultDailyLimit } return &LoginSMSService{db: db, cfg: cfg, now: time.Now} } func (s *LoginSMSService) effectiveProvider() string { provider := strings.ToLower(strings.TrimSpace(s.cfg.Provider)) if provider == "" { provider = "mock" } if provider != "aliyun" { return "mock" } if strings.TrimSpace(s.cfg.AccessKeyID) == "" || strings.TrimSpace(s.cfg.AccessKeySecret) == "" || strings.TrimSpace(s.cfg.SignName) == "" || strings.TrimSpace(s.cfg.TemplateCode) == "" { applog.Warnf("[SMS] 阿里云短信配置不完整,已降级为 Mock 模式") return "mock" } return "aliyun" } // SendLoginCode 生成 6 位验证码并发送到用户已绑定手机号。 func (s *LoginSMSService) SendLoginCode(user *model.User, requestIP string) (*SendLoginCodeResult, error) { if s.db == nil { return nil, newAuthError(http.StatusServiceUnavailable, "sms_unavailable", "短信服务未配置") } if user == nil { return nil, newAuthError(http.StatusUnauthorized, "login_failed", "用户名或密码错误") } phone, err := NormalizePhone(user.Phone) if err != nil { return nil, err } now := s.now() cooldown := time.Duration(s.cfg.CooldownSeconds) * time.Second var latest model.LoginSMSCode if err := s.db.Where("user_id = ? AND purpose = ?", user.ID, smsPurposeLogin). Order("id DESC").First(&latest).Error; err == nil { if latest.ConsumedAt == nil && now.Sub(latest.CreatedAt) < cooldown { return nil, newAuthError(http.StatusTooManyRequests, "sms_cooldown", "验证码发送过于频繁,请稍后再试") } } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } provider := s.effectiveProvider() if provider != "mock" { start, end := chinaDayRange(now) var count int64 if err := s.db.Model(&model.LoginSMSCode{}). Where("user_id = ? AND purpose = ? AND created_at >= ? AND created_at < ?", user.ID, smsPurposeLogin, start, end). Count(&count).Error; err != nil { return nil, err } if count >= int64(s.cfg.DailyLimit) { return nil, newAuthError(http.StatusTooManyRequests, "sms_daily_limit", "今日验证码发送次数已达上限,请明天再试") } } code, err := generateSMSCode() if err != nil { return nil, err } if provider == "aliyun" { if err := s.sendViaAliyun(phone, code); err != nil { return nil, err } } else { applog.Warnf("[SMS/MOCK] 【开发模式】登录验证码:%s -> %s(生产环境请配置 WORKER_SMS_PROVIDER=aliyun)", phone, code) } ttl := time.Duration(s.cfg.CodeTTLSeconds) * time.Second row := &model.LoginSMSCode{ UserID: user.ID, Phone: phone, Purpose: smsPurposeLogin, CodeHash: HashSMSCode(code), ExpiresAt: now.Add(ttl), RequestIP: truncateString(strings.TrimSpace(requestIP), 64), CreatedAt: now, } if err := s.db.Create(row).Error; err != nil { return nil, err } return &SendLoginCodeResult{ Sent: true, PhoneMasked: MaskPhone(phone), ExpiresInSeconds: int(ttl.Seconds()), }, nil } // VerifyLoginCode 校验并一次性消费验证码;同一验证码错 5 次即作废。 func (s *LoginSMSService) VerifyLoginCode(userID uint, codeValue string) error { if s.db == nil { return newAuthError(http.StatusServiceUnavailable, "sms_unavailable", "短信服务未配置") } code := strings.TrimSpace(codeValue) if code == "" { return newAuthError(http.StatusBadRequest, "sms_code_required", "请输入短信验证码") } var latest model.LoginSMSCode if err := s.db.Where("user_id = ? AND purpose = ?", userID, smsPurposeLogin). Order("id DESC").First(&latest).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return newAuthError(http.StatusBadRequest, "sms_code_invalid", "短信验证码错误或已过期") } return err } now := s.now() valid := latest.ConsumedAt == nil && latest.ExpiresAt.After(now) && subtle.ConstantTimeCompare([]byte(latest.CodeHash), []byte(HashSMSCode(code))) == 1 if !valid { if latest.ConsumedAt == nil { var row struct { FailedAttemptCount int ConsumedAt *time.Time } result := s.db.Raw(` UPDATE login_sms_codes SET failed_attempt_count = failed_attempt_count + 1, consumed_at = CASE WHEN failed_attempt_count + 1 >= ? THEN ? ELSE consumed_at END WHERE id = ? AND consumed_at IS NULL RETURNING failed_attempt_count, consumed_at`, smsMaxVerifyAttempts, now, latest.ID, ).Scan(&row) if result.Error != nil { return result.Error } if row.FailedAttemptCount >= smsMaxVerifyAttempts || row.ConsumedAt != nil { return newAuthError(http.StatusBadRequest, "sms_code_locked", "验证码错误次数过多,请重新获取验证码") } } return newAuthError(http.StatusBadRequest, "sms_code_invalid", "短信验证码错误或已过期") } result := s.db.Model(&model.LoginSMSCode{}). Where("id = ? AND consumed_at IS NULL", latest.ID). Update("consumed_at", now) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return newAuthError(http.StatusBadRequest, "sms_code_invalid", "验证码已失效,请重新获取") } return nil } // NormalizePhone 校验中国大陆手机号。 func NormalizePhone(value string) (string, error) { phone := strings.TrimSpace(value) if phone == "" { return "", newAuthError(http.StatusConflict, "phone_unbound", "该账号尚未绑定手机号,请联系管理员") } if !phoneRegexp.MatchString(phone) { return "", newAuthError(http.StatusBadRequest, "phone_invalid", "手机号格式不正确") } return phone, nil } // MaskPhone 返回 138****8000 形式的脱敏号码。 func MaskPhone(value string) string { phone := strings.TrimSpace(value) if !phoneRegexp.MatchString(phone) { return phone } return phone[:3] + "****" + phone[len(phone)-4:] } // HashSMSCode 计算短信验证码摘要(只存哈希)。 func HashSMSCode(code string) string { sum := sha256.Sum256([]byte(code)) return hex.EncodeToString(sum[:]) } func generateSMSCode() (string, error) { raw := make([]byte, 6) if _, err := rand.Read(raw); err != nil { return "", err } var builder strings.Builder builder.Grow(6) for _, value := range raw { builder.WriteByte(byte('0' + value%10)) } return builder.String(), nil } func chinaDayRange(now time.Time) (time.Time, time.Time) { china := time.FixedZone("CST", 8*60*60) local := now.In(china) start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, china) return start, start.Add(24 * time.Hour) } func truncateString(value string, max int) string { if len(value) <= max { return value } return value[:max] } // sendViaAliyun 使用阿里云 Dysmsapi RPC 签名(HMAC-SHA1)直接发起 HTTP 调用。 func (s *LoginSMSService) sendViaAliyun(phone, code string) error { params := map[string]string{ "AccessKeyId": strings.TrimSpace(s.cfg.AccessKeyID), "Action": "SendSms", "Format": "JSON", "PhoneNumbers": phone, "RegionId": "cn-hangzhou", "SignName": strings.TrimSpace(s.cfg.SignName), "SignatureMethod": "HMAC-SHA1", "SignatureNonce": uuid.NewString(), "SignatureVersion": "1.0", "TemplateCode": strings.TrimSpace(s.cfg.TemplateCode), "TemplateParam": fmt.Sprintf(`{"code":%q}`, code), "Timestamp": s.now().UTC().Format("2006-01-02T15:04:05Z"), "Version": aliyunDysmsAPIVersion, } canonical := canonicalAliyunQuery(params) stringToSign := "GET&%2F&" + aliyunPercentEncode(canonical) mac := hmac.New(sha1.New, []byte(strings.TrimSpace(s.cfg.AccessKeySecret)+"&")) _, _ = mac.Write([]byte(stringToSign)) signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) endpoint := aliyunDysmsEndpoint + "?" + canonical + "&Signature=" + aliyunPercentEncode(signature) client := &http.Client{Timeout: 10 * time.Second} response, err := client.Get(endpoint) if err != nil { return newAuthError(http.StatusBadGateway, "sms_send_failed", "短信发送失败:"+err.Error()) } defer response.Body.Close() body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024)) var payload struct { Code string `json:"Code"` Message string `json:"Message"` } _ = json.Unmarshal(body, &payload) if response.StatusCode < 200 || response.StatusCode >= 300 || payload.Code != "OK" { message := strings.TrimSpace(payload.Message) if message == "" { message = strings.TrimSpace(string(body)) } if message == "" { message = "未知错误" } return newAuthError(http.StatusBadGateway, "sms_send_failed", "短信发送失败:"+message) } return nil } func canonicalAliyunQuery(params map[string]string) string { keys := make([]string, 0, len(params)) for key := range params { keys = append(keys, key) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, key := range keys { parts = append(parts, aliyunPercentEncode(key)+"="+aliyunPercentEncode(params[key])) } return strings.Join(parts, "&") } func aliyunPercentEncode(value string) string { encoded := url.QueryEscape(value) encoded = strings.ReplaceAll(encoded, "+", "%20") encoded = strings.ReplaceAll(encoded, "*", "%2A") encoded = strings.ReplaceAll(encoded, "%7E", "~") return encoded }