profile(16 并发)显示请求路径的 CPU 有 ~70% 花在鉴权上:每个代理请求都要用 密钥哈希查 api_keys、再用 user_id 查 users,两条随机读。 - 新增鉴权缓存(OT_PROXY_AUTH_CACHE_TTL,默认 2s,0=关闭):缓存密钥与用户行, 但密钥状态、用户状态、密钥过期仍按当前时间逐请求判定;管理端写操作与用户的 密钥写操作经中间件立即清空缓存。余额随用户行一起缓存,只影响「≤0 就 402」 这道软闸,最长滞后一个 TTL(请求照样计费)。 - last_used_at 节流改由缓存内的 touch 表记账(原先按 DB 行时间戳判断,行被缓存后 时间戳不再变化,会导致每请求都写一次库)。touch 用 sync.Map:早先与缓存共用一把 排他锁,16 并发下互斥饥饿把吞吐从 31k 拖到 3.2k(p99 7ms),换 sync.Map 后消失。 - 去掉 usage_logs 上从未被查询过滤的 key_id / trace_id 索引(没有查询按它们过滤, 每个索引都是每行一次 B-tree 插入);schema v8 迁移里显式 DROP 旧库上的索引。 实测(16 并发、mock 上游、SQLite): - 鉴权缓存开:p50 131–177µs、p99 0.6–1.2ms、RPS 56k–81k - 鉴权缓存关:p50 535–631µs、p99 1.6–2.8ms、RPS 21k–27k (4 并发下同样受益:30k→41k) 测试:新增缓存命中零 SQL、状态/过期重判、失效、TTL 到期、缓存关闭、并发七项; go vet、go test ./...、-race(proxy/usage/store/api)全绿。
293 lines
11 KiB
Go
293 lines
11 KiB
Go
// Package config 加载服务配置:.env / 环境变量 / 默认值(viper)。
|
||
// 所有项均可用环境变量 OT_<KEY> 覆盖(点号转下划线,如 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=立刻跳过
|
||
AuthCacheTTL time.Duration // 鉴权缓存 TTL,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.auth_cache_ttl", "2s")
|
||
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"),
|
||
AuthCacheTTL: v.GetDuration("proxy.auth_cache_ttl"),
|
||
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
|
||
}
|