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

@@ -0,0 +1,136 @@
// 编辑器 BLoC — 手写状态管理 + 撤销/重做
import 'package:flutter_bloc/flutter_bloc.dart';
import '../widgets/stroke_model.dart';
// ===== Events =====
abstract class EditorEvent {}
class BrushChanged extends EditorEvent {
final BrushType type;
final String color;
final double width;
BrushChanged({required this.type, required this.color, required this.width});
}
class StrokeCompleted extends EditorEvent {
final Stroke stroke;
StrokeCompleted(this.stroke);
}
class Undo extends EditorEvent {}
class Redo extends EditorEvent {}
class ClearCanvas extends EditorEvent {}
class StrokesLoaded extends EditorEvent {
final List<Stroke> strokes;
StrokesLoaded(this.strokes);
}
// ===== State =====
class EditorState {
final List<Stroke> strokes;
final List<Stroke> redoStack;
final BrushType brushType;
final String brushColor;
final double brushWidth;
final int maxUndoSteps;
const EditorState({
this.strokes = const [],
this.redoStack = const [],
this.brushType = BrushType.pen,
this.brushColor = '#2D2420',
this.brushWidth = 3.0,
this.maxUndoSteps = 50,
});
EditorState copyWith({
List<Stroke>? strokes,
List<Stroke>? redoStack,
BrushType? brushType,
String? brushColor,
double? brushWidth,
}) =>
EditorState(
strokes: strokes ?? this.strokes,
redoStack: redoStack ?? this.redoStack,
brushType: brushType ?? this.brushType,
brushColor: brushColor ?? this.brushColor,
brushWidth: brushWidth ?? this.brushWidth,
maxUndoSteps: maxUndoSteps,
);
}
// ===== BLoC =====
class EditorBloc extends Bloc<EditorEvent, EditorState> {
EditorBloc() : super(const EditorState()) {
on<BrushChanged>(_onBrushChanged);
on<StrokeCompleted>(_onStrokeCompleted);
on<Undo>(_onUndo);
on<Redo>(_onRedo);
on<ClearCanvas>(_onClearCanvas);
on<StrokesLoaded>(_onStrokesLoaded);
}
void _onBrushChanged(BrushChanged event, Emitter<EditorState> emit) {
emit(state.copyWith(
brushType: event.type,
brushColor: event.color,
brushWidth: event.width,
));
}
void _onStrokeCompleted(StrokeCompleted event, Emitter<EditorState> emit) {
final updatedStrokes = List<Stroke>.from(state.strokes)..add(event.stroke);
// 超过最大撤销步数时移除最旧的
if (updatedStrokes.length > state.maxUndoSteps) {
updatedStrokes.removeAt(0);
}
emit(state.copyWith(
strokes: updatedStrokes,
redoStack: [], // 新笔画清空重做栈
));
}
void _onUndo(Undo event, Emitter<EditorState> emit) {
if (state.strokes.isEmpty) return;
final updatedStrokes = List<Stroke>.from(state.strokes);
final lastStroke = updatedStrokes.removeLast();
final updatedRedoStack = List<Stroke>.from(state.redoStack)..add(lastStroke);
emit(state.copyWith(
strokes: updatedStrokes,
redoStack: updatedRedoStack,
));
}
void _onRedo(Redo event, Emitter<EditorState> emit) {
if (state.redoStack.isEmpty) return;
final updatedRedoStack = List<Stroke>.from(state.redoStack);
final stroke = updatedRedoStack.removeLast();
final updatedStrokes = List<Stroke>.from(state.strokes)..add(stroke);
emit(state.copyWith(
strokes: updatedStrokes,
redoStack: updatedRedoStack,
));
}
void _onClearCanvas(ClearCanvas event, Emitter<EditorState> emit) {
emit(state.copyWith(strokes: [], redoStack: []));
}
void _onStrokesLoaded(StrokesLoaded event, Emitter<EditorState> emit) {
emit(state.copyWith(strokes: event.strokes, redoStack: []));
}
}

View File

