Compare commits

...

16 Commits

Author SHA1 Message Date
xfy
95fb8d8698 Merge branch 'feature/admin-database-system'
Some checks failed
CI / check (push) Failing after 5m21s
CI / build (push) Has been skipped
抽取 /admin/system 页面级导航 Tab 公共组件并迁移调用方:
- feat(database): SystemTab::as_str/from_str 字符串桥接 + 单测
- feat(ui): Tabs 组件(与 FilterTabs 并列,页面导航语义,无动画)
- refactor(admin): System 组件改用 Tabs,移除内联 tab 与临时 allow
2026-06-30 14:56:20 +08:00
xfy
bf91d18c4f refactor(admin): use shared Tabs component in /admin/system
移除 System 组件内联的 tab 按钮,改调公共 Tabs 组件。
active_tab signal 经 SystemTab::as_str/from_str 与 Tabs 的 String API 桥接,
match 穷举面板选择逻辑不变。视觉/行为零变化,只消除重复。

同时移除 Task 1/2 加的临时 #[allow(dead_code)](as_str/from_str/Tabs 现已有调用方)。
2026-06-30 14:29:05 +08:00
xfy
ff7d9e1e46 feat(ui): add Tabs component for page-level navigation
与 FilterTabs(列表筛选 + 滑动动画)并列,职责区分:
- FilterTabs:同质数据过滤(全部/待审/已通过)
- Tabs:切换到结构不同的面板(数据库/服务器/SQL 控制台)

选中态用按钮自身 border-b-2 下划线(无动画)。
API 与 FilterTabs 对齐(String value + EventHandler),便于调用方迁移。
2026-06-30 14:16:36 +08:00
xfy
ef75631889 feat(database): add SystemTab::as_str/from_str string bridge
为后续用基于 String 的 Tabs 公共组件替换内联 tab 做准备。
as_str/from_str 是纯函数双向映射,带 roundtrip/稳定 key/拒绝未知输入三类单测。
2026-06-30 14:13:54 +08:00
xfy
0b6a076d78 feat(database): use real COUNT(*) for small tables, fall back to estimate for large
row_estimate came from pg_class.reltuples, which is -1 for tables that
have never been ANALYZEd (the common case on a fresh/small DB), so the
admin table list showed -1 for every table — meaningless.

Replace row_estimate with row_count + row_count_estimated:
- total_size < 100MB: SELECT count(*) (real value, cost negligible on
  small tables; identifier quoted via PG escaping to stay injection-safe)
- total_size >= 100MB: fall back to reltuples estimate, flagged so the UI
  can prefix with ~

Frontend now shows real counts (1/2/14...) for small tables and ~N for
large ones; header copy updated to reflect the mixed source.
2026-06-30 13:57:37 +08:00
xfy
36168f26d7 fix(database): cast extract(epoch)::double precision in active-connections query
extract(epoch FROM now() - query_start) returns numeric (decimal) in
PostgreSQL, but the result was read as f64 via r.get(4). tokio-postgres
has no FromSql<f64> impl for the numeric type, so get_db_status panicked
at runtime with 'error deserializing column 4' (HTTP 500).

Cast to ::double precision (float8) to match query_duration_secs: Option<f64>.

Audited the remaining column mappings in status.rs to rule out further
type mismatches — all sound: reltuples::bigint->i64, native bigint size
columns->i64, last_vacuum/last_analyze timestamptz->DateTime<Utc>
(chrono feature enabled), schema_migrations.version TEXT->String,
count(*)::int->i32 (fixed previously).
2026-06-30 13:51:48 +08:00
xfy
116286f80f fix(database): cast count(*)::int in get_db_status to match i32 field
count(*) returns bigint (int8) in PostgreSQL, but the result was read as
i32 via conn_row.get(0). tokio-postgres's FromSql<i32> rejects an int8
column, so get_db_status panicked at runtime with
'error deserializing column 0' (HTTP 500).

Cast count(*)::int to int4, matching the adjacent setting::int column
and the total_connections/max_connections i32 struct fields. No other
queries in the file are affected: reltuples is already ::bigint -> i64,
size columns are native bigint -> i64, and system_status.rs reads its
count(*) as i64 correctly.
2026-06-30 13:47:24 +08:00
xfy
ecf3c73715 fix(database): make src/api/database compile under WASM (web-only) build
The /admin/system feature was never verified against the WASM frontend
target (dx serve builds src/main.rs with --features web and no server).
The entire src/api/database/ tree unconditionally pulled in server-only
crates (axum, sqlparser, dashmap, tokio, tokio_postgres, futures, regex)
and server-gated helpers (get_current_admin_user, parse_session_token,
get_user_by_token), so the frontend build failed with 43 errors.

Root cause + fixes (mirror the established settings.rs pattern):

- Gate server-only  imports behind #[cfg(feature = server)] in
  status/system_status/sql_console/schema/tasks/backup. The #[server] macro
  strips call sites but NOT use statements, so unresolved imports remained.
- Ungate  in main.rs: SystemSnapshot is a shared serde
  type referenced by ServerStatus.host on both targets; only the sampler
  task/SNAPSHOT static are internally server-gated.
- Gate  (pure Axum handler, zero WASM consumers) and the
  download_backup Axum handler in backup.rs.
- Move restore_backup's validation preamble (regex/backup_path/fs) inside
  the #[cfg(feature = server)] block; gate std::path/chrono::Utc imports.
- Add [[bin]] required-features=[server] for generate_highlight_css so the
  syntect-dependent build tool is skipped in web-only builds.

Genuine WASM bugs surfaced once the server bodies stopped masking them:

- system.rs: Closure import path (dioxus::prelude::wasm_bindgen ->
  wasm_bindgen, matching write.rs); setTimeout expects i32 not u32;
  editor_handle() -> editor_handle.read() (Signal is not Fn in 0.7).
- codemirror_bridge.rs: set_schema extern took &SqlSchema but SqlSchema is
  a serde type with no IntoWasmAbi. Changed both set_schema signatures to
  &JsValue; call site now serializes via serde-wasm-bindgen::to_value.
- system.rs: signals .set() inside spawn/use_future/onclick closures need
   bindings (incl. the *_f rebinding copies); added cfg_attr
  allow(unused_mut) on the 4 tab components, matching write.rs convention.

Verified: cargo check (default/server) clean; cargo check --features web
--target wasm32-unknown-unknown clean (0 errors); dx check clean; 411
cargo tests pass.
2026-06-30 13:41:23 +08:00
xfy
37d7ec49eb fix(database): harden SQL console guards + review fixes
最终 code review 发现的安全/正确性修复:

- C1 (critical): execute_one 改用 stmt.to_string() 重序列化的单条语句 SQL
  而非原始整段 SQL,保证执行的语句与护栏检查的 AST 一致;杜绝 allow_multi
  时整段 SQL 被重复执行、读写分类与实际执行解耦。
- C2 (critical): check_guards 在 AST 层结构性禁止 DROP SCHEMA(ObjectType
  匹配),不再仅靠字符串预检——防 SQL 注释/空白绕过。
- I2: 备份/恢复在 DATABASE_URL 为空时提前失败任务,而非传空串给 pg_dump/psql。
- I4: 回退备份进度计算用 u32 避免 >255 表时截断/溢出。
- I5: SQL 导出用真实表名(table 模式)而非硬编码 "export"。
- M7: 移除 SqlResult 中未使用的 total_estimate 字段。
2026-06-29 19:24:06 +08:00
xfy
6e94db0c2b feat(admin): add backup/restore tab (dual-mode + task progress polling)
新增 dashmap 依赖。create_backup 探测 pg_dump 优先(含 schema,签名头前置)、
不可用回退纯 SQL(COPY TO STDOUT 逐表,按表精确进度)。restore_backup 校验
签名头(仅本系统备份)+ 路径穿越防护 + 二次确认,探测 psql 执行。
list_backups/delete_backup 管理备份,GET /api/database/backups/{name} 下载
(admin 鉴权 + 白名单)。tasks.rs DashMap 进度表(1h 惰性 GC),前端每 1.5s
轮询 get_task_progress 显示进度条/stage,完成后刷新列表。
backups/ gitignored 不直接暴露。AGENTS.md 同步新增 CodeMirror 子项目与
数据库管理章节。
2026-06-29 19:16:11 +08:00
xfy
5c4b2a53d1 feat(admin): add data export tab (Axum streaming, SQL/CSV)
新增 GET /api/database/export Axum 流式路由(镜像 upload.rs 的 cookie→admin
鉴权),支持按表/按查询导出。CSV 用 COPY ... TO STDOUT WITH CSV 流式
(大表不 OOM),SQL 逐行拼 INSERT(含类型化转义)。表名白名单 + 查询
AST 只读强制。前端 ExportTab 触发 window.open 下载。
2026-06-29 19:01:16 +08:00
xfy
a49f47c8a6 feat(admin): add SQL console tab (read-write + 4 guards + sqlparser)
新增 sqlparser 依赖(optional+server),AppError 加 BadRequest 变体(护栏
返回动态用户可见消息)。execute_sql server function 全读写执行,4 道护栏:
(1) sqlparser AST 高危语句闸门——DROP DATABASE/SCHEMA 字符串预检绝禁、
DROP/TRUNCATE/ALTER 需勾选「我了解后果」;(2) 无 WHERE 的 UPDATE/DELETE 拒绝;
(3) 复用 STATEMENT_TIMEOUT_SECS 超时上限;(4) 前端写操作二次确认。
默认禁多语句,结果 500 行截断。get_db_schema 拉表/列供 CodeMirror 补全。

前端 SqlConsoleTab:CodeMirror 编辑器(Vim + Catppuccin 主题跟随站点 +
实时 schema 补全)+ 选项 toggles + 结果表格 + EXPLAIN 输出。
2026-06-29 18:56:15 +08:00
xfy
c4c490b881 feat(admin): add server status tab (sysinfo host metrics + cache hit rate)
新增 sysinfo 依赖(optional+server),sysinfo_sampler 后台采样任务
(SYSINFO_SAMPLE_SECS 可配,默认 0.5s)周期刷新 CPU/内存/磁盘/load 到 RwLock
快照,get_server_status 只读快照零成本。cache.rs 给 9 个缓存加 AtomicU64
hit/miss 计数 + cache_stats() 聚合命中率。

前端 ServerStatusTab:应用内指标(运行时间/连接池/会话/CPU/内存/磁盘/load)+
缓存命中率表 + 刷新按钮 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。
2026-06-29 18:45:02 +08:00
xfy
7553dcf405 feat(admin): add database status tab (tables/connections/migration version)
get_db_status server function 查 pg_catalog 聚合:数据库总大小/连接数/
表清单(行数估算+大小+死元组)/索引占用 Top10/活跃连接(过滤自身)/迁移版本。
前端 DbStatusTab:概览卡片 + 表/索引/连接表格 + 刷新按钮 + 自动刷新开关
(1s/2s/5s/30s/手动,默认手动;wasm 端用 web_sys setTimeout 轮询,无新依赖)。
2026-06-29 18:36:08 +08:00
xfy
81a4196e43 feat(admin): add /admin/system route shell with 5 tabs
新增系统管理入口页(数据库状态/服务器状态/SQL 控制台/导出/备份恢复),
顶部 tab 切换(用 use_signal 不深链)。路由注册 + 导航加「系统」项。
tab 内容待后续 task 填充。
2026-06-29 18:26:24 +08:00
xfy
c74e59485d feat(editor): add codemirror-editor subproject + Rust bridge
新建 libs/codemirror-editor(pnpm/Vite IIFE/@catppuccin/codemirror Catppuccin
Latte+Mocha 主题/@codemirror/lang-sql schema 补全/@replit/codemirror-vim),
输出 public/codemirror/。Rust 桥接 codemirror_bridge.rs 镜像 tiptap_bridge.rs
(Reflect::get 取对象字面量 + EditorHandle 持有闭包 + Drop→destroy)。
接入 Makefile/Dioxus.toml/.gitignore。

注意:thememirror 不含 catppuccin 主题,改用官方 @catppuccin/codemirror@1.0.3
(导出 catppuccinLatte/catppuccinMocha)。
2026-06-29 18:24:38 +08:00
34 changed files with 5255 additions and 28 deletions

4
.gitignore vendored
View File

