Files
hms/crates/erp-core/src/error.rs
iven 5ba11f985f fix(web,plugin): 前端审计修复 — 401 消除 + 统计卡片 crash + 销售漏斗 500 + antd 6 废弃 API
- 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 日志输出
2026-04-18 20:31:49 +08:00

189 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
}
}
}