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:
136
app/lib/features/editor/bloc/editor_bloc.dart
Normal file
136
app/lib/features/editor/bloc/editor_bloc.dart
Normal 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: []));
|
||||
}
|
||||
}
|
||||
242
app/lib/features/editor/widgets/handwriting_canvas.dart
Normal file
242
app/lib/features/editor/widgets/handwriting_canvas.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
app/lib/features/editor/widgets/stroke_model.dart
Normal file
111
app/lib/features/editor/widgets/stroke_model.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
235
app/lib/features/editor/widgets/stroke_renderer.dart
Normal file
235
app/lib/features/editor/widgets/stroke_renderer.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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<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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
263
crates/erp-diary/src/handler/journal_handler.rs
Normal file
263
crates/erp-diary/src/handler/journal_handler.rs
Normal 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,
|
||||
})))
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// erp-diary API 处理器占位
|
||||
// 后续 Phase B2-B7 会实现 ~10 个处理器
|
||||
// erp-diary API 处理器
|
||||
|
||||
pub mod journal_handler;
|
||||
pub mod sync_handler;
|
||||
|
||||
53
crates/erp-diary/src/handler/sync_handler.rs
Normal file
53
crates/erp-diary/src/handler/sync_handler.rs
Normal 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)))
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
306
crates/erp-diary/src/service/journal_service.rs
Normal file
306
crates/erp-diary/src/service/journal_service.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// erp-diary 业务服务占位
|
||||
// 后续 Phase B2-B6 会实现 ~12 个服务
|
||||
// erp-diary 业务服务
|
||||
|
||||
pub mod journal_service;
|
||||
pub mod sync_service;
|
||||
|
||||
236
crates/erp-diary/src/service/sync_service.rs
Normal file
236
crates/erp-diary/src/service/sync_service.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user