diff --git a/backend/internal/integrations/sms/aliyun.go b/backend/internal/integrations/sms/aliyun.go index 20ea5fb..8afe13d 100644 --- a/backend/internal/integrations/sms/aliyun.go +++ b/backend/internal/integrations/sms/aliyun.go @@ -81,16 +81,21 @@ func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code s resultCode := dara.StringValue(resp.Body.Code) if resultCode != "OK" { + resultMessage := dara.StringValue(resp.Body.Message) if p.log != nil { p.log.Warn( "aliyun sms rejected", zap.String("phone", maskPhone(phone)), zap.String("request_id", dara.StringValue(resp.Body.RequestId)), zap.String("code", resultCode), - zap.String("message", dara.StringValue(resp.Body.Message)), + zap.String("message", resultMessage), ) } - return fmt.Errorf("aliyun sms rejected: %s", resultCode) + providerErr := &ProviderError{Code: resultCode, Message: resultMessage} + if resultCode == "isv.BUSINESS_LIMIT_CONTROL" { + providerErr.Err = ErrProviderRateLimited + } + return providerErr } if p.log != nil { diff --git a/backend/internal/integrations/sms/provider.go b/backend/internal/integrations/sms/provider.go index 4ff4408..7fd1fef 100644 --- a/backend/internal/integrations/sms/provider.go +++ b/backend/internal/integrations/sms/provider.go @@ -3,10 +3,38 @@ package sms import ( "context" "errors" + "fmt" ) -var ErrProviderConfigInvalid = errors.New("sms provider config invalid") +var ( + ErrProviderConfigInvalid = errors.New("sms provider config invalid") + ErrProviderRateLimited = errors.New("sms provider rate limited") +) type Provider interface { SendLoginCode(ctx context.Context, phone string, code string) error } + +type ProviderError struct { + Code string + Message string + Err error +} + +func (e *ProviderError) Error() string { + if e.Message != "" { + return fmt.Sprintf("sms provider rejected: %s: %s", e.Code, e.Message) + } + if e.Code != "" { + return fmt.Sprintf("sms provider rejected: %s", e.Code) + } + return "sms provider rejected" +} + +func (e *ProviderError) Unwrap() error { + return e.Err +} + +func IsProviderRateLimited(err error) bool { + return errors.Is(err, ErrProviderRateLimited) +} diff --git a/backend/internal/integrations/sms/provider_test.go b/backend/internal/integrations/sms/provider_test.go new file mode 100644 index 0000000..a2f4bdf --- /dev/null +++ b/backend/internal/integrations/sms/provider_test.go @@ -0,0 +1,29 @@ +package sms + +import ( + "errors" + "testing" +) + +func TestIsProviderRateLimited(t *testing.T) { + err := &ProviderError{ + Code: "isv.BUSINESS_LIMIT_CONTROL", + Message: "触发小时级流控Permits:5", + Err: ErrProviderRateLimited, + } + + if !IsProviderRateLimited(err) { + t.Fatalf("expected provider rate limit error") + } + if !errors.Is(err, ErrProviderRateLimited) { + t.Fatalf("expected errors.Is to match provider rate limit") + } +} + +func TestProviderErrorMessage(t *testing.T) { + err := &ProviderError{Code: "isv.INVALID_PARAMETERS", Message: "参数错误"} + + if got := err.Error(); got != "sms provider rejected: isv.INVALID_PARAMETERS: 参数错误" { + t.Fatalf("unexpected error message: %s", got) + } +} diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index ffca128..17f696f 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -25,6 +25,12 @@ var ( ErrUserDisabled = errors.New("user disabled") ) +const ( + smsLoginCooldown = 60 * time.Second + smsLoginHourlyLimit = 5 + smsLoginHourlyWindow = time.Hour +) + type Service struct { users *UserRepository redis *redis.Client @@ -59,6 +65,15 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string) error { return ErrCodeRateLimited } + hourlyKey := "sms:hourly:login:" + phone + hourlyCount, err := s.redis.Get(ctx, hourlyKey).Int() + if err != nil && !errors.Is(err, redis.Nil) { + return err + } + if hourlyCount >= smsLoginHourlyLimit { + return ErrCodeRateLimited + } + code, err := randomDigits(6) if err != nil { return err @@ -71,12 +86,23 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string) error { if s.log != nil { s.log.Warn("sms login code send failed", zap.String("phone", PublicPhone(phone)), zap.Error(err)) } + if smsprovider.IsProviderRateLimited(err) { + pipe := s.redis.TxPipeline() + pipe.Set(ctx, cooldownKey, "1", smsLoginCooldown) + pipe.Set(ctx, hourlyKey, smsLoginHourlyLimit, smsLoginHourlyWindow) + if _, pipeErr := pipe.Exec(ctx); pipeErr != nil { + return pipeErr + } + return ErrCodeRateLimited + } return ErrSMSSendFailed } pipe := s.redis.TxPipeline() pipe.Set(ctx, codeKey(phone), code, 5*time.Minute) - pipe.Set(ctx, cooldownKey, "1", 60*time.Second) + pipe.Set(ctx, cooldownKey, "1", smsLoginCooldown) + pipe.Incr(ctx, hourlyKey) + pipe.Expire(ctx, hourlyKey, smsLoginHourlyWindow) pipe.Incr(ctx, "sms:daily:login:"+phone) pipe.Expire(ctx, "sms:daily:login:"+phone, 24*time.Hour) if _, err := pipe.Exec(ctx); err != nil {