@@ -0,0 +1,242 @@
/// 手写 Canvas 主组件 — 暖记手写引擎的用户交互层。
///
/// 核心设计决策:
/// - 使用 [Listener] 而非 [GestureDetector] 处理指针事件(降低延迟)
/// - 支持触控笔模式下的掌心抑制palm rejection
/// - 轻量去抖±1px 移动平均过滤,消除手指微小抖动
/// - [RepaintBoundary] 隔离重绘范围,避免影响父组件
///
/// 性能目标p99 延迟 < 16ms满足 60fps 要求)。
library;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'stroke_model.dart';
import 'stroke_renderer.dart';
// ============================================================
// 手写 Canvas 组件
// ============================================================
/// 手写画布,接收用户输入并实时渲染笔画。
///
/// 使用方式:
/// ```dart
/// HandwritingCanvas(
/// brushType: BrushType.pen,
/// brushColor: '#2D2420',
/// brushWidth: 3.0,
/// strokes: editorState.strokes,
/// onStrokeCompleted: (stroke) => context.read<EditorBloc>().add(
/// StrokeCompleted(stroke),
/// ),
/// )
/// ```
class HandwritingCanvas extends StatefulWidget {
const HandwritingCanvas({
super.key,
this.brushType = BrushType.pen,
this.brushColor = '#2D2420',
this.brushWidth = 3.0,
this.onStrokeCompleted,
this.strokes = const [],
});
/// 当前选中的画笔类型。
final BrushType brushType;
/// 当前画笔颜色CSS 十六进制格式,如 '#2D2420')。
final String brushColor;
/// 当前画笔宽度(基准 3.0)。
final double brushWidth;
/// 笔画完成时的回调,将新笔画传递给上层(通常是 BLoC
final ValueChanged<Stroke>? onStrokeCompleted;
/// 已有的笔画列表(用于重绘历史内容)。
final List<Stroke> strokes;
@override
State<HandwritingCanvas> createState() => _HandwritingCanvasState();
}
class _HandwritingCanvasState extends State<HandwritingCanvas> {
// ============================================================
// 状态
// ============================================================
/// 正在绘制中的笔画采样点。
List<StrokePoint> _currentPoints = const [];
/// RepaintBoundary 的 key用于隔离重绘。
final GlobalKey _repaintBoundaryKey = GlobalKey();
/// UUID 生成器实例。
static const _uuid = Uuid();
// ============================================================
// 掌心抑制
// ============================================================
/// 是否启用掌心抑制。
///
/// 当设备支持触控笔 (stylus) 时启用:只响应 stylus 输入,
/// 忽略触摸 (touch) 输入,防止手掌误触。
bool _palmRejectionEnabled = false;
// ============================================================
// 去抖
// ============================================================
/// 上一个有效采样点的坐标(用于去抖计算)。
Offset? _lastAcceptedPoint;
/// 去抖阈值(像素):移动距离小于此值的点将被过滤。
static const double _debounceThreshold = 1.0;
// ============================================================
// 指针事件处理
// ============================================================
/// 指针按下:开始新笔画。
///
/// 掌心抑制逻辑:如果已检测到触控笔设备,则只响应 stylus 事件,
/// 忽略 touch 事件。首次检测到 stylus 时自动启用抑制。
void _onPointerDown(PointerDownEvent event) {
// 检测触控笔设备并启用掌心抑制
if (event.kind == PointerDeviceKind.stylus) {
_palmRejectionEnabled = true;
}
// 掌心抑制:如果已启用且当前非 stylus 输入,忽略
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
// 开始新笔画
final point = StrokePoint(
x: event.localPosition.dx,
y: event.localPosition.dy,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
_currentPoints = [point];
_lastAcceptedPoint = Offset(point.x, point.y);
}
/// 指针移动:添加采样点(带去抖过滤)。
void _onPointerMove(PointerMoveEvent event) {
if (_currentPoints.isEmpty) return;
// 掌心抑制
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
final candidate = Offset(event.localPosition.dx, event.localPosition.dy);
// 轻量去抖:移动距离 < 阈值时忽略(消除手指微抖)
if (_lastAcceptedPoint != null) {
final distance = (candidate - _lastAcceptedPoint!).distance;
if (distance < _debounceThreshold) return;
}
final point = StrokePoint(
x: candidate.dx,
y: candidate.dy,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
setState(() {
_currentPoints = [..._currentPoints, point];
});
_lastAcceptedPoint = candidate;
}
/// 指针抬起:完成笔画并通过回调传递给上层。
void _onPointerUp(PointerUpEvent event) {
if (_currentPoints.isEmpty) return;
// 掌心抑制
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
// 至少需要 2 个点才能构成有意义的笔画
if (_currentPoints.length < 2) {
_currentPoints = const [];
_lastAcceptedPoint = null;
return;
}
// 创建不可变的 Stroke 对象
final stroke = Stroke(
id: _uuid.v4(),
points: List.unmodifiable(_currentPoints),
brushType: widget.brushType,
color: widget.brushColor,
width: widget.brushWidth,
);
// 通知上层
widget.onStrokeCompleted?.call(stroke);
// 重置当前绘制状态
setState(() {
_currentPoints = const [];
_lastAcceptedPoint = null;
});
}
/// 指针取消(如来电打断):丢弃当前笔画。
void _onPointerCancel(PointerCancelEvent event) {
setState(() {
_currentPoints = const [];
_lastAcceptedPoint = null;
});
}
// ============================================================
// 构建
// ============================================================
@override
Widget build(BuildContext context) {
// 构造当前正在绘制的笔画(用于实时预览)
final currentStroke = _currentPoints.length >= 2
? Stroke(
id: '__current__', // 临时 ID不会被持久化
points: _currentPoints,
brushType: widget.brushType,
color: widget.brushColor,
width: widget.brushWidth,
)
: null;
return RepaintBoundary(
key: _repaintBoundaryKey,
child: ClipRect(
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
child: CustomPaint(
painter: StrokePainter(
completedStrokes: widget.strokes,
currentStroke: currentStroke,
),
// 占满父组件可用空间
size: Size.infinite,
),
),
),
);
}
}

View File

@@ -0,0 +1,111 @@
// 笔画数据模型 — 手写不可变类(避免 build_runner 依赖)
import 'dart:collection';
/// 画笔类型
enum BrushType {
pen('pen'),
pencil('pencil'),
marker('marker'),
eraser('eraser');
const BrushType(this.value);
final String value;
}
/// 笔画点
class StrokePoint {
const StrokePoint({
required this.x,
required this.y,
this.pressure = 0.5,
this.timestamp = 0,
});
final double x;
final double y;
final double pressure;
final int timestamp;
StrokePoint copyWith({
double? x,
double? y,
double? pressure,
int? timestamp,
}) =>
StrokePoint(
x: x ?? this.x,
y: y ?? this.y,
pressure: pressure ?? this.pressure,
timestamp: timestamp ?? this.timestamp,
);
Map<String, dynamic> toJson() => {
'x': x,
'y': y,
'pressure': pressure,
'timestamp': timestamp,
};
factory StrokePoint.fromJson(Map<String, dynamic> json) => StrokePoint(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5,
timestamp: (json['timestamp'] as int?) ?? 0,
);
}
/// 笔画
class Stroke {
const Stroke({
required this.id,
required this.points,
this.brushType = BrushType.pen,
this.color = '#2D2420',
this.width = 3.0,
});
final String id;
final List<StrokePoint> points;
final BrushType brushType;
final String color;
final double width;
Stroke copyWith({
String? id,
List<StrokePoint>? points,
BrushType? brushType,
String? color,
double? width,
}) =>
Stroke(
id: id ?? this.id,
points: points ?? this.points,
brushType: brushType ?? this.brushType,
color: color ?? this.color,
width: width ?? this.width,
);
Map<String, dynamic> toJson() => {
'id': id,
'points': points.map((p) => p.toJson()).toList(),
'brushType': brushType.value,
'color': color,
'width': width,
};
factory Stroke.fromJson(Map<String, dynamic> json) => Stroke(
id: json['id'] as String,
points: UnmodifiableListView(
(json['points'] as List)
.map((p) => StrokePoint.fromJson(p as Map<String, dynamic>))
.toList(),
),
brushType: BrushType.values.firstWhere(
(b) => b.value == json['brushType'],
orElse: () => BrushType.pen,
),
color: (json['color'] as String?) ?? '#2D2420',
width: (json['width'] as num?)?.toDouble() ?? 3.0,
);
}

View File

@@ -0,0 +1,235 @@
// 笔画渲染器 — 将 StrokePoint 列表转换为 Flutter 可绘制路径。
//
// 核心流程:
// StrokePoint[] → perfect_freehand.getStroke() → Point[] (轮廓多边形)
// → buildStrokePath() → Path → CustomPainter.drawPath()
//
// 每种画笔pen/pencil/marker/eraser拥有独立的 perfect_freehand 参数,
// 产生不同的视觉效果。
import 'package:flutter/widgets.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'stroke_model.dart';
// ============================================================
// 画笔参数配置
// ============================================================
/// perfect_freehand 参数集,按画笔类型区分。
///
/// 参考设计规格 v1.2:钢笔细致、铅笔柔和、马克笔粗半透明、橡皮擦大范围。
class _BrushConfig {
final double size;
final double thinning;
final double smoothing;
final double streamline;
final bool simulatePressure;
const _BrushConfig({
required this.size,
required this.thinning,
required this.smoothing,
this.streamline = 0.5,
required this.simulatePressure,
});
}
/// 各画笔的渲染参数。
const Map<BrushType, _BrushConfig> _brushConfigs = {
/// 钢笔:中等粗细,强压感变化,模拟毛笔效果
BrushType.pen: _BrushConfig(
size: 8,
thinning: 0.7,
smoothing: 0.5,
simulatePressure: true,
),
/// 铅笔:细线,轻微压感,高平滑度产生自然线条
BrushType.pencil: _BrushConfig(
size: 4,
thinning: 0.3,
smoothing: 0.7,
simulatePressure: true,
),
/// 马克笔:粗线,几乎无压感变化,不模拟压力
BrushType.marker: _BrushConfig(
size: 16,
thinning: 0.1,
smoothing: 0.5,
simulatePressure: false,
),
/// 橡皮擦:最大范围,无压感变化
BrushType.eraser: _BrushConfig(
size: 32,
thinning: 0,
smoothing: 0.5,
simulatePressure: false,
),
};
// ============================================================
// 点转换工具函数
// ============================================================
/// 将 [StrokePoint] 列表转换为 perfect_freehand 轮廓点列表。
///
/// [brushType] 决定渲染参数,[width] 作为乘数影响最终笔画大小。
/// 当 [points] 少于 2 个时返回空列表(无法构成笔画)。
List<Offset> pointsToOutline(
List<StrokePoint> points,
BrushType brushType,
double width,
) {
if (points.length < 2) return const [];
final config = _brushConfigs[brushType]!;
final widthMultiplier = width / 3.0; // 3.0 是默认宽度,缩放到用户选择
// 转换为 perfect_freehand 的 Point 格式
final pfPoints = points
.map((p) => pf.Point(p.x, p.y, p.pressure))
.toList(growable: false);
// 调用 getStroke 获取轮廓多边形
final outline = pf.getStroke(
pfPoints,
size: config.size * widthMultiplier,
thinning: config.thinning,
smoothing: config.smoothing,
streamline: config.streamline,
simulatePressure: config.simulatePressure,
isComplete: true, // 已完成笔画,启用端点处理
);
// 转换为 Flutter Offset
return outline.map((p) => Offset(p.x, p.y)).toList(growable: false);
}
// ============================================================
// 路径构建
// ============================================================
/// 将轮廓点列表构建为 Flutter [Path]。
///
/// 使用 [Path.addPolygon] 直接构建闭合多边形,
/// 比 lineTo 逐点连接更高效,且自动闭合。
Path buildStrokePath(List<Offset> outlinePoints) {
if (outlinePoints.isEmpty) return Path();
final path = Path();
path.addPolygon(outlinePoints, true); // true = 自动闭合
return path;
}
// ============================================================
// 颜色解析工具
// ============================================================
/// 将 CSS 十六进制颜色字符串 (#RRGGBB) 解析为 Flutter [Color]。
///
/// 如果解析失败,回退到默认的深色文字色 #2D2420。
Color _parseHexColor(String hex) {
final hexStr = hex.replaceFirst('#', '');
if (hexStr.length != 6) return const Color(0xFF2D2420);
final value = int.tryParse(hexStr, radix: 16);
if (value == null) return const Color(0xFF2D2420);
return Color(0xFF000000 + value);
}
// ============================================================
// 笔画绘制器
// ============================================================
/// 自定义 [CustomPainter],负责将所有笔画渲染到 Canvas。
///
/// 接收 [completedStrokes](已完成笔画列表)和可选的 [currentStroke]
/// (正在绘制中的笔画),分别渲染。
///
/// 渲染策略:
/// - 钢笔/铅笔:正常不透明绘制
/// - 马克笔:半透明 (0.4 opacity) 模拟荧光笔效果
/// - 橡皮擦:使用 [BlendMode.dstOut] 擦除底层内容
class StrokePainter extends CustomPainter {
final List<Stroke> completedStrokes;
final Stroke? currentStroke;
StrokePainter({
required this.completedStrokes,
this.currentStroke,
});
@override
void paint(Canvas canvas, Size size) {
// 先绘制已完成的笔画
for (final stroke in completedStrokes) {
_drawStroke(canvas, stroke);
}
// 再绘制正在进行的笔画(覆盖在已完成笔画之上)
if (currentStroke != null) {
_drawStroke(canvas, currentStroke!);
}
}
/// 渲染单条笔画。
void _drawStroke(Canvas canvas, Stroke stroke) {
final outlinePoints = pointsToOutline(
stroke.points,
stroke.brushType,
stroke.width,
);
if (outlinePoints.isEmpty) return;
final path = buildStrokePath(outlinePoints);
// 根据画笔类型选择不同的绘制策略
final paint = _createPaint(stroke);
canvas.drawPath(path, paint);
}
/// 根据画笔类型创建对应的 [Paint] 对象。
Paint _createPaint(Stroke stroke) {
final color = _parseHexColor(stroke.color);
switch (stroke.brushType) {
case BrushType.pen:
case BrushType.pencil:
// 钢笔和铅笔:不透明实心绘制
return Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
case BrushType.marker:
// 马克笔:半透明模拟荧光笔
return Paint()
..color = color.withValues(alpha: 0.4)
..style = PaintingStyle.fill
..isAntiAlias = true;
case BrushType.eraser:
// 橡皮擦:使用 dstOut 混合模式擦除底层像素
return Paint()
..color = const Color(0xFFFFFFFF) // 颜色无关紧要blendMode 决定效果
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut
..isAntiAlias = true;
}
}
/// 判断是否需要重绘。
///
/// 始终比较引用:当 strokes 列表或 currentStroke 发生变化时重绘。
/// 这是最安全的策略因为笔画数据是不可变的freezed
/// 引用变化意味着内容一定变化。
@override
bool shouldRepaint(StrokePainter oldDelegate) {
return !identical(completedStrokes, oldDelegate.completedStrokes) ||
!identical(currentStroke, oldDelegate.currentStroke);
}
}

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),
}
}
}

