diff --git a/src/api/error.rs b/src/api/error.rs index 1e5f259..31d241a 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -30,21 +30,34 @@ pub enum AppError { impl AppError { /// 记录并包装数据库连接错误。 /// - /// 日志仅记录 Display 摘要,避免 Debug 输出中的 SQL/参数泄露。 - pub fn db_conn(e: impl std::fmt::Display + std::fmt::Debug) -> Self { - tracing::error!("DB connection failed: {e}"); + /// 对外只暴露通用提示(脱敏),但服务端日志会展开 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::fmt::Display + std::fmt::Debug) -> Self { - tracing::error!("Query failed: {e}"); + 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::fmt::Display + std::fmt::Debug) -> Self { - tracing::error!("Transaction failed: {e}"); + 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()) } } @@ -80,7 +93,10 @@ mod tests { #[test] fn db_conn_hides_internal_details() { - let err: ServerFnError = AppError::db_conn("connection refused on port 5432").into(); + // 入参是实现 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"), @@ -94,7 +110,8 @@ mod tests { #[test] fn query_hides_sql_details() { - let err: ServerFnError = AppError::query("syntax error at SELECT * FROM").into(); + 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}"); } @@ -124,7 +141,8 @@ mod tests { #[test] fn transaction_hides_sql_details() { // 事务错误同样返回通用提示,不泄露 SQL 细节。 - let err: ServerFnError = AppError::tx("deadlock detected on UPDATE posts").into(); + 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!( diff --git a/src/db/migrate.rs b/src/db/migrate.rs index a3d06b9..2bdfb34 100644 --- a/src/db/migrate.rs +++ b/src/db/migrate.rs @@ -91,18 +91,22 @@ impl From for MigrateError { impl std::fmt::Display for MigrateError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // 主动展开 source 链:tokio_postgres::Error 的 Display 对 DB 侧错误只打印 + // 无信息量的 `db error`,真正的 message/SQLSTATE/约束名藏在 source() 里。 match self { MigrateError::Pool(e) => { - write!(f, "database pool error: {}", e)?; - fmt_source_chain(f, e) + write!(f, "database pool error: {}", crate::db::format_with_sources(e)) } MigrateError::Query(e) => { - write!(f, "database query error: {}", e)?; - fmt_source_chain(f, e) + write!(f, "database query error: {}", crate::db::format_with_sources(e)) } MigrateError::Apply { version, source } => { - write!(f, "migration {} failed: {}", version, source)?; - fmt_source_chain(f, source) + write!( + f, + "migration {} failed: {}", + version, + crate::db::format_with_sources(source) + ) } } } @@ -118,26 +122,6 @@ impl std::error::Error for MigrateError { } } -/// 把错误的 `source()` 链逐层拼到 Display 输出里,用 `: ` 分隔。 -/// -/// 存在的原因:`tokio_postgres::Error` 的 `Display` 对 DB 侧错误只会打印 -/// 无信息量的 `db error`,真正的消息文本(如 -/// `column "x" of relation "y" already exists`、SQLSTATE、约束名)藏在 -/// `source()` 链里的 `postgres::error::DbError`。不主动遍历链就会丢失全部上下文, -/// 日志只显示 `migration 001 failed: db error`,难以定位。 -fn fmt_source_chain( - f: &mut std::fmt::Formatter<'_>, - mut source: &(dyn std::error::Error + 'static), -) -> std::fmt::Result { - while let Some(next) = source.source() { - // `DbError` 的 Display 已经包含 message(必要时还有 detail/hint), - // 直接拼上即可;避免逐层重复打印 `db error` 这类占位串。 - write!(f, ": {next}")?; - source = next; - } - Ok(()) -} - /// 在**已获取**的连接上执行迁移主体逻辑(咨询锁 + 建表 + 应用迁移 + 解锁)。 /// /// 调用方负责自行控制连接获取策略——例如 `main.rs` 启动时用 diff --git a/src/db/mod.rs b/src/db/mod.rs index cefe0cb..c9d66f4 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -8,6 +8,33 @@ //! 这种 stub 模式是 Dioxus fullstack 项目的常见做法:服务端函数体在 WASM 构建时会被剥离, //! 但模块结构必须保持一致,因此需要一个占位实现来满足编译器的符号解析。 +/// 错误格式化工具:把 `std::error::Error` 的 source 链完整展开为字符串。 +/// +/// 存在的原因:`tokio_postgres::Error` 的 `Display` 对 DB 侧错误只会打印 +/// 无信息量的占位串 `db error`,真正的消息文本(如 +/// `column "x" of relation "y" already exists`、SQLSTATE、约束名)藏在 +/// `source()` 链里的 `postgres::error::DbError`。不主动遍历链,日志和错误 +/// 字符串就会全部折叠成 `db error`,无法定位失败原因。 +/// +/// 用法:`format!("...: {}", format_with_sources(&e))` 或直接 +/// `format_with_sources(&e)` 得到完整的 `e: cause: deeper cause`。 +#[cfg(feature = "server")] +pub fn format_with_sources(e: &dyn std::error::Error) -> String { + use std::fmt::Write; + let mut s = e.to_string(); + let mut cur: &dyn std::error::Error = e; + while let Some(next) = cur.source() { + // 跳过与外层 Display 完全相同的占位层(如 tokio_postgres 的 `db error`), + // 避免输出 `db error: db error` 这种重复。只在能带来新信息时追加。 + let next_disp = next.to_string(); + if !next_disp.is_empty() && next_disp != s { + let _ = write!(s, ": {next_disp}"); + } + cur = next; + } + s +} + /// 真实的 PostgreSQL 连接池实现,仅在启用 server feature 时编译。 #[cfg(feature = "server")] pub mod pool; diff --git a/src/db/pool.rs b/src/db/pool.rs index 1a752d7..0c4f158 100644 --- a/src/db/pool.rs +++ b/src/db/pool.rs @@ -256,12 +256,14 @@ pub async fn ensure_database() -> Result<(), String> { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { return Err(format!( - "could not connect to 'postgres' maintenance database within {timeout_secs}s: {e}" + "could not connect to 'postgres' maintenance database within {timeout_secs}s: {}", + crate::db::format_with_sources(&e) )); } tracing::warn!( - "ensure_database: connect to 'postgres' failed, ~{}s remaining: {e}", - remaining.as_secs() + "ensure_database: connect to 'postgres' failed, ~{}s remaining: {}", + remaining.as_secs(), + crate::db::format_with_sources(&e) ); tokio::time::sleep(std::cmp::min(retry_interval, remaining)).await; } @@ -270,7 +272,10 @@ pub async fn ensure_database() -> Result<(), String> { // 连接的后台驱动任务:出错时仅记录,连接随即作废。 tokio::spawn(async move { if let Err(e) = connection.await { - tracing::warn!("postgres maintenance connection ended: {e}"); + tracing::warn!( + "postgres maintenance connection ended: {}", + crate::db::format_with_sources(&e) + ); } }); @@ -281,7 +286,12 @@ pub async fn ensure_database() -> Result<(), String> { &[&db_name], ) .await - .map_err(|e| format!("failed to query pg_database: {e}"))? + .map_err(|e| { + format!( + "failed to query pg_database: {}", + crate::db::format_with_sources(&e) + ) + })? .get(0); if exists { @@ -295,7 +305,12 @@ pub async fn ensure_database() -> Result<(), String> { client .batch_execute(&stmt) .await - .map_err(|e| format!("failed to create database {db_name:?}: {e}"))?; + .map_err(|e| { + format!( + "failed to create database {db_name:?}: {}", + crate::db::format_with_sources(&e) + ) + })?; tracing::info!("created database {:?}", db_name); Ok(()) }