From d0653614e0062c043d9a2673693315d724f49245 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 1 Jun 2026 00:36:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(diary):=20=E6=89=8B=E5=86=99=E5=BC=95?= =?UTF-8?q?=E6=93=8E=20+=20=E6=97=A5=E8=AE=B0=20CRUD=20+=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20API=20(Phase=20F3=20+=20B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 for AppError + 8个单元测试 - 路由注册: /diary/journals + /diary/sync 验证: - cargo check: 0 error - cargo test: 433 测试全通过 - flutter analyze: 1 warning (unused private param) --- app/lib/features/editor/bloc/editor_bloc.dart | 136 ++++++++ .../editor/widgets/handwriting_canvas.dart | 242 ++++++++++++++ .../features/editor/widgets/stroke_model.dart | 111 +++++++ .../editor/widgets/stroke_renderer.dart | 235 ++++++++++++++ crates/erp-diary/src/error.rs | 118 +++++++ .../erp-diary/src/handler/journal_handler.rs | 263 +++++++++++++++ crates/erp-diary/src/handler/mod.rs | 6 +- crates/erp-diary/src/handler/sync_handler.rs | 53 +++ crates/erp-diary/src/lib.rs | 19 ++ .../erp-diary/src/service/journal_service.rs | 306 ++++++++++++++++++ crates/erp-diary/src/service/mod.rs | 6 +- crates/erp-diary/src/service/sync_service.rs | 236 ++++++++++++++ 12 files changed, 1727 insertions(+), 4 deletions(-) create mode 100644 app/lib/features/editor/bloc/editor_bloc.dart create mode 100644 app/lib/features/editor/widgets/handwriting_canvas.dart create mode 100644 app/lib/features/editor/widgets/stroke_model.dart create mode 100644 app/lib/features/editor/widgets/stroke_renderer.dart create mode 100644 crates/erp-diary/src/handler/journal_handler.rs create mode 100644 crates/erp-diary/src/handler/sync_handler.rs create mode 100644 crates/erp-diary/src/service/journal_service.rs create mode 100644 crates/erp-diary/src/service/sync_service.rs diff --git a/app/lib/features/editor/bloc/editor_bloc.dart b/app/lib/features/editor/bloc/editor_bloc.dart new file mode 100644 index 0000000..eae1653 --- /dev/null +++ b/app/lib/features/editor/bloc/editor_bloc.dart @@ -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 strokes; + StrokesLoaded(this.strokes); +} + +// ===== State ===== + +class EditorState { + final List strokes; + final List 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? strokes, + List? 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 { + EditorBloc() : super(const EditorState()) { + on(_onBrushChanged); + on(_onStrokeCompleted); + on(_onUndo); + on(_onRedo); + on(_onClearCanvas); + on(_onStrokesLoaded); + } + + void _onBrushChanged(BrushChanged event, Emitter emit) { + emit(state.copyWith( + brushType: event.type, + brushColor: event.color, + brushWidth: event.width, + )); + } + + void _onStrokeCompleted(StrokeCompleted event, Emitter emit) { + final updatedStrokes = List.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 emit) { + if (state.strokes.isEmpty) return; + + final updatedStrokes = List.from(state.strokes); + final lastStroke = updatedStrokes.removeLast(); + final updatedRedoStack = List.from(state.redoStack)..add(lastStroke); + + emit(state.copyWith( + strokes: updatedStrokes, + redoStack: updatedRedoStack, + )); + } + + void _onRedo(Redo event, Emitter emit) { + if (state.redoStack.isEmpty) return; + + final updatedRedoStack = List.from(state.redoStack); + final stroke = updatedRedoStack.removeLast(); + final updatedStrokes = List.from(state.strokes)..add(stroke); + + emit(state.copyWith( + strokes: updatedStrokes, + redoStack: updatedRedoStack, + )); + } + + void _onClearCanvas(ClearCanvas event, Emitter emit) { + emit(state.copyWith(strokes: [], redoStack: [])); + } + + void _onStrokesLoaded(StrokesLoaded event, Emitter emit) { + emit(state.copyWith(strokes: event.strokes, redoStack: [])); + } +} diff --git a/app/lib/features/editor/widgets/handwriting_canvas.dart b/app/lib/features/editor/widgets/handwriting_canvas.dart new file mode 100644 index 0000000..e927b8f --- /dev/null +++ b/app/lib/features/editor/widgets/handwriting_canvas.dart @@ -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().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? onStrokeCompleted; + + /// 已有的笔画列表(用于重绘历史内容)。 + final List strokes; + + @override + State createState() => _HandwritingCanvasState(); +} + +class _HandwritingCanvasState extends State { + // ============================================================ + // 状态 + // ============================================================ + + /// 正在绘制中的笔画采样点。 + List _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, + ), + ), + ), + ); + } +} diff --git a/app/lib/features/editor/widgets/stroke_model.dart b/app/lib/features/editor/widgets/stroke_model.dart new file mode 100644 index 0000000..e6ee406 --- /dev/null +++ b/app/lib/features/editor/widgets/stroke_model.dart @@ -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 toJson() => { + 'x': x, + 'y': y, + 'pressure': pressure, + 'timestamp': timestamp, + }; + + factory StrokePoint.fromJson(Map 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 points; + final BrushType brushType; + final String color; + final double width; + + Stroke copyWith({ + String? id, + List? 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 toJson() => { + 'id': id, + 'points': points.map((p) => p.toJson()).toList(), + 'brushType': brushType.value, + 'color': color, + 'width': width, + }; + + factory Stroke.fromJson(Map json) => Stroke( + id: json['id'] as String, + points: UnmodifiableListView( + (json['points'] as List) + .map((p) => StrokePoint.fromJson(p as Map)) + .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, + ); +} diff --git a/app/lib/features/editor/widgets/stroke_renderer.dart b/app/lib/features/editor/widgets/stroke_renderer.dart new file mode 100644 index 0000000..6be95f2 --- /dev/null +++ b/app/lib/features/editor/widgets/stroke_renderer.dart @@ -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 _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 pointsToOutline( + List 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 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 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); + } +} diff --git a/crates/erp-diary/src/error.rs b/crates/erp-diary/src/error.rs index c8b6314..f8051d7 100644 --- a/crates/erp-diary/src/error.rs +++ b/crates/erp-diary/src/error.rs @@ -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 层统一返回 AppError,Service 层统一返回 DiaryError。 +/// 这个 impl 让 Handler 中的 `?` 操作符自动完成转换。 +impl From 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 = Result; + #[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 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), + } + } +} diff --git a/crates/erp-diary/src/handler/journal_handler.rs b/crates/erp-diary/src/handler/journal_handler.rs new file mode 100644 index 0000000..ebc6f77 --- /dev/null +++ b/crates/erp-diary/src/handler/journal_handler.rs @@ -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, + /// 按心情筛选 (happy/calm/sad/angry/thinking) + pub mood: Option, + /// 日期范围起始 + pub date_from: Option, + /// 日期范围结束 + pub date_to: Option, + /// 按班级筛选 + pub class_id: Option, + /// 页码(默认 1) + pub page: Option, + /// 每页条数(默认 20,最大 100) + pub page_size: Option, +} + +#[utoipa::path( + post, + path = "/api/v1/diary/journals", + request_body = CreateJournalReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + 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), + (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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + 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), + (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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + 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>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "日记管理" +)] +/// GET /api/v1/diary/journals +/// +/// 获取日记列表(分页 + 筛选)。需要 `diary.journal.read` 权限。 +/// 支持按作者、心情、日期范围、班级筛选。 +pub async fn list_journals( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + DiaryState: FromRef, + 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, + }))) +} diff --git a/crates/erp-diary/src/handler/mod.rs b/crates/erp-diary/src/handler/mod.rs index 50446a3..5d06a56 100644 --- a/crates/erp-diary/src/handler/mod.rs +++ b/crates/erp-diary/src/handler/mod.rs @@ -1,2 +1,4 @@ -// erp-diary API 处理器占位 -// 后续 Phase B2-B7 会实现 ~10 个处理器 +// erp-diary API 处理器 + +pub mod journal_handler; +pub mod sync_handler; diff --git a/crates/erp-diary/src/handler/sync_handler.rs b/crates/erp-diary/src/handler/sync_handler.rs new file mode 100644 index 0000000..1d57509 --- /dev/null +++ b/crates/erp-diary/src/handler/sync_handler.rs @@ -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), + (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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + 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))) +} diff --git a/crates/erp-diary/src/lib.rs b/crates/erp-diary/src/lib.rs index e2cf8ec..f09c504 100644 --- a/crates/erp-diary/src/lib.rs +++ b/crates/erp-diary/src/lib.rs @@ -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), + ) } } diff --git a/crates/erp-diary/src/service/journal_service.rs b/crates/erp-diary/src/service/journal_service.rs new file mode 100644 index 0000000..c1b2c79 --- /dev/null +++ b/crates/erp-diary/src/service/journal_service.rs @@ -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 { + 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 { + 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 { + // 查找现有记录 + 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, + mood: Option, + date_from: Option, + date_to: Option, + class_id: Option, + page: u64, + page_size: u64, + db: &DatabaseConnection, + ) -> DiaryResult<(Vec, 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 = 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, + } +} diff --git a/crates/erp-diary/src/service/mod.rs b/crates/erp-diary/src/service/mod.rs index f387554..d3cfe3c 100644 --- a/crates/erp-diary/src/service/mod.rs +++ b/crates/erp-diary/src/service/mod.rs @@ -1,2 +1,4 @@ -// erp-diary 业务服务占位 -// 后续 Phase B2-B6 会实现 ~12 个服务 +// erp-diary 业务服务 + +pub mod journal_service; +pub mod sync_service; diff --git a/crates/erp-diary/src/service/sync_service.rs b/crates/erp-diary/src/service/sync_service.rs new file mode 100644 index 0000000..8719395 --- /dev/null +++ b/crates/erp-diary/src/service/sync_service.rs @@ -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>, + client_changes: Vec, + db: &DatabaseConnection, + ) -> DiaryResult { + 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 = 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(()) + } +}