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 模式的边车文件后缀:`-wal` / `-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} }