Files
openteam/server/internal/store/db.go
Sakurasan 04dfb4285d feat: 会话亲和粘住缓存渠道;透传 opaque 字段;用量记思考/1h 缓存写/实际模型
会话亲和(proxy/affinity.go):同一会话粘住上次应答的渠道,命中上游提示词
缓存而非被加权随机打散后全价重算。会话身份 = 密钥 + 模型 + 请求文本稳定前缀
摘要,不依赖各客户端会话头;TTL 默认 5m(OT_PROXY_AFFINITY_TTL,0=关闭),
成功应答刷新。亲和渠道必须在候选集内(健康/未冷却/绑定),否则回退加权随机。
proxyOne 增加 served 返回值区分「应答成功」与「已终结但失败」。

opaque 透传(convert):chat↔responses 互转原样携带 metadata、service_tier、
parallel_tool_calls。此前逐字段重建请求体会丢它们,而中转上游会校验/使用。

用量细化(proxy+store):usage_logs 新增 reasoning_tokens(展示口径,不重复
计费)、cache_write_1h_tokens(Anthropic 1h 缓存写按 2× 输入价,此前统一
1.25× 少算 60%)、served_model(上游实际应答模型,映射/聚合商场景对账)。
schema v6→v7。

验证:go vet ./... 干净;go test ./... 与 go test -race ./... 全绿;
新增测试覆盖亲和粘性与冷却回退、opaque 双向透传、reasoning 不重复计费、
1h/5m 缓存写分开计价、served_model 提取(非流式与流式)。
2026-10-30 11:41:51 +08:00

162 lines
5.6 KiB
Go
Raw Permalink 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 不会重建,需删掉重迁一次。
// v7:usage_logs 加 reasoning_tokens / cache_write_1h_tokens / served_model 三列。
const schemaVersion = 7
// 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)
}