refactor(api): unify error handling with AppError enum

Replace scattered ServerFnError::new("...") inline calls and utils.rs
helper functions with a single AppError enum that provides:
- Automatic tracing::error logging for system errors
- Safe generic messages to frontend (no SQL/connection detail leaks)
- Typed error variants (Unauthorized, Forbidden, NotFound, etc.)
- 5 unit tests for message passthrough and detail hiding

Removes src/api/utils.rs entirely; all error creation now goes through
src/api/error.rs.
This commit is contained in:
xfy 2026-06-10 13:16:06 +08:00
parent b7220c28ef
commit a3fa602df2
7 changed files with 157 additions and 141 deletions

View File

@ -8,7 +8,7 @@ use crate::auth::{password, session};
#[cfg(feature = "server")]
use crate::auth::session::get_session_from_ctx;
#[cfg(feature = "server")]
use crate::api::utils::{db_conn_error, query_error};
use crate::api::error::AppError;
use crate::db::pool::get_conn;
use crate::models::user::{PublicUser, User, UserRole};
@ -90,12 +90,12 @@ pub async fn register(
});
}
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let admin_count: i64 = client
.query_one("SELECT COUNT(*) FROM users WHERE role = 'admin'", &[])
.await
.map_err(query_error)?
.map_err(AppError::query)?
.get(0);
if admin_count > 0 {
@ -106,10 +106,7 @@ pub async fn register(
});
}
let password_hash = password::hash_password(&password).map_err(|e| {
tracing::error!("Register password hash failed: {:?}", e);
ServerFnError::new(format!("密码哈希失败: {}", e))
})?;
let password_hash = password::hash_password(&password).map_err(|_| AppError::Internal("密码处理失败"))?;
let result = client
.query_one(
@ -156,7 +153,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
}
}
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let row = match client
.query_opt(
@ -174,15 +171,12 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
});
}
Err(e) => {
return Err(query_error(e));
return Err(AppError::query(e).into());
}
};
let password_hash: String = row.get("password_hash");
let valid = password::verify_password(&password, &password_hash).map_err(|e| {
tracing::error!("Login password verify failed: {:?}", e);
ServerFnError::new(format!("密码验证失败: {}", e))
})?;
let valid = password::verify_password(&password, &password_hash).map_err(|_| AppError::Internal("密码处理失败"))?;
if !valid {
return Ok(AuthResponse {
@ -202,10 +196,7 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
&[&user_id, &token, &expires_at],
)
.await
.map_err(|e| {
tracing::error!("Login session insert failed: {:?}", e);
ServerFnError::new(format!("创建 session 失败: {}", e))
})?;
.map_err(AppError::db_conn)?;
let cookie = format!(
"session={token}; HttpOnly; Path=/; Max-Age={}; SameSite=Lax",
@ -228,9 +219,8 @@ pub async fn login(username: String, password: String) -> Result<AuthResponse, S
pub async fn logout() -> Result<AuthResponse, ServerFnError> {
let token = get_session_from_ctx();
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
// 清除 cookie
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
ctx.add_response_header(
SET_COOKIE,
@ -238,15 +228,11 @@ pub async fn logout() -> Result<AuthResponse, ServerFnError> {
);
}
// 删除当前 session
if let Some(t) = token {
client
.execute("DELETE FROM sessions WHERE token = $1", &[&t])
.await
.map_err(|e| {
tracing::error!("Logout session delete failed: {:?}", e);
ServerFnError::new(format!("删除 session 失败: {}", e))
})?;
.map_err(AppError::query)?;
}
Ok(AuthResponse {
@ -263,7 +249,7 @@ pub struct CurrentUserResponse {
#[cfg(feature = "server")]
pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnError> {
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let row = client
.query_opt(
@ -274,7 +260,7 @@ pub async fn get_user_by_token(token: &str) -> Result<Option<User>, ServerFnErro
&[&token],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let user = match row {
Some(row) => {
@ -302,10 +288,7 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
None => return Ok(CurrentUserResponse { user: None }),
};
let user = match get_user_by_token(&token).await? {
Some(u) => Some(PublicUser::from(u)),
None => None,
};
let user = get_user_by_token(&token).await?.map(PublicUser::from);
Ok(CurrentUserResponse { user })
}

88
src/api/error.rs Normal file
View File

@ -0,0 +1,88 @@
use dioxus::prelude::ServerFnError;
#[derive(Debug)]
pub enum AppError {
Unauthorized(&'static str),
Forbidden(&'static str),
NotFound(&'static str),
DbConn(String),
Query(String),
Transaction(String),
Internal(&'static str),
}
impl AppError {
pub fn db_conn(e: impl std::fmt::Display) -> Self {
tracing::error!("DB connection failed: {e}");
AppError::DbConn(e.to_string())
}
pub fn query(e: impl std::fmt::Display) -> Self {
tracing::error!("Query failed: {e}");
AppError::Query(e.to_string())
}
pub fn tx(e: impl std::fmt::Display) -> Self {
tracing::error!("Transaction failed: {e}");
AppError::Transaction(e.to_string())
}
}
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(),
AppError::DbConn(_) => "服务暂时不可用".to_string(),
AppError::Query(_) => "操作失败".to_string(),
AppError::Transaction(_) => "操作失败".to_string(),
AppError::Internal(m) => m.to_string(),
};
ServerFnError::new(msg)
}
}
#[cfg(test)]
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}");
}
}

View File

@ -1,8 +1,8 @@
pub mod auth;
pub mod error;
pub mod image;
pub mod markdown;
pub mod posts;
pub mod rate_limit;
pub mod slug;
pub mod upload;
pub mod utils;

View File

@ -1,9 +1,9 @@
#![allow(clippy::unused_unit, deprecated, unused_imports)]
#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)]
use dioxus::prelude::*;
#[cfg(feature = "server")]
use crate::api::utils::{db_conn_error, query_error, tx_error};
use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::auth::session::get_session_from_ctx;
use crate::db::pool::get_conn;
@ -26,19 +26,16 @@ pub use crate::api::slug::{ensure_unique_slug, is_valid_slug, slugify};
// ============================================================================
#[cfg(feature = "server")]
async fn get_current_admin_user() -> Result<User, ServerFnError> {
let token = match get_session_from_ctx() {
Some(t) => t,
None => return Err(ServerFnError::new("未登录")),
};
async fn get_current_admin_user() -> Result<User, AppError> {
let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
let user = match crate::api::auth::get_user_by_token(&token).await? {
Some(u) => u,
None => return Err(ServerFnError::new("会话已过期")),
};
let user = crate::api::auth::get_user_by_token(&token)
.await
.map_err(AppError::query)?
.ok_or(AppError::Unauthorized("会话已过期"))?;
if user.role != UserRole::Admin {
return Err(ServerFnError::new("权限不足"));
return Err(AppError::Forbidden("权限不足"));
}
Ok(user)
@ -249,7 +246,7 @@ pub async fn create_post(
_ => crate::api::slug::slugify(&title),
};
let mut client = get_conn().await.map_err(db_conn_error)?;
let mut client = get_conn().await.map_err(AppError::db_conn)?;
let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, None).await?;
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
@ -266,7 +263,7 @@ pub async fn create_post(
None
};
let tx = client.transaction().await.map_err(tx_error)?;
let tx = client.transaction().await.map_err(AppError::db_conn)?;
let row = tx
.query_one(
@ -286,10 +283,7 @@ pub async fn create_post(
],
)
.await
.map_err(|e| {
tracing::error!("create post failed: {:?}", e);
ServerFnError::new(format!("创建文章失败: {}", e))
})?;
.map_err(AppError::tx)?;
let post_id: i32 = row.get(0);
@ -308,10 +302,7 @@ pub async fn create_post(
&[&tag_name.as_str()],
)
.await
.map_err(|e| {
tracing::error!("create tag failed: {:?}", e);
ServerFnError::new(format!("创建标签失败: {}", e))
})?;
.map_err(AppError::tx)?;
match row {
Some(r) => r.get(0),
@ -319,13 +310,8 @@ pub async fn create_post(
let row = tx
.query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
.await
.map_err(|e| {
tracing::error!("query tag failed: {:?}", e);
ServerFnError::new(format!("查询标签失败: {}", e))
})?;
row.map(|r| r.get(0)).ok_or_else(|| {
ServerFnError::new(format!("标签不存在: {}", tag_name))
})?
.map_err(AppError::query)?;
row.map(|r| r.get(0)).ok_or(AppError::NotFound("标签不存在"))?
}
}
};
@ -335,14 +321,11 @@ pub async fn create_post(
&[&post_id, &tag_id],
)
.await
.map_err(|e| {
tracing::error!("link tag failed: {:?}", e);
ServerFnError::new(format!("关联标签失败: {}", e))
})?;
.map_err(AppError::tx)?;
}
}
tx.commit().await.map_err(tx_error)?;
tx.commit().await.map_err(AppError::tx)?;
// Invalidate caches after successful creation
#[cfg(feature = "server")]
@ -377,13 +360,12 @@ pub async fn update_post(
) -> Result<CreatePostResponse, ServerFnError> {
let user = get_current_admin_user().await?;
let mut client = get_conn().await.map_err(db_conn_error)?;
let mut client = get_conn().await.map_err(AppError::db_conn)?;
// Get old slug before updating (for cache invalidation)
let old_slug: Option<String> = client
.query_opt("SELECT slug FROM posts WHERE id = $1", &[&post_id])
.await
.map_err(query_error)?
.map_err(AppError::query)?
.map(|r| r.get(0));
let exists: bool = client
@ -392,7 +374,7 @@ pub async fn update_post(
&[&post_id, &user.id],
)
.await
.map_err(query_error)?
.map_err(AppError::query)?
.is_some();
if !exists {
@ -429,9 +411,8 @@ pub async fn update_post(
let post_status = PostStatus::from_str(&status).unwrap_or(PostStatus::Draft);
let cover_image = cover_image.filter(|s| !s.trim().is_empty());
let tx = client.transaction().await.map_err(tx_error)?;
let tx = client.transaction().await.map_err(AppError::db_conn)?;
// Get old tags before deleting them (for cache invalidation)
let old_tags: Vec<String> = {
let rows = tx
.query(
@ -439,7 +420,7 @@ pub async fn update_post(
&[&post_id],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
rows.iter().map(|r| r.get(0)).collect()
};
@ -449,7 +430,7 @@ pub async fn update_post(
&[&post_id],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let published_at = if post_status == PostStatus::Published {
let was_published = old_status_row
@ -487,10 +468,7 @@ pub async fn update_post(
],
)
.await
.map_err(|e| {
tracing::error!("update post failed: {:?}", e);
ServerFnError::new(format!("更新文章失败: {}", e))
})?;
.map_err(AppError::tx)?;
let tags_cleaned: Vec<String> = tags
.into_iter()
@ -502,10 +480,7 @@ pub async fn update_post(
tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&post_id])
.await
.map_err(|e| {
tracing::error!("delete old tags failed: {:?}", e);
ServerFnError::new(format!("删除旧标签失败: {}", e))
})?;
.map_err(AppError::tx)?;
for tag_name in &tags_cleaned {
let tag_id: i32 = {
@ -515,10 +490,7 @@ pub async fn update_post(
&[&tag_name.as_str()],
)
.await
.map_err(|e| {
tracing::error!("create tag failed: {:?}", e);
ServerFnError::new(format!("创建标签失败: {}", e))
})?;
.map_err(AppError::tx)?;
match row {
Some(r) => r.get(0),
@ -526,12 +498,9 @@ pub async fn update_post(
let row = tx
.query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()])
.await
.map_err(|e| {
tracing::error!("query tag failed: {:?}", e);
ServerFnError::new(format!("查询标签失败: {}", e))
})?;
.map_err(AppError::query)?;
row.map(|r| r.get(0))
.ok_or_else(|| ServerFnError::new(format!("标签不存在: {}", tag_name)))?
.ok_or(AppError::NotFound("标签不存在"))?
}
}
};
@ -541,13 +510,10 @@ pub async fn update_post(
&[&post_id, &tag_id],
)
.await
.map_err(|e| {
tracing::error!("link tag failed: {:?}", e);
ServerFnError::new(format!("关联标签失败: {}", e))
})?;
.map_err(AppError::tx)?;
}
tx.commit().await.map_err(tx_error)?;
tx.commit().await.map_err(AppError::tx)?;
// Invalidate caches after successful update
#[cfg(feature = "server")]
@ -586,7 +552,7 @@ pub async fn update_post(
pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let row = client
.query_opt(
@ -602,7 +568,7 @@ pub async fn get_post_by_id(post_id: i32) -> Result<SinglePostResponse, ServerFn
&[&post_id],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let post = match row {
Some(row) => Some(row_to_post_list(&client, &row).await),
@ -618,7 +584,7 @@ pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, Server
return Ok(SinglePostResponse { post: cached });
}
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let row = client
.query_opt(
@ -652,7 +618,7 @@ pub async fn get_post_by_slug(slug: String) -> Result<SinglePostResponse, Server
&[&slug],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let post = match row {
Some(row) => Some(row_to_post_full(&client, &row).await),
@ -675,7 +641,7 @@ pub async fn list_published_posts(
return Ok(PostListResponse { posts: cached });
}
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let offset = ((page - 1).max(0) as i64) * (per_page as i64);
let limit = per_page as i64;
@ -695,7 +661,7 @@ pub async fn list_published_posts(
&[&limit, &offset],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let mut posts = Vec::new();
for row in &rows {
@ -710,7 +676,7 @@ pub async fn list_published_posts(
pub async fn list_posts() -> Result<PostListResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let rows = client
.query(
@ -727,7 +693,7 @@ pub async fn list_posts() -> Result<PostListResponse, ServerFnError> {
&[],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let mut posts = Vec::new();
for row in &rows {
@ -741,7 +707,7 @@ pub async fn list_posts() -> Result<PostListResponse, ServerFnError> {
pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let result = client
.execute(
@ -749,10 +715,7 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
&[&post_id],
)
.await
.map_err(|e| {
tracing::error!("delete failed: {:?}", e);
ServerFnError::new(format!("删除失败: {}", e))
})?;
.map_err(AppError::tx)?;
if result == 0 {
return Ok(CreatePostResponse {
@ -783,7 +746,7 @@ pub async fn list_tags() -> Result<TagListResponse, ServerFnError> {
return Ok(TagListResponse { tags: cached });
}
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let rows = client
.query(
@ -796,7 +759,7 @@ pub async fn list_tags() -> Result<TagListResponse, ServerFnError> {
&[],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let tags: Vec<Tag> = rows
.iter()
@ -817,7 +780,7 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
return Ok(PostListResponse { posts: cached });
}
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let rows = client
.query(
@ -836,7 +799,7 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
&[&tag_name],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let mut posts = Vec::new();
for row in &rows {
@ -855,12 +818,12 @@ pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let total: i64 = client
.query_one("SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL", &[])
.await
.map_err(query_error)?
.map_err(AppError::query)?
.get(0);
let drafts: i64 = client
@ -869,7 +832,7 @@ pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
&[],
)
.await
.map_err(query_error)?
.map_err(AppError::query)?
.get(0);
let published: i64 = client
@ -878,7 +841,7 @@ pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
&[],
)
.await
.map_err(query_error)?
.map_err(AppError::query)?
.get(0);
let stats = PostStats {
@ -892,7 +855,7 @@ pub async fn get_post_stats() -> Result<PostStatsResponse, ServerFnError> {
#[server(SearchPosts, "/api")]
pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnError> {
let client = get_conn().await.map_err(db_conn_error)?;
let client = get_conn().await.map_err(AppError::db_conn)?;
let q = query.trim();
if q.is_empty() {
@ -917,7 +880,7 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
&[&q],
)
.await
.map_err(query_error)?;
.map_err(AppError::query)?;
let mut posts = Vec::new();
for row in &rows {

View File

@ -41,7 +41,7 @@ pub async fn ensure_unique_slug(
base: &str,
exclude_id: Option<i32>,
) -> Result<String, ServerFnError> {
use crate::api::utils::query_error;
use crate::api::error::AppError;
let mut candidate = base.to_string();
let mut suffix = 2;
@ -54,7 +54,7 @@ pub async fn ensure_unique_slug(
&[&candidate, &exclude],
)
.await
.map_err(query_error)?
.map_err(AppError::query)?
.is_some()
} else {
client
@ -63,7 +63,7 @@ pub async fn ensure_unique_slug(
&[&candidate],
)
.await
.map_err(query_error)?
.map_err(AppError::query)?
.is_some()
};
@ -75,7 +75,7 @@ pub async fn ensure_unique_slug(
suffix += 1;
if candidate.len() > 200 {
return Err(ServerFnError::new("无法生成唯一 slug"));
return Err(AppError::Internal("无法生成唯一 slug").into());
}
}
}

View File

@ -1,19 +0,0 @@
#![allow(clippy::unused_unit)]
#[cfg(feature = "server")]
pub fn db_conn_error(e: impl std::fmt::Display) -> dioxus::prelude::ServerFnError {
tracing::error!("DB connection failed: {}", e);
dioxus::prelude::ServerFnError::new(format!("数据库连接失败: {}", e))
}
#[cfg(feature = "server")]
pub fn query_error(e: impl std::fmt::Display) -> dioxus::prelude::ServerFnError {
tracing::error!("Query failed: {}", e);
dioxus::prelude::ServerFnError::new(format!("查询失败: {}", e))
}
#[cfg(feature = "server")]
pub fn tx_error(e: impl std::fmt::Display) -> dioxus::prelude::ServerFnError {
tracing::error!("Transaction failed: {}", e);
dioxus::prelude::ServerFnError::new(format!("事务失败: {}", e))
}

View File

@ -211,6 +211,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
let on_submit = move |_| {
if title().trim().is_empty() {
error.set(Some("标题不能为空".to_string()));
#[allow(clippy::needless_return)]
return;
}