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

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

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

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

View File

@@ -4,6 +4,8 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use erp_core::error::AppError;
#[derive(Debug, thiserror::Error)]
pub enum DiaryError {
#[error("日记未找到: {0}")]
@@ -35,8 +37,37 @@ pub enum DiaryError {
#[error("内部错误: {0}")]
Internal(String),
#[error("{0}")]
Validation(String),
}
/// DiaryError -> AppError 转换
///
/// Handler 层统一返回 AppErrorService 层统一返回 DiaryError。
/// 这个 impl 让 Handler 中的 `?` 操作符自动完成转换。
impl From<DiaryError> for AppError {
fn from(err: DiaryError) -> Self {
match err {
DiaryError::NotFound(msg) => AppError::NotFound(msg),
DiaryError::VersionConflict { .. } => AppError::VersionMismatch,
DiaryError::InvalidClassCode | DiaryError::ClassCodeExpired => {
AppError::Validation(err.to_string())
}
DiaryError::ClassCodeLocked { .. } => AppError::TooManyRequests,
DiaryError::Forbidden => AppError::Forbidden("权限不足".to_string()),
DiaryError::ContentSafetyViolation => AppError::Validation(err.to_string()),
DiaryError::SyncFailed(_) => AppError::Internal(err.to_string()),
DiaryError::BadRequest(msg) => AppError::Validation(msg),
DiaryError::Internal(_) => AppError::Internal(err.to_string()),
DiaryError::Validation(msg) => AppError::Validation(msg),
}
}
}
/// Diary 模块 Result 类型别名
pub type DiaryResult<T> = Result<T, DiaryError>;
#[derive(Serialize)]
struct ErrorBody {
error: String,
@@ -57,6 +88,7 @@ impl IntoResponse for DiaryError {
DiaryError::SyncFailed(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
DiaryError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
DiaryError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
DiaryError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()),
};
let body = ErrorBody {
@@ -73,3 +105,89 @@ impl From<sea_orm::DbErr> for DiaryError {
DiaryError::Internal(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use erp_core::error::AppError;
#[test]
fn diary_error_not_found_maps_to_app_not_found() {
let app: AppError = DiaryError::NotFound("journal-123".to_string()).into();
match app {
AppError::NotFound(msg) => assert_eq!(msg, "journal-123"),
other => panic!("Expected NotFound, got {:?}", other),
}
}
#[test]
fn diary_error_version_conflict_maps_to_version_mismatch() {
let app: AppError = DiaryError::VersionConflict {
local: 1,
server: 2,
}
.into();
match app {
AppError::VersionMismatch => {}
other => panic!("Expected VersionMismatch, got {:?}", other),
}
}
#[test]
fn diary_error_forbidden_maps_to_app_forbidden() {
let app: AppError = DiaryError::Forbidden.into();
match app {
AppError::Forbidden(_) => {}
other => panic!("Expected Forbidden, got {:?}", other),
}
}
#[test]
fn diary_error_internal_maps_to_app_internal() {
let app: AppError = DiaryError::Internal("db error".to_string()).into();
match app {
AppError::Internal(_) => {}
other => panic!("Expected Internal, got {:?}", other),
}
}
#[test]
fn diary_error_validation_maps_to_app_validation() {
let app: AppError = DiaryError::Validation("标题不能为空".to_string()).into();
match app {
AppError::Validation(msg) => assert_eq!(msg, "标题不能为空"),
other => panic!("Expected Validation, got {:?}", other),
}
}
#[test]
fn diary_error_bad_request_maps_to_app_validation() {
let app: AppError = DiaryError::BadRequest("参数错误".to_string()).into();
match app {
AppError::Validation(msg) => assert_eq!(msg, "参数错误"),
other => panic!("Expected Validation, got {:?}", other),
}
}
#[test]
fn diary_error_class_code_locked_maps_to_too_many_requests() {
let app: AppError = DiaryError::ClassCodeLocked {
lockout_minutes: 30,
}
.into();
match app {
AppError::TooManyRequests => {}
other => panic!("Expected TooManyRequests, got {:?}", other),
}
}
#[test]
fn db_err_maps_to_diary_internal() {
let err = sea_orm::DbErr::Custom("connection failed".to_string());
let diary_err: DiaryError = err.into();
match diary_err {
DiaryError::Internal(msg) => assert!(msg.contains("connection failed")),
other => panic!("Expected Internal, got {:?}", other),
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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