@ -9,6 +9,7 @@ others/
public/style.css
public/highlight.css
public/tiptap
public/codemirror
public/lightbox
public/yggdrasil-core
public/doc
@ -21,6 +22,9 @@ uploads/*
!uploads/.gitkeep
uploads/.cache/
# Database backups
backups/
profile.json.gz
# Git worktrees

View File

@ -162,6 +162,30 @@ Core JavaScript bundle in `libs/yggdrasil-core/`, built as an IIFE library expos
Do not edit `public/yggdrasil-core/` — they are build artifacts.
## CodeMirror Editor Subproject
CodeMirror 6 code editor in `libs/codemirror-editor/`, built as an IIFE library exposing `window.CodeMirrorEditor` (object literal `{ create(containerId, options) }`) and `window.EditorOptions` (a class so `new EditorOptions()` survives TS erasure from wasm). Mirrors the tiptap-editor packaging pattern exactly.
- Output: `public/codemirror/` (`editor.js`, `editor.js.map`). No CSS file — themes are JS `Extension`s from `@catppuccin/codemirror` (Latte light / Mocha dark, matching the syntax-highlighting `themes/`).
- `make build` runs `pnpm ci --include=dev && pnpm run build` inside `libs/codemirror-editor`; the `build` script is `tsc --noEmit && vite build` (Vite 8 / Rolldown, type-check before bundle). Output is IIFE (`formats: ['iife']`, `exports: 'default'`).
- Features: `@codemirror/lang-sql` with live schema autocomplete (schema injected via `set_schema` from `get_db_schema` server function), `@replit/codemirror-vim` keymap (injected before other keymaps), Catppuccin theme hot-swap via `Compartment.reconfigure` (no instance rebuild — preserves Vim state/cursor/undo).
- Rust bridge: `src/codemirror_bridge.rs` mirrors `src/tiptap_bridge.rs``get_module()` uses `js_sys::Reflect::get` + `unchecked_into` (object literal, not a constructor, so NOT an extern fn call and NOT `dyn_into`); `EditorHandle` holds the instance + all `Closure`s; `impl Drop` calls `destroy()`.
- Shared data types `SqlSchema`/`SqlTable` (serde) compile on both targets; the wasm-bindgen externs live in a `#[cfg(target_arch = "wasm32")] mod wasm`.
- Unit tests: `pnpm test` (Vitest 4 + happy-dom), covering getValue/setValue, setTheme (Compartment), setSchema, vim toggle, onChange.
- Injected via `Dioxus.toml` `script` array (`/codemirror/editor.js`).
Do not edit `public/codemirror/` — they are build artifacts.
## Database Management (`/admin/system`)
Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服务器状态 / SQL 控制台 / 数据导出 / 备份恢复. All gated by `get_current_admin_user` (admin-only). Backend in `src/api/database/` (status/system_status/sql_console/schema/export/backup/tasks), page in `src/pages/admin/system.rs`.
- **SQL 控制台** is full read-write with 4 guards: (1) `sqlparser` AST gates — `DROP DATABASE`/`DROP SCHEMA`/`CREATE DATABASE` absolutely forbidden (string pre-check); `DROP`/`TRUNCATE`/`ALTER` require a `confirm_dangerous` checkbox; (2) `UPDATE`/`DELETE` without `WHERE` rejected; (3) `STATEMENT_TIMEOUT_SECS` query timeout (pool-level GUC); (4) frontend write-confirm dialog. Multi-statement disabled by default. Results capped at 500 rows.
- **备份恢复** uses `dashmap` task-progress table; `create_backup`/`restore_backup` return a task_id immediately and poll `get_task_progress`. Backup prefers `pg_dump` (full, incl. schema), falls back to per-table `COPY TO STDOUT` (data only) when `pg_dump` is unavailable. Backup files carry a `-- YGGDRASIL BACKUP v1` signature header; restore rejects non-system files. `backups/` is gitignored and served only via `GET /api/database/backups/{filename}` (admin-gated, path-allowlist).
- **服务器状态** uses `sysinfo` (optional, server feature) with a background sampler (`SYSINFO_SAMPLE_SECS`, default 0.5s) writing to a `RwLock<SystemSnapshot>`; server functions read the snapshot (zero sampling cost), so frontend can poll high-frequency. `src/cache.rs` exposes moka hit-rate via `AtomicU64` hit/miss counters per cache + `cache_stats()`.
- New env var: `SYSINFO_SAMPLE_SECS` (sysinfo sampling interval in seconds, supports decimals).
- New deps (all `optional = true`, server feature): `sysinfo`, `sqlparser`, `dashmap`.
## Syntax Highlighting Pipeline
- `themes/` contains Catppuccin Latte (light) and Mocha (dark) `.tmTheme` files

98
Cargo.lock generated
View File

@ -2248,7 +2248,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
"windows-core 0.62.2",
]
[[package]]
@ -2908,6 +2908,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -4008,6 +4017,15 @@ dependencies = [
"lock_api",
]
[[package]]
name = "sqlparser"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7bbffee862a796d67959a89859d6b1046bb5016d63e23835ad0da182777bbe0"
dependencies = [
"log",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@ -4111,6 +4129,19 @@ dependencies = [
"yaml-rust",
]
[[package]]
name = "sysinfo"
version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"windows",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
@ -4962,19 +4993,52 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link",
"windows-result",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@ -4986,6 +5050,17 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@ -5010,10 +5085,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@ -5395,6 +5479,7 @@ dependencies = [
"axum",
"bytes",
"chrono",
"dashmap",
"deadpool-postgres",
"dioxus",
"dotenvy",
@ -5411,10 +5496,13 @@ dependencies = [
"rand 0.8.6",
"regex",
"serde",
"serde-wasm-bindgen",
"serde_json",
"serial_test",
"sha2 0.10.9",
"sqlparser",
"syntect",
"sysinfo",
"tokio",
"tokio-postgres",
"tower-http",

View File

@ -3,6 +3,13 @@ name = "yggdrasil"
version = "0.3.0"
edition = "2021"
# 构建时工具二进制:用 syntectserver-only 依赖)生成 highlight.css。
# 仅在启用 server feature 时编译——WASM 前端构建(--no-default-features --features web
# 不会拉入 syntect此二进制若也参与编译会因找不到 syntect 报错。
[[bin]]
name = "generate_highlight_css"
required-features = ["server"]
[dependencies]
dioxus = { version = "0.7.9", features = ["fullstack", "router"] }
serde = { version = "1.0", features = ["derive"] }
@ -35,6 +42,9 @@ image = { version = "0.25", optional = true, default-features = false, features
zenwebp = { version = "0.3", optional = true }
moka = { version = "0.12", features = ["future", "sync"], optional = true }
governor = { version = "0.8", optional = true }
sysinfo = { version = "0.34", optional = true }
sqlparser = { version = "0.45", optional = true }
dashmap = { version = "6.2", optional = true }
md-5 = { version = "0.10", optional = true }
futures = { version = "0.3", optional = true }
bytes = { version = "1", optional = true }
@ -44,6 +54,8 @@ web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Eleme
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
# 把 serde 类型(如 SqlSchema序列化为 JsValue供 wasm-bindgen extern 传递。
serde-wasm-bindgen = "0.6"
[dev-dependencies]
tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "fs", "time", "sync"] }
@ -87,6 +99,9 @@ server = [
"dep:zenwebp",
"dep:moka",
"dep:governor",
"dep:sysinfo",
"dep:sqlparser",
"dep:dashmap",
"dep:md-5",
"dep:futures",
"dep:bytes",

View File

@ -11,8 +11,8 @@ watch_path = ["src", "Cargo.toml"]
[web.resource]
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css"]
script = ["/tiptap/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
[web.resource.dev]
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css"]
script = ["/tiptap/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]

View File

@ -1,9 +1,10 @@
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental build-lightbox build-lightbox-incremental build-core build-core-incremental highlight-css test doc doc-open start clippy fix
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental build-lightbox build-lightbox-incremental build-core build-core-incremental build-codemirror build-codemirror-incremental highlight-css test doc doc-open start clippy fix
build:
@$(MAKE) build-editor
@$(MAKE) build-lightbox
@$(MAKE) build-core
@$(MAKE) build-codemirror
@$(MAKE) highlight-css
@tailwindcss -i input.css -o public/style.css --minify
@$(MAKE) doc
@ -13,6 +14,7 @@ build-linux:
@$(MAKE) build-editor
@$(MAKE) build-lightbox
@$(MAKE) build-core
@$(MAKE) build-codemirror
@$(MAKE) highlight-css
@tailwindcss -i input.css -o public/style.css --minify
@dx build @client --release --debug-symbols=false --wasm-js-cfg false
@ -53,7 +55,16 @@ build-core:
build-core-incremental:
@cd libs/yggdrasil-core && pnpm run build
dev: build-editor-incremental build-lightbox-incremental build-core-incremental
build-codemirror:
@echo "Building CodeMirror editor..."
@cd libs/codemirror-editor && pnpm ci --include=dev && pnpm run build
@echo "CodeMirror editor built."
# dev 用的增量构建:跳过 pnpm ci假设 node_modules 已存在),仅 vite build。
build-codemirror-incremental:
@cd libs/codemirror-editor && pnpm run build
dev: build-editor-incremental build-lightbox-incremental build-core-incremental build-codemirror-incremental
@echo "Cleaning static/..."
@rm -rf static/
@echo "Building Tiptap editor (incremental)..."
@ -74,6 +85,7 @@ test:
@cd libs/tiptap-editor && pnpm test
@cd libs/lightbox && pnpm test
@cd libs/yggdrasil-core && pnpm test
@cd libs/codemirror-editor && pnpm test
clippy:
@cargo clippy --all-targets --all-features -- -D warnings

View File

@ -0,0 +1,30 @@
{
"name": "@yggdrasil/codemirror-editor",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && vite build",
"dev": "vite",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@catppuccin/codemirror": "^1.0.3",
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0",
"@replit/codemirror-vim": "^6.3.0",
"codemirror": "^6.0.1"
},
"devDependencies": {
"happy-dom": "^20.10.6",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
}
}

1041
libs/codemirror-editor/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { EditorOptions, CodeMirrorInstance } from '../editor';
describe('CodeMirrorInstance', () => {
let container: HTMLElement;
beforeEach(() => {
container = document.createElement('div');
container.id = 'test-cm';
document.body.appendChild(container);
});
it('getValue/setValue 往返', () => {
const inst = new CodeMirrorInstance(container, new EditorOptions());
inst.setValue('SELECT 1');
expect(inst.getValue()).toBe('SELECT 1');
inst.destroy();
});
it('初始 value 正确', () => {
const opts = new EditorOptions();
opts.value = 'SELECT * FROM posts';
const inst = new CodeMirrorInstance(container, opts);
expect(inst.getValue()).toBe('SELECT * FROM posts');
inst.destroy();
});
it('setTheme 不抛错(走 Compartment reconfigure', () => {
const inst = new CodeMirrorInstance(container, new EditorOptions());
expect(() => inst.setTheme('dark')).not.toThrow();
expect(() => inst.setTheme('light')).not.toThrow();
inst.destroy();
});
it('setSchema 更新 lang-sql 配置', () => {
const inst = new CodeMirrorInstance(container, new EditorOptions());
expect(() =>
inst.setSchema({ tables: [{ name: 'posts', columns: ['id', 'title'] }] }),
).not.toThrow();
inst.destroy();
});
it('vim 开关vim:true 注入false 不注入', () => {
const optsOn = new EditorOptions();
optsOn.vim = true;
const instOn = new CodeMirrorInstance(container, optsOn);
instOn.destroy();
const optsOff = new EditorOptions();
optsOff.vim = false;
const instOff = new CodeMirrorInstance(container, optsOff);
instOff.destroy();
// happy-dom 无法验证 keymap 行为,仅验证配置加载不抛错
});
it('onChange 在内容变更时触发', () => {
let captured = '';
const opts = new EditorOptions();
opts.onChange = (v) => {
captured = v;
};
const inst = new CodeMirrorInstance(container, opts);
inst.setValue('hello');
expect(captured).toBe('hello');
inst.destroy();
});
});

View File

@ -0,0 +1,127 @@
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { sql, PostgreSQL } from '@codemirror/lang-sql';
import { vim } from '@replit/codemirror-vim';
import { themeExtension, type ThemeName } from './themes';
/** SQL 补全用 schema 数据(由 Rust 侧从实时库拉取注入)。 */
export interface SqlSchema {
tables: { name: string; columns: string[] }[];
}
/**
* CodeMirrorEditor.create
* class interface便 TS
* wasm `new EditorOptions()` setter
*/
export class EditorOptions {
language?: string;
theme?: ThemeName;
vim?: boolean;
schema?: SqlSchema;
value?: string;
onChange?: (value: string) => void;
onReady?: () => void;
}
/**
* CodeMirror
* create() Compartment theme/schema/vim
* reconfigure Vim
*/
export class CodeMirrorInstance {
private view: EditorView;
private themeCompartment = new Compartment();
private schemaCompartment = new Compartment();
private vimCompartment = new Compartment();
constructor(container: HTMLElement, options: EditorOptions) {
const theme: ThemeName = options.theme ?? 'light';
const schema = options.schema ?? { tables: [] };
this.view = new EditorView({
state: EditorState.create({
doc: options.value ?? '',
extensions: [
basicSetup,
// vim 必须在 keymap 最前(@replit/codemirror-vim 仓库要求)
this.vimCompartment.of(options.vim ? [vim()] : []),
this.themeCompartment.of(themeExtension(theme)),
this.schemaCompartment.of(
sql({
dialect: PostgreSQL,
schema: schemaToCompletions(schema),
upperCaseKeywords: true,
}),
),
EditorView.updateListener.of((v) => {
if (v.docChanged) {
options.onChange?.(this.view.state.doc.toString());
}
}),
],
}),
parent: container,
});
options.onReady?.();
}
getValue(): string {
return this.view.state.doc.toString();
}
setValue(s: string): void {
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert: s },
});
}
/** 热切换主题不重建实例Compartment.reconfigure。 */
setTheme(theme: ThemeName): void {
this.view.dispatch({
effects: this.themeCompartment.reconfigure(themeExtension(theme)),
});
}
/** 更新 SQL 补全 schemaCompartment.reconfigure。 */
setSchema(schema: SqlSchema): void {
this.view.dispatch({
effects: this.schemaCompartment.reconfigure(
sql({
dialect: PostgreSQL,
schema: schemaToCompletions(schema),
upperCaseKeywords: true,
}),
),
});
}
focus(): void {
this.view.focus();
}
destroy(): void {
this.view.destroy();
}
}
/** 把 SqlSchema 转成 @codemirror/lang-sql 期望的补全结构。 */
function schemaToCompletions(schema: SqlSchema) {
return schema.tables.map((t) => ({
label: t.name,
type: 'table',
detail: 'table',
columns: t.columns.map((c) => ({ label: c, type: 'column' })),
}));
}
// 暴露 EditorOptions 到 window供 wasm-bindgen 用 new EditorOptions()。
// IIFE 的 name 只能挂一个全局CodeMirrorEditor故手动 hoist EditorOptions。
declare global {
interface Window {
EditorOptions: typeof EditorOptions;
}
}
window.EditorOptions = EditorOptions;

