fix: 全系统审计问题修复 — 安全/数据完整性/功能缺陷/UX (Phase 1-5)
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>
This commit is contained in:
@@ -113,6 +113,7 @@ pub struct MessageTemplateResp {
|
||||
pub language: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 创建消息模板请求
|
||||
@@ -148,6 +149,20 @@ 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 ============
|
||||
|
||||
/// 消息订阅偏好响应
|
||||
|
||||
@@ -9,6 +9,29 @@ 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",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use axum::Json;
|
||||
use axum::extract::FromRef;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
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};
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq};
|
||||
use crate::message_state::MessageState;
|
||||
use crate::service::template_service::TemplateService;
|
||||
|
||||
@@ -88,3 +89,52 @@ where
|
||||
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(())))
|
||||
}
|
||||
|
||||
@@ -41,10 +41,15 @@ impl MessageModule {
|
||||
"/message-templates",
|
||||
get(template_handler::list_templates).post(template_handler::create_template),
|
||||
)
|
||||
.route(
|
||||
"/message-templates/{id}",
|
||||
put(template_handler::update_template).delete(template_handler::delete_template),
|
||||
)
|
||||
// 订阅偏好路由
|
||||
.route(
|
||||
"/message-subscriptions",
|
||||
put(subscription_handler::update_subscription),
|
||||
get(subscription_handler::get_subscription)
|
||||
.put(subscription_handler::update_subscription),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ impl MessageService {
|
||||
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 WHERE tenant_id = $4 AND recipient_id = $5 AND is_read = false AND deleted_at IS NULL",
|
||||
"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(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp};
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq};
|
||||
use crate::entity::message_template;
|
||||
use crate::error::{MessageError, MessageResult};
|
||||
|
||||
@@ -88,6 +88,82 @@ impl TemplateService {
|
||||
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 {
|
||||
@@ -110,6 +186,7 @@ impl TemplateService {
|
||||
language: m.language.clone(),
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
version: m.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user