feat: initialize Nuanji (Warm Notes) project

- Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin)
- Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs)
- Integrated erp-diary into workspace and erp-server
- Added DiaryModule registration in main.rs
- Added DiaryState FromRef in state.rs
- Diary routes mounted (empty routes, ready for implementation)
- Product design spec v1.2 preserved in docs/
- Implementation plan preserved in plans/

Cargo check: OK
Cargo test: OK (78+ base tests passing)
This commit is contained in:
iven
2026-05-31 20:52:19 +08:00
commit c539e6fd83
285 changed files with 59156 additions and 0 deletions

125
crates/erp-diary/src/dto.rs Normal file
View File

@@ -0,0 +1,125 @@
// erp-diary 数据传输对象 (DTO)
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// 日记心情枚举
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Mood {
Happy,
Calm,
Sad,
Angry,
Thinking,
}
/// 天气枚举
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Weather {
Sunny,
Cloudy,
Rainy,
Snowy,
Windy,
}
/// 创建日记请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateJournalReq {
pub title: String,
pub date: chrono::NaiveDate,
pub mood: Mood,
pub weather: Weather,
pub tags: Vec<String>,
pub is_private: bool,
pub class_id: Option<uuid::Uuid>,
pub assigned_topic_id: Option<uuid::Uuid>,
}
/// 更新日记请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateJournalReq {
pub title: Option<String>,
pub mood: Option<Mood>,
pub weather: Option<Weather>,
pub tags: Option<Vec<String>>,
pub is_private: Option<bool>,
pub shared_to_class: Option<bool>,
pub version: i32,
}
/// 日记响应
#[derive(Debug, Serialize, ToSchema)]
pub struct JournalResp {
pub id: uuid::Uuid,
pub author_id: uuid::Uuid,
pub class_id: Option<uuid::Uuid>,
pub title: String,
pub date: chrono::NaiveDate,
pub mood: Mood,
pub weather: Weather,
pub tags: Vec<String>,
pub is_private: bool,
pub shared_to_class: bool,
pub version: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
/// 创建班级请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateClassReq {
pub name: String,
pub school_name: Option<String>,
}
/// 加入班级请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct JoinClassReq {
pub class_code: String,
}
/// 班级响应
#[derive(Debug, Serialize, ToSchema)]
pub struct ClassResp {
pub id: uuid::Uuid,
pub name: String,
pub school_name: Option<String>,
pub teacher_id: uuid::Uuid,
pub class_code: String,
pub member_count: i32,
pub is_active: bool,
}
/// 同步请求
#[derive(Debug, Deserialize, ToSchema)]
pub struct SyncReq {
pub last_sync_time: Option<chrono::DateTime<chrono::Utc>>,
pub changes: Vec<SyncChange>,
}
/// 同步变更条目
#[derive(Debug, Deserialize, ToSchema)]
pub enum SyncChange {
CreateJournal { data: serde_json::Value },
UpdateJournal { id: uuid::Uuid, version: i32, data: serde_json::Value },
DeleteJournal { id: uuid::Uuid, version: i32 },
}
/// 同步响应
#[derive(Debug, Serialize, ToSchema)]
pub struct SyncResp {
pub server_changes: Vec<serde_json::Value>,
pub conflicts: Vec<ConflictInfo>,
pub sync_time: chrono::DateTime<chrono::Utc>,
}
/// 冲突信息
#[derive(Debug, Serialize, ToSchema)]
pub struct ConflictInfo {
pub journal_id: uuid::Uuid,
pub local_version: i32,
pub server_version: i32,
}

View File

@@ -0,0 +1,2 @@
// erp-diary SeaORM 实体占位
// 后续 Phase B1 会定义完整的 ~15 个实体

View File

@@ -0,0 +1,75 @@
// erp-diary 错误类型
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum DiaryError {
#[error("日记未找到: {0}")]
NotFound(String),
#[error("版本冲突: 本地版本 {local}, 服务端版本 {server}")]
VersionConflict { local: i32, server: i32 },
#[error("班级码无效")]
InvalidClassCode,
#[error("班级码已过期")]
ClassCodeExpired,
#[error("班级码尝试次数过多,请 {lockout_minutes} 分钟后重试")]
ClassCodeLocked { lockout_minutes: u32 },
#[error("无权限执行此操作")]
Forbidden,
#[error("内容安全检查未通过")]
ContentSafetyViolation,
#[error("同步失败: {0}")]
SyncFailed(String),
#[error("{0}")]
BadRequest(String),
#[error("内部错误: {0}")]
Internal(String),
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
message: String,
}
impl IntoResponse for DiaryError {
fn into_response(self) -> Response {
let (status, message) = match &self {
DiaryError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
DiaryError::VersionConflict { .. } => (StatusCode::CONFLICT, self.to_string()),
DiaryError::InvalidClassCode | DiaryError::ClassCodeExpired => {
(StatusCode::BAD_REQUEST, self.to_string())
}
DiaryError::ClassCodeLocked { .. } => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
DiaryError::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
DiaryError::ContentSafetyViolation => (StatusCode::BAD_REQUEST, self.to_string()),
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()),
};
let body = ErrorBody {
error: format!("diary.{}", status.as_u16()),
message,
};
(status, axum::Json(body)).into_response()
}
}
impl From<sea_orm::DbErr> for DiaryError {
fn from(err: sea_orm::DbErr) -> Self {
DiaryError::Internal(err.to_string())
}
}