View File

@ -0,0 +1,31 @@
import { CodeMirrorInstance, EditorOptions } from './editor';
/**
* { create }
* IIFE window.CodeMirrorEditor Rust Reflect::get
* wasm-bindgen extern fn
*/
const CodeMirrorEditor = {
_instances: new Map<string, CodeMirrorInstance>(),
create(
containerId: string,
options: EditorOptions = new EditorOptions(),
): CodeMirrorInstance | null {
const container = document.getElementById(containerId);
if (!container) return null;
// 销毁同 id 的旧实例
const existing = this._instances.get(containerId);
if (existing) {
existing.destroy();
this._instances.delete(containerId);
}
const instance = new CodeMirrorInstance(container, options);
this._instances.set(containerId, instance);
return instance;
},
};
export default CodeMirrorEditor;

View File

@ -0,0 +1,11 @@
// @catppuccin/codemirror 提供现成的 Catppuccin 主题 Extension
// 与项目 themes/ 下的 Catppuccin Latte/Mocha .tmTheme 视觉一致。
import { catppuccinLatte, catppuccinMocha } from '@catppuccin/codemirror';
import type { Extension } from '@codemirror/state';
export type ThemeName = 'light' | 'dark';
/** 根据主题名返回对应的 CodeMirror 主题 Extension。 */
export function themeExtension(name: ThemeName): Extension {
return name === 'light' ? catppuccinLatte : catppuccinMocha;
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": []
},
"include": ["src"]
}

View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
// 输出直写 public/codemirror/Dioxus 直接托管,无需拷贝步骤。
outDir: resolve(__dirname, '../../public/codemirror'),
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
// IIFE 产物挂在 window.CodeMirrorEditor 上Rust 侧用 Reflect::get 取。
name: 'CodeMirrorEditor',
fileName: () => 'editor.js',
formats: ['iife'],
},
rolldownOptions: {
output: {
// 默认导出(对象字面量 { create() })成为 window.CodeMirrorEditor。
exports: 'default',
assetFileNames: 'editor.[ext]',
},
},
cssCodeSplit: false,
minify: true,
sourcemap: true,
},
});

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
},
})

590
src/api/database/backup.rs Normal file
View File

@ -0,0 +1,590 @@
#![allow(clippy::unused_unit, deprecated)]
//! 备份与恢复(读写,最高风险)。
//!
//! 备份:探测 pg_dump 可用性——可用则子进程生成完整 .sql不可用则回退纯 SQL
//! (仅数据)。备份文件含签名头。
//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护。
//! 长耗时操作走后台任务 + 进度轮询(见 [`super::tasks`])。
// Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。
#[cfg(feature = "server")]
use std::path::{Component, PathBuf};
#[cfg(feature = "server")]
use chrono::Utc;
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
// admin 鉴权 + AppError + tasks 进度表仅在 server 构建里被 server function 体引用。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::database::tasks::{self, TaskKind, TaskStatus};
#[cfg(feature = "server")]
use crate::api::error::AppError;
/// 备份目录(项目根,与 uploads/ 平级gitignored
const BACKUP_DIR: &str = "backups";
/// 文件名白名单正则:仅字母数字下划线点连字符(防路径穿越)。
const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
/// 备份文件签名头(恢复时校验,拒绝非本系统文件)。
const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
/// 备份文件元信息(列表展示用)。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BackupInfo {
pub filename: String,
pub size_bytes: u64,
/// 备份模式pg_dump / sql-fallback从签名头解析
pub mode: String,
pub created_at: Option<String>,
}
/// 发起备份,立即返回 task_id后台任务执行。
#[server(CreateBackup, "/api")]
pub async fn create_backup() -> Result<String, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let task_id = uuid::Uuid::new_v4().to_string();
tasks::insert(task_id.clone(), TaskKind::Backup);
let tid = task_id.clone();
tokio::spawn(async move {
run_backup(&tid).await;
});
Ok(task_id)
}
#[cfg(not(feature = "server"))]
{
Ok(String::new())
}
}
/// 后台执行备份pg_dump 优先,不可用回退纯 SQL。
#[cfg(feature = "server")]
async fn run_backup(task_id: &str) {
let _ = std::fs::create_dir_all(BACKUP_DIR);
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
// 探测 pg_dump
let pg_dump_ok = std::process::Command::new("pg_dump")
.arg("--version")
.output()
.is_ok();
if pg_dump_ok {
run_pg_dump_backup(task_id, &timestamp).await;
} else {
run_sql_fallback_backup(task_id, &timestamp).await;
}
}
/// pg_dump 模式:子进程生成完整备份(含 schema前置签名头。
#[cfg(feature = "server")]
async fn run_pg_dump_backup(task_id: &str, timestamp: &str) {
tasks::update(
task_id,
"正在用 pg_dump 导出",
10,
TaskStatus::Running,
None,
None,
None,
);
let filename = format!("backup_{}.sql", timestamp);
let path = backup_path(&filename);
let db_url = match std::env::var("DATABASE_URL") {
Ok(u) if !u.is_empty() => u,
_ => {
tasks::update(
task_id,
"DATABASE_URL 未配置",
100,
TaskStatus::Failed,
None,
Some("pg_dump 备份需要 DATABASE_URL".to_string()),
None,
);
return;
}
};
let mut header = String::new();
header.push_str(&format!("{}\n", BACKUP_SIGNATURE));
header.push_str(&format!("-- created_at: {}\n", Utc::now()));
header.push_str("-- mode: pg_dump\n");
// 先写签名头,再追加 pg_dump 输出。
let write_header = std::fs::write(&path, &header);
if write_header.is_err() {
tasks::update(
task_id,
"写入备份文件失败",
100,
TaskStatus::Failed,
None,
Some("无法写入备份目录".to_string()),
None,
);
return;
}
let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) {
Ok(f) => f,
Err(e) => {
tasks::update(
task_id,
"pg_dump 启动失败",
100,
TaskStatus::Failed,
None,
Some(e.to_string()),
None,
);
return;
}
};
let child = match std::process::Command::new("pg_dump")
.arg(&db_url)
.stdout(std::process::Stdio::from(stdout_file))
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
tasks::update(
task_id,
"pg_dump 启动失败",
100,
TaskStatus::Failed,
None,
Some(e.to_string()),
None,
);
return;
}
};
let output = child.wait_with_output();
match output {
Ok(o) if o.status.success() => {
tasks::update(
task_id,
"完成",
100,
TaskStatus::Done,
None,
None,
Some(filename),
);
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
tasks::update(
task_id,
"pg_dump 失败",
100,
TaskStatus::Failed,
None,
Some(stderr),
None,
);
}
Err(e) => {
tasks::update(
task_id,
"pg_dump 执行失败",
100,
TaskStatus::Failed,
None,
Some(e.to_string()),
None,
);
}
}
}
/// 纯 SQL 回退:仅备份数据(不含 schema按表计数精确进度。
#[cfg(feature = "server")]
async fn run_sql_fallback_backup(task_id: &str, timestamp: &str) {
tasks::update(
task_id,
"pg_dump 不可用,使用纯 SQL 回退(仅数据)",
10,
TaskStatus::Running,
Some("仅备份数据,不含 schema/索引/触发器".to_string()),
None,
None,
);
let filename = format!("backup_{}_sqlfallback.sql", timestamp);
let path = backup_path(&filename);
let client = match crate::db::pool::get_conn().await {
Ok(c) => c,
Err(e) => {
tasks::update(
task_id,
"数据库连接失败",
100,
TaskStatus::Failed,
None,
Some(e.to_string()),
None,
);
return;
}
};
// 取 public schema 下所有表名
let tables: Vec<String> = match client
.query(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
&[],
)
.await
{
Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(),
Err(e) => {
tasks::update(
task_id,
"读取表清单失败",
100,
TaskStatus::Failed,
None,
Some(e.to_string()),
None,
);
return;
}
};
let total = tables.len().max(1);
let mut out = String::new();
out.push_str(&format!("{}\n", BACKUP_SIGNATURE));
out.push_str(&format!("-- created_at: {}\n", Utc::now()));
out.push_str("-- mode: sql-fallback\n\n");
for (i, table) in tables.iter().enumerate() {
out.push_str(&format!("\n-- table: {}\n", table));
let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table);
match client.copy_out(&copy_stmt).await {
Ok(stream) => {
use futures::StreamExt;
// CopyOutStream 是 !Unpin必须 pin 才能调 next。
tokio::pin!(stream);
while let Some(chunk) = stream.next().await {
if let Ok(bytes) = chunk {
out.push_str(&String::from_utf8_lossy(&bytes));
}
}
}
Err(e) => {
out.push_str(&format!("-- 导出失败: {}\n", e));
}
}
// 按表更新进度(用 u32 避免大 schema 下的截断/溢出)
tasks::update(
task_id,
&format!("导出表 {}/{}", i + 1, total),
(10 + (i + 1) as u32 * 90 / total as u32).min(99) as u8,
TaskStatus::Running,
None,
None,
None,
);
}
if std::fs::write(&path, out).is_err() {
tasks::update(
task_id,
"写入备份文件失败",
100,
TaskStatus::Failed,
None,
Some("无法写入备份目录".to_string()),
None,
);
return;
}
tasks::update(
task_id,
"完成",
100,
TaskStatus::Done,
None,
None,
Some(filename),
);
}
/// 发起恢复:校验签名 + 路径穿越防护 + 二次确认,立即返回 task_id。
#[server(RestoreBackup, "/api")]
pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> {
let _user = get_current_admin_user().await?;
// 全部校验都在 server cfg 块内confirm/regex/backup_path/std::fs 都是 server-only。
// WASM 侧的 server-function 客户端桩只返回 Ok(String::new())。
#[cfg(feature = "server")]
{
if !confirm {
return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
}
// 路径穿越防护
let re = regex::Regex::new(FILENAME_RE).unwrap();
if !re.is_match(&filename) {
return Err(AppError::BadRequest("无效的文件名".to_string()).into());
}
let path = backup_path(&filename);
if !path.exists() {
return Err(AppError::NotFound("备份文件不存在").into());
}
// 签名校验:首行需含签名
let head = std::fs::read_to_string(&path)
.ok()
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
.unwrap_or_default();
if !head.contains(BACKUP_SIGNATURE) {
return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into());
}
let task_id = uuid::Uuid::new_v4().to_string();
tasks::insert(task_id.clone(), TaskKind::Restore);
let tid = task_id.clone();
let f = filename;
tokio::spawn(async move {
run_restore(&tid, &f).await;
});
Ok(task_id)
}
#[cfg(not(feature = "server"))]
{
// WASM 客户端桩:忽略参数,返回空 task_id。
let _ = (filename, confirm);
Ok(String::new())
}
}
/// 后台执行恢复:探测 psql可用则 psql -f不可用则报告。
#[cfg(feature = "server")]
async fn run_restore(task_id: &str, filename: &str) {
let path = backup_path(filename);
let db_url = match std::env::var("DATABASE_URL") {
Ok(u) if !u.is_empty() => u,
_ => {
tasks::update(
task_id,
"DATABASE_URL 未配置",
100,
TaskStatus::Failed,
None,
Some("恢复需要 DATABASE_URL".to_string()),
None,
);
return;
}
};
let psql_ok = std::process::Command::new("psql")
.arg("--version")
.output()
.is_ok();
if !psql_ok {
tasks::update(
task_id,
"psql 不可用",
100,
TaskStatus::Failed,
None,
Some("恢复需要 psql但当前环境未安装 psql".to_string()),
None,
);
return;
}
tasks::update(
task_id,
"正在用 psql 恢复",
50,
TaskStatus::Running,
None,
None,
None,
);
let output = std::process::Command::new("psql")
.arg(&db_url)
.arg("-f")
.arg(&path)
.stderr(std::process::Stdio::piped())
.output();
match output {
Ok(o) if o.status.success() => {
tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None);
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
tasks::update(
task_id,
"恢复失败",
100,
TaskStatus::Failed,
None,
Some(stderr),
None,
);
}
Err(e) => {
tasks::update(
task_id,
"psql 启动失败",
100,
TaskStatus::Failed,
None,
Some(e.to_string()),
None,
);
}
}
}
/// 列出 backups/ 目录下的备份文件元信息。
#[server(ListBackups, "/api")]
pub async fn list_backups() -> Result<Vec<BackupInfo>, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let mut infos: Vec<BackupInfo> = Vec::new();
if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".sql") {
continue;
}
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let mode = std::fs::read_to_string(entry.path())
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("-- mode:"))
.map(|l| l.trim_start_matches("-- mode: ").trim().to_string())
})
.unwrap_or_else(|| "unknown".to_string());
let created_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| {
chrono::DateTime::<Utc>::from_timestamp(d.as_secs() as i64, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_default()
});
infos.push(BackupInfo {
filename: name,
size_bytes: meta.len(),
mode,
created_at,
});
}
}
// 按创建时间降序(新的在前)
infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(infos)
}
#[cfg(not(feature = "server"))]
{
Ok(vec![])
}
}
/// 删除备份文件。
#[server(DeleteBackup, "/api")]
pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let re = regex::Regex::new(FILENAME_RE).unwrap();
if !re.is_match(&filename) {
return Err(AppError::BadRequest("无效的文件名".to_string()).into());
}
let path = backup_path(&filename);
if !path.exists() {
return Err(AppError::NotFound("备份文件不存在").into());
}
std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?;
Ok(())
}
#[cfg(not(feature = "server"))]
{
Ok(())
}
}
/// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。
#[cfg(feature = "server")]
fn backup_path(filename: &str) -> PathBuf {
let raw: PathBuf = [BACKUP_DIR, filename].iter().collect();
// 确保规范化后首两段仍是 BACKUP_DIR/filename防任何路径穿越残留
let mut it = raw.components();
let _ = it.next(); // BACKUP_DIR
if it.all(|c| !matches!(c, Component::ParentDir | Component::RootDir)) {
raw
} else {
PathBuf::from(BACKUP_DIR)
}
}
/// Axum 处理器下载备份文件admin 鉴权 + 路径白名单)。
/// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
#[cfg(feature = "server")]
pub async fn download_backup(
axum::extract::Path(filename): axum::extract::Path<String>,
headers: axum::http::HeaderMap,
) -> Result<impl axum::response::IntoResponse, (axum::http::StatusCode, String)> {
use axum::http::{header, StatusCode};
// 鉴权
let cookie_header = headers
.get("cookie")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string);
let token = match token {
Some(t) => t,
None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
};
let user = match crate::api::auth::get_user_by_token(&token).await {
Ok(Some(u)) => u,
_ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
};
if user.role != crate::models::user::UserRole::Admin {
return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
}
// 路径白名单
let re = regex::Regex::new(FILENAME_RE).unwrap();
if !re.is_match(&filename) {
return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
}
let path = backup_path(&filename);
let bytes = tokio::fs::read(&path)
.await
.map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?;
let disposition = format!("attachment; filename=\"{}\"", filename);
Ok((
StatusCode::OK,
[
(
header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/sql; charset=utf-8"),
),
(
header::CONTENT_DISPOSITION,
axum::http::HeaderValue::from_str(&disposition)
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")),
),
],
axum::body::Body::from(bytes),
))
}

