feat: initialize ERP base platform (extracted from HMS)

- Stripped 11 business crates (health, ai, dialysis, plugins)
- Cleaned AppState, AppConfig, main.rs from business coupling
- Reduced migrations from 169 to 53 (base-only)
- Removed health_provider trait from erp-core
- Removed business integration tests
- Removed gateway rate limiting middleware
- Base capabilities: auth, RBAC, JWT, config, workflow, message, plugin, audit, crypto, RLS, multi-tenant

Cargo check: OK
Cargo test: OK
This commit is contained in:
iven
2026-05-31 20:35:57 +08:00
commit 59856ac2fc
639 changed files with 124710 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
[package]
name = "erp-message"
version.workspace = true
edition.workspace = true
[dependencies]
erp-core.workspace = true
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
uuid = { workspace = true, features = ["v7", "serde"] }
chrono = { workspace = true, features = ["serde"] }
axum = { workspace = true }
sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "with-uuid", "with-chrono", "with-json"] }
tracing = { workspace = true }
anyhow.workspace = true
thiserror.workspace = true
utoipa = { workspace = true, features = ["uuid", "chrono"] }
async-trait.workspace = true
validator.workspace = true
futures.workspace = true
tokio-stream.workspace = true
async-stream.workspace = true

View File

@@ -0,0 +1,517 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
// ============ 消息 DTO ============
/// 消息响应
#[derive(Debug, Serialize, ToSchema)]
pub struct MessageResp {
pub id: Uuid,
pub tenant_id: Uuid,
pub template_id: Option<Uuid>,
pub sender_id: Option<Uuid>,
pub sender_type: String,
pub recipient_id: Uuid,
pub recipient_type: String,
pub title: String,
pub body: String,
pub priority: String,
pub business_type: Option<String>,
pub business_id: Option<Uuid>,
pub is_read: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub read_at: Option<DateTime<Utc>>,
pub is_archived: bool,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub sent_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
/// 发送消息请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct SendMessageReq {
#[validate(length(min = 1, max = 200, message = "标题不能为空且不超过200字符"))]
pub title: String,
#[validate(length(min = 1, message = "内容不能为空"))]
pub body: String,
pub recipient_id: Uuid,
#[serde(default = "default_recipient_type")]
#[validate(custom(function = "validate_recipient_type"))]
pub recipient_type: String,
#[serde(default = "default_priority")]
#[validate(custom(function = "validate_priority"))]
pub priority: String,
pub template_id: Option<Uuid>,
pub business_type: Option<String>,
pub business_id: Option<Uuid>,
}
fn validate_recipient_type(value: &str) -> Result<(), validator::ValidationError> {
match value {
"user" | "role" | "department" | "all" => Ok(()),
_ => Err(validator::ValidationError::new("invalid_recipient_type")),
}
}
fn validate_priority(value: &str) -> Result<(), validator::ValidationError> {
match value {
"normal" | "important" | "urgent" => Ok(()),
_ => Err(validator::ValidationError::new("invalid_priority")),
}
}
fn default_recipient_type() -> String {
"user".to_string()
}
fn default_priority() -> String {
"normal".to_string()
}
/// 消息列表查询参数
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct MessageQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub is_read: Option<bool>,
pub priority: Option<String>,
pub business_type: Option<String>,
pub status: Option<String>,
}
impl MessageQuery {
/// 获取安全的分页大小(上限 100
pub fn safe_page_size(&self) -> u64 {
self.page_size.unwrap_or(20).min(100)
}
}
/// 未读消息计数响应
#[derive(Debug, Serialize, ToSchema)]
pub struct UnreadCountResp {
pub count: i64,
}
// ============ 消息模板 DTO ============
/// 消息模板响应
#[derive(Debug, Serialize, ToSchema)]
pub struct MessageTemplateResp {
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
pub code: String,
pub channel: String,
pub title_template: String,
pub body_template: String,
pub language: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
/// 创建消息模板请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateTemplateReq {
#[validate(length(min = 1, max = 100, message = "名称不能为空且不超过100字符"))]
pub name: String,
#[validate(length(min = 1, max = 50, message = "编码不能为空且不超过50字符"))]
pub code: String,
#[serde(default = "default_channel")]
#[validate(custom(function = "validate_channel"))]
pub channel: String,
#[validate(length(min = 1, max = 200, message = "标题模板不能为空"))]
pub title_template: String,
#[validate(length(min = 1, message = "内容模板不能为空"))]
pub body_template: String,
#[serde(default = "default_language")]
pub language: String,
}
fn default_channel() -> String {
"in_app".to_string()
}
fn validate_channel(value: &str) -> Result<(), validator::ValidationError> {
match value {
"in_app" | "email" | "sms" | "wechat" => Ok(()),
_ => Err(validator::ValidationError::new("invalid_channel")),
}
}
fn default_language() -> String {
"zh-CN".to_string()
}
/// 更新消息模板请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateTemplateReq {
#[validate(length(min = 1, max = 100, message = "名称不能为空且不超过100字符"))]
pub name: Option<String>,
#[validate(length(min = 1, max = 200, message = "标题模板不能为空"))]
pub title_template: Option<String>,
#[validate(length(min = 1, message = "内容模板不能为空"))]
pub body_template: Option<String>,
#[validate(length(min = 1, max = 10, message = "语言代码无效"))]
pub language: Option<String>,
#[validate(custom(function = "validate_channel"))]
pub channel: Option<String>,
pub version: i32,
}
// ============ 消息订阅偏好 DTO ============
/// 消息订阅偏好响应
#[derive(Debug, Serialize, ToSchema)]
pub struct MessageSubscriptionResp {
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub notification_types: Option<serde_json::Value>,
pub channel_preferences: Option<serde_json::Value>,
pub dnd_enabled: bool,
pub dnd_start: Option<String>,
pub dnd_end: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
/// 更新消息订阅偏好请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateSubscriptionReq {
pub notification_types: Option<serde_json::Value>,
pub channel_preferences: Option<serde_json::Value>,
pub dnd_enabled: Option<bool>,
#[validate(length(max = 8, message = "免打扰开始时间格式无效"))]
pub dnd_start: Option<String>,
#[validate(length(max = 8, message = "免打扰结束时间格式无效"))]
pub dnd_end: Option<String>,
pub version: i32,
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
use validator::Validate;
// ============ SendMessageReq 测试 ============
fn valid_send_message_req() -> SendMessageReq {
SendMessageReq {
title: "系统通知".to_string(),
body: "您有一条新消息".to_string(),
recipient_id: Uuid::now_v7(),
recipient_type: "user".to_string(),
priority: "normal".to_string(),
template_id: None,
business_type: None,
business_id: None,
}
}
#[test]
fn send_message_req_valid() {
let req = valid_send_message_req();
assert!(req.validate().is_ok());
}
#[test]
fn send_message_req_empty_title_fails() {
let mut req = valid_send_message_req();
req.title = "".to_string();
assert!(req.validate().is_err());
}
#[test]
fn send_message_req_title_too_long_fails() {
let mut req = valid_send_message_req();
req.title = "x".repeat(201);
assert!(req.validate().is_err());
}
#[test]
fn send_message_req_title_max_length_ok() {
let mut req = valid_send_message_req();
req.title = "x".repeat(200);
assert!(req.validate().is_ok());
}
#[test]
fn send_message_req_empty_body_fails() {
let mut req = valid_send_message_req();
req.body = "".to_string();
assert!(req.validate().is_err());
}
#[test]
fn send_message_req_valid_recipient_types() {
for rt in &["user", "role", "department", "all"] {
let mut req = valid_send_message_req();
req.recipient_type = rt.to_string();
assert!(
req.validate().is_ok(),
"recipient_type '{}' should be valid",
rt
);
}
}
#[test]
fn send_message_req_invalid_recipient_type_fails() {
let mut req = valid_send_message_req();
req.recipient_type = "invalid".to_string();
assert!(req.validate().is_err());
}
#[test]
fn send_message_req_valid_priorities() {
for p in &["normal", "important", "urgent"] {
let mut req = valid_send_message_req();
req.priority = p.to_string();
assert!(req.validate().is_ok(), "priority '{}' should be valid", p);
}
}
#[test]
fn send_message_req_invalid_priority_fails() {
let mut req = valid_send_message_req();
req.priority = "critical".to_string();
assert!(req.validate().is_err());
}
#[test]
fn send_message_req_default_recipient_type_is_user() {
assert_eq!(default_recipient_type(), "user");
}
#[test]
fn send_message_req_default_priority_is_normal() {
assert_eq!(default_priority(), "normal");
}
// ============ MessageQuery 测试 ============
#[test]
fn message_query_safe_page_size_default() {
let query = MessageQuery {
page: None,
page_size: None,
is_read: None,
priority: None,
business_type: None,
status: None,
};
assert_eq!(query.safe_page_size(), 20);
}
#[test]
fn message_query_safe_page_size_custom() {
let query = MessageQuery {
page: None,
page_size: Some(50),
is_read: None,
priority: None,
business_type: None,
status: None,
};
assert_eq!(query.safe_page_size(), 50);
}
#[test]
fn message_query_safe_page_size_capped_at_100() {
let query = MessageQuery {
page: None,
page_size: Some(200),
is_read: None,
priority: None,
business_type: None,
status: None,
};
assert_eq!(query.safe_page_size(), 100);
}
#[test]
fn message_query_safe_page_size_exactly_100() {
let query = MessageQuery {
page: None,
page_size: Some(100),
is_read: None,
priority: None,
business_type: None,
status: None,
};
assert_eq!(query.safe_page_size(), 100);
}
// ============ CreateTemplateReq 测试 ============
fn valid_create_template_req() -> CreateTemplateReq {
CreateTemplateReq {
name: "欢迎模板".to_string(),
code: "WELCOME".to_string(),
channel: "in_app".to_string(),
title_template: "欢迎加入".to_string(),
body_template: "您好,{{name}},欢迎加入平台".to_string(),
language: "zh-CN".to_string(),
}
}
#[test]
fn create_template_req_valid() {
let req = valid_create_template_req();
assert!(req.validate().is_ok());
}
#[test]
fn create_template_req_empty_name_fails() {
let mut req = valid_create_template_req();
req.name = "".to_string();
assert!(req.validate().is_err());
}
#[test]
fn create_template_req_name_too_long_fails() {
let mut req = valid_create_template_req();
req.name = "x".repeat(101);
assert!(req.validate().is_err());
}
#[test]
fn create_template_req_name_max_length_ok() {
let mut req = valid_create_template_req();
req.name = "x".repeat(100);
assert!(req.validate().is_ok());
}
#[test]
fn create_template_req_empty_code_fails() {
let mut req = valid_create_template_req();
req.code = "".to_string();
assert!(req.validate().is_err());
}
#[test]
fn create_template_req_code_too_long_fails() {
let mut req = valid_create_template_req();
req.code = "X".repeat(51);
assert!(req.validate().is_err());
}
#[test]
fn create_template_req_code_max_length_ok() {
let mut req = valid_create_template_req();
req.code = "X".repeat(50);
assert!(req.validate().is_ok());
}
#[test]
fn create_template_req_valid_channels() {
for ch in &["in_app", "email", "sms", "wechat"] {
let mut req = valid_create_template_req();
req.channel = ch.to_string();
assert!(req.validate().is_ok(), "channel '{}' should be valid", ch);
}
}
#[test]
fn create_template_req_invalid_channel_fails() {
let mut req = valid_create_template_req();
req.channel = "telegram".to_string();
assert!(req.validate().is_err());
}
#[test]
fn create_template_req_empty_title_template_fails() {
let mut req = valid_create_template_req();
req.title_template = "".to_string();
assert!(req.validate().is_err());
}
#[test]
fn create_template_req_title_template_too_long_fails() {
let mut req = valid_create_template_req();
req.title_template = "x".repeat(201);
assert!(req.validate().is_err());
}
#[test]
fn create_template_req_empty_body_template_fails() {
let mut req = valid_create_template_req();
req.body_template = "".to_string();
assert!(req.validate().is_err());
}
#[test]
fn create_template_req_default_channel_is_in_app() {
assert_eq!(default_channel(), "in_app");
}
#[test]
fn create_template_req_default_language_is_zh_cn() {
assert_eq!(default_language(), "zh-CN");
}
// ============ 自定义验证函数测试 ============
#[test]
fn validate_recipient_type_valid() {
for rt in &["user", "role", "department", "all"] {
assert!(
validate_recipient_type(rt).is_ok(),
"'{}' should be a valid recipient type",
rt
);
}
}
#[test]
fn validate_recipient_type_invalid() {
assert!(validate_recipient_type("invalid").is_err());
assert!(validate_recipient_type("").is_err());
assert!(validate_recipient_type("USER").is_err());
}
#[test]
fn validate_priority_valid() {
for p in &["normal", "important", "urgent"] {
assert!(
validate_priority(p).is_ok(),
"'{}' should be a valid priority",
p
);
}
}
#[test]
fn validate_priority_invalid() {
assert!(validate_priority("critical").is_err());
assert!(validate_priority("").is_err());
assert!(validate_priority("NORMAL").is_err());
}
#[test]
fn validate_channel_valid() {
for ch in &["in_app", "email", "sms", "wechat"] {
assert!(
validate_channel(ch).is_ok(),
"'{}' should be a valid channel",
ch
);
}
}
#[test]
fn validate_channel_invalid() {
assert!(validate_channel("slack").is_err());
assert!(validate_channel("").is_err());
assert!(validate_channel("EMAIL").is_err());
}
}

