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

View File

@@ -0,0 +1,194 @@
use axum::Json;
use axum::extract::FromRef;
use axum::extract::{Extension, Path, Query, State};
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use validator::Validate;
use crate::dto::{MessageQuery, MessageResp, SendMessageReq, UnreadCountResp};
use crate::message_state::MessageState;
use crate::service::message_service::MessageService;
#[utoipa::path(
get,
path = "/api/v1/messages",
params(MessageQuery),
responses(
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<MessageResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息管理"
)]
/// 查询消息列表。
pub async fn list_messages<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<MessageQuery>,
) -> Result<Json<ApiResponse<PaginatedResponse<MessageResp>>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "message.list")?;
let db = &_state.db;
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let (messages, total) = MessageService::list(ctx.tenant_id, ctx.user_id, &query, db).await?;
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: messages,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
get,
path = "/api/v1/messages/unread-count",
responses(
(status = 200, description = "成功", body = ApiResponse<UnreadCountResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息管理"
)]
/// 获取未读消息数量。
pub async fn unread_count<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<UnreadCountResp>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "message.list")?;
let result = MessageService::unread_count(ctx.tenant_id, ctx.user_id, &_state.db).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
post,
path = "/api/v1/messages",
request_body = SendMessageReq,
responses(
(status = 200, description = "成功", body = ApiResponse<MessageResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息管理"
)]
/// 发送消息。
pub async fn send_message<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<SendMessageReq>,
) -> Result<Json<ApiResponse<MessageResp>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "message.send")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let resp = MessageService::send(
ctx.tenant_id,
ctx.user_id,
&req,
&_state.db,
&_state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
put,
path = "/api/v1/messages/{id}/read",
params(("id" = Uuid, Path, description = "消息ID")),
responses(
(status = 200, description = "成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息管理"
)]
/// 标记消息已读。
pub async fn mark_read<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
MessageService::mark_read(id, ctx.tenant_id, ctx.user_id, &_state.db).await?;
Ok(Json(ApiResponse::ok(())))
}
#[utoipa::path(
put,
path = "/api/v1/messages/read-all",
responses(
(status = 200, description = "成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息管理"
)]
/// 标记所有消息已读。
pub async fn mark_all_read<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
MessageService::mark_all_read(ctx.tenant_id, ctx.user_id, &_state.db).await?;
Ok(Json(ApiResponse::ok(())))
}
#[utoipa::path(
delete,
path = "/api/v1/messages/{id}",
params(("id" = Uuid, Path, description = "消息ID")),
responses(
(status = 200, description = "成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息管理"
)]
/// 删除消息。
pub async fn delete_message<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
MessageService::delete(id, ctx.tenant_id, ctx.user_id, &_state.db).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -0,0 +1,4 @@
pub mod message_handler;
pub mod sse_handler;
pub mod subscription_handler;
pub mod template_handler;

View File

