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
+34
-1
@@ -195,7 +195,7 @@
|
||||
|
||||
> **发现的不一致(如实登记)**:design/24 §9.2 与本题面任务书写「白名单 **23** 项」,但设计所列清单与代码 `documentExtAllowed` 均为 **22** 项(见上表)——**以代码的 22 项为准**。另:design/24 §6.2 的 `document_unreadable`(400) **在代码里没有对应 REST 错误码**,它只以 `document-read` 的工具错误形态存在(见本节 ⑤)。
|
||||
|
||||
> **契约版本 `v0.59`**(**文档进对话(`F1/F2`)+ 上下文预算按窗口推导(`T1`)+ 语音回合进编排(`G5/C1/C3`)+ `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5/C1/C3,commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实):**A. 文档存储与摄取(两个新端点 + 一个可选请求字段 = 纯增量;DTO 形状变更是破坏性,但单数形状从未发布,故无兼容期)**——① **新增 `POST /api/v1/documents`**(owner|admin,`multipart/form-data`,字段 `file`)→ 首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(客户端可当成功处理);单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB = 20971520)**,`≤0` 视为用默认;它与图片上限 `CHAT_IMAGE_MAX_BYTES` **刻意解耦**(改文档上限不会静默改掉图片行为)。② **新增 `GET /api/v1/documents/{name}`**:**必须带 Bearer(owner|admin)**——与图片读侧(免鉴权的能力 URL)**刻意相反**(文档摄取在服务端完成,不需要被 `<img>`/provider 直接消费),响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`(浏览器永不内联渲染)。③ **类型裁决(重要)**:**字节嗅探是唯一裁决者**(`http.DetectContentType`,放行的嗅探基类型只有 `text/plain`),**客户端声明的 `Content-Type` 与原始文件名只作参考、可被嗅探否决**;**扩展名白名单 deny-by-default,实际 **22** 个**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`(⚠️ design/24 §9.2 写「23 项」但所列清单与代码均为 **22** 项,**以代码 22 为准**,见 §1.2「发现的不一致」);**明令拒绝** `text/html`、SVG/XML、zip 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)——即便 `.js`/`.py` 嗅探成 `text/plain` 也拒(判定 = 扩展名 ∧ 嗅探的**合取**)。④ **边界(勿与图片混用)**:`url` 是**相对路径**(`/api/v1/documents/<32hex>.<ext>`;图片侧是绝对 URL);`mime` 是**嗅探所得基类型**(故 `data.json` 返回 `text/plain`,不是 `application/json`);落盘名 = `sha256(bytes)[:32] + ext`(**内容寻址**,原始文件名永不参与路径 ⇒ 同字节同扩展名天然同名去重)。⑤ **4 个错误码**:`unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404,含名字形态非法与路径穿越) / `too_many_documents`(400)。⑥ **`chat` 请求新增可选 `"documents":[{"name":"<32hex>.<ext>"}]`**(REST 同步 / REST `stream:true` / WS `chat` **三处同形**):**≤5 条**(超出 `too_many_documents` 400,且**先于任何文件系统访问**);**只收服务端存储名、绝不收 URL**(不重开 F3 那种「服务端代取 URL」的 SSRF 面);客户端多带的 `mime/url/size` **一律被服务端覆盖**(MIME/大小取自落盘字节);名非法/穿越/不存在 → `document_not_found`(404);**任一条不合规都让整个请求被拒**,绝不「丢掉这条附件但照样起回合」(WS 侧经 `error` 帧带 `data.code` 下发并中止本回合,REST 侧回对应状态码)。⑦ **响应 / 历史 DTO 由 `document`(单对象)改为 `documents`(数组,≤5)**——元素形状 `{"name","mime","size","chars","truncated","status"}`,`status ∈ extracted|listed|unreadable`;**0 个附件时该键省略**;同一 DTO 供 `GET /sessions/{id}/messages`、`message_persisted`、`message_edited`、WS 握手回放 **四处同时生效**(同一个 `historyMessage` 结构)。⚠️ **原单数形状从未发布过**(不在任何已发布版本号里)⇒ **无兼容期**,直接换数组(design/24 §6.1 那句 `"document":{…}` 已随之作废)。⚠️ **`chars`/`truncated` 改为「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断(**不是**「原文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。⑧ **内置工具 `document-read`**(模型侧工具,客户端一般只需知道它存在):`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000** 字符、上限 **64K**;`offset` 单位是**字符(rune)**、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;`offset<0` 或 `>totalChars` → 错误(`==totalChars` 合法,读到末尾空片段);`extractTruncated=true` **专指**「撞上 8 MiB sidecar 安全上限」(≠ 本回合注入被截Line truncated
|
||||
> **契约版本 `v0.60`**(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实):**A. 新增 `GET /api/v1/admin/system/storage`**(**owner-only**、只读、**无审计动作**)→ `200 {data:{main,documents,vector}}`——① `main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;`walBytes`/`shmBytes` 边车不存在时如实为 `0`;`freeBytes = pageSize × freelistPages`);② `documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**;空表 `bytes` 为 `0` 而非 `null`);③ `vector` = `{kind:"pgvector"|"none",enabled,ready}`——`kind` 说「配没配 `DB_PG_URL`」,`ready` 说「现在真的在跑什么」(配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端显示「已配置但未就绪(检索已降级)」,**别把 `enabled` 当生效**)。**B. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份,调用前后 `documents` 行数与数据目录文件集合均不变)。**C. `vector` 的真源**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(避免面板与运行时给出两个答案)。**D. 失败不伪装**:未接活连接 / 路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**。**E. 面板**:`frontend/web` 的存储卡改为真数据(`value` = `SQLite 12.3 MiB`;`hint` = WAL / 页数 / 空闲页;详情两行 = `documents N 件 · …` 与 `PG 增强 …`),取数走与其它已接线卡片**同一条**加载/错误态(数据没到就显示「端点报错:…」,不回退成 `0`)。**F. 兼容性**:**纯新增端点 + 纯新增前端行为**,既有端点 / WS 帧 / 字段形状**一字未动**(前端 `MiniMetricSpec` 新增的 `extra?: string[]` 为可选补充详情行,不传时渲染与旧版逐字等价)。**上一版 `v0.59`**:(**文档进对话(`F1/F2`)+ 上下文预算按窗口推导(`T1`)+ 语音回合进编排(`G5/C1/C3`)+ `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5/C1/C3,commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实):**A. 文档存储与摄取(两个新端点 + 一个可选请求字段 = 纯增量;DTO 形状变更是破坏性,但单数形状从未发布,故无兼容期)**——① **新增 `POST /api/v1/documents`**(owner|admin,`multipart/form-data`,字段 `file`)→ 首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(客户端可当成功处理);单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB = 20971520)**,`≤0` 视为用默认;它与图片上限 `CHAT_IMAGE_MAX_BYTES` **刻意解耦**(改文档上限不会静默改掉图片行为)。② **新增 `GET /api/v1/documents/{name}`**:**必须带 Bearer(owner|admin)**——与图片读侧(免鉴权的能力 URL)**刻意相反**(文档摄取在服务端完成,不需要被 `<img>`/provider 直接消费),响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`(浏览器永不内联渲染)。③ **类型裁决(重要)**:**字节嗅探是唯一裁决者**(`http.DetectContentType`,放行的嗅探基类型只有 `text/plain`),**客户端声明的 `Content-Type` 与原始文件名只作参考、可被嗅探否决**;**扩展名白名单 deny-by-default,实际 **22** 个**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`(⚠️ design/24 §9.2 写「23 项」但所列清单与代码均为 **22** 项,**以代码 22 为准**,见 §1.2「发现的不一致」);**明令拒绝** `text/html`、SVG/XML、zip 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)——即便 `.js`/`.py` 嗅探成 `text/plain` 也拒(判定 = 扩展名 ∧ 嗅探的**合取**)。④ **边Line truncated
|
||||
|
||||
## 2. REST 端点(管理,`/api/v1/admin/*`)
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
| 21 | `/api/v1/admin/system/rotate-jwt` | POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body `{newSecret}`(≥32 位,不强校验熵)→ `{rotated:true,note,refreshTokensRevoked}`——**现有 access token 立即失效需重新登录,且**所有 refresh token 一并作废(`refreshTokensRevoked` 为删除条数);新密钥**不回显、不落日志** |
|
||||
| 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `<dataDir>/backup-<ts>.db` → `{path,size}` |
|
||||
| 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) |
|
||||
| 24 | `/api/v1/admin/system/storage` | GET | ✅ | **存储状态(只读,v0.60)**:owner-only、**无审计动作**;`{data:{main:{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes},documents:{count,bytes},vector:{kind:"pgvector"|"none",enabled,ready}}}`——**绝不含凭据**(DSN/密码/`postgres://` 全文一个字都不回);未接活连接 / 路径未配置 / `documents` 表读不到 → `500 storage_unavailable`(**不回全 0 假快照**)。详见下方「系统设置契约」 |
|
||||
|
||||
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` · `GET /api/v1/admin/observability/logs` · `GET /api/v1/admin/notifications` 已实现,返回真实数据(**日志 tail 见下方「可观测性契约」**;未注册 `GET /api/v1/admin/logs`)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);**主人档案保存(v0.51)同样已实现**——`GET`/`PUT /api/v1/admin/owner-profile` 真实落盘 + 热替换活档案,面板的「保存」不再是假成功;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**编辑环境变量/重启服务/检查更新**——后端如实返回 `501 not_implemented`,不伪造成功。(**知识库删除已实现**:`POST /admin/rag/delete` 真实删除,见下方写操作契约;**插件安装已实现**:v1 为 MCP server 热安装。)
|
||||
|
||||
@@ -387,6 +388,37 @@
|
||||
- `POST /system/backup`(无 body)→ `200 {data:{path,size}}`。**备份数据**:经 SQLite `VACUUM INTO`(含已提交 WAL 内容)快照到 `<dataDir>/backup-<ts>.db`,`path` 为备份文件绝对路径、`size` 为字节数;数据库路径未配置回 `500 backup_unavailable`、快照失败回 `500 backup_failed`。
|
||||
- `POST /system/restore` body `{path}` → `200 {data:{restored:true,backup,note:"已安排恢复,需重启服务后生效"}}`。**恢复数据**:`path` 须为 **dataDir 内**的备份文件(**路径穿越防护**——`filepath.Rel` 校验,`..` 逃逸回 `400`);文件不存在回 `404`;**先自动备份当前数据库**(`backup` 为快照路径)再替换 DB 文件;运行进程持有 DB 句柄,**替换后需重启服务生效**;数据库路径未配置回 `500 backup_unavailable`、替换失败回 `500 restore_failed`。
|
||||
|
||||
**存储状态契约(`GET /api/v1/admin/system/storage`,v0.60)** —— 上面四条是**动作**(写 + 审计),这一条是**只读查询**:owner-only、**不审计**、**只读**(只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的活连接——不新开连接池、不写、不迁移、不备份)。它回答「现在这台机器上到底存了什么」,是面板首页「存储」卡的数据源。
|
||||
|
||||
- **请求**:无 body、无 query。`Authorization: Bearer <JWT>`(role=owner)或 `X-API-Key`;**无凭据 / 非 owner → `401`**。
|
||||
- **响应 `200 {data:…}`**(三块,字段名逐字对齐 `internal/services/gateway/admin_system_storage.go`):
|
||||
|
||||
```json
|
||||
{"data":{
|
||||
"main":{"kind":"sqlite","path":"/srv/cyrene/data/cyrene.db","fileBytes":12894208,
|
||||
"walBytes":107872,"shmBytes":32768,"pageSize":4096,"pageCount":3148,
|
||||
"freelistPages":12,"freeBytes":49152},
|
||||
"documents":{"count":37,"bytes":2143567},
|
||||
"vector":{"kind":"none","enabled":false,"ready":false}
|
||||
}}
|
||||
```
|
||||
|
||||
- `main.kind` 恒为 `"sqlite"`(当前唯一的主库引擎);`main.path` = 组合根实际打开的库文件路径(与备份/恢复端点同一个来源)。
|
||||
- `main.fileBytes` = 主库文件字节数(`os.Stat`);`walBytes`/`shmBytes` = `<db>-wal` / `<db>-shm` 边车文件字节数,**合法地不存在时如实为 `0`**(已 checkpoint 或库未开 WAL)。
|
||||
- `main.pageSize`/`pageCount`/`freelistPages` 来自 `PRAGMA page_size` / `page_count` / `freelist_count`(一条 SELECT 取完);`freeBytes = pageSize × freelistPages`,即「已分配但空闲、VACUUM 可回收」的量级。
|
||||
- `documents` = `documents` 表的**行数**与 `SUM(size)`(读表,**不 walk 目录**);空表时 `bytes` 为 `0`(`COALESCE`),**不是** `null`。
|
||||
- `vector` = 可选 pgvector 增强的**运行时真相**(主人 2.4 的困惑点:面板只写「SQLite」会让人以为 PG 没生效):
|
||||
- `kind`:`"pgvector"` = 已配置 `DB_PG_URL`;`"none"` = 未配置(前端显示「未启用(未配置 `DB_PG_URL`)」)。
|
||||
- `enabled`:开关在不在(等价于 `kind != "none"`)。
|
||||
- `ready`:**活着的**向量索引确实是 PostgreSQL+pgvector 后端。配了 PG 但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`(检索已降级),客户端应显示「已配置但未就绪」,**不要**把 `enabled` 当成「生效了」。
|
||||
- 真源 = 组合根的运行时状态(`DB_PG_URL` 非空 + 活索引的具体类型),网关**不自行探测**。
|
||||
- **不含凭据(红线)**:DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**,`vector` 段只有 `kind`/`enabled`/`ready` 三个键(有专门用例断言响应体里搜不到这些串)。
|
||||
- **错误**:
|
||||
- `401`(无凭据 / 非 owner / 凭据无效——与其它 `ownerAuth` 端点同档);
|
||||
- `500 {error:{code:"storage_unavailable",message}}`:数据库**路径**未配置、活**连接**未注入、或页级统计 / `documents` 表读不到(例如库没跑迁移)。**诚实边界**:此时**不返回**任何全 0 的假快照,客户端应显示错误而不是 `0`。
|
||||
- **只读保证**:调用前后 `documents` 行数与数据目录内的文件集合均不变(有用例钉住:既没有写、也没有顺手备份)。
|
||||
- **前端用法**:`adminSystemStorage()`(`frontend/web/src/lib/api/adminSystem.ts`)→ 卡片 `value` = `SQLite 12.3 MiB`、`hint` = WAL/页数/空闲页、详情行 = `documents N 件` 与 `PG 增强 …`;取数走与其它已接线卡片同一条加载/错误态。
|
||||
|
||||
**知识库 RAG 契约**:
|
||||
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
|
||||
- `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。
|
||||
@@ -666,6 +698,7 @@
|
||||
- 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。
|
||||
|
||||
## 5. 变更记录
|
||||
- `v0.60`(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」,看不出 PG 增强有没有生效)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实)。**一句话**:**纯新增一个只读端点**,既有端点 / WS 帧 / 字段形状**一字未动**;面板那张假卡换成真数据。**A. 新端点** `GET /api/v1/admin/system/storage`(**owner-only**、**只读**、**无审计动作**,与四条 `POST /system/*` 动作端点同门不同档)→ `200 {data:{main,documents,vector}}`:`main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;WAL/SHM 边车**合法地不存在**时如实为 `0`;`freeBytes = pageSize × freelistPages`)、`documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**,空表 `bytes` 为 `0` 而非 `null`)、`vector` = `{kind:"pgvector"|"none",enabled,ready}`。**B. `vector` 的语义(主人 2.4 的困惑点)**:`kind` 说「配没配 `DB_PG_URL`」,`enabled` 说「开关在不在」(等价于 `kind != "none"`),`ready` 说「**活着的**向量索引真的是 pgvector 后端吗」—— 配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端必须显示「已配置但未就绪(检索已降级)」,**不要把 `enabled` 当成生效**。客户端显示建议:`none` → 「未启用(未配置 `DB_PG_URL`)」、`enabled && ready` → 「启用(`pgvector` · ready)」、`enabled && !ready` → 未就绪。**C. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有 `kind`/`enabled`/`ready` 三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,且复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份;调用前后 `documents` 行数与数据目录内文件集合均不变,有用例钉住)。**D. 真源(`vector`)**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(否则面板与运行时会给出两个答案)。**E. 失败不伪装**:未接活连接 / 数据库路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**;无凭据 → `401`。**F. 面板**:`frontend/web` 的存储卡(`admin/modules/dashboard/overviewMetrics.ts`)改为真数据 —— `value` = `SQLite 12.3 MiB`(`main.fileBytes` 的人类可读量级)、`hint` = `WAL … · N 页(空闲 M 页 / …)`、新增两行详情 = `documents N 件 · …` 与 `PG 增强 …`;取数走与其它已接线卡片**同一条** `useAsyncData` 加载/错误态(数据没到就显示「端点报错:…」,**不回退成 0、不假装成功**)。**G. 实现落点**:`internal/services/gateway/admin_system_storage.go`(新文件)+ `routes.go`(注册)+ `deps.go`/`server.go`(注入 `Deps.Database` / `Deps.Vector`)+ `cmd/cyrene/wire_frontend.go`(组合根真源)+ 前端 `lib/types/adminWrite.ts` / `lib/api/adminSystem.ts` / `admin/modules/dashboard/{useDashboardExtras,overviewMetrics,MiniMetric}`。**H. 兼容性**:**纯新增**——新端点为纯增量;前端 `MiniMetricSpec` 新增的 `extra?: string[]` 是**可选**补充详情行,不传时卡片渲染与旧版**逐字等价**(其它卡片一字未改)。**未做 / 诚实边界**:`main` 只报 SQLite(当前唯一主库引擎);`vector` 只报 `kind`/`enabled`/`ready`(**刻意不回 host**,把泄漏面压到最小);不报表级明细、不报备份文件列表(`documents` 之外的占用仍要下钻到备份/文件系统看)。
|
||||
- `v0.59`(**文档进对话 + 上下文预算按窗口推导 + 语音回合进编排 + `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5 / C1 / C3;commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实)。**三档标注**:**纯增量** = 两个 documents 端点、`chat.documents` 可选字段、4 个文档错误码、`models.json` 组键 `contextWindow`、4 个 `.env` 键、`document-read` 工具、`pending_full` 的 `clientMsgId?`/`messageId?`、定向 `error{code:"chat_busy"}` 帧、`voice_input.clientMsgId`、`voice_output.sessionId`/`deviceId`;**行为变更** = ① 语音回合改走编排器(落库 / 流式 / 跨端 / 忙时排队)② `CONTEXT_MAX_TOKENS` **未设置时改为按窗口推导**(`.env` 里写着 `32768` 仍是显式覆盖)③ 队列满时新增**定向回执**(此前 WS 无等价物);**破坏性** = ① `historyMessage` 的 `document`(单对象)→ **`documents`(数组,≤5)** ——**但单数形状从未随任何已发布版本号发布过**,故**无兼容期** ② `chars` / `truncated` 的语义由「抽取侧」改为 **「本回合注入侧」**(全文长度只有 `document-read` 的 `totalChars` 能看到)。**A. 文档(新增 §1.2 + §1 行 16/17)**——`POST /api/v1/documents`(multipart `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 20 MiB)→ `201 {name,url,mime,size,sha256,deduped:false}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**;`GET /api/v1/documents/{name}` **必须带 Bearer(owner|admin)**(与图片读侧的免鉴权能力 URL 刻意相反)+ `Content-Disposition: attachment` + `nosniff`;**字节嗅探是唯一类型裁决**(放行基类型只有 `text/plain`),**扩展名白名单 22 项** deny-by-default,`mime` 是嗅探基类型(`data.json` → `text/plain`)、`url` 是**相对路径**;错误码 `unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404) / `too_many_documents`(400);`chat` 请求(REST 同步 / REST `stream:true` / WS **三处同形**)增可选 `documents:[{name}]`(**≤5、只收服务端存储名、不收 URL**,先于任何 FS 访问判条数,客户端多带的 `mime/url/size` 被服务端覆盖);DTO 四处同源(`GET /sessions/{id}/messages` / `message_persisted` / `message_edited` / 握手回放)改 `documents` 数组,元素 `{name,mime,size,chars,truncated,status}`、`status ∈ extracted|listed|unreadable`、无附件时省略该键;内置工具 `document-read`(`{name,offset?,limit?}` → `totalChars`/`truncated`/`nextOffset`/`text`/`extractTruncated?`,`limit` 默认 8000、上限 64K、`offset` 为 0 基 rune,策略默认 owner-only)。**B. 配置(§2「上下文窗口与预算契约(v0.59)」)**——`models.json` 组级新增 `contextWindow`(0/省略 = 未声明 ⇒ 131072 保守估算 + **恰好一条 WARN**;负数 = 校验失败);`.env` 新增 `LLM_CONTEXT_WINDOW` / `CONTEXT_BUDGET_RATIO`(默认 `2/3`)/ `DOCUMENT_MAX_INJECT_CHARS`,并**变更 `CONTEXT_MAX_TOKENS` 语义**(未设置 ⇒ 按窗口推导;`>0` 覆盖;`0`/负 = 关闭压缩)——推导 `budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 封顶 `窗口/2`,**1M 窗口 ⇒ 699050 tokens**;另**补登记 `.env` → 面板的 materialize 语义**(每次启动一次;面板非空 ⇒ 组集合由面板决定、env 不复活被删的组;键级只在面板缺该键时补,面板的**显式空值/`false`/`null` 绝不覆盖**;「面板改一次、重启被打回」为硬约束;凭据不进日志;范围仅 `models.json` 一族)。**C. 语音(新增 §3.15)**——语音轮次**现在走与文本同一条路径**(落库 = 转写文本 + 助手回复、发 `message_persisted` 与流式帧、跨端同步、忙时按既有规则排队,`pending_ack`/`pending_drain`/`session_busy` 语义零变化);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 增可选 `clientMsgId`(重复到达回 `chat_duplicate`,不产生新回合、不发音频);`voice_output` 增 `sessionId`(恒有)/ `deviceId`(非空才有),**空正文与 TTS 失败/降级都走既有 `provider` 降级信号**(不再把 `stopReason` 当语音内容);**时点** = 回合结算时才到(可能晚于若干状态帧、也可能由续跑或被 steer 注入的那一回合产生);**定向目标 = 发起 `voice_input` 的那条连接本身**(不按 `deviceId` 路由)、**断线不补投**(带来源 WARN),重连带同一 `clientMsgId` 重试安全。**D. `G5`(§3.2)**——`pending_full` **广播**载荷增 `clientMsgId?` / `messageId?`(前者 = 被拒那条消息的幂等键,未携带则整个键Line truncated
|
||||
- `v0.58`(**纯新增 env 键:`LLM_MULTIMODAL`——让 env-only 部署也能声明「主模型自身能收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实)。**新增 1 个 `.env` 键**:`LLM_MULTIMODAL`(`bool`,**默认 `false`**)= `models.json` 组 `multimodal` 的 **env 等价物**,**只在没有有效 `models.json`(走 `.env` 合成组)时生效**(有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径);`true` = 图片**直进主模型上下文**;**留空 / `false` = 把图交给 vision/OCR 工具换成文本描述**;**非 bool 值(如 `maybe`)启动即报错**(fail-fast,不静默回退)。**兼容性**:默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**。**无端点 / 帧 / 字段形状变更**(纯新增 env 键,见 §2「模型配置契约」表与 §1 行 3)。
|
||||
- `v0.57`(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`(回合内转向的消费回执)**,依据 design/15 §2.0 / §2.1 / §2.4 / §3.x / §5(**13.4.5 目标态规格**,commit `3d8822c`),2026-10-04 落实;**本版是已实现并已推库行为的登记,只有一处纯新增帧 + 一处链路图纯增量键,无其它契约变更**)。**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发,`messageId` 是那条用户消息的**权威 id**(与 `message_persisted` 的 `data.message.id` 同源)、`atStep` 是吃进它的**模型步序号(1 基)**;同一批次吃进 N 条就发 N 帧、**不合并**。语义=「**已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「**已读、未答**」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达,**不需要第二个确认帧**(与「忙『稍后回』」的 pending 气泡不是同一个态)。**角色门**:它**携带具体消息身份**,因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**;帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(无可注入消息的边界一帧都不发)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=**已消费**——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),绝不被第二个回合再答一遍;去重键=后端 `message.id`,候选必须**同时**满足「在该会话转录里」与「在本编排器的 pending 里」(**不用时间戳**:Windows `time.Now()` 步长约 0.44ms,连发常同毫秒并列,任何毫秒基线都会漏注入或重复注入);**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**(与 Cline PR #13958「只打断在飞的模型请求、工具与整轮继续」同口径)。**C. 注入的范围(重要口径)**——注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**(它与 `message_persisted` 是两回事:后者在该消息落库时发,前者在该消息被吃进时发)。最新性=**每个边界重读该会话转录**(`history.Recent`),不靠回合开始那一次播种(回合开始前已有的消息由 `startTurnMessages` 播种,**绝不重复注入**)。**D. 失败一律让路(诚实边界)**——未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**:该消息**留在 pending**、照旧作为自己的回合被回答,**绝不静默消失**(转向是增益,任何一处失败都不该让已经在跑的回合失败)。**未做**:**没有「取回 / 撤回 pending 消息」的上行帧**(design/15 §5 提到的队列取回暂无协议面),也没有「已撤回」类下行帧;`steer_ack` 只报「**吃进**」,**不报「回答」**(回答由既有的 `message.*` / `turn.end` 表达)。**E. 一处随实现落地的链路图纯增量键**——注入时给该轮图的 **input 节点**打 `props.steered:true`(`loop.FlowPublisher.MarkInputSteered`),让流程图如实表达「这一轮的输入在回合中途被追加过」;图仍**只有一条主干**(**不是**被画成第二轮,与 I1 一致),标记只并入既有 input 节点、**不新建节点、不改标签**。**F. 实现落点**——loop 侧 `internal/runtime/loop/steer.go`(`SteerRequest` / `SteerSource` / `WithSteerSource` / `steerAtBoundary`)+ `run.go` 的边界调用点 + `flow.go` 的 `MarkInputSteered`;网关侧 `internal/services/gateway/session_steer.go`(每回合一份的 `steerTurn` 游标 / `match` / `consumePending`)+ `session_orchestratLine truncated
|
||||
|
||||
+44
-2
@@ -1,6 +1,6 @@
|
||||
# Cyrene v2 后端 API 参考(api-reference.md)
|
||||
|
||||
> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.58`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。**
|
||||
> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.60`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。**
|
||||
> **本文件的正确用法**:查「有哪些端点、大致形状、错误码、部署注意事项」可以;**写客户端代码时请以 `Docs/api-client.md` 为准**,本文件的字段表 / 示例可能滞后。
|
||||
> **历史包袱(如实)**:本文件长期自称「全部后端 API 的唯一详细参考 / 超详细主文档 / 每变更必同步」,但实际**系统性滞后于实现与 api-client**(2026-10-03 的 16 路 design-vs-code 审计结论)。本次已修正与实现**直接矛盾**的陈述、并把若干复述型段落改为引用 api-client;**§1 的 REST 端点表已于 2026-10-04 按 `internal/services/gateway/routes.go` 逐条核对并补登记**(补上此前缺失的 `POST /auth/refresh`、`POST /auth/logout`、`POST /upload`、`GET /files/{name}`、三支 `/audio/*`、`GET /admin/llm/catalog`、`GET /admin/observability/logs`、`GET /admin/notifications`、`GET /admin/plugins/installed`、`GET|PUT /admin/owner-profile`、`POST /admin/automations/reminders`)。**仍未补全的是 WS 事件帧清单**:§6.4 自述「并非全表」,`DevDocs/audit/00-总览.md` §4④ 列出的 `session_read` / `message_edited` / `message_deleted` 等仍未逐行登记。
|
||||
> **其余仍成立**:所有字段名、取值、鉴权规则、状态码最终**以代码为准**(`internal/services/gateway/*.go`);契约变更版本化(REST 走 `/api/v1`,破坏性变更升 v2),不得破坏既有客户端(版本化承诺本身记在 api-client §5)。
|
||||
@@ -154,10 +154,11 @@
|
||||
| 59 | POST | `/api/v1/admin/system/rotate-jwt` | **owner** | 200 | 轮换 JWT 密钥 | M10/M11 |
|
||||
| 60 | POST | `/api/v1/admin/system/backup` | **owner** | 200 | 备份数据库 | M10/M11 |
|
||||
| 61 | POST | `/api/v1/admin/system/restore` | **owner** | 200 | 恢复数据库 | M10/M11 |
|
||||
| 69 | GET | `/api/v1/admin/system/storage` | **owner** | 200/500 | **存储状态(只读,v0.60)**:SQLite 文件/WAL/页级统计 + `documents` 表件数与占用 + 向量增强真假(`kind`/`enabled`/`ready`);**不含凭据**(DSN 一个字都不回);路径/活连接未接入或读不到表 → `500 storage_unavailable`(见 §5.35) | M10/M11 |
|
||||
|
||||
**另**:外部 webhook 回调(如 `webhook` 渠道的 HMAC 回调)由 composition root 通过 `Deps.Webhooks` 动态挂载,**不走网关鉴权**(handler 自校验签名/授权);路径由调用方决定,不在上表枚举。
|
||||
|
||||
**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**v0.59 再补两行**:§1.2 的 `#19`/`#20`(`POST /api/v1/documents`、`GET /api/v1/documents/{name}`,均按 `routes.go` 的 `writeAuth` 注册核对)——同样取本表下一个可用号、不动既有编号。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。
|
||||
**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**v0.59 再补两行**:§1.2 的 `#19`/`#20`(`POST /api/v1/documents`、`GET /api/v1/documents/{name}`,均按 `routes.go` 的 `writeAuth` 注册核对)——同样取本表下一个可用号、不动既有编号。**v0.60 再补一行**:`#69` GET `/api/v1/admin/system/storage`(owner-only 只读,按 `routes.go` 的 `ownerAuth` 注册核对;**无审计动作**——它是查询不是动作),同样取下一个可用号、既有编号未动。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。
|
||||
|
||||
**WS**:`GET /ws/chat`(握手,JWT,见 §6)。
|
||||
|
||||
@@ -1146,6 +1147,47 @@ curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
|
||||
-d '{"path":"/abs/path/data/backup-20260908T000000.000000000Z.db"}'
|
||||
```
|
||||
|
||||
### 5.35 GET /api/v1/admin/system/storage(v0.60,只读)
|
||||
|
||||
- **鉴权**:**owner**(`ownerAuth`,与四条 `POST /system/*` 动作端点同门)。无凭据 / 非 owner → `401`。
|
||||
- **Body / query**:无。
|
||||
- **性质**:**只读查询**,**没有审计动作**(审计只覆盖动作)。实现只有 `SELECT`/`PRAGMA` 与 `os.Stat`,且复用组合根注入的**活连接**(`Deps.Database`)——**不新开连接池、不写、不迁移、不备份**。调用前后 `documents` 行数与数据目录内文件集合均不变。
|
||||
- **响应**:`200`
|
||||
|
||||
```json
|
||||
{"data":{
|
||||
"main":{"kind":"sqlite","path":"/srv/cyrene/data/cyrene.db","fileBytes":12894208,
|
||||
"walBytes":107872,"shmBytes":32768,"pageSize":4096,"pageCount":3148,
|
||||
"freelistPages":12,"freeBytes":49152},
|
||||
"documents":{"count":37,"bytes":2143567},
|
||||
"vector":{"kind":"none","enabled":false,"ready":false}
|
||||
}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `main.kind` | string | 恒 `"sqlite"`(当前唯一主库引擎) |
|
||||
| `main.path` | string | 组合根实际打开的库文件路径(与备份/恢复端点同源) |
|
||||
| `main.fileBytes` | int | 主库文件字节数(`os.Stat`) |
|
||||
| `main.walBytes` / `main.shmBytes` | int | `<db>-wal` / `<db>-shm` 边车字节数;**合法地不存在时如实为 `0`**(已 checkpoint / 未开 WAL) |
|
||||
| `main.pageSize` / `main.pageCount` / `main.freelistPages` | int | `PRAGMA page_size` / `page_count` / `freelist_count`(一条 SELECT 取完) |
|
||||
| `main.freeBytes` | int | `pageSize × freelistPages`(已分配但空闲、VACUUM 可回收的量级) |
|
||||
| `documents.count` | int | `documents` 表行数 |
|
||||
| `documents.bytes` | int | `SUM(documents.size)`;空表为 `0`(`COALESCE`),**不是** `null` |
|
||||
| `vector.kind` | string | `"pgvector"` = 已配置 `DB_PG_URL`;`"none"` = 未配置 |
|
||||
| `vector.enabled` | bool | 增强开关在不在(等价于 `kind != "none"`) |
|
||||
| `vector.ready` | bool | **活着的**向量索引确实是 PostgreSQL+pgvector 后端 |
|
||||
|
||||
- **`vector` 的读法(重要)**:`kind` 说「配没配 `DB_PG_URL`」,`ready` 说「现在真的在跑什么」。配了 PG 但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`(检索已降级),客户端应显示「已配置但未就绪」,**不要把 `enabled` 当成生效**。`kind:"none"` 时前端显示「未启用(未配置 `DB_PG_URL`)」。**真源** = 组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()`(`DB_PG_URL` 非空 + 活索引的具体类型),网关不读环境变量、不新开 PG 连接、不自行探测。
|
||||
- **不含凭据(红线)**:DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**,`vector` 段只有 `kind`/`enabled`/`ready` 三个键(有用例断言响应体里搜不到这些串)。
|
||||
- **错误**:`401`(无凭据 / 非 owner);`500 {"error":{"code":"storage_unavailable","message":…}}` —— 数据库**路径**未配置、活**连接**未注入、或页级统计 / `documents` 表读不到(例如库没跑迁移)。**诚实边界**:此时**不返回**任何全 0 的假快照。
|
||||
- **前端落点**:`frontend/web/src/lib/api/adminSystem.ts` 的 `adminSystemStorage()` → 面板首页「存储」卡(`admin/modules/dashboard/overviewMetrics.ts`):`value` = `SQLite 12.3 MiB`、`hint` = WAL / 页数 / 空闲页、详情两行 = `documents N 件 · …` 与 `PG 增强 …`。取数走与其它已接线卡片同一条加载/错误态(失败显示端点错误,不回退成 `0`)。curl:
|
||||
|
||||
```bash
|
||||
curl -s http://<host>:8080/api/v1/admin/system/storage \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. WebSocket 实时协议(`GET /ws/chat`)
|
||||
|
||||
@@ -3,11 +3,13 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
)
|
||||
|
||||
@@ -76,6 +78,8 @@ func (a *app) wireFrontend() error {
|
||||
Webhooks: a.channelBridge.WebhookHandlers(),
|
||||
DatabasePath: a.dbPath,
|
||||
DataDir: resolveDataDir(a.cfg),
|
||||
Database: a.db,
|
||||
Vector: a.vectorState(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway: %w", err)
|
||||
@@ -111,3 +115,23 @@ func (a *app) wireFrontend() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vectorState 把「PG 增强到底有没有生效」的**运行时真相**报给网关,供管理侧
|
||||
// 「存储状态」只读端点作答(design/03 §3;面板首页的存储卡)。
|
||||
//
|
||||
// 真源就在本组合根,且只有两项事实,因此这里**复用**运行时状态而不新造探测:
|
||||
//
|
||||
// - Configured = `DB_PG_URL` 是否配置(非空即算)—— 开关在不在;
|
||||
// - Live = 活着的向量索引确实落在 pgvector 后端上 —— 真的在不在跑。
|
||||
//
|
||||
// 两者会不一致:配置了 PG 但连接/schema 准备失败时,wireMemory 把 vindex 留成
|
||||
// nil(语义检索降级),此时 Configured=true、Live=false,面板据此显示「已配置但
|
||||
// 未就绪」,而不是把「配了」当成「生效了」。这里绝不新开 PG 连接去探测:那既多
|
||||
// 一次网络往返,也可能与运行中的索引给出不同答案。
|
||||
func (a *app) vectorState() gateway.VectorState {
|
||||
_, live := a.vindex.(*vector.PGVectorIndex)
|
||||
return gateway.VectorState{
|
||||
Configured: strings.TrimSpace(a.cfg.DB.PGURL) != "",
|
||||
Live: live,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export interface MiniMetricSpec {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
/** 可选的补充详情行:逐行渲染在 `hint` 下方。**不传时卡片与既有形状
|
||||
* 逐字等价**(其它卡片因此一字未改);存储卡用它把 PG 增强状态单独
|
||||
* 摆一行,而不是挤进 `hint`。 */
|
||||
extra?: string[];
|
||||
state: MetricState;
|
||||
/** Admin module this card drills down to; absent = not a link. */
|
||||
section?: AdminSectionId;
|
||||
@@ -25,7 +29,7 @@ const STATE_BADGE: Record<MetricState, { label: string; tone: TagTone }> = {
|
||||
placeholder: { label: "占位", tone: "warn" },
|
||||
};
|
||||
|
||||
export function MiniMetric({ icon, label, value, hint, state, section }: MiniMetricSpec): ReactNode {
|
||||
export function MiniMetric({ icon, label, value, hint, extra, state, section }: MiniMetricSpec): ReactNode {
|
||||
const badge = STATE_BADGE[state];
|
||||
const body = (
|
||||
<>
|
||||
@@ -36,6 +40,11 @@ export function MiniMetric({ icon, label, value, hint, state, section }: MiniMet
|
||||
</div>
|
||||
<div className="mt-1.5 text-lg font-semibold leading-tight">{value}</div>
|
||||
<div className="mt-0.5 break-words text-xs text-[var(--mu-text-lo)]">{hint}</div>
|
||||
{extra?.map((line) => (
|
||||
<div key={line} className="mt-0.5 break-words text-xs text-[var(--mu-text-lo)]">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
const base =
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
AdminLlmConfig,
|
||||
AdminObservabilityMetrics,
|
||||
AdminRagResponse,
|
||||
AdminStorageStatus,
|
||||
AdminStorageVector,
|
||||
AdminUserRecord,
|
||||
InstalledPlugin,
|
||||
} from "../../../lib/types";
|
||||
@@ -21,6 +23,10 @@ import type { DashboardExtras, Loaded } from "./useDashboardExtras";
|
||||
* 8 rounds before the agent must answer. Shown as a fact, not as a metric. */
|
||||
const TOOL_LOOP_CAP = 8;
|
||||
|
||||
/** 字节单位阶梯(1 KiB = 1024 B)。 */
|
||||
const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"] as const;
|
||||
const BYTE_STEP = 1024;
|
||||
|
||||
export function buildOverviewMetrics(extras: DashboardExtras): MiniMetricSpec[] {
|
||||
return [
|
||||
llmMetric(extras.llm),
|
||||
@@ -30,7 +36,7 @@ export function buildOverviewMetrics(extras: DashboardExtras): MiniMetricSpec[]
|
||||
userMetric(extras.users),
|
||||
gatewayMetric(extras.metrics),
|
||||
agentCallMetric(extras.metrics),
|
||||
storageMetric(),
|
||||
storageMetric(extras.storage),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -179,20 +185,51 @@ function agentCallMetric(m: Loaded<AdminObservabilityMetrics>): MiniMetricSpec {
|
||||
};
|
||||
}
|
||||
|
||||
/** Storage has no read-only endpoint in `lib/api.ts` (backup/restore are writes
|
||||
* that would touch the filesystem), so this card states the configured default
|
||||
* and names the missing endpoint instead of showing a made-up figure. */
|
||||
function storageMetric(): MiniMetricSpec {
|
||||
/** 存储卡(v0.60):取 `GET /admin/system/storage` 的真实快照 —— 主库体积
|
||||
* (`value`)、WAL / 页数 / 空闲页、`documents` 件数与占用,以及 PG/pgvector
|
||||
* 增强到底有没有生效(主人 2.4 的困惑点:只写「SQLite」会让人以为 PG 没接上)。
|
||||
*
|
||||
* 取数与其它已接线卡片同一条路:数据没到就 `pending(...)`(显示端点错误,
|
||||
* **绝不**回退成 0 或假装成功)。 */
|
||||
function storageMetric(storage: Loaded<AdminStorageStatus>): MiniMetricSpec {
|
||||
if (storage.data == null) {
|
||||
return pending("storage", "存储", "SQLite 主库 · WAL · documents · PG/pgvector 增强", storage, "system");
|
||||
}
|
||||
const { main, documents, vector } = storage.data;
|
||||
return {
|
||||
icon: "storage",
|
||||
label: "存储",
|
||||
value: "SQLite",
|
||||
hint: "默认引擎 · PG/pgvector 可选增强;缺只读查询端点(未在 lib/api.ts 暴露)",
|
||||
state: "placeholder",
|
||||
value: `SQLite ${formatBytes(main.fileBytes)}`,
|
||||
hint: `WAL ${formatBytes(main.walBytes)} · ${main.pageCount} 页(空闲 ${main.freelistPages} 页 / ${formatBytes(main.freeBytes)})`,
|
||||
extra: [
|
||||
`documents ${documents.count} 件 · ${formatBytes(documents.bytes)}`,
|
||||
`PG 增强 ${vectorLabel(vector)}`,
|
||||
],
|
||||
state: "ok",
|
||||
section: "system",
|
||||
};
|
||||
}
|
||||
|
||||
/** PG 增强一行:未配置 / 已就绪 / 已配置但未就绪(降级)三者必须可区分 ——
|
||||
* 绝不把「配了」写成「生效了」。 */
|
||||
function vectorLabel(vector: AdminStorageVector): string {
|
||||
if (vector.kind === "none" || !vector.enabled) return "未启用(未配置 DB_PG_URL)";
|
||||
if (vector.ready) return `启用(${vector.kind} · ready)`;
|
||||
return `已配置但未就绪(${vector.kind} 连接失败,检索已降级)`;
|
||||
}
|
||||
|
||||
/** 字节数 → 「12.3 MiB」这类人类可读量级;0 如实显示 `0 B`。 */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= BYTE_STEP && unit < BYTE_UNITS.length - 1) {
|
||||
value /= BYTE_STEP;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(unit === 0 || value >= 100 ? 0 : 1)} ${BYTE_UNITS[unit]}`;
|
||||
}
|
||||
|
||||
/** ISO → local "MM-DD HH:mm"; absent or unparseable values render as an em dash. */
|
||||
function formatTime(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
adminListRag,
|
||||
adminListUsers,
|
||||
adminObservabilityMetrics,
|
||||
adminSystemStorage,
|
||||
} from "../../../lib/api";
|
||||
import { useAsyncData } from "../../../lib/useAsyncData";
|
||||
import type {
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
AdminLlmConfig,
|
||||
AdminObservabilityMetrics,
|
||||
AdminRagResponse,
|
||||
AdminStorageStatus,
|
||||
AdminUserRecord,
|
||||
InstalledPlugin,
|
||||
} from "../../../lib/types";
|
||||
@@ -32,6 +34,7 @@ export interface DashboardExtras {
|
||||
automations: Loaded<AdminAutomationRecord[]>;
|
||||
rag: Loaded<AdminRagResponse>;
|
||||
users: Loaded<AdminUserRecord[]>;
|
||||
storage: Loaded<AdminStorageStatus>;
|
||||
}
|
||||
|
||||
/** The secondary dashboard sources, all from endpoints that already exist in
|
||||
@@ -44,11 +47,15 @@ export function useDashboardExtras(): DashboardExtras {
|
||||
const automations = useAsyncData(adminListAutomations, []);
|
||||
const rag = useAsyncData(adminListRag, []);
|
||||
const users = useAsyncData(adminListUsers, []);
|
||||
// 存储快照(v0.60):只读端点,取数的加载/错误态与其它卡片同一条路。
|
||||
const storage = useAsyncData(adminSystemStorage, []);
|
||||
useAutoReload(llm.reload, 30000);
|
||||
useAutoReload(metrics.reload, 15000);
|
||||
useAutoReload(installed.reload, 30000);
|
||||
useAutoReload(automations.reload, 30000);
|
||||
useAutoReload(rag.reload, 30000);
|
||||
useAutoReload(users.reload, 30000);
|
||||
return { llm, metrics, installed, automations, rag, users };
|
||||
// 库体积/页数变化很慢,比其它卡片再放慢一档(仍是同一个 useAutoReload)。
|
||||
useAutoReload(storage.reload, 60000);
|
||||
return { llm, metrics, installed, automations, rag, users, storage };
|
||||
}
|
||||
@@ -115,4 +115,5 @@ export {
|
||||
adminSystemRestore,
|
||||
adminSystemRevokeKeys,
|
||||
adminSystemRotateJWT,
|
||||
adminSystemStorage,
|
||||
} from "./api/adminSystem";
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AdminNotification,
|
||||
AdminNotificationQuery,
|
||||
AdminObservabilityMetrics,
|
||||
AdminStorageStatus,
|
||||
AdminTraceSpan,
|
||||
AuditRecord,
|
||||
BackthinkItem,
|
||||
@@ -65,6 +66,16 @@ export function adminListNotifications(
|
||||
* All four are implemented (not 501) — they return real data.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** GET /api/v1/admin/system/storage — the **read-only** storage snapshot behind the
|
||||
* dashboard's 存储 card (v0.60): SQLite file/WAL/page stats, the documents table's
|
||||
* count+bytes, and whether the optional pgvector enhancement is configured and
|
||||
* actually live. The `vector` block never carries credentials (no DSN, no password).
|
||||
* owner-only; the backend answers `500 storage_unavailable` rather than a fake
|
||||
* all-zero snapshot when the live DB handle is not wired. */
|
||||
export function adminSystemStorage(): Promise<AdminStorageStatus> {
|
||||
return request<AdminStorageStatus>("/admin/system/storage");
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/system/revoke-keys — revokes every registered API key. */
|
||||
export function adminSystemRevokeKeys(): Promise<SystemRevokeKeysResp> {
|
||||
return request<SystemRevokeKeysResp>("/admin/system/revoke-keys", { method: "POST" });
|
||||
|
||||
@@ -187,3 +187,40 @@ export interface SystemRestoreResp {
|
||||
backup: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/* --- System storage status (read-only, v0.60). Field names mirror the gateway DTOs
|
||||
* in internal/services/gateway/admin_system_storage.go. --- */
|
||||
|
||||
/** 主库(SQLite)段:文件体积 + 页级统计。 */
|
||||
export interface AdminStorageMain {
|
||||
kind: "sqlite";
|
||||
path: string;
|
||||
fileBytes: number;
|
||||
walBytes: number;
|
||||
shmBytes: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
freelistPages: number;
|
||||
freeBytes: number;
|
||||
}
|
||||
|
||||
/** `documents` 表段:件数与占用字节。 */
|
||||
export interface AdminStorageDocuments {
|
||||
count: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** 向量增强段:`kind` 说配了什么、`ready` 说现在真的在跑什么。
|
||||
* **绝不含凭据**(DSN 一个字都不回)。 */
|
||||
export interface AdminStorageVector {
|
||||
kind: "pgvector" | "none";
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/system/storage — the read-only storage snapshot (owner-only). */
|
||||
export interface AdminStorageStatus {
|
||||
main: AdminStorageMain;
|
||||
documents: AdminStorageDocuments;
|
||||
vector: AdminStorageVector;
|
||||
}
|
||||
@@ -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