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
+44 -8
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"math/big"
"regexp"
"strings"
"time"
"hfb_sys/backend/internal/captcha"
@@ -48,12 +49,18 @@ const (
loginMaxFailureCount = 5
)
// SiteSMSProviderResolver 用于根据分销站点标识解析专属 SMS Provider
type SiteSMSProviderResolver interface {
ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error)
}
type Service struct {
users *UserRepository
redis *redis.Client
jwt *JWTManager
sms smsprovider.Provider
log *zap.Logger
users *UserRepository
redis *redis.Client
jwt *JWTManager
sms smsprovider.Provider
smsResolver SiteSMSProviderResolver
log *zap.Logger
}
type LoginResult struct {
@@ -65,6 +72,10 @@ func NewService(users *UserRepository, redis *redis.Client, jwt *JWTManager, sms
return &Service{users: users, redis: redis, jwt: jwt, sms: sms, log: log}
}
func (s *Service) SetSiteSMSResolver(resolver SiteSMSProviderResolver) {
s.smsResolver = resolver
}
func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) {
if s.redis == nil {
return nil, ErrDependencyUnavailable
@@ -76,7 +87,7 @@ func (s *Service) Captcha(ctx context.Context) (*captcha.Item, error) {
return item, err
}
func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode string) error {
func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID string, captchaCode string, siteContext ...string) error {
if s.redis == nil {
return ErrDependencyUnavailable
}
@@ -117,14 +128,39 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin
return err
}
if s.sms == nil {
provider := s.sms
siteCode := ""
domain := ""
if len(siteContext) > 0 {
siteCode = strings.TrimSpace(siteContext[0])
}
if len(siteContext) > 1 {
domain = strings.TrimSpace(siteContext[1])
}
if s.smsResolver != nil && (siteCode != "" || domain != "") {
customProvider, resolveErr := s.smsResolver.ResolveSMSProvider(ctx, siteCode, domain)
if resolveErr != nil {
if s.log != nil {
s.log.Warn("解析分销站点独立短信服务商失败,将回退至主站默认通道",
zap.String("site_code", siteCode),
zap.String("domain", domain),
zap.Error(resolveErr),
)
}
} else if customProvider != nil {
provider = customProvider
}
}
if provider == nil {
return ErrDependencyUnavailable
}
if err := s.sms.SendLoginCode(ctx, phone, code); err != nil {
if err := provider.SendLoginCode(ctx, phone, code); err != nil {
if s.log != nil {
fields := []zap.Field{
zap.String("module", "auth"),
zap.String("phone", PublicPhone(phone)),
zap.String("site_code", siteCode),
zap.Error(err),
}
if requestID := logging.RequestIDFromContext(ctx); requestID != "" {