- 全项目 .rs 统一 cargo fmt 排版(换行/缩进/参数分行/导入排序) - infra/docker: DOCKER_CLIENT 连接失败不 panic,降级返回 None (博客不依赖 Docker,panic=abort 下会导致整个进程崩溃) - infra/mod: 去除多余空行 - database/mod: 模块声明按字母序重新排序
161 lines
6.1 KiB
Rust
161 lines
6.1 KiB
Rust
//! 应用错误类型与 `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("操作失败"));
|
||
}
|
||
}
|