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 输出。
This commit is contained in:
xfy 2026-06-29 18:56:15 +08:00
parent c4c490b881
commit a49f47c8a6
7 changed files with 655 additions and 1 deletions

10
Cargo.lock generated
View File

@ -4017,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"
@ -5489,6 +5498,7 @@ dependencies = [
"serde_json",
"serial_test",
"sha2 0.10.9",
"sqlparser",
"syntect",
"sysinfo",
"tokio",

View File

@ -36,6 +36,7 @@ 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 }
md-5 = { version = "0.10", optional = true }
futures = { version = "0.3", optional = true }
bytes = { version = "1", optional = true }
@ -89,6 +90,7 @@ server = [
"dep:moka",
"dep:governor",
"dep:sysinfo",
"dep:sqlparser",
"dep:md-5",
"dep:futures",
"dep:bytes",

View File

@ -10,3 +10,7 @@
pub mod status;
/// 服务器状态聚合查询(应用内 + 主机层)。
pub mod system_status;
/// SQL 控制台执行(全读写 + 4 护栏)。
pub mod sql_console;
/// SQL 补全用 schema 拉取。
pub mod schema;

View File

@ -0,0 +1,48 @@
#![allow(clippy::unused_unit, deprecated)]
//! SQL 补全用 schema 拉取(供 CodeMirror lang-sql 表/列补全)。
use dioxus::prelude::*;
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
use crate::codemirror_bridge::SqlSchema;
use crate::db::pool::get_conn;
/// 拉取数据库 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,320 @@
#![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};
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
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,
/// 截断时的估算总行数(重查用)。
pub total_estimate: Option<i64>,
}
/// 结果行数上限(超出截断 + 提示)。
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::Statement;
for stmt in asts {
match stmt {
// 护栏 1需确认的高危语句
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;
// 逐条执行allow_multi 时多条,否则单条)
let mut last_result = SqlResult::default();
for stmt in &asts {
last_result = execute_one(&client, stmt, &sql, opts.with_explain, start).await?;
}
Ok(last_result)
}
#[cfg(not(feature = "server"))]
{
let _ = (sql, opts);
Ok(SqlResult::default())
}
}
/// 执行单条语句,返回结果。
#[cfg(feature = "server")]
async fn execute_one(
client: &deadpool_postgres::Object,
stmt: &sqlparser::ast::Statement,
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 模式:包裹原 SQL 执行计划,取首列文本拼接
let explain_sql = format!("EXPLAIN {}", 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(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(sql, &[]).await.map_err(AppError::query)?;
Ok(SqlResult {
affected_rows: affected,
statement_type,
elapsed_ms: start().elapsed().as_millis() as u64,
..Default::default()
})
}
}

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

@ -61,7 +61,7 @@ pub fn System() -> Element {
match active_tab() {
SystemTab::DbStatus => rsx! { DbStatusTab {} },
SystemTab::ServerStatus => rsx! { ServerStatusTab {} },
SystemTab::SqlConsole => rsx! { div { class: "text-paper-secondary py-8", "SQL 控制台(待实现)" } },
SystemTab::SqlConsole => rsx! { SqlConsoleTab {} },
SystemTab::Export => rsx! { div { class: "text-paper-secondary py-8", "数据导出(待实现)" } },
SystemTab::Backup => rsx! { div { class: "text-paper-secondary py-8", "备份恢复(待实现)" } },
}
@ -634,3 +634,269 @@ fn ServerStatusTab() -> Element {
}
}
}
/// SQL 控制台 tabCodeMirror 编辑器SQL 高亮/补全/Vim+ 4 道护栏 + 结果表 + EXPLAIN。
///
/// 护栏 4前端二次确认提交写操作前弹窗确认。
#[allow(non_snake_case)]
fn SqlConsoleTab() -> Element {
use crate::api::database::sql_console::SqlResult;
#[cfg(target_arch = "wasm32")]
use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts};
#[cfg(target_arch = "wasm32")]
use crate::api::database::schema::get_db_schema;
use crate::components::ui::ADMIN_TABLE_CLASS;
#[cfg(target_arch = "wasm32")]
use crate::codemirror_bridge;
use crate::theme::use_theme;
#[cfg(target_arch = "wasm32")]
use crate::theme::Theme;
let theme = use_theme();
let sql_text = use_signal(String::new);
let result = use_signal(|| Option::<SqlResult>::None);
let mut error = use_signal(|| Option::<String>::None);
let mut running = use_signal(|| false);
// 选项 toggles
let mut with_explain = use_signal(|| false);
let mut allow_multi = use_signal(|| false);
let mut confirm_dangerous = use_signal(|| false);
// theme/sql_text 仅在 wasm32 块内使用server 构建时显式引用避免 unused 警告。
#[cfg(not(target_arch = "wasm32"))]
let _ = (&theme, &sql_text);
// CodeMirror 实例句柄(仅 WASM
#[cfg(target_arch = "wasm32")]
let mut editor_handle: Signal<Option<codemirror_bridge::EditorHandle>> = use_signal(|| None);
// 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。
#[cfg(target_arch = "wasm32")]
{
use dioxus::prelude::wasm_bindgen::closure::Closure;
use_effect(move || {
if editor_handle().is_some() {
return;
}
let mut text = sql_text;
let on_change = Closure::new(move |v: String| {
text.set(v);
});
let on_ready = Closure::new(|| {});
let theme_name = if theme() == Theme::Dark { "dark" } else { "light" };
let opts = codemirror_bridge::EditorOptions::new();
opts.set_language("sql");
opts.set_theme(theme_name);
opts.set_vim(true);
opts.set_on_change(&on_change);
opts.set_on_ready(&on_ready);
match codemirror_bridge::get_module().create("sql-editor", &opts) {
Ok(Some(inst)) => {
let handle = codemirror_bridge::EditorHandle::new(inst, on_change, on_ready);
editor_handle.set(Some(handle));
}
_ => {}
}
// 异步拉取 schema 注入补全
spawn(async move {
if let Ok(schema) = get_db_schema().await {
if let Some(h) = editor_handle.read().as_ref() {
h.instance().set_schema(&schema);
}
}
});
});
// 主题切换时同步编辑器主题
use_effect(move || {
let t = theme();
if let Some(h) = editor_handle.read().as_ref() {
h.instance().set_theme(if t == Theme::Dark { "dark" } else { "light" });
}
});
}
// 执行 SQL
let mut run_sql = move || {
running.set(true);
error.set(None);
#[cfg(target_arch = "wasm32")]
{
let sql = sql_text.read().clone();
let opts = ExecuteSqlOpts {
allow_multi: allow_multi(),
confirm_dangerous: confirm_dangerous(),
with_explain: with_explain(),
};
// 护栏 4写操作前端二次确认简单判断含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词)
let lower = sql.to_lowercase();
let looks_write = ["update ", "delete ", "insert ", "alter ", "drop ", "truncate ", "create "]
.iter()
.any(|k| lower.contains(k));
if looks_write && !confirm_dangerous() {
// 简单提示;真正的高危放行靠 confirm_dangerous 开关
let confirmed = web_sys::window().and_then(|w| {
w.confirm_with_message(
"这是写操作(修改数据/结构),确认执行?\n\n高危操作DROP/TRUNCATE/ALTER还需勾选「我了解后果」。",
)
.ok()
});
if confirmed != Some(true) {
running.set(false);
return;
}
}
spawn(async move {
match execute_sql(sql, opts).await {
Ok(r) => result.set(Some(r)),
Err(e) => error.set(Some(e.to_string())),
}
running.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
{
running.set(false);
}
};
let current_result = result.read().clone();
let current_error = error.read().clone();
let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0);
let affected = current_result.as_ref().map(|r| r.affected_rows).unwrap_or(0);
let stmt_type = current_result
.as_ref()
.map(|r| r.statement_type.clone())
.unwrap_or_default();
let truncated = current_result.as_ref().map(|r| r.truncated).unwrap_or(false);
// 结果行预格式化(避免在 rsx for 循环体内格式化)
let result_rows: Vec<Vec<String>> = current_result
.as_ref()
.map(|r| {
r.rows
.iter()
.map(|row| {
row.iter()
.map(|cell| match cell {
serde_json::Value::Null => "NULL".to_string(),
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect()
})
.collect()
})
.unwrap_or_default();
let result_columns: Vec<String> = current_result
.as_ref()
.map(|r| r.columns.clone())
.unwrap_or_default();
let explain = current_result.as_ref().and_then(|r| r.explain.clone());
rsx! {
div { class: "space-y-4",
// 编辑器容器
div {
class: "border border-paper-border rounded-lg overflow-hidden bg-paper-entry",
id: "sql-editor",
style: "min-height: 200px"
}
// 选项 + 执行按钮
div { class: "flex flex-wrap items-center gap-3",
button {
class: "px-4 py-1.5 text-sm bg-paper-accent text-paper-theme rounded hover:brightness-110 transition disabled:opacity-50",
disabled: running(),
onclick: move |_| run_sql(),
if running() { "执行中..." } else { "执行 (Ctrl+Enter)" }
}
label { class: "flex items-center gap-1 text-sm text-paper-secondary",
input {
r#type: "checkbox",
class: "mr-1",
checked: with_explain(),
onchange: move |e| with_explain.set(e.checked()),
}
"EXPLAIN"
}
label { class: "flex items-center gap-1 text-sm text-paper-secondary",
input {
r#type: "checkbox",
class: "mr-1",
checked: allow_multi(),
onchange: move |e| allow_multi.set(e.checked()),
}
"允许多语句"
}
label { class: "flex items-center gap-1 text-sm text-red-600 dark:text-red-400",
input {
r#type: "checkbox",
class: "mr-1",
checked: confirm_dangerous(),
onchange: move |e| confirm_dangerous.set(e.checked()),
}
"我了解后果(放开 DROP/TRUNCATE/ALTER"
}
}
// 错误
if let Some(err) = current_error {
div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300",
"{err}"
}
}
// 结果摘要
if current_result.is_some() {
div { class: "flex flex-wrap gap-4 text-sm text-paper-secondary",
span { "类型:{stmt_type}" }
if affected > 0 {
span { "影响行数:{affected}" }
}
span { "耗时:{elapsed}ms" }
if truncated {
span { class: "text-amber-600 dark:text-amber-400", "结果超过 500 行,已截断" }
}
}
}
// 结果表格
if !result_rows.is_empty() {
div { class: "{ADMIN_TABLE_CLASS}",
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-paper-border text-left text-paper-secondary",
for col in result_columns.iter() {
th { class: "px-4 py-2 font-medium whitespace-nowrap", "{col}" }
}
}
}
tbody {
for row in result_rows.iter() {
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
for cell in row.iter() {
td { class: "px-4 py-2 font-mono text-xs text-paper-secondary", "{cell}" }
}
}
}
}
}
}
}
}
// EXPLAIN 输出
if let Some(explain) = explain {
div { class: "{ADMIN_TABLE_CLASS}",
div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary",
"执行计划"
}
pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre", "{explain}" }
}
}
}
}
}