Files
nj/crates/erp-diary/src/error.rs
iven e0052ea99b fix(diary): 添加事务 — create_class/join_class/parent 删除原子化
- create_class: 班级创建 + 老师成员插入包裹在 db.transaction() 中
- join_class: 成员插入 + member_count 更新包裹在事务中
- delete_child_data: PIPL 删除权 — 逐条软删除包裹在事务中(避免部分删除)
- DiaryError: 添加 From<TransactionError<DiaryError>> 支持事务闭包

审计 ID: B-07, B-11, 8a-C03
2026-06-03 01:03:57 +08:00

206 lines
6.7 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.
// 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 层统一返回 AppErrorService 层统一返回 DiaryError。
/// 这个 impl 让 Handler 中的 `?` 操作符自动完成转换。
impl From<DiaryError> 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<T> = Result<T, DiaryError>;
#[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<sea_orm::DbErr> for DiaryError {
fn from(err: sea_orm::DbErr) -> Self {
DiaryError::Internal(err.to_string())
}
}
/// 支持 db.transaction() 闭包的错误转换
impl From<sea_orm::TransactionError<DiaryError>> for DiaryError {
fn from(err: sea_orm::TransactionError<DiaryError>) -> 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),
}
}
}