View File

@@ -0,0 +1,263 @@
// 日记 API 处理器 — CRUD + 列表
use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::response::Json;
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::{CreateJournalReq, JournalResp, UpdateJournalReq};
use crate::service::journal_service::JournalService;
use crate::state::DiaryState;
/// 日记列表查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct JournalListParams {
/// 按作者筛选
pub author_id: Option<Uuid>,
/// 按心情筛选 (happy/calm/sad/angry/thinking)
pub mood: Option<String>,
/// 日期范围起始
pub date_from: Option<chrono::NaiveDate>,
/// 日期范围结束
pub date_to: Option<chrono::NaiveDate>,
/// 按班级筛选
pub class_id: Option<Uuid>,
/// 页码(默认 1
pub page: Option<u64>,
/// 每页条数(默认 20最大 100
pub page_size: Option<u64>,
}
#[utoipa::path(
post,
path = "/api/v1/diary/journals",
request_body = CreateJournalReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<JournalResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "日记管理"
)]
/// POST /api/v1/diary/journals
///
/// 创建日记条目。需要 `diary.journal.create` 权限。
pub async fn create_journal<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateJournalReq>,
) -> Result<Json<ApiResponse<JournalResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.create")?;
// 基础验证
if req.title.trim().is_empty() {
return Err(AppError::Validation("标题不能为空".to_string()));
}
let resp = JournalService::create(
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
get,
path = "/api/v1/diary/journals/{id}",
params(("id" = Uuid, Path, description = "日记ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<JournalResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "日记不存在"),
),
security(("bearer_auth" = [])),
tag = "日记管理"
)]
/// GET /api/v1/diary/journals/:id
///
/// 获取日记详情。需要 `diary.journal.read` 权限。
pub async fn get_journal<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<JournalResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp = JournalService::get_by_id(ctx.tenant_id, id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
put,
path = "/api/v1/diary/journals/{id}",
params(("id" = Uuid, Path, description = "日记ID")),
request_body = UpdateJournalReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<JournalResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "日记不存在"),
(status = 409, description = "版本冲突"),
),
security(("bearer_auth" = [])),
tag = "日记管理"
)]
/// PUT /api/v1/diary/journals/:id
///
/// 更新日记。需要 `diary.journal.update` 权限。
/// 请求体中必须包含 `version` 字段用于乐观锁检查。
pub async fn update_journal<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateJournalReq>,
) -> Result<Json<ApiResponse<JournalResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.update")?;
let resp = JournalService::update(
ctx.tenant_id,
ctx.user_id,
id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
/// 删除日记请求体(包含版本号)
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct DeleteJournalReq {
/// 当前版本号(乐观锁)
pub version: i32,
}
#[utoipa::path(
delete,
path = "/api/v1/diary/journals/{id}",
params(("id" = Uuid, Path, description = "日记ID")),
request_body = DeleteJournalReq,
responses(
(status = 200, description = "删除成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "日记不存在"),
(status = 409, description = "版本冲突"),
),
security(("bearer_auth" = [])),
tag = "日记管理"
)]
/// DELETE /api/v1/diary/journals/:id
///
/// 软删除日记。需要 `diary.journal.delete` 权限。
/// 请求体中必须包含 `version` 字段用于乐观锁检查。
pub async fn delete_journal<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteJournalReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.delete")?;
JournalService::delete(
ctx.tenant_id,
ctx.user_id,
id,
req.version,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("日记已删除".to_string()),
}))
}
#[utoipa::path(
get,
path = "/api/v1/diary/journals",
params(JournalListParams),
responses(
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<JournalResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "日记管理"
)]
/// GET /api/v1/diary/journals
///
/// 获取日记列表(分页 + 筛选)。需要 `diary.journal.read` 权限。
/// 支持按作者、心情、日期范围、班级筛选。
pub async fn list_journals<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<JournalListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<JournalResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let (items, total) = JournalService::list(
ctx.tenant_id,
params.author_id,
params.mood,
params.date_from,
params.date_to,
params.class_id,
page,
page_size,
&state.db,
)
.await?;
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: items,
total,
page,
page_size,
total_pages,
})))
}