233
src/api/database/export.rs Normal file
View File

@ -0,0 +1,233 @@
//! 数据导出Axum 流式路由(大文件不走 JSON 序列化)。
//!
//! 鉴权镜像 [`crate::api::upload`]:从 cookie 取 session校验管理员。
//! 两种来源:按表导出(表名白名单)/ 按查询导出强制只读AST 校验)。
//! 两种格式CSVCOPY TO STDOUT 流式)/ SQL逐行拼 INSERT
#![allow(clippy::unused_unit)]
use axum::body::Body;
use axum::extract::Query;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::IntoResponse;
use futures::StreamExt;
use serde::Deserialize;
use crate::auth::session::parse_session_token;
#[derive(Deserialize)]
pub struct ExportParams {
/// `table:<name>` 或 `query:<SELECT ...>`。
pub source: String,
/// `sql` 或 `csv`。
pub format: String,
/// 是否带表头CSV/ 列名SQL INSERT。默认 true。
pub include_columns: Option<bool>,
}
/// POST/GET /api/database/export —— 流式导出。
pub async fn export_data(
headers: HeaderMap,
Query(params): Query<ExportParams>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// 1. 鉴权cookie → session → admin
let cookie_header = headers
.get("cookie")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
let token = parse_session_token(cookie_header).map(str::to_string);
let token = match token {
Some(t) => t,
None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
};
let user = match crate::api::auth::get_user_by_token(&token).await {
Ok(Some(u)) => u,
_ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
};
if user.role != crate::models::user::UserRole::Admin {
return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
}
// 2. 解析来源 + 白名单/只读校验
let include_columns = params.include_columns.unwrap_or(true);
let (source_sql, table_name) = parse_source(&params.source)?;
match params.format.as_str() {
"csv" => export_csv(source_sql, include_columns).await,
"sql" => export_sql(source_sql, include_columns, table_name).await,
_ => Err((StatusCode::BAD_REQUEST, "不支持的格式".to_string())),
}
.map(|(body, content_type, filename)| {
let disposition = format!("attachment; filename=\"{}\"", filename);
let disposition_value = axum::http::HeaderValue::from_str(&disposition)
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment"));
let content_type_value = axum::http::HeaderValue::from_str(content_type)
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("application/octet-stream"));
(
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type_value),
(header::CONTENT_DISPOSITION, disposition_value),
],
body,
)
})
}
/// 解析导出来源,返回(内部 SQL表名
/// - `table:posts` → 校验表名合法后,`SELECT * FROM "posts"`(只读)+ 表名 "posts"。
/// - `query:SELECT ...` → 校验为只读语句后原样返回,表名为 "export"。
fn parse_source(source: &str) -> Result<(String, String), (StatusCode, String)> {
if let Some(table) = source.strip_prefix("table:") {
// 表名白名单:仅允许标识符字符,防注入
let t = table.trim();
if t.is_empty() || !is_simple_ident(t) {
return Err((StatusCode::BAD_REQUEST, "无效的表名".to_string()));
}
Ok((format!("SELECT * FROM \"{}\"", t), t.to_string()))
} else if let Some(query) = source.strip_prefix("query:") {
// 只读校验sqlparser 解析后所有语句均为 Query/Explain
let dialect = sqlparser::dialect::PostgreSqlDialect {};
let parsed = sqlparser::parser::Parser::parse_sql(&dialect, query)
.map_err(|_| (StatusCode::BAD_REQUEST, "SQL 解析失败".to_string()))?;
if parsed.iter().any(|s| !is_read_only_ast(s)) {
return Err((
StatusCode::BAD_REQUEST,
"导出查询必须是只读SELECT/EXPLAIN".to_string(),
));
}
Ok((query.to_string(), "export".to_string()))
} else {
Err((
StatusCode::BAD_REQUEST,
"source 必须是 table:<name> 或 query:<sql>".to_string(),
))
}
}
/// 简单标识符校验(字母数字下划线,防 SQL 注入与路径穿越)。
fn is_simple_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// 判断 AST 是否只读SELECT/EXPLAIN
fn is_read_only_ast(stmt: &sqlparser::ast::Statement) -> bool {
use sqlparser::ast::Statement;
matches!(stmt, Statement::Query(_) | Statement::Explain { .. })
}
/// CSV 导出:用 COPY ... TO STDOUT WITH CSV 流式(大表不 OOM
async fn export_csv(
source_sql: String,
include_columns: bool,
) -> Result<(Body, &'static str, String), (StatusCode, String)> {
let client = crate::db::pool::get_conn()
.await
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "数据库不可用".to_string()))?;
let header_clause = if include_columns { "HEADER" } else { "" };
let copy_stmt = format!("COPY ({}) TO STDOUT WITH CSV {}", source_sql, header_clause);
let stream = client
.copy_out(&copy_stmt)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("COPY 失败:{e}")))?;
// tokio_postgres 的 copy_out 流产出 Bytes直接转 axum Body。
let mapped = stream.map(|res| res.map_err(std::io::Error::other));
let body = Body::from_stream(mapped);
Ok((body, "text/csv; charset=utf-8", "export.csv".to_string()))
}
/// SQL 导出:逐行拼 INSERT纯数据不含 DDL
async fn export_sql(
source_sql: String,
include_columns: bool,
table_name: String,
) -> Result<(Body, &'static str, String), (StatusCode, String)> {
let client = crate::db::pool::get_conn()
.await
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "数据库不可用".to_string()))?;
let rows = client
.query(&source_sql, &[])
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("查询失败:{e}")))?;
let columns: Vec<String> = rows
.first()
.map(|r| r.columns().iter().map(|c| c.name().to_string()).collect())
.unwrap_or_default();
let mut out = String::new();
out.push_str("-- Yggdrasil 数据导出(仅数据,不含 schema\n");
let col_clause = if include_columns && !columns.is_empty() {
format!("({})", columns.join(", "))
} else {
String::new()
};
for r in &rows {
let vals: Vec<String> = (0..r.len())
.map(|i| sql_quote_cell(r, i))
.collect();
out.push_str(&format!(
"INSERT INTO {} {} VALUES ({});\n",
&table_name,
col_clause,
vals.join(", ")
));
}
let body = Body::from(out);
Ok((
body,
"application/sql; charset=utf-8",
"export.sql".to_string(),
))
}
/// 把一个单元格值转成 SQL 字面量字符串单引号转义数字原样NULL
fn sql_quote_cell(row: &tokio_postgres::Row, idx: usize) -> String {
let ty = row.columns().get(idx).map(|c| c.type_().name()).unwrap_or("");
match ty {
"int2" => row
.try_get::<_, Option<i16>>(idx)
.ok()
.flatten()
.map(|v| v.to_string())
.unwrap_or_else(|| "NULL".into()),
"int4" => row
.try_get::<_, Option<i32>>(idx)
.ok()
.flatten()
.map(|v| v.to_string())
.unwrap_or_else(|| "NULL".into()),
"int8" => row
.try_get::<_, Option<i64>>(idx)
.ok()
.flatten()
.map(|v| v.to_string())
.unwrap_or_else(|| "NULL".into()),
"float4" | "float8" => row
.try_get::<_, Option<f64>>(idx)
.ok()
.flatten()
.map(|v| v.to_string())
.unwrap_or_else(|| "NULL".into()),
"bool" => row
.try_get::<_, Option<bool>>(idx)
.ok()
.flatten()
.map(|v| if v { "TRUE" } else { "FALSE" }.to_string())
.unwrap_or_else(|| "NULL".into()),
_ => {
// 字符串/时间戳等:单引号转义
match row.try_get::<_, Option<String>>(idx) {
Ok(Some(s)) => format!("'{}'", s.replace('\'', "''")),
_ => "NULL".into(),
}
}
}
}

23
src/api/database/mod.rs Normal file
View File

@ -0,0 +1,23 @@
//! 数据库管理 server functions 与 Axum 处理器。
//!
//! 按功能拆分子模块:
//! - [`status`]:数据库运行状态聚合(表/连接/死元组/迁移版本)。
//!
//! 后续 task 会新增:`system_status`(服务器状态)、`sql_console`SQL 执行+护栏)、
//! `schema`SQL 补全数据)、`export`(流式导出)、`backup`/`tasks`(备份恢复+进度)。
/// 数据库运行状态聚合查询。
pub mod status;
/// 服务器状态聚合查询(应用内 + 主机层)。
pub mod system_status;
/// SQL 控制台执行(全读写 + 4 护栏)。
pub mod sql_console;
/// SQL 补全用 schema 拉取。
pub mod schema;
/// 数据导出 Axum 流式处理器(仅 server纯 Axum 路由,无 WASM 消费者)。
#[cfg(feature = "server")]
pub mod export;
/// 备份/恢复(双模式 + 任务进度)。
pub mod backup;
/// 备份/恢复异步任务进度表。
pub mod tasks;

View File

@ -0,0 +1,53 @@
#![allow(clippy::unused_unit, deprecated)]
//! SQL 补全用 schema 拉取(供 CodeMirror lang-sql 表/列补全)。
use dioxus::prelude::*;
// admin 鉴权 + DB 查询仅在 server 构建里被 server function 体引用。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::db::pool::get_conn;
// SqlSchema/SqlTable 是两端共享的纯数据类型(定义在 codemirror_bridge
use crate::codemirror_bridge::SqlSchema;
/// 拉取数据库 schema表名 + 列名),供 CodeMirror SQL 补全。
#[server(GetDbSchema, "/api")]
pub async fn get_db_schema() -> Result<SqlSchema, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let client = get_conn().await.map_err(AppError::db_conn)?;
let rows = client
.query(
"SELECT t.table_name, \
string_agg(c.column_name, ',' ORDER BY c.ordinal_position) \
FROM information_schema.tables t \
JOIN information_schema.columns c USING (table_schema, table_name) \
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' \
GROUP BY t.table_name ORDER BY t.table_name",
&[],
)
.await
.map_err(AppError::query)?;
let tables = rows
.into_iter()
.map(|r| {
let cols: String = r.get(1);
crate::codemirror_bridge::SqlTable {
name: r.get(0),
columns: cols.split(',').map(|s| s.to_string()).collect(),
}
})
.collect();
Ok(SqlSchema { tables })
}
#[cfg(not(feature = "server"))]
{
Ok(SqlSchema::default())
}
}

View File

