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