新增功能: - 新增 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>
893 lines
35 KiB
Go
893 lines
35 KiB
Go
package admin
|
||
|
||
import (
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// SettingHandler 系统设置处理器
|
||
type SettingHandler struct {
|
||
settingService *service.SettingService
|
||
emailService *service.EmailService
|
||
turnstileService *service.TurnstileService
|
||
opsService *service.OpsService
|
||
}
|
||
|
||
// NewSettingHandler 创建系统设置处理器
|
||
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService) *SettingHandler {
|
||
return &SettingHandler{
|
||
settingService: settingService,
|
||
emailService: emailService,
|
||
turnstileService: turnstileService,
|
||
opsService: opsService,
|
||
}
|
||
}
|
||
|
||
// GetSettings 获取所有系统设置
|
||
// GET /api/v1/admin/settings
|
||
func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||
settings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// Check if ops monitoring is enabled (respects config.ops.enabled)
|
||
opsEnabled := h.opsService != nil && h.opsService.IsMonitoringEnabled(c.Request.Context())
|
||
|
||
response.Success(c, dto.SystemSettings{
|
||
RegistrationEnabled: settings.RegistrationEnabled,
|
||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||
SMTPHost: settings.SMTPHost,
|
||
SMTPPort: settings.SMTPPort,
|
||
SMTPUsername: settings.SMTPUsername,
|
||
SMTPPasswordConfigured: settings.SMTPPasswordConfigured,
|
||
SMTPFrom: settings.SMTPFrom,
|
||
SMTPFromName: settings.SMTPFromName,
|
||
SMTPUseTLS: settings.SMTPUseTLS,
|
||
TurnstileEnabled: settings.TurnstileEnabled,
|
||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
|
||
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
|
||
LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
|
||
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
|
||
LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL,
|
||
SiteName: settings.SiteName,
|
||
SiteLogo: settings.SiteLogo,
|
||
SiteSubtitle: settings.SiteSubtitle,
|
||
APIBaseURL: settings.APIBaseURL,
|
||
ContactInfo: settings.ContactInfo,
|
||
DocURL: settings.DocURL,
|
||
HomeContent: settings.HomeContent,
|
||
HideCcsImportButton: settings.HideCcsImportButton,
|
||
DefaultConcurrency: settings.DefaultConcurrency,
|
||
DefaultBalance: settings.DefaultBalance,
|
||
EnableModelFallback: settings.EnableModelFallback,
|
||
FallbackModelAnthropic: settings.FallbackModelAnthropic,
|
||
FallbackModelOpenAI: settings.FallbackModelOpenAI,
|
||
FallbackModelGemini: settings.FallbackModelGemini,
|
||
FallbackModelAntigravity: settings.FallbackModelAntigravity,
|
||
EnableIdentityPatch: settings.EnableIdentityPatch,
|
||
IdentityPatchPrompt: settings.IdentityPatchPrompt,
|
||
SoraBaseURL: settings.SoraBaseURL,
|
||
SoraTimeout: settings.SoraTimeout,
|
||
SoraMaxRetries: settings.SoraMaxRetries,
|
||
SoraPollInterval: settings.SoraPollInterval,
|
||
SoraCallLogicMode: settings.SoraCallLogicMode,
|
||
SoraCacheEnabled: settings.SoraCacheEnabled,
|
||
SoraCacheBaseDir: settings.SoraCacheBaseDir,
|
||
SoraCacheVideoDir: settings.SoraCacheVideoDir,
|
||
SoraCacheMaxBytes: settings.SoraCacheMaxBytes,
|
||
SoraCacheAllowedHosts: settings.SoraCacheAllowedHosts,
|
||
SoraCacheUserDirEnabled: settings.SoraCacheUserDirEnabled,
|
||
SoraWatermarkFreeEnabled: settings.SoraWatermarkFreeEnabled,
|
||
SoraWatermarkFreeParseMethod: settings.SoraWatermarkFreeParseMethod,
|
||
SoraWatermarkFreeCustomParseURL: settings.SoraWatermarkFreeCustomParseURL,
|
||
SoraWatermarkFreeCustomParseToken: settings.SoraWatermarkFreeCustomParseToken,
|
||
SoraWatermarkFreeFallbackOnFailure: settings.SoraWatermarkFreeFallbackOnFailure,
|
||
SoraTokenRefreshEnabled: settings.SoraTokenRefreshEnabled,
|
||
OpsMonitoringEnabled: opsEnabled && settings.OpsMonitoringEnabled,
|
||
OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled,
|
||
OpsQueryModeDefault: settings.OpsQueryModeDefault,
|
||
OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds,
|
||
})
|
||
}
|
||
|
||
// UpdateSettingsRequest 更新设置请求
|
||
type UpdateSettingsRequest struct {
|
||
// 注册设置
|
||
RegistrationEnabled bool `json:"registration_enabled"`
|
||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||
|
||
// 邮件服务设置
|
||
SMTPHost string `json:"smtp_host"`
|
||
SMTPPort int `json:"smtp_port"`
|
||
SMTPUsername string `json:"smtp_username"`
|
||
SMTPPassword string `json:"smtp_password"`
|
||
SMTPFrom string `json:"smtp_from_email"`
|
||
SMTPFromName string `json:"smtp_from_name"`
|
||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||
|
||
// Cloudflare Turnstile 设置
|
||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
||
|
||
// LinuxDo Connect OAuth 登录
|
||
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
||
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
|
||
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
|
||
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
||
|
||
// OEM设置
|
||
SiteName string `json:"site_name"`
|
||
SiteLogo string `json:"site_logo"`
|
||
SiteSubtitle string `json:"site_subtitle"`
|
||
APIBaseURL string `json:"api_base_url"`
|
||
ContactInfo string `json:"contact_info"`
|
||
DocURL string `json:"doc_url"`
|
||
HomeContent string `json:"home_content"`
|
||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||
|
||
// 默认配置
|
||
DefaultConcurrency int `json:"default_concurrency"`
|
||
DefaultBalance float64 `json:"default_balance"`
|
||
|
||
// Model fallback configuration
|
||
EnableModelFallback bool `json:"enable_model_fallback"`
|
||
FallbackModelAnthropic string `json:"fallback_model_anthropic"`
|
||
FallbackModelOpenAI string `json:"fallback_model_openai"`
|
||
FallbackModelGemini string `json:"fallback_model_gemini"`
|
||
FallbackModelAntigravity string `json:"fallback_model_antigravity"`
|
||
|
||
// Identity patch configuration (Claude -> Gemini)
|
||
EnableIdentityPatch bool `json:"enable_identity_patch"`
|
||
IdentityPatchPrompt string `json:"identity_patch_prompt"`
|
||
|
||
// Sora configuration
|
||
SoraBaseURL string `json:"sora_base_url"`
|
||
SoraTimeout int `json:"sora_timeout"`
|
||
SoraMaxRetries int `json:"sora_max_retries"`
|
||
SoraPollInterval float64 `json:"sora_poll_interval"`
|
||
SoraCallLogicMode string `json:"sora_call_logic_mode"`
|
||
SoraCacheEnabled bool `json:"sora_cache_enabled"`
|
||
SoraCacheBaseDir string `json:"sora_cache_base_dir"`
|
||
SoraCacheVideoDir string `json:"sora_cache_video_dir"`
|
||
SoraCacheMaxBytes int64 `json:"sora_cache_max_bytes"`
|
||
SoraCacheAllowedHosts []string `json:"sora_cache_allowed_hosts"`
|
||
SoraCacheUserDirEnabled bool `json:"sora_cache_user_dir_enabled"`
|
||
SoraWatermarkFreeEnabled bool `json:"sora_watermark_free_enabled"`
|
||
SoraWatermarkFreeParseMethod string `json:"sora_watermark_free_parse_method"`
|
||
SoraWatermarkFreeCustomParseURL string `json:"sora_watermark_free_custom_parse_url"`
|
||
SoraWatermarkFreeCustomParseToken string `json:"sora_watermark_free_custom_parse_token"`
|
||
SoraWatermarkFreeFallbackOnFailure bool `json:"sora_watermark_free_fallback_on_failure"`
|
||
SoraTokenRefreshEnabled bool `json:"sora_token_refresh_enabled"`
|
||
|
||
// Ops monitoring (vNext)
|
||
OpsMonitoringEnabled *bool `json:"ops_monitoring_enabled"`
|
||
OpsRealtimeMonitoringEnabled *bool `json:"ops_realtime_monitoring_enabled"`
|
||
OpsQueryModeDefault *string `json:"ops_query_mode_default"`
|
||
OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"`
|
||
}
|
||
|
||
// UpdateSettings 更新系统设置
|
||
// PUT /api/v1/admin/settings
|
||
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||
var req UpdateSettingsRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
previousSettings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// 验证参数
|
||
if req.DefaultConcurrency < 1 {
|
||
req.DefaultConcurrency = 1
|
||
}
|
||
if req.DefaultBalance < 0 {
|
||
req.DefaultBalance = 0
|
||
}
|
||
if req.SMTPPort <= 0 {
|
||
req.SMTPPort = 587
|
||
}
|
||
|
||
// Turnstile 参数验证
|
||
if req.TurnstileEnabled {
|
||
// 检查必填字段
|
||
if req.TurnstileSiteKey == "" {
|
||
response.BadRequest(c, "Turnstile Site Key is required when enabled")
|
||
return
|
||
}
|
||
// 如果未提供 secret key,使用已保存的值(留空保留当前值)
|
||
if req.TurnstileSecretKey == "" {
|
||
if previousSettings.TurnstileSecretKey == "" {
|
||
response.BadRequest(c, "Turnstile Secret Key is required when enabled")
|
||
return
|
||
}
|
||
req.TurnstileSecretKey = previousSettings.TurnstileSecretKey
|
||
}
|
||
|
||
// 当 site_key 或 secret_key 任一变化时验证(避免配置错误导致无法登录)
|
||
siteKeyChanged := previousSettings.TurnstileSiteKey != req.TurnstileSiteKey
|
||
secretKeyChanged := previousSettings.TurnstileSecretKey != req.TurnstileSecretKey
|
||
if siteKeyChanged || secretKeyChanged {
|
||
if err := h.turnstileService.ValidateSecretKey(c.Request.Context(), req.TurnstileSecretKey); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// LinuxDo Connect 参数验证
|
||
if req.LinuxDoConnectEnabled {
|
||
req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID)
|
||
req.LinuxDoConnectClientSecret = strings.TrimSpace(req.LinuxDoConnectClientSecret)
|
||
req.LinuxDoConnectRedirectURL = strings.TrimSpace(req.LinuxDoConnectRedirectURL)
|
||
|
||
if req.LinuxDoConnectClientID == "" {
|
||
response.BadRequest(c, "LinuxDo Client ID is required when enabled")
|
||
return
|
||
}
|
||
if req.LinuxDoConnectRedirectURL == "" {
|
||
response.BadRequest(c, "LinuxDo Redirect URL is required when enabled")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL); err != nil {
|
||
response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
|
||
// 如果未提供 client_secret,则保留现有值(如有)。
|
||
if req.LinuxDoConnectClientSecret == "" {
|
||
if previousSettings.LinuxDoConnectClientSecret == "" {
|
||
response.BadRequest(c, "LinuxDo Client Secret is required when enabled")
|
||
return
|
||
}
|
||
req.LinuxDoConnectClientSecret = previousSettings.LinuxDoConnectClientSecret
|
||
}
|
||
}
|
||
|
||
// Sora 参数校验与清理
|
||
req.SoraBaseURL = strings.TrimSpace(req.SoraBaseURL)
|
||
if req.SoraBaseURL == "" {
|
||
req.SoraBaseURL = previousSettings.SoraBaseURL
|
||
}
|
||
if req.SoraBaseURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(req.SoraBaseURL); err != nil {
|
||
response.BadRequest(c, "Sora Base URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
}
|
||
if req.SoraTimeout <= 0 {
|
||
req.SoraTimeout = previousSettings.SoraTimeout
|
||
}
|
||
if req.SoraMaxRetries < 0 {
|
||
req.SoraMaxRetries = previousSettings.SoraMaxRetries
|
||
}
|
||
if req.SoraPollInterval <= 0 {
|
||
req.SoraPollInterval = previousSettings.SoraPollInterval
|
||
}
|
||
if req.SoraCacheMaxBytes < 0 {
|
||
req.SoraCacheMaxBytes = 0
|
||
}
|
||
req.SoraCacheAllowedHosts = normalizeStringList(req.SoraCacheAllowedHosts)
|
||
req.SoraWatermarkFreeCustomParseURL = strings.TrimSpace(req.SoraWatermarkFreeCustomParseURL)
|
||
|
||
// Ops metrics collector interval validation (seconds).
|
||
if req.OpsMetricsIntervalSeconds != nil {
|
||
v := *req.OpsMetricsIntervalSeconds
|
||
if v < 60 {
|
||
v = 60
|
||
}
|
||
if v > 3600 {
|
||
v = 3600
|
||
}
|
||
req.OpsMetricsIntervalSeconds = &v
|
||
}
|
||
|
||
settings := &service.SystemSettings{
|
||
RegistrationEnabled: req.RegistrationEnabled,
|
||
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
||
PromoCodeEnabled: req.PromoCodeEnabled,
|
||
SMTPHost: req.SMTPHost,
|
||
SMTPPort: req.SMTPPort,
|
||
SMTPUsername: req.SMTPUsername,
|
||
SMTPPassword: req.SMTPPassword,
|
||
SMTPFrom: req.SMTPFrom,
|
||
SMTPFromName: req.SMTPFromName,
|
||
SMTPUseTLS: req.SMTPUseTLS,
|
||
TurnstileEnabled: req.TurnstileEnabled,
|
||
TurnstileSiteKey: req.TurnstileSiteKey,
|
||
TurnstileSecretKey: req.TurnstileSecretKey,
|
||
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
|
||
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
||
SiteName: req.SiteName,
|
||
SiteLogo: req.SiteLogo,
|
||
SiteSubtitle: req.SiteSubtitle,
|
||
APIBaseURL: req.APIBaseURL,
|
||
ContactInfo: req.ContactInfo,
|
||
DocURL: req.DocURL,
|
||
HomeContent: req.HomeContent,
|
||
HideCcsImportButton: req.HideCcsImportButton,
|
||
DefaultConcurrency: req.DefaultConcurrency,
|
||
DefaultBalance: req.DefaultBalance,
|
||
EnableModelFallback: req.EnableModelFallback,
|
||
FallbackModelAnthropic: req.FallbackModelAnthropic,
|
||
FallbackModelOpenAI: req.FallbackModelOpenAI,
|
||
FallbackModelGemini: req.FallbackModelGemini,
|
||
FallbackModelAntigravity: req.FallbackModelAntigravity,
|
||
EnableIdentityPatch: req.EnableIdentityPatch,
|
||
IdentityPatchPrompt: req.IdentityPatchPrompt,
|
||
SoraBaseURL: req.SoraBaseURL,
|
||
SoraTimeout: req.SoraTimeout,
|
||
SoraMaxRetries: req.SoraMaxRetries,
|
||
SoraPollInterval: req.SoraPollInterval,
|
||
SoraCallLogicMode: req.SoraCallLogicMode,
|
||
SoraCacheEnabled: req.SoraCacheEnabled,
|
||
SoraCacheBaseDir: req.SoraCacheBaseDir,
|
||
SoraCacheVideoDir: req.SoraCacheVideoDir,
|
||
SoraCacheMaxBytes: req.SoraCacheMaxBytes,
|
||
SoraCacheAllowedHosts: req.SoraCacheAllowedHosts,
|
||
SoraCacheUserDirEnabled: req.SoraCacheUserDirEnabled,
|
||
SoraWatermarkFreeEnabled: req.SoraWatermarkFreeEnabled,
|
||
SoraWatermarkFreeParseMethod: req.SoraWatermarkFreeParseMethod,
|
||
SoraWatermarkFreeCustomParseURL: req.SoraWatermarkFreeCustomParseURL,
|
||
SoraWatermarkFreeCustomParseToken: req.SoraWatermarkFreeCustomParseToken,
|
||
SoraWatermarkFreeFallbackOnFailure: req.SoraWatermarkFreeFallbackOnFailure,
|
||
SoraTokenRefreshEnabled: req.SoraTokenRefreshEnabled,
|
||
OpsMonitoringEnabled: func() bool {
|
||
if req.OpsMonitoringEnabled != nil {
|
||
return *req.OpsMonitoringEnabled
|
||
}
|
||
return previousSettings.OpsMonitoringEnabled
|
||
}(),
|
||
OpsRealtimeMonitoringEnabled: func() bool {
|
||
if req.OpsRealtimeMonitoringEnabled != nil {
|
||
return *req.OpsRealtimeMonitoringEnabled
|
||
}
|
||
return previousSettings.OpsRealtimeMonitoringEnabled
|
||
}(),
|
||
OpsQueryModeDefault: func() string {
|
||
if req.OpsQueryModeDefault != nil {
|
||
return *req.OpsQueryModeDefault
|
||
}
|
||
return previousSettings.OpsQueryModeDefault
|
||
}(),
|
||
OpsMetricsIntervalSeconds: func() int {
|
||
if req.OpsMetricsIntervalSeconds != nil {
|
||
return *req.OpsMetricsIntervalSeconds
|
||
}
|
||
return previousSettings.OpsMetricsIntervalSeconds
|
||
}(),
|
||
}
|
||
|
||
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
h.auditSettingsUpdate(c, previousSettings, settings, req)
|
||
|
||
// 重新获取设置返回
|
||
updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, dto.SystemSettings{
|
||
RegistrationEnabled: updatedSettings.RegistrationEnabled,
|
||
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
|
||
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
|
||
SMTPHost: updatedSettings.SMTPHost,
|
||
SMTPPort: updatedSettings.SMTPPort,
|
||
SMTPUsername: updatedSettings.SMTPUsername,
|
||
SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured,
|
||
SMTPFrom: updatedSettings.SMTPFrom,
|
||
SMTPFromName: updatedSettings.SMTPFromName,
|
||
SMTPUseTLS: updatedSettings.SMTPUseTLS,
|
||
TurnstileEnabled: updatedSettings.TurnstileEnabled,
|
||
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
||
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
||
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
|
||
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
|
||
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
|
||
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
|
||
SiteName: updatedSettings.SiteName,
|
||
SiteLogo: updatedSettings.SiteLogo,
|
||
SiteSubtitle: updatedSettings.SiteSubtitle,
|
||
APIBaseURL: updatedSettings.APIBaseURL,
|
||
ContactInfo: updatedSettings.ContactInfo,
|
||
DocURL: updatedSettings.DocURL,
|
||
HomeContent: updatedSettings.HomeContent,
|
||
HideCcsImportButton: updatedSettings.HideCcsImportButton,
|
||
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
||
DefaultBalance: updatedSettings.DefaultBalance,
|
||
EnableModelFallback: updatedSettings.EnableModelFallback,
|
||
FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic,
|
||
FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI,
|
||
FallbackModelGemini: updatedSettings.FallbackModelGemini,
|
||
FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity,
|
||
EnableIdentityPatch: updatedSettings.EnableIdentityPatch,
|
||
IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt,
|
||
SoraBaseURL: updatedSettings.SoraBaseURL,
|
||
SoraTimeout: updatedSettings.SoraTimeout,
|
||
SoraMaxRetries: updatedSettings.SoraMaxRetries,
|
||
SoraPollInterval: updatedSettings.SoraPollInterval,
|
||
SoraCallLogicMode: updatedSettings.SoraCallLogicMode,
|
||
SoraCacheEnabled: updatedSettings.SoraCacheEnabled,
|
||
SoraCacheBaseDir: updatedSettings.SoraCacheBaseDir,
|
||
SoraCacheVideoDir: updatedSettings.SoraCacheVideoDir,
|
||
SoraCacheMaxBytes: updatedSettings.SoraCacheMaxBytes,
|
||
SoraCacheAllowedHosts: updatedSettings.SoraCacheAllowedHosts,
|
||
SoraCacheUserDirEnabled: updatedSettings.SoraCacheUserDirEnabled,
|
||
SoraWatermarkFreeEnabled: updatedSettings.SoraWatermarkFreeEnabled,
|
||
SoraWatermarkFreeParseMethod: updatedSettings.SoraWatermarkFreeParseMethod,
|
||
SoraWatermarkFreeCustomParseURL: updatedSettings.SoraWatermarkFreeCustomParseURL,
|
||
SoraWatermarkFreeCustomParseToken: updatedSettings.SoraWatermarkFreeCustomParseToken,
|
||
SoraWatermarkFreeFallbackOnFailure: updatedSettings.SoraWatermarkFreeFallbackOnFailure,
|
||
SoraTokenRefreshEnabled: updatedSettings.SoraTokenRefreshEnabled,
|
||
OpsMonitoringEnabled: updatedSettings.OpsMonitoringEnabled,
|
||
OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled,
|
||
OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault,
|
||
OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds,
|
||
})
|
||
}
|
||
|
||
func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.SystemSettings, after *service.SystemSettings, req UpdateSettingsRequest) {
|
||
if before == nil || after == nil {
|
||
return
|
||
}
|
||
|
||
changed := diffSettings(before, after, req)
|
||
if len(changed) == 0 {
|
||
return
|
||
}
|
||
|
||
subject, _ := middleware.GetAuthSubjectFromContext(c)
|
||
role, _ := middleware.GetUserRoleFromContext(c)
|
||
log.Printf("AUDIT: settings updated at=%s user_id=%d role=%s changed=%v",
|
||
time.Now().UTC().Format(time.RFC3339),
|
||
subject.UserID,
|
||
role,
|
||
changed,
|
||
)
|
||
}
|
||
|
||
func diffSettings(before *service.SystemSettings, after *service.SystemSettings, req UpdateSettingsRequest) []string {
|
||
changed := make([]string, 0, 20)
|
||
if before.RegistrationEnabled != after.RegistrationEnabled {
|
||
changed = append(changed, "registration_enabled")
|
||
}
|
||
if before.EmailVerifyEnabled != after.EmailVerifyEnabled {
|
||
changed = append(changed, "email_verify_enabled")
|
||
}
|
||
if before.SMTPHost != after.SMTPHost {
|
||
changed = append(changed, "smtp_host")
|
||
}
|
||
if before.SMTPPort != after.SMTPPort {
|
||
changed = append(changed, "smtp_port")
|
||
}
|
||
if before.SMTPUsername != after.SMTPUsername {
|
||
changed = append(changed, "smtp_username")
|
||
}
|
||
if req.SMTPPassword != "" {
|
||
changed = append(changed, "smtp_password")
|
||
}
|
||
if before.SMTPFrom != after.SMTPFrom {
|
||
changed = append(changed, "smtp_from_email")
|
||
}
|
||
if before.SMTPFromName != after.SMTPFromName {
|
||
changed = append(changed, "smtp_from_name")
|
||
}
|
||
if before.SMTPUseTLS != after.SMTPUseTLS {
|
||
changed = append(changed, "smtp_use_tls")
|
||
}
|
||
if before.TurnstileEnabled != after.TurnstileEnabled {
|
||
changed = append(changed, "turnstile_enabled")
|
||
}
|
||
if before.TurnstileSiteKey != after.TurnstileSiteKey {
|
||
changed = append(changed, "turnstile_site_key")
|
||
}
|
||
if req.TurnstileSecretKey != "" {
|
||
changed = append(changed, "turnstile_secret_key")
|
||
}
|
||
if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled {
|
||
changed = append(changed, "linuxdo_connect_enabled")
|
||
}
|
||
if before.LinuxDoConnectClientID != after.LinuxDoConnectClientID {
|
||
changed = append(changed, "linuxdo_connect_client_id")
|
||
}
|
||
if req.LinuxDoConnectClientSecret != "" {
|
||
changed = append(changed, "linuxdo_connect_client_secret")
|
||
}
|
||
if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL {
|
||
changed = append(changed, "linuxdo_connect_redirect_url")
|
||
}
|
||
if before.SiteName != after.SiteName {
|
||
changed = append(changed, "site_name")
|
||
}
|
||
if before.SiteLogo != after.SiteLogo {
|
||
changed = append(changed, "site_logo")
|
||
}
|
||
if before.SiteSubtitle != after.SiteSubtitle {
|
||
changed = append(changed, "site_subtitle")
|
||
}
|
||
if before.APIBaseURL != after.APIBaseURL {
|
||
changed = append(changed, "api_base_url")
|
||
}
|
||
if before.ContactInfo != after.ContactInfo {
|
||
changed = append(changed, "contact_info")
|
||
}
|
||
if before.DocURL != after.DocURL {
|
||
changed = append(changed, "doc_url")
|
||
}
|
||
if before.HomeContent != after.HomeContent {
|
||
changed = append(changed, "home_content")
|
||
}
|
||
if before.HideCcsImportButton != after.HideCcsImportButton {
|
||
changed = append(changed, "hide_ccs_import_button")
|
||
}
|
||
if before.DefaultConcurrency != after.DefaultConcurrency {
|
||
changed = append(changed, "default_concurrency")
|
||
}
|
||
if before.DefaultBalance != after.DefaultBalance {
|
||
changed = append(changed, "default_balance")
|
||
}
|
||
if before.EnableModelFallback != after.EnableModelFallback {
|
||
changed = append(changed, "enable_model_fallback")
|
||
}
|
||
if before.FallbackModelAnthropic != after.FallbackModelAnthropic {
|
||
changed = append(changed, "fallback_model_anthropic")
|
||
}
|
||
if before.FallbackModelOpenAI != after.FallbackModelOpenAI {
|
||
changed = append(changed, "fallback_model_openai")
|
||
}
|
||
if before.FallbackModelGemini != after.FallbackModelGemini {
|
||
changed = append(changed, "fallback_model_gemini")
|
||
}
|
||
if before.FallbackModelAntigravity != after.FallbackModelAntigravity {
|
||
changed = append(changed, "fallback_model_antigravity")
|
||
}
|
||
if before.EnableIdentityPatch != after.EnableIdentityPatch {
|
||
changed = append(changed, "enable_identity_patch")
|
||
}
|
||
if before.IdentityPatchPrompt != after.IdentityPatchPrompt {
|
||
changed = append(changed, "identity_patch_prompt")
|
||
}
|
||
if before.SoraBaseURL != after.SoraBaseURL {
|
||
changed = append(changed, "sora_base_url")
|
||
}
|
||
if before.SoraTimeout != after.SoraTimeout {
|
||
changed = append(changed, "sora_timeout")
|
||
}
|
||
if before.SoraMaxRetries != after.SoraMaxRetries {
|
||
changed = append(changed, "sora_max_retries")
|
||
}
|
||
if before.SoraPollInterval != after.SoraPollInterval {
|
||
changed = append(changed, "sora_poll_interval")
|
||
}
|
||
if before.SoraCallLogicMode != after.SoraCallLogicMode {
|
||
changed = append(changed, "sora_call_logic_mode")
|
||
}
|
||
if before.SoraCacheEnabled != after.SoraCacheEnabled {
|
||
changed = append(changed, "sora_cache_enabled")
|
||
}
|
||
if before.SoraCacheBaseDir != after.SoraCacheBaseDir {
|
||
changed = append(changed, "sora_cache_base_dir")
|
||
}
|
||
if before.SoraCacheVideoDir != after.SoraCacheVideoDir {
|
||
changed = append(changed, "sora_cache_video_dir")
|
||
}
|
||
if before.SoraCacheMaxBytes != after.SoraCacheMaxBytes {
|
||
changed = append(changed, "sora_cache_max_bytes")
|
||
}
|
||
if strings.Join(before.SoraCacheAllowedHosts, ",") != strings.Join(after.SoraCacheAllowedHosts, ",") {
|
||
changed = append(changed, "sora_cache_allowed_hosts")
|
||
}
|
||
if before.SoraCacheUserDirEnabled != after.SoraCacheUserDirEnabled {
|
||
changed = append(changed, "sora_cache_user_dir_enabled")
|
||
}
|
||
if before.SoraWatermarkFreeEnabled != after.SoraWatermarkFreeEnabled {
|
||
changed = append(changed, "sora_watermark_free_enabled")
|
||
}
|
||
if before.SoraWatermarkFreeParseMethod != after.SoraWatermarkFreeParseMethod {
|
||
changed = append(changed, "sora_watermark_free_parse_method")
|
||
}
|
||
if before.SoraWatermarkFreeCustomParseURL != after.SoraWatermarkFreeCustomParseURL {
|
||
changed = append(changed, "sora_watermark_free_custom_parse_url")
|
||
}
|
||
if before.SoraWatermarkFreeCustomParseToken != after.SoraWatermarkFreeCustomParseToken {
|
||
changed = append(changed, "sora_watermark_free_custom_parse_token")
|
||
}
|
||
if before.SoraWatermarkFreeFallbackOnFailure != after.SoraWatermarkFreeFallbackOnFailure {
|
||
changed = append(changed, "sora_watermark_free_fallback_on_failure")
|
||
}
|
||
if before.SoraTokenRefreshEnabled != after.SoraTokenRefreshEnabled {
|
||
changed = append(changed, "sora_token_refresh_enabled")
|
||
}
|
||
if before.OpsMonitoringEnabled != after.OpsMonitoringEnabled {
|
||
changed = append(changed, "ops_monitoring_enabled")
|
||
}
|
||
if before.OpsRealtimeMonitoringEnabled != after.OpsRealtimeMonitoringEnabled {
|
||
changed = append(changed, "ops_realtime_monitoring_enabled")
|
||
}
|
||
if before.OpsQueryModeDefault != after.OpsQueryModeDefault {
|
||
changed = append(changed, "ops_query_mode_default")
|
||
}
|
||
if before.OpsMetricsIntervalSeconds != after.OpsMetricsIntervalSeconds {
|
||
changed = append(changed, "ops_metrics_interval_seconds")
|
||
}
|
||
return changed
|
||
}
|
||
|
||
func normalizeStringList(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
|
||
}
|
||
|
||
// TestSMTPRequest 测试SMTP连接请求
|
||
type TestSMTPRequest struct {
|
||
SMTPHost string `json:"smtp_host" binding:"required"`
|
||
SMTPPort int `json:"smtp_port"`
|
||
SMTPUsername string `json:"smtp_username"`
|
||
SMTPPassword string `json:"smtp_password"`
|
||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||
}
|
||
|
||
// TestSMTPConnection 测试SMTP连接
|
||
// POST /api/v1/admin/settings/test-smtp
|
||
func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
|
||
var req TestSMTPRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
if req.SMTPPort <= 0 {
|
||
req.SMTPPort = 587
|
||
}
|
||
|
||
// 如果未提供密码,从数据库获取已保存的密码
|
||
password := req.SMTPPassword
|
||
if password == "" {
|
||
savedConfig, err := h.emailService.GetSMTPConfig(c.Request.Context())
|
||
if err == nil && savedConfig != nil {
|
||
password = savedConfig.Password
|
||
}
|
||
}
|
||
|
||
config := &service.SMTPConfig{
|
||
Host: req.SMTPHost,
|
||
Port: req.SMTPPort,
|
||
Username: req.SMTPUsername,
|
||
Password: password,
|
||
UseTLS: req.SMTPUseTLS,
|
||
}
|
||
|
||
err := h.emailService.TestSMTPConnectionWithConfig(config)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{"message": "SMTP connection successful"})
|
||
}
|
||
|
||
// SendTestEmailRequest 发送测试邮件请求
|
||
type SendTestEmailRequest struct {
|
||
Email string `json:"email" binding:"required,email"`
|
||
SMTPHost string `json:"smtp_host" binding:"required"`
|
||
SMTPPort int `json:"smtp_port"`
|
||
SMTPUsername string `json:"smtp_username"`
|
||
SMTPPassword string `json:"smtp_password"`
|
||
SMTPFrom string `json:"smtp_from_email"`
|
||
SMTPFromName string `json:"smtp_from_name"`
|
||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||
}
|
||
|
||
// SendTestEmail 发送测试邮件
|
||
// POST /api/v1/admin/settings/send-test-email
|
||
func (h *SettingHandler) SendTestEmail(c *gin.Context) {
|
||
var req SendTestEmailRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
if req.SMTPPort <= 0 {
|
||
req.SMTPPort = 587
|
||
}
|
||
|
||
// 如果未提供密码,从数据库获取已保存的密码
|
||
password := req.SMTPPassword
|
||
if password == "" {
|
||
savedConfig, err := h.emailService.GetSMTPConfig(c.Request.Context())
|
||
if err == nil && savedConfig != nil {
|
||
password = savedConfig.Password
|
||
}
|
||
}
|
||
|
||
config := &service.SMTPConfig{
|
||
Host: req.SMTPHost,
|
||
Port: req.SMTPPort,
|
||
Username: req.SMTPUsername,
|
||
Password: password,
|
||
From: req.SMTPFrom,
|
||
FromName: req.SMTPFromName,
|
||
UseTLS: req.SMTPUseTLS,
|
||
}
|
||
|
||
siteName := h.settingService.GetSiteName(c.Request.Context())
|
||
subject := "[" + siteName + "] Test Email"
|
||
body := `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
|
||
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; }
|
||
.content { padding: 40px 30px; text-align: center; }
|
||
.success { color: #10b981; font-size: 48px; margin-bottom: 20px; }
|
||
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>` + siteName + `</h1>
|
||
</div>
|
||
<div class="content">
|
||
<div class="success">✓</div>
|
||
<h2>Email Configuration Successful!</h2>
|
||
<p>This is a test email to verify your SMTP settings are working correctly.</p>
|
||
</div>
|
||
<div class="footer">
|
||
<p>This is an automated test message.</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`
|
||
|
||
if err := h.emailService.SendEmailWithConfig(config, req.Email, subject, body); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{"message": "Test email sent successfully"})
|
||
}
|
||
|
||
// GetAdminAPIKey 获取管理员 API Key 状态
|
||
// GET /api/v1/admin/settings/admin-api-key
|
||
func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) {
|
||
maskedKey, exists, err := h.settingService.GetAdminAPIKeyStatus(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{
|
||
"exists": exists,
|
||
"masked_key": maskedKey,
|
||
})
|
||
}
|
||
|
||
// RegenerateAdminAPIKey 生成/重新生成管理员 API Key
|
||
// POST /api/v1/admin/settings/admin-api-key/regenerate
|
||
func (h *SettingHandler) RegenerateAdminAPIKey(c *gin.Context) {
|
||
key, err := h.settingService.GenerateAdminAPIKey(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{
|
||
"key": key, // 完整 key 只在生成时返回一次
|
||
})
|
||
}
|
||
|
||
// DeleteAdminAPIKey 删除管理员 API Key
|
||
// DELETE /api/v1/admin/settings/admin-api-key
|
||
func (h *SettingHandler) DeleteAdminAPIKey(c *gin.Context) {
|
||
if err := h.settingService.DeleteAdminAPIKey(c.Request.Context()); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, gin.H{"message": "Admin API key deleted"})
|
||
}
|
||
|
||
// GetStreamTimeoutSettings 获取流超时处理配置
|
||
// GET /api/v1/admin/settings/stream-timeout
|
||
func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) {
|
||
settings, err := h.settingService.GetStreamTimeoutSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, dto.StreamTimeoutSettings{
|
||
Enabled: settings.Enabled,
|
||
Action: settings.Action,
|
||
TempUnschedMinutes: settings.TempUnschedMinutes,
|
||
ThresholdCount: settings.ThresholdCount,
|
||
ThresholdWindowMinutes: settings.ThresholdWindowMinutes,
|
||
})
|
||
}
|
||
|
||
// UpdateStreamTimeoutSettingsRequest 更新流超时配置请求
|
||
type UpdateStreamTimeoutSettingsRequest struct {
|
||
Enabled bool `json:"enabled"`
|
||
Action string `json:"action"`
|
||
TempUnschedMinutes int `json:"temp_unsched_minutes"`
|
||
ThresholdCount int `json:"threshold_count"`
|
||
ThresholdWindowMinutes int `json:"threshold_window_minutes"`
|
||
}
|
||
|
||
// UpdateStreamTimeoutSettings 更新流超时处理配置
|
||
// PUT /api/v1/admin/settings/stream-timeout
|
||
func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) {
|
||
var req UpdateStreamTimeoutSettingsRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
|
||
settings := &service.StreamTimeoutSettings{
|
||
Enabled: req.Enabled,
|
||
Action: req.Action,
|
||
TempUnschedMinutes: req.TempUnschedMinutes,
|
||
ThresholdCount: req.ThresholdCount,
|
||
ThresholdWindowMinutes: req.ThresholdWindowMinutes,
|
||
}
|
||
|
||
if err := h.settingService.SetStreamTimeoutSettings(c.Request.Context(), settings); err != nil {
|
||
response.BadRequest(c, err.Error())
|
||
return
|
||
}
|
||
|
||
// 重新获取设置返回
|
||
updatedSettings, err := h.settingService.GetStreamTimeoutSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
response.Success(c, dto.StreamTimeoutSettings{
|
||
Enabled: updatedSettings.Enabled,
|
||
Action: updatedSettings.Action,
|
||
TempUnschedMinutes: updatedSettings.TempUnschedMinutes,
|
||
ThresholdCount: updatedSettings.ThresholdCount,
|
||
ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes,
|
||
})
|
||
}
|