对接阿里云短信OK
This commit is contained in:
@@ -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", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user