feat: 实现素材使用状态管理系统

1. 数据库层面改进:
   - 新增material_usage_records表记录素材使用历史
   - 为material_segments表添加usage_count、is_used、last_used_at字段
   - 实现数据库迁移逻辑

2. 数据模型和仓库:
   - 创建MaterialUsageRecord、MaterialUsageStats等数据模型
   - 实现MaterialUsageRepository处理使用记录的CRUD操作
   - 支持批量创建使用记录和统计查询

3. 业务逻辑改进:
   - 修改MaterialMatchingService,在获取素材片段时排除已使用的片段
   - 实现素材使用状态的自动更新机制
   - 支持重置素材使用状态功能

4. 前端集成:
   - 修改handleApplyMatchingResult函数,应用匹配结果时自动创建使用记录
   - 新增多个后端命令支持素材使用状态管理
   - 实现从匹配结果自动创建使用记录的便捷方法

5. 核心功能:
   - 一个素材片段只能使用一次的限制机制
   - 模板匹配时自动排除已使用的素材片段
   - 完整的使用历史记录和统计功能
   - 支持项目级别的使用状态重置

这个实现确保了素材的合理使用,避免重复使用同一素材片段,
同时提供了完整的使用追踪和管理功能。
This commit is contained in:
imeepos
2025-07-16 14:38:08 +08:00
parent 730ac22b84
commit a325b3ccc8
13 changed files with 910 additions and 8 deletions

View File

