Files
hfb_sys/backend/internal/modules/distribution/sms_resolver.go
T
yml2213 dc13b5dc82 feat(distribution): 支持分站独立短信验证码通道
- 新增分销站点独立短信服务商配置(阿里云),登录验证码按 site_code/domain
  解析专属通道,未启用或解析失败时回退主站默认通道
- 密钥经 crypto.Encryptor 加密落库,模型字段 json 序列化隔离,DTO 仅回传掩码
- provider 白名单校验,mock 仅在非生产环境放行;启用时强制校验必填凭据
- 配置读写/测试接口限管理员权限,测试发送增加 60s 频控(Redis,缺省进程内)
- 解密失败记告警并优雅回退主站,避免把密文当明文密钥使用
- 两套前端新增短信配置入口、必填与手机号校验、发送冷却
- 新增迁移 000071 及加解密、白名单、频控、解析回退等单测
2026-09-16 14:06:01 +08:00

306 lines
8.4 KiB
Go

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
}