feat(admin): 面板「存储」卡片接真数据(含 PG 增强状态)+ 契约 v0.60
- 现状: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:
1 parent
137b3e9eaf
commit
c4e09afd38
14 files changed
+751
-13
No files matched your search
@@ -0,0 +1,185 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 存储状态**只读**端点(GET /api/v1/admin/system/storage,仅 owner;design/10 §3
|
||||
// 模块 14 系统设置 + design/03 §3 记忆的向量增强)。面板首页的「存储」卡此前
|
||||
// 只能写死「SQLite」(`overviewMetrics.ts` 的占位),主人看不出 PG 增强到底
|
||||
// 有没有生效 —— 这个端点把三件事一起答清楚:主库实际多大、文档表存了多少、
|
||||
// 向量增强现在**跑的是什么**。
|
||||
//
|
||||
// 三条纪律(本文件全部实现都必须遵守):
|
||||
//
|
||||
// 1. **只读**:只有 SELECT / PRAGMA 与 os.Stat —— 不写、不迁移、不备份、
|
||||
// 不建连接池(用组合根注入的活连接,见 Deps.Database)。
|
||||
// 2. **不回显凭据**:向量段只回 kind/enabled/ready;DSN(可能含密码)一个
|
||||
// 字都不回,也因此没有任何「原样回显 DSN」的代码路径。
|
||||
// 3. **不猜**:读不到就回 500 + 明确 code,绝不用 0 假装成功(面板据此
|
||||
// 显示错误,而不是一个看着正常的假数字)。
|
||||
const storageLogSource = "gateway/admin_system_storage"
|
||||
|
||||
// 向量增强的 kind 枚举(契约值,Docs/api-client.md 与前端逐字比对)。
|
||||
const (
|
||||
vectorKindPGVector = "pgvector"
|
||||
vectorKindNone = "none"
|
||||
)
|
||||
|
||||
// storageKindSQLite 是主库段的 kind 取值(当前唯一的主库引擎)。
|
||||
const storageKindSQLite = "sqlite"
|
||||
|
||||
// SQLite WAL 模式的边车文件后缀:`<db>-wal` / `<db>-shm`。
|
||||
const (
|
||||
storageWALSuffix = "-wal"
|
||||
storageSHMSuffix = "-shm"
|
||||
)
|
||||
|
||||
// storageMain 是主库(SQLite)段:文件体积 + 页级统计。页级信息取自
|
||||
// `PRAGMA page_size/page_count/freelist_count`,freeBytes = 空闲页 × 页大小,
|
||||
// 即「已分配但未使用」的空间(VACUUM 能回收的量级)。
|
||||
type storageMain 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"`
|
||||
}
|
||||
|
||||
// storageDocuments 是 `documents` 表段:件数与占用字节(读表,不 walk 目录)。
|
||||
type storageDocuments struct {
|
||||
Count int64 `json:"count"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// storageVector 是向量增强段。kind 说「配了什么」(`pgvector` / `none`),
|
||||
// ready 说「现在真的在跑什么」:配置了 PG URL 但连接失败时 enabled 为 true、
|
||||
// ready 为 false,前端据此显示「已降级」而不是假装增强生效。
|
||||
type storageVector struct {
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
// storageStatus 是响应 data 本体。
|
||||
type storageStatus struct {
|
||||
Main storageMain `json:"main"`
|
||||
Documents storageDocuments `json:"documents"`
|
||||
Vector storageVector `json:"vector"`
|
||||
}
|
||||
|
||||
// adminStorageStatus 处理 GET /api/v1/admin/system/storage。它只读,因此不审计
|
||||
// (与其它管理读端点同档:审计只覆盖动作)。
|
||||
func (s *Server) adminStorageStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(s.dbPath) == "" {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "storage_unavailable", "数据库路径未配置"))
|
||||
return
|
||||
}
|
||||
if s.db == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "storage_unavailable", "数据库连接未配置"))
|
||||
return
|
||||
}
|
||||
st, err := storageStatusOf(r.Context(), s.db, s.dbPath, s.vector)
|
||||
if err != nil {
|
||||
logger.WithSource(s.log, storageLogSource).Error("read storage status", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "storage_unavailable", "读取存储状态失败"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// storageStatusOf 采集一次存储快照。conn 必须是组合根已打开的**活连接**
|
||||
// (Deps.Database)—— 本函数绝不自己开池,也不写任何东西。
|
||||
func storageStatusOf(ctx context.Context, conn *sql.DB, dbPath string, vec VectorState) (storageStatus, error) {
|
||||
main, err := storageMainOf(ctx, conn, dbPath)
|
||||
if err != nil {
|
||||
return storageStatus{}, err
|
||||
}
|
||||
docs, err := storageDocumentsOf(ctx, conn)
|
||||
if err != nil {
|
||||
return storageStatus{}, err
|
||||
}
|
||||
return storageStatus{Main: main, Documents: docs, Vector: vectorStatusOf(vec)}, nil
|
||||
}
|
||||
|
||||
// storageMainOf 读主库的文件体积与页级统计。
|
||||
func storageMainOf(ctx context.Context, conn *sql.DB, dbPath string) (storageMain, error) {
|
||||
var pageSize, pageCount, freelistPages int64
|
||||
// 三个 pragma_* 表值函数在一条 SELECT 里取完,避免三次往返。
|
||||
const pageStats = `SELECT
|
||||
(SELECT page_size FROM pragma_page_size),
|
||||
(SELECT page_count FROM pragma_page_count),
|
||||
(SELECT freelist_count FROM pragma_freelist_count)`
|
||||
if err := conn.QueryRowContext(ctx, pageStats).Scan(&pageSize, &pageCount, &freelistPages); err != nil {
|
||||
return storageMain{}, fmt.Errorf("read sqlite page stats: %w", err)
|
||||
}
|
||||
fileBytes, err := storageFileBytes(dbPath)
|
||||
if err != nil {
|
||||
return storageMain{}, err
|
||||
}
|
||||
return storageMain{
|
||||
Kind: storageKindSQLite,
|
||||
Path: dbPath,
|
||||
FileBytes: fileBytes,
|
||||
WALBytes: storageSidecarBytes(dbPath + storageWALSuffix),
|
||||
SHMBytes: storageSidecarBytes(dbPath + storageSHMSuffix),
|
||||
PageSize: pageSize,
|
||||
PageCount: pageCount,
|
||||
FreelistPages: freelistPages,
|
||||
FreeBytes: pageSize * freelistPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// storageFileBytes 返回主库文件的字节数。文件不存在是**硬错误**:组合根已经
|
||||
// 打开了这条路径,stat 不到说明报出来的 path 与实际在用的文件不是同一份,
|
||||
// 这时给出一个 0 会让面板显示一个看着正常的假体积。
|
||||
func storageFileBytes(path string) (int64, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("stat database file: %w", err)
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// storageSidecarBytes 返回 WAL/SHM 边车文件的字节数。两者可以合法地不存在
|
||||
// (已 checkpoint 过、或库根本没开 WAL),此时如实地算 0 字节。
|
||||
func storageSidecarBytes(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// storageDocumentsOf 读 `documents` 表的件数与占用。空表时 SUM(size) 为 NULL,
|
||||
// 用 COALESCE 收敛成 0;表不存在(迁移没跑过)则直接报错,不静默报 0。
|
||||
func storageDocumentsOf(ctx context.Context, conn *sql.DB) (storageDocuments, error) {
|
||||
const docsStats = `SELECT COUNT(*), COALESCE(SUM(size), 0) FROM documents`
|
||||
var docs storageDocuments
|
||||
if err := conn.QueryRowContext(ctx, docsStats).Scan(&docs.Count, &docs.Bytes); err != nil {
|
||||
return storageDocuments{}, fmt.Errorf("read documents stats: %w", err)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
// vectorStatusOf 把组合根注入的运行时状态翻成契约形状:kind = 配了什么、
|
||||
// enabled = 开关在不在、ready = 现在真的跑的是什么。未配置时三者收成
|
||||
// `none`(前端显示「未启用(未配置 DB_PG_URL)」)。
|
||||
func vectorStatusOf(vec VectorState) storageVector {
|
||||
if !vec.Configured {
|
||||
return storageVector{Kind: vectorKindNone}
|
||||
}
|
||||
return storageVector{Kind: vectorKindPGVector, Enabled: true, Ready: vec.Live}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -10,6 +11,23 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// VectorState 是组合根报告的向量增强(pgvector)运行状态,供管理侧
|
||||
// 「存储状态」端点回答「PG 到底有没有生效」(design/03 §3)。
|
||||
//
|
||||
// 它是**复用**而非探测:网关不读环境变量、不新开 PG 连接、不
|
||||
// 自行 ping —— 真相只有组合根知道(DB_PG_URL 是否配置 + 活的向量
|
||||
// 索引是哪个后端),因此由它注入,避免面板看到与运行时不一致的
|
||||
// 第二个判断。
|
||||
type VectorState struct {
|
||||
// Configured 报告 DB_PG_URL 是否已配置(非空即算),即
|
||||
// 增强开关在不在。
|
||||
Configured bool
|
||||
// Live 报告当前活的向量索引确实是 PostgreSQL+pgvector 后端。
|
||||
// 配置了但连接/schema 准备失败时为 false —— 此时语义检索已降级,
|
||||
// 面板必须显示「未就绪」而不是假装增强生效。
|
||||
Live bool
|
||||
}
|
||||
|
||||
// Deps 承载网关被注入的依赖。除 Auth 外每个字段都容忍
|
||||
// nil,Auth 是必需的(认证支撑登录与每一个 RBAC
|
||||
// 决策)。Registry/Loop 由具体的 *capability.Registry 与
|
||||
@@ -91,6 +109,16 @@ type Deps struct {
|
||||
// 图片上传存储(<DataDir>/uploads)及其静态路由。可选;
|
||||
// 为空时上传 handler 回退到 cfg.DataDir。
|
||||
DataDir string
|
||||
// Database 是组合根已打开的活 SQLite 连接池。管理侧的
|
||||
// 「存储状态」只读端点用它读页级统计与 documents 表 ——
|
||||
// 复用既有池,端点自身不另开连接或连接池。可选;为 nil 时
|
||||
// 该端点诚实作答(500 storage_unavailable),绝不假装
|
||||
// 拿到了数据。
|
||||
Database *sql.DB
|
||||
// Vector 是组合根报告的向量增强(pgvector)运行时状态,
|
||||
// 见 VectorState。网关只读它,绝不自行探测。可选;零值
|
||||
// 表示「未配置」(kind:"none"),与未启用增强的部署一致。
|
||||
Vector VectorState
|
||||
// Streaming 标记注入的 Loop 具备流式能力(其 OnStream
|
||||
// 已接线为发布 ws.stream_chunk)。设置后 hub 会跳过它自己的
|
||||
// 单次最终 stream_chunk 发送,因为增量已通过 OnStream 流出。
|
||||
|
||||
@@ -227,6 +227,9 @@ func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(htt
|
||||
mux.Handle("POST /api/v1/admin/system/rotate-jwt", ownerAuth(http.HandlerFunc(s.adminRotateJWT)))
|
||||
mux.Handle("POST /api/v1/admin/system/backup", ownerAuth(http.HandlerFunc(s.adminBackup)))
|
||||
mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore)))
|
||||
// 存储状态**只读**查询(面板首页「存储」卡的数据源,v0.60):只读、不审计,
|
||||
// 因此与四个动作端点同门(owner)但不带审计动作。
|
||||
mux.Handle("GET /api/v1/admin/system/storage", ownerAuth(http.HandlerFunc(s.adminStorageStatus)))
|
||||
}
|
||||
|
||||
// mountWebhooks 挂载外部提供的入站回调(例如 webhook
|
||||
|
||||
@@ -20,6 +20,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -79,6 +80,13 @@ type Server struct {
|
||||
// (尽力而为)。见 Deps.OwnerRegistrar。
|
||||
ownerRegistrar OwnerRegistrar
|
||||
webhooks map[string]http.Handler
|
||||
// db 是组合根注入的活 SQLite 连接池(见 Deps.Database)。管理侧
|
||||
// 「存储状态」只读端点用它读页级统计与 documents 表;为 nil 时该
|
||||
// 端点回 500 storage_unavailable(不另开池、不假装有数据)。
|
||||
db *sql.DB
|
||||
// vector 是组合根报告的 pgvector 增强运行时状态(见
|
||||
// Deps.Vector / VectorState)。零值 = 未配置。
|
||||
vector VectorState
|
||||
// envResolver 解析会话每轮的 loop 环境(其角色)。见
|
||||
// persona_env.go / design/22 §4.5。
|
||||
envResolver turnEnvResolver
|
||||
@@ -124,6 +132,8 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
webhooks: deps.Webhooks,
|
||||
dbPath: deps.DatabasePath,
|
||||
dataDir: deps.DataDir,
|
||||
db: deps.Database,
|
||||
vector: deps.Vector,
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
|
||||
|
||||
Reference in new issue
Block a user