This commit is contained in:
lychang
2026-07-20 02:15:39 +08:00
parent 1700585a36
commit be581977f3
10 changed files with 399 additions and 115 deletions

View File

@@ -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"
]
}

View File

@@ -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
);

141
src/cache/mod.rs vendored
View File

@@ -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<TagStat>,
/// tag → 包含该标签的相册列表(倒排索引,供按标签搜索用)
pub tag_albums: HashMap<String, Vec<AlbumInfo>>,
pub last_updated: chrono::DateTime<chrono::Utc>,
}
impl TagCache {
pub fn update(&mut self, tags: Vec<TagStat>) {
pub fn update(&mut self, tags: Vec<TagStat>, tag_albums: HashMap<String, Vec<AlbumInfo>>) {
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<AlbumInfo> {
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<PersonWithCounts>,
/// 搜索索引:关键词小写 → person_id 集合
pub search_index: HashMap<String, Vec<i64>>,
/// person_id → person 投影(用于按 id 快速访问)
pub id_map: HashMap<i64, PersonWithCounts>,
pub last_updated: chrono::DateTime<chrono::Utc>,
}
impl PersonCache {
pub fn update(&mut self, persons: Vec<PersonWithCounts>) {
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<RwLock<AlbumsCache>>,
pub tags: Arc<RwLock<TagCache>>,
pub persons: Arc<RwLock<PersonCache>>,
}
/// 从缓存搜索人物(按姓名/别名)
pub fn search(&self, query: &str) -> Vec<PersonWithCounts> {
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()
}
}
}
/// 刷新所有缓存(相册 + 标签 + 人物)
pub async fn refresh_all_caches(
db: &Database,
albums_cache: &Arc<RwLock<AlbumsCache>>,
persons_cache: &Arc<RwLock<PersonCache>>,
tags_cache: &Arc<RwLock<TagCache>>,
) {
// 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<RwLock<PersonCache>>) {
let persons = db.get_all_persons_with_aliases().await;
let mut pc = persons_cache.write().await;
pc.update(persons);
}

View File

@@ -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<i64> = rows.iter().map(|r| r.get::<i64, _>("id")).collect();
let aliases = self.batch_get_person_aliases(&pids).await;
rows.iter().map(|r| {
let pid = r.get::<i64, _>("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<PersonWithCounts> {
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<i64> = rows.iter().map(|r| r.get::<i64, _>("id")).collect();
let aliases = self.batch_get_person_aliases(&pids).await;
rows.iter().map(|r| {
let pid = r.get::<i64, _>("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<i64, Vec<String>> {
if pids.is_empty() { return HashMap::new(); }
let placeholders: Vec<String> = 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<i64, Vec<String>> = 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<String, Vec<AlbumInfo>> {
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<String, Vec<AlbumInfo>> = HashMap::new();
for r in &rows {
let tag: String = r.get("tag");
let album = AlbumInfo {
id: Uuid::parse_str(&r.get::<String, _>("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<TagCoOccurrence> {
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;

View File

@@ -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::<Vec<[String; 2]>>(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::<Vec<[String; 2]>>(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;

View File

@@ -22,6 +22,10 @@ pub struct TagDictFile {
pub description: Option<String>,
pub tags: HashMap<String, TagCategoryDef>,
pub stop_words: Vec<String>,
#[serde(default)]
pub person_prefixes: Vec<String>,
#[serde(default)]
pub excluded_person_names: Vec<String>,
}
/// 分类定义
@@ -57,6 +61,10 @@ pub struct TagDict {
pub tag_to_category: HashMap<String, String>,
/// 停用词集合
pub stop_words: HashSet<String>,
/// 模特称呼前缀(提取人名时先移除的称谓词)
pub person_prefixes: Vec<String>,
/// 排除的人名(工作室/平台名,不应作为人物)
pub excluded_person_names: HashSet<String>,
}
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<String> = 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(),
)
}

View File

@@ -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<tokio::sync::RwLock<AlbumsCache>>) {
match db.get_all_albums().await {
Ok(albums) => {
let mut c = cache.write().await;
c.update(albums);
}
Err(e) => {
eprintln!("❌ 缓存刷新失败: {}", e);
}
}
}

View File

@@ -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::<Vec<_>>(),
}))
}

View File

@@ -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<Arc<Database>>) -> 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<Arc<RwLock<PersonCache>>>) -> 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<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
/// GET /api/people/search?q=关键词 — 从缓存搜索人名
pub async fn search_people(
cache: web::Data<Arc<RwLock<PersonCache>>>,
query: web::Query<HashMap<String, String>>,
) -> 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<Arc<Database>>, path: web::Path<String>) -> impl Responder {
/// GET /api/people/{name} — 从缓存查找人物详情(需要 DB 获取相册列表)
pub async fn get_person_detail(
db: web::Data<Arc<Database>>,
cache: web::Data<Arc<RwLock<PersonCache>>>,
path: web::Path<String>,
) -> 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<Arc<Database>>, path: web::Path<i64>) -> impl Responder {
let aliases = db.get_person_aliases(path.into_inner()).await;
HttpResponse::Ok().json(aliases)
}
}

View File

@@ -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<Arc<Database>>) -> impl Responder {
HttpResponse::Ok().json(db.get_metadata_stats().await)
}
pub async fn search_by_tag(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
/// GET /api/tags/search?tag=xxx — 从缓存按标签搜索相册
pub async fn search_by_tag(
cache: web::Data<Arc<RwLock<TagCache>>>,
query: web::Query<HashMap<String, String>>,
) -> 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<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> 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<Arc<RwLock<TagCache>>>,
query: web::Query<HashMap<String, String>>,
) -> 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<TagStat> = c.popular_tags.iter().take(limit).cloned().collect();
HttpResponse::Ok().json(TagListResponse { total: tags.len(), tags })
}
}