@@ -10,6 +10,7 @@ use crate::data::models::{
}; };
use crate::data::repositories::{ use crate::data::repositories::{
material_repository::MaterialRepository, material_repository::MaterialRepository,
material_usage_repository::MaterialUsageRepository,
video_classification_repository::VideoClassificationRepository, video_classification_repository::VideoClassificationRepository,
template_matching_result_repository::TemplateMatchingResultRepository, template_matching_result_repository::TemplateMatchingResultRepository,
}; };
@@ -23,6 +24,7 @@ use std::sync::Arc;
/// 素材匹配服务 /// 素材匹配服务
pub struct MaterialMatchingService { pub struct MaterialMatchingService {
material_repo: Arc<MaterialRepository>, material_repo: Arc<MaterialRepository>,
material_usage_repo: Arc<MaterialUsageRepository>,
template_service: Arc<TemplateService>, template_service: Arc<TemplateService>,
video_classification_repo: Arc<VideoClassificationRepository>, video_classification_repo: Arc<VideoClassificationRepository>,
matching_result_service: Option<Arc<TemplateMatchingResultService>>, matching_result_service: Option<Arc<TemplateMatchingResultService>>,
@@ -85,11 +87,13 @@ impl MaterialMatchingService {
/// 创建新的素材匹配服务实例 /// 创建新的素材匹配服务实例
pub fn new( pub fn new(
material_repo: Arc<MaterialRepository>, material_repo: Arc<MaterialRepository>,
material_usage_repo: Arc<MaterialUsageRepository>,
template_service: Arc<TemplateService>, template_service: Arc<TemplateService>,
video_classification_repo: Arc<VideoClassificationRepository>, video_classification_repo: Arc<VideoClassificationRepository>,
) -> Self { ) -> Self {
Self { Self {
material_repo, material_repo,
material_usage_repo,
template_service, template_service,
video_classification_repo, video_classification_repo,
matching_result_service: None, matching_result_service: None,
@@ -99,12 +103,14 @@ impl MaterialMatchingService {
/// 创建新的素材匹配服务实例(带结果保存功能) /// 创建新的素材匹配服务实例(带结果保存功能)
pub fn new_with_result_service( pub fn new_with_result_service(
material_repo: Arc<MaterialRepository>, material_repo: Arc<MaterialRepository>,
material_usage_repo: Arc<MaterialUsageRepository>,
template_service: Arc<TemplateService>, template_service: Arc<TemplateService>,
video_classification_repo: Arc<VideoClassificationRepository>, video_classification_repo: Arc<VideoClassificationRepository>,
matching_result_service: Arc<TemplateMatchingResultService>, matching_result_service: Arc<TemplateMatchingResultService>,
) -> Self { ) -> Self {
Self { Self {
material_repo, material_repo,
material_usage_repo,
template_service, template_service,
video_classification_repo, video_classification_repo,
matching_result_service: Some(matching_result_service), matching_result_service: Some(matching_result_service),
@@ -128,8 +134,8 @@ impl MaterialMatchingService {
classification_records.insert(material.id.clone(), records); classification_records.insert(material.id.clone(), records);
} }
// 获取所有可用的素材片段(已分类的) // 获取所有可用的素材片段(已分类的,排除已使用的
let available_segments = self.get_classified_segments(&project_materials, &classification_records).await?; let available_segments = self.get_classified_segments(&project_materials, &classification_records, &request.project_id).await?;
@@ -247,14 +253,29 @@ impl MaterialMatchingService {
Ok((matching_result, None)) Ok((matching_result, None))
} }
/// 获取已分类的素材片段 /// 获取已分类的素材片段(排除已使用的片段)
async fn get_classified_segments( async fn get_classified_segments(
&self, &self,
materials: &[Material], materials: &[Material],
classification_records: &HashMap<String, Vec<VideoClassificationRecord>>, classification_records: &HashMap<String, Vec<VideoClassificationRecord>>,
project_id: &str,
) -> Result<Vec<(MaterialSegment, String)>> { ) -> Result<Vec<(MaterialSegment, String)>> {
let mut classified_segments = Vec::new(); let mut classified_segments = Vec::new();
// 获取项目中已使用的素材片段ID列表
let used_segment_ids = match self.material_usage_repo.get_usage_records_by_project(project_id) {
Ok(usage_records) => {
usage_records.into_iter()
.map(|record| record.material_segment_id)
.collect::<HashSet<String>>()
}
Err(e) => {
// 如果获取使用记录失败,记录警告但不影响匹配流程
eprintln!("警告:获取素材使用记录失败: {},将继续进行匹配", e);
HashSet::new()
}
};
for material in materials { for material in materials {
// 只处理有分类记录的素材 // 只处理有分类记录的素材
@@ -270,6 +291,11 @@ impl MaterialMatchingService {
if let Some(duration) = material.get_duration() { if let Some(duration) = material.get_duration() {
// 为每个分类记录创建一个虚拟片段 // 为每个分类记录创建一个虚拟片段
for record in records { for record in records {
// 检查该片段是否已被使用
if used_segment_ids.contains(&record.segment_id) {
continue; // 跳过已使用的片段
}
// 创建虚拟片段使用分类记录中的segment_id // 创建虚拟片段使用分类记录中的segment_id
let virtual_segment = MaterialSegment { let virtual_segment = MaterialSegment {
id: record.segment_id.clone(), // 使用分类记录中的segment_id id: record.segment_id.clone(), // 使用分类记录中的segment_id
@@ -281,6 +307,9 @@ impl MaterialMatchingService {
file_path: material.original_path.clone(), // 使用原始文件路径 file_path: material.original_path.clone(), // 使用原始文件路径
file_size: material.file_size, file_size: material.file_size,
thumbnail_path: material.thumbnail_path.clone(), thumbnail_path: material.thumbnail_path.clone(),
usage_count: 0,
is_used: false,
last_used_at: None,
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
}; };
@@ -290,6 +319,11 @@ impl MaterialMatchingService {
} else { } else {
// 为每个素材片段查找对应的分类记录 // 为每个素材片段查找对应的分类记录
for segment in &material.segments { for segment in &material.segments {
// 检查该片段是否已被使用
if used_segment_ids.contains(&segment.id) {
continue; // 跳过已使用的片段
}
// 查找该片段的分类记录 // 查找该片段的分类记录
if let Some(record) = records.iter().find(|r| r.segment_id == segment.id) { if let Some(record) = records.iter().find(|r| r.segment_id == segment.id) {
classified_segments.push((segment.clone(), record.category.clone())); classified_segments.push((segment.clone(), record.category.clone()));

View File

@@ -133,6 +133,9 @@ pub struct MaterialSegment {
pub file_path: String, pub file_path: String,
pub file_size: u64, pub file_size: u64,
pub thumbnail_path: Option<String>, // 缩略图路径 pub thumbnail_path: Option<String>, // 缩略图路径
pub usage_count: u32, // 使用次数
pub is_used: bool, // 是否已使用
pub last_used_at: Option<DateTime<Utc>>, // 最后使用时间
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@@ -367,6 +370,9 @@ impl MaterialSegment {
file_path, file_path,
file_size, file_size,
thumbnail_path: None, thumbnail_path: None,
usage_count: 0,
is_used: false,
last_used_at: None,
created_at: Utc::now(), created_at: Utc::now(),
} }
} }

View File

@@ -0,0 +1,244 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
/// 素材使用类型枚举
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum MaterialUsageType {
/// 模板匹配使用
TemplateMatching,
/// 手动编辑使用
ManualEdit,
/// 其他用途
Other,
}
impl Default for MaterialUsageType {
fn default() -> Self {
Self::TemplateMatching
}
}
/// 素材使用记录实体模型
/// 记录素材片段在各种场景下的使用情况
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialUsageRecord {
pub id: String,
pub material_segment_id: String,
pub material_id: String,
pub project_id: String,
pub template_matching_result_id: String,
pub template_id: String,
pub binding_id: String,
pub track_segment_id: String,
pub usage_type: MaterialUsageType,
pub usage_context: Option<String>, // JSON格式的使用上下文信息
pub created_at: DateTime<Utc>,
}
impl MaterialUsageRecord {
/// 创建新的素材使用记录实例
pub fn new(
material_segment_id: String,
material_id: String,
project_id: String,
template_matching_result_id: String,
template_id: String,
binding_id: String,
track_segment_id: String,
usage_type: MaterialUsageType,
usage_context: Option<String>,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
material_segment_id,
material_id,
project_id,
template_matching_result_id,
template_id,
binding_id,
track_segment_id,
usage_type,
usage_context,
created_at: Utc::now(),
}
}
/// 创建模板匹配使用记录
pub fn new_template_matching(
material_segment_id: String,
material_id: String,
project_id: String,
template_matching_result_id: String,
template_id: String,
binding_id: String,
track_segment_id: String,
match_score: f64,
match_reason: String,
) -> Self {
let usage_context = serde_json::json!({
"match_score": match_score,
"match_reason": match_reason,
"usage_timestamp": Utc::now().to_rfc3339()
}).to_string();
Self::new(
material_segment_id,
material_id,
project_id,
template_matching_result_id,
template_id,
binding_id,
track_segment_id,
MaterialUsageType::TemplateMatching,
Some(usage_context),
)
}
/// 验证记录的有效性
pub fn validate(&self) -> Result<(), String> {
if self.material_segment_id.is_empty() {
return Err("素材片段ID不能为空".to_string());
}
if self.material_id.is_empty() {
return Err("素材ID不能为空".to_string());
}
if self.project_id.is_empty() {
return Err("项目ID不能为空".to_string());
}
if self.template_matching_result_id.is_empty() {
return Err("模板匹配结果ID不能为空".to_string());
}
Ok(())
}
}
/// 素材使用统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialUsageStats {
pub material_id: String,
pub material_name: String,
pub total_segments: u32,
pub used_segments: u32,
pub unused_segments: u32,
pub total_usage_count: u32,
pub last_used_at: Option<DateTime<Utc>>,
pub usage_rate: f64, // 使用率 (used_segments / total_segments)
}
impl MaterialUsageStats {
/// 创建新的素材使用统计实例
pub fn new(
material_id: String,
material_name: String,
total_segments: u32,
used_segments: u32,
total_usage_count: u32,
last_used_at: Option<DateTime<Utc>>,
) -> Self {
let unused_segments = total_segments.saturating_sub(used_segments);
let usage_rate = if total_segments > 0 {
used_segments as f64 / total_segments as f64
} else {
0.0
};
Self {
material_id,
material_name,
total_segments,
used_segments,
unused_segments,
total_usage_count,
last_used_at,
usage_rate,
}
}
}
/// 项目素材使用概览
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMaterialUsageOverview {
pub project_id: String,
pub total_materials: u32,
pub total_segments: u32,
pub used_segments: u32,
pub unused_segments: u32,
pub total_usage_count: u32,
pub overall_usage_rate: f64,
pub materials_stats: Vec<MaterialUsageStats>,
}
impl ProjectMaterialUsageOverview {
/// 创建新的项目素材使用概览实例
pub fn new(
project_id: String,
materials_stats: Vec<MaterialUsageStats>,
) -> Self {
let total_materials = materials_stats.len() as u32;
let total_segments: u32 = materials_stats.iter().map(|s| s.total_segments).sum();
let used_segments: u32 = materials_stats.iter().map(|s| s.used_segments).sum();
let unused_segments = total_segments.saturating_sub(used_segments);
let total_usage_count: u32 = materials_stats.iter().map(|s| s.total_usage_count).sum();
let overall_usage_rate = if total_segments > 0 {
used_segments as f64 / total_segments as f64
} else {
0.0
};
Self {
project_id,
total_materials,
total_segments,
used_segments,
unused_segments,
total_usage_count,
overall_usage_rate,
materials_stats,
}
}
}
/// 创建素材使用记录请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateMaterialUsageRecordRequest {
pub material_segment_id: String,
pub material_id: String,
pub project_id: String,
pub template_matching_result_id: String,
pub template_id: String,
pub binding_id: String,
pub track_segment_id: String,
pub usage_type: MaterialUsageType,
pub usage_context: Option<String>,
}
impl CreateMaterialUsageRecordRequest {
/// 验证请求的有效性
pub fn validate(&self) -> Result<(), String> {
if self.material_segment_id.is_empty() {
return Err("素材片段ID不能为空".to_string());
}
if self.material_id.is_empty() {
return Err("素材ID不能为空".to_string());
}
if self.project_id.is_empty() {
return Err("项目ID不能为空".to_string());
}
Ok(())
}
/// 转换为素材使用记录实体
pub fn to_entity(self) -> MaterialUsageRecord {
MaterialUsageRecord::new(
self.material_segment_id,
self.material_id,
self.project_id,
self.template_matching_result_id,
self.template_id,
self.binding_id,
self.track_segment_id,
self.usage_type,
self.usage_context,
)
}
}

View File

@@ -1,6 +1,7 @@
pub mod project; pub mod project;
pub mod material; pub mod material;
pub mod material_segment_view; pub mod material_segment_view;
pub mod material_usage;
pub mod model; pub mod model;
pub mod ai_classification; pub mod ai_classification;
pub mod video_classification; pub mod video_classification;

View File

@@ -434,6 +434,11 @@ impl MaterialRepository {
file_path: row.get("file_path")?, file_path: row.get("file_path")?,
file_size: row.get::<_, i64>("file_size")? as u64, file_size: row.get::<_, i64>("file_size")? as u64,
thumbnail_path: row.get("thumbnail_path")?, thumbnail_path: row.get("thumbnail_path")?,
usage_count: row.get::<_, Option<u32>>("usage_count")?.unwrap_or(0),
is_used: row.get::<_, Option<bool>>("is_used")?.unwrap_or(false),
last_used_at: row.get::<_, Option<String>>("last_used_at")?
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc)),
created_at: { created_at: {
let created_at_str = row.get::<_, String>("created_at")?; let created_at_str = row.get::<_, String>("created_at")?;
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
@@ -615,7 +620,7 @@ impl MaterialRepository {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, material_id, segment_index, start_time, end_time, duration, "SELECT id, material_id, segment_index, start_time, end_time, duration,
file_path, file_size, thumbnail_path, created_at file_path, file_size, thumbnail_path, usage_count, is_used, last_used_at, created_at
FROM material_segments WHERE id = ?1" FROM material_segments WHERE id = ?1"
)?; )?;
@@ -630,8 +635,13 @@ impl MaterialRepository {
file_path: row.get(6)?, file_path: row.get(6)?,
file_size: row.get(7)?, file_size: row.get(7)?,
thumbnail_path: row.get(8)?, thumbnail_path: row.get(8)?,
usage_count: row.get::<_, Option<u32>>(9)?.unwrap_or(0),
is_used: row.get::<_, Option<bool>>(10)?.unwrap_or(false),
last_used_at: row.get::<_, Option<String>>(11)?
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc)),
created_at: { created_at: {
let created_at_str = row.get::<_, String>(9)?; let created_at_str = row.get::<_, String>(12)?;
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") {
dt.and_utc() dt.and_utc()
@@ -658,7 +668,7 @@ impl MaterialRepository {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, material_id, segment_index, start_time, end_time, duration, "SELECT id, material_id, segment_index, start_time, end_time, duration,
file_path, file_size, thumbnail_path, created_at file_path, file_size, thumbnail_path, usage_count, is_used, last_used_at, created_at
FROM material_segments WHERE id = ?1" FROM material_segments WHERE id = ?1"
)?; )?;
@@ -673,8 +683,13 @@ impl MaterialRepository {
file_path: row.get(6)?, file_path: row.get(6)?,
file_size: row.get(7)?, file_size: row.get(7)?,
thumbnail_path: row.get(8)?, thumbnail_path: row.get(8)?,
usage_count: row.get::<_, Option<u32>>(9)?.unwrap_or(0),
is_used: row.get::<_, Option<bool>>(10)?.unwrap_or(false),
last_used_at: row.get::<_, Option<String>>(11)?
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc)),
created_at: { created_at: {
let created_at_str = row.get::<_, String>(9)?; let created_at_str = row.get::<_, String>(12)?;
// 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS) // 尝试解析SQLite DATETIME格式 (YYYY-MM-DD HH:MM:SS)
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S") {
dt.and_utc() dt.and_utc()

View File

@@ -0,0 +1,283 @@
use std::sync::Arc;
use rusqlite::{Connection, Row, Result};
use chrono::{DateTime, Utc};
use crate::data::models::material_usage::{
MaterialUsageRecord, MaterialUsageStats, ProjectMaterialUsageOverview,
CreateMaterialUsageRecordRequest, MaterialUsageType
};
use crate::infrastructure::database::Database;
/// 素材使用记录仓库
/// 负责管理素材使用记录的数据库操作
pub struct MaterialUsageRepository {
database: Arc<Database>,
}
impl MaterialUsageRepository {
/// 创建新的素材使用记录仓库实例
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
/// 创建素材使用记录
pub fn create_usage_record(&self, request: CreateMaterialUsageRecordRequest) -> Result<MaterialUsageRecord> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let record = request.to_entity();
conn.execute(
"INSERT INTO material_usage_records (
id, material_segment_id, material_id, project_id,
template_matching_result_id, template_id, binding_id,
track_segment_id, usage_type, usage_context, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
&record.id,
&record.material_segment_id,
&record.material_id,
&record.project_id,
&record.template_matching_result_id,
&record.template_id,
&record.binding_id,
&record.track_segment_id,
&serde_json::to_string(&record.usage_type).unwrap(),
&record.usage_context,
&record.created_at.to_rfc3339(),
],
)?;
// 更新素材片段的使用状态
self.update_segment_usage_status(&conn, &record.material_segment_id)?;
Ok(record)
}
/// 批量创建素材使用记录
pub fn create_usage_records_batch(&self, requests: Vec<CreateMaterialUsageRecordRequest>) -> Result<Vec<MaterialUsageRecord>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut records = Vec::new();
// 开始事务
let tx = conn.unchecked_transaction()?;
for request in requests {
let record = request.to_entity();
tx.execute(
"INSERT INTO material_usage_records (
id, material_segment_id, material_id, project_id,
template_matching_result_id, template_id, binding_id,
track_segment_id, usage_type, usage_context, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
&record.id,
&record.material_segment_id,
&record.material_id,
&record.project_id,
&record.template_matching_result_id,
&record.template_id,
&record.binding_id,
&record.track_segment_id,
&serde_json::to_string(&record.usage_type).unwrap(),
&record.usage_context,
&record.created_at.to_rfc3339(),
],
)?;
// 更新素材片段的使用状态
self.update_segment_usage_status_in_tx(&tx, &record.material_segment_id)?;
records.push(record);
}
// 提交事务
tx.commit()?;
Ok(records)
}
/// 更新素材片段的使用状态
fn update_segment_usage_status(&self, conn: &Connection, segment_id: &str) -> Result<()> {
// 获取当前使用次数
let usage_count: u32 = conn.query_row(
"SELECT COUNT(*) FROM material_usage_records WHERE material_segment_id = ?1",
[segment_id],
|row| row.get(0)
)?;
// 更新素材片段的使用状态
conn.execute(
"UPDATE material_segments SET
usage_count = ?1,
is_used = ?2,
last_used_at = ?3
WHERE id = ?4",
rusqlite::params![
usage_count,
usage_count > 0,
Utc::now().to_rfc3339(),
segment_id
],
)?;
Ok(())
}
/// 在事务中更新素材片段的使用状态
fn update_segment_usage_status_in_tx(&self, tx: &rusqlite::Transaction, segment_id: &str) -> Result<()> {
// 获取当前使用次数
let usage_count: u32 = tx.query_row(
"SELECT COUNT(*) FROM material_usage_records WHERE material_segment_id = ?1",
[segment_id],
|row| row.get(0)
)?;
// 更新素材片段的使用状态
tx.execute(
"UPDATE material_segments SET
usage_count = ?1,
is_used = ?2,
last_used_at = ?3
WHERE id = ?4",
rusqlite::params![
usage_count,
usage_count > 0,
Utc::now().to_rfc3339(),
segment_id
],
)?;
Ok(())
}
/// 获取素材使用记录列表
pub fn get_usage_records_by_project(&self, project_id: &str) -> Result<Vec<MaterialUsageRecord>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, material_segment_id, material_id, project_id,
template_matching_result_id, template_id, binding_id,
track_segment_id, usage_type, usage_context, created_at
FROM material_usage_records
WHERE project_id = ?1
ORDER BY created_at DESC"
)?;
let rows = stmt.query_map([project_id], |row| {
self.row_to_usage_record(row)
})?;
let mut records = Vec::new();
for row in rows {
records.push(row?);
}
Ok(records)
}
/// 获取素材使用记录按模板匹配结果ID
pub fn get_usage_records_by_matching_result(&self, matching_result_id: &str) -> Result<Vec<MaterialUsageRecord>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, material_segment_id, material_id, project_id,
template_matching_result_id, template_id, binding_id,
track_segment_id, usage_type, usage_context, created_at
FROM material_usage_records
WHERE template_matching_result_id = ?1
ORDER BY created_at DESC"
)?;
let rows = stmt.query_map([matching_result_id], |row| {
self.row_to_usage_record(row)
})?;
let mut records = Vec::new();
for row in rows {
records.push(row?);
}
Ok(records)
}
/// 获取素材使用统计信息
pub fn get_material_usage_stats(&self, project_id: &str) -> Result<Vec<MaterialUsageStats>> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT
m.id as material_id,
m.name as material_name,
COUNT(ms.id) as total_segments,
COUNT(CASE WHEN ms.is_used = 1 THEN 1 END) as used_segments,
COALESCE(SUM(ms.usage_count), 0) as total_usage_count,
MAX(ms.last_used_at) as last_used_at
FROM materials m
LEFT JOIN material_segments ms ON m.id = ms.material_id
WHERE m.project_id = ?1
GROUP BY m.id, m.name
ORDER BY m.name"
)?;
let rows = stmt.query_map([project_id], |row| {
let material_id: String = row.get("material_id")?;
let material_name: String = row.get("material_name")?;
let total_segments: u32 = row.get::<_, i64>("total_segments")? as u32;
let used_segments: u32 = row.get::<_, i64>("used_segments")? as u32;
let total_usage_count: u32 = row.get::<_, i64>("total_usage_count")? as u32;
let last_used_at: Option<DateTime<Utc>> = row.get::<_, Option<String>>("last_used_at")?
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc));
Ok(MaterialUsageStats::new(
material_id,
material_name,
total_segments,
used_segments,
total_usage_count,
last_used_at,
))
})?;
let mut stats = Vec::new();
for row in rows {
stats.push(row?);
}
Ok(stats)
}
/// 获取项目素材使用概览
pub fn get_project_usage_overview(&self, project_id: &str) -> Result<ProjectMaterialUsageOverview> {
let materials_stats = self.get_material_usage_stats(project_id)?;
Ok(ProjectMaterialUsageOverview::new(project_id.to_string(), materials_stats))
}
/// 将数据库行转换为素材使用记录对象
fn row_to_usage_record(&self, row: &Row) -> Result<MaterialUsageRecord> {
let usage_type_str: String = row.get("usage_type")?;
let usage_type: MaterialUsageType = serde_json::from_str(&usage_type_str)
.unwrap_or(MaterialUsageType::TemplateMatching);
let created_at_str: String = row.get("created_at")?;
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
.map_err(|_| rusqlite::Error::InvalidColumnType(10, "created_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
Ok(MaterialUsageRecord {
id: row.get("id")?,
material_segment_id: row.get("material_segment_id")?,
material_id: row.get("material_id")?,
project_id: row.get("project_id")?,
template_matching_result_id: row.get("template_matching_result_id")?,
template_id: row.get("template_id")?,
binding_id: row.get("binding_id")?,
track_segment_id: row.get("track_segment_id")?,
usage_type,
usage_context: row.get("usage_context")?,
created_at,
})
}
}

View File

@@ -1,5 +1,6 @@
pub mod project_repository; pub mod project_repository;
pub mod material_repository; pub mod material_repository;
pub mod material_usage_repository;
pub mod model_repository; pub mod model_repository;
pub mod ai_classification_repository; pub mod ai_classification_repository;
pub mod video_classification_repository; pub mod video_classification_repository;

View File

@@ -749,6 +749,28 @@ impl Database {
[], [],
)?; )?;
// 创建素材使用记录表
conn.execute(
"CREATE TABLE IF NOT EXISTS material_usage_records (
id TEXT PRIMARY KEY,
material_segment_id TEXT NOT NULL,
material_id TEXT NOT NULL,
project_id TEXT NOT NULL,
template_matching_result_id TEXT NOT NULL,
template_id TEXT NOT NULL,
binding_id TEXT NOT NULL,
track_segment_id TEXT NOT NULL,
usage_type TEXT NOT NULL DEFAULT 'TemplateMatching',
usage_context TEXT,
created_at DATETIME NOT NULL,
FOREIGN KEY (material_segment_id) REFERENCES material_segments (id) ON DELETE CASCADE,
FOREIGN KEY (material_id) REFERENCES materials (id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
FOREIGN KEY (template_matching_result_id) REFERENCES template_matching_results (id) ON DELETE CASCADE
)",
[],
)?;
// 创建性能监控表 // 创建性能监控表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS performance_metrics ( "CREATE TABLE IF NOT EXISTS performance_metrics (
@@ -1369,6 +1391,25 @@ impl Database {
println!("Added thumbnail_path column to materials table"); println!("Added thumbnail_path column to materials table");
} }
// 添加素材使用状态字段到素材片段表
let has_usage_count_column = conn.prepare("SELECT usage_count FROM material_segments LIMIT 1").is_ok();
if !has_usage_count_column {
println!("Adding usage tracking columns to material_segments table");
conn.execute(
"ALTER TABLE material_segments ADD COLUMN usage_count INTEGER DEFAULT 0",
[],
)?;
conn.execute(
"ALTER TABLE material_segments ADD COLUMN is_used BOOLEAN DEFAULT 0",
[],
)?;
conn.execute(
"ALTER TABLE material_segments ADD COLUMN last_used_at DATETIME",
[],
)?;
println!("Added usage tracking columns to material_segments table");
}
// 暂时禁用自动清理,避免启动时卡住 // 暂时禁用自动清理,避免启动时卡住
// self.cleanup_invalid_projects()?; // self.cleanup_invalid_projects()?;

View File

@@ -194,6 +194,16 @@ pub fn run() {
commands::material_matching_commands::execute_material_matching_with_save, commands::material_matching_commands::execute_material_matching_with_save,
commands::material_matching_commands::get_project_material_stats_for_matching, commands::material_matching_commands::get_project_material_stats_for_matching,
commands::material_matching_commands::validate_template_binding_for_matching, commands::material_matching_commands::validate_template_binding_for_matching,
// 素材使用记录命令
commands::material_usage_commands::create_material_usage_record,
commands::material_usage_commands::create_material_usage_records_batch,
commands::material_usage_commands::get_project_material_usage_records,
commands::material_usage_commands::get_matching_result_usage_records,
commands::material_usage_commands::get_project_material_usage_stats,
commands::material_usage_commands::get_project_material_usage_overview,
commands::material_usage_commands::create_usage_records_from_matching_result,
commands::material_usage_commands::reset_material_segment_usage,
commands::material_usage_commands::reset_project_material_usage,
// 模板匹配结果命令 // 模板匹配结果命令
commands::template_matching_result_commands::save_matching_result, commands::template_matching_result_commands::save_matching_result,
commands::template_matching_result_commands::get_matching_result_detail, commands::template_matching_result_commands::get_matching_result_detail,

View File

@@ -37,8 +37,13 @@ pub async fn execute_material_matching(
VideoClassificationRepository::new(database.inner().clone()) VideoClassificationRepository::new(database.inner().clone())
); );
let material_usage_repo = Arc::new(
crate::data::repositories::material_usage_repository::MaterialUsageRepository::new(database.inner().clone())
);
let matching_service = MaterialMatchingService::new( let matching_service = MaterialMatchingService::new(
material_repo, material_repo,
material_usage_repo,
template_service, template_service,
video_classification_repo, video_classification_repo,
); );
@@ -70,11 +75,15 @@ pub async fn execute_material_matching_with_save(
); );
// 创建匹配结果服务 // 创建匹配结果服务
let material_usage_repo = Arc::new(
crate::data::repositories::material_usage_repository::MaterialUsageRepository::new(database.inner().clone())
);
let matching_result_repo = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone())); let matching_result_repo = Arc::new(TemplateMatchingResultRepository::new(database.inner().clone()));
let matching_result_service = Arc::new(TemplateMatchingResultService::new(matching_result_repo)); let matching_result_service = Arc::new(TemplateMatchingResultService::new(matching_result_repo));
let matching_service = MaterialMatchingService::new_with_result_service( let matching_service = MaterialMatchingService::new_with_result_service(
material_repo, material_repo,
material_usage_repo,
template_service, template_service,
video_classification_repo, video_classification_repo,
matching_result_service, matching_result_service,

View File

@@ -0,0 +1,240 @@
use tauri::State;
use std::sync::Arc;
use crate::data::models::material_usage::{
CreateMaterialUsageRecordRequest, MaterialUsageRecord, MaterialUsageStats,
ProjectMaterialUsageOverview, MaterialUsageType
};
use crate::data::repositories::material_usage_repository::MaterialUsageRepository;
use crate::app_state::AppState;
/// 创建素材使用记录
#[tauri::command]
pub async fn create_material_usage_record(
state: State<'_, AppState>,
request: CreateMaterialUsageRecordRequest,
) -> Result<MaterialUsageRecord, String> {
let database = state.get_database();
let repo = MaterialUsageRepository::new(database);
// 验证请求
request.validate().map_err(|e| format!("请求验证失败: {}", e))?;
repo.create_usage_record(request)
.map_err(|e| format!("创建素材使用记录失败: {}", e))
}
/// 批量创建素材使用记录
#[tauri::command]
pub async fn create_material_usage_records_batch(
state: State<'_, AppState>,
requests: Vec<CreateMaterialUsageRecordRequest>,
) -> Result<Vec<MaterialUsageRecord>, String> {
let database = state.get_database();
let repo = MaterialUsageRepository::new(database);
// 验证所有请求
for request in &requests {
request.validate().map_err(|e| format!("请求验证失败: {}", e))?;
}
repo.create_usage_records_batch(requests)
.map_err(|e| format!("批量创建素材使用记录失败: {}", e))
}
/// 获取项目的素材使用记录
#[tauri::command]
pub async fn get_project_material_usage_records(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<MaterialUsageRecord>, String> {
let database = state.get_database();
let repo = MaterialUsageRepository::new(database);
repo.get_usage_records_by_project(&project_id)
.map_err(|e| format!("获取项目素材使用记录失败: {}", e))
}
/// 获取模板匹配结果的素材使用记录
#[tauri::command]
pub async fn get_matching_result_usage_records(
state: State<'_, AppState>,
matching_result_id: String,
) -> Result<Vec<MaterialUsageRecord>, String> {
let database = state.get_database();
let repo = MaterialUsageRepository::new(database);
repo.get_usage_records_by_matching_result(&matching_result_id)
.map_err(|e| format!("获取匹配结果使用记录失败: {}", e))
}
/// 获取项目的素材使用统计信息
#[tauri::command]
pub async fn get_project_material_usage_stats(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<MaterialUsageStats>, String> {
let database = state.get_database();
let repo = MaterialUsageRepository::new(database);
repo.get_material_usage_stats(&project_id)
.map_err(|e| format!("获取素材使用统计失败: {}", e))
}
/// 获取项目的素材使用概览
#[tauri::command]
pub async fn get_project_material_usage_overview(
state: State<'_, AppState>,
project_id: String,
) -> Result<ProjectMaterialUsageOverview, String> {
let database = state.get_database();
let repo = MaterialUsageRepository::new(database);
repo.get_project_usage_overview(&project_id)
.map_err(|e| format!("获取项目素材使用概览失败: {}", e))
}
/// 从匹配结果创建素材使用记录
/// 这是一个便捷方法,用于在应用匹配结果时自动创建使用记录
#[tauri::command]
pub async fn create_usage_records_from_matching_result(
state: State<'_, AppState>,
project_id: String,
template_id: String,
binding_id: String,
template_matching_result_id: String,
matches: Vec<serde_json::Value>, // 匹配结果的JSON格式
) -> Result<Vec<MaterialUsageRecord>, String> {
let database = state.get_database();
let repo = MaterialUsageRepository::new(database);
let mut requests = Vec::new();
// 解析匹配结果并创建使用记录请求
for match_value in matches {
// 尝试解析匹配结果
let segment_result = match_value.get("segment_result");
let track_segment = match_value.get("track_segment");
if let (Some(segment_result), Some(track_segment)) = (segment_result, track_segment) {
// 提取必要的字段
let material_segment_id = segment_result.get("material_segment_id")
.and_then(|v| v.as_str())
.ok_or("缺少material_segment_id字段")?;
let material_id = segment_result.get("material_id")
.and_then(|v| v.as_str())
.ok_or("缺少material_id字段")?;
let track_segment_id = track_segment.get("id")
.and_then(|v| v.as_str())
.ok_or("缺少track_segment_id字段")?;
let match_score = segment_result.get("match_score")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let match_reason = segment_result.get("match_reason")
.and_then(|v| v.as_str())
.unwrap_or("模板匹配");
// 创建使用上下文
let usage_context = serde_json::json!({
"match_score": match_score,
"match_reason": match_reason,
"usage_timestamp": chrono::Utc::now().to_rfc3339()
}).to_string();
// 创建使用记录请求
let request = CreateMaterialUsageRecordRequest {
material_segment_id: material_segment_id.to_string(),
material_id: material_id.to_string(),
project_id: project_id.clone(),
template_matching_result_id: template_matching_result_id.clone(),
template_id: template_id.clone(),
binding_id: binding_id.clone(),
track_segment_id: track_segment_id.to_string(),
usage_type: MaterialUsageType::TemplateMatching,
usage_context: Some(usage_context),
};
requests.push(request);
}
}
if requests.is_empty() {
return Ok(Vec::new());
}
// 批量创建使用记录
repo.create_usage_records_batch(requests)
.map_err(|e| format!("从匹配结果创建使用记录失败: {}", e))
}
/// 重置素材片段的使用状态
#[tauri::command]
pub async fn reset_material_segment_usage(
state: State<'_, AppState>,
segment_ids: Vec<String>,
) -> Result<String, String> {
let database = state.get_database();
let conn = database.get_connection();
let conn = conn.lock().unwrap();
// 开始事务
let tx = conn.unchecked_transaction()
.map_err(|e| format!("开始事务失败: {}", e))?;
// 删除使用记录
for segment_id in &segment_ids {
tx.execute(
"DELETE FROM material_usage_records WHERE material_segment_id = ?1",
[segment_id],
).map_err(|e| format!("删除使用记录失败: {}", e))?;
// 重置素材片段状态
tx.execute(
"UPDATE material_segments SET usage_count = 0, is_used = 0, last_used_at = NULL WHERE id = ?1",
[segment_id],
).map_err(|e| format!("重置片段状态失败: {}", e))?;
}
// 提交事务
tx.commit().map_err(|e| format!("提交事务失败: {}", e))?;
Ok(format!("成功重置 {} 个素材片段的使用状态", segment_ids.len()))
}
/// 重置项目所有素材的使用状态
#[tauri::command]
pub async fn reset_project_material_usage(
state: State<'_, AppState>,
project_id: String,
) -> Result<String, String> {
let database = state.get_database();
let conn = database.get_connection();
let conn = conn.lock().unwrap();
// 开始事务
let tx = conn.unchecked_transaction()
.map_err(|e| format!("开始事务失败: {}", e))?;
// 删除项目的所有使用记录
let deleted_records = tx.execute(
"DELETE FROM material_usage_records WHERE project_id = ?1",
[&project_id],
).map_err(|e| format!("删除使用记录失败: {}", e))?;
// 重置项目所有素材片段状态
let updated_segments = tx.execute(
"UPDATE material_segments SET usage_count = 0, is_used = 0, last_used_at = NULL
WHERE material_id IN (SELECT id FROM materials WHERE project_id = ?1)",
[&project_id],
).map_err(|e| format!("重置片段状态失败: {}", e))?;
// 提交事务
tx.commit().map_err(|e| format!("提交事务失败: {}", e))?;
Ok(format!("成功重置项目素材使用状态:删除 {} 条使用记录,重置 {} 个素材片段",
deleted_records, updated_segments))
}

View File

@@ -1,6 +1,7 @@
pub mod project_commands; pub mod project_commands;
pub mod system_commands; pub mod system_commands;
pub mod material_commands; pub mod material_commands;
pub mod material_usage_commands;
pub mod database_commands; pub mod database_commands;
pub mod material_segment_view_commands; pub mod material_segment_view_commands;
pub mod model_commands; pub mod model_commands;

View File

@@ -373,7 +373,7 @@ export const ProjectDetails: React.FC = () => {
const description = `模板 ${currentMatchingBinding.template_name} 的匹配结果,成功匹配 ${result.statistics.matched_segments} 个片段`; const description = `模板 ${currentMatchingBinding.template_name} 的匹配结果,成功匹配 ${result.statistics.matched_segments} 个片段`;
// 调用后端API保存匹配结果 // 调用后端API保存匹配结果
await invoke('save_matching_result', { const savedResult = await invoke('save_matching_result', {
serviceResult: { serviceResult: {
project_id: project?.id, project_id: project?.id,
template_id: currentMatchingBinding.binding.template_id, template_id: currentMatchingBinding.binding.template_id,
@@ -388,6 +388,23 @@ export const ProjectDetails: React.FC = () => {
matchingDurationMs: 0 // 使用默认值 matchingDurationMs: 0 // 使用默认值
}); });
// 创建素材使用记录
if (savedResult && typeof savedResult === 'object' && 'id' in savedResult) {
try {
await invoke('create_usage_records_from_matching_result', {
project_id: project?.id,
template_id: currentMatchingBinding.binding.template_id,
binding_id: currentMatchingBinding.binding.id,
template_matching_result_id: (savedResult as any).id,
matches: result.matches
});
console.log('素材使用记录创建成功');
} catch (usageError) {
console.warn('创建素材使用记录失败,但匹配结果已保存:', usageError);
// 不阻断主流程,只记录警告
}
}
// 关闭对话框 // 关闭对话框
setShowMatchingResultDialog(false); setShowMatchingResultDialog(false);
setMatchingResult(null); setMatchingResult(null);