diff --git a/apps/desktop/src-tauri/src/business/errors.rs b/apps/desktop/src-tauri/src/business/errors.rs index d4724a7..e57d581 100644 --- a/apps/desktop/src-tauri/src/business/errors.rs +++ b/apps/desktop/src-tauri/src/business/errors.rs @@ -166,6 +166,10 @@ pub enum BusinessError { /// 操作不被允许 #[error("操作不被允许: {0}")] OperationNotAllowed(String), + + /// 状态无效 + #[error("状态无效: {0}")] + InvalidState(String), } /// 错误结果类型别名 @@ -254,6 +258,9 @@ impl From for AppError { BusinessError::OperationNotAllowed(operation) => AppError::Permission { operation, }, + BusinessError::InvalidState(state) => AppError::Internal { + message: format!("状态无效: {}", state), + }, } } } diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index 345c39c..1e760d7 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -22,6 +22,9 @@ pub mod template_matching_result_service; pub mod export_record_service; pub mod video_generation_service; pub mod jianying_export; +pub mod outfit_analysis_service; +pub mod outfit_item_service; +pub mod outfit_matching_service; #[cfg(test)] pub mod tests; diff --git a/apps/desktop/src-tauri/src/business/services/outfit_analysis_service.rs b/apps/desktop/src-tauri/src/business/services/outfit_analysis_service.rs new file mode 100644 index 0000000..7071e0b --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/outfit_analysis_service.rs @@ -0,0 +1,314 @@ +use crate::data::models::outfit_analysis::{ + OutfitAnalysis, CreateOutfitAnalysisRequest, UpdateOutfitAnalysisRequest, + OutfitAnalysisQueryOptions, OutfitAnalysisStats, AnalysisStatus, ImageAnalysisResult, + OutfitProduct, ColorHSV +}; +use crate::data::repositories::outfit_analysis_repository::OutfitAnalysisRepository; +use crate::business::errors::BusinessError; +use crate::infrastructure::gemini_service::GeminiService; +use anyhow::Result; +use std::sync::Arc; +use std::path::Path; +use tokio::fs; +use base64::{Engine as _, engine::general_purpose}; + +/// 服装分析业务服务 +/// 遵循 Tauri 开发规范的业务逻辑层设计原则 +pub struct OutfitAnalysisService { + repository: Arc, + gemini_service: Arc, +} + +impl OutfitAnalysisService { + /// 创建新的服装分析服务实例 + pub fn new(repository: Arc, gemini_service: Arc) -> Self { + Self { repository, gemini_service } + } + + /// 创建服装分析记录 + pub async fn create_analysis(&self, request: CreateOutfitAnalysisRequest) -> Result { + // 验证输入数据 + self.validate_create_request(&request)?; + + // 检查图像文件是否存在 + if !Path::new(&request.image_path).exists() { + return Err(BusinessError::InvalidInput(format!("图像文件不存在: {}", request.image_path)).into()); + } + + // 创建分析记录 + let analysis = self.repository.create(&request)?; + + Ok(analysis) + } + + /// 开始分析图像 + pub async fn start_analysis(&self, analysis_id: &str) -> Result<()> { + // 获取分析记录 + let analysis = self.repository.get_by_id(analysis_id)? + .ok_or_else(|| BusinessError::NotFound(format!("分析记录不存在: {}", analysis_id)))?; + + // 检查状态 + if analysis.analysis_status != AnalysisStatus::Pending { + return Err(BusinessError::InvalidState(format!("分析记录状态不正确: {:?}", analysis.analysis_status)).into()); + } + + // 更新状态为处理中 + self.repository.update(analysis_id, &UpdateOutfitAnalysisRequest { + analysis_status: Some(AnalysisStatus::Processing), + analysis_result: None, + error_message: None, + })?; + + // 异步执行分析 + let repository = Arc::clone(&self.repository); + let gemini_service = Arc::clone(&self.gemini_service); + let analysis_clone = analysis.clone(); + + tokio::spawn(async move { + let mut gemini_service_mut = (*gemini_service).clone(); + let result = Self::perform_analysis(&mut gemini_service_mut, &analysis_clone).await; + + match result { + Ok(analysis_result) => { + let _ = repository.update(&analysis_clone.id, &UpdateOutfitAnalysisRequest { + analysis_status: Some(AnalysisStatus::Completed), + analysis_result: Some(analysis_result), + error_message: None, + }); + } + Err(error) => { + let _ = repository.update(&analysis_clone.id, &UpdateOutfitAnalysisRequest { + analysis_status: Some(AnalysisStatus::Failed), + analysis_result: None, + error_message: Some(error.to_string()), + }); + } + } + }); + + Ok(()) + } + + /// 执行图像分析 + async fn perform_analysis(gemini_service: &mut GeminiService, analysis: &OutfitAnalysis) -> Result { + // 读取图像文件 + let image_data = fs::read(&analysis.image_path).await?; + let image_base64 = general_purpose::STANDARD.encode(&image_data); + + // 构建分析提示词 + let prompt = Self::build_analysis_prompt(); + + // 调用Gemini API进行分析 + let response = gemini_service.analyze_image_with_prompt(&image_base64, &prompt).await?; + + // 解析响应 + let analysis_result = Self::parse_analysis_response(&response)?; + + Ok(analysis_result) + } + + /// 构建分析提示词 + fn build_analysis_prompt() -> String { + r#" + 你是一个专业的服装搭配分析师。请分析这张图片中的服装搭配,并按照以下JSON格式返回结果: + + { + "environment_tags": ["环境标签1", "环境标签2"], + "environment_color_pattern": {"hue": 0.5, "saturation": 0.3, "value": 0.8}, + "dress_color_pattern": {"hue": 0.6, "saturation": 0.4, "value": 0.7}, + "style_description": "整体风格描述", + "products": [ + { + "category": "服装类别", + "description": "服装描述", + "color_pattern": {"hue": 0.4, "saturation": 0.5, "value": 0.6}, + "design_styles": ["设计风格1", "设计风格2"], + "color_pattern_match_dress": 0.8, + "color_pattern_match_environment": 0.7 + } + ] + } + + 注意: + 1. HSV颜色值范围都是0.0-1.0 + 2. 匹配度范围是0.0-1.0 + 3. 环境标签用英文 + 4. 设计风格标签用英文 + 5. 类别和描述用中文 + "#.to_string() + } + + /// 解析分析响应 + fn parse_analysis_response(response: &str) -> Result { + // 尝试从响应中提取JSON + let json_start = response.find('{').unwrap_or(0); + let json_end = response.rfind('}').map(|i| i + 1).unwrap_or(response.len()); + let json_str = &response[json_start..json_end]; + + // 解析JSON + let parsed: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| BusinessError::InvalidInput(format!("解析分析结果失败: {}", e)))?; + + // 构建分析结果 + let environment_tags = parsed["environment_tags"] + .as_array() + .unwrap_or(&Vec::new()) + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + + let environment_color = Self::parse_color_hsv(&parsed["environment_color_pattern"])?; + let dress_color = Self::parse_color_hsv(&parsed["dress_color_pattern"])?; + + let style_description = parsed["style_description"] + .as_str() + .unwrap_or("未知风格") + .to_string(); + + let products = parsed["products"] + .as_array() + .unwrap_or(&Vec::new()) + .iter() + .filter_map(|product| Self::parse_outfit_product(product).ok()) + .collect(); + + Ok(ImageAnalysisResult { + environment_tags, + environment_color_pattern: environment_color, + dress_color_pattern: dress_color, + style_description, + products, + }) + } + + /// 解析颜色HSV值 + fn parse_color_hsv(color_value: &serde_json::Value) -> Result { + let hue = color_value["hue"].as_f64().unwrap_or(0.0); + let saturation = color_value["saturation"].as_f64().unwrap_or(0.0); + let value = color_value["value"].as_f64().unwrap_or(0.0); + + Ok(ColorHSV::new(hue, saturation, value)) + } + + /// 解析服装产品信息 + fn parse_outfit_product(product_value: &serde_json::Value) -> Result { + let category = product_value["category"] + .as_str() + .unwrap_or("未知类别") + .to_string(); + + let description = product_value["description"] + .as_str() + .unwrap_or("无描述") + .to_string(); + + let color_pattern = Self::parse_color_hsv(&product_value["color_pattern"])?; + + let design_styles = product_value["design_styles"] + .as_array() + .unwrap_or(&Vec::new()) + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + + let color_pattern_match_dress = product_value["color_pattern_match_dress"] + .as_f64() + .unwrap_or(0.0); + + let color_pattern_match_environment = product_value["color_pattern_match_environment"] + .as_f64() + .unwrap_or(0.0); + + Ok(OutfitProduct { + category, + description, + color_pattern, + design_styles, + color_pattern_match_dress, + color_pattern_match_environment, + }) + } + + /// 获取分析记录列表 + pub async fn get_analyses(&self, options: OutfitAnalysisQueryOptions) -> Result> { + Ok(self.repository.list(&options)?) + } + + /// 根据ID获取分析记录 + pub async fn get_analysis_by_id(&self, id: &str) -> Result> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + Ok(self.repository.get_by_id(id)?) + } + + /// 删除分析记录 + pub async fn delete_analysis(&self, id: &str) -> Result<()> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + self.repository.delete(id)?; + Ok(()) + } + + /// 获取分析统计信息 + pub async fn get_analysis_stats(&self, project_id: Option<&str>) -> Result { + Ok(self.repository.get_stats(project_id)?) + } + + /// 验证创建请求 + fn validate_create_request(&self, request: &CreateOutfitAnalysisRequest) -> Result<()> { + if request.project_id.trim().is_empty() { + return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into()); + } + + if request.image_path.trim().is_empty() { + return Err(BusinessError::InvalidInput("图像路径不能为空".to_string()).into()); + } + + if request.image_name.trim().is_empty() { + return Err(BusinessError::InvalidInput("图像名称不能为空".to_string()).into()); + } + + // 检查文件扩展名 + let path = Path::new(&request.image_path); + if let Some(extension) = path.extension() { + let ext = extension.to_string_lossy().to_lowercase(); + if !matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp") { + return Err(BusinessError::InvalidInput(format!("不支持的图像格式: {}", ext)).into()); + } + } else { + return Err(BusinessError::InvalidInput("无法识别图像文件格式".to_string()).into()); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_color_hsv() { + let color_json = serde_json::json!({ + "hue": 0.5, + "saturation": 0.8, + "value": 0.9 + }); + + let color = OutfitAnalysisService::parse_color_hsv(&color_json).unwrap(); + assert_eq!(color.hue, 0.5); + assert_eq!(color.saturation, 0.8); + assert_eq!(color.value, 0.9); + } + + #[test] + fn test_build_analysis_prompt() { + let prompt = OutfitAnalysisService::build_analysis_prompt(); + assert!(prompt.contains("服装搭配分析师")); + assert!(prompt.contains("JSON格式")); + } +} diff --git a/apps/desktop/src-tauri/src/business/services/outfit_item_service.rs b/apps/desktop/src-tauri/src/business/services/outfit_item_service.rs new file mode 100644 index 0000000..14db64c --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/outfit_item_service.rs @@ -0,0 +1,516 @@ +use crate::data::models::outfit_item::{ + OutfitItem, CreateOutfitItemRequest, UpdateOutfitItemRequest, + OutfitItemQueryOptions, OutfitItemStats, OutfitCategory, OutfitStyle +}; +use crate::data::models::outfit_analysis::ColorHSV; +use crate::data::repositories::outfit_item_repository::OutfitItemRepository; +use crate::business::errors::BusinessError; +use anyhow::Result; +use std::sync::Arc; + +/// 服装单品业务服务 +/// 遵循 Tauri 开发规范的业务逻辑层设计原则 +pub struct OutfitItemService { + repository: Arc, +} + +impl OutfitItemService { + /// 创建新的服装单品服务实例 + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + /// 创建服装单品 + pub async fn create_item(&self, request: CreateOutfitItemRequest) -> Result { + // 验证输入数据 + self.validate_create_request(&request)?; + + // 创建服装单品 + let item = self.repository.create(&request)?; + + Ok(item) + } + + /// 批量创建服装单品(从分析结果) + pub async fn create_items_from_analysis(&self, + project_id: &str, + analysis_id: &str, + products: &[crate::data::models::outfit_analysis::OutfitProduct] + ) -> Result> { + let mut created_items = Vec::new(); + + for (index, product) in products.iter().enumerate() { + let request = CreateOutfitItemRequest { + project_id: project_id.to_string(), + analysis_id: Some(analysis_id.to_string()), + name: format!("{} #{}", product.category, index + 1), + category: OutfitCategory::from_string(&product.category), + brand: None, + model: None, + color_primary: product.color_pattern.clone(), + color_secondary: None, + styles: product.design_styles.iter() + .map(|s| OutfitStyle::from_string(s)) + .collect(), + design_elements: product.design_styles.clone(), + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: Vec::new(), + tags: vec![product.category.clone()], + notes: Some(product.description.clone()), + }; + + match self.repository.create(&request) { + Ok(item) => created_items.push(item), + Err(e) => { + eprintln!("创建服装单品失败: {}", e); + // 继续处理其他单品,不中断整个流程 + } + } + } + + Ok(created_items) + } + + /// 获取服装单品列表 + pub async fn get_items(&self, options: OutfitItemQueryOptions) -> Result> { + Ok(self.repository.list(&options)?) + } + + /// 根据ID获取服装单品 + pub async fn get_item_by_id(&self, id: &str) -> Result> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + Ok(self.repository.get_by_id(id)?) + } + + /// 更新服装单品 + pub async fn update_item(&self, id: &str, request: UpdateOutfitItemRequest) -> Result<()> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + // 验证更新请求 + self.validate_update_request(&request)?; + + // 检查单品是否存在 + if self.repository.get_by_id(id)?.is_none() { + return Err(BusinessError::NotFound(format!("服装单品不存在: {}", id)).into()); + } + + self.repository.update(id, &request)?; + Ok(()) + } + + /// 删除服装单品 + pub async fn delete_item(&self, id: &str) -> Result<()> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + // 检查单品是否存在 + if self.repository.get_by_id(id)?.is_none() { + return Err(BusinessError::NotFound(format!("服装单品不存在: {}", id)).into()); + } + + self.repository.delete(id)?; + Ok(()) + } + + /// 根据颜色搜索服装单品 + pub async fn search_by_color(&self, + project_id: &str, + target_color: &ColorHSV, + threshold: f64 + ) -> Result> { + if project_id.trim().is_empty() { + return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into()); + } + + if threshold < 0.0 || threshold > 1.0 { + return Err(BusinessError::InvalidInput("相似度阈值必须在0.0-1.0之间".to_string()).into()); + } + + Ok(self.repository.search_by_color(project_id, target_color, threshold)?) + } + + /// 根据类别搜索服装单品 + pub async fn search_by_category(&self, + project_id: &str, + category: &OutfitCategory + ) -> Result> { + if project_id.trim().is_empty() { + return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into()); + } + + let options = OutfitItemQueryOptions { + project_id: Some(project_id.to_string()), + category: Some(category.clone()), + styles: None, + color_similarity_threshold: None, + target_color: None, + brand: None, + tags: None, + limit: None, + offset: None, + }; + + Ok(self.repository.list(&options)?) + } + + /// 根据风格搜索服装单品 + pub async fn search_by_styles(&self, + project_id: &str, + styles: &[OutfitStyle] + ) -> Result> { + if project_id.trim().is_empty() { + return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into()); + } + + if styles.is_empty() { + return Err(BusinessError::InvalidInput("风格列表不能为空".to_string()).into()); + } + + let options = OutfitItemQueryOptions { + project_id: Some(project_id.to_string()), + category: None, + styles: Some(styles.to_vec()), + color_similarity_threshold: None, + target_color: None, + brand: None, + tags: None, + limit: None, + offset: None, + }; + + Ok(self.repository.list(&options)?) + } + + /// 智能推荐搭配单品 + pub async fn recommend_matching_items(&self, + project_id: &str, + base_item_id: &str, + max_results: Option + ) -> Result> { + // 获取基础单品 + let base_item = self.repository.get_by_id(base_item_id)? + .ok_or_else(|| BusinessError::NotFound(format!("基础单品不存在: {}", base_item_id)))?; + + // 获取项目中的所有单品 + let all_items = self.repository.list(&OutfitItemQueryOptions { + project_id: Some(project_id.to_string()), + category: None, + styles: None, + color_similarity_threshold: None, + target_color: None, + brand: None, + tags: None, + limit: None, + offset: None, + })?; + + // 过滤掉基础单品本身 + let candidate_items: Vec = all_items.into_iter() + .filter(|item| item.id != base_item_id) + .collect(); + + // 计算匹配度并排序 + let mut scored_items: Vec<(OutfitItem, f64)> = candidate_items.into_iter() + .map(|item| { + let score = self.calculate_matching_score(&base_item, &item); + (item, score) + }) + .collect(); + + // 按匹配度降序排序 + scored_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + // 限制结果数量 + let limit = max_results.unwrap_or(10) as usize; + let recommended_items: Vec = scored_items.into_iter() + .take(limit) + .map(|(item, _)| item) + .collect(); + + Ok(recommended_items) + } + + /// 计算两个单品的匹配度 + fn calculate_matching_score(&self, item1: &OutfitItem, item2: &OutfitItem) -> f64 { + let mut score = 0.0; + + // 颜色匹配度 (权重: 0.4) + let color_similarity = item1.color_primary.similarity(&item2.color_primary); + score += color_similarity * 0.4; + + // 风格匹配度 (权重: 0.3) + let style_similarity = self.calculate_style_similarity(&item1.styles, &item2.styles); + score += style_similarity * 0.3; + + // 类别互补性 (权重: 0.3) + let category_compatibility = self.calculate_category_compatibility(&item1.category, &item2.category); + score += category_compatibility * 0.3; + + score.clamp(0.0, 1.0) + } + + /// 计算风格相似度 + fn calculate_style_similarity(&self, styles1: &[OutfitStyle], styles2: &[OutfitStyle]) -> f64 { + if styles1.is_empty() || styles2.is_empty() { + return 0.5; // 默认中等相似度 + } + + let common_styles = styles1.iter() + .filter(|style1| styles2.contains(style1)) + .count(); + + let total_unique_styles = styles1.len() + styles2.len() - common_styles; + + if total_unique_styles == 0 { + 1.0 + } else { + common_styles as f64 / total_unique_styles as f64 + } + } + + /// 计算类别兼容性 + fn calculate_category_compatibility(&self, category1: &OutfitCategory, category2: &OutfitCategory) -> f64 { + match (category1, category2) { + // 相同类别,兼容性较低(避免重复) + (a, b) if a == b => 0.2, + // 上装与下装,兼容性高 + (OutfitCategory::Top, OutfitCategory::Bottom) | + (OutfitCategory::Bottom, OutfitCategory::Top) => 0.9, + // 连衣裙与配饰,兼容性高 + (OutfitCategory::Dress, OutfitCategory::Accessory) | + (OutfitCategory::Accessory, OutfitCategory::Dress) => 0.8, + // 外套与其他类别,兼容性中等 + (OutfitCategory::Outerwear, _) | + (_, OutfitCategory::Outerwear) => 0.7, + // 鞋类与其他类别,兼容性中等 + (OutfitCategory::Footwear, _) | + (_, OutfitCategory::Footwear) => 0.6, + // 配饰与其他类别,兼容性中等 + (OutfitCategory::Accessory, _) | + (_, OutfitCategory::Accessory) => 0.6, + // 其他组合,默认兼容性 + _ => 0.5, + } + } + + /// 获取服装单品统计信息 + pub async fn get_item_stats(&self, project_id: Option<&str>) -> Result { + Ok(self.repository.get_stats(project_id)?) + } + + /// 验证创建请求 + fn validate_create_request(&self, request: &CreateOutfitItemRequest) -> Result<()> { + if request.project_id.trim().is_empty() { + return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into()); + } + + if request.name.trim().is_empty() { + return Err(BusinessError::InvalidInput("服装名称不能为空".to_string()).into()); + } + + Ok(()) + } + + /// 验证更新请求 + fn validate_update_request(&self, request: &UpdateOutfitItemRequest) -> Result<()> { + if let Some(name) = &request.name { + if name.trim().is_empty() { + return Err(BusinessError::InvalidInput("服装名称不能为空".to_string()).into()); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::models::outfit_analysis::ColorHSV; + + #[test] + fn test_calculate_style_similarity() { + let service = OutfitItemService::new(Arc::new( + // Mock repository - in real tests, use a proper mock + unsafe { std::mem::zeroed() } + )); + + let styles1 = vec![OutfitStyle::Casual, OutfitStyle::Trendy]; + let styles2 = vec![OutfitStyle::Casual, OutfitStyle::Formal]; + + let similarity = service.calculate_style_similarity(&styles1, &styles2); + assert!(similarity > 0.0 && similarity < 1.0); + } + + #[test] + fn test_calculate_category_compatibility() { + let service = OutfitItemService::new(Arc::new( + // Mock repository - in real tests, use a proper mock + unsafe { std::mem::zeroed() } + )); + + let compatibility = service.calculate_category_compatibility( + &OutfitCategory::Top, + &OutfitCategory::Bottom + ); + assert_eq!(compatibility, 0.9); + + let same_category = service.calculate_category_compatibility( + &OutfitCategory::Top, + &OutfitCategory::Top + ); + assert_eq!(same_category, 0.2); + } + + #[test] + fn test_calculate_matching_score() { + let service = OutfitItemService::new(Arc::new( + unsafe { std::mem::zeroed() } + )); + + let item1 = OutfitItem { + id: "1".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "红色T恤".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.0, 0.8, 0.9), // 红色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let item2 = OutfitItem { + id: "2".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "蓝色裤子".to_string(), + category: OutfitCategory::Bottom, + brand: None, + model: None, + color_primary: ColorHSV::new(0.6, 0.7, 0.8), // 蓝色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let score = service.calculate_matching_score(&item1, &item2); + + // 应该有一定的匹配度(相同风格,不同类别) + assert!(score > 0.0 && score <= 1.0); + + // 由于风格相同且类别互补,分数应该比较高 + assert!(score > 0.5); + } + + #[test] + fn test_validate_create_request() { + let service = OutfitItemService::new(Arc::new( + unsafe { std::mem::zeroed() } + )); + + // 测试有效请求 + let valid_request = CreateOutfitItemRequest { + project_id: "test_project".to_string(), + analysis_id: None, + name: "测试T恤".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.5, 0.8, 0.9), + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + }; + + assert!(service.validate_create_request(&valid_request).is_ok()); + + // 测试无效请求 - 空项目ID + let invalid_request = CreateOutfitItemRequest { + project_id: "".to_string(), + ..valid_request.clone() + }; + + assert!(service.validate_create_request(&invalid_request).is_err()); + + // 测试无效请求 - 空名称 + let invalid_request = CreateOutfitItemRequest { + name: "".to_string(), + ..valid_request.clone() + }; + + assert!(service.validate_create_request(&invalid_request).is_err()); + } + + #[test] + fn test_validate_update_request() { + let service = OutfitItemService::new(Arc::new( + unsafe { std::mem::zeroed() } + )); + + // 测试有效更新请求 + let valid_request = UpdateOutfitItemRequest { + name: Some("新名称".to_string()), + category: Some(OutfitCategory::Bottom), + brand: None, + model: None, + color_primary: None, + color_secondary: None, + styles: None, + design_elements: None, + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: None, + tags: None, + notes: None, + }; + + assert!(service.validate_update_request(&valid_request).is_ok()); + + // 测试无效更新请求 - 空名称 + let invalid_request = UpdateOutfitItemRequest { + name: Some("".to_string()), + ..valid_request.clone() + }; + + assert!(service.validate_update_request(&invalid_request).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/business/services/outfit_matching_service.rs b/apps/desktop/src-tauri/src/business/services/outfit_matching_service.rs new file mode 100644 index 0000000..b5aec44 --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/outfit_matching_service.rs @@ -0,0 +1,963 @@ +use crate::data::models::outfit_matching::{ + OutfitMatching, CreateOutfitMatchingRequest, UpdateOutfitMatchingRequest, + OutfitMatchingQueryOptions, OutfitMatchingStats, MatchingType, + MatchingScoreDetails, MatchingSuggestion, MatchingOutfitItem, + SmartMatchingRequest, SmartMatchingResult, ConfidenceLevel +}; +use crate::data::models::outfit_item::{OutfitItem, OutfitCategory, OutfitStyle}; +use crate::data::models::outfit_analysis::ColorHSV; +use crate::data::repositories::outfit_matching_repository::OutfitMatchingRepository; +use crate::data::repositories::outfit_item_repository::OutfitItemRepository; +use crate::business::errors::BusinessError; +use anyhow::Result; +use std::sync::Arc; +use std::collections::HashMap; + +/// 服装搭配业务服务 +/// 遵循 Tauri 开发规范的业务逻辑层设计原则 +pub struct OutfitMatchingService { + repository: Arc, + item_repository: Arc, +} + +impl OutfitMatchingService { + /// 创建新的服装搭配服务实例 + pub fn new( + repository: Arc, + item_repository: Arc + ) -> Self { + Self { repository, item_repository } + } + + /// 创建服装搭配 + pub async fn create_matching(&self, request: CreateOutfitMatchingRequest) -> Result { + // 验证输入数据 + self.validate_create_request(&request)?; + + // 检查项目中是否存在相同名称的搭配 + let existing_matchings = self.repository.list(&OutfitMatchingQueryOptions { + project_id: Some(request.project_id.clone()), + filters: None, + sort_by: None, + sort_order: None, + limit: None, + offset: None, + })?; + + if existing_matchings.iter().any(|m| m.matching_name == request.matching_name) { + return Err(BusinessError::DuplicateName(request.matching_name).into()); + } + + // 检查所有单品是否存在 + let mut items = Vec::new(); + for item_id in &request.item_ids { + let item = self.item_repository.get_by_id(item_id)? + .ok_or_else(|| BusinessError::NotFound(format!("服装单品不存在: {}", item_id)))?; + items.push(item); + } + + // 创建搭配记录 + let matching = self.repository.create(&request)?; + + // 计算搭配评分和建议 + let (score_details, suggestions) = self.calculate_matching_score_and_suggestions(&items); + + // 构建搭配单品列表 + let matching_items = self.build_matching_items(&items); + + // 提取色彩搭配方案 + let color_palette = self.extract_color_palette(&items); + + // 更新搭配记录 + let mut update_request = UpdateOutfitMatchingRequest { + matching_name: None, + matching_type: None, + item_ids: None, + occasion_tags: None, + season_tags: None, + style_description: None, + is_favorite: None, + }; + + // 更新搭配记录(在实际实现中,这些字段应该直接在创建时设置) + // 这里模拟更新操作 + self.repository.update(&matching.id, &update_request)?; + + // 返回完整的搭配记录 + let updated_matching = self.repository.get_by_id(&matching.id)? + .ok_or_else(|| BusinessError::NotFound(format!("搭配记录不存在: {}", matching.id)))?; + + Ok(updated_matching) + } + + /// 获取服装搭配列表 + pub async fn get_matchings(&self, options: OutfitMatchingQueryOptions) -> Result> { + Ok(self.repository.list(&options)?) + } + + /// 根据ID获取服装搭配 + pub async fn get_matching_by_id(&self, id: &str) -> Result> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + Ok(self.repository.get_by_id(id)?) + } + + /// 更新服装搭配 + pub async fn update_matching(&self, id: &str, request: UpdateOutfitMatchingRequest) -> Result<()> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + // 检查搭配是否存在 + let matching = self.repository.get_by_id(id)? + .ok_or_else(|| BusinessError::NotFound(format!("搭配记录不存在: {}", id)))?; + + // 检查名称是否重复 + if let Some(name) = &request.matching_name { + if name != &matching.matching_name { + let existing_matchings = self.repository.list(&OutfitMatchingQueryOptions { + project_id: Some(matching.project_id.clone()), + filters: None, + sort_by: None, + sort_order: None, + limit: None, + offset: None, + })?; + + if existing_matchings.iter().any(|m| m.matching_name == *name) { + return Err(BusinessError::DuplicateName(name.clone()).into()); + } + } + } + + // 更新搭配记录 + self.repository.update(id, &request)?; + + Ok(()) + } + + /// 删除服装搭配 + pub async fn delete_matching(&self, id: &str) -> Result<()> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + // 检查搭配是否存在 + if self.repository.get_by_id(id)?.is_none() { + return Err(BusinessError::NotFound(format!("搭配记录不存在: {}", id)).into()); + } + + self.repository.delete(id)?; + Ok(()) + } + + /// 增加穿着次数 + pub async fn increment_wear_count(&self, id: &str) -> Result<()> { + if id.trim().is_empty() { + return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into()); + } + + // 检查搭配是否存在 + if self.repository.get_by_id(id)?.is_none() { + return Err(BusinessError::NotFound(format!("搭配记录不存在: {}", id)).into()); + } + + self.repository.increment_wear_count(id)?; + Ok(()) + } + + /// 智能搭配推荐 + pub async fn smart_matching(&self, request: SmartMatchingRequest) -> Result { + // 验证请求 + if request.project_id.trim().is_empty() { + return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into()); + } + + // 获取项目中的所有单品 + let all_items = self.item_repository.list(&crate::data::models::outfit_item::OutfitItemQueryOptions { + project_id: Some(request.project_id.clone()), + category: None, + styles: None, + color_similarity_threshold: None, + target_color: None, + brand: None, + tags: None, + limit: None, + offset: None, + })?; + + // 过滤掉排除的单品 + let candidate_items: Vec = if let Some(exclude_ids) = &request.exclude_item_ids { + all_items.into_iter() + .filter(|item| !exclude_ids.contains(&item.id)) + .collect() + } else { + all_items + }; + + // 如果指定了基础单品,则基于它进行搭配 + let mut base_item: Option = None; + if let Some(base_id) = &request.base_item_id { + base_item = self.item_repository.get_by_id(base_id)?; + if base_item.is_none() { + return Err(BusinessError::NotFound(format!("基础单品不存在: {}", base_id)).into()); + } + } + + // 生成搭配组合 + let matchings = self.generate_outfit_combinations( + &candidate_items, + base_item.as_ref(), + request.preferred_styles.as_deref(), + request.target_occasion.as_deref(), + request.target_season.as_deref(), + request.color_preferences.as_deref(), + request.max_results.unwrap_or(5) + )?; + + // 构建推荐结果 + let result = SmartMatchingResult { + recommended_matchings: matchings, + reasoning: "基于您的偏好和搭配规则生成的推荐搭配".to_string(), + confidence_score: 0.85, // 示例置信度 + }; + + Ok(result) + } + + /// 生成服装搭配组合 + fn generate_outfit_combinations( + &self, + items: &[OutfitItem], + base_item: Option<&OutfitItem>, + preferred_styles: Option<&[OutfitStyle]>, + target_occasion: Option<&str>, + target_season: Option<&str>, + color_preferences: Option<&[ColorHSV]>, + max_results: u32 + ) -> Result> { + // 按类别分组单品 + let mut items_by_category: HashMap> = HashMap::new(); + for item in items { + items_by_category.entry(item.category.clone()) + .or_insert_with(Vec::new) + .push(item); + } + + // 生成搭配组合(简化版) + let mut matchings = Vec::new(); + + // 如果有基础单品,则基于它生成搭配 + if let Some(base) = base_item { + // 为每个类别选择最匹配的单品 + let mut matching_items = vec![base.clone()]; + + for (category, category_items) in &items_by_category { + // 跳过与基础单品相同类别的单品 + if *category == base.category { + continue; + } + + // 选择最匹配的单品 + if let Some(best_match) = self.find_best_matching_item( + base, + category_items, + preferred_styles, + color_preferences + ) { + matching_items.push(best_match.clone()); + } + } + + // 创建搭配 + if matching_items.len() > 1 { + let matching = self.create_matching_from_items( + &matching_items, + MatchingType::StyleConsistent, + target_occasion, + target_season + ); + matchings.push(matching); + } + } else { + // 没有基础单品,生成多种搭配组合 + // 这里简化实现,实际应用中应该有更复杂的算法 + + // 尝试上装+下装的组合 + if let (Some(tops), Some(bottoms)) = ( + items_by_category.get(&OutfitCategory::Top), + items_by_category.get(&OutfitCategory::Bottom) + ) { + for top in tops.iter().take(3) { + for bottom in bottoms.iter().take(3) { + let mut outfit_items = vec![(*top).clone(), (*bottom).clone()]; + + // 可能添加外套 + if let Some(outerwears) = items_by_category.get(&OutfitCategory::Outerwear) { + if let Some(outerwear) = outerwears.first() { + outfit_items.push((*outerwear).clone()); + } + } + + // 可能添加鞋子 + if let Some(footwears) = items_by_category.get(&OutfitCategory::Footwear) { + if let Some(footwear) = footwears.first() { + outfit_items.push((*footwear).clone()); + } + } + + let matching = self.create_matching_from_items( + &outfit_items, + MatchingType::StyleConsistent, + target_occasion, + target_season + ); + matchings.push(matching); + + if matchings.len() >= max_results as usize { + break; + } + } + + if matchings.len() >= max_results as usize { + break; + } + } + } + + // 尝试连衣裙+配饰的组合 + if matchings.len() < max_results as usize { + if let Some(dresses) = items_by_category.get(&OutfitCategory::Dress) { + for dress in dresses.iter().take(2) { + let mut outfit_items = vec![(*dress).clone()]; + + // 添加配饰 + if let Some(accessories) = items_by_category.get(&OutfitCategory::Accessory) { + if let Some(accessory) = accessories.first() { + outfit_items.push((*accessory).clone()); + } + } + + // 可能添加外套 + if let Some(outerwears) = items_by_category.get(&OutfitCategory::Outerwear) { + if let Some(outerwear) = outerwears.first() { + outfit_items.push((*outerwear).clone()); + } + } + + let matching = self.create_matching_from_items( + &outfit_items, + MatchingType::StyleConsistent, + target_occasion, + target_season + ); + matchings.push(matching); + + if matchings.len() >= max_results as usize { + break; + } + } + } + } + } + + // 限制结果数量 + matchings.truncate(max_results as usize); + + Ok(matchings) + } + + /// 查找最匹配的单品 + fn find_best_matching_item<'a>( + &self, + base_item: &OutfitItem, + candidates: &[&'a OutfitItem], + preferred_styles: Option<&[OutfitStyle]>, + color_preferences: Option<&[ColorHSV]> + ) -> Option<&'a OutfitItem> { + if candidates.is_empty() { + return None; + } + + // 计算每个候选项的匹配分数 + let mut scored_candidates: Vec<(&OutfitItem, f64)> = candidates.iter() + .map(|item| { + let mut score = 0.0; + + // 颜色匹配度 (权重: 0.4) + let color_similarity = base_item.color_primary.similarity(&item.color_primary); + score += color_similarity * 0.4; + + // 风格匹配度 (权重: 0.3) + let style_similarity = self.calculate_style_similarity(&base_item.styles, &item.styles); + score += style_similarity * 0.3; + + // 偏好风格匹配 (权重: 0.2) + if let Some(styles) = preferred_styles { + let preferred_match = item.styles.iter() + .filter(|style| styles.contains(style)) + .count() as f64 / styles.len().max(1) as f64; + score += preferred_match * 0.2; + } + + // 偏好颜色匹配 (权重: 0.1) + if let Some(colors) = color_preferences { + let mut best_color_match: f64 = 0.0; + for color in colors { + let match_score = item.color_primary.similarity(color); + best_color_match = best_color_match.max(match_score); + } + score += best_color_match * 0.1; + } + + (*item, score) + }) + .collect(); + + // 按分数降序排序 + scored_candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + // 返回最高分的候选项 + scored_candidates.first().map(move |(item, _)| *item) + } + + /// 从单品列表创建搭配 + fn create_matching_from_items( + &self, + items: &[OutfitItem], + matching_type: MatchingType, + occasion: Option<&str>, + season: Option<&str> + ) -> OutfitMatching { + // 计算搭配评分和建议 + let (score_details, suggestions) = self.calculate_matching_score_and_suggestions(items); + + // 构建搭配单品列表 + let matching_items = self.build_matching_items(items); + + // 提取色彩搭配方案 + let color_palette = self.extract_color_palette(items); + + // 生成风格描述 + let style_description = self.generate_style_description(items); + + // 构建场合标签 + let occasion_tags = if let Some(occ) = occasion { + vec![occ.to_string()] + } else { + Vec::new() + }; + + // 构建季节标签 + let season_tags = if let Some(sea) = season { + vec![sea.to_string()] + } else { + Vec::new() + }; + + // 创建搭配记录 + OutfitMatching { + id: uuid::Uuid::new_v4().to_string(), + project_id: items.first().map(|i| i.project_id.clone()).unwrap_or_default(), + matching_name: format!("搭配 {}", chrono::Utc::now().format("%Y%m%d%H%M%S")), + matching_type, + items: matching_items, + score_details, + suggestions, + occasion_tags, + season_tags, + style_description, + color_palette, + is_favorite: false, + wear_count: 0, + last_worn_date: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + } + } + + /// 计算搭配评分和建议 + fn calculate_matching_score_and_suggestions(&self, items: &[OutfitItem]) -> (MatchingScoreDetails, Vec) { + // 计算颜色和谐度 + let color_harmony_score = self.calculate_color_harmony(items); + + // 计算风格一致性 + let style_consistency_score = self.calculate_style_consistency(items); + + // 计算比例协调度 + let proportion_score = 0.75; // 简化实现 + + // 计算场合适宜度 + let occasion_appropriateness = 0.8; // 简化实现 + + // 计算时尚度 + let trend_factor = 0.7; // 简化实现 + + // 创建评分详情 + let mut score_details = MatchingScoreDetails { + color_harmony_score, + style_consistency_score, + proportion_score, + occasion_appropriateness, + trend_factor, + overall_score: 0.0, + }; + + // 计算综合评分 + score_details.calculate_overall_score(); + + // 生成搭配建议 + let mut suggestions = Vec::new(); + + // 如果颜色和谐度较低,添加颜色建议 + if color_harmony_score < 0.6 { + suggestions.push(MatchingSuggestion { + suggestion_type: "颜色调整".to_string(), + description: "考虑选择更协调的颜色组合,可以尝试使用互补色或类似色".to_string(), + priority: 4, + }); + } + + // 如果风格一致性较低,添加风格建议 + if style_consistency_score < 0.6 { + suggestions.push(MatchingSuggestion { + suggestion_type: "风格优化".to_string(), + description: "搭配中的单品风格不够统一,建议选择风格更一致的单品".to_string(), + priority: 3, + }); + } + + (score_details, suggestions) + } + + /// 计算颜色和谐度 + fn calculate_color_harmony(&self, items: &[OutfitItem]) -> f64 { + if items.len() <= 1 { + return 1.0; // 单个单品默认和谐度为1.0 + } + + let mut total_similarity = 0.0; + let mut comparison_count = 0; + + // 计算所有单品之间的颜色相似度 + for i in 0..items.len() { + for j in i+1..items.len() { + let similarity = items[i].color_primary.similarity(&items[j].color_primary); + total_similarity += similarity; + comparison_count += 1; + } + } + + if comparison_count == 0 { + return 1.0; + } + + // 返回平均相似度 + total_similarity / comparison_count as f64 + } + + /// 计算风格一致性 + fn calculate_style_consistency(&self, items: &[OutfitItem]) -> f64 { + if items.len() <= 1 { + return 1.0; // 单个单品默认一致性为1.0 + } + + // 收集所有风格 + let mut all_styles = Vec::new(); + for item in items { + for style in &item.styles { + if !all_styles.contains(style) { + all_styles.push(style.clone()); + } + } + } + + if all_styles.is_empty() { + return 0.5; // 没有风格信息,返回中等一致性 + } + + // 计算每个单品与所有风格的匹配度 + let mut total_consistency = 0.0; + + for item in items { + let mut style_matches = 0; + for style in &all_styles { + if item.styles.contains(style) { + style_matches += 1; + } + } + + let item_consistency = style_matches as f64 / all_styles.len() as f64; + total_consistency += item_consistency; + } + + // 返回平均一致性 + total_consistency / items.len() as f64 + } + + /// 构建搭配单品列表 + fn build_matching_items(&self, items: &[OutfitItem]) -> Vec { + items.iter().map(|item| { + MatchingOutfitItem { + item_id: item.id.clone(), + item_name: item.name.clone(), + category: item.category.clone(), + color_primary: item.color_primary.clone(), + styles: item.styles.clone(), + role_in_outfit: self.determine_item_role(item), + image_url: item.image_urls.first().cloned(), + } + }).collect() + } + + /// 确定单品在搭配中的角色 + fn determine_item_role(&self, item: &OutfitItem) -> String { + match item.category { + OutfitCategory::Top | OutfitCategory::Bottom | OutfitCategory::Dress => "主角".to_string(), + OutfitCategory::Outerwear => "重点".to_string(), + OutfitCategory::Footwear | OutfitCategory::Accessory => "配角".to_string(), + _ => "点缀".to_string(), + } + } + + /// 提取色彩搭配方案 + fn extract_color_palette(&self, items: &[OutfitItem]) -> Vec { + let mut palette = Vec::new(); + + for item in items { + palette.push(item.color_primary.clone()); + if let Some(secondary) = &item.color_secondary { + palette.push(secondary.clone()); + } + } + + // 去重 + palette.dedup_by(|a, b| a.similarity(b) > 0.9); + + palette + } + + /// 生成风格描述 + fn generate_style_description(&self, items: &[OutfitItem]) -> String { + if items.is_empty() { + return "未知风格".to_string(); + } + + // 统计风格出现频率 + let mut style_counts: HashMap = HashMap::new(); + for item in items { + for style in &item.styles { + *style_counts.entry(style.clone()).or_insert(0) += 1; + } + } + + // 找出最常见的风格 + let mut sorted_styles: Vec<(OutfitStyle, usize)> = style_counts.into_iter().collect(); + sorted_styles.sort_by(|a, b| b.1.cmp(&a.1)); + + if sorted_styles.is_empty() { + return "简约搭配".to_string(); + } + + // 生成描述 + let main_style = &sorted_styles[0].0; + let style_name = main_style.to_string(); + + if sorted_styles.len() == 1 || sorted_styles[0].1 > sorted_styles[1].1 { + format!("{}风格搭配", style_name) + } else { + let second_style = &sorted_styles[1].0; + format!("{}与{}混搭风格", style_name, second_style.to_string()) + } + } + + /// 计算风格相似度 + fn calculate_style_similarity(&self, styles1: &[OutfitStyle], styles2: &[OutfitStyle]) -> f64 { + if styles1.is_empty() || styles2.is_empty() { + return 0.5; // 默认中等相似度 + } + + let common_styles = styles1.iter() + .filter(|style1| styles2.contains(style1)) + .count(); + + let total_unique_styles = styles1.len() + styles2.len() - common_styles; + + if total_unique_styles == 0 { + 1.0 + } else { + common_styles as f64 / total_unique_styles as f64 + } + } + + /// 获取服装搭配统计信息 + pub async fn get_matching_stats(&self, project_id: Option<&str>) -> Result { + Ok(self.repository.get_stats(project_id)?) + } + + /// 验证创建请求 + fn validate_create_request(&self, request: &CreateOutfitMatchingRequest) -> Result<()> { + if request.project_id.trim().is_empty() { + return Err(BusinessError::InvalidInput("项目ID不能为空".to_string()).into()); + } + + if request.matching_name.trim().is_empty() { + return Err(BusinessError::InvalidInput("搭配名称不能为空".to_string()).into()); + } + + if request.item_ids.is_empty() { + return Err(BusinessError::InvalidInput("搭配单品列表不能为空".to_string()).into()); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::models::outfit_analysis::ColorHSV; + use crate::data::models::outfit_item::{OutfitCategory, OutfitStyle}; + + #[test] + fn test_calculate_color_harmony() { + // 创建测试用的服装单品 + let items = vec![ + OutfitItem { + id: "1".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "红色T恤".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.0, 0.8, 0.9), // 红色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }, + OutfitItem { + id: "2".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "红色裤子".to_string(), + category: OutfitCategory::Bottom, + brand: None, + model: None, + color_primary: ColorHSV::new(0.02, 0.75, 0.85), // 相似的红色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }, + ]; + + // 创建模拟的仓库 + let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let service = OutfitMatchingService::new(matching_repo, item_repo); + + let harmony_score = service.calculate_color_harmony(&items); + + // 相似颜色应该有较高的和谐度 + assert!(harmony_score > 0.7); + } + + #[test] + fn test_calculate_style_consistency() { + let items = vec![ + OutfitItem { + id: "1".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "休闲T恤".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.0, 0.8, 0.9), + color_secondary: None, + styles: vec![OutfitStyle::Casual, OutfitStyle::Trendy], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }, + OutfitItem { + id: "2".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "休闲裤子".to_string(), + category: OutfitCategory::Bottom, + brand: None, + model: None, + color_primary: ColorHSV::new(0.6, 0.5, 0.8), + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }, + ]; + + let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let service = OutfitMatchingService::new(matching_repo, item_repo); + + let consistency_score = service.calculate_style_consistency(&items); + + // 有共同风格的单品应该有较高的一致性 + assert!(consistency_score > 0.5); + } + + #[test] + fn test_determine_item_role() { + let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let service = OutfitMatchingService::new(matching_repo, item_repo); + + let top_item = OutfitItem { + id: "1".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "T恤".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.0, 0.8, 0.9), + color_secondary: None, + styles: vec![], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + + let role = service.determine_item_role(&top_item); + assert_eq!(role, "主角"); + + let accessory_item = OutfitItem { + category: OutfitCategory::Accessory, + ..top_item.clone() + }; + + let accessory_role = service.determine_item_role(&accessory_item); + assert_eq!(accessory_role, "配角"); + } + + #[test] + fn test_generate_style_description() { + let items = vec![ + OutfitItem { + id: "1".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "休闲T恤".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.0, 0.8, 0.9), + color_secondary: None, + styles: vec![OutfitStyle::Casual, OutfitStyle::Casual], // 重复的休闲风格 + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }, + ]; + + let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let service = OutfitMatchingService::new(matching_repo, item_repo); + + let description = service.generate_style_description(&items); + assert!(description.contains("休闲")); + } + + #[test] + fn test_validate_create_request() { + let matching_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let item_repo = std::sync::Arc::new(unsafe { std::mem::zeroed() }); + let service = OutfitMatchingService::new(matching_repo, item_repo); + + // 测试有效请求 + let valid_request = CreateOutfitMatchingRequest { + project_id: "test_project".to_string(), + matching_name: "测试搭配".to_string(), + matching_type: MatchingType::StyleConsistent, + item_ids: vec!["item1".to_string(), "item2".to_string()], + occasion_tags: vec![], + season_tags: vec![], + style_description: None, + }; + + assert!(service.validate_create_request(&valid_request).is_ok()); + + // 测试无效请求 - 空项目ID + let invalid_request = CreateOutfitMatchingRequest { + project_id: "".to_string(), + ..valid_request.clone() + }; + + assert!(service.validate_create_request(&invalid_request).is_err()); + + // 测试无效请求 - 空搭配名称 + let invalid_request = CreateOutfitMatchingRequest { + matching_name: "".to_string(), + ..valid_request.clone() + }; + + assert!(service.validate_create_request(&invalid_request).is_err()); + + // 测试无效请求 - 空单品列表 + let invalid_request = CreateOutfitMatchingRequest { + item_ids: vec![], + ..valid_request.clone() + }; + + assert!(service.validate_create_request(&invalid_request).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 8be0d13..2c5c1ef 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -10,3 +10,6 @@ pub mod project_template_binding; pub mod template_matching_result; pub mod export_record; pub mod video_generation; +pub mod outfit_analysis; +pub mod outfit_item; +pub mod outfit_matching; diff --git a/apps/desktop/src-tauri/src/data/models/outfit_analysis.rs b/apps/desktop/src-tauri/src/data/models/outfit_analysis.rs new file mode 100644 index 0000000..85c5197 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/outfit_analysis.rs @@ -0,0 +1,206 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// HSV颜色模型 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ColorHSV { + pub hue: f64, // 色相 (0.0-1.0) + pub saturation: f64, // 饱和度 (0.0-1.0) + pub value: f64, // 明度 (0.0-1.0) +} + +impl ColorHSV { + pub fn new(hue: f64, saturation: f64, value: f64) -> Self { + Self { + hue: hue.clamp(0.0, 1.0), + saturation: saturation.clamp(0.0, 1.0), + value: value.clamp(0.0, 1.0), + } + } + + /// 转换为RGB十六进制字符串 + pub fn to_rgb_hex(&self) -> String { + let (r, g, b) = hsv_to_rgb(self.hue, self.saturation, self.value); + format!("#{:02x}{:02x}{:02x}", + (r * 255.0) as u8, + (g * 255.0) as u8, + (b * 255.0) as u8) + } + + /// 从RGB十六进制字符串创建 + pub fn from_rgb_hex(hex: &str) -> Result { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 { + return Err("Invalid hex color format".to_string()); + } + + let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid red component")?; + let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid green component")?; + let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid blue component")?; + + let (h, s, v) = rgb_to_hsv(r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0); + Ok(Self::new(h, s, v)) + } + + /// 计算与另一个颜色的相似度 (0.0-1.0) + pub fn similarity(&self, other: &ColorHSV) -> f64 { + let hue_diff = (self.hue - other.hue).abs().min(1.0 - (self.hue - other.hue).abs()); + let sat_diff = (self.saturation - other.saturation).abs(); + let val_diff = (self.value - other.value).abs(); + + // 加权计算相似度,色相权重最高 + let similarity = 1.0 - (hue_diff * 0.5 + sat_diff * 0.3 + val_diff * 0.2); + similarity.clamp(0.0, 1.0) + } +} + +/// 分析状态枚举 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AnalysisStatus { + Pending, // 待分析 + Processing, // 分析中 + Completed, // 已完成 + Failed, // 分析失败 +} + +impl Default for AnalysisStatus { + fn default() -> Self { + Self::Pending + } +} + +/// 服装项目信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitProduct { + pub category: String, // 服装类别 (如: "牛仔裤", "T恤") + pub description: String, // 描述 + pub color_pattern: ColorHSV, // 主色调 + pub design_styles: Vec, // 设计风格标签 + pub color_pattern_match_dress: f64, // 与整体搭配的颜色匹配度 (0.0-1.0) + pub color_pattern_match_environment: f64, // 与环境的颜色匹配度 (0.0-1.0) +} + +/// 图像分析结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageAnalysisResult { + pub environment_tags: Vec, // 环境标签 + pub environment_color_pattern: ColorHSV, // 环境色调 + pub dress_color_pattern: ColorHSV, // 整体搭配色调 + pub style_description: String, // 风格描述 + pub products: Vec, // 识别到的服装项目 +} + +/// 服装分析记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitAnalysis { + pub id: String, + pub project_id: String, + pub image_path: String, // 原始图像路径 + pub image_name: String, // 图像名称 + pub analysis_status: AnalysisStatus, // 分析状态 + pub analysis_result: Option, // 分析结果 + pub error_message: Option, // 错误信息 + pub created_at: DateTime, + pub updated_at: DateTime, + pub analyzed_at: Option>, +} + +/// 创建分析请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateOutfitAnalysisRequest { + pub project_id: String, + pub image_path: String, + pub image_name: String, +} + +/// 更新分析请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateOutfitAnalysisRequest { + pub analysis_status: Option, + pub analysis_result: Option, + pub error_message: Option, +} + +/// 分析查询选项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitAnalysisQueryOptions { + pub project_id: Option, + pub status: Option, + pub limit: Option, + pub offset: Option, +} + +/// 分析统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitAnalysisStats { + pub total_count: u32, + pub pending_count: u32, + pub processing_count: u32, + pub completed_count: u32, + pub failed_count: u32, +} + +// HSV和RGB转换函数 +fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (f64, f64, f64) { + let c = v * s; + let x = c * (1.0 - ((h * 6.0) % 2.0 - 1.0).abs()); + let m = v - c; + + let (r_prime, g_prime, b_prime) = match (h * 6.0) as i32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + 5 => (c, 0.0, x), + _ => (0.0, 0.0, 0.0), + }; + + (r_prime + m, g_prime + m, b_prime + m) +} + +fn rgb_to_hsv(r: f64, g: f64, b: f64) -> (f64, f64, f64) { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + + let h = if delta == 0.0 { + 0.0 + } else if max == r { + ((g - b) / delta) % 6.0 + } else if max == g { + (b - r) / delta + 2.0 + } else { + (r - g) / delta + 4.0 + } / 6.0; + + let s = if max == 0.0 { 0.0 } else { delta / max }; + let v = max; + + (h.abs(), s, v) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_color_hsv_conversion() { + let color = ColorHSV::new(0.5, 0.8, 0.9); + let hex = color.to_rgb_hex(); + let converted = ColorHSV::from_rgb_hex(&hex).unwrap(); + + assert!((color.hue - converted.hue).abs() < 0.01); + assert!((color.saturation - converted.saturation).abs() < 0.01); + assert!((color.value - converted.value).abs() < 0.01); + } + + #[test] + fn test_color_similarity() { + let color1 = ColorHSV::new(0.5, 0.8, 0.9); + let color2 = ColorHSV::new(0.52, 0.82, 0.88); + let similarity = color1.similarity(&color2); + + assert!(similarity > 0.8); // 应该很相似 + } +} diff --git a/apps/desktop/src-tauri/src/data/models/outfit_item.rs b/apps/desktop/src-tauri/src/data/models/outfit_item.rs new file mode 100644 index 0000000..4f0d74e --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/outfit_item.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use super::outfit_analysis::ColorHSV; + +/// 服装类别枚举 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum OutfitCategory { + Top, // 上装 (T恤、衬衫、毛衣等) + Bottom, // 下装 (裤子、裙子等) + Dress, // 连衣裙 + Outerwear, // 外套 + Footwear, // 鞋类 + Accessory, // 配饰 + Other, // 其他 +} + +impl OutfitCategory { + pub fn from_string(category: &str) -> Self { + match category.to_lowercase().as_str() { + "上装" | "t恤" | "衬衫" | "毛衣" | "背心" | "top" => Self::Top, + "下装" | "裤子" | "牛仔裤" | "短裤" | "裙子" | "bottom" => Self::Bottom, + "连衣裙" | "长裙" | "dress" => Self::Dress, + "外套" | "夹克" | "大衣" | "outerwear" => Self::Outerwear, + "鞋子" | "运动鞋" | "高跟鞋" | "footwear" => Self::Footwear, + "配饰" | "帽子" | "包包" | "首饰" | "accessory" => Self::Accessory, + _ => Self::Other, + } + } + + pub fn to_chinese(&self) -> &'static str { + match self { + Self::Top => "上装", + Self::Bottom => "下装", + Self::Dress => "连衣裙", + Self::Outerwear => "外套", + Self::Footwear => "鞋类", + Self::Accessory => "配饰", + Self::Other => "其他", + } + } +} + +/// 服装风格枚举 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum OutfitStyle { + Casual, // 休闲 + Formal, // 正式 + Business, // 商务 + Sporty, // 运动 + Vintage, // 复古 + Bohemian, // 波西米亚 + Minimalist, // 极简 + Streetwear, // 街头 + Elegant, // 优雅 + Trendy, // 时尚 + Other(String), // 其他自定义风格 +} + +impl OutfitStyle { + pub fn from_string(style: &str) -> Self { + match style.to_lowercase().as_str() { + "casual" | "休闲" => Self::Casual, + "formal" | "正式" => Self::Formal, + "business" | "商务" => Self::Business, + "sporty" | "运动" => Self::Sporty, + "vintage" | "复古" => Self::Vintage, + "bohemian" | "波西米亚" => Self::Bohemian, + "minimalist" | "极简" => Self::Minimalist, + "streetwear" | "街头" => Self::Streetwear, + "elegant" | "优雅" => Self::Elegant, + "trendy" | "时尚" => Self::Trendy, + _ => Self::Other(style.to_string()), + } + } + + pub fn to_string(&self) -> String { + match self { + Self::Casual => "休闲".to_string(), + Self::Formal => "正式".to_string(), + Self::Business => "商务".to_string(), + Self::Sporty => "运动".to_string(), + Self::Vintage => "复古".to_string(), + Self::Bohemian => "波西米亚".to_string(), + Self::Minimalist => "极简".to_string(), + Self::Streetwear => "街头".to_string(), + Self::Elegant => "优雅".to_string(), + Self::Trendy => "时尚".to_string(), + Self::Other(s) => s.clone(), + } + } +} + +/// 服装尺寸信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitSize { + pub size_label: String, // 尺码标签 (如: "M", "L", "XL") + pub measurements: Option, // 具体尺寸 +} + +/// 具体尺寸测量 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SizeMeasurements { + pub chest: Option, // 胸围 (cm) + pub waist: Option, // 腰围 (cm) + pub hip: Option, // 臀围 (cm) + pub length: Option, // 长度 (cm) + pub sleeve: Option, // 袖长 (cm) +} + +/// 服装材质信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitMaterial { + pub fabric_type: String, // 面料类型 (如: "棉", "聚酯纤维") + pub fabric_composition: Vec, // 面料成分 + pub care_instructions: Vec, // 护理说明 +} + +/// 面料成分 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FabricComponent { + pub material: String, // 材料名称 + pub percentage: f64, // 百分比 +} + +/// 服装单品实体 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitItem { + pub id: String, + pub project_id: String, + pub analysis_id: Option, // 关联的分析记录ID + pub name: String, // 服装名称 + pub category: OutfitCategory, // 服装类别 + pub brand: Option, // 品牌 + pub model: Option, // 型号 + pub color_primary: ColorHSV, // 主色调 + pub color_secondary: Option, // 次要色调 + pub styles: Vec, // 风格标签 + pub design_elements: Vec, // 设计元素 (如: "条纹", "印花", "刺绣") + pub size: Option, // 尺寸信息 + pub material: Option, // 材质信息 + pub price: Option, // 价格 + pub purchase_date: Option>, // 购买日期 + pub image_urls: Vec, // 图片URL列表 + pub tags: Vec, // 自定义标签 + pub notes: Option, // 备注 + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 创建服装单品请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateOutfitItemRequest { + pub project_id: String, + pub analysis_id: Option, + pub name: String, + pub category: OutfitCategory, + pub brand: Option, + pub model: Option, + pub color_primary: ColorHSV, + pub color_secondary: Option, + pub styles: Vec, + pub design_elements: Vec, + pub size: Option, + pub material: Option, + pub price: Option, + pub purchase_date: Option>, + pub image_urls: Vec, + pub tags: Vec, + pub notes: Option, +} + +/// 更新服装单品请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateOutfitItemRequest { + pub name: Option, + pub category: Option, + pub brand: Option, + pub model: Option, + pub color_primary: Option, + pub color_secondary: Option, + pub styles: Option>, + pub design_elements: Option>, + pub size: Option, + pub material: Option, + pub price: Option, + pub purchase_date: Option>, + pub image_urls: Option>, + pub tags: Option>, + pub notes: Option, +} + +/// 服装单品查询选项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitItemQueryOptions { + pub project_id: Option, + pub category: Option, + pub styles: Option>, + pub color_similarity_threshold: Option, // 颜色相似度阈值 + pub target_color: Option, // 目标颜色 + pub brand: Option, + pub tags: Option>, + pub limit: Option, + pub offset: Option, +} + +/// 服装单品统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitItemStats { + pub total_count: u32, + pub category_counts: std::collections::HashMap, + pub style_counts: std::collections::HashMap, + pub brand_counts: std::collections::HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_outfit_category_conversion() { + assert_eq!(OutfitCategory::from_string("T恤"), OutfitCategory::Top); + assert_eq!(OutfitCategory::from_string("牛仔裤"), OutfitCategory::Bottom); + assert_eq!(OutfitCategory::from_string("连衣裙"), OutfitCategory::Dress); + } + + #[test] + fn test_outfit_style_conversion() { + assert_eq!(OutfitStyle::from_string("休闲"), OutfitStyle::Casual); + assert_eq!(OutfitStyle::from_string("正式"), OutfitStyle::Formal); + + let custom_style = OutfitStyle::from_string("自定义风格"); + match custom_style { + OutfitStyle::Other(s) => assert_eq!(s, "自定义风格"), + _ => panic!("Expected Other variant"), + } + } +} diff --git a/apps/desktop/src-tauri/src/data/models/outfit_matching.rs b/apps/desktop/src-tauri/src/data/models/outfit_matching.rs new file mode 100644 index 0000000..1e630e3 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/outfit_matching.rs @@ -0,0 +1,237 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use super::outfit_analysis::ColorHSV; +use super::outfit_item::{OutfitCategory, OutfitStyle}; + +/// 匹配类型枚举 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum MatchingType { + ColorHarmony, // 颜色和谐搭配 + StyleConsistent, // 风格一致搭配 + Complementary, // 互补搭配 + Seasonal, // 季节性搭配 + Occasion, // 场合搭配 + Trendy, // 时尚搭配 +} + +impl MatchingType { + pub fn to_chinese(&self) -> &'static str { + match self { + Self::ColorHarmony => "颜色和谐", + Self::StyleConsistent => "风格一致", + Self::Complementary => "互补搭配", + Self::Seasonal => "季节搭配", + Self::Occasion => "场合搭配", + Self::Trendy => "时尚搭配", + } + } +} + +/// 匹配置信度等级 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ConfidenceLevel { + Low, // 低 (0.0-0.4) + Medium, // 中 (0.4-0.7) + High, // 高 (0.7-0.9) + Excellent, // 极佳 (0.9-1.0) +} + +impl ConfidenceLevel { + pub fn from_score(score: f64) -> Self { + match score { + s if s >= 0.9 => Self::Excellent, + s if s >= 0.7 => Self::High, + s if s >= 0.4 => Self::Medium, + _ => Self::Low, + } + } + + pub fn to_chinese(&self) -> &'static str { + match self { + Self::Low => "低", + Self::Medium => "中", + Self::High => "高", + Self::Excellent => "极佳", + } + } +} + +/// 搭配评分详情 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchingScoreDetails { + pub color_harmony_score: f64, // 颜色和谐度评分 (0.0-1.0) + pub style_consistency_score: f64, // 风格一致性评分 (0.0-1.0) + pub proportion_score: f64, // 比例协调度评分 (0.0-1.0) + pub occasion_appropriateness: f64, // 场合适宜度评分 (0.0-1.0) + pub trend_factor: f64, // 时尚度评分 (0.0-1.0) + pub overall_score: f64, // 综合评分 (0.0-1.0) +} + +impl MatchingScoreDetails { + pub fn calculate_overall_score(&mut self) { + // 加权计算综合评分 + self.overall_score = ( + self.color_harmony_score * 0.3 + + self.style_consistency_score * 0.25 + + self.proportion_score * 0.2 + + self.occasion_appropriateness * 0.15 + + self.trend_factor * 0.1 + ).clamp(0.0, 1.0); + } + + pub fn get_confidence_level(&self) -> ConfidenceLevel { + ConfidenceLevel::from_score(self.overall_score) + } +} + +/// 搭配建议 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchingSuggestion { + pub suggestion_type: String, // 建议类型 (如: "颜色调整", "风格优化") + pub description: String, // 建议描述 + pub priority: u8, // 优先级 (1-5, 5最高) +} + +/// 搭配组合中的单品 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchingOutfitItem { + pub item_id: String, // 服装单品ID + pub item_name: String, // 服装名称 + pub category: OutfitCategory, // 类别 + pub color_primary: ColorHSV, // 主色调 + pub styles: Vec, // 风格 + pub role_in_outfit: String, // 在搭配中的角色 (如: "主角", "配角", "点缀") + pub image_url: Option, // 图片URL +} + +/// 搭配匹配结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitMatching { + pub id: String, + pub project_id: String, + pub matching_name: String, // 搭配名称 + pub matching_type: MatchingType, // 匹配类型 + pub items: Vec, // 搭配的服装单品 + pub score_details: MatchingScoreDetails, // 评分详情 + pub suggestions: Vec, // 搭配建议 + pub occasion_tags: Vec, // 适用场合标签 + pub season_tags: Vec, // 适用季节标签 + pub style_description: String, // 风格描述 + pub color_palette: Vec, // 色彩搭配方案 + pub is_favorite: bool, // 是否收藏 + pub wear_count: u32, // 穿着次数 + pub last_worn_date: Option>, // 最后穿着日期 + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 创建搭配匹配请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateOutfitMatchingRequest { + pub project_id: String, + pub matching_name: String, + pub matching_type: MatchingType, + pub item_ids: Vec, // 服装单品ID列表 + pub occasion_tags: Vec, + pub season_tags: Vec, + pub style_description: Option, +} + +/// 更新搭配匹配请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateOutfitMatchingRequest { + pub matching_name: Option, + pub matching_type: Option, + pub item_ids: Option>, + pub occasion_tags: Option>, + pub season_tags: Option>, + pub style_description: Option, + pub is_favorite: Option, +} + +/// 搭配搜索过滤条件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitMatchingSearchFilters { + pub categories: Option>, // 包含的服装类别 + pub styles: Option>, // 风格过滤 + pub colors: Option>, // 颜色过滤 + pub color_similarity_threshold: Option, // 颜色相似度阈值 + pub occasions: Option>, // 场合过滤 + pub seasons: Option>, // 季节过滤 + pub min_score: Option, // 最低评分 + pub confidence_level: Option, // 置信度等级 + pub matching_types: Option>, // 匹配类型 +} + +/// 搭配匹配查询选项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitMatchingQueryOptions { + pub project_id: Option, + pub filters: Option, + pub sort_by: Option, // 排序字段 ("score", "created_at", "wear_count") + pub sort_order: Option, // 排序方向 ("asc", "desc") + pub limit: Option, + pub offset: Option, +} + +/// 智能搭配推荐请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmartMatchingRequest { + pub project_id: String, + pub base_item_id: Option, // 基础单品ID (可选) + pub target_occasion: Option, // 目标场合 + pub target_season: Option, // 目标季节 + pub preferred_styles: Option>, // 偏好风格 + pub color_preferences: Option>, // 颜色偏好 + pub exclude_item_ids: Option>, // 排除的单品ID + pub max_results: Option, // 最大结果数 +} + +/// 智能搭配推荐结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SmartMatchingResult { + pub recommended_matchings: Vec, // 推荐的搭配 + pub reasoning: String, // 推荐理由 + pub confidence_score: f64, // 推荐置信度 +} + +/// 搭配匹配统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutfitMatchingStats { + pub total_matchings: u32, + pub favorite_count: u32, + pub average_score: f64, + pub most_worn_matching_id: Option, + pub style_distribution: std::collections::HashMap, + pub occasion_distribution: std::collections::HashMap, + pub season_distribution: std::collections::HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_confidence_level_from_score() { + assert_eq!(ConfidenceLevel::from_score(0.95), ConfidenceLevel::Excellent); + assert_eq!(ConfidenceLevel::from_score(0.8), ConfidenceLevel::High); + assert_eq!(ConfidenceLevel::from_score(0.5), ConfidenceLevel::Medium); + assert_eq!(ConfidenceLevel::from_score(0.2), ConfidenceLevel::Low); + } + + #[test] + fn test_matching_score_calculation() { + let mut score_details = MatchingScoreDetails { + color_harmony_score: 0.8, + style_consistency_score: 0.9, + proportion_score: 0.7, + occasion_appropriateness: 0.8, + trend_factor: 0.6, + overall_score: 0.0, + }; + + score_details.calculate_overall_score(); + assert!(score_details.overall_score > 0.7); + assert!(score_details.overall_score < 0.9); + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs index c1e9e9f..bfac0f3 100644 --- a/apps/desktop/src-tauri/src/data/repositories/mod.rs +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -8,3 +8,6 @@ pub mod project_template_binding_repository; pub mod template_matching_result_repository; pub mod export_record_repository; pub mod video_generation_repository; +pub mod outfit_analysis_repository; +pub mod outfit_item_repository; +pub mod outfit_matching_repository; diff --git a/apps/desktop/src-tauri/src/data/repositories/outfit_analysis_repository.rs b/apps/desktop/src-tauri/src/data/repositories/outfit_analysis_repository.rs new file mode 100644 index 0000000..c6c754b --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/outfit_analysis_repository.rs @@ -0,0 +1,290 @@ +use rusqlite::{Result, Row, OptionalExtension}; +use std::sync::Arc; +use chrono::{DateTime, Utc}; +use crate::data::models::outfit_analysis::{ + OutfitAnalysis, CreateOutfitAnalysisRequest, UpdateOutfitAnalysisRequest, + OutfitAnalysisQueryOptions, OutfitAnalysisStats, AnalysisStatus, ImageAnalysisResult +}; +use crate::infrastructure::database::Database; + +/// 服装分析数据仓库 +/// 遵循 Tauri 开发规范的数据访问层设计 +pub struct OutfitAnalysisRepository { + database: Arc, +} + +impl OutfitAnalysisRepository { + /// 创建新的服装分析仓库实例 + pub fn new(database: Arc) -> Result { + Ok(OutfitAnalysisRepository { database }) + } + + /// 创建服装分析记录 + pub fn create(&self, request: &CreateOutfitAnalysisRequest) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let analysis = OutfitAnalysis { + id: uuid::Uuid::new_v4().to_string(), + project_id: request.project_id.clone(), + image_path: request.image_path.clone(), + image_name: request.image_name.clone(), + analysis_status: AnalysisStatus::Pending, + analysis_result: None, + error_message: None, + created_at: Utc::now(), + updated_at: Utc::now(), + analyzed_at: None, + }; + + conn.execute( + "INSERT INTO outfit_analyses ( + id, project_id, image_path, image_name, analysis_status, + analysis_result, error_message, created_at, updated_at, analyzed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + [ + analysis.id.as_str(), + analysis.project_id.as_str(), + analysis.image_path.as_str(), + analysis.image_name.as_str(), + &serde_json::to_string(&analysis.analysis_status).unwrap(), + analysis.analysis_result.as_ref().map(|r| serde_json::to_string(r).unwrap()).as_deref().unwrap_or(""), + analysis.error_message.as_deref().unwrap_or(""), + analysis.created_at.to_rfc3339().as_str(), + analysis.updated_at.to_rfc3339().as_str(), + analysis.analyzed_at.as_ref().map(|d| d.to_rfc3339()).as_deref().unwrap_or(""), + ], + )?; + + Ok(analysis) + } + + /// 根据ID获取服装分析记录 + pub fn get_by_id(&self, id: &str) -> Result> { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, project_id, image_path, image_name, analysis_status, + analysis_result, error_message, created_at, updated_at, analyzed_at + FROM outfit_analyses WHERE id = ?1" + )?; + + let analysis = stmt.query_row([id], |row| { + self.row_to_outfit_analysis(row) + }).optional()?; + + Ok(analysis) + } + + /// 更新服装分析记录 + pub fn update(&self, id: &str, request: &UpdateOutfitAnalysisRequest) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut updates = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(status) = &request.analysis_status { + updates.push("analysis_status = ?"); + params.push(Box::new(serde_json::to_string(status).unwrap())); + } + + if let Some(result) = &request.analysis_result { + updates.push("analysis_result = ?"); + params.push(Box::new(serde_json::to_string(result).unwrap())); + } + + if let Some(error) = &request.error_message { + updates.push("error_message = ?"); + params.push(Box::new(error.clone())); + } + + if !updates.is_empty() { + updates.push("updated_at = ?"); + params.push(Box::new(Utc::now().to_rfc3339())); + + // 如果状态更新为已完成,设置分析时间 + if let Some(AnalysisStatus::Completed) = &request.analysis_status { + updates.push("analyzed_at = ?"); + params.push(Box::new(Utc::now().to_rfc3339())); + } + + params.push(Box::new(id.to_string())); + + let sql = format!( + "UPDATE outfit_analyses SET {} WHERE id = ?", + updates.join(", ") + ); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + conn.execute(&sql, param_refs.as_slice())?; + } + + Ok(()) + } + + /// 删除服装分析记录 + pub fn delete(&self, id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute("DELETE FROM outfit_analyses WHERE id = ?1", [id])?; + Ok(()) + } + + /// 查询服装分析记录列表 + pub fn list(&self, options: &OutfitAnalysisQueryOptions) -> Result> { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut sql = "SELECT id, project_id, image_path, image_name, analysis_status, + analysis_result, error_message, created_at, updated_at, analyzed_at + FROM outfit_analyses WHERE 1=1".to_string(); + let mut params: Vec> = Vec::new(); + + if let Some(project_id) = &options.project_id { + sql.push_str(" AND project_id = ?"); + params.push(Box::new(project_id.clone())); + } + + if let Some(status) = &options.status { + sql.push_str(" AND analysis_status = ?"); + params.push(Box::new(serde_json::to_string(status).unwrap())); + } + + sql.push_str(" ORDER BY created_at DESC"); + + if let Some(limit) = options.limit { + sql.push_str(&format!(" LIMIT {}", limit)); + } + + if let Some(offset) = options.offset { + sql.push_str(&format!(" OFFSET {}", offset)); + } + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let analysis_iter = stmt.query_map(param_refs.as_slice(), |row| { + self.row_to_outfit_analysis(row) + })?; + + let mut analyses = Vec::new(); + for analysis in analysis_iter { + analyses.push(analysis?); + } + + Ok(analyses) + } + + /// 获取服装分析统计信息 + pub fn get_stats(&self, project_id: Option<&str>) -> Result { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut sql = "SELECT analysis_status, COUNT(*) as count FROM outfit_analyses".to_string(); + let mut params: Vec> = Vec::new(); + + if let Some(project_id) = project_id { + sql.push_str(" WHERE project_id = ?"); + params.push(Box::new(project_id.to_string())); + } + + sql.push_str(" GROUP BY analysis_status"); + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut total_count = 0u32; + let mut pending_count = 0u32; + let mut processing_count = 0u32; + let mut completed_count = 0u32; + let mut failed_count = 0u32; + + let rows = stmt.query_map(param_refs.as_slice(), |row| { + let status_str: String = row.get(0)?; + let count: u32 = row.get(1)?; + Ok((status_str, count)) + })?; + + for row in rows { + let (status_str, count) = row?; + total_count += count; + + if let Ok(status) = serde_json::from_str::(&status_str) { + match status { + AnalysisStatus::Pending => pending_count = count, + AnalysisStatus::Processing => processing_count = count, + AnalysisStatus::Completed => completed_count = count, + AnalysisStatus::Failed => failed_count = count, + } + } + } + + Ok(OutfitAnalysisStats { + total_count, + pending_count, + processing_count, + completed_count, + failed_count, + }) + } + + /// 将数据库行转换为OutfitAnalysis对象 + fn row_to_outfit_analysis(&self, row: &Row) -> Result { + let analysis_status_str: String = row.get(4)?; + let analysis_status = serde_json::from_str(&analysis_status_str) + .unwrap_or(AnalysisStatus::Pending); + + let analysis_result: Option = row.get(5)?; + let analysis_result = analysis_result + .and_then(|s| serde_json::from_str::(&s).ok()); + + let created_at_str: String = row.get(7)?; + let updated_at_str: String = row.get(8)?; + let analyzed_at_str: Option = row.get(9)?; + + Ok(OutfitAnalysis { + id: row.get(0)?, + project_id: row.get(1)?, + image_path: row.get(2)?, + image_name: row.get(3)?, + analysis_status, + analysis_result, + error_message: row.get(6)?, + created_at: DateTime::parse_from_rfc3339(&created_at_str).unwrap().with_timezone(&Utc), + updated_at: DateTime::parse_from_rfc3339(&updated_at_str).unwrap().with_timezone(&Utc), + analyzed_at: analyzed_at_str + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::database::Database; + + #[test] + fn test_create_and_get_outfit_analysis() { + let database = Arc::new(Database::new_in_memory().unwrap()); + let repo = OutfitAnalysisRepository::new(database).unwrap(); + + let request = CreateOutfitAnalysisRequest { + project_id: "test_project".to_string(), + image_path: "/path/to/image.jpg".to_string(), + image_name: "test_image.jpg".to_string(), + }; + + let analysis = repo.create(&request).unwrap(); + assert_eq!(analysis.project_id, "test_project"); + assert_eq!(analysis.image_name, "test_image.jpg"); + assert_eq!(analysis.analysis_status, AnalysisStatus::Pending); + + let retrieved = repo.get_by_id(&analysis.id).unwrap().unwrap(); + assert_eq!(retrieved.id, analysis.id); + assert_eq!(retrieved.project_id, analysis.project_id); + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/outfit_item_repository.rs b/apps/desktop/src-tauri/src/data/repositories/outfit_item_repository.rs new file mode 100644 index 0000000..a4f61a8 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/outfit_item_repository.rs @@ -0,0 +1,331 @@ +use rusqlite::{Result, Row, OptionalExtension}; +use std::sync::Arc; +use chrono::{DateTime, Utc}; +use crate::data::models::outfit_item::{ + OutfitItem, CreateOutfitItemRequest, UpdateOutfitItemRequest, + OutfitItemQueryOptions, OutfitItemStats, OutfitCategory, OutfitStyle +}; +use crate::data::models::outfit_analysis::ColorHSV; +use crate::infrastructure::database::Database; + +/// 服装单品数据仓库 +/// 遵循 Tauri 开发规范的数据访问层设计 +pub struct OutfitItemRepository { + database: Arc, +} + +impl OutfitItemRepository { + /// 创建新的服装单品仓库实例 + pub fn new(database: Arc) -> Result { + Ok(OutfitItemRepository { database }) + } + + /// 创建服装单品 + pub fn create(&self, request: &CreateOutfitItemRequest) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let item = OutfitItem { + id: uuid::Uuid::new_v4().to_string(), + project_id: request.project_id.clone(), + analysis_id: request.analysis_id.clone(), + name: request.name.clone(), + category: request.category.clone(), + brand: request.brand.clone(), + model: request.model.clone(), + color_primary: request.color_primary.clone(), + color_secondary: request.color_secondary.clone(), + styles: request.styles.clone(), + design_elements: request.design_elements.clone(), + size: request.size.clone(), + material: request.material.clone(), + price: request.price, + purchase_date: request.purchase_date, + image_urls: request.image_urls.clone(), + tags: request.tags.clone(), + notes: request.notes.clone(), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + conn.execute( + "INSERT INTO outfit_items ( + id, project_id, analysis_id, name, category, brand, model, + color_primary, color_secondary, styles, design_elements, + size_info, material_info, price, purchase_date, image_urls, + tags, notes, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + [ + item.id.as_str(), + item.project_id.as_str(), + item.analysis_id.as_deref().unwrap_or(""), + item.name.as_str(), + &serde_json::to_string(&item.category).unwrap(), + item.brand.as_deref().unwrap_or(""), + item.model.as_deref().unwrap_or(""), + &serde_json::to_string(&item.color_primary).unwrap(), + item.color_secondary.as_ref().map(|c| serde_json::to_string(c).unwrap()).as_deref().unwrap_or(""), + &serde_json::to_string(&item.styles).unwrap(), + &serde_json::to_string(&item.design_elements).unwrap(), + item.size.as_ref().map(|s| serde_json::to_string(s).unwrap()).as_deref().unwrap_or(""), + item.material.as_ref().map(|m| serde_json::to_string(m).unwrap()).as_deref().unwrap_or(""), + item.price.map(|p| p.to_string()).as_deref().unwrap_or(""), + item.purchase_date.as_ref().map(|d| d.to_rfc3339()).as_deref().unwrap_or(""), + &serde_json::to_string(&item.image_urls).unwrap(), + &serde_json::to_string(&item.tags).unwrap(), + item.notes.as_deref().unwrap_or(""), + item.created_at.to_rfc3339().as_str(), + item.updated_at.to_rfc3339().as_str(), + ], + )?; + + Ok(item) + } + + /// 根据ID获取服装单品 + pub fn get_by_id(&self, id: &str) -> Result> { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, project_id, analysis_id, name, category, brand, model, + color_primary, color_secondary, styles, design_elements, + size_info, material_info, price, purchase_date, image_urls, + tags, notes, created_at, updated_at + FROM outfit_items WHERE id = ?1" + )?; + + let item = stmt.query_row([id], |row| { + self.row_to_outfit_item(row) + }).optional()?; + + Ok(item) + } + + /// 更新服装单品 + pub fn update(&self, id: &str, request: &UpdateOutfitItemRequest) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut updates = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(name) = &request.name { + updates.push("name = ?"); + params.push(Box::new(name.clone())); + } + + if let Some(category) = &request.category { + updates.push("category = ?"); + params.push(Box::new(serde_json::to_string(category).unwrap())); + } + + if let Some(brand) = &request.brand { + updates.push("brand = ?"); + params.push(Box::new(brand.clone())); + } + + if let Some(color_primary) = &request.color_primary { + updates.push("color_primary = ?"); + params.push(Box::new(serde_json::to_string(color_primary).unwrap())); + } + + if let Some(styles) = &request.styles { + updates.push("styles = ?"); + params.push(Box::new(serde_json::to_string(styles).unwrap())); + } + + if let Some(tags) = &request.tags { + updates.push("tags = ?"); + params.push(Box::new(serde_json::to_string(tags).unwrap())); + } + + if !updates.is_empty() { + updates.push("updated_at = ?"); + params.push(Box::new(Utc::now().to_rfc3339())); + params.push(Box::new(id.to_string())); + + let sql = format!( + "UPDATE outfit_items SET {} WHERE id = ?", + updates.join(", ") + ); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + conn.execute(&sql, param_refs.as_slice())?; + } + + Ok(()) + } + + /// 删除服装单品 + pub fn delete(&self, id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute("DELETE FROM outfit_items WHERE id = ?1", [id])?; + Ok(()) + } + + /// 查询服装单品列表 + pub fn list(&self, options: &OutfitItemQueryOptions) -> Result> { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut sql = "SELECT id, project_id, analysis_id, name, category, brand, model, + color_primary, color_secondary, styles, design_elements, + size_info, material_info, price, purchase_date, image_urls, + tags, notes, created_at, updated_at + FROM outfit_items WHERE 1=1".to_string(); + let mut params: Vec> = Vec::new(); + + if let Some(project_id) = &options.project_id { + sql.push_str(" AND project_id = ?"); + params.push(Box::new(project_id.clone())); + } + + if let Some(category) = &options.category { + sql.push_str(" AND category = ?"); + params.push(Box::new(serde_json::to_string(category).unwrap())); + } + + if let Some(brand) = &options.brand { + sql.push_str(" AND brand = ?"); + params.push(Box::new(brand.clone())); + } + + sql.push_str(" ORDER BY created_at DESC"); + + if let Some(limit) = options.limit { + sql.push_str(&format!(" LIMIT {}", limit)); + } + + if let Some(offset) = options.offset { + sql.push_str(&format!(" OFFSET {}", offset)); + } + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let item_iter = stmt.query_map(param_refs.as_slice(), |row| { + self.row_to_outfit_item(row) + })?; + + let mut items = Vec::new(); + for item in item_iter { + items.push(item?); + } + + Ok(items) + } + + /// 根据颜色相似度搜索服装单品 + pub fn search_by_color(&self, project_id: &str, target_color: &ColorHSV, threshold: f64) -> Result> { + let items = self.list(&OutfitItemQueryOptions { + project_id: Some(project_id.to_string()), + category: None, + styles: None, + color_similarity_threshold: None, + target_color: None, + brand: None, + tags: None, + limit: None, + offset: None, + })?; + + let mut matching_items = Vec::new(); + for item in items { + let similarity = item.color_primary.similarity(target_color); + if similarity >= threshold { + matching_items.push(item); + } + } + + // 按相似度排序 + matching_items.sort_by(|a, b| { + let sim_a = a.color_primary.similarity(target_color); + let sim_b = b.color_primary.similarity(target_color); + sim_b.partial_cmp(&sim_a).unwrap_or(std::cmp::Ordering::Equal) + }); + + Ok(matching_items) + } + + /// 获取服装单品统计信息 + pub fn get_stats(&self, project_id: Option<&str>) -> Result { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut sql = "SELECT COUNT(*) as total FROM outfit_items".to_string(); + let mut params: Vec> = Vec::new(); + + if let Some(project_id) = project_id { + sql.push_str(" WHERE project_id = ?"); + params.push(Box::new(project_id.to_string())); + } + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total_count: u32 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?; + + Ok(OutfitItemStats { + total_count, + category_counts: std::collections::HashMap::new(), + style_counts: std::collections::HashMap::new(), + brand_counts: std::collections::HashMap::new(), + }) + } + + /// 将数据库行转换为OutfitItem对象 + fn row_to_outfit_item(&self, row: &Row) -> Result { + let category_str: String = row.get(4)?; + let category = serde_json::from_str(&category_str).unwrap_or(OutfitCategory::Other); + + let color_primary_str: String = row.get(7)?; + let color_primary = serde_json::from_str(&color_primary_str) + .unwrap_or(ColorHSV::new(0.0, 0.0, 0.0)); + + let color_secondary_str: Option = row.get(8)?; + let color_secondary = color_secondary_str + .and_then(|s| serde_json::from_str(&s).ok()); + + let styles_str: String = row.get(9)?; + let styles = serde_json::from_str(&styles_str).unwrap_or_default(); + + let design_elements_str: String = row.get(10)?; + let design_elements = serde_json::from_str(&design_elements_str).unwrap_or_default(); + + let image_urls_str: String = row.get(15)?; + let image_urls = serde_json::from_str(&image_urls_str).unwrap_or_default(); + + let tags_str: String = row.get(16)?; + let tags = serde_json::from_str(&tags_str).unwrap_or_default(); + + let created_at_str: String = row.get(18)?; + let updated_at_str: String = row.get(19)?; + + Ok(OutfitItem { + id: row.get(0)?, + project_id: row.get(1)?, + analysis_id: row.get(2)?, + name: row.get(3)?, + category, + brand: row.get(5)?, + model: row.get(6)?, + color_primary, + color_secondary, + styles, + design_elements, + size: None, // TODO: 解析size_info JSON + material: None, // TODO: 解析material_info JSON + price: row.get::<_, Option>(13)?.and_then(|s| s.parse().ok()), + purchase_date: row.get::<_, Option>(14)? + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + image_urls, + tags, + notes: row.get(17)?, + created_at: DateTime::parse_from_rfc3339(&created_at_str).unwrap().with_timezone(&Utc), + updated_at: DateTime::parse_from_rfc3339(&updated_at_str).unwrap().with_timezone(&Utc), + }) + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/outfit_matching_repository.rs b/apps/desktop/src-tauri/src/data/repositories/outfit_matching_repository.rs new file mode 100644 index 0000000..56a0c04 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/outfit_matching_repository.rs @@ -0,0 +1,347 @@ +use rusqlite::{Result, Row, OptionalExtension}; +use std::sync::Arc; +use chrono::{DateTime, Utc}; +use crate::data::models::outfit_matching::{ + OutfitMatching, CreateOutfitMatchingRequest, UpdateOutfitMatchingRequest, + OutfitMatchingQueryOptions, OutfitMatchingStats, MatchingType, + MatchingScoreDetails, MatchingSuggestion, MatchingOutfitItem +}; +use crate::data::models::outfit_analysis::ColorHSV; +use crate::infrastructure::database::Database; + +/// 服装搭配数据仓库 +/// 遵循 Tauri 开发规范的数据访问层设计 +pub struct OutfitMatchingRepository { + database: Arc, +} + +impl OutfitMatchingRepository { + /// 创建新的服装搭配仓库实例 + pub fn new(database: Arc) -> Result { + Ok(OutfitMatchingRepository { database }) + } + + /// 创建服装搭配 + pub fn create(&self, request: &CreateOutfitMatchingRequest) -> Result { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + // 创建默认评分详情 + let mut score_details = MatchingScoreDetails { + color_harmony_score: 0.0, + style_consistency_score: 0.0, + proportion_score: 0.0, + occasion_appropriateness: 0.0, + trend_factor: 0.0, + overall_score: 0.0, + }; + score_details.calculate_overall_score(); + + let matching = OutfitMatching { + id: uuid::Uuid::new_v4().to_string(), + project_id: request.project_id.clone(), + matching_name: request.matching_name.clone(), + matching_type: request.matching_type.clone(), + items: Vec::new(), // 将在后续填充 + score_details, + suggestions: Vec::new(), + occasion_tags: request.occasion_tags.clone(), + season_tags: request.season_tags.clone(), + style_description: request.style_description.clone().unwrap_or_default(), + color_palette: Vec::new(), + is_favorite: false, + wear_count: 0, + last_worn_date: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + conn.execute( + "INSERT INTO outfit_matchings ( + id, project_id, matching_name, matching_type, items, + score_details, suggestions, occasion_tags, season_tags, + style_description, color_palette, is_favorite, wear_count, + last_worn_date, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", + [ + matching.id.as_str(), + matching.project_id.as_str(), + matching.matching_name.as_str(), + &serde_json::to_string(&matching.matching_type).unwrap(), + &serde_json::to_string(&request.item_ids).unwrap(), // 暂时存储item_ids + &serde_json::to_string(&matching.score_details).unwrap(), + &serde_json::to_string(&matching.suggestions).unwrap(), + &serde_json::to_string(&matching.occasion_tags).unwrap(), + &serde_json::to_string(&matching.season_tags).unwrap(), + matching.style_description.as_str(), + &serde_json::to_string(&matching.color_palette).unwrap(), + if matching.is_favorite { "1" } else { "0" }, + &matching.wear_count.to_string(), + matching.last_worn_date.as_ref().map(|d| d.to_rfc3339()).as_deref().unwrap_or(""), + matching.created_at.to_rfc3339().as_str(), + matching.updated_at.to_rfc3339().as_str(), + ], + )?; + + Ok(matching) + } + + /// 根据ID获取服装搭配 + pub fn get_by_id(&self, id: &str) -> Result> { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, project_id, matching_name, matching_type, items, + score_details, suggestions, occasion_tags, season_tags, + style_description, color_palette, is_favorite, wear_count, + last_worn_date, created_at, updated_at + FROM outfit_matchings WHERE id = ?1" + )?; + + let matching = stmt.query_row([id], |row| { + self.row_to_outfit_matching(row) + }).optional()?; + + Ok(matching) + } + + /// 更新服装搭配 + pub fn update(&self, id: &str, request: &UpdateOutfitMatchingRequest) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + let mut updates = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(name) = &request.matching_name { + updates.push("matching_name = ?"); + params.push(Box::new(name.clone())); + } + + if let Some(matching_type) = &request.matching_type { + updates.push("matching_type = ?"); + params.push(Box::new(serde_json::to_string(matching_type).unwrap())); + } + + if let Some(item_ids) = &request.item_ids { + updates.push("items = ?"); + params.push(Box::new(serde_json::to_string(item_ids).unwrap())); + } + + if let Some(occasion_tags) = &request.occasion_tags { + updates.push("occasion_tags = ?"); + params.push(Box::new(serde_json::to_string(occasion_tags).unwrap())); + } + + if let Some(season_tags) = &request.season_tags { + updates.push("season_tags = ?"); + params.push(Box::new(serde_json::to_string(season_tags).unwrap())); + } + + if let Some(style_description) = &request.style_description { + updates.push("style_description = ?"); + params.push(Box::new(style_description.clone())); + } + + if let Some(is_favorite) = request.is_favorite { + updates.push("is_favorite = ?"); + params.push(Box::new(if is_favorite { "1" } else { "0" })); + } + + if !updates.is_empty() { + updates.push("updated_at = ?"); + params.push(Box::new(Utc::now().to_rfc3339())); + params.push(Box::new(id.to_string())); + + let sql = format!( + "UPDATE outfit_matchings SET {} WHERE id = ?", + updates.join(", ") + ); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + conn.execute(&sql, param_refs.as_slice())?; + } + + Ok(()) + } + + /// 删除服装搭配 + pub fn delete(&self, id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute("DELETE FROM outfit_matchings WHERE id = ?1", [id])?; + Ok(()) + } + + /// 查询服装搭配列表 + pub fn list(&self, options: &OutfitMatchingQueryOptions) -> Result> { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut sql = "SELECT id, project_id, matching_name, matching_type, items, + score_details, suggestions, occasion_tags, season_tags, + style_description, color_palette, is_favorite, wear_count, + last_worn_date, created_at, updated_at + FROM outfit_matchings WHERE 1=1".to_string(); + let mut params: Vec> = Vec::new(); + + if let Some(project_id) = &options.project_id { + sql.push_str(" AND project_id = ?"); + params.push(Box::new(project_id.clone())); + } + + // 添加排序 + let sort_by = options.sort_by.as_deref().unwrap_or("created_at"); + let sort_order = options.sort_order.as_deref().unwrap_or("desc"); + sql.push_str(&format!(" ORDER BY {} {}", sort_by, sort_order.to_uppercase())); + + if let Some(limit) = options.limit { + sql.push_str(&format!(" LIMIT {}", limit)); + } + + if let Some(offset) = options.offset { + sql.push_str(&format!(" OFFSET {}", offset)); + } + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let matching_iter = stmt.query_map(param_refs.as_slice(), |row| { + self.row_to_outfit_matching(row) + })?; + + let mut matchings = Vec::new(); + for matching in matching_iter { + matchings.push(matching?); + } + + Ok(matchings) + } + + /// 增加穿着次数 + pub fn increment_wear_count(&self, id: &str) -> Result<()> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + conn.execute( + "UPDATE outfit_matchings + SET wear_count = wear_count + 1, + last_worn_date = ?, + updated_at = ? + WHERE id = ?", + [ + Utc::now().to_rfc3339().as_str(), + Utc::now().to_rfc3339().as_str(), + id, + ], + )?; + + Ok(()) + } + + /// 获取服装搭配统计信息 + pub fn get_stats(&self, project_id: Option<&str>) -> Result { + let conn = self.database.get_read_connection(); + let conn = conn.lock().unwrap(); + + let mut sql = "SELECT COUNT(*) as total, + COUNT(CASE WHEN is_favorite = '1' THEN 1 END) as favorites, + AVG(CAST(JSON_EXTRACT(score_details, '$.overall_score') AS REAL)) as avg_score + FROM outfit_matchings".to_string(); + let mut params: Vec> = Vec::new(); + + if let Some(project_id) = project_id { + sql.push_str(" WHERE project_id = ?"); + params.push(Box::new(project_id.to_string())); + } + + let mut stmt = conn.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let (total_matchings, favorite_count, average_score) = stmt.query_row(param_refs.as_slice(), |row| { + Ok(( + row.get::<_, u32>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, Option>(2)?.unwrap_or(0.0), + )) + })?; + + Ok(OutfitMatchingStats { + total_matchings, + favorite_count, + average_score, + most_worn_matching_id: None, // TODO: 实现最常穿搭配查询 + style_distribution: std::collections::HashMap::new(), + occasion_distribution: std::collections::HashMap::new(), + season_distribution: std::collections::HashMap::new(), + }) + } + + /// 将数据库行转换为OutfitMatching对象 + fn row_to_outfit_matching(&self, row: &Row) -> Result { + let matching_type_str: String = row.get(3)?; + let matching_type = serde_json::from_str(&matching_type_str) + .unwrap_or(MatchingType::ColorHarmony); + + let items_str: String = row.get(4)?; + let items = serde_json::from_str(&items_str).unwrap_or_default(); + + let score_details_str: String = row.get(5)?; + let score_details = serde_json::from_str(&score_details_str) + .unwrap_or_else(|_| MatchingScoreDetails { + color_harmony_score: 0.0, + style_consistency_score: 0.0, + proportion_score: 0.0, + occasion_appropriateness: 0.0, + trend_factor: 0.0, + overall_score: 0.0, + }); + + let suggestions_str: String = row.get(6)?; + let suggestions = serde_json::from_str(&suggestions_str).unwrap_or_default(); + + let occasion_tags_str: String = row.get(7)?; + let occasion_tags = serde_json::from_str(&occasion_tags_str).unwrap_or_default(); + + let season_tags_str: String = row.get(8)?; + let season_tags = serde_json::from_str(&season_tags_str).unwrap_or_default(); + + let color_palette_str: String = row.get(10)?; + let color_palette = serde_json::from_str(&color_palette_str).unwrap_or_default(); + + let is_favorite_str: String = row.get(11)?; + let is_favorite = is_favorite_str == "1"; + + let wear_count_str: String = row.get(12)?; + let wear_count = wear_count_str.parse().unwrap_or(0); + + let last_worn_date_str: Option = row.get(13)?; + let last_worn_date = last_worn_date_str + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + + let created_at_str: String = row.get(14)?; + let updated_at_str: String = row.get(15)?; + + Ok(OutfitMatching { + id: row.get(0)?, + project_id: row.get(1)?, + matching_name: row.get(2)?, + matching_type, + items, + score_details, + suggestions, + occasion_tags, + season_tags, + style_description: row.get(9)?, + color_palette, + is_favorite, + wear_count, + last_worn_date, + created_at: DateTime::parse_from_rfc3339(&created_at_str).unwrap().with_timezone(&Utc), + updated_at: DateTime::parse_from_rfc3339(&updated_at_str).unwrap().with_timezone(&Utc), + }) + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/database.rs b/apps/desktop/src-tauri/src/infrastructure/database.rs index 2a82b3b..0f486b9 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database.rs @@ -808,6 +808,77 @@ impl Database { [], )?; + // 创建服装分析表 + conn.execute( + "CREATE TABLE IF NOT EXISTS outfit_analyses ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + image_path TEXT NOT NULL, + image_name TEXT NOT NULL, + analysis_status TEXT NOT NULL DEFAULT 'Pending', + analysis_result TEXT, + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + analyzed_at DATETIME, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE + )", + [], + )?; + + // 创建服装单品表 + conn.execute( + "CREATE TABLE IF NOT EXISTS outfit_items ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + analysis_id TEXT, + name TEXT NOT NULL, + category TEXT NOT NULL, + brand TEXT, + model TEXT, + color_primary TEXT NOT NULL, + color_secondary TEXT, + styles TEXT NOT NULL, + design_elements TEXT, + size_info TEXT, + material_info TEXT, + price REAL, + purchase_date DATETIME, + image_urls TEXT, + tags TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY (analysis_id) REFERENCES outfit_analyses (id) ON DELETE SET NULL + )", + [], + )?; + + // 创建服装搭配表 + conn.execute( + "CREATE TABLE IF NOT EXISTS outfit_matchings ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + matching_name TEXT NOT NULL, + matching_type TEXT NOT NULL, + items TEXT NOT NULL, + score_details TEXT NOT NULL, + suggestions TEXT, + occasion_tags TEXT, + season_tags TEXT, + style_description TEXT NOT NULL, + color_palette TEXT, + is_favorite BOOLEAN DEFAULT 0, + wear_count INTEGER DEFAULT 0, + last_worn_date DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE + )", + [], + )?; + // 创建索引 conn.execute( "CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)", @@ -951,6 +1022,64 @@ impl Database { [], )?; + // 创建服装分析表索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_analyses_project_id ON outfit_analyses (project_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_analyses_status ON outfit_analyses (analysis_status)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_analyses_created_at ON outfit_analyses (created_at)", + [], + )?; + + // 创建服装单品表索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_items_project_id ON outfit_items (project_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_items_analysis_id ON outfit_items (analysis_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_items_category ON outfit_items (category)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_items_brand ON outfit_items (brand)", + [], + )?; + + // 创建服装搭配表索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_matchings_project_id ON outfit_matchings (project_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_matchings_type ON outfit_matchings (matching_type)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_matchings_favorite ON outfit_matchings (is_favorite)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_outfit_matchings_created_at ON outfit_matchings (created_at)", + [], + )?; + // 添加新字段(如果不存在)- 数据库迁移 let _ = conn.execute( "ALTER TABLE template_materials ADD COLUMN file_exists BOOLEAN DEFAULT FALSE", diff --git a/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs b/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs index 40ae028..60b917e 100644 --- a/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs +++ b/apps/desktop/src-tauri/src/infrastructure/gemini_service.rs @@ -86,9 +86,13 @@ struct ContentPart { #[serde(untagged)] enum Part { Text { text: String }, - FileData { + FileData { #[serde(rename = "fileData")] - file_data: FileData + file_data: FileData + }, + InlineData { + #[serde(rename = "inlineData")] + inline_data: InlineData }, } @@ -100,6 +104,13 @@ struct FileData { file_uri: String, } +#[derive(Debug, Serialize)] +struct InlineData { + #[serde(rename = "mimeType")] + mime_type: String, + data: String, +} + #[derive(Debug, Serialize)] struct GenerationConfig { temperature: f32, @@ -134,6 +145,7 @@ struct ResponsePart { /// Gemini API服务 /// 遵循 Tauri 开发规范的基础设施层设计模式 +#[derive(Clone)] pub struct GeminiService { config: GeminiConfig, client: reqwest::Client, @@ -409,6 +421,64 @@ impl GeminiService { Ok((file_uri, analysis_result)) } + + /// 分析图像内容(用于服装搭配分析) + pub async fn analyze_image_with_prompt(&mut self, image_base64: &str, prompt: &str) -> Result { + println!("🧠 正在进行图像分析..."); + + // 获取访问令牌 + let access_token = self.get_access_token().await?; + + // 创建客户端配置 + let client_config = self.create_gemini_client(&access_token); + + // 准备请求数据 + let request_data = GenerateContentRequest { + contents: vec![ContentPart { + role: "user".to_string(), + parts: vec![ + Part::Text { text: prompt.to_string() }, + Part::InlineData { + inline_data: InlineData { + mime_type: "image/jpeg".to_string(), + data: image_base64.to_string(), + } + } + ], + }], + generation_config: GenerationConfig { + temperature: self.config.temperature, + top_k: 32, + top_p: 1.0, + max_output_tokens: self.config.max_tokens, + }, + }; + + // 发送请求到Cloudflare Gateway + let generate_url = format!("{}/{}:generateContent", client_config.gateway_url, self.config.model_name); + + // 重试机制 + let mut last_error = None; + + for attempt in 0..self.config.max_retries { + match self.send_generate_request(&generate_url, &client_config, &request_data).await { + Ok(result) => { + let content = self.parse_gemini_response_content(&result)?; + println!("✅ 图像分析完成"); + return Ok(content); + } + Err(e) => { + last_error = Some(e); + + if attempt < self.config.max_retries - 1 { + tokio::time::sleep(tokio::time::Duration::from_secs(self.config.retry_delay)).await; + } + } + } + } + + Err(anyhow!("图像分析失败,已重试{}次: {}", self.config.max_retries, last_error.unwrap())) + } } #[cfg(test)] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9c40492..7c57e18 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -255,7 +255,28 @@ pub fn run() { commands::debug_commands::test_parse_draft_file, commands::debug_commands::validate_template_structure, // 便捷工具命令 - commands::tools_commands::clean_jsonl_data + commands::tools_commands::clean_jsonl_data, + // 服装搭配命令 + commands::outfit_commands::create_outfit_analysis, + commands::outfit_commands::start_outfit_analysis, + commands::outfit_commands::list_outfit_analyses, + commands::outfit_commands::get_outfit_analysis_by_id, + commands::outfit_commands::delete_outfit_analysis, + commands::outfit_commands::get_outfit_analysis_stats, + commands::outfit_commands::create_outfit_item, + commands::outfit_commands::create_outfit_items_from_analysis, + commands::outfit_commands::list_outfit_items, + commands::outfit_commands::get_outfit_item_by_id, + commands::outfit_commands::update_outfit_item, + commands::outfit_commands::delete_outfit_item, + commands::outfit_commands::create_outfit_matching, + commands::outfit_commands::list_outfit_matchings, + commands::outfit_commands::get_outfit_matching_by_id, + commands::outfit_commands::update_outfit_matching, + commands::outfit_commands::delete_outfit_matching, + commands::outfit_commands::smart_outfit_matching, + commands::outfit_commands::increment_outfit_matching_wear_count, + commands::outfit_commands::save_outfit_image ]) .setup(|app| { // 初始化日志系统 diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 67618b5..cefec33 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -17,3 +17,4 @@ pub mod template_matching_result_commands; pub mod export_record_commands; pub mod video_generation_commands; pub mod tools_commands; +pub mod outfit_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/outfit_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/outfit_commands.rs new file mode 100644 index 0000000..482ddfe --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/outfit_commands.rs @@ -0,0 +1,333 @@ +/** + * 服装搭配相关的 Tauri 命令 + * 遵循 Tauri 开发规范的 API 设计原则 + */ + +use tauri::{command, State}; +use std::sync::Arc; + +use crate::app_state::AppState; +use crate::business::services::outfit_analysis_service::OutfitAnalysisService; +use crate::business::services::outfit_item_service::OutfitItemService; +use crate::business::services::outfit_matching_service::OutfitMatchingService; +use crate::data::models::outfit_analysis::{ + OutfitAnalysis, CreateOutfitAnalysisRequest, + OutfitAnalysisQueryOptions, OutfitAnalysisStats +}; +use crate::data::models::outfit_item::{ + OutfitItem, CreateOutfitItemRequest, UpdateOutfitItemRequest, + OutfitItemQueryOptions +}; +use crate::data::models::outfit_matching::{ + OutfitMatching, CreateOutfitMatchingRequest, UpdateOutfitMatchingRequest, + OutfitMatchingQueryOptions, SmartMatchingRequest, SmartMatchingResult +}; +use crate::data::repositories::outfit_analysis_repository::OutfitAnalysisRepository; +use crate::data::repositories::outfit_item_repository::OutfitItemRepository; +use crate::data::repositories::outfit_matching_repository::OutfitMatchingRepository; +use crate::infrastructure::gemini_service::GeminiService; + +// 辅助函数:创建服务实例 +fn create_analysis_service(database: Arc) -> Result { + let analysis_repository = OutfitAnalysisRepository::new(database.clone()) + .map_err(|e| format!("创建分析仓库失败: {}", e))?; + let gemini_service = GeminiService::new(None); + Ok(OutfitAnalysisService::new( + Arc::new(analysis_repository), + Arc::new(gemini_service) + )) +} + +fn create_item_service(database: Arc) -> Result { + let item_repository = OutfitItemRepository::new(database.clone()) + .map_err(|e| format!("创建单品仓库失败: {}", e))?; + Ok(OutfitItemService::new(Arc::new(item_repository))) +} + +fn create_matching_service(database: Arc) -> Result { + let matching_repository = OutfitMatchingRepository::new(database.clone()) + .map_err(|e| format!("创建搭配仓库失败: {}", e))?; + let item_repository = OutfitItemRepository::new(database.clone()) + .map_err(|e| format!("创建单品仓库失败: {}", e))?; + Ok(OutfitMatchingService::new( + Arc::new(matching_repository), + Arc::new(item_repository) + )) +} + +/// 创建服装分析记录 +#[command] +pub async fn create_outfit_analysis( + state: State<'_, AppState>, + request: CreateOutfitAnalysisRequest, +) -> Result { + let database = state.get_database(); + let service = create_analysis_service(database)?; + service.create_analysis(request).await + .map_err(|e| format!("创建分析记录失败: {}", e)) +} + +/// 开始分析 +#[command] +pub async fn start_outfit_analysis( + state: State<'_, AppState>, + analysis_id: String, +) -> Result<(), String> { + let database = state.get_database(); + let service = create_analysis_service(database)?; + service.start_analysis(&analysis_id).await + .map_err(|e| format!("开始分析失败: {}", e)) +} + +/// 获取分析记录列表 +#[command] +pub async fn list_outfit_analyses( + state: State<'_, AppState>, + options: OutfitAnalysisQueryOptions, +) -> Result, String> { + let database = state.get_database(); + let service = create_analysis_service(database)?; + service.get_analyses(options).await + .map_err(|e| format!("获取分析记录列表失败: {}", e)) +} + +/// 根据ID获取服装分析记录 +#[command] +pub async fn get_outfit_analysis_by_id( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + let database = state.get_database(); + let service = create_analysis_service(database)?; + service.get_analysis_by_id(&id).await + .map_err(|e| format!("获取分析记录失败: {}", e)) +} + +/// 删除分析记录 +#[command] +pub async fn delete_outfit_analysis( + state: State<'_, AppState>, + id: String, +) -> Result<(), String> { + let database = state.get_database(); + let service = create_analysis_service(database)?; + service.delete_analysis(&id).await + .map_err(|e| format!("删除分析记录失败: {}", e)) +} + +/// 获取分析统计信息 +#[command] +pub async fn get_outfit_analysis_stats( + state: State<'_, AppState>, + project_id: Option, +) -> Result { + let database = state.get_database(); + let service = create_analysis_service(database)?; + service.get_analysis_stats(project_id.as_deref()).await + .map_err(|e| format!("获取分析统计信息失败: {}", e)) +} + +/// 创建服装单品 +#[command] +pub async fn create_outfit_item( + state: State<'_, AppState>, + request: CreateOutfitItemRequest, +) -> Result { + let database = state.get_database(); + let service = create_item_service(database)?; + service.create_item(request).await + .map_err(|e| format!("创建服装单品失败: {}", e)) +} + +/// 从分析结果创建服装单品 +#[command] +pub async fn create_outfit_items_from_analysis( + state: State<'_, AppState>, + project_id: String, + analysis_id: String, +) -> Result, String> { + // 这是一个简化的实现,实际应该从分析结果中提取产品信息 + Err("功能开发中".to_string()) +} + +/// 获取服装单品列表 +#[command] +pub async fn list_outfit_items( + state: State<'_, AppState>, + options: OutfitItemQueryOptions, +) -> Result, String> { + let database = state.get_database(); + let service = create_item_service(database)?; + service.get_items(options).await + .map_err(|e| format!("获取服装单品列表失败: {}", e)) +} + +/// 根据ID获取服装单品 +#[command] +pub async fn get_outfit_item_by_id( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + let database = state.get_database(); + let service = create_item_service(database)?; + service.get_item_by_id(&id).await + .map_err(|e| format!("获取服装单品失败: {}", e)) +} + +/// 更新服装单品 +#[command] +pub async fn update_outfit_item( + state: State<'_, AppState>, + id: String, + request: UpdateOutfitItemRequest, +) -> Result<(), String> { + let database = state.get_database(); + let service = create_item_service(database)?; + service.update_item(&id, request).await + .map_err(|e| format!("更新服装单品失败: {}", e)) +} + +/// 删除服装单品 +#[command] +pub async fn delete_outfit_item( + state: State<'_, AppState>, + id: String, +) -> Result<(), String> { + let database = state.get_database(); + let service = create_item_service(database)?; + service.delete_item(&id).await + .map_err(|e| format!("删除服装单品失败: {}", e)) +} + +/// 创建服装搭配 +#[command] +pub async fn create_outfit_matching( + state: State<'_, AppState>, + request: CreateOutfitMatchingRequest, +) -> Result { + let database = state.get_database(); + let service = create_matching_service(database)?; + service.create_matching(request).await + .map_err(|e| format!("创建服装搭配失败: {}", e)) +} + +/// 获取服装搭配列表 +#[command] +pub async fn list_outfit_matchings( + state: State<'_, AppState>, + options: OutfitMatchingQueryOptions, +) -> Result, String> { + let database = state.get_database(); + let service = create_matching_service(database)?; + service.get_matchings(options).await + .map_err(|e| format!("获取服装搭配列表失败: {}", e)) +} + +/// 根据ID获取服装搭配 +#[command] +pub async fn get_outfit_matching_by_id( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + let database = state.get_database(); + let service = create_matching_service(database)?; + service.get_matching_by_id(&id).await + .map_err(|e| format!("获取服装搭配失败: {}", e)) +} + +/// 更新服装搭配 +#[command] +pub async fn update_outfit_matching( + state: State<'_, AppState>, + id: String, + request: UpdateOutfitMatchingRequest, +) -> Result<(), String> { + let database = state.get_database(); + let service = create_matching_service(database)?; + service.update_matching(&id, request).await + .map_err(|e| format!("更新服装搭配失败: {}", e)) +} + +/// 删除服装搭配 +#[command] +pub async fn delete_outfit_matching( + state: State<'_, AppState>, + id: String, +) -> Result<(), String> { + let database = state.get_database(); + let service = create_matching_service(database)?; + service.delete_matching(&id).await + .map_err(|e| format!("删除服装搭配失败: {}", e)) +} + +/// 智能搭配推荐 +#[command] +pub async fn smart_outfit_matching( + state: State<'_, AppState>, + request: SmartMatchingRequest, +) -> Result { + let database = state.get_database(); + let service = create_matching_service(database)?; + service.smart_matching(request).await + .map_err(|e| format!("智能搭配推荐失败: {}", e)) +} + +/// 增加搭配穿着次数 +#[command] +pub async fn increment_outfit_matching_wear_count( + state: State<'_, AppState>, + id: String, +) -> Result<(), String> { + let database = state.get_database(); + let service = create_matching_service(database)?; + service.increment_wear_count(&id).await + .map_err(|e| format!("增加搭配穿着次数失败: {}", e)) +} + +/// 保存上传的图片文件 +#[command] +pub async fn save_outfit_image( + state: State<'_, AppState>, + project_id: String, + file_name: String, + file_data: Vec, +) -> Result { + use std::fs; + use std::path::Path; + + // 获取项目路径 + let repository_guard = state.get_project_repository() + .map_err(|e| format!("获取项目仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("项目仓库未初始化")?; + + let project = repository.find_by_id(&project_id) + .map_err(|e| format!("获取项目失败: {}", e))? + .ok_or("项目不存在")?; + + // 创建保存目录 + let project_path = Path::new(&project.path); + let outfit_dir = project_path.join("outfit_images"); + + if !outfit_dir.exists() { + fs::create_dir_all(&outfit_dir) + .map_err(|e| format!("创建目录失败: {}", e))?; + } + + // 生成唯一文件名 + let timestamp = chrono::Utc::now().timestamp(); + let extension = Path::new(&file_name) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("jpg"); + let unique_name = format!("{}_{}.{}", timestamp, uuid::Uuid::new_v4(), extension); + let file_path = outfit_dir.join(&unique_name); + + // 保存文件 + fs::write(&file_path, file_data) + .map_err(|e| format!("保存文件失败: {}", e))?; + + // 返回文件路径 + Ok(file_path.to_string_lossy().to_string()) +} diff --git a/apps/desktop/src-tauri/tests/outfit_integration_test.rs b/apps/desktop/src-tauri/tests/outfit_integration_test.rs new file mode 100644 index 0000000..ad7a60f --- /dev/null +++ b/apps/desktop/src-tauri/tests/outfit_integration_test.rs @@ -0,0 +1,296 @@ +/** + * 服装搭配功能集成测试 + * 测试从图像分析到搭配推荐的完整流程 + */ + +#[cfg(test)] +mod outfit_integration_tests { + use std::sync::Arc; + use chrono::Utc; + + use crate::data::models::outfit_analysis::{ + OutfitAnalysis, CreateOutfitAnalysisRequest, AnalysisStatus, ColorHSV + }; + use crate::data::models::outfit_item::{ + OutfitItem, CreateOutfitItemRequest, OutfitCategory, OutfitStyle + }; + use crate::data::models::outfit_matching::{ + OutfitMatching, CreateOutfitMatchingRequest, MatchingType + }; + use crate::data::repositories::outfit_analysis_repository::OutfitAnalysisRepository; + use crate::data::repositories::outfit_item_repository::OutfitItemRepository; + use crate::data::repositories::outfit_matching_repository::OutfitMatchingRepository; + use crate::business::services::outfit_analysis_service::OutfitAnalysisService; + use crate::business::services::outfit_item_service::OutfitItemService; + use crate::business::services::outfit_matching_service::OutfitMatchingService; + use crate::infrastructure::database::Database; + use crate::infrastructure::gemini_service::GeminiService; + + /// 测试完整的服装分析流程 + #[tokio::test] + async fn test_complete_outfit_analysis_workflow() { + // 创建内存数据库 + let database = Arc::new(Database::new_in_memory().unwrap()); + + // 创建仓库 + let analysis_repo = Arc::new(OutfitAnalysisRepository::new(database.clone()).unwrap()); + let item_repo = Arc::new(OutfitItemRepository::new(database.clone()).unwrap()); + let matching_repo = Arc::new(OutfitMatchingRepository::new(database.clone()).unwrap()); + + // 创建服务(注意:在实际测试中,应该使用模拟的GeminiService) + let gemini_service = Arc::new(GeminiService::new().unwrap()); + let analysis_service = OutfitAnalysisService::new(analysis_repo.clone(), gemini_service); + let item_service = OutfitItemService::new(item_repo.clone()); + let matching_service = OutfitMatchingService::new(matching_repo.clone(), item_repo.clone()); + + // 1. 创建分析记录 + let analysis_request = CreateOutfitAnalysisRequest { + project_id: "test_project".to_string(), + image_path: "/test/image.jpg".to_string(), + image_name: "test_image.jpg".to_string(), + }; + + let analysis = analysis_service.create_analysis(analysis_request).await.unwrap(); + assert_eq!(analysis.analysis_status, AnalysisStatus::Pending); + + // 2. 模拟分析完成,手动创建服装单品 + let item1_request = CreateOutfitItemRequest { + project_id: "test_project".to_string(), + analysis_id: Some(analysis.id.clone()), + name: "红色T恤".to_string(), + category: OutfitCategory::Top, + brand: Some("测试品牌".to_string()), + model: None, + color_primary: ColorHSV::new(0.0, 0.8, 0.9), // 红色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec!["纯色".to_string()], + size: None, + material: None, + price: Some(99.0), + purchase_date: None, + image_urls: vec![], + tags: vec!["夏季".to_string(), "休闲".to_string()], + notes: Some("舒适的棉质T恤".to_string()), + }; + + let item1 = item_service.create_item(item1_request).await.unwrap(); + assert_eq!(item1.category, OutfitCategory::Top); + + let item2_request = CreateOutfitItemRequest { + project_id: "test_project".to_string(), + analysis_id: Some(analysis.id.clone()), + name: "蓝色牛仔裤".to_string(), + category: OutfitCategory::Bottom, + brand: Some("测试品牌".to_string()), + model: None, + color_primary: ColorHSV::new(0.6, 0.7, 0.8), // 蓝色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec!["水洗".to_string()], + size: None, + material: None, + price: Some(199.0), + purchase_date: None, + image_urls: vec![], + tags: vec!["四季".to_string(), "百搭".to_string()], + notes: Some("经典款牛仔裤".to_string()), + }; + + let item2 = item_service.create_item(item2_request).await.unwrap(); + assert_eq!(item2.category, OutfitCategory::Bottom); + + // 3. 创建搭配 + let matching_request = CreateOutfitMatchingRequest { + project_id: "test_project".to_string(), + matching_name: "休闲夏日搭配".to_string(), + matching_type: MatchingType::StyleConsistent, + item_ids: vec![item1.id.clone(), item2.id.clone()], + occasion_tags: vec!["日常".to_string(), "休闲".to_string()], + season_tags: vec!["夏季".to_string()], + style_description: Some("轻松休闲的夏日搭配".to_string()), + }; + + let matching = matching_service.create_matching(matching_request).await.unwrap(); + assert_eq!(matching.matching_type, MatchingType::StyleConsistent); + assert_eq!(matching.items.len(), 2); + + // 4. 验证搭配评分 + assert!(matching.score_details.overall_score >= 0.0); + assert!(matching.score_details.overall_score <= 1.0); + + // 5. 测试搭配推荐 + let recommended_items = item_service.recommend_matching_items( + "test_project", + &item1.id, + Some(3) + ).await.unwrap(); + + // 应该推荐item2,因为它与item1风格匹配且类别互补 + assert!(recommended_items.iter().any(|item| item.id == item2.id)); + + println!("✅ 服装分析完整流程测试通过"); + } + + /// 测试颜色匹配功能 + #[tokio::test] + async fn test_color_matching_functionality() { + let database = Arc::new(Database::new_in_memory().unwrap()); + let item_repo = Arc::new(OutfitItemRepository::new(database.clone()).unwrap()); + let item_service = OutfitItemService::new(item_repo.clone()); + + // 创建不同颜色的服装单品 + let red_item = CreateOutfitItemRequest { + project_id: "test_project".to_string(), + analysis_id: None, + name: "红色上衣".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.0, 0.8, 0.9), // 纯红色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + }; + + let blue_item = CreateOutfitItemRequest { + project_id: "test_project".to_string(), + analysis_id: None, + name: "蓝色上衣".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.6, 0.8, 0.9), // 纯蓝色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + }; + + let similar_red_item = CreateOutfitItemRequest { + project_id: "test_project".to_string(), + analysis_id: None, + name: "深红色上衣".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.02, 0.75, 0.85), // 相似的红色 + color_secondary: None, + styles: vec![OutfitStyle::Casual], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + }; + + // 创建服装单品 + let red = item_service.create_item(red_item).await.unwrap(); + let blue = item_service.create_item(blue_item).await.unwrap(); + let similar_red = item_service.create_item(similar_red_item).await.unwrap(); + + // 测试颜色搜索 + let target_color = ColorHSV::new(0.0, 0.8, 0.9); // 红色 + let matching_items = item_service.search_by_color( + "test_project", + &target_color, + 0.7 // 70% 相似度阈值 + ).await.unwrap(); + + // 应该找到红色和相似红色的单品,但不包括蓝色 + assert!(matching_items.iter().any(|item| item.id == red.id)); + assert!(matching_items.iter().any(|item| item.id == similar_red.id)); + assert!(!matching_items.iter().any(|item| item.id == blue.id)); + + println!("✅ 颜色匹配功能测试通过"); + } + + /// 测试搭配评分算法 + #[tokio::test] + async fn test_outfit_scoring_algorithm() { + let database = Arc::new(Database::new_in_memory().unwrap()); + let matching_repo = Arc::new(OutfitMatchingRepository::new(database.clone()).unwrap()); + let item_repo = Arc::new(OutfitItemRepository::new(database.clone()).unwrap()); + let matching_service = OutfitMatchingService::new(matching_repo, item_repo); + + // 创建测试用的服装单品 + let items = vec![ + OutfitItem { + id: "1".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "白色T恤".to_string(), + category: OutfitCategory::Top, + brand: None, + model: None, + color_primary: ColorHSV::new(0.0, 0.0, 1.0), // 白色 + color_secondary: None, + styles: vec![OutfitStyle::Casual, OutfitStyle::Minimalist], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + OutfitItem { + id: "2".to_string(), + project_id: "test".to_string(), + analysis_id: None, + name: "黑色裤子".to_string(), + category: OutfitCategory::Bottom, + brand: None, + model: None, + color_primary: ColorHSV::new(0.0, 0.0, 0.0), // 黑色 + color_secondary: None, + styles: vec![OutfitStyle::Casual, OutfitStyle::Minimalist], + design_elements: vec![], + size: None, + material: None, + price: None, + purchase_date: None, + image_urls: vec![], + tags: vec![], + notes: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + ]; + + // 测试评分计算 + let (score_details, suggestions) = matching_service.calculate_matching_score_and_suggestions(&items); + + // 验证评分范围 + assert!(score_details.color_harmony_score >= 0.0 && score_details.color_harmony_score <= 1.0); + assert!(score_details.style_consistency_score >= 0.0 && score_details.style_consistency_score <= 1.0); + assert!(score_details.overall_score >= 0.0 && score_details.overall_score <= 1.0); + + // 黑白搭配应该有较高的风格一致性(都是极简风格) + assert!(score_details.style_consistency_score > 0.5); + + println!("✅ 搭配评分算法测试通过"); + println!(" 颜色和谐度: {:.2}", score_details.color_harmony_score); + println!(" 风格一致性: {:.2}", score_details.style_consistency_score); + println!(" 综合评分: {:.2}", score_details.overall_score); + } +}