// Package config 加载服务配置:.env / 环境变量 / 默认值(viper)。 // 所有项均可用环境变量 OT_ 覆盖(点号转下划线,如 db.driver → OT_DB_DRIVER)。 package config import ( "fmt" "os" "strings" "time" "github.com/spf13/viper" ) type Config struct { Env string // development | production Port int DB DBConfig JWT JWTConfig Auth AuthConfig Proxy ProxyConfig RateLimit RateLimitConfig WebAuthn WebAuthnConfig Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量) // 管理员初始密码:仅用于生产启动断言,Seed 仍按原样直接读环境变量 AdminPassword string // 跨域白名单(逗号分隔 origin)。为空 = 不输出任何 CORS 头: // 网关自己托管 web/dist 时前后端同源,本就不需要 CORS;开发用 vite proxy 也不需要。 CORSAllowOrigins []string // 反向代理列表(CIDR 或 IP)。只有配置它,gin 才信任 X-Forwarded-For 取客户端 IP, // 认证限速依赖这个 IP;直连部署留空即可。 TrustedProxies []string } // WebAuthnConfig Passkey(WebAuthn)配置。 type WebAuthnConfig struct { RPID string // Relying Party ID(域名,如 localhost) RPOrigin string // 前端来源,如 http://localhost:5173 RPName string // 展示名 } // RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。 type RateLimitConfig struct { UserRPS int // 用户级每秒请求数上限(0=不限制) // 认证端点限速(每分钟):登录是密码爆破面,注册是批量建号面。0=不限制。 LoginIPPerMin int // 单 IP 登录尝试数 LoginUserPerMin int // 单 IP 对同一账号的登录尝试数(挡住换 IP 定向爆破) RegisterIPPerMin int // 单 IP 注册次数 } type DBConfig struct { Driver string // sqlite | postgres DSN string } type JWTConfig struct { Secret string AccessTTL time.Duration RefreshTTL time.Duration Issuer string CookieName string CookieSecure bool CookieDomain string } type AuthConfig struct { RegistrationMode string // open | invite Argon2Time uint32 Argon2Memory uint32 Argon2Threads uint8 Argon2KeyLen uint32 SaltLen int } type ProxyConfig struct { DefaultChannelName string // 首次启动自动创建的渠道名(如 openai) UpstreamBaseURL string // 渠道 base_url 默认值 UpstreamKey string // 渠道上游 key 默认值 DefaultModel string // 渠道模型导入时使用的模型名 Timeout time.Duration MaxBodyMB int // /v1 请求体上限(MB),超限按客户端协议返回 413 FailoverBudget time.Duration // 故障转移给"第二个及以后的渠道"留的时间预算,0=不限 StreamHeartbeat time.Duration // 流式空闲多久发一次 SSE 注释心跳,0=关闭 FirstTokenWait time.Duration // 流式放行前等内容的最长时间,0=不限;超时未出内容换下一个渠道 AffinityTTL time.Duration // 会话亲和 TTL,0=关闭;同一会话粘住上次应答的渠道以命中上游缓存 ChannelAcquireWait time.Duration // 渠道并发满载时每个候选的排队时长,0=立刻跳过 HealthInterval time.Duration // 渠道健康检查周期 HealthFailThreshold int // 连续失败 N 次进 cooldown LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关) } // loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。 // 已存在的环境变量优先,不覆盖。 func loadDotEnv() { data, err := os.ReadFile(".env") if err != nil { return } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { continue } k = strings.TrimSpace(k) v = strings.Trim(strings.TrimSpace(v), `"'`) if k != "" && os.Getenv(k) == "" { _ = os.Setenv(k, v) } } } func Load() (*Config, error) { loadDotEnv() v := viper.New() v.SetEnvPrefix("OT") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() // 默认值(与 .env.example 对应) v.SetDefault("env", "development") v.SetDefault("port", 8080) v.SetDefault("db.driver", "sqlite") v.SetDefault("db.dsn", "data/openteam.db") v.SetDefault("jwt.secret", "dev-only-secret-change-me") v.SetDefault("jwt.access_ttl", "2h") v.SetDefault("jwt.refresh_ttl", "168h") v.SetDefault("jwt.issuer", "openteam") v.SetDefault("jwt.cookie_name", "ot_refresh") v.SetDefault("jwt.cookie_secure", false) v.SetDefault("jwt.cookie_domain", "") v.SetDefault("auth.registration_mode", "open") v.SetDefault("auth.argon2_time", 3) v.SetDefault("auth.argon2_memory", 64*1024) // 64 MiB v.SetDefault("auth.argon2_threads", 2) v.SetDefault("auth.argon2_keylen", 32) v.SetDefault("auth.salt_len", 16) v.SetDefault("proxy.default_channel_name", "openai") v.SetDefault("proxy.upstream_base_url", "https://api.openai.com") v.SetDefault("proxy.upstream_key", "") v.SetDefault("proxy.default_model", "gpt-4o-mini") v.SetDefault("proxy.timeout", "300s") v.SetDefault("proxy.max_body_mb", 32) v.SetDefault("proxy.failover_budget", "30s") v.SetDefault("proxy.stream_heartbeat", "15s") v.SetDefault("proxy.first_token_wait", "0s") v.SetDefault("proxy.affinity_ttl", "5m") v.SetDefault("proxy.channel_acquire_wait", "150ms") v.SetDefault("proxy.health_interval", "60s") v.SetDefault("proxy.health_fail_threshold", 2) v.SetDefault("proxy.log_raw", false) v.SetDefault("ratelimit.user_rps", 20) v.SetDefault("ratelimit.login_ip_per_min", 30) v.SetDefault("ratelimit.login_user_per_min", 10) v.SetDefault("ratelimit.register_ip_per_min", 5) v.SetDefault("cors.allow_origins", "") v.SetDefault("server.trusted_proxies", "") v.SetDefault("webauthn.rp_id", "localhost") v.SetDefault("webauthn.rp_origin", "http://localhost:5173") v.SetDefault("webauthn.rp_name", "openteam") cfg := &Config{ Env: v.GetString("env"), Port: v.GetInt("port"), DB: DBConfig{ Driver: v.GetString("db.driver"), DSN: v.GetString("db.dsn"), }, JWT: JWTConfig{ Secret: v.GetString("jwt.secret"), AccessTTL: v.GetDuration("jwt.access_ttl"), RefreshTTL: v.GetDuration("jwt.refresh_ttl"), Issuer: v.GetString("jwt.issuer"), CookieName: v.GetString("jwt.cookie_name"), CookieSecure: v.GetBool("jwt.cookie_secure"), CookieDomain: v.GetString("jwt.cookie_domain"), }, Auth: AuthConfig{ RegistrationMode: v.GetString("auth.registration_mode"), Argon2Time: v.GetUint32("auth.argon2_time"), Argon2Memory: v.GetUint32("auth.argon2_memory"), Argon2Threads: v.GetUint8("auth.argon2_threads"), Argon2KeyLen: v.GetUint32("auth.argon2_keylen"), SaltLen: v.GetInt("auth.salt_len"), }, Proxy: ProxyConfig{ DefaultChannelName: v.GetString("proxy.default_channel_name"), UpstreamBaseURL: v.GetString("proxy.upstream_base_url"), UpstreamKey: v.GetString("proxy.upstream_key"), DefaultModel: v.GetString("proxy.default_model"), Timeout: v.GetDuration("proxy.timeout"), MaxBodyMB: v.GetInt("proxy.max_body_mb"), FailoverBudget: v.GetDuration("proxy.failover_budget"), StreamHeartbeat: v.GetDuration("proxy.stream_heartbeat"), FirstTokenWait: v.GetDuration("proxy.first_token_wait"), AffinityTTL: v.GetDuration("proxy.affinity_ttl"), ChannelAcquireWait: v.GetDuration("proxy.channel_acquire_wait"), HealthInterval: v.GetDuration("proxy.health_interval"), HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"), LogRaw: v.GetBool("proxy.log_raw"), }, RateLimit: RateLimitConfig{ UserRPS: v.GetInt("ratelimit.user_rps"), LoginIPPerMin: v.GetInt("ratelimit.login_ip_per_min"), LoginUserPerMin: v.GetInt("ratelimit.login_user_per_min"), RegisterIPPerMin: v.GetInt("ratelimit.register_ip_per_min"), }, WebAuthn: WebAuthnConfig{ RPID: v.GetString("webauthn.rp_id"), RPOrigin: v.GetString("webauthn.rp_origin"), RPName: v.GetString("webauthn.rp_name"), }, Master: v.GetString("master_key"), AdminPassword: v.GetString("admin_password"), CORSAllowOrigins: splitList(v.GetString("cors.allow_origins")), TrustedProxies: splitList(v.GetString("server.trusted_proxies")), } if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } // splitList 解析逗号分隔列表(去空白、忽略空项)。 func splitList(s string) []string { if strings.TrimSpace(s) == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } if len(out) == 0 { return nil } return out } // Load 之后、使用之前统一校验。env 值枚举化:OT_ENV 拼错(如 "prod")会静默落入 // 开发配置,让下面所有生产断言形同虚设。 func (c *Config) validate() error { if c.Env != "development" && c.Env != "production" { return fmt.Errorf(`OT_ENV 只能是 "development" 或 "production",当前 %q(拼错会静默跳过生产密钥断言)`, c.Env) } if c.Env != "production" { return nil } var problems []string if c.JWT.Secret == "" || knownSecret(c.JWT.Secret) { problems = append(problems, "OT_JWT_SECRET 未设置或仍是仓库里的默认值(可伪造会话 token)") } if len(c.Master) < 16 || knownSecret(c.Master) { problems = append(problems, "OT_MASTER_KEY 未设置、少于 16 字节或仍是示例占位值(缺失/公开时渠道密文可被公开还原)") } if c.AdminPassword == "" || c.AdminPassword == "admin123" { problems = append(problems, "OT_ADMIN_PASSWORD 未设置或仍是 admin123") } if !c.JWT.CookieSecure { problems = append(problems, "OT_JWT_COOKIE_SECURE 必须为 true(生产环境 refresh cookie 走 HTTP 明文可被中间人截取;自签证书或反代终止 TLS 后也要置 true)") } if len(problems) > 0 { return fmt.Errorf("生产环境启动被拒绝:%s", strings.Join(problems, ";")) } return nil } // knownSecret 仓库/示例文件里公开出现过的密钥占位值:复制 .env.example 不改密钥 // 是最常见的部署路径,必须当作默认值拦下。 func knownSecret(s string) bool { switch s { case "dev-only-secret-change-me", "change-me-to-a-long-random-string", "change-me-master-key": return true } return false }