View File

@@ -0,0 +1,61 @@
// erp-diary 事件定义
/// 日记模块领域事件
pub enum DiaryEvent {
/// 日记创建
JournalCreated {
journal_id: uuid::Uuid,
author_id: uuid::Uuid,
class_id: Option<uuid::Uuid>,
},
/// 日记更新
JournalUpdated {
journal_id: uuid::Uuid,
author_id: uuid::Uuid,
version: i32,
},
/// 日记删除
JournalDeleted {
journal_id: uuid::Uuid,
author_id: uuid::Uuid,
},
/// 日记分享到班级
JournalShared {
journal_id: uuid::Uuid,
author_id: uuid::Uuid,
class_id: uuid::Uuid,
},
/// 班级创建
ClassCreated {
class_id: uuid::Uuid,
teacher_id: uuid::Uuid,
},
/// 学生加入班级
StudentJoinedClass {
class_id: uuid::Uuid,
student_id: uuid::Uuid,
},
/// 老师布置主题
TopicAssigned {
topic_id: uuid::Uuid,
class_id: uuid::Uuid,
teacher_id: uuid::Uuid,
},
/// 老师点评
CommentCreated {
comment_id: uuid::Uuid,
journal_id: uuid::Uuid,
teacher_id: uuid::Uuid,
student_id: uuid::Uuid,
},
/// 家长绑定孩子
ParentBound {
parent_id: uuid::Uuid,
child_id: uuid::Uuid,
},
/// 成就解锁
AchievementUnlocked {
user_id: uuid::Uuid,
achievement_id: String,
},
}

View File

@@ -0,0 +1,2 @@
// erp-diary API 处理器占位
// 后续 Phase B2-B7 会实现 ~10 个处理器

112
crates/erp-diary/src/lib.rs Normal file
View File

@@ -0,0 +1,112 @@
pub mod entity;
pub mod service;
pub mod handler;
pub mod dto;
pub mod error;
pub mod event;
pub mod state;
pub use state::DiaryState;
use erp_core::module::ErpModule;
/// 暖记日记业务模块
pub struct DiaryModule;
impl ErpModule for DiaryModule {
fn name(&self) -> &str {
"diary"
}
fn id(&self) -> &str {
"erp-diary"
}
fn version(&self) -> &str {
env!("CARGO_PKG_VERSION")
}
fn module_type(&self) -> erp_core::module::ModuleType {
erp_core::module::ModuleType::Builtin
}
fn dependencies(&self) -> Vec<&str> {
vec!["erp-auth", "erp-core"]
}
fn permissions(&self) -> Vec<erp_core::module::PermissionDescriptor> {
vec![
erp_core::module::PermissionDescriptor {
code: "diary.journal.create".into(),
name: "创建日记".into(),
module: "diary".into(),
description: "允许创建日记条目".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.journal.read".into(),
name: "查看日记".into(),
module: "diary".into(),
description: "允许查看日记条目".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.journal.update".into(),
name: "编辑日记".into(),
module: "diary".into(),
description: "允许编辑日记条目".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.journal.delete".into(),
name: "删除日记".into(),
module: "diary".into(),
description: "允许删除日记条目".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.class.manage".into(),
name: "管理班级".into(),
module: "diary".into(),
description: "允许创建和管理班级".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.topic.assign".into(),
name: "布置主题".into(),
module: "diary".into(),
description: "允许老师布置日记主题".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.comment.write".into(),
name: "写评语".into(),
module: "diary".into(),
description: "允许老师点评日记".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.parent.bind".into(),
name: "家长绑定".into(),
module: "diary".into(),
description: "允许家长绑定孩子账号".into(),
},
]
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl DiaryModule {
/// 公开路由(无需认证)
pub fn public_routes<S>() -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
axum::Router::new()
}
/// 受保护路由(需要 JWT 认证)
pub fn protected_routes<S>() -> axum::Router<S>
where
crate::state::DiaryState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
axum::Router::new()
}
}

View File

@@ -0,0 +1,2 @@
// erp-diary 业务服务占位
// 后续 Phase B2-B6 会实现 ~12 个服务

View File

@@ -0,0 +1,13 @@
// erp-diary State 定义
use sea_orm::DatabaseConnection;
use erp_core::crypto::PiiCrypto;
use erp_core::events::EventBus;
/// 暖记模块状态,通过 Axum State 提取
#[derive(Clone)]
pub struct DiaryState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub crypto: PiiCrypto,
}