Files
openteam/server/internal/app/app.go
T
Sakurasan b2e500ff51 feat: 金额 decimal 精确计费、failover 按失败类型封顶、渠道小时桶预聚合、分层守护测试
- 计费:19 个金额字段 float64→decimal.Decimal,DB 列保持 numeric(20,8) 零迁移;
  API 金额仍为 JSON number(MarshalJSONWithoutQuotes),前端零改动
- failover:保留时间预算,新增 OT_PROXY_FAILOVER_CAPS_NET/SERVER/RATE/QUOTA
  按失败类型封顶换渠道次数(429/配额类瞬时失败时间预算形同虚设)
- 预聚合:新增 usage_channel_hourlies(schema v10),记账事务内同步 upsert,
  长窗口渠道统计走桶不再扫明细,spent_last_30d 改扫 usage_daily,v10 一次性回填幂等
- 守护:internal/arch_test.go 用 go/parser 强制分层依赖(fail-closed)
2026-11-09 00:41:48 +08:00

185 lines
5.8 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 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"
"github.com/shopspring/decimal"
"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
// usageRetention 用量明细保留期任务(未开启时为 no-op)
usageRetention func()
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()
// 开发环境不拦空/占位主密钥,但必须让人看见:空主密钥退化为 sha256(""),
// 任何能读到数据库的人都能解出全部上游 key。这类实例后续补上 OT_MASTER_KEY
// 会直接变成「全部渠道解密失败」,所以一并给出迁移命令。
if reason := cfg.InsecureMasterKey(); reason != "" {
log.Printf("WARN: OT_MASTER_KEY %s;任何能读到数据库的人都能解出上游 key。"+
"配置真实主密钥后执行 `openteam rotate-master-key` 迁移已有渠道(无需重填 key)", reason)
}
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)
// 用量明细保留期(默认 0=永久保留,只清明细不动余额流水)
a.usageRetention = usage.StartRetention(a.ctx, db, cfg.Usage.RetentionDays)
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() {
if a.usageRetention != nil {
a.usageRetention()
}
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: decimal.NewFromInt(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: decimal.NewFromFloat(0.15), // 每百万 token,示例价
OutputPrice: decimal.NewFromFloat(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
}