ba5a09862f
The default socks5 proxy (172.16.32.16:3389) was unreachable for most deployments, causing version check to timeout after 30 seconds. Setting the default to empty string allows direct connection to the Gitea API, which is the expected behavior for most users. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2786 lines
128 KiB
Go
2786 lines
128 KiB
Go
// Package config provides configuration loading, defaults, and validation.
|
||
package config
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/spf13/viper"
|
||
)
|
||
|
||
const (
|
||
RunModeStandard = "standard"
|
||
RunModeSimple = "simple"
|
||
)
|
||
|
||
// 使用量记录队列溢出策略
|
||
const (
|
||
UsageRecordOverflowPolicyDrop = "drop"
|
||
UsageRecordOverflowPolicySample = "sample"
|
||
UsageRecordOverflowPolicySync = "sync"
|
||
)
|
||
|
||
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
|
||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
|
||
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||
|
||
// UMQ(用户消息队列)模式常量
|
||
const (
|
||
// UMQModeSerialize: 账号级串行锁 + RPM 自适应延迟
|
||
UMQModeSerialize = "serialize"
|
||
// UMQModeThrottle: 仅 RPM 自适应前置延迟,不阻塞并发
|
||
UMQModeThrottle = "throttle"
|
||
)
|
||
|
||
// 连接池隔离策略常量
|
||
// 用于控制上游 HTTP 连接池的隔离粒度,影响连接复用和资源消耗
|
||
const (
|
||
// ConnectionPoolIsolationProxy: 按代理隔离
|
||
// 同一代理地址共享连接池,适合代理数量少、账户数量多的场景
|
||
ConnectionPoolIsolationProxy = "proxy"
|
||
// ConnectionPoolIsolationAccount: 按账户隔离
|
||
// 每个账户独立连接池,适合账户数量少、需要严格隔离的场景
|
||
ConnectionPoolIsolationAccount = "account"
|
||
// ConnectionPoolIsolationAccountProxy: 按账户+代理组合隔离(默认)
|
||
// 同一账户+代理组合共享连接池,提供最细粒度的隔离
|
||
ConnectionPoolIsolationAccountProxy = "account_proxy"
|
||
)
|
||
|
||
// DefaultUpstreamResponseReadMaxBytes 上游非流式响应体的默认读取上限。
|
||
// 128 MB 可容纳 2-3 张 4K PNG(base64 膨胀 33%,单张 4K PNG 最坏约 67MB base64)。
|
||
// 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
|
||
const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024
|
||
|
||
type Config struct {
|
||
Server ServerConfig `mapstructure:"server"`
|
||
Log LogConfig `mapstructure:"log"`
|
||
CORS CORSConfig `mapstructure:"cors"`
|
||
Security SecurityConfig `mapstructure:"security"`
|
||
Billing BillingConfig `mapstructure:"billing"`
|
||
Turnstile TurnstileConfig `mapstructure:"turnstile"`
|
||
Database DatabaseConfig `mapstructure:"database"`
|
||
Redis RedisConfig `mapstructure:"redis"`
|
||
Ops OpsConfig `mapstructure:"ops"`
|
||
JWT JWTConfig `mapstructure:"jwt"`
|
||
Totp TotpConfig `mapstructure:"totp"`
|
||
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
||
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
|
||
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
|
||
GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"`
|
||
GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"`
|
||
Default DefaultConfig `mapstructure:"default"`
|
||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||
Pricing PricingConfig `mapstructure:"pricing"`
|
||
Gateway GatewayConfig `mapstructure:"gateway"`
|
||
APIKeyAuth APIKeyAuthCacheConfig `mapstructure:"api_key_auth_cache"`
|
||
SubscriptionCache SubscriptionCacheConfig `mapstructure:"subscription_cache"`
|
||
SubscriptionMaintenance SubscriptionMaintenanceConfig `mapstructure:"subscription_maintenance"`
|
||
Dashboard DashboardCacheConfig `mapstructure:"dashboard_cache"`
|
||
DashboardAgg DashboardAggregationConfig `mapstructure:"dashboard_aggregation"`
|
||
UsageCleanup UsageCleanupConfig `mapstructure:"usage_cleanup"`
|
||
Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
|
||
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
||
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
||
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
||
Gemini GeminiConfig `mapstructure:"gemini"`
|
||
Update UpdateConfig `mapstructure:"update"`
|
||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||
}
|
||
|
||
type LogConfig struct {
|
||
Level string `mapstructure:"level"`
|
||
Format string `mapstructure:"format"`
|
||
ServiceName string `mapstructure:"service_name"`
|
||
Environment string `mapstructure:"env"`
|
||
Caller bool `mapstructure:"caller"`
|
||
StacktraceLevel string `mapstructure:"stacktrace_level"`
|
||
Output LogOutputConfig `mapstructure:"output"`
|
||
Rotation LogRotationConfig `mapstructure:"rotation"`
|
||
Sampling LogSamplingConfig `mapstructure:"sampling"`
|
||
}
|
||
|
||
type LogOutputConfig struct {
|
||
ToStdout bool `mapstructure:"to_stdout"`
|
||
ToFile bool `mapstructure:"to_file"`
|
||
FilePath string `mapstructure:"file_path"`
|
||
}
|
||
|
||
type LogRotationConfig struct {
|
||
MaxSizeMB int `mapstructure:"max_size_mb"`
|
||
MaxBackups int `mapstructure:"max_backups"`
|
||
MaxAgeDays int `mapstructure:"max_age_days"`
|
||
Compress bool `mapstructure:"compress"`
|
||
LocalTime bool `mapstructure:"local_time"`
|
||
}
|
||
|
||
type LogSamplingConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
Initial int `mapstructure:"initial"`
|
||
Thereafter int `mapstructure:"thereafter"`
|
||
}
|
||
|
||
type GeminiConfig struct {
|
||
OAuth GeminiOAuthConfig `mapstructure:"oauth"`
|
||
Quota GeminiQuotaConfig `mapstructure:"quota"`
|
||
}
|
||
|
||
type GeminiOAuthConfig struct {
|
||
ClientID string `mapstructure:"client_id"`
|
||
ClientSecret string `mapstructure:"client_secret"`
|
||
Scopes string `mapstructure:"scopes"`
|
||
}
|
||
|
||
type GeminiQuotaConfig struct {
|
||
Tiers map[string]GeminiTierQuotaConfig `mapstructure:"tiers"`
|
||
Policy string `mapstructure:"policy"`
|
||
}
|
||
|
||
type GeminiTierQuotaConfig struct {
|
||
ProRPD *int64 `mapstructure:"pro_rpd" json:"pro_rpd"`
|
||
FlashRPD *int64 `mapstructure:"flash_rpd" json:"flash_rpd"`
|
||
CooldownMinutes *int `mapstructure:"cooldown_minutes" json:"cooldown_minutes"`
|
||
}
|
||
|
||
type UpdateConfig struct {
|
||
// GitHubRepo 是历史配置字段名,用于在线更新的代码仓库,格式 owner/repo。
|
||
GitHubRepo string `mapstructure:"github_repo"`
|
||
// ProxyURL 用于访问代码仓库的代理地址
|
||
// 支持 http/https/socks5/socks5h 协议
|
||
// 例如: "http://127.0.0.1:7890", "socks5://127.0.0.1:1080"
|
||
ProxyURL string `mapstructure:"proxy_url"`
|
||
}
|
||
|
||
type IdempotencyConfig struct {
|
||
// ObserveOnly 为 true 时处于观察期:未携带 Idempotency-Key 的请求继续放行。
|
||
ObserveOnly bool `mapstructure:"observe_only"`
|
||
// DefaultTTLSeconds 关键写接口的幂等记录默认 TTL(秒)。
|
||
DefaultTTLSeconds int `mapstructure:"default_ttl_seconds"`
|
||
// SystemOperationTTLSeconds 系统操作接口的幂等记录 TTL(秒)。
|
||
SystemOperationTTLSeconds int `mapstructure:"system_operation_ttl_seconds"`
|
||
// ProcessingTimeoutSeconds processing 状态锁超时(秒)。
|
||
ProcessingTimeoutSeconds int `mapstructure:"processing_timeout_seconds"`
|
||
// FailedRetryBackoffSeconds 失败退避窗口(秒)。
|
||
FailedRetryBackoffSeconds int `mapstructure:"failed_retry_backoff_seconds"`
|
||
// MaxStoredResponseLen 持久化响应体最大长度(字节)。
|
||
MaxStoredResponseLen int `mapstructure:"max_stored_response_len"`
|
||
// CleanupIntervalSeconds 过期记录清理周期(秒)。
|
||
CleanupIntervalSeconds int `mapstructure:"cleanup_interval_seconds"`
|
||
// CleanupBatchSize 每次清理的最大记录数。
|
||
CleanupBatchSize int `mapstructure:"cleanup_batch_size"`
|
||
}
|
||
|
||
type LinuxDoConnectConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
ClientID string `mapstructure:"client_id"`
|
||
ClientSecret string `mapstructure:"client_secret"`
|
||
AuthorizeURL string `mapstructure:"authorize_url"`
|
||
TokenURL string `mapstructure:"token_url"`
|
||
UserInfoURL string `mapstructure:"userinfo_url"`
|
||
Scopes string `mapstructure:"scopes"`
|
||
RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记)
|
||
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/linuxdo/callback)
|
||
TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none
|
||
UsePKCE bool `mapstructure:"use_pkce"`
|
||
|
||
// 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。
|
||
// 为空时,服务端会尝试一组常见字段名。
|
||
UserInfoEmailPath string `mapstructure:"userinfo_email_path"`
|
||
UserInfoIDPath string `mapstructure:"userinfo_id_path"`
|
||
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
||
}
|
||
|
||
type WeChatConnectConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
AppID string `mapstructure:"app_id"`
|
||
AppSecret string `mapstructure:"app_secret"`
|
||
OpenAppID string `mapstructure:"open_app_id"`
|
||
OpenAppSecret string `mapstructure:"open_app_secret"`
|
||
MPAppID string `mapstructure:"mp_app_id"`
|
||
MPAppSecret string `mapstructure:"mp_app_secret"`
|
||
MobileAppID string `mapstructure:"mobile_app_id"`
|
||
MobileAppSecret string `mapstructure:"mobile_app_secret"`
|
||
OpenEnabled bool `mapstructure:"open_enabled"`
|
||
MPEnabled bool `mapstructure:"mp_enabled"`
|
||
MobileEnabled bool `mapstructure:"mobile_enabled"`
|
||
Mode string `mapstructure:"mode"`
|
||
Scopes string `mapstructure:"scopes"`
|
||
RedirectURL string `mapstructure:"redirect_url"`
|
||
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"`
|
||
}
|
||
|
||
type OIDCConnectConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
ProviderName string `mapstructure:"provider_name"` // 显示名: "Keycloak" 等
|
||
ClientID string `mapstructure:"client_id"`
|
||
ClientSecret string `mapstructure:"client_secret"`
|
||
IssuerURL string `mapstructure:"issuer_url"`
|
||
DiscoveryURL string `mapstructure:"discovery_url"`
|
||
AuthorizeURL string `mapstructure:"authorize_url"`
|
||
TokenURL string `mapstructure:"token_url"`
|
||
UserInfoURL string `mapstructure:"userinfo_url"`
|
||
JWKSURL string `mapstructure:"jwks_url"`
|
||
Scopes string `mapstructure:"scopes"` // 默认 "openid email profile"
|
||
RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记)
|
||
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/oidc/callback)
|
||
TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none
|
||
UsePKCE bool `mapstructure:"use_pkce"`
|
||
ValidateIDToken bool `mapstructure:"validate_id_token"`
|
||
UsePKCEExplicit bool `mapstructure:"-" yaml:"-"`
|
||
ValidateIDTokenExplicit bool `mapstructure:"-" yaml:"-"`
|
||
AllowedSigningAlgs string `mapstructure:"allowed_signing_algs"` // 默认 "RS256,ES256,PS256"
|
||
ClockSkewSeconds int `mapstructure:"clock_skew_seconds"` // 默认 120
|
||
RequireEmailVerified bool `mapstructure:"require_email_verified"` // 默认 false
|
||
|
||
// 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。
|
||
// 为空时,服务端会尝试一组常见字段名。
|
||
UserInfoEmailPath string `mapstructure:"userinfo_email_path"`
|
||
UserInfoIDPath string `mapstructure:"userinfo_id_path"`
|
||
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
|
||
}
|
||
|
||
type EmailOAuthProviderConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
ClientID string `mapstructure:"client_id"`
|
||
ClientSecret string `mapstructure:"client_secret"`
|
||
AuthorizeURL string `mapstructure:"authorize_url"`
|
||
TokenURL string `mapstructure:"token_url"`
|
||
UserInfoURL string `mapstructure:"userinfo_url"`
|
||
EmailsURL string `mapstructure:"emails_url"`
|
||
Scopes string `mapstructure:"scopes"`
|
||
RedirectURL string `mapstructure:"redirect_url"`
|
||
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"`
|
||
}
|
||
|
||
const (
|
||
defaultWeChatConnectMode = "open"
|
||
defaultWeChatConnectScopes = "snsapi_login"
|
||
defaultWeChatConnectFrontendRedirect = "/auth/wechat/callback"
|
||
)
|
||
|
||
func firstNonEmptyString(values ...string) string {
|
||
for _, value := range values {
|
||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||
return trimmed
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func normalizeWeChatConnectMode(raw string) string {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "mp":
|
||
return "mp"
|
||
case "mobile":
|
||
return "mobile"
|
||
default:
|
||
return defaultWeChatConnectMode
|
||
}
|
||
}
|
||
|
||
func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string {
|
||
mode = normalizeWeChatConnectMode(mode)
|
||
switch mode {
|
||
case "open":
|
||
if openEnabled {
|
||
return "open"
|
||
}
|
||
case "mp":
|
||
if mpEnabled {
|
||
return "mp"
|
||
}
|
||
case "mobile":
|
||
if mobileEnabled {
|
||
return "mobile"
|
||
}
|
||
}
|
||
switch {
|
||
case openEnabled:
|
||
return "open"
|
||
case mpEnabled:
|
||
return "mp"
|
||
case mobileEnabled:
|
||
return "mobile"
|
||
default:
|
||
return mode
|
||
}
|
||
}
|
||
|
||
func defaultWeChatConnectScopesForMode(mode string) string {
|
||
switch normalizeWeChatConnectMode(mode) {
|
||
case "mp":
|
||
return "snsapi_userinfo"
|
||
case "mobile":
|
||
return ""
|
||
default:
|
||
return defaultWeChatConnectScopes
|
||
}
|
||
}
|
||
|
||
func normalizeWeChatConnectScopes(raw, mode string) string {
|
||
switch normalizeWeChatConnectMode(mode) {
|
||
case "mp":
|
||
switch strings.TrimSpace(raw) {
|
||
case "snsapi_base":
|
||
return "snsapi_base"
|
||
case "snsapi_userinfo":
|
||
return "snsapi_userinfo"
|
||
default:
|
||
return defaultWeChatConnectScopesForMode(mode)
|
||
}
|
||
case "mobile":
|
||
return ""
|
||
default:
|
||
return defaultWeChatConnectScopes
|
||
}
|
||
}
|
||
|
||
func shouldApplyLegacyWeChatEnv(configKey, envKey string) bool {
|
||
if viper.InConfig(configKey) {
|
||
return false
|
||
}
|
||
_, hasNewEnv := os.LookupEnv(envKey)
|
||
return !hasNewEnv
|
||
}
|
||
|
||
func hasExplicitConfigOrEnv(configKey, envKey string) bool {
|
||
if viper.InConfig(configKey) {
|
||
return true
|
||
}
|
||
_, ok := os.LookupEnv(envKey)
|
||
return ok
|
||
}
|
||
|
||
func applyLegacyWeChatConnectEnvCompatibility(cfg *WeChatConnectConfig) {
|
||
if cfg == nil {
|
||
return
|
||
}
|
||
|
||
legacyOpenAppID := ""
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.open_app_id", "WECHAT_CONNECT_OPEN_APP_ID") &&
|
||
shouldApplyLegacyWeChatEnv("wechat_connect.app_id", "WECHAT_CONNECT_APP_ID") {
|
||
legacyOpenAppID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID"))
|
||
if legacyOpenAppID != "" {
|
||
cfg.OpenAppID = legacyOpenAppID
|
||
}
|
||
}
|
||
|
||
legacyOpenAppSecret := ""
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.open_app_secret", "WECHAT_CONNECT_OPEN_APP_SECRET") &&
|
||
shouldApplyLegacyWeChatEnv("wechat_connect.app_secret", "WECHAT_CONNECT_APP_SECRET") {
|
||
legacyOpenAppSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET"))
|
||
if legacyOpenAppSecret != "" {
|
||
cfg.OpenAppSecret = legacyOpenAppSecret
|
||
}
|
||
}
|
||
|
||
legacyMPAppID := ""
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.mp_app_id", "WECHAT_CONNECT_MP_APP_ID") &&
|
||
shouldApplyLegacyWeChatEnv("wechat_connect.app_id", "WECHAT_CONNECT_APP_ID") {
|
||
legacyMPAppID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID"))
|
||
if legacyMPAppID != "" {
|
||
cfg.MPAppID = legacyMPAppID
|
||
}
|
||
}
|
||
|
||
legacyMPAppSecret := ""
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.mp_app_secret", "WECHAT_CONNECT_MP_APP_SECRET") &&
|
||
shouldApplyLegacyWeChatEnv("wechat_connect.app_secret", "WECHAT_CONNECT_APP_SECRET") {
|
||
legacyMPAppSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET"))
|
||
if legacyMPAppSecret != "" {
|
||
cfg.MPAppSecret = legacyMPAppSecret
|
||
}
|
||
}
|
||
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.frontend_redirect_url", "WECHAT_CONNECT_FRONTEND_REDIRECT_URL") {
|
||
if legacyFrontend := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL")); legacyFrontend != "" {
|
||
cfg.FrontendRedirectURL = legacyFrontend
|
||
}
|
||
}
|
||
|
||
hasLegacyOpen := legacyOpenAppID != "" && legacyOpenAppSecret != ""
|
||
hasLegacyMP := legacyMPAppID != "" && legacyMPAppSecret != ""
|
||
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.enabled", "WECHAT_CONNECT_ENABLED") && (hasLegacyOpen || hasLegacyMP) {
|
||
cfg.Enabled = true
|
||
}
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.open_enabled", "WECHAT_CONNECT_OPEN_ENABLED") && hasLegacyOpen {
|
||
cfg.OpenEnabled = true
|
||
}
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.mp_enabled", "WECHAT_CONNECT_MP_ENABLED") && hasLegacyMP {
|
||
cfg.MPEnabled = true
|
||
}
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.mode", "WECHAT_CONNECT_MODE") {
|
||
switch {
|
||
case hasLegacyMP && !hasLegacyOpen:
|
||
cfg.Mode = "mp"
|
||
case hasLegacyOpen:
|
||
cfg.Mode = "open"
|
||
}
|
||
}
|
||
if shouldApplyLegacyWeChatEnv("wechat_connect.scopes", "WECHAT_CONNECT_SCOPES") {
|
||
switch {
|
||
case hasLegacyMP && !hasLegacyOpen:
|
||
cfg.Scopes = defaultWeChatConnectScopesForMode("mp")
|
||
case hasLegacyOpen:
|
||
cfg.Scopes = defaultWeChatConnectScopesForMode("open")
|
||
}
|
||
}
|
||
}
|
||
|
||
func normalizeWeChatConnectConfig(cfg *WeChatConnectConfig) {
|
||
if cfg == nil {
|
||
return
|
||
}
|
||
|
||
cfg.AppID = strings.TrimSpace(cfg.AppID)
|
||
cfg.AppSecret = strings.TrimSpace(cfg.AppSecret)
|
||
cfg.OpenAppID = strings.TrimSpace(cfg.OpenAppID)
|
||
cfg.OpenAppSecret = strings.TrimSpace(cfg.OpenAppSecret)
|
||
cfg.MPAppID = strings.TrimSpace(cfg.MPAppID)
|
||
cfg.MPAppSecret = strings.TrimSpace(cfg.MPAppSecret)
|
||
cfg.MobileAppID = strings.TrimSpace(cfg.MobileAppID)
|
||
cfg.MobileAppSecret = strings.TrimSpace(cfg.MobileAppSecret)
|
||
cfg.Mode = normalizeWeChatConnectMode(cfg.Mode)
|
||
cfg.RedirectURL = strings.TrimSpace(cfg.RedirectURL)
|
||
cfg.FrontendRedirectURL = strings.TrimSpace(cfg.FrontendRedirectURL)
|
||
|
||
cfg.AppID = firstNonEmptyString(cfg.AppID, cfg.OpenAppID, cfg.MPAppID, cfg.MobileAppID)
|
||
cfg.AppSecret = firstNonEmptyString(cfg.AppSecret, cfg.OpenAppSecret, cfg.MPAppSecret, cfg.MobileAppSecret)
|
||
cfg.OpenAppID = firstNonEmptyString(cfg.OpenAppID, cfg.AppID)
|
||
cfg.OpenAppSecret = firstNonEmptyString(cfg.OpenAppSecret, cfg.AppSecret)
|
||
cfg.MPAppID = firstNonEmptyString(cfg.MPAppID, cfg.AppID)
|
||
cfg.MPAppSecret = firstNonEmptyString(cfg.MPAppSecret, cfg.AppSecret)
|
||
cfg.MobileAppID = firstNonEmptyString(cfg.MobileAppID, cfg.AppID)
|
||
cfg.MobileAppSecret = firstNonEmptyString(cfg.MobileAppSecret, cfg.AppSecret)
|
||
|
||
if !cfg.OpenEnabled && !cfg.MPEnabled && !cfg.MobileEnabled && cfg.Enabled {
|
||
switch cfg.Mode {
|
||
case "mp":
|
||
cfg.MPEnabled = true
|
||
case "mobile":
|
||
cfg.MobileEnabled = true
|
||
default:
|
||
cfg.OpenEnabled = true
|
||
}
|
||
}
|
||
cfg.Mode = normalizeWeChatConnectStoredMode(cfg.OpenEnabled, cfg.MPEnabled, cfg.MobileEnabled, cfg.Mode)
|
||
cfg.Scopes = normalizeWeChatConnectScopes(cfg.Scopes, cfg.Mode)
|
||
if cfg.FrontendRedirectURL == "" {
|
||
cfg.FrontendRedirectURL = defaultWeChatConnectFrontendRedirect
|
||
}
|
||
}
|
||
|
||
// TokenRefreshConfig OAuth token自动刷新配置
|
||
type TokenRefreshConfig struct {
|
||
// 是否启用自动刷新
|
||
Enabled bool `mapstructure:"enabled"`
|
||
// 检查间隔(分钟)
|
||
CheckIntervalMinutes int `mapstructure:"check_interval_minutes"`
|
||
// 提前刷新时间(小时),在token过期前多久开始刷新
|
||
RefreshBeforeExpiryHours float64 `mapstructure:"refresh_before_expiry_hours"`
|
||
// 最大重试次数
|
||
MaxRetries int `mapstructure:"max_retries"`
|
||
// 重试退避基础时间(秒)
|
||
RetryBackoffSeconds int `mapstructure:"retry_backoff_seconds"`
|
||
}
|
||
|
||
type PricingConfig struct {
|
||
// 价格数据远程URL(默认使用LiteLLM镜像)
|
||
RemoteURL string `mapstructure:"remote_url"`
|
||
// 哈希校验文件URL
|
||
HashURL string `mapstructure:"hash_url"`
|
||
// 本地数据目录
|
||
DataDir string `mapstructure:"data_dir"`
|
||
// 回退文件路径
|
||
FallbackFile string `mapstructure:"fallback_file"`
|
||
// 更新间隔(小时)
|
||
UpdateIntervalHours int `mapstructure:"update_interval_hours"`
|
||
// 哈希校验间隔(分钟)
|
||
HashCheckIntervalMinutes int `mapstructure:"hash_check_interval_minutes"`
|
||
}
|
||
|
||
type ServerConfig struct {
|
||
Host string `mapstructure:"host"`
|
||
Port int `mapstructure:"port"`
|
||
Mode string `mapstructure:"mode"` // debug/release
|
||
FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接
|
||
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
|
||
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
|
||
TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP)
|
||
MaxRequestBodySize int64 `mapstructure:"max_request_body_size"` // 全局最大请求体限制
|
||
H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置
|
||
}
|
||
|
||
// H2CConfig HTTP/2 Cleartext 配置
|
||
type H2CConfig struct {
|
||
Enabled bool `mapstructure:"enabled"` // 是否启用 H2C
|
||
MaxConcurrentStreams uint32 `mapstructure:"max_concurrent_streams"` // 最大并发流数量
|
||
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲超时(秒)
|
||
MaxReadFrameSize int `mapstructure:"max_read_frame_size"` // 最大帧大小(字节)
|
||
MaxUploadBufferPerConnection int `mapstructure:"max_upload_buffer_per_connection"` // 每个连接的上传缓冲区(字节)
|
||
MaxUploadBufferPerStream int `mapstructure:"max_upload_buffer_per_stream"` // 每个流的上传缓冲区(字节)
|
||
}
|
||
|
||
type CORSConfig struct {
|
||
AllowedOrigins []string `mapstructure:"allowed_origins"`
|
||
AllowCredentials bool `mapstructure:"allow_credentials"`
|
||
}
|
||
|
||
type SecurityConfig struct {
|
||
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
|
||
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
|
||
CSP CSPConfig `mapstructure:"csp"`
|
||
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
|
||
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
|
||
}
|
||
|
||
type URLAllowlistConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
UpstreamHosts []string `mapstructure:"upstream_hosts"`
|
||
PricingHosts []string `mapstructure:"pricing_hosts"`
|
||
CRSHosts []string `mapstructure:"crs_hosts"`
|
||
AllowPrivateHosts bool `mapstructure:"allow_private_hosts"`
|
||
// 关闭 URL 白名单校验时,是否允许 http URL(默认只允许 https)
|
||
AllowInsecureHTTP bool `mapstructure:"allow_insecure_http"`
|
||
}
|
||
|
||
type ResponseHeaderConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
AdditionalAllowed []string `mapstructure:"additional_allowed"`
|
||
ForceRemove []string `mapstructure:"force_remove"`
|
||
}
|
||
|
||
type CSPConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
Policy string `mapstructure:"policy"`
|
||
}
|
||
|
||
type ProxyFallbackConfig struct {
|
||
// AllowDirectOnError 当辅助服务的代理初始化失败时是否允许回退直连。
|
||
// 仅影响以下非 AI 账号连接的辅助服务:
|
||
// - Gitea Release 更新检查
|
||
// - 定价数据拉取
|
||
// 不影响 AI 账号网关连接(Claude/OpenAI/Gemini/Antigravity),
|
||
// 这些关键路径的代理失败始终返回错误,不会回退直连。
|
||
// 默认 false:避免因代理配置错误导致服务器真实 IP 泄露。
|
||
AllowDirectOnError bool `mapstructure:"allow_direct_on_error"`
|
||
}
|
||
|
||
type ProxyProbeConfig struct {
|
||
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证
|
||
}
|
||
|
||
type BillingConfig struct {
|
||
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
|
||
}
|
||
|
||
type CircuitBreakerConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
FailureThreshold int `mapstructure:"failure_threshold"`
|
||
ResetTimeoutSeconds int `mapstructure:"reset_timeout_seconds"`
|
||
HalfOpenRequests int `mapstructure:"half_open_requests"`
|
||
}
|
||
|
||
type ConcurrencyConfig struct {
|
||
// PingInterval: 并发等待期间的 SSE ping 间隔(秒)
|
||
PingInterval int `mapstructure:"ping_interval"`
|
||
}
|
||
|
||
type ImageConcurrencyConfig struct {
|
||
// Enabled: 是否启用图片生成独立并发限制,默认关闭以保持现有行为
|
||
Enabled bool `mapstructure:"enabled"`
|
||
// MaxConcurrentRequests: 当前进程允许同时处理的图片生成请求数,0表示不限制
|
||
MaxConcurrentRequests int `mapstructure:"max_concurrent_requests"`
|
||
// OverflowMode: 图片并发达到上限后的处理方式:reject/wait
|
||
OverflowMode string `mapstructure:"overflow_mode"`
|
||
// WaitTimeoutSeconds: overflow_mode=wait 时等待图片并发槽位的超时时间(秒)
|
||
WaitTimeoutSeconds int `mapstructure:"wait_timeout_seconds"`
|
||
// MaxWaitingRequests: overflow_mode=wait 时当前进程允许排队等待的图片请求数
|
||
MaxWaitingRequests int `mapstructure:"max_waiting_requests"`
|
||
}
|
||
|
||
const (
|
||
ImageConcurrencyOverflowModeReject = "reject"
|
||
ImageConcurrencyOverflowModeWait = "wait"
|
||
)
|
||
|
||
// GatewayConfig API网关相关配置
|
||
type GatewayConfig struct {
|
||
// 等待上游响应头的超时时间(秒),0表示无超时
|
||
// 注意:这不影响流式数据传输,只控制等待响应头的时间
|
||
ResponseHeaderTimeout int `mapstructure:"response_header_timeout"`
|
||
// 请求体最大字节数,用于网关请求体大小限制
|
||
MaxBodySize int64 `mapstructure:"max_body_size"`
|
||
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
|
||
UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"`
|
||
// 代理探测响应体读取上限(字节)
|
||
ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"`
|
||
// Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销)
|
||
GeminiDebugResponseHeaders bool `mapstructure:"gemini_debug_response_headers"`
|
||
// ConnectionPoolIsolation: 上游连接池隔离策略(proxy/account/account_proxy)
|
||
ConnectionPoolIsolation string `mapstructure:"connection_pool_isolation"`
|
||
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
|
||
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
|
||
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
|
||
// CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
|
||
// 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
|
||
CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"`
|
||
// ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。
|
||
// 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。
|
||
ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"`
|
||
// ForcedCodexInstructionsTemplate: 启动时从模板文件读取并缓存的模板内容。
|
||
// 该字段不直接参与配置反序列化,仅用于请求热路径避免重复读盘。
|
||
ForcedCodexInstructionsTemplate string `mapstructure:"-"`
|
||
// OpenAIPassthroughAllowTimeoutHeaders: OpenAI 透传模式是否放行客户端超时头
|
||
// 关闭(默认)可避免 x-stainless-timeout 等头导致上游提前断流。
|
||
OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"`
|
||
// OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP)
|
||
OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"`
|
||
// ImageConcurrency: 图片生成独立并发限制配置(默认关闭)
|
||
ImageConcurrency ImageConcurrencyConfig `mapstructure:"image_concurrency"`
|
||
|
||
// HTTP 上游连接池配置(性能优化:支持高并发场景调优)
|
||
// MaxIdleConns: 所有主机的最大空闲连接总数
|
||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||
// MaxIdleConnsPerHost: 每个主机的最大空闲连接数(关键参数,影响连接复用率)
|
||
MaxIdleConnsPerHost int `mapstructure:"max_idle_conns_per_host"`
|
||
// MaxConnsPerHost: 每个主机的最大连接数(包括活跃+空闲),0表示无限制
|
||
MaxConnsPerHost int `mapstructure:"max_conns_per_host"`
|
||
// IdleConnTimeoutSeconds: 空闲连接超时时间(秒)
|
||
IdleConnTimeoutSeconds int `mapstructure:"idle_conn_timeout_seconds"`
|
||
// MaxUpstreamClients: 上游连接池客户端最大缓存数量
|
||
// 当使用连接池隔离策略时,系统会为不同的账户/代理组合创建独立的 HTTP 客户端
|
||
// 此参数限制缓存的客户端数量,超出后会淘汰最久未使用的客户端
|
||
// 建议值:预估的活跃账户数 * 1.2(留有余量)
|
||
MaxUpstreamClients int `mapstructure:"max_upstream_clients"`
|
||
// ClientIdleTTLSeconds: 上游连接池客户端空闲回收阈值(秒)
|
||
// 超过此时间未使用的客户端会被标记为可回收
|
||
// 建议值:根据用户访问频率设置,一般 10-30 分钟
|
||
ClientIdleTTLSeconds int `mapstructure:"client_idle_ttl_seconds"`
|
||
// ConcurrencySlotTTLMinutes: 并发槽位过期时间(分钟)
|
||
// 应大于最长 LLM 请求时间,防止请求完成前槽位过期
|
||
ConcurrencySlotTTLMinutes int `mapstructure:"concurrency_slot_ttl_minutes"`
|
||
// SessionIdleTimeoutMinutes: 会话空闲超时时间(分钟),默认 5 分钟
|
||
// 用于 Anthropic OAuth/SetupToken 账号的会话数量限制功能
|
||
// 空闲超过此时间的会话将被自动释放
|
||
SessionIdleTimeoutMinutes int `mapstructure:"session_idle_timeout_minutes"`
|
||
|
||
// StreamDataIntervalTimeout: 流数据间隔超时(秒),0表示禁用
|
||
StreamDataIntervalTimeout int `mapstructure:"stream_data_interval_timeout"`
|
||
// StreamKeepaliveInterval: 流式 keepalive 间隔(秒),0表示禁用
|
||
StreamKeepaliveInterval int `mapstructure:"stream_keepalive_interval"`
|
||
// ImageStreamDataIntervalTimeout: 图片流数据间隔超时(秒),0表示禁用
|
||
ImageStreamDataIntervalTimeout int `mapstructure:"image_stream_data_interval_timeout"`
|
||
// ImageStreamKeepaliveInterval: 图片流式 keepalive 间隔(秒),0表示禁用
|
||
ImageStreamKeepaliveInterval int `mapstructure:"image_stream_keepalive_interval"`
|
||
// KiroStreamKeepaliveInterval: Kiro 流式 keepalive 间隔(秒),0使用默认 25 秒
|
||
KiroStreamKeepaliveInterval int `mapstructure:"kiro_stream_keepalive_interval"`
|
||
// MaxLineSize: 上游 SSE 单行最大字节数(0使用默认值)
|
||
MaxLineSize int `mapstructure:"max_line_size"`
|
||
|
||
// 是否记录上游错误响应体摘要(避免输出请求内容)
|
||
LogUpstreamErrorBody bool `mapstructure:"log_upstream_error_body"`
|
||
// 上游错误响应体记录最大字节数(超过会截断)
|
||
LogUpstreamErrorBodyMaxBytes int `mapstructure:"log_upstream_error_body_max_bytes"`
|
||
|
||
// API-key 账号在客户端未提供 anthropic-beta 时,是否按需自动补齐(默认关闭以保持兼容)
|
||
InjectBetaForAPIKey bool `mapstructure:"inject_beta_for_apikey"`
|
||
|
||
// 是否允许对部分 400 错误触发 failover(默认关闭以避免改变语义)
|
||
FailoverOn400 bool `mapstructure:"failover_on_400"`
|
||
|
||
// 账户切换最大次数(遇到上游错误时切换到其他账户的次数上限)
|
||
MaxAccountSwitches int `mapstructure:"max_account_switches"`
|
||
// Gemini 账户切换最大次数(Gemini 平台单独配置,因 API 限制更严格)
|
||
MaxAccountSwitchesGemini int `mapstructure:"max_account_switches_gemini"`
|
||
|
||
// Antigravity 429 fallback 限流时间(分钟),解析重置时间失败时使用
|
||
AntigravityFallbackCooldownMinutes int `mapstructure:"antigravity_fallback_cooldown_minutes"`
|
||
|
||
// Scheduling: 账号调度相关配置
|
||
Scheduling GatewaySchedulingConfig `mapstructure:"scheduling"`
|
||
|
||
// TLSFingerprint: TLS指纹伪装配置
|
||
TLSFingerprint TLSFingerprintConfig `mapstructure:"tls_fingerprint"`
|
||
|
||
// UsageRecord: 使用量记录异步队列配置(有界队列 + 固定 worker)
|
||
UsageRecord GatewayUsageRecordConfig `mapstructure:"usage_record"`
|
||
|
||
// UserGroupRateCacheTTLSeconds: 用户分组倍率热路径缓存 TTL(秒)
|
||
UserGroupRateCacheTTLSeconds int `mapstructure:"user_group_rate_cache_ttl_seconds"`
|
||
// ModelsListCacheTTLSeconds: /v1/models 模型列表短缓存 TTL(秒)
|
||
ModelsListCacheTTLSeconds int `mapstructure:"models_list_cache_ttl_seconds"`
|
||
|
||
// UserMessageQueue: 用户消息串行队列配置
|
||
// 对 role:"user" 的真实用户消息实施账号级串行化 + RPM 自适应延迟
|
||
UserMessageQueue UserMessageQueueConfig `mapstructure:"user_message_queue"`
|
||
}
|
||
|
||
// UserMessageQueueConfig 用户消息串行队列配置
|
||
// 用于 Anthropic OAuth/SetupToken 账号的用户消息串行化发送
|
||
type UserMessageQueueConfig struct {
|
||
// Mode: 模式选择
|
||
// "serialize" = 账号级串行锁 + RPM 自适应延迟
|
||
// "throttle" = 仅 RPM 自适应前置延迟,不阻塞并发
|
||
// "" = 禁用(默认)
|
||
Mode string `mapstructure:"mode"`
|
||
// Enabled: 已废弃,仅向后兼容(等同于 mode: "serialize")
|
||
Enabled bool `mapstructure:"enabled"`
|
||
// LockTTLMs: 串行锁 TTL(毫秒),应大于最长请求时间
|
||
LockTTLMs int `mapstructure:"lock_ttl_ms"`
|
||
// WaitTimeoutMs: 等待获取锁的超时时间(毫秒)
|
||
WaitTimeoutMs int `mapstructure:"wait_timeout_ms"`
|
||
// MinDelayMs: RPM 自适应延迟下限(毫秒)
|
||
MinDelayMs int `mapstructure:"min_delay_ms"`
|
||
// MaxDelayMs: RPM 自适应延迟上限(毫秒)
|
||
MaxDelayMs int `mapstructure:"max_delay_ms"`
|
||
// CleanupIntervalSeconds: 孤儿锁清理间隔(秒),0 表示禁用
|
||
CleanupIntervalSeconds int `mapstructure:"cleanup_interval_seconds"`
|
||
}
|
||
|
||
// WaitTimeout 返回等待超时的 time.Duration
|
||
func (c *UserMessageQueueConfig) WaitTimeout() time.Duration {
|
||
if c.WaitTimeoutMs <= 0 {
|
||
return 30 * time.Second
|
||
}
|
||
return time.Duration(c.WaitTimeoutMs) * time.Millisecond
|
||
}
|
||
|
||
// GetEffectiveMode 返回生效的模式
|
||
// 注意:Mode 字段已在 load() 中做过白名单校验和规范化,此处无需重复验证
|
||
func (c *UserMessageQueueConfig) GetEffectiveMode() string {
|
||
if c.Mode == UMQModeSerialize || c.Mode == UMQModeThrottle {
|
||
return c.Mode
|
||
}
|
||
if c.Enabled {
|
||
return UMQModeSerialize // 向后兼容
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// GatewayOpenAIWSConfig OpenAI Responses WebSocket 配置。
|
||
// 注意:默认全局开启;如需回滚可使用 force_http 或关闭 enabled。
|
||
type GatewayOpenAIWSConfig struct {
|
||
// ModeRouterV2Enabled: 新版 WS mode 路由开关(默认 false;关闭时保持 legacy 行为)
|
||
ModeRouterV2Enabled bool `mapstructure:"mode_router_v2_enabled"`
|
||
// IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough)
|
||
IngressModeDefault string `mapstructure:"ingress_mode_default"`
|
||
// Enabled: 全局总开关(默认 true)
|
||
Enabled bool `mapstructure:"enabled"`
|
||
// OAuthEnabled: 是否允许 OpenAI OAuth 账号使用 WS
|
||
OAuthEnabled bool `mapstructure:"oauth_enabled"`
|
||
// APIKeyEnabled: 是否允许 OpenAI API Key 账号使用 WS
|
||
APIKeyEnabled bool `mapstructure:"apikey_enabled"`
|
||
// ForceHTTP: 全局强制 HTTP(用于紧急回滚)
|
||
ForceHTTP bool `mapstructure:"force_http"`
|
||
// AllowStoreRecovery: 允许在 WSv2 下按策略恢复 store=true(默认 false)
|
||
AllowStoreRecovery bool `mapstructure:"allow_store_recovery"`
|
||
// IngressPreviousResponseRecoveryEnabled: ingress 模式收到 previous_response_not_found 时,是否允许自动去掉 previous_response_id 重试一次(默认 true)
|
||
IngressPreviousResponseRecoveryEnabled bool `mapstructure:"ingress_previous_response_recovery_enabled"`
|
||
// StoreDisabledConnMode: store=false 且无可复用会话连接时的建连策略(strict/adaptive/off)
|
||
// - strict: 强制新建连接(隔离优先)
|
||
// - adaptive: 仅在高风险失败后强制新建连接(性能与隔离折中)
|
||
// - off: 不强制新建连接(复用优先)
|
||
StoreDisabledConnMode string `mapstructure:"store_disabled_conn_mode"`
|
||
// StoreDisabledForceNewConn: store=false 且无可复用粘连连接时是否强制新建连接(默认 true,保障会话隔离)
|
||
// 兼容旧配置;当 StoreDisabledConnMode 为空时才生效。
|
||
StoreDisabledForceNewConn bool `mapstructure:"store_disabled_force_new_conn"`
|
||
// PrewarmGenerateEnabled: 是否启用 WSv2 generate=false 预热(默认 false)
|
||
PrewarmGenerateEnabled bool `mapstructure:"prewarm_generate_enabled"`
|
||
|
||
// Feature 开关:v2 优先于 v1
|
||
ResponsesWebsockets bool `mapstructure:"responses_websockets"`
|
||
ResponsesWebsocketsV2 bool `mapstructure:"responses_websockets_v2"`
|
||
|
||
// 连接池参数
|
||
MaxConnsPerAccount int `mapstructure:"max_conns_per_account"`
|
||
MinIdlePerAccount int `mapstructure:"min_idle_per_account"`
|
||
MaxIdlePerAccount int `mapstructure:"max_idle_per_account"`
|
||
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限
|
||
DynamicMaxConnsByAccountConcurrencyEnabled bool `mapstructure:"dynamic_max_conns_by_account_concurrency_enabled"`
|
||
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor))
|
||
OAuthMaxConnsFactor float64 `mapstructure:"oauth_max_conns_factor"`
|
||
// APIKeyMaxConnsFactor: API Key 账号连接池系数(effective=ceil(concurrency*factor))
|
||
APIKeyMaxConnsFactor float64 `mapstructure:"apikey_max_conns_factor"`
|
||
DialTimeoutSeconds int `mapstructure:"dial_timeout_seconds"`
|
||
ReadTimeoutSeconds int `mapstructure:"read_timeout_seconds"`
|
||
WriteTimeoutSeconds int `mapstructure:"write_timeout_seconds"`
|
||
PoolTargetUtilization float64 `mapstructure:"pool_target_utilization"`
|
||
QueueLimitPerConn int `mapstructure:"queue_limit_per_conn"`
|
||
// EventFlushBatchSize: WS 流式写出批量 flush 阈值(事件条数)
|
||
EventFlushBatchSize int `mapstructure:"event_flush_batch_size"`
|
||
// EventFlushIntervalMS: WS 流式写出最大等待时间(毫秒);0 表示仅按 batch 触发
|
||
EventFlushIntervalMS int `mapstructure:"event_flush_interval_ms"`
|
||
// PrewarmCooldownMS: 连接池预热触发冷却时间(毫秒)
|
||
PrewarmCooldownMS int `mapstructure:"prewarm_cooldown_ms"`
|
||
// FallbackCooldownSeconds: WS 回退冷却窗口,避免 WS/HTTP 抖动;0 表示关闭冷却
|
||
FallbackCooldownSeconds int `mapstructure:"fallback_cooldown_seconds"`
|
||
// RetryBackoffInitialMS: WS 重试初始退避(毫秒);<=0 表示关闭退避
|
||
RetryBackoffInitialMS int `mapstructure:"retry_backoff_initial_ms"`
|
||
// RetryBackoffMaxMS: WS 重试最大退避(毫秒)
|
||
RetryBackoffMaxMS int `mapstructure:"retry_backoff_max_ms"`
|
||
// RetryJitterRatio: WS 重试退避抖动比例(0-1)
|
||
RetryJitterRatio float64 `mapstructure:"retry_jitter_ratio"`
|
||
// RetryTotalBudgetMS: WS 单次请求重试总预算(毫秒);0 表示关闭预算限制
|
||
RetryTotalBudgetMS int `mapstructure:"retry_total_budget_ms"`
|
||
// PayloadLogSampleRate: payload_schema 日志采样率(0-1)
|
||
PayloadLogSampleRate float64 `mapstructure:"payload_log_sample_rate"`
|
||
|
||
// 账号调度与粘连参数
|
||
LBTopK int `mapstructure:"lb_top_k"`
|
||
// StickySessionTTLSeconds: session_hash -> account_id 粘连 TTL
|
||
StickySessionTTLSeconds int `mapstructure:"sticky_session_ttl_seconds"`
|
||
// SessionHashReadOldFallback: 会话哈希迁移期是否允许“新 key 未命中时回退读旧 SHA-256 key”
|
||
SessionHashReadOldFallback bool `mapstructure:"session_hash_read_old_fallback"`
|
||
// SessionHashDualWriteOld: 会话哈希迁移期是否双写旧 SHA-256 key(短 TTL)
|
||
SessionHashDualWriteOld bool `mapstructure:"session_hash_dual_write_old"`
|
||
// MetadataBridgeEnabled: RequestMetadata 迁移期是否保留旧 ctxkey.* 兼容桥接
|
||
MetadataBridgeEnabled bool `mapstructure:"metadata_bridge_enabled"`
|
||
// StickyResponseIDTTLSeconds: response_id -> account_id 粘连 TTL
|
||
StickyResponseIDTTLSeconds int `mapstructure:"sticky_response_id_ttl_seconds"`
|
||
// StickyPreviousResponseTTLSeconds: 兼容旧键(当新键未设置时回退)
|
||
StickyPreviousResponseTTLSeconds int `mapstructure:"sticky_previous_response_ttl_seconds"`
|
||
|
||
SchedulerScoreWeights GatewayOpenAIWSSchedulerScoreWeights `mapstructure:"scheduler_score_weights"`
|
||
}
|
||
|
||
// GatewayOpenAIWSSchedulerScoreWeights 账号调度打分权重。
|
||
type GatewayOpenAIWSSchedulerScoreWeights struct {
|
||
Priority float64 `mapstructure:"priority"`
|
||
Load float64 `mapstructure:"load"`
|
||
Queue float64 `mapstructure:"queue"`
|
||
ErrorRate float64 `mapstructure:"error_rate"`
|
||
TTFT float64 `mapstructure:"ttft"`
|
||
}
|
||
|
||
// GatewayUsageRecordConfig 使用量记录异步队列配置
|
||
type GatewayUsageRecordConfig struct {
|
||
// WorkerCount: worker 初始数量(自动扩缩容开启时作为初始并发上限)
|
||
WorkerCount int `mapstructure:"worker_count"`
|
||
// QueueSize: 队列容量(有界)
|
||
QueueSize int `mapstructure:"queue_size"`
|
||
// TaskTimeoutSeconds: 单个使用量记录任务超时(秒)
|
||
TaskTimeoutSeconds int `mapstructure:"task_timeout_seconds"`
|
||
// OverflowPolicy: 队列满时策略(drop/sample/sync)
|
||
OverflowPolicy string `mapstructure:"overflow_policy"`
|
||
// OverflowSamplePercent: sample 策略下,同步回写采样百分比(1-100)
|
||
OverflowSamplePercent int `mapstructure:"overflow_sample_percent"`
|
||
|
||
// AutoScaleEnabled: 是否启用 worker 自动扩缩容
|
||
AutoScaleEnabled bool `mapstructure:"auto_scale_enabled"`
|
||
// AutoScaleMinWorkers: 自动扩缩容最小 worker 数
|
||
AutoScaleMinWorkers int `mapstructure:"auto_scale_min_workers"`
|
||
// AutoScaleMaxWorkers: 自动扩缩容最大 worker 数
|
||
AutoScaleMaxWorkers int `mapstructure:"auto_scale_max_workers"`
|
||
// AutoScaleUpQueuePercent: 队列占用率达到该阈值时触发扩容
|
||
AutoScaleUpQueuePercent int `mapstructure:"auto_scale_up_queue_percent"`
|
||
// AutoScaleDownQueuePercent: 队列占用率低于该阈值时触发缩容
|
||
AutoScaleDownQueuePercent int `mapstructure:"auto_scale_down_queue_percent"`
|
||
// AutoScaleUpStep: 每次扩容步长
|
||
AutoScaleUpStep int `mapstructure:"auto_scale_up_step"`
|
||
// AutoScaleDownStep: 每次缩容步长
|
||
AutoScaleDownStep int `mapstructure:"auto_scale_down_step"`
|
||
// AutoScaleCheckIntervalSeconds: 自动扩缩容检测间隔(秒)
|
||
AutoScaleCheckIntervalSeconds int `mapstructure:"auto_scale_check_interval_seconds"`
|
||
// AutoScaleCooldownSeconds: 自动扩缩容冷却时间(秒)
|
||
AutoScaleCooldownSeconds int `mapstructure:"auto_scale_cooldown_seconds"`
|
||
}
|
||
|
||
// TLSFingerprintConfig TLS指纹伪装配置
|
||
// 用于模拟 Claude CLI (Node.js) 的 TLS 握手特征,避免被识别为非官方客户端
|
||
type TLSFingerprintConfig struct {
|
||
// Enabled: 是否全局启用TLS指纹功能
|
||
Enabled bool `mapstructure:"enabled"`
|
||
// Profiles: 预定义的TLS指纹配置模板
|
||
// key 为模板名称,如 "claude_cli_v2", "chrome_120" 等
|
||
Profiles map[string]TLSProfileConfig `mapstructure:"profiles"`
|
||
}
|
||
|
||
// TLSProfileConfig 单个TLS指纹模板的配置
|
||
// 所有列表字段为空时使用内置默认值(Claude CLI 2.x / Node.js 20.x)
|
||
// 建议通过 TLS 指纹采集工具 (tests/tls-fingerprint-web) 获取完整配置
|
||
type TLSProfileConfig struct {
|
||
// Name: 模板显示名称
|
||
Name string `mapstructure:"name"`
|
||
// EnableGREASE: 是否启用GREASE扩展(Chrome使用,Node.js不使用)
|
||
EnableGREASE bool `mapstructure:"enable_grease"`
|
||
// CipherSuites: TLS加密套件列表
|
||
CipherSuites []uint16 `mapstructure:"cipher_suites"`
|
||
// Curves: 椭圆曲线列表
|
||
Curves []uint16 `mapstructure:"curves"`
|
||
// PointFormats: 点格式列表
|
||
PointFormats []uint16 `mapstructure:"point_formats"`
|
||
// SignatureAlgorithms: 签名算法列表
|
||
SignatureAlgorithms []uint16 `mapstructure:"signature_algorithms"`
|
||
// ALPNProtocols: ALPN协议列表(如 ["h2", "http/1.1"])
|
||
ALPNProtocols []string `mapstructure:"alpn_protocols"`
|
||
// SupportedVersions: 支持的TLS版本列表(如 [0x0304, 0x0303] 即 TLS1.3, TLS1.2)
|
||
SupportedVersions []uint16 `mapstructure:"supported_versions"`
|
||
// KeyShareGroups: Key Share中发送的曲线组(如 [29] 即 X25519)
|
||
KeyShareGroups []uint16 `mapstructure:"key_share_groups"`
|
||
// PSKModes: PSK密钥交换模式(如 [1] 即 psk_dhe_ke)
|
||
PSKModes []uint16 `mapstructure:"psk_modes"`
|
||
// Extensions: TLS扩展类型ID列表,按发送顺序排列
|
||
// 空则使用内置默认顺序 [0,11,10,35,16,22,23,13,43,45,51]
|
||
// GREASE值(如0x0a0a)会自动插入GREASE扩展
|
||
Extensions []uint16 `mapstructure:"extensions"`
|
||
}
|
||
|
||
// GatewaySchedulingConfig accounts scheduling configuration.
|
||
type GatewaySchedulingConfig struct {
|
||
// 粘性会话排队配置
|
||
StickySessionMaxWaiting int `mapstructure:"sticky_session_max_waiting"`
|
||
StickySessionWaitTimeout time.Duration `mapstructure:"sticky_session_wait_timeout"`
|
||
|
||
// 兜底排队配置
|
||
FallbackWaitTimeout time.Duration `mapstructure:"fallback_wait_timeout"`
|
||
FallbackMaxWaiting int `mapstructure:"fallback_max_waiting"`
|
||
|
||
// 兜底层账户选择策略: "last_used"(按最后使用时间排序,默认) 或 "random"(随机)
|
||
FallbackSelectionMode string `mapstructure:"fallback_selection_mode"`
|
||
|
||
// 负载计算
|
||
LoadBatchEnabled bool `mapstructure:"load_batch_enabled"`
|
||
// 快照桶读取时的 MGET 分块大小
|
||
SnapshotMGetChunkSize int `mapstructure:"snapshot_mget_chunk_size"`
|
||
// 快照重建时的缓存写入分块大小
|
||
SnapshotWriteChunkSize int `mapstructure:"snapshot_write_chunk_size"`
|
||
|
||
// 过期槽位清理周期(0 表示禁用)
|
||
SlotCleanupInterval time.Duration `mapstructure:"slot_cleanup_interval"`
|
||
|
||
// 受控回源配置
|
||
DbFallbackEnabled bool `mapstructure:"db_fallback_enabled"`
|
||
// 受控回源超时(秒),0 表示不额外收紧超时
|
||
DbFallbackTimeoutSeconds int `mapstructure:"db_fallback_timeout_seconds"`
|
||
// 受控回源限流(实例级 QPS),0 表示不限制
|
||
DbFallbackMaxQPS int `mapstructure:"db_fallback_max_qps"`
|
||
|
||
// Outbox 轮询与滞后阈值配置
|
||
// Outbox 轮询周期(秒)
|
||
OutboxPollIntervalSeconds int `mapstructure:"outbox_poll_interval_seconds"`
|
||
// Outbox 滞后告警阈值(秒)
|
||
OutboxLagWarnSeconds int `mapstructure:"outbox_lag_warn_seconds"`
|
||
// Outbox 触发强制重建阈值(秒)
|
||
OutboxLagRebuildSeconds int `mapstructure:"outbox_lag_rebuild_seconds"`
|
||
// Outbox 连续滞后触发次数
|
||
OutboxLagRebuildFailures int `mapstructure:"outbox_lag_rebuild_failures"`
|
||
// Outbox 积压触发重建阈值(行数)
|
||
OutboxBacklogRebuildRows int `mapstructure:"outbox_backlog_rebuild_rows"`
|
||
|
||
// 全量重建周期配置
|
||
// 全量重建周期(秒),0 表示禁用
|
||
FullRebuildIntervalSeconds int `mapstructure:"full_rebuild_interval_seconds"`
|
||
}
|
||
|
||
func (s *ServerConfig) Address() string {
|
||
return fmt.Sprintf("%s:%d", s.Host, s.Port)
|
||
}
|
||
|
||
// DatabaseConfig 数据库连接配置
|
||
// 性能优化:新增连接池参数,避免频繁创建/销毁连接
|
||
type DatabaseConfig struct {
|
||
Host string `mapstructure:"host"`
|
||
Port int `mapstructure:"port"`
|
||
User string `mapstructure:"user"`
|
||
Password string `mapstructure:"password"`
|
||
DBName string `mapstructure:"dbname"`
|
||
SSLMode string `mapstructure:"sslmode"`
|
||
// AutoMigrate controls whether startup applies embedded SQL migrations.
|
||
AutoMigrate bool `mapstructure:"auto_migrate"`
|
||
// 连接池配置(性能优化:可配置化连接池参数)
|
||
// MaxOpenConns: 最大打开连接数,控制数据库连接上限,防止资源耗尽
|
||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||
// MaxIdleConns: 最大空闲连接数,保持热连接减少建连延迟
|
||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||
// ConnMaxLifetimeMinutes: 连接最大存活时间,防止长连接导致的资源泄漏
|
||
ConnMaxLifetimeMinutes int `mapstructure:"conn_max_lifetime_minutes"`
|
||
// ConnMaxIdleTimeMinutes: 空闲连接最大存活时间,及时释放不活跃连接
|
||
ConnMaxIdleTimeMinutes int `mapstructure:"conn_max_idle_time_minutes"`
|
||
}
|
||
|
||
func (d *DatabaseConfig) DSN() string {
|
||
// 当密码为空时不包含 password 参数,避免 libpq 解析错误
|
||
if d.Password == "" {
|
||
return fmt.Sprintf(
|
||
"host=%s port=%d user=%s dbname=%s sslmode=%s",
|
||
d.Host, d.Port, d.User, d.DBName, d.SSLMode,
|
||
)
|
||
}
|
||
return fmt.Sprintf(
|
||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
||
)
|
||
}
|
||
|
||
// DSNWithTimezone returns DSN with timezone setting
|
||
func (d *DatabaseConfig) DSNWithTimezone(tz string) string {
|
||
if tz == "" {
|
||
tz = "Asia/Shanghai"
|
||
}
|
||
// 当密码为空时不包含 password 参数,避免 libpq 解析错误
|
||
if d.Password == "" {
|
||
return fmt.Sprintf(
|
||
"host=%s port=%d user=%s dbname=%s sslmode=%s TimeZone=%s",
|
||
d.Host, d.Port, d.User, d.DBName, d.SSLMode, tz,
|
||
)
|
||
}
|
||
return fmt.Sprintf(
|
||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s",
|
||
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode, tz,
|
||
)
|
||
}
|
||
|
||
// RedisConfig Redis 连接配置
|
||
// 性能优化:新增连接池和超时参数,提升高并发场景下的吞吐量
|
||
type RedisConfig struct {
|
||
Host string `mapstructure:"host"`
|
||
Port int `mapstructure:"port"`
|
||
Password string `mapstructure:"password"`
|
||
DB int `mapstructure:"db"`
|
||
// 连接池与超时配置(性能优化:可配置化连接池参数)
|
||
// DialTimeoutSeconds: 建立连接超时,防止慢连接阻塞
|
||
DialTimeoutSeconds int `mapstructure:"dial_timeout_seconds"`
|
||
// ReadTimeoutSeconds: 读取超时,避免慢查询阻塞连接池
|
||
ReadTimeoutSeconds int `mapstructure:"read_timeout_seconds"`
|
||
// WriteTimeoutSeconds: 写入超时,避免慢写入阻塞连接池
|
||
WriteTimeoutSeconds int `mapstructure:"write_timeout_seconds"`
|
||
// PoolSize: 连接池大小,控制最大并发连接数
|
||
PoolSize int `mapstructure:"pool_size"`
|
||
// MinIdleConns: 最小空闲连接数,保持热连接减少冷启动延迟
|
||
MinIdleConns int `mapstructure:"min_idle_conns"`
|
||
// EnableTLS: 是否启用 TLS/SSL 连接
|
||
EnableTLS bool `mapstructure:"enable_tls"`
|
||
}
|
||
|
||
func (r *RedisConfig) Address() string {
|
||
return fmt.Sprintf("%s:%d", r.Host, r.Port)
|
||
}
|
||
|
||
type OpsConfig struct {
|
||
// Enabled controls whether ops features should run.
|
||
//
|
||
// NOTE: vNext still has a DB-backed feature flag (ops_monitoring_enabled) for runtime on/off.
|
||
// This config flag is the "hard switch" for deployments that want to disable ops completely.
|
||
Enabled bool `mapstructure:"enabled"`
|
||
|
||
// UsePreaggregatedTables prefers ops_metrics_hourly/daily for long-window dashboard queries.
|
||
UsePreaggregatedTables bool `mapstructure:"use_preaggregated_tables"`
|
||
|
||
// Cleanup controls periodic deletion of old ops data to prevent unbounded growth.
|
||
Cleanup OpsCleanupConfig `mapstructure:"cleanup"`
|
||
|
||
// MetricsCollectorCache controls Redis caching for expensive per-window collector queries.
|
||
MetricsCollectorCache OpsMetricsCollectorCacheConfig `mapstructure:"metrics_collector_cache"`
|
||
|
||
// Pre-aggregation configuration.
|
||
Aggregation OpsAggregationConfig `mapstructure:"aggregation"`
|
||
}
|
||
|
||
type OpsCleanupConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
Schedule string `mapstructure:"schedule"`
|
||
|
||
// Retention days (0 disables that cleanup target).
|
||
//
|
||
// vNext requirement: default 30 days across ops datasets.
|
||
ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"`
|
||
MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"`
|
||
HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"`
|
||
}
|
||
|
||
type OpsAggregationConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
}
|
||
|
||
type OpsMetricsCollectorCacheConfig struct {
|
||
Enabled bool `mapstructure:"enabled"`
|
||
TTL time.Duration `mapstructure:"ttl"`
|
||
}
|
||
|
||
type JWTConfig struct {
|
||
Secret string `mapstructure:"secret"`
|
||
ExpireHour int `mapstructure:"expire_hour"`
|
||
// AccessTokenExpireMinutes: Access Token有效期(分钟)
|
||
// - >0: 使用分钟配置(优先级高于 ExpireHour)
|
||
// - =0: 回退使用 ExpireHour(向后兼容旧配置)
|
||
AccessTokenExpireMinutes int `mapstructure:"access_token_expire_minutes"`
|
||
// RefreshTokenExpireDays: Refresh Token有效期(天),默认30天
|
||
RefreshTokenExpireDays int `mapstructure:"refresh_token_expire_days"`
|
||
// RefreshWindowMinutes: 刷新窗口(分钟),在Access Token过期前多久开始允许刷新
|
||
RefreshWindowMinutes int `mapstructure:"refresh_window_minutes"`
|
||
}
|
||
|
||
// TotpConfig TOTP 双因素认证配置
|
||
type TotpConfig struct {
|
||
// EncryptionKey 用于加密 TOTP 密钥的 AES-256 密钥(32 字节 hex 编码)
|
||
// 如果为空,将自动生成一个随机密钥(仅适用于开发环境)
|
||
EncryptionKey string `mapstructure:"encryption_key"`
|
||
// EncryptionKeyConfigured 标记加密密钥是否为手动配置(非自动生成)
|
||
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
|
||
EncryptionKeyConfigured bool `mapstructure:"-"`
|
||
}
|
||
|
||
type TurnstileConfig struct {
|
||
Required bool `mapstructure:"required"`
|
||
}
|
||
|
||
type DefaultConfig struct {
|
||
AdminEmail string `mapstructure:"admin_email"`
|
||
AdminPassword string `mapstructure:"admin_password"`
|
||
UserConcurrency int `mapstructure:"user_concurrency"`
|
||
UserBalance float64 `mapstructure:"user_balance"`
|
||
APIKeyPrefix string `mapstructure:"api_key_prefix"`
|
||
RateMultiplier float64 `mapstructure:"rate_multiplier"`
|
||
}
|
||
|
||
type RateLimitConfig struct {
|
||
OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟)
|
||
OAuth401CooldownMinutes int `mapstructure:"oauth_401_cooldown_minutes"` // OAuth 401临时不可调度冷却(分钟)
|
||
}
|
||
|
||
// APIKeyAuthCacheConfig API Key 认证缓存配置
|
||
type APIKeyAuthCacheConfig struct {
|
||
L1Size int `mapstructure:"l1_size"`
|
||
L1TTLSeconds int `mapstructure:"l1_ttl_seconds"`
|
||
L2TTLSeconds int `mapstructure:"l2_ttl_seconds"`
|
||
NegativeTTLSeconds int `mapstructure:"negative_ttl_seconds"`
|
||
JitterPercent int `mapstructure:"jitter_percent"`
|
||
Singleflight bool `mapstructure:"singleflight"`
|
||
}
|
||
|
||
// SubscriptionCacheConfig 订阅认证 L1 缓存配置
|
||
type SubscriptionCacheConfig struct {
|
||
L1Size int `mapstructure:"l1_size"`
|
||
L1TTLSeconds int `mapstructure:"l1_ttl_seconds"`
|
||
JitterPercent int `mapstructure:"jitter_percent"`
|
||
}
|
||
|
||
// SubscriptionMaintenanceConfig 订阅窗口维护后台任务配置。
|
||
// 用于将“请求路径触发的维护动作”有界化,避免高并发下 goroutine 膨胀。
|
||
type SubscriptionMaintenanceConfig struct {
|
||
WorkerCount int `mapstructure:"worker_count"`
|
||
QueueSize int `mapstructure:"queue_size"`
|
||
}
|
||
|
||
// DashboardCacheConfig 仪表盘统计缓存配置
|
||
type DashboardCacheConfig struct {
|
||
// Enabled: 是否启用仪表盘缓存
|
||
Enabled bool `mapstructure:"enabled"`
|
||
// KeyPrefix: Redis key 前缀,用于多环境隔离
|
||
KeyPrefix string `mapstructure:"key_prefix"`
|
||
// StatsFreshTTLSeconds: 缓存命中认为“新鲜”的时间窗口(秒)
|
||
StatsFreshTTLSeconds int `mapstructure:"stats_fresh_ttl_seconds"`
|
||
// StatsTTLSeconds: Redis 缓存总 TTL(秒)
|
||
StatsTTLSeconds int `mapstructure:"stats_ttl_seconds"`
|
||
// StatsRefreshTimeoutSeconds: 异步刷新超时(秒)
|
||
StatsRefreshTimeoutSeconds int `mapstructure:"stats_refresh_timeout_seconds"`
|
||
}
|
||
|
||
// DashboardAggregationConfig 仪表盘预聚合配置
|
||
type DashboardAggregationConfig struct {
|
||
// Enabled: 是否启用预聚合作业
|
||
Enabled bool `mapstructure:"enabled"`
|
||
// IntervalSeconds: 聚合刷新间隔(秒)
|
||
IntervalSeconds int `mapstructure:"interval_seconds"`
|
||
// LookbackSeconds: 回看窗口(秒)
|
||
LookbackSeconds int `mapstructure:"lookback_seconds"`
|
||
// BackfillEnabled: 是否允许全量回填
|
||
BackfillEnabled bool `mapstructure:"backfill_enabled"`
|
||
// BackfillMaxDays: 回填最大跨度(天)
|
||
BackfillMaxDays int `mapstructure:"backfill_max_days"`
|
||
// Retention: 各表保留窗口(天)
|
||
Retention DashboardAggregationRetentionConfig `mapstructure:"retention"`
|
||
// RecomputeDays: 启动时重算最近 N 天
|
||
RecomputeDays int `mapstructure:"recompute_days"`
|
||
}
|
||
|
||
// DashboardAggregationRetentionConfig 预聚合保留窗口
|
||
type DashboardAggregationRetentionConfig struct {
|
||
UsageLogsDays int `mapstructure:"usage_logs_days"`
|
||
UsageBillingDedupDays int `mapstructure:"usage_billing_dedup_days"`
|
||
HourlyDays int `mapstructure:"hourly_days"`
|
||
DailyDays int `mapstructure:"daily_days"`
|
||
}
|
||
|
||
// UsageCleanupConfig 使用记录清理任务配置
|
||
type UsageCleanupConfig struct {
|
||
// Enabled: 是否启用清理任务执行器
|
||
Enabled bool `mapstructure:"enabled"`
|
||
// MaxRangeDays: 单次任务允许的最大时间跨度(天)
|
||
MaxRangeDays int `mapstructure:"max_range_days"`
|
||
// BatchSize: 单批删除数量
|
||
BatchSize int `mapstructure:"batch_size"`
|
||
// WorkerIntervalSeconds: 后台任务轮询间隔(秒)
|
||
WorkerIntervalSeconds int `mapstructure:"worker_interval_seconds"`
|
||
// TaskTimeoutSeconds: 单次任务最大执行时长(秒)
|
||
TaskTimeoutSeconds int `mapstructure:"task_timeout_seconds"`
|
||
}
|
||
|
||
func NormalizeRunMode(value string) string {
|
||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||
switch normalized {
|
||
case RunModeStandard, RunModeSimple:
|
||
return normalized
|
||
default:
|
||
return RunModeStandard
|
||
}
|
||
}
|
||
|
||
// Load 读取并校验完整配置(要求 jwt.secret 已显式提供)。
|
||
func Load() (*Config, error) {
|
||
return load(false)
|
||
}
|
||
|
||
// LoadForBootstrap 读取启动阶段配置。
|
||
//
|
||
// 启动阶段允许 jwt.secret 先留空,后续由数据库初始化流程补齐并再次完整校验。
|
||
func LoadForBootstrap() (*Config, error) {
|
||
return load(true)
|
||
}
|
||
|
||
func load(allowMissingJWTSecret bool) (*Config, error) {
|
||
viper.SetConfigName("config")
|
||
viper.SetConfigType("yaml")
|
||
|
||
// Add config paths in priority order
|
||
// 1. DATA_DIR environment variable (highest priority)
|
||
if dataDir := os.Getenv("DATA_DIR"); dataDir != "" {
|
||
viper.AddConfigPath(dataDir)
|
||
}
|
||
// 2. Docker data directory
|
||
viper.AddConfigPath("/app/data")
|
||
// 3. Current directory
|
||
viper.AddConfigPath(".")
|
||
// 4. Config subdirectory
|
||
viper.AddConfigPath("./config")
|
||
// 5. System config directory
|
||
viper.AddConfigPath("/etc/sub2api")
|
||
|
||
// 环境变量支持
|
||
viper.AutomaticEnv()
|
||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||
|
||
// 默认值
|
||
setDefaults()
|
||
|
||
if err := viper.ReadInConfig(); err != nil {
|
||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||
return nil, fmt.Errorf("read config error: %w", err)
|
||
}
|
||
// 配置文件不存在时使用默认值
|
||
}
|
||
|
||
var cfg Config
|
||
if err := viper.Unmarshal(&cfg); err != nil {
|
||
return nil, fmt.Errorf("unmarshal config error: %w", err)
|
||
}
|
||
|
||
cfg.RunMode = NormalizeRunMode(cfg.RunMode)
|
||
cfg.Server.Mode = strings.ToLower(strings.TrimSpace(cfg.Server.Mode))
|
||
if cfg.Server.Mode == "" {
|
||
cfg.Server.Mode = "debug"
|
||
}
|
||
cfg.Server.FrontendURL = strings.TrimSpace(cfg.Server.FrontendURL)
|
||
cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret)
|
||
cfg.LinuxDo.ClientID = strings.TrimSpace(cfg.LinuxDo.ClientID)
|
||
cfg.LinuxDo.ClientSecret = strings.TrimSpace(cfg.LinuxDo.ClientSecret)
|
||
cfg.LinuxDo.AuthorizeURL = strings.TrimSpace(cfg.LinuxDo.AuthorizeURL)
|
||
cfg.LinuxDo.TokenURL = strings.TrimSpace(cfg.LinuxDo.TokenURL)
|
||
cfg.LinuxDo.UserInfoURL = strings.TrimSpace(cfg.LinuxDo.UserInfoURL)
|
||
cfg.LinuxDo.Scopes = strings.TrimSpace(cfg.LinuxDo.Scopes)
|
||
cfg.LinuxDo.RedirectURL = strings.TrimSpace(cfg.LinuxDo.RedirectURL)
|
||
cfg.LinuxDo.FrontendRedirectURL = strings.TrimSpace(cfg.LinuxDo.FrontendRedirectURL)
|
||
cfg.LinuxDo.TokenAuthMethod = strings.ToLower(strings.TrimSpace(cfg.LinuxDo.TokenAuthMethod))
|
||
cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath)
|
||
cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath)
|
||
cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath)
|
||
applyLegacyWeChatConnectEnvCompatibility(&cfg.WeChat)
|
||
normalizeWeChatConnectConfig(&cfg.WeChat)
|
||
cfg.OIDC.ProviderName = strings.TrimSpace(cfg.OIDC.ProviderName)
|
||
cfg.OIDC.ClientID = strings.TrimSpace(cfg.OIDC.ClientID)
|
||
cfg.OIDC.ClientSecret = strings.TrimSpace(cfg.OIDC.ClientSecret)
|
||
cfg.OIDC.IssuerURL = strings.TrimSpace(cfg.OIDC.IssuerURL)
|
||
cfg.OIDC.DiscoveryURL = strings.TrimSpace(cfg.OIDC.DiscoveryURL)
|
||
cfg.OIDC.AuthorizeURL = strings.TrimSpace(cfg.OIDC.AuthorizeURL)
|
||
cfg.OIDC.TokenURL = strings.TrimSpace(cfg.OIDC.TokenURL)
|
||
cfg.OIDC.UserInfoURL = strings.TrimSpace(cfg.OIDC.UserInfoURL)
|
||
cfg.OIDC.JWKSURL = strings.TrimSpace(cfg.OIDC.JWKSURL)
|
||
cfg.OIDC.Scopes = strings.TrimSpace(cfg.OIDC.Scopes)
|
||
cfg.OIDC.RedirectURL = strings.TrimSpace(cfg.OIDC.RedirectURL)
|
||
cfg.OIDC.FrontendRedirectURL = strings.TrimSpace(cfg.OIDC.FrontendRedirectURL)
|
||
cfg.OIDC.TokenAuthMethod = strings.ToLower(strings.TrimSpace(cfg.OIDC.TokenAuthMethod))
|
||
cfg.OIDC.AllowedSigningAlgs = strings.TrimSpace(cfg.OIDC.AllowedSigningAlgs)
|
||
cfg.OIDC.UserInfoEmailPath = strings.TrimSpace(cfg.OIDC.UserInfoEmailPath)
|
||
cfg.OIDC.UserInfoIDPath = strings.TrimSpace(cfg.OIDC.UserInfoIDPath)
|
||
cfg.OIDC.UserInfoUsernamePath = strings.TrimSpace(cfg.OIDC.UserInfoUsernamePath)
|
||
cfg.OIDC.UsePKCEExplicit = hasExplicitConfigOrEnv("oidc_connect.use_pkce", "OIDC_CONNECT_USE_PKCE")
|
||
cfg.OIDC.ValidateIDTokenExplicit = hasExplicitConfigOrEnv("oidc_connect.validate_id_token", "OIDC_CONNECT_VALIDATE_ID_TOKEN")
|
||
cfg.Dashboard.KeyPrefix = strings.TrimSpace(cfg.Dashboard.KeyPrefix)
|
||
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
|
||
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
|
||
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
|
||
cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy)
|
||
cfg.Log.Level = strings.ToLower(strings.TrimSpace(cfg.Log.Level))
|
||
cfg.Log.Format = strings.ToLower(strings.TrimSpace(cfg.Log.Format))
|
||
cfg.Log.ServiceName = strings.TrimSpace(cfg.Log.ServiceName)
|
||
cfg.Log.Environment = strings.TrimSpace(cfg.Log.Environment)
|
||
cfg.Log.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel))
|
||
cfg.Log.Output.FilePath = strings.TrimSpace(cfg.Log.Output.FilePath)
|
||
cfg.Gateway.ForcedCodexInstructionsTemplateFile = strings.TrimSpace(cfg.Gateway.ForcedCodexInstructionsTemplateFile)
|
||
if cfg.Gateway.ForcedCodexInstructionsTemplateFile != "" {
|
||
content, err := os.ReadFile(cfg.Gateway.ForcedCodexInstructionsTemplateFile)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read forced codex instructions template %q: %w", cfg.Gateway.ForcedCodexInstructionsTemplateFile, err)
|
||
}
|
||
cfg.Gateway.ForcedCodexInstructionsTemplate = string(content)
|
||
}
|
||
|
||
// 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。
|
||
// 新键未配置(<=0)时回退旧键;新键优先。
|
||
if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 {
|
||
cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds
|
||
}
|
||
|
||
// Normalize UMQ mode: 白名单校验,非法值在加载时一次性 warn 并清空
|
||
if m := cfg.Gateway.UserMessageQueue.Mode; m != "" && m != UMQModeSerialize && m != UMQModeThrottle {
|
||
slog.Warn("invalid user_message_queue mode, disabling",
|
||
"mode", m,
|
||
"valid_modes", []string{UMQModeSerialize, UMQModeThrottle})
|
||
cfg.Gateway.UserMessageQueue.Mode = ""
|
||
}
|
||
|
||
// Auto-generate TOTP encryption key if not set (32 bytes = 64 hex chars for AES-256)
|
||
cfg.Totp.EncryptionKey = strings.TrimSpace(cfg.Totp.EncryptionKey)
|
||
if cfg.Totp.EncryptionKey == "" {
|
||
key, err := generateJWTSecret(32) // Reuse the same random generation function
|
||
if err != nil {
|
||
return nil, fmt.Errorf("generate totp encryption key error: %w", err)
|
||
}
|
||
cfg.Totp.EncryptionKey = key
|
||
cfg.Totp.EncryptionKeyConfigured = false
|
||
slog.Warn("TOTP encryption key auto-generated. Consider setting a fixed key for production.")
|
||
} else {
|
||
cfg.Totp.EncryptionKeyConfigured = true
|
||
}
|
||
|
||
originalJWTSecret := cfg.JWT.Secret
|
||
if allowMissingJWTSecret && originalJWTSecret == "" {
|
||
// 启动阶段允许先无 JWT 密钥,后续在数据库初始化后补齐。
|
||
cfg.JWT.Secret = strings.Repeat("0", 32)
|
||
}
|
||
|
||
if err := cfg.Validate(); err != nil {
|
||
return nil, fmt.Errorf("validate config error: %w", err)
|
||
}
|
||
|
||
if allowMissingJWTSecret && originalJWTSecret == "" {
|
||
cfg.JWT.Secret = ""
|
||
}
|
||
|
||
if !cfg.Security.URLAllowlist.Enabled {
|
||
slog.Warn("security.url_allowlist.enabled=false; allowlist/SSRF checks disabled (minimal format validation only).")
|
||
}
|
||
if !cfg.Security.ResponseHeaders.Enabled {
|
||
slog.Warn("security.response_headers.enabled=false; configurable header filtering disabled (default allowlist only).")
|
||
}
|
||
|
||
if cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) {
|
||
slog.Warn("JWT secret appears weak; use a 32+ character random secret in production.")
|
||
}
|
||
if len(cfg.Security.ResponseHeaders.AdditionalAllowed) > 0 || len(cfg.Security.ResponseHeaders.ForceRemove) > 0 {
|
||
slog.Info("response header policy configured",
|
||
"additional_allowed", cfg.Security.ResponseHeaders.AdditionalAllowed,
|
||
"force_remove", cfg.Security.ResponseHeaders.ForceRemove,
|
||
)
|
||
}
|
||
|
||
return &cfg, nil
|
||
}
|
||
|
||
func setDefaults() {
|
||
viper.SetDefault("run_mode", RunModeStandard)
|
||
|
||
// Server
|
||
viper.SetDefault("server.host", "0.0.0.0")
|
||
viper.SetDefault("server.port", 8080)
|
||
viper.SetDefault("server.mode", "release")
|
||
viper.SetDefault("server.frontend_url", "")
|
||
viper.SetDefault("server.read_header_timeout", 30) // 30秒读取请求头
|
||
viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时
|
||
viper.SetDefault("server.trusted_proxies", []string{})
|
||
viper.SetDefault("server.max_request_body_size", int64(256*1024*1024))
|
||
// H2C 默认配置
|
||
viper.SetDefault("server.h2c.enabled", false)
|
||
viper.SetDefault("server.h2c.max_concurrent_streams", uint32(50)) // 50 个并发流
|
||
viper.SetDefault("server.h2c.idle_timeout", 75) // 75 秒
|
||
viper.SetDefault("server.h2c.max_read_frame_size", 1<<20) // 1MB(够用)
|
||
viper.SetDefault("server.h2c.max_upload_buffer_per_connection", 2<<20) // 2MB
|
||
viper.SetDefault("server.h2c.max_upload_buffer_per_stream", 512<<10) // 512KB
|
||
|
||
// Log
|
||
viper.SetDefault("log.level", "info")
|
||
viper.SetDefault("log.format", "console")
|
||
viper.SetDefault("log.service_name", "sub2api")
|
||
viper.SetDefault("log.env", "production")
|
||
viper.SetDefault("log.caller", true)
|
||
viper.SetDefault("log.stacktrace_level", "error")
|
||
viper.SetDefault("log.output.to_stdout", true)
|
||
viper.SetDefault("log.output.to_file", true)
|
||
viper.SetDefault("log.output.file_path", "")
|
||
viper.SetDefault("log.rotation.max_size_mb", 100)
|
||
viper.SetDefault("log.rotation.max_backups", 10)
|
||
viper.SetDefault("log.rotation.max_age_days", 7)
|
||
viper.SetDefault("log.rotation.compress", true)
|
||
viper.SetDefault("log.rotation.local_time", true)
|
||
viper.SetDefault("log.sampling.enabled", false)
|
||
viper.SetDefault("log.sampling.initial", 100)
|
||
viper.SetDefault("log.sampling.thereafter", 100)
|
||
|
||
// CORS
|
||
viper.SetDefault("cors.allowed_origins", []string{})
|
||
viper.SetDefault("cors.allow_credentials", true)
|
||
|
||
// Security
|
||
viper.SetDefault("security.url_allowlist.enabled", false)
|
||
viper.SetDefault("security.url_allowlist.upstream_hosts", []string{
|
||
"api.openai.com",
|
||
"api.anthropic.com",
|
||
"api.kimi.com",
|
||
"open.bigmodel.cn",
|
||
"api.minimaxi.com",
|
||
"generativelanguage.googleapis.com",
|
||
"cloudcode-pa.googleapis.com",
|
||
"*.openai.azure.com",
|
||
})
|
||
viper.SetDefault("security.url_allowlist.pricing_hosts", []string{
|
||
"raw.githubusercontent.com",
|
||
})
|
||
viper.SetDefault("security.url_allowlist.crs_hosts", []string{})
|
||
viper.SetDefault("security.url_allowlist.allow_private_hosts", true)
|
||
viper.SetDefault("security.url_allowlist.allow_insecure_http", true)
|
||
viper.SetDefault("security.response_headers.enabled", true)
|
||
viper.SetDefault("security.response_headers.additional_allowed", []string{})
|
||
viper.SetDefault("security.response_headers.force_remove", []string{})
|
||
viper.SetDefault("security.csp.enabled", true)
|
||
viper.SetDefault("security.csp.policy", DefaultCSPPolicy)
|
||
viper.SetDefault("security.proxy_probe.insecure_skip_verify", false)
|
||
|
||
// Security - disable direct fallback on proxy error
|
||
viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false)
|
||
|
||
// Billing
|
||
viper.SetDefault("billing.circuit_breaker.enabled", true)
|
||
viper.SetDefault("billing.circuit_breaker.failure_threshold", 5)
|
||
viper.SetDefault("billing.circuit_breaker.reset_timeout_seconds", 30)
|
||
viper.SetDefault("billing.circuit_breaker.half_open_requests", 3)
|
||
|
||
// Turnstile
|
||
viper.SetDefault("turnstile.required", false)
|
||
|
||
// LinuxDo Connect OAuth 登录
|
||
viper.SetDefault("linuxdo_connect.enabled", false)
|
||
viper.SetDefault("linuxdo_connect.client_id", "")
|
||
viper.SetDefault("linuxdo_connect.client_secret", "")
|
||
viper.SetDefault("linuxdo_connect.authorize_url", "https://connect.linux.do/oauth2/authorize")
|
||
viper.SetDefault("linuxdo_connect.token_url", "https://connect.linux.do/oauth2/token")
|
||
viper.SetDefault("linuxdo_connect.userinfo_url", "https://connect.linux.do/api/user")
|
||
viper.SetDefault("linuxdo_connect.scopes", "user")
|
||
viper.SetDefault("linuxdo_connect.redirect_url", "")
|
||
viper.SetDefault("linuxdo_connect.frontend_redirect_url", "/auth/linuxdo/callback")
|
||
viper.SetDefault("linuxdo_connect.token_auth_method", "client_secret_post")
|
||
viper.SetDefault("linuxdo_connect.use_pkce", false)
|
||
viper.SetDefault("linuxdo_connect.userinfo_email_path", "")
|
||
viper.SetDefault("linuxdo_connect.userinfo_id_path", "")
|
||
viper.SetDefault("linuxdo_connect.userinfo_username_path", "")
|
||
|
||
// WeChat Connect OAuth 登录
|
||
viper.SetDefault("wechat_connect.enabled", false)
|
||
viper.SetDefault("wechat_connect.app_id", "")
|
||
viper.SetDefault("wechat_connect.app_secret", "")
|
||
viper.SetDefault("wechat_connect.open_app_id", "")
|
||
viper.SetDefault("wechat_connect.open_app_secret", "")
|
||
viper.SetDefault("wechat_connect.mp_app_id", "")
|
||
viper.SetDefault("wechat_connect.mp_app_secret", "")
|
||
viper.SetDefault("wechat_connect.mobile_app_id", "")
|
||
viper.SetDefault("wechat_connect.mobile_app_secret", "")
|
||
viper.SetDefault("wechat_connect.open_enabled", false)
|
||
viper.SetDefault("wechat_connect.mp_enabled", false)
|
||
viper.SetDefault("wechat_connect.mobile_enabled", false)
|
||
viper.SetDefault("wechat_connect.mode", defaultWeChatConnectMode)
|
||
viper.SetDefault("wechat_connect.scopes", defaultWeChatConnectScopes)
|
||
viper.SetDefault("wechat_connect.redirect_url", "")
|
||
viper.SetDefault("wechat_connect.frontend_redirect_url", defaultWeChatConnectFrontendRedirect)
|
||
|
||
// Generic OIDC OAuth 登录
|
||
viper.SetDefault("oidc_connect.enabled", false)
|
||
viper.SetDefault("oidc_connect.provider_name", "OIDC")
|
||
viper.SetDefault("oidc_connect.client_id", "")
|
||
viper.SetDefault("oidc_connect.client_secret", "")
|
||
viper.SetDefault("oidc_connect.issuer_url", "")
|
||
viper.SetDefault("oidc_connect.discovery_url", "")
|
||
viper.SetDefault("oidc_connect.authorize_url", "")
|
||
viper.SetDefault("oidc_connect.token_url", "")
|
||
viper.SetDefault("oidc_connect.userinfo_url", "")
|
||
viper.SetDefault("oidc_connect.jwks_url", "")
|
||
viper.SetDefault("oidc_connect.scopes", "openid email profile")
|
||
viper.SetDefault("oidc_connect.redirect_url", "")
|
||
viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback")
|
||
viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post")
|
||
viper.SetDefault("oidc_connect.use_pkce", true)
|
||
viper.SetDefault("oidc_connect.validate_id_token", true)
|
||
viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256")
|
||
viper.SetDefault("oidc_connect.clock_skew_seconds", 120)
|
||
viper.SetDefault("oidc_connect.require_email_verified", false)
|
||
viper.SetDefault("oidc_connect.userinfo_email_path", "")
|
||
viper.SetDefault("oidc_connect.userinfo_id_path", "")
|
||
viper.SetDefault("oidc_connect.userinfo_username_path", "")
|
||
|
||
// Database
|
||
viper.SetDefault("database.host", "localhost")
|
||
viper.SetDefault("database.port", 5432)
|
||
viper.SetDefault("database.user", "postgres")
|
||
viper.SetDefault("database.password", "postgres")
|
||
viper.SetDefault("database.dbname", "sub2api")
|
||
viper.SetDefault("database.sslmode", "prefer")
|
||
viper.SetDefault("database.auto_migrate", true)
|
||
viper.SetDefault("database.max_open_conns", 256)
|
||
viper.SetDefault("database.max_idle_conns", 128)
|
||
viper.SetDefault("database.conn_max_lifetime_minutes", 30)
|
||
viper.SetDefault("database.conn_max_idle_time_minutes", 5)
|
||
|
||
// Redis
|
||
viper.SetDefault("redis.host", "localhost")
|
||
viper.SetDefault("redis.port", 6379)
|
||
viper.SetDefault("redis.password", "")
|
||
viper.SetDefault("redis.db", 0)
|
||
viper.SetDefault("redis.dial_timeout_seconds", 5)
|
||
viper.SetDefault("redis.read_timeout_seconds", 3)
|
||
viper.SetDefault("redis.write_timeout_seconds", 3)
|
||
viper.SetDefault("redis.pool_size", 1024)
|
||
viper.SetDefault("redis.min_idle_conns", 128)
|
||
viper.SetDefault("redis.enable_tls", false)
|
||
|
||
// Ops (vNext)
|
||
viper.SetDefault("ops.enabled", true)
|
||
viper.SetDefault("ops.use_preaggregated_tables", true)
|
||
viper.SetDefault("ops.cleanup.enabled", true)
|
||
viper.SetDefault("ops.cleanup.schedule", "0 2 * * *")
|
||
// Retention days: vNext defaults to 30 days across ops datasets.
|
||
viper.SetDefault("ops.cleanup.error_log_retention_days", 30)
|
||
viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30)
|
||
viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30)
|
||
viper.SetDefault("ops.aggregation.enabled", true)
|
||
viper.SetDefault("ops.metrics_collector_cache.enabled", true)
|
||
// TTL should be slightly larger than collection interval (1m) to maximize cross-replica cache hits.
|
||
viper.SetDefault("ops.metrics_collector_cache.ttl", 65*time.Second)
|
||
|
||
// JWT
|
||
viper.SetDefault("jwt.secret", "")
|
||
viper.SetDefault("jwt.expire_hour", 24)
|
||
viper.SetDefault("jwt.access_token_expire_minutes", 0) // 0 表示回退到 expire_hour
|
||
viper.SetDefault("jwt.refresh_token_expire_days", 30) // 30天Refresh Token有效期
|
||
viper.SetDefault("jwt.refresh_window_minutes", 2) // 过期前2分钟开始允许刷新
|
||
|
||
// TOTP
|
||
viper.SetDefault("totp.encryption_key", "")
|
||
|
||
// Default
|
||
// Admin credentials are created via the setup flow (web wizard / CLI / AUTO_SETUP).
|
||
// Do not ship fixed defaults here to avoid insecure "known credentials" in production.
|
||
viper.SetDefault("default.admin_email", "")
|
||
viper.SetDefault("default.admin_password", "")
|
||
viper.SetDefault("default.user_concurrency", 5)
|
||
viper.SetDefault("default.user_balance", 0)
|
||
viper.SetDefault("default.api_key_prefix", "sk-")
|
||
viper.SetDefault("default.rate_multiplier", 1.0)
|
||
|
||
// RateLimit
|
||
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
||
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
|
||
|
||
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
|
||
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
|
||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
|
||
viper.SetDefault("pricing.data_dir", "./data")
|
||
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||
|
||
// Update
|
||
viper.SetDefault("update.github_repo", "kgod/sub2api")
|
||
viper.SetDefault("update.proxy_url", "")
|
||
|
||
// Timezone (default to Asia/Shanghai for Chinese users)
|
||
viper.SetDefault("timezone", "Asia/Shanghai")
|
||
|
||
// API Key auth cache
|
||
viper.SetDefault("api_key_auth_cache.l1_size", 65535)
|
||
viper.SetDefault("api_key_auth_cache.l1_ttl_seconds", 15)
|
||
viper.SetDefault("api_key_auth_cache.l2_ttl_seconds", 300)
|
||
viper.SetDefault("api_key_auth_cache.negative_ttl_seconds", 30)
|
||
viper.SetDefault("api_key_auth_cache.jitter_percent", 10)
|
||
viper.SetDefault("api_key_auth_cache.singleflight", true)
|
||
|
||
// Subscription auth L1 cache
|
||
viper.SetDefault("subscription_cache.l1_size", 16384)
|
||
viper.SetDefault("subscription_cache.l1_ttl_seconds", 10)
|
||
viper.SetDefault("subscription_cache.jitter_percent", 10)
|
||
|
||
// Dashboard cache
|
||
viper.SetDefault("dashboard_cache.enabled", true)
|
||
viper.SetDefault("dashboard_cache.key_prefix", "sub2api:")
|
||
viper.SetDefault("dashboard_cache.stats_fresh_ttl_seconds", 15)
|
||
viper.SetDefault("dashboard_cache.stats_ttl_seconds", 30)
|
||
viper.SetDefault("dashboard_cache.stats_refresh_timeout_seconds", 30)
|
||
|
||
// Dashboard aggregation
|
||
viper.SetDefault("dashboard_aggregation.enabled", true)
|
||
viper.SetDefault("dashboard_aggregation.interval_seconds", 60)
|
||
viper.SetDefault("dashboard_aggregation.lookback_seconds", 120)
|
||
viper.SetDefault("dashboard_aggregation.backfill_enabled", false)
|
||
viper.SetDefault("dashboard_aggregation.backfill_max_days", 31)
|
||
viper.SetDefault("dashboard_aggregation.retention.usage_logs_days", 90)
|
||
viper.SetDefault("dashboard_aggregation.retention.usage_billing_dedup_days", 365)
|
||
viper.SetDefault("dashboard_aggregation.retention.hourly_days", 180)
|
||
viper.SetDefault("dashboard_aggregation.retention.daily_days", 730)
|
||
viper.SetDefault("dashboard_aggregation.recompute_days", 2)
|
||
|
||
// Usage cleanup task
|
||
viper.SetDefault("usage_cleanup.enabled", true)
|
||
viper.SetDefault("usage_cleanup.max_range_days", 31)
|
||
viper.SetDefault("usage_cleanup.batch_size", 5000)
|
||
viper.SetDefault("usage_cleanup.worker_interval_seconds", 10)
|
||
viper.SetDefault("usage_cleanup.task_timeout_seconds", 1800)
|
||
|
||
// Idempotency
|
||
viper.SetDefault("idempotency.observe_only", true)
|
||
viper.SetDefault("idempotency.default_ttl_seconds", 86400)
|
||
viper.SetDefault("idempotency.system_operation_ttl_seconds", 3600)
|
||
viper.SetDefault("idempotency.processing_timeout_seconds", 30)
|
||
viper.SetDefault("idempotency.failed_retry_backoff_seconds", 5)
|
||
viper.SetDefault("idempotency.max_stored_response_len", 64*1024)
|
||
viper.SetDefault("idempotency.cleanup_interval_seconds", 60)
|
||
viper.SetDefault("idempotency.cleanup_batch_size", 500)
|
||
|
||
// Gateway
|
||
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
|
||
viper.SetDefault("gateway.log_upstream_error_body", true)
|
||
viper.SetDefault("gateway.log_upstream_error_body_max_bytes", 2048)
|
||
viper.SetDefault("gateway.inject_beta_for_apikey", false)
|
||
viper.SetDefault("gateway.failover_on_400", false)
|
||
viper.SetDefault("gateway.max_account_switches", 10)
|
||
viper.SetDefault("gateway.max_account_switches_gemini", 3)
|
||
viper.SetDefault("gateway.force_codex_cli", false)
|
||
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
|
||
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
|
||
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
|
||
viper.SetDefault("gateway.openai_ws.enabled", true)
|
||
viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false)
|
||
viper.SetDefault("gateway.openai_ws.ingress_mode_default", "ctx_pool")
|
||
viper.SetDefault("gateway.openai_ws.oauth_enabled", true)
|
||
viper.SetDefault("gateway.openai_ws.apikey_enabled", true)
|
||
viper.SetDefault("gateway.openai_ws.force_http", false)
|
||
viper.SetDefault("gateway.openai_ws.allow_store_recovery", false)
|
||
viper.SetDefault("gateway.openai_ws.ingress_previous_response_recovery_enabled", true)
|
||
viper.SetDefault("gateway.openai_ws.store_disabled_conn_mode", "strict")
|
||
viper.SetDefault("gateway.openai_ws.store_disabled_force_new_conn", true)
|
||
viper.SetDefault("gateway.openai_ws.prewarm_generate_enabled", false)
|
||
viper.SetDefault("gateway.openai_ws.responses_websockets", false)
|
||
viper.SetDefault("gateway.openai_ws.responses_websockets_v2", true)
|
||
viper.SetDefault("gateway.openai_ws.max_conns_per_account", 128)
|
||
viper.SetDefault("gateway.openai_ws.min_idle_per_account", 4)
|
||
viper.SetDefault("gateway.openai_ws.max_idle_per_account", 12)
|
||
viper.SetDefault("gateway.openai_ws.dynamic_max_conns_by_account_concurrency_enabled", true)
|
||
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 1.0)
|
||
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 1.0)
|
||
viper.SetDefault("gateway.openai_ws.dial_timeout_seconds", 10)
|
||
viper.SetDefault("gateway.openai_ws.read_timeout_seconds", 900)
|
||
viper.SetDefault("gateway.openai_ws.write_timeout_seconds", 120)
|
||
viper.SetDefault("gateway.openai_ws.pool_target_utilization", 0.7)
|
||
viper.SetDefault("gateway.openai_ws.queue_limit_per_conn", 64)
|
||
viper.SetDefault("gateway.openai_ws.event_flush_batch_size", 1)
|
||
viper.SetDefault("gateway.openai_ws.event_flush_interval_ms", 10)
|
||
viper.SetDefault("gateway.openai_ws.prewarm_cooldown_ms", 300)
|
||
viper.SetDefault("gateway.openai_ws.fallback_cooldown_seconds", 30)
|
||
viper.SetDefault("gateway.openai_ws.retry_backoff_initial_ms", 120)
|
||
viper.SetDefault("gateway.openai_ws.retry_backoff_max_ms", 2000)
|
||
viper.SetDefault("gateway.openai_ws.retry_jitter_ratio", 0.2)
|
||
viper.SetDefault("gateway.openai_ws.retry_total_budget_ms", 5000)
|
||
viper.SetDefault("gateway.openai_ws.payload_log_sample_rate", 0.2)
|
||
viper.SetDefault("gateway.openai_ws.lb_top_k", 7)
|
||
viper.SetDefault("gateway.openai_ws.sticky_session_ttl_seconds", 3600)
|
||
viper.SetDefault("gateway.openai_ws.session_hash_read_old_fallback", true)
|
||
viper.SetDefault("gateway.openai_ws.session_hash_dual_write_old", true)
|
||
viper.SetDefault("gateway.openai_ws.metadata_bridge_enabled", true)
|
||
viper.SetDefault("gateway.openai_ws.sticky_response_id_ttl_seconds", 3600)
|
||
viper.SetDefault("gateway.openai_ws.sticky_previous_response_ttl_seconds", 3600)
|
||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.priority", 1.0)
|
||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.load", 1.0)
|
||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.queue", 0.7)
|
||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.error_rate", 0.8)
|
||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5)
|
||
viper.SetDefault("gateway.image_concurrency.enabled", false)
|
||
viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
|
||
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
|
||
viper.SetDefault("gateway.image_concurrency.wait_timeout_seconds", 30)
|
||
viper.SetDefault("gateway.image_concurrency.max_waiting_requests", 100)
|
||
viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1)
|
||
viper.SetDefault("gateway.antigravity_extra_retries", 10)
|
||
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
|
||
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
|
||
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
|
||
viper.SetDefault("gateway.gemini_debug_response_headers", false)
|
||
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
|
||
// HTTP 上游连接池配置(针对 5000+ 并发用户优化)
|
||
viper.SetDefault("gateway.max_idle_conns", 2560) // 最大空闲连接总数(高并发场景可调大)
|
||
viper.SetDefault("gateway.max_idle_conns_per_host", 120) // 每主机最大空闲连接(HTTP/2 场景默认)
|
||
viper.SetDefault("gateway.max_conns_per_host", 1024) // 每主机最大连接数(含活跃;流式/HTTP1.1 场景可调大,如 2400+)
|
||
viper.SetDefault("gateway.idle_conn_timeout_seconds", 90) // 空闲连接超时(秒)
|
||
viper.SetDefault("gateway.max_upstream_clients", 5000)
|
||
viper.SetDefault("gateway.client_idle_ttl_seconds", 900)
|
||
viper.SetDefault("gateway.concurrency_slot_ttl_minutes", 30) // 并发槽位过期时间(支持超长请求)
|
||
viper.SetDefault("gateway.stream_data_interval_timeout", 180)
|
||
viper.SetDefault("gateway.stream_keepalive_interval", 10)
|
||
viper.SetDefault("gateway.image_stream_data_interval_timeout", 900)
|
||
viper.SetDefault("gateway.image_stream_keepalive_interval", 10)
|
||
viper.SetDefault("gateway.kiro_stream_keepalive_interval", 25)
|
||
viper.SetDefault("gateway.max_line_size", 500*1024*1024)
|
||
viper.SetDefault("gateway.scheduling.sticky_session_max_waiting", 3)
|
||
viper.SetDefault("gateway.scheduling.sticky_session_wait_timeout", 120*time.Second)
|
||
viper.SetDefault("gateway.scheduling.fallback_wait_timeout", 30*time.Second)
|
||
viper.SetDefault("gateway.scheduling.fallback_max_waiting", 100)
|
||
viper.SetDefault("gateway.scheduling.fallback_selection_mode", "last_used")
|
||
viper.SetDefault("gateway.scheduling.load_batch_enabled", true)
|
||
viper.SetDefault("gateway.scheduling.snapshot_mget_chunk_size", 128)
|
||
viper.SetDefault("gateway.scheduling.snapshot_write_chunk_size", 256)
|
||
viper.SetDefault("gateway.scheduling.slot_cleanup_interval", 30*time.Second)
|
||
viper.SetDefault("gateway.scheduling.db_fallback_enabled", true)
|
||
viper.SetDefault("gateway.scheduling.db_fallback_timeout_seconds", 0)
|
||
viper.SetDefault("gateway.scheduling.db_fallback_max_qps", 0)
|
||
viper.SetDefault("gateway.scheduling.outbox_poll_interval_seconds", 1)
|
||
viper.SetDefault("gateway.scheduling.outbox_lag_warn_seconds", 5)
|
||
viper.SetDefault("gateway.scheduling.outbox_lag_rebuild_seconds", 10)
|
||
viper.SetDefault("gateway.scheduling.outbox_lag_rebuild_failures", 3)
|
||
viper.SetDefault("gateway.scheduling.outbox_backlog_rebuild_rows", 10000)
|
||
viper.SetDefault("gateway.scheduling.full_rebuild_interval_seconds", 300)
|
||
viper.SetDefault("gateway.usage_record.worker_count", 128)
|
||
viper.SetDefault("gateway.usage_record.queue_size", 16384)
|
||
viper.SetDefault("gateway.usage_record.task_timeout_seconds", 5)
|
||
viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySample)
|
||
viper.SetDefault("gateway.usage_record.overflow_sample_percent", 10)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_enabled", true)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_min_workers", 128)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_max_workers", 512)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_up_queue_percent", 70)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_down_queue_percent", 15)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_up_step", 32)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_down_step", 16)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_check_interval_seconds", 3)
|
||
viper.SetDefault("gateway.usage_record.auto_scale_cooldown_seconds", 10)
|
||
viper.SetDefault("gateway.user_group_rate_cache_ttl_seconds", 30)
|
||
viper.SetDefault("gateway.models_list_cache_ttl_seconds", 15)
|
||
// TLS指纹伪装配置(默认关闭,需要账号级别单独启用)
|
||
// 用户消息串行队列默认值
|
||
viper.SetDefault("gateway.user_message_queue.enabled", false)
|
||
viper.SetDefault("gateway.user_message_queue.lock_ttl_ms", 120000)
|
||
viper.SetDefault("gateway.user_message_queue.wait_timeout_ms", 30000)
|
||
viper.SetDefault("gateway.user_message_queue.min_delay_ms", 200)
|
||
viper.SetDefault("gateway.user_message_queue.max_delay_ms", 2000)
|
||
viper.SetDefault("gateway.user_message_queue.cleanup_interval_seconds", 60)
|
||
|
||
viper.SetDefault("gateway.tls_fingerprint.enabled", true)
|
||
viper.SetDefault("concurrency.ping_interval", 10)
|
||
|
||
// TokenRefresh
|
||
viper.SetDefault("token_refresh.enabled", true)
|
||
viper.SetDefault("token_refresh.check_interval_minutes", 5) // 每5分钟检查一次
|
||
viper.SetDefault("token_refresh.refresh_before_expiry_hours", 0.5) // 提前30分钟刷新(适配Google 1小时token)
|
||
viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次
|
||
viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒
|
||
|
||
// Gemini OAuth - configure via environment variables or config file
|
||
// GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET
|
||
// Default: uses Gemini CLI public credentials (set via environment)
|
||
viper.SetDefault("gemini.oauth.client_id", "")
|
||
viper.SetDefault("gemini.oauth.client_secret", "")
|
||
viper.SetDefault("gemini.oauth.scopes", "")
|
||
viper.SetDefault("gemini.quota.policy", "")
|
||
|
||
// Subscription Maintenance (bounded queue + worker pool)
|
||
viper.SetDefault("subscription_maintenance.worker_count", 2)
|
||
viper.SetDefault("subscription_maintenance.queue_size", 1024)
|
||
|
||
}
|
||
|
||
func (c *Config) Validate() error {
|
||
jwtSecret := strings.TrimSpace(c.JWT.Secret)
|
||
if jwtSecret == "" {
|
||
return fmt.Errorf("jwt.secret is required")
|
||
}
|
||
// NOTE: 按 UTF-8 编码后的字节长度计算。
|
||
// 选择 bytes 而不是 rune 计数,确保二进制/随机串的长度语义更接近“熵”而非“字符数”。
|
||
if len([]byte(jwtSecret)) < 32 {
|
||
return fmt.Errorf("jwt.secret must be at least 32 bytes")
|
||
}
|
||
switch c.Log.Level {
|
||
case "debug", "info", "warn", "error":
|
||
case "":
|
||
return fmt.Errorf("log.level is required")
|
||
default:
|
||
return fmt.Errorf("log.level must be one of: debug/info/warn/error")
|
||
}
|
||
switch c.Log.Format {
|
||
case "json", "console":
|
||
case "":
|
||
return fmt.Errorf("log.format is required")
|
||
default:
|
||
return fmt.Errorf("log.format must be one of: json/console")
|
||
}
|
||
switch c.Log.StacktraceLevel {
|
||
case "none", "error", "fatal":
|
||
case "":
|
||
return fmt.Errorf("log.stacktrace_level is required")
|
||
default:
|
||
return fmt.Errorf("log.stacktrace_level must be one of: none/error/fatal")
|
||
}
|
||
if !c.Log.Output.ToStdout && !c.Log.Output.ToFile {
|
||
return fmt.Errorf("log.output.to_stdout and log.output.to_file cannot both be false")
|
||
}
|
||
if c.Log.Rotation.MaxSizeMB <= 0 {
|
||
return fmt.Errorf("log.rotation.max_size_mb must be positive")
|
||
}
|
||
if c.Log.Rotation.MaxBackups < 0 {
|
||
return fmt.Errorf("log.rotation.max_backups must be non-negative")
|
||
}
|
||
if c.Log.Rotation.MaxAgeDays < 0 {
|
||
return fmt.Errorf("log.rotation.max_age_days must be non-negative")
|
||
}
|
||
if c.Log.Sampling.Enabled {
|
||
if c.Log.Sampling.Initial <= 0 {
|
||
return fmt.Errorf("log.sampling.initial must be positive when sampling is enabled")
|
||
}
|
||
if c.Log.Sampling.Thereafter <= 0 {
|
||
return fmt.Errorf("log.sampling.thereafter must be positive when sampling is enabled")
|
||
}
|
||
} else {
|
||
if c.Log.Sampling.Initial < 0 {
|
||
return fmt.Errorf("log.sampling.initial must be non-negative")
|
||
}
|
||
if c.Log.Sampling.Thereafter < 0 {
|
||
return fmt.Errorf("log.sampling.thereafter must be non-negative")
|
||
}
|
||
}
|
||
|
||
if c.SubscriptionMaintenance.WorkerCount < 0 {
|
||
return fmt.Errorf("subscription_maintenance.worker_count must be non-negative")
|
||
}
|
||
if c.SubscriptionMaintenance.QueueSize < 0 {
|
||
return fmt.Errorf("subscription_maintenance.queue_size must be non-negative")
|
||
}
|
||
|
||
// Gemini OAuth 配置校验:client_id 与 client_secret 必须同时设置或同时留空。
|
||
// 留空时表示使用内置的 Gemini CLI OAuth 客户端(其 client_secret 通过环境变量注入)。
|
||
geminiClientID := strings.TrimSpace(c.Gemini.OAuth.ClientID)
|
||
geminiClientSecret := strings.TrimSpace(c.Gemini.OAuth.ClientSecret)
|
||
if (geminiClientID == "") != (geminiClientSecret == "") {
|
||
return fmt.Errorf("gemini.oauth.client_id and gemini.oauth.client_secret must be both set or both empty")
|
||
}
|
||
|
||
if strings.TrimSpace(c.Server.FrontendURL) != "" {
|
||
if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil {
|
||
return fmt.Errorf("server.frontend_url invalid: %w", err)
|
||
}
|
||
u, err := url.Parse(strings.TrimSpace(c.Server.FrontendURL))
|
||
if err != nil {
|
||
return fmt.Errorf("server.frontend_url invalid: %w", err)
|
||
}
|
||
if u.RawQuery != "" || u.ForceQuery {
|
||
return fmt.Errorf("server.frontend_url invalid: must not include query")
|
||
}
|
||
if u.User != nil {
|
||
return fmt.Errorf("server.frontend_url invalid: must not include userinfo")
|
||
}
|
||
warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL)
|
||
}
|
||
if c.JWT.ExpireHour <= 0 {
|
||
return fmt.Errorf("jwt.expire_hour must be positive")
|
||
}
|
||
if c.JWT.ExpireHour > 168 {
|
||
return fmt.Errorf("jwt.expire_hour must be <= 168 (7 days)")
|
||
}
|
||
if c.JWT.ExpireHour > 24 {
|
||
slog.Warn("jwt.expire_hour is high; consider shorter expiration for security", "expire_hour", c.JWT.ExpireHour)
|
||
}
|
||
// JWT Refresh Token配置验证
|
||
if c.JWT.AccessTokenExpireMinutes < 0 {
|
||
return fmt.Errorf("jwt.access_token_expire_minutes must be non-negative")
|
||
}
|
||
if c.JWT.AccessTokenExpireMinutes > 720 {
|
||
slog.Warn("jwt.access_token_expire_minutes is high; consider shorter expiration for security", "access_token_expire_minutes", c.JWT.AccessTokenExpireMinutes)
|
||
}
|
||
if c.JWT.RefreshTokenExpireDays <= 0 {
|
||
return fmt.Errorf("jwt.refresh_token_expire_days must be positive")
|
||
}
|
||
if c.JWT.RefreshTokenExpireDays > 90 {
|
||
slog.Warn("jwt.refresh_token_expire_days is high; consider shorter expiration for security", "refresh_token_expire_days", c.JWT.RefreshTokenExpireDays)
|
||
}
|
||
if c.JWT.RefreshWindowMinutes < 0 {
|
||
return fmt.Errorf("jwt.refresh_window_minutes must be non-negative")
|
||
}
|
||
if c.Security.CSP.Enabled && strings.TrimSpace(c.Security.CSP.Policy) == "" {
|
||
return fmt.Errorf("security.csp.policy is required when CSP is enabled")
|
||
}
|
||
if c.LinuxDo.Enabled {
|
||
if strings.TrimSpace(c.LinuxDo.ClientID) == "" {
|
||
return fmt.Errorf("linuxdo_connect.client_id is required when linuxdo_connect.enabled=true")
|
||
}
|
||
if strings.TrimSpace(c.LinuxDo.AuthorizeURL) == "" {
|
||
return fmt.Errorf("linuxdo_connect.authorize_url is required when linuxdo_connect.enabled=true")
|
||
}
|
||
if strings.TrimSpace(c.LinuxDo.TokenURL) == "" {
|
||
return fmt.Errorf("linuxdo_connect.token_url is required when linuxdo_connect.enabled=true")
|
||
}
|
||
if strings.TrimSpace(c.LinuxDo.UserInfoURL) == "" {
|
||
return fmt.Errorf("linuxdo_connect.userinfo_url is required when linuxdo_connect.enabled=true")
|
||
}
|
||
if strings.TrimSpace(c.LinuxDo.RedirectURL) == "" {
|
||
return fmt.Errorf("linuxdo_connect.redirect_url is required when linuxdo_connect.enabled=true")
|
||
}
|
||
method := strings.ToLower(strings.TrimSpace(c.LinuxDo.TokenAuthMethod))
|
||
switch method {
|
||
case "", "client_secret_post", "client_secret_basic", "none":
|
||
default:
|
||
return fmt.Errorf("linuxdo_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none")
|
||
}
|
||
if (method == "" || method == "client_secret_post" || method == "client_secret_basic") &&
|
||
strings.TrimSpace(c.LinuxDo.ClientSecret) == "" {
|
||
return fmt.Errorf("linuxdo_connect.client_secret is required when linuxdo_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic")
|
||
}
|
||
if strings.TrimSpace(c.LinuxDo.FrontendRedirectURL) == "" {
|
||
return fmt.Errorf("linuxdo_connect.frontend_redirect_url is required when linuxdo_connect.enabled=true")
|
||
}
|
||
|
||
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil {
|
||
return fmt.Errorf("linuxdo_connect.authorize_url invalid: %w", err)
|
||
}
|
||
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil {
|
||
return fmt.Errorf("linuxdo_connect.token_url invalid: %w", err)
|
||
}
|
||
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil {
|
||
return fmt.Errorf("linuxdo_connect.userinfo_url invalid: %w", err)
|
||
}
|
||
if err := ValidateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil {
|
||
return fmt.Errorf("linuxdo_connect.redirect_url invalid: %w", err)
|
||
}
|
||
if err := ValidateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil {
|
||
return fmt.Errorf("linuxdo_connect.frontend_redirect_url invalid: %w", err)
|
||
}
|
||
|
||
warnIfInsecureURL("linuxdo_connect.authorize_url", c.LinuxDo.AuthorizeURL)
|
||
warnIfInsecureURL("linuxdo_connect.token_url", c.LinuxDo.TokenURL)
|
||
warnIfInsecureURL("linuxdo_connect.userinfo_url", c.LinuxDo.UserInfoURL)
|
||
warnIfInsecureURL("linuxdo_connect.redirect_url", c.LinuxDo.RedirectURL)
|
||
warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL)
|
||
}
|
||
if c.WeChat.Enabled {
|
||
weChat := c.WeChat
|
||
normalizeWeChatConnectConfig(&weChat)
|
||
|
||
if weChat.OpenEnabled {
|
||
if strings.TrimSpace(weChat.OpenAppID) == "" {
|
||
return fmt.Errorf("wechat_connect.open_app_id is required when wechat_connect.open_enabled=true")
|
||
}
|
||
if strings.TrimSpace(weChat.OpenAppSecret) == "" {
|
||
return fmt.Errorf("wechat_connect.open_app_secret is required when wechat_connect.open_enabled=true")
|
||
}
|
||
}
|
||
if weChat.MPEnabled {
|
||
if strings.TrimSpace(weChat.MPAppID) == "" {
|
||
return fmt.Errorf("wechat_connect.mp_app_id is required when wechat_connect.mp_enabled=true")
|
||
}
|
||
if strings.TrimSpace(weChat.MPAppSecret) == "" {
|
||
return fmt.Errorf("wechat_connect.mp_app_secret is required when wechat_connect.mp_enabled=true")
|
||
}
|
||
}
|
||
if weChat.MobileEnabled {
|
||
if strings.TrimSpace(weChat.MobileAppID) == "" {
|
||
return fmt.Errorf("wechat_connect.mobile_app_id is required when wechat_connect.mobile_enabled=true")
|
||
}
|
||
if strings.TrimSpace(weChat.MobileAppSecret) == "" {
|
||
return fmt.Errorf("wechat_connect.mobile_app_secret is required when wechat_connect.mobile_enabled=true")
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(weChat.RedirectURL); v != "" {
|
||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return fmt.Errorf("wechat_connect.redirect_url invalid: %w", err)
|
||
}
|
||
warnIfInsecureURL("wechat_connect.redirect_url", v)
|
||
}
|
||
if err := ValidateFrontendRedirectURL(weChat.FrontendRedirectURL); err != nil {
|
||
return fmt.Errorf("wechat_connect.frontend_redirect_url invalid: %w", err)
|
||
}
|
||
warnIfInsecureURL("wechat_connect.frontend_redirect_url", weChat.FrontendRedirectURL)
|
||
}
|
||
if c.OIDC.Enabled {
|
||
if strings.TrimSpace(c.OIDC.ClientID) == "" {
|
||
return fmt.Errorf("oidc_connect.client_id is required when oidc_connect.enabled=true")
|
||
}
|
||
if strings.TrimSpace(c.OIDC.IssuerURL) == "" {
|
||
return fmt.Errorf("oidc_connect.issuer_url is required when oidc_connect.enabled=true")
|
||
}
|
||
if strings.TrimSpace(c.OIDC.RedirectURL) == "" {
|
||
return fmt.Errorf("oidc_connect.redirect_url is required when oidc_connect.enabled=true")
|
||
}
|
||
if strings.TrimSpace(c.OIDC.FrontendRedirectURL) == "" {
|
||
return fmt.Errorf("oidc_connect.frontend_redirect_url is required when oidc_connect.enabled=true")
|
||
}
|
||
if !scopeContainsOpenID(c.OIDC.Scopes) {
|
||
return fmt.Errorf("oidc_connect.scopes must contain openid")
|
||
}
|
||
|
||
method := strings.ToLower(strings.TrimSpace(c.OIDC.TokenAuthMethod))
|
||
switch method {
|
||
case "", "client_secret_post", "client_secret_basic", "none":
|
||
default:
|
||
return fmt.Errorf("oidc_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none")
|
||
}
|
||
if (method == "" || method == "client_secret_post" || method == "client_secret_basic") &&
|
||
strings.TrimSpace(c.OIDC.ClientSecret) == "" {
|
||
return fmt.Errorf("oidc_connect.client_secret is required when oidc_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic")
|
||
}
|
||
if c.OIDC.ClockSkewSeconds < 0 || c.OIDC.ClockSkewSeconds > 600 {
|
||
return fmt.Errorf("oidc_connect.clock_skew_seconds must be between 0 and 600")
|
||
}
|
||
if c.OIDC.ValidateIDToken && strings.TrimSpace(c.OIDC.AllowedSigningAlgs) == "" {
|
||
return fmt.Errorf("oidc_connect.allowed_signing_algs is required when oidc_connect.validate_id_token=true")
|
||
}
|
||
|
||
if err := ValidateAbsoluteHTTPURL(c.OIDC.IssuerURL); err != nil {
|
||
return fmt.Errorf("oidc_connect.issuer_url invalid: %w", err)
|
||
}
|
||
if v := strings.TrimSpace(c.OIDC.DiscoveryURL); v != "" {
|
||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return fmt.Errorf("oidc_connect.discovery_url invalid: %w", err)
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(c.OIDC.AuthorizeURL); v != "" {
|
||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return fmt.Errorf("oidc_connect.authorize_url invalid: %w", err)
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(c.OIDC.TokenURL); v != "" {
|
||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return fmt.Errorf("oidc_connect.token_url invalid: %w", err)
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(c.OIDC.UserInfoURL); v != "" {
|
||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return fmt.Errorf("oidc_connect.userinfo_url invalid: %w", err)
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(c.OIDC.JWKSURL); v != "" {
|
||
if err := ValidateAbsoluteHTTPURL(v); err != nil {
|
||
return fmt.Errorf("oidc_connect.jwks_url invalid: %w", err)
|
||
}
|
||
}
|
||
if err := ValidateAbsoluteHTTPURL(c.OIDC.RedirectURL); err != nil {
|
||
return fmt.Errorf("oidc_connect.redirect_url invalid: %w", err)
|
||
}
|
||
if err := ValidateFrontendRedirectURL(c.OIDC.FrontendRedirectURL); err != nil {
|
||
return fmt.Errorf("oidc_connect.frontend_redirect_url invalid: %w", err)
|
||
}
|
||
|
||
warnIfInsecureURL("oidc_connect.issuer_url", c.OIDC.IssuerURL)
|
||
warnIfInsecureURL("oidc_connect.discovery_url", c.OIDC.DiscoveryURL)
|
||
warnIfInsecureURL("oidc_connect.authorize_url", c.OIDC.AuthorizeURL)
|
||
warnIfInsecureURL("oidc_connect.token_url", c.OIDC.TokenURL)
|
||
warnIfInsecureURL("oidc_connect.userinfo_url", c.OIDC.UserInfoURL)
|
||
warnIfInsecureURL("oidc_connect.jwks_url", c.OIDC.JWKSURL)
|
||
warnIfInsecureURL("oidc_connect.redirect_url", c.OIDC.RedirectURL)
|
||
warnIfInsecureURL("oidc_connect.frontend_redirect_url", c.OIDC.FrontendRedirectURL)
|
||
}
|
||
if c.Billing.CircuitBreaker.Enabled {
|
||
if c.Billing.CircuitBreaker.FailureThreshold <= 0 {
|
||
return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive")
|
||
}
|
||
if c.Billing.CircuitBreaker.ResetTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("billing.circuit_breaker.reset_timeout_seconds must be positive")
|
||
}
|
||
if c.Billing.CircuitBreaker.HalfOpenRequests <= 0 {
|
||
return fmt.Errorf("billing.circuit_breaker.half_open_requests must be positive")
|
||
}
|
||
}
|
||
if c.Database.MaxOpenConns <= 0 {
|
||
return fmt.Errorf("database.max_open_conns must be positive")
|
||
}
|
||
if c.Database.MaxIdleConns < 0 {
|
||
return fmt.Errorf("database.max_idle_conns must be non-negative")
|
||
}
|
||
if c.Database.MaxIdleConns > c.Database.MaxOpenConns {
|
||
return fmt.Errorf("database.max_idle_conns cannot exceed database.max_open_conns")
|
||
}
|
||
if c.Database.ConnMaxLifetimeMinutes < 0 {
|
||
return fmt.Errorf("database.conn_max_lifetime_minutes must be non-negative")
|
||
}
|
||
if c.Database.ConnMaxIdleTimeMinutes < 0 {
|
||
return fmt.Errorf("database.conn_max_idle_time_minutes must be non-negative")
|
||
}
|
||
if c.Redis.DialTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("redis.dial_timeout_seconds must be positive")
|
||
}
|
||
if c.Redis.ReadTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("redis.read_timeout_seconds must be positive")
|
||
}
|
||
if c.Redis.WriteTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("redis.write_timeout_seconds must be positive")
|
||
}
|
||
if c.Redis.PoolSize <= 0 {
|
||
return fmt.Errorf("redis.pool_size must be positive")
|
||
}
|
||
if c.Redis.MinIdleConns < 0 {
|
||
return fmt.Errorf("redis.min_idle_conns must be non-negative")
|
||
}
|
||
if c.Redis.MinIdleConns > c.Redis.PoolSize {
|
||
return fmt.Errorf("redis.min_idle_conns cannot exceed redis.pool_size")
|
||
}
|
||
if c.Dashboard.Enabled {
|
||
if c.Dashboard.StatsFreshTTLSeconds <= 0 {
|
||
return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be positive")
|
||
}
|
||
if c.Dashboard.StatsTTLSeconds <= 0 {
|
||
return fmt.Errorf("dashboard_cache.stats_ttl_seconds must be positive")
|
||
}
|
||
if c.Dashboard.StatsRefreshTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("dashboard_cache.stats_refresh_timeout_seconds must be positive")
|
||
}
|
||
if c.Dashboard.StatsFreshTTLSeconds > c.Dashboard.StatsTTLSeconds {
|
||
return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be <= dashboard_cache.stats_ttl_seconds")
|
||
}
|
||
} else {
|
||
if c.Dashboard.StatsFreshTTLSeconds < 0 {
|
||
return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be non-negative")
|
||
}
|
||
if c.Dashboard.StatsTTLSeconds < 0 {
|
||
return fmt.Errorf("dashboard_cache.stats_ttl_seconds must be non-negative")
|
||
}
|
||
if c.Dashboard.StatsRefreshTimeoutSeconds < 0 {
|
||
return fmt.Errorf("dashboard_cache.stats_refresh_timeout_seconds must be non-negative")
|
||
}
|
||
}
|
||
if c.DashboardAgg.Enabled {
|
||
if c.DashboardAgg.IntervalSeconds <= 0 {
|
||
return fmt.Errorf("dashboard_aggregation.interval_seconds must be positive")
|
||
}
|
||
if c.DashboardAgg.LookbackSeconds < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.lookback_seconds must be non-negative")
|
||
}
|
||
if c.DashboardAgg.BackfillMaxDays < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.backfill_max_days must be non-negative")
|
||
}
|
||
if c.DashboardAgg.BackfillEnabled && c.DashboardAgg.BackfillMaxDays == 0 {
|
||
return fmt.Errorf("dashboard_aggregation.backfill_max_days must be positive")
|
||
}
|
||
if c.DashboardAgg.Retention.UsageLogsDays <= 0 {
|
||
return fmt.Errorf("dashboard_aggregation.retention.usage_logs_days must be positive")
|
||
}
|
||
if c.DashboardAgg.Retention.UsageBillingDedupDays <= 0 {
|
||
return fmt.Errorf("dashboard_aggregation.retention.usage_billing_dedup_days must be positive")
|
||
}
|
||
if c.DashboardAgg.Retention.UsageBillingDedupDays < c.DashboardAgg.Retention.UsageLogsDays {
|
||
return fmt.Errorf("dashboard_aggregation.retention.usage_billing_dedup_days must be greater than or equal to usage_logs_days")
|
||
}
|
||
if c.DashboardAgg.Retention.HourlyDays <= 0 {
|
||
return fmt.Errorf("dashboard_aggregation.retention.hourly_days must be positive")
|
||
}
|
||
if c.DashboardAgg.Retention.DailyDays <= 0 {
|
||
return fmt.Errorf("dashboard_aggregation.retention.daily_days must be positive")
|
||
}
|
||
if c.DashboardAgg.RecomputeDays < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.recompute_days must be non-negative")
|
||
}
|
||
} else {
|
||
if c.DashboardAgg.IntervalSeconds < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.interval_seconds must be non-negative")
|
||
}
|
||
if c.DashboardAgg.LookbackSeconds < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.lookback_seconds must be non-negative")
|
||
}
|
||
if c.DashboardAgg.BackfillMaxDays < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.backfill_max_days must be non-negative")
|
||
}
|
||
if c.DashboardAgg.Retention.UsageLogsDays < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.retention.usage_logs_days must be non-negative")
|
||
}
|
||
if c.DashboardAgg.Retention.UsageBillingDedupDays < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.retention.usage_billing_dedup_days must be non-negative")
|
||
}
|
||
if c.DashboardAgg.Retention.UsageBillingDedupDays > 0 &&
|
||
c.DashboardAgg.Retention.UsageLogsDays > 0 &&
|
||
c.DashboardAgg.Retention.UsageBillingDedupDays < c.DashboardAgg.Retention.UsageLogsDays {
|
||
return fmt.Errorf("dashboard_aggregation.retention.usage_billing_dedup_days must be greater than or equal to usage_logs_days")
|
||
}
|
||
if c.DashboardAgg.Retention.HourlyDays < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.retention.hourly_days must be non-negative")
|
||
}
|
||
if c.DashboardAgg.Retention.DailyDays < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.retention.daily_days must be non-negative")
|
||
}
|
||
if c.DashboardAgg.RecomputeDays < 0 {
|
||
return fmt.Errorf("dashboard_aggregation.recompute_days must be non-negative")
|
||
}
|
||
}
|
||
if c.UsageCleanup.Enabled {
|
||
if c.UsageCleanup.MaxRangeDays <= 0 {
|
||
return fmt.Errorf("usage_cleanup.max_range_days must be positive")
|
||
}
|
||
if c.UsageCleanup.BatchSize <= 0 {
|
||
return fmt.Errorf("usage_cleanup.batch_size must be positive")
|
||
}
|
||
if c.UsageCleanup.WorkerIntervalSeconds <= 0 {
|
||
return fmt.Errorf("usage_cleanup.worker_interval_seconds must be positive")
|
||
}
|
||
if c.UsageCleanup.TaskTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("usage_cleanup.task_timeout_seconds must be positive")
|
||
}
|
||
} else {
|
||
if c.UsageCleanup.MaxRangeDays < 0 {
|
||
return fmt.Errorf("usage_cleanup.max_range_days must be non-negative")
|
||
}
|
||
if c.UsageCleanup.BatchSize < 0 {
|
||
return fmt.Errorf("usage_cleanup.batch_size must be non-negative")
|
||
}
|
||
if c.UsageCleanup.WorkerIntervalSeconds < 0 {
|
||
return fmt.Errorf("usage_cleanup.worker_interval_seconds must be non-negative")
|
||
}
|
||
if c.UsageCleanup.TaskTimeoutSeconds < 0 {
|
||
return fmt.Errorf("usage_cleanup.task_timeout_seconds must be non-negative")
|
||
}
|
||
}
|
||
if c.Idempotency.DefaultTTLSeconds <= 0 {
|
||
return fmt.Errorf("idempotency.default_ttl_seconds must be positive")
|
||
}
|
||
if c.Idempotency.SystemOperationTTLSeconds <= 0 {
|
||
return fmt.Errorf("idempotency.system_operation_ttl_seconds must be positive")
|
||
}
|
||
if c.Idempotency.ProcessingTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("idempotency.processing_timeout_seconds must be positive")
|
||
}
|
||
if c.Idempotency.FailedRetryBackoffSeconds <= 0 {
|
||
return fmt.Errorf("idempotency.failed_retry_backoff_seconds must be positive")
|
||
}
|
||
if c.Idempotency.MaxStoredResponseLen <= 0 {
|
||
return fmt.Errorf("idempotency.max_stored_response_len must be positive")
|
||
}
|
||
if c.Idempotency.CleanupIntervalSeconds <= 0 {
|
||
return fmt.Errorf("idempotency.cleanup_interval_seconds must be positive")
|
||
}
|
||
if c.Idempotency.CleanupBatchSize <= 0 {
|
||
return fmt.Errorf("idempotency.cleanup_batch_size must be positive")
|
||
}
|
||
if c.Gateway.MaxBodySize <= 0 {
|
||
return fmt.Errorf("gateway.max_body_size must be positive")
|
||
}
|
||
if c.Gateway.UpstreamResponseReadMaxBytes <= 0 {
|
||
return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive")
|
||
}
|
||
if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 {
|
||
return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive")
|
||
}
|
||
if strings.TrimSpace(c.Gateway.ConnectionPoolIsolation) != "" {
|
||
switch c.Gateway.ConnectionPoolIsolation {
|
||
case ConnectionPoolIsolationProxy, ConnectionPoolIsolationAccount, ConnectionPoolIsolationAccountProxy:
|
||
default:
|
||
return fmt.Errorf("gateway.connection_pool_isolation must be one of: %s/%s/%s",
|
||
ConnectionPoolIsolationProxy, ConnectionPoolIsolationAccount, ConnectionPoolIsolationAccountProxy)
|
||
}
|
||
}
|
||
if c.Gateway.ImageConcurrency.MaxConcurrentRequests < 0 {
|
||
return fmt.Errorf("gateway.image_concurrency.max_concurrent_requests must be non-negative")
|
||
}
|
||
switch strings.TrimSpace(c.Gateway.ImageConcurrency.OverflowMode) {
|
||
case "", ImageConcurrencyOverflowModeReject, ImageConcurrencyOverflowModeWait:
|
||
default:
|
||
return fmt.Errorf("gateway.image_concurrency.overflow_mode must be one of: %s/%s",
|
||
ImageConcurrencyOverflowModeReject, ImageConcurrencyOverflowModeWait)
|
||
}
|
||
if c.Gateway.ImageConcurrency.WaitTimeoutSeconds < 0 {
|
||
return fmt.Errorf("gateway.image_concurrency.wait_timeout_seconds must be non-negative")
|
||
}
|
||
if c.Gateway.ImageConcurrency.MaxWaitingRequests < 0 {
|
||
return fmt.Errorf("gateway.image_concurrency.max_waiting_requests must be non-negative")
|
||
}
|
||
if c.Gateway.MaxIdleConns <= 0 {
|
||
return fmt.Errorf("gateway.max_idle_conns must be positive")
|
||
}
|
||
if c.Gateway.MaxIdleConnsPerHost <= 0 {
|
||
return fmt.Errorf("gateway.max_idle_conns_per_host must be positive")
|
||
}
|
||
if c.Gateway.MaxConnsPerHost < 0 {
|
||
return fmt.Errorf("gateway.max_conns_per_host must be non-negative")
|
||
}
|
||
if c.Gateway.IdleConnTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("gateway.idle_conn_timeout_seconds must be positive")
|
||
}
|
||
if c.Gateway.IdleConnTimeoutSeconds > 180 {
|
||
slog.Warn("gateway.idle_conn_timeout_seconds is high; consider 60-120 seconds for better connection reuse", "idle_conn_timeout_seconds", c.Gateway.IdleConnTimeoutSeconds)
|
||
}
|
||
if c.Gateway.MaxUpstreamClients <= 0 {
|
||
return fmt.Errorf("gateway.max_upstream_clients must be positive")
|
||
}
|
||
if c.Gateway.ClientIdleTTLSeconds <= 0 {
|
||
return fmt.Errorf("gateway.client_idle_ttl_seconds must be positive")
|
||
}
|
||
if c.Gateway.ConcurrencySlotTTLMinutes <= 0 {
|
||
return fmt.Errorf("gateway.concurrency_slot_ttl_minutes must be positive")
|
||
}
|
||
if c.Gateway.StreamDataIntervalTimeout < 0 {
|
||
return fmt.Errorf("gateway.stream_data_interval_timeout must be non-negative")
|
||
}
|
||
if c.Gateway.StreamDataIntervalTimeout != 0 &&
|
||
(c.Gateway.StreamDataIntervalTimeout < 30 || c.Gateway.StreamDataIntervalTimeout > 300) {
|
||
return fmt.Errorf("gateway.stream_data_interval_timeout must be 0 or between 30-300 seconds")
|
||
}
|
||
if c.Gateway.StreamKeepaliveInterval < 0 {
|
||
return fmt.Errorf("gateway.stream_keepalive_interval must be non-negative")
|
||
}
|
||
if c.Gateway.StreamKeepaliveInterval != 0 &&
|
||
(c.Gateway.StreamKeepaliveInterval < 5 || c.Gateway.StreamKeepaliveInterval > 30) {
|
||
return fmt.Errorf("gateway.stream_keepalive_interval must be 0 or between 5-30 seconds")
|
||
}
|
||
if c.Gateway.ImageStreamDataIntervalTimeout < 0 {
|
||
return fmt.Errorf("gateway.image_stream_data_interval_timeout must be non-negative")
|
||
}
|
||
if c.Gateway.ImageStreamDataIntervalTimeout != 0 &&
|
||
(c.Gateway.ImageStreamDataIntervalTimeout < 60 || c.Gateway.ImageStreamDataIntervalTimeout > 1800) {
|
||
return fmt.Errorf("gateway.image_stream_data_interval_timeout must be 0 or between 60-1800 seconds")
|
||
}
|
||
if c.Gateway.ImageStreamKeepaliveInterval < 0 {
|
||
return fmt.Errorf("gateway.image_stream_keepalive_interval must be non-negative")
|
||
}
|
||
if c.Gateway.ImageStreamKeepaliveInterval != 0 &&
|
||
(c.Gateway.ImageStreamKeepaliveInterval < 5 || c.Gateway.ImageStreamKeepaliveInterval > 60) {
|
||
return fmt.Errorf("gateway.image_stream_keepalive_interval must be 0 or between 5-60 seconds")
|
||
}
|
||
if c.Gateway.KiroStreamKeepaliveInterval < 0 {
|
||
return fmt.Errorf("gateway.kiro_stream_keepalive_interval must be non-negative")
|
||
}
|
||
if c.Gateway.KiroStreamKeepaliveInterval != 0 &&
|
||
(c.Gateway.KiroStreamKeepaliveInterval < 5 || c.Gateway.KiroStreamKeepaliveInterval > 30) {
|
||
return fmt.Errorf("gateway.kiro_stream_keepalive_interval must be 0 or between 5-30 seconds")
|
||
}
|
||
// 兼容旧键 sticky_previous_response_ttl_seconds
|
||
if c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 {
|
||
c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds
|
||
}
|
||
if c.Gateway.OpenAIWS.MaxConnsPerAccount <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.max_conns_per_account must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.MinIdlePerAccount < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.min_idle_per_account must be non-negative")
|
||
}
|
||
if c.Gateway.OpenAIWS.MaxIdlePerAccount < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.max_idle_per_account must be non-negative")
|
||
}
|
||
if c.Gateway.OpenAIWS.MinIdlePerAccount > c.Gateway.OpenAIWS.MaxIdlePerAccount {
|
||
return fmt.Errorf("gateway.openai_ws.min_idle_per_account must be <= max_idle_per_account")
|
||
}
|
||
if c.Gateway.OpenAIWS.MaxIdlePerAccount > c.Gateway.OpenAIWS.MaxConnsPerAccount {
|
||
return fmt.Errorf("gateway.openai_ws.max_idle_per_account must be <= max_conns_per_account")
|
||
}
|
||
if c.Gateway.OpenAIWS.OAuthMaxConnsFactor <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.oauth_max_conns_factor must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.APIKeyMaxConnsFactor <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.apikey_max_conns_factor must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.DialTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.dial_timeout_seconds must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.ReadTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.read_timeout_seconds must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.WriteTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.write_timeout_seconds must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.PoolTargetUtilization <= 0 || c.Gateway.OpenAIWS.PoolTargetUtilization > 1 {
|
||
return fmt.Errorf("gateway.openai_ws.pool_target_utilization must be within (0,1]")
|
||
}
|
||
if c.Gateway.OpenAIWS.QueueLimitPerConn <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.queue_limit_per_conn must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.EventFlushBatchSize <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.event_flush_batch_size must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.EventFlushIntervalMS < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.event_flush_interval_ms must be non-negative")
|
||
}
|
||
if c.Gateway.OpenAIWS.PrewarmCooldownMS < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.prewarm_cooldown_ms must be non-negative")
|
||
}
|
||
if c.Gateway.OpenAIWS.FallbackCooldownSeconds < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.fallback_cooldown_seconds must be non-negative")
|
||
}
|
||
if c.Gateway.OpenAIWS.RetryBackoffInitialMS < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.retry_backoff_initial_ms must be non-negative")
|
||
}
|
||
if c.Gateway.OpenAIWS.RetryBackoffMaxMS < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.retry_backoff_max_ms must be non-negative")
|
||
}
|
||
if c.Gateway.OpenAIWS.RetryBackoffInitialMS > 0 && c.Gateway.OpenAIWS.RetryBackoffMaxMS > 0 &&
|
||
c.Gateway.OpenAIWS.RetryBackoffMaxMS < c.Gateway.OpenAIWS.RetryBackoffInitialMS {
|
||
return fmt.Errorf("gateway.openai_ws.retry_backoff_max_ms must be >= retry_backoff_initial_ms")
|
||
}
|
||
if c.Gateway.OpenAIWS.RetryJitterRatio < 0 || c.Gateway.OpenAIWS.RetryJitterRatio > 1 {
|
||
return fmt.Errorf("gateway.openai_ws.retry_jitter_ratio must be within [0,1]")
|
||
}
|
||
if c.Gateway.OpenAIWS.RetryTotalBudgetMS < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.retry_total_budget_ms must be non-negative")
|
||
}
|
||
if mode := strings.ToLower(strings.TrimSpace(c.Gateway.OpenAIWS.IngressModeDefault)); mode != "" {
|
||
switch mode {
|
||
case "off", "ctx_pool", "passthrough":
|
||
case "shared", "dedicated":
|
||
slog.Warn("gateway.openai_ws.ingress_mode_default is deprecated, treating as ctx_pool; please update to off|ctx_pool|passthrough", "value", mode)
|
||
default:
|
||
return fmt.Errorf("gateway.openai_ws.ingress_mode_default must be one of off|ctx_pool|passthrough")
|
||
}
|
||
}
|
||
if mode := strings.ToLower(strings.TrimSpace(c.Gateway.OpenAIWS.StoreDisabledConnMode)); mode != "" {
|
||
switch mode {
|
||
case "strict", "adaptive", "off":
|
||
default:
|
||
return fmt.Errorf("gateway.openai_ws.store_disabled_conn_mode must be one of strict|adaptive|off")
|
||
}
|
||
}
|
||
if c.Gateway.OpenAIWS.PayloadLogSampleRate < 0 || c.Gateway.OpenAIWS.PayloadLogSampleRate > 1 {
|
||
return fmt.Errorf("gateway.openai_ws.payload_log_sample_rate must be within [0,1]")
|
||
}
|
||
if c.Gateway.OpenAIWS.LBTopK <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.lb_top_k must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.StickySessionTTLSeconds <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.sticky_session_ttl_seconds must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.sticky_response_id_ttl_seconds must be positive")
|
||
}
|
||
if c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.sticky_previous_response_ttl_seconds must be non-negative")
|
||
}
|
||
if c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority < 0 ||
|
||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load < 0 ||
|
||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 ||
|
||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 ||
|
||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 {
|
||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative")
|
||
}
|
||
weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority +
|
||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load +
|
||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue +
|
||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate +
|
||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT
|
||
if weightSum <= 0 {
|
||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
|
||
}
|
||
if c.Gateway.MaxLineSize < 0 {
|
||
return fmt.Errorf("gateway.max_line_size must be non-negative")
|
||
}
|
||
if c.Gateway.MaxLineSize != 0 && c.Gateway.MaxLineSize < 1024*1024 {
|
||
return fmt.Errorf("gateway.max_line_size must be at least 1MB")
|
||
}
|
||
if c.Gateway.UsageRecord.WorkerCount <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.worker_count must be positive")
|
||
}
|
||
if c.Gateway.UsageRecord.QueueSize <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.queue_size must be positive")
|
||
}
|
||
if c.Gateway.UsageRecord.TaskTimeoutSeconds <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.task_timeout_seconds must be positive")
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(c.Gateway.UsageRecord.OverflowPolicy)) {
|
||
case UsageRecordOverflowPolicyDrop, UsageRecordOverflowPolicySample, UsageRecordOverflowPolicySync:
|
||
default:
|
||
return fmt.Errorf("gateway.usage_record.overflow_policy must be one of: %s/%s/%s",
|
||
UsageRecordOverflowPolicyDrop, UsageRecordOverflowPolicySample, UsageRecordOverflowPolicySync)
|
||
}
|
||
if c.Gateway.UsageRecord.OverflowSamplePercent < 0 || c.Gateway.UsageRecord.OverflowSamplePercent > 100 {
|
||
return fmt.Errorf("gateway.usage_record.overflow_sample_percent must be between 0-100")
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(c.Gateway.UsageRecord.OverflowPolicy), UsageRecordOverflowPolicySample) &&
|
||
c.Gateway.UsageRecord.OverflowSamplePercent <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.overflow_sample_percent must be positive when overflow_policy=sample")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleEnabled {
|
||
if c.Gateway.UsageRecord.AutoScaleMinWorkers <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_min_workers must be positive")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleMaxWorkers <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_max_workers must be positive")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleMaxWorkers < c.Gateway.UsageRecord.AutoScaleMinWorkers {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_max_workers must be >= auto_scale_min_workers")
|
||
}
|
||
if c.Gateway.UsageRecord.WorkerCount < c.Gateway.UsageRecord.AutoScaleMinWorkers ||
|
||
c.Gateway.UsageRecord.WorkerCount > c.Gateway.UsageRecord.AutoScaleMaxWorkers {
|
||
return fmt.Errorf("gateway.usage_record.worker_count must be between auto_scale_min_workers and auto_scale_max_workers")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleUpQueuePercent <= 0 || c.Gateway.UsageRecord.AutoScaleUpQueuePercent > 100 {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_up_queue_percent must be between 1-100")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleDownQueuePercent < 0 || c.Gateway.UsageRecord.AutoScaleDownQueuePercent >= 100 {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_down_queue_percent must be between 0-99")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleDownQueuePercent >= c.Gateway.UsageRecord.AutoScaleUpQueuePercent {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_down_queue_percent must be less than auto_scale_up_queue_percent")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleUpStep <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_up_step must be positive")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleDownStep <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_down_step must be positive")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleCheckIntervalSeconds <= 0 {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_check_interval_seconds must be positive")
|
||
}
|
||
if c.Gateway.UsageRecord.AutoScaleCooldownSeconds < 0 {
|
||
return fmt.Errorf("gateway.usage_record.auto_scale_cooldown_seconds must be non-negative")
|
||
}
|
||
}
|
||
if c.Gateway.UserGroupRateCacheTTLSeconds <= 0 {
|
||
return fmt.Errorf("gateway.user_group_rate_cache_ttl_seconds must be positive")
|
||
}
|
||
if c.Gateway.ModelsListCacheTTLSeconds < 10 || c.Gateway.ModelsListCacheTTLSeconds > 30 {
|
||
return fmt.Errorf("gateway.models_list_cache_ttl_seconds must be between 10-30")
|
||
}
|
||
if c.Gateway.Scheduling.StickySessionMaxWaiting <= 0 {
|
||
return fmt.Errorf("gateway.scheduling.sticky_session_max_waiting must be positive")
|
||
}
|
||
if c.Gateway.Scheduling.StickySessionWaitTimeout <= 0 {
|
||
return fmt.Errorf("gateway.scheduling.sticky_session_wait_timeout must be positive")
|
||
}
|
||
if c.Gateway.Scheduling.FallbackWaitTimeout <= 0 {
|
||
return fmt.Errorf("gateway.scheduling.fallback_wait_timeout must be positive")
|
||
}
|
||
if c.Gateway.Scheduling.FallbackMaxWaiting <= 0 {
|
||
return fmt.Errorf("gateway.scheduling.fallback_max_waiting must be positive")
|
||
}
|
||
if c.Gateway.Scheduling.SnapshotMGetChunkSize <= 0 {
|
||
return fmt.Errorf("gateway.scheduling.snapshot_mget_chunk_size must be positive")
|
||
}
|
||
if c.Gateway.Scheduling.SnapshotWriteChunkSize <= 0 {
|
||
return fmt.Errorf("gateway.scheduling.snapshot_write_chunk_size must be positive")
|
||
}
|
||
if c.Gateway.Scheduling.SlotCleanupInterval < 0 {
|
||
return fmt.Errorf("gateway.scheduling.slot_cleanup_interval must be non-negative")
|
||
}
|
||
if c.Gateway.Scheduling.DbFallbackTimeoutSeconds < 0 {
|
||
return fmt.Errorf("gateway.scheduling.db_fallback_timeout_seconds must be non-negative")
|
||
}
|
||
if c.Gateway.Scheduling.DbFallbackMaxQPS < 0 {
|
||
return fmt.Errorf("gateway.scheduling.db_fallback_max_qps must be non-negative")
|
||
}
|
||
if c.Gateway.Scheduling.OutboxPollIntervalSeconds <= 0 {
|
||
return fmt.Errorf("gateway.scheduling.outbox_poll_interval_seconds must be positive")
|
||
}
|
||
if c.Gateway.Scheduling.OutboxLagWarnSeconds < 0 {
|
||
return fmt.Errorf("gateway.scheduling.outbox_lag_warn_seconds must be non-negative")
|
||
}
|
||
if c.Gateway.Scheduling.OutboxLagRebuildSeconds < 0 {
|
||
return fmt.Errorf("gateway.scheduling.outbox_lag_rebuild_seconds must be non-negative")
|
||
}
|
||
if c.Gateway.Scheduling.OutboxLagRebuildFailures <= 0 {
|
||
return fmt.Errorf("gateway.scheduling.outbox_lag_rebuild_failures must be positive")
|
||
}
|
||
if c.Gateway.Scheduling.OutboxBacklogRebuildRows < 0 {
|
||
return fmt.Errorf("gateway.scheduling.outbox_backlog_rebuild_rows must be non-negative")
|
||
}
|
||
if c.Gateway.Scheduling.FullRebuildIntervalSeconds < 0 {
|
||
return fmt.Errorf("gateway.scheduling.full_rebuild_interval_seconds must be non-negative")
|
||
}
|
||
if c.Gateway.Scheduling.OutboxLagWarnSeconds > 0 &&
|
||
c.Gateway.Scheduling.OutboxLagRebuildSeconds > 0 &&
|
||
c.Gateway.Scheduling.OutboxLagRebuildSeconds < c.Gateway.Scheduling.OutboxLagWarnSeconds {
|
||
return fmt.Errorf("gateway.scheduling.outbox_lag_rebuild_seconds must be >= outbox_lag_warn_seconds")
|
||
}
|
||
if c.Ops.MetricsCollectorCache.TTL < 0 {
|
||
return fmt.Errorf("ops.metrics_collector_cache.ttl must be non-negative")
|
||
}
|
||
if c.Ops.Cleanup.ErrorLogRetentionDays < 0 {
|
||
return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative")
|
||
}
|
||
if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 {
|
||
return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative")
|
||
}
|
||
if c.Ops.Cleanup.HourlyMetricsRetentionDays < 0 {
|
||
return fmt.Errorf("ops.cleanup.hourly_metrics_retention_days must be non-negative")
|
||
}
|
||
if c.Ops.Cleanup.Enabled && strings.TrimSpace(c.Ops.Cleanup.Schedule) == "" {
|
||
return fmt.Errorf("ops.cleanup.schedule is required when ops.cleanup.enabled=true")
|
||
}
|
||
if c.Concurrency.PingInterval < 5 || c.Concurrency.PingInterval > 30 {
|
||
return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeStringSlice(values []string) []string {
|
||
if len(values) == 0 {
|
||
return values
|
||
}
|
||
normalized := make([]string, 0, len(values))
|
||
for _, v := range values {
|
||
trimmed := strings.TrimSpace(v)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
normalized = append(normalized, trimmed)
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
func isWeakJWTSecret(secret string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(secret))
|
||
if lower == "" {
|
||
return true
|
||
}
|
||
weak := map[string]struct{}{
|
||
"change-me-in-production": {},
|
||
"changeme": {},
|
||
"secret": {},
|
||
"password": {},
|
||
"123456": {},
|
||
"12345678": {},
|
||
"admin": {},
|
||
"jwt-secret": {},
|
||
}
|
||
_, exists := weak[lower]
|
||
return exists
|
||
}
|
||
|
||
func generateJWTSecret(byteLength int) (string, error) {
|
||
if byteLength <= 0 {
|
||
byteLength = 32
|
||
}
|
||
buf := make([]byte, byteLength)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(buf), nil
|
||
}
|
||
|
||
// GetServerAddress returns the server address (host:port) from config file or environment variable.
|
||
// This is a lightweight function that can be used before full config validation,
|
||
// such as during setup wizard startup.
|
||
// Priority: config.yaml > environment variables > defaults
|
||
func GetServerAddress() string {
|
||
v := viper.New()
|
||
v.SetConfigName("config")
|
||
v.SetConfigType("yaml")
|
||
v.AddConfigPath(".")
|
||
v.AddConfigPath("./config")
|
||
v.AddConfigPath("/etc/sub2api")
|
||
|
||
// Support SERVER_HOST and SERVER_PORT environment variables
|
||
v.AutomaticEnv()
|
||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||
v.SetDefault("server.host", "0.0.0.0")
|
||
v.SetDefault("server.port", 8080)
|
||
|
||
// Try to read config file (ignore errors if not found)
|
||
_ = v.ReadInConfig()
|
||
|
||
host := v.GetString("server.host")
|
||
port := v.GetInt("server.port")
|
||
return fmt.Sprintf("%s:%d", host, port)
|
||
}
|
||
|
||
// ValidateAbsoluteHTTPURL 验证是否为有效的绝对 HTTP(S) URL
|
||
func ValidateAbsoluteHTTPURL(raw string) error {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return fmt.Errorf("empty url")
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !u.IsAbs() {
|
||
return fmt.Errorf("must be absolute")
|
||
}
|
||
if !isHTTPScheme(u.Scheme) {
|
||
return fmt.Errorf("unsupported scheme: %s", u.Scheme)
|
||
}
|
||
if strings.TrimSpace(u.Host) == "" {
|
||
return fmt.Errorf("missing host")
|
||
}
|
||
if u.Fragment != "" {
|
||
return fmt.Errorf("must not include fragment")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ValidateFrontendRedirectURL 验证前端重定向 URL(可以是绝对 URL 或相对路径)
|
||
func ValidateFrontendRedirectURL(raw string) error {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return fmt.Errorf("empty url")
|
||
}
|
||
if strings.ContainsAny(raw, "\r\n") {
|
||
return fmt.Errorf("contains invalid characters")
|
||
}
|
||
if strings.HasPrefix(raw, "/") {
|
||
if strings.HasPrefix(raw, "//") {
|
||
return fmt.Errorf("must not start with //")
|
||
}
|
||
return nil
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !u.IsAbs() {
|
||
return fmt.Errorf("must be absolute http(s) url or relative path")
|
||
}
|
||
if !isHTTPScheme(u.Scheme) {
|
||
return fmt.Errorf("unsupported scheme: %s", u.Scheme)
|
||
}
|
||
if strings.TrimSpace(u.Host) == "" {
|
||
return fmt.Errorf("missing host")
|
||
}
|
||
if u.Fragment != "" {
|
||
return fmt.Errorf("must not include fragment")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func scopeContainsOpenID(scopes string) bool {
|
||
for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) {
|
||
if scope == "openid" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isHTTPScheme 检查是否为 HTTP 或 HTTPS 协议
|
||
func isHTTPScheme(scheme string) bool {
|
||
return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https")
|
||
}
|
||
|
||
func warnIfInsecureURL(field, raw string) {
|
||
u, err := url.Parse(strings.TrimSpace(raw))
|
||
if err != nil {
|
||
return
|
||
}
|
||
if strings.EqualFold(u.Scheme, "http") {
|
||
slog.Warn("url uses http scheme; use https in production to avoid token leakage", "field", field)
|
||
}
|
||
}
|