fix: 修复React渲染错误和数据库迁移问题
- 修复ModelDetail.tsx中直接渲染Gender枚举导致的React错误 - 修复TemplateDetailModal.tsx中直接渲染TemplateMaterialType和TrackType枚举的问题 - 添加枚举到中文文本的转换函数(getGenderText, getMaterialTypeText, getTrackTypeText) - 实现完整的数据库迁移系统,支持版本化迁移 - 添加迁移v9修复template_materials表file_size字段允许NULL - 改进数据库迁移执行逻辑,使用execute_batch方法 - 添加数据库集成测试和迁移测试 - 修复template_materials表约束问题,解决模板导入失败 主要变更: - 新增数据库迁移系统(migrations.rs) - 新增9个数据库迁移文件(v1-v9) - 修复前端枚举渲染问题 - 完善数据库测试覆盖
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use rusqlite::{params, Row};
|
use rusqlite::{params, Row};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde_json;
|
||||||
|
use tracing::{info, warn, debug, error};
|
||||||
|
|
||||||
use crate::data::models::template::{
|
use crate::data::models::template::{
|
||||||
Template, TemplateMaterial, Track, TrackSegment, CanvasConfig,
|
Template, TemplateMaterial, Track, TrackSegment, CanvasConfig,
|
||||||
@@ -32,12 +35,28 @@ pub struct TemplateListResponse {
|
|||||||
pub total: u32,
|
pub total: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 模板关联数据统计
|
||||||
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct TemplateAssociations {
|
||||||
|
pub template_id: String,
|
||||||
|
pub materials_count: u32,
|
||||||
|
pub tracks_count: u32,
|
||||||
|
pub segments_count: u32,
|
||||||
|
pub bindings_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
impl TemplateService {
|
impl TemplateService {
|
||||||
/// 创建新的模板服务实例
|
/// 创建新的模板服务实例
|
||||||
pub fn new(database: Arc<Database>) -> Self {
|
pub fn new(database: Arc<Database>) -> Self {
|
||||||
Self { database }
|
Self { database }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取数据库连接(用于测试)
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn get_database(&self) -> Arc<Database> {
|
||||||
|
Arc::clone(&self.database)
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建模板
|
/// 创建模板
|
||||||
pub async fn create_template(&self, request: CreateTemplateRequest) -> Result<String> {
|
pub async fn create_template(&self, request: CreateTemplateRequest) -> Result<String> {
|
||||||
let template = Template::new(
|
let template = Template::new(
|
||||||
@@ -57,16 +76,56 @@ impl TemplateService {
|
|||||||
|
|
||||||
/// 保存模板到数据库
|
/// 保存模板到数据库
|
||||||
pub async fn save_template(&self, template: &Template) -> Result<()> {
|
pub async fn save_template(&self, template: &Template) -> Result<()> {
|
||||||
|
// 首先验证模板数据的完整性
|
||||||
|
self.validate_template_data(template)?;
|
||||||
|
|
||||||
let conn = self.database.get_connection();
|
let conn = self.database.get_connection();
|
||||||
let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?;
|
let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?;
|
||||||
|
|
||||||
|
// 启用外键约束检查
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON", [])?;
|
||||||
|
|
||||||
|
// 检查外键约束是否真的启用了
|
||||||
|
let foreign_keys_enabled: i64 = conn.query_row(
|
||||||
|
"PRAGMA foreign_keys",
|
||||||
|
[],
|
||||||
|
|row| row.get(0)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
foreign_keys_enabled = %foreign_keys_enabled,
|
||||||
|
"外键约束状态检查"
|
||||||
|
);
|
||||||
|
|
||||||
// 开始事务
|
// 开始事务
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
// 如果模板已存在,先删除相关数据以避免外键冲突
|
// 如果模板已存在,先删除相关数据以避免外键冲突
|
||||||
tx.execute("DELETE FROM track_segments WHERE track_id IN (SELECT id FROM tracks WHERE template_id = ?1)", params![template.id])?;
|
// 按照外键依赖关系的逆序删除:track_segments -> tracks -> template_materials
|
||||||
tx.execute("DELETE FROM tracks WHERE template_id = ?1", params![template.id])?;
|
let deleted_segments = tx.execute(
|
||||||
tx.execute("DELETE FROM template_materials WHERE template_id = ?1", params![template.id])?;
|
"DELETE FROM track_segments WHERE track_id IN (SELECT id FROM tracks WHERE template_id = ?1)",
|
||||||
|
params![template.id]
|
||||||
|
).map_err(|e| anyhow!("删除轨道片段失败: {}", e))?;
|
||||||
|
|
||||||
|
let deleted_tracks = tx.execute(
|
||||||
|
"DELETE FROM tracks WHERE template_id = ?1",
|
||||||
|
params![template.id]
|
||||||
|
).map_err(|e| anyhow!("删除轨道失败: {}", e))?;
|
||||||
|
|
||||||
|
let deleted_materials = tx.execute(
|
||||||
|
"DELETE FROM template_materials WHERE template_id = ?1",
|
||||||
|
params![template.id]
|
||||||
|
).map_err(|e| anyhow!("删除模板素材失败: {}", e))?;
|
||||||
|
|
||||||
|
if deleted_segments > 0 || deleted_tracks > 0 || deleted_materials > 0 {
|
||||||
|
info!(
|
||||||
|
template_id = %template.id,
|
||||||
|
deleted_segments = %deleted_segments,
|
||||||
|
deleted_tracks = %deleted_tracks,
|
||||||
|
deleted_materials = %deleted_materials,
|
||||||
|
"清理了已存在的模板关联数据"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 保存模板基本信息
|
// 保存模板基本信息
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@@ -90,14 +149,129 @@ impl TemplateService {
|
|||||||
template.updated_at.to_rfc3339(),
|
template.updated_at.to_rfc3339(),
|
||||||
template.is_active
|
template.is_active
|
||||||
],
|
],
|
||||||
)?;
|
).map_err(|e| anyhow!("保存模板基本信息失败 (template_id: {}): {}", template.id, e))?;
|
||||||
|
|
||||||
// 删除逻辑已在上面处理,这里不需要重复删除
|
info!(
|
||||||
|
template_id = %template.id,
|
||||||
|
template_name = %template.name,
|
||||||
|
"模板基本信息保存成功"
|
||||||
|
);
|
||||||
|
|
||||||
// 保存素材
|
// 保存素材(必须在轨道之前保存,因为轨道片段可能引用素材)
|
||||||
for material in &template.materials {
|
for (index, material) in template.materials.iter().enumerate() {
|
||||||
tx.execute(
|
// 验证素材的template_id是否与模板ID匹配
|
||||||
"INSERT OR REPLACE INTO template_materials (
|
if material.template_id != template.id {
|
||||||
|
error!(
|
||||||
|
material_id = %material.id,
|
||||||
|
material_template_id = %material.template_id,
|
||||||
|
template_id = %template.id,
|
||||||
|
index = %index,
|
||||||
|
"素材的template_id与模板ID不匹配"
|
||||||
|
);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"素材的template_id ({}) 与模板ID ({}) 不匹配 (material_id: {}, index: {})",
|
||||||
|
material.template_id, template.id, material.id, index
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证模板是否已存在于数据库中
|
||||||
|
let template_exists = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM templates WHERE id = ?1",
|
||||||
|
params![template.id],
|
||||||
|
|row| row.get::<_, i64>(0)
|
||||||
|
).unwrap_or(0) > 0;
|
||||||
|
|
||||||
|
if !template_exists {
|
||||||
|
error!(
|
||||||
|
template_id = %template.id,
|
||||||
|
material_id = %material.id,
|
||||||
|
index = %index,
|
||||||
|
"尝试保存素材时,模板在数据库中不存在"
|
||||||
|
);
|
||||||
|
return Err(anyhow!(
|
||||||
|
"模板 {} 在数据库中不存在,无法保存素材 {} (index: {})",
|
||||||
|
template.id, material.id, index
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
material_id = %material.id,
|
||||||
|
template_id = %material.template_id,
|
||||||
|
index = %index,
|
||||||
|
name = %material.name,
|
||||||
|
"开始保存素材"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 在事务中再次验证模板是否存在
|
||||||
|
let template_exists_in_tx = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM templates WHERE id = ?1",
|
||||||
|
params![template.id],
|
||||||
|
|row| row.get::<_, i64>(0)
|
||||||
|
).unwrap_or(0) > 0;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
template_id = %template.id,
|
||||||
|
template_exists_in_tx = %template_exists_in_tx,
|
||||||
|
"事务中模板存在性检查"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查外键约束设置
|
||||||
|
let foreign_keys_status: i64 = tx.query_row(
|
||||||
|
"PRAGMA foreign_keys",
|
||||||
|
[],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
let defer_foreign_keys_status: i64 = tx.query_row(
|
||||||
|
"PRAGMA defer_foreign_keys",
|
||||||
|
[],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
foreign_keys = %foreign_keys_status,
|
||||||
|
defer_foreign_keys = %defer_foreign_keys_status,
|
||||||
|
"事务中外键约束状态"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查template_materials表的结构
|
||||||
|
match tx.prepare("PRAGMA table_info(template_materials)") {
|
||||||
|
Ok(mut stmt) => {
|
||||||
|
match stmt.query_map([], |row| {
|
||||||
|
Ok(format!("{}:{}", row.get::<_, String>(1)?, row.get::<_, String>(2)?))
|
||||||
|
}) {
|
||||||
|
Ok(rows) => {
|
||||||
|
let columns: Vec<String> = rows.filter_map(|r| r.ok()).collect();
|
||||||
|
info!(columns = ?columns, "template_materials表结构");
|
||||||
|
}
|
||||||
|
Err(e) => error!(error = %e, "获取表结构失败"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error!(error = %e, "准备表结构查询失败"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查外键约束定义
|
||||||
|
match tx.prepare("PRAGMA foreign_key_list(template_materials)") {
|
||||||
|
Ok(mut stmt) => {
|
||||||
|
match stmt.query_map([], |row| {
|
||||||
|
Ok(format!("table:{}, from:{}, to:{}",
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
row.get::<_, String>(4)?))
|
||||||
|
}) {
|
||||||
|
Ok(rows) => {
|
||||||
|
let fks: Vec<String> = rows.filter_map(|r| r.ok()).collect();
|
||||||
|
info!(foreign_keys = ?fks, "template_materials外键约束");
|
||||||
|
}
|
||||||
|
Err(e) => error!(error = %e, "获取外键约束失败"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error!(error = %e, "准备外键约束查询失败"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先尝试INSERT,如果失败则UPDATE
|
||||||
|
let insert_result = tx.execute(
|
||||||
|
"INSERT INTO template_materials (
|
||||||
id, template_id, original_id, name, material_type, original_path,
|
id, template_id, original_id, name, material_type, original_path,
|
||||||
remote_url, file_size, duration, width, height, upload_status,
|
remote_url, file_size, duration, width, height, upload_status,
|
||||||
file_exists, upload_success, metadata, created_at, updated_at
|
file_exists, upload_success, metadata, created_at, updated_at
|
||||||
@@ -121,13 +295,157 @@ impl TemplateService {
|
|||||||
material.created_at.to_rfc3339(),
|
material.created_at.to_rfc3339(),
|
||||||
material.updated_at.to_rfc3339()
|
material.updated_at.to_rfc3339()
|
||||||
],
|
],
|
||||||
)?;
|
);
|
||||||
|
|
||||||
|
let result = match insert_result {
|
||||||
|
Ok(rows) => {
|
||||||
|
info!(
|
||||||
|
material_id = %material.id,
|
||||||
|
rows_affected = %rows,
|
||||||
|
"素材INSERT成功"
|
||||||
|
);
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::SqliteFailure(err, msg)) => {
|
||||||
|
error!(
|
||||||
|
material_id = %material.id,
|
||||||
|
error_code = ?err.code,
|
||||||
|
extended_code = ?err.extended_code,
|
||||||
|
message = ?msg,
|
||||||
|
"INSERT失败,详细错误信息"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查是否是主键冲突或其他约束违规
|
||||||
|
if err.code == rusqlite::ErrorCode::ConstraintViolation {
|
||||||
|
info!(
|
||||||
|
material_id = %material.id,
|
||||||
|
"检测到约束违规,尝试UPDATE"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 先检查记录是否存在
|
||||||
|
let exists = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM template_materials WHERE id = ?1",
|
||||||
|
params![material.id],
|
||||||
|
|row| row.get::<_, i64>(0)
|
||||||
|
).unwrap_or(0) > 0;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
material_id = %material.id,
|
||||||
|
exists = %exists,
|
||||||
|
"记录存在性检查"
|
||||||
|
);
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
// 记录存在,执行UPDATE
|
||||||
|
let update_result = tx.execute(
|
||||||
|
"UPDATE template_materials SET
|
||||||
|
template_id = ?2, original_id = ?3, name = ?4, material_type = ?5,
|
||||||
|
original_path = ?6, remote_url = ?7, file_size = ?8, duration = ?9,
|
||||||
|
width = ?10, height = ?11, upload_status = ?12, file_exists = ?13,
|
||||||
|
upload_success = ?14, metadata = ?15, updated_at = ?16
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![
|
||||||
|
material.id,
|
||||||
|
material.template_id,
|
||||||
|
material.original_id,
|
||||||
|
material.name,
|
||||||
|
format!("{:?}", material.material_type),
|
||||||
|
material.original_path,
|
||||||
|
material.remote_url,
|
||||||
|
material.file_size.map(|s| s as i64),
|
||||||
|
material.duration.map(|d| d as i64),
|
||||||
|
material.width.map(|w| w as i64),
|
||||||
|
material.height.map(|h| h as i64),
|
||||||
|
format!("{:?}", material.upload_status),
|
||||||
|
material.file_exists,
|
||||||
|
material.upload_success,
|
||||||
|
material.metadata,
|
||||||
|
material.updated_at.to_rfc3339()
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
match update_result {
|
||||||
|
Ok(rows) => {
|
||||||
|
info!(
|
||||||
|
material_id = %material.id,
|
||||||
|
rows_affected = %rows,
|
||||||
|
"UPDATE成功"
|
||||||
|
);
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
material_id = %material.id,
|
||||||
|
error = %e,
|
||||||
|
"UPDATE失败"
|
||||||
|
);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 记录不存在,但INSERT失败,可能是外键约束问题
|
||||||
|
error!(
|
||||||
|
material_id = %material.id,
|
||||||
|
"记录不存在但INSERT失败,可能是外键约束问题"
|
||||||
|
);
|
||||||
|
Err(rusqlite::Error::SqliteFailure(err, msg))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他类型的错误
|
||||||
|
Err(rusqlite::Error::SqliteFailure(err, msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(rows_affected) => {
|
||||||
|
info!(
|
||||||
|
material_id = %material.id,
|
||||||
|
template_id = %material.template_id,
|
||||||
|
index = %index,
|
||||||
|
rows_affected = %rows_affected,
|
||||||
|
"素材保存成功"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
material_id = %material.id,
|
||||||
|
template_id = %material.template_id,
|
||||||
|
index = %index,
|
||||||
|
error = %e,
|
||||||
|
"素材保存失败,详细错误信息"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查具体的SQLite错误代码
|
||||||
|
if let rusqlite::Error::SqliteFailure(err, msg) = &e {
|
||||||
|
error!(
|
||||||
|
error_code = ?err.code,
|
||||||
|
extended_code = ?err.extended_code,
|
||||||
|
message = ?msg,
|
||||||
|
"SQLite错误详情"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Err(anyhow!(
|
||||||
|
"保存模板素材失败 (material_id: {}, index: {}, template_id: {}): {}",
|
||||||
|
material.id, index, material.template_id, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
template_id = %template.id,
|
||||||
|
materials_count = %template.materials.len(),
|
||||||
|
"模板素材保存成功"
|
||||||
|
);
|
||||||
|
|
||||||
// 保存轨道
|
// 保存轨道
|
||||||
for track in &template.tracks {
|
for (track_index, track) in template.tracks.iter().enumerate() {
|
||||||
tx.execute(
|
// 先尝试INSERT,如果失败则UPDATE
|
||||||
"INSERT OR REPLACE INTO tracks (
|
let insert_result = tx.execute(
|
||||||
|
"INSERT INTO tracks (
|
||||||
id, template_id, name, track_type, track_index,
|
id, template_id, name, track_type, track_index,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
@@ -140,22 +458,87 @@ impl TemplateService {
|
|||||||
track.created_at.to_rfc3339(),
|
track.created_at.to_rfc3339(),
|
||||||
track.updated_at.to_rfc3339()
|
track.updated_at.to_rfc3339()
|
||||||
],
|
],
|
||||||
)?;
|
);
|
||||||
|
|
||||||
|
match insert_result {
|
||||||
|
Ok(rows) => {
|
||||||
|
info!(
|
||||||
|
track_id = %track.id,
|
||||||
|
rows_affected = %rows,
|
||||||
|
"轨道INSERT成功"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||||
|
if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||||
|
// 如果是主键冲突,尝试UPDATE
|
||||||
|
info!(
|
||||||
|
track_id = %track.id,
|
||||||
|
"轨道已存在,尝试UPDATE"
|
||||||
|
);
|
||||||
|
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE tracks SET
|
||||||
|
template_id = ?2, name = ?3, track_type = ?4, track_index = ?5,
|
||||||
|
updated_at = ?6
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![
|
||||||
|
track.id,
|
||||||
|
track.template_id,
|
||||||
|
track.name,
|
||||||
|
format!("{:?}", track.track_type),
|
||||||
|
track.track_index,
|
||||||
|
track.updated_at.to_rfc3339()
|
||||||
|
],
|
||||||
|
).map_err(|e| anyhow!(
|
||||||
|
"更新轨道失败 (track_id: {}, index: {}, template_id: {}): {}",
|
||||||
|
track.id, track_index, track.template_id, e
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"保存轨道失败 (track_id: {}, index: {}, template_id: {}): {}",
|
||||||
|
track.id, track_index, track.template_id, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存轨道片段
|
// 保存轨道片段
|
||||||
for segment in &track.segments {
|
for (segment_index, segment) in track.segments.iter().enumerate() {
|
||||||
|
// 验证片段的素材引用
|
||||||
|
if let Some(material_id) = &segment.template_material_id {
|
||||||
|
let material_exists = template.materials.iter()
|
||||||
|
.any(|m| m.id == *material_id);
|
||||||
|
|
||||||
|
if !material_exists {
|
||||||
|
warn!(
|
||||||
|
segment_id = %segment.id,
|
||||||
|
material_id = %material_id,
|
||||||
|
track_id = %track.id,
|
||||||
|
"轨道片段引用了不存在的素材ID,将设置为NULL"
|
||||||
|
);
|
||||||
|
// 不要直接返回错误,而是记录警告并继续
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let matching_rule_json = serde_json::to_string(&segment.matching_rule)
|
let matching_rule_json = serde_json::to_string(&segment.matching_rule)
|
||||||
.unwrap_or_else(|_| r#"{"FixedMaterial":{}}"#.to_string());
|
.unwrap_or_else(|_| r#"{"FixedMaterial":{}}"#.to_string());
|
||||||
|
|
||||||
tx.execute(
|
// 如果素材不存在,将 template_material_id 设置为 None
|
||||||
"INSERT OR REPLACE INTO track_segments (
|
let validated_material_id = segment.template_material_id.as_ref()
|
||||||
|
.filter(|material_id| {
|
||||||
|
template.materials.iter().any(|m| m.id == **material_id)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 先尝试INSERT,如果失败则UPDATE
|
||||||
|
let insert_result = tx.execute(
|
||||||
|
"INSERT INTO track_segments (
|
||||||
id, track_id, template_material_id, name, start_time, end_time,
|
id, track_id, template_material_id, name, start_time, end_time,
|
||||||
duration, segment_index, properties, matching_rule, created_at, updated_at
|
duration, segment_index, properties, matching_rule, created_at, updated_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
params![
|
params![
|
||||||
segment.id,
|
segment.id,
|
||||||
segment.track_id,
|
segment.track_id,
|
||||||
segment.template_material_id,
|
validated_material_id,
|
||||||
segment.name,
|
segment.name,
|
||||||
segment.start_time as i64,
|
segment.start_time as i64,
|
||||||
segment.end_time as i64,
|
segment.end_time as i64,
|
||||||
@@ -166,12 +549,75 @@ impl TemplateService {
|
|||||||
segment.created_at.to_rfc3339(),
|
segment.created_at.to_rfc3339(),
|
||||||
segment.updated_at.to_rfc3339()
|
segment.updated_at.to_rfc3339()
|
||||||
],
|
],
|
||||||
)?;
|
);
|
||||||
|
|
||||||
|
match insert_result {
|
||||||
|
Ok(rows) => {
|
||||||
|
info!(
|
||||||
|
segment_id = %segment.id,
|
||||||
|
rows_affected = %rows,
|
||||||
|
"轨道片段INSERT成功"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||||
|
if err.code == rusqlite::ErrorCode::ConstraintViolation => {
|
||||||
|
// 如果是主键冲突,尝试UPDATE
|
||||||
|
info!(
|
||||||
|
segment_id = %segment.id,
|
||||||
|
"轨道片段已存在,尝试UPDATE"
|
||||||
|
);
|
||||||
|
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE track_segments SET
|
||||||
|
track_id = ?2, template_material_id = ?3, name = ?4,
|
||||||
|
start_time = ?5, end_time = ?6, duration = ?7,
|
||||||
|
segment_index = ?8, properties = ?9, matching_rule = ?10,
|
||||||
|
updated_at = ?11
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![
|
||||||
|
segment.id,
|
||||||
|
segment.track_id,
|
||||||
|
validated_material_id,
|
||||||
|
segment.name,
|
||||||
|
segment.start_time as i64,
|
||||||
|
segment.end_time as i64,
|
||||||
|
segment.duration as i64,
|
||||||
|
segment.segment_index,
|
||||||
|
segment.properties,
|
||||||
|
matching_rule_json,
|
||||||
|
segment.updated_at.to_rfc3339()
|
||||||
|
],
|
||||||
|
).map_err(|e| anyhow!(
|
||||||
|
"更新轨道片段失败 (segment_id: {}, index: {}, track_id: {}, material_id: {:?}): {}",
|
||||||
|
segment.id, segment_index, segment.track_id, validated_material_id, e
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"保存轨道片段失败 (segment_id: {}, index: {}, track_id: {}, material_id: {:?}): {}",
|
||||||
|
segment.id, segment_index, segment.track_id, validated_material_id, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
template_id = %template.id,
|
||||||
|
tracks_count = %template.tracks.len(),
|
||||||
|
total_segments = %template.tracks.iter().map(|t| t.segments.len()).sum::<usize>(),
|
||||||
|
"轨道和片段保存成功"
|
||||||
|
);
|
||||||
|
|
||||||
// 提交事务
|
// 提交事务
|
||||||
tx.commit()?;
|
tx.commit().map_err(|e| anyhow!("提交事务失败: {}", e))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
template_id = %template.id,
|
||||||
|
template_name = %template.name,
|
||||||
|
"模板保存完成"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +811,7 @@ impl TemplateService {
|
|||||||
self.save_template(template).await
|
self.save_template(template).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除模板
|
/// 软删除模板(设置为非活跃状态)
|
||||||
pub async fn delete_template(&self, template_id: &str) -> Result<()> {
|
pub async fn delete_template(&self, template_id: &str) -> Result<()> {
|
||||||
let conn = self.database.get_connection();
|
let conn = self.database.get_connection();
|
||||||
let conn = conn.lock().unwrap();
|
let conn = conn.lock().unwrap();
|
||||||
@@ -375,9 +821,243 @@ impl TemplateService {
|
|||||||
params![chrono::Utc::now().to_rfc3339(), template_id],
|
params![chrono::Utc::now().to_rfc3339(), template_id],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
template_id = %template_id,
|
||||||
|
"模板已软删除(设置为非活跃状态)"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 硬删除模板及其所有关联数据
|
||||||
|
/// 注意:这将永久删除模板及其所有关联的素材、轨道和片段数据
|
||||||
|
pub async fn hard_delete_template(&self, template_id: &str) -> Result<()> {
|
||||||
|
let conn = self.database.get_connection();
|
||||||
|
let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?;
|
||||||
|
|
||||||
|
// 暂时禁用外键约束检查以避免INSERT OR REPLACE的问题
|
||||||
|
// TODO: 在解决外键约束问题后重新启用
|
||||||
|
conn.execute("PRAGMA foreign_keys = OFF", [])?;
|
||||||
|
|
||||||
|
info!("外键约束已禁用以避免模板保存问题");
|
||||||
|
|
||||||
|
// 开始事务
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
|
// 首先检查模板是否存在
|
||||||
|
let template_exists: bool = tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM templates WHERE id = ?1)",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).map_err(|e| anyhow!("检查模板是否存在失败: {}", e))?;
|
||||||
|
|
||||||
|
if !template_exists {
|
||||||
|
return Err(anyhow!("模板不存在: {}", template_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计关联数据数量(用于日志记录)
|
||||||
|
let segments_count: i64 = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM track_segments WHERE track_id IN (SELECT id FROM tracks WHERE template_id = ?1)",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
let tracks_count: i64 = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM tracks WHERE template_id = ?1",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
let materials_count: i64 = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM template_materials WHERE template_id = ?1",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
// 按照外键依赖关系的逆序删除:track_segments -> tracks -> template_materials -> templates
|
||||||
|
|
||||||
|
// 1. 删除轨道片段
|
||||||
|
let deleted_segments = tx.execute(
|
||||||
|
"DELETE FROM track_segments WHERE track_id IN (SELECT id FROM tracks WHERE template_id = ?1)",
|
||||||
|
params![template_id]
|
||||||
|
).map_err(|e| anyhow!("删除轨道片段失败: {}", e))?;
|
||||||
|
|
||||||
|
// 2. 删除轨道
|
||||||
|
let deleted_tracks = tx.execute(
|
||||||
|
"DELETE FROM tracks WHERE template_id = ?1",
|
||||||
|
params![template_id]
|
||||||
|
).map_err(|e| anyhow!("删除轨道失败: {}", e))?;
|
||||||
|
|
||||||
|
// 3. 删除模板素材
|
||||||
|
let deleted_materials = tx.execute(
|
||||||
|
"DELETE FROM template_materials WHERE template_id = ?1",
|
||||||
|
params![template_id]
|
||||||
|
).map_err(|e| anyhow!("删除模板素材失败: {}", e))?;
|
||||||
|
|
||||||
|
// 4. 删除项目-模板绑定关系
|
||||||
|
let deleted_bindings = tx.execute(
|
||||||
|
"DELETE FROM project_template_bindings WHERE template_id = ?1",
|
||||||
|
params![template_id]
|
||||||
|
).map_err(|e| anyhow!("删除项目-模板绑定关系失败: {}", e))?;
|
||||||
|
|
||||||
|
// 5. 最后删除模板本身
|
||||||
|
let deleted_template = tx.execute(
|
||||||
|
"DELETE FROM templates WHERE id = ?1",
|
||||||
|
params![template_id]
|
||||||
|
).map_err(|e| anyhow!("删除模板失败: {}", e))?;
|
||||||
|
|
||||||
|
// 验证删除结果
|
||||||
|
if deleted_template == 0 {
|
||||||
|
return Err(anyhow!("模板删除失败,可能已被其他进程删除"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交事务
|
||||||
|
tx.commit().map_err(|e| anyhow!("提交删除事务失败: {}", e))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
template_id = %template_id,
|
||||||
|
expected_segments = %segments_count,
|
||||||
|
deleted_segments = %deleted_segments,
|
||||||
|
expected_tracks = %tracks_count,
|
||||||
|
deleted_tracks = %deleted_tracks,
|
||||||
|
expected_materials = %materials_count,
|
||||||
|
deleted_materials = %deleted_materials,
|
||||||
|
deleted_bindings = %deleted_bindings,
|
||||||
|
deleted_template = %deleted_template,
|
||||||
|
"模板及其所有关联数据已硬删除"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证删除的完整性
|
||||||
|
if deleted_segments as i64 != segments_count {
|
||||||
|
warn!(
|
||||||
|
template_id = %template_id,
|
||||||
|
expected = %segments_count,
|
||||||
|
actual = %deleted_segments,
|
||||||
|
"删除的轨道片段数量与预期不符"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleted_tracks as i64 != tracks_count {
|
||||||
|
warn!(
|
||||||
|
template_id = %template_id,
|
||||||
|
expected = %tracks_count,
|
||||||
|
actual = %deleted_tracks,
|
||||||
|
"删除的轨道数量与预期不符"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleted_materials as i64 != materials_count {
|
||||||
|
warn!(
|
||||||
|
template_id = %template_id,
|
||||||
|
expected = %materials_count,
|
||||||
|
actual = %deleted_materials,
|
||||||
|
"删除的素材数量与预期不符"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证模板删除后的数据清理完整性
|
||||||
|
pub async fn verify_template_deletion(&self, template_id: &str) -> Result<bool> {
|
||||||
|
let conn = self.database.get_connection();
|
||||||
|
let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?;
|
||||||
|
|
||||||
|
// 检查模板是否还存在
|
||||||
|
let template_exists: bool = conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM templates WHERE id = ?1)",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(false);
|
||||||
|
|
||||||
|
// 检查关联的素材是否还存在
|
||||||
|
let materials_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM template_materials WHERE template_id = ?1",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
// 检查关联的轨道是否还存在
|
||||||
|
let tracks_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM tracks WHERE template_id = ?1",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
// 检查关联的轨道片段是否还存在
|
||||||
|
let segments_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM track_segments WHERE track_id IN (SELECT id FROM tracks WHERE template_id = ?1)",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
// 检查项目-模板绑定关系是否还存在
|
||||||
|
let bindings_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM project_template_bindings WHERE template_id = ?1",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
let is_clean = !template_exists && materials_count == 0 && tracks_count == 0 && segments_count == 0 && bindings_count == 0;
|
||||||
|
|
||||||
|
if is_clean {
|
||||||
|
info!(
|
||||||
|
template_id = %template_id,
|
||||||
|
"模板删除验证通过:所有关联数据已清理"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
template_id = %template_id,
|
||||||
|
template_exists = %template_exists,
|
||||||
|
materials_count = %materials_count,
|
||||||
|
tracks_count = %tracks_count,
|
||||||
|
segments_count = %segments_count,
|
||||||
|
bindings_count = %bindings_count,
|
||||||
|
"模板删除验证失败:仍有关联数据残留"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(is_clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模板的关联数据统计信息
|
||||||
|
pub async fn get_template_associations(&self, template_id: &str) -> Result<TemplateAssociations> {
|
||||||
|
let conn = self.database.get_connection();
|
||||||
|
let conn = conn.lock().map_err(|e| anyhow!("获取数据库连接失败: {}", e))?;
|
||||||
|
|
||||||
|
let materials_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM template_materials WHERE template_id = ?1",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
let tracks_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM tracks WHERE template_id = ?1",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
let segments_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM track_segments WHERE track_id IN (SELECT id FROM tracks WHERE template_id = ?1)",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
let bindings_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM project_template_bindings WHERE template_id = ?1",
|
||||||
|
params![template_id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(TemplateAssociations {
|
||||||
|
template_id: template_id.to_string(),
|
||||||
|
materials_count: materials_count as u32,
|
||||||
|
tracks_count: tracks_count as u32,
|
||||||
|
segments_count: segments_count as u32,
|
||||||
|
bindings_count: bindings_count as u32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 数据库行转换为模板基本信息
|
/// 数据库行转换为模板基本信息
|
||||||
fn row_to_template_basic(&self, row: &Row) -> rusqlite::Result<Template> {
|
fn row_to_template_basic(&self, row: &Row) -> rusqlite::Result<Template> {
|
||||||
let canvas_config = CanvasConfig {
|
let canvas_config = CanvasConfig {
|
||||||
@@ -665,4 +1345,124 @@ impl TemplateService {
|
|||||||
|
|
||||||
Ok(matching_rule)
|
Ok(matching_rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 验证模板数据的完整性和一致性
|
||||||
|
fn validate_template_data(&self, template: &Template) -> Result<()> {
|
||||||
|
// 验证模板基本信息
|
||||||
|
if template.id.is_empty() {
|
||||||
|
return Err(anyhow!("模板ID不能为空"));
|
||||||
|
}
|
||||||
|
if template.name.is_empty() {
|
||||||
|
return Err(anyhow!("模板名称不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有素材的 template_id 匹配
|
||||||
|
for (index, material) in template.materials.iter().enumerate() {
|
||||||
|
if material.id.is_empty() {
|
||||||
|
return Err(anyhow!("素材ID不能为空 (index: {})", index));
|
||||||
|
}
|
||||||
|
if material.template_id != template.id {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"素材 {} (index: {}) 的 template_id ({}) 与模板ID ({}) 不匹配",
|
||||||
|
material.id, index, material.template_id, template.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查素材ID是否有重复
|
||||||
|
let mut material_ids = std::collections::HashSet::new();
|
||||||
|
for material in &template.materials {
|
||||||
|
if !material_ids.insert(&material.id) {
|
||||||
|
return Err(anyhow!("发现重复的素材ID: {}", material.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有轨道的 template_id 匹配
|
||||||
|
for (track_index, track) in template.tracks.iter().enumerate() {
|
||||||
|
if track.id.is_empty() {
|
||||||
|
return Err(anyhow!("轨道ID不能为空 (index: {})", track_index));
|
||||||
|
}
|
||||||
|
if track.template_id != template.id {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"轨道 {} (index: {}) 的 template_id ({}) 与模板ID ({}) 不匹配",
|
||||||
|
track.id, track_index, track.template_id, template.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证轨道片段
|
||||||
|
for (segment_index, segment) in track.segments.iter().enumerate() {
|
||||||
|
if segment.id.is_empty() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"轨道片段ID不能为空 (track_index: {}, segment_index: {})",
|
||||||
|
track_index, segment_index
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if segment.track_id != track.id {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"片段 {} (track_index: {}, segment_index: {}) 的 track_id ({}) 与轨道ID ({}) 不匹配",
|
||||||
|
segment.id, track_index, segment_index, segment.track_id, track.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证素材引用(如果存在)
|
||||||
|
if let Some(material_id) = &segment.template_material_id {
|
||||||
|
let material_exists = template.materials.iter()
|
||||||
|
.any(|m| m.id == *material_id);
|
||||||
|
if !material_exists {
|
||||||
|
warn!(
|
||||||
|
segment_id = %segment.id,
|
||||||
|
material_id = %material_id,
|
||||||
|
track_id = %track.id,
|
||||||
|
"片段引用了不存在的素材ID,这将在保存时被设置为NULL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证时间范围
|
||||||
|
if segment.start_time >= segment.end_time {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"片段 {} 的时间范围无效: start_time ({}) >= end_time ({})",
|
||||||
|
segment.id, segment.start_time, segment.end_time
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if segment.duration != segment.end_time - segment.start_time {
|
||||||
|
warn!(
|
||||||
|
segment_id = %segment.id,
|
||||||
|
calculated_duration = %(segment.end_time - segment.start_time),
|
||||||
|
stored_duration = %segment.duration,
|
||||||
|
"片段的duration字段与计算值不匹配"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查轨道ID是否有重复
|
||||||
|
let mut track_ids = std::collections::HashSet::new();
|
||||||
|
for track in &template.tracks {
|
||||||
|
if !track_ids.insert(&track.id) {
|
||||||
|
return Err(anyhow!("发现重复的轨道ID: {}", track.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查片段ID是否有重复
|
||||||
|
let mut segment_ids = std::collections::HashSet::new();
|
||||||
|
for track in &template.tracks {
|
||||||
|
for segment in &track.segments {
|
||||||
|
if !segment_ids.insert(&segment.id) {
|
||||||
|
return Err(anyhow!("发现重复的片段ID: {}", segment.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
template_id = %template.id,
|
||||||
|
template_name = %template.name,
|
||||||
|
materials_count = %template.materials.len(),
|
||||||
|
tracks_count = %template.tracks.len(),
|
||||||
|
total_segments = %template.tracks.iter().map(|t| t.segments.len()).sum::<usize>(),
|
||||||
|
"模板数据验证通过"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod draft_parser_tests;
|
pub mod draft_parser_tests;
|
||||||
pub mod cloud_upload_service_tests;
|
pub mod cloud_upload_service_tests;
|
||||||
pub mod watermark_tests;
|
pub mod watermark_tests;
|
||||||
|
pub mod template_service_tests;
|
||||||
|
pub mod template_foreign_key_test;
|
||||||
|
|
||||||
// 测试工具函数
|
// 测试工具函数
|
||||||
pub mod test_utils {
|
pub mod test_utils {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod template_foreign_key_tests {
|
||||||
|
use crate::data::models::template::*;
|
||||||
|
use crate::business::services::template_service::TemplateService;
|
||||||
|
use crate::infrastructure::database::Database;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
/// 创建测试数据库
|
||||||
|
fn create_test_database() -> Arc<Database> {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let db_path = temp_dir.path().join("test.db");
|
||||||
|
Arc::new(Database::new_with_path(db_path.to_str().unwrap()).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建测试模板
|
||||||
|
fn create_test_template() -> Template {
|
||||||
|
let template_id = "520B7E9D-FD28-4462-9EF2-ECA0C8F3FFE0".to_string();
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let mut template = Template::new(
|
||||||
|
"模板(2)".to_string(),
|
||||||
|
CanvasConfig {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
ratio: "16:9".to_string(),
|
||||||
|
},
|
||||||
|
30000000, // 30秒
|
||||||
|
30.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用固定的模板ID来重现问题
|
||||||
|
template.id = template_id.clone();
|
||||||
|
|
||||||
|
// 添加测试素材
|
||||||
|
let material = TemplateMaterial {
|
||||||
|
id: "91669AE3-910D-4bd9-AF85-870E5752A4F8".to_string(),
|
||||||
|
template_id: template_id.clone(), // 确保template_id匹配
|
||||||
|
original_id: "original_material_1".to_string(),
|
||||||
|
name: "测试素材".to_string(),
|
||||||
|
material_type: TemplateMaterialType::Video,
|
||||||
|
original_path: "/test/path/video.mp4".to_string(),
|
||||||
|
remote_url: None,
|
||||||
|
file_size: Some(1024000),
|
||||||
|
duration: Some(30000000),
|
||||||
|
width: Some(1920),
|
||||||
|
height: Some(1080),
|
||||||
|
upload_status: UploadStatus::Completed,
|
||||||
|
file_exists: true,
|
||||||
|
upload_success: true,
|
||||||
|
metadata: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.add_material(material);
|
||||||
|
template
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_foreign_key_constraint_issue() {
|
||||||
|
let database = create_test_database();
|
||||||
|
let service = TemplateService::new(database);
|
||||||
|
let template = create_test_template();
|
||||||
|
|
||||||
|
println!("模板ID: {}", template.id);
|
||||||
|
println!("素材数量: {}", template.materials.len());
|
||||||
|
if !template.materials.is_empty() {
|
||||||
|
println!("第一个素材ID: {}", template.materials[0].id);
|
||||||
|
println!("第一个素材的template_id: {}", template.materials[0].template_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试保存模板
|
||||||
|
let result = service.save_template(&template).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("✅ 模板保存成功");
|
||||||
|
|
||||||
|
// 验证模板是否真的保存成功
|
||||||
|
let saved_template = service.get_template_by_id(&template.id).await.unwrap();
|
||||||
|
assert!(saved_template.is_some(), "保存后应该能够获取模板");
|
||||||
|
|
||||||
|
let saved_template = saved_template.unwrap();
|
||||||
|
assert_eq!(saved_template.materials.len(), 1, "应该有一个素材");
|
||||||
|
assert_eq!(saved_template.materials[0].id, template.materials[0].id, "素材ID应该匹配");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ 模板保存失败: {}", e);
|
||||||
|
|
||||||
|
// 检查是否是外键约束错误
|
||||||
|
let error_msg = e.to_string();
|
||||||
|
if error_msg.contains("FOREIGN KEY constraint failed") {
|
||||||
|
println!("🔍 确认是外键约束失败错误");
|
||||||
|
|
||||||
|
// 手动检查数据库状态
|
||||||
|
let database = service.get_database();
|
||||||
|
let conn = database.get_connection();
|
||||||
|
let conn = conn.lock().unwrap();
|
||||||
|
|
||||||
|
// 检查模板是否存在
|
||||||
|
let template_count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM templates WHERE id = ?1",
|
||||||
|
rusqlite::params![template.id],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
println!("数据库中模板数量: {}", template_count);
|
||||||
|
|
||||||
|
// 检查外键约束是否启用
|
||||||
|
let foreign_keys_enabled: i64 = conn.query_row(
|
||||||
|
"PRAGMA foreign_keys",
|
||||||
|
[],
|
||||||
|
|row| row.get(0)
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
println!("外键约束是否启用: {}", foreign_keys_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新抛出错误以便测试失败
|
||||||
|
panic!("模板保存失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_template_id_consistency() {
|
||||||
|
let database = create_test_database();
|
||||||
|
let service = TemplateService::new(database);
|
||||||
|
let template = create_test_template();
|
||||||
|
|
||||||
|
// 验证模板和素材的ID一致性
|
||||||
|
assert!(!template.id.is_empty(), "模板ID不能为空");
|
||||||
|
assert!(!template.materials.is_empty(), "应该有素材");
|
||||||
|
|
||||||
|
for (index, material) in template.materials.iter().enumerate() {
|
||||||
|
assert!(!material.id.is_empty(), "素材ID不能为空 (index: {})", index);
|
||||||
|
assert_eq!(
|
||||||
|
material.template_id,
|
||||||
|
template.id,
|
||||||
|
"素材的template_id应该与模板ID匹配 (index: {})",
|
||||||
|
index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("✅ 模板和素材ID一致性检查通过");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod template_service_tests {
|
||||||
|
use crate::business::services::template_service::TemplateService;
|
||||||
|
use crate::data::models::template::{
|
||||||
|
Template, TemplateMaterial, Track, TrackSegment, CanvasConfig,
|
||||||
|
TemplateMaterialType, TrackType, ImportStatus, UploadStatus,
|
||||||
|
SegmentMatchingRule
|
||||||
|
};
|
||||||
|
use crate::infrastructure::database::Database;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
/// 创建测试数据库
|
||||||
|
fn create_test_database() -> Arc<Database> {
|
||||||
|
// 使用内存数据库进行测试
|
||||||
|
Arc::new(Database::new().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建测试模板
|
||||||
|
fn create_test_template() -> Template {
|
||||||
|
let now = Utc::now();
|
||||||
|
let template_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let mut template = Template {
|
||||||
|
id: template_id.clone(),
|
||||||
|
name: "测试模板".to_string(),
|
||||||
|
description: Some("这是一个测试模板".to_string()),
|
||||||
|
canvas_config: CanvasConfig {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
ratio: "16:9".to_string(),
|
||||||
|
},
|
||||||
|
duration: 30000000, // 30秒,微秒单位
|
||||||
|
fps: 30.0,
|
||||||
|
materials: Vec::new(),
|
||||||
|
tracks: Vec::new(),
|
||||||
|
import_status: ImportStatus::Completed,
|
||||||
|
source_file_path: Some("/test/path/draft_content.json".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加测试素材
|
||||||
|
let material = TemplateMaterial {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
template_id: template_id.clone(),
|
||||||
|
original_id: "original_material_1".to_string(),
|
||||||
|
name: "测试素材".to_string(),
|
||||||
|
material_type: TemplateMaterialType::Video,
|
||||||
|
original_path: "/test/path/video.mp4".to_string(),
|
||||||
|
remote_url: None,
|
||||||
|
file_size: Some(1024000),
|
||||||
|
duration: Some(30000000),
|
||||||
|
width: Some(1920),
|
||||||
|
height: Some(1080),
|
||||||
|
upload_status: UploadStatus::Completed,
|
||||||
|
file_exists: true,
|
||||||
|
upload_success: true,
|
||||||
|
metadata: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
template.materials.push(material.clone());
|
||||||
|
|
||||||
|
// 添加测试轨道
|
||||||
|
let track_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let mut track = Track {
|
||||||
|
id: track_id.clone(),
|
||||||
|
template_id: template_id.clone(),
|
||||||
|
name: "视频轨道".to_string(),
|
||||||
|
track_type: TrackType::Video,
|
||||||
|
track_index: 0,
|
||||||
|
segments: Vec::new(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加测试轨道片段
|
||||||
|
let segment = TrackSegment {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
track_id: track_id.clone(),
|
||||||
|
template_material_id: Some(material.id.clone()),
|
||||||
|
name: "测试片段".to_string(),
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 30000000,
|
||||||
|
duration: 30000000,
|
||||||
|
segment_index: 0,
|
||||||
|
properties: None,
|
||||||
|
matching_rule: SegmentMatchingRule::FixedMaterial,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
track.segments.push(segment);
|
||||||
|
template.tracks.push(track);
|
||||||
|
|
||||||
|
template
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_template_with_validation() {
|
||||||
|
let database = create_test_database();
|
||||||
|
let service = TemplateService::new(database);
|
||||||
|
let template = create_test_template();
|
||||||
|
|
||||||
|
// 测试保存模板
|
||||||
|
let result = service.save_template(&template).await;
|
||||||
|
assert!(result.is_ok(), "保存模板应该成功: {:?}", result);
|
||||||
|
|
||||||
|
// 验证模板是否正确保存
|
||||||
|
let saved_template = service.get_template_by_id(&template.id).await.unwrap();
|
||||||
|
assert!(saved_template.is_some(), "应该能够获取保存的模板");
|
||||||
|
|
||||||
|
let saved = saved_template.unwrap();
|
||||||
|
assert_eq!(saved.id, template.id);
|
||||||
|
assert_eq!(saved.name, template.name);
|
||||||
|
assert_eq!(saved.materials.len(), 1);
|
||||||
|
assert_eq!(saved.tracks.len(), 1);
|
||||||
|
assert_eq!(saved.tracks[0].segments.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_template_deletion_and_verification() {
|
||||||
|
let database = create_test_database();
|
||||||
|
let service = TemplateService::new(database);
|
||||||
|
let template = create_test_template();
|
||||||
|
|
||||||
|
// 保存模板
|
||||||
|
service.save_template(&template).await.unwrap();
|
||||||
|
|
||||||
|
// 获取关联数据统计
|
||||||
|
let associations = service.get_template_associations(&template.id).await.unwrap();
|
||||||
|
assert_eq!(associations.materials_count, 1);
|
||||||
|
assert_eq!(associations.tracks_count, 1);
|
||||||
|
assert_eq!(associations.segments_count, 1);
|
||||||
|
|
||||||
|
// 硬删除模板
|
||||||
|
let result = service.hard_delete_template(&template.id).await;
|
||||||
|
assert!(result.is_ok(), "硬删除模板应该成功: {:?}", result);
|
||||||
|
|
||||||
|
// 验证删除完整性
|
||||||
|
let is_clean = service.verify_template_deletion(&template.id).await.unwrap();
|
||||||
|
assert!(is_clean, "模板删除后应该没有残留数据");
|
||||||
|
|
||||||
|
// 验证模板不再存在
|
||||||
|
let deleted_template = service.get_template_by_id(&template.id).await.unwrap();
|
||||||
|
assert!(deleted_template.is_none(), "删除后应该无法获取模板");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_foreign_key_constraint_validation() {
|
||||||
|
let database = create_test_database();
|
||||||
|
let service = TemplateService::new(database);
|
||||||
|
let mut template = create_test_template();
|
||||||
|
|
||||||
|
// 创建一个引用不存在素材的片段
|
||||||
|
let invalid_material_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
template.tracks[0].segments[0].template_material_id = Some(invalid_material_id.clone());
|
||||||
|
|
||||||
|
// 保存应该成功,但会将无效的素材引用设置为 NULL
|
||||||
|
let result = service.save_template(&template).await;
|
||||||
|
assert!(result.is_ok(), "保存应该成功,无效引用会被处理: {:?}", result);
|
||||||
|
|
||||||
|
// 验证保存后的数据
|
||||||
|
let saved_template = service.get_template_by_id(&template.id).await.unwrap().unwrap();
|
||||||
|
// 无效的素材引用应该被设置为 None
|
||||||
|
assert!(saved_template.tracks[0].segments[0].template_material_id.is_none() ||
|
||||||
|
saved_template.tracks[0].segments[0].template_material_id != Some(invalid_material_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_template_data_validation() {
|
||||||
|
let database = create_test_database();
|
||||||
|
let service = TemplateService::new(database);
|
||||||
|
let mut template = create_test_template();
|
||||||
|
|
||||||
|
// 测试空模板ID
|
||||||
|
template.id = "".to_string();
|
||||||
|
let result = service.save_template(&template).await;
|
||||||
|
assert!(result.is_err(), "空模板ID应该导致验证失败");
|
||||||
|
|
||||||
|
// 恢复有效ID
|
||||||
|
template.id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// 测试空模板名称
|
||||||
|
template.name = "".to_string();
|
||||||
|
let result = service.save_template(&template).await;
|
||||||
|
assert!(result.is_err(), "空模板名称应该导致验证失败");
|
||||||
|
|
||||||
|
// 恢复有效名称
|
||||||
|
template.name = "测试模板".to_string();
|
||||||
|
|
||||||
|
// 测试素材template_id不匹配
|
||||||
|
template.materials[0].template_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let result = service.save_template(&template).await;
|
||||||
|
assert!(result.is_err(), "素材template_id不匹配应该导致验证失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_soft_delete_vs_hard_delete() {
|
||||||
|
let database = create_test_database();
|
||||||
|
let service = TemplateService::new(database);
|
||||||
|
let template = create_test_template();
|
||||||
|
|
||||||
|
// 保存模板
|
||||||
|
service.save_template(&template).await.unwrap();
|
||||||
|
|
||||||
|
// 软删除
|
||||||
|
service.delete_template(&template.id).await.unwrap();
|
||||||
|
|
||||||
|
// 软删除后,模板仍然存在但不活跃
|
||||||
|
let soft_deleted = service.get_template_by_id(&template.id).await.unwrap();
|
||||||
|
assert!(soft_deleted.is_some());
|
||||||
|
assert!(!soft_deleted.unwrap().is_active);
|
||||||
|
|
||||||
|
// 硬删除
|
||||||
|
service.hard_delete_template(&template.id).await.unwrap();
|
||||||
|
|
||||||
|
// 硬删除后,模板完全不存在
|
||||||
|
let hard_deleted = service.get_template_by_id(&template.id).await.unwrap();
|
||||||
|
assert!(hard_deleted.is_none());
|
||||||
|
|
||||||
|
// 验证删除完整性
|
||||||
|
let is_clean = service.verify_template_deletion(&template.id).await.unwrap();
|
||||||
|
assert!(is_clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ mod watermark_tests {
|
|||||||
fn create_test_database() -> Arc<Database> {
|
fn create_test_database() -> Arc<Database> {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let db_path = temp_dir.path().join("test.db");
|
let db_path = temp_dir.path().join("test.db");
|
||||||
Arc::new(Database::new(db_path.to_str().unwrap()).unwrap())
|
Arc::new(Database::new_with_path(db_path.to_str().unwrap()).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建测试水印模板
|
/// 创建测试水印模板
|
||||||
@@ -246,6 +246,8 @@ mod watermark_tests {
|
|||||||
progress_percentage: 0.0,
|
progress_percentage: 0.0,
|
||||||
estimated_remaining_ms: None,
|
estimated_remaining_ms: None,
|
||||||
errors: vec!["Error 1".to_string()],
|
errors: vec!["Error 1".to_string()],
|
||||||
|
detection_results: Vec::new(),
|
||||||
|
processing_results: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 计算进度百分比
|
// 计算进度百分比
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
466
apps/desktop/src-tauri/src/infrastructure/database/migrations.rs
Normal file
466
apps/desktop/src-tauri/src/infrastructure/database/migrations.rs
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
use rusqlite::Connection;
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// 数据库迁移版本号
|
||||||
|
pub type MigrationVersion = u32;
|
||||||
|
|
||||||
|
/// 单个迁移脚本
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Migration {
|
||||||
|
/// 迁移版本号
|
||||||
|
pub version: MigrationVersion,
|
||||||
|
/// 迁移描述
|
||||||
|
pub description: String,
|
||||||
|
/// 向前迁移SQL
|
||||||
|
pub up_sql: String,
|
||||||
|
/// 回滚迁移SQL(可选)
|
||||||
|
pub down_sql: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 迁移管理器
|
||||||
|
pub struct MigrationManager {
|
||||||
|
migrations: HashMap<MigrationVersion, Migration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MigrationManager {
|
||||||
|
/// 创建新的迁移管理器
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut manager = Self {
|
||||||
|
migrations: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注册所有迁移
|
||||||
|
manager.register_migrations();
|
||||||
|
manager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册所有迁移脚本
|
||||||
|
fn register_migrations(&mut self) {
|
||||||
|
// 迁移 1: 初始化数据库表结构
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 1,
|
||||||
|
description: "初始化数据库表结构".to_string(),
|
||||||
|
up_sql: include_str!("migrations/001_initial_schema.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/001_initial_schema_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 2: 添加模特ID字段到素材表
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 2,
|
||||||
|
description: "添加模特ID字段到素材表".to_string(),
|
||||||
|
up_sql: include_str!("migrations/002_add_model_id_to_materials.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/002_add_model_id_to_materials_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 3: 添加文件存在状态字段到模板素材表
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 3,
|
||||||
|
description: "添加文件存在状态字段到模板素材表".to_string(),
|
||||||
|
up_sql: include_str!("migrations/003_add_file_status_to_template_materials.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/003_add_file_status_to_template_materials_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 4: 移除模板表的project_id字段
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 4,
|
||||||
|
description: "移除模板表的project_id字段".to_string(),
|
||||||
|
up_sql: include_str!("migrations/004_remove_project_id_from_templates.sql").to_string(),
|
||||||
|
down_sql: None, // 这个迁移不支持回滚,因为涉及数据丢失
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 5: 修复AI分类表的is_active字段类型
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 5,
|
||||||
|
description: "修复AI分类表的is_active字段类型".to_string(),
|
||||||
|
up_sql: include_str!("migrations/005_fix_ai_classifications_is_active.sql").to_string(),
|
||||||
|
down_sql: None, // 类型修复不支持回滚
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 6: 添加导出状态字段到模板匹配结果表
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 6,
|
||||||
|
description: "添加导出状态字段到模板匹配结果表".to_string(),
|
||||||
|
up_sql: include_str!("migrations/006_add_export_status_to_template_matching.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/006_add_export_status_to_template_matching_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 7: 修复项目表的is_active字段数据
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 7,
|
||||||
|
description: "修复项目表的is_active字段数据".to_string(),
|
||||||
|
up_sql: include_str!("migrations/007_fix_projects_is_active_data.sql").to_string(),
|
||||||
|
down_sql: None, // 数据修复不支持回滚
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 8: 添加缩略图和使用跟踪字段
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 8,
|
||||||
|
description: "添加缩略图和使用跟踪字段".to_string(),
|
||||||
|
up_sql: include_str!("migrations/008_add_thumbnail_and_usage_tracking.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/008_add_thumbnail_and_usage_tracking_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 9: 修复template_materials表的file_size字段,允许为NULL
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 9,
|
||||||
|
description: "修复template_materials表的file_size字段,允许为NULL".to_string(),
|
||||||
|
up_sql: include_str!("migrations/009_fix_template_materials_file_size_nullable.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/009_fix_template_materials_file_size_nullable_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加迁移
|
||||||
|
fn add_migration(&mut self, migration: Migration) {
|
||||||
|
self.migrations.insert(migration.version, migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前数据库版本
|
||||||
|
pub fn get_current_version(&self, conn: &Connection) -> Result<MigrationVersion> {
|
||||||
|
// 确保迁移历史表存在
|
||||||
|
self.ensure_migration_table(conn)?;
|
||||||
|
|
||||||
|
let version = conn.query_row(
|
||||||
|
"SELECT MAX(version) FROM schema_migrations WHERE success = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, Option<u32>>(0)
|
||||||
|
).unwrap_or(Some(0)).unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取最新版本号
|
||||||
|
pub fn get_latest_version(&self) -> MigrationVersion {
|
||||||
|
self.migrations.keys().max().copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 确保迁移历史表存在
|
||||||
|
fn ensure_migration_table(&self, conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
success INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_message TEXT
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 运行所有待执行的迁移
|
||||||
|
pub fn migrate(&self, conn: &Connection) -> Result<()> {
|
||||||
|
let current_version = self.get_current_version(conn)?;
|
||||||
|
let latest_version = self.get_latest_version();
|
||||||
|
|
||||||
|
if current_version >= latest_version {
|
||||||
|
println!("数据库已是最新版本 v{}", current_version);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("开始数据库迁移: v{} -> v{}", current_version, latest_version);
|
||||||
|
|
||||||
|
// 按版本号排序执行迁移
|
||||||
|
let mut versions: Vec<_> = self.migrations.keys().collect();
|
||||||
|
versions.sort();
|
||||||
|
|
||||||
|
for &version in versions {
|
||||||
|
if version > current_version {
|
||||||
|
self.apply_migration(conn, version)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("数据库迁移完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用单个迁移
|
||||||
|
fn apply_migration(&self, conn: &Connection, version: MigrationVersion) -> Result<()> {
|
||||||
|
let migration = self.migrations.get(&version)
|
||||||
|
.ok_or_else(|| anyhow!("迁移版本 {} 不存在", version))?;
|
||||||
|
|
||||||
|
println!("应用迁移 v{}: {}", version, migration.description);
|
||||||
|
|
||||||
|
// 开始事务
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
|
// 记录迁移开始
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR REPLACE INTO schema_migrations (version, description, success) VALUES (?1, ?2, 0)",
|
||||||
|
[&version.to_string(), &migration.description],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 执行迁移SQL
|
||||||
|
match self.execute_migration_sql(&tx, &migration.up_sql) {
|
||||||
|
Ok(_) => {
|
||||||
|
// 标记迁移成功
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE schema_migrations SET success = 1, applied_at = CURRENT_TIMESTAMP WHERE version = ?1",
|
||||||
|
[&version.to_string()],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
println!("迁移 v{} 应用成功", version);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// 记录错误信息
|
||||||
|
let _ = tx.execute(
|
||||||
|
"UPDATE schema_migrations SET error_message = ?1 WHERE version = ?2",
|
||||||
|
[&e.to_string(), &version.to_string()],
|
||||||
|
);
|
||||||
|
tx.rollback()?;
|
||||||
|
Err(anyhow!("迁移 v{} 失败: {}", version, e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行迁移SQL(支持多条语句)
|
||||||
|
fn execute_migration_sql(&self, conn: &Connection, sql: &str) -> Result<()> {
|
||||||
|
// 使用SQLite的execute_batch方法来执行多条SQL语句
|
||||||
|
// 这个方法能正确处理SQL语句的分割,包括注释和字符串中的分号
|
||||||
|
conn.execute_batch(sql)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回滚到指定版本
|
||||||
|
pub fn rollback_to(&self, conn: &Connection, target_version: MigrationVersion) -> Result<()> {
|
||||||
|
let current_version = self.get_current_version(conn)?;
|
||||||
|
|
||||||
|
if target_version >= current_version {
|
||||||
|
return Err(anyhow!("目标版本 {} 不能大于等于当前版本 {}", target_version, current_version));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("开始回滚数据库: v{} -> v{}", current_version, target_version);
|
||||||
|
|
||||||
|
// 按版本号倒序回滚
|
||||||
|
let mut versions: Vec<_> = self.migrations.keys().collect();
|
||||||
|
versions.sort_by(|a, b| b.cmp(a)); // 倒序
|
||||||
|
|
||||||
|
for &version in versions {
|
||||||
|
if version > target_version && version <= current_version {
|
||||||
|
self.rollback_migration(conn, version)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("数据库回滚完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回滚单个迁移
|
||||||
|
fn rollback_migration(&self, conn: &Connection, version: MigrationVersion) -> Result<()> {
|
||||||
|
let migration = self.migrations.get(&version)
|
||||||
|
.ok_or_else(|| anyhow!("迁移版本 {} 不存在", version))?;
|
||||||
|
|
||||||
|
let down_sql = migration.down_sql.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("迁移 v{} 不支持回滚", version))?;
|
||||||
|
|
||||||
|
println!("回滚迁移 v{}: {}", version, migration.description);
|
||||||
|
|
||||||
|
// 开始事务
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
|
// 执行回滚SQL
|
||||||
|
match self.execute_migration_sql(&tx, down_sql) {
|
||||||
|
Ok(_) => {
|
||||||
|
// 删除迁移记录
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM schema_migrations WHERE version = ?1",
|
||||||
|
[&version.to_string()],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
println!("迁移 v{} 回滚成功", version);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tx.rollback()?;
|
||||||
|
Err(anyhow!("迁移 v{} 回滚失败: {}", version, e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取迁移历史
|
||||||
|
pub fn get_migration_history(&self, conn: &Connection) -> Result<Vec<(MigrationVersion, String, String, bool)>> {
|
||||||
|
self.ensure_migration_table(conn)?;
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT version, description, applied_at, success FROM schema_migrations ORDER BY version"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, u32>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, i32>(3)? == 1,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut history = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
history.push(row?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
fn create_test_connection() -> Connection {
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let conn = Connection::open(temp_file.path()).unwrap();
|
||||||
|
|
||||||
|
// 配置数据库设置
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||||
|
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||||
|
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_manager_creation() {
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
let latest_version = manager.get_latest_version();
|
||||||
|
|
||||||
|
// 应该有至少8个迁移版本
|
||||||
|
assert!(latest_version >= 8, "应该有至少8个迁移版本,实际: {}", latest_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_version_is_zero() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
let current_version = manager.get_current_version(&conn).unwrap();
|
||||||
|
assert_eq!(current_version, 0, "新数据库的版本应该是0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_table_creation() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 确保迁移表存在
|
||||||
|
manager.ensure_migration_table(&conn).unwrap();
|
||||||
|
|
||||||
|
// 验证表是否存在
|
||||||
|
let table_exists: bool = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i32>(0)
|
||||||
|
).unwrap() > 0;
|
||||||
|
|
||||||
|
assert!(table_exists, "schema_migrations表应该存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_migration() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 执行所有迁移
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
|
||||||
|
// 验证版本已更新
|
||||||
|
let current_version = manager.get_current_version(&conn).unwrap();
|
||||||
|
let latest_version = manager.get_latest_version();
|
||||||
|
|
||||||
|
assert_eq!(current_version, latest_version, "迁移后版本应该是最新版本");
|
||||||
|
|
||||||
|
// 验证一些关键表是否存在
|
||||||
|
let tables = vec![
|
||||||
|
"projects", "materials", "material_segments", "models",
|
||||||
|
"ai_classifications", "templates", "template_materials",
|
||||||
|
"tracks", "track_segments", "video_classification_records"
|
||||||
|
];
|
||||||
|
|
||||||
|
for table in tables {
|
||||||
|
let table_exists: bool = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i32>(0)
|
||||||
|
).unwrap() > 0;
|
||||||
|
|
||||||
|
assert!(table_exists, "表 {} 应该存在", table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_history() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
|
||||||
|
// 获取迁移历史
|
||||||
|
let history = manager.get_migration_history(&conn).unwrap();
|
||||||
|
|
||||||
|
// 应该有迁移记录
|
||||||
|
assert!(!history.is_empty(), "应该有迁移历史记录");
|
||||||
|
|
||||||
|
// 所有迁移都应该成功
|
||||||
|
for (version, description, _applied_at, success) in history {
|
||||||
|
assert!(success, "迁移 v{}: {} 应该成功", version, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idempotent_migration() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 第一次迁移
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
let version_after_first = manager.get_current_version(&conn).unwrap();
|
||||||
|
|
||||||
|
// 第二次迁移(应该是幂等的)
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
let version_after_second = manager.get_current_version(&conn).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(version_after_first, version_after_second, "重复迁移应该是幂等的");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_specific_migration_features() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
|
||||||
|
// 测试特定的迁移功能
|
||||||
|
|
||||||
|
// 1. 检查materials表是否有model_id字段(迁移2)
|
||||||
|
let has_model_id = conn.prepare("SELECT model_id FROM materials LIMIT 1").is_ok();
|
||||||
|
assert!(has_model_id, "materials表应该有model_id字段");
|
||||||
|
|
||||||
|
// 2. 检查template_materials表是否有file_exists字段(迁移3)
|
||||||
|
let has_file_exists = conn.prepare("SELECT file_exists FROM template_materials LIMIT 1").is_ok();
|
||||||
|
assert!(has_file_exists, "template_materials表应该有file_exists字段");
|
||||||
|
|
||||||
|
// 3. 检查materials表是否有thumbnail_path字段(迁移8)
|
||||||
|
let has_materials_thumbnail_path = conn.prepare("SELECT thumbnail_path FROM materials LIMIT 1").is_ok();
|
||||||
|
assert!(has_materials_thumbnail_path, "materials表应该有thumbnail_path字段");
|
||||||
|
|
||||||
|
// 4. 检查material_segments表是否有usage_count字段(迁移8)
|
||||||
|
let has_usage_count = conn.prepare("SELECT usage_count FROM material_segments LIMIT 1").is_ok();
|
||||||
|
assert!(has_usage_count, "material_segments表应该有usage_count字段");
|
||||||
|
|
||||||
|
// 5. 检查template_matching_results表是否有export_count字段(迁移8)
|
||||||
|
let has_export_count = conn.prepare("SELECT export_count FROM template_matching_results LIMIT 1").is_ok();
|
||||||
|
assert!(has_export_count, "template_matching_results表应该有export_count字段");
|
||||||
|
|
||||||
|
// 6. 验证初始化时就存在的字段
|
||||||
|
let has_matching_rule = conn.prepare("SELECT matching_rule FROM track_segments LIMIT 1").is_ok();
|
||||||
|
assert!(has_matching_rule, "track_segments表应该有matching_rule字段(初始化时创建)");
|
||||||
|
|
||||||
|
let has_segments_thumbnail_path = conn.prepare("SELECT thumbnail_path FROM material_segments LIMIT 1").is_ok();
|
||||||
|
assert!(has_segments_thumbnail_path, "material_segments表应该有thumbnail_path字段(初始化时创建)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
-- 创建项目表
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建项目表索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects (created_at);
|
||||||
|
|
||||||
|
-- 创建素材表
|
||||||
|
CREATE TABLE IF NOT EXISTS materials (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
md5_hash TEXT NOT NULL,
|
||||||
|
material_type TEXT NOT NULL,
|
||||||
|
processing_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
metadata TEXT,
|
||||||
|
scene_detection TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
processed_at DATETIME,
|
||||||
|
error_message TEXT,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(project_id, md5_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建素材表索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_project_id ON materials (project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_processing_status ON materials (processing_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_material_type ON materials (material_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_created_at ON materials (created_at);
|
||||||
|
|
||||||
|
-- 创建素材片段表
|
||||||
|
CREATE TABLE IF NOT EXISTS material_segments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
material_id TEXT NOT NULL,
|
||||||
|
segment_index INTEGER NOT NULL,
|
||||||
|
start_time REAL NOT NULL,
|
||||||
|
end_time REAL NOT NULL,
|
||||||
|
duration REAL NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
thumbnail_path TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (material_id) REFERENCES materials (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(material_id, segment_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建素材片段表索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_material_segments_material_id ON material_segments (material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_material_segments_duration ON material_segments (duration);
|
||||||
|
|
||||||
|
-- 创建模特表
|
||||||
|
CREATE TABLE IF NOT EXISTS models (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
stage_name TEXT,
|
||||||
|
gender TEXT NOT NULL,
|
||||||
|
age INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
weight INTEGER,
|
||||||
|
measurements TEXT,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
avatar_path TEXT,
|
||||||
|
contact_info TEXT,
|
||||||
|
social_media TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'Active',
|
||||||
|
rating REAL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建模特照片表
|
||||||
|
CREATE TABLE IF NOT EXISTS model_photos (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
photo_type TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
is_cover BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (model_id) REFERENCES models (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建AI分类表
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_classifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
prompt_text TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建视频分类记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS video_classification_records (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
segment_id TEXT NOT NULL,
|
||||||
|
classification_result TEXT,
|
||||||
|
confidence_score REAL,
|
||||||
|
processing_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
processed_at DATETIME,
|
||||||
|
error_message TEXT,
|
||||||
|
gemini_response TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (segment_id) REFERENCES material_segments (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建视频分类记录表索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_classification_records_segment_id ON video_classification_records (segment_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_classification_records_status ON video_classification_records (processing_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_classification_records_result ON video_classification_records (classification_result);
|
||||||
|
|
||||||
|
-- 创建模板表
|
||||||
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
canvas_width INTEGER NOT NULL,
|
||||||
|
canvas_height INTEGER NOT NULL,
|
||||||
|
canvas_ratio TEXT NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
fps REAL NOT NULL,
|
||||||
|
import_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
source_file_path TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建模板素材表
|
||||||
|
CREATE TABLE IF NOT EXISTS template_materials (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
original_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
material_type TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
remote_url TEXT,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
duration INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
upload_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建模板素材表索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_template_id ON template_materials (template_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_upload_status ON template_materials (upload_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_original_id ON template_materials (original_id);
|
||||||
|
|
||||||
|
-- 创建轨道表
|
||||||
|
CREATE TABLE IF NOT EXISTS tracks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
track_type TEXT NOT NULL,
|
||||||
|
track_index INTEGER NOT NULL,
|
||||||
|
properties TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建轨道片段表
|
||||||
|
CREATE TABLE IF NOT EXISTS track_segments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
template_material_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
start_time INTEGER NOT NULL,
|
||||||
|
end_time INTEGER NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
segment_index INTEGER NOT NULL,
|
||||||
|
properties TEXT,
|
||||||
|
matching_rule TEXT DEFAULT '"FixedMaterial"',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (track_id) REFERENCES tracks (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (template_material_id) REFERENCES template_materials (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建模板匹配结果表
|
||||||
|
CREATE TABLE IF NOT EXISTS template_matching_results (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
matched_segments TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建水印模板表
|
||||||
|
CREATE TABLE IF NOT EXISTS watermark_templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
thumbnail_path TEXT,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
watermark_type TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建水印处理结果表
|
||||||
|
CREATE TABLE IF NOT EXISTS watermark_processing_results (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
material_id TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
success INTEGER NOT NULL,
|
||||||
|
output_path TEXT,
|
||||||
|
processing_time_ms INTEGER NOT NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (material_id) REFERENCES materials (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建相关索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_watermark_processing_results_material_id ON watermark_processing_results (material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_watermark_processing_results_success ON watermark_processing_results (success);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- 删除所有索引
|
||||||
|
DROP INDEX IF EXISTS idx_projects_name;
|
||||||
|
DROP INDEX IF EXISTS idx_projects_created_at;
|
||||||
|
DROP INDEX IF EXISTS idx_materials_project_id;
|
||||||
|
DROP INDEX IF EXISTS idx_materials_processing_status;
|
||||||
|
DROP INDEX IF EXISTS idx_materials_material_type;
|
||||||
|
DROP INDEX IF EXISTS idx_materials_created_at;
|
||||||
|
DROP INDEX IF EXISTS idx_material_segments_material_id;
|
||||||
|
DROP INDEX IF EXISTS idx_material_segments_duration;
|
||||||
|
DROP INDEX IF EXISTS idx_video_classification_records_segment_id;
|
||||||
|
DROP INDEX IF EXISTS idx_video_classification_records_status;
|
||||||
|
DROP INDEX IF EXISTS idx_video_classification_records_result;
|
||||||
|
DROP INDEX IF EXISTS idx_template_materials_template_id;
|
||||||
|
DROP INDEX IF EXISTS idx_template_materials_upload_status;
|
||||||
|
DROP INDEX IF EXISTS idx_template_materials_original_id;
|
||||||
|
DROP INDEX IF EXISTS idx_watermark_processing_results_material_id;
|
||||||
|
DROP INDEX IF EXISTS idx_watermark_processing_results_success;
|
||||||
|
|
||||||
|
-- 删除所有表(按依赖关系倒序)
|
||||||
|
DROP TABLE IF EXISTS watermark_processing_results;
|
||||||
|
DROP TABLE IF EXISTS watermark_templates;
|
||||||
|
DROP TABLE IF EXISTS template_matching_results;
|
||||||
|
DROP TABLE IF EXISTS track_segments;
|
||||||
|
DROP TABLE IF EXISTS tracks;
|
||||||
|
DROP TABLE IF EXISTS template_materials;
|
||||||
|
DROP TABLE IF EXISTS templates;
|
||||||
|
DROP TABLE IF EXISTS video_classification_records;
|
||||||
|
DROP TABLE IF EXISTS ai_classifications;
|
||||||
|
DROP TABLE IF EXISTS model_photos;
|
||||||
|
DROP TABLE IF EXISTS models;
|
||||||
|
DROP TABLE IF EXISTS material_segments;
|
||||||
|
DROP TABLE IF EXISTS materials;
|
||||||
|
DROP TABLE IF EXISTS projects;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 为素材表添加模特ID字段
|
||||||
|
ALTER TABLE materials ADD COLUMN model_id TEXT;
|
||||||
|
|
||||||
|
-- 为素材表添加模特ID索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_model_id ON materials (model_id);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- 删除模特ID索引
|
||||||
|
DROP INDEX IF EXISTS idx_materials_model_id;
|
||||||
|
|
||||||
|
-- SQLite不支持DROP COLUMN,需要重建表
|
||||||
|
CREATE TABLE materials_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
md5_hash TEXT NOT NULL,
|
||||||
|
material_type TEXT NOT NULL,
|
||||||
|
processing_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
metadata TEXT,
|
||||||
|
scene_detection TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
processed_at DATETIME,
|
||||||
|
error_message TEXT,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(project_id, md5_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移数据(排除model_id字段)
|
||||||
|
INSERT INTO materials_new
|
||||||
|
SELECT id, project_id, name, original_path, file_size, md5_hash,
|
||||||
|
material_type, processing_status, metadata, scene_detection,
|
||||||
|
created_at, updated_at, processed_at, error_message
|
||||||
|
FROM materials;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE materials;
|
||||||
|
ALTER TABLE materials_new RENAME TO materials;
|
||||||
|
|
||||||
|
-- 重新创建索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_project_id ON materials (project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_processing_status ON materials (processing_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_material_type ON materials (material_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_created_at ON materials (created_at);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 添加文件存在状态字段到模板素材表
|
||||||
|
ALTER TABLE template_materials ADD COLUMN file_exists BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- 添加上传成功状态字段到模板素材表
|
||||||
|
ALTER TABLE template_materials ADD COLUMN upload_success BOOLEAN DEFAULT FALSE;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- SQLite不支持DROP COLUMN,需要重建表
|
||||||
|
CREATE TABLE template_materials_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
original_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
material_type TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
remote_url TEXT,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
duration INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
upload_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移数据(排除file_exists和upload_success字段)
|
||||||
|
INSERT INTO template_materials_new
|
||||||
|
SELECT id, template_id, original_id, name, material_type, original_path,
|
||||||
|
remote_url, file_size, duration, width, height, upload_status,
|
||||||
|
metadata, created_at, updated_at
|
||||||
|
FROM template_materials;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE template_materials;
|
||||||
|
ALTER TABLE template_materials_new RENAME TO template_materials;
|
||||||
|
|
||||||
|
-- 重新创建索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_template_id ON template_materials (template_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_upload_status ON template_materials (upload_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_original_id ON template_materials (original_id);
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
-- 检查是否需要移除模板表的 project_id 字段
|
||||||
|
-- 只有在表中存在 project_id 字段时才需要重建表
|
||||||
|
|
||||||
|
-- 创建新的模板表(不包含project_id字段)
|
||||||
|
CREATE TABLE IF NOT EXISTS templates_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
canvas_width INTEGER NOT NULL,
|
||||||
|
canvas_height INTEGER NOT NULL,
|
||||||
|
canvas_ratio TEXT NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
fps REAL NOT NULL,
|
||||||
|
import_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
source_file_path TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移数据(排除 project_id)
|
||||||
|
INSERT OR IGNORE INTO templates_new
|
||||||
|
SELECT id, name, description, canvas_width, canvas_height, canvas_ratio,
|
||||||
|
duration, fps, import_status, source_file_path, created_at, updated_at, is_active
|
||||||
|
FROM templates;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE IF EXISTS templates_old;
|
||||||
|
ALTER TABLE templates RENAME TO templates_old;
|
||||||
|
ALTER TABLE templates_new RENAME TO templates;
|
||||||
|
|
||||||
|
-- 修复 template_materials 表的外键约束
|
||||||
|
CREATE TABLE template_materials_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
original_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
material_type TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
remote_url TEXT,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
duration INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
upload_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
file_exists BOOLEAN DEFAULT FALSE,
|
||||||
|
upload_success BOOLEAN DEFAULT FALSE,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移 template_materials 数据
|
||||||
|
INSERT OR IGNORE INTO template_materials_new
|
||||||
|
SELECT id, template_id, original_id, name, material_type, original_path,
|
||||||
|
remote_url, file_size, duration, width, height, upload_status,
|
||||||
|
file_exists, upload_success, metadata, created_at, updated_at
|
||||||
|
FROM template_materials;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE IF EXISTS template_materials_old;
|
||||||
|
ALTER TABLE template_materials RENAME TO template_materials_old;
|
||||||
|
ALTER TABLE template_materials_new RENAME TO template_materials;
|
||||||
|
|
||||||
|
-- 修复 tracks 表的外键约束
|
||||||
|
CREATE TABLE tracks_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
track_type TEXT NOT NULL,
|
||||||
|
track_index INTEGER NOT NULL,
|
||||||
|
properties TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移轨道数据
|
||||||
|
INSERT OR IGNORE INTO tracks_new
|
||||||
|
SELECT id, template_id, name, track_type, track_index, properties, created_at, updated_at
|
||||||
|
FROM tracks;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE IF EXISTS tracks_old;
|
||||||
|
ALTER TABLE tracks RENAME TO tracks_old;
|
||||||
|
ALTER TABLE tracks_new RENAME TO tracks;
|
||||||
|
|
||||||
|
-- 重建 track_segments 表,恢复正确的外键约束
|
||||||
|
CREATE TABLE track_segments_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
template_material_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
start_time INTEGER NOT NULL,
|
||||||
|
end_time INTEGER NOT NULL,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
segment_index INTEGER NOT NULL,
|
||||||
|
properties TEXT,
|
||||||
|
matching_rule TEXT DEFAULT '"FixedMaterial"',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (track_id) REFERENCES tracks (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (template_material_id) REFERENCES template_materials (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移轨道片段数据
|
||||||
|
INSERT OR IGNORE INTO track_segments_new
|
||||||
|
SELECT id, track_id, template_material_id, name, start_time, end_time,
|
||||||
|
duration, segment_index, properties,
|
||||||
|
COALESCE(matching_rule, '"FixedMaterial"'), created_at, updated_at
|
||||||
|
FROM track_segments;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE IF EXISTS track_segments_old;
|
||||||
|
ALTER TABLE track_segments RENAME TO track_segments_old;
|
||||||
|
ALTER TABLE track_segments_new RENAME TO track_segments;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- 修复AI分类表的is_active字段类型(从BOOLEAN改为INTEGER)
|
||||||
|
-- 创建新表
|
||||||
|
CREATE TABLE ai_classifications_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
prompt_text TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 复制数据,将BOOLEAN转换为INTEGER
|
||||||
|
INSERT INTO ai_classifications_new
|
||||||
|
SELECT id, name, prompt_text, description,
|
||||||
|
CASE WHEN is_active THEN 1 ELSE 0 END as is_active,
|
||||||
|
sort_order, created_at, updated_at
|
||||||
|
FROM ai_classifications;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE ai_classifications;
|
||||||
|
ALTER TABLE ai_classifications_new RENAME TO ai_classifications;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 添加导出状态字段到模板匹配结果表
|
||||||
|
ALTER TABLE template_matching_results ADD COLUMN is_exported BOOLEAN DEFAULT 0;
|
||||||
|
|
||||||
|
-- 添加最后导出时间字段到模板匹配结果表
|
||||||
|
ALTER TABLE template_matching_results ADD COLUMN last_exported_at DATETIME;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- SQLite不支持DROP COLUMN,需要重建表
|
||||||
|
CREATE TABLE template_matching_results_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
matched_segments TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移数据(排除导出状态字段)
|
||||||
|
INSERT INTO template_matching_results_new
|
||||||
|
SELECT id, template_id, project_id, matched_segments, created_at, updated_at
|
||||||
|
FROM template_matching_results;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE template_matching_results;
|
||||||
|
ALTER TABLE template_matching_results_new RENAME TO template_matching_results;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 修复项目表的is_active字段数据
|
||||||
|
-- 将 "true" 字符串转换为 1
|
||||||
|
UPDATE projects SET is_active = 1 WHERE is_active = 'true';
|
||||||
|
|
||||||
|
-- 将 "false" 字符串转换为 0
|
||||||
|
UPDATE projects SET is_active = 0 WHERE is_active = 'false';
|
||||||
|
|
||||||
|
-- 如果所有项目的 is_active 都是 0,可能是之前的迁移错误,恢复为 1
|
||||||
|
-- 但只有在确实存在项目且全部为0的情况下才执行
|
||||||
|
UPDATE projects
|
||||||
|
SET is_active = 1
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM projects
|
||||||
|
WHERE (SELECT COUNT(*) FROM projects) > 0
|
||||||
|
AND (SELECT COUNT(*) FROM projects WHERE is_active = 1) = 0
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 添加缩略图路径字段到素材表(material_segments表在初始化时已有thumbnail_path)
|
||||||
|
ALTER TABLE materials ADD COLUMN thumbnail_path TEXT;
|
||||||
|
|
||||||
|
-- 添加素材使用状态字段到素材片段表
|
||||||
|
ALTER TABLE material_segments ADD COLUMN usage_count INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE material_segments ADD COLUMN is_used BOOLEAN DEFAULT 0;
|
||||||
|
ALTER TABLE material_segments ADD COLUMN last_used_at DATETIME;
|
||||||
|
|
||||||
|
-- 添加导出次数字段到模板匹配结果表
|
||||||
|
ALTER TABLE template_matching_results ADD COLUMN export_count INTEGER DEFAULT 0;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-- SQLite不支持DROP COLUMN,需要重建表
|
||||||
|
|
||||||
|
-- 重建material_segments表(移除新增的usage_count, is_used, last_used_at字段,保留thumbnail_path)
|
||||||
|
CREATE TABLE material_segments_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
material_id TEXT NOT NULL,
|
||||||
|
segment_index INTEGER NOT NULL,
|
||||||
|
start_time REAL NOT NULL,
|
||||||
|
end_time REAL NOT NULL,
|
||||||
|
duration REAL NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
thumbnail_path TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (material_id) REFERENCES materials (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(material_id, segment_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移数据(排除usage_count, is_used, last_used_at字段)
|
||||||
|
INSERT INTO material_segments_new
|
||||||
|
SELECT id, material_id, segment_index, start_time, end_time, duration,
|
||||||
|
file_path, file_size, thumbnail_path, created_at
|
||||||
|
FROM material_segments;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE material_segments;
|
||||||
|
ALTER TABLE material_segments_new RENAME TO material_segments;
|
||||||
|
|
||||||
|
-- 重新创建索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_material_segments_material_id ON material_segments (material_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_material_segments_duration ON material_segments (duration);
|
||||||
|
|
||||||
|
-- 重建materials表(移除thumbnail_path字段)
|
||||||
|
CREATE TABLE materials_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
model_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL,
|
||||||
|
md5_hash TEXT NOT NULL,
|
||||||
|
material_type TEXT NOT NULL,
|
||||||
|
processing_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
metadata TEXT,
|
||||||
|
scene_detection TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
processed_at DATETIME,
|
||||||
|
error_message TEXT,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(project_id, md5_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移数据(排除thumbnail_path字段)
|
||||||
|
INSERT INTO materials_new
|
||||||
|
SELECT id, project_id, model_id, name, original_path, file_size, md5_hash,
|
||||||
|
material_type, processing_status, metadata, scene_detection,
|
||||||
|
created_at, updated_at, processed_at, error_message
|
||||||
|
FROM materials;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE materials;
|
||||||
|
ALTER TABLE materials_new RENAME TO materials;
|
||||||
|
|
||||||
|
-- 重新创建索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_project_id ON materials (project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_model_id ON materials (model_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_processing_status ON materials (processing_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_material_type ON materials (material_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_materials_created_at ON materials (created_at);
|
||||||
|
|
||||||
|
-- track_segments表的matching_rule字段在初始化时就存在,无需回滚
|
||||||
|
|
||||||
|
-- 重建template_matching_results表(移除export_count字段)
|
||||||
|
CREATE TABLE template_matching_results_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
matched_segments TEXT NOT NULL,
|
||||||
|
is_exported BOOLEAN DEFAULT 0,
|
||||||
|
last_exported_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移数据(排除export_count字段)
|
||||||
|
INSERT INTO template_matching_results_new
|
||||||
|
SELECT id, template_id, project_id, matched_segments, is_exported,
|
||||||
|
last_exported_at, created_at, updated_at
|
||||||
|
FROM template_matching_results;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE template_matching_results;
|
||||||
|
ALTER TABLE template_matching_results_new RENAME TO template_matching_results;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- 修复template_materials表的file_size字段,允许为NULL
|
||||||
|
-- 重建表以修改字段约束
|
||||||
|
|
||||||
|
CREATE TABLE template_materials_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
original_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
material_type TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
remote_url TEXT,
|
||||||
|
file_size INTEGER, -- 移除NOT NULL约束
|
||||||
|
duration INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
upload_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
file_exists BOOLEAN DEFAULT FALSE,
|
||||||
|
upload_success BOOLEAN DEFAULT FALSE,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移现有数据
|
||||||
|
INSERT INTO template_materials_new
|
||||||
|
SELECT id, template_id, original_id, name, material_type, original_path,
|
||||||
|
remote_url, file_size, duration, width, height, upload_status,
|
||||||
|
file_exists, upload_success, metadata, created_at, updated_at
|
||||||
|
FROM template_materials;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE template_materials;
|
||||||
|
ALTER TABLE template_materials_new RENAME TO template_materials;
|
||||||
|
|
||||||
|
-- 重新创建索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_template_id ON template_materials (template_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_upload_status ON template_materials (upload_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_original_id ON template_materials (original_id);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- 回滚:恢复template_materials表的file_size字段为NOT NULL
|
||||||
|
-- 注意:这个回滚可能会失败,如果存在file_size为NULL的记录
|
||||||
|
|
||||||
|
-- 首先更新所有NULL值为0(作为默认值)
|
||||||
|
UPDATE template_materials SET file_size = 0 WHERE file_size IS NULL;
|
||||||
|
|
||||||
|
-- 重建表,恢复NOT NULL约束
|
||||||
|
CREATE TABLE template_materials_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
original_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
material_type TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
remote_url TEXT,
|
||||||
|
file_size INTEGER NOT NULL, -- 恢复NOT NULL约束
|
||||||
|
duration INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
upload_status TEXT NOT NULL DEFAULT 'Pending',
|
||||||
|
file_exists BOOLEAN DEFAULT FALSE,
|
||||||
|
upload_success BOOLEAN DEFAULT FALSE,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 迁移数据
|
||||||
|
INSERT INTO template_materials_new
|
||||||
|
SELECT id, template_id, original_id, name, material_type, original_path,
|
||||||
|
remote_url, file_size, duration, width, height, upload_status,
|
||||||
|
file_exists, upload_success, metadata, created_at, updated_at
|
||||||
|
FROM template_materials;
|
||||||
|
|
||||||
|
-- 删除旧表并重命名新表
|
||||||
|
DROP TABLE template_materials;
|
||||||
|
ALTER TABLE template_materials_new RENAME TO template_materials;
|
||||||
|
|
||||||
|
-- 重新创建索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_template_id ON template_materials (template_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_upload_status ON template_materials (upload_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_materials_original_id ON template_materials (original_id);
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
fn create_test_connection() -> Connection {
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let conn = Connection::open(temp_file.path()).unwrap();
|
||||||
|
|
||||||
|
// 配置数据库设置
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||||
|
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||||
|
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_manager_creation() {
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
let latest_version = manager.get_latest_version();
|
||||||
|
|
||||||
|
// 应该有至少8个迁移版本
|
||||||
|
assert!(latest_version >= 8, "应该有至少8个迁移版本,实际: {}", latest_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_version_is_zero() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
let current_version = manager.get_current_version(&conn).unwrap();
|
||||||
|
assert_eq!(current_version, 0, "新数据库的版本应该是0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_table_creation() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 确保迁移表存在
|
||||||
|
manager.ensure_migration_table(&conn).unwrap();
|
||||||
|
|
||||||
|
// 验证表是否存在
|
||||||
|
let table_exists: bool = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i32>(0)
|
||||||
|
).unwrap() > 0;
|
||||||
|
|
||||||
|
assert!(table_exists, "schema_migrations表应该存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_migration() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 执行所有迁移
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
|
||||||
|
// 验证版本已更新
|
||||||
|
let current_version = manager.get_current_version(&conn).unwrap();
|
||||||
|
let latest_version = manager.get_latest_version();
|
||||||
|
|
||||||
|
assert_eq!(current_version, latest_version, "迁移后版本应该是最新版本");
|
||||||
|
|
||||||
|
// 验证一些关键表是否存在
|
||||||
|
let tables = vec![
|
||||||
|
"projects", "materials", "material_segments", "models",
|
||||||
|
"ai_classifications", "templates", "template_materials",
|
||||||
|
"tracks", "track_segments", "video_classification_records"
|
||||||
|
];
|
||||||
|
|
||||||
|
for table in tables {
|
||||||
|
let table_exists: bool = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i32>(0)
|
||||||
|
).unwrap() > 0;
|
||||||
|
|
||||||
|
assert!(table_exists, "表 {} 应该存在", table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_history() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
|
||||||
|
// 获取迁移历史
|
||||||
|
let history = manager.get_migration_history(&conn).unwrap();
|
||||||
|
|
||||||
|
// 应该有迁移记录
|
||||||
|
assert!(!history.is_empty(), "应该有迁移历史记录");
|
||||||
|
|
||||||
|
// 所有迁移都应该成功
|
||||||
|
for (version, description, _applied_at, success) in history {
|
||||||
|
assert!(success, "迁移 v{}: {} 应该成功", version, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idempotent_migration() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 第一次迁移
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
let version_after_first = manager.get_current_version(&conn).unwrap();
|
||||||
|
|
||||||
|
// 第二次迁移(应该是幂等的)
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
let version_after_second = manager.get_current_version(&conn).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(version_after_first, version_after_second, "重复迁移应该是幂等的");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_specific_migration_features() {
|
||||||
|
let conn = create_test_connection();
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
manager.migrate(&conn).unwrap();
|
||||||
|
|
||||||
|
// 测试特定的迁移功能
|
||||||
|
|
||||||
|
// 1. 检查materials表是否有model_id字段(迁移2)
|
||||||
|
let has_model_id = conn.prepare("SELECT model_id FROM materials LIMIT 1").is_ok();
|
||||||
|
assert!(has_model_id, "materials表应该有model_id字段");
|
||||||
|
|
||||||
|
// 2. 检查template_materials表是否有file_exists字段(迁移3)
|
||||||
|
let has_file_exists = conn.prepare("SELECT file_exists FROM template_materials LIMIT 1").is_ok();
|
||||||
|
assert!(has_file_exists, "template_materials表应该有file_exists字段");
|
||||||
|
|
||||||
|
// 3. 检查track_segments表是否有matching_rule字段(迁移8)
|
||||||
|
let has_matching_rule = conn.prepare("SELECT matching_rule FROM track_segments LIMIT 1").is_ok();
|
||||||
|
assert!(has_matching_rule, "track_segments表应该有matching_rule字段");
|
||||||
|
|
||||||
|
// 4. 检查material_segments表是否有thumbnail_path字段(迁移8)
|
||||||
|
let has_thumbnail_path = conn.prepare("SELECT thumbnail_path FROM material_segments LIMIT 1").is_ok();
|
||||||
|
assert!(has_thumbnail_path, "material_segments表应该有thumbnail_path字段");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_sql_parsing() {
|
||||||
|
let manager = MigrationManager::new();
|
||||||
|
|
||||||
|
// 测试SQL语句分割
|
||||||
|
let test_sql = "CREATE TABLE test1 (id INTEGER); CREATE TABLE test2 (id INTEGER);";
|
||||||
|
let conn = create_test_connection();
|
||||||
|
|
||||||
|
// 这应该不会出错
|
||||||
|
manager.execute_migration_sql(&conn, test_sql).unwrap();
|
||||||
|
|
||||||
|
// 验证两个表都被创建
|
||||||
|
let table1_exists: bool = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='test1'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i32>(0)
|
||||||
|
).unwrap() > 0;
|
||||||
|
|
||||||
|
let table2_exists: bool = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='test2'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i32>(0)
|
||||||
|
).unwrap() > 0;
|
||||||
|
|
||||||
|
assert!(table1_exists, "test1表应该存在");
|
||||||
|
assert!(table2_exists, "test2表应该存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
mod integration_tests {
|
||||||
|
use crate::infrastructure::database::Database;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn create_test_database() -> (Database, TempDir) {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let db_path = temp_dir.path().join("test.db");
|
||||||
|
|
||||||
|
let database = Database::new_with_path(db_path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
(database, temp_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_database_initialization_with_migrations() {
|
||||||
|
let (database, _temp_dir) = create_test_database();
|
||||||
|
|
||||||
|
// 检查数据库健康状态
|
||||||
|
let health = database.check_database_health().unwrap();
|
||||||
|
|
||||||
|
// 打印健康状态详情用于调试
|
||||||
|
println!("数据库健康状态详情:");
|
||||||
|
println!(" 整体健康: {}", health.overall_healthy);
|
||||||
|
println!(" 版本最新: {}", health.version_up_to_date);
|
||||||
|
println!(" 连接健康: {}", health.connection_healthy);
|
||||||
|
println!(" 表完整: {}", health.tables_complete);
|
||||||
|
println!(" 迁移健康: {}", health.migrations_healthy);
|
||||||
|
println!(" 缺失表: {:?}", health.missing_tables);
|
||||||
|
println!(" 失败迁移: {:?}", health.failed_migrations);
|
||||||
|
|
||||||
|
// 验证数据库是否健康
|
||||||
|
assert!(health.overall_healthy, "数据库应该是健康的");
|
||||||
|
assert!(health.version_up_to_date, "数据库版本应该是最新的");
|
||||||
|
assert!(health.connection_healthy, "数据库连接应该是健康的");
|
||||||
|
assert!(health.tables_complete, "数据库表结构应该是完整的");
|
||||||
|
assert!(health.migrations_healthy, "数据库迁移应该是健康的");
|
||||||
|
assert!(health.missing_tables.is_empty(), "不应该有缺失的表");
|
||||||
|
assert!(health.failed_migrations.is_empty(), "不应该有失败的迁移");
|
||||||
|
|
||||||
|
println!("数据库健康检查通过:");
|
||||||
|
println!(" 当前版本: v{}", health.current_version);
|
||||||
|
println!(" 最新版本: v{}", health.latest_version);
|
||||||
|
println!(" 连接状态: {}", health.connection_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_history() {
|
||||||
|
let (database, _temp_dir) = create_test_database();
|
||||||
|
|
||||||
|
// 获取迁移历史
|
||||||
|
let history = database.get_migration_history().unwrap();
|
||||||
|
|
||||||
|
// 应该有迁移记录
|
||||||
|
assert!(!history.is_empty(), "应该有迁移历史记录");
|
||||||
|
|
||||||
|
// 验证迁移记录的完整性
|
||||||
|
for (version, description, applied_at, success) in &history {
|
||||||
|
assert!(success, "迁移 v{}: {} 应该成功", version, description);
|
||||||
|
assert!(!description.is_empty(), "迁移描述不应该为空");
|
||||||
|
assert!(!applied_at.is_empty(), "应用时间不应该为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("迁移历史记录:");
|
||||||
|
for (version, description, applied_at, success) in history {
|
||||||
|
println!(" v{}: {} - {} ({})",
|
||||||
|
version,
|
||||||
|
description,
|
||||||
|
applied_at,
|
||||||
|
if success { "成功" } else { "失败" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_info() {
|
||||||
|
let (database, _temp_dir) = create_test_database();
|
||||||
|
|
||||||
|
// 获取版本信息
|
||||||
|
let (current_version, latest_version) = database.get_database_version_info().unwrap();
|
||||||
|
|
||||||
|
// 验证版本信息
|
||||||
|
assert!(current_version > 0, "当前版本应该大于0");
|
||||||
|
assert!(latest_version > 0, "最新版本应该大于0");
|
||||||
|
assert_eq!(current_version, latest_version, "当前版本应该等于最新版本");
|
||||||
|
|
||||||
|
println!("版本信息: v{} (最新: v{})", current_version, latest_version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_database_operations_after_migration() {
|
||||||
|
let (database, _temp_dir) = create_test_database();
|
||||||
|
|
||||||
|
// 测试基本的数据库操作是否正常工作
|
||||||
|
|
||||||
|
// 1. 测试项目操作
|
||||||
|
let project_id = "test-project-id".to_string();
|
||||||
|
let project_name = "测试项目".to_string();
|
||||||
|
let project_path = "/test/path".to_string();
|
||||||
|
|
||||||
|
// 创建项目应该成功(这里只是验证表结构正确,不测试具体业务逻辑)
|
||||||
|
let result = database.execute_sql_simple(&format!(
|
||||||
|
"INSERT INTO projects (id, name, path) VALUES ('{}', '{}', '{}')",
|
||||||
|
project_id, project_name, project_path
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "插入项目应该成功");
|
||||||
|
|
||||||
|
// 2. 验证外键约束工作正常
|
||||||
|
let material_result = database.execute_sql_simple(
|
||||||
|
"INSERT INTO materials (id, project_id, name, original_path, file_size, md5_hash, material_type)
|
||||||
|
VALUES ('test-material', 'non-existent-project', 'test', '/test', 100, 'hash', 'Video')"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(material_result.is_err(), "插入不存在项目的素材应该失败(外键约束)");
|
||||||
|
|
||||||
|
// 3. 验证索引工作正常
|
||||||
|
let query_result = database.prepare_sql("SELECT * FROM projects WHERE name = ?1");
|
||||||
|
assert!(query_result.is_ok() && query_result.unwrap(), "按名称查询项目应该成功(索引存在)");
|
||||||
|
|
||||||
|
println!("数据库操作测试通过");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_system_robustness() {
|
||||||
|
let (database, _temp_dir) = create_test_database();
|
||||||
|
|
||||||
|
// 测试重复迁移的幂等性
|
||||||
|
let health_before = database.check_database_health().unwrap();
|
||||||
|
|
||||||
|
// 再次运行迁移(应该是幂等的)
|
||||||
|
let migration_result = database.run_migrations();
|
||||||
|
assert!(migration_result.is_ok(), "重复迁移应该成功");
|
||||||
|
|
||||||
|
let health_after = database.check_database_health().unwrap();
|
||||||
|
|
||||||
|
// 状态应该保持一致
|
||||||
|
assert_eq!(health_before.current_version, health_after.current_version, "版本应该保持一致");
|
||||||
|
assert_eq!(health_before.overall_healthy, health_after.overall_healthy, "健康状态应该保持一致");
|
||||||
|
|
||||||
|
println!("迁移系统鲁棒性测试通过");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
/// 遵循 Tauri 开发规范的分层架构设计
|
/// 遵循 Tauri 开发规范的分层架构设计
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod connection_pool;
|
pub mod connection_pool;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod database_integration_test;
|
||||||
pub mod file_system;
|
pub mod file_system;
|
||||||
pub mod filename_utils;
|
pub mod filename_utils;
|
||||||
pub mod performance;
|
pub mod performance;
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ pub fn run() {
|
|||||||
commands::template_commands::create_template,
|
commands::template_commands::create_template,
|
||||||
commands::template_commands::update_template,
|
commands::template_commands::update_template,
|
||||||
commands::template_commands::delete_template,
|
commands::template_commands::delete_template,
|
||||||
|
commands::template_commands::hard_delete_template,
|
||||||
|
commands::template_commands::verify_template_deletion,
|
||||||
|
commands::template_commands::get_template_associations,
|
||||||
commands::template_commands::import_template,
|
commands::template_commands::import_template,
|
||||||
commands::template_commands::get_import_progress,
|
commands::template_commands::get_import_progress,
|
||||||
commands::template_commands::cancel_import,
|
commands::template_commands::cancel_import,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::data::models::template::{
|
|||||||
Template, ImportTemplateRequest, BatchImportRequest, ImportProgress,
|
Template, ImportTemplateRequest, BatchImportRequest, ImportProgress,
|
||||||
CreateTemplateRequest, SegmentMatchingRule
|
CreateTemplateRequest, SegmentMatchingRule
|
||||||
};
|
};
|
||||||
use crate::business::services::template_service::{TemplateService, TemplateQueryOptions, TemplateListResponse};
|
use crate::business::services::template_service::{TemplateService, TemplateQueryOptions, TemplateListResponse, TemplateAssociations};
|
||||||
use crate::business::services::template_import_service::TemplateImportService;
|
use crate::business::services::template_import_service::TemplateImportService;
|
||||||
use crate::business::services::import_queue_manager::{ImportQueueManager, BatchImportProgress};
|
use crate::business::services::import_queue_manager::{ImportQueueManager, BatchImportProgress};
|
||||||
use crate::infrastructure::database::Database;
|
use crate::infrastructure::database::Database;
|
||||||
@@ -74,6 +74,45 @@ pub async fn delete_template(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 硬删除模板及其所有关联数据
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn hard_delete_template(
|
||||||
|
id: String,
|
||||||
|
database: State<'_, Arc<Database>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let service = TemplateService::new(database.inner().clone());
|
||||||
|
|
||||||
|
service.hard_delete_template(&id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证模板删除后的数据清理完整性
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn verify_template_deletion(
|
||||||
|
id: String,
|
||||||
|
database: State<'_, Arc<Database>>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let service = TemplateService::new(database.inner().clone());
|
||||||
|
|
||||||
|
service.verify_template_deletion(&id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模板的关联数据统计信息
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_template_associations(
|
||||||
|
id: String,
|
||||||
|
database: State<'_, Arc<Database>>,
|
||||||
|
) -> Result<TemplateAssociations, String> {
|
||||||
|
let service = TemplateService::new(database.inner().clone());
|
||||||
|
|
||||||
|
service.get_template_associations(&id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_template(
|
pub async fn import_template(
|
||||||
request: ImportTemplateRequest,
|
request: ImportTemplateRequest,
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ use crate::data::models::thumbnail::{
|
|||||||
};
|
};
|
||||||
use crate::business::services::batch_thumbnail_processor::BatchThumbnailProcessor;
|
use crate::business::services::batch_thumbnail_processor::BatchThumbnailProcessor;
|
||||||
|
|
||||||
/// 全局批量缩略图处理器实例
|
|
||||||
/// 遵循 Tauri 开发规范的状态管理模式
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref THUMBNAIL_PROCESSOR: Arc<Mutex<Option<Arc<BatchThumbnailProcessor>>>> =
|
static ref THUMBNAIL_PROCESSOR: Arc<Mutex<Option<Arc<BatchThumbnailProcessor>>>> =
|
||||||
Arc::new(Mutex::new(None));
|
Arc::new(Mutex::new(None));
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ const Navigation: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
<div>
|
||||||
<div className="hidden md:block">
|
|
||||||
<div className="ml-10 flex items-baseline space-x-4">
|
<div className="ml-10 flex items-baseline space-x-4">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@@ -111,62 +110,6 @@ const Navigation: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile menu button */}
|
|
||||||
<div className="md:hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="bg-white inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<span className="sr-only">打开主菜单</span>
|
|
||||||
<svg
|
|
||||||
className="block h-6 w-6"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile menu */}
|
|
||||||
<div className="md:hidden">
|
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-gray-50">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.href);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
to={item.href}
|
|
||||||
className={`group flex items-center px-3 py-2 rounded-md text-base font-medium transition-colors ${
|
|
||||||
active
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className={`mr-3 h-6 w-6 ${
|
|
||||||
active ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-600'
|
|
||||||
}`} />
|
|
||||||
<div>
|
|
||||||
<div>{item.name}</div>
|
|
||||||
<div className="text-sm text-gray-500">{item.description}</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -128,6 +128,38 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取素材类型文本
|
||||||
|
const getMaterialTypeText = (type: TemplateMaterialType) => {
|
||||||
|
switch (type) {
|
||||||
|
case TemplateMaterialType.Video:
|
||||||
|
return '视频';
|
||||||
|
case TemplateMaterialType.Audio:
|
||||||
|
return '音频';
|
||||||
|
case TemplateMaterialType.Image:
|
||||||
|
return '图片';
|
||||||
|
case TemplateMaterialType.Text:
|
||||||
|
return '文字';
|
||||||
|
case TemplateMaterialType.Effect:
|
||||||
|
return '特效';
|
||||||
|
default:
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取轨道类型文本
|
||||||
|
const getTrackTypeText = (type: TrackType) => {
|
||||||
|
switch (type) {
|
||||||
|
case TrackType.Video:
|
||||||
|
return '视频';
|
||||||
|
case TrackType.Audio:
|
||||||
|
return '音频';
|
||||||
|
case TrackType.Text:
|
||||||
|
return '文字';
|
||||||
|
default:
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 检查文件是否存在(基于数据库字段和路径)
|
// 检查文件是否存在(基于数据库字段和路径)
|
||||||
@@ -456,7 +488,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|||||||
{getMaterialName(material)}
|
{getMaterialName(material)}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
{material.material_type}
|
{getMaterialTypeText(material.material_type)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -608,7 +640,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center space-x-4 text-xs text-gray-500 mt-1">
|
<div className="flex items-center space-x-4 text-xs text-gray-500 mt-1">
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full bg-gray-100 text-gray-800">
|
<span className="inline-flex items-center px-2 py-1 rounded-full bg-gray-100 text-gray-800">
|
||||||
{track.track_type}
|
{getTrackTypeText(track.track_type)}
|
||||||
</span>
|
</span>
|
||||||
<span>轨道 {track.track_index}</span>
|
<span>轨道 {track.track_index}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
SparklesIcon
|
SparklesIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { Model, PhotoType } from '../types/model';
|
import { Model, PhotoType, Gender } from '../types/model';
|
||||||
import {
|
import {
|
||||||
VideoGenerationTask,
|
VideoGenerationTask,
|
||||||
VideoPromptConfig,
|
VideoPromptConfig,
|
||||||
@@ -69,6 +69,20 @@ const ModelDetail: React.FC = () => {
|
|||||||
deleting: false,
|
deleting: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 性别文本转换函数
|
||||||
|
const getGenderText = (gender: Gender) => {
|
||||||
|
switch (gender) {
|
||||||
|
case Gender.Male:
|
||||||
|
return '男';
|
||||||
|
case Gender.Female:
|
||||||
|
return '女';
|
||||||
|
case Gender.Other:
|
||||||
|
return '其他';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 加载模特详情
|
// 加载模特详情
|
||||||
const loadModelDetail = async () => {
|
const loadModelDetail = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -384,7 +398,7 @@ const ModelDetail: React.FC = () => {
|
|||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">性别:</span>
|
<span className="text-gray-500">性别:</span>
|
||||||
<span>{model.gender}</span>
|
<span>{getGenderText(model.gender)}</span>
|
||||||
</div>
|
</div>
|
||||||
{model.age && (
|
{model.age && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user