feat(distribution): 支持分站独立短信验证码通道
- 新增分销站点独立短信服务商配置(阿里云),登录验证码按 site_code/domain 解析专属通道,未启用或解析失败时回退主站默认通道 - 密钥经 crypto.Encryptor 加密落库,模型字段 json 序列化隔离,DTO 仅回传掩码 - provider 白名单校验,mock 仅在非生产环境放行;启用时强制校验必填凭据 - 配置读写/测试接口限管理员权限,测试发送增加 60s 频控(Redis,缺省进程内) - 解密失败记告警并优雅回退主站,避免把密文当明文密钥使用 - 两套前端新增短信配置入口、必填与手机号校验、发送冷却 - 新增迁移 000071 及加解密、白名单、频控、解析回退等单测
This commit is contained in:
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -18,6 +19,7 @@ type SendSMSRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
SiteCode string `json:"site_code"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -83,13 +85,52 @@ func (h *Handler) SendSMS(c *gin.Context) {
|
||||
response.BadRequest(c, "手机号和图形验证码不能为空")
|
||||
return
|
||||
}
|
||||
if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode)); err != nil {
|
||||
|
||||
siteCode := strings.TrimSpace(firstNonEmpty(
|
||||
c.GetHeader("X-Distribution-Site-Code"),
|
||||
c.Query("distribution_site_code"),
|
||||
c.Query("site_code"),
|
||||
req.SiteCode,
|
||||
))
|
||||
domain := strings.TrimSpace(firstNonEmpty(
|
||||
c.GetHeader("X-Distribution-Domain"),
|
||||
c.Query("distribution_domain"),
|
||||
c.Query("domain"),
|
||||
))
|
||||
if siteCode == "" && domain == "" {
|
||||
host := extractHost(c.Request.Host)
|
||||
if host != "" && host != "localhost" && host != "127.0.0.1" && host != "::1" {
|
||||
domain = host
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), siteCode, domain); err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"phone": PublicPhone(req.Phone), "expires_in": 300})
|
||||
}
|
||||
|
||||
func firstNonEmpty(items ...string) string {
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item) != "" {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractHost(rawHost string) string {
|
||||
rawHost = strings.TrimSpace(rawHost)
|
||||
if rawHost == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(rawHost); err == nil {
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
return strings.Trim(rawHost, "[]")
|
||||
}
|
||||
|
||||
// Login 短信验证码登录
|
||||
// @Summary 短信验证码登录
|
||||
// @Description 使用手机号和验证码登录
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/captcha"
|
||||
@@ -48,12 +49,18 @@ const (
|
||||
loginMaxFailureCount = 5
|
||||
)
|
||||
|
||||
// SiteSMSProviderResolver 用于根据分销站点标识解析专属 SMS Provider
|
||||
type SiteSMSProviderResolver interface {
|
||||
ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
users *UserRepository
|
||||
redis *redis.Client
|
||||
jwt *JWTManager
|
||||
sms smsprovider.Provider
|
||||
log *zap.Logger
|
||||
users *UserRepository
|
||||
redis *redis.Client
|
||||
jwt *JWTManager
|
||||
sms smsprovider.Provider
|
||||
smsResolver SiteSMSProviderResolver
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
@@ -65,6 +72,10 @@ func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, sms
|
||||
return &Service{users: users, redis: redis, jwt: jwt, sms: sms, log: log}
|
||||
}
|
||||
|
||||
func (s *Service) SetSiteSMSResolver(resolver SiteSMSProviderResolver) {
|
||||
s.smsResolver = resolver
|
||||
}
|
||||
|
||||
func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) {
|
||||
if s.redis == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
@@ -76,7 +87,7 @@ func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) {
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode string) error {
|
||||
func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode string, siteContext ...string) error {
|
||||
if s.redis == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
@@ -117,14 +128,39 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin
|
||||
return err
|
||||
}
|
||||
|
||||
if s.sms == nil {
|
||||
provider := s.sms
|
||||
siteCode := ""
|
||||
domain := ""
|
||||
if len(siteContext) > 0 {
|
||||
siteCode = strings.TrimSpace(siteContext[0])
|
||||
}
|
||||
if len(siteContext) > 1 {
|
||||
domain = strings.TrimSpace(siteContext[1])
|
||||
}
|
||||
if s.smsResolver != nil && (siteCode != "" || domain != "") {
|
||||
customProvider, resolveErr := s.smsResolver.ResolveSMSProvider(ctx, siteCode, domain)
|
||||
if resolveErr != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("解析分销站点独立短信服务商失败,将回退至主站默认通道",
|
||||
zap.String("site_code", siteCode),
|
||||
zap.String("domain", domain),
|
||||
zap.Error(resolveErr),
|
||||
)
|
||||
}
|
||||
} else if customProvider != nil {
|
||||
provider = customProvider
|
||||
}
|
||||
}
|
||||
|
||||
if provider == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if err := s.sms.SendLoginCode(ctx, phone, code); err != nil {
|
||||
if err := provider.SendLoginCode(ctx, phone, code); err != nil {
|
||||
if s.log != nil {
|
||||
fields := []zap.Field{
|
||||
zap.String("module", "auth"),
|
||||
zap.String("phone", PublicPhone(phone)),
|
||||
zap.String("site_code", siteCode),
|
||||
zap.Error(err),
|
||||
}
|
||||
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
smsprovider "hfb_sys/backend/internal/integrations/sms"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type recordProvider struct {
|
||||
sentPhones []string
|
||||
}
|
||||
|
||||
func (p *recordProvider) SendLoginCode(ctx context.Context, phone string, code string) error {
|
||||
p.sentPhones = append(p.sentPhones, phone)
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockResolver struct {
|
||||
customProvider smsprovider.Provider
|
||||
calledSites []string
|
||||
}
|
||||
|
||||
func (m *mockResolver) ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error) {
|
||||
m.calledSites = append(m.calledSites, siteCode)
|
||||
if siteCode == "site_custom" {
|
||||
return m.customProvider, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSendSMSCode_SiteResolutionAndFallback(t *testing.T) {
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "127.0.0.1:16379",
|
||||
DB: 14,
|
||||
})
|
||||
ctx := t.Context()
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
t.Skipf("跳过测试:未检测到测试 Redis: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = rdb.FlushDB(context.Background()).Err()
|
||||
_ = rdb.Close()
|
||||
})
|
||||
_ = rdb.FlushDB(ctx)
|
||||
|
||||
defaultProv := &recordProvider{}
|
||||
customProv := &recordProvider{}
|
||||
resolver := &mockResolver{customProvider: customProv}
|
||||
|
||||
svc := NewService(nil, rdb, nil, defaultProv, zap.NewNop())
|
||||
svc.SetSiteSMSResolver(resolver)
|
||||
|
||||
// 1. 无分站标识 -> 使用默认主站 provider
|
||||
if err := rdb.Set(ctx, "captcha:auth:sms:cap1", "ABCD", time.Minute).Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phone1 := "13800138001"
|
||||
err := svc.SendSMSCode(ctx, phone1, "cap1", "ABCD")
|
||||
if err != nil {
|
||||
t.Fatalf("SendSMSCode failed: %v", err)
|
||||
}
|
||||
if len(defaultProv.sentPhones) != 1 || defaultProv.sentPhones[0] != phone1 {
|
||||
t.Fatalf("expected default provider to send, got %v", defaultProv.sentPhones)
|
||||
}
|
||||
if len(customProv.sentPhones) != 0 {
|
||||
t.Fatalf("custom provider should not be called without site")
|
||||
}
|
||||
|
||||
// 2. 携带自定义短信分站标识 -> 使用 custom provider
|
||||
if err := rdb.Set(ctx, "captcha:auth:sms:cap2", "EFGH", time.Minute).Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phone2 := "13800138002"
|
||||
err = svc.SendSMSCode(ctx, phone2, "cap2", "EFGH", "site_custom")
|
||||
if err != nil {
|
||||
t.Fatalf("SendSMSCode with custom site failed: %v", err)
|
||||
}
|
||||
if len(customProv.sentPhones) != 1 || customProv.sentPhones[0] != phone2 {
|
||||
t.Fatalf("expected custom provider to send, got %v", customProv.sentPhones)
|
||||
}
|
||||
|
||||
// 3. 携带未配置短信分站标识 -> 优雅降级到 default provider
|
||||
if err := rdb.Set(ctx, "captcha:auth:sms:cap3", "JKLM", time.Minute).Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phone3 := "13800138003"
|
||||
err = svc.SendSMSCode(ctx, phone3, "cap3", "JKLM", "site_fallback")
|
||||
if err != nil {
|
||||
t.Fatalf("SendSMSCode with fallback site failed: %v", err)
|
||||
}
|
||||
if len(defaultProv.sentPhones) != 2 || defaultProv.sentPhones[1] != phone3 {
|
||||
t.Fatalf("expected default provider to handle fallback, got %v", defaultProv.sentPhones)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user