diff --git a/docs/api.md b/docs/api.md index f6a279d..d079903 100644 --- a/docs/api.md +++ b/docs/api.md @@ -287,6 +287,31 @@ Base URL: `http://:/api` --- +### POST /api/people/delete/{name} +删除人物(从 imager.db + search.db 中清除),并重新解析受影响的相册。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| name | string | 人物名(URL 编码) | + +**响应**: + +```json +{ + "success": true, + "person": "人物名", + "albums_affected": 5, + "persons_updated": 3, + "tags_updated": 8, + "synced": 5, + "message": "人物 '人物名' 已删除,影响 5 个相册,重新解析完成..." +} +``` + +--- + ## 标签 / 元数据 ### GET /api/metadata/stats @@ -343,6 +368,31 @@ Base URL: `http://:/api` --- +### POST /api/tags/delete/{name} +删除标签(从 dict/album_tags/tags 表中清除),并重新解析受影响的相册。 + +**参数**(path): + +| 参数 | 类型 | 说明 | +|------|------|------| +| name | string | 标签名(URL 编码) | + +**响应**: + +```json +{ + "success": true, + "tag": "标签名", + "albums_affected": 5, + "persons_updated": 3, + "tags_updated": 8, + "synced": 5, + "message": "标签 '标签名' 已删除,影响 5 个相册,重新解析完成..." +} +``` + +--- + ## 推荐 ### GET /api/recommend/tags @@ -553,14 +603,14 @@ Base URL: `http://:/api` [ { "word": "写真", - "type_": "tag", + "type": "tag", "category": "topic", "freq": null, "merged_to": null }, { "word": "张三", - "type_": "person", + "type": "person", "category": null, "freq": null, "merged_to": null @@ -568,7 +618,7 @@ Base URL: `http://:/api` ] ``` -**type_ 可选值**:`tag`、`person`、`stop_word`、`person_prefix`、`excluded_person` +**type 可选值**:`tag`、`person`、`stop_word`、`person_prefix`、`excluded_person` **响应**: diff --git a/src/db/batch.rs b/src/db/batch.rs index c7db95a..1327fc9 100644 --- a/src/db/batch.rs +++ b/src/db/batch.rs @@ -25,7 +25,6 @@ impl Database { ) -> Result<(), sqlx::Error> { for result in results { let album_id = &result.album_id; - let person_name = result.person_name.as_deref(); // ── 标记处理时间(无论是否有人物,只要进了解析管线就标记)── sqlx::query( @@ -34,30 +33,38 @@ impl Database { .bind(album_id) .execute(&mut **tx).await?; - if let Some(name) = person_name { - // ── insert or ignore 人物 ── - let pid = sqlx::query_scalar::<_, i64>( - "INSERT INTO persons (name, display_name) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET name = name RETURNING id" - ) - .bind(name).bind(name) - .fetch_one(&mut **tx).await?; - - // ── 清旧关联 + 写新关联 ── + // ── 先清空旧人物关联,再写新关联 ── + if !result.person_names.is_empty() { sqlx::query("DELETE FROM person_albums WHERE album_id = ?") .bind(album_id).execute(&mut **tx).await?; - sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)") - .bind(pid).bind(album_id).execute(&mut **tx).await?; - // ── 更新计数 ── - sqlx::query( - "UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?), \ - total_images = COALESCE((SELECT SUM(image_count) FROM albums \ - JOIN person_albums ON albums.id = person_albums.album_id \ - WHERE person_albums.person_id = ?), 0) \ - WHERE id = ?" - ) - .bind(pid).bind(pid).bind(pid) - .execute(&mut **tx).await?; + for name in &result.person_names { + // ── insert or ignore 人物 ── + let pid = sqlx::query_scalar::<_, i64>( + "INSERT INTO persons (name, display_name) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET name = name RETURNING id" + ) + .bind(name).bind(name) + .fetch_one(&mut **tx).await?; + + // ── 写新关联 ── + sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)") + .bind(pid).bind(album_id).execute(&mut **tx).await?; + + // ── 更新该人物的计数 ── + sqlx::query( + "UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?), \ + total_images = COALESCE((SELECT SUM(image_count) FROM albums \ + JOIN person_albums ON albums.id = person_albums.album_id \ + WHERE person_albums.person_id = ?), 0) \ + WHERE id = ?" + ) + .bind(pid).bind(pid).bind(pid) + .execute(&mut **tx).await?; + } + } else { + // 无人名时也清空旧关联(避免残留) + sqlx::query("DELETE FROM person_albums WHERE album_id = ?") + .bind(album_id).execute(&mut **tx).await?; } // ── 写标签(tags/all_words 列已迁移到 search.db,只维护 album_tags 表)── diff --git a/src/engine/person_merger.rs b/src/engine/person_merger.rs index 18593e2..f199aa0 100644 --- a/src/engine/person_merger.rs +++ b/src/engine/person_merger.rs @@ -129,7 +129,7 @@ pub fn deep_match_title( // 策略1: 用正则提取人名 let extracted = crate::engine::title_parser::extract_person_name_from_title(title_clean); - if let Some(ref e) = extracted { + if let Some(e) = extracted.first() { if known_names.contains(e) { result.matched = true; result.method = "jieba_extract".to_string(); diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs index b12e74f..f75852a 100644 --- a/src/engine/pipeline.rs +++ b/src/engine/pipeline.rs @@ -104,7 +104,7 @@ pub async fn phase_parse_dirty(db: &Database, dirty_set: &DirtySet) -> (usize, u }) .collect(); - let person_count = parsed.iter().filter(|r| r.person_name.is_some()).count(); + let person_count = parsed.iter().filter(|r| !r.person_names.is_empty()).count(); let tag_count = parsed.iter().filter(|r| !r.tags.is_empty()).count(); // ── Phase 1b:批量写入 ── @@ -419,7 +419,7 @@ pub async fn sync_albums_to_search_db(db: &Database, search_db: &SearchDb) -> us if let Some(title) = album.title.as_deref() { if !title.is_empty() { let parse_result = title_parser::parse_album_title(&album.id, title); - if let Some(ref person_name) = parse_result.person_name { + for person_name in &parse_result.person_names { let person_id = match person_cache.get(person_name) { Some(&id) => id, None => { @@ -507,7 +507,7 @@ pub async fn sync_dirty_albums(db: &Database, search_db: &SearchDb, dirty: &Dirt if let Some(title) = album.title.as_deref() { if !title.is_empty() { let parse_result = title_parser::parse_album_title(&album.id, title); - if let Some(ref person_name) = parse_result.person_name { + for person_name in &parse_result.person_names { let person_id = match person_cache.get(person_name) { Some(&id) => id, None => { diff --git a/src/engine/title_parser.rs b/src/engine/title_parser.rs index 348c95c..304dd79 100644 --- a/src/engine/title_parser.rs +++ b/src/engine/title_parser.rs @@ -281,8 +281,8 @@ pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::P // Step 1: 切分 let segments = max_match_segment(&cleaned); - // Step 2: 从 segments 中选人(优先级最高的) - let mut person_candidates: Vec<(String, u8)> = Vec::new(); // (name, priority) + // Step 2: 收集所有人名候选(按优先级排序,全部保留) + let mut person_candidates: Vec<(String, u8)> = Vec::new(); for seg in &segments { match seg.category.as_str() { @@ -301,21 +301,33 @@ pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::P } } - // 按优先级排序,取最优 + // 按优先级排序 person_candidates.sort_by_key(|(_, p)| *p); - if let Some((name, _)) = person_candidates.first() { - let (primary, _aliases) = split_person_name_with_aliases(name); + // 收集最终人名列表(去重) + let mut person_names: Vec = Vec::new(); + let mut seen_names: HashSet = HashSet::new(); + for (name, _) in &person_candidates { + let (primary, _) = split_person_name_with_aliases(name); let final_name = if primary.is_empty() { name.clone() } else { primary }; - - if final_name.chars().count() >= 2 { - result.person_name = Some(final_name); + if final_name.chars().count() >= 2 && seen_names.insert(final_name.to_lowercase()) { + person_names.push(final_name); } } + result.person_names = person_names; + + // Step 3: 收集标签(跳过所有 person 片段) + // 构建所有已识别 person 片段的小写集合(用于过滤 other 类中与 person 重叠的片段) + let person_segment_lower: HashSet = segments.iter() + .filter(|s| s.category == "person" || s.category == "person_include") + .map(|s| s.text.to_lowercase()) + .collect(); + + let person_name_lower: HashSet = result.person_names.iter() + .map(|n| n.to_lowercase()) + .collect(); - // Step 3: 收集标签(非 person / stop / other 类的片段) let mut tags: Vec<(String, String)> = Vec::new(); - let person_lower = result.person_name.as_ref().map(|n| n.to_lowercase()); for seg in &segments { let cat = &seg.category; @@ -323,16 +335,19 @@ pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::P // 跳过停用词 if cat == "stop" { continue; } - // 跳过 person 相关 + // 跳过 person 相关片段 if cat == "person" || cat == "person_include" { continue; } - // 如果是 other 类,检查是否被选为人名 + // 如果是 other 类,检查是否与任何人名重叠 if cat == "other" { - if let Some(ref pl) = person_lower { - if seg.text.to_lowercase().contains(pl) || pl.contains(&seg.text.to_lowercase()) { - continue; - } - } + let seg_lower = seg.text.to_lowercase(); + let overlaps_person = person_segment_lower.iter().any(|pl| { + seg_lower.contains(pl) || pl.contains(&seg_lower) + }) || person_name_lower.iter().any(|pl| { + seg_lower.contains(pl) || pl.contains(&seg_lower) + }); + if overlaps_person { continue; } + // other 类也做个简单过滤:太短、纯数字等跳过 if seg.text.chars().count() < 2 { continue; } if seg.text.chars().all(|c| c.is_ascii_digit()) { continue; } @@ -484,8 +499,13 @@ pub fn parse_album_title_with_dict( // Phase 3: 合并结果 — dict 优先,parser 补充 let mut result = crate::models::parse::ParseResult::new(album_id.to_string()); - // person: dict 命中 > parser 命中 - result.person_name = dict_person.or(parse_result.person_name); + // persons: dict 命中 + parser 结果合并去重 + let mut seen_persons = HashSet::new(); + for name in dict_person.iter().chain(parse_result.person_names.iter()) { + if seen_persons.insert(name.to_lowercase()) { + result.person_names.push(name.clone()); + } + } // tags: dict 标签优先,parser 标签补充去重 let mut seen = HashSet::new(); @@ -502,18 +522,19 @@ pub fn parse_album_title_with_dict( result } -/// dict 条目按词长降序匹配标题,返回 (person, tags, 剩余文本) +/// dict 条目按词长降序匹配标题,返回 (persons, tags, 剩余文本) fn extract_with_dict( title: &str, dict_entries: &[crate::search_db::DictEntry], -) -> (Option, Vec<(String, String)>, String) { +) -> (Vec, Vec<(String, String)>, String) { // 按词长降序排列 let mut sorted: Vec<&crate::search_db::DictEntry> = dict_entries.iter().collect(); sorted.sort_by(|a, b| b.word.len().cmp(&a.word.len())); let title_lower = title.to_lowercase(); let mut matched_ranges: Vec<(usize, usize)> = Vec::new(); - let mut person: Option = None; + let mut persons: Vec = Vec::new(); + let mut seen_persons: HashSet = HashSet::new(); let mut tags: Vec<(String, String)> = Vec::new(); let mut seen_tag_words = HashSet::new(); @@ -534,8 +555,11 @@ fn extract_with_dict( matched_ranges.push((abs_start, abs_end)); match entry.type_.as_str() { - "person" if person.is_none() => { - person = Some(entry.merged_to.as_ref().unwrap_or(&entry.word).clone()); + "person" => { + let resolved = entry.merged_to.as_ref().unwrap_or(&entry.word).clone(); + if seen_persons.insert(resolved.to_lowercase()) { + persons.push(resolved); + } } "tag" => { if seen_tag_words.insert(entry.word.clone()) { @@ -550,7 +574,7 @@ fn extract_with_dict( // 构建剩余文本:去掉已匹配的部分 if matched_ranges.is_empty() { - return (person, tags, title.to_string()); + return (persons, tags, title.to_string()); } matched_ranges.sort_by_key(|&(s, _)| s); @@ -570,20 +594,20 @@ fn extract_with_dict( // 合并连续空格 let remaining = remaining.split_whitespace().collect::>().join(" "); - (person, tags, remaining) + (persons, tags, remaining) } // ── 兼容函数(供 pipeline / person_merger 调用) ── -/// 从标题中提取人物名(兼容函数) -pub fn extract_person_name_from_title(title: &str) -> Option { - parse_album_title("", title).person_name +/// 从标题中提取人物名列表(兼容函数) +pub fn extract_person_name_from_title(title: &str) -> Vec { + parse_album_title("", title).person_names } /// 从标题中提取人物名和标签(兼容函数) -pub fn extract_person_and_tags(title: &str) -> (Option, Vec<(String, String)>) { +pub fn extract_person_and_tags(title: &str) -> (Vec, Vec<(String, String)>) { let result = parse_album_title("", title); - (result.person_name, result.tags) + (result.person_names, result.tags) } #[cfg(test)] @@ -593,7 +617,7 @@ mod tests { #[test] fn test_parse_album_title_with_person() { let r = parse_album_title("id1", "杨晨晨Yome 私房写真"); - assert_eq!(r.person_name.as_deref(), Some("杨晨晨")); + assert_eq!(r.person_names.first().map(|s| s.as_str()), Some("杨晨晨")); assert!(!r.tags.is_empty()); let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect(); assert!(tag_names.contains(&"私房"), "应包含 '私房' 标签"); @@ -603,7 +627,7 @@ mod tests { #[test] fn test_parse_album_title_no_person() { let r = parse_album_title("id2", "浴室 可爱 性感 写真"); - assert!(r.person_name.is_none()); + assert!(r.person_names.is_empty()); assert!(!r.tags.is_empty()); let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect(); assert!(tag_names.contains(&"浴室")); @@ -614,14 +638,14 @@ mod tests { #[test] fn test_parse_album_title_with_prefix() { let r = parse_album_title("id3", "美女模特 杨晨晨 私房"); - assert_eq!(r.person_name.as_deref(), Some("杨晨晨")); + assert_eq!(r.person_names.first().map(|s| s.as_str()), Some("杨晨晨")); } #[test] fn test_parse_continuous_chinese() { // 连续中文:杨晨晨私房写真 — 应能切分出人名和标签 let r = parse_album_title("id4", "杨晨晨私房写真"); - assert_eq!(r.person_name.as_deref(), Some("杨晨晨")); + assert_eq!(r.person_names.first().map(|s| s.as_str()), Some("杨晨晨")); let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect(); assert!(tag_names.contains(&"私房"), "应包含 '私房'"); assert!(tag_names.contains(&"写真"), "应包含 '写真'"); diff --git a/src/main.rs b/src/main.rs index 0222382..9889ab4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -204,9 +204,11 @@ async fn main() -> std::io::Result<()> { println!(" - GET /api/people/search?q=关键词 - 搜索人员"); println!(" - GET /api/people/{{name}} - 人员详情"); println!(" - GET /api/people/{{id}}/aliases - 人员别名"); + println!(" - POST /api/people/delete/{{name}} - 删除人员并重解析受影响相册"); println!(" - GET /api/metadata/stats - 元数据统计"); println!(" - GET /api/tags/search?tag=标签 - 按标签搜索"); println!(" - GET /api/tags/popular?limit=50 - 热门标签"); + println!(" - POST /api/tags/delete/{{name}} - 删除标签并重解析受影响相册"); println!("推荐接口:"); println!(" - GET /api/recommend/tags?q=xxx - 标签推荐"); println!(" - GET /api/recommend/persons?q=xxx - 人物推荐"); @@ -262,10 +264,12 @@ async fn main() -> std::io::Result<()> { .route("/api/people/search", web::get().to(routes::search_people)) .route("/api/people/{name}", web::get().to(routes::get_person_detail)) .route("/api/people/{id}/aliases", web::get().to(routes::get_person_aliases)) + .route("/api/people/delete/{name}", web::post().to(routes::delete_person)) // 标签 API .route("/api/metadata/stats", web::get().to(routes::get_metadata_stats)) .route("/api/tags/search", web::get().to(routes::search_by_tag)) .route("/api/tags/popular", web::get().to(routes::get_popular_tags)) + .route("/api/tags/delete/{name}", web::post().to(routes::delete_tag)) // 推荐 API .route("/api/recommend/tags", web::get().to(routes::recommend_tags)) .route("/api/recommend/persons", web::get().to(routes::recommend_persons)) diff --git a/src/models/parse.rs b/src/models/parse.rs index f3391ca..fcfc427 100644 --- a/src/models/parse.rs +++ b/src/models/parse.rs @@ -5,23 +5,21 @@ use serde::{Serialize, Deserialize}; /// 一次 title 解析的完整结果 /// -/// 在一个相册上通过一次 jieba 分词,同时提取: -/// - 人名 + 别名(含 include 扩展、prefix 移除) +/// 在一个相册上通过一次分词,同时提取: +/// - 人名列表(支持多个) /// - 标签列表(含分类) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ParseResult { /// 相册 UUID pub album_id: String, - /// 最终人名 | None = 未找到 - pub person_name: Option, - /// 中文主名(split_person_name_with_aliases 后的 primary) - pub person_primary: Option, + /// 最终识别的人名列表 | 空 = 未找到 + pub person_names: Vec, /// 标签列表 (tag, category) pub tags: Vec<(String, String)>, } impl ParseResult { pub fn new(album_id: String) -> Self { - Self { album_id, person_name: None, person_primary: None, tags: Vec::new() } + Self { album_id, person_names: Vec::new(), tags: Vec::new() } } } diff --git a/src/routes/dict.rs b/src/routes/dict.rs index bebfe14..67c1dd3 100644 --- a/src/routes/dict.rs +++ b/src/routes/dict.rs @@ -46,6 +46,7 @@ pub async fn dict_reload(search_db: web::Data>) -> impl Responder #[derive(serde::Deserialize)] pub struct DictEntryInput { pub word: String, + #[serde(rename = "type")] pub type_: String, pub category: Option, pub freq: Option, diff --git a/src/routes/person.rs b/src/routes/person.rs index 4957736..b8c62b5 100644 --- a/src/routes/person.rs +++ b/src/routes/person.rs @@ -1,5 +1,6 @@ -use crate::cache::PersonCache; +use crate::cache::{PersonCache, AlbumsCache, TagCache}; use crate::db::Database; +use crate::engine; use crate::search_db::SearchDb; use crate::models::*; use actix_web::{HttpResponse, Responder, web}; @@ -109,3 +110,80 @@ pub async fn get_person_aliases(search_db: web::Data>, path: web:: let aliases = search_db.get_person_aliases(path.into_inner()).await; HttpResponse::Ok().json(aliases) } + +/// POST /api/people/delete/{name} — 删除 person(从 imager.db + search.db)并重解析受影响的相册 +pub async fn delete_person( + db: web::Data>, + search_db: web::Data>, + albums_cache: web::Data>>, + persons_cache: web::Data>>, + tags_cache: web::Data>>, + path: web::Path, +) -> impl Responder { + let person_name = path.into_inner().trim().to_string(); + if person_name.is_empty() { + return HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, "message": "人名不能为空" + })); + } + + // 1. 从 search.db 删除并获取受影响 album_ids + let album_ids = match search_db.delete_person_and_get_albums(&person_name).await { + Ok(ids) => ids, + Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "message": format!("从 search.db 删除人物 '{}' 失败: {}", person_name, e), + })), + }; + + // 2. 从 imager.db 删除(persons + person_aliases + person_albums) + if let Some(person) = db.get_person_by_name(&person_name).await { + db.delete_person_completely(person.id).await; + } + + // 3. 重置受影响相册的 processed_at(触发重新解析) + for aid in &album_ids { + sqlx::query("UPDATE albums SET processed_at = NULL WHERE id = ?") + .bind(aid) + .execute(&db.pool) + .await + .ok(); + } + + // 4. 重新解析受影响相册,同步到 search.db + if !album_ids.is_empty() { + let mut dirty = engine::pipeline::DirtySet::new(); + for aid in &album_ids { dirty.add(aid); } + let (persons_updated, tags_updated) = engine::pipeline::phase_parse_dirty(&db, &dirty).await; + let synced = engine::pipeline::sync_dirty_albums(&db, &search_db, &dirty).await; + + // 5. 重载内存词典 + engine::tag_dict::reload_from_search_db(&search_db).await; + + // 6. 刷新缓存 + crate::cache::refresh_all_caches(&db, &search_db, &albums_cache, &persons_cache, &tags_cache).await; + + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "person": person_name, + "albums_affected": album_ids.len(), + "persons_updated": persons_updated, + "tags_updated": tags_updated, + "synced": synced, + "message": format!( + "人物 '{}' 已删除,影响 {} 个相册,重新解析完成(更新人物 {},更新标签 {},同步 {} 个)", + person_name, album_ids.len(), persons_updated, tags_updated, synced, + ) + })) + } else { + // 无受影响相册,只需刷新缓存 + crate::cache::refresh_all_caches(&db, &search_db, &albums_cache, &persons_cache, &tags_cache).await; + + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "person": person_name, + "albums_affected": 0, + "message": format!("人物 '{}' 已删除,无关联相册", person_name), + })) + } +} diff --git a/src/routes/tag.rs b/src/routes/tag.rs index 7653a5e..8b50bd9 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -1,5 +1,7 @@ use crate::cache::{TagCache, AlbumsCache, PersonCache}; +use crate::engine; use crate::models::*; +use crate::search_db::SearchDb; use actix_web::{HttpResponse, Responder, web}; use std::collections::HashMap; use std::sync::Arc; @@ -49,3 +51,65 @@ pub async fn get_popular_tags( let tags: Vec = c.popular_tags.iter().take(limit).cloned().collect(); HttpResponse::Ok().json(TagListResponse { total: tags.len(), tags }) } + +/// POST /api/tags/delete/{name} — 删除 tag(从 dict/album_tags/tags)并重解析受影响的相册 +pub async fn delete_tag( + db: web::Data>, + search_db: web::Data>, + albums_cache: web::Data>>, + persons_cache: web::Data>>, + tags_cache: web::Data>>, + path: web::Path, +) -> impl Responder { + let tag_name = path.into_inner().trim().to_string(); + if tag_name.is_empty() { + return HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, "message": "标签名不能为空" + })); + } + + // 1. 删除 tag(dict/album_tags/tags),获取受影响 album_ids + let album_ids = match search_db.delete_tag_and_get_albums(&tag_name).await { + Ok(ids) => ids, + Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "message": format!("删除标签 '{}' 失败: {}", tag_name, e), + })), + }; + + // 2. 重置受影响相册的 processed_at,触发重新解析 + for aid in &album_ids { + sqlx::query("UPDATE albums SET processed_at = NULL WHERE id = ?") + .bind(aid) + .execute(&db.pool) + .await + .ok(); + } + + // 3. 重新解析受影响相册 + let mut dirty = engine::pipeline::DirtySet::new(); + for aid in &album_ids { dirty.add(aid); } + let (persons_updated, tags_updated) = engine::pipeline::phase_parse_dirty(&db, &dirty).await; + + // 4. 同步到 search.db + let synced = engine::pipeline::sync_dirty_albums(&db, &search_db, &dirty).await; + + // 5. 重载内存词典(dict 表已更新) + engine::tag_dict::reload_from_search_db(&search_db).await; + + // 6. 刷新内存缓存 + crate::cache::refresh_all_caches(&db, &search_db, &albums_cache, &persons_cache, &tags_cache).await; + + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "tag": tag_name, + "albums_affected": album_ids.len(), + "persons_updated": persons_updated, + "tags_updated": tags_updated, + "synced": synced, + "message": format!( + "标签 '{}' 已删除,影响 {} 个相册,重新解析完成(更新人物 {},更新标签 {},同步 {} 个)", + tag_name, album_ids.len(), persons_updated, tags_updated, synced, + ) + })) +} diff --git a/src/search_db/mod.rs b/src/search_db/mod.rs index 8997a38..3e209f5 100644 --- a/src/search_db/mod.rs +++ b/src/search_db/mod.rs @@ -315,6 +315,87 @@ impl SearchDb { Ok(()) } + /// 删除 tag:从 dict/album_tags/tags 表中删除,返回受影响的 album_id 列表 + pub async fn delete_tag_and_get_albums(&self, tag_name: &str) -> Result, sqlx::Error> { + // 1. 查找 tag_id(tags 表中可能存在多个同名 tag,但 name 应唯一) + let tag_ids: Vec = sqlx::query_scalar("SELECT id FROM tags WHERE name = ?") + .bind(tag_name).fetch_all(&self.pool).await.unwrap_or_default(); + + // 2. 查找关联 album_ids + let album_ids: Vec = if tag_ids.is_empty() { + Vec::new() + } else { + // 用参数占位构建 IN 查询 + let placeholders: Vec = tag_ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!("SELECT DISTINCT album_id FROM album_tags WHERE tag_id IN ({})", placeholders.join(",")); + let mut q = sqlx::query(&sql); + for id in &tag_ids { q = q.bind(*id); } + q.fetch_all(&self.pool).await? + .iter().map(|r| r.get::("album_id")).collect() + }; + + // 3. 删除 album_tags 关联 + if !tag_ids.is_empty() { + let placeholders: Vec = tag_ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!("DELETE FROM album_tags WHERE tag_id IN ({})", placeholders.join(",")); + let mut q = sqlx::query(&sql); + for id in &tag_ids { q = q.bind(*id); } + q.execute(&self.pool).await?; + } + + // 4. 删除 tags 表条目 + sqlx::query("DELETE FROM tags WHERE name = ?").bind(tag_name).execute(&self.pool).await?; + + // 5. 删除 dict 表条目 + sqlx::query("DELETE FROM dict WHERE word = ? AND type = 'tag'").bind(tag_name).execute(&self.pool).await?; + + Ok(album_ids) + } + + /// 删除 person:从 search.db 的 album_persons/persons/person_aliases 中删除,返回受影响的 album_id 列表 + pub async fn delete_person_and_get_albums(&self, person_name: &str) -> Result, sqlx::Error> { + // 1. 查找 person_id + let person_ids: Vec = sqlx::query_scalar("SELECT id FROM persons WHERE name = ?") + .bind(person_name).fetch_all(&self.pool).await.unwrap_or_default(); + + // 2. 查找关联 album_ids + let album_ids: Vec = if person_ids.is_empty() { + Vec::new() + } else { + let placeholders: Vec = person_ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!("SELECT DISTINCT album_id FROM album_persons WHERE person_id IN ({})", placeholders.join(",")); + let mut q = sqlx::query(&sql); + for id in &person_ids { q = q.bind(*id); } + q.fetch_all(&self.pool).await? + .iter().map(|r| r.get::("album_id")).collect() + }; + + // 3. 删除关联表 + if !person_ids.is_empty() { + let placeholders: Vec = person_ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!("DELETE FROM album_persons WHERE person_id IN ({})", placeholders.join(",")); + let mut q = sqlx::query(&sql); + for id in &person_ids { q = q.bind(*id); } + q.execute(&self.pool).await?; + + let sql2 = format!("DELETE FROM person_aliases WHERE person_id IN ({})", placeholders.join(",")); + let mut q2 = sqlx::query(&sql2); + for id in &person_ids { q2 = q2.bind(*id); } + q2.execute(&self.pool).await?; + + let sql3 = format!("DELETE FROM persons WHERE id IN ({})", placeholders.join(",")); + let mut q3 = sqlx::query(&sql3); + for id in &person_ids { q3 = q3.bind(*id); } + q3.execute(&self.pool).await?; + } + + // 4. 删除 dict 表中的对应条目(如果存在) + sqlx::query("DELETE FROM dict WHERE word = ? AND type = 'person'") + .bind(person_name).execute(&self.pool).await?; + + Ok(album_ids) + } + pub async fn clear_all(&self) -> Result<(), sqlx::Error> { let mut tx = self.pool.begin().await?; sqlx::query("DELETE FROM album_tags").execute(&mut *tx).await?; diff --git a/src/services/search_export.rs b/src/services/search_export.rs index 850402c..67de362 100644 --- a/src/services/search_export.rs +++ b/src/services/search_export.rs @@ -76,15 +76,15 @@ pub async fn export_to_search_db( let parse_result = title_parser::parse_album_title_with_dict(&album_id, title, &dict_entries); // 统计 dict 命中 - if let Some(ref person_name) = parse_result.person_name { - let from_dict = dict_entries.iter().any(|e| { + let dict_matched_person = parse_result.person_names.iter().any(|pn| { + dict_entries.iter().any(|e| { e.type_ == "person" - && (e.word.to_lowercase() == person_name.to_lowercase() - || e.merged_to.as_ref().map_or(false, |m| m.to_lowercase() == person_name.to_lowercase())) - }); - if from_dict { - result.dict_matched_persons += 1; - } + && (e.word.to_lowercase() == pn.to_lowercase() + || e.merged_to.as_ref().map_or(false, |m| m.to_lowercase() == pn.to_lowercase())) + }) + }); + if dict_matched_person { + result.dict_matched_persons += 1; } // ── 2. 写入 search.db ── @@ -117,7 +117,7 @@ pub async fn export_to_search_db( result.album_tags_created += 1; } - if let Some(person_name) = &parse_result.person_name { + for person_name in &parse_result.person_names { // 检查是否有 merged_to 别名解析 let resolved_from = dict_entries.iter().find(|e| { e.type_ == "person" @@ -146,10 +146,21 @@ pub async fn export_to_search_db( .map_err(|e| format!("关联相册人物 {} → {} 失败: {}", album_id, person_name, e))?; result.album_persons_created += 1; - if let Some(primary) = &parse_result.person_primary { - if primary != person_name { + // 同步别名 + let (primary, aliases) = title_parser::split_person_name_with_aliases(person_name); + if !aliases.is_empty() { + for (alias_type, alias_name) in &aliases { + if alias_name != person_name { + search_db + .insert_person_alias(person_id, alias_type, alias_name) + .await + .map_err(|e| format!("插入别名 {} → {} 失败: {}", person_name, alias_name, e))?; + result.aliases_created += 1; + } + } + if primary != person_name.as_str() && !primary.is_empty() { search_db - .insert_person_alias(person_id, "primary", primary) + .insert_person_alias(person_id, "primary", &primary) .await .map_err(|e| format!("插入别名 {} → {} 失败: {}", person_name, primary, e))?; result.aliases_created += 1;