Files
mixvideo-v2/apps/desktop/src-tauri/src/business/services/material_service.rs
imeepos 6bd12a4a63 feat: 实现项目素材批量删除功能
- 后端实现:
  * 在MaterialRepository中添加batch_delete方法,支持事务处理
  * 在MaterialService中添加batch_delete_materials业务逻辑
  * 添加BatchDeleteResult和BatchDeleteFailedItem数据结构
  * 新增batch_delete_materials Tauri命令接口
  * 实现参数验证和错误处理机制

- 前端实现:
  * 创建useBatchSelection Hook管理批量选择状态
  * 实现BatchDeleteConfirmDialog批量删除确认对话框
  * 在MaterialCard组件中添加批量选择支持
  * 在ProjectDetails页面集成批量选择和删除功能
  * 添加批量操作UI控件(全选/取消全选/批量删除按钮)

- 功能特性:
  * 支持最多50个素材的批量选择
  * 单次最多删除100个素材的限制
  * 详细的删除结果反馈(成功/失败统计)
  * 失败项目的具体错误信息显示
  * 批量选择模式的视觉反馈
  * 完善的用户确认和通知机制

- 测试:
  * 添加批量删除功能的单元测试
  * 测试数据结构创建和验证逻辑

遵循Tauri开发规范和前端UI/UX设计标准,提供安全可靠的批量删除体验。
2025-07-21 14:28:06 +08:00