@@ -0,0 +1,322 @@
use std::cell::Cell;
use std::collections::HashSet;
use axum::extract::{Extension, Query};
use axum::http::{HeaderMap, HeaderValue, header};
use axum::response::IntoResponse;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures::stream::Stream;
use sea_orm::ConnectionTrait;
use serde::Deserialize;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::types::TenantContext;
/// 包装 SSE 响应,添加 Cache-Control: no-store 头
pub struct NoCacheSse<S>(Sse<S>);
impl<S> IntoResponse for NoCacheSse<S>
where
S: Stream<Item = Result<Event, std::convert::Infallible>> + Send + 'static,
{
fn into_response(self) -> axum::response::Response {
let mut response = self.0.into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store, no-cache, must-revalidate"),
);
response
}
}
use crate::message_state::MessageState;
/// SSE 查询参数
#[derive(Debug, Deserialize, Default)]
pub struct SseQuery {
/// 逗号分隔的患者 ID 列表,为空则订阅所有管床患者
pub patient_ids: Option<String>,
}
/// SSE 消息推送端点。
///
/// 监听所有事件,按类型分发为不同 SSE event
/// - `message.sent` → SSE event: `message`
/// - `alert.triggered` → SSE event: `alert`
/// - `device.readings.synced` → SSE event: `vital_update`
///
/// 增强:
/// - Event ID支持 Last-Event-ID 断点续传)
/// - 30s 心跳保活
/// - 患者选择性订阅(?patient_ids=id1,id2
pub async fn message_stream(
axum::extract::State(state): axum::extract::State<MessageState>,
Extension(ctx): Extension<TenantContext>,
headers: HeaderMap,
Query(query): Query<SseQuery>,
) -> Result<NoCacheSse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
let user_id = ctx.user_id;
let tenant_id = ctx.tenant_id;
let last_event_id: Option<Uuid> = headers
.get("Last-Event-ID")
.and_then(|v| v.to_str().ok())
.and_then(|s| Uuid::parse_str(s).ok());
let subscribed_patient_ids: Option<HashSet<String>> = query.patient_ids.as_ref().map(|s| {
s.split(',')
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect()
});
let (mut rx, _handle) = state.event_bus.subscribe_filtered(String::new());
let db = state.db.clone();
let last_event_id_cell = Cell::new(last_event_id);
let sse_stream = async_stream::stream! {
loop {
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
rx.recv(),
).await;
match result {
Ok(Some(event)) => {
if event.tenant_id != tenant_id {
continue;
}
// Last-Event-ID 恢复:跳过已发送的事件
if let Some(skip_until) = last_event_id_cell.take()
&& event.id <= skip_until {
last_event_id_cell.set(Some(skip_until));
continue;
}
match event.event_type.as_str() {
"message.sent" => {
let is_recipient = event.payload.get("recipient_id")
.and_then(|v| v.as_str())
.map(|s| s == user_id.to_string())
.unwrap_or(false);
if !is_recipient {
continue;
}
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event("message")
.id(event.id.to_string())
.data(data));
}
"alert.triggered" => {
let patient_id = event.payload.get("patient_id")
.and_then(|v| v.as_str());
// 患者订阅过滤
if let (Some(pid_str), Some(subscribed)) = (patient_id, &subscribed_patient_ids)
&& !subscribed.contains(pid_str) {
continue;
}
if let Some(pid_str) = patient_id {
let pid = Uuid::parse_str(pid_str).ok();
if let Some(pid) = pid {
let is_doctor = is_doctor_for_patient(
&db, tenant_id, user_id, pid,
).await;
if !is_doctor {
continue;
}
}
}
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event("alert")
.id(event.id.to_string())
.data(data));
}
"device.readings.synced" => {
let patient_id = event.payload.get("patient_id")
.and_then(|v| v.as_str());
// 患者订阅过滤
if let (Some(pid_str), Some(subscribed)) = (patient_id, &subscribed_patient_ids)
&& !subscribed.contains(pid_str) {
continue;
}
if let Some(pid_str) = patient_id {
let pid = Uuid::parse_str(pid_str).ok();
if let Some(pid) = pid {
let is_doctor = is_doctor_for_patient(
&db, tenant_id, user_id, pid,
).await;
if !is_doctor {
continue;
}
}
}
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event("vital_update")
.id(event.id.to_string())
.data(data));
}
_ => {}
}
}
Ok(None) => {
break;
}
Err(_) => {
// 超时 = 发送心跳
yield Ok(Event::default().comment("ping"));
}
}
}
};
Ok(NoCacheSse(
Sse::new(sse_stream).keep_alive(
KeepAlive::new()
.interval(std::time::Duration::from_secs(30))
.text("ping"),
),
))
}
/// 检查 user_id 对应的医生是否是某患者的管床医生。
async fn is_doctor_for_patient(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
user_id: Uuid,
patient_id: Uuid,
) -> bool {
let sql = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"SELECT COUNT(*) AS cnt FROM patient_doctor_relation
WHERE tenant_id = $1 AND doctor_id = $2 AND patient_id = $3 AND deleted_at IS NULL"#,
[tenant_id.into(), user_id.into(), patient_id.into()],
);
match db.query_one(sql).await {
Ok(Some(row)) => {
let cnt: i64 = row.try_get::<i64>("", "cnt").unwrap_or(0);
cnt > 0
}
_ => {
tracing::warn!(
user_id = %user_id,
patient_id = %patient_id,
"查询医患关系失败,跳过推送"
);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patient_id_parsing_from_payload() {
let payload = serde_json::json!({
"patient_id": "550e8400-e29b-41d4-a716-446655440000",
"severity": "critical",
"rule_name": "心率过高",
});
let pid_str = payload.get("patient_id").and_then(|v| v.as_str());
assert!(pid_str.is_some());
let pid = Uuid::parse_str(pid_str.unwrap()).ok();
assert!(pid.is_some());
assert_eq!(
pid.unwrap(),
Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
);
}
#[test]
fn patient_id_missing_returns_none() {
let payload = serde_json::json!({
"severity": "warning",
});
let pid_str = payload.get("patient_id").and_then(|v| v.as_str());
assert!(pid_str.is_none());
}
#[test]
fn patient_id_invalid_uuid_returns_none() {
let payload = serde_json::json!({
"patient_id": "not-a-uuid",
});
let pid_str = payload.get("patient_id").and_then(|v| v.as_str());
assert!(pid_str.is_some());
let pid = Uuid::parse_str(pid_str.unwrap()).ok();
assert!(pid.is_none());
}
#[test]
fn sse_query_parses_patient_ids() {
let query = SseQuery {
patient_ids: Some("id1,id2,id3".into()),
};
assert!(query.patient_ids.is_some());
let ids = query.patient_ids.unwrap();
assert_eq!(ids, "id1,id2,id3");
}
#[test]
fn sse_query_default_is_empty() {
let query = SseQuery::default();
assert!(query.patient_ids.is_none());
}
#[test]
fn subscribed_patient_ids_parsing() {
let query = SseQuery {
patient_ids: Some("aaa,bbb,ccc".into()),
};
let set: Option<HashSet<String>> = query.patient_ids.map(|s: String| {
s.split(',')
.map(|id: &str| id.trim().to_string())
.filter(|id: &String| !id.is_empty())
.collect()
});
assert!(set.is_some());
let set = set.unwrap();
assert_eq!(set.len(), 3);
assert!(set.contains("aaa"));
assert!(set.contains("bbb"));
assert!(set.contains("ccc"));
}
#[test]
fn last_event_id_parsing_from_headers() {
let event_id = Uuid::now_v7();
let mut headers = HeaderMap::new();
headers.insert("Last-Event-ID", event_id.to_string().parse().unwrap());
let parsed: Option<Uuid> = headers
.get("Last-Event-ID")
.and_then(|v| v.to_str().ok())
.and_then(|s| Uuid::parse_str(s).ok());
assert_eq!(parsed, Some(event_id));
}
#[test]
fn last_event_id_missing_returns_none() {
let headers = HeaderMap::new();
let parsed: Option<Uuid> = headers
.get("Last-Event-ID")
.and_then(|v| v.to_str().ok())
.and_then(|s| Uuid::parse_str(s).ok());
assert!(parsed.is_none());
}
}

View File

@@ -0,0 +1,60 @@
use axum::Json;
use axum::extract::FromRef;
use axum::extract::{Extension, State};
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::UpdateSubscriptionReq;
use crate::message_state::MessageState;
use crate::service::subscription_service::SubscriptionService;
#[utoipa::path(
get,
path = "/api/v1/message-subscriptions",
responses(
(status = 200, description = "成功", body = ApiResponse<crate::dto::MessageSubscriptionResp>),
(status = 401, description = "未授权"),
),
security(("bearer_auth" = [])),
tag = "消息订阅"
)]
/// 获取当前用户的消息订阅偏好。
pub async fn get_subscription<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<crate::dto::MessageSubscriptionResp>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let resp = SubscriptionService::get(ctx.tenant_id, ctx.user_id, &_state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
put,
path = "/api/v1/message-subscriptions",
request_body = UpdateSubscriptionReq,
responses(
(status = 200, description = "成功", body = ApiResponse<crate::dto::MessageSubscriptionResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息订阅"
)]
/// 更新消息订阅偏好。
pub async fn update_subscription<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<UpdateSubscriptionReq>,
) -> Result<Json<ApiResponse<crate::dto::MessageSubscriptionResp>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let resp = SubscriptionService::upsert(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}

