feat(distribution): 支持分站独立短信验证码通道

- 新增分销站点独立短信服务商配置(阿里云),登录验证码按 site_code/domain
  解析专属通道,未启用或解析失败时回退主站默认通道
- 密钥经 crypto.Encryptor 加密落库,模型字段 json 序列化隔离,DTO 仅回传掩码
- provider 白名单校验,mock 仅在非生产环境放行;启用时强制校验必填凭据
- 配置读写/测试接口限管理员权限,测试发送增加 60s 频控(Redis,缺省进程内)
- 解密失败记告警并优雅回退主站,避免把密文当明文密钥使用
- 两套前端新增短信配置入口、必填与手机号校验、发送冷却
- 新增迁移 000071 及加解密、白名单、频控、解析回退等单测
This commit is contained in:
yml2213
2026-09-16 14:06:01 +08:00
parent aef858f399
commit dc13b5dc82
16 changed files with 1971 additions and 14 deletions
+42 -1
View File
@@ -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 使用手机号和验证码登录
+44 -8
View File
@@ -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)
}
}