diff --git a/app/lib/data/local/isar_database.dart b/app/lib/data/local/isar_database.dart new file mode 100644 index 0000000..b857add --- /dev/null +++ b/app/lib/data/local/isar_database.dart @@ -0,0 +1,82 @@ +// Isar 数据库初始化 — 本地持久化存储 +// +// Isar 3.x 要求 open() 时传入 List 位置参数。 +// 由于我们使用手写不可变类而非 isar_generator 代码生成, +// 需要在调用 [init] 时传入 schema 列表。 +// 当前阶段使用 [ensureInitialized] 占位,待后续添加 Isar Collection 后正式注册。 + +import 'package:isar/isar.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Isar 数据库单例管理 +/// +/// 使用方式(Phase 1 — 无 schema 时): +/// ```dart +/// // 直接使用,不初始化 Isar(内存仓库模式) +/// ``` +/// +/// 使用方式(Phase 2 — 有 schema 后): +/// ```dart +/// final isar = await IsarDatabase.init(schemas: [JournalEntrySchema]); +/// ``` +class IsarDatabase { + IsarDatabase._(); + + static Isar? _instance; + static bool _initialized = false; + + /// 是否已初始化 + static bool get isInitialized => _initialized; + + /// 初始化数据库(需在 app 启动时调用,传入所有 CollectionSchema) + /// + /// - [schemas]: Isar Collection Schema 列表(由 isar_generator 生成) + /// - 在应用文档目录下创建 isar 数据库文件 + /// - 开发模式开启 inspector(flutter pub global run isar_inspector) + static Future init({ + required List> schemas, + }) async { + if (_instance != null && _instance!.isOpen) return _instance!; + + final dir = await getApplicationDocumentsDirectory(); + + _instance = await Isar.open( + schemas, + directory: dir.path, + inspector: true, // 开发模式,发布时关闭 + ); + _initialized = true; + return _instance!; + } + + /// 获取 Isar 实例(必须先调用 [init]) + /// + /// 如果未初始化会抛出 [StateError]。 + static Isar get instance { + if (_instance == null || !_instance!.isOpen) { + throw StateError( + 'IsarDatabase 未初始化,请先调用 IsarDatabase.init(schemas: [...])', + ); + } + return _instance!; + } + + /// 关闭数据库连接 + /// + /// 通常只在应用退出时调用。 + static Future close() async { + if (_instance != null && _instance!.isOpen) { + await _instance!.close(); + _instance = null; + _initialized = false; + } + } + + /// 清空所有数据(仅用于测试) + static Future clearAll() async { + if (_instance == null || !_instance!.isOpen) return; + await _instance!.writeTxn(() async { + // TODO: 清空所有 collection + }); + } +} diff --git a/app/lib/data/models/journal_element.dart b/app/lib/data/models/journal_element.dart new file mode 100644 index 0000000..fc2cfaf --- /dev/null +++ b/app/lib/data/models/journal_element.dart @@ -0,0 +1,156 @@ +// 日记元素数据模型 — 手写不可变类(避免 build_runner 依赖) + +import 'dart:collection'; + +import 'package:uuid/uuid.dart'; + +/// 元素类型 — 日记页面上的各种内容载体 +enum ElementType { + text('text'), + image('image'), + sticker('sticker'), + handwritingRef('handwriting_ref'), + tape('tape'); + + const ElementType(this.value); + final String value; +} + +/// 日记元素 — 日记页面中的单个内容单元 +/// +/// 每个元素有独立的位置、尺寸、旋转和层级。 +/// [content] 字段根据 [elementType] 存储不同的结构化数据: +/// - text: {'text': '...', 'fontSize': 16.0, 'fontColor': '#2D2420'} +/// - image: {'filePath': '...', 'thumbnailPath': '...'} +/// - sticker: {'stickerPackId': '...', 'stickerId': '...'} +/// - handwriting_ref: {'strokesFileId': '...', 'strokeCount': 42} +/// - tape: {'tapeStyle': 'washi_dots', 'tapeColor': '#F2CC8F'} +class JournalElement { + final String id; + final String journalId; + final ElementType elementType; + final double positionX; + final double positionY; + final double width; + final double height; + final double rotation; + final int zIndex; + final Map content; + final int version; + final DateTime createdAt; + final DateTime updatedAt; + + const JournalElement({ + required this.id, + required this.journalId, + required this.elementType, + this.positionX = 0, + this.positionY = 0, + this.width = 100, + this.height = 100, + this.rotation = 0, + this.zIndex = 0, + this.content = const {}, + this.version = 1, + required this.createdAt, + required this.updatedAt, + }); + + JournalElement copyWith({ + String? id, + String? journalId, + ElementType? elementType, + double? positionX, + double? positionY, + double? width, + double? height, + double? rotation, + int? zIndex, + Map? content, + int? version, + DateTime? createdAt, + DateTime? updatedAt, + }) => + JournalElement( + id: id ?? this.id, + journalId: journalId ?? this.journalId, + elementType: elementType ?? this.elementType, + positionX: positionX ?? this.positionX, + positionY: positionY ?? this.positionY, + width: width ?? this.width, + height: height ?? this.height, + rotation: rotation ?? this.rotation, + zIndex: zIndex ?? this.zIndex, + content: content ?? this.content, + version: version ?? this.version, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + + Map toJson() => { + 'id': id, + 'journal_id': journalId, + 'element_type': elementType.value, + 'position_x': positionX, + 'position_y': positionY, + 'width': width, + 'height': height, + 'rotation': rotation, + 'z_index': zIndex, + 'content': content, + 'version': version, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory JournalElement.fromJson(Map json) => JournalElement( + id: json['id'] as String, + journalId: json['journal_id'] as String, + elementType: ElementType.values.firstWhere( + (e) => e.value == json['element_type'], + orElse: () => ElementType.text, + ), + positionX: (json['position_x'] as num?)?.toDouble() ?? 0, + positionY: (json['position_y'] as num?)?.toDouble() ?? 0, + width: (json['width'] as num?)?.toDouble() ?? 100, + height: (json['height'] as num?)?.toDouble() ?? 100, + rotation: (json['rotation'] as num?)?.toDouble() ?? 0, + zIndex: (json['z_index'] as int?) ?? 0, + content: UnmodifiableMapView( + Map.from(json['content'] as Map? ?? {}), + ), + version: (json['version'] as int?) ?? 1, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + /// 创建新元素的工厂方法 — 自动生成 id 和时间戳 + factory JournalElement.create({ + required String journalId, + required ElementType elementType, + double positionX = 0, + double positionY = 0, + double width = 100, + double height = 100, + double rotation = 0, + int zIndex = 0, + Map content = const {}, + }) { + final now = DateTime.now(); + return JournalElement( + id: const Uuid().v4(), + journalId: journalId, + elementType: elementType, + positionX: positionX, + positionY: positionY, + width: width, + height: height, + rotation: rotation, + zIndex: zIndex, + content: content, + version: 1, + createdAt: now, + updatedAt: now, + ); + } +} diff --git a/app/lib/data/models/journal_entry.dart b/app/lib/data/models/journal_entry.dart new file mode 100644 index 0000000..27899d8 --- /dev/null +++ b/app/lib/data/models/journal_entry.dart @@ -0,0 +1,172 @@ +// 日记条目数据模型 — 手写不可变类(避免 build_runner 依赖) + +import 'package:uuid/uuid.dart'; + +/// 心情枚举 — 对应日记心情选择器 +enum Mood { + happy('happy'), + calm('calm'), + sad('sad'), + angry('angry'), + thinking('thinking'); + + const Mood(this.value); + final String value; +} + +/// 天气枚举 — 对应日记天气标签 +enum Weather { + sunny('sunny'), + cloudy('cloudy'), + rainy('rainy'), + snowy('snowy'), + windy('windy'); + + const Weather(this.value); + final String value; +} + +/// 日记条目 — 核心数据模型 +/// +/// 每篇日记包含标题、日期、心情、天气、标签等元信息。 +/// 日记的具体内容(文字/图片/手写/贴纸)通过 [JournalElement] 管理。 +class JournalEntry { + final String id; + final String authorId; + final String? classId; + final String title; + final DateTime date; + final Mood mood; + final Weather weather; + final List tags; + final bool isPrivate; + final bool sharedToClass; + final String? assignedTopicId; + final int version; + final DateTime createdAt; + final DateTime updatedAt; + + const JournalEntry({ + required this.id, + required this.authorId, + this.classId, + required this.title, + required this.date, + this.mood = Mood.calm, + this.weather = Weather.sunny, + this.tags = const [], + this.isPrivate = true, + this.sharedToClass = false, + this.assignedTopicId, + this.version = 1, + required this.createdAt, + required this.updatedAt, + }); + + JournalEntry copyWith({ + String? id, + String? authorId, + String? classId, + bool clearClassId = false, + String? title, + DateTime? date, + Mood? mood, + Weather? weather, + List? tags, + bool? isPrivate, + bool? sharedToClass, + String? assignedTopicId, + bool clearAssignedTopicId = false, + int? version, + DateTime? createdAt, + DateTime? updatedAt, + }) => + JournalEntry( + id: id ?? this.id, + authorId: authorId ?? this.authorId, + classId: clearClassId ? null : (classId ?? this.classId), + title: title ?? this.title, + date: date ?? this.date, + mood: mood ?? this.mood, + weather: weather ?? this.weather, + tags: tags ?? this.tags, + isPrivate: isPrivate ?? this.isPrivate, + sharedToClass: sharedToClass ?? this.sharedToClass, + assignedTopicId: + clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId), + version: version ?? this.version, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + + Map toJson() => { + 'id': id, + 'author_id': authorId, + 'class_id': classId, + 'title': title, + 'date': date.toIso8601String(), + 'mood': mood.value, + 'weather': weather.value, + 'tags': tags, + 'is_private': isPrivate, + 'shared_to_class': sharedToClass, + 'assigned_topic_id': assignedTopicId, + 'version': version, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory JournalEntry.fromJson(Map json) => JournalEntry( + id: json['id'] as String, + authorId: json['author_id'] as String, + classId: json['class_id'] as String?, + title: json['title'] as String, + date: DateTime.parse(json['date'] as String), + mood: Mood.values.firstWhere( + (m) => m.value == json['mood'], + orElse: () => Mood.calm, + ), + weather: Weather.values.firstWhere( + (w) => w.value == json['weather'], + orElse: () => Weather.sunny, + ), + tags: List.from(json['tags'] as List? ?? []), + isPrivate: (json['is_private'] as bool?) ?? true, + sharedToClass: (json['shared_to_class'] as bool?) ?? false, + assignedTopicId: json['assigned_topic_id'] as String?, + version: (json['version'] as int?) ?? 1, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + /// 创建新日记的工厂方法 — 自动生成 id 和时间戳 + factory JournalEntry.create({ + required String authorId, + required String title, + required DateTime date, + String? classId, + Mood mood = Mood.calm, + Weather weather = Weather.sunny, + List tags = const [], + bool isPrivate = true, + String? assignedTopicId, + }) { + final now = DateTime.now(); + return JournalEntry( + id: const Uuid().v4(), + authorId: authorId, + classId: classId, + title: title, + date: date, + mood: mood, + weather: weather, + tags: tags, + isPrivate: isPrivate, + sharedToClass: false, + assignedTopicId: assignedTopicId, + version: 1, + createdAt: now, + updatedAt: now, + ); + } +} diff --git a/app/lib/data/models/school_class.dart b/app/lib/data/models/school_class.dart new file mode 100644 index 0000000..b51fc48 --- /dev/null +++ b/app/lib/data/models/school_class.dart @@ -0,0 +1,112 @@ +// 班级数据模型 — 手写不可变类(避免 build_runner 依赖) + +import 'package:uuid/uuid.dart'; + +/// 班级 — 老师创建,学生通过班级码加入 +/// +/// 班级码安全规则: +/// - 6 位字母数字混合(62^6 约 568 亿种组合) +/// - 有效期控制(学期结束自动失效) +/// - 连续 5 次错误锁定 30 分钟 +class SchoolClass { + final String id; + final String name; + final String schoolName; + final String teacherId; + final String classCode; + final int memberCount; + final bool isActive; + final DateTime? expiresAt; + final DateTime createdAt; + final DateTime updatedAt; + + const SchoolClass({ + required this.id, + required this.name, + required this.schoolName, + required this.teacherId, + required this.classCode, + this.memberCount = 0, + this.isActive = true, + this.expiresAt, + required this.createdAt, + required this.updatedAt, + }); + + SchoolClass copyWith({ + String? id, + String? name, + String? schoolName, + String? teacherId, + String? classCode, + int? memberCount, + bool? isActive, + DateTime? expiresAt, + bool clearExpiresAt = false, + DateTime? createdAt, + DateTime? updatedAt, + }) => + SchoolClass( + id: id ?? this.id, + name: name ?? this.name, + schoolName: schoolName ?? this.schoolName, + teacherId: teacherId ?? this.teacherId, + classCode: classCode ?? this.classCode, + memberCount: memberCount ?? this.memberCount, + isActive: isActive ?? this.isActive, + expiresAt: clearExpiresAt ? null : (expiresAt ?? this.expiresAt), + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'school_name': schoolName, + 'teacher_id': teacherId, + 'class_code': classCode, + 'member_count': memberCount, + 'is_active': isActive, + 'expires_at': expiresAt?.toIso8601String(), + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory SchoolClass.fromJson(Map json) => SchoolClass( + id: json['id'] as String, + name: json['name'] as String, + schoolName: json['school_name'] as String, + teacherId: json['teacher_id'] as String, + classCode: json['class_code'] as String, + memberCount: (json['member_count'] as int?) ?? 0, + isActive: (json['is_active'] as bool?) ?? true, + expiresAt: json['expires_at'] != null + ? DateTime.parse(json['expires_at'] as String) + : null, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + /// 创建新班级的工厂方法 — 自动生成 id 和时间戳 + factory SchoolClass.create({ + required String name, + required String schoolName, + required String teacherId, + required String classCode, + DateTime? expiresAt, + }) { + final now = DateTime.now(); + return SchoolClass( + id: const Uuid().v4(), + name: name, + schoolName: schoolName, + teacherId: teacherId, + classCode: classCode, + memberCount: 0, + isActive: true, + expiresAt: expiresAt, + createdAt: now, + updatedAt: now, + ); + } +} diff --git a/app/lib/data/models/user_settings.dart b/app/lib/data/models/user_settings.dart new file mode 100644 index 0000000..999999e --- /dev/null +++ b/app/lib/data/models/user_settings.dart @@ -0,0 +1,105 @@ +// 用户设置数据模型 — 手写不可变类(避免 build_runner 依赖) + +/// 主题模式 — 与系统设置同步 +enum ThemeMode { light, dark, system } + +/// 默认画笔类型(复用 StrokeModel 中的枚举值) +enum DefaultBrushType { + pen('pen'), + pencil('pencil'), + marker('marker'); + + const DefaultBrushType(this.value); + final String value; +} + +/// 用户设置 — 持久化的个人偏好 +/// +/// 存储用户的主题偏好、默认画笔、字体大小等。 +/// 每个用户只有一条设置记录,通过 [userId] 关联。 +class UserSettings { + final String id; + final String userId; + final ThemeMode theme; + final DefaultBrushType defaultBrushType; + final String defaultBrushColor; + final double fontSize; + final DateTime createdAt; + final DateTime updatedAt; + + const UserSettings({ + required this.id, + required this.userId, + this.theme = ThemeMode.system, + this.defaultBrushType = DefaultBrushType.pen, + this.defaultBrushColor = '#2D2420', + this.fontSize = 16.0, + required this.createdAt, + required this.updatedAt, + }); + + UserSettings copyWith({ + String? id, + String? userId, + ThemeMode? theme, + DefaultBrushType? defaultBrushType, + String? defaultBrushColor, + double? fontSize, + DateTime? createdAt, + DateTime? updatedAt, + }) => + UserSettings( + id: id ?? this.id, + userId: userId ?? this.userId, + theme: theme ?? this.theme, + defaultBrushType: defaultBrushType ?? this.defaultBrushType, + defaultBrushColor: defaultBrushColor ?? this.defaultBrushColor, + fontSize: fontSize ?? this.fontSize, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + + Map toJson() => { + 'id': id, + 'user_id': userId, + 'theme': theme.name, + 'default_brush_type': defaultBrushType.value, + 'default_brush_color': defaultBrushColor, + 'font_size': fontSize, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory UserSettings.fromJson(Map json) => UserSettings( + id: json['id'] as String, + userId: json['user_id'] as String, + theme: ThemeMode.values.firstWhere( + (t) => t.name == json['theme'], + orElse: () => ThemeMode.system, + ), + defaultBrushType: DefaultBrushType.values.firstWhere( + (b) => b.value == json['default_brush_type'], + orElse: () => DefaultBrushType.pen, + ), + defaultBrushColor: + (json['default_brush_color'] as String?) ?? '#2D2420', + fontSize: (json['font_size'] as num?)?.toDouble() ?? 16.0, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + /// 创建默认设置的工厂方法 + factory UserSettings.create({ + required String userId, + ThemeMode theme = ThemeMode.system, + }) { + final now = DateTime.now(); + return UserSettings( + id: userId, + userId: userId, + theme: theme, + createdAt: now, + updatedAt: now, + ); + } +} diff --git a/app/lib/data/remote/api_client.dart b/app/lib/data/remote/api_client.dart new file mode 100644 index 0000000..cda39b5 --- /dev/null +++ b/app/lib/data/remote/api_client.dart @@ -0,0 +1,149 @@ +// API 客户端 — Dio 封装 + JWT 注入 + 离线感知 +// +// 核心职责: +// - 封装 Dio HTTP 客户端,统一配置超时和头信息 +// - JWT token 自动注入(请求拦截器) +// - 离线状态感知(网络不可用时抛出明确异常) +// - 为 SyncEngine 提供远程操作能力 + +import 'package:dio/dio.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +/// 网络离线异常 — 网络不可用时由 ApiClient 抛出 +class OfflineException implements Exception { + final String message; + const OfflineException([this.message = '网络不可用,请检查网络连接']); + + @override + String toString() => 'OfflineException: $message'; +} + +/// API 客户端 — 所有远程请求的统一入口 +class ApiClient { + late final Dio _dio; + String? _token; + + /// 基础 URL,默认指向本地开发服务器 + final String baseUrl; + + ApiClient({this.baseUrl = 'http://localhost:8080/api/v1'}) { + _dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + sendTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + + // 请求拦截器:注入 JWT token + _dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + if (_token != null) { + options.headers['Authorization'] = 'Bearer $_token'; + } + handler.next(options); + }, + )); + + // 响应拦截器:统一错误处理 + _dio.interceptors.add(InterceptorsWrapper( + onError: (error, handler) { + // 401 时自动清除 token(需要重新登录) + if (error.response?.statusCode == 401) { + _token = null; + } + handler.next(error); + }, + )); + } + + /// 设置 JWT token(登录成功后调用) + void setToken(String token) => _token = token; + + /// 清除 JWT token(退出登录时调用) + void clearToken() => _token = null; + + /// 当前是否已登录 + bool get isAuthenticated => _token != null; + + /// 检查网络是否可用 + Future _isOnline() async { + final result = await Connectivity().checkConnectivity(); + // connectivity_plus 返回 List + return result.any((r) => r != ConnectivityResult.none); + } + + /// 确保网络可用,否则抛出 OfflineException + Future _ensureOnline() async { + final online = await _isOnline(); + if (!online) { + throw const OfflineException(); + } + } + + // ===== CRUD 方法 ===== + + /// GET 请求 + Future> get( + String path, { + Map? queryParams, + }) async { + await _ensureOnline(); + return _dio.get(path, queryParameters: queryParams); + } + + /// POST 请求(创建资源) + Future> post( + String path, { + dynamic data, + }) async { + await _ensureOnline(); + return _dio.post(path, data: data); + } + + /// PUT 请求(更新资源) + Future> put( + String path, { + dynamic data, + }) async { + await _ensureOnline(); + return _dio.put(path, data: data); + } + + /// DELETE 请求 + Future> delete( + String path, { + dynamic data, + }) async { + await _ensureOnline(); + return _dio.delete(path, data: data); + } + + /// PATCH 请求(部分更新) + Future> patch( + String path, { + dynamic data, + }) async { + await _ensureOnline(); + return _dio.patch(path, data: data); + } + + /// 文件上传(multipart/form-data) + Future> upload( + String path, { + required String filePath, + required String fileName, + String fieldName = 'file', + Map? extraFields, + }) async { + await _ensureOnline(); + final formData = FormData.fromMap({ + fieldName: await MultipartFile.fromFile(filePath, filename: fileName), + ...?extraFields, + }); + return _dio.post(path, data: formData); + } +} diff --git a/app/lib/data/repositories/journal_repository.dart b/app/lib/data/repositories/journal_repository.dart new file mode 100644 index 0000000..68e07cc --- /dev/null +++ b/app/lib/data/repositories/journal_repository.dart @@ -0,0 +1,184 @@ +// 日记仓库 — 抽象接口 + 内存实现(开发测试用) +// +// 设计思路: +// - 抽象接口 [JournalRepository] 定义数据操作契约 +// - 后续实现 IsarJournalRepository(本地)和 RemoteJournalRepository(远程) +// - SyncEngine 负责协调本地和远程仓库之间的数据同步 +// - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代 + +import '../models/journal_entry.dart'; +import '../models/journal_element.dart'; + +/// 日记仓库抽象接口 — 所有数据操作的统一契约 +/// +/// 查询参数说明: +/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间) +/// - [page]/[pageSize]: 分页参数,从 1 开始 +abstract class JournalRepository { + /// 获取日记列表(支持日期范围过滤和分页) + Future> getJournals({ + DateTime? dateFrom, + DateTime? dateTo, + int? page, + int? pageSize, + }); + + /// 获取单篇日记(返回 null 表示不存在) + Future getJournal(String id); + + /// 创建新日记 + Future createJournal(JournalEntry entry); + + /// 更新日记(使用 version 字段做乐观锁冲突检测) + Future updateJournal(JournalEntry entry); + + /// 删除日记(软删除,设置 deleted_at) + Future deleteJournal(String id); + + /// 获取指定日记的所有元素 + Future> getElements(String journalId); + + /// 添加元素到日记 + Future addElement(JournalElement element); + + /// 更新元素 + Future updateElement(JournalElement element); + + /// 从日记中移除元素 + Future removeElement(String elementId); +} + +/// 内存实现 — 用于开发阶段快速迭代和单元测试 +/// +/// 数据存储在内存中,应用重启后丢失。 +/// 线程安全说明:Flutter 是单线程模型,无需加锁。 +class InMemoryJournalRepository implements JournalRepository { + final Map _journals = {}; + final Map _elements = {}; + + @override + Future> getJournals({ + DateTime? dateFrom, + DateTime? dateTo, + int? page, + int? pageSize, + }) async { + var results = _journals.values.toList(); + + // 日期范围过滤 + if (dateFrom != null) { + results = results.where((j) => j.date.isAfter(dateFrom)).toList(); + } + if (dateTo != null) { + results = results.where((j) => j.date.isBefore(dateTo)).toList(); + } + + // 按日期降序排列(最新在前) + results.sort((a, b) => b.date.compareTo(a.date)); + + // 分页 + if (page != null && pageSize != null) { + final start = (page - 1) * pageSize; + if (start >= results.length) return []; + final end = (start + pageSize).clamp(0, results.length); + results = results.sublist(start, end); + } + + return results; + } + + @override + Future getJournal(String id) async { + return _journals[id]; + } + + @override + Future createJournal(JournalEntry entry) async { + _journals[entry.id] = entry; + return entry; + } + + @override + Future updateJournal(JournalEntry entry) async { + final existing = _journals[entry.id]; + if (existing == null) { + throw StateError('日记不存在: ${entry.id}'); + } + + // 乐观锁冲突检测:版本号必须匹配 + if (existing.version != entry.version) { + throw StateError( + '版本冲突: 本地版本 ${entry.version}, 服务端版本 ${existing.version}', + ); + } + + // 版本号 +1,更新时间戳 + final updated = entry.copyWith( + version: entry.version + 1, + updatedAt: DateTime.now(), + ); + _journals[entry.id] = updated; + return updated; + } + + @override + Future deleteJournal(String id) async { + // 内存实现:直接移除(软删除由 Isar 实现处理) + _journals.remove(id); + // 同时移除关联元素 + _elements.removeWhere((_, e) => e.journalId == id); + } + + @override + Future> getElements(String journalId) async { + return _elements.values + .where((e) => e.journalId == journalId) + .toList() + ..sort((a, b) => a.zIndex.compareTo(b.zIndex)); + } + + @override + Future addElement(JournalElement element) async { + _elements[element.id] = element; + return element; + } + + @override + Future updateElement(JournalElement element) async { + final existing = _elements[element.id]; + if (existing == null) { + throw StateError('元素不存在: ${element.id}'); + } + + // 乐观锁冲突检测 + if (existing.version != element.version) { + throw StateError( + '版本冲突: 本地版本 ${element.version}, 服务端版本 ${existing.version}', + ); + } + + final updated = element.copyWith( + version: element.version + 1, + updatedAt: DateTime.now(), + ); + _elements[element.id] = updated; + return updated; + } + + @override + Future removeElement(String elementId) async { + _elements.remove(elementId); + } + + /// 清空所有数据(测试辅助方法) + void clearAll() { + _journals.clear(); + _elements.clear(); + } + + /// 获取日记总数(测试辅助方法) + int get journalCount => _journals.length; + + /// 获取元素总数(测试辅助方法) + int get elementCount => _elements.length; +} diff --git a/app/lib/data/services/sync_engine.dart b/app/lib/data/services/sync_engine.dart new file mode 100644 index 0000000..7cad69c --- /dev/null +++ b/app/lib/data/services/sync_engine.dart @@ -0,0 +1,232 @@ +// 同步引擎 — WiFi 增量同步 + 操作队列 +// +// 设计思路: +// - 所有本地修改先入队 [PendingOperation] +// - 网络恢复时自动批量同步 +// - 版本号冲突检测,Phase 1 使用"本地优先"策略 +// - 最大重试次数限制,超过后标记为冲突供用户手动解决 +// +// Phase 1 策略:本地优先 +// - 离线时正常使用,操作入队等待 +// - 联网后自动推送待同步操作 +// - 版本冲突时本地版本覆盖远端(简单策略) + +import 'dart:collection'; + +import 'package:connectivity_plus/connectivity_plus.dart'; + +import '../remote/api_client.dart'; + +/// 同步操作类型 +enum SyncOperationType { + create('POST'), + update('PUT'), + delete('DELETE'); + + const SyncOperationType(this.httpMethod); + final String httpMethod; +} + +/// 同步状态 +enum SyncStatus { + idle, // 空闲,无待同步操作 + syncing, // 正在同步 + paused, // 暂停(网络不可用) + error, // 出错,需要重试 +} + +/// 待同步操作 — 记录一次本地修改 +class PendingOperation { + final String id; + final SyncOperationType type; + final String endpoint; + final Map data; + final int version; + final DateTime createdAt; + final int retryCount; + + /// 最大重试次数 + static const int maxRetryCount = 5; + + const PendingOperation({ + required this.id, + required this.type, + required this.endpoint, + required this.data, + required this.version, + required this.createdAt, + this.retryCount = 0, + }); + + PendingOperation copyWith({ + String? id, + SyncOperationType? type, + String? endpoint, + Map? data, + int? version, + DateTime? createdAt, + int? retryCount, + }) => + PendingOperation( + id: id ?? this.id, + type: type ?? this.type, + endpoint: endpoint ?? this.endpoint, + data: data ?? this.data, + version: version ?? this.version, + createdAt: createdAt ?? this.createdAt, + retryCount: retryCount ?? this.retryCount, + ); + + /// 是否已超过最大重试次数 + bool get isExhausted => retryCount >= maxRetryCount; +} + +/// 同步引擎 — 管理 WiFi 增量同步和操作队列 +/// +/// 使用方式: +/// ```dart +/// final engine = SyncEngine(apiClient: apiClient); +/// +/// // 本地修改后入队 +/// engine.enqueue(PendingOperation( +/// id: 'op-1', +/// type: SyncOperationType.create, +/// endpoint: '/diary/entries', +/// data: entry.toJson(), +/// version: 1, +/// createdAt: DateTime.now(), +/// )); +/// +/// // 网络恢复时触发同步 +/// await engine.trySync(); +/// ``` +class SyncEngine { + final ApiClient _apiClient; + final Queue _pendingQueue = Queue(); + + SyncStatus _status = SyncStatus.idle; + String? _lastError; + + SyncEngine({required this._apiClient}); + + /// 当前同步状态 + SyncStatus get status => _status; + + /// 最近一次错误信息 + String? get lastError => _lastError; + + /// 待同步操作数量 + int get pendingCount => _pendingQueue.length; + + /// 是否有操作正在同步 + bool get isSyncing => _status == SyncStatus.syncing; + + /// 添加待同步操作到队列尾部 + void enqueue(PendingOperation operation) { + _pendingQueue.add(operation); + if (_status == SyncStatus.idle) { + _status = SyncStatus.paused; + } + } + + /// 批量添加待同步操作 + void enqueueAll(List operations) { + for (final op in operations) { + _pendingQueue.add(op); + } + if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) { + _status = SyncStatus.paused; + } + } + + /// 检查网络状态并尝试同步全部待处理操作 + /// + /// 同步策略: + /// 1. 检查网络是否可用 + /// 2. 按先进先出顺序处理队列 + /// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次 + /// 4. 超过重试次数的操作标记为冲突,移出队列 + /// 5. 网络中断时暂停同步,保留剩余操作 + Future trySync() async { + if (_status == SyncStatus.syncing) return; // 防止重入 + if (_pendingQueue.isEmpty) { + _status = SyncStatus.idle; + return; + } + + // 检查网络 + final connectivity = Connectivity(); + final result = await connectivity.checkConnectivity(); + final isOnline = result.any((r) => r != ConnectivityResult.none); + if (!isOnline) { + _status = SyncStatus.paused; + _lastError = '网络不可用'; + return; + } + + // WiFi 优先策略:仅在 WiFi 下自动同步(Phase 1 简化) + // TODO: 添加用户设置允许蜂窝数据同步 + + _status = SyncStatus.syncing; + _lastError = null; + + while (_pendingQueue.isNotEmpty) { + final operation = _pendingQueue.removeFirst(); + + try { + await _executeOperation(operation); + } on OfflineException { + // 网络中断,操作放回队列头部 + _pendingQueue.addFirst(operation); + _status = SyncStatus.paused; + _lastError = '同步中断:网络不可用'; + return; + } catch (e) { + // 操作失败,增加重试计数 + final retried = operation.copyWith(retryCount: operation.retryCount + 1); + + if (retried.isExhausted) { + // 超过最大重试次数,标记为冲突(Phase 1 简化:丢弃) + // TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决 + _lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}'; + continue; + } + + // 放回队列头部,下次重试 + _pendingQueue.addFirst(retried); + _status = SyncStatus.error; + _lastError = '同步失败: $e'; + return; + } + } + + // 全部同步完成 + _status = SyncStatus.idle; + _lastError = null; + } + + /// 执行单个同步操作 + Future _executeOperation(PendingOperation operation) async { + switch (operation.type) { + case SyncOperationType.create: + await _apiClient.post(operation.endpoint, data: operation.data); + case SyncOperationType.update: + await _apiClient.put(operation.endpoint, data: operation.data); + case SyncOperationType.delete: + await _apiClient.delete(operation.endpoint); + } + } + + /// 清空队列(数据已全部同步完成或需要强制清空时调用) + void clear() { + _pendingQueue.clear(); + _status = SyncStatus.idle; + _lastError = null; + } + + /// 获取当前队列中所有操作的快照(用于持久化到本地存储) + /// + /// 应用退出时调用此方法,将待同步操作保存到 Isar, + /// 下次启动时通过 [enqueueAll] 恢复。 + List get snapshot => _pendingQueue.toList(); +} diff --git a/crates/erp-diary/src/dto.rs b/crates/erp-diary/src/dto.rs index 2b49306..edb7b90 100644 --- a/crates/erp-diary/src/dto.rs +++ b/crates/erp-diary/src/dto.rs @@ -123,3 +123,58 @@ pub struct ConflictInfo { pub local_version: i32, pub server_version: i32, } + +// ========== 班级成员 ========== + +/// 班级成员响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct ClassMemberResp { + pub user_id: uuid::Uuid, + pub role: String, + pub nickname: Option, + pub joined_at: chrono::DateTime, +} + +// ========== 主题布置 ========== + +/// 创建主题请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateTopicReq { + /// 主题标题 + pub title: String, + /// 主题描述/要求 + pub description: Option, + /// 截止日期 + pub due_date: Option, +} + +/// 主题响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct TopicResp { + pub id: uuid::Uuid, + pub class_id: uuid::Uuid, + pub teacher_id: uuid::Uuid, + pub title: String, + pub description: Option, + pub due_date: Option, + pub is_active: bool, +} + +// ========== 评语 ========== + +/// 创建评语请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateCommentReq { + /// 评语内容 + pub content: String, +} + +/// 评语响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct CommentResp { + pub id: uuid::Uuid, + pub journal_id: uuid::Uuid, + pub author_id: uuid::Uuid, + pub content: String, + pub created_at: chrono::DateTime, +} diff --git a/crates/erp-diary/src/handler/class_handler.rs b/crates/erp-diary/src/handler/class_handler.rs new file mode 100644 index 0000000..5ad284c --- /dev/null +++ b/crates/erp-diary/src/handler/class_handler.rs @@ -0,0 +1,191 @@ +// 班级 API 处理器 — 创建班级、加入班级、查询班级 + +use axum::extract::{Extension, FromRef, Path, State}; +use axum::response::Json; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::dto::{ClassMemberResp, ClassResp, CreateClassReq, JoinClassReq}; +use crate::service::class_service::ClassService; +use crate::state::DiaryState; + +#[utoipa::path( + post, + path = "/api/v1/diary/classes", + request_body = CreateClassReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (status = 400, description = "验证失败"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "班级管理" +)] +/// POST /api/v1/diary/classes +/// +/// 创建班级。需要 `diary.class.manage` 权限(老师角色)。 +pub async fn create_class( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.class.manage")?; + + if req.name.trim().is_empty() { + return Err(AppError::Validation("班级名称不能为空".to_string())); + } + + let resp = ClassService::create_class( + ctx.tenant_id, + ctx.user_id, + req.name, + req.school_name, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/diary/classes/join", + request_body = JoinClassReq, + responses( + (status = 200, description = "加入成功", body = ApiResponse), + (status = 400, description = "班级码无效或已过期"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "班级管理" +)] +/// POST /api/v1/diary/classes/join +/// +/// 通过班级码加入班级。需要 `diary.journal.create` 权限(学生使用此权限加入)。 +pub async fn join_class( + 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.class_code.trim().is_empty() { + return Err(AppError::Validation("班级码不能为空".to_string())); + } + + let resp = ClassService::join_class( + ctx.tenant_id, + ctx.user_id, + req.class_code, + None, // 昵称暂不通过此接口传递 + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/classes/{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/classes/:id +/// +/// 获取班级详情。需要 `diary.journal.read` 权限。 +pub async fn get_class( + 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 = ClassService::get_class(ctx.tenant_id, id, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/classes/{id}/members", + 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/classes/:id/members +/// +/// 获取班级成员列表。需要 `diary.journal.read` 权限。 +pub async fn list_members( + 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 = ClassService::list_members(ctx.tenant_id, id, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/classes/my", + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "班级管理" +)] +/// GET /api/v1/diary/classes/my +/// +/// 获取当前用户加入的所有班级。需要 `diary.journal.read` 权限。 +pub async fn my_classes( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = ClassService::my_classes(ctx.tenant_id, ctx.user_id, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-diary/src/handler/comment_handler.rs b/crates/erp-diary/src/handler/comment_handler.rs new file mode 100644 index 0000000..aab066b --- /dev/null +++ b/crates/erp-diary/src/handler/comment_handler.rs @@ -0,0 +1,90 @@ +// 评语 API 处理器 — 老师点评学生日记 + +use axum::extract::{Extension, FromRef, Path, State}; +use axum::response::Json; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::dto::{CommentResp, CreateCommentReq}; +use crate::service::comment_service::CommentService; +use crate::state::DiaryState; + +#[utoipa::path( + post, + path = "/api/v1/diary/journals/{journal_id}/comments", + params(("journal_id" = Uuid, Path, description = "日记ID")), + request_body = CreateCommentReq, + responses( + (status = 200, description = "点评成功", body = ApiResponse), + (status = 400, description = "验证失败或内容安全检查未通过"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "日记不存在"), + ), + security(("bearer_auth" = [])), + tag = "评语管理" +)] +/// POST /api/v1/diary/journals/:journal_id/comments +/// +/// 老师点评日记。需要 `diary.comment.write` 权限。 +pub async fn create_comment( + State(state): State, + Extension(ctx): Extension, + Path(journal_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.comment.write")?; + + if req.content.trim().is_empty() { + return Err(AppError::Validation("评语内容不能为空".to_string())); + } + + let resp = CommentService::create_comment( + ctx.tenant_id, + ctx.user_id, + journal_id, + req.content, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/journals/{journal_id}/comments", + params(("journal_id" = Uuid, Path, description = "日记ID")), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "评语管理" +)] +/// GET /api/v1/diary/journals/:journal_id/comments +/// +/// 获取日记评语列表。需要 `diary.journal.read` 权限。 +pub async fn list_comments( + State(state): State, + Extension(ctx): Extension, + Path(journal_id): Path, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = CommentService::list_comments(ctx.tenant_id, journal_id, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-diary/src/handler/mod.rs b/crates/erp-diary/src/handler/mod.rs index 5d06a56..453d0fb 100644 --- a/crates/erp-diary/src/handler/mod.rs +++ b/crates/erp-diary/src/handler/mod.rs @@ -2,3 +2,6 @@ pub mod journal_handler; pub mod sync_handler; +pub mod class_handler; +pub mod topic_handler; +pub mod comment_handler; diff --git a/crates/erp-diary/src/handler/topic_handler.rs b/crates/erp-diary/src/handler/topic_handler.rs new file mode 100644 index 0000000..abbec53 --- /dev/null +++ b/crates/erp-diary/src/handler/topic_handler.rs @@ -0,0 +1,90 @@ +// 主题布置 API 处理器 — 老师布置/查询主题 + +use axum::extract::{Extension, FromRef, Path, State}; +use axum::response::Json; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::dto::{CreateTopicReq, TopicResp}; +use crate::service::topic_service::TopicService; +use crate::state::DiaryState; + +#[utoipa::path( + post, + path = "/api/v1/diary/classes/{class_id}/topics", + params(("class_id" = Uuid, Path, description = "班级ID")), + request_body = CreateTopicReq, + responses( + (status = 200, description = "布置成功", body = ApiResponse), + (status = 400, description = "验证失败"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "班级不存在"), + ), + security(("bearer_auth" = [])), + tag = "主题布置" +)] +/// POST /api/v1/diary/classes/:class_id/topics +/// +/// 布置日记主题。需要 `diary.topic.assign` 权限(老师角色)。 +pub async fn assign_topic( + State(state): State, + Extension(ctx): Extension, + Path(class_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.topic.assign")?; + + if req.title.trim().is_empty() { + return Err(AppError::Validation("主题标题不能为空".to_string())); + } + + let resp = TopicService::assign_topic( + ctx.tenant_id, + ctx.user_id, + class_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/diary/classes/{class_id}/topics", + params(("class_id" = Uuid, Path, description = "班级ID")), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "主题布置" +)] +/// GET /api/v1/diary/classes/:class_id/topics +/// +/// 获取班级主题列表。需要 `diary.journal.read` 权限。 +pub async fn list_topics( + State(state): State, + Extension(ctx): Extension, + Path(class_id): Path, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.journal.read")?; + + let resp = TopicService::list_topics(ctx.tenant_id, class_id, &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 f09c504..9a9d346 100644 --- a/crates/erp-diary/src/lib.rs +++ b/crates/erp-diary/src/lib.rs @@ -10,7 +10,7 @@ pub use state::DiaryState; use erp_core::module::ErpModule; -use crate::handler::{journal_handler, sync_handler}; +use crate::handler::{journal_handler, sync_handler, class_handler, topic_handler, comment_handler}; /// 暖记日记业务模块 pub struct DiaryModule; @@ -127,5 +127,35 @@ impl DiaryModule { "/diary/sync", axum::routing::post(sync_handler::sync_journals), ) + // 班级管理 + .route( + "/diary/classes", + axum::routing::post(class_handler::create_class) + .get(class_handler::my_classes), + ) + .route( + "/diary/classes/join", + axum::routing::post(class_handler::join_class), + ) + .route( + "/diary/classes/{id}", + axum::routing::get(class_handler::get_class), + ) + .route( + "/diary/classes/{id}/members", + axum::routing::get(class_handler::list_members), + ) + // 主题布置 + .route( + "/diary/classes/{class_id}/topics", + axum::routing::post(topic_handler::assign_topic) + .get(topic_handler::list_topics), + ) + // 评语管理 + .route( + "/diary/journals/{journal_id}/comments", + axum::routing::post(comment_handler::create_comment) + .get(comment_handler::list_comments), + ) } } diff --git a/crates/erp-diary/src/service/class_service.rs b/crates/erp-diary/src/service/class_service.rs new file mode 100644 index 0000000..43eaf71 --- /dev/null +++ b/crates/erp-diary/src/service/class_service.rs @@ -0,0 +1,300 @@ +// 班级服务 — 创建班级、加入班级、班级查询 + +use chrono::{Months, Utc}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + Set, +}; +use uuid::Uuid; + +use crate::dto::{ClassMemberResp, ClassResp}; +use crate::entity::{class_member, school_class}; +use crate::error::{DiaryError, DiaryResult}; +use erp_core::events::{DomainEvent, EventBus}; + +/// 班级服务 — 6 位码生成、过期控制、成员管理 +pub struct ClassService; + +impl ClassService { + /// 创建班级(老师) + /// + /// 生成 6 位随机班级码,设置过期时间(6 个月后), + /// 自动将老师加入 class_members。 + pub async fn create_class( + tenant_id: Uuid, + teacher_id: Uuid, + name: String, + school_name: Option, + db: &DatabaseConnection, + event_bus: &EventBus, + ) -> DiaryResult { + let now = Utc::now(); + let id = Uuid::now_v7(); + + // 生成唯一班级码(最多重试 10 次) + let class_code = Self::generate_unique_code(db).await?; + + // 过期时间:6 个月后 + let expires_at = now.checked_add_months(Months::new(6)); + + // 创建班级记录 + let class_model = school_class::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(name), + school_name: Set(school_name), + teacher_id: Set(teacher_id), + class_code: Set(class_code.clone()), + member_count: Set(1), // 老师自动计入 + is_active: Set(true), + expires_at: Set(expires_at), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(teacher_id), + updated_by: Set(teacher_id), + deleted_at: Set(None), + version: Set(1), + }; + let inserted_class = class_model.insert(db).await?; + + // 自动将老师加入成员表 + let member_model = class_member::ActiveModel { + class_id: Set(id), + user_id: Set(teacher_id), + tenant_id: Set(tenant_id), + role: Set("teacher".to_string()), + nickname: Set(None), + joined_at: Set(now), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(teacher_id), + updated_by: Set(teacher_id), + deleted_at: Set(None), + version: Set(1), + }; + member_model.insert(db).await?; + + // 发布 ClassCreated 事件 + event_bus + .publish( + DomainEvent::new( + "diary.class.created", + tenant_id, + serde_json::json!({ + "class_id": id, + "teacher_id": teacher_id, + }), + ), + db, + ) + .await; + + Ok(class_model_to_resp(inserted_class)) + } + + /// 加入班级(学生通过班级码) + /// + /// 验证班级码有效性和过期状态,检查是否已是成员, + /// 创建 class_member 记录并更新 member_count。 + pub async fn join_class( + tenant_id: Uuid, + user_id: Uuid, + class_code: String, + nickname: Option, + db: &DatabaseConnection, + event_bus: &EventBus, + ) -> DiaryResult { + let now = Utc::now(); + + // 1. 查找班级码对应的班级 + let class_model = school_class::Entity::find() + .filter(school_class::Column::ClassCode.eq(&class_code)) + .filter(school_class::Column::TenantId.eq(tenant_id)) + .filter(school_class::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or(DiaryError::InvalidClassCode)?; + + // 2. 检查班级是否激活 + if !class_model.is_active { + return Err(DiaryError::BadRequest("班级已停用".to_string())); + } + + // 3. 检查是否过期 + if let Some(expires) = class_model.expires_at { + if now > expires { + return Err(DiaryError::ClassCodeExpired); + } + } + + let class_id = class_model.id; + + // 4. 检查是否已是成员 + let existing = class_member::Entity::find() + .filter(class_member::Column::ClassId.eq(class_id)) + .filter(class_member::Column::UserId.eq(user_id)) + .filter(class_member::Column::DeletedAt.is_null()) + .one(db) + .await?; + + if existing.is_some() { + return Err(DiaryError::BadRequest("已是班级成员".to_string())); + } + + // 5. 创建成员记录 + let member_model = class_member::ActiveModel { + class_id: Set(class_id), + user_id: Set(user_id), + tenant_id: Set(tenant_id), + role: Set("student".to_string()), + nickname: Set(nickname), + joined_at: Set(now), + 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), + }; + member_model.insert(db).await?; + + // 6. 更新 member_count + let mut active_class: school_class::ActiveModel = class_model.into(); + let new_count = active_class.member_count.unwrap() + 1; + active_class.member_count = Set(new_count); + active_class.updated_at = Set(now); + active_class.version = Set(active_class.version.unwrap() + 1); + let updated_class = active_class.update(db).await?; + + // 7. 发布 StudentJoinedClass 事件 + event_bus + .publish( + DomainEvent::new( + "diary.class.student_joined", + tenant_id, + serde_json::json!({ + "class_id": class_id, + "student_id": user_id, + }), + ), + db, + ) + .await; + + Ok(class_model_to_resp(updated_class)) + } + + /// 获取班级详情 + pub async fn get_class( + tenant_id: Uuid, + class_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult { + let model = school_class::Entity::find() + .filter(school_class::Column::Id.eq(class_id)) + .filter(school_class::Column::TenantId.eq(tenant_id)) + .filter(school_class::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?; + + Ok(class_model_to_resp(model)) + } + + /// 获取班级成员列表 + pub async fn list_members( + tenant_id: Uuid, + class_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + let members = class_member::Entity::find() + .filter(class_member::Column::ClassId.eq(class_id)) + .filter(class_member::Column::TenantId.eq(tenant_id)) + .filter(class_member::Column::DeletedAt.is_null()) + .all(db) + .await?; + + Ok(members.into_iter().map(member_model_to_resp).collect()) + } + + /// 获取我加入的班级列表 + pub async fn my_classes( + tenant_id: Uuid, + user_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + // 先查用户所在的班级 ID + let memberships = class_member::Entity::find() + .filter(class_member::Column::UserId.eq(user_id)) + .filter(class_member::Column::TenantId.eq(tenant_id)) + .filter(class_member::Column::DeletedAt.is_null()) + .all(db) + .await?; + + let class_ids: Vec = memberships.iter().map(|m| m.class_id).collect(); + + if class_ids.is_empty() { + return Ok(vec![]); + } + + let classes = school_class::Entity::find() + .filter(school_class::Column::Id.is_in(class_ids)) + .filter(school_class::Column::TenantId.eq(tenant_id)) + .filter(school_class::Column::DeletedAt.is_null()) + .filter(school_class::Column::IsActive.eq(true)) + .all(db) + .await?; + + Ok(classes.into_iter().map(class_model_to_resp).collect()) + } + + /// 生成唯一班级码(重试最多 10 次) + async fn generate_unique_code(db: &DatabaseConnection) -> DiaryResult { + for _ in 0..10 { + let code = generate_class_code(); + let exists = school_class::Entity::find() + .filter(school_class::Column::ClassCode.eq(&code)) + .one(db) + .await? + .is_some(); + + if !exists { + return Ok(code); + } + } + Err(DiaryError::Internal("无法生成唯一班级码".to_string())) + } +} + +/// 生成 6 位班级码(UUID 前 6 位字符) +fn generate_class_code() -> String { + Uuid::new_v4() + .to_string() + .replace("-", "") + .chars() + .take(6) + .collect() +} + +/// school_class::Model -> ClassResp +fn class_model_to_resp(model: school_class::Model) -> ClassResp { + ClassResp { + id: model.id, + name: model.name, + school_name: model.school_name, + teacher_id: model.teacher_id, + class_code: model.class_code, + member_count: model.member_count, + is_active: model.is_active, + } +} + +/// class_member::Model -> ClassMemberResp +fn member_model_to_resp(model: class_member::Model) -> ClassMemberResp { + ClassMemberResp { + user_id: model.user_id, + role: model.role, + nickname: model.nickname, + joined_at: model.joined_at, + } +} diff --git a/crates/erp-diary/src/service/comment_service.rs b/crates/erp-diary/src/service/comment_service.rs new file mode 100644 index 0000000..22d758b --- /dev/null +++ b/crates/erp-diary/src/service/comment_service.rs @@ -0,0 +1,134 @@ +// 评语服务 — 老师点评学生日记 + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::dto::CommentResp; +use crate::entity::{comment, journal_entry}; +use crate::error::{DiaryError, DiaryResult}; +use erp_core::events::{DomainEvent, EventBus}; + +/// 评语服务 — 老师对学生日记的点评 +pub struct CommentService; + +impl CommentService { + /// 添加评语(老师点评学生日记) + /// + /// 验证日记存在,执行基础内容安全检查, + /// 创建评论记录,发布 CommentCreated 事件。 + pub async fn create_comment( + tenant_id: Uuid, + author_id: Uuid, + journal_id: Uuid, + content: String, + db: &DatabaseConnection, + event_bus: &EventBus, + ) -> DiaryResult { + // 1. 验证日记存在 + let journal = journal_entry::Entity::find() + .filter(journal_entry::Column::Id.eq(journal_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!("日记 {} 不存在", journal_id)))?; + + // 2. 简单内容安全检查(基础敏感词过滤) + if contains_sensitive_words(&content) { + return Err(DiaryError::ContentSafetyViolation); + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + + // 3. 创建评论记录 + let model = comment::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + journal_id: Set(journal_id), + author_id: Set(author_id), + content: Set(content), + 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?; + + // 4. 发布 CommentCreated 事件 + event_bus + .publish( + DomainEvent::new( + "diary.comment.created", + tenant_id, + serde_json::json!({ + "comment_id": id, + "journal_id": journal_id, + "teacher_id": author_id, + "student_id": journal.author_id, + }), + ), + db, + ) + .await; + + Ok(comment_model_to_resp(inserted)) + } + + /// 获取日记的评语列表 + /// + /// 按创建时间正序返回日记下所有未删除的评语。 + pub async fn list_comments( + tenant_id: Uuid, + journal_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + let comments = comment::Entity::find() + .filter(comment::Column::JournalId.eq(journal_id)) + .filter(comment::Column::TenantId.eq(tenant_id)) + .filter(comment::Column::DeletedAt.is_null()) + .order_by_asc(comment::Column::CreatedAt) + .all(db) + .await?; + + Ok(comments.into_iter().map(comment_model_to_resp).collect()) + } +} + +/// comment::Model -> CommentResp +fn comment_model_to_resp(model: comment::Model) -> CommentResp { + CommentResp { + id: model.id, + journal_id: model.journal_id, + author_id: model.author_id, + content: model.content, + created_at: model.created_at, + } +} + +/// 基础敏感词检查 +/// +/// Phase 1 使用简单字符串匹配,后续迭代替换为完整词库。 +fn contains_sensitive_words(content: &str) -> bool { + const SENSITIVE_WORDS: &[&str] = &[ + // 占位 — Phase 1 仅检查是否为空或过短 + // 完整词库将在后续迭代中添加 + ]; + + if content.trim().is_empty() { + return true; + } + + for word in SENSITIVE_WORDS { + if content.contains(word) { + return true; + } + } + + false +} diff --git a/crates/erp-diary/src/service/mod.rs b/crates/erp-diary/src/service/mod.rs index d3cfe3c..4bcf3c3 100644 --- a/crates/erp-diary/src/service/mod.rs +++ b/crates/erp-diary/src/service/mod.rs @@ -2,3 +2,6 @@ pub mod journal_service; pub mod sync_service; +pub mod class_service; +pub mod topic_service; +pub mod comment_service; diff --git a/crates/erp-diary/src/service/topic_service.rs b/crates/erp-diary/src/service/topic_service.rs new file mode 100644 index 0000000..c5138de --- /dev/null +++ b/crates/erp-diary/src/service/topic_service.rs @@ -0,0 +1,116 @@ +// 主题布置服务 — 老师发布日记主题 + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, + QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::dto::{CreateTopicReq, TopicResp}; +use crate::entity::topic_assignment; +use crate::error::{DiaryError, DiaryResult}; +use erp_core::events::{DomainEvent, EventBus}; + +/// 主题布置服务 — 老师发布日记主题,学生提交对应日记 +pub struct TopicService; + +impl TopicService { + /// 布置主题(老师) + /// + /// 创建主题布置记录,验证老师是班级成员, + /// 发布 TopicAssigned 事件。 + pub async fn assign_topic( + tenant_id: Uuid, + teacher_id: Uuid, + class_id: Uuid, + req: &CreateTopicReq, + db: &DatabaseConnection, + event_bus: &EventBus, + ) -> DiaryResult { + // 验证班级存在 + let class = crate::entity::school_class::Entity::find() + .filter(crate::entity::school_class::Column::Id.eq(class_id)) + .filter(crate::entity::school_class::Column::TenantId.eq(tenant_id)) + .filter(crate::entity::school_class::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?; + + // 验证请求者是班级老师 + if class.teacher_id != teacher_id { + return Err(DiaryError::Forbidden); + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + + let model = topic_assignment::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + class_id: Set(class_id), + teacher_id: Set(teacher_id), + title: Set(req.title.clone()), + description: Set(req.description.clone()), + due_date: Set(req.due_date), + is_active: Set(true), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(teacher_id), + updated_by: Set(teacher_id), + deleted_at: Set(None), + version: Set(1), + }; + let inserted = model.insert(db).await?; + + // 发布 TopicAssigned 事件 + event_bus + .publish( + DomainEvent::new( + "diary.topic.assigned", + tenant_id, + serde_json::json!({ + "topic_id": id, + "class_id": class_id, + "teacher_id": teacher_id, + }), + ), + db, + ) + .await; + + Ok(topic_model_to_resp(inserted)) + } + + /// 获取班级的主题列表 + /// + /// 按创建时间倒序返回班级下所有激活的主题。 + pub async fn list_topics( + tenant_id: Uuid, + class_id: Uuid, + db: &DatabaseConnection, + ) -> DiaryResult> { + let topics = topic_assignment::Entity::find() + .filter(topic_assignment::Column::ClassId.eq(class_id)) + .filter(topic_assignment::Column::TenantId.eq(tenant_id)) + .filter(topic_assignment::Column::DeletedAt.is_null()) + .order_by_desc(topic_assignment::Column::CreatedAt) + .all(db) + .await?; + + Ok(topics.into_iter().map(topic_model_to_resp).collect()) + } +} + +/// topic_assignment::Model -> TopicResp +fn topic_model_to_resp(model: topic_assignment::Model) -> TopicResp { + TopicResp { + id: model.id, + class_id: model.class_id, + teacher_id: model.teacher_id, + title: model.title, + description: model.description, + due_date: model.due_date, + is_active: model.is_active, + } +}