feat(distribution): 支持分站独立短信验证码通道

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