View File

@@ -1,2 +1,4 @@
// erp-diary API 处理器占位
// 后续 Phase B2-B7 会实现 ~10 个处理器
// erp-diary API 处理器
pub mod journal_handler;
pub mod sync_handler;

View File

@@ -0,0 +1,53 @@
// 日记同步 API 处理器
use axum::extract::{Extension, FromRef, State};
use axum::response::Json;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::SyncReq;
use crate::dto::SyncResp;
use crate::service::sync_service::SyncService;
use crate::state::DiaryState;
#[utoipa::path(
post,
path = "/api/v1/diary/sync",
request_body = SyncReq,
responses(
(status = 200, description = "同步成功", body = ApiResponse<SyncResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 409, description = "存在版本冲突"),
),
security(("bearer_auth" = [])),
tag = "日记同步"
)]
/// POST /api/v1/diary/sync
///
/// 日记同步端点。客户端提交本地变更,服务端返回服务端变更和冲突列表。
/// 需要 `diary.journal.read` 权限。
pub async fn sync_journals<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<SyncReq>,
) -> Result<Json<ApiResponse<SyncResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp = SyncService::sync(
ctx.tenant_id,
ctx.user_id,
req.last_sync_time,
req.changes,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}

View File

@@ -10,6 +10,8 @@ pub use state::DiaryState;
use erp_core::module::ErpModule;
use crate::handler::{journal_handler, sync_handler};
/// 暖记日记业务模块
pub struct DiaryModule;
@@ -108,5 +110,22 @@ impl DiaryModule {
S: Clone + Send + Sync + 'static,
{
axum::Router::new()
// 日记 CRUD
.route(
"/diary/journals",
axum::routing::get(journal_handler::list_journals)
.post(journal_handler::create_journal),
)
.route(
"/diary/journals/{id}",
axum::routing::get(journal_handler::get_journal)
.put(journal_handler::update_journal)
.delete(journal_handler::delete_journal),
)
// 日记同步
.route(
"/diary/sync",
axum::routing::post(sync_handler::sync_journals),
)
}
}

