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:
194
crates/erp-message/src/handler/message_handler.rs
Normal file
194
crates/erp-message/src/handler/message_handler.rs
Normal 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(())))
|
||||
}
|
||||
4
crates/erp-message/src/handler/mod.rs
Normal file
4
crates/erp-message/src/handler/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod message_handler;
|
||||
pub mod sse_handler;
|
||||
pub mod subscription_handler;
|
||||
pub mod template_handler;
|
||||
322
crates/erp-message/src/handler/sse_handler.rs
Normal file
322
crates/erp-message/src/handler/sse_handler.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
60
crates/erp-message/src/handler/subscription_handler.rs
Normal file
60
crates/erp-message/src/handler/subscription_handler.rs
Normal 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)))
|
||||
}
|
||||
140
crates/erp-message/src/handler/template_handler.rs
Normal file
140
crates/erp-message/src/handler/template_handler.rs
Normal 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(())))
|
||||
}
|
||||
Reference in New Issue
Block a user