yggdrasil/src/api/error.rs
xfy 65a7b1226f style: 全项目格式规范化(cargo fmt)+ Docker daemon 断连容错
- 全项目 .rs 统一 cargo fmt 排版(换行/缩进/参数分行/导入排序)
- infra/docker: DOCKER_CLIENT 连接失败不 panic,降级返回 None
  (博客不依赖 Docker,panic=abort 下会导致整个进程崩溃)
- infra/mod: 去除多余空行
- database/mod: 模块声明按字母序重新排序
2026-07-15 17:22:06 +08:00

161 lines
6.1 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 {
/// 记录并包装数据库连接错误。
///
/// 对外只暴露通用提示(脱敏),但服务端日志会展开 source 链,记录完整的
/// 失败原因(如 `db error: connection refused`、IO 错误等)。传入的错误类型
/// 必须实现 `std::error::Error` —— DB 相关错误(`tokio_postgres::Error`、
/// `deadpool_postgres::PoolError`)均满足,这一点是上次"日志只显示 `db error`"
/// 问题的根治前提。
pub fn db_conn(e: impl std::error::Error) -> Self {
tracing::error!(
"DB connection failed: {}",
crate::db::format_with_sources(&e)
);
AppError::DbConn("connection error".to_string())
}
/// 记录并包装 SQL 查询错误。
pub fn query(e: impl std::error::Error) -> Self {
tracing::error!("Query failed: {}", crate::db::format_with_sources(&e));
AppError::Query("query error".to_string())
}
/// 记录并包装数据库事务错误。
pub fn tx(e: impl std::error::Error) -> Self {
tracing::error!("Transaction failed: {}", crate::db::format_with_sources(&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() {
// 入参是实现 Error 的类型;构造函数内部会把 source 链写进日志,
// 但对外ServerFnError必须只暴露通用提示。
let src = std::io::Error::other("connection refused on port 5432");
let err: ServerFnError = AppError::db_conn(src).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 src = std::io::Error::other("syntax error at SELECT * FROM");
let err: ServerFnError = AppError::query(src).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 src = std::io::Error::other("deadlock detected on UPDATE posts");
let err: ServerFnError = AppError::tx(src).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("操作失败"));
}
}