diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index 2730b9d..3570654 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -17,6 +17,7 @@ type DistributionSite struct { FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"` DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"` + SMSConfigJSON string `gorm:"type:text" json:"-"` ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"` TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"` TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index 286a630..068e12e 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "net" "net/http" "strings" @@ -18,6 +19,7 @@ type SendSMSRequest struct { Phone string `json:"phone" binding:"required"` CaptchaID string `json:"captcha_id" binding:"required"` CaptchaCode string `json:"captcha_code" binding:"required"` + SiteCode string `json:"site_code"` } type LoginRequest struct { @@ -83,13 +85,52 @@ func (h *Handler) SendSMS(c *gin.Context) { response.BadRequest(c, "手机号和图形验证码不能为空") return } - if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode)); err != nil { + + siteCode := strings.TrimSpace(firstNonEmpty( + c.GetHeader("X-Distribution-Site-Code"), + c.Query("distribution_site_code"), + c.Query("site_code"), + req.SiteCode, + )) + domain := strings.TrimSpace(firstNonEmpty( + c.GetHeader("X-Distribution-Domain"), + c.Query("distribution_domain"), + c.Query("domain"), + )) + if siteCode == "" && domain == "" { + host := extractHost(c.Request.Host) + if host != "" && host != "localhost" && host != "127.0.0.1" && host != "::1" { + domain = host + } + } + + if err := h.service.SendSMSCode(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), siteCode, domain); err != nil { writeAuthError(c, err) return } response.OK(c, gin.H{"phone": PublicPhone(req.Phone), "expires_in": 300}) } +func firstNonEmpty(items ...string) string { + for _, item := range items { + if strings.TrimSpace(item) != "" { + return item + } + } + return "" +} + +func extractHost(rawHost string) string { + rawHost = strings.TrimSpace(rawHost) + if rawHost == "" { + return "" + } + if host, _, err := net.SplitHostPort(rawHost); err == nil { + return strings.Trim(host, "[]") + } + return strings.Trim(rawHost, "[]") +} + // Login 短信验证码登录 // @Summary 短信验证码登录 // @Description 使用手机号和验证码登录 diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index ec44b49..8551ee2 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -7,6 +7,7 @@ import ( "fmt" "math/big" "regexp" + "strings" "time" "hfb_sys/backend/internal/captcha" @@ -48,12 +49,18 @@ const ( loginMaxFailureCount = 5 ) +// SiteSMSProviderResolver 用于根据分销站点标识解析专属 SMS Provider +type SiteSMSProviderResolver interface { + ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error) +} + type Service struct { - users *UserRepository - redis *redis.Client - jwt *JWTManager - sms smsprovider.Provider - log *zap.Logger + users *UserRepository + redis *redis.Client + jwt *JWTManager + sms smsprovider.Provider + smsResolver SiteSMSProviderResolver + log *zap.Logger } type LoginResult struct { @@ -65,6 +72,10 @@ func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, sms return &Service{users: users, redis: redis, jwt: jwt, sms: sms, log: log} } +func (s *Service) SetSiteSMSResolver(resolver SiteSMSProviderResolver) { + s.smsResolver = resolver +} + func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) { if s.redis == nil { return nil, ErrDependencyUnavailable @@ -76,7 +87,7 @@ func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) { return item, err } -func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode string) error { +func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode string, siteContext ...string) error { if s.redis == nil { return ErrDependencyUnavailable } @@ -117,14 +128,39 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin return err } - if s.sms == nil { + provider := s.sms + siteCode := "" + domain := "" + if len(siteContext) > 0 { + siteCode = strings.TrimSpace(siteContext[0]) + } + if len(siteContext) > 1 { + domain = strings.TrimSpace(siteContext[1]) + } + if s.smsResolver != nil && (siteCode != "" || domain != "") { + customProvider, resolveErr := s.smsResolver.ResolveSMSProvider(ctx, siteCode, domain) + if resolveErr != nil { + if s.log != nil { + s.log.Warn("解析分销站点独立短信服务商失败,将回退至主站默认通道", + zap.String("site_code", siteCode), + zap.String("domain", domain), + zap.Error(resolveErr), + ) + } + } else if customProvider != nil { + provider = customProvider + } + } + + if provider == nil { return ErrDependencyUnavailable } - if err := s.sms.SendLoginCode(ctx, phone, code); err != nil { + if err := provider.SendLoginCode(ctx, phone, code); err != nil { if s.log != nil { fields := []zap.Field{ zap.String("module", "auth"), zap.String("phone", PublicPhone(phone)), + zap.String("site_code", siteCode), zap.Error(err), } if requestID := logging.RequestIDFromContext(ctx); requestID != "" { diff --git a/backend/internal/modules/auth/sms_resolver_test.go b/backend/internal/modules/auth/sms_resolver_test.go new file mode 100644 index 0000000..0af99fe --- /dev/null +++ b/backend/internal/modules/auth/sms_resolver_test.go @@ -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) + } +} diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index b6b94a8..7cdedb1 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -519,3 +519,36 @@ type DistributionTrendPoint struct { GMVCent int64 `json:"gmv_cent"` ProfitCent int64 `json:"profit_cent"` } + +// 分销站点独立短信配置结构体 +type SiteSMSConfigDTO struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + AccessKeyID string `json:"access_key_id"` + MaskedSecret string `json:"masked_secret"` + HasSecret bool `json:"has_secret"` + Endpoint string `json:"endpoint"` + SignName string `json:"sign_name"` + LoginTemplateCode string `json:"login_template_code"` +} + +type UpdateSiteSMSConfigRequest struct { + Enabled *bool `json:"enabled"` + Provider *string `json:"provider"` + AccessKeyID *string `json:"access_key_id"` + AccessKeySecret *string `json:"access_key_secret"` + Endpoint *string `json:"endpoint"` + SignName *string `json:"sign_name"` + LoginTemplateCode *string `json:"login_template_code"` +} + +type TestSiteSMSConfigRequest struct { + Phone string `json:"phone" binding:"required"` + Enabled *bool `json:"enabled"` + Provider *string `json:"provider"` + AccessKeyID *string `json:"access_key_id"` + AccessKeySecret *string `json:"access_key_secret"` + Endpoint *string `json:"endpoint"` + SignName *string `json:"sign_name"` + LoginTemplateCode *string `json:"login_template_code"` +} diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index c97f7d5..7aa473c 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -82,6 +82,57 @@ func (h *Handler) UpdateSite(c *gin.Context) { response.OK(c, item) } +func (h *Handler) GetSiteSMSConfig(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + adminID, _ := currentAdminID(c) + item, err := h.service.GetSiteSMSConfig(c.Request.Context(), adminID, id) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) UpdateSiteSMSConfig(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req UpdateSiteSMSConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "短信配置参数不正确") + return + } + adminID, _ := currentAdminID(c) + item, err := h.service.UpdateSiteSMSConfig(c.Request.Context(), adminID, id, req) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) TestSiteSMSConfig(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req TestSiteSMSConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "测试参数不正确,手机号必填") + return + } + adminID, _ := currentAdminID(c) + if err := h.service.TestSiteSMSConfig(c.Request.Context(), adminID, id, req); err != nil { + writeError(c, err) + return + } + response.OK(c, gin.H{"message": "测试短信发送成功"}) +} + func (h *Handler) ListRules(c *gin.Context) { var siteID *uint64 if raw := strings.TrimSpace(c.Query("site_id")); raw != "" { @@ -828,6 +879,12 @@ func writeError(c *gin.Context, err error) { response.BadRequest(c, "请选择要加价的商品") case errors.Is(err, ErrInvalidListingStatus): response.BadRequest(c, "商品展示状态不正确") + case errors.Is(err, ErrSMSConfigInvalid): + response.BadRequest(c, "分销短信配置不完整,启用时必须配置 AccessKey、签名与模板") + case errors.Is(err, ErrSMSTestRateLimited): + response.Error(c, http.StatusTooManyRequests, "rate_limited", "测试短信发送过于频繁,请稍后再试") + case errors.Is(err, ErrSMSSendFailed): + response.Error(c, http.StatusBadGateway, "sms_send_failed", "短信测试发送失败,请检查配置与通道额度") case errors.Is(err, ErrSiteAccessDenied): response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点") case errors.Is(err, ErrDependencyUnavailable): diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index b459f0c..44c33b0 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -17,6 +17,7 @@ import ( listingpkg "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/systemconfig" "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/pkg/crypto" "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" @@ -75,6 +76,8 @@ func normalizeReceiveRatioDelta(delta float64) (float64, error) { type Repository struct { db *gorm.DB redis *redis.Client + encryptor crypto.Encryptor + allowMockSMS bool siteSyncLocks sync.Map } @@ -86,6 +89,65 @@ func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository { return r } +func (r *Repository) SetEncryptor(enc crypto.Encryptor) { + if r != nil { + r.encryptor = enc + } +} + +// SetAllowMockSMS 设置是否允许使用 mock 短信服务商,仅非生产环境应开启。 +func (r *Repository) SetAllowMockSMS(allow bool) { + if r != nil { + r.allowMockSMS = allow + } +} + +// validateSMSProvider 校验并规范化短信服务商;mock 仅在显式允许时可用(非生产环境)。 +func (r *Repository) validateSMSProvider(provider string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(provider)) + if normalized == "" { + return "aliyun", nil + } + switch normalized { + case "aliyun": + return normalized, nil + case "mock": + if r != nil && r.allowMockSMS { + return normalized, nil + } + return "", fmt.Errorf("%w: 当前环境不允许使用 mock 短信服务商", ErrSMSConfigInvalid) + default: + return "", fmt.Errorf("%w: 不支持的短信服务商 %q", ErrSMSConfigInvalid, provider) + } +} + +func (r *Repository) encryptSecret(plaintext string) (string, error) { + plaintext = strings.TrimSpace(plaintext) + if plaintext == "" { + return "", nil + } + if r != nil && r.encryptor != nil { + return r.encryptor.Encrypt(plaintext) + } + return plaintext, nil +} + +// decryptSecret 解密访问密钥。解密失败时返回错误,避免把密文当明文密钥继续使用。 +func (r *Repository) decryptSecret(ciphertext string) (string, error) { + ciphertext = strings.TrimSpace(ciphertext) + if ciphertext == "" { + return "", nil + } + if r != nil && r.encryptor != nil { + plain, err := r.encryptor.Decrypt(ciphertext) + if err != nil { + return "", fmt.Errorf("解密短信访问密钥失败: %w", err) + } + return plain, nil + } + return ciphertext, nil +} + func (r *Repository) hasGlobalDistributionAccess(ctx context.Context, adminID uint64) (bool, error) { return r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID) } @@ -524,6 +586,139 @@ func siteDTO(row model.DistributionSite) SiteDTO { } } +func (r *Repository) GetSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfigDTO, error) { + cfg, err := r.GetDecryptedSiteSMSConfig(ctx, adminID, siteID) + if err != nil { + return nil, err + } + return cfg.ToDTO(), nil +} + +func (r *Repository) GetDecryptedSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfig, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + var row model.DistributionSite + if err := r.db.WithContext(ctx).First(&row, siteID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSiteNotFound + } + return nil, err + } + if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil { + return nil, err + } + + if strings.TrimSpace(row.SMSConfigJSON) == "" { + return &SiteSMSConfig{ + Enabled: false, + Provider: "aliyun", + Endpoint: "dysmsapi.aliyuncs.com", + }, nil + } + + cfg, err := ParseSiteSMSConfig(row.SMSConfigJSON) + if err != nil { + return nil, err + } + plainSecret, err := r.decryptSecret(cfg.AccessKeySecret) + if err != nil { + return nil, err + } + cfg.AccessKeySecret = plainSecret + return cfg, nil +} + +func (r *Repository) UpdateSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req UpdateSiteSMSConfigRequest) (*SiteSMSConfigDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + var row model.DistributionSite + if err := r.db.WithContext(ctx).First(&row, siteID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSiteNotFound + } + return nil, err + } + if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil { + return nil, err + } + + cfg, err := ParseSiteSMSConfig(row.SMSConfigJSON) + if err != nil { + return nil, fmt.Errorf("解析现有短信配置失败: %w", err) + } + if cfg == nil { + cfg = &SiteSMSConfig{} + } + + existingSecret, err := r.decryptSecret(cfg.AccessKeySecret) + if err != nil { + return nil, err + } + + if req.Enabled != nil { + cfg.Enabled = *req.Enabled + } + if req.Provider != nil { + cfg.Provider = strings.TrimSpace(*req.Provider) + } + if req.AccessKeyID != nil { + cfg.AccessKeyID = strings.TrimSpace(*req.AccessKeyID) + } + if req.AccessKeySecret != nil && strings.TrimSpace(*req.AccessKeySecret) != "" { + existingSecret = strings.TrimSpace(*req.AccessKeySecret) + } + if req.Endpoint != nil { + cfg.Endpoint = strings.TrimSpace(*req.Endpoint) + } + if req.SignName != nil { + cfg.SignName = strings.TrimSpace(*req.SignName) + } + if req.LoginTemplateCode != nil { + cfg.LoginTemplateCode = strings.TrimSpace(*req.LoginTemplateCode) + } + + // 完整性校验:必须属于合法 provider 白名单,且开启时必须提供完整凭据 + providerType, err := r.validateSMSProvider(cfg.Provider) + if err != nil { + return nil, err + } + cfg.Provider = providerType + + if cfg.Enabled { + if providerType == "aliyun" { + if strings.TrimSpace(cfg.AccessKeyID) == "" || + strings.TrimSpace(existingSecret) == "" || + strings.TrimSpace(cfg.SignName) == "" || + strings.TrimSpace(cfg.LoginTemplateCode) == "" { + return nil, ErrSMSConfigInvalid + } + } + } + + encSecret, err := r.encryptSecret(existingSecret) + if err != nil { + return nil, fmt.Errorf("加密短信密钥失败: %w", err) + } + cfg.AccessKeySecret = encSecret + + bytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + row.SMSConfigJSON = string(bytes) + if err := r.db.WithContext(ctx).Model(&row).Update("sms_config_json", row.SMSConfigJSON).Error; err != nil { + return nil, err + } + + r.evictSiteInfoCache(ctx, &row) + + dtoCfg := *cfg + dtoCfg.AccessKeySecret = existingSecret + return dtoCfg.ToDTO(), nil +} + func ruleDTO(row model.DistributionRuleVersion) RuleDTO { return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt} } diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 0d43929..6035dc5 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -3,15 +3,18 @@ package distribution import ( "context" "errors" + "strings" + smsprovider "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/modules/auth" ) var ErrDependencyUnavailable = errors.New("distribution dependency unavailable") type Service struct { - repo *Repository - jwt *auth.JWTManager + repo *Repository + jwt *auth.JWTManager + smsResolver *SMSResolver } func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service { @@ -22,6 +25,22 @@ func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service { return s } +func (s *Service) SetSMSResolver(resolver *SMSResolver) { + s.smsResolver = resolver +} + +func (s *Service) SMSResolver() *SMSResolver { + return s.smsResolver +} + +// ResolveSMSProvider 实现 auth.SiteSMSProviderResolver 接口 +func (s *Service) ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error) { + if s == nil || s.smsResolver == nil { + return nil, nil + } + return s.smsResolver.ResolveSMSProvider(ctx, siteCode, domain) +} + func (s *Service) available() error { if s == nil || s.repo == nil { return ErrDependencyUnavailable @@ -323,3 +342,95 @@ func (s *Service) GetDashboardAnalytics(ctx context.Context, adminID, siteID uin } return s.repo.GetDashboardAnalytics(ctx, adminID, siteID) } + +func (s *Service) GetSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfigDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.GetSiteSMSConfig(ctx, adminID, siteID) +} + +func (s *Service) UpdateSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req UpdateSiteSMSConfigRequest) (*SiteSMSConfigDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + dto, err := s.repo.UpdateSiteSMSConfig(ctx, adminID, siteID, req) + if err != nil { + return nil, err + } + if s.smsResolver != nil { + s.smsResolver.InvalidateCache(siteID) + } + return dto, nil +} + +func (s *Service) TestSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req TestSiteSMSConfigRequest) error { + if err := s.available(); err != nil { + return err + } + if s.smsResolver == nil { + return errors.New("sms resolver unavailable") + } + + phone := strings.TrimSpace(req.Phone) + if phone == "" { + return errors.New("测试手机号不能为空") + } + + // 先获取当前站点已持久化的配置及已解密的密钥 + existingCfg, err := s.repo.GetDecryptedSiteSMSConfig(ctx, adminID, siteID) + if err != nil { + return err + } + + // 构造测试配置(优先取请求参数,缺省回退至已有配置) + testCfg := SiteSMSConfig{ + Enabled: true, + } + if req.Provider != nil && strings.TrimSpace(*req.Provider) != "" { + testCfg.Provider = strings.TrimSpace(*req.Provider) + } else if existingCfg != nil && existingCfg.Provider != "" { + testCfg.Provider = existingCfg.Provider + } + if req.AccessKeyID != nil && strings.TrimSpace(*req.AccessKeyID) != "" { + testCfg.AccessKeyID = strings.TrimSpace(*req.AccessKeyID) + } else if existingCfg != nil { + testCfg.AccessKeyID = existingCfg.AccessKeyID + } + if req.AccessKeySecret != nil && strings.TrimSpace(*req.AccessKeySecret) != "" { + testCfg.AccessKeySecret = strings.TrimSpace(*req.AccessKeySecret) + } else if existingCfg != nil { + testCfg.AccessKeySecret = existingCfg.AccessKeySecret + } + if req.Endpoint != nil && strings.TrimSpace(*req.Endpoint) != "" { + testCfg.Endpoint = strings.TrimSpace(*req.Endpoint) + } else if existingCfg != nil { + testCfg.Endpoint = existingCfg.Endpoint + } + if req.SignName != nil && strings.TrimSpace(*req.SignName) != "" { + testCfg.SignName = strings.TrimSpace(*req.SignName) + } else if existingCfg != nil { + testCfg.SignName = existingCfg.SignName + } + if req.LoginTemplateCode != nil && strings.TrimSpace(*req.LoginTemplateCode) != "" { + testCfg.LoginTemplateCode = strings.TrimSpace(*req.LoginTemplateCode) + } else if existingCfg != nil { + testCfg.LoginTemplateCode = existingCfg.LoginTemplateCode + } + + providerType, err := s.repo.validateSMSProvider(testCfg.Provider) + if err != nil { + return err + } + testCfg.Provider = providerType + if providerType == "aliyun" { + if strings.TrimSpace(testCfg.AccessKeyID) == "" || + strings.TrimSpace(testCfg.AccessKeySecret) == "" || + strings.TrimSpace(testCfg.SignName) == "" || + strings.TrimSpace(testCfg.LoginTemplateCode) == "" { + return ErrSMSConfigInvalid + } + } + + return s.smsResolver.TestSendSMS(ctx, siteID, testCfg, phone) +} diff --git a/backend/internal/modules/distribution/sms_resolver.go b/backend/internal/modules/distribution/sms_resolver.go new file mode 100644 index 0000000..2521e02 --- /dev/null +++ b/backend/internal/modules/distribution/sms_resolver.go @@ -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 +} diff --git a/backend/internal/modules/distribution/sms_resolver_test.go b/backend/internal/modules/distribution/sms_resolver_test.go new file mode 100644 index 0000000..06de0f9 --- /dev/null +++ b/backend/internal/modules/distribution/sms_resolver_test.go @@ -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) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2e988cc..4ede3c6 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -317,8 +317,17 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { var distributionRepo *distribution.Repository if deps.DB != nil { distributionRepo = distribution.NewRepository(deps.DB, deps.Redis) + distributionRepo.SetEncryptor(fieldEncryptor) + distributionRepo.SetAllowMockSMS(!config.IsProductionEnv(cfg.AppEnv)) } distributionService := distribution.NewService(distributionRepo, jwtManager) + if distributionRepo != nil { + distributionResolver := distribution.NewSMSResolver(distributionRepo, logger) + distributionService.SetSMSResolver(distributionResolver) + if authService != nil { + authService.SetSiteSMSResolver(distributionService) + } + } distributionHandler := distribution.NewHandler(distributionService) backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile) var adminRoleRepo *adminrole.Repository @@ -647,6 +656,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/distribution/sites/:id/sync-accounts", requirePerm("distribution:account_display"), distributionHandler.SyncSiteAccounts) adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite) adminRoutes.PUT("/distribution/sites/:id/branding", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteBranding) + adminRoutes.GET("/distribution/sites/:id/sms-config", requirePerm("distribution:site_view"), distributionHandler.GetSiteSMSConfig) + adminRoutes.PUT("/distribution/sites/:id/sms-config", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteSMSConfig) + adminRoutes.POST("/distribution/sites/:id/sms-config/test", requirePerm("distribution:site_manage"), distributionHandler.TestSiteSMSConfig) adminRoutes.GET("/distribution/sites/:id/wallet", requirePerm("distribution:settlement_view"), distributionHandler.GetSiteWallet) adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal) adminRoutes.GET("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_view"), distributionHandler.ListSiteWithdrawals) diff --git a/backend/migrations/000071_distribution_site_sms_config.sql b/backend/migrations/000071_distribution_site_sms_config.sql new file mode 100644 index 0000000..0f46211 --- /dev/null +++ b/backend/migrations/000071_distribution_site_sms_config.sql @@ -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; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index fff60ba..28c10fe 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -692,3 +692,50 @@ export async function fetchDashboardAnalytics(siteID: number): Promise>(`/admin/distribution/sites/${siteID}/analytics`) 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 { + const resp = await api.get>(`/admin/distribution/sites/${siteID}/sms-config`) + return extract(resp) +} + +export async function updateSiteSMSConfig(siteID: number, payload: UpdateSiteSMSConfigPayload): Promise { + const resp = await api.put>(`/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>(`/admin/distribution/sites/${siteID}/sms-config/test`, payload) + return extract(resp) +} diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx index 9e5fd7a..34aa826 100644 --- a/distribution-admin/src/pages/SiteSettings.tsx +++ b/distribution-admin/src/pages/SiteSettings.tsx @@ -16,6 +16,9 @@ import { Tag, Tabs, Collapse, + Switch, + Alert, + Popconfirm, } from 'antd' import { SaveOutlined, @@ -27,14 +30,22 @@ import { InfoCircleOutlined, SkinOutlined, GlobalOutlined, + MessageOutlined, + SendOutlined, } from '@ant-design/icons' import { listSites, updateSiteBranding, updateSite, getMe, + fetchSiteSMSConfig, + updateSiteSMSConfig, + testSiteSMSConfig, type Site, type SiteBrandingPayload, + type SiteSMSConfig, + type UpdateSiteSMSConfigPayload, + type TestSiteSMSConfigPayload, } from '../api' import { useMember } from '../MemberContext' @@ -76,6 +87,31 @@ export default function SiteSettings() { const [receiveRatioDelta, setReceiveRatioDelta] = useState(0) const [savingRatio, setSavingRatio] = useState(false) + // SMS config state + const [smsConfig, setSmsConfig] = useState({ + 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) => { setLoading(true) try { @@ -105,6 +141,14 @@ export default function SiteSettings() { setFooterInfo({}) } setReceiveRatioDelta(Math.max(0, found.receive_ratio_delta || 0)) + + try { + const sms = await fetchSiteSMSConfig(id) + setSmsConfig(sms) + setSmsSecretInput('') + } catch { + // ignore + } } } catch { 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() { if (!siteID) return setSaving(true) @@ -299,6 +446,153 @@ export default function SiteSettings() { + + +
+ + + + 独立短信验证码配置 + + + + 开启后,本分站访客登录/注册时将调用分站专属的阿里云短信模板与签名,并直接计入本站自己的阿里云短信额度;未开启时自动回退使用主站默认短信通道。 + +
+ + + +
+ + + + + setSmsConfig({ ...smsConfig, enabled: val })} + /> + + {smsConfig.enabled ? '已开启(优先使用分站凭据)' : '已停用(回退使用主站额度)'} + + + + + + + setSmsConfig({ ...smsConfig, sign_name: e.target.value })} + /> + + + + + setSmsConfig({ ...smsConfig, login_template_code: e.target.value })} + /> + + + + + + + + setSmsConfig({ ...smsConfig, access_key_id: e.target.value })} + /> + + + + + setSmsSecretInput(e.target.value)} + /> + + + + + + + + setSmsConfig({ ...smsConfig, endpoint: e.target.value })} + /> + + + + + + setTestPhone(e.target.value)} + /> + 0} + onConfirm={() => void handleTestSMS()} + > + + + + + + + + +
+
+
+ {/* Left Visual Form Customizer */} diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index af657b6..9f3c25a 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -301,3 +301,64 @@ export async function syncDistributionSiteAccounts(siteId: number, req?: SyncSit ) 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>( + `/admin/distribution/sites/${siteId}/sms-config` + ) + return data.data +} + +export async function updateDistributionSiteSMSConfig( + siteId: number, + payload: UpdateDistributionSiteSMSConfigPayload +) { + const { data } = await apiClient.put>( + `/admin/distribution/sites/${siteId}/sms-config`, + payload + ) + return data.data +} + +export async function testDistributionSiteSMSConfig( + siteId: number, + payload: TestDistributionSiteSMSConfigPayload +) { + const { data } = await apiClient.post>( + `/admin/distribution/sites/${siteId}/sms-config/test`, + payload + ) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 290cf47..97f9eb8 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -1,6 +1,6 @@