Files
openteam/server/internal/config/config.go
T
Sakurasan 8c12207478 perf: 目录缓存与流式正文快路径,请求热路径开销减半
按性能分析逐项落地,实测对比(16 并发、mock 上游零延迟、SQLite):

- 每请求 SQL 10 → 2:模型/渠道目录(模型行、绑定、渠道行)进程内缓存 10s,
  运行期状态(冷却、健康)仍每次实时判定;管理端写操作经中间件、
  健康状态变化经回调立即失效。新增 TestProxyRequestSQLBudget 钉住预算。
- 端到端 p50 1.3–1.6ms → 0.66–0.70ms,RPS 3.5–8.9k → 16–18k。
- 流式正文累积:字符串 += 改 strings.Builder(原为 O(n²)),
  逐行 JSON 提取改字节快路径,并加廉价的子串预过滤、拿到输出 usage 后停累积。
  500 块:949µs/7.9MB 分配 → 83µs/136KB。
- 请求体定点改写:模型映射从整包 unmarshal/marshal 改为顶层字段替换
  (100KB:585µs → 29µs),stream_options 注入同理;requestText 改为
  只在「上游没报用量」时按需提取,亲和键改用原始体前缀。
- 渠道满载背压:新增 Acquire 有界排队(OT_PROXY_CHANNEL_ACQUIRE_WAIT,
  默认 150ms);排队仍满时返回 503 + Retry-After 而不是 502。
  实测渠道上限 4、16 并发:无排队 81 次 503;排队 150ms 后 400/400 成功。
- 记账按批聚合:同批同用户一次扣款、流水批量插入、日表按 (user,model,日期)
  聚合,替代每行 5 条语句;幂等仍靠 ref_id 唯一索引兜底。
- gorm 开启 PrepareStmt(SQL 解析此前占 CPU 15%);tiktoken 编码器按模型复用。
- 管理端用量列表不再返回原始请求/响应体(可能几十 MB),
  改为 has_raw 标记 + GET /admin/usage/:id 按需拉取,前端弹窗打开时取。

验证:go vet、go test ./...、go test -race(proxy/channel/usage/tokenizer)、
vue-tsc、eslint、vite build 全绿;本地起 mock 上游实测列表/详情接口与
管理页弹窗按需加载。
2026-10-30 14:14:21 +08:00

290 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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=立刻跳过
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
}