@ -0,0 +1,344 @@
#![allow(clippy::unused_unit, deprecated)]
//! SQL 控制台执行(全读写 + 4 道护栏)。
//!
//! 护栏:
//! 1. 高危语句闸门:`DROP DATABASE`/`DROP SCHEMA`(字符串预检)绝禁;
//! `DROP`/`TRUNCATE`/`ALTER` 需 `confirm_dangerous`。
//! 2. 无 WHERE 拦截:`UPDATE`/`DELETE` 无 `selection` 拒绝。
//! 3. 查询超时上限:复用 `STATEMENT_TIMEOUT_SECS`pool 层已注入 GUC
//! 4. 前端二次确认(前端实现)。
//!
//! 默认禁止多语句(`allow_multi` 放开)。
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
// admin 鉴权 + DB 查询仅在 server 构建里被 server function 体引用。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::db::pool::get_conn;
#[derive(Deserialize, Serialize, Debug, Clone, Copy)]
pub struct ExecuteSqlOpts {
/// 是否允许多语句(`;` 分隔),默认 false。
pub allow_multi: bool,
/// 是否勾选「我了解后果」(放开 DROP/TRUNCATE/ALTER 等高危)。
pub confirm_dangerous: bool,
/// 是否带 EXPLAIN 执行计划。
pub with_explain: bool,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct SqlResult {
pub columns: Vec<String>,
/// 每格用 JSON 表示text/int/timestamp/bool/null
pub rows: Vec<Vec<serde_json::Value>>,
pub affected_rows: u64,
pub elapsed_ms: u64,
/// 语句类型(来自 AST如 "Select"/"Update"/"CreateTable")。
pub statement_type: String,
pub explain: Option<String>,
/// 是否因 500 行上限截断。
pub truncated: bool,
}
/// 结果行数上限(超出截断 + 提示)。
const MAX_ROWS: usize = 500;
/// 绝对禁止的语句关键词字符串预检sqlparser 无 ObjectType::Database/Schema
/// 命中即拒,不可放行。
const ABSOLUTELY_FORBIDDEN: &[&str] = &["drop database", "drop schema", "create database"];
/// 护栏检查返回值。
#[derive(Debug)]
enum GuardResult {
Allowed,
/// 需 confirm_dangerous 才放行。
NeedsConfirm,
/// 不可放行(附带原因)。
Forbidden(String),
}
/// 护栏 1+2sqlparser 解析后遍历 AST检查高危语句与无 WHERE 的 UPDATE/DELETE。
#[cfg(feature = "server")]
fn check_guards(
asts: &[sqlparser::ast::Statement],
confirm_dangerous: bool,
) -> GuardResult {
use sqlparser::ast::{ObjectType, Statement};
for stmt in asts {
match stmt {
// 护栏 1绝禁DROP SCHEMA 永远禁止ObjectType 无 Database 变体,
// DROP DATABASE 由字符串预检 + AST 双重拦截——见上方 ABSOLUTELY_FORBIDDEN
// 这里在 AST 层结构性禁止 DROP SCHEMA防 SQL 注释/空白绕过字符串预检。
Statement::Drop {
object_type: ObjectType::Schema,
..
} => {
return GuardResult::Forbidden("禁止 DROP SCHEMA".to_string());
}
// 护栏 1需确认的高危语句DROP TABLE/VIEW/INDEX 等、TRUNCATE、ALTER
Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => {
if !confirm_dangerous {
return GuardResult::NeedsConfirm;
}
}
// 护栏 2UPDATE 无 WHERE
Statement::Update { selection: None, .. } => {
return GuardResult::Forbidden(
"UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
);
}
// 护栏 2DELETE 无 WHERE
Statement::Delete { selection: None, .. } => {
return GuardResult::Forbidden(
"DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
);
}
_ => {}
}
}
GuardResult::Allowed
}
/// 提取语句类型名AST 变体名,如 "Select"/"Insert"/"Update")。
#[cfg(feature = "server")]
fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String {
use sqlparser::ast::Statement;
let name = match stmt {
Statement::Query(_) => "Select",
Statement::Insert { .. } => "Insert",
Statement::Update { .. } => "Update",
Statement::Delete { .. } => "Delete",
Statement::CreateTable { .. } => "CreateTable",
Statement::AlterTable { .. } => "AlterTable",
Statement::Drop { .. } => "Drop",
Statement::Truncate { .. } => "Truncate",
Statement::Explain { .. } => "Explain",
_ => "Other",
};
name.to_string()
}
/// 判断语句是否只读SELECT/EXPLAIN/SHOW/WITH...SELECT
#[cfg(feature = "server")]
fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
use sqlparser::ast::Statement;
matches!(
stmt,
Statement::Query(_) | Statement::Explain { .. }
)
}
/// 把一列的值转成 JSON按 PG 类型名分发)。
#[cfg(feature = "server")]
fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
use serde_json::json;
let ty = row.columns().get(idx).map(|c| c.type_().name()).unwrap_or("");
match ty {
"int2" => row
.try_get::<_, Option<i16>>(idx)
.ok()
.flatten()
.map(|v| json!(v))
.unwrap_or(serde_json::Value::Null),
"int4" => row
.try_get::<_, Option<i32>>(idx)
.ok()
.flatten()
.map(|v| json!(v))
.unwrap_or(serde_json::Value::Null),
"int8" => row
.try_get::<_, Option<i64>>(idx)
.ok()
.flatten()
.map(|v| json!(v))
.unwrap_or(serde_json::Value::Null),
"float4" => row
.try_get::<_, Option<f32>>(idx)
.ok()
.flatten()
.map(|v| json!(v))
.unwrap_or(serde_json::Value::Null),
"float8" => row
.try_get::<_, Option<f64>>(idx)
.ok()
.flatten()
.map(|v| json!(v))
.unwrap_or(serde_json::Value::Null),
"bool" => row
.try_get::<_, Option<bool>>(idx)
.ok()
.flatten()
.map(|v| json!(v))
.unwrap_or(serde_json::Value::Null),
// 其余text/varchar/timestamp/jsonb/...)一律按字符串取,失败则 null
_ => row
.try_get::<_, Option<String>>(idx)
.ok()
.flatten()
.map(|v| json!(v))
.unwrap_or(serde_json::Value::Null),
}
}
/// 执行 SQL全读写管理员。护栏见模块文档。
#[server(ExecuteSql, "/api")]
pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
use sqlparser::dialect::PostgreSqlDialect;
use sqlparser::parser::Parser;
// 护栏 1绝禁字符串预检 DROP/CREATE DATABASE、DROP SCHEMA
let normalized = sql.to_lowercase();
for forbidden in ABSOLUTELY_FORBIDDEN {
if normalized.contains(forbidden) {
return Err(AppError::BadRequest(format!(
"禁止的操作:{}",
forbidden.to_uppercase()
))
.into());
}
}
// 解析 SQL
let dialect = PostgreSqlDialect {};
let asts = Parser::parse_sql(&dialect, &sql)
.map_err(|e| AppError::BadRequest(format!("SQL 解析失败:{e}")))?;
if asts.is_empty() {
return Err(AppError::BadRequest("空的 SQL 语句".into()).into());
}
// 多语句检查(默认禁止)
if asts.len() > 1 && !opts.allow_multi {
return Err(AppError::BadRequest(
"检测到多条语句,请勾选「允许多语句」后再执行".into(),
)
.into());
}
// 护栏 1+2AST 检查
match check_guards(&asts, opts.confirm_dangerous) {
GuardResult::Forbidden(msg) => {
return Err(AppError::BadRequest(msg).into());
}
GuardResult::NeedsConfirm => {
return Err(AppError::BadRequest(
"高危操作DROP/TRUNCATE/ALTER需勾选「我了解后果」".into(),
)
.into());
}
GuardResult::Allowed => {}
}
let client = get_conn().await.map_err(AppError::db_conn)?;
let start = std::time::Instant::now;
// 逐条执行:每条用其 AST 重序列化的形式stmt.to_string()
// 而非原始整段 SQL——保证执行的语句与护栏检查的 AST 完全一致,
// 避免 allow_multi 时整段 SQL 被重复执行,也杜绝读/写分类与实际执行解耦。
let mut last_result = SqlResult::default();
for stmt in &asts {
// 重序列化单条语句为可执行 SQL去掉末尾分号避免与 execute 的隐式分号冲突)
let stmt_sql = stmt.to_string();
last_result = execute_one(&client, stmt, &stmt_sql, opts.with_explain, start).await?;
}
Ok(last_result)
}
#[cfg(not(feature = "server"))]
{
let _ = (sql, opts);
Ok(SqlResult::default())
}
}
/// 执行单条语句,返回结果。
///
/// `stmt_sql` 必须是 `stmt` 重序列化后的**单条**语句 SQL不含其他语句
/// 保证护栏检查的 AST 与实际执行的语句一致。
#[cfg(feature = "server")]
async fn execute_one(
client: &deadpool_postgres::Object,
stmt: &sqlparser::ast::Statement,
stmt_sql: &str,
with_explain: bool,
start: impl Fn() -> std::time::Instant + Copy,
) -> Result<SqlResult, ServerFnError> {
let statement_type = statement_type_name(stmt);
let read_only = is_read_only(stmt);
if with_explain && read_only {
// EXPLAIN 模式:包裹单条语句取执行计划,取首列文本拼接
let explain_sql = format!("EXPLAIN {}", stmt_sql.trim_end_matches(';'));
let rows = client
.query(&explain_sql, &[])
.await
.map_err(AppError::query)?;
let explain = rows
.iter()
.filter_map(|r| r.try_get::<_, String>(0).ok())
.collect::<Vec<_>>()
.join("\n");
return Ok(SqlResult {
statement_type,
explain: Some(explain),
elapsed_ms: start().elapsed().as_millis() as u64,
..Default::default()
});
}
if read_only {
// 只读:取结果集。列名从第一行取(空结果集时无列名,前端容错)。
let rows = client
.query(stmt_sql, &[])
.await
.map_err(AppError::query)?;
let columns: Vec<String> = rows
.first()
.map(|r| {
r.columns()
.iter()
.map(|c| c.name().to_string())
.collect()
})
.unwrap_or_default();
let mut data: Vec<Vec<serde_json::Value>> = Vec::new();
let mut truncated = false;
for r in &rows {
if data.len() >= MAX_ROWS {
truncated = true;
break;
}
let row: Vec<serde_json::Value> = (0..r.len()).map(|i| col_to_json(r, i)).collect();
data.push(row);
}
Ok(SqlResult {
columns,
rows: data,
truncated,
statement_type,
elapsed_ms: start().elapsed().as_millis() as u64,
..Default::default()
})
} else {
// 写操作:返回影响行数
let affected = client
.execute(stmt_sql, &[])
.await
.map_err(AppError::query)?;
Ok(SqlResult {
affected_rows: affected,
statement_type,
elapsed_ms: start().elapsed().as_millis() as u64,
..Default::default()
})
}
}

240
src/api/database/status.rs Normal file
View File

