P0 修复:
- 侧边栏路由不稳定: Content 区域添加 key={currentPath} 强制重渲染
- 轮播图缩略图不显示: BannerManage 导入 resolveMediaUrl + 反斜杠转正斜杠
- 超长名称导致 500: patient_handler 添加 name.len() > 255 校验
- 迁移 m20260515_000146: version 乐观锁 version+1 修复
P1 修复:
- 排班路由被冻结: routeConfig.ts 移除 /health/schedules 的 frozen 标记
- 轮播图 Switch 切换无效: 切换前先 GET 最新 version 避免乐观锁冲突
- thumbnail_url 反斜杠: media_service 存储时统一 replace('\', '/')
P2 修复:
- 预约类型 follow_up 未映射: APPOINTMENT_TYPE_MAP 补充 '随访'
- 日期选择器未汉化: DatePicker.RangePicker 添加中文 placeholder
- 轮播图 title 必填校验: banner_handler 添加空标题拒绝
- 文章分类重名: article_category_service 添加同名检查
794 lines
24 KiB
Rust
794 lines
24 KiB
Rust
//! 媒体库 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().replace('\\', "/"))),
|
||
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"))
|
||
.col_expr(
|
||
banner::Column::Version,
|
||
Expr::col(banner::Column::Version).add(1),
|
||
)
|
||
.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))
|
||
.col_expr(
|
||
media_item::Column::Version,
|
||
Expr::col(media_item::Column::Version).add(1),
|
||
)
|
||
.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"))
|
||
.col_expr(
|
||
banner::Column::Version,
|
||
Expr::col(banner::Column::Version).add(1),
|
||
)
|
||
.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().replace('\\', "/")));
|
||
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
|
||
}
|