- 现状: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 恢复权威产物。
186 lines
7.3 KiB
Go
186 lines
7.3 KiB
Go
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}
|
||
}
|