@ -0,0 +1,240 @@
#![allow(clippy::unused_unit, deprecated)]
//! 数据库运行状态聚合查询(只读)。
//!
//! 全部查询走 `pg_catalog` / `pg_stat_*` / `schema_migrations`,零写、零风险。
//! [`get_db_status`] 在一次 server function 调用里聚合多组数据返回。
use chrono::{DateTime, Utc};
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
// 仅 server 构建用到admin 鉴权 + DB 查询。WASM 侧的 server-function 客户端桩
// 不解析这些符号,必须 gate 以避免在非 server 构建里找不到 server-only 符号。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::db::pool::get_conn;
/// 数据库状态聚合数据。
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct DbStatus {
/// 当前数据库总大小(字节)。
pub db_size_bytes: i64,
/// 当前数据库的活跃连接数。
pub total_connections: i32,
/// PG 配置的最大连接数(`max_connections`)。
pub max_connections: i32,
/// 已应用的最新迁移版本(`schema_migrations.version`)。
pub migration_version: Option<String>,
/// 最新迁移的应用时间。
pub migration_applied_at: Option<DateTime<Utc>>,
/// 用户表清单(按总大小降序)。
pub tables: Vec<TableInfo>,
/// 索引占用 Top N。
pub top_indexes: Vec<IndexInfo>,
/// 活跃连接列表(已过滤掉自身这条查询)。
pub active_connections: Vec<ConnInfo>,
}
/// 单张表的统计信息。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TableInfo {
pub name: String,
/// 行数total_size 小于阈值时为真实 COUNT(*),否则回退 reltuples 估算(见 row_count_estimated
pub row_count: i64,
/// true 表示 row_count 是 reltuples 估算值(大表回退,未 ANALYZE 时 reltuples=-1 会显示为估算)。
pub row_count_estimated: bool,
pub table_size_bytes: i64,
pub index_size_bytes: i64,
pub total_size_bytes: i64,
pub last_vacuum: Option<DateTime<Utc>>,
pub last_analyze: Option<DateTime<Utc>>,
pub dead_tuples: i64,
pub live_tuples: i64,
}
/// 索引占用信息。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct IndexInfo {
pub name: String,
pub table_name: String,
pub size_bytes: i64,
}
/// 单条活跃连接信息。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ConnInfo {
pub pid: i32,
pub user: String,
pub state: Option<String>,
pub query: Option<String>,
/// 当前查询已运行秒数(无查询时为 None
pub query_duration_secs: Option<f64>,
}
/// 获取数据库运行状态(只读,管理员)。
#[server(GetDbStatus, "/api")]
pub async fn get_db_status() -> Result<DbStatus, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let client = get_conn().await.map_err(AppError::db_conn)?;
// 数据库总大小
let db_size: i64 = client
.query_one("SELECT pg_database_size(current_database())", &[])
.await
.map_err(AppError::query)?
.get(0);
// 当前库连接数 + 全局最大连接数。
// count(*) 原生返回 bigint(int8),与下方 setting::int 一并显式转 int4
// 以匹配 total_connections/max_connections 的 i32 类型(否则 FromSql 反序列化失败)。
let conn_row = client
.query_one(
"SELECT count(*)::int, \
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') \
FROM pg_stat_activity WHERE datname = current_database()",
&[],
)
.await
.map_err(AppError::query)?;
let total_conn: i32 = conn_row.get(0);
let max_conn: i32 = conn_row.get(1);
// 最新迁移版本schema_migrations 由 migrate.rs 创建)
let migration = client
.query_opt(
"SELECT version, applied_at FROM schema_migrations \
ORDER BY applied_at DESC LIMIT 1",
&[],
)
.await
.map_err(AppError::query)?;
let (migration_version, migration_applied_at) = match migration {
Some(row) => (Some(row.get(0)), Some(row.get(1))),
None => (None, None),
};
// 表清单:大小/统计走 catalog行数对小表用真实 COUNT(*),大表回退 reltuples 估算。
// 阈值 100MB超过则 COUNT(*) 成本过高(全表扫描),降级为估算值并标注。
const COUNT_SIZE_THRESHOLD: i64 = 100 * 1024 * 1024;
let table_rows = client
.query(
"SELECT c.relname, c.reltuples::bigint, pg_relation_size(c.oid), \
pg_total_relation_size(c.oid) - pg_relation_size(c.oid), \
pg_total_relation_size(c.oid), s.last_vacuum, s.last_analyze, \
s.n_dead_tup, s.n_live_tup \
FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid \
WHERE c.relkind = 'r' AND n.nspname = 'public' \
ORDER BY pg_total_relation_size(c.oid) DESC",
&[],
)
.await
.map_err(AppError::query)?;
let tables = {
let mut out = Vec::with_capacity(table_rows.len());
for r in table_rows {
let name: String = r.get(0);
let reltuples: i64 = r.get(1);
let total_size: i64 = r.get(4);
// 小表:真实 COUNT(*);大表:回退 reltuples 估算。
// relname 来自 pg_class 可信,但表名可能含大写/特殊字符,需 PG 标识符转义(双引号包裹,内部双引号双写)。
let (row_count, estimated) = if total_size < COUNT_SIZE_THRESHOLD {
let ident = format!("\"{}\"", name.replace('"', "\"\""));
let cnt: i64 = client
.query_one(&format!("SELECT count(*)::bigint FROM {}", ident), &[])
.await
.map_err(AppError::query)?
.get(0);
(cnt, false)
} else {
(reltuples, true)
};
out.push(TableInfo {
name,
row_count,
row_count_estimated: estimated,
table_size_bytes: r.get(2),
index_size_bytes: r.get(3),
total_size_bytes: total_size,
last_vacuum: r.get(5),
last_analyze: r.get(6),
dead_tuples: r.get(7),
live_tuples: r.get(8),
});
}
out
};
// 索引占用 Top 10
let index_rows = client
.query(
"SELECT c.relname AS index_name, cl.relname AS table_name, \
pg_relation_size(c.oid) \
FROM pg_class c \
JOIN pg_index i ON i.indexrelid = c.oid \
JOIN pg_class cl ON cl.oid = i.indrelid \
JOIN pg_namespace n ON n.oid = cl.relnamespace \
WHERE n.nspname = 'public' \
ORDER BY pg_relation_size(c.oid) DESC LIMIT 10",
&[],
)
.await
.map_err(AppError::query)?;
let top_indexes = index_rows
.into_iter()
.map(|r| IndexInfo {
name: r.get(0),
table_name: r.get(1),
size_bytes: r.get(2),
})
.collect();
// 活跃连接(过滤自身 pid避免循环显示
// extract(epoch FROM ...) 原生返回 numeric(decimal)tokio-postgres 无 FromSql<f64>
// 实现该类型;显式 ::double precision 转 float8 以匹配 query_duration_secs 的 f64。
let conn_rows = client
.query(
"SELECT pid, usename, state, query, \
extract(epoch FROM now() - query_start)::double precision \
FROM pg_stat_activity \
WHERE datname = current_database() AND pid <> pg_backend_pid() \
ORDER BY query_start DESC NULLS LAST LIMIT 50",
&[],
)
.await
.map_err(AppError::query)?;
let active_connections = conn_rows
.into_iter()
.map(|r| ConnInfo {
pid: r.get(0),
user: r.get::<_, Option<String>>(1).unwrap_or_default(),
state: r.get(2),
query: r.get(3),
query_duration_secs: r.get(4),
})
.collect();
Ok(DbStatus {
db_size_bytes: db_size,
total_connections: total_conn,
max_connections: max_conn,
migration_version,
migration_applied_at,
tables,
top_indexes,
active_connections,
})
}
#[cfg(not(feature = "server"))]
{
Ok(DbStatus::default())
}
}

View File

@ -0,0 +1,121 @@
#![allow(clippy::unused_unit, deprecated)]
//! 服务器状态聚合查询(只读):应用内指标 + 主机层指标。
//!
//! 应用内DB 连接池、moka 缓存命中率、活跃会话数、进程运行时间。
//! 主机层sysinfo 后台采样快照CPU/内存/磁盘/load由 [`sysinfo_sampler`] 维护。
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
// admin 鉴权 + AppError 仅在 server 构建里被 server function 体引用。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError;
/// 单个缓存的统计快照(前端展示用)。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CacheStat {
pub name: String,
pub entry_count: u64,
pub hits: u64,
pub misses: u64,
/// 命中率0.01.0)。
pub hit_rate: f64,
}
/// 服务器状态聚合数据。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ServerStatus {
/// 进程运行时间(秒)。
pub uptime_secs: u64,
/// DB 连接池总大小(已创建连接数)。
pub pool_size: usize,
/// 连接池最大容量。
pub pool_max_size: usize,
/// 当前空闲可用连接数。
pub pool_available: usize,
/// 正在等待获取连接的请求数。
pub pool_waiting: usize,
/// 有效会话数sessions 表 expires_at > now())。
pub active_sessions: i64,
/// 主机层指标快照CPU/内存/磁盘等)。
pub host: crate::sysinfo_sampler::SystemSnapshot,
/// 各缓存命中率与条目数。
pub caches: Vec<CacheStat>,
}
/// 进程启动时刻LazyLock用于计算运行时间。
#[cfg(feature = "server")]
static STARTED_AT: std::sync::LazyLock<std::time::Instant> =
std::sync::LazyLock::new(std::time::Instant::now);
/// 获取服务器状态(只读,管理员)。
#[server(GetServerStatus, "/api")]
pub async fn get_server_status() -> Result<ServerStatus, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
use crate::cache::cache_stats;
use crate::db::pool::{get_conn, DB_POOL};
// 连接池状态
let pool_status = DB_POOL.status();
let pool_size = pool_status.size;
let pool_max_size = pool_status.max_size;
let pool_available = pool_status.available;
let pool_waiting = pool_status.waiting;
// 活跃会话数
let client = get_conn().await.map_err(AppError::db_conn)?;
let active_sessions: i64 = client
.query_one("SELECT count(*) FROM sessions WHERE expires_at > now()", &[])
.await
.map_err(AppError::query)?
.get(0);
// 进程运行时间
let uptime_secs = STARTED_AT.elapsed().as_secs();
// 主机层快照
let host = crate::sysinfo_sampler::read_snapshot().await;
// 缓存统计
let caches = cache_stats()
.into_iter()
.map(|s| CacheStat {
name: s.name.to_string(),
entry_count: s.entry_count,
hits: s.hits,
misses: s.misses,
hit_rate: s.hit_rate,
})
.collect();
Ok(ServerStatus {
uptime_secs,
pool_size,
pool_max_size,
pool_available,
pool_waiting,
active_sessions,
host,
caches,
})
}
#[cfg(not(feature = "server"))]
{
Ok(ServerStatus {
uptime_secs: 0,
pool_size: 0,
pool_max_size: 0,
pool_available: 0,
pool_waiting: 0,
active_sessions: 0,
host: crate::sysinfo_sampler::read_snapshot().await,
caches: vec![],
})
}
}

124
src/api/database/tasks.rs Normal file
View File

@ -0,0 +1,124 @@
#![allow(clippy::unused_unit, deprecated)]
//! 备份/恢复的异步任务进度表DashMap
//!
//! create_backup/restore_backup 立即返回 task_id后台任务跑时通过
//! [`update`] 更新进度,前端轮询 [`get_task_progress`]。
//! 已完成超过 1 小时的任务惰性清理,避免内存累积。
use chrono::{DateTime, Utc};
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
// admin 鉴权仅 server 构建用到。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user;
// DashMap / LazyLock 仅 server 构建持有任务进度表WASM 端只序列化 TaskProgress。
#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")]
use dashmap::DashMap;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub enum TaskKind {
Backup,
Restore,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub enum TaskStatus {
Running,
Done,
Failed,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct TaskProgress {
pub id: String,
pub kind: TaskKind,
pub stage: String,
pub percent: u8,
pub detail: Option<String>,
pub status: TaskStatus,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
/// 完成后的备份文件名(下载/恢复用)。
pub result_filename: Option<String>,
}
#[cfg(feature = "server")]
static TASKS: LazyLock<DashMap<String, TaskProgress>> = LazyLock::new(DashMap::new);
/// 注册新任务(初始 Running0%)。
#[cfg(feature = "server")]
pub(super) fn insert(id: String, kind: TaskKind) {
TASKS.insert(
id.clone(),
TaskProgress {
id,
kind,
stage: "排队中".to_string(),
percent: 0,
detail: None,
status: TaskStatus::Running,
error: None,
created_at: Utc::now(),
result_filename: None,
},
);
}
/// 更新任务进度(后台任务调用)。
#[cfg(feature = "server")]
pub(super) fn update(
id: &str,
stage: &str,
percent: u8,
status: TaskStatus,
detail: Option<String>,
error: Option<String>,
result_filename: Option<String>,
) {
if let Some(mut p) = TASKS.get_mut(id) {
p.stage = stage.to_string();
p.percent = percent;
p.status = status;
p.detail = detail;
p.error = error;
p.result_filename = result_filename;
}
}
/// 惰性清理已完成超过 1 小时的任务(查询时顺便清)。
#[cfg(feature = "server")]
fn gc_old() {
let cutoff = Utc::now() - chrono::Duration::hours(1);
TASKS.retain(|_, p| !(matches!(p.status, TaskStatus::Done | TaskStatus::Failed) && p.created_at < cutoff));
}
/// 查询任务进度(轮询用)。
#[server(GetTaskProgress, "/api")]
pub async fn get_task_progress(task_id: String) -> Result<TaskProgress, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
gc_old();
TASKS.get(&task_id)
.map(|p| p.clone())
.ok_or_else(|| crate::api::error::AppError::NotFound("任务不存在").into())
}
#[cfg(not(feature = "server"))]
{
Ok(TaskProgress {
id: task_id,
kind: TaskKind::Backup,
stage: String::new(),
percent: 0,
detail: None,
status: TaskStatus::Done,
error: None,
created_at: Utc::now(),
result_filename: None,
})
}
}

View File

