feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动)
- 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底),
  队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断)
- 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id
- 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、
  超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;
  绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable)
- LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时
- 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64
- 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
This commit is contained in:
AskaEth committed 2026-10-04 22:16:26 +08:00
1 parent 18381f65fd
commit be38651ba4
32 files changed
+2233 -44

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
package main
import (
"context"
"database/sql"
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住用量落库与保留期清理**被接上组合根**(切片 U1;与
// TestWireDocumentGC_IsWired 同一手法):
//
// - sink 必须装进模型栈,且模型栈必须由组合根构造 —— 少任一处,落库都会
// **静默不工作**(日志照记、库里一行没有),而这不会让任何既有用例变红;
// - 写入器与清理器必须在**关库之前**停掉(两者都要用那条连接池);
// - 启动期必须真的把两者拉起来(`a.startUsage(ctx)`);
// - 保留期 ≤0 = 关闭清理,且关闭是**显式**的(记一条 INFO)。
func TestWireUsage_IsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
"a.usage = a.newUsageSink()",
"model.WithRuntimeUsageSink(a.usage)",
},
"wire_usage.go": {
"model.NewUsageWriter(a.db, a.log)",
"a.cfg.Server.LLMUsageRetention",
"model.PurgeUsage(ctx, g.db, before)",
"a.log.Info(\"llm usage retention disabled\"",
},
"wire_services.go": {
// 先让单写者把队列里剩下的样本写完,再停清理,最后才关库。
"a.stopUsage()",
},
"main.go": {
"a.startUsage(ctx)",
},
"wire_frontend.go": {
// 管理端点的只读汇总出口(网关复用组合根已打开的连接池)。
"Usage: model.NewUsageReader(a.db)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 用量落库没有被接上组合根", file, want)
}
}
}
// 新 env 键必须与代码里的 env 标签逐字一致(AGENTS §10 铁律)。
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
if !strings.Contains(string(raw), "LLM_USAGE_RETENTION=") {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", "LLM_USAGE_RETENTION=")
}
if tag := envTagOf(t, "LLMUsageRetention"); tag != "LLM_USAGE_RETENTION" {
t.Fatalf("config.ServerConfig.LLMUsageRetention 的 env 标签 = %q, 与模板不一致", tag)
}
}
// envTagOf 返回 config.ServerConfig 上某字段的 env 标签。
func envTagOf(t *testing.T, field string) string {
t.Helper()
typ, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName(field)
if !ok {
t.Fatalf("config.ServerConfig 里没有字段 %q", field)
}
return typ.Tag.Get("env")
}
// usageGCDB 打开一个带迁移的内存库。
func usageGCDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", slog.New(slog.NewTextHandler(os.Stderr, nil)))
if err != nil {
t.Fatalf("open in-memory db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.MigrateUp(ctx, conn, slog.New(slog.NewTextHandler(os.Stderr, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
return conn
}
// usageGCApp 构造一个只带清理器所需依赖的 app。
func usageGCApp(t *testing.T, conn *sql.DB, retention time.Duration) *app {
t.Helper()
return &app{
cfg: &config.Config{Server: config.ServerConfig{LLMUsageRetention: retention}},
db: conn,
log: slog.New(slog.NewTextHandler(os.Stderr, nil)),
}
}
// TestUsageRetentionGC_SweepRemovesExpiredOnly 钉住清理判据:早于保留期的样本被
// 删、保留期内的原样留着。
func TestUsageRetentionGC_SweepRemovesExpiredOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := usageGCDB(t)
insertUsageAt(t, conn, "2020-01-01T00:00:00.000Z")
insertUsageAt(t, conn, "2099-01-01T00:00:00.000Z")
gc := usageGCApp(t, conn, 720*time.Hour).newUsageRetentionGC()
if gc == nil {
t.Fatal("保留期 > 0 时必须构造清理器")
}
gc.sweep(ctx)
if rows := countUsage(t, conn); rows != 1 {
t.Fatalf("清理后剩 %d 行, want 1(只删过期那条)", rows)
}
var remaining string
if err := conn.QueryRowContext(ctx, `SELECT created_at FROM llm_usage`).Scan(&remaining); err != nil {
t.Fatalf("read remaining: %v", err)
}
if remaining != "2099-01-01T00:00:00.000Z" {
t.Fatalf("剩下的是 %q, want 未过期那条", remaining)
}
}
// TestUsageRetentionGC_DisabledWhenNonPositive 钉住「≤0 = 关闭清理」是显式关:
// 0 与负值都不构造清理器(因而一行都不会被删),正数才构造。
func TestUsageRetentionGC_DisabledWhenNonPositive(t *testing.T) {
t.Parallel()
conn := usageGCDB(t)
for _, retention := range []time.Duration{0, -time.Hour} {
if gc := usageGCApp(t, conn, retention).newUsageRetentionGC(); gc != nil {
t.Fatalf("LLM_USAGE_RETENTION=%s 时不该构造清理器", retention)
}
}
// 关闭状态下过期行原样留着(没有任何代码路径去删它)。
insertUsageAt(t, conn, "2020-01-01T00:00:00.000Z")
if rows := countUsage(t, conn); rows != 1 {
t.Fatalf("关闭清理却动了数据:剩 %d 行", rows)
}
if gc := usageGCApp(t, conn, time.Hour).newUsageRetentionGC(); gc == nil {
t.Fatal("LLM_USAGE_RETENTION=1h 时必须构造清理器")
}
}
// TestUsageWriter_NilDBMeansNoSink 钉住「库未接 ⇒ sink 为 nil ⇒ 只记日志」的
// 组合根口径(与 model 层的 nil-sink 行为合成一条链)。
func TestUsageWriter_NilDBMeansNoSink(t *testing.T) {
t.Parallel()
a := &app{log: slog.New(slog.NewTextHandler(os.Stderr, nil))}
if sink := a.newUsageSink(); sink != nil {
t.Fatalf("库未接时 newUsageSink 必须返回 nil,实得 %#v", sink)
}
if gc := a.newUsageRetentionGC(); gc != nil {
t.Fatal("库未接时不该构造清理器")
}
// 未注入 sink 的模型栈照常构造(不 panic),且模型栈不因 nil sink 变红。
stack := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"),
config.LLMConfig{}, a.log)
if stack == nil {
t.Fatal("nil sink 不该影响模型栈构造")
}
}
// insertUsageAt 写一行带指定 created_at 的用量样本。
func insertUsageAt(t *testing.T, conn *sql.DB, createdAt string) {
t.Helper()
if _, err := conn.ExecContext(context.Background(),
`INSERT INTO llm_usage (purpose, adapter, model, prompt_tokens, created_at)
VALUES ('chat', 'openai', 'm', 1, ?)`, createdAt); err != nil {
t.Fatalf("insert usage row: %v", err)
}
}
// countUsage 返回 llm_usage 行数。
func countUsage(t *testing.T, conn *sql.DB) int64 {
t.Helper()
var rows int64
if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM llm_usage`).Scan(&rows); err != nil {
t.Fatalf("count llm_usage: %v", err)
}
return rows
}