269 lines
9.4 KiB
Rust
269 lines
9.4 KiB
Rust
use axum::{
|
|
Json,
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
/// 业务状态码 (Business Code)
|
|
/// 前端根据此 Code 进行逻辑跳转
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
|
#[repr(u32)]
|
|
pub enum BizCode {
|
|
Success = 0,
|
|
|
|
// 10xxx: 服务端通用错误
|
|
ServerError = 10000, // 未知/兜底错误
|
|
DbError = 10001, // 数据库操作失败
|
|
CacheError = 10002, // Redis/缓存失败
|
|
SerializationError = 10003, // JSON 解析/序列化失败
|
|
ExternalServiceError = 10004, // 调用第三方/下游服务失败
|
|
ConfigError = 10005, // 配置加载失败
|
|
|
|
// 20xxx: 认证授权相关 (双 Token 核心逻辑)
|
|
Unauthorized = 20000, // 未认证 (通用)
|
|
AccessTokenExpired = 20001, // [前端触发] 静默刷新
|
|
RefreshTokenExpired = 20002, // [前端触发] 强制登出
|
|
PermissionDenied = 20003, // 无权限 (403)
|
|
AccountDisabled = 20004, // 账号被禁用/锁定
|
|
InvalidCredentials = 20005, // 账号或密码错误
|
|
MissingHeader = 20006, // 缺少必要的 Header (如 x-trace-id, Authorization)
|
|
|
|
// === 30xxx: 客户端输入错误 ===
|
|
BadRequest = 30000, // 通用请求参数错误
|
|
ValidationError = 30001, // 字段校验不通过 (Validation)
|
|
ResourceNotFound = 30002, // 资源不存在 (404)
|
|
ResourceAlreadyExists = 30003, // 资源冲突/重复 (409)
|
|
MethodNotAllowed = 30004, // HTTP 方法不支持
|
|
|
|
// === 40xxx: 业务流控/状态 ===
|
|
RateLimitExceeded = 40000, // 请求过于频繁 (429)
|
|
PreconditionFailed = 40001, // 业务前置条件不满足 (如:余额不足无法支付)
|
|
}
|
|
|
|
/// 全局应用错误枚举
|
|
#[derive(Error, Debug)]
|
|
pub enum AppError {
|
|
// --- 基础设施错误 ---
|
|
#[cfg(feature = "with-sqlx")]
|
|
#[error("Database error: {0}")]
|
|
DbError(sqlx::Error),
|
|
|
|
#[cfg(feature = "with-redis")]
|
|
#[error("Cache error: {0}")]
|
|
CacheError(#[from] redis::RedisError),
|
|
|
|
// 外部服务调用错误 (如调用其他微服务失败)
|
|
#[error("External service error: {0}")]
|
|
ExternalReqError(String), // 对应 reqwest::Error
|
|
|
|
// 消息队列错误
|
|
#[error("MQ error: {0}")]
|
|
MqError(String),
|
|
|
|
#[error("IO error: {0}")]
|
|
IoError(#[from] std::io::Error),
|
|
|
|
// 序列化/反序列化错误
|
|
#[error("Serialization error: {0}")]
|
|
SerdeError(#[from] serde_json::Error),
|
|
|
|
// 任意未预期错误 (兜底)
|
|
#[cfg(feature = "with-anyhow")]
|
|
#[error("Unexpected error: {0}")]
|
|
AnyhowError(#[from] anyhow::Error),
|
|
|
|
// ======================================================
|
|
// 2. 认证与授权层 (Auth) - 双 Token 核心
|
|
// ======================================================
|
|
#[error("Authentication failed: {0}")]
|
|
AuthError(String), // 通用认证失败 (签名错误、格式错误)
|
|
|
|
#[error("Missing authorization header")]
|
|
MissingAuthHeader,
|
|
|
|
#[error("Access token has expired")]
|
|
AccessTokenExpired, // 触发 refresh
|
|
|
|
#[error("Refresh token has expired")]
|
|
RefreshTokenExpired, // 触发 logout
|
|
|
|
#[error("Permission denied: {0}")]
|
|
PermissionDenied(String), // 403
|
|
|
|
#[error("Account is disabled or locked")]
|
|
AccountLocked,
|
|
|
|
// ======================================================
|
|
// 3. 客户端输入层 (Client Side)
|
|
// ======================================================
|
|
#[error("Resource not found: {0}")]
|
|
NotFound(String), // 404
|
|
|
|
#[error("Resource already exists: {0}")]
|
|
AlreadyExists(String), // 409 Conflict
|
|
|
|
#[error("Invalid request parameters: {0}")]
|
|
BadRequest(String), // 400
|
|
|
|
#[cfg(feature = "with-validator")]
|
|
#[error("Validation error: {0}")]
|
|
ValidationError(String),
|
|
|
|
// ======================================================
|
|
// 4. 业务逻辑层 (Business Logic)
|
|
// ======================================================
|
|
#[error("Rate limit exceeded, please try again later")]
|
|
RateLimitExceeded, // 429
|
|
|
|
#[error("Business precondition failed: {0}")]
|
|
BusinessLogicError(String), // 业务状态冲突 (例如:订单已支付不能取消)
|
|
}
|
|
|
|
/// 响应给前端的 JSON 结构
|
|
#[derive(Serialize)]
|
|
pub struct ErrorResponse {
|
|
pub code: u32,
|
|
pub message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub trace_id: Option<String>, // 可选:返回 trace_id 方便排查
|
|
}
|
|
|
|
// 1. 实现 sqlx::Error -> AppError
|
|
#[cfg(feature = "with-sqlx")]
|
|
impl From<sqlx::Error> for AppError {
|
|
fn from(e: sqlx::Error) -> Self {
|
|
match e {
|
|
// 将 "查不到数据" 转换为 "NotFound" 业务错误
|
|
sqlx::Error::RowNotFound => AppError::NotFound("Database row not found".into()),
|
|
// 其他错误保持为 DbError
|
|
other => AppError::DbError(other),
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. 实现 validator::ValidationErrors -> AppError
|
|
#[cfg(feature = "with-validator")]
|
|
impl From<validator::ValidationErrors> for AppError {
|
|
fn from(e: validator::ValidationErrors) -> Self {
|
|
// 将复杂的校验错误结构体转为字符串供前端显示
|
|
// 你也可以选择在这里只取第一个错误,或者转为 JSON 字符串
|
|
AppError::ValidationError(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl AppError {
|
|
// 映射 HTTP 状态码 (给网关/浏览器看)
|
|
fn http_status(&self) -> StatusCode {
|
|
match self {
|
|
AppError::DbError(_)
|
|
| AppError::CacheError(_)
|
|
| AppError::ExternalReqError(_)
|
|
| AppError::MqError(_)
|
|
| AppError::IoError(_)
|
|
| AppError::SerdeError(_)
|
|
| AppError::AnyhowError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
// 401 Unauthorized
|
|
AppError::AuthError(_)
|
|
| AppError::MissingAuthHeader
|
|
| AppError::AccessTokenExpired
|
|
| AppError::RefreshTokenExpired
|
|
| AppError::AccountLocked => StatusCode::UNAUTHORIZED,
|
|
|
|
// 403 Forbidden
|
|
AppError::PermissionDenied(_) => StatusCode::FORBIDDEN,
|
|
|
|
// 404 Not Found
|
|
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
|
|
|
// 409 Conflict
|
|
AppError::AlreadyExists(_) => StatusCode::CONFLICT,
|
|
|
|
// 429 Too Many Requests
|
|
AppError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
|
|
|
|
// 400 Bad Request (默认)
|
|
_ => StatusCode::BAD_REQUEST,
|
|
}
|
|
}
|
|
|
|
// 映射业务状态码 (给前端代码看)
|
|
fn biz_code(&self) -> BizCode {
|
|
match self {
|
|
// Infra
|
|
AppError::DbError(_) => BizCode::DbError,
|
|
AppError::CacheError(_) => BizCode::CacheError,
|
|
AppError::ExternalReqError(_) => BizCode::ExternalServiceError,
|
|
AppError::MqError(_) | AppError::IoError(_) | AppError::AnyhowError(_) => {
|
|
BizCode::ServerError
|
|
}
|
|
AppError::SerdeError(_) => BizCode::SerializationError,
|
|
|
|
// Auth
|
|
AppError::AuthError(_) => BizCode::Unauthorized,
|
|
AppError::MissingAuthHeader => BizCode::MissingHeader,
|
|
AppError::AccessTokenExpired => BizCode::AccessTokenExpired,
|
|
AppError::RefreshTokenExpired => BizCode::RefreshTokenExpired,
|
|
AppError::PermissionDenied(_) => BizCode::PermissionDenied,
|
|
AppError::AccountLocked => BizCode::AccountDisabled,
|
|
|
|
// Client
|
|
AppError::NotFound(_) => BizCode::ResourceNotFound,
|
|
AppError::AlreadyExists(_) => BizCode::ResourceAlreadyExists,
|
|
AppError::BadRequest(_) => BizCode::BadRequest,
|
|
AppError::ValidationError(_) => BizCode::ValidationError,
|
|
|
|
// Biz
|
|
AppError::RateLimitExceeded => BizCode::RateLimitExceeded,
|
|
AppError::BusinessLogicError(_) => BizCode::PreconditionFailed,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 核心:实现 Axum 的 IntoResponse
|
|
impl IntoResponse for AppError {
|
|
fn into_response(self) -> Response {
|
|
let status = self.http_status();
|
|
let biz_code = self.biz_code();
|
|
// 生产环境通常不把详细的 DB 报错返回给前端,防止泄露表结构
|
|
// 但这里为了演示,我们先直接使用 self.to_string()
|
|
// 建议:在生产环境针对 DbError/AnyhowError 返回统一的 "Internal Server Error"
|
|
let message = match self {
|
|
AppError::DbError(_) | AppError::AnyhowError(_) => {
|
|
// 如果是生产环境(release模式),隐藏敏感信息
|
|
#[cfg(not(debug_assertions))]
|
|
{
|
|
"Internal server error".to_string()
|
|
}
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
self.to_string()
|
|
}
|
|
}
|
|
_ => self.to_string(),
|
|
};
|
|
|
|
// 1. 自动打印 Error 日志 (带上 Trace)
|
|
// 只有开启 telemetry feature 时才打印
|
|
#[cfg(feature = "telemetry")]
|
|
{
|
|
tracing::error!(
|
|
%status,
|
|
code = ?biz_code, // 打印枚举名
|
|
error_msg = %self, // 打印详细错误信息
|
|
"Request failed"
|
|
);
|
|
}
|
|
|
|
// 2. 构建 JSON
|
|
let body = Json(ErrorResponse {
|
|
code: biz_code as u32,
|
|
message,
|
|
trace_id: None, // 实际项目中从 tracing context 获取
|
|
});
|
|
|
|
(status, body).into_response()
|
|
}
|
|
}
|