- API client: proactive token refresh(请求前 30s 检查过期,提前刷新避免 401) - Plugin store: fetchPlugins promise 去重,防止 StrictMode 并发重复请求 - Home stats: 简化 useEffect 加载逻辑,修复 tagColor undefined crash - PluginGraphPage: valueStyle → styles.content, Spin tip → description(antd 6) - DashboardWidgets: trailColor → railColor(antd 6) - data_service: build_scope_sql 参数索引修复(硬编码 $100 → 动态 values.len()+1) - erp-core error: Internal 错误添加 tracing::error 日志输出
189 lines
5.9 KiB
Rust
189 lines
5.9 KiB
Rust
use axum::http::StatusCode;
|
||
use axum::response::{IntoResponse, Response};
|
||
use axum::Json;
|
||
use serde::Serialize;
|
||
|
||
/// 统一错误响应格式
|
||
#[derive(Debug, Serialize)]
|
||
pub struct ErrorResponse {
|
||
pub error: String,
|
||
pub message: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub details: Option<serde_json::Value>,
|
||
}
|
||
|
||
/// 平台级错误类型
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum AppError {
|
||
#[error("资源未找到: {0}")]
|
||
NotFound(String),
|
||
|
||
#[error("验证失败: {0}")]
|
||
Validation(String),
|
||
|
||
#[error("未授权")]
|
||
Unauthorized,
|
||
|
||
#[error("禁止访问: {0}")]
|
||
Forbidden(String),
|
||
|
||
#[error("冲突: {0}")]
|
||
Conflict(String),
|
||
|
||
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||
VersionMismatch,
|
||
|
||
#[error("请求过于频繁,请稍后重试")]
|
||
TooManyRequests,
|
||
|
||
#[error("内部错误: {0}")]
|
||
Internal(String),
|
||
}
|
||
|
||
impl IntoResponse for AppError {
|
||
fn into_response(self) -> Response {
|
||
let (status, message) = match &self {
|
||
AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||
AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "未授权".to_string()),
|
||
AppError::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()),
|
||
AppError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||
AppError::VersionMismatch => (StatusCode::CONFLICT, self.to_string()),
|
||
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
|
||
AppError::Internal(msg) => {
|
||
tracing::error!("Internal error: {}", msg);
|
||
(StatusCode::INTERNAL_SERVER_ERROR, "内部错误".to_string())
|
||
}
|
||
};
|
||
|
||
let body = ErrorResponse {
|
||
error: status.canonical_reason().unwrap_or("Error").to_string(),
|
||
message,
|
||
details: None,
|
||
};
|
||
|
||
(status, Json(body)).into_response()
|
||
}
|
||
}
|
||
|
||
impl From<anyhow::Error> for AppError {
|
||
fn from(err: anyhow::Error) -> Self {
|
||
AppError::Internal(err.to_string())
|
||
}
|
||
}
|
||
|
||
impl From<sea_orm::DbErr> for AppError {
|
||
fn from(err: sea_orm::DbErr) -> Self {
|
||
match err {
|
||
sea_orm::DbErr::RecordNotFound(msg) => AppError::NotFound(msg),
|
||
sea_orm::DbErr::Query(sea_orm::RuntimeErr::SqlxError(e))
|
||
if e.to_string().contains("duplicate key") =>
|
||
{
|
||
AppError::Conflict("记录已存在".to_string())
|
||
}
|
||
_ => AppError::Internal(err.to_string()),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub type AppResult<T> = Result<T, AppError>;
|
||
|
||
/// 检查乐观锁版本是否匹配。
|
||
///
|
||
/// 返回下一个版本号(actual + 1),或 VersionMismatch 错误。
|
||
pub fn check_version(expected: i32, actual: i32) -> AppResult<i32> {
|
||
if expected == actual {
|
||
Ok(actual + 1)
|
||
} else {
|
||
Err(AppError::VersionMismatch)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn check_version_ok() {
|
||
assert_eq!(check_version(1, 1).unwrap(), 2);
|
||
assert_eq!(check_version(5, 5).unwrap(), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn check_version_mismatch() {
|
||
let result = check_version(1, 2);
|
||
assert!(result.is_err());
|
||
match result.unwrap_err() {
|
||
AppError::VersionMismatch => {}
|
||
other => panic!("Expected VersionMismatch, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn db_err_record_not_found_maps_to_not_found() {
|
||
let err = sea_orm::DbErr::RecordNotFound("test".to_string());
|
||
let app_err: AppError = err.into();
|
||
match app_err {
|
||
AppError::NotFound(msg) => assert_eq!(msg, "test"),
|
||
other => panic!("Expected NotFound, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn db_err_generic_maps_to_internal() {
|
||
let db_err = sea_orm::DbErr::Custom("some error".to_string());
|
||
let app_err: AppError = db_err.into();
|
||
match app_err {
|
||
AppError::Internal(msg) => assert!(msg.contains("some error")),
|
||
other => panic!("Expected Internal, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn app_error_into_response_status_codes() {
|
||
// NotFound -> 404
|
||
let resp = AppError::NotFound("test".to_string()).into_response();
|
||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||
|
||
// Validation -> 400
|
||
let resp = AppError::Validation("bad input".to_string()).into_response();
|
||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||
|
||
// Unauthorized -> 401
|
||
let resp = AppError::Unauthorized.into_response();
|
||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||
|
||
// Forbidden -> 403
|
||
let resp = AppError::Forbidden("no access".to_string()).into_response();
|
||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||
|
||
// VersionMismatch -> 409
|
||
let resp = AppError::VersionMismatch.into_response();
|
||
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
||
|
||
// TooManyRequests -> 429
|
||
let resp = AppError::TooManyRequests.into_response();
|
||
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||
|
||
// Internal -> 500
|
||
let resp = AppError::Internal("oops".to_string()).into_response();
|
||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||
}
|
||
|
||
#[test]
|
||
fn app_error_internal_hides_details_from_response() {
|
||
// Internal errors should map to 500 with a generic message
|
||
let resp = AppError::Internal("sensitive db error detail".to_string()).into_response();
|
||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||
}
|
||
|
||
#[test]
|
||
fn anyhow_error_maps_to_internal() {
|
||
let err: AppError = anyhow::anyhow!("something went wrong").into();
|
||
match err {
|
||
AppError::Internal(msg) => assert_eq!(msg, "something went wrong"),
|
||
other => panic!("Expected Internal, got {:?}", other),
|
||
}
|
||
}
|
||
}
|