1064 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::{Result, anyhow};
use std::path::Path;
use std::fs;
use std::time::Instant;
use std::sync::Arc;
use crate::data::models::material::{
Material, MaterialType, ProcessingStatus, CreateMaterialRequest,
MaterialImportResult, MaterialProcessingConfig, MaterialMetadata,
BatchDeleteResult, BatchDeleteFailedItem
};
use crate::data::repositories::material_repository::MaterialRepository;
use crate::infrastructure::ffmpeg::FFmpegService;
use crate::infrastructure::monitoring::PERFORMANCE_MONITOR;
use crate::business::errors::error_utils;
use tracing::{info, warn, error, debug};
/// 素材服务
/// 遵循 Tauri 开发规范的业务逻辑层设计
pub struct MaterialService;
impl MaterialService {
/// 导入素材文件
pub fn import_materials(
repository: &MaterialRepository,
request: CreateMaterialRequest,
config: &MaterialProcessingConfig,
) -> Result<MaterialImportResult> {
let timer = PERFORMANCE_MONITOR.start_operation("import_materials");
let start_time = Instant::now();
info!(
project_id = %request.project_id,
file_count = request.file_paths.len(),
"开始导入素材文件"
);
let mut result = MaterialImportResult {
total_files: request.file_paths.len() as u32,
processed_files: 0,
skipped_files: 0,
failed_files: 0,
created_materials: Vec::new(),
errors: Vec::new(),
processing_time: 0.0,
};
for file_path in &request.file_paths {
debug!(file_path = %file_path, "处理文件");
match Self::process_single_file(repository, &request.project_id, file_path, config, request.model_id.clone()) {
Ok(Some(material)) => {
info!(
file_path = %file_path,
material_id = %material.id,
"文件处理成功"
);
result.created_materials.push(material);
result.processed_files += 1;
}
Ok(None) => {
// 文件被跳过(重复)
warn!(file_path = %file_path, "文件被跳过(重复)");
result.skipped_files += 1;
}
Err(e) => {
error!(
file_path = %file_path,
error = %e,
"文件处理失败"
);
result.failed_files += 1;
result.errors.push(format!("处理文件 {} 失败: {}", file_path, e));
}
}
}
result.processing_time = start_time.elapsed().as_secs_f64();
let success = result.failed_files == 0;
timer.finish(success);
info!(
processed_files = result.processed_files,
skipped_files = result.skipped_files,
failed_files = result.failed_files,
processing_time = result.processing_time,
"素材导入完成"
);
PERFORMANCE_MONITOR.record_metric("import_files_per_second",
result.total_files as f64 / result.processing_time);
Ok(result)
}
/// 处理单个文件
fn process_single_file(
repository: &MaterialRepository,
project_id: &str,
file_path: &str,
config: &MaterialProcessingConfig,
model_id: Option<String>,
) -> Result<Option<Material>> {
let _timer = PERFORMANCE_MONITOR.start_operation("process_single_file");
// 验证文件路径
error_utils::validate_file_path(file_path)
.map_err(|e| anyhow!("文件验证失败: {}", e))?;
let path = Path::new(file_path);
// 获取文件信息
let metadata = fs::metadata(path)?;
let file_size = metadata.len();
// 计算MD5哈希
let md5_hash = Self::calculate_md5(file_path)?;
// 检查是否已存在相同的文件
if repository.exists_by_md5(project_id, &md5_hash)? {
return Ok(None); // 跳过重复文件
}
// 获取文件名和扩展名
let file_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let extension = path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("");
// 确定素材类型
let material_type = MaterialType::from_extension(extension);
// 添加调试日志
println!("Creating material with model_id: {:?}", model_id);
// 创建素材对象(带模特绑定)
let material = Material::new_with_model(
project_id.to_string(),
file_name,
file_path.to_string(),
file_size,
md5_hash,
material_type,
model_id,
);
// 保存到数据库
repository.create(&material)?;
// 如果启用自动处理,则开始处理
if config.auto_process.unwrap_or(true) {
// 同步处理素材(提取元数据、场景检测等)
match Self::process_material(repository, &material.id, config) {
Ok(_) => {
// 处理成功,重新获取更新后的素材
if let Ok(Some(updated_material)) = repository.get_by_id(&material.id) {
return Ok(Some(updated_material));
}
}
Err(e) => {
// 处理失败,但不影响导入结果,只记录错误
eprintln!("素材处理失败: {} - {}", material.name, e);
}
}
}
Ok(Some(material))
}
/// 计算文件的MD5哈希值
pub fn calculate_md5(file_path: &str) -> Result<String> {
let data = fs::read(file_path)?;
let digest = md5::compute(&data);
Ok(format!("{:x}", digest))
}
/// 获取项目的所有素材
pub fn get_project_materials(
repository: &MaterialRepository,
project_id: &str,
) -> Result<Vec<Material>> {
let mut materials = repository.get_by_project_id(project_id)?;
// 为每个素材加载片段信息
for material in &mut materials {
material.segments = repository.get_segments(&material.id)?;
}
Ok(materials)
}
/// 获取所有素材
pub fn get_all_materials(
repository: &MaterialRepository,
) -> Result<Vec<Material>> {
let mut materials = repository.get_all()?;
// 为每个素材加载片段信息
for material in &mut materials {
material.segments = repository.get_segments(&material.id)?;
}
Ok(materials)
}
/// 获取素材详情
pub fn get_material_by_id(
repository: &MaterialRepository,
id: &str,
) -> Result<Option<Material>> {
if let Some(mut material) = repository.get_by_id(id)? {
// 加载片段信息
material.segments = repository.get_segments(&material.id)?;
Ok(Some(material))
} else {
Ok(None)
}
}
/// 删除素材
pub fn delete_material(
repository: &MaterialRepository,
id: &str,
) -> Result<()> {
// TODO: 删除相关的文件
repository.delete(id)?;
Ok(())
}
/// 批量删除素材
/// 返回删除结果统计信息
pub fn batch_delete_materials(
repository: &MaterialRepository,
material_ids: Vec<String>,
) -> Result<BatchDeleteResult> {
info!(
material_count = material_ids.len(),
"开始批量删除素材"
);
// 参数验证
if material_ids.is_empty() {
return Err(anyhow!("素材ID列表不能为空"));
}
if material_ids.len() > 100 {
return Err(anyhow!("单次批量删除不能超过100个素材"));
}
// 执行批量删除
let (successful_deletes, failed_deletes) = repository.batch_delete(&material_ids)?;
let result = BatchDeleteResult {
total_count: material_ids.len(),
success_count: successful_deletes.len(),
failed_count: failed_deletes.len(),
successful_ids: successful_deletes,
failed_items: failed_deletes.into_iter().map(|(id, error)| BatchDeleteFailedItem {
id,
error_message: error,
}).collect(),
};
info!(
total_count = result.total_count,
success_count = result.success_count,
failed_count = result.failed_count,
"批量删除素材完成"
);
Ok(result)
}
/// 更新素材处理状态
pub fn update_material_status(
repository: &MaterialRepository,
id: &str,
status: ProcessingStatus,
error_message: Option<String>,
) -> Result<()> {
if let Some(mut material) = repository.get_by_id(id)? {
material.update_status(status, error_message);
repository.update(&material)?;
}
Ok(())
}
/// 获取项目素材统计
pub fn get_project_stats(
repository: &MaterialRepository,
project_id: &str,
) -> Result<crate::data::models::material::MaterialStats> {
repository.get_project_stats(project_id)
}
/// 验证文件是否为支持的格式
pub fn is_supported_format(file_path: &str) -> bool {
let path = Path::new(file_path);
if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
let material_type = MaterialType::from_extension(extension);
!matches!(material_type, MaterialType::Other)
} else {
false
}
}
/// 获取支持的文件扩展名列表
pub fn get_supported_extensions() -> Vec<&'static str> {
vec![
// 视频格式
"mp4", "avi", "mov", "mkv", "wmv", "flv", "webm", "m4v",
// 音频格式
"mp3", "wav", "flac", "aac", "ogg", "wma", "m4a",
// 图片格式
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp", "svg",
// 文档格式
"pdf", "doc", "docx", "txt", "md", "rtf",
]
}
/// 批量处理素材
pub fn batch_process_materials(
repository: &MaterialRepository,
material_ids: Vec<String>,
config: &MaterialProcessingConfig,
) -> Result<Vec<String>> {
let mut processed_ids = Vec::new();
for material_id in material_ids {
match Self::process_material(repository, &material_id, config) {
Ok(_) => {
processed_ids.push(material_id);
}
Err(e) => {
// 更新状态为失败
let _ = Self::update_material_status(
repository,
&material_id,
ProcessingStatus::Failed,
Some(e.to_string()),
);
}
}
}
Ok(processed_ids)
}
/// 处理单个素材(提取元数据、场景检测等)
fn process_material(
repository: &MaterialRepository,
material_id: &str,
config: &MaterialProcessingConfig,
) -> Result<()> {
// 更新状态为处理中
Self::update_material_status(
repository,
material_id,
ProcessingStatus::Processing,
None,
)?;
// 获取素材信息
let mut material = repository.get_by_id(material_id)?
.ok_or_else(|| anyhow!("素材不存在: {}", material_id))?;
// 1. 提取元数据
match Self::extract_metadata(&material.original_path, &material.material_type) {
Ok(metadata) => {
material.set_metadata(metadata);
repository.update(&material)?;
}
Err(e) => {
Self::update_material_status(
repository,
material_id,
ProcessingStatus::Failed,
Some(format!("元数据提取失败: {}", e)),
)?;
return Err(e);
}
}
// 2. 场景检测(如果是视频且启用了场景检测)
if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection {
println!("开始视频场景检测: {}", material.original_path);
// 如果设置了跳过开头,先创建临时视频文件
let detection_file_path = if let Some(skip_ms) = config.skip_start_ms {
if skip_ms > 0 {
println!("AI生成视频前置跳过: {}ms", skip_ms);
match crate::infrastructure::ffmpeg::FFmpegService::create_trimmed_video(&material.original_path, skip_ms) {
Ok(temp_path) => {
println!("临时视频创建成功,用于场景检测: {}", temp_path);
temp_path
}
Err(e) => {
eprintln!("创建临时视频失败,使用原视频: {}", e);
material.original_path.clone()
}
}
} else {
material.original_path.clone()
}
} else {
material.original_path.clone()
};
match Self::detect_video_scenes(&detection_file_path, config.scene_detection_threshold) {
Ok(mut scene_detection) => {
// 如果使用了临时文件,需要调整场景时间戳
if let Some(skip_ms) = config.skip_start_ms {
if skip_ms > 0 && detection_file_path != material.original_path {
let skip_seconds = skip_ms as f64 / 1000.0;
println!("调整场景时间戳,补偿跳过的{}", skip_seconds);
for scene in &mut scene_detection.scenes {
scene.start_time += skip_seconds;
scene.end_time += skip_seconds;
}
}
}
println!("场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
material.set_scene_detection(scene_detection);
repository.update(&material)?;
// 清理临时文件
if detection_file_path != material.original_path {
if let Err(e) = std::fs::remove_file(&detection_file_path) {
eprintln!("清理临时文件失败: {}", e);
} else {
println!("临时文件清理成功: {}", detection_file_path);
}
}
}
Err(e) => {
// 场景检测失败不应该导致整个处理失败
eprintln!("场景检测失败: {}", e);
// 清理临时文件
if detection_file_path != material.original_path {
if let Err(e) = std::fs::remove_file(&detection_file_path) {
eprintln!("清理临时文件失败: {}", e);
}
}
}
}
} else {
println!("跳过场景检测 - 视频类型: {}, 启用检测: {}",
matches!(material.material_type, MaterialType::Video),
config.enable_scene_detection);
}
// 3. 检查是否需要切分视频
let should_segment = material.needs_segmentation(config.max_segment_duration) ||
(matches!(material.material_type, MaterialType::Video) && material.scene_detection.is_some());
if should_segment {
println!("开始视频切分 - 总时长检查: {}, 场景检测: {}",
material.needs_segmentation(config.max_segment_duration),
material.scene_detection.is_some());
match Self::segment_video(repository, &material, config) {
Ok(_) => {
println!("视频切分完成: {}", material.name);
}
Err(e) => {
Self::update_material_status(
repository,
material_id,
ProcessingStatus::Failed,
Some(format!("视频切分失败: {}", e)),
)?;
return Err(e);
}
}
} else {
println!("跳过视频切分 - 不满足切分条件");
}
// 标记为完成
Self::update_material_status(
repository,
material_id,
ProcessingStatus::Completed,
None,
)?;
Ok(())
}
/// 提取素材元数据
pub fn extract_metadata(file_path: &str, material_type: &MaterialType) -> Result<MaterialMetadata> {
match material_type {
MaterialType::Video | MaterialType::Audio => {
// 使用 FFmpeg 提取视频/音频元数据
FFmpegService::extract_metadata(file_path)
}
MaterialType::Image => {
// TODO: 实现图片元数据提取
Ok(MaterialMetadata::None)
}
_ => {
// 其他类型不需要元数据
Ok(MaterialMetadata::None)
}
}
}
/// 检测视频场景
pub fn detect_video_scenes(
file_path: &str,
threshold: f64,
) -> Result<crate::data::models::material::SceneDetection> {
let scene_times = FFmpegService::detect_scenes(file_path, threshold)?;
// 获取视频总时长
let total_duration = if let Ok(metadata) = Self::extract_metadata(file_path, &MaterialType::Video) {
if let MaterialMetadata::Video(video_meta) = metadata {
video_meta.duration
} else {
return Err(anyhow!("无法获取视频元数据"));
}
} else {
return Err(anyhow!("无法提取视频元数据"));
};
let mut scenes = Vec::new();
let mut previous_time = 0.0;
// 根据场景切换点创建场景片段
for (index, &scene_time) in scene_times.iter().enumerate() {
if scene_time > previous_time {
scenes.push(crate::data::models::material::SceneSegment {
start_time: previous_time,
end_time: scene_time,
duration: scene_time - previous_time,
scene_id: index as u32,
confidence: 1.0,
});
previous_time = scene_time;
}
}
// 添加最后一个场景片段
if previous_time < total_duration {
scenes.push(crate::data::models::material::SceneSegment {
start_time: previous_time,
end_time: total_duration,
duration: total_duration - previous_time,
scene_id: scenes.len() as u32,
confidence: 1.0,
});
}
println!("场景检测完成,共 {} 个场景:", scenes.len());
for (i, scene) in scenes.iter().enumerate() {
println!(" 场景 {}: {:.2}s - {:.2}s (时长: {:.2}s)",
i + 1, scene.start_time, scene.end_time, scene.duration);
}
let total_scenes = scenes.len() as u32;
Ok(crate::data::models::material::SceneDetection {
scenes,
total_scenes,
detection_method: "ffmpeg_scene_filter".to_string(),
threshold,
})
}
/// 切分视频
pub fn segment_video(
repository: &MaterialRepository,
material: &Material,
config: &MaterialProcessingConfig,
) -> Result<()> {
// 首先获取项目信息来确定输出路径
let project = Self::get_project_for_material(&material.project_id)?;
// 第一步:根据场景检测结果进行分镜头切分
let primary_segments = if let Some(scene_detection) = &material.scene_detection {
// 使用场景检测结果,每个场景作为一个片段
println!("使用场景检测结果进行分镜头切分,共 {} 个场景", scene_detection.scenes.len());
Self::create_segments_from_scenes_direct(&scene_detection.scenes)
} else {
// 没有场景检测结果,使用固定时长切分
if let Some(duration) = material.get_duration() {
println!("没有场景检测结果,使用固定时长切分");
Self::create_fixed_segments(duration, config.max_segment_duration)
} else {
return Err(anyhow!("无法获取视频时长"));
}
};
// 第二步:对超长的分镜头进行二次切分
let final_segments = Self::apply_duration_limit(&primary_segments, config.max_segment_duration);
println!("分镜头切分完成:{} 个片段", primary_segments.len());
println!("二次切分后:{} 个片段", final_segments.len());
// 创建输出目录:项目目录/pending/素材名称_segments (使用英文避免中文路径问题)
let mut project_path = project.path.clone();
// 处理 Windows 长路径格式,移除 \\?\ 前缀
if project_path.starts_with("\\\\?\\") {
project_path = project_path[4..].to_string();
}
let pending_dir = std::path::Path::new(&project_path).join("pending");
// 确保pending目录存在
if !pending_dir.exists() {
std::fs::create_dir_all(&pending_dir)
.map_err(|e| anyhow!("创建pending目录失败: {}", e))?;
println!("创建pending目录: {:?}", pending_dir);
}
let material_name = material.name.trim_end_matches(".mp4");
let output_dir = pending_dir.join(format!("{}_segments", material_name));
// 标准化路径确保FFmpeg可以处理
let output_dir_str = output_dir.to_string_lossy().to_string();
let normalized_output_dir = if output_dir_str.starts_with("\\\\?\\") {
output_dir_str[4..].to_string()
} else {
output_dir_str
};
println!("视频切分输出目录: {}", normalized_output_dir);
// 根据配置选择切分模式
let output_files = match config.split_mode {
crate::data::models::material::VideoSplitMode::Fast => {
println!("使用快速切分模式(可能有前几秒无画面问题)");
FFmpegService::split_video_fast(
&material.original_path,
&normalized_output_dir,
&final_segments,
&material.name.replace(".mp4", ""),
)?
}
crate::data::models::material::VideoSplitMode::Accurate => {
println!("使用精确切分模式(重新编码,确保画面完整)");
FFmpegService::split_video(
&material.original_path,
&normalized_output_dir,
&final_segments,
&material.name.replace(".mp4", ""),
)?
}
crate::data::models::material::VideoSplitMode::Smart => {
println!("使用智能切分模式(关键帧对齐)");
FFmpegService::split_video_smart(
&material.original_path,
&normalized_output_dir,
&final_segments,
&material.name.replace(".mp4", ""),
)?
}
};
// 保存片段信息到数据库
for (index, output_file) in output_files.iter().enumerate() {
let (start_time, end_time) = final_segments[index];
let file_size = std::fs::metadata(output_file)?.len();
let segment = crate::data::models::material::MaterialSegment::new(
material.id.clone(),
index as u32,
start_time,
end_time,
output_file.clone(),
file_size,
);
repository.create_segment(&segment)?;
}
Ok(())
}
/// 直接根据场景创建分镜头片段(第一次切分)
pub fn create_segments_from_scenes_direct(
scenes: &[crate::data::models::material::SceneSegment],
) -> Vec<(f64, f64)> {
let mut segments = Vec::new();
for scene in scenes {
segments.push((scene.start_time, scene.end_time));
}
println!("分镜头切分:根据 {} 个场景创建 {} 个片段", scenes.len(), segments.len());
for (i, (start, end)) in segments.iter().enumerate() {
println!(" 分镜头 {}: {:.2}s - {:.2}s (时长: {:.2}s)",
i + 1, start, end, end - start);
}
segments
}
/// 对片段应用时长限制(二次切分)
pub fn apply_duration_limit(
segments: &[(f64, f64)],
max_duration: f64,
) -> Vec<(f64, f64)> {
let mut final_segments = Vec::new();
println!("开始二次切分,最大时长限制: {}", max_duration);
for (i, (start, end)) in segments.iter().enumerate() {
let duration = end - start;
if duration > max_duration {
println!(" 分镜头 {} 超长 ({:.2}s > {:.2}s),进行二次切分",
i + 1, duration, max_duration);
// 将超长片段按最大时长切分
let sub_segments = Self::create_fixed_segments_range(*start, *end, max_duration);
println!(" 切分为 {} 个子片段:", sub_segments.len());
for (j, (sub_start, sub_end)) in sub_segments.iter().enumerate() {
println!(" 子片段 {}: {:.2}s - {:.2}s (时长: {:.2}s)",
j + 1, sub_start, sub_end, sub_end - sub_start);
}
final_segments.extend(sub_segments);
} else {
println!(" 分镜头 {} 时长合适 ({:.2}s ≤ {:.2}s),保持不变",
i + 1, duration, max_duration);
final_segments.push((*start, *end));
}
}
println!("二次切分完成,最终 {} 个片段", final_segments.len());
final_segments
}
/// 根据场景创建切分片段(旧版本,保留兼容性)
pub fn create_segments_from_scenes(
scenes: &[crate::data::models::material::SceneSegment],
max_duration: f64,
) -> Vec<(f64, f64)> {
// 使用新的两步法
let primary_segments = Self::create_segments_from_scenes_direct(scenes);
Self::apply_duration_limit(&primary_segments, max_duration)
}
/// 创建固定时长的切分片段
pub fn create_fixed_segments(total_duration: f64, max_duration: f64) -> Vec<(f64, f64)> {
Self::create_fixed_segments_range(0.0, total_duration, max_duration)
}
/// 在指定时间范围内创建固定时长的切分片段
fn create_fixed_segments_range(start_time: f64, end_time: f64, max_duration: f64) -> Vec<(f64, f64)> {
let mut segments = Vec::new();
let mut current_time = start_time;
while current_time < end_time {
let segment_end = (current_time + max_duration).min(end_time);
segments.push((current_time, segment_end));
current_time = segment_end;
}
segments
}
/// 清理失效的素材记录
pub fn cleanup_invalid_materials(
repository: &MaterialRepository,
project_id: &str,
) -> Result<u32> {
let materials = repository.get_by_project_id(project_id)?;
let mut cleaned_count = 0;
for material in materials {
// 检查原始文件是否还存在
if !Path::new(&material.original_path).exists() {
repository.delete(&material.id)?;
cleaned_count += 1;
}
}
Ok(cleaned_count)
}
/// 获取素材对应的项目信息
fn get_project_for_material(project_id: &str) -> Result<crate::data::models::project::Project> {
use crate::infrastructure::database::Database;
use crate::data::repositories::project_repository::ProjectRepository;
use crate::business::services::project_service::ProjectService;
// 创建数据库连接
let db = Arc::new(Database::new()?);
let project_repo = ProjectRepository::new(db.clone())?;
// 获取项目信息
ProjectService::get_project_by_id(&project_repo, project_id)?
.ok_or_else(|| anyhow!("找不到项目: {}", project_id))
}
/// 绑定素材到模特
pub fn bind_material_to_model(
repository: &MaterialRepository,
material_id: &str,
model_id: &str,
) -> Result<()> {
info!(
material_id = %material_id,
model_id = %model_id,
"开始绑定素材到模特"
);
// 验证素材是否存在
let material = repository.get_by_id(material_id)?
.ok_or_else(|| anyhow!("找不到素材: {}", material_id))?;
// 验证模特是否存在
Self::validate_model_exists(model_id)?;
// 执行绑定
repository.update_model_binding(material_id, Some(model_id))?;
info!(
material_id = %material_id,
model_id = %model_id,
material_name = %material.name,
"素材绑定到模特成功"
);
Ok(())
}
/// 解除素材与模特的绑定
pub fn unbind_material_from_model(
repository: &MaterialRepository,
material_id: &str,
) -> Result<()> {
info!(material_id = %material_id, "开始解除素材与模特的绑定");
// 验证素材是否存在
let material = repository.get_by_id(material_id)?
.ok_or_else(|| anyhow!("找不到素材: {}", material_id))?;
// 执行解绑
repository.update_model_binding(material_id, None)?;
info!(
material_id = %material_id,
material_name = %material.name,
"素材与模特解绑成功"
);
Ok(())
}
/// 批量绑定素材到模特
pub fn batch_bind_materials_to_model(
repository: &MaterialRepository,
material_ids: &[String],
model_id: &str,
) -> Result<()> {
info!(
material_count = material_ids.len(),
model_id = %model_id,
"开始批量绑定素材到模特"
);
// 验证模特是否存在
Self::validate_model_exists(model_id)?;
// 验证所有素材是否存在
for material_id in material_ids {
if repository.get_by_id(material_id)?.is_none() {
return Err(anyhow!("找不到素材: {}", material_id));
}
}
// 执行批量绑定
repository.batch_update_model_binding(material_ids, Some(model_id))?;
info!(
material_count = material_ids.len(),
model_id = %model_id,
"批量绑定素材到模特成功"
);
Ok(())
}
/// 批量解除素材与模特的绑定
pub fn batch_unbind_materials_from_model(
repository: &MaterialRepository,
material_ids: &[String],
) -> Result<()> {
info!(
material_count = material_ids.len(),
"开始批量解除素材与模特的绑定"
);
// 验证所有素材是否存在
for material_id in material_ids {
if repository.get_by_id(material_id)?.is_none() {
return Err(anyhow!("找不到素材: {}", material_id));
}
}
// 执行批量解绑
repository.batch_update_model_binding(material_ids, None)?;
info!(
material_count = material_ids.len(),
"批量解除素材与模特绑定成功"
);
Ok(())
}
/// 获取模特的所有素材
pub fn get_materials_by_model(
repository: &MaterialRepository,
model_id: &str,
) -> Result<Vec<Material>> {
debug!(model_id = %model_id, "获取模特的所有素材");
// 验证模特是否存在
Self::validate_model_exists(model_id)?;
repository.get_by_model_id(model_id)
}
/// 获取未绑定模特的素材
pub fn get_unbound_materials(
repository: &MaterialRepository,
project_id: Option<&str>,
) -> Result<Vec<Material>> {
debug!(project_id = ?project_id, "获取未绑定模特的素材");
if let Some(pid) = project_id {
repository.get_unassociated_materials(Some(pid))
} else {
repository.get_unbound_materials()
}
}
/// 获取模特的素材统计信息
pub fn get_model_material_statistics(
repository: &MaterialRepository,
model_id: &str,
) -> Result<crate::data::repositories::material_repository::ModelMaterialStatistics> {
debug!(model_id = %model_id, "获取模特的素材统计信息");
// 验证模特是否存在
Self::validate_model_exists(model_id)?;
repository.get_model_statistics(model_id)
}
/// 验证模特是否存在
fn validate_model_exists(model_id: &str) -> Result<()> {
use crate::infrastructure::database::Database;
use crate::data::repositories::model_repository::ModelRepository;
let db = Arc::new(Database::new()?);
let model_repo = ModelRepository::new(db.clone());
if model_repo.get_by_id(model_id)?.is_none() {
return Err(anyhow!("找不到模特: {}", model_id));
}
Ok(())
}
/// 切换素材的模特绑定
pub fn switch_material_model(
repository: &MaterialRepository,
material_id: &str,
new_model_id: &str,
) -> Result<()> {
info!(
material_id = %material_id,
new_model_id = %new_model_id,
"开始切换素材的模特绑定"
);
// 验证素材是否存在
let material = repository.get_by_id(material_id)?
.ok_or_else(|| anyhow!("找不到素材: {}", material_id))?;
// 验证新模特是否存在
Self::validate_model_exists(new_model_id)?;
// 执行切换
repository.update_model_binding(material_id, Some(new_model_id))?;
info!(
material_id = %material_id,
material_name = %material.name,
new_model_id = %new_model_id,
"素材模特绑定切换成功"
);
Ok(())
}
/// 获取项目中绑定模特的素材统计
pub fn get_project_model_binding_stats(
repository: &MaterialRepository,
project_id: &str,
) -> Result<ProjectModelBindingStats> {
debug!(project_id = %project_id, "获取项目中模特绑定统计");
let bound_count = repository.count_bound_materials_in_project(project_id)?;
let unbound_count = repository.count_unbound_materials_in_project(project_id)?;
let total_count = bound_count + unbound_count;
Ok(ProjectModelBindingStats {
total_materials: total_count,
bound_materials: bound_count,
unbound_materials: unbound_count,
binding_rate: if total_count > 0 {
(bound_count as f64 / total_count as f64) * 100.0
} else {
0.0
},
})
}
/// 获取全局模特绑定统计
pub fn get_global_model_binding_stats(
repository: &MaterialRepository,
) -> Result<ProjectModelBindingStats> {
debug!("获取全局模特绑定统计");
let bound_count = repository.count_global_bound_materials()?;
let unbound_count = repository.count_global_unbound_materials()?;
let total_count = bound_count + unbound_count;
Ok(ProjectModelBindingStats {
total_materials: total_count,
bound_materials: bound_count,
unbound_materials: unbound_count,
binding_rate: if total_count > 0 {
(bound_count as f64 / total_count as f64) * 100.0
} else {
0.0
},
})
}
}
/// 项目模特绑定统计信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProjectModelBindingStats {
pub total_materials: u32,
pub bound_materials: u32,
pub unbound_materials: u32,
pub binding_rate: f64, // 绑定率百分比
}