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