feat(admin): 面板「存储」卡片接真数据(含 PG 增强状态)+ 契约 v0.60
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 现状:overviewMetrics.ts:188 的「存储」卡是写死的 value:"SQLite",后端也没有对应读端点(/admin/system/* 只有 revoke-keys/rotate-jwt/backup/restore 四个动作端点)。

- 新增只读端点 GET /api/v1/admin/system/storage(ownerAuth):main{kind,path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes} + documents{count,bytes} + vector{kind,enabled,ready};只读(SELECT/PRAGMA + os.Stat)、复用活连接不开新池、**绝不含凭据**(只回 kind,不回 host/DSN)、失败一律 500 storage_unavailable(不回全 0 假快照;表缺失也 500 而不是静默 0)。

- vector 真源 = 组合根 app.vectorState()(Configured = DB_PG_URL 非空;Live = 向量索引类型断言成功),零新探测 ⇒ 面板现在能区分「未启用(未配置 DB_PG_URL)/ 启用(pgvector·ready)/ 已配置但未就绪(连接失败,检索降级)」,正好解答主人对「面板只写 SQLite」的困惑。

- 前端:卡片显示真实体积 + WAL/页数/空闲页 + documents 件数与占用 + PG 那一行;取数走与已接线卡片同一条加载/错误态(失败显示 '—' + 端点报错,不静默显示 0)。

- 契约 v0.60(项目红线):api-client(版本段 + 端点表 + 存储状态契约小节 + changelog)与 api-reference(版本号 + 端点表 + 详情小节)同步;字段名三方(代码/Docs/前端)逐字对齐。

- 主会话独立验收:隔离 worktree(ST1 的 14 文件,排除 B1 在途的 chat_documents.go/documents_pdf_test.go)build/vet 绿、全树 59 包 0 FAIL、存储端点 7 用例 PASS、双平台交叉编译绿。

- ⚠️ 该切片自首一起事故:为通过 cmd/cyrene 类型检查跑了 go build ./cmd/cyrene/(单 main 包)⇒ 覆盖了仓库根 cyrene.exe(49,675,264 B / 19:32:19,脏树未 strip);上一个权威产物仍在 cyrene.exe~(39,507,968 B / 19:25:13)。运行中的 PID 477964 未受影响;主会话将在整树干净后重跑 ctrl.bat build 恢复权威产物。
This commit is contained in:
AskaEth committed 2026-10-04 19:44:53 +08:00
1 parent 137b3e9eaf
commit c4e09afd38
14 files changed
+751 -13

No files matched your search

@@ -0,0 +1,311 @@
package gateway
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// storageTestDB 在 dir 下打开一个真实 SQLite 文件库、跑迁移,并按 sizes 插入
// 等量的 documents 行(每行 size 取该元素值),返回路径与**仍打开**的活连接
// —— 存储状态端点用的就是这条连接(Deps.Database)。
func storageTestDB(t *testing.T, dir string, sizes []int64) (string, *sql.DB) {
t.Helper()
ctx := context.Background()
path := filepath.Join(dir, "cyrene.db")
conn, err := db.Open(ctx, path, gwTestLogger())
if err != nil {
t.Fatalf("open file db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
t.Fatalf("migrate: %v", err)
}
for i, size := range sizes {
name := "doc-" + string(rune('a'+i)) + ".txt"
if _, err := conn.ExecContext(ctx,
`INSERT INTO documents (name, sha256, mime, size) VALUES (?, ?, ?, ?)`,
name, strings.Repeat("a", 64), "text/plain", size); err != nil {
t.Fatalf("insert document %d: %v", i, err)
}
}
return path, conn
}
// storageStatusDTO 是端点的响应形状(与 internal/services/gateway/admin_system_storage.go
// 的字段逐字对齐);测试用它反解,字段名写错就会解不出来。
type storageStatusDTO struct {
Main struct {
Kind string `json:"kind"`
Path string `json:"path"`
FileBytes int64 `json:"fileBytes"`
WALBytes int64 `json:"walBytes"`
SHMBytes int64 `json:"shmBytes"`
PageSize int64 `json:"pageSize"`
PageCount int64 `json:"pageCount"`
FreelistPages int64 `json:"freelistPages"`
FreeBytes int64 `json:"freeBytes"`
} `json:"main"`
Documents struct {
Count int64 `json:"count"`
Bytes int64 `json:"bytes"`
} `json:"documents"`
Vector struct {
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Ready bool `json:"ready"`
} `json:"vector"`
}
// getStorageStatus 以一个 owner token 取一次存储状态,返回状态码与原始响应体。
func getStorageStatus(t *testing.T, url string, jwt *auth.JWTService) (int, []byte) {
t.Helper()
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, url+"/api/v1/admin/system/storage", nil, tok)
return resp.StatusCode, body
}
func decodeStorageStatus(t *testing.T, body []byte) storageStatusDTO {
t.Helper()
var st storageStatusDTO
if err := json.Unmarshal(decodeBody(t, body)["data"], &st); err != nil {
t.Fatalf("decode storage status: %v (body=%s)", err, body)
}
return st
}
// --- GET /api/v1/admin/system/storage(存储状态只读端点) --------------------
func TestAdminStorageStatusReportsRealValues(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, []int64{11, 22, 33})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath, Database: conn})
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
st := decodeStorageStatus(t, body)
if st.Main.Kind != "sqlite" {
t.Fatalf("main.kind = %q, want sqlite", st.Main.Kind)
}
if st.Main.Path != dbPath {
t.Fatalf("main.path = %q, want %q", st.Main.Path, dbPath)
}
if st.Main.PageSize <= 0 || st.Main.PageCount <= 0 {
t.Fatalf("page stats = (%d, %d), want both > 0", st.Main.PageSize, st.Main.PageCount)
}
info, err := os.Stat(dbPath)
if err != nil {
t.Fatalf("stat db: %v", err)
}
if st.Main.FileBytes != info.Size() {
t.Fatalf("main.fileBytes = %d, want stat size %d", st.Main.FileBytes, info.Size())
}
if st.Main.FreeBytes != st.Main.PageSize*st.Main.FreelistPages {
t.Fatalf("main.freeBytes = %d, want pageSize*freelistPages = %d",
st.Main.FreeBytes, st.Main.PageSize*st.Main.FreelistPages)
}
if st.Main.WALBytes < 0 || st.Main.SHMBytes < 0 {
t.Fatalf("sidecar bytes negative: wal=%d shm=%d", st.Main.WALBytes, st.Main.SHMBytes)
}
// documents 段必须与插入的行逐字对上(把 count 写死 0 的变异在这里变红)。
if st.Documents.Count != 3 || st.Documents.Bytes != 66 {
t.Fatalf("documents = %+v, want count 3 bytes 66", st.Documents)
}
// 未配置 PG ⇒ 如实报 none,且不假装 ready。
if st.Vector.Kind != "none" || st.Vector.Enabled || st.Vector.Ready {
t.Fatalf("vector = %+v, want {none false false}", st.Vector)
}
}
func TestAdminStorageStatusUnauthenticated(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, nil)
_, ts, _ := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath, Database: conn})
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/system/storage", nil, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (body=%s)", resp.StatusCode, body)
}
wantErrorShape(t, body)
}
// TestAdminStorageStatusHidesCredentials 钉住红线:PG URL(可能含密码)绝不
// 出现在响应体里;只回 kind/enabled/ready。
func TestAdminStorageStatusHidesCredentials(t *testing.T) {
t.Parallel()
const dsn = "postgres://cyrene_user:s3cret-pw@db.internal:5432/cyrene?sslmode=disable"
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, nil)
cfg := cfgDefault()
cfg.DB.PGURL = dsn
_, ts, jwt := buildEnv(t, cfg, Deps{
DatabasePath: dbPath,
Database: conn,
Vector: VectorState{Configured: true, Live: false},
})
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
for _, secret := range []string{dsn, "s3cret-pw", "postgres://", "cyrene_user", "db.internal", "sslmode"} {
if strings.Contains(string(body), secret) {
t.Fatalf("response body leaks %q: %s", secret, body)
}
}
st := decodeStorageStatus(t, body)
// 配置了但索引没跑起来 ⇒ enabled 为真、ready 为假(面板显示「已降级」)。
if st.Vector.Kind != "pgvector" || !st.Vector.Enabled || st.Vector.Ready {
t.Fatalf("vector = %+v, want {pgvector true false}", st.Vector)
}
}
func TestAdminStorageStatusVectorReady(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, nil)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{
DatabasePath: dbPath,
Database: conn,
Vector: VectorState{Configured: true, Live: true},
})
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
st := decodeStorageStatus(t, body)
if st.Vector.Kind != "pgvector" || !st.Vector.Enabled || !st.Vector.Ready {
t.Fatalf("vector = %+v, want {pgvector true true}", st.Vector)
}
}
// TestAdminStorageStatusUnwired 覆盖三种「没接线」:路径空 / 连接空 / 两者都空。
// 一律 500 + storage_unavailable,绝不回一个全 0 的假快照。
func TestAdminStorageStatusUnwired(t *testing.T) {
t.Parallel()
dir := t.TempDir()
barePath, conn := storageBareDB(t, dir, "unwired.db")
cases := []struct {
name string
deps Deps
}{
{"no path no conn", Deps{}},
{"conn but no path", Deps{Database: conn}},
{"path but no conn", Deps{DatabasePath: barePath}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, ts, jwt := buildEnv(t, cfgDefault(), tc.deps)
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", status, body)
}
wantErrorShape(t, body)
var env struct {
Code string `json:"code"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &env); err != nil {
t.Fatalf("decode error envelope: %v", err)
}
if env.Code != "storage_unavailable" {
t.Fatalf("error code = %q, want storage_unavailable", env.Code)
}
})
}
}
// TestAdminStorageStatusMissingDocumentsTable 钉住「不静默报 0」:库没跑迁移
// (documents 表不存在)时如实 500,而不是回 documents.count = 0。
func TestAdminStorageStatusMissingDocumentsTable(t *testing.T) {
t.Parallel()
dir := t.TempDir()
barePath, conn := storageBareDB(t, dir, "bare.db")
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: barePath, Database: conn})
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", status, body)
}
wantErrorShape(t, body)
}
// TestAdminStorageStatusIsReadOnly 钉住「只读」:查询前后 documents 行数与数据
// 目录里的文件集合都不变(既没有写、也没有顺手备份)。
func TestAdminStorageStatusIsReadOnly(t *testing.T) {
t.Parallel()
dir := t.TempDir()
dbPath, conn := storageTestDB(t, dir, []int64{5, 7})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath, Database: conn})
before := listDir(t, dir)
status, body := getStorageStatus(t, ts.URL, jwt)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
var count int64
if err := conn.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM documents`).Scan(&count); err != nil {
t.Fatalf("count documents: %v", err)
}
if count != 2 {
t.Fatalf("documents rows = %d, want 2 (endpoint wrote something)", count)
}
after := listDir(t, dir)
if !sameFileSet(before, after) {
t.Fatalf("data dir changed: before=%v after=%v", before, after)
}
}
// storageBareDB 打开 dir/name 的 SQLite 文件库但**不跑迁移**(用于演练
// 「表不存在」「连接未接线」这两类边界)。
func storageBareDB(t *testing.T, dir, name string) (string, *sql.DB) {
t.Helper()
path := filepath.Join(dir, name)
conn, err := db.Open(context.Background(), path, gwTestLogger())
if err != nil {
t.Fatalf("open bare db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return path, conn
}
// listDir 返回 dir 下的文件名集合,用于「端点没动文件系统」的断言。
func listDir(t *testing.T, dir string) map[string]bool {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read dir: %v", err)
}
names := make(map[string]bool, len(entries))
for _, e := range entries {
names[e.Name()] = true
}
return names
}
// sameFileSet 比较两个文件名集合(与顺序无关)。
func sameFileSet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for name := range a {
if !b[name] {
return false
}
}
return true
}