View File

@@ -0,0 +1,306 @@
// 日记 CRUD 服务
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, PaginatorTrait,
QueryFilter, QueryOrder, Set,
};
use uuid::Uuid;
use crate::dto::{CreateJournalReq, JournalResp, UpdateJournalReq};
use crate::entity::journal_entry;
use crate::error::{DiaryError, DiaryResult};
use erp_core::error::check_version;
use erp_core::events::{DomainEvent, EventBus};
/// 日记 CRUD 服务 — 创建、读取、更新、软删除日记条目
pub struct JournalService;
impl JournalService {
/// 创建日记
///
/// 构建包含所有标准字段的 ActiveModel插入后发布 diary.created 事件。
pub async fn create(
tenant_id: Uuid,
author_id: Uuid,
req: &CreateJournalReq,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<JournalResp> {
let now = Utc::now();
let id = Uuid::now_v7();
let model = journal_entry::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
author_id: Set(author_id),
class_id: Set(req.class_id),
title: Set(req.title.clone()),
date: Set(req.date),
mood: Set(serde_json::to_string(&req.mood).unwrap_or_else(|_| "happy".to_string())),
weather: Set(
serde_json::to_string(&req.weather).unwrap_or_else(|_| "sunny".to_string()),
),
tags: Set(Some(serde_json::json!(req.tags))),
is_private: Set(req.is_private),
shared_to_class: Set(false),
assigned_topic_id: Set(req.assigned_topic_id),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(author_id),
updated_by: Set(author_id),
deleted_at: Set(None),
version: Set(1),
};
let inserted = model.insert(db).await?;
// 发布领域事件
event_bus
.publish(
DomainEvent::new(
"diary.created",
tenant_id,
serde_json::json!({
"journal_id": id,
"author_id": author_id,
"class_id": req.class_id,
}),
),
db,
)
.await;
Ok(model_to_resp(inserted))
}
/// 获取日记详情
///
/// 按 id + tenant_id + 未删除 查询,找不到返回 NotFound。
pub async fn get_by_id(
tenant_id: Uuid,
id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<JournalResp> {
let model = journal_entry::Entity::find()
.filter(journal_entry::Column::Id.eq(id))
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", id)))?;
Ok(model_to_resp(model))
}
/// 更新日记(带版本检查)
///
/// 乐观锁:请求中的 version 必须匹配当前数据库记录的 version
/// 否则返回 VersionConflict 错误。
pub async fn update(
tenant_id: Uuid,
operator_id: Uuid,
id: Uuid,
req: &UpdateJournalReq,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<JournalResp> {
// 查找现有记录
let model = journal_entry::Entity::find()
.filter(journal_entry::Column::Id.eq(id))
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", id)))?;
// 版本检查
let new_version = check_version(req.version, model.version)
.map_err(|_| DiaryError::VersionConflict {
local: req.version,
server: model.version,
})?;
let now = Utc::now();
// 构建更新模型
let mut active: journal_entry::ActiveModel = model.into();
if let Some(ref title) = req.title {
active.title = Set(title.clone());
}
if let Some(ref mood) = req.mood {
active.mood = Set(serde_json::to_string(mood).unwrap_or_else(|_| "happy".to_string()));
}
if let Some(ref weather) = req.weather {
active.weather = Set(
serde_json::to_string(weather).unwrap_or_else(|_| "sunny".to_string()),
);
}
if let Some(ref tags) = req.tags {
active.tags = Set(Some(serde_json::json!(tags)));
}
if let Some(is_private) = req.is_private {
active.is_private = Set(is_private);
}
if let Some(shared_to_class) = req.shared_to_class {
active.shared_to_class = Set(shared_to_class);
}
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(new_version);
let updated = active.update(db).await?;
// 发布领域事件
event_bus
.publish(
DomainEvent::new(
"diary.updated",
tenant_id,
serde_json::json!({
"journal_id": id,
"author_id": operator_id,
"version": new_version,
}),
),
db,
)
.await;
Ok(model_to_resp(updated))
}
/// 软删除日记
///
/// 设置 deleted_at = now, version + 1发布 diary.deleted 事件。
pub async fn delete(
tenant_id: Uuid,
operator_id: Uuid,
id: Uuid,
version: i32,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<()> {
let model = journal_entry::Entity::find()
.filter(journal_entry::Column::Id.eq(id))
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", id)))?;
// 版本检查
let new_version = check_version(version, model.version)
.map_err(|_| DiaryError::VersionConflict {
local: version,
server: model.version,
})?;
let now = Utc::now();
let mut active: journal_entry::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(new_version);
active.update(db).await?;
// 发布领域事件
event_bus
.publish(
DomainEvent::new(
"diary.deleted",
tenant_id,
serde_json::json!({
"journal_id": id,
"author_id": operator_id,
}),
),
db,
)
.await;
Ok(())
}
/// 日记列表(分页 + 筛选)
///
/// 支持按作者、心情、日期范围、班级筛选。
/// 返回 (items, total)。
pub async fn list(
tenant_id: Uuid,
author_id: Option<Uuid>,
mood: Option<String>,
date_from: Option<chrono::NaiveDate>,
date_to: Option<chrono::NaiveDate>,
class_id: Option<Uuid>,
page: u64,
page_size: u64,
db: &DatabaseConnection,
) -> DiaryResult<(Vec<JournalResp>, u64)> {
let mut condition = Condition::all()
.add(journal_entry::Column::TenantId.eq(tenant_id))
.add(journal_entry::Column::DeletedAt.is_null());
if let Some(aid) = author_id {
condition = condition.add(journal_entry::Column::AuthorId.eq(aid));
}
if let Some(ref m) = mood {
condition = condition.add(journal_entry::Column::Mood.eq(m));
}
if let Some(from) = date_from {
condition = condition.add(journal_entry::Column::Date.gte(from));
}
if let Some(to) = date_to {
condition = condition.add(journal_entry::Column::Date.lte(to));
}
if let Some(cid) = class_id {
condition = condition.add(journal_entry::Column::ClassId.eq(cid));
}
let page_size = page_size.min(100).max(1);
let page = page.max(1);
let paginator = journal_entry::Entity::find()
.filter(condition)
.order_by_desc(journal_entry::Column::Date)
.order_by_desc(journal_entry::Column::CreatedAt)
.paginate(db, page_size);
let total = paginator.num_items().await?;
let models = paginator
.fetch_page(page.saturating_sub(1))
.await?;
let items = models.into_iter().map(model_to_resp).collect();
Ok((items, total))
}
}
/// Entity Model -> JournalResp DTO 转换
fn model_to_resp(model: journal_entry::Model) -> JournalResp {
use crate::dto::{Mood, Weather};
let mood: Mood = serde_json::from_str(&model.mood).unwrap_or(Mood::Happy);
let weather: Weather = serde_json::from_str(&model.weather).unwrap_or(Weather::Sunny);
let tags: Vec<String> = model
.tags
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
JournalResp {
id: model.id,
author_id: model.author_id,
class_id: model.class_id,
title: model.title,
date: model.date,
mood,
weather,
tags,
is_private: model.is_private,
shared_to_class: model.shared_to_class,
version: model.version,
created_at: model.created_at,
updated_at: model.updated_at,
}
}

View File

@@ -1,2 +1,4 @@
// erp-diary 业务服务占位
// 后续 Phase B2-B6 会实现 ~12 个服务
// erp-diary 业务服务
pub mod journal_service;
pub mod sync_service;

View File

@@ -0,0 +1,236 @@
// 日记同步服务 — 版本号冲突检测 + 增量同步
use chrono::{DateTime, Utc};
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{ConflictInfo, SyncChange, SyncResp};
use crate::entity::journal_entry;
use crate::error::{DiaryError, DiaryResult};
/// 同步服务 — 处理客户端变更上传和服务端变更下发
pub struct SyncService;
impl SyncService {
/// 同步:客户端提交变更,服务端返回服务端变更 + 冲突
///
/// 流程:
/// 1. 逐条处理客户端变更create/update/delete
/// 2. 获取 last_sync_time 之后服务端更新的记录
/// 3. 检测版本冲突
/// 4. 返回 (server_changes, conflicts, sync_time)
pub async fn sync(
tenant_id: Uuid,
user_id: Uuid,
last_sync_time: Option<DateTime<Utc>>,
client_changes: Vec<SyncChange>,
db: &DatabaseConnection,
) -> DiaryResult<SyncResp> {
let mut conflicts = Vec::new();
// 1. 处理客户端变更
for change in client_changes {
if let Err(e) = Self::apply_client_change(tenant_id, user_id, change, db).await {
// 版本冲突收集到冲突列表,其他错误直接返回
match e {
DiaryError::VersionConflict {
local,
server,
} => {
conflicts.push(ConflictInfo {
journal_id: Uuid::nil(), // ID 在 apply_client_change 内部处理
local_version: local,
server_version: server,
});
}
_ => return Err(e),
}
}
}
// 2. 获取服务端变更last_sync_time 之后更新的)
let mut condition = Condition::all()
.add(journal_entry::Column::TenantId.eq(tenant_id))
.add(journal_entry::Column::AuthorId.eq(user_id));
if let Some(since) = last_sync_time {
condition = condition.add(journal_entry::Column::UpdatedAt.gt(since));
}
let server_records = journal_entry::Entity::find()
.filter(condition)
.all(db)
.await?;
// 3. 转换为 JSON 格式的服务端变更
let server_changes: Vec<serde_json::Value> = server_records
.iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"title": r.title,
"date": r.date,
"mood": r.mood,
"weather": r.weather,
"tags": r.tags,
"is_private": r.is_private,
"shared_to_class": r.shared_to_class,
"version": r.version,
"updated_at": r.updated_at,
"deleted_at": r.deleted_at,
})
})
.collect();
// 4. 返回同步结果
Ok(SyncResp {
server_changes,
conflicts,
sync_time: Utc::now(),
})
}
/// 处理单条客户端变更
async fn apply_client_change(
tenant_id: Uuid,
user_id: Uuid,
change: SyncChange,
db: &DatabaseConnection,
) -> DiaryResult<()> {
use sea_orm::ActiveModelTrait;
match change {
SyncChange::CreateJournal { data } => {
// 客户端创建 — 直接插入
let id = data
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.unwrap_or_else(Uuid::now_v7);
let title = data
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let now = Utc::now();
let model = journal_entry::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
author_id: Set(user_id),
class_id: Set(None),
title: Set(title),
date: Set(
data.get("date")
.and_then(|v| v.as_str())
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| now.date_naive()),
),
mood: Set(
data.get("mood")
.and_then(|v| v.as_str())
.unwrap_or("happy")
.to_string(),
),
weather: Set(
data.get("weather")
.and_then(|v| v.as_str())
.unwrap_or("sunny")
.to_string(),
),
tags: Set(data.get("tags").cloned()),
is_private: Set(
data.get("is_private")
.and_then(|v| v.as_bool())
.unwrap_or(true),
),
shared_to_class: Set(false),
assigned_topic_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(user_id),
updated_by: Set(user_id),
deleted_at: Set(None),
version: Set(1),
};
model.insert(db).await?;
}
SyncChange::UpdateJournal {
id,
version,
data,
} => {
// 客户端更新 — 带版本检查
let existing = journal_entry::Entity::find()
.filter(journal_entry::Column::Id.eq(id))
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", id)))?;
if existing.version != version {
return Err(DiaryError::VersionConflict {
local: version,
server: existing.version,
});
}
let now = Utc::now();
let mut active: journal_entry::ActiveModel = existing.into();
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
active.title = Set(title.to_string());
}
if let Some(mood) = data.get("mood").and_then(|v| v.as_str()) {
active.mood = Set(mood.to_string());
}
if let Some(weather) = data.get("weather").and_then(|v| v.as_str()) {
active.weather = Set(weather.to_string());
}
if let Some(tags) = data.get("tags").cloned() {
active.tags = Set(Some(tags));
}
if let Some(is_private) = data.get("is_private").and_then(|v| v.as_bool()) {
active.is_private = Set(is_private);
}
if let Some(shared) = data.get("shared_to_class").and_then(|v| v.as_bool()) {
active.shared_to_class = Set(shared);
}
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(version + 1);
active.update(db).await?;
}
SyncChange::DeleteJournal { id, version } => {
// 客户端删除 — 软删除带版本检查
let existing = journal_entry::Entity::find()
.filter(journal_entry::Column::Id.eq(id))
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", id)))?;
if existing.version != version {
return Err(DiaryError::VersionConflict {
local: version,
server: existing.version,
});
}
let now = Utc::now();
let mut active: journal_entry::ActiveModel = existing.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(version + 1);
active.update(db).await?;
}
}
Ok(())
}
}