// erp-diary 错误类型 use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Serialize; use erp_core::error::AppError; #[derive(Debug, thiserror::Error)] pub enum DiaryError { #[error("日记未找到: {0}")] NotFound(String), #[error("版本冲突: 本地版本 {local}, 服务端版本 {server}")] VersionConflict { local: i32, server: i32 }, #[error("班级码无效")] InvalidClassCode, #[error("班级码已过期")] ClassCodeExpired, #[error("班级码尝试次数过多,请 {lockout_minutes} 分钟后重试")] ClassCodeLocked { lockout_minutes: u32 }, #[error("无权限执行此操作")] Forbidden, #[error("内容安全检查未通过")] ContentSafetyViolation, #[error("同步失败: {0}")] SyncFailed(String), #[error("{0}")] BadRequest(String), #[error("内部错误: {0}")] Internal(String), #[error("{0}")] Validation(String), } /// DiaryError -> AppError 转换 /// /// Handler 层统一返回 AppError,Service 层统一返回 DiaryError。 /// 这个 impl 让 Handler 中的 `?` 操作符自动完成转换。 impl From for AppError { fn from(err: DiaryError) -> Self { match err { DiaryError::NotFound(msg) => AppError::NotFound(msg), DiaryError::VersionConflict { .. } => AppError::VersionMismatch, DiaryError::InvalidClassCode | DiaryError::ClassCodeExpired => { AppError::Validation(err.to_string()) } DiaryError::ClassCodeLocked { .. } => AppError::TooManyRequests, DiaryError::Forbidden => AppError::Forbidden("权限不足".to_string()), DiaryError::ContentSafetyViolation => AppError::Validation(err.to_string()), DiaryError::SyncFailed(_) => AppError::Internal(err.to_string()), DiaryError::BadRequest(msg) => AppError::Validation(msg), DiaryError::Internal(_) => AppError::Internal(err.to_string()), DiaryError::Validation(msg) => AppError::Validation(msg), } } } /// Diary 模块 Result 类型别名 pub type DiaryResult = Result; #[derive(Serialize)] struct ErrorBody { error: String, message: String, } impl IntoResponse for DiaryError { fn into_response(self) -> Response { let (status, message) = match &self { DiaryError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), DiaryError::VersionConflict { .. } => (StatusCode::CONFLICT, self.to_string()), DiaryError::InvalidClassCode | DiaryError::ClassCodeExpired => { (StatusCode::BAD_REQUEST, self.to_string()) } DiaryError::ClassCodeLocked { .. } => (StatusCode::TOO_MANY_REQUESTS, self.to_string()), DiaryError::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), DiaryError::ContentSafetyViolation => (StatusCode::BAD_REQUEST, self.to_string()), DiaryError::SyncFailed(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), DiaryError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), DiaryError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), DiaryError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()), }; let body = ErrorBody { error: format!("diary.{}", status.as_u16()), message, }; (status, axum::Json(body)).into_response() } } impl From for DiaryError { fn from(err: sea_orm::DbErr) -> Self { DiaryError::Internal(err.to_string()) } } /// 支持 db.transaction() 闭包的错误转换 impl From> for DiaryError { fn from(err: sea_orm::TransactionError) -> Self { match err { sea_orm::TransactionError::Connection(db_err) => { DiaryError::Internal(db_err.to_string()) } sea_orm::TransactionError::Transaction(inner) => inner, } } } #[cfg(test)] mod tests { use super::*; use erp_core::error::AppError; #[test] fn diary_error_not_found_maps_to_app_not_found() { let app: AppError = DiaryError::NotFound("journal-123".to_string()).into(); match app { AppError::NotFound(msg) => assert_eq!(msg, "journal-123"), other => panic!("Expected NotFound, got {:?}", other), } } #[test] fn diary_error_version_conflict_maps_to_version_mismatch() { let app: AppError = DiaryError::VersionConflict { local: 1, server: 2, } .into(); match app { AppError::VersionMismatch => {} other => panic!("Expected VersionMismatch, got {:?}", other), } } #[test] fn diary_error_forbidden_maps_to_app_forbidden() { let app: AppError = DiaryError::Forbidden.into(); match app { AppError::Forbidden(_) => {} other => panic!("Expected Forbidden, got {:?}", other), } } #[test] fn diary_error_internal_maps_to_app_internal() { let app: AppError = DiaryError::Internal("db error".to_string()).into(); match app { AppError::Internal(_) => {} other => panic!("Expected Internal, got {:?}", other), } } #[test] fn diary_error_validation_maps_to_app_validation() { let app: AppError = DiaryError::Validation("标题不能为空".to_string()).into(); match app { AppError::Validation(msg) => assert_eq!(msg, "标题不能为空"), other => panic!("Expected Validation, got {:?}", other), } } #[test] fn diary_error_bad_request_maps_to_app_validation() { let app: AppError = DiaryError::BadRequest("参数错误".to_string()).into(); match app { AppError::Validation(msg) => assert_eq!(msg, "参数错误"), other => panic!("Expected Validation, got {:?}", other), } } #[test] fn diary_error_class_code_locked_maps_to_too_many_requests() { let app: AppError = DiaryError::ClassCodeLocked { lockout_minutes: 30, } .into(); match app { AppError::TooManyRequests => {} other => panic!("Expected TooManyRequests, got {:?}", other), } } #[test] fn db_err_maps_to_diary_internal() { let err = sea_orm::DbErr::Custom("connection failed".to_string()); let diary_err: DiaryError = err.into(); match diary_err { DiaryError::Internal(msg) => assert!(msg.contains("connection failed")), other => panic!("Expected Internal, got {:?}", other), } } }