Files
openteam/server/internal/store/db.go
T
Sakurasan 1f8af649ca fix: 收尾 P2——登录恒定耗时、AdminUsage N+1、用量索引重塑与时区缝
- 登录失败路径补 argon2 恒定耗时校验(用户不存在/被禁用与密码错误的时序不可区分,抹掉用户名枚举面)
- AdminUsage 每行 2 次查询改批量预取(100 行分页省 200 次查询)
- usage_logs 补 key_id 索引与 (user,created)/(channel,created) 复合索引(schema v6);AutoMigrate 按名跳过已存在索引,旧库的复合索引需一次性删掉重建,用 system_configs 打标记防 PG 每次启动重演
- 用量统计默认窗口改 UTC(usage_daily 的 date 键是 UTC,非 UTC 服务器上图表窗口偏移一天)
- 注册 email 大小写归一;并发撞唯一索引回查后返回 409 而非 500
- PatchKey 的 name 校验与 CreateKey 对齐(1-64 字符),PATCH 不再能绕过
- TrendChart 条宽不超过槽位(点数多时不再互相重叠),超过 32 个点隐藏标签行
2026-10-29 03:05:56 +08:00

161 lines
5.5 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 store
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// schemaVersion 当前 schema 版本。struct 变更(加列/改列/删列)时递增,
// 触发一次 AutoMigrate 并把新版本写入库(SQLite 用 PRAGMA user_version)。
// AutoMigrate 对已有表的列判定不收敛(每次都重建表:CREATE __temp + INSERT SELECT + DROP),
// 大表上一次重建数十秒且每次重启重演,所以之后版本未变就直接跳过。
//
// 注意:c39c3e7 给 Model 加了 cache_write_price/context_length/supports_tools/
// supports_vision/supports_reasoning 等列并删掉了 display_name,但当时漏改这里,
// 导致已建库的实例永远跳过重迁移、新增模型时 Create 因缺列而失败。这里补到 3 触发一次迁移。
// v4:users 加 token_version(会话撤销)。
// v5:usage_logs 补 key_id/channel_id 索引与 (user,created)/(channel,created) 复合索引。
// v6:修正 v5 复合索引列序——AutoMigrate 按名跳过已存在的索引,旧库上的
// idx_user_created 是 user_id 单列,光改 struct tag 不会重建,需删掉重迁一次。
const schemaVersion = 6
// Open 打开数据库连接并自动迁移。
// 开发默认 SQLite(自动追加 WAL 与 busy_timeout,见 withSQLitePragmas),生产可切 postgres。
func Open(driver, dsn string) (*gorm.DB, error) {
var dialector gorm.Dialector
switch driver {
case "postgres":
dialector = postgresDialector(dsn)
default:
dsn = withSQLitePragmas(dsn)
// 确保 SQLite 文件所在目录存在
if dir := sqliteDir(dsn); dir != "" {
_ = os.MkdirAll(dir, 0o755)
}
dialector = sqlite.Open(dsn)
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
})
if err != nil {
return nil, err
}
configurePool(db, driver)
if driver != "postgres" && currentSQLiteVersion(db) >= schemaVersion {
log.Printf("store: connected driver=%s (schema up-to-date v%d, skip migrate)", driver, schemaVersion)
return db, nil
}
if err := db.AutoMigrate(AllModels()...); err != nil {
return nil, err
}
reshapeUsageLogIndexes(db)
if driver != "postgres" {
setSQLiteVersion(db, schemaVersion)
}
log.Printf("store: connected driver=%s (migrated, schema v%d)", driver, schemaVersion)
// 将已有渠道的超时时间从 120000ms 更新为 300000ms(幂等操作)
db.Model(&Channel{}).Where("timeout_ms = ?", 120000).Update("timeout_ms", 300000)
return db, nil
}
// reshapeUsageLogIndexes 一次性重建 usage_logs 的复合索引:AutoMigrate 按名跳过已存在的
// 索引,历史库上的 idx_user_created 是 user_id 单列、idx_channel_created 形状同样不对,
// struct tag 改了也不会生效。删掉后紧接的 AutoMigrate 按新定义重建。
// 用 system_configs 打标记:PG 每次启动都走迁移路径,没有标记会反复重建索引。
func reshapeUsageLogIndexes(db *gorm.DB) {
var done string
db.Model(&SystemConfig{}).Where("key = ?", "index_reshape_v6").Pluck("value", &done)
if done != "" {
return
}
for _, name := range []string{"idx_user_created", "idx_channel_created"} {
if db.Migrator().HasIndex(&UsageLog{}, name) {
if err := db.Migrator().DropIndex(&UsageLog{}, name); err != nil {
log.Printf("store: drop legacy index %s: %v", name, err)
return
}
}
}
if err := db.AutoMigrate(&UsageLog{}); err != nil {
log.Printf("store: recreate usage_logs indexes: %v", err)
return
}
db.Create(&SystemConfig{Key: "index_reshape_v6", Value: `"done"`})
log.Printf("store: usage_logs indexes reshaped (user+created / channel+created)")
}
// withSQLitePragmas 给 DSN 补上 busy_timeout 与 WAL。
// glebarez(modernc)驱动只解析 _pragma/_txlock/_time_format/vfs 四种参数——
// 旧注释宣称的 `?_journal_mode=WAL` 实际不生效,缺 WAL 时并发写会频繁 database is locked。
// 用户 DSN 里已显式声明的项不覆盖。
func withSQLitePragmas(dsn string) string {
for _, p := range []string{"_pragma=busy_timeout(10000)", "_pragma=journal_mode(WAL)"} {
if strings.Contains(dsn, p) {
continue
}
if strings.Contains(dsn, "?") {
dsn += "&" + p
} else {
dsn += "?" + p
}
}
return dsn
}
// configurePool 连接池上限。此前全仓无任何配置:PG 连接数无上限,
// SQLite 多连接并发写叠加无 WAL 加剧锁竞争。
func configurePool(db *gorm.DB, driver string) {
sqlDB, err := db.DB()
if err != nil {
return
}
if driver == "postgres" {
sqlDB.SetMaxOpenConns(30)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
return
}
// SQLite:WAL 下读写可并发,写仍串行——限连接数让写竞争在 busy_timeout 内排队
sqlDB.SetMaxOpenConns(8)
sqlDB.SetConnMaxIdleTime(10 * time.Minute)
}
// currentSQLiteVersion 读取 PRAGMA user_version。
func currentSQLiteVersion(db *gorm.DB) int {
var v int
db.Raw("PRAGMA user_version").Scan(&v)
return v
}
// setSQLiteVersion 写入 PRAGMA user_version。
func setSQLiteVersion(db *gorm.DB, v int) {
db.Exec(fmt.Sprintf("PRAGMA user_version = %d", v))
}
// sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。
func sqliteDir(dsn string) string {
d := dsn
if i := strings.IndexByte(d, '?'); i >= 0 {
d = d[:i]
}
if strings.HasPrefix(d, "file:") {
d = d[len("file:"):]
}
if d == "" || d == ":memory:" || strings.Contains(d, "::") {
return ""
}
return filepath.Dir(d)
}