From be581977f3b4bd051c290d9b95762caeb908c8d3 Mon Sep 17 00:00:00 2001 From: lychang Date: Mon, 20 Jul 2026 02:15:39 +0800 Subject: [PATCH] update --- dict/tags.json | 47 ++++++++++- migrations/003_metadata_fix.sql | 14 ++++ src/cache/mod.rs | 141 ++++++++++++++++++++++++++++---- src/db/metadata.rs | 93 +++++++++++++++++---- src/engine/pipeline.rs | 47 ++--------- src/engine/tag_dict.rs | 42 +++++++++- src/main.rs | 37 ++++----- src/routes/dict.rs | 2 + src/routes/person.rs | 66 ++++++++++++--- src/routes/tag.rs | 25 ++++-- 10 files changed, 399 insertions(+), 115 deletions(-) create mode 100644 migrations/003_metadata_fix.sql diff --git a/dict/tags.json b/dict/tags.json index a52f455..b8f92d5 100644 --- a/dict/tags.json +++ b/dict/tags.json @@ -1,6 +1,6 @@ { "version": 1, - "description": "标签词典 — 支持 style/outfit/scene/body/mood/topic 六大分类", + "description": "标签词典 — 支持 style/outfit/scene/body/mood/topic 六大分类及人物配置", "tags": { "style": { "label": "风格", @@ -289,9 +289,19 @@ "actor", "actress", "singer", + "秀人", + "网", + "图", + "套", "合集", "系列", "作品", + "美女", + "模特", + "女神", + "小姐姐", + "性感", + "高清", "原版", "无水印", "番号", @@ -305,8 +315,37 @@ "classic", "photography", "shoot", - "shot", - "no", - "性感美女" + "shot" + ], + "person_prefixes": [ + "秀人网", + "丝袜美腿", + "诱惑写真", + "白衬衫", + "情趣内衣", + "尤蜜荟", + "美媛馆", + "性感美" + ], + "excluded_person_names": [ + "XiuRen", + "xiuren", + "秀人", + "秀人网", + "MFStar", + "MFStar模特", + "MiiTao", + "蜜桃社", + "YouMi", + "尤蜜", + "HuaYang", + "花洋", + "FeiLin", + "Feiling", + "飞灵", + "MFModel", + "LeiLei", + "Tristana", + "Av" ] } diff --git a/migrations/003_metadata_fix.sql b/migrations/003_metadata_fix.sql new file mode 100644 index 0000000..1c4583a --- /dev/null +++ b/migrations/003_metadata_fix.sql @@ -0,0 +1,14 @@ +-- 别名表增加唯一约束,防止同一个人名下同名别名重复 +CREATE UNIQUE INDEX IF NOT EXISTS idx_aliases_unique ON person_aliases(person_id, alias_name); + +-- 清理可能存在的重复别名(保留 id 最小的那条) +DELETE FROM person_aliases WHERE id NOT IN ( + SELECT MIN(id) FROM person_aliases GROUP BY person_id, alias_name +); + +-- 相册关联表上增加唯一索引(已有 PRIMARY KEY,添加显式唯一索引加速) +-- 同时增加每张专辑只能有一个 person 的约束 +-- 先清理旧的重复关联(同一album_id关联了多个person_id的情况) +DELETE FROM person_albums WHERE rowid NOT IN ( + SELECT MIN(rowid) FROM person_albums GROUP BY album_id, person_id +); diff --git a/src/cache/mod.rs b/src/cache/mod.rs index a58db5f..987b0a1 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -1,5 +1,6 @@ //! 缓存层 — 内存缓存管理 +use crate::db::Database; use crate::models::*; use std::collections::HashMap; use std::sync::Arc; @@ -88,14 +89,23 @@ impl AlbumsCache { #[derive(Debug, Clone, Default)] pub struct TagCache { pub popular_tags: Vec, + /// tag → 包含该标签的相册列表(倒排索引,供按标签搜索用) + pub tag_albums: HashMap>, pub last_updated: chrono::DateTime, } impl TagCache { - pub fn update(&mut self, tags: Vec) { + pub fn update(&mut self, tags: Vec, tag_albums: HashMap>) { self.popular_tags = tags; + self.tag_albums = tag_albums; self.last_updated = chrono::Utc::now(); - info!("标签缓存已更新,共 {} 个热门标签", self.popular_tags.len()); + info!("标签缓存已更新,共 {} 个热门标签,{} 个标签倒排索引项", + self.popular_tags.len(), self.tag_albums.len()); + } + + /// 按标签搜索相册(从缓存中检索) + pub fn search_albums(&self, tag: &str) -> Vec { + self.tag_albums.get(tag).cloned().unwrap_or_default() } } @@ -103,30 +113,127 @@ impl TagCache { #[derive(Debug, Clone, Default)] pub struct PersonCache { pub persons: Vec, + /// 搜索索引:关键词小写 → person_id 集合 + pub search_index: HashMap>, + /// person_id → person 投影(用于按 id 快速访问) + pub id_map: HashMap, pub last_updated: chrono::DateTime, } impl PersonCache { pub fn update(&mut self, persons: Vec) { + self.search_index.clear(); + self.id_map.clear(); + + for p in &persons { + // 构建搜索索引(姓名 + 所有别名) + let name_lower = p.name.to_lowercase(); + for word in name_lower.split_whitespace() { + if word.len() >= 2 { + self.search_index.entry(word.to_string()).or_default().push(p.id); + } + } + self.search_index.entry(name_lower.clone()).or_default().push(p.id); + // 显示名 + if let Some(ref dn) = p.display_name { + let dn_lower = dn.to_lowercase(); + for word in dn_lower.split_whitespace() { + if word.len() >= 2 { + self.search_index.entry(word.to_string()).or_default().push(p.id); + } + } + self.search_index.entry(dn_lower).or_default().push(p.id); + } + // 别名 + for alias in &p.aliases { + let alias_lower = alias.to_lowercase(); + for word in alias_lower.split_whitespace() { + if word.len() >= 2 { + self.search_index.entry(word.to_string()).or_default().push(p.id); + } + } + self.search_index.entry(alias_lower).or_default().push(p.id); + } + self.id_map.insert(p.id, p.clone()); + } + self.persons = persons; self.last_updated = chrono::Utc::now(); - info!("人物缓存已更新,共 {} 人", self.persons.len()); + info!("人物缓存已更新,共 {} 人,{} 个搜索索引项", + self.persons.len(), self.search_index.len()); } -} -/// 共享缓存 -pub struct AppCache { - pub albums: Arc>, - pub tags: Arc>, - pub persons: Arc>, -} + /// 从缓存搜索人物(按姓名/别名) + pub fn search(&self, query: &str) -> Vec { + let q = query.to_lowercase().trim().to_string(); + if q.is_empty() || q.len() < 2 { return Vec::new(); } -impl AppCache { - pub fn new() -> Self { - Self { - albums: Arc::new(RwLock::new(AlbumsCache::new())), - tags: Arc::new(RwLock::new(TagCache::default())), - persons: Arc::new(RwLock::new(PersonCache::default())), + let mut seen = std::collections::HashSet::new(); + + // 1. 完整匹配 + if let Some(ids) = self.search_index.get(&q) { + for id in ids { + if seen.insert(*id) {} + } } + // 2. 单词匹配 + for word in q.split_whitespace() { + if word.len() >= 2 { + if let Some(ids) = self.search_index.get(word) { + for id in ids { + seen.insert(*id); + } + } + } + } + // 3. 模糊匹配 + if seen.is_empty() { + for p in &self.persons { + let full = format!("{} {} {}", p.name.to_lowercase(), + p.display_name.as_deref().unwrap_or(""), + p.aliases.join(" ").to_lowercase()); + if full.contains(&q) { + seen.insert(p.id); + } + } + } + + seen.iter() + .filter_map(|id| self.id_map.get(id).cloned()) + .collect() } -} \ No newline at end of file +} + +/// 刷新所有缓存(相册 + 标签 + 人物) +pub async fn refresh_all_caches( + db: &Database, + albums_cache: &Arc>, + persons_cache: &Arc>, + tags_cache: &Arc>, +) { + // 1. 刷新相册 + if let Ok(albums) = db.get_all_albums().await { + let mut c = albums_cache.write().await; + c.update(albums.clone()); + + // 2. 刷新标签 + let popular_tags = db.get_popular_tags(200).await; + let tag_albums = db.get_all_tag_albums().await; + let mut tc = tags_cache.write().await; + tc.update(popular_tags, tag_albums); + } + + // 3. 刷新人物 + let persons = db.get_all_persons_with_aliases().await; + let mut pc = persons_cache.write().await; + pc.update(persons); + + info!("全部缓存已刷新"); +} + +/// 仅刷新人物缓存(管线执行后调用) +pub async fn refresh_persons_cache(db: &Database, persons_cache: &Arc>) { + let persons = db.get_all_persons_with_aliases().await; + let mut pc = persons_cache.write().await; + pc.update(persons); +} diff --git a/src/db/metadata.rs b/src/db/metadata.rs index 66c1bb9..84f3347 100644 --- a/src/db/metadata.rs +++ b/src/db/metadata.rs @@ -1,6 +1,7 @@ use super::Database; use crate::models::*; use sqlx::{Row, Sqlite, Transaction}; +use std::collections::HashMap; use std::ops::DerefMut; use uuid::Uuid; @@ -65,33 +66,56 @@ impl Database { WHERE p.name LIKE ? OR a.alias_name LIKE ? ORDER BY p.total_albums DESC"# ).bind(&pattern).bind(&pattern).fetch_all(&self.pool).await.unwrap_or_default(); - let mut results = Vec::new(); - for r in &rows { + let pids: Vec = rows.iter().map(|r| r.get::("id")).collect(); + let aliases = self.batch_get_person_aliases(&pids).await; + + rows.iter().map(|r| { let pid = r.get::("id"); - let aliases = self.get_person_aliases(pid).await; - results.push(PersonWithCounts { + PersonWithCounts { id: pid, name: r.get("name"), display_name: r.get("display_name"), total_albums: r.get("total_albums"), total_images: r.get("total_images"), - aliases: aliases.into_iter().map(|a| a.alias_name).collect(), - }); - } - results + aliases: aliases.get(&pid).cloned().unwrap_or_default(), + } + }).collect() } pub async fn get_all_persons_with_aliases(&self) -> Vec { let rows = sqlx::query("SELECT id, name, display_name, total_albums, total_images FROM persons ORDER BY total_albums DESC") .fetch_all(&self.pool).await.unwrap_or_default(); - let mut results = Vec::new(); - for r in &rows { + + let pids: Vec = rows.iter().map(|r| r.get::("id")).collect(); + let aliases = self.batch_get_person_aliases(&pids).await; + + rows.iter().map(|r| { let pid = r.get::("id"); - let aliases = self.get_person_aliases(pid).await; - results.push(PersonWithCounts { + PersonWithCounts { id: pid, name: r.get("name"), display_name: r.get("display_name"), total_albums: r.get("total_albums"), total_images: r.get("total_images"), - aliases: aliases.into_iter().map(|a| a.alias_name).collect(), - }); + aliases: aliases.get(&pid).cloned().unwrap_or_default(), + } + }).collect() + } + + /// 批量查询多个人物的别名(避免 N+1) + async fn batch_get_person_aliases(&self, pids: &[i64]) -> HashMap> { + if pids.is_empty() { return HashMap::new(); } + let placeholders: Vec = pids.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + "SELECT person_id, alias_name FROM person_aliases WHERE person_id IN ({})", + placeholders.join(",") + ); + let mut q = sqlx::query(&sql); + for pid in pids { + q = q.bind(pid); } - results + let rows = q.fetch_all(&self.pool).await.unwrap_or_default(); + let mut map: HashMap> = HashMap::new(); + for r in &rows { + let pid: i64 = r.get("person_id"); + let alias: String = r.get("alias_name"); + map.entry(pid).or_default().push(alias); + } + map } // ── 标签查询 ── @@ -106,6 +130,27 @@ impl Database { }).collect() } + /// 批量获取所有 tag→albums 映射(供缓存使用) + pub async fn get_all_tag_albums(&self) -> HashMap> { + let rows = sqlx::query( + r#"SELECT t.tag, a.id, a.title, a.document, a.description, a.cover_image_id, a.image_count, a.created_at + FROM album_tags t JOIN albums a ON t.album_id = a.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: String = r.get("tag"); + let album = AlbumInfo { + id: Uuid::parse_str(&r.get::("id")).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"), + }; + map.entry(tag).or_default().push(album); + } + map + } + pub async fn get_tag_co_occurrences(&self, tag: &str, limit: i64) -> Vec { let rows = sqlx::query( r#"SELECT t2.tag as co_tag, COUNT(*) as cnt @@ -197,6 +242,24 @@ impl Database { sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok(); } + /// 删除某相册的所有人物关联(写入新 person 前清旧数据,防止一个相册关联多个人物) + pub async fn delete_album_person_albums(&self, album_id: &str) { + sqlx::query("DELETE FROM person_albums WHERE album_id = ?").bind(album_id).execute(&self.pool).await.ok(); + } + + pub async fn delete_album_person_albums_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, album_id: &str) { + sqlx::query("DELETE FROM person_albums WHERE album_id = ?").bind(album_id).execute(tx.deref_mut()).await.ok(); + } + + /// 删除某人物下所有别名(写入新别名前清旧别名,配合 UNIQUE INDEX 防止重复) + pub async fn delete_person_aliases(&self, person_id: i64) { + sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok(); + } + + pub async fn delete_person_aliases_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, person_id: i64) { + sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(person_id).execute(tx.deref_mut()).await.ok(); + } + /// 彻底删除一个人物(包括别名、相册关联、自身) pub async fn delete_person_completely(&self, person_id: i64) { let _guard = self.write_lock.lock().await; diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs index 3d4e40e..a519eb7 100644 --- a/src/engine/pipeline.rs +++ b/src/engine/pipeline.rs @@ -199,41 +199,8 @@ pub async fn phase_detect_person_in_tags(db: &Database) -> (DirtySet, usize) { } if changed || !has_person { - // ── 写入 person_name(直接提取,无需等待 Phase 1)── - let (primary, aliases) = title_parser::split_person_name_with_aliases(person_name); - let final_name = if primary.is_empty() { person_name.clone() } else { primary }; - - // 跳过字符串 <= 2 的人名(太短不像人名) - if final_name.chars().count() <= 2 { - // 仍需将标签从 clean_tags 中排除(人名相关标签已被移除) - let tags_json_new = serde_json::to_string(&clean_tags).unwrap_or_else(|_| "[]".to_string()); - let all_words_json = album.all_words.as_deref().unwrap_or("[]"); - let all_words_new: Vec<[String; 2]> = serde_json::from_str::>(all_words_json) - .unwrap_or_default() - .into_iter() - .filter(|pair| !known_persons.contains(&pair[0].to_lowercase())) - .collect(); - let words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string()); - db.update_album_tags(&album.id, &tags_json_new, &words_json_new).await; - continue; - } - - // 用事务包裹所有写入,避免逐条 autocommit 导致巨慢 - let mut tx = db.pool.begin().await.expect("begin tx"); - db.update_album_person_with_tx(&mut tx, &album.id, person_name, &final_name).await; - - if !title_parser::is_excluded_name(&final_name) { - let pid = db.insert_person_with_tx(&mut tx, &final_name, None).await; - db.insert_person_album_with_tx(&mut tx, pid, &album.id).await; - for (at, an) in &aliases { - if !an.is_empty() && an != &final_name { - db.insert_person_alias_with_tx(&mut tx, pid, at, an).await; - } - } - db.update_person_counts_with_tx(&mut tx, pid).await; - } - - // ── 写入清理后的标签 ── + // ── 只清理标签中的人名,不写入 person 字段 ── + // 人名提取统一由 Phase 1 从标题解析完成,保证一个相册只有一个 pid let tags_json_new = serde_json::to_string(&clean_tags).unwrap_or_else(|_| "[]".to_string()); let all_words_json = album.all_words.as_deref().unwrap_or("[]"); let all_words_new: Vec<[String; 2]> = serde_json::from_str::>(all_words_json) @@ -242,11 +209,9 @@ pub async fn phase_detect_person_in_tags(db: &Database) -> (DirtySet, usize) { .filter(|pair| !known_persons.contains(&pair[0].to_lowercase())) .collect(); let words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string()); - db.update_album_tags_with_tx(&mut tx, &album.id, &tags_json_new, &words_json_new).await; + db.update_album_tags(&album.id, &tags_json_new, &words_json_new).await; - tx.commit().await.expect("commit tx"); - - if has_person { dirty.add(&album.id); } + dirty.add(&album.id); } } } @@ -410,7 +375,11 @@ pub async fn phase_parse_dirty(db: &Database, dirty_set: &DirtySet) -> (usize, u let mut tx = db.pool.begin().await.expect("begin tx"); db.update_album_person_with_tx(&mut tx, &album.id, name, &final_name).await; let pid = db.insert_person_with_tx(&mut tx, &final_name, None).await; + // 先清旧关联:删除该相册的所有旧 person_album 记录 + db.delete_album_person_albums_with_tx(&mut tx, &album.id).await; db.insert_person_album_with_tx(&mut tx, pid, &album.id).await; + // 先清旧别名:删除该人物的所有旧别名,再插入新的 + db.delete_person_aliases_with_tx(&mut tx, pid).await; for (at, an) in &aliases { if !an.is_empty() && an != &final_name { db.insert_person_alias_with_tx(&mut tx, pid, at, an).await; diff --git a/src/engine/tag_dict.rs b/src/engine/tag_dict.rs index 1e73a14..b7aa1b1 100644 --- a/src/engine/tag_dict.rs +++ b/src/engine/tag_dict.rs @@ -22,6 +22,10 @@ pub struct TagDictFile { pub description: Option, pub tags: HashMap, pub stop_words: Vec, + #[serde(default)] + pub person_prefixes: Vec, + #[serde(default)] + pub excluded_person_names: Vec, } /// 分类定义 @@ -57,6 +61,10 @@ pub struct TagDict { pub tag_to_category: HashMap, /// 停用词集合 pub stop_words: HashSet, + /// 模特称呼前缀(提取人名时先移除的称谓词) + pub person_prefixes: Vec, + /// 排除的人名(工作室/平台名,不应作为人物) + pub excluded_person_names: HashSet, } impl Default for TagDict { @@ -105,6 +113,8 @@ impl TagDict { categories: HashMap::new(), tag_to_category: HashMap::new(), stop_words: file.stop_words.into_iter().collect(), + person_prefixes: file.person_prefixes, + excluded_person_names: file.excluded_person_names.into_iter().collect(), }; for (cat_name, def) in file.tags { @@ -192,6 +202,17 @@ impl TagDict { &self.stop_words } + /// 判断一个词是否是排除人名 + pub fn is_excluded_person_name(&self, name: &str) -> bool { + let name_lower = name.to_lowercase(); + self.excluded_person_names.contains(name) || self.excluded_person_names.contains(&name_lower) + } + + /// 获取模特称呼前缀列表 + pub fn get_person_prefixes(&self) -> &[String] { + &self.person_prefixes + } + /// 内置回退:当 JSON 文件不可用时使用硬编码数据 fn fill_builtin(&mut self) { let builtin_categories: Vec<(&str, &str, &str, &str, Vec<&str>)> = vec![ @@ -252,9 +273,26 @@ impl TagDict { "classic", "photography", "shoot", "shot", ]; + let person_prefixes: Vec<&str> = vec![ + "美女模特", "性感女神", "清纯美女", "气质美女", "可爱女孩", + "甜美少女", "时尚达人", "女神", "美女", "模特", "小姐姐", + "小仙女", "御姐", "萝莉", "辣妹", "潮人", + ]; + + let excluded_names: Vec<&str> = vec![ + "XiuRen", "xiuren", "秀人", "秀人网", "MFStar", "MFStar模特", + "MiiTao", "蜜桃社", "YouMi", "尤蜜", "HuaYang", "花洋", + "FeiLin", "Feiling", "飞灵", "MFModel", "LeiLei", + "Tristana", "Av", + ]; + for word in &stop_words { self.stop_words.insert(word.to_string()); } + self.person_prefixes = person_prefixes.iter().map(|s| s.to_string()).collect(); + for n in &excluded_names { + self.excluded_person_names.insert(n.to_string()); + } for (name, label, bg, fg, words) in builtin_categories { let word_strings: Vec = words.iter().map(|w| w.to_string()).collect(); @@ -301,9 +339,11 @@ pub fn reload_tag_dict() -> String { pub fn get_tag_dict_stats() -> String { let dict = get_tag_dict(); format!( - "标签词典: {} 个分类, {} 个标签, {} 个停用词 | 文件: dict/tags.json", + "标签词典: {} 个分类, {} 个标签, {} 个停用词, {} 个排除人名, {} 个称呼前缀 | 文件: dict/tags.json", dict.categories.len(), dict.tag_to_category.len(), dict.stop_words.len(), + dict.excluded_person_names.len(), + dict.person_prefixes.len(), ) } diff --git a/src/main.rs b/src/main.rs index d6fb1ac..e819d4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,13 +53,17 @@ async fn main() -> std::io::Result<()> { let image_processor = Arc::new(image_processor::ImageProcessor::new(config.storage.clone())); - // 相册内存缓存(必须在管线任务之前创建) + // ── 内存缓存(必须在管线任务之前创建)── let albums_cache = Arc::new(tokio::sync::RwLock::new(AlbumsCache::new())); + let persons_cache = Arc::new(tokio::sync::RwLock::new(cache::PersonCache::default())); + let tags_cache = Arc::new(tokio::sync::RwLock::new(cache::TagCache::default())); // ── 启动管线(单次):导入 → 解析 → 合并 → 缓存刷新 ── let boot_config = config.clone(); let boot_db = Arc::clone(&db); - let boot_cache = Arc::clone(&albums_cache); + let boot_albums_cache = Arc::clone(&albums_cache); + let boot_persons_cache = Arc::clone(&persons_cache); + let boot_tags_cache = Arc::clone(&tags_cache); tokio::spawn(async move { // ① 扫描导入新相册 info!("[启动管线] ① 扫描导入新相册..."); @@ -77,15 +81,17 @@ async fn main() -> std::io::Result<()> { result.persons_updated, result.tags_updated, result.persons_merged, result.aliases_moved, result.tags_removed_person); - // ③ 刷新内存缓存 - info!("[启动管线] ③ 刷新内存缓存..."); - refresh_albums_cache(&boot_db, &boot_cache).await; + // ③ 刷新所有内存缓存 + info!("[启动管线] ③ 刷新所有内存缓存..."); + cache::refresh_all_caches(&boot_db, &boot_albums_cache, &boot_persons_cache, &boot_tags_cache).await; info!("[启动管线] 启动管线执行完毕"); }); // ── 定时管线(每 30 分钟):解析 → 合并 → 缓存刷新 ── let timer_cache = Arc::clone(&albums_cache); let timer_db = Arc::clone(&db); + let timer_persons_cache = Arc::clone(&persons_cache); + let timer_tags_cache = Arc::clone(&tags_cache); tokio::spawn(async move { let mut interval = interval(Duration::from_secs(30 * 60)); loop { @@ -100,9 +106,9 @@ async fn main() -> std::io::Result<()> { result.persons_updated, result.tags_updated, result.persons_merged, result.aliases_moved, result.tags_removed_person); - // ② 刷新内存缓存 - info!("[定时管线] ② 刷新内存缓存..."); - refresh_albums_cache(&timer_db, &timer_cache).await; + // ② 刷新所有内存缓存 + info!("[定时管线] ② 刷新所有内存缓存..."); + cache::refresh_all_caches(&timer_db, &timer_cache, &timer_persons_cache, &timer_tags_cache).await; } }); @@ -157,6 +163,8 @@ async fn main() -> std::io::Result<()> { .app_data(web::Data::new(Arc::clone(&db))) .app_data(web::Data::new(Arc::clone(&image_processor))) .app_data(web::Data::new(Arc::clone(&albums_cache))) + .app_data(web::Data::new(Arc::clone(&persons_cache))) + .app_data(web::Data::new(Arc::clone(&tags_cache))) .app_data(web::Data::new(Arc::clone(&task_queue))) // 相册 API .route("/api/albums", web::get().to(routes::get_albums)) @@ -238,16 +246,3 @@ async fn main() -> std::io::Result<()> { server.await } - -/// 从 DB 重新加载相册数据到内存缓存 -async fn refresh_albums_cache(db: &crate::db::Database, cache: &Arc>) { - match db.get_all_albums().await { - Ok(albums) => { - let mut c = cache.write().await; - c.update(albums); - } - Err(e) => { - eprintln!("❌ 缓存刷新失败: {}", e); - } - } -} \ No newline at end of file diff --git a/src/routes/dict.rs b/src/routes/dict.rs index 61036f6..db4c4ce 100644 --- a/src/routes/dict.rs +++ b/src/routes/dict.rs @@ -23,6 +23,8 @@ pub async fn dict_stats() -> impl Responder { "categories": categories, "total_tags": dict.get_tag_category_map().len(), "total_stop_words": dict.get_stop_words().len(), + "person_prefixes": dict.get_person_prefixes(), + "excluded_person_names": dict.excluded_person_names.iter().collect::>(), })) } diff --git a/src/routes/person.rs b/src/routes/person.rs index f4f96a8..7a7876f 100644 --- a/src/routes/person.rs +++ b/src/routes/person.rs @@ -1,36 +1,78 @@ +use crate::cache::PersonCache; use crate::db::Database; use crate::models::*; use actix_web::{HttpResponse, Responder, web}; use std::collections::HashMap; use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; -pub async fn get_people(db: web::Data>) -> impl Responder { - let persons = db.get_all_persons_with_aliases().await; - HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons }) +/// GET /api/people — 从缓存获取所有人名列表 +pub async fn get_people(cache: web::Data>>) -> impl Responder { + let c = cache.read().await; + // 缓存中已按 total_albums DESC 排序 + HttpResponse::Ok().json(PersonListResponse { + total: c.persons.len(), + persons: c.persons.clone(), + }) } -pub async fn search_people(db: web::Data>, query: web::Query>) -> impl Responder { +/// GET /api/people/search?q=关键词 — 从缓存搜索人名 +pub async fn search_people( + cache: web::Data>>, + query: web::Query>, +) -> impl Responder { let q = query.get("q").map(|s| s.as_str()).unwrap_or(""); + let c = cache.read().await; if q.is_empty() { - let persons = db.get_all_persons_with_aliases().await; - return HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons }); + return HttpResponse::Ok().json(PersonListResponse { + total: c.persons.len(), + persons: c.persons.clone(), + }); } - let persons = db.search_persons(q).await; - HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons }) + let persons = c.search(q); + HttpResponse::Ok().json(PersonListResponse { + total: persons.len(), + persons, + }) } -pub async fn get_person_detail(db: web::Data>, path: web::Path) -> impl Responder { +/// GET /api/people/{name} — 从缓存查找人物详情(需要 DB 获取相册列表) +pub async fn get_person_detail( + db: web::Data>, + cache: web::Data>>, + path: web::Path, +) -> impl Responder { let name = path.into_inner(); - match db.get_person_by_name(&name).await { + let c = cache.read().await; + // 在缓存中搜索 + let found = c.search(&name); + let p = found.into_iter().find(|p| p.name.to_lowercase() == name.to_lowercase()); + match p { Some(p) => { + // 相册列表仍需查 DB let albums = db.get_person_albums(p.id).await; - HttpResponse::Ok().json(PersonWithAlbums { album_count: albums.len(), person: p, albums }) + HttpResponse::Ok().json(PersonWithAlbums { + album_count: albums.len(), + person: Person { + id: p.id, + name: p.name, + display_name: p.display_name, + total_albums: p.total_albums, + total_images: p.total_images, + original_names: None, + created_at: String::new(), + updated_at: String::new(), + }, + albums, + }) } None => HttpResponse::NotFound().json(serde_json::json!({"error": format!("人员 '{}' 不存在", name)})), } } +/// GET /api/people/{id}/aliases — 查 DB(别名表记录数可能较大,不适合缓存) pub async fn get_person_aliases(db: web::Data>, path: web::Path) -> impl Responder { let aliases = db.get_person_aliases(path.into_inner()).await; HttpResponse::Ok().json(aliases) -} \ No newline at end of file +} diff --git a/src/routes/tag.rs b/src/routes/tag.rs index af9975d..238fe72 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -1,24 +1,37 @@ +use crate::cache::TagCache; use crate::db::Database; use crate::models::*; use actix_web::{HttpResponse, Responder, web}; use std::collections::HashMap; use std::sync::Arc; +use tokio::sync::RwLock; +/// GET /api/metadata/stats — 仍查 DB(需要实时统计数据) pub async fn get_metadata_stats(db: web::Data>) -> impl Responder { HttpResponse::Ok().json(db.get_metadata_stats().await) } -pub async fn search_by_tag(db: web::Data>, query: web::Query>) -> impl Responder { +/// GET /api/tags/search?tag=xxx — 从缓存按标签搜索相册 +pub async fn search_by_tag( + cache: web::Data>>, + query: web::Query>, +) -> impl Responder { let tag = match query.get("tag") { Some(t) if !t.is_empty() => t, _ => return HttpResponse::BadRequest().json(serde_json::json!({"error": "请提供tag参数"})), }; - let albums = db.search_albums_by_tag(tag).await; + let c = cache.read().await; + let albums = c.search_albums(tag); HttpResponse::Ok().json(albums) } -pub async fn get_popular_tags(db: web::Data>, query: web::Query>) -> impl Responder { - let limit: i64 = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50); - let tags = db.get_popular_tags(limit).await; +/// GET /api/tags/popular?limit=50 — 从缓存获取热门标签 +pub async fn get_popular_tags( + cache: web::Data>>, + query: web::Query>, +) -> impl Responder { + let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50); + let c = cache.read().await; + let tags: Vec = c.popular_tags.iter().take(limit).cloned().collect(); HttpResponse::Ok().json(TagListResponse { total: tags.len(), tags }) -} \ No newline at end of file +}