feat(sora): 新增 Sora 平台支持并修复高危安全和性能问题

新增功能:
- 新增 Sora 账号管理和 OAuth 认证
- 新增 Sora 视频/图片生成 API 网关
- 新增 Sora 任务调度和缓存机制
- 新增 Sora 使用统计和计费支持
- 前端增加 Sora 平台配置界面

安全修复(代码审核):
- [SEC-001] 限制媒体下载响应体大小(图片 20MB、视频 200MB),防止 DoS 攻击
- [SEC-002] 限制 SDK API 响应大小(1MB),防止内存耗尽
- [SEC-003] 修复 SSRF 风险,添加 URL 验证并强制使用代理配置

BUG 修复(代码审核):
- [BUG-001] 修复 for 循环内 defer 累积导致的资源泄漏
- [BUG-002] 修复图片并发槽位获取失败时已持有锁未释放的永久泄漏

性能优化(代码审核):
- [PERF-001] 添加 Sentinel Token 缓存(3 分钟有效期),减少 PoW 计算开销

技术细节:
- 使用 io.LimitReader 限制所有外部输入的大小
- 添加 urlvalidator 验证防止 SSRF 攻击
- 使用 sync.Map 实现线程安全的包级缓存
- 优化并发槽位管理,添加 releaseAll 模式防止泄漏

影响范围:
- 后端:新增 Sora 相关数据模型、服务、网关和管理接口
- 前端:新增 Sora 平台配置、账号管理和监控界面
- 配置:新增 Sora 相关配置项和环境变量

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-01-29 16:18:38 +08:00
parent bece1b5201
commit 13262a5698
97 changed files with 29541 additions and 68 deletions

View File