@ -14,6 +14,8 @@ pub enum AppError {
Forbidden(&'static str),
/// 资源不存在404
NotFound(&'static str),
/// 客户端请求错误400——业务规则拒绝消息原样透传给用户。
BadRequest(String),
/// 数据库连接失败。
DbConn(String),
/// SQL 查询执行失败。
@ -54,6 +56,8 @@ impl From<AppError> for ServerFnError {
AppError::Unauthorized(m) => m.to_string(),
AppError::Forbidden(m) => m.to_string(),
AppError::NotFound(m) => m.to_string(),
// BadRequest 是业务规则拒绝(如 SQL 护栏拦截),消息原样透传给用户。
AppError::BadRequest(m) => m.to_string(),
AppError::DbConn(_) => "服务暂时不可用".to_string(),
AppError::Query(_) => "操作失败".to_string(),
AppError::Transaction(_) => "操作失败".to_string(),

View File

@ -10,6 +10,8 @@ pub mod auth;
pub mod comments;
/// CSRF 防护中间件。
pub mod csrf;
/// 数据库管理接口(运行状态 / SQL 控制台 / 导出 / 备份恢复)。
pub mod database;
/// 应用错误类型与转换。
pub mod error;
/// 健康检查端点liveness / readiness

View File

@ -203,6 +203,98 @@ static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
.build()
});
// ============================================================================
// 命中率统计(供系统状态面板展示)
// ============================================================================
/// 单个缓存的命中/未命中计数。用 AtomicU64 在 get_* 路径上记录。
#[cfg(feature = "server")]
pub struct CacheStats {
pub name: &'static str,
hits: std::sync::atomic::AtomicU64,
misses: std::sync::atomic::AtomicU64,
}
#[cfg(feature = "server")]
impl CacheStats {
pub const fn new(name: &'static str) -> Self {
Self {
name,
hits: std::sync::atomic::AtomicU64::new(0),
misses: std::sync::atomic::AtomicU64::new(0),
}
}
fn record_hit(&self) {
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
fn record_miss(&self) {
self.misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
// 每个缓存一份统计实例const启动时零开销初始化
#[cfg(feature = "server")]
static POST_LIST_STATS: CacheStats = CacheStats::new("文章列表");
#[cfg(feature = "server")]
static TAG_STATS: CacheStats = CacheStats::new("标签");
#[cfg(feature = "server")]
static SINGLE_POST_STATS: CacheStats = CacheStats::new("单篇文章");
#[cfg(feature = "server")]
static POST_STATS_STATS: CacheStats = CacheStats::new("文章统计");
#[cfg(feature = "server")]
static TAG_POSTS_STATS: CacheStats = CacheStats::new("标签文章");
#[cfg(feature = "server")]
static COMMENT_STATS: CacheStats = CacheStats::new("评论");
#[cfg(feature = "server")]
static PENDING_COUNT_STATS: CacheStats = CacheStats::new("待审评论数");
#[cfg(feature = "server")]
static SESSION_STATS: CacheStats = CacheStats::new("会话用户");
#[cfg(feature = "server")]
static SEARCH_STATS: CacheStats = CacheStats::new("搜索");
/// 缓存统计快照项(序列化给前端展示)。
#[cfg(feature = "server")]
#[derive(Debug)]
pub struct CacheStatSnapshot {
pub name: &'static str,
pub entry_count: u64,
pub hits: u64,
pub misses: u64,
pub hit_rate: f64,
}
/// 聚合所有缓存的统计快照(供 get_server_status 调用)。
#[cfg(feature = "server")]
pub fn cache_stats() -> Vec<CacheStatSnapshot> {
fn snap(
stats: &CacheStats,
entry_count: u64,
) -> CacheStatSnapshot {
let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
let total = hits + misses;
let hit_rate = if total == 0 { 0.0 } else { hits as f64 / total as f64 };
CacheStatSnapshot {
name: stats.name,
entry_count,
hits,
misses,
hit_rate,
}
}
vec![
snap(&POST_LIST_STATS, POST_LIST_CACHE.entry_count()),
snap(&TAG_STATS, TAG_LIST_CACHE.entry_count()),
snap(&SINGLE_POST_STATS, SINGLE_POST_CACHE.entry_count()),
snap(&POST_STATS_STATS, POST_STATS_CACHE.entry_count()),
snap(&TAG_POSTS_STATS, TAG_POSTS_CACHE.entry_count()),
snap(&COMMENT_STATS, COMMENT_CACHE.entry_count()),
snap(&PENDING_COUNT_STATS, PENDING_COUNT_CACHE.entry_count()),
snap(&SESSION_STATS, SESSION_CACHE.entry_count()),
snap(&SEARCH_STATS, SEARCH_CACHE.entry_count()),
]
}
// ============================================================================
// 公共缓存 API
// ============================================================================
@ -210,7 +302,13 @@ static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
/// 读取文章分页列表缓存。
#[cfg(feature = "server")]
pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
POST_LIST_CACHE.get(key).await
let v = POST_LIST_CACHE.get(key).await;
if v.is_some() {
POST_LIST_STATS.record_hit();
} else {
POST_LIST_STATS.record_miss();
}
v
}
/// 写入文章分页列表缓存。
@ -222,10 +320,16 @@ pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64)
/// 读取已发布文章总数缓存。
#[cfg(feature = "server")]
pub async fn get_total_published_posts() -> Option<i64> {
POST_LIST_CACHE
let v = POST_LIST_CACHE
.get(&CacheKey::TotalPublishedPosts)
.await
.map(|(_, total)| total)
.map(|(_, total)| total);
if v.is_some() {
POST_LIST_STATS.record_hit();
} else {
POST_LIST_STATS.record_miss();
}
v
}
/// 写入已发布文章总数缓存,文章列表部分置空以节省内存。
@ -239,7 +343,13 @@ pub async fn set_total_published_posts(total: i64) {
/// 读取全部标签缓存。
#[cfg(feature = "server")]
pub async fn get_tag_list() -> Option<Vec<Tag>> {
TAG_LIST_CACHE.get(&CacheKey::AllTags).await
let v = TAG_LIST_CACHE.get(&CacheKey::AllTags).await;
if v.is_some() {
TAG_STATS.record_hit();
} else {
TAG_STATS.record_miss();
}
v
}
/// 写入全部标签缓存。
@ -251,9 +361,15 @@ pub async fn set_tag_list(tags: Vec<Tag>) {
/// 按 slug 读取单篇文章缓存。
#[cfg(feature = "server")]
pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
SINGLE_POST_CACHE
let v = SINGLE_POST_CACHE
.get(&CacheKey::PostBySlug(slug.to_string()))
.await
.await;
if v.is_some() {
SINGLE_POST_STATS.record_hit();
} else {
SINGLE_POST_STATS.record_miss();
}
v
}
/// 按 slug 写入单篇文章缓存None 表示文章不存在。
@ -267,9 +383,15 @@ pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
/// 按标签读取文章列表缓存。
#[cfg(feature = "server")]
pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
TAG_POSTS_CACHE
let v = TAG_POSTS_CACHE
.get(&CacheKey::PostsByTag(tag.to_string()))
.await
.await;
if v.is_some() {
TAG_POSTS_STATS.record_hit();
} else {
TAG_POSTS_STATS.record_miss();
}
v
}
/// 按标签写入文章列表缓存。
@ -283,7 +405,13 @@ pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
/// 按标签+分页读取文章列表缓存。
#[cfg(feature = "server")]
pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
TAG_POSTS_CACHE.get(key).await
let v = TAG_POSTS_CACHE.get(key).await;
if v.is_some() {
TAG_POSTS_STATS.record_hit();
} else {
TAG_POSTS_STATS.record_miss();
}
v
}
/// 按标签+分页写入文章列表缓存。
@ -295,7 +423,13 @@ pub async fn set_posts_by_tag_paged(key: &CacheKey, posts: Vec<PostListItem>, to
/// 读取文章统计缓存。
#[cfg(feature = "server")]
pub async fn get_post_stats() -> Option<PostStats> {
POST_STATS_CACHE.get(&CacheKey::PostStats).await
let v = POST_STATS_CACHE.get(&CacheKey::PostStats).await;
if v.is_some() {
POST_STATS_STATS.record_hit();
} else {
POST_STATS_STATS.record_miss();
}
v
}
/// 写入文章统计缓存。
@ -370,9 +504,15 @@ pub fn invalidate_all_post_caches() {
/// 按文章主键读取评论列表缓存。
#[cfg(feature = "server")]
pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
COMMENT_CACHE
let v = COMMENT_CACHE
.get(&CacheKey::CommentsByPost { post_id })
.await
.await;
if v.is_some() {
COMMENT_STATS.record_hit();
} else {
COMMENT_STATS.record_miss();
}
v
}
/// 按文章主键写入评论列表缓存。
@ -386,9 +526,15 @@ pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
/// 读取待审核评论总数缓存。
#[cfg(feature = "server")]
pub async fn get_pending_count() -> Option<i64> {
PENDING_COUNT_CACHE
let v = PENDING_COUNT_CACHE
.get(&CacheKey::PendingCommentCount)
.await
.await;
if v.is_some() {
PENDING_COUNT_STATS.record_hit();
} else {
PENDING_COUNT_STATS.record_miss();
}
v
}
/// 写入待审核评论总数缓存。
@ -408,7 +554,13 @@ pub fn normalize_search_key(query: &str) -> String {
/// 读取会话用户缓存。
#[cfg(feature = "server")]
pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
SESSION_CACHE.get(token_hash).await
let v = SESSION_CACHE.get(token_hash).await;
if v.is_some() {
SESSION_STATS.record_hit();
} else {
SESSION_STATS.record_miss();
}
v
}
/// 写入会话用户缓存。
@ -426,7 +578,13 @@ pub async fn invalidate_session_user(token_hash: &str) {
/// 读取搜索结果缓存。
#[cfg(feature = "server")]
pub async fn get_search_results(query: &str) -> Option<(Vec<PostListItem>, i64)> {
SEARCH_CACHE.get(&normalize_search_key(query)).await
let v = SEARCH_CACHE.get(&normalize_search_key(query)).await;
if v.is_some() {
SEARCH_STATS.record_hit();
} else {
SEARCH_STATS.record_miss();
}
v
}
/// 写入搜索结果缓存。

188
src/codemirror_bridge.rs Normal file
View File

@ -0,0 +1,188 @@
//! CodeMirror 编辑器的 wasm-bindgen 绑定层。
//!
//! 封装与 `window.CodeMirrorEditor`IIFE 暴露的全局对象字面量)的全部交互,
//! 严格镜像 [`crate::tiptap_bridge`] 的结构:共享纯数据类型双目标编译,
//! wasm-bindgen extern + [`EditorHandle`] 仅在 WASM 前端编译server 构建无 window
//!
//! 与 tiptap 一样,`CodeMirrorEditor` 是 IIFE 挂在 window 上的**对象字面量**
//! `{ create }`),不是函数——因此用 `js_sys::Reflect::get` 做属性访问拿到,
//! 不能用 wasm-bindgen 的 extern fn那会被编成函数调用"not a function")。
use serde::{Deserialize, Serialize};
/// SQL 补全用 schema 数据,由 `get_db_schema` server function 填充。
// 暂未被消费Task 5 的 get_db_schema 会用到),先 allow dead_code 避免 -D warnings 阻断 clippy。
#[allow(dead_code)]
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct SqlSchema {
pub tables: Vec<SqlTable>,
}
/// 单张表的补全数据:表名 + 列名列表。
#[allow(dead_code)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SqlTable {
pub name: String,
pub columns: Vec<String>,
}
// ============================================================================
// 以下全部仅在 WASM 前端编译wasm-bindgen extern + EditorHandle + 闭包。
// 放在 #[cfg] 子模块内,避免 server 构建尝试编译引用 JS 对象的 extern。
// ============================================================================
#[cfg(target_arch = "wasm32")]
pub mod wasm {
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
// —— window.CodeMirrorEditor 模块对象 ——
//
// CodeMirrorEditor 是 IIFE 产物挂在 window 上的对象字面量(含 create 方法),
// 不是函数。wasm-bindgen 对 `fn get_module() -> T` 形式的 extern 会生成
// `window.CodeMirrorEditor()`(函数调用),会因 "not a function" 失败。
// 因此用 js_sys::Reflect::get 做属性访问拿到模块对象,再 unchecked_into。
#[wasm_bindgen]
extern "C" {
/// `window.CodeMirrorEditor` 模块对象的 Rust 映射IIFE 产物挂在 window 上的对象字面量)。
/// 不是函数——通过 [`get_module`] 用 Reflect::get 取属性而非 extern fn 调用拿到。
pub type CodeMirrorEditorModule;
/// 调用 `CodeMirrorEditor.create(containerId, opts)`。
/// 找不到容器返回 null被 Option 捕获);构造失败抛异常(被 catch 捕获)。
#[wasm_bindgen(method, catch)]
pub fn create(
this: &CodeMirrorEditorModule,
container_id: &str,
opts: &EditorOptions,
) -> Result<Option<EditorInstance>, JsValue>;
}
/// 读取 `window.CodeMirrorEditor`IIFE 默认导出,顶层 var 即 window 属性)。
/// 用 Reflect::get 做属性访问——extern fn 形式会被 wasm-bindgen 编成函数调用。
///
/// 用 unchecked_into 而非 dyn_intoCodeMirrorEditor 是 JS 对象字面量,
/// 不是 wasm-bindgen 注册的构造函数实例dyn_into 的 instanceof 检查必然失败。
/// unchecked_into 只做编译期类型标注,不做运行时校验
/// Reflect.get 已保证拿到的是目标对象)。
pub fn get_module() -> CodeMirrorEditorModule {
let window = web_sys::window().expect("no window");
let val = js_sys::Reflect::get(&window, &"CodeMirrorEditor".into())
.expect("window.CodeMirrorEditor missing");
val.unchecked_into::<CodeMirrorEditorModule>()
}
// —— 编辑器实例CodeMirrorInstance——
#[wasm_bindgen]
extern "C" {
/// `CodeMirrorEditor.create` 返回的编辑器实例对象,承载 CodeMirror EditorView。
pub type EditorInstance;
/// 返回当前文档全文。
#[wasm_bindgen(method, js_name = getValue)]
pub fn get_value(this: &EditorInstance) -> String;
/// 替换整个文档内容dispatch changes触发 onChange
#[wasm_bindgen(method, js_name = setValue)]
pub fn set_value(this: &EditorInstance, s: &str);
/// 热切换主题Compartment.reconfigure不重建实例
#[wasm_bindgen(method, js_name = setTheme)]
pub fn set_theme(this: &EditorInstance, theme: &str);
/// 更新 SQL 补全 schemaCompartment.reconfigure
/// 参数为 serde_wasm_bindgen::to_value 序列化后的 JsValue
///SqlSchema 是 serde 类型,非 wasm-bindgen 类型,故不能直接传 &SqlSchema
#[wasm_bindgen(method, js_name = setSchema)]
pub fn set_schema(this: &EditorInstance, schema: &wasm_bindgen::JsValue);
/// 让编辑器获取焦点。
#[wasm_bindgen(method)]
pub fn focus(this: &EditorInstance);
/// 销毁编辑器,释放 JS 侧资源。
#[wasm_bindgen(method)]
pub fn destroy(this: &EditorInstance);
}
// —— EditorOptions用 builder 模式setter构造 JS 对象 ——
#[wasm_bindgen]
extern "C" {
/// 传给 `CodeMirrorEditor.create` 的配置对象,对应 JS 侧的 EditorOptions。
/// 用 `new()` 创建空对象后通过 setter 链式设置字段。
pub type EditorOptions;
/// 构造一个空的 EditorOptions随后用各 setter 填充。
#[wasm_bindgen(constructor)]
pub fn new() -> EditorOptions;
/// 语言(默认 'sql')。
#[wasm_bindgen(method, setter, js_name = language)]
pub fn set_language(this: &EditorOptions, v: &str);
/// 主题:'light'Catppuccin Latte或 'dark'Catppuccin Mocha
#[wasm_bindgen(method, setter, js_name = theme)]
pub fn set_theme(this: &EditorOptions, v: &str);
/// 是否启用 Vim keymap。
#[wasm_bindgen(method, setter, js_name = vim)]
pub fn set_vim(this: &EditorOptions, v: bool);
/// SQL 补全 schema表/列数据。v 为 serde_wasm_bindgen::to_value 序列化结果。
#[wasm_bindgen(method, setter, js_name = schema)]
pub fn set_schema(this: &EditorOptions, v: &wasm_bindgen::JsValue);
/// 初始文档内容。
#[wasm_bindgen(method, setter, js_name = value)]
pub fn set_value(this: &EditorOptions, v: &str);
/// 文档变更回调(参数为最新全文)。
#[wasm_bindgen(method, setter, js_name = onChange)]
pub fn set_on_change(this: &EditorOptions, cb: &Closure<dyn FnMut(String)>);
/// 编辑器就绪回调(构造末尾同步触发一次)。
#[wasm_bindgen(method, setter, js_name = onReady)]
pub fn set_on_ready(this: &EditorOptions, cb: &Closure<dyn FnMut()>);
}
/// 编辑器实例句柄:持有 instance + 所有 ClosureDrop 时销毁实例并释放闭包。
///
/// 闭包字段 `_` 前缀表示仅用于保持生命周期——它们被注入 JS 后JS 侧持有
/// 函数引用;只要 [`EditorHandle`] 存活闭包就不会被回收。Drop 时随结构释放。
pub struct EditorHandle {
instance: EditorInstance,
_on_change: Closure<dyn FnMut(String)>,
_on_ready: Closure<dyn FnMut()>,
}
impl EditorHandle {
/// 调用方须先把各 closure set 进 EditorOptions再 create
/// 然后把返回的 instance + 同名 closure 一起传入 new。
pub fn new(
instance: EditorInstance,
on_change: Closure<dyn FnMut(String)>,
on_ready: Closure<dyn FnMut()>,
) -> Self {
Self {
instance,
_on_change: on_change,
_on_ready: on_ready,
}
}
/// 借用底层实例,供宿主调 getValue/setTheme/setSchema 等。
pub fn instance(&self) -> &EditorInstance {
&self.instance
}
}
impl Drop for EditorHandle {
fn drop(&mut self) {
// 销毁 JS 侧编辑器;随后 _on_change/_on_ready 字段按声明顺序释放,
// 释放 wasm-bindgen 函数表槽位。
let _ = self.instance.destroy();
}
}
}
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

View File

@ -72,6 +72,11 @@ pub fn AdminLayout() -> Element {
label: "回收站",
is_active: matches!(route, Route::Trash {}) || matches!(route, Route::TrashPage { .. }),
},
NavItemConfig {
route: Route::System {},
label: "系统",
is_active: matches!(route, Route::System {}),
},
];
// 右侧操作区:主题切换 + 登出按钮

View File

@ -274,3 +274,46 @@ pub fn FilterTabs(
}
}
}
/// 页面级导航 Tab 组件。
///
/// 与 [`FilterTabs`] 的区别:
/// - [`FilterTabs`]:**列表筛选**语义,带平滑滑动底部指示条动画(靠 WASM 端测量
/// `offsetLeft/offsetWidth`)。适合「全部 / 待审核 / 已通过」这类同质数据过滤。
/// - `Tabs`:**页面导航**语义,选中态用按钮自身 `border-b-2` 下划线(无动画)。
/// 适合「数据库状态 / 服务器状态 / SQL 控制台」这类切换到结构完全不同的面板。
///
/// 因 Dioxus 0.7 `#[component]` 宏不支持泛型类型参数,value 用 `String`(与
/// `FilterTabs` 一致);调用方负责枚举 ↔ String 的桥接(见 `SystemTab::as_str`)。
///
/// Props:
/// - `items`:`(value, label)` 列表,`value` 是稳定字符串 key,`label` 是展示文案
/// - `active_value`:当前选中项的 value
/// - `on_change`:切换时回调,传入新选中项的 value
#[component]
pub fn Tabs(
items: Vec<(&'static str, &'static str)>,
active_value: String,
on_change: EventHandler<String>,
) -> Element {
rsx! {
div { class: "flex flex-wrap gap-1 border-b border-paper-border",
for (value, label) in items {
button {
key: "{value}",
class: "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
class: if active_value == *value {
"border-paper-accent text-paper-accent"
} else {
"border-transparent text-paper-secondary hover:text-paper-primary"
},
onclick: {
let v = value.to_string();
move |_| on_change.call(v.clone())
},
{label}
}
}
}
}
}