View File

@@ -0,0 +1,58 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "messages")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sender_id: Option<Uuid>,
pub sender_type: String,
pub recipient_id: Uuid,
pub recipient_type: String,
pub title: String,
pub body: String,
pub priority: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub business_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub business_id: Option<Uuid>,
pub is_read: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub read_at: Option<DateTimeUtc>,
pub is_archived: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<DateTimeUtc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sent_at: Option<DateTimeUtc>,
pub status: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::message_template::Entity",
from = "Column::TemplateId",
to = "super::message_template::Column::Id"
)]
MessageTemplate,
}
impl Related<super::message_template::Entity> for Entity {
fn to() -> RelationDef {
Relation::MessageTemplate.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "message_subscriptions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub notification_types: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_preferences: Option<serde_json::Value>,
pub dnd_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub dnd_start: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dnd_end: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,37 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "message_templates")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
pub code: String,
pub channel: String,
pub title_template: String,
pub body_template: String,
pub language: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_by: Uuid,
pub updated_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::message::Entity")]
Message,
}
impl Related<super::message::Entity> for Entity {
fn to() -> RelationDef {
Relation::Message.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,3 @@
pub mod message;
pub mod message_subscription;
pub mod message_template;

View File

@@ -0,0 +1,144 @@
use erp_core::error::AppError;
/// 消息中心模块错误类型。
#[derive(Debug, thiserror::Error)]
pub enum MessageError {
#[error("验证失败: {0}")]
Validation(String),
#[error("未找到: {0}")]
NotFound(String),
#[error("模板编码已存在: {0}")]
DuplicateTemplateCode(String),
#[error("渲染失败: {0}")]
TemplateRenderError(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
}
impl From<MessageError> for AppError {
fn from(err: MessageError) -> Self {
match err {
MessageError::Validation(msg) => AppError::Validation(msg),
MessageError::NotFound(msg) => AppError::NotFound(msg),
MessageError::DuplicateTemplateCode(msg) => AppError::Conflict(msg),
MessageError::TemplateRenderError(msg) => AppError::Internal(msg),
MessageError::VersionMismatch => AppError::VersionMismatch,
}
}
}
impl From<sea_orm::TransactionError<MessageError>> for MessageError {
fn from(err: sea_orm::TransactionError<MessageError>) -> Self {
match err {
sea_orm::TransactionError::Connection(db_err) => {
MessageError::Validation(db_err.to_string())
}
sea_orm::TransactionError::Transaction(msg_err) => msg_err,
}
}
}
pub type MessageResult<T> = Result<T, MessageError>;
#[cfg(test)]
mod tests {
use super::*;
use erp_core::error::AppError;
#[test]
fn validation_maps_to_app_validation() {
let app: AppError = MessageError::Validation("标题不能为空".to_string()).into();
match app {
AppError::Validation(msg) => assert_eq!(msg, "标题不能为空"),
other => panic!("Expected AppError::Validation, got {:?}", other),
}
}
#[test]
fn not_found_maps_to_app_not_found() {
let app: AppError = MessageError::NotFound("消息不存在".to_string()).into();
match app {
AppError::NotFound(msg) => assert_eq!(msg, "消息不存在"),
other => panic!("Expected AppError::NotFound, got {:?}", other),
}
}
#[test]
fn duplicate_template_code_maps_to_app_conflict() {
let app: AppError = MessageError::DuplicateTemplateCode("WELCOME".to_string()).into();
match app {
AppError::Conflict(msg) => assert_eq!(msg, "WELCOME"),
other => panic!("Expected AppError::Conflict, got {:?}", other),
}
}
#[test]
fn template_render_error_maps_to_app_internal() {
let app: AppError = MessageError::TemplateRenderError("变量缺失".to_string()).into();
match app {
AppError::Internal(msg) => assert_eq!(msg, "变量缺失"),
other => panic!("Expected AppError::Internal, got {:?}", other),
}
}
#[test]
fn version_mismatch_maps_to_app_version_mismatch() {
let app: AppError = MessageError::VersionMismatch.into();
match app {
AppError::VersionMismatch => {}
other => panic!("Expected AppError::VersionMismatch, got {:?}", other),
}
}
#[test]
fn error_display_format() {
assert_eq!(
MessageError::Validation("字段为空".to_string()).to_string(),
"验证失败: 字段为空"
);
assert_eq!(
MessageError::NotFound("id=123".to_string()).to_string(),
"未找到: id=123"
);
assert_eq!(
MessageError::DuplicateTemplateCode("CODE".to_string()).to_string(),
"模板编码已存在: CODE"
);
assert_eq!(
MessageError::TemplateRenderError("解析失败".to_string()).to_string(),
"渲染失败: 解析失败"
);
assert_eq!(
MessageError::VersionMismatch.to_string(),
"版本冲突: 数据已被其他操作修改,请刷新后重试"
);
}
#[test]
fn transaction_connection_error_maps_to_validation() {
let db_err = sea_orm::DbErr::Conn(sea_orm::RuntimeErr::Internal("连接超时".to_string()));
let tx_err: sea_orm::TransactionError<MessageError> =
sea_orm::TransactionError::Connection(db_err);
let msg_err: MessageError = tx_err.into();
match msg_err {
MessageError::Validation(msg) => assert!(msg.contains("连接超时")),
other => panic!("期望 Validation得到 {:?}", other),
}
}
#[test]
fn transaction_inner_error_passthrough() {
let inner = MessageError::NotFound("模板不存在".to_string());
let tx_err: sea_orm::TransactionError<MessageError> =
sea_orm::TransactionError::Transaction(inner);
let msg_err: MessageError = tx_err.into();
match msg_err {
MessageError::NotFound(msg) => assert_eq!(msg, "模板不存在"),
other => panic!("期望 NotFound得到 {:?}", other),
}
}
}

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(())))
}