View File

@@ -0,0 +1,140 @@
use axum::Json;
use axum::extract::FromRef;
use axum::extract::{Extension, Path, Query, State};
use serde::Deserialize;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use validator::Validate;
use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq};
use crate::message_state::MessageState;
use crate::service::template_service::TemplateService;
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct TemplateQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[utoipa::path(
get,
path = "/api/v1/message-templates",
params(TemplateQuery),
responses(
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<MessageTemplateResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息模板"
)]
/// 查询消息模板列表。
pub async fn list_templates<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<TemplateQuery>,
) -> Result<Json<ApiResponse<PaginatedResponse<MessageTemplateResp>>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "message.template.list")?;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).max(1);
let (templates, total) =
TemplateService::list(ctx.tenant_id, page, page_size, &_state.db).await?;
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: templates,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
post,
path = "/api/v1/message-templates",
request_body = CreateTemplateReq,
responses(
(status = 200, description = "成功", body = ApiResponse<MessageTemplateResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息模板"
)]
/// 创建消息模板。
pub async fn create_template<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateTemplateReq>,
) -> Result<Json<ApiResponse<MessageTemplateResp>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "message.template.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let resp = TemplateService::create(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
put,
path = "/api/v1/message-templates/{id}",
request_body = UpdateTemplateReq,
responses(
(status = 200, description = "成功", body = ApiResponse<MessageTemplateResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "消息模板"
)]
/// 更新消息模板。
pub async fn update_template<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateTemplateReq>,
) -> Result<Json<ApiResponse<MessageTemplateResp>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "message.template.manage")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let resp = TemplateService::update(id, ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
/// 删除消息模板。
#[allow(clippy::type_complexity)]
pub async fn delete_template<S>(
State(_state): State<MessageState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
MessageState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "message.template.manage")?;
TemplateService::delete(id, ctx.tenant_id, ctx.user_id, &_state.db).await?;
Ok(Json(ApiResponse::ok(())))
}