diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 2edb018..db6fe90 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -246,14 +246,20 @@ pub async fn refresh_all_caches( a }).collect(); - let mut c = albums_cache.write().await; - c.update(enriched); - - // 2. 刷新标签(从 search.db) + // 2. 刷新标签(tag→albums 倒排索引直接从已富化的 album 列表构建,零 DB 查询) let popular_tags = search_db.get_popular_tags(200).await; - let tag_albums = search_db.get_all_tag_albums().await; + let mut tag_albums: HashMap> = HashMap::new(); + for album in &enriched { + for tag in &album.tags { + tag_albums.entry(tag.clone()).or_default().push(album.clone()); + } + } let mut tc = tags_cache.write().await; tc.update(popular_tags, tag_albums); + + // 3. 刷新相册缓存(依赖 enriched 数据,必须在标签刷新之后执行) + let mut c = albums_cache.write().await; + c.update(enriched); } // 3. 刷新人物(从 search.db) diff --git a/src/db/album.rs b/src/db/album.rs index 8a0f2b0..d7d0d40 100644 --- a/src/db/album.rs +++ b/src/db/album.rs @@ -228,10 +228,66 @@ impl Database { pub async fn delete_album(&self, album_id: Uuid) -> Result<(), sqlx::Error> { let _guard = self.write_lock.lock().await; - sqlx::query("DELETE FROM albums WHERE id = ?").bind(album_id.to_string()).execute(&self.pool).await?; + let aid = album_id.to_string(); + // 先删除 person_albums(无 CASCADE) + sqlx::query("DELETE FROM person_albums WHERE album_id = ?").bind(&aid).execute(&self.pool).await?; + // 再删除 albums(images/favorites/album_tags 由 CASCADE 自动处理) + sqlx::query("DELETE FROM albums WHERE id = ?").bind(&aid).execute(&self.pool).await?; Ok(()) } + /// 批量删除相册(级联清理所有关联数据) + pub async fn delete_albums_batch(&self, album_ids: &[Uuid]) -> usize { + if album_ids.is_empty() { return 0; } + let _guard = self.write_lock.lock().await; + for id in album_ids { + let aid = id.to_string(); + sqlx::query("DELETE FROM person_albums WHERE album_id = ?").bind(&aid).execute(&self.pool).await.ok(); + sqlx::query("DELETE FROM albums WHERE id = ?").bind(&aid).execute(&self.pool).await.ok(); + } + album_ids.len() + } + + /// 查询 image_count = 0 的空相册 + pub async fn get_empty_albums(&self) -> Vec { + let rows = sqlx::query( + r#"SELECT id, title, document, description, cover_image_id, image_count, created_at + FROM albums WHERE image_count IS NULL OR image_count = 0 + ORDER BY created_at DESC"# + ).fetch_all(&self.pool).await.unwrap_or_default(); + rows.iter().map(|r| { + let id_str = r.get::("id"); + AlbumInfo { + id: Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()), + title: r.get("title"), document: r.get("document"), + description: r.get("description"), cover_image_id: r.get("cover_image_id"), + image_count: r.get("image_count"), created_at: r.get("created_at"), + tags: vec![], persons: vec![], + } + }).collect() + } + + /// 查找重名相册,返回 (keep_id, [duplicate_ids]) + pub async fn find_duplicate_albums(&self) -> Vec<(Uuid, Vec)> { + let rows = sqlx::query( + r#"SELECT id, title FROM albums WHERE title != '' ORDER BY created_at ASC"# + ).fetch_all(&self.pool).await.unwrap_or_default(); + + let mut title_map: std::collections::HashMap> = std::collections::HashMap::new(); + for r in &rows { + let title: String = r.get("title"); + let id_str: String = r.get("id"); + if let Ok(id) = Uuid::parse_str(&id_str) { + title_map.entry(title).or_default().push(id); + } + } + + title_map.into_iter() + .filter(|(_, ids)| ids.len() > 1) + .map(|(_, ids)| (ids[0], ids[1..].to_vec())) + .collect() + } + pub async fn update_album_cover(&self, album_id: Uuid, image_id: Uuid) -> Result<(), sqlx::Error> { sqlx::query("UPDATE albums SET cover_image_id = ? WHERE id = ?") .bind(image_id.to_string()).bind(album_id.to_string()).execute(&self.pool).await?; diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs index 8d0ac1b..464a9d3 100644 --- a/src/engine/pipeline.rs +++ b/src/engine/pipeline.rs @@ -244,6 +244,47 @@ async fn phase_clean_persons_combined(db: &Database) -> (DirtySet, usize, usize) (dirty, non_persons_deleted, stop_word_persons_removed) } +// ════════════════════════════════════════════════ +// Phase Z0:删除空相册(image_count = 0) +// ════════════════════════════════════════════════ + +/// 删除所有图片数为 0 的空相册 +pub async fn phase_delete_empty_albums(db: &Database) -> usize { + let empty = db.get_empty_albums().await; + let count = empty.len(); + if count == 0 { + println!("[管线·PhaseZ0] 无空相册需要删除"); + return 0; + } + let ids: Vec = empty.iter().map(|a| a.id).collect(); + let deleted = db.delete_albums_batch(&ids).await; + println!("[管线·PhaseZ0] 删除 {} 个空相册(无图片)", deleted); + deleted +} + +// ════════════════════════════════════════════════ +// Phase Z1:去重同名相册 +// ════════════════════════════════════════════════ + +/// 查找同名的相册,只保留第一个(最早创建的),删除其余 +pub async fn phase_dedup_albums(db: &Database) -> usize { + let dups = db.find_duplicate_albums().await; + let mut total_deleted = 0usize; + for (keep, to_delete) in &dups { + let deleted = db.delete_albums_batch(to_delete).await; + total_deleted += deleted; + for id in to_delete { + println!("[管线·PhaseZ1] 删除重复相册 {}(保留 {})", id, keep); + } + } + if total_deleted == 0 { + println!("[管线·PhaseZ1] 无重复相册"); + } else { + println!("[管线·PhaseZ1] 去重完成:删除 {} 个重复相册", total_deleted); + } + total_deleted +} + // ════════════════════════════════════════════════ // Pipeline 一键执行 // ════════════════════════════════════════════════ @@ -262,16 +303,26 @@ pub struct PipelineResult { pub persons_merged: usize, // Phase ④ pub aliases_moved: usize, // Phase ④ pub tags_removed_person: usize, // Phase ⑤ + pub empty_albums_deleted: usize, // Phase Z0 + pub dup_albums_deleted: usize, // Phase Z1 } -/// 一键执行精简管线(5 阶段,2-3 次全库扫描) +/// 一键执行精简管线 /// +/// Phase Z0 删除空相册 +/// Phase Z1 去重同名相册 /// Phase ① 合并预处理(清洗+短标签+人名标签+新相册) → 1次相册扫描 /// Phase ② 合并人物清理(非人物+停用词人物) → 1次人物扫描 /// Phase ③ 增量解析脏集 → 只扫脏集 /// Phase ④ 人物合并 + 重计 → 1次人物扫描 /// Phase ⑤ 后校验标签中的人名 pub async fn run_pipeline(db: &Database) -> PipelineResult { + // Phase Z0:删除空相册 + let empty_albums_deleted = phase_delete_empty_albums(db).await; + + // Phase Z1:去重同名相册 + let dup_albums_deleted = phase_dedup_albums(db).await; + // Phase ①:合并预处理(1 次相册扫描) let pre = phase_preprocess_combined(db).await; let tags_cleaned = pre.tags_cleaned; @@ -305,6 +356,7 @@ pub async fn run_pipeline(db: &Database) -> PipelineResult { non_persons_deleted, stop_word_persons_removed, new_albums_found, persons_updated, tags_updated, persons_merged, aliases_moved, tags_removed_person, + empty_albums_deleted, dup_albums_deleted, } } diff --git a/src/routes/pipeline.rs b/src/routes/pipeline.rs index 347fb69..dfec3de 100644 --- a/src/routes/pipeline.rs +++ b/src/routes/pipeline.rs @@ -25,9 +25,12 @@ pub async fn pipeline_run( "persons_merged": result.persons_merged, "aliases_moved": result.aliases_moved, "tags_removed_person": result.tags_removed_person, + "empty_albums_deleted": result.empty_albums_deleted, + "dup_albums_deleted": result.dup_albums_deleted, "search_db_synced": synced, "message": format!( - "管线执行完毕:清洗标点{}相册,移除短标签{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{},同步标签/人物到search.db{}", + "管线执行完毕:删除空相册{},去重{},清洗标点{}相册,移除短标签{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{},同步标签/人物到search.db{}", + result.empty_albums_deleted, result.dup_albums_deleted, result.tags_cleaned, result.short_tags_removed, result.person_like_tags_removed, result.dirty_albums_detected, result.non_persons_deleted, result.stop_word_persons_removed, result.new_albums_found, result.persons_updated, result.tags_updated, diff --git a/src/search_db/search.rs b/src/search_db/search.rs index 97ff734..7c2a46e 100644 --- a/src/search_db/search.rs +++ b/src/search_db/search.rs @@ -151,41 +151,10 @@ impl SearchDb { rows.iter().map(|r| TagStat { tag: r.get("name"), count: r.get("cnt"), - album_count: 0, + album_count: r.get("cnt"), }).collect() } - /// 获取标签倒排索引:tag → albums(缓存烘焙用) - pub async fn get_all_tag_albums(&self) -> HashMap> { - let rows = sqlx::query( - r#"SELECT t.name as tag_name, - a.id, a.title, a.cover_image_id, a.image_count, a.created_at - FROM tags t - JOIN album_tags at ON t.id = at.tag_id - JOIN albums a ON a.id = at.album_id - ORDER BY a.created_at DESC"# - ).fetch_all(&self.pool).await.unwrap_or_default(); - - let mut map: HashMap> = HashMap::new(); - for r in &rows { - let tag_name: String = r.get("tag_name"); - let id_str: String = r.get("id"); - let album = AlbumInfo { - id: Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()), - title: r.get("title"), - document: String::new(), - description: None, - cover_image_id: r.get("cover_image_id"), - image_count: r.get("image_count"), - created_at: r.get("created_at"), - tags: vec![], - persons: vec![], - }; - map.entry(tag_name).or_default().push(album); - } - map - } - /// 按标签搜索相册 pub async fn search_albums_by_tag(&self, tag: &str) -> Vec { let rows = sqlx::query(