Phase 1 安全热修复: - P0-1: /uploads 文件服务添加 JWT 认证中间件(支持 header + query param) - P0-2: analytics/batch 路由从 public 移到 protected_routes - P0-3: plugin engine SQL 注入修复(format! → 参数化查询) - P0-new: stats_service compute_avg_field 字段白名单 + FLOAT8 类型转换 Phase 2 数据完整性: - P0-4: 组织删除级联检查(添加部门存在性校验) - P0-5: 部门删除级联检查(添加岗位 + 用户存在性校验) - P0-8: workflow on_tenant_deleted 实现 5 实体批量删除 - P0-7: 并行网关 race condition 修复(consumed → completed 原子转换) Phase 3 P1 后端 Bug: - P1-12: plugin host 表名消毒(使用 sanitize_identifier) - P1-10: workflow deprecated 状态转换(published → deprecated) - P1-11: workflow 更新验证条件(nodes/edges 任一变化即验证) - P0-9: 小程序 .gitignore 添加 .env/.env.*/日志 - P1-19: 小程序加密密钥替换为 64 字符强密钥 Phase 4 消息模块: - P1-5: 通知偏好 GET 路由 + handler - P1-4: 消息模板 update/delete CRUD + version - P2-8: mark_all_read SQL 添加 version + 1 - P2-7: markAsRead 改为乐观更新 + 失败回滚 Phase 5 前端修复: - P2-9: 通知面板点击导航到 /messages - P2-1: 随访任务患者名批量 ID 解析(替代 UUID 显示) - P2-5: AppointmentList 分离 patient_id/doctor_id 分别调用 API - P2-17: PluginMarket installed 字段修正(name → id) - P3-3: 路由标题 fallback 改为模式匹配(支持 :id 动态路径) - P2-15: workflow updateDefinition 添加 version 字段 - P3-9: Kanban 版本使用记录实际 version - P2-21: secure-storage 生产环境无密钥时阻止存储 - P3-11: destroyOnHidden → destroyOnClose - P3-13: PendingTasks 深色模式 Tag 颜色适配 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
510 lines
14 KiB
Rust
510 lines
14 KiB
Rust
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>,
|
||
pub language: Option<String>,
|
||
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, ToSchema)]
|
||
pub struct UpdateSubscriptionReq {
|
||
pub notification_types: Option<serde_json::Value>,
|
||
pub channel_preferences: Option<serde_json::Value>,
|
||
pub dnd_enabled: Option<bool>,
|
||
pub dnd_start: Option<String>,
|
||
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());
|
||
}
|
||
}
|