diff --git a/crates/erp-health/src/dto/banner_dto.rs b/crates/erp-health/src/dto/banner_dto.rs new file mode 100644 index 0000000..f1620ec --- /dev/null +++ b/crates/erp-health/src/dto/banner_dto.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use erp_core::sanitize::{sanitize_option, sanitize_string}; + +// --------------------------------------------------------------------------- +// 轮播图 DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct BannerResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub media_item_id: Uuid, + pub title: Option, + pub subtitle: Option, + pub link_type: Option, + pub link_target: Option, + pub sort_order: i32, + pub status: String, + pub start_time: Option>, + pub end_time: Option>, + /// 媒体文件访问 URL + pub image_url: Option, + /// 缩略图 URL + pub thumbnail_url: Option, + /// 关联的媒体文件是否已被删除 + pub media_deleted: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub created_by: Option, + pub updated_by: Option, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateBannerReq { + pub media_item_id: Uuid, + pub title: Option, + pub subtitle: Option, + pub link_type: Option, + pub link_target: Option, + #[serde(default)] + pub sort_order: i32, + #[serde(default = "default_active")] + pub status: String, + pub start_time: Option>, + pub end_time: Option>, +} + +fn default_active() -> String { + "active".to_string() +} + +impl CreateBannerReq { + pub fn sanitize(&mut self) { + self.title = sanitize_option(self.title.take()); + self.subtitle = sanitize_option(self.subtitle.take()); + if let Some(ref mut v) = self.link_type { + *v = sanitize_string(v); + } + self.link_target = sanitize_option(self.link_target.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateBannerReq { + pub media_item_id: Option, + pub title: Option, + pub subtitle: Option, + pub link_type: Option, + pub link_target: Option, + pub sort_order: Option, + pub status: Option, + pub start_time: Option>, + pub end_time: Option>, + pub version: i32, +} + +impl UpdateBannerReq { + pub fn sanitize(&mut self) { + self.title = sanitize_option(self.title.take()); + self.subtitle = sanitize_option(self.subtitle.take()); + if let Some(ref mut v) = self.link_type { + *v = sanitize_string(v); + } + self.link_target = sanitize_option(self.link_target.take()); + } +} + +/// 轮播图排序请求 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SortBannerReq { + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SortItem { + pub id: Uuid, + pub sort_order: i32, +} + +/// 小程序端公开轮播图(精简字段) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PublicBannerResp { + pub id: Uuid, + pub title: Option, + pub subtitle: Option, + pub image_url: Option, + pub link_type: Option, + pub link_target: Option, +} diff --git a/crates/erp-health/src/dto/media_dto.rs b/crates/erp-health/src/dto/media_dto.rs new file mode 100644 index 0000000..f9720a6 --- /dev/null +++ b/crates/erp-health/src/dto/media_dto.rs @@ -0,0 +1,132 @@ +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use erp_core::sanitize::{sanitize_option, sanitize_string}; + +// --------------------------------------------------------------------------- +// 媒体文件 DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct MediaItemResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub folder_id: Option, + pub filename: String, + pub storage_path: String, + pub thumbnail_path: Option, + pub content_type: String, + pub file_size: i64, + pub width: Option, + pub height: Option, + pub alt_text: Option, + pub is_public: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub created_by: Option, + pub updated_by: Option, + pub version: i32, +} + +#[derive(Debug, Clone, Deserialize, IntoParams)] +pub struct MediaListParams { + pub page: Option, + pub page_size: Option, + pub folder_id: Option, + /// 按内容类型筛选(如 image/png) + pub content_type: Option, + /// 仅公开资源 + pub is_public: Option, + /// 关键词搜索(文件名模糊匹配) + pub keyword: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateMediaItemReq { + pub filename: Option, + pub alt_text: Option, + pub is_public: Option, + pub folder_id: Option, + pub version: i32, +} + +impl UpdateMediaItemReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.filename { + *v = sanitize_string(v); + } + self.alt_text = sanitize_option(self.alt_text.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct MoveMediaReq { + /// 目标文件夹 ID(None 表示移到根目录) + pub folder_id: Option, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct BatchDeleteReq { + pub ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CropReq { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub version: i32, +} + +// --------------------------------------------------------------------------- +// 文件夹 DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FolderResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub parent_id: Option, + pub sort_order: i32, + /// 子文件夹 + pub children: Vec, + /// 文件夹内文件数量 + pub item_count: i64, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateFolderReq { + pub name: String, + pub parent_id: Option, + #[serde(default)] + pub sort_order: i32, +} + +impl CreateFolderReq { + pub fn sanitize(&mut self) { + self.name = sanitize_string(&self.name); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateFolderReq { + pub name: Option, + pub parent_id: Option, + pub sort_order: Option, + pub version: i32, +} + +impl UpdateFolderReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.name { + *v = sanitize_string(v); + } + } +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index c6f94be..070395d 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -1,6 +1,7 @@ pub mod alert_dto; pub mod appointment_dto; pub mod article_dto; +pub mod banner_dto; pub mod ble_gateway_dto; pub mod care_plan_dto; pub mod consent_dto; @@ -11,6 +12,7 @@ pub mod doctor_dto; pub mod follow_up_dto; pub mod follow_up_template_dto; pub mod health_data_dto; +pub mod media_dto; pub mod medication_record_dto; pub mod medication_reminder_dto; pub mod patient_dto; diff --git a/crates/erp-health/src/service/media_service.rs b/crates/erp-health/src/service/media_service.rs new file mode 100644 index 0000000..4768fc7 --- /dev/null +++ b/crates/erp-health/src/service/media_service.rs @@ -0,0 +1,781 @@ +//! 媒体库 Service — 文件上传/CRUD + 缩略图/裁剪 + 文件夹管理 + +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; + +use crate::dto::media_dto::*; +use crate::entity::{banner, media_folder, media_item}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 媒体文件查询 +// --------------------------------------------------------------------------- + +/// 分页查询媒体文件,支持按文件夹/类型/关键词/公开状态筛选 +pub async fn list_media_items( + state: &HealthState, + tenant_id: Uuid, + params: MediaListParams, +) -> HealthResult> { + let page = params.page.unwrap_or(1).max(1); + let limit = params.page_size.unwrap_or(20).min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = media_item::Entity::find() + .filter(media_item::Column::TenantId.eq(tenant_id)) + .filter(media_item::Column::DeletedAt.is_null()); + + if let Some(fid) = params.folder_id { + query = query.filter(media_item::Column::FolderId.eq(fid)); + } + if let Some(ref ct) = params.content_type { + query = query.filter(media_item::Column::ContentType.contains(ct)); + } + if let Some(pub_flag) = params.is_public { + query = query.filter(media_item::Column::IsPublic.eq(pub_flag)); + } + if let Some(ref kw) = params.keyword { + query = query.filter(media_item::Column::Filename.contains(kw)); + } + + let total = query.clone().count(&state.db).await?; + + let models = query + .order_by_desc(media_item::Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data: Vec = models.into_iter().map(model_to_resp).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size: limit, + total_pages, + }) +} + +/// 获取单个媒体文件详情 +pub async fn get_media_item( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + let model = find_media_item(state, tenant_id, id).await?; + Ok(model_to_resp(model)) +} + +// --------------------------------------------------------------------------- +// 文件上传 +// --------------------------------------------------------------------------- + +/// 上传媒体文件:保存到磁盘 → 生成缩略图 → 创建 DB 记录 +#[allow(clippy::too_many_arguments)] +pub async fn upload_media( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + file_data: &[u8], + filename: &str, + content_type: &str, + folder_id: Option, + is_public: bool, + upload_dir: &str, +) -> HealthResult { + let id = Uuid::now_v7(); + let now = Utc::now(); + + // 构造存储路径: {upload_dir}/{tenant_id}/{uuid}.ext + let ext = Path::new(filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("bin"); + let relative_path = format!( + "{}/{}/{}.{}", + upload_dir.trim_end_matches('/'), + tenant_id, + id, + ext + ); + let storage_path = PathBuf::from(&relative_path); + + // 确保目录存在 + if let Some(parent) = storage_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| HealthError::Validation(format!("创建上传目录失败: {}", e)))?; + } + + // 写入文件 + tokio::fs::write(&storage_path, file_data) + .await + .map_err(|e| HealthError::Validation(format!("文件写入失败: {}", e)))?; + + // 读取图片尺寸 + let (width, height) = read_image_dimensions(&storage_path); + + // 生成缩略图 + let thumbnail_path = generate_thumbnail_path(&storage_path); + if let Some(thumb_dst) = &thumbnail_path { + if let Err(e) = std::fs::create_dir_all(thumb_dst.parent().unwrap_or(Path::new("."))) { + tracing::warn!("创建缩略图目录失败: {}", e); + } + if let Err(e) = generate_thumbnail(&storage_path, thumb_dst, 200) { + tracing::warn!("生成缩略图失败 (非致命): {}", e); + } + } + + let model = media_item::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + folder_id: Set(folder_id), + filename: Set(filename.to_string()), + storage_path: Set(relative_path.clone()), + thumbnail_path: Set(thumbnail_path.map(|p| p.to_string_lossy().to_string())), + content_type: Set(content_type.to_string()), + file_size: Set(file_data.len() as i64), + width: Set(width), + height: Set(height), + alt_text: Set(None), + is_public: Set(is_public), + 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 m = model.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "media_item.created", "media_item") + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(model_to_resp(m)) +} + +// --------------------------------------------------------------------------- +// 媒体文件修改 +// --------------------------------------------------------------------------- + +/// 更新媒体文件元数据(文件名/alt/公开状态/文件夹),带乐观锁 +pub async fn update_media_item( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: UpdateMediaItemReq, +) -> HealthResult { + let model = find_media_item(state, tenant_id, id).await?; + let next_ver = + check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?; + + let mut active: media_item::ActiveModel = model.into(); + if let Some(v) = req.filename { + active.filename = Set(v); + } + if let Some(v) = req.alt_text { + active.alt_text = Set(Some(v)); + } + if let Some(v) = req.is_public { + active.is_public = Set(v); + } + if let Some(v) = req.folder_id { + active.folder_id = Set(Some(v)); + } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "media_item.updated", "media_item") + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(model_to_resp(m)) +} + +/// 软删除媒体文件:标记 deleted_at + 级联停用关联 banner + 删除磁盘文件 +pub async fn delete_media_item( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let model = find_media_item(state, tenant_id, id).await?; + let next_ver = + check_version(version, model.version).map_err(|_| HealthError::VersionMismatch)?; + + // 软删除 + let mut active: media_item::ActiveModel = model.clone().into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + // 级联:关联的 banner 全部设为 inactive + banner::Entity::update_many() + .col_expr(banner::Column::Status, Expr::value("inactive")) + .filter(banner::Column::MediaItemId.eq(id)) + .filter(banner::Column::TenantId.eq(tenant_id)) + .exec(&state.db) + .await?; + + // 删除磁盘文件(忽略文件不存在的错误) + let storage = PathBuf::from(&model.storage_path); + if storage.exists() { + let _ = tokio::fs::remove_file(&storage).await; + } + if let Some(ref thumb) = model.thumbnail_path { + let thumb_path = PathBuf::from(thumb); + if thumb_path.exists() { + let _ = tokio::fs::remove_file(&thumb_path).await; + } + } + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "media_item.deleted", "media_item") + .with_resource_id(id), + &state.db, + ) + .await; + + Ok(()) +} + +/// 批量软删除媒体文件 +pub async fn batch_delete( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: BatchDeleteReq, +) -> HealthResult<()> { + if req.ids.is_empty() { + return Ok(()); + } + + let now = Utc::now(); + + media_item::Entity::update_many() + .col_expr(media_item::Column::DeletedAt, Expr::value(Some(now))) + .col_expr(media_item::Column::UpdatedAt, Expr::value(now)) + .col_expr(media_item::Column::UpdatedBy, Expr::value(operator_id)) + .filter(media_item::Column::Id.is_in(req.ids.clone())) + .filter(media_item::Column::TenantId.eq(tenant_id)) + .filter(media_item::Column::DeletedAt.is_null()) + .exec(&state.db) + .await?; + + // 级联:停用关联 banner + banner::Entity::update_many() + .col_expr(banner::Column::Status, Expr::value("inactive")) + .filter(banner::Column::MediaItemId.is_in(req.ids.clone())) + .filter(banner::Column::TenantId.eq(tenant_id)) + .exec(&state.db) + .await?; + + audit_service::record( + AuditLog::new( + tenant_id, + operator_id, + "media_item.batch_deleted", + "media_item", + ), + &state.db, + ) + .await; + + Ok(()) +} + +/// 移动媒体文件到指定文件夹(None 表示根目录) +pub async fn move_media( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: MoveMediaReq, +) -> HealthResult { + let model = find_media_item(state, tenant_id, id).await?; + let next_ver = + check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?; + + let mut active: media_item::ActiveModel = model.into(); + active.folder_id = Set(req.folder_id); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "media_item.moved", "media_item") + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(model_to_resp(m)) +} + +/// 裁剪图片并重新生成缩略图 +pub async fn crop_media( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: CropReq, +) -> HealthResult { + let model = find_media_item(state, tenant_id, id).await?; + let next_ver = + check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?; + + // 裁剪图片 + let src_path = PathBuf::from(&model.storage_path); + let cropped_id = Uuid::now_v7(); + let ext = src_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("png"); + let cropped_relative = format!("uploads/{}/{}.{}", tenant_id, cropped_id, ext); + let cropped_path = PathBuf::from(&cropped_relative); + + if let Some(parent) = cropped_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| HealthError::Validation(format!("创建目录失败: {}", e)))?; + } + + crop_image( + &src_path, + &cropped_path, + req.x, + req.y, + req.width, + req.height, + )?; + + // 更新尺寸 + let (new_width, new_height) = read_image_dimensions(&cropped_path); + + // 重新生成缩略图 + let thumbnail_path = generate_thumbnail_path(&cropped_path); + if let Some(thumb_dst) = &thumbnail_path { + if let Err(e) = std::fs::create_dir_all(thumb_dst.parent().unwrap_or(Path::new("."))) { + tracing::warn!("创建缩略图目录失败: {}", e); + } + if let Err(e) = generate_thumbnail(&cropped_path, thumb_dst, 200) { + tracing::warn!("生成缩略图失败 (非致命): {}", e); + } + } + + let mut active: media_item::ActiveModel = model.into(); + active.storage_path = Set(cropped_relative); + active.thumbnail_path = Set(thumbnail_path.map(|p| p.to_string_lossy().to_string())); + active.width = Set(new_width); + active.height = Set(new_height); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "media_item.cropped", "media_item") + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(model_to_resp(m)) +} + +// --------------------------------------------------------------------------- +// 图片处理工具函数(同步) +// --------------------------------------------------------------------------- + +/// 中心裁剪 + 缩放生成缩略图 +pub fn generate_thumbnail(src: &Path, dst: &Path, size: u32) -> HealthResult<()> { + let mut img = image::open(src).map_err(|e| HealthError::Validation(e.to_string()))?; + let (w, h) = (img.width(), img.height()); + let crop_size = w.min(h); + let x = (w - crop_size) / 2; + let y = (h - crop_size) / 2; + let thumb = img.crop(x, y, crop_size, crop_size).resize_exact( + size, + size, + image::imageops::FilterType::Lanczos3, + ); + thumb + .save(dst) + .map_err(|e| HealthError::Validation(e.to_string()))?; + Ok(()) +} + +/// 按指定区域裁剪图片 +pub fn crop_image(src: &Path, dst: &Path, x: u32, y: u32, w: u32, h: u32) -> HealthResult<()> { + let mut img = image::open(src).map_err(|e| HealthError::Validation(e.to_string()))?; + let cropped = img.crop(x, y, w, h); + cropped + .save(dst) + .map_err(|e| HealthError::Validation(e.to_string()))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// 文件夹管理 +// --------------------------------------------------------------------------- + +/// 获取文件夹树形结构(含每个文件夹的文件数量) +pub async fn list_folders(state: &HealthState, tenant_id: Uuid) -> HealthResult> { + // 查询所有未删除文件夹 + let folders = media_folder::Entity::find() + .filter(media_folder::Column::TenantId.eq(tenant_id)) + .filter(media_folder::Column::DeletedAt.is_null()) + .order_by_asc(media_folder::Column::SortOrder) + .order_by_asc(media_folder::Column::CreatedAt) + .all(&state.db) + .await?; + + // 统计每个文件夹的文件数 + let folder_ids: Vec = folders.iter().map(|f| f.id).collect(); + let mut count_map: std::collections::HashMap = std::collections::HashMap::new(); + + if !folder_ids.is_empty() { + // 逐文件夹计数(SeaORM 没有 GROUP BY count 的便捷方法,用子查询方式) + for fid in &folder_ids { + let count = media_item::Entity::find() + .filter(media_item::Column::TenantId.eq(tenant_id)) + .filter(media_item::Column::FolderId.eq(*fid)) + .filter(media_item::Column::DeletedAt.is_null()) + .count(&state.db) + .await?; + count_map.insert(*fid, count as i64); + } + } + + // 构建扁平列表 + let flat: Vec = folders + .into_iter() + .map(|f| FolderResp { + id: f.id, + tenant_id: f.tenant_id, + name: f.name, + parent_id: f.parent_id, + sort_order: f.sort_order, + children: vec![], + item_count: count_map.get(&f.id).copied().unwrap_or(0), + created_at: f.created_at, + updated_at: f.updated_at, + version: f.version, + }) + .collect(); + + // 组装树形结构 + Ok(build_folder_tree(flat)) +} + +/// 创建文件夹 +pub async fn create_folder( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateFolderReq, +) -> HealthResult { + let id = Uuid::now_v7(); + let now = Utc::now(); + + let model = media_folder::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name), + parent_id: Set(req.parent_id), + sort_order: Set(req.sort_order), + 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 m = model.insert(&state.db).await?; + + audit_service::record( + AuditLog::new( + tenant_id, + operator_id, + "media_folder.created", + "media_folder", + ) + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(FolderResp { + id: m.id, + tenant_id: m.tenant_id, + name: m.name, + parent_id: m.parent_id, + sort_order: m.sort_order, + children: vec![], + item_count: 0, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + }) +} + +/// 更新文件夹信息(名称/父级/排序),带乐观锁 +pub async fn update_folder( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: UpdateFolderReq, +) -> HealthResult { + let model = find_media_folder(state, tenant_id, id).await?; + let next_ver = + check_version(req.version, model.version).map_err(|_| HealthError::VersionMismatch)?; + + let mut active: media_folder::ActiveModel = model.into(); + if let Some(v) = req.name { + active.name = Set(v); + } + if let Some(v) = req.parent_id { + active.parent_id = Set(Some(v)); + } + if let Some(v) = req.sort_order { + active.sort_order = Set(v); + } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new( + tenant_id, + operator_id, + "media_folder.updated", + "media_folder", + ) + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(FolderResp { + id: m.id, + tenant_id: m.tenant_id, + name: m.name, + parent_id: m.parent_id, + sort_order: m.sort_order, + children: vec![], + item_count: 0, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + }) +} + +/// 删除文件夹(仅当文件夹为空时允许删除) +pub async fn delete_folder( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let model = find_media_folder(state, tenant_id, id).await?; + let next_ver = + check_version(version, model.version).map_err(|_| HealthError::VersionMismatch)?; + + // 检查是否包含文件 + let item_count = media_item::Entity::find() + .filter(media_item::Column::TenantId.eq(tenant_id)) + .filter(media_item::Column::FolderId.eq(id)) + .filter(media_item::Column::DeletedAt.is_null()) + .count(&state.db) + .await?; + + if item_count > 0 { + return Err(HealthError::Validation( + "文件夹内仍有文件,无法删除".to_string(), + )); + } + + // 检查是否包含子文件夹 + let child_count = media_folder::Entity::find() + .filter(media_folder::Column::TenantId.eq(tenant_id)) + .filter(media_folder::Column::ParentId.eq(id)) + .filter(media_folder::Column::DeletedAt.is_null()) + .count(&state.db) + .await?; + + if child_count > 0 { + return Err(HealthError::Validation( + "文件夹内仍有子文件夹,无法删除".to_string(), + )); + } + + // 软删除 + let mut active: media_folder::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(next_ver); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new( + tenant_id, + operator_id, + "media_folder.deleted", + "media_folder", + ) + .with_resource_id(id), + &state.db, + ) + .await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 内部辅助函数 +// --------------------------------------------------------------------------- + +/// 查找媒体文件(未删除) +async fn find_media_item( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + media_item::Entity::find() + .filter(media_item::Column::Id.eq(id)) + .filter(media_item::Column::TenantId.eq(tenant_id)) + .filter(media_item::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::MediaNotFound) +} + +/// 查找文件夹(未删除) +async fn find_media_folder( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + media_folder::Entity::find() + .filter(media_folder::Column::Id.eq(id)) + .filter(media_folder::Column::TenantId.eq(tenant_id)) + .filter(media_folder::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::MediaFolderNotFound) +} + +/// 将实体模型转换为 DTO 响应 +fn model_to_resp(m: media_item::Model) -> MediaItemResp { + MediaItemResp { + id: m.id, + tenant_id: m.tenant_id, + folder_id: m.folder_id, + filename: m.filename, + storage_path: m.storage_path, + thumbnail_path: m.thumbnail_path, + content_type: m.content_type, + file_size: m.file_size, + width: m.width, + height: m.height, + alt_text: m.alt_text, + is_public: m.is_public, + created_at: m.created_at, + updated_at: m.updated_at, + created_by: m.created_by, + updated_by: m.updated_by, + version: m.version, + } +} + +/// 读取图片文件的宽高(非图片或读取失败返回 None) +fn read_image_dimensions(path: &Path) -> (Option, Option) { + image::open(path) + .map(|img| (Some(img.width() as i32), Some(img.height() as i32))) + .unwrap_or((None, None)) +} + +/// 根据存储路径推导缩略图路径(同目录下加 _thumb 后缀) +fn generate_thumbnail_path(storage_path: &Path) -> Option { + let stem = storage_path.file_stem()?.to_str()?; + let ext = storage_path.extension()?.to_str()?; + let parent = storage_path.parent()?; + Some(parent.join(format!("{}_thumb.{}", stem, ext))) +} + +/// 将扁平文件夹列表组装成树形结构 +fn build_folder_tree(flat: Vec) -> Vec { + use std::collections::HashMap; + + // id -> (parent_id, index in flat) + let mut by_id: HashMap, usize)> = HashMap::new(); + for (i, f) in flat.iter().enumerate() { + by_id.insert(f.id, (f.parent_id, i)); + } + + // 收集根节点 + let mut root_ids: Vec = Vec::new(); + for f in &flat { + match f.parent_id { + None => root_ids.push(f.id), + Some(pid) if !by_id.contains_key(&pid) => root_ids.push(f.id), + _ => {} + } + } + + // 递归构建 + fn build_children(parent_id: Uuid, flat: &[FolderResp]) -> Vec { + let mut children: Vec = Vec::new(); + for f in flat { + if f.parent_id == Some(parent_id) { + let mut node = f.clone(); + node.children = build_children(f.id, flat); + children.push(node); + } + } + children.sort_by_key(|c| c.sort_order); + children + } + + let mut result: Vec = Vec::new(); + for rid in root_ids { + if let Some(&(_, idx)) = by_id.get(&rid) { + let mut node = flat[idx].clone(); + node.children = build_children(rid, &flat); + result.push(node); + } + } + result.sort_by_key(|c| c.sort_order); + result +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 17bc2b5..f2d911e 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -25,6 +25,7 @@ pub mod follow_up_service; pub mod follow_up_template_service; pub mod health_data_service; pub mod masking; +pub mod media_service; pub mod medication_record_service; pub mod medication_reminder_service; pub mod patient_service; diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 4988c0c..189e2d1 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -135,6 +135,9 @@ mod m20260508_000130_fix_operator_permissions_and_nurse_devices; mod m20260508_000131_fix_all_role_permissions; mod m20260508_000132_fix_doctor_permissions_restore; mod m20260510_000133_create_patient_role; +mod m20260510_000134_create_media_folder; +mod m20260510_000135_create_media_item; +mod m20260510_000136_create_banner; pub struct Migrator; @@ -277,6 +280,9 @@ impl MigratorTrait for Migrator { Box::new(m20260508_000131_fix_all_role_permissions::Migration), Box::new(m20260508_000132_fix_doctor_permissions_restore::Migration), Box::new(m20260510_000133_create_patient_role::Migration), + Box::new(m20260510_000134_create_media_folder::Migration), + Box::new(m20260510_000135_create_media_item::Migration), + Box::new(m20260510_000136_create_banner::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260510_000134_create_media_folder.rs b/crates/erp-server/migration/src/m20260510_000134_create_media_folder.rs new file mode 100644 index 0000000..0279371 --- /dev/null +++ b/crates/erp-server/migration/src/m20260510_000134_create_media_folder.rs @@ -0,0 +1,99 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(MediaFolder::Table) + .col( + ColumnDef::new(MediaFolder::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(MediaFolder::TenantId).uuid().not_null()) + .col(ColumnDef::new(MediaFolder::Name).string_len(100).not_null()) + .col(ColumnDef::new(MediaFolder::ParentId).uuid().null()) + .col( + ColumnDef::new(MediaFolder::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(MediaFolder::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(MediaFolder::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(MediaFolder::CreatedBy).uuid().null()) + .col(ColumnDef::new(MediaFolder::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(MediaFolder::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(MediaFolder::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .name("fk_media_folder_parent") + .from(MediaFolder::Table, MediaFolder::ParentId) + .to(MediaFolder::Table, MediaFolder::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_media_folder_tenant_parent") + .table(MediaFolder::Table) + .col(MediaFolder::TenantId) + .col(MediaFolder::ParentId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MediaFolder::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum MediaFolder { + Table, + Id, + TenantId, + Name, + ParentId, + SortOrder, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260510_000135_create_media_item.rs b/crates/erp-server/migration/src/m20260510_000135_create_media_item.rs new file mode 100644 index 0000000..9860e96 --- /dev/null +++ b/crates/erp-server/migration/src/m20260510_000135_create_media_item.rs @@ -0,0 +1,148 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(MediaItem::Table) + .col( + ColumnDef::new(MediaItem::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(MediaItem::TenantId).uuid().not_null()) + .col(ColumnDef::new(MediaItem::FolderId).uuid().null()) + .col( + ColumnDef::new(MediaItem::Filename) + .string_len(255) + .not_null(), + ) + .col( + ColumnDef::new(MediaItem::StoragePath) + .string_len(500) + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(MediaItem::ThumbnailPath) + .string_len(500) + .null(), + ) + .col( + ColumnDef::new(MediaItem::ContentType) + .string_len(100) + .not_null(), + ) + .col(ColumnDef::new(MediaItem::FileSize).big_integer().not_null()) + .col(ColumnDef::new(MediaItem::Width).integer().null()) + .col(ColumnDef::new(MediaItem::Height).integer().null()) + .col(ColumnDef::new(MediaItem::AltText).string_len(255).null()) + .col( + ColumnDef::new(MediaItem::IsPublic) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(MediaItem::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(MediaItem::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(MediaItem::CreatedBy).uuid().null()) + .col(ColumnDef::new(MediaItem::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(MediaItem::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(MediaItem::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .name("fk_media_item_folder") + .from(MediaItem::Table, MediaItem::FolderId) + .to(MediaFolderRef::Table, MediaFolderRef::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_media_item_tenant_folder") + .table(MediaItem::Table) + .col(MediaItem::TenantId) + .col(MediaItem::FolderId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_media_item_tenant_public") + .table(MediaItem::Table) + .col(MediaItem::TenantId) + .col(MediaItem::IsPublic) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MediaItem::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum MediaItem { + Table, + Id, + TenantId, + FolderId, + Filename, + StoragePath, + ThumbnailPath, + ContentType, + FileSize, + Width, + Height, + AltText, + IsPublic, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +/// 外键引用 media_folder 表 +#[derive(DeriveIden)] +enum MediaFolderRef { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260510_000136_create_banner.rs b/crates/erp-server/migration/src/m20260510_000136_create_banner.rs new file mode 100644 index 0000000..ed5a564 --- /dev/null +++ b/crates/erp-server/migration/src/m20260510_000136_create_banner.rs @@ -0,0 +1,136 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Banner::Table) + .col(ColumnDef::new(Banner::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Banner::TenantId).uuid().not_null()) + .col(ColumnDef::new(Banner::MediaItemId).uuid().not_null()) + .col(ColumnDef::new(Banner::Title).string_len(100).null()) + .col(ColumnDef::new(Banner::Subtitle).string_len(255).null()) + .col(ColumnDef::new(Banner::LinkType).string_len(20).null()) + .col(ColumnDef::new(Banner::LinkTarget).string_len(500).null()) + .col( + ColumnDef::new(Banner::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Banner::Status) + .string_len(20) + .not_null() + .default("active"), + ) + .col( + ColumnDef::new(Banner::StartTime) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Banner::EndTime) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Banner::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Banner::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Banner::CreatedBy).uuid().null()) + .col(ColumnDef::new(Banner::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(Banner::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Banner::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .name("fk_banner_media_item") + .from(Banner::Table, Banner::MediaItemId) + .to(MediaItemRef::Table, MediaItemRef::Id) + .on_delete(ForeignKeyAction::Restrict), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_banner_tenant_status") + .table(Banner::Table) + .col(Banner::TenantId) + .col(Banner::Status) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_banner_sort") + .table(Banner::Table) + .col(Banner::SortOrder) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Banner::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Banner { + Table, + Id, + TenantId, + MediaItemId, + Title, + Subtitle, + LinkType, + LinkTarget, + SortOrder, + Status, + StartTime, + EndTime, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +/// 外键引用 media_item 表 +#[derive(DeriveIden)] +enum MediaItemRef { + Table, + Id, +}