@@ -219,6 +219,29 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
// Sora settings
updates[SettingKeySoraBaseURL] = strings.TrimSpace(settings.SoraBaseURL)
updates[SettingKeySoraTimeout] = strconv.Itoa(settings.SoraTimeout)
updates[SettingKeySoraMaxRetries] = strconv.Itoa(settings.SoraMaxRetries)
updates[SettingKeySoraPollInterval] = strconv.FormatFloat(settings.SoraPollInterval, 'f', -1, 64)
updates[SettingKeySoraCallLogicMode] = settings.SoraCallLogicMode
updates[SettingKeySoraCacheEnabled] = strconv.FormatBool(settings.SoraCacheEnabled)
updates[SettingKeySoraCacheBaseDir] = settings.SoraCacheBaseDir
updates[SettingKeySoraCacheVideoDir] = settings.SoraCacheVideoDir
updates[SettingKeySoraCacheMaxBytes] = strconv.FormatInt(settings.SoraCacheMaxBytes, 10)
allowedHostsRaw, err := marshalStringSliceSetting(settings.SoraCacheAllowedHosts)
if err != nil {
return fmt.Errorf("marshal sora cache allowed hosts: %w", err)
}
updates[SettingKeySoraCacheAllowedHosts] = allowedHostsRaw
updates[SettingKeySoraCacheUserDirEnabled] = strconv.FormatBool(settings.SoraCacheUserDirEnabled)
updates[SettingKeySoraWatermarkFreeEnabled] = strconv.FormatBool(settings.SoraWatermarkFreeEnabled)
updates[SettingKeySoraWatermarkFreeParseMethod] = settings.SoraWatermarkFreeParseMethod
updates[SettingKeySoraWatermarkFreeCustomParseURL] = strings.TrimSpace(settings.SoraWatermarkFreeCustomParseURL)
updates[SettingKeySoraWatermarkFreeCustomParseToken] = settings.SoraWatermarkFreeCustomParseToken
updates[SettingKeySoraWatermarkFreeFallbackOnFailure] = strconv.FormatBool(settings.SoraWatermarkFreeFallbackOnFailure)
updates[SettingKeySoraTokenRefreshEnabled] = strconv.FormatBool(settings.SoraTokenRefreshEnabled)
// Ops monitoring (vNext)
updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled)
updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled)
@@ -227,7 +250,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
}
err := s.settingRepo.SetMultiple(ctx, updates)
err = s.settingRepo.SetMultiple(ctx, updates)
if err == nil && s.onUpdate != nil {
s.onUpdate() // Invalidate cache after settings update
}
@@ -295,6 +318,41 @@ func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
return s.cfg.Default.UserBalance
}
// GetSoraConfig 获取 Sora 配置(优先读取 DB 设置,回退 config.yaml
func (s *SettingService) GetSoraConfig(ctx context.Context) config.SoraConfig {
base := config.SoraConfig{}
if s.cfg != nil {
base = s.cfg.Sora
}
if s.settingRepo == nil {
return base
}
keys := []string{
SettingKeySoraBaseURL,
SettingKeySoraTimeout,
SettingKeySoraMaxRetries,
SettingKeySoraPollInterval,
SettingKeySoraCallLogicMode,
SettingKeySoraCacheEnabled,
SettingKeySoraCacheBaseDir,
SettingKeySoraCacheVideoDir,
SettingKeySoraCacheMaxBytes,
SettingKeySoraCacheAllowedHosts,
SettingKeySoraCacheUserDirEnabled,
SettingKeySoraWatermarkFreeEnabled,
SettingKeySoraWatermarkFreeParseMethod,
SettingKeySoraWatermarkFreeCustomParseURL,
SettingKeySoraWatermarkFreeCustomParseToken,
SettingKeySoraWatermarkFreeFallbackOnFailure,
SettingKeySoraTokenRefreshEnabled,
}
values, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return base
}
return mergeSoraConfig(base, values)
}
// InitializeDefaultSettings 初始化默认设置
func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// 检查是否已有设置
@@ -308,6 +366,12 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
}
// 初始化默认设置
soraCfg := config.SoraConfig{}
if s.cfg != nil {
soraCfg = s.cfg.Sora
}
allowedHostsRaw, _ := marshalStringSliceSetting(soraCfg.Cache.AllowedHosts)
defaults := map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false",
@@ -328,6 +392,25 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyEnableIdentityPatch: "true",
SettingKeyIdentityPatchPrompt: "",
// Sora defaults
SettingKeySoraBaseURL: soraCfg.BaseURL,
SettingKeySoraTimeout: strconv.Itoa(soraCfg.Timeout),
SettingKeySoraMaxRetries: strconv.Itoa(soraCfg.MaxRetries),
SettingKeySoraPollInterval: strconv.FormatFloat(soraCfg.PollInterval, 'f', -1, 64),
SettingKeySoraCallLogicMode: soraCfg.CallLogicMode,
SettingKeySoraCacheEnabled: strconv.FormatBool(soraCfg.Cache.Enabled),
SettingKeySoraCacheBaseDir: soraCfg.Cache.BaseDir,
SettingKeySoraCacheVideoDir: soraCfg.Cache.VideoDir,
SettingKeySoraCacheMaxBytes: strconv.FormatInt(soraCfg.Cache.MaxBytes, 10),
SettingKeySoraCacheAllowedHosts: allowedHostsRaw,
SettingKeySoraCacheUserDirEnabled: strconv.FormatBool(soraCfg.Cache.UserDirEnabled),
SettingKeySoraWatermarkFreeEnabled: strconv.FormatBool(soraCfg.WatermarkFree.Enabled),
SettingKeySoraWatermarkFreeParseMethod: soraCfg.WatermarkFree.ParseMethod,
SettingKeySoraWatermarkFreeCustomParseURL: soraCfg.WatermarkFree.CustomParseURL,
SettingKeySoraWatermarkFreeCustomParseToken: soraCfg.WatermarkFree.CustomParseToken,
SettingKeySoraWatermarkFreeFallbackOnFailure: strconv.FormatBool(soraCfg.WatermarkFree.FallbackOnFailure),
SettingKeySoraTokenRefreshEnabled: strconv.FormatBool(soraCfg.TokenRefresh.Enabled),
// Ops monitoring defaults (vNext)
SettingKeyOpsMonitoringEnabled: "true",
SettingKeyOpsRealtimeMonitoringEnabled: "true",
@@ -434,6 +517,26 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
result.IdentityPatchPrompt = settings[SettingKeyIdentityPatchPrompt]
// Sora settings
soraCfg := s.parseSoraConfig(settings)
result.SoraBaseURL = soraCfg.BaseURL
result.SoraTimeout = soraCfg.Timeout
result.SoraMaxRetries = soraCfg.MaxRetries
result.SoraPollInterval = soraCfg.PollInterval
result.SoraCallLogicMode = soraCfg.CallLogicMode
result.SoraCacheEnabled = soraCfg.Cache.Enabled
result.SoraCacheBaseDir = soraCfg.Cache.BaseDir
result.SoraCacheVideoDir = soraCfg.Cache.VideoDir
result.SoraCacheMaxBytes = soraCfg.Cache.MaxBytes
result.SoraCacheAllowedHosts = soraCfg.Cache.AllowedHosts
result.SoraCacheUserDirEnabled = soraCfg.Cache.UserDirEnabled
result.SoraWatermarkFreeEnabled = soraCfg.WatermarkFree.Enabled
result.SoraWatermarkFreeParseMethod = soraCfg.WatermarkFree.ParseMethod
result.SoraWatermarkFreeCustomParseURL = soraCfg.WatermarkFree.CustomParseURL
result.SoraWatermarkFreeCustomParseToken = soraCfg.WatermarkFree.CustomParseToken
result.SoraWatermarkFreeFallbackOnFailure = soraCfg.WatermarkFree.FallbackOnFailure
result.SoraTokenRefreshEnabled = soraCfg.TokenRefresh.Enabled
// Ops monitoring settings (default: enabled, fail-open)
result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled])
result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled])
@@ -471,6 +574,131 @@ func (s *SettingService) getStringOrDefault(settings map[string]string, key, def
return defaultValue
}
func (s *SettingService) parseSoraConfig(settings map[string]string) config.SoraConfig {
base := config.SoraConfig{}
if s.cfg != nil {
base = s.cfg.Sora
}
return mergeSoraConfig(base, settings)
}
func mergeSoraConfig(base config.SoraConfig, settings map[string]string) config.SoraConfig {
cfg := base
if settings == nil {
return cfg
}
if raw, ok := settings[SettingKeySoraBaseURL]; ok {
if trimmed := strings.TrimSpace(raw); trimmed != "" {
cfg.BaseURL = trimmed
}
}
if raw, ok := settings[SettingKeySoraTimeout]; ok {
if v, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && v > 0 {
cfg.Timeout = v
}
}
if raw, ok := settings[SettingKeySoraMaxRetries]; ok {
if v, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && v >= 0 {
cfg.MaxRetries = v
}
}
if raw, ok := settings[SettingKeySoraPollInterval]; ok {
if v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil && v > 0 {
cfg.PollInterval = v
}
}
if raw, ok := settings[SettingKeySoraCallLogicMode]; ok && strings.TrimSpace(raw) != "" {
cfg.CallLogicMode = strings.TrimSpace(raw)
}
if raw, ok := settings[SettingKeySoraCacheEnabled]; ok {
cfg.Cache.Enabled = parseBoolSetting(raw, cfg.Cache.Enabled)
}
if raw, ok := settings[SettingKeySoraCacheBaseDir]; ok && strings.TrimSpace(raw) != "" {
cfg.Cache.BaseDir = strings.TrimSpace(raw)
}
if raw, ok := settings[SettingKeySoraCacheVideoDir]; ok && strings.TrimSpace(raw) != "" {
cfg.Cache.VideoDir = strings.TrimSpace(raw)
}
if raw, ok := settings[SettingKeySoraCacheMaxBytes]; ok {
if v, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64); err == nil && v >= 0 {
cfg.Cache.MaxBytes = v
}
}
if raw, ok := settings[SettingKeySoraCacheAllowedHosts]; ok {
cfg.Cache.AllowedHosts = parseStringSliceSetting(raw)
}
if raw, ok := settings[SettingKeySoraCacheUserDirEnabled]; ok {
cfg.Cache.UserDirEnabled = parseBoolSetting(raw, cfg.Cache.UserDirEnabled)
}
if raw, ok := settings[SettingKeySoraWatermarkFreeEnabled]; ok {
cfg.WatermarkFree.Enabled = parseBoolSetting(raw, cfg.WatermarkFree.Enabled)
}
if raw, ok := settings[SettingKeySoraWatermarkFreeParseMethod]; ok && strings.TrimSpace(raw) != "" {
cfg.WatermarkFree.ParseMethod = strings.TrimSpace(raw)
}
if raw, ok := settings[SettingKeySoraWatermarkFreeCustomParseURL]; ok && strings.TrimSpace(raw) != "" {
cfg.WatermarkFree.CustomParseURL = strings.TrimSpace(raw)
}
if raw, ok := settings[SettingKeySoraWatermarkFreeCustomParseToken]; ok {
cfg.WatermarkFree.CustomParseToken = raw
}
if raw, ok := settings[SettingKeySoraWatermarkFreeFallbackOnFailure]; ok {
cfg.WatermarkFree.FallbackOnFailure = parseBoolSetting(raw, cfg.WatermarkFree.FallbackOnFailure)
}
if raw, ok := settings[SettingKeySoraTokenRefreshEnabled]; ok {
cfg.TokenRefresh.Enabled = parseBoolSetting(raw, cfg.TokenRefresh.Enabled)
}
return cfg
}
func parseBoolSetting(raw string, fallback bool) bool {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return fallback
}
if v, err := strconv.ParseBool(trimmed); err == nil {
return v
}
return fallback
}
func parseStringSliceSetting(raw string) []string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return []string{}
}
var values []string
if err := json.Unmarshal([]byte(trimmed), &values); err == nil {
return normalizeStringSlice(values)
}
parts := strings.FieldsFunc(trimmed, func(r rune) bool {
return r == ',' || r == '\n' || r == ';'
})
return normalizeStringSlice(parts)
}
func marshalStringSliceSetting(values []string) (string, error) {
normalized := normalizeStringSlice(values)
data, err := json.Marshal(normalized)
if err != nil {
return "", err
}
return string(data), nil
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return []string{}
}
normalized := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
normalized = append(normalized, trimmed)
}
}
return normalized
}
// IsTurnstileEnabled 检查是否启用 Turnstile 验证
func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled)