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