feat(health): 实现媒体库 service — CRUD + 缩略图 + 裁剪
This commit is contained in:
113
crates/erp-health/src/dto/banner_dto.rs
Normal file
113
crates/erp-health/src/dto/banner_dto.rs
Normal 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>,
|
||||
}
|
||||
132
crates/erp-health/src/dto/media_dto.rs
Normal file
132
crates/erp-health/src/dto/media_dto.rs
Normal 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 {
|
||||
/// 目标文件夹 ID(None 表示移到根目录)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
781
crates/erp-health/src/service/media_service.rs
Normal file
781
crates/erp-health/src/service/media_service.rs
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user