update
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
14
migrations/003_metadata_fix.sql
Normal file
14
migrations/003_metadata_fix.sql
Normal 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
141
src/cache/mod.rs
vendored
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
37
src/main.rs
37
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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user