package distribution import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "math/big" "strings" "sync" "time" smsprovider "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/model" smsnode "hfb_sys/backend/internal/modules/sms" "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"` // BusinessTemplates 站点自有的节点短信模板 CODE,key 为 sms 模块登记的节点标识。 // 站点启用独立短信后,这里配置了模板的节点走站点通道与站点签名; // 未配置的节点回退主站通道与主站模板。 BusinessTemplates map[string]string `json:"business_templates,omitempty"` } // normalizeBusinessTemplates 只保留已登记节点且模板 CODE 非空的配置项。 func normalizeBusinessTemplates(in map[string]string) map[string]string { if len(in) == 0 { return nil } out := make(map[string]string, len(in)) for code, template := range in { code = strings.TrimSpace(code) template = strings.TrimSpace(template) if code == "" || template == "" || !smsnode.IsKnownNode(code) { continue } out[code] = template } if len(out) == 0 { return nil } return out } // 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, BusinessTemplates: normalizeBusinessTemplates(c.BusinessTemplates), } } // 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 } cfg.BusinessTemplates = normalizeBusinessTemplates(cfg.BusinessTemplates) 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) } } // cachedSMSProvider 是站点 Provider 的缓存项。 // fingerprint 取自站点凭据与关键字段,凭据轮换后指纹变化即自动重建, // 避免「后台改了 AccessKey,长期运行的任务仍在用旧缓存」。 type cachedSMSProvider struct { provider smsprovider.Provider fingerprint string } // cacheFingerprint 用凭据密文与关键字段生成缓存指纹。 // 密文即每次保存都会变化,因此重复保存同一份配置最多只是重建一次 Provider,不影响正确性。 func (c *SiteSMSConfig) cacheFingerprint() string { raw := strings.Join([]string{ strings.TrimSpace(c.Provider), strings.TrimSpace(c.AccessKeyID), strings.TrimSpace(c.AccessKeySecret), strings.TrimSpace(c.Endpoint), strings.TrimSpace(c.SignName), strings.TrimSpace(c.LoginTemplateCode), }, "|") sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } // SMSResolver 管理分销站点的 SMS Provider 实例与缓存 type SMSResolver struct { repo *Repository logger *zap.Logger mu sync.RWMutex providers map[uint64]cachedSMSProvider testCooldown sync.Map } func NewSMSResolver(repo *Repository, logger *zap.Logger) *SMSResolver { return &SMSResolver{ repo: repo, logger: logger, providers: make(map[uint64]cachedSMSProvider), } } // InvalidateCache 清除特定站点的 Provider 缓存(配置更新时调用) func (r *SMSResolver) InvalidateCache(siteID uint64) { r.mu.Lock() defer r.mu.Unlock() delete(r.providers, siteID) } // cached 返回指纹一致的缓存 Provider,指纹不一致(凭据已轮换)时返回 nil 由调用方重建。 func (r *SMSResolver) cached(siteID uint64, fingerprint string) smsprovider.Provider { r.mu.RLock() entry, ok := r.providers[siteID] r.mu.RUnlock() if !ok || entry.provider == nil || entry.fingerprint != fingerprint { return nil } return entry.provider } func (r *SMSResolver) store(siteID uint64, fingerprint string, provider smsprovider.Provider) { r.mu.Lock() r.providers[siteID] = cachedSMSProvider{provider: provider, fingerprint: fingerprint} r.mu.Unlock() } // 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 } // 检查内存缓存;凭据已轮换(指纹不一致)时视为未命中,重建 Provider fingerprint := cfg.cacheFingerprint() if cached := r.cached(site.ID, fingerprint); cached != nil { return cached, 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.store(site.ID, fingerprint, provider) return provider, nil } // ResolveBusinessSender 返回启用独立短信的分站通道,用于业务节点短信。 // 返回值为站点 Provider、站点签名与该站点的节点模板映射; // 站点未启用、配置破损或密钥解密失败时返回 nil Provider,调用方回退主站通道。 func (r *SMSResolver) ResolveBusinessSender(ctx context.Context, siteID uint64) (smsprovider.Provider, string, map[string]string, error) { if r == nil || r.repo == nil || siteID == 0 { return nil, "", nil, nil } site, err := r.repo.FindActiveSiteByID(ctx, siteID) if err != nil { if errors.Is(err, ErrSiteNotFound) { return nil, "", nil, nil } return nil, "", nil, err } if site == nil || strings.TrimSpace(site.SMSConfigJSON) == "" { return nil, "", nil, nil } cfg, err := ParseSiteSMSConfig(site.SMSConfigJSON) if err != nil || cfg == nil || !cfg.Enabled { return nil, "", nil, nil } fingerprint := cfg.cacheFingerprint() if cached := r.cached(site.ID, fingerprint); cached != nil { return cached, strings.TrimSpace(cfg.SignName), cfg.BusinessTemplates, nil } 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, "", cfg.BusinessTemplates, nil } cfg.AccessKeySecret = plainSecret provider, buildErr := BuildProvider(*cfg, r.logger) if buildErr != nil || provider == nil { if r.logger != nil { r.logger.Warn("分销站点自定义短信配置无效,节点短信将回退主站通道", zap.Uint64("site_id", site.ID), zap.String("site_code", site.SiteCode), zap.Error(buildErr), ) } return nil, "", cfg.BusinessTemplates, nil } r.store(site.ID, fingerprint, provider) return provider, strings.TrimSpace(cfg.SignName), cfg.BusinessTemplates, 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 } // FindActiveSiteByID 按主键查询状态为 active 的分销站点实体。 func (r *Repository) FindActiveSiteByID(ctx context.Context, siteID uint64) (*model.DistributionSite, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } if siteID == 0 { return nil, ErrSiteNotFound } var site model.DistributionSite if err := r.db.WithContext(ctx).Where("id = ? AND status = 'active'", siteID).First(&site).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSiteNotFound } return nil, err } return &site, nil }