- users 加 token_version(schema v4):改密/登出递增,该用户全部已发 access/refresh 立即作废;refresh 改轮换式换发新 cookie - 邀请码改精确匹配 + 注册成功后消费(一码一用),修复子串匹配让任意后缀放行、可无限薅体验金 - 生产校验拦下 .env.example 占位密钥;OT_ENV 枚举校验(拼错不再静默跳过断言);生产强制 COOKIE_SECURE;非 development 拒绝默认密码播种管理员 - WebAuthn 会话启用过期校验 + 4096 容量上限(未认证 login/begin 可无限撑爆内存),begin/complete 挂登录级限速 - /api/v1 挂 1MB 请求体上限;login 先限 IP 桶再解析 body;全局安全响应头 + CSP - 连接池配置(PG 30/SQLite 8);SQLite DSN 自动追加 WAL + busy_timeout(驱动只认 _pragma,旧注释的 _journal_mode 写法不生效)
168 lines
4.8 KiB
Go
168 lines
4.8 KiB
Go
// Package app 应用容器:装配配置、数据库、密码/加密/JWT 与记账器。
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/openteam/server/internal/channel"
|
|
"github.com/openteam/server/internal/config"
|
|
"github.com/openteam/server/internal/passkey"
|
|
"github.com/openteam/server/internal/pkg/crypto"
|
|
"github.com/openteam/server/internal/pkg/jwt"
|
|
"github.com/openteam/server/internal/pkg/ratelimit"
|
|
"github.com/openteam/server/internal/store"
|
|
"github.com/openteam/server/internal/usage"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type App struct {
|
|
Cfg *config.Config
|
|
DB *gorm.DB
|
|
Hasher *crypto.PasswordHasher
|
|
Enc *crypto.Encryptor
|
|
JWT *jwt.Manager
|
|
Usage *usage.Recorder
|
|
Health *channel.HealthMonitor
|
|
Limit *ratelimit.Limiter
|
|
Passkeys *passkey.Service
|
|
startedAt time.Time
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func New(cfg *config.Config) (*App, error) {
|
|
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
a := &App{
|
|
Cfg: cfg,
|
|
DB: db,
|
|
Hasher: crypto.NewPasswordHasher(cfg.Auth.Argon2Time, cfg.Auth.Argon2Memory, cfg.Auth.Argon2Threads, cfg.Auth.Argon2KeyLen, cfg.Auth.SaltLen),
|
|
Enc: crypto.NewEncryptor(cfg.Master),
|
|
JWT: jwt.NewManager(cfg.JWT.Secret, cfg.JWT.Issuer, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL),
|
|
startedAt: time.Now(),
|
|
}
|
|
a.ctx, a.cancel = context.WithCancel(context.Background())
|
|
a.Usage = usage.NewRecorder(db)
|
|
a.Limit = ratelimit.New()
|
|
|
|
if err := a.Seed(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
a.Health = channel.NewHealthMonitor(db, a.Enc, channel.HealthConfig{
|
|
Interval: cfg.Proxy.HealthInterval,
|
|
FailThreshold: cfg.Proxy.HealthFailThreshold,
|
|
})
|
|
a.Health.Start(a.ctx)
|
|
|
|
a.Passkeys, err = passkey.New(db, passkey.Config{
|
|
RPID: cfg.WebAuthn.RPID, Origin: cfg.WebAuthn.RPOrigin, Name: cfg.WebAuthn.RPName,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (a *App) Close() {
|
|
a.Usage.Close()
|
|
}
|
|
|
|
// Seed 首次启动初始化:管理员账号 + 默认渠道 + 默认模型。
|
|
func (a *App) Seed() error {
|
|
// 1. 管理员(从环境变量读取,默认 admin/admin123,生产必须改)
|
|
var count int64
|
|
a.DB.Model(&store.User{}).Where("role = ?", store.RoleAdmin).Count(&count)
|
|
if count == 0 {
|
|
password := a.Cfg.AdminPassword
|
|
if password == "" {
|
|
// config.validate 已拒绝缺 OT_ADMIN_PASSWORD 的生产配置;这里再拦一层,
|
|
// 防止绕过 Load 的调用方(测试/工具)带着公开默认密码进非开发环境。
|
|
if a.Cfg.Env != "development" {
|
|
return fmt.Errorf("非 development 环境必须显式设置 OT_ADMIN_PASSWORD,拒绝用默认密码创建管理员")
|
|
}
|
|
password = "admin123"
|
|
log.Printf("seed: using default admin password admin123 — DO NOT expose this instance to the public internet")
|
|
}
|
|
hash, err := a.Hasher.HashPassword(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
admin := store.User{
|
|
Username: envOr("OT_ADMIN_USERNAME", "admin"),
|
|
Email: envOr("OT_ADMIN_EMAIL", "admin@localhost"),
|
|
PasswordHash: hash,
|
|
Role: store.RoleAdmin,
|
|
Balance: 1000, // 初始余额,便于联调;生产由充值/调整决定
|
|
Status: store.UserStatusActive,
|
|
}
|
|
if err := a.DB.Create(&admin).Error; err != nil {
|
|
return err
|
|
}
|
|
log.Printf("seed: created admin user %q (change the default password!)", admin.Username)
|
|
}
|
|
|
|
// 2. 默认渠道(配置了上游 key 时创建)
|
|
if a.Cfg.Proxy.UpstreamKey != "" {
|
|
var chCount int64
|
|
a.DB.Model(&store.Channel{}).Count(&chCount)
|
|
if chCount == 0 {
|
|
enc, err := a.Enc.Encrypt(a.Cfg.Proxy.UpstreamKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ch := store.Channel{
|
|
Name: a.Cfg.Proxy.DefaultChannelName,
|
|
Provider: store.ChannelProviderOpenAI,
|
|
Formats: []string{store.FormatChat, store.FormatResponses},
|
|
BaseURL: a.Cfg.Proxy.UpstreamBaseURL,
|
|
APIKeyEnc: enc,
|
|
Weight: 1,
|
|
Priority: 0,
|
|
TimeoutMS: int(a.Cfg.Proxy.Timeout / time.Millisecond),
|
|
MaxConcurrency: 16,
|
|
HealthStatus: store.ChannelHealthHealthy,
|
|
Enabled: true,
|
|
}
|
|
if err := a.DB.Create(&ch).Error; err != nil {
|
|
return err
|
|
}
|
|
// 默认模型 + 绑定
|
|
m := store.Model{
|
|
Name: a.Cfg.Proxy.DefaultModel,
|
|
InputPrice: 0.15, // 每百万 token,示例价
|
|
OutputPrice: 0.60,
|
|
Enabled: true,
|
|
}
|
|
if err := a.DB.Create(&m).Error; err == nil {
|
|
a.DB.Create(&store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: m.Name})
|
|
}
|
|
log.Printf("seed: created default channel %q (%s)", ch.Name, ch.BaseURL)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Shutdown(ctx context.Context) {
|
|
a.cancel()
|
|
a.Usage.Close()
|
|
if sqlDB, err := a.DB.DB(); err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
_ = ctx
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
v := envLookup(key)
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|