View File

@ -23,6 +23,10 @@ mod hooks;
mod models;
mod pages;
mod router;
// sysinfo_sampler主机指标快照。
// SystemSnapshot 结构体两端都编译(被 system_status 的 ServerStatus 字段引用);
// 真正的采样任务 / RwLock / read_snapshot 实现在本模块内部自行 #[cfg(feature = "server")] gate。
mod sysinfo_sampler;
// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。
#[cfg(feature = "server")]
mod ssr_cache;
@ -31,6 +35,9 @@ mod theme;
// tiptap_bridge共享类型UploadsInFlight/UploadErrorEntry两端都编译
// wasm-bindgen extern 与 EditorHandle 在内部的 #[cfg(wasm32)] 子模块里。
mod tiptap_bridge;
// codemirror_bridgeSQL 编辑器的 wasm-bindgen 绑定,结构镜像 tiptap_bridge。
// 共享类型SqlSchema/SqlTable两端都编译extern 与 EditorHandle 在 #[cfg(wasm32)] 子模块里。
mod codemirror_bridge;
mod utils;
mod webp;
@ -322,6 +329,9 @@ fn main() {
tasks::image_cache_cleanup::run_cleanup().await;
});
// 启动后台采样任务sysinfo 主机指标CPU/内存/磁盘server function 只读快照。
sysinfo_sampler::spawn_sampler();
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。
// 注意src/ssr_cache.rs 中的世代号是未来就绪基础设施,当前并不会使
// Dioxus 0.7 的 SSR 缓存实际失效Dioxus 未暴露相应 API。在 API 可用
@ -379,6 +389,24 @@ fn main() {
))
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
// 数据导出:流式响应,走 GET + query参数较短
// 鉴权在 handler 内部从 cookie 校验 adminCSRF 最外层拦截非法来源。
let export_route = axum::Router::new()
.route(
"/api/database/export",
axum::routing::get(crate::api::database::export::export_data),
)
// 备份下载admin 鉴权 + 路径白名单backups/ 不直接暴露静态目录)
.route(
"/api/database/backups/{filename}",
axum::routing::get(crate::api::database::backup::download_backup),
)
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(120),
))
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
let dioxus_app =
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
@ -416,8 +444,8 @@ fn main() {
axum::routing::get(|| async { StatusCode::NOT_FOUND }),
);
// 合并upload 路由保持自己独立的 300s 超时;app routes 加可选压缩/30sstatic routes 无任何中间件
let router = upload_route.merge(app_routes).merge(static_routes);
// 合并upload 路由保持自己独立的 300s 超时;export 路由 120sapp routes 加可选压缩/30sstatic routes 无任何中间件
let router = upload_route.merge(export_route).merge(app_routes).merge(static_routes);
Ok(router)
});

View File

@ -8,6 +8,8 @@ pub mod comments;
pub mod dashboard;
/// 文章管理列表页面模块。
pub mod posts;
/// 系统管理页面模块(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)。
pub mod system;
/// 回收站管理页面模块。
pub mod trash;
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
@ -19,6 +21,8 @@ pub use comments::{AdminComments, AdminCommentsPage};
pub use dashboard::Admin;
/// 文章管理列表组件(带默认分页)。
pub use posts::{Posts, PostsPage};
/// 系统管理入口组件。
pub use system::System;
/// 回收站管理组件(带默认分页)。
pub use trash::{Trash, TrashPage};
/// 文章编辑器组件(新建与编辑模式)。

1453
src/pages/admin/system.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,8 @@ use crate::components::frontend_layout::FrontendLayout;
use crate::context::UserContext;
use crate::pages::about::About;
use crate::pages::admin::{
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Trash, TrashPage, Write, WriteEdit,
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, System, Trash, TrashPage, Write,
WriteEdit,
};
use crate::pages::archives::Archives;
use crate::pages::home::{Home, HomePage};
@ -90,6 +91,9 @@ pub enum Route {
/// 回收站分页
#[route("/trash/:page")]
TrashPage { page: i32 },
/// 系统管理(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)
#[route("/system")]
System {},
#[end_layout]
#[end_nest]

106
src/sysinfo_sampler.rs Normal file
View File

@ -0,0 +1,106 @@
//! sysinfo 主机指标后台采样。
//!
//! sysinfo 的 CPU% **不是即时值**,需要两次采样间的 delta。因此用后台任务周期
//! 刷新server function 只读 [`RwLock`] 快照(毫秒级返回,零采样成本)。
//!
//! 采样间隔由环境变量 `SYSINFO_SAMPLE_SECS` 配置(默认 0.5 秒,支持小数如 0.1)。
// LazyLock / Duration 仅 server 构建的采样任务用到WASM 端只序列化 SystemSnapshot。
#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")]
use std::time::Duration;
use serde::{Deserialize, Serialize};
/// 主机指标快照(由后台采样任务周期更新)。
#[derive(Clone, Default, Serialize, Deserialize, Debug)]
pub struct SystemSnapshot {
/// 总体 CPU 使用率(百分比)。
pub cpu_usage: f32,
/// 系统 1 分钟平均负载。
pub load_avg_1: f64,
/// 总物理内存(字节)。
pub total_memory: u64,
/// 已用物理内存(字节)。
pub used_memory: u64,
/// 主磁盘总空间(字节,取根分区或最大盘)。
pub disk_total: u64,
/// 主磁盘可用空间(字节)。
pub disk_available: u64,
/// 操作系统版本(如 "macOS 15.5")。
pub os_name: String,
/// 内核版本。
pub kernel_version: String,
/// 系统启动后秒数。
pub uptime_secs: u64,
}
#[cfg(feature = "server")]
static SNAPSHOT: LazyLock<tokio::sync::RwLock<SystemSnapshot>> =
LazyLock::new(|| tokio::sync::RwLock::new(SystemSnapshot::default()));
/// 采样间隔,由环境变量 `SYSINFO_SAMPLE_SECS` 配置,默认 0.5 秒,下限 0.05 秒。
#[cfg(feature = "server")]
fn sample_interval() -> Duration {
let secs = std::env::var("SYSINFO_SAMPLE_SECS")
.ok()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.5);
Duration::from_secs_f64(secs.max(0.05))
}
/// 启动后台采样任务。应在 main 启动流程migrate 之后、serve 之前)调用一次。
///
/// CPU% 需两次采样 delta故循环里先 refresh 再 sleep 再 refresh 才有有效值。
#[cfg(feature = "server")]
pub fn spawn_sampler() {
tokio::spawn(async move {
use sysinfo::{Disks, System};
let mut sys = System::new();
let interval = sample_interval();
// 首次 refresh 建立基线CPU% 在下一轮才有意义。
sys.refresh_cpu_usage();
let disks = Disks::new_with_refreshed_list();
loop {
tokio::time::sleep(interval).await;
sys.refresh_cpu_usage();
sys.refresh_memory();
let load = System::load_average();
// 主磁盘:取空间最大的盘(通常是数据盘)。
let (disk_total, disk_available) = disks
.list()
.iter()
.max_by_key(|d| d.total_space())
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or((0, 0));
let snap = SystemSnapshot {
cpu_usage: sys.global_cpu_usage(),
load_avg_1: load.one,
total_memory: sys.total_memory(),
used_memory: sys.used_memory(),
disk_total,
disk_available,
os_name: System::long_os_version().unwrap_or_default(),
kernel_version: System::kernel_version().unwrap_or_default(),
uptime_secs: System::uptime(),
};
*SNAPSHOT.write().await = snap;
}
});
}
/// 读取最新快照(只读,毫秒级返回,不触发采样)。
#[cfg(feature = "server")]
pub async fn read_snapshot() -> SystemSnapshot {
SNAPSHOT.read().await.clone()
}
#[cfg(not(feature = "server"))]
pub async fn read_snapshot() -> SystemSnapshot {
SystemSnapshot::default()
}