449 lines
14 KiB
Go
449 lines
14 KiB
Go
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
|
|
}
|