Files
openteam/server/internal/config/config.go
T
Sakurasan c39c3e7da4 feat: 补齐上游细节、渠道观测与模型定价元数据
- 上游 429 的 Retry-After 转成渠道冷却时长(夹在 10 分钟内;比默认值短则不缩短,
  否则 1s 就把坏渠道放回轮转)。失败计数改由 proxyOne 就地报——只有它读得到响应头。
- 故障转移时间预算 OT_PROXY_FAILOVER_BUDGET(默认 30s):只约束第二个及以后的渠道,
  首个不受限(长回答本就要跑几分钟),但坏渠道不再把客户端拖到 N×timeout。
- 流式空闲心跳 OT_PROXY_STREAM_HEARTBEAT(默认 15s):补一行 SSE 注释 `: ping`,
  防止 nginx/Cloudflare 的空闲超时掐掉长思考。心跳与正文共用一把写的锁,
  gin 的 ResponseWriter 不是并发安全的;注释行不进转换也不计费。
- 渠道观测:GET /api/v1/admin/channels/stats?window=5m|1h|6h|24h|7d 聚合请求量、
  成功率、P50/P95/max 延迟、成本与 top_errors;分位只取成功样本,canceled(客户端
  主动断开)不计入渠道错误率。渠道页桌面表格加「近N小时」列、移动端加同内容条,
  顶部可切窗口。
- 修一个让统计瞎掉的归因问题:全部渠道失败时 recordError 传的是 nil,
  usage_logs 里 channel_id=0,这类失败在渠道维度完全看不见。改为归到最后一个
  真正试过的渠道。
- 模型定价与元数据:Model 新增 cache_write_price(0 则回退输入价×1.25,可显式配
  Anthropic 1h 的 ×2)、context_length 与 supports_tools/vision/reasoning;
  finishUsage 走 CacheWritePriceOr()。/v1/models 叠加用户与密钥白名单过滤
  (原来会把限制外的模型名直接泄漏在列表里)并返回元数据,价格不外泄。
- /v1/models 的 Anthropic 分支原是死代码:协议只按路径判,而两种客户端共用这个路径。
  改为按 x-api-key / anthropic-version 头判,OpenAI SDK 从不发这两个头,互不影响。
- NewGateway 的十个位置参数换成 Options 结构体(三个 Duration 两个 int,传错编译期发现不了)。

新增 6 个用例:Retry-After 两种写法与夹顶、重试预算截止、空闲心跳出帧、
/v1/models 过滤与双形状、缓存写价两条路径、失败归因。
2026-10-17 04:09:39 +08:00

267 lines
9.2 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=关闭
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.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"),
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
}
// validate 生产环境的密钥断言:这些默认值是公开已知的,忘配就等于没有认证。
//
// master key 缺失时 crypto.NewEncryptor 会用 sha256("") 派生密钥(结果同样公开),
// 渠道上游密钥的密文就变成谁都能解;jwt.secret 用默认值则能直接伪造管理员 token。
func (c *Config) validate() error {
if c.Env != "production" {
return nil
}
var problems []string
if c.JWT.Secret == "" || c.JWT.Secret == "dev-only-secret-change-me" {
problems = append(problems, "OT_JWT_SECRET 未设置或仍是仓库里的默认值(可伪造会话 token)")
}
if len(c.Master) < 16 {
problems = append(problems, "OT_MASTER_KEY 未设置或少于 16 字节(缺失时会用 sha256(\"\") 派生,渠道密文可被公开还原)")
}
if c.AdminPassword == "" || c.AdminPassword == "admin123" {
problems = append(problems, "OT_ADMIN_PASSWORD 未设置或仍是 admin123")
}
if len(problems) > 0 {
return fmt.Errorf("生产环境启动被拒绝:%s", strings.Join(problems, ";"))
}
return nil
}