对接阿里云短信OK

This commit is contained in:
yml2213
2026-05-29 12:11:13 +08:00
parent a334f1837d
commit eb80a3ba84
14 changed files with 495 additions and 29 deletions
+18
View File
@@ -14,6 +14,7 @@ type Config struct {
RedisDB int
JWTSecret string
Storage StorageConfig
SMS SMSConfig
}
type StorageConfig struct {
@@ -23,6 +24,15 @@ type StorageConfig struct {
SecretAccessKey string
}
type SMSConfig struct {
Provider string
AliyunAccessKeyID string
AliyunAccessKeySecret string
AliyunEndpoint string
AliyunSignName string
AliyunLoginTemplateCode string
}
func Load() Config {
return Config{
AppEnv: getEnv("APP_ENV", "development"),
@@ -38,6 +48,14 @@ func Load() Config {
AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "minioadmin"),
SecretAccessKey: getEnv("STORAGE_SECRET_ACCESS_KEY", "minioadmin"),
},
SMS: SMSConfig{
Provider: getEnv("SMS_PROVIDER", "mock"),
AliyunAccessKeyID: getEnv("ALIYUN_ACCESS_KEY_ID", ""),
AliyunAccessKeySecret: getEnv("ALIYUN_ACCESS_KEY_SECRET", ""),
AliyunEndpoint: getEnv("ALIYUN_SMS_ENDPOINT", "dysmsapi.aliyuncs.com"),
AliyunSignName: getEnv("ALIYUN_SMS_SIGN_NAME", ""),
AliyunLoginTemplateCode: getEnv("ALIYUN_SMS_LOGIN_TEMPLATE_CODE", ""),
},
}
}
+112
View File
@@ -0,0 +1,112 @@
package sms
import (
"context"
"encoding/json"
"fmt"
"strings"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
dysms "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
"github.com/alibabacloud-go/tea/dara"
"go.uber.org/zap"
)
type AliyunConfig struct {
AccessKeyID string
AccessKeySecret string
Endpoint string
SignName string
LoginTemplateCode string
}
type AliyunProvider struct {
client *dysms.Client
signName string
loginTemplateCode string
log *zap.Logger
}
func NewAliyunProvider(cfg AliyunConfig, log *zap.Logger) (*AliyunProvider, error) {
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
if cfg.Endpoint == "" {
cfg.Endpoint = "dysmsapi.aliyuncs.com"
}
if strings.TrimSpace(cfg.AccessKeyID) == "" ||
strings.TrimSpace(cfg.AccessKeySecret) == "" ||
strings.TrimSpace(cfg.SignName) == "" ||
strings.TrimSpace(cfg.LoginTemplateCode) == "" {
return nil, ErrProviderConfigInvalid
}
client, err := dysms.NewClient(&openapi.Config{
AccessKeyId: dara.String(strings.TrimSpace(cfg.AccessKeyID)),
AccessKeySecret: dara.String(strings.TrimSpace(cfg.AccessKeySecret)),
Endpoint: dara.String(cfg.Endpoint),
})
if err != nil {
return nil, err
}
return &AliyunProvider{
client: client,
signName: strings.TrimSpace(cfg.SignName),
loginTemplateCode: strings.TrimSpace(cfg.LoginTemplateCode),
log: log,
}, nil
}
func (p *AliyunProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
params, err := json.Marshal(map[string]string{"code": code})
if err != nil {
return err
}
req := &dysms.SendSmsRequest{}
req.SetPhoneNumbers(phone)
req.SetSignName(p.signName)
req.SetTemplateCode(p.loginTemplateCode)
req.SetTemplateParam(string(params))
resp, err := p.client.SendSmsWithContext(ctx, req, &dara.RuntimeOptions{})
if err != nil {
if p.log != nil {
p.log.Warn("aliyun sms send failed", zap.String("phone", maskPhone(phone)), zap.Error(err))
}
return fmt.Errorf("aliyun sms send failed: %w", err)
}
if resp == nil || resp.Body == nil {
return fmt.Errorf("aliyun sms empty response")
}
resultCode := dara.StringValue(resp.Body.Code)
if resultCode != "OK" {
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)),
)
}
return fmt.Errorf("aliyun sms rejected: %s", resultCode)
}
if p.log != nil {
p.log.Info(
"aliyun sms sent",
zap.String("phone", maskPhone(phone)),
zap.String("request_id", dara.StringValue(resp.Body.RequestId)),
zap.String("biz_id", dara.StringValue(resp.Body.BizId)),
)
}
return nil
}
func maskPhone(phone string) string {
if len(phone) < 7 {
return phone
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
+25
View File
@@ -0,0 +1,25 @@
package sms
import (
"context"
"go.uber.org/zap"
)
type MockProvider struct {
log *zap.Logger
}
func NewMockProvider(log *zap.Logger) *MockProvider {
return &MockProvider{log: log}
}
func (p *MockProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
if err := ctx.Err(); err != nil {
return err
}
if p.log != nil {
p.log.Info("mock sms code generated", zap.String("phone", phone), zap.String("code", code))
}
return nil
}
@@ -0,0 +1,12 @@
package sms
import (
"context"
"errors"
)
var ErrProviderConfigInvalid = errors.New("sms provider config invalid")
type Provider interface {
SendLoginCode(ctx context.Context, phone string, code string) error
}
@@ -0,0 +1,21 @@
package sms
import "context"
type UnavailableProvider struct {
err error
}
func NewUnavailableProvider(err error) *UnavailableProvider {
return &UnavailableProvider{err: err}
}
func (p *UnavailableProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
if err := ctx.Err(); err != nil {
return err
}
if p.err != nil {
return p.err
}
return ErrProviderConfigInvalid
}
+2
View File
@@ -84,6 +84,8 @@ func writeAuthError(c *gin.Context, err error) {
response.BadRequest(c, "手机号格式不正确")
case errors.Is(err, ErrCodeRateLimited):
response.Error(c, http.StatusTooManyRequests, "rate_limited", "验证码发送过于频繁")
case errors.Is(err, ErrSMSSendFailed):
response.Error(c, http.StatusBadGateway, "sms_send_failed", "验证码发送失败,请稍后重试")
case errors.Is(err, ErrCodeInvalid):
response.BadRequest(c, "验证码错误或已过期")
case errors.Is(err, ErrUserDisabled):
+15 -5
View File
@@ -9,6 +9,7 @@ import (
"regexp"
"time"
smsprovider "hfb_sys/backend/internal/integrations/sms"
"hfb_sys/backend/internal/model"
"github.com/redis/go-redis/v9"
@@ -20,6 +21,7 @@ var (
ErrInvalidPhone = errors.New("invalid phone")
ErrCodeRateLimited = errors.New("sms code rate limited")
ErrCodeInvalid = errors.New("sms code invalid")
ErrSMSSendFailed = errors.New("sms send failed")
ErrUserDisabled = errors.New("user disabled")
)
@@ -27,6 +29,7 @@ type Service struct {
users *UserRepository
redis *redis.Client
jwt *JWTManager
sms smsprovider.Provider
log *zap.Logger
}
@@ -35,8 +38,8 @@ type LoginResult struct {
Tokens TokenPair `json:"tokens"`
}
func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, log *zap.Logger) *Service {
return &Service{users: users, redis: redis, jwt: jwt, log: log}
func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, sms smsprovider.Provider, log *zap.Logger) *Service {
return &Service{users: users, redis: redis, jwt: jwt, sms: sms, log: log}
}
func (s *Service) SendSMSCode(ctx context.Context, phone string) error {
@@ -61,6 +64,16 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string) error {
return err
}
if s.sms == nil {
return ErrDependencyUnavailable
}
if err := s.sms.SendLoginCode(ctx, phone, code); err != nil {
if s.log != nil {
s.log.Warn("sms login code send failed", zap.String("phone", PublicPhone(phone)), zap.Error(err))
}
return ErrSMSSendFailed
}
pipe := s.redis.TxPipeline()
pipe.Set(ctx, codeKey(phone), code, 5*time.Minute)
pipe.Set(ctx, cooldownKey, "1", 60*time.Second)
@@ -69,9 +82,6 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string) error {
if _, err := pipe.Exec(ctx); err != nil {
return err
}
// Development SMS adapter: keep the real provider boundary, but log only in local development.
s.log.Info("mock sms code generated", zap.String("phone", phone), zap.String("code", code))
return nil
}
+25 -1
View File
@@ -1,8 +1,11 @@
package router
import (
"strings"
"hfb_sys/backend/internal/config"
"hfb_sys/backend/internal/handler"
smsintegration "hfb_sys/backend/internal/integrations/sms"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/adminaudit"
"hfb_sys/backend/internal/modules/adminauth"
@@ -44,7 +47,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
if deps.DB != nil {
userRepo = auth.NewUserRepository(deps.DB)
}
authService := auth.NewService(userRepo, deps.Redis, jwtManager, logger)
smsProvider := newSMSProvider(cfg, logger)
authService := auth.NewService(userRepo, deps.Redis, jwtManager, smsProvider, logger)
authHandler := auth.NewHandler(authService)
var adminAuthRepo *adminauth.Repository
if deps.DB != nil {
@@ -336,3 +340,23 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
return engine
}
func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider {
switch strings.ToLower(strings.TrimSpace(cfg.SMS.Provider)) {
case "aliyun":
provider, err := smsintegration.NewAliyunProvider(smsintegration.AliyunConfig{
AccessKeyID: cfg.SMS.AliyunAccessKeyID,
AccessKeySecret: cfg.SMS.AliyunAccessKeySecret,
Endpoint: cfg.SMS.AliyunEndpoint,
SignName: cfg.SMS.AliyunSignName,
LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode,
}, logger)
if err != nil {
logger.Warn("aliyun sms provider unavailable; sms send will fail", zap.Error(err))
return smsintegration.NewUnavailableProvider(err)
}
return provider
default:
return smsintegration.NewMockProvider(logger)
}
}