Files
Cyrene_v2/internal/services/gateway/admin_system_storage.go
T
AskaEth c4e09afd38
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 恢复权威产物。
2026-10-04 19:44:53 +08:00

186 lines
7.3 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 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}
}