yggdrasil/src/api/error.rs
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

149 lines
5.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 应用错误类型与 `ServerFnError` 转换。
//!
//! `AppError` 封装认证、权限、数据库、内部错误等场景,
//! 并转换为对外友好的 `ServerFnError` 消息,避免泄露 SQL 细节。
use dioxus::prelude::ServerFnError;
/// 应用层统一错误类型。
#[derive(Debug)]
pub enum AppError {
/// 未认证401
Unauthorized(&'static str),
/// 无权限403
Forbidden(&'static str),
/// 资源不存在404
NotFound(&'static str),
/// 客户端请求错误400——业务规则拒绝消息原样透传给用户。
BadRequest(String),
/// 数据库连接失败。
DbConn(String),
/// SQL 查询执行失败。
Query(String),
/// 数据库事务失败。
Transaction(String),
/// 内部通用错误。
Internal(&'static str),
}
#[cfg(feature = "server")]
impl AppError {
/// 记录并包装数据库连接错误。
///
/// 日志仅记录 Display 摘要,避免 Debug 输出中的 SQL/参数泄露。
pub fn db_conn(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
tracing::error!("DB connection failed: {e}");
AppError::DbConn("connection error".to_string())
}
/// 记录并包装 SQL 查询错误。
pub fn query(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
tracing::error!("Query failed: {e}");
AppError::Query("query error".to_string())
}
/// 记录并包装数据库事务错误。
pub fn tx(e: impl std::fmt::Display + std::fmt::Debug) -> Self {
tracing::error!("Transaction failed: {e}");
AppError::Transaction("transaction error".to_string())
}
}
/// 转换为 `ServerFnError`,对数据库类错误返回通用中文提示。
impl From<AppError> for ServerFnError {
fn from(err: AppError) -> ServerFnError {
let msg = match &err {
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(),
AppError::Internal(m) => m.to_string(),
};
ServerFnError::new(msg)
}
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn unauthorized_message_passthrough() {
let err: ServerFnError = AppError::Unauthorized("未登录").into();
let msg = err.to_string();
assert!(msg.contains("未登录"), "expected '未登录' in: {msg}");
}
#[test]
fn db_conn_hides_internal_details() {
let err: ServerFnError = AppError::db_conn("connection refused on port 5432").into();
let msg = err.to_string();
assert!(
!msg.contains("5432"),
"should not leak internal details: {msg}"
);
assert!(
msg.contains("服务暂时不可用"),
"expected generic message: {msg}"
);
}
#[test]
fn query_hides_sql_details() {
let err: ServerFnError = AppError::query("syntax error at SELECT * FROM").into();
let msg = err.to_string();
assert!(!msg.contains("SELECT"), "should not leak SQL: {msg}");
}
#[test]
fn forbidden_message_passthrough() {
let err: ServerFnError = AppError::Forbidden("权限不足").into();
let msg = err.to_string();
assert!(msg.contains("权限不足"), "expected '权限不足': {msg}");
}
#[test]
fn not_found_message_passthrough() {
let err: ServerFnError = AppError::NotFound("文章不存在").into();
let msg = err.to_string();
assert!(msg.contains("文章不存在"), "expected passthrough: {msg}");
}
#[test]
fn internal_message_passthrough() {
// Internal 错误的消息原样透传,便于向用户展示可读的内部错误描述。
let err: ServerFnError = AppError::Internal("内部错误").into();
let msg = err.to_string();
assert!(msg.contains("内部错误"), "expected passthrough: {msg}");
}
#[test]
fn transaction_hides_sql_details() {
// 事务错误同样返回通用提示,不泄露 SQL 细节。
let err: ServerFnError = AppError::tx("deadlock detected on UPDATE posts").into();
let msg = err.to_string();
assert!(!msg.contains("UPDATE"), "should not leak SQL: {msg}");
assert!(
!msg.contains("deadlock"),
"should not leak error detail: {msg}"
);
assert!(msg.contains("操作失败"), "expected generic message: {msg}");
}
#[test]
fn db_conn_query_transaction_all_return_generic_message() {
// 三类数据库错误对外均返回固定中文提示,避免泄露实现细节。
let db_conn: ServerFnError = AppError::DbConn("x".into()).into();
let query: ServerFnError = AppError::Query("x".into()).into();
let tx: ServerFnError = AppError::Transaction("x".into()).into();
assert!(db_conn.to_string().contains("服务暂时不可用"));
assert!(query.to_string().contains("操作失败"));
assert!(tx.to_string().contains("操作失败"));
}
}