feat(health): 实现媒体库 service — CRUD + 缩略图 + 裁剪

This commit is contained in:
iven
2026-05-10 15:08:26 +08:00
parent 603a986281
commit 601d977438
9 changed files with 1418 additions and 0 deletions

View File

@@ -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<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
pub sort_order: i32,
pub status: String,
pub start_time: Option<chrono::DateTime<chrono::Utc>>,
pub end_time: Option<chrono::DateTime<chrono::Utc>>,
/// 媒体文件访问 URL
pub image_url: Option<String>,
/// 缩略图 URL
pub thumbnail_url: Option<String>,
/// 关联的媒体文件是否已被删除
pub media_deleted: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateBannerReq {
pub media_item_id: Uuid,
pub title: Option<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
#[serde(default)]
pub sort_order: i32,
#[serde(default = "default_active")]
pub status: String,
pub start_time: Option<chrono::DateTime<chrono::Utc>>,
pub end_time: Option<chrono::DateTime<chrono::Utc>>,
}
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<Uuid>,
pub title: Option<String>,
pub subtitle: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
pub sort_order: Option<i32>,
pub status: Option<String>,
pub start_time: Option<chrono::DateTime<chrono::Utc>>,
pub end_time: Option<chrono::DateTime<chrono::Utc>>,
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<SortItem>,
}
#[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<String>,
pub subtitle: Option<String>,
pub image_url: Option<String>,
pub link_type: Option<String>,
pub link_target: Option<String>,
}

View File

@@ -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<Uuid>,
pub filename: String,
pub storage_path: String,
pub thumbnail_path: Option<String>,
pub content_type: String,
pub file_size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub alt_text: Option<String>,
pub is_public: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub created_by: Option<Uuid>,
pub updated_by: Option<Uuid>,
pub version: i32,
}
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct MediaListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub folder_id: Option<Uuid>,
/// 按内容类型筛选(如 image/png
pub content_type: Option<String>,
/// 仅公开资源
pub is_public: Option<bool>,
/// 关键词搜索(文件名模糊匹配)
pub keyword: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateMediaItemReq {
pub filename: Option<String>,
pub alt_text: Option<String>,
pub is_public: Option<bool>,
pub folder_id: Option<Uuid>,
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 {
/// 目标文件夹 IDNone 表示移到根目录)
pub folder_id: Option<Uuid>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchDeleteReq {
pub ids: Vec<Uuid>,
}
#[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<Uuid>,
pub sort_order: i32,
/// 子文件夹
pub children: Vec<FolderResp>,
/// 文件夹内文件数量
pub item_count: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateFolderReq {
pub name: String,
pub parent_id: Option<Uuid>,
#[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<String>,
pub parent_id: Option<Uuid>,
pub sort_order: Option<i32>,
pub version: i32,
}
impl UpdateFolderReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name {
*v = sanitize_string(v);
}
}
}

View File

@@ -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;

View File

@@ -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<PaginatedResponse<MediaItemResp>> {
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<MediaItemResp> = 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<MediaItemResp> {
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<Uuid>,
file_data: &[u8],
filename: &str,
content_type: &str,
folder_id: Option<Uuid>,
is_public: bool,
upload_dir: &str,
) -> HealthResult<MediaItemResp> {
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<Uuid>,
req: UpdateMediaItemReq,
) -> HealthResult<MediaItemResp> {
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<Uuid>,
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<Uuid>,
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<Uuid>,
req: MoveMediaReq,
) -> HealthResult<MediaItemResp> {
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<Uuid>,
req: CropReq,
) -> HealthResult<MediaItemResp> {
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<Vec<FolderResp>> {
// 查询所有未删除文件夹
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<Uuid> = folders.iter().map(|f| f.id).collect();
let mut count_map: std::collections::HashMap<Uuid, i64> = 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<FolderResp> = 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<Uuid>,
req: CreateFolderReq,
) -> HealthResult<FolderResp> {
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<Uuid>,
req: UpdateFolderReq,
) -> HealthResult<FolderResp> {
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<Uuid>,
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::Model> {
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::Model> {
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<i32>, Option<i32>) {
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<PathBuf> {
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<FolderResp>) -> Vec<FolderResp> {
use std::collections::HashMap;
// id -> (parent_id, index in flat)
let mut by_id: HashMap<Uuid, (Option<Uuid>, usize)> = HashMap::new();
for (i, f) in flat.iter().enumerate() {
by_id.insert(f.id, (f.parent_id, i));
}
// 收集根节点
let mut root_ids: Vec<Uuid> = 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<FolderResp> {
let mut children: Vec<FolderResp> = 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<FolderResp> = 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
}

View File

@@ -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;

View File

@@ -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),
]
}
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}