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
+1
View File
@@ -17,6 +17,7 @@ type DistributionSite struct {
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
SMSConfigJSON string `gorm:"type:text" json:"-"`
ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
+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)
}
}
@@ -519,3 +519,36 @@ type DistributionTrendPoint struct {
GMVCent int64 `json:"gmv_cent"`
ProfitCent int64 `json:"profit_cent"`
}
// 分销站点独立短信配置结构体
type SiteSMSConfigDTO struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
AccessKeyID string `json:"access_key_id"`
MaskedSecret string `json:"masked_secret"`
HasSecret bool `json:"has_secret"`
Endpoint string `json:"endpoint"`
SignName string `json:"sign_name"`
LoginTemplateCode string `json:"login_template_code"`
}
type UpdateSiteSMSConfigRequest struct {
Enabled *bool `json:"enabled"`
Provider *string `json:"provider"`
AccessKeyID *string `json:"access_key_id"`
AccessKeySecret *string `json:"access_key_secret"`
Endpoint *string `json:"endpoint"`
SignName *string `json:"sign_name"`
LoginTemplateCode *string `json:"login_template_code"`
}
type TestSiteSMSConfigRequest struct {
Phone string `json:"phone" binding:"required"`
Enabled *bool `json:"enabled"`
Provider *string `json:"provider"`
AccessKeyID *string `json:"access_key_id"`
AccessKeySecret *string `json:"access_key_secret"`
Endpoint *string `json:"endpoint"`
SignName *string `json:"sign_name"`
LoginTemplateCode *string `json:"login_template_code"`
}
@@ -82,6 +82,57 @@ func (h *Handler) UpdateSite(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) GetSiteSMSConfig(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.GetSiteSMSConfig(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) UpdateSiteSMSConfig(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateSiteSMSConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "短信配置参数不正确")
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.UpdateSiteSMSConfig(c.Request.Context(), adminID, id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) TestSiteSMSConfig(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req TestSiteSMSConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "测试参数不正确,手机号必填")
return
}
adminID, _ := currentAdminID(c)
if err := h.service.TestSiteSMSConfig(c.Request.Context(), adminID, id, req); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"message": "测试短信发送成功"})
}
func (h *Handler) ListRules(c *gin.Context) {
var siteID *uint64
if raw := strings.TrimSpace(c.Query("site_id")); raw != "" {
@@ -828,6 +879,12 @@ func writeError(c *gin.Context, err error) {
response.BadRequest(c, "请选择要加价的商品")
case errors.Is(err, ErrInvalidListingStatus):
response.BadRequest(c, "商品展示状态不正确")
case errors.Is(err, ErrSMSConfigInvalid):
response.BadRequest(c, "分销短信配置不完整,启用时必须配置 AccessKey、签名与模板")
case errors.Is(err, ErrSMSTestRateLimited):
response.Error(c, http.StatusTooManyRequests, "rate_limited", "测试短信发送过于频繁,请稍后再试")
case errors.Is(err, ErrSMSSendFailed):
response.Error(c, http.StatusBadGateway, "sms_send_failed", "短信测试发送失败,请检查配置与通道额度")
case errors.Is(err, ErrSiteAccessDenied):
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点")
case errors.Is(err, ErrDependencyUnavailable):
@@ -17,6 +17,7 @@ import (
listingpkg "hfb_sys/backend/internal/modules/listing"
"hfb_sys/backend/internal/modules/systemconfig"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/crypto"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
@@ -75,6 +76,8 @@ func normalizeReceiveRatioDelta(delta float64) (float64, error) {
type Repository struct {
db *gorm.DB
redis *redis.Client
encryptor crypto.Encryptor
allowMockSMS bool
siteSyncLocks sync.Map
}
@@ -86,6 +89,65 @@ func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
return r
}
func (r *Repository) SetEncryptor(enc crypto.Encryptor) {
if r != nil {
r.encryptor = enc
}
}
// SetAllowMockSMS 设置是否允许使用 mock 短信服务商,仅非生产环境应开启。
func (r *Repository) SetAllowMockSMS(allow bool) {
if r != nil {
r.allowMockSMS = allow
}
}
// validateSMSProvider 校验并规范化短信服务商;mock 仅在显式允许时可用(非生产环境)。
func (r *Repository) validateSMSProvider(provider string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(provider))
if normalized == "" {
return "aliyun", nil
}
switch normalized {
case "aliyun":
return normalized, nil
case "mock":
if r != nil && r.allowMockSMS {
return normalized, nil
}
return "", fmt.Errorf("%w: 当前环境不允许使用 mock 短信服务商", ErrSMSConfigInvalid)
default:
return "", fmt.Errorf("%w: 不支持的短信服务商 %q", ErrSMSConfigInvalid, provider)
}
}
func (r *Repository) encryptSecret(plaintext string) (string, error) {
plaintext = strings.TrimSpace(plaintext)
if plaintext == "" {
return "", nil
}
if r != nil && r.encryptor != nil {
return r.encryptor.Encrypt(plaintext)
}
return plaintext, nil
}
// decryptSecret 解密访问密钥。解密失败时返回错误,避免把密文当明文密钥继续使用。
func (r *Repository) decryptSecret(ciphertext string) (string, error) {
ciphertext = strings.TrimSpace(ciphertext)
if ciphertext == "" {
return "", nil
}
if r != nil && r.encryptor != nil {
plain, err := r.encryptor.Decrypt(ciphertext)
if err != nil {
return "", fmt.Errorf("解密短信访问密钥失败: %w", err)
}
return plain, nil
}
return ciphertext, nil
}
func (r *Repository) hasGlobalDistributionAccess(ctx context.Context, adminID uint64) (bool, error) {
return r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID)
}
@@ -524,6 +586,139 @@ func siteDTO(row model.DistributionSite) SiteDTO {
}
}
func (r *Repository) GetSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfigDTO, error) {
cfg, err := r.GetDecryptedSiteSMSConfig(ctx, adminID, siteID)
if err != nil {
return nil, err
}
return cfg.ToDTO(), nil
}
func (r *Repository) GetDecryptedSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfig, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
return nil, err
}
if strings.TrimSpace(row.SMSConfigJSON) == "" {
return &SiteSMSConfig{
Enabled: false,
Provider: "aliyun",
Endpoint: "dysmsapi.aliyuncs.com",
}, nil
}
cfg, err := ParseSiteSMSConfig(row.SMSConfigJSON)
if err != nil {
return nil, err
}
plainSecret, err := r.decryptSecret(cfg.AccessKeySecret)
if err != nil {
return nil, err
}
cfg.AccessKeySecret = plainSecret
return cfg, nil
}
func (r *Repository) UpdateSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req UpdateSiteSMSConfigRequest) (*SiteSMSConfigDTO, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
return nil, err
}
cfg, err := ParseSiteSMSConfig(row.SMSConfigJSON)
if err != nil {
return nil, fmt.Errorf("解析现有短信配置失败: %w", err)
}
if cfg == nil {
cfg = &SiteSMSConfig{}
}
existingSecret, err := r.decryptSecret(cfg.AccessKeySecret)
if err != nil {
return nil, err
}
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if req.Provider != nil {
cfg.Provider = strings.TrimSpace(*req.Provider)
}
if req.AccessKeyID != nil {
cfg.AccessKeyID = strings.TrimSpace(*req.AccessKeyID)
}
if req.AccessKeySecret != nil && strings.TrimSpace(*req.AccessKeySecret) != "" {
existingSecret = strings.TrimSpace(*req.AccessKeySecret)
}
if req.Endpoint != nil {
cfg.Endpoint = strings.TrimSpace(*req.Endpoint)
}
if req.SignName != nil {
cfg.SignName = strings.TrimSpace(*req.SignName)
}
if req.LoginTemplateCode != nil {
cfg.LoginTemplateCode = strings.TrimSpace(*req.LoginTemplateCode)
}
// 完整性校验:必须属于合法 provider 白名单,且开启时必须提供完整凭据
providerType, err := r.validateSMSProvider(cfg.Provider)
if err != nil {
return nil, err
}
cfg.Provider = providerType
if cfg.Enabled {
if providerType == "aliyun" {
if strings.TrimSpace(cfg.AccessKeyID) == "" ||
strings.TrimSpace(existingSecret) == "" ||
strings.TrimSpace(cfg.SignName) == "" ||
strings.TrimSpace(cfg.LoginTemplateCode) == "" {
return nil, ErrSMSConfigInvalid
}
}
}
encSecret, err := r.encryptSecret(existingSecret)
if err != nil {
return nil, fmt.Errorf("加密短信密钥失败: %w", err)
}
cfg.AccessKeySecret = encSecret
bytes, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
row.SMSConfigJSON = string(bytes)
if err := r.db.WithContext(ctx).Model(&row).Update("sms_config_json", row.SMSConfigJSON).Error; err != nil {
return nil, err
}
r.evictSiteInfoCache(ctx, &row)
dtoCfg := *cfg
dtoCfg.AccessKeySecret = existingSecret
return dtoCfg.ToDTO(), nil
}
func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt}
}
@@ -3,15 +3,18 @@ package distribution
import (
"context"
"errors"
"strings"
smsprovider "hfb_sys/backend/internal/integrations/sms"
"hfb_sys/backend/internal/modules/auth"
)
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
type Service struct {
repo *Repository
jwt *auth.JWTManager
repo *Repository
jwt *auth.JWTManager
smsResolver *SMSResolver
}
func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service {
@@ -22,6 +25,22 @@ func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service {
return s
}
func (s *Service) SetSMSResolver(resolver *SMSResolver) {
s.smsResolver = resolver
}
func (s *Service) SMSResolver() *SMSResolver {
return s.smsResolver
}
// ResolveSMSProvider 实现 auth.SiteSMSProviderResolver 接口
func (s *Service) ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error) {
if s == nil || s.smsResolver == nil {
return nil, nil
}
return s.smsResolver.ResolveSMSProvider(ctx, siteCode, domain)
}
func (s *Service) available() error {
if s == nil || s.repo == nil {
return ErrDependencyUnavailable
@@ -323,3 +342,95 @@ func (s *Service) GetDashboardAnalytics(ctx context.Context, adminID, siteID uin
}
return s.repo.GetDashboardAnalytics(ctx, adminID, siteID)
}
func (s *Service) GetSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfigDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetSiteSMSConfig(ctx, adminID, siteID)
}
func (s *Service) UpdateSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req UpdateSiteSMSConfigRequest) (*SiteSMSConfigDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
dto, err := s.repo.UpdateSiteSMSConfig(ctx, adminID, siteID, req)
if err != nil {
return nil, err
}
if s.smsResolver != nil {
s.smsResolver.InvalidateCache(siteID)
}
return dto, nil
}
func (s *Service) TestSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req TestSiteSMSConfigRequest) error {
if err := s.available(); err != nil {
return err
}
if s.smsResolver == nil {
return errors.New("sms resolver unavailable")
}
phone := strings.TrimSpace(req.Phone)
if phone == "" {
return errors.New("测试手机号不能为空")
}
// 先获取当前站点已持久化的配置及已解密的密钥
existingCfg, err := s.repo.GetDecryptedSiteSMSConfig(ctx, adminID, siteID)
if err != nil {
return err
}
// 构造测试配置(优先取请求参数,缺省回退至已有配置)
testCfg := SiteSMSConfig{
Enabled: true,
}
if req.Provider != nil && strings.TrimSpace(*req.Provider) != "" {
testCfg.Provider = strings.TrimSpace(*req.Provider)
} else if existingCfg != nil && existingCfg.Provider != "" {
testCfg.Provider = existingCfg.Provider
}
if req.AccessKeyID != nil && strings.TrimSpace(*req.AccessKeyID) != "" {
testCfg.AccessKeyID = strings.TrimSpace(*req.AccessKeyID)
} else if existingCfg != nil {
testCfg.AccessKeyID = existingCfg.AccessKeyID
}
if req.AccessKeySecret != nil && strings.TrimSpace(*req.AccessKeySecret) != "" {
testCfg.AccessKeySecret = strings.TrimSpace(*req.AccessKeySecret)
} else if existingCfg != nil {
testCfg.AccessKeySecret = existingCfg.AccessKeySecret
}
if req.Endpoint != nil && strings.TrimSpace(*req.Endpoint) != "" {
testCfg.Endpoint = strings.TrimSpace(*req.Endpoint)
} else if existingCfg != nil {
testCfg.Endpoint = existingCfg.Endpoint
}
if req.SignName != nil && strings.TrimSpace(*req.SignName) != "" {
testCfg.SignName = strings.TrimSpace(*req.SignName)
} else if existingCfg != nil {
testCfg.SignName = existingCfg.SignName
}
if req.LoginTemplateCode != nil && strings.TrimSpace(*req.LoginTemplateCode) != "" {
testCfg.LoginTemplateCode = strings.TrimSpace(*req.LoginTemplateCode)
} else if existingCfg != nil {
testCfg.LoginTemplateCode = existingCfg.LoginTemplateCode
}
providerType, err := s.repo.validateSMSProvider(testCfg.Provider)
if err != nil {
return err
}
testCfg.Provider = providerType
if providerType == "aliyun" {
if strings.TrimSpace(testCfg.AccessKeyID) == "" ||
strings.TrimSpace(testCfg.AccessKeySecret) == "" ||
strings.TrimSpace(testCfg.SignName) == "" ||
strings.TrimSpace(testCfg.LoginTemplateCode) == "" {
return ErrSMSConfigInvalid
}
}
return s.smsResolver.TestSendSMS(ctx, siteID, testCfg, phone)
}
@@ -0,0 +1,305 @@
package distribution
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"sync"
"time"
smsprovider "hfb_sys/backend/internal/integrations/sms"
"hfb_sys/backend/internal/model"
"go.uber.org/zap"
"gorm.io/gorm"
)
var (
ErrSMSConfigInvalid = errors.New("分销短信配置不完整")
ErrSMSTestRateLimited = errors.New("测试短信发送过于频繁,请稍后再试")
ErrSMSSendFailed = errors.New("短信服务商发送失败,请检查配置与通道额度")
)
// SiteSMSConfig 是分销站点独立短信服务商的持久化配置结构
type SiteSMSConfig struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"` // 默认 "aliyun"
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
Endpoint string `json:"endpoint,omitempty"`
SignName string `json:"sign_name"`
LoginTemplateCode string `json:"login_template_code"`
}
// MaskSecret 对敏感密钥进行掩码脱敏
func MaskSecret(secret string) string {
s := strings.TrimSpace(secret)
if s == "" {
return ""
}
if len(s) <= 6 {
return "******"
}
return s[:3] + "******" + s[len(s)-3:]
}
// ToDTO 将持久化结构转换为对外展示的 DTO(脱敏密钥)
func (c *SiteSMSConfig) ToDTO() *SiteSMSConfigDTO {
if c == nil {
return &SiteSMSConfigDTO{}
}
hasSecret := strings.TrimSpace(c.AccessKeySecret) != ""
return &SiteSMSConfigDTO{
Enabled: c.Enabled,
Provider: c.Provider,
AccessKeyID: c.AccessKeyID,
MaskedSecret: MaskSecret(c.AccessKeySecret),
HasSecret: hasSecret,
Endpoint: c.Endpoint,
SignName: c.SignName,
LoginTemplateCode: c.LoginTemplateCode,
}
}
// ParseSiteSMSConfig 从 JSON 文本解析短信配置
func ParseSiteSMSConfig(raw string) (*SiteSMSConfig, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return &SiteSMSConfig{}, nil
}
var cfg SiteSMSConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// BuildProvider 根据站点短信配置构造对应的 smsprovider.Provider
func BuildProvider(cfg SiteSMSConfig, logger *zap.Logger) (smsprovider.Provider, error) {
if !cfg.Enabled {
return nil, nil
}
providerType := strings.ToLower(strings.TrimSpace(cfg.Provider))
if providerType == "" {
providerType = "aliyun"
}
switch providerType {
case "aliyun":
if strings.TrimSpace(cfg.AccessKeyID) == "" ||
strings.TrimSpace(cfg.AccessKeySecret) == "" ||
strings.TrimSpace(cfg.SignName) == "" ||
strings.TrimSpace(cfg.LoginTemplateCode) == "" {
return nil, ErrSMSConfigInvalid
}
endpoint := strings.TrimSpace(cfg.Endpoint)
if endpoint == "" {
endpoint = "dysmsapi.aliyuncs.com"
}
return smsprovider.NewAliyunProvider(smsprovider.AliyunConfig{
AccessKeyID: strings.TrimSpace(cfg.AccessKeyID),
AccessKeySecret: strings.TrimSpace(cfg.AccessKeySecret),
Endpoint: endpoint,
SignName: strings.TrimSpace(cfg.SignName),
LoginTemplateCode: strings.TrimSpace(cfg.LoginTemplateCode),
}, logger)
case "mock":
return smsprovider.NewMockProvider(logger), nil
default:
return nil, fmt.Errorf("不支持的短信服务商: %s", cfg.Provider)
}
}
// SMSResolver 管理分销站点的 SMS Provider 实例与缓存
type SMSResolver struct {
repo *Repository
logger *zap.Logger
mu sync.RWMutex
providers map[uint64]smsprovider.Provider
testCooldown sync.Map
}
func NewSMSResolver(repo *Repository, logger *zap.Logger) *SMSResolver {
return &SMSResolver{
repo: repo,
logger: logger,
providers: make(map[uint64]smsprovider.Provider),
}
}
// InvalidateCache 清除特定站点的 Provider 缓存(配置更新时调用)
func (r *SMSResolver) InvalidateCache(siteID uint64) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.providers, siteID)
}
// ResolveSMSProvider 根据 siteCode 或 domain 解析分销站点专属的 SMS Provider。
// 若分销站点未启用自定义短信或配置不完整,则返回 nil,调用方将优雅降级为主站默认 Provider。
func (r *SMSResolver) ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error) {
if r == nil || r.repo == nil {
return nil, nil
}
siteCode = strings.TrimSpace(siteCode)
domain = strings.TrimSpace(domain)
if siteCode == "" && domain == "" {
return nil, nil
}
site, err := r.repo.FindActiveSiteByCodeOrDomain(ctx, siteCode, domain)
if err != nil {
if errors.Is(err, ErrSiteNotFound) {
return nil, nil
}
return nil, err
}
if site == nil || strings.TrimSpace(site.SMSConfigJSON) == "" {
return nil, nil
}
cfg, err := ParseSiteSMSConfig(site.SMSConfigJSON)
if err != nil || cfg == nil || !cfg.Enabled {
return nil, nil
}
// 检查内存缓存
r.mu.RLock()
p, ok := r.providers[site.ID]
r.mu.RUnlock()
if ok && p != nil {
return p, nil
}
// 解密 AccessKeySecret;解密失败说明密钥轮换或密文损坏,回退主站并留下告警
plainSecret, decErr := r.repo.decryptSecret(cfg.AccessKeySecret)
if decErr != nil {
if r.logger != nil {
r.logger.Warn("解密分销站点短信密钥失败,将回退至主站默认配置",
zap.Uint64("site_id", site.ID),
zap.String("site_code", site.SiteCode),
zap.Error(decErr),
)
}
return nil, nil
}
cfg.AccessKeySecret = plainSecret
// 实例化 Provider 并写入缓存
provider, err := BuildProvider(*cfg, r.logger)
if err != nil {
if r.logger != nil {
r.logger.Warn("分销站点自定义短信配置无效,将回退至主站默认配置",
zap.Uint64("site_id", site.ID),
zap.String("site_code", site.SiteCode),
zap.Error(err),
)
}
return nil, nil
}
r.mu.Lock()
r.providers[site.ID] = provider
r.mu.Unlock()
return provider, nil
}
// TestSendSMS 使用给定的短信配置,向指定手机号发送一条测试验证码以检验连通性
func (r *SMSResolver) TestSendSMS(ctx context.Context, siteID uint64, cfg SiteSMSConfig, phone string) error {
phone = strings.TrimSpace(phone)
if phone == "" {
return errors.New("手机号不能为空")
}
// 60秒频控检查(优先走 Redis 分布式 TTL,缺省回退进程内限流)
redisKey := fmt.Sprintf("hfb:dist:sms:test_cooldown:%d:%s", siteID, phone)
memKey := fmt.Sprintf("%d:%s", siteID, phone)
useRedis := r.repo != nil && r.repo.redis != nil
if useRedis {
ok, err := r.repo.redis.SetNX(ctx, redisKey, "1", 60*time.Second).Result()
if err == nil && !ok {
return ErrSMSTestRateLimited
}
} else {
if lastVal, ok := r.testCooldown.Load(memKey); ok {
if lastTime, isTime := lastVal.(time.Time); isTime && time.Since(lastTime) < 60*time.Second {
return ErrSMSTestRateLimited
}
}
}
cfg.Enabled = true // 测试模式强制启用以检验
provider, err := BuildProvider(cfg, r.logger)
if err != nil {
if useRedis {
_ = r.repo.redis.Del(ctx, redisKey).Err()
}
return err
}
// 生成 6 位加密随机验证码
n, randErr := rand.Int(rand.Reader, big.NewInt(900000))
var codeVal int64 = 123456
if randErr == nil {
codeVal = n.Int64() + 100000
}
testCode := fmt.Sprintf("%06d", codeVal)
if err := provider.SendLoginCode(ctx, phone, testCode); err != nil {
if useRedis {
_ = r.repo.redis.Del(ctx, redisKey).Err()
} else {
r.testCooldown.Delete(memKey)
}
if r.logger != nil {
r.logger.Warn("测试短信发送失败",
zap.Uint64("site_id", siteID),
zap.String("phone", phone),
zap.Error(err),
)
}
return ErrSMSSendFailed
}
if !useRedis {
r.testCooldown.Store(memKey, time.Now())
}
return nil
}
// FindActiveSiteByCodeOrDomain 查询状态为 active 的分销站点实体
func (r *Repository) FindActiveSiteByCodeOrDomain(ctx context.Context, code, domain string) (*model.DistributionSite, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
code = strings.TrimSpace(code)
domain = strings.TrimSpace(domain)
if code == "" && domain == "" {
return nil, ErrSiteNotFound
}
var site model.DistributionSite
query := r.db.WithContext(ctx).Where("status = 'active'")
if code != "" && domain != "" {
query = query.Where("site_code = ? OR domain = ?", code, domain)
} else if code != "" {
query = query.Where("site_code = ?", code)
} else {
query = query.Where("domain = ?", domain)
}
if err := query.First(&site).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
return &site, nil
}
@@ -0,0 +1,396 @@
package distribution
import (
"errors"
"strings"
"testing"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/pkg/crypto"
"go.uber.org/zap"
)
func TestMaskSecret(t *testing.T) {
cases := []struct {
input string
want string
}{
{"", ""},
{"123", "******"},
{"123456", "******"},
{"1234567", "123******567"},
{"LTAI5tSecret123", "LTA******123"},
}
for _, c := range cases {
got := MaskSecret(c.input)
if got != c.want {
t.Errorf("MaskSecret(%q) = %q, want %q", c.input, got, c.want)
}
}
}
func TestBuildProvider(t *testing.T) {
logger := zap.NewNop()
// 1. mock provider
mockCfg := SiteSMSConfig{
Enabled: true,
Provider: "mock",
}
provider, err := BuildProvider(mockCfg, logger)
if err != nil || provider == nil {
t.Fatalf("expected mock provider, got err: %v, provider: %v", err, provider)
}
// 2. aliyun missing fields
invalidAliyunCfg := SiteSMSConfig{
Enabled: true,
Provider: "aliyun",
AccessKeyID: "id",
}
_, err = BuildProvider(invalidAliyunCfg, logger)
if err != ErrSMSConfigInvalid {
t.Fatalf("expected ErrSMSConfigInvalid, got: %v", err)
}
// 3. disabled returns nil
disabledCfg := SiteSMSConfig{
Enabled: false,
Provider: "mock",
}
p, err := BuildProvider(disabledCfg, logger)
if err != nil || p != nil {
t.Fatalf("expected nil provider for disabled, got %v, %v", p, err)
}
}
func TestSMSResolver_ResolveAndInvalidate(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db, nil)
resolver := NewSMSResolver(repo, zap.NewNop())
ctx := t.Context()
// 1. 站点未配置短信 -> 返回 nil (回退主站)
siteNoSMS := model.DistributionSite{
SiteCode: "site_no_sms",
SiteName: "无短信站",
Domain: "nosms.com",
Status: "active",
}
if err := db.Create(&siteNoSMS).Error; err != nil {
t.Fatal(err)
}
p, err := resolver.ResolveSMSProvider(ctx, "site_no_sms", "")
if err != nil || p != nil {
t.Fatalf("expected nil provider for unconfigured site, got %v, %v", p, err)
}
// 2. 站点配置了 mock 自定义短信并启用
mockJSON := `{"enabled":true,"provider":"mock","sign_name":"【分站签名】","login_template_code":"SMS_001"}`
siteWithSMS := model.DistributionSite{
SiteCode: "site_with_sms",
SiteName: "独立短信站",
Domain: "customsms.com",
Status: "active",
SMSConfigJSON: mockJSON,
}
if err := db.Create(&siteWithSMS).Error; err != nil {
t.Fatal(err)
}
// 通过 siteCode 解析
p1, err := resolver.ResolveSMSProvider(ctx, "site_with_sms", "")
if err != nil || p1 == nil {
t.Fatalf("expected resolved mock provider, got %v, err=%v", p1, err)
}
// 通过 domain 解析
p2, err := resolver.ResolveSMSProvider(ctx, "", "customsms.com")
if err != nil || p2 == nil {
t.Fatalf("expected resolved mock provider by domain, got %v, err=%v", p2, err)
}
// 验证缓存命中
if p1 != p2 {
t.Fatalf("expected same cached provider instance")
}
// 3. 清理缓存后重新解析
resolver.InvalidateCache(siteWithSMS.ID)
if len(resolver.providers) != 0 {
t.Fatalf("expected empty cache after InvalidateCache")
}
}
func TestRepository_GetAndUpdateSiteSMSConfig(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db, nil)
ctx := t.Context()
site := model.DistributionSite{
SiteCode: "site_cfg_test",
SiteName: "配置测试站",
Domain: "cfg.com",
Status: "active",
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
// 1. 获取初始配置(空)
dto, err := repo.GetSiteSMSConfig(ctx, 0, site.ID)
if err != nil {
t.Fatalf("GetSiteSMSConfig failed: %v", err)
}
if dto.Enabled || dto.HasSecret {
t.Fatalf("expected default empty config, got %+v", dto)
}
// 2. 更新配置(包含密钥)
enabled := true
provider := "aliyun"
ak := "LTAI_TEST_KEY"
secret := "SECRET_TEST_998877"
sign := "【我的分站】"
template := "SMS_12345"
updated, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
Enabled: &enabled,
Provider: &provider,
AccessKeyID: &ak,
AccessKeySecret: &secret,
SignName: &sign,
LoginTemplateCode: &template,
})
if err != nil {
t.Fatalf("UpdateSiteSMSConfig failed: %v", err)
}
if !updated.Enabled || !updated.HasSecret || updated.SignName != sign {
t.Fatalf("expected updated config, got %+v", updated)
}
if updated.MaskedSecret == secret {
t.Fatalf("secret should be masked in DTO")
}
// 3. 再次更新,不传 AccessKeySecret(留空),应保留原密钥
newSign := "【更新后的签名】"
emptySecret := ""
updated2, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
SignName: &newSign,
AccessKeySecret: &emptySecret,
})
if err != nil {
t.Fatalf("UpdateSiteSMSConfig failed: %v", err)
}
if updated2.SignName != newSign {
t.Fatalf("SignName not updated: %s", updated2.SignName)
}
if !updated2.HasSecret {
t.Fatalf("existing secret should be preserved when updating with empty secret")
}
// 4. 验证从 SiteDTO 获取时不泄露 SMSConfig(已隔离)
foundSite, err := repo.FindSite(ctx, 0, site.ID)
if err != nil {
t.Fatal(err)
}
_ = foundSite
// 5. 验证内部解密读取方法能够解密原密钥
decryptedCfg, err := repo.GetDecryptedSiteSMSConfig(ctx, 0, site.ID)
if err != nil {
t.Fatalf("GetDecryptedSiteSMSConfig failed: %v", err)
}
if decryptedCfg.AccessKeySecret != secret {
t.Fatalf("expected decrypted secret %q, got %q", secret, decryptedCfg.AccessKeySecret)
}
// 6. 验证开启状态下缺少必填项时拦截报错
emptySign := ""
_, err = repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
SignName: &emptySign,
})
if !errors.Is(err, ErrSMSConfigInvalid) {
t.Fatalf("expected ErrSMSConfigInvalid when required field is empty, got %v", err)
}
}
func TestSMSResolver_TestSendSMS(t *testing.T) {
resolver := NewSMSResolver(nil, zap.NewNop())
ctx := t.Context()
// 1. 空手机号报错
err := resolver.TestSendSMS(ctx, 1, SiteSMSConfig{}, "")
if err == nil {
t.Fatal("expected error for empty phone")
}
// 2. mock provider 测试发送成功
err = resolver.TestSendSMS(ctx, 1, SiteSMSConfig{
Provider: "mock",
}, "13800138000")
if err != nil {
t.Fatalf("expected success for mock provider test send, got %v", err)
}
// 3. 60秒内同站点同手机号触发频控限制
err = resolver.TestSendSMS(ctx, 1, SiteSMSConfig{
Provider: "mock",
}, "13800138000")
if !errors.Is(err, ErrSMSTestRateLimited) {
t.Fatalf("expected ErrSMSTestRateLimited on immediate repeat test, got %v", err)
}
}
func TestRepository_SMSConfigEncryptionAtRest(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db, nil)
// 使用 32 字节 AES 密钥构建 FieldEncryptor
encryptor, err := crypto.NewFieldEncryptor("0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatalf("failed to create field encryptor: %v", err)
}
repo.SetEncryptor(encryptor)
ctx := t.Context()
site := model.DistributionSite{
SiteCode: "site_enc_test",
SiteName: "加密测试站",
Domain: "enctest.com",
Status: "active",
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
// 1. 验证写入非法 provider 被拒绝
badProvider := "tencent"
_, err = repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
Provider: &badProvider,
})
if !errors.Is(err, ErrSMSConfigInvalid) {
t.Fatalf("expected ErrSMSConfigInvalid for unapproved provider, got %v", err)
}
// 2. 写入合法 Aliyun 配置
enabled := true
provider := "aliyun"
ak := "LTAI_PLAINTEXT_ID"
secret := "SUPER_SECRET_KEY_999888"
sign := "【测试加固】"
template := "SMS_666777"
updated, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
Enabled: &enabled,
Provider: &provider,
AccessKeyID: &ak,
AccessKeySecret: &secret,
SignName: &sign,
LoginTemplateCode: &template,
})
if err != nil {
t.Fatalf("UpdateSiteSMSConfig failed: %v", err)
}
if !updated.HasSecret || updated.MaskedSecret == secret {
t.Fatalf("expected secret masked in DTO: %+v", updated)
}
// 3. 直接查底层 DB 原始数据,断言落库 JSON 绝不含明文 secret
var rawSite model.DistributionSite
if err := db.First(&rawSite, site.ID).Error; err != nil {
t.Fatal(err)
}
if strings.Contains(rawSite.SMSConfigJSON, secret) {
t.Fatalf("CRITICAL: raw database JSON contains plaintext secret! JSON=%s", rawSite.SMSConfigJSON)
}
// 4. 读取解密配置,断言可精准还原明文
decryptedCfg, err := repo.GetDecryptedSiteSMSConfig(ctx, 0, site.ID)
if err != nil {
t.Fatalf("GetDecryptedSiteSMSConfig failed: %v", err)
}
if decryptedCfg.AccessKeySecret != secret {
t.Fatalf("decrypted secret mismatch: got %q, want %q", decryptedCfg.AccessKeySecret, secret)
}
// 5. SMSResolver 能正确使用该站点 provider
resolver := NewSMSResolver(repo, zap.NewNop())
p, err := resolver.ResolveSMSProvider(ctx, "site_enc_test", "")
if err != nil || p == nil {
t.Fatalf("expected resolved aliyun provider, got %v, err=%v", p, err)
}
}
func TestRepository_SMSConfigDecryptFailure(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db, nil)
encryptor, err := crypto.NewFieldEncryptor("0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatalf("failed to create field encryptor: %v", err)
}
repo.SetEncryptor(encryptor)
ctx := t.Context()
// 直接写入一段无法解密的密文,模拟密钥轮换/密文损坏
corrupted := `{"enabled":true,"provider":"mock","access_key_secret":"@@not-a-valid-cipher@@"}`
site := model.DistributionSite{
SiteCode: "site_bad_cipher",
SiteName: "损坏密文站",
Domain: "badcipher.com",
Status: "active",
SMSConfigJSON: corrupted,
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
// 1. 管理侧读取应显式报错,而不是把密文当明文返回
if _, err := repo.GetDecryptedSiteSMSConfig(ctx, 0, site.ID); err == nil {
t.Fatal("expected decrypt error for corrupted ciphertext, got nil")
}
// 2. 登录链路应优雅降级:返回 nil provider(回退主站)而不是抛错
resolver := NewSMSResolver(repo, zap.NewNop())
p, err := resolver.ResolveSMSProvider(ctx, "site_bad_cipher", "")
if err != nil || p != nil {
t.Fatalf("expected graceful fallback to nil provider, got %v, err=%v", p, err)
}
}
func TestRepository_SMSProviderWhitelist(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db, nil) // 默认不允许 mock(生产语义)
ctx := t.Context()
site := model.DistributionSite{
SiteCode: "site_provider_wl",
SiteName: "服务商白名单站",
Domain: "providerwl.com",
Status: "active",
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
enabled := true
mockProvider := "mock"
// 1. 默认环境下 mock 被拒绝
if _, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
Enabled: &enabled,
Provider: &mockProvider,
}); !errors.Is(err, ErrSMSConfigInvalid) {
t.Fatalf("expected ErrSMSConfigInvalid for mock in production semantics, got %v", err)
}
// 2. 非生产环境显式放行后 mock 可用
repo.SetAllowMockSMS(true)
if _, err := repo.UpdateSiteSMSConfig(ctx, 0, site.ID, UpdateSiteSMSConfigRequest{
Enabled: &enabled,
Provider: &mockProvider,
}); err != nil {
t.Fatalf("expected mock provider to be accepted when allowed, got %v", err)
}
}
+12
View File
@@ -317,8 +317,17 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
var distributionRepo *distribution.Repository
if deps.DB != nil {
distributionRepo = distribution.NewRepository(deps.DB, deps.Redis)
distributionRepo.SetEncryptor(fieldEncryptor)
distributionRepo.SetAllowMockSMS(!config.IsProductionEnv(cfg.AppEnv))
}
distributionService := distribution.NewService(distributionRepo, jwtManager)
if distributionRepo != nil {
distributionResolver := distribution.NewSMSResolver(distributionRepo, logger)
distributionService.SetSMSResolver(distributionResolver)
if authService != nil {
authService.SetSiteSMSResolver(distributionService)
}
}
distributionHandler := distribution.NewHandler(distributionService)
backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile)
var adminRoleRepo *adminrole.Repository
@@ -647,6 +656,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/distribution/sites/:id/sync-accounts", requirePerm("distribution:account_display"), distributionHandler.SyncSiteAccounts)
adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite)
adminRoutes.PUT("/distribution/sites/:id/branding", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteBranding)
adminRoutes.GET("/distribution/sites/:id/sms-config", requirePerm("distribution:site_view"), distributionHandler.GetSiteSMSConfig)
adminRoutes.PUT("/distribution/sites/:id/sms-config", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteSMSConfig)
adminRoutes.POST("/distribution/sites/:id/sms-config/test", requirePerm("distribution:site_manage"), distributionHandler.TestSiteSMSConfig)
adminRoutes.GET("/distribution/sites/:id/wallet", requirePerm("distribution:settlement_view"), distributionHandler.GetSiteWallet)
adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal)
adminRoutes.GET("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_view"), distributionHandler.ListSiteWithdrawals)
@@ -0,0 +1,10 @@
-- +goose Up
-- 分销站点自定义短信配置(JSON格式存储服务商、AccessKey、签名、模板Code等)
ALTER TABLE distribution_sites
ADD COLUMN sms_config_json TEXT NULL COMMENT '分销站点独立短信服务商配置JSON' AFTER listing_qrcode_url;
-- +goose Down
ALTER TABLE distribution_sites
DROP COLUMN sms_config_json;