feat(diary): 手写引擎 + 日记 CRUD + 同步 API (Phase F3 + B2)

Flutter 手写引擎 (Phase F3):
- stroke_model.dart: 笔画数据模型 (StrokePoint/Stroke/BrushType)
- stroke_renderer.dart: perfect_freehand 渲染管线 + 四画笔参数
- handwriting_canvas.dart: Listener 输入 + 掌心抑制 + 去抖过滤
- editor_bloc.dart: BLoC 状态管理 + 撤销/重做 (50步)

Rust 日记 CRUD + 同步 (Phase B2):
- journal_service.rs: CRUD + 软删除 + 分页列表 + 事件发布
- sync_service.rs: 版本号同步 + 冲突检测
- journal_handler.rs: 5个API端点 + utoipa注解 + 权限守卫
- sync_handler.rs: 同步API端点
- error.rs: From<DiaryError> for AppError + 8个单元测试
- 路由注册: /diary/journals + /diary/sync

验证:
- cargo check: 0 error
- cargo test: 433 测试全通过
- flutter analyze: 1 warning (unused private param)
This commit is contained in:
iven
2026-06-01 00:36:05 +08:00
parent ee5ce9bc56
commit d0653614e0
12 changed files with 1727 additions and 4 deletions

View File

@@ -4,6 +4,8 @@ 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}")]
@@ -35,8 +37,37 @@ pub enum DiaryError {
#[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,
@@ -57,6 +88,7 @@ impl IntoResponse for DiaryError {
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 {
@@ -73,3 +105,89 @@ impl From<sea_orm::DbErr> for DiaryError {
DiaryError::Internal(err.to_string())
}
}
#[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),
}
}
}