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"` FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"` 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"` 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"` TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
+42 -1
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"errors" "errors"
"net"
"net/http" "net/http"
"strings" "strings"
@@ -18,6 +19,7 @@ type SendSMSRequest struct {
Phone string `json:"phone" binding:"required"` Phone string `json:"phone" binding:"required"`
CaptchaID string `json:"captcha_id" binding:"required"` CaptchaID string `json:"captcha_id" binding:"required"`
CaptchaCode string `json:"captcha_code" binding:"required"` CaptchaCode string `json:"captcha_code" binding:"required"`
SiteCode string `json:"site_code"`
} }
type LoginRequest struct { type LoginRequest struct {
@@ -83,13 +85,52 @@ func (h *Handler) SendSMS(c *gin.Context) {
response.BadRequest(c, "手机号和图形验证码不能为空") response.BadRequest(c, "手机号和图形验证码不能为空")
return 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) writeAuthError(c, err)
return return
} }
response.OK(c, gin.H{"phone": PublicPhone(req.Phone), "expires_in": 300}) 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 短信验证码登录 // Login 短信验证码登录
// @Summary 短信验证码登录 // @Summary 短信验证码登录
// @Description 使用手机号和验证码登录 // @Description 使用手机号和验证码登录
+44 -8
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"regexp" "regexp"
"strings"
"time" "time"
"hfb_sys/backend/internal/captcha" "hfb_sys/backend/internal/captcha"
@@ -48,12 +49,18 @@ const (
loginMaxFailureCount = 5 loginMaxFailureCount = 5
) )
// SiteSMSProviderResolver 用于根据分销站点标识解析专属 SMS Provider
type SiteSMSProviderResolver interface {
ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error)
}
type Service struct { type Service struct {
users *UserRepository users *UserRepository
redis *redis.Client redis *redis.Client
jwt *JWTManager jwt *JWTManager
sms smsprovider.Provider sms smsprovider.Provider
log *zap.Logger smsResolver SiteSMSProviderResolver
log *zap.Logger
} }
type LoginResult struct { 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} 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) { func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) {
if s.redis == nil { if s.redis == nil {
return nil, ErrDependencyUnavailable return nil, ErrDependencyUnavailable
@@ -76,7 +87,7 @@ func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) {
return item, err 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 { if s.redis == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
} }
@@ -117,14 +128,39 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin
return err 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 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 { if s.log != nil {
fields := []zap.Field{ fields := []zap.Field{
zap.String("module", "auth"), zap.String("module", "auth"),
zap.String("phone", PublicPhone(phone)), zap.String("phone", PublicPhone(phone)),
zap.String("site_code", siteCode),
zap.Error(err), zap.Error(err),
} }
if requestID := logging.RequestIDFromContext(ctx); requestID != "" { 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"` GMVCent int64 `json:"gmv_cent"`
ProfitCent int64 `json:"profit_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) 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) { func (h *Handler) ListRules(c *gin.Context) {
var siteID *uint64 var siteID *uint64
if raw := strings.TrimSpace(c.Query("site_id")); raw != "" { if raw := strings.TrimSpace(c.Query("site_id")); raw != "" {
@@ -828,6 +879,12 @@ func writeError(c *gin.Context, err error) {
response.BadRequest(c, "请选择要加价的商品") response.BadRequest(c, "请选择要加价的商品")
case errors.Is(err, ErrInvalidListingStatus): case errors.Is(err, ErrInvalidListingStatus):
response.BadRequest(c, "商品展示状态不正确") 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): case errors.Is(err, ErrSiteAccessDenied):
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点") response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点")
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
@@ -17,6 +17,7 @@ import (
listingpkg "hfb_sys/backend/internal/modules/listing" listingpkg "hfb_sys/backend/internal/modules/listing"
"hfb_sys/backend/internal/modules/systemconfig" "hfb_sys/backend/internal/modules/systemconfig"
"hfb_sys/backend/internal/modules/wallet" "hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/crypto"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -75,6 +76,8 @@ func normalizeReceiveRatioDelta(delta float64) (float64, error) {
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
redis *redis.Client redis *redis.Client
encryptor crypto.Encryptor
allowMockSMS bool
siteSyncLocks sync.Map siteSyncLocks sync.Map
} }
@@ -86,6 +89,65 @@ func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
return r 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) { func (r *Repository) hasGlobalDistributionAccess(ctx context.Context, adminID uint64) (bool, error) {
return r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID) 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 { 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} 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 ( import (
"context" "context"
"errors" "errors"
"strings"
smsprovider "hfb_sys/backend/internal/integrations/sms"
"hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/auth"
) )
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable") var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
type Service struct { type Service struct {
repo *Repository repo *Repository
jwt *auth.JWTManager jwt *auth.JWTManager
smsResolver *SMSResolver
} }
func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service { func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service {
@@ -22,6 +25,22 @@ func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service {
return s 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 { func (s *Service) available() error {
if s == nil || s.repo == nil { if s == nil || s.repo == nil {
return ErrDependencyUnavailable return ErrDependencyUnavailable
@@ -323,3 +342,95 @@ func (s *Service) GetDashboardAnalytics(ctx context.Context, adminID, siteID uin
} }
return s.repo.GetDashboardAnalytics(ctx, adminID, siteID) 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 var distributionRepo *distribution.Repository
if deps.DB != nil { if deps.DB != nil {
distributionRepo = distribution.NewRepository(deps.DB, deps.Redis) distributionRepo = distribution.NewRepository(deps.DB, deps.Redis)
distributionRepo.SetEncryptor(fieldEncryptor)
distributionRepo.SetAllowMockSMS(!config.IsProductionEnv(cfg.AppEnv))
} }
distributionService := distribution.NewService(distributionRepo, jwtManager) 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) distributionHandler := distribution.NewHandler(distributionService)
backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile) backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile)
var adminRoleRepo *adminrole.Repository 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.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", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite)
adminRoutes.PUT("/distribution/sites/:id/branding", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteBranding) 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.GET("/distribution/sites/:id/wallet", requirePerm("distribution:settlement_view"), distributionHandler.GetSiteWallet)
adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal) adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal)
adminRoutes.GET("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_view"), distributionHandler.ListSiteWithdrawals) 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;
+47
View File
@@ -692,3 +692,50 @@ export async function fetchDashboardAnalytics(siteID: number): Promise<Distribut
const resp = await api.get<ApiResponse<DistributionAnalytics>>(`/admin/distribution/sites/${siteID}/analytics`) const resp = await api.get<ApiResponse<DistributionAnalytics>>(`/admin/distribution/sites/${siteID}/analytics`)
return extract(resp) return extract(resp)
} }
export interface SiteSMSConfig {
enabled: boolean
provider: string
access_key_id: string
masked_secret: string
has_secret: boolean
endpoint: string
sign_name: string
login_template_code: string
}
export interface UpdateSiteSMSConfigPayload {
enabled?: boolean
provider?: string
access_key_id?: string
access_key_secret?: string
endpoint?: string
sign_name?: string
login_template_code?: string
}
export interface TestSiteSMSConfigPayload {
phone: string
enabled?: boolean
provider?: string
access_key_id?: string
access_key_secret?: string
endpoint?: string
sign_name?: string
login_template_code?: string
}
export async function fetchSiteSMSConfig(siteID: number): Promise<SiteSMSConfig> {
const resp = await api.get<ApiResponse<SiteSMSConfig>>(`/admin/distribution/sites/${siteID}/sms-config`)
return extract(resp)
}
export async function updateSiteSMSConfig(siteID: number, payload: UpdateSiteSMSConfigPayload): Promise<SiteSMSConfig> {
const resp = await api.put<ApiResponse<SiteSMSConfig>>(`/admin/distribution/sites/${siteID}/sms-config`, payload)
return extract(resp)
}
export async function testSiteSMSConfig(siteID: number, payload: TestSiteSMSConfigPayload): Promise<{ message: string }> {
const resp = await api.post<ApiResponse<{ message: string }>>(`/admin/distribution/sites/${siteID}/sms-config/test`, payload)
return extract(resp)
}
@@ -16,6 +16,9 @@ import {
Tag, Tag,
Tabs, Tabs,
Collapse, Collapse,
Switch,
Alert,
Popconfirm,
} from 'antd' } from 'antd'
import { import {
SaveOutlined, SaveOutlined,
@@ -27,14 +30,22 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
SkinOutlined, SkinOutlined,
GlobalOutlined, GlobalOutlined,
MessageOutlined,
SendOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
listSites, listSites,
updateSiteBranding, updateSiteBranding,
updateSite, updateSite,
getMe, getMe,
fetchSiteSMSConfig,
updateSiteSMSConfig,
testSiteSMSConfig,
type Site, type Site,
type SiteBrandingPayload, type SiteBrandingPayload,
type SiteSMSConfig,
type UpdateSiteSMSConfigPayload,
type TestSiteSMSConfigPayload,
} from '../api' } from '../api'
import { useMember } from '../MemberContext' import { useMember } from '../MemberContext'
@@ -76,6 +87,31 @@ export default function SiteSettings() {
const [receiveRatioDelta, setReceiveRatioDelta] = useState(0) const [receiveRatioDelta, setReceiveRatioDelta] = useState(0)
const [savingRatio, setSavingRatio] = useState(false) const [savingRatio, setSavingRatio] = useState(false)
// SMS config state
const [smsConfig, setSmsConfig] = useState<SiteSMSConfig>({
enabled: false,
provider: 'aliyun',
access_key_id: '',
masked_secret: '',
has_secret: false,
endpoint: 'dysmsapi.aliyuncs.com',
sign_name: '',
login_template_code: '',
})
const [smsSecretInput, setSmsSecretInput] = useState('')
const [savingSMS, setSavingSMS] = useState(false)
const [testPhone, setTestPhone] = useState('')
const [testingSMS, setTestingSMS] = useState(false)
const [testCooldown, setTestCooldown] = useState(0)
useEffect(() => {
if (testCooldown <= 0) return
const timer = setInterval(() => {
setTestCooldown((prev) => (prev > 0 ? prev - 1 : 0))
}, 1000)
return () => clearInterval(timer)
}, [testCooldown])
const loadSite = useCallback(async (id: number) => { const loadSite = useCallback(async (id: number) => {
setLoading(true) setLoading(true)
try { try {
@@ -105,6 +141,14 @@ export default function SiteSettings() {
setFooterInfo({}) setFooterInfo({})
} }
setReceiveRatioDelta(Math.max(0, found.receive_ratio_delta || 0)) setReceiveRatioDelta(Math.max(0, found.receive_ratio_delta || 0))
try {
const sms = await fetchSiteSMSConfig(id)
setSmsConfig(sms)
setSmsSecretInput('')
} catch {
// ignore
}
} }
} catch { } catch {
message.error('加载站点设置失败') message.error('加载站点设置失败')
@@ -202,6 +246,109 @@ export default function SiteSettings() {
} }
} }
async function handleSaveSMS() {
if (!siteID) return
if (smsConfig.enabled) {
if (!smsConfig.access_key_id?.trim()) {
message.warning('启用独立短信时,阿里云 AccessKey ID 不能为空')
return
}
if (!smsConfig.has_secret && !smsSecretInput.trim()) {
message.warning('启用独立短信时,阿里云 AccessKey Secret 不能为空')
return
}
if (!smsConfig.sign_name?.trim()) {
message.warning('启用独立短信时,短信签名不能为空')
return
}
if (!smsConfig.login_template_code?.trim()) {
message.warning('启用独立短信时,登录验证码模板 Code 不能为空')
return
}
}
setSavingSMS(true)
try {
const payload: UpdateSiteSMSConfigPayload = {
enabled: smsConfig.enabled,
provider: smsConfig.provider || 'aliyun',
access_key_id: smsConfig.access_key_id.trim(),
endpoint: smsConfig.endpoint.trim() || 'dysmsapi.aliyuncs.com',
sign_name: smsConfig.sign_name.trim(),
login_template_code: smsConfig.login_template_code.trim(),
}
if (smsSecretInput.trim()) {
payload.access_key_secret = smsSecretInput.trim()
}
const updated = await updateSiteSMSConfig(siteID, payload)
setSmsConfig(updated)
setSmsSecretInput('')
message.success('短信服务配置保存成功')
} catch (err: unknown) {
const msg =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: '保存短信配置失败'
message.error(msg || '保存短信配置失败')
} finally {
setSavingSMS(false)
}
}
async function handleTestSMS() {
if (!siteID) return
const phone = testPhone.trim()
if (!phone) {
message.warning('请输入接收测试短信的手机号')
return
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
message.warning('手机号格式不正确,请输入有效的11位手机号码')
return
}
if (!smsConfig.access_key_id?.trim()) {
message.warning('请先填写阿里云 AccessKey ID')
return
}
if (!smsConfig.has_secret && !smsSecretInput.trim()) {
message.warning('请先填写阿里云 AccessKey Secret')
return
}
if (!smsConfig.sign_name?.trim()) {
message.warning('请先填写短信签名')
return
}
if (!smsConfig.login_template_code?.trim()) {
message.warning('请先填写登录验证码模板 Code')
return
}
setTestingSMS(true)
try {
const payload: TestSiteSMSConfigPayload = {
phone,
enabled: true,
provider: smsConfig.provider || 'aliyun',
access_key_id: smsConfig.access_key_id.trim(),
endpoint: smsConfig.endpoint.trim() || 'dysmsapi.aliyuncs.com',
sign_name: smsConfig.sign_name.trim(),
login_template_code: smsConfig.login_template_code.trim(),
}
if (smsSecretInput.trim()) {
payload.access_key_secret = smsSecretInput.trim()
}
await testSiteSMSConfig(siteID, payload)
message.success('测试短信发送成功,请查收手机短信')
setTestCooldown(60)
} catch (err: unknown) {
const msg =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: '测试短信发送失败'
message.error(msg || '测试短信发送失败')
} finally {
setTestingSMS(false)
}
}
async function handleSave() { async function handleSave() {
if (!siteID) return if (!siteID) return
setSaving(true) setSaving(true)
@@ -299,6 +446,153 @@ export default function SiteSettings() {
</Space> </Space>
</Card> </Card>
<Card loading={loading}>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Space align="center">
<MessageOutlined style={{ fontSize: 18, color: '#1677ff' }} />
<Typography.Title level={5} style={{ margin: 0 }}>
独立短信验证码配置
</Typography.Title>
</Space>
<Typography.Paragraph type="secondary" style={{ marginTop: 4, marginBottom: 0 }}>
开启后,本分站访客登录/注册时将调用分站专属的阿里云短信模板与签名,并直接计入本站自己的阿里云短信额度;未开启时自动回退使用主站默认短信通道。
</Typography.Paragraph>
</div>
<Alert
type={smsConfig.enabled ? 'info' : 'warning'}
showIcon
message={
smsConfig.enabled
? '当前已启用独立短信服务:分站登录验证码将消耗您配置的阿里云额度,展示您的专属短信签名。'
: '当前未启用独立短信服务:分站将免费使用主站平台额度发送验证码(展示主站默认签名)。'
}
/>
<Form layout="vertical">
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item label="启用独立短信服务">
<Space>
<Switch
checked={smsConfig.enabled}
onChange={(val) => setSmsConfig({ ...smsConfig, enabled: val })}
/>
<Typography.Text type="secondary">
{smsConfig.enabled ? '已开启(优先使用分站凭据)' : '已停用(回退使用主站额度)'}
</Typography.Text>
</Space>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="短信服务商">
<Select
value={smsConfig.provider || 'aliyun'}
onChange={(val) => setSmsConfig({ ...smsConfig, provider: val })}
options={[{ label: '阿里云短信服务 (Aliyun Dysms)', value: 'aliyun' }]}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item label="短信签名 (SignName)" required={smsConfig.enabled} tooltip="在阿里云短信控制台申请并已通过审核的签名,例如:【XX租号】">
<Input
placeholder="例如:XX租号"
value={smsConfig.sign_name}
onChange={(e) => setSmsConfig({ ...smsConfig, sign_name: e.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="验证码模板 Code (TemplateCode)" required={smsConfig.enabled} tooltip="在阿里云申请的验证码模板,必须包含 ${code} 变量,例如:SMS_123456789">
<Input
placeholder="例如:SMS_123456789"
value={smsConfig.login_template_code}
onChange={(e) => setSmsConfig({ ...smsConfig, login_template_code: e.target.value })}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item label="阿里云 AccessKey ID" required={smsConfig.enabled} tooltip="具备 Dysms 权限的阿里云 RAM 用户 AccessKey ID">
<Input
placeholder="例如:LTAI5t..."
value={smsConfig.access_key_id}
onChange={(e) => setSmsConfig({ ...smsConfig, access_key_id: e.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
label="阿里云 AccessKey Secret"
required={smsConfig.enabled && !smsConfig.has_secret}
tooltip={smsConfig.has_secret ? `已保存密钥 (${smsConfig.masked_secret}),如需更换请输入新密钥,留空则保持不变` : '请输入阿里云 AccessKey Secret'}
>
<Input.Password
placeholder={smsConfig.has_secret ? '已设置密钥,留空保持不变' : '请输入 AccessKey Secret'}
value={smsSecretInput}
onChange={(e) => setSmsSecretInput(e.target.value)}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item label="API Endpoint (可选)" tooltip="默认 dysmsapi.aliyuncs.com">
<Input
placeholder="dysmsapi.aliyuncs.com"
value={smsConfig.endpoint}
onChange={(e) => setSmsConfig({ ...smsConfig, endpoint: e.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="连通性测试">
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入手机号测试接收"
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
/>
<Popconfirm
title="确认发送测试短信?"
description="将向填写的手机号发送一条随机验证码,并消耗该站点的阿里云短信额度。"
okText="确认发送"
cancelText="取消"
disabled={testingSMS || testCooldown > 0}
onConfirm={() => void handleTestSMS()}
>
<Button
icon={<SendOutlined />}
loading={testingSMS}
disabled={testCooldown > 0}
>
{testCooldown > 0 ? `重新发送(${testCooldown}s)` : '发送测试'}
</Button>
</Popconfirm>
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Button
type="primary"
icon={<SaveOutlined />}
loading={savingSMS}
onClick={() => void handleSaveSMS()}
>
保存短信配置
</Button>
</Form>
</Space>
</Card>
<Row gutter={24}> <Row gutter={24}>
{/* Left Visual Form Customizer */} {/* Left Visual Form Customizer */}
<Col xs={24} lg={14}> <Col xs={24} lg={14}>
@@ -301,3 +301,64 @@ export async function syncDistributionSiteAccounts(siteId: number, req?: SyncSit
) )
return data.data return data.data
} }
export interface DistributionSiteSMSConfig {
enabled: boolean
provider: string
access_key_id: string
masked_secret: string
has_secret: boolean
endpoint: string
sign_name: string
login_template_code: string
}
export interface UpdateDistributionSiteSMSConfigPayload {
enabled?: boolean
provider?: string
access_key_id?: string
access_key_secret?: string
endpoint?: string
sign_name?: string
login_template_code?: string
}
export interface TestDistributionSiteSMSConfigPayload {
phone: string
enabled?: boolean
provider?: string
access_key_id?: string
access_key_secret?: string
endpoint?: string
sign_name?: string
login_template_code?: string
}
export async function fetchDistributionSiteSMSConfig(siteId: number) {
const { data } = await apiClient.get<ApiResponse<DistributionSiteSMSConfig>>(
`/admin/distribution/sites/${siteId}/sms-config`
)
return data.data
}
export async function updateDistributionSiteSMSConfig(
siteId: number,
payload: UpdateDistributionSiteSMSConfigPayload
) {
const { data } = await apiClient.put<ApiResponse<DistributionSiteSMSConfig>>(
`/admin/distribution/sites/${siteId}/sms-config`,
payload
)
return data.data
}
export async function testDistributionSiteSMSConfig(
siteId: number,
payload: TestDistributionSiteSMSConfigPayload
) {
const { data } = await apiClient.post<ApiResponse<{ message: string }>>(
`/admin/distribution/sites/${siteId}/sms-config/test`,
payload
)
return data.data
}
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { import {
Plus, Plus,
Shop, Shop,
@@ -11,6 +11,7 @@ import {
Picture, Picture,
Upload, Upload,
Coin, Coin,
Message,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { import {
fetchDistributionSites, fetchDistributionSites,
@@ -18,6 +19,9 @@ import {
onboardDistributionSite, onboardDistributionSite,
syncDistributionSiteAccounts, syncDistributionSiteAccounts,
updateDistributionSite, updateDistributionSite,
fetchDistributionSiteSMSConfig,
updateDistributionSiteSMSConfig,
testDistributionSiteSMSConfig,
type DistributionSite, type DistributionSite,
type OnboardDistributionSitePayload, type OnboardDistributionSitePayload,
type OnboardDistributionSiteResult, type OnboardDistributionSiteResult,
@@ -63,6 +67,45 @@ const qrForm = reactive({
listing_qrcode_url: '', listing_qrcode_url: '',
}) })
const smsDialogVisible = ref(false)
const smsLoading = ref(false)
const smsSubmitting = ref(false)
const smsTesting = ref(false)
const smsTestCooldown = ref(0)
let smsCooldownTimer: ReturnType<typeof setInterval> | null = null
function startSmsCooldown(seconds = 60) {
smsTestCooldown.value = seconds
if (smsCooldownTimer) clearInterval(smsCooldownTimer)
smsCooldownTimer = setInterval(() => {
if (smsTestCooldown.value > 0) {
smsTestCooldown.value--
} else if (smsCooldownTimer) {
clearInterval(smsCooldownTimer)
smsCooldownTimer = null
}
}, 1000)
}
onUnmounted(() => {
if (smsCooldownTimer) clearInterval(smsCooldownTimer)
})
const smsSecretInput = ref('')
const smsTestPhone = ref('')
const smsForm = reactive({
site_id: 0,
site_name: '',
enabled: false,
provider: 'aliyun',
sign_name: '',
login_template_code: '',
access_key_id: '',
masked_secret: '',
has_secret: false,
endpoint: '',
})
function handleOpenSettlement(row: DistributionSite) { function handleOpenSettlement(row: DistributionSite) {
settlementForm.site_id = row.id settlementForm.site_id = row.id
settlementForm.site_name = row.site_name settlementForm.site_name = row.site_name
@@ -154,6 +197,143 @@ async function handleQrcodeSubmit() {
} }
} }
async function handleOpenSMS(row: DistributionSite) {
smsForm.site_id = row.id
smsForm.site_name = row.site_name
smsSecretInput.value = ''
smsTestPhone.value = ''
smsDialogVisible.value = true
smsLoading.value = true
try {
const cfg = await fetchDistributionSiteSMSConfig(row.id)
smsForm.enabled = cfg.enabled
smsForm.provider = cfg.provider || 'aliyun'
smsForm.sign_name = cfg.sign_name || ''
smsForm.login_template_code = cfg.login_template_code || ''
smsForm.access_key_id = cfg.access_key_id || ''
smsForm.masked_secret = cfg.masked_secret || ''
smsForm.has_secret = cfg.has_secret
smsForm.endpoint = cfg.endpoint || ''
} catch (err) {
ElMessage.error(readError(err, '读取分站短信配置失败'))
} finally {
smsLoading.value = false
}
}
async function handleSMSSubmit() {
if (smsForm.enabled) {
if (!smsForm.sign_name.trim()) {
ElMessage.warning('启用独立短信时,短信签名不能为空')
return
}
if (!smsForm.login_template_code.trim()) {
ElMessage.warning('启用独立短信时,登录验证码模板 Code 不能为空')
return
}
if (!smsForm.access_key_id.trim()) {
ElMessage.warning('启用独立短信时,AccessKey ID 不能为空')
return
}
if (!smsForm.has_secret && !smsSecretInput.value.trim()) {
ElMessage.warning('启用独立短信时,AccessKey Secret 不能为空')
return
}
}
smsSubmitting.value = true
try {
const payload: {
enabled: boolean
provider: string
sign_name: string
login_template_code: string
access_key_id: string
access_key_secret?: string
endpoint?: string
} = {
enabled: smsForm.enabled,
provider: smsForm.provider || 'aliyun',
sign_name: smsForm.sign_name.trim(),
login_template_code: smsForm.login_template_code.trim(),
access_key_id: smsForm.access_key_id.trim(),
endpoint: smsForm.endpoint.trim(),
}
if (smsSecretInput.value.trim()) {
payload.access_key_secret = smsSecretInput.value.trim()
}
await updateDistributionSiteSMSConfig(smsForm.site_id, payload)
ElMessage.success(`【${smsForm.site_name}】短信配置已保存`)
smsDialogVisible.value = false
} catch (err) {
ElMessage.error(readError(err, '保存短信配置失败'))
} finally {
smsSubmitting.value = false
}
}
async function handleSMSTest() {
const phone = smsTestPhone.value.trim()
if (!phone) {
ElMessage.warning('请输入接收测试短信的手机号')
return
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
ElMessage.warning('手机号格式不正确,请输入有效的11位手机号码')
return
}
if (!smsForm.access_key_id.trim()) {
ElMessage.warning('请先填写 AccessKey ID')
return
}
if (!smsForm.has_secret && !smsSecretInput.value.trim()) {
ElMessage.warning('请先填写 AccessKey Secret')
return
}
if (!smsForm.sign_name.trim()) {
ElMessage.warning('请先填写短信签名')
return
}
if (!smsForm.login_template_code.trim()) {
ElMessage.warning('请先填写验证码模板 Code')
return
}
try {
await ElMessageBox.confirm(
`将向手机号 ${phone} 发送一条测试验证码,并消耗该站点的阿里云短信额度,是否继续?`,
'确认发送测试短信',
{
confirmButtonText: '确认发送',
cancelButtonText: '取消',
type: 'info',
}
)
} catch {
return
}
smsTesting.value = true
try {
const payload = {
phone,
enabled: true,
provider: smsForm.provider || 'aliyun',
sign_name: smsForm.sign_name.trim(),
login_template_code: smsForm.login_template_code.trim(),
access_key_id: smsForm.access_key_id.trim(),
endpoint: smsForm.endpoint.trim() || undefined,
access_key_secret: smsSecretInput.value.trim() || undefined,
}
await testDistributionSiteSMSConfig(smsForm.site_id, payload)
ElMessage.success('测试短信发送成功,请查收手机短信')
startSmsCooldown(60)
} catch (err) {
ElMessage.error(readError(err, '测试短信发送失败'))
} finally {
smsTesting.value = false
}
}
const onboardForm = reactive({ const onboardForm = reactive({
site_name: '', site_name: '',
site_code: '', site_code: '',
@@ -377,7 +557,7 @@ onMounted(() => {
{{ formatDateTime(row.created_at) }} {{ formatDateTime(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="340" fixed="right"> <el-table-column label="操作" width="410" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
type="primary" type="primary"
@@ -397,6 +577,9 @@ onMounted(() => {
<el-button type="primary" link :icon="Picture" @click="handleOpenQrcode(row)"> <el-button type="primary" link :icon="Picture" @click="handleOpenQrcode(row)">
二维码 二维码
</el-button> </el-button>
<el-button type="primary" link :icon="Message" @click="handleOpenSMS(row)">
短信配置
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -663,6 +846,82 @@ onMounted(() => {
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 短信配置对话框 -->
<el-dialog
v-model="smsDialogVisible"
title="分销站点独立短信服务配置"
width="620px"
destroy-on-close
>
<div v-loading="smsLoading">
<el-alert
type="info"
:closable="false"
show-icon
title="启用后,该分站用户的登录注册验证码将使用独立的签名与模板,并直接计入该分站的阿里云短信额度;未启用时自动回退使用主站默认短信通道。"
style="margin-bottom: 16px"
/>
<el-form label-width="130px">
<el-form-item label="分销站点">
<el-input :model-value="smsForm.site_name" disabled />
</el-form-item>
<el-form-item label="启用独立短信">
<el-switch v-model="smsForm.enabled" />
<span style="margin-left: 12px; font-size: 13px; color: #6b7280">
{{ smsForm.enabled ? '已启用(优先使用分站凭据)' : '未启用(回退使用主站额度)' }}
</span>
</el-form-item>
<el-form-item label="短信服务商">
<el-select v-model="smsForm.provider" style="width: 100%">
<el-option label="阿里云短信服务 (Aliyun Dysms)" value="aliyun" />
</el-select>
</el-form-item>
<el-form-item label="短信签名" :required="smsForm.enabled">
<el-input v-model="smsForm.sign_name" placeholder="例如:XX租号" />
</el-form-item>
<el-form-item label="验证码模板 Code" :required="smsForm.enabled">
<el-input v-model="smsForm.login_template_code" placeholder="例如:SMS_123456789" />
</el-form-item>
<el-form-item label="AccessKey ID" :required="smsForm.enabled">
<el-input v-model="smsForm.access_key_id" placeholder="例如:LTAI5t..." />
</el-form-item>
<el-form-item label="AccessKey Secret" :required="smsForm.enabled && !smsForm.has_secret">
<el-input
v-model="smsSecretInput"
type="password"
show-password
:placeholder="
smsForm.has_secret
? `已设置密钥 (${smsForm.masked_secret}),留空保持不变`
: '请输入 AccessKey Secret'
"
/>
</el-form-item>
<el-form-item label="API Endpoint">
<el-input v-model="smsForm.endpoint" placeholder="默认 dysmsapi.aliyuncs.com" />
</el-form-item>
<el-form-item label="连通性测试">
<div style="display: flex; gap: 8px; width: 100%">
<el-input v-model="smsTestPhone" placeholder="输入接收测试短信的手机号" />
<el-button
:loading="smsTesting"
:disabled="smsTestCooldown > 0"
@click="handleSMSTest"
>
{{ smsTestCooldown > 0 ? `重新发送(${smsTestCooldown}s)` : '发送测试' }}
</el-button>
</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="smsDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="smsSubmitting" @click="handleSMSSubmit">
保存设置
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>