View File

@@ -0,0 +1,10 @@
pub mod dto;
pub mod entity;
pub mod error;
pub mod handler;
pub mod message_state;
pub mod module;
pub mod service;
pub use message_state::MessageState;
pub use module::MessageModule;

View File

@@ -0,0 +1,9 @@
use erp_core::events::EventBus;
use sea_orm::DatabaseConnection;
/// 消息中心模块状态,通过 FromRef 从 AppState 提取。
#[derive(Clone)]
pub struct MessageState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,570 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, FromQueryResult,
PaginatorTrait, QueryFilter, Set, Statement,
};
use uuid::Uuid;
use crate::dto::{MessageQuery, MessageResp, SendMessageReq, UnreadCountResp};
use crate::entity::message;
use crate::error::{MessageError, MessageResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::EventBus;
/// 原始 SQL 查询 user_id 的结果结构体。
#[derive(Debug, FromQueryResult)]
struct UserIdRow {
user_id: Uuid,
}
/// 消息服务。
pub struct MessageService;
impl MessageService {
/// 查询消息列表(分页)。
pub async fn list(
tenant_id: Uuid,
recipient_id: Uuid,
query: &MessageQuery,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<(Vec<MessageResp>, u64)> {
let page_size = query.safe_page_size();
let mut q = message::Entity::find()
.filter(message::Column::TenantId.eq(tenant_id))
.filter(message::Column::RecipientId.eq(recipient_id))
.filter(message::Column::DeletedAt.is_null());
if let Some(is_read) = query.is_read {
q = q.filter(message::Column::IsRead.eq(is_read));
}
if let Some(ref priority) = query.priority {
q = q.filter(message::Column::Priority.eq(priority.as_str()));
}
if let Some(ref business_type) = query.business_type {
q = q.filter(message::Column::BusinessType.eq(business_type.as_str()));
}
if let Some(ref status) = query.status {
q = q.filter(message::Column::Status.eq(status.as_str()));
}
let paginator = q.paginate(db, page_size);
let total = paginator
.num_items()
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
let page_index = query.page.unwrap_or(1).saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
let resps = models.iter().map(Self::model_to_resp).collect();
Ok((resps, total))
}
/// 获取未读消息数量。
pub async fn unread_count(
tenant_id: Uuid,
recipient_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<UnreadCountResp> {
let count = message::Entity::find()
.filter(message::Column::TenantId.eq(tenant_id))
.filter(message::Column::RecipientId.eq(recipient_id))
.filter(message::Column::IsRead.eq(false))
.filter(message::Column::DeletedAt.is_null())
.count(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(UnreadCountResp {
count: count as i64,
})
}
/// 发送消息。
///
/// 根据 `recipient_type` 执行不同的投递策略:
/// - `"user"` — 单条消息,直接投递给 `recipient_id` 指定的用户。
/// - `"role"` — 查询 `user_roles` 表,向该角色下的所有用户批量投递。
/// - `"department"` — 查询 `user_departments` 表,向该部门下的所有用户批量投递。
/// - `"all"` — 查询 `users` 表,向租户内所有活跃用户批量投递。
pub async fn send(
tenant_id: Uuid,
sender_id: Uuid,
req: &SendMessageReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> MessageResult<MessageResp> {
let now = Utc::now();
// Resolve target user IDs based on recipient type
let recipient_user_ids = match req.recipient_type.as_str() {
"user" => vec![req.recipient_id],
"role" => Self::resolve_user_ids_by_role(db, req.recipient_id, tenant_id).await?,
"department" => {
Self::resolve_user_ids_by_department(db, req.recipient_id, tenant_id).await?
}
"all" => Self::resolve_all_active_user_ids(db, tenant_id).await?,
other => {
return Err(MessageError::Validation(format!(
"不支持的收件人类型: {other}"
)));
}
};
if recipient_user_ids.is_empty() {
return Err(MessageError::Validation(
"没有找到符合条件的收件人".to_string(),
));
}
// Build message models for all recipients
let models: Vec<message::ActiveModel> = recipient_user_ids
.iter()
.map(|uid| message::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
template_id: Set(req.template_id),
sender_id: Set(Some(sender_id)),
sender_type: Set("user".to_string()),
recipient_id: Set(*uid),
recipient_type: Set("user".to_string()),
title: Set(req.title.clone()),
body: Set(req.body.clone()),
priority: Set(req.priority.clone()),
business_type: Set(req.business_type.clone()),
business_id: Set(req.business_id),
is_read: Set(false),
read_at: Set(None),
is_archived: Set(false),
archived_at: Set(None),
sent_at: Set(Some(now)),
status: Set("sent".to_string()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(sender_id),
updated_by: Set(sender_id),
deleted_at: Set(None),
version: Set(1),
})
.collect();
// Batch insert all messages
message::Entity::insert_many(models)
.exec(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
// Publish one event per batch (summary event)
event_bus
.publish(
erp_core::events::DomainEvent::new(
"message.sent",
tenant_id,
serde_json::json!({
"recipient_type": req.recipient_type,
"recipient_count": recipient_user_ids.len(),
"title": req.title,
}),
),
db,
)
.await;
audit_service::record(
AuditLog::new(tenant_id, Some(sender_id), "message.send", "message").with_changes(
None,
Some(serde_json::json!({
"recipient_type": req.recipient_type,
"recipient_count": recipient_user_ids.len(),
"title": req.title,
})),
),
db,
)
.await;
// Construct a representative response (no row returned from batch insert)
Ok(MessageResp {
id: Uuid::nil(),
tenant_id,
template_id: req.template_id,
sender_id: Some(sender_id),
sender_type: "user".to_string(),
recipient_id: req.recipient_id,
recipient_type: req.recipient_type.clone(),
title: req.title.clone(),
body: req.body.clone(),
priority: req.priority.clone(),
business_type: req.business_type.clone(),
business_id: req.business_id,
is_read: false,
read_at: None,
is_archived: false,
status: "sent".to_string(),
sent_at: Some(now),
created_at: now,
updated_at: now,
version: 1,
})
}
/// 根据角色 ID 查询关联的用户 ID 列表(跨模块 raw SQL
async fn resolve_user_ids_by_role(
db: &sea_orm::DatabaseConnection,
role_id: Uuid,
tenant_id: Uuid,
) -> MessageResult<Vec<Uuid>> {
let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT user_id FROM user_roles WHERE role_id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
[role_id.into(), tenant_id.into()],
))
.all(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(rows.into_iter().map(|r| r.user_id).collect())
}
/// 根据部门 ID 查询关联的用户 ID 列表(跨模块 raw SQL
async fn resolve_user_ids_by_department(
db: &sea_orm::DatabaseConnection,
department_id: Uuid,
tenant_id: Uuid,
) -> MessageResult<Vec<Uuid>> {
let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT user_id FROM user_departments WHERE department_id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
[department_id.into(), tenant_id.into()],
))
.all(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(rows.into_iter().map(|r| r.user_id).collect())
}
/// 查询租户内所有活跃用户的 ID 列表(跨模块 raw SQL
async fn resolve_all_active_user_ids(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
) -> MessageResult<Vec<Uuid>> {
let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT id AS user_id FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'active'",
[tenant_id.into()],
))
.all(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(rows.into_iter().map(|r| r.user_id).collect())
}
/// 系统发送消息(由事件处理器调用)。
///
/// 幂等保证:当 `business_id` 存在时,若同 tenant + recipient + business_id 的消息已存在,
/// 直接返回已有消息,避免 outbox relay 重放导致重复通知。
#[allow(clippy::too_many_arguments)]
pub async fn send_system(
tenant_id: Uuid,
recipient_id: Uuid,
title: String,
body: String,
priority: &str,
business_type: Option<String>,
business_id: Option<Uuid>,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> MessageResult<MessageResp> {
// 幂等检查:防止 outbox relay 重放导致重复消息
if let Some(bid) = business_id {
let existing = message::Entity::find()
.filter(message::Column::TenantId.eq(tenant_id))
.filter(message::Column::RecipientId.eq(recipient_id))
.filter(message::Column::BusinessId.eq(bid))
.filter(message::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
if let Some(m) = existing {
tracing::debug!(
message_id = %m.id,
business_id = %bid,
"消息已存在,跳过重复创建(幂等保护)"
);
return Ok(Self::model_to_resp(&m));
}
}
let id = Uuid::now_v7();
let now = Utc::now();
let system_user = Uuid::nil();
let model = message::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
template_id: Set(None),
sender_id: Set(None),
sender_type: Set("system".to_string()),
recipient_id: Set(recipient_id),
recipient_type: Set("user".to_string()),
title: Set(title),
body: Set(body),
priority: Set(priority.to_string()),
business_type: Set(business_type),
business_id: Set(business_id),
is_read: Set(false),
read_at: Set(None),
is_archived: Set(false),
archived_at: Set(None),
sent_at: Set(Some(now)),
status: Set("sent".to_string()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user),
updated_by: Set(system_user),
deleted_at: Set(None),
version: Set(1),
};
let inserted = model
.insert(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"message.sent",
tenant_id,
serde_json::json!({
"message_id": id,
"recipient_id": recipient_id,
}),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(system_user),
"message.send_system",
"message",
)
.with_resource_id(id),
db,
)
.await;
Ok(Self::model_to_resp(&inserted))
}
/// 标记消息已读。
pub async fn mark_read(
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<()> {
let model = message::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| MessageError::NotFound(format!("消息不存在: {id}")))?;
if model.recipient_id != user_id {
return Err(MessageError::Validation(
"只能标记自己的消息为已读".to_string(),
));
}
if model.is_read {
return Ok(());
}
let current_version = model.version;
let mut active: message::ActiveModel = model.into();
active.is_read = Set(true);
active.read_at = Set(Some(Utc::now()));
active.version = Set(current_version + 1);
active.updated_at = Set(Utc::now());
active.updated_by = Set(user_id);
active
.update(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "message.mark_read", "message")
.with_resource_id(id),
db,
)
.await;
Ok(())
}
/// 标记所有消息已读(批量 UPDATE避免 N+1
pub async fn mark_all_read(
tenant_id: Uuid,
user_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<()> {
let now = Utc::now();
db.execute(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"UPDATE messages SET is_read = true, read_at = $1, updated_at = $2, updated_by = $3, version = version + 1 WHERE tenant_id = $4 AND recipient_id = $5 AND is_read = false AND deleted_at IS NULL",
[
now.into(),
now.into(),
user_id.into(),
tenant_id.into(),
user_id.into(),
],
))
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "message.mark_all_read", "message"),
db,
)
.await;
Ok(())
}
/// 删除消息(软删除)。
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<()> {
let model = message::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| MessageError::NotFound(format!("消息不存在: {id}")))?;
if model.recipient_id != user_id {
return Err(MessageError::Validation("只能删除自己的消息".to_string()));
}
let current_version = model.version;
let mut active: message::ActiveModel = model.into();
active.version = Set(current_version + 1);
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(user_id);
active
.update(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "message.delete", "message")
.with_resource_id(id),
db,
)
.await;
Ok(())
}
pub(crate) fn model_to_resp(m: &message::Model) -> MessageResp {
MessageResp {
id: m.id,
tenant_id: m.tenant_id,
template_id: m.template_id,
sender_id: m.sender_id,
sender_type: m.sender_type.clone(),
recipient_id: m.recipient_id,
recipient_type: m.recipient_type.clone(),
title: m.title.clone(),
body: m.body.clone(),
priority: m.priority.clone(),
business_type: m.business_type.clone(),
business_id: m.business_id,
is_read: m.is_read,
read_at: m.read_at,
is_archived: m.is_archived,
status: m.status.clone(),
sent_at: m.sent_at,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn sample_model() -> message::Model {
message::Model {
id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
tenant_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
template_id: None,
sender_id: None,
sender_type: "system".to_string(),
recipient_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
recipient_type: "user".to_string(),
title: "测试消息".to_string(),
body: "消息内容".to_string(),
priority: "normal".to_string(),
business_type: None,
business_id: None,
is_read: false,
read_at: None,
is_archived: false,
archived_at: None,
sent_at: None,
status: "sent".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(),
updated_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(),
deleted_at: None,
version: 1,
}
}
#[test]
fn model_to_resp_maps_all_fields() {
let m = sample_model();
let resp = MessageService::model_to_resp(&m);
assert_eq!(resp.id, m.id);
assert_eq!(resp.tenant_id, m.tenant_id);
assert_eq!(resp.title, "测试消息");
assert_eq!(resp.body, "消息内容");
assert_eq!(resp.priority, "normal");
assert_eq!(resp.is_read, false);
assert_eq!(resp.status, "sent");
assert_eq!(resp.version, 1);
}
#[test]
fn model_to_resp_preserves_optional_fields() {
let m = sample_model();
let resp = MessageService::model_to_resp(&m);
assert_eq!(resp.template_id, None);
assert_eq!(resp.sender_id, None);
assert_eq!(resp.business_type, None);
assert_eq!(resp.read_at, None);
assert_eq!(resp.sent_at, None);
}
}

View File

@@ -0,0 +1,3 @@
pub mod message_service;
pub mod subscription_service;
pub mod template_service;

View File

@@ -0,0 +1,155 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{MessageSubscriptionResp, UpdateSubscriptionReq};
use crate::entity::message_subscription;
use crate::error::{MessageError, MessageResult};
use erp_core::error::check_version;
/// 消息订阅偏好服务。
pub struct SubscriptionService;
impl SubscriptionService {
/// 获取用户订阅偏好。
pub async fn get(
tenant_id: Uuid,
user_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<MessageSubscriptionResp> {
let model = message_subscription::Entity::find()
.filter(message_subscription::Column::TenantId.eq(tenant_id))
.filter(message_subscription::Column::UserId.eq(user_id))
.filter(message_subscription::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?
.ok_or_else(|| MessageError::NotFound("订阅偏好不存在".to_string()))?;
Ok(Self::model_to_resp(&model))
}
/// 创建或更新用户订阅偏好upsert
pub async fn upsert(
tenant_id: Uuid,
user_id: Uuid,
req: &UpdateSubscriptionReq,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<MessageSubscriptionResp> {
let existing = message_subscription::Entity::find()
.filter(message_subscription::Column::TenantId.eq(tenant_id))
.filter(message_subscription::Column::UserId.eq(user_id))
.filter(message_subscription::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
let now = Utc::now();
if let Some(model) = existing {
let current_version = model.version;
let next_ver = check_version(req.version, current_version)
.map_err(|_| MessageError::VersionMismatch)?;
let mut active: message_subscription::ActiveModel = model.into();
if let Some(types) = &req.notification_types {
active.notification_types = Set(Some(types.clone()));
}
if let Some(prefs) = &req.channel_preferences {
active.channel_preferences = Set(Some(prefs.clone()));
}
if let Some(dnd) = req.dnd_enabled {
active.dnd_enabled = Set(dnd);
}
if let Some(ref start) = req.dnd_start {
active.dnd_start = Set(Some(start.clone()));
}
if let Some(ref end) = req.dnd_end {
active.dnd_end = Set(Some(end.clone()));
}
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(Self::model_to_resp(&updated))
} else {
let id = Uuid::now_v7();
let model = message_subscription::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
user_id: Set(user_id),
notification_types: Set(req.notification_types.clone()),
channel_preferences: Set(req.channel_preferences.clone()),
dnd_enabled: Set(req.dnd_enabled.unwrap_or(false)),
dnd_start: Set(req.dnd_start.clone()),
dnd_end: Set(req.dnd_end.clone()),
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),
};
let inserted = model
.insert(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(Self::model_to_resp(&inserted))
}
}
pub(crate) fn model_to_resp(m: &message_subscription::Model) -> MessageSubscriptionResp {
MessageSubscriptionResp {
id: m.id,
tenant_id: m.tenant_id,
user_id: m.user_id,
notification_types: m.notification_types.clone(),
channel_preferences: m.channel_preferences.clone(),
dnd_enabled: m.dnd_enabled,
dnd_start: m.dnd_start.clone(),
dnd_end: m.dnd_end.clone(),
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn model_to_resp_maps_all_fields() {
let m = message_subscription::Model {
id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
tenant_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
user_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
notification_types: Some(serde_json::json!(["appointment"])),
channel_preferences: Some(serde_json::json!(["in_app"])),
dnd_enabled: true,
dnd_start: Some("22:00".to_string()),
dnd_end: Some("08:00".to_string()),
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
updated_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
deleted_at: None,
version: 1,
};
let resp = SubscriptionService::model_to_resp(&m);
assert_eq!(resp.user_id, m.user_id);
assert_eq!(resp.dnd_enabled, true);
assert_eq!(resp.dnd_start, Some("22:00".to_string()));
assert_eq!(resp.dnd_end, Some("08:00".to_string()));
assert_eq!(resp.version, 1);
}
}

View File

@@ -0,0 +1,296 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq};
use crate::entity::message_template;
use crate::error::{MessageError, MessageResult};
/// 消息模板服务。
pub struct TemplateService;
impl TemplateService {
/// 查询模板列表。
pub async fn list(
tenant_id: Uuid,
page: u64,
page_size: u64,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<(Vec<MessageTemplateResp>, u64)> {
let paginator = message_template::Entity::find()
.filter(message_template::Column::TenantId.eq(tenant_id))
.filter(message_template::Column::DeletedAt.is_null())
.paginate(db, page_size);
let total = paginator
.num_items()
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
let page_index = page.saturating_sub(1);
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
let resps = models.iter().map(Self::model_to_resp).collect();
Ok((resps, total))
}
/// 创建消息模板。
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateTemplateReq,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<MessageTemplateResp> {
// 检查编码唯一性
let existing = message_template::Entity::find()
.filter(message_template::Column::TenantId.eq(tenant_id))
.filter(message_template::Column::Code.eq(&req.code))
.filter(message_template::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(MessageError::DuplicateTemplateCode(format!(
"模板编码已存在: {}",
req.code
)));
}
let id = Uuid::now_v7();
let now = Utc::now();
let model = message_template::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
channel: Set(req.channel.clone()),
title_template: Set(req.title_template.clone()),
body_template: Set(req.body_template.clone()),
language: Set(req.language.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let inserted = model
.insert(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(Self::model_to_resp(&inserted))
}
/// 更新消息模板。
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &UpdateTemplateReq,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<MessageTemplateResp> {
let model = message_template::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| MessageError::NotFound(format!("模板不存在: {id}")))?;
let current_version = model.version;
let next_ver = erp_core::error::check_version(req.version, current_version)
.map_err(|_| MessageError::VersionMismatch)?;
let mut active: message_template::ActiveModel = model.into();
if let Some(name) = &req.name {
active.name = Set(name.clone());
}
if let Some(title) = &req.title_template {
active.title_template = Set(title.clone());
}
if let Some(body) = &req.body_template {
active.body_template = Set(body.clone());
}
if let Some(lang) = &req.language {
active.language = Set(lang.clone());
}
if let Some(channel) = &req.channel {
active.channel = Set(channel.clone());
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(Self::model_to_resp(&updated))
}
/// 软删除消息模板。
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> MessageResult<()> {
let model = message_template::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or_else(|| MessageError::NotFound(format!("模板不存在: {id}")))?;
let current_version = model.version;
let mut active: message_template::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
.map_err(|e| MessageError::Validation(e.to_string()))?;
Ok(())
}
/// 使用模板渲染消息内容。
/// 支持 {{variable}} 格式的变量插值。
pub fn render(template: &str, variables: &std::collections::HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in variables {
result = result.replace(&format!("{{{{{}}}}}", key), value);
}
result
}
pub(crate) fn model_to_resp(m: &message_template::Model) -> MessageTemplateResp {
MessageTemplateResp {
id: m.id,
tenant_id: m.tenant_id,
name: m.name.clone(),
code: m.code.clone(),
channel: m.channel.clone(),
title_template: m.title_template.clone(),
body_template: m.body_template.clone(),
language: m.language.clone(),
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_replaces_single_variable() {
let mut vars = std::collections::HashMap::new();
vars.insert("name".to_string(), "张三".to_string());
let result = TemplateService::render("您好,{{name}}", &vars);
assert_eq!(result, "您好,张三");
}
#[test]
fn render_replaces_multiple_variables() {
let mut vars = std::collections::HashMap::new();
vars.insert("name".to_string(), "李四".to_string());
vars.insert("code".to_string(), "ORD-001".to_string());
let result = TemplateService::render("{{name}},您的订单 {{code}} 已发货", &vars);
assert_eq!(result, "李四,您的订单 ORD-001 已发货");
}
#[test]
fn render_no_variables_returns_original() {
let vars = std::collections::HashMap::new();
let result = TemplateService::render("没有变量的模板", &vars);
assert_eq!(result, "没有变量的模板");
}
#[test]
fn render_missing_variable_leaves_placeholder() {
let vars = std::collections::HashMap::new();
let result = TemplateService::render("您好,{{name}}", &vars);
assert_eq!(result, "您好,{{name}}");
}
#[test]
fn render_same_variable_multiple_times() {
let mut vars = std::collections::HashMap::new();
vars.insert("user".to_string(), "王五".to_string());
let result = TemplateService::render("{{user}} 你好,{{user}} 的订单已确认", &vars);
assert_eq!(result, "王五 你好,王五 的订单已确认");
}
#[test]
fn render_empty_template() {
let mut vars = std::collections::HashMap::new();
vars.insert("name".to_string(), "test".to_string());
let result = TemplateService::render("", &vars);
assert_eq!(result, "");
}
#[test]
fn render_empty_variable_value() {
let mut vars = std::collections::HashMap::new();
vars.insert("name".to_string(), "".to_string());
let result = TemplateService::render("您好,{{name}}", &vars);
assert_eq!(result, "您好,!");
}
#[test]
fn render_adjacent_variables() {
let mut vars = std::collections::HashMap::new();
vars.insert("a".to_string(), "1".to_string());
vars.insert("b".to_string(), "2".to_string());
let result = TemplateService::render("{{a}}{{b}}", &vars);
assert_eq!(result, "12");
}
#[test]
fn render_extra_variables_not_in_template_are_ignored() {
let mut vars = std::collections::HashMap::new();
vars.insert("name".to_string(), "赵六".to_string());
vars.insert("unused".to_string(), "ignore".to_string());
let result = TemplateService::render("你好 {{name}}", &vars);
assert_eq!(result, "你好 赵六");
}
#[test]
fn model_to_resp_maps_all_fields() {
let m = message_template::Model {
id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
tenant_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
name: "欢迎消息".to_string(),
code: "WELCOME".to_string(),
channel: "in_app".to_string(),
title_template: "欢迎 {{name}}".to_string(),
body_template: "{{name}},欢迎使用".to_string(),
language: "zh-CN".to_string(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
created_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
updated_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
deleted_at: None,
version: 2,
};
let resp = TemplateService::model_to_resp(&m);
assert_eq!(resp.name, "欢迎消息");
assert_eq!(resp.code, "WELCOME");
assert_eq!(resp.channel, "in_app");
assert_eq!(resp.language, "zh-CN");
assert_eq!(resp.version, 2);
}
}