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 }