M0+M1: 基建 + 用户/密钥/核心代理
后端 (Go/Gin/GORM): - 配置(viper+env)、SQLite/Postgres 迁移、argon2id、AES-GCM 渠道密钥、JWT+refresh cookie - 用户注册/登录/刷新/登出、API Key CRUD(仅存哈希、明文一次展示) - 代理网关: /v1/chat/completions、/v1/responses、/v1/models 直通 OpenAI 渠道 非流式+流式(SSE 零缓冲转发), 用量捕获(chat 末块/responses completed 嵌套), OpenAI 错误格式(401/402/404/502), 余额检查 - 异步批量记账 + 余额流水 + 日聚合, admin 用户/余额/配置 API - 单测: crypto/jwt/apikey/流式 usage 提取 前端 (Vue3+TS+Vite+Tailwind v4): - taste-skill 设计 tokens: 深色仪表盘, 石墨+信号铜色, Outfit+JetBrains Mono - Landing/登录/注册, 控制台(仪表盘图表/密钥管理/用量明细) - 基础组件 Button/Input/Badge/Modal, ECharts 用量图 部署: docker-compose(nginx+api+postgres), 双 Dockerfile, nginx SSE 反代 联调: scripts/mockupstream 本地 mock 上游, 端到端验证通过
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// Package app 应用容器:装配配置、数据库、密码/加密/JWT 与记账器。
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/openteam/server/internal/config"
|
||||
"github.com/openteam/server/internal/pkg/crypto"
|
||||
"github.com/openteam/server/internal/pkg/jwt"
|
||||
"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
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
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.Usage = usage.NewRecorder(db)
|
||||
|
||||
if err := a.Seed(); 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 {
|
||||
hash, err := a.Hasher.HashPassword(envOr("OT_ADMIN_PASSWORD", "admin123"))
|
||||
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,
|
||||
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,
|
||||
DisplayName: 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.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
|
||||
}
|
||||
Reference in New Issue
Block a user