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