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>
271 lines
9.3 KiB
Rust
271 lines
9.3 KiB
Rust
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
|
||
}
|
||
|
||
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, "你好 赵六");
|
||
}
|
||
}
|