Skip to main content

yggdrasil/api/
error.rs

1//! 应用错误类型与 `ServerFnError` 转换。
2//!
3//! `AppError` 封装认证、权限、数据库、内部错误等场景,
4//! 并转换为对外友好的 `ServerFnError` 消息,避免泄露 SQL 细节。
5
6use dioxus::prelude::ServerFnError;
7
8/// 应用层统一错误类型。
9#[derive(Debug)]
10pub enum AppError {
11    /// 未认证(401)。
12    Unauthorized(&'static str),
13    /// 无权限(403)。
14    Forbidden(&'static str),
15    /// 资源不存在(404)。
16    NotFound(&'static str),
17    /// 客户端请求错误(400)——业务规则拒绝,消息原样透传给用户。
18    BadRequest(String),
19    /// 数据库连接失败。
20    DbConn(String),
21    /// SQL 查询执行失败。
22    Query(String),
23    /// 数据库事务失败。
24    Transaction(String),
25    /// 内部通用错误。
26    Internal(&'static str),
27}
28
29#[cfg(feature = "server")]
30impl AppError {
31    /// 记录并包装数据库连接错误。
32    ///
33    /// 对外只暴露通用提示(脱敏),但服务端日志会展开 source 链,记录完整的
34    /// 失败原因(如 `db error: connection refused`、IO 错误等)。传入的错误类型
35    /// 必须实现 `std::error::Error` —— DB 相关错误(`tokio_postgres::Error`、
36    /// `deadpool_postgres::PoolError`)均满足,这一点是上次"日志只显示 `db error`"
37    /// 问题的根治前提。
38    pub fn db_conn(e: impl std::error::Error) -> Self {
39        tracing::error!(
40            "DB connection failed: {}",
41            crate::db::format_with_sources(&e)
42        );
43        AppError::DbConn("connection error".to_string())
44    }
45
46    /// 记录并包装 SQL 查询错误。
47    pub fn query(e: impl std::error::Error) -> Self {
48        tracing::error!("Query failed: {}", crate::db::format_with_sources(&e));
49        AppError::Query("query error".to_string())
50    }
51
52    /// 记录并包装数据库事务错误。
53    pub fn tx(e: impl std::error::Error) -> Self {
54        tracing::error!("Transaction failed: {}", crate::db::format_with_sources(&e));
55        AppError::Transaction("transaction error".to_string())
56    }
57}
58
59/// 转换为 `ServerFnError`,对数据库类错误返回通用中文提示。
60impl From<AppError> for ServerFnError {
61    fn from(err: AppError) -> ServerFnError {
62        let msg = match &err {
63            AppError::Unauthorized(m) => m.to_string(),
64            AppError::Forbidden(m) => m.to_string(),
65            AppError::NotFound(m) => m.to_string(),
66            // BadRequest 是业务规则拒绝(如 SQL 护栏拦截),消息原样透传给用户。
67            AppError::BadRequest(m) => m.to_string(),
68            AppError::DbConn(_) => "服务暂时不可用".to_string(),
69            AppError::Query(_) => "操作失败".to_string(),
70            AppError::Transaction(_) => "操作失败".to_string(),
71            AppError::Internal(m) => m.to_string(),
72        };
73        ServerFnError::new(msg)
74    }
75}
76
77#[cfg(all(test, feature = "server"))]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn unauthorized_message_passthrough() {
83        let err: ServerFnError = AppError::Unauthorized("未登录").into();
84        let msg = err.to_string();
85        assert!(msg.contains("未登录"), "expected '未登录' in: {msg}");
86    }
87
88    #[test]
89    fn db_conn_hides_internal_details() {
90        // 入参是实现 Error 的类型;构造函数内部会把 source 链写进日志,
91        // 但对外(ServerFnError)必须只暴露通用提示。
92        let src = std::io::Error::other("connection refused on port 5432");
93        let err: ServerFnError = AppError::db_conn(src).into();
94        let msg = err.to_string();
95        assert!(
96            !msg.contains("5432"),
97            "should not leak internal details: {msg}"
98        );
99        assert!(
100            msg.contains("服务暂时不可用"),
101            "expected generic message: {msg}"
102        );
103    }
104
105    #[test]
106    fn query_hides_sql_details() {
107        let src = std::io::Error::other("syntax error at SELECT * FROM");
108        let err: ServerFnError = AppError::query(src).into();
109        let msg = err.to_string();
110        assert!(!msg.contains("SELECT"), "should not leak SQL: {msg}");
111    }
112
113    #[test]
114    fn forbidden_message_passthrough() {
115        let err: ServerFnError = AppError::Forbidden("权限不足").into();
116        let msg = err.to_string();
117        assert!(msg.contains("权限不足"), "expected '权限不足': {msg}");
118    }
119
120    #[test]
121    fn not_found_message_passthrough() {
122        let err: ServerFnError = AppError::NotFound("文章不存在").into();
123        let msg = err.to_string();
124        assert!(msg.contains("文章不存在"), "expected passthrough: {msg}");
125    }
126
127    #[test]
128    fn internal_message_passthrough() {
129        // Internal 错误的消息原样透传,便于向用户展示可读的内部错误描述。
130        let err: ServerFnError = AppError::Internal("内部错误").into();
131        let msg = err.to_string();
132        assert!(msg.contains("内部错误"), "expected passthrough: {msg}");
133    }
134
135    #[test]
136    fn transaction_hides_sql_details() {
137        // 事务错误同样返回通用提示,不泄露 SQL 细节。
138        let src = std::io::Error::other("deadlock detected on UPDATE posts");
139        let err: ServerFnError = AppError::tx(src).into();
140        let msg = err.to_string();
141        assert!(!msg.contains("UPDATE"), "should not leak SQL: {msg}");
142        assert!(
143            !msg.contains("deadlock"),
144            "should not leak error detail: {msg}"
145        );
146        assert!(msg.contains("操作失败"), "expected generic message: {msg}");
147    }
148
149    #[test]
150    fn db_conn_query_transaction_all_return_generic_message() {
151        // 三类数据库错误对外均返回固定中文提示,避免泄露实现细节。
152        let db_conn: ServerFnError = AppError::DbConn("x".into()).into();
153        let query: ServerFnError = AppError::Query("x".into()).into();
154        let tx: ServerFnError = AppError::Transaction("x".into()).into();
155
156        assert!(db_conn.to_string().contains("服务暂时不可用"));
157        assert!(query.to_string().contains("操作失败"));
158        assert!(tx.to_string().contains("操作失败"));
159    }
160}