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
@@ -17,6 +17,7 @@ import (
listingpkg "hfb_sys/backend/internal/modules/listing"
"hfb_sys/backend/internal/modules/systemconfig"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/crypto"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
@@ -75,6 +76,8 @@ func normalizeReceiveRatioDelta(delta float64) (float64, error) {
type Repository struct {
db *gorm.DB
redis *redis.Client
encryptor crypto.Encryptor
allowMockSMS bool
siteSyncLocks sync.Map
}
@@ -86,6 +89,65 @@ func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
return r
}
func (r *Repository) SetEncryptor(enc crypto.Encryptor) {
if r != nil {
r.encryptor = enc
}
}
// SetAllowMockSMS 设置是否允许使用 mock 短信服务商,仅非生产环境应开启。
func (r *Repository) SetAllowMockSMS(allow bool) {
if r != nil {
r.allowMockSMS = allow
}
}
// validateSMSProvider 校验并规范化短信服务商;mock 仅在显式允许时可用(非生产环境)。
func (r *Repository) validateSMSProvider(provider string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(provider))
if normalized == "" {
return "aliyun", nil
}
switch normalized {
case "aliyun":
return normalized, nil
case "mock":
if r != nil && r.allowMockSMS {
return normalized, nil
}
return "", fmt.Errorf("%w: 当前环境不允许使用 mock 短信服务商", ErrSMSConfigInvalid)
default:
return "", fmt.Errorf("%w: 不支持的短信服务商 %q", ErrSMSConfigInvalid, provider)
}
}
func (r *Repository) encryptSecret(plaintext string) (string, error) {
plaintext = strings.TrimSpace(plaintext)
if plaintext == "" {
return "", nil
}
if r != nil && r.encryptor != nil {
return r.encryptor.Encrypt(plaintext)
}
return plaintext, nil
}
// decryptSecret 解密访问密钥。解密失败时返回错误,避免把密文当明文密钥继续使用。
func (r *Repository) decryptSecret(ciphertext string) (string, error) {
ciphertext = strings.TrimSpace(ciphertext)
if ciphertext == "" {
return "", nil
}
if r != nil && r.encryptor != nil {
plain, err := r.encryptor.Decrypt(ciphertext)
if err != nil {
return "", fmt.Errorf("解密短信访问密钥失败: %w", err)
}
return plain, nil
}
return ciphertext, nil
}
func (r *Repository) hasGlobalDistributionAccess(ctx context.Context, adminID uint64) (bool, error) {
return r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID)
}
@@ -524,6 +586,139 @@ func siteDTO(row model.DistributionSite) SiteDTO {
}
}
func (r *Repository) GetSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfigDTO, error) {
cfg, err := r.GetDecryptedSiteSMSConfig(ctx, adminID, siteID)
if err != nil {
return nil, err
}
return cfg.ToDTO(), nil
}
func (r *Repository) GetDecryptedSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfig, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
return nil, err
}
if strings.TrimSpace(row.SMSConfigJSON) == "" {
return &SiteSMSConfig{
Enabled: false,
Provider: "aliyun",
Endpoint: "dysmsapi.aliyuncs.com",
}, nil
}
cfg, err := ParseSiteSMSConfig(row.SMSConfigJSON)
if err != nil {
return nil, err
}
plainSecret, err := r.decryptSecret(cfg.AccessKeySecret)
if err != nil {
return nil, err
}
cfg.AccessKeySecret = plainSecret
return cfg, nil
}
func (r *Repository) UpdateSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req UpdateSiteSMSConfigRequest) (*SiteSMSConfigDTO, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, siteID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
return nil, err
}
cfg, err := ParseSiteSMSConfig(row.SMSConfigJSON)
if err != nil {
return nil, fmt.Errorf("解析现有短信配置失败: %w", err)
}
if cfg == nil {
cfg = &SiteSMSConfig{}
}
existingSecret, err := r.decryptSecret(cfg.AccessKeySecret)
if err != nil {
return nil, err
}
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if req.Provider != nil {
cfg.Provider = strings.TrimSpace(*req.Provider)
}
if req.AccessKeyID != nil {
cfg.AccessKeyID = strings.TrimSpace(*req.AccessKeyID)
}
if req.AccessKeySecret != nil && strings.TrimSpace(*req.AccessKeySecret) != "" {
existingSecret = strings.TrimSpace(*req.AccessKeySecret)
}
if req.Endpoint != nil {
cfg.Endpoint = strings.TrimSpace(*req.Endpoint)
}
if req.SignName != nil {
cfg.SignName = strings.TrimSpace(*req.SignName)
}
if req.LoginTemplateCode != nil {
cfg.LoginTemplateCode = strings.TrimSpace(*req.LoginTemplateCode)
}
// 完整性校验:必须属于合法 provider 白名单,且开启时必须提供完整凭据
providerType, err := r.validateSMSProvider(cfg.Provider)
if err != nil {
return nil, err
}
cfg.Provider = providerType
if cfg.Enabled {
if providerType == "aliyun" {
if strings.TrimSpace(cfg.AccessKeyID) == "" ||
strings.TrimSpace(existingSecret) == "" ||
strings.TrimSpace(cfg.SignName) == "" ||
strings.TrimSpace(cfg.LoginTemplateCode) == "" {
return nil, ErrSMSConfigInvalid
}
}
}
encSecret, err := r.encryptSecret(existingSecret)
if err != nil {
return nil, fmt.Errorf("加密短信密钥失败: %w", err)
}
cfg.AccessKeySecret = encSecret
bytes, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
row.SMSConfigJSON = string(bytes)
if err := r.db.WithContext(ctx).Model(&row).Update("sms_config_json", row.SMSConfigJSON).Error; err != nil {
return nil, err
}
r.evictSiteInfoCache(ctx, &row)
dtoCfg := *cfg
dtoCfg.AccessKeySecret = existingSecret
return dtoCfg.ToDTO(), nil
}
func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt}
}