This commit is contained in:
lychang
2026-07-19 23:56:28 +08:00
parent 34bcd67682
commit 36969a4fa0
16 changed files with 1925 additions and 350 deletions

View File

@@ -171,6 +171,13 @@ impl Database {
sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
}
/// 彻底删除一个人物(包括别名、相册关联、自身)
pub async fn delete_person_completely(&self, person_id: i64) {
sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
sqlx::query("DELETE FROM persons WHERE id = ?").bind(person_id).execute(&self.pool).await.ok();
}
/// 把 src_id 的所有相册关联、别名移动到 dst_id,然后删除 src_id
pub async fn merge_person_into(&self, src_id: i64, dst_id: i64) {
// 1. 移动相册关联
@@ -216,6 +223,14 @@ impl Database {
.bind(person_name_original).bind(person_name_cleaned).bind(album_id).execute(&self.pool).await.ok();
}
/// 清空该人物所有关联相册的 person 字段(当该人物被确认为非人物后调用)
pub async fn update_album_person_by_person_id(&self, person_id: i64) {
sqlx::query(
"UPDATE albums SET person_name_original = NULL, person_name_cleaned = NULL, \
processed_at = datetime('now') WHERE id IN (SELECT album_id FROM person_albums WHERE person_id = ?)"
).bind(person_id).execute(&self.pool).await.ok();
}
pub async fn update_album_tags(&self, album_id: &str, tags_json: &str, all_words_json: &str) {
sqlx::query("UPDATE albums SET tags = ?, all_words = ?, processed_at = datetime('now') WHERE id = ?")
.bind(tags_json).bind(all_words_json).bind(album_id).execute(&self.pool).await.ok();

View File

@@ -296,13 +296,24 @@ pub async fn execute_merge(db: &Database) -> MergeResult {
// 其余的合并到目标
for (src_id, src_name, _, _) in &items[1..] {
println!("[合并] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id);
// 合并前统计 src 的别名数
let src_aliases = db.get_person_aliases(*src_id).await;
let alias_count_before = src_aliases.len();
db.merge_person_into(*src_id, dst_id).await;
// 合并后统计新增的别名数 (= src 别名数 + src 原名)
result.persons_merged += 1;
// src 原名 + src 的 display_name + src 原有的别名
let moved = alias_count_before + 1; // +1 因为原名会成为 merged_original 别名
result.aliases_moved += moved;
}
}
if result.persons_merged > 0 {
println!("[合并] 完成:分析 {} 组,合并 {} 个人物", result.groups_analyzed, result.persons_merged);
println!("[合并] 完成:分析 {} 组,合并 {} 个人物,迁移 {} 个别名",
result.groups_analyzed, result.persons_merged, result.aliases_moved);
}
result

View File

@@ -1,91 +1,473 @@
//! 处理管线 — 批量处理相册元数据
//!
//! 核心架构:脏集追踪 + 增量解析
//!
//! Phase 0 清洗标签标点
//! Phase A1 检测标签中的人名并直接提取到 person 字段 → 加入脏集
//! Phase A2 清理非人物 → 加入脏集
//! Phase A3 收集新相册/半成品 → 加入脏集
//! Phase 1 解析脏集相册(增量解析,不扫全库)
//! Phase 2 人物合并
//! Phase B 后校验标签中的人名
//!
//! 脏集(DirtySet)贯穿各阶段,每个阶段发现的"需要重新解析"的相册都会加入脏集,
//! 最终只处理脏集里的相册,而非全库扫描,大幅提升效率。
use crate::db::Database;
use crate::engine::title_parser;
use crate::engine::tag_extractor;
use crate::engine::person_merger;
use std::collections::HashSet;
/// 处理所有相册的元数据:提取人名 + 标签(单次 jieba 分词)
/// 一阶段:一次调用 extract_person_and_tags 同时获取人名和标签
pub async fn process_all_albums(db: &Database) -> (usize, usize) {
// ════════════════════════════════════════════════
// 脏集 — 追踪需要重新解析的相册 ID
// ════════════════════════════════════════════════
/// 脏集:记录哪些相册需要重新解析
pub struct DirtySet {
ids: HashSet<String>,
}
impl DirtySet {
pub fn new() -> Self { Self { ids: HashSet::new() } }
pub fn add(&mut self, id: &str) { self.ids.insert(id.to_string()); }
pub fn extend(&mut self, ids: impl IntoIterator<Item = String>) { self.ids.extend(ids); }
pub fn contains(&self, id: &str) -> bool { self.ids.contains(id) }
pub fn len(&self) -> usize { self.ids.len() }
pub fn is_empty(&self) -> bool { self.ids.is_empty() }
pub fn into_inner(self) -> HashSet<String> { self.ids }
}
// ════════════════════════════════════════════════
// Phase 0:清洗已有标签中的头尾标点
// ════════════════════════════════════════════════
pub async fn phase_clean_tags(db: &Database) -> usize {
let albums = db.get_all_albums_full().await;
let mut person_count = 0;
let mut tag_count = 0;
// 已有人物库时优先用匹配(更准确)
let patterns = person_merger::collect_person_patterns(db).await;
let use_patterns = !patterns.is_empty();
let mut cleaned_count = 0usize;
let mut removed_tags = HashSet::new();
for album in &albums {
let title = album.title.as_deref().unwrap_or("");
if title.is_empty() { continue; }
let tags_json = match album.tags.as_deref() {
Some(t) if !t.is_empty() && t != "[]" => t,
_ => continue,
};
let old_tags: Vec<String> = match serde_json::from_str(tags_json) {
Ok(t) => t, Err(_) => continue,
};
if old_tags.is_empty() { continue; }
// 判断是否需要处理
let need_person = album.person_name_original.is_none()
|| album.person_name_original.as_deref() == Some("");
let need_tags = album.tags.as_deref().unwrap_or("").is_empty()
|| album.tags.as_deref() == Some("[]");
if !need_person && !need_tags { continue; }
// 一次 jieba 分词,同时获取人名 + 标签
let (person_from_jieba, tags_from_jieba) = title_parser::extract_person_and_tags(title);
// ── 处理人名 ──
if need_person {
let person_name = if use_patterns {
// 已有人物库,优先用匹配;匹配不到再回退到 jieba
person_merger::match_title(title, &patterns)
.map(|(_, name, _)| name)
.or(person_from_jieba)
} else {
person_from_jieba
let mut changed = false;
let mut new_tags = Vec::new();
for tag in old_tags {
let cleaned = match tag_extractor::clean_tag(&tag) {
Some(t) => t,
None => { removed_tags.insert(tag); changed = true; continue; }
};
if cleaned != tag { removed_tags.insert(tag); changed = true; }
new_tags.push(cleaned);
}
if !changed { continue; }
cleaned_count += 1;
if let Some(person_name) = person_name {
// 拆分中文主名 + 别名
let (primary_name, aliases) = title_parser::split_person_name_with_aliases(&person_name);
let final_name = if primary_name.is_empty() { person_name.clone() } else { primary_name };
db.update_album_person(&album.id, &person_name, &final_name).await;
let tags_json_new = serde_json::to_string(&new_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_map(|pair| { let cleaned = tag_extractor::clean_tag(&pair[0])?; Some([cleaned, pair[1].clone()]) })
.collect();
let all_words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
db.update_album_tags(&album.id, &tags_json_new, &all_words_json_new).await;
}
// 创建人员(先查是否已有同名)
let pid = db.insert_person(&final_name, None).await;
db.insert_person_album(pid, &album.id).await;
if cleaned_count > 0 {
println!("[管线·Phase0] 标签清洗完成:清洗 {} 个相册,移除标签: {:?}",
cleaned_count, removed_tags.iter().collect::<Vec<_>>());
} else {
println!("[管线·Phase0] 标签清洗完成:无需要清洗的标签");
}
cleaned_count
}
// 保存所有别名
for (alias_type, alias_name) in &aliases {
if !alias_name.is_empty() && alias_name != &final_name {
db.insert_person_alias(pid, alias_type, alias_name).await;
}
}
// ════════════════════════════════════════════════
// Phase A1:检测标签中的人名并直接提取到 person 字段
// ════════════════════════════════════════════════
db.update_person_counts(pid).await;
person_count += 1;
/// Phase A1:检测标签中可能的人名并直接提取到 person_name
///
/// 核心变化:不再只标记脏集等待 Phase 1,而是直接从 tags 中提取人名写入 person 字段。
/// 因为 tags 中的人名比从 title 重解析更直接、更准确。
///
/// 两层检测 + 最终写入:
/// 1. 逐个标签 — 标签是已知人物或像人名 → 收集为候选人名(中文加权)
/// 2. jieba 合并(空格分隔)— 发现跨标签拆分的人名(["杨晨","晨Yome"])
/// 3. 最佳候选人名 → 写入 person_name,从标签中移除关联标签
///
/// 返回 (脏集, 从标签中移除的人名标签数)
pub async fn phase_detect_person_in_tags(db: &Database) -> (DirtySet, usize) {
let albums = db.get_all_albums_full().await;
let mut dirty = DirtySet::new();
let mut person_like_removed = 0usize;
let known_persons: HashSet<String> = db.get_all_person_names().await
.into_iter().map(|(n, _)| n.to_lowercase()).collect();
for album in &albums {
let tags_json = match album.tags.as_deref() {
Some(t) if !t.is_empty() && t != "[]" => t,
_ => continue,
};
let old_tags: Vec<String> = match serde_json::from_str(tags_json) {
Ok(t) => t, Err(_) => continue,
};
let has_person = album.person_name_original.as_deref()
.map(|s| !s.is_empty()).unwrap_or(false);
// 快速判断是否需要处理
let has_known_tag = old_tags.iter().any(|t| known_persons.contains(&t.to_lowercase()));
let has_like_tag = !has_person && old_tags.iter().any(|t|
title_parser::is_likely_person_name(t) && !title_parser::is_excluded_name(t));
if has_person && !has_known_tag && !has_like_tag { continue; }
// ── 第一层:逐个标签收集候选人名 ──
let mut known_person_found: Option<String> = None;
let mut person_like_tags: Vec<String> = Vec::new();
for tag in &old_tags {
let tag_lower = tag.to_lowercase();
if known_persons.contains(&tag_lower) {
known_person_found = Some(tag.clone());
continue;
}
if !has_person && title_parser::is_likely_person_name(tag) && !title_parser::is_excluded_name(tag) {
person_like_tags.push(tag.clone());
continue;
}
}
// ── 处理标签 ──
if need_tags {
// 用当前 album 的人名(优先已有的,否则用刚提取的)
let person = album.person_name_cleaned.as_deref()
.or_else(|| album.person_name_original.as_deref());
// 从 person_like_tags 中选最优(中文加权)
let best_layer1 = person_like_tags.into_iter().max_by_key(|t| {
if let Some(cn) = title_parser::extract_cn(t) {
if cn.len() >= 2 { cn.len() * 10 + t.len() } else { t.len() }
} else { t.len() }
});
// 结合分类词库匹配(高质量标签)+ jieba 名词提取
let mut tags = tag_extractor::extract_tags_from_title(title, person);
tags.extend(tags_from_jieba);
// ── 第二层:空格连接所有标签,用 jieba 提取人名 ──
let combined = old_tags.join(" ");
let (jieba_name, _) = title_parser::extract_person_and_tags(&combined);
let unique = tag_extractor::unique_tags(tags);
if !unique.is_empty() {
let tag_names: Vec<String> = unique.iter().map(|(t, _)| t.clone()).collect();
let tags_json = serde_json::to_string(&tag_names).unwrap_or_else(|_| "[]".to_string());
let all_words: Vec<[String; 2]> = unique.iter().map(|(t, tp)| [t.clone(), tp.clone()]).collect();
let words_json = serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string());
db.update_album_tags(&album.id, &tags_json, &words_json).await;
tag_count += 1;
// ── 确定最终人名 ──
let final_person = known_person_found.or_else(|| {
match (&jieba_name, &best_layer1) {
(Some(j), Some(b)) => {
// jieba 结果更完整 → 用 jieba
if j.len() > b.len() || title_parser::is_excluded_name(b) { Some(j.clone()) } else { Some(b.clone()) }
}
(Some(j), None) => Some(j.clone()),
(None, Some(b)) => Some(b.clone()),
(None, None) => None,
}
});
if let Some(ref person_name) = final_person {
// ── 从标签中移除所有人名相关标签 ──
let mut changed = false;
let mut clean_tags: Vec<String> = Vec::new();
let person_lower = person_name.to_lowercase();
let (primary_cleaned, _) = title_parser::split_person_name_with_aliases(person_name);
let primary_lower = primary_cleaned.to_lowercase();
for tag in &old_tags {
let tag_lower = tag.to_lowercase();
let should_remove =
tag_lower == person_lower // 完全匹配
|| tag_lower == primary_lower // 中文核心匹配
|| (tag.len() >= 2 && tag.chars().all(|c| c.is_ascii_alphabetic())
&& !tag_lower.starts_with("cos")
&& person_lower.contains(&tag_lower)) // 英文后缀
|| (tag_lower.len() >= 2 && person_lower.contains(&tag_lower)); // 人名包含
if should_remove {
person_like_removed += 1;
changed = true;
continue;
}
clean_tags.push(tag.clone());
}
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 };
db.update_album_person(&album.id, person_name, &final_name).await;
if !title_parser::is_excluded_name(&final_name) {
let pid = db.insert_person(&final_name, None).await;
db.insert_person_album(pid, &album.id).await;
for (at, an) in &aliases {
if !an.is_empty() && an != &final_name {
db.insert_person_alias(pid, at, an).await;
}
}
db.update_person_counts(pid).await;
}
// ── 写入清理后的标签 ──
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;
if has_person { dirty.add(&album.id); }
}
}
}
println!("[管线] 处理完成:{} 个人名,{} 个标签", person_count, tag_count);
(person_count, tag_count)
if person_like_removed > 0 {
println!("[管线·PhaseA1] 从标签中检测并提取人名:移除 {} 个人名标签,写入 {} 个相册的 person 字段",
person_like_removed, dirty.len());
} else {
println!("[管线·PhaseA1] 检测标签中的人名:无异常");
}
(dirty, person_like_removed)
}
// ════════════════════════════════════════════════
// Phase A2:清理 persons 表中的非人物
// ════════════════════════════════════════════════
pub async fn phase_clean_persons(db: &Database) -> (DirtySet, usize) {
let persons = db.get_all_persons_with_counts().await;
let mut dirty = DirtySet::new();
let mut deleted = 0usize;
for (pid, name, _albums, _images) in &persons {
if title_parser::is_excluded_name(name) || !title_parser::is_likely_person_name(name) {
let album_list = db.get_person_albums(*pid).await;
let album_ids: Vec<String> = album_list.into_iter().map(|a| a.id.to_string()).collect();
for aid in &album_ids { dirty.add(aid); }
println!("[管线·PhaseA2] 删除非人物:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
db.update_album_person_by_person_id(*pid).await;
db.delete_person_completely(*pid).await;
deleted += 1;
}
}
if deleted > 0 {
println!("[管线·PhaseA2] 清理完成:删除 {} 个非人物,标记 {} 个脏相册", deleted, dirty.len());
} else {
println!("[管线·PhaseA2] 清理完成:无非人物需要删除");
}
(dirty, deleted)
}
// ════════════════════════════════════════════════
// Phase A3:收集新相册 + 半成品
// ════════════════════════════════════════════════
pub async fn phase_collect_new_albums(db: &Database) -> DirtySet {
let albums = db.get_all_albums_full().await;
let mut dirty = DirtySet::new();
let mut new_count = 0usize;
let mut partial_count = 0usize;
for album in &albums {
let tags_empty = album.tags.as_deref()
.map(|t| t.is_empty() || t == "[]" || t == "null").unwrap_or(true);
if tags_empty { dirty.add(&album.id); new_count += 1; continue; }
let has_person = album.person_name_original.as_deref()
.map(|s| !s.is_empty()).unwrap_or(false);
if !has_person { dirty.add(&album.id); partial_count += 1; }
}
println!("[管线·PhaseA3] 收集相册:{} 个全新,{} 个半成品,脏集合计 {} 个", new_count, partial_count, dirty.len());
dirty
}
// ════════════════════════════════════════════════
// Phase 1:增量解析 — 只处理脏集中的相册
// ════════════════════════════════════════════════
pub async fn phase_parse_dirty(db: &Database, dirty_set: &DirtySet) -> (usize, usize) {
if dirty_set.is_empty() {
println!("[管线·Phase1] 增量解析跳过:脏集为空");
return (0, 0);
}
let albums = db.get_all_albums_full().await;
let patterns = person_merger::collect_person_patterns(db).await;
let use_patterns = !patterns.is_empty();
let mut person_updated = 0usize;
let mut tag_updated = 0usize;
let mut skipped = 0usize;
for album in &albums {
if !dirty_set.contains(&album.id) { continue; }
let title = album.title.as_deref().unwrap_or("");
if title.is_empty() { skipped += 1; continue; }
let (jieba_person, jieba_tags) = title_parser::extract_person_and_tags(title);
let best_person = if use_patterns {
person_merger::match_title(title, &patterns)
.map(|(_, name, _)| name).or(jieba_person)
} else {
jieba_person
};
if let Some(name) = &best_person {
let (primary, aliases) = title_parser::split_person_name_with_aliases(name);
let final_name = if primary.is_empty() { name.clone() } else { primary };
db.update_album_person(&album.id, name, &final_name).await;
let pid = db.insert_person(&final_name, None).await;
db.insert_person_album(pid, &album.id).await;
for (at, an) in &aliases {
if !an.is_empty() && an != &final_name {
db.insert_person_alias(pid, at, an).await;
}
}
db.update_person_counts(pid).await;
person_updated += 1;
}
// 确定标签排除用的人名 — 用最完整的名字
// 优先级:person_name_original(完整含英文后缀) > best_person > person_name_cleaned(仅中文)
let exclude_person = album.person_name_original.as_deref()
.filter(|s| !s.is_empty())
.or_else(|| best_person.as_deref())
.or_else(|| album.person_name_cleaned.as_deref().filter(|s| !s.is_empty()));
let mut raw_tags = tag_extractor::extract_tags_from_title(title, exclude_person);
raw_tags.extend(jieba_tags);
let unique = tag_extractor::unique_tags(raw_tags);
let tag_names: Vec<String> = unique.iter().map(|(t, _)| t.clone()).collect();
if !tag_names.is_empty() {
let tags_json = serde_json::to_string(&tag_names).unwrap_or_else(|_| "[]".to_string());
let all_words: Vec<[String; 2]> = unique.iter().map(|(t, tp)| [t.clone(), tp.clone()]).collect();
let words_json = serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string());
db.update_album_tags(&album.id, &tags_json, &words_json).await;
tag_updated += 1;
}
}
println!("[管线·Phase1] 增量解析完成:脏集 {} 个,处理 {} 个,跳过 {} 个,更新人名 {} 个,更新标签 {} 个",
dirty_set.len(), person_updated.max(tag_updated), skipped, person_updated, tag_updated);
(person_updated, tag_updated)
}
// ════════════════════════════════════════════════
// Phase 2:人物合并
// ════════════════════════════════════════════════
pub async fn phase_merge_persons(db: &Database) -> (usize, usize) {
let result = person_merger::execute_merge(db).await;
if result.persons_merged > 0 {
println!("[管线·Phase2] 人物合并完成:分析 {} 组,合并 {} 人,迁移 {} 个别名",
result.groups_analyzed, result.persons_merged, result.aliases_moved);
} else {
println!("[管线·Phase2] 人物合并完成:无需要合并的人物");
}
(result.persons_merged, result.aliases_moved)
}
// ════════════════════════════════════════════════
// Phase B:后校验标签 — 用合并后的人名检查
// ════════════════════════════════════════════════
pub async fn phase_clean_tags_after_parse(db: &Database) -> usize {
let albums = db.get_all_albums_full().await;
let all_person_names: HashSet<String> = db.get_all_person_names().await
.into_iter().map(|(n, _)| n.to_lowercase()).collect();
let all_aliases: HashSet<String> = db.get_all_aliases().await
.into_iter().map(|(a, _)| a.to_lowercase()).collect();
let person_or_alias: HashSet<String> = all_person_names.into_iter().chain(all_aliases).collect();
if person_or_alias.is_empty() {
println!("[管线·PhaseB] 后校验标签完成:无人物数据,跳过");
return 0;
}
let mut tag_removed = 0usize;
for album in &albums {
let tags_json = match album.tags.as_deref() {
Some(t) if !t.is_empty() && t != "[]" => t, _ => continue,
};
let old_tags: Vec<String> = match serde_json::from_str(tags_json) { Ok(t) => t, Err(_) => continue, };
let mut album_removed = 0usize;
let good_tags: Vec<String> = old_tags.into_iter().filter(|t| {
if person_or_alias.contains(&t.to_lowercase()) { album_removed += 1; false } else { true }
}).collect();
if album_removed == 0 { continue; }
tag_removed += album_removed;
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| !person_or_alias.contains(&pair[0].to_lowercase())).collect();
let tags_json_new = serde_json::to_string(&good_tags).unwrap_or_else(|_| "[]".to_string());
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;
}
if tag_removed > 0 {
println!("[管线·PhaseB] 后校验标签完成:移除 {} 个人名/别名", tag_removed);
} else {
println!("[管线·PhaseB] 后校验标签完成:无需要修复的标签");
}
tag_removed
}
// ════════════════════════════════════════════════
// Pipeline 一键执行(7 阶段)
// ════════════════════════════════════════════════
#[derive(Debug, Clone, Default)]
pub struct PipelineResult {
pub tags_cleaned: usize, // Phase 0
pub person_like_tags_removed: usize, // Phase A1
pub dirty_albums_detected: usize, // A1+A2+A3
pub non_persons_deleted: usize, // Phase A2
pub new_albums_found: usize, // Phase A3
pub persons_updated: usize, // Phase 1
pub tags_updated: usize, // Phase 1
pub persons_merged: usize, // Phase 2
pub aliases_moved: usize, // Phase 2
pub tags_removed_person: usize, // Phase B
}
/// 一键执行完整管线(7 阶段顺序串行)
pub async fn run_pipeline(db: &Database) -> PipelineResult {
let tags_cleaned = phase_clean_tags(db).await;
let (dirty_a1, person_like_tags_removed) = phase_detect_person_in_tags(db).await;
let (dirty_a2, non_persons_deleted) = phase_clean_persons(db).await;
let dirty_a3 = phase_collect_new_albums(db).await;
let new_albums_found = dirty_a3.len();
let mut master_dirty = DirtySet::new();
master_dirty.extend(dirty_a1.into_inner());
master_dirty.extend(dirty_a2.into_inner());
master_dirty.extend(dirty_a3.into_inner());
let dirty_albums_detected = master_dirty.len();
let (persons_updated, tags_updated) = phase_parse_dirty(db, &master_dirty).await;
let (persons_merged, aliases_moved) = phase_merge_persons(db).await;
let tags_removed_person = phase_clean_tags_after_parse(db).await;
PipelineResult {
tags_cleaned, person_like_tags_removed, dirty_albums_detected,
non_persons_deleted, new_albums_found,
persons_updated, tags_updated, persons_merged,
aliases_moved, tags_removed_person,
}
}

View File

@@ -22,6 +22,35 @@ lazy_static! {
lazy_static! {
static ref WORD_RE: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap();
static ref HAS_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
/// 提取标签中的有效部分(核心字词,去头尾标点)
static ref TAG_CORE_RE: Regex = Regex::new(
r#"^[\s.,;:!?'\"‘’“”\-–—()\[\]{}【】《》〈〉、,。;:?!…~·•]+|[\s.,;:!?'\"‘’“”\-–—()\[\]{}【】《》〈〉、,。;:?!…~·•]+$"#
).unwrap();
}
/// 判断字符串是否全为标点符号(不含任何字、字母或数字)
pub fn is_punctuation_only(s: &str) -> bool {
if s.is_empty() { return true; }
// 检查是否包含任意"有效字符"(中文字、字母、数字),
// 一个都不包含 → 纯标点/符号
!s.chars().any(|c| {
c.is_ascii_alphanumeric() // ASCII 字母/数字
|| ('\u{4e00}'..='\u{9fff}').contains(&c) // CJK 统一表意文字
|| ('\u{3400}'..='\u{4dbf}').contains(&c) // CJK 扩展 A
|| ('\u{f900}'..='\u{faff}').contains(&c) // CJK 兼容
})
}
/// 清洗单个标签:去除头尾标点符号
/// 例如 "性感!" → "性感","...比基尼" → "比基尼", " – 室内" → "室内"
/// 清洗后如果只剩下标点或为空,则返回 None
pub fn clean_tag(tag: &str) -> Option<String> {
let cleaned = TAG_CORE_RE.replace_all(tag.trim(), "").trim().to_string();
if cleaned.is_empty() || is_punctuation_only(&cleaned) || cleaned.len() < 2 {
None
} else {
Some(cleaned)
}
}
// ── 标签词库(按分类) ──
@@ -152,6 +181,13 @@ fn extract_tags_with_jieba(
// 也提取中文核心名用于排除
if let Some(cn) = title_parser::extract_cn(n) {
v.push(cn.to_lowercase());
// 中英文混合名的英文后缀也要排除(如 "水淼Aqua" → "aqua")
if let Some(rest) = n.strip_prefix(&cn) {
let suffix = rest.trim();
if !suffix.is_empty() {
v.push(suffix.to_lowercase());
}
}
}
v
})
@@ -163,6 +199,9 @@ fn extract_tags_with_jieba(
let word_lower = word.to_lowercase();
// 纯标点/符号跳过
if is_punctuation_only(word) { continue; }
// 排除纯数字
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
@@ -182,14 +221,14 @@ fn extract_tags_with_jieba(
"n" | "ng" => "noun", // 普通名词
"nz" => "proper", // 其它专名
"ns" => "location", // 地名
"nt" => "organization", // 机构团体名
"vn" => "noun_verb", // 名动词
"a" | "ad" | "an" => "adj", // 形容词
"v" | "vd" | "vg" => "verb", // 动词
"eng" => "english", // 英文词
"eng" => "other", // 英文词(归入 other)
"x" => "other", // 其它
// 排除的词性
// 排除的词性(这些不应该作为标签)
"nr" | "nrfg" | "nrt" => continue, // 人名类跳过
"nt" => continue, // 机构团体名(不是标签)
"vn" => continue, // 名动词(分类模糊)
"a" | "ad" | "an" => continue, // 形容词(不是标签)
"v" | "vd" | "vg" => continue, // 动词(不是标签)
"m" | "mq" => continue, // 数词
"q" | "qv" | "qt" => continue, // 量词
"r" | "rr" | "rz" => continue, // 代词
@@ -207,9 +246,6 @@ fn extract_tags_with_jieba(
_ => continue,
};
// 进一步过滤:太短的英文词或太通用的词
if category == "english" && word.len() < 3 { continue; }
tags.push((word.to_string(), category.to_string()));
}
@@ -231,6 +267,15 @@ pub fn extract_tags_from_title(
for part in name.split_whitespace() {
title_lower = title_lower.replace(&part.to_lowercase(), " ");
}
// 中英文混合名:人名的英文后缀也从 title 中移除(如 "水淼Aqua" → "aqua")
if let Some(cn) = title_parser::extract_cn(name) {
if let Some(rest) = name.strip_prefix(&cn) {
let suffix = rest.trim().to_lowercase();
if !suffix.is_empty() {
title_lower = title_lower.replace(&suffix, " ");
}
}
}
}
let stop_words = get_stop_words_set();
@@ -265,6 +310,7 @@ pub fn extract_tags_from_title(
// 基础过滤
if word.len() < 2 { continue; }
if is_punctuation_only(word) { continue; }
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
if stop_words.contains(word_lower.as_str()) { continue; }
if let Some(ref pn) = person_name_lower {
@@ -287,8 +333,7 @@ pub fn extract_tags_from_title(
seen.insert(word_lower.clone());
let tag_type = if HAS_CHINESE.is_match(word) { "chinese" } else { "english" };
tags.push((word.to_string(), tag_type.to_string()));
tags.push((word.to_string(), "other".to_string()));
}
tags
@@ -302,8 +347,7 @@ pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
// 分类排序权重:预设词库分类最优先,然后是 jieba 提取的名词类
let category_order: Vec<&str> = vec![
"style", "outfit", "scene", "body", "mood", "topic",
"noun", "proper", "location", "organization", "noun_verb",
"adj", "verb", "chinese", "english", "other"
"noun", "proper", "location", "other"
];
let mut sorted_tags = tags;
@@ -314,14 +358,38 @@ pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
});
for (tag, tag_type) in sorted_tags {
let key = tag.to_lowercase();
// 清洗标签:去除头尾标点
let cleaned_tag = match clean_tag(&tag) {
Some(t) => t,
None => continue, // 清洗后为空 → 跳过
};
// 最终安全过滤:坏标签一律剔除(停用词等)
if is_bad_tag(&cleaned_tag) { continue; }
let key = cleaned_tag.to_lowercase();
if seen.insert(key) {
result.push((tag, tag_type));
result.push((cleaned_tag, tag_type));
}
}
result
}
/// 判断一个标签是否"坏标签"(应该在数据库中清理掉)
/// 坏标签定义:标点符号、含头尾标点需要清洗、太短、纯数字、停用词
/// 如果标签需要清洗(如 "性感!"),也会被标记为坏标签
pub fn is_bad_tag(tag: &str) -> bool {
let tag = tag.trim();
if tag.is_empty() || tag.len() < 2 { return true; }
if tag.chars().all(|c| c.is_ascii_digit()) { return true; }
if is_punctuation_only(tag) { return true; }
// 如果标签清洗后变短了,说明含有头尾标点 → 坏标签
if let Some(cleaned) = clean_tag(tag) {
if cleaned != tag { return true; }
}
let stop_words = get_stop_words_set();
if stop_words.contains(&tag.to_lowercase().as_str()) { return true; }
false
}
/// 获取标签的中文分类名(用于前端展示)
pub fn get_category_label(category: &str) -> &'static str {
match category {
@@ -334,12 +402,6 @@ pub fn get_category_label(category: &str) -> &'static str {
"noun" => "名词",
"proper" => "专名",
"location" => "地名",
"organization" => "机构",
"noun_verb" => "名动",
"adj" => "形容词",
"verb" => "动词",
"chinese" => "中文",
"english" => "英文",
_ => "其他",
}
}
@@ -356,12 +418,6 @@ pub fn get_category_color(category: &str) -> (&'static str, &'static str) {
"noun" => ("#eff6ff", "#1d4ed8"), // 蓝灰底 + 深蓝字
"proper" => ("#e0e7ff", "#4338ca"), // 靛蓝底 + 深靛字
"location"=> ("#ecfdf5", "#047857"), // 翠绿底 + 深绿字
"organization" => ("#fef9c3", "#a16207"), // 浅黄底 + 暗黄字
"noun_verb" => ("#fce7f3", "#be185d"), // 浅粉底 + 深粉字
"adj" => ("#f3e8ff", "#6d28d9"), // 浅紫底 + 深紫字
"verb" => ("#fed7aa", "#c2410c"), // 橙底 + 深橙字
"chinese" => ("#f1f5f9", "#475569"), // 浅灰底 + 深灰字
"english" => ("#f1f5f9", "#64748b"), // 浅灰底 + 灰字
_ => ("#f1f5f9", "#64748b"), // 默认
}
}

View File

@@ -96,9 +96,14 @@ lazy_static! {
static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
static ref RE_ENGLISH_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap();
static ref RE_TRAILING_PUNCT: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap();
/// 人名模式:2-5个中文字符 + 可选英文后缀
/// 人名模式:2-5个中文字符(纯中文,不混杂英文)
/// 中英文混杂的情况由 split_person_name_with_aliases 处理为 主名+别名
static ref RE_CN_NAME: Regex = Regex::new(
r"([\u{4e00}-\u{9fff}]{2,5}(?:[A-Za-z][A-Za-z0-9_]*)?)"
r"([\u{4e00}-\u{9fff}]{2,5})"
).unwrap();
/// 纯英文人名模式:2-15个英文字母,首字母可大写
static ref RE_EN_NAME: Regex = Regex::new(
r"([A-Za-z][A-Za-z]{1,14})"
).unwrap();
/// 常见人名后缀(表明前面是人名)
static ref RE_NAME_CONTEXT: Regex = Regex::new(
@@ -201,6 +206,7 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
}
// 策略C: 直接在整个标题中搜索人名模式
// 先搜纯中文(优先级更高)
for cap in RE_CN_NAME.captures_iter(&t) {
let name = clean_name(&cap[1]);
if is_likely_person_name(&name) {
@@ -208,6 +214,16 @@ pub fn extract_person_name_from_title(title: &str) -> Option<String> {
}
}
// 再搜纯英文
for cap in RE_EN_NAME.captures_iter(&t) {
let name = clean_name(&cap[1]);
// 排除停用词
if STOP_WORDS_SET.contains(&name.to_lowercase().as_str()) { continue; }
if is_likely_person_name(&name) {
return Some(name);
}
}
None
}
@@ -245,13 +261,25 @@ fn extract_person_with_jieba(title: &str) -> Option<String> {
}
/// 从一个文本片段中提取人名
/// 只支持纯中文(2-6字)或纯英文(2-15字),不混杂
/// 中文优先级更高
fn extract_name_from_segment(segment: &str) -> Option<String> {
let seg = segment.trim();
if seg.is_empty() {
return None;
}
// 尝试直接匹配人名模式
// 判断片段是否纯中文
let is_pure_cn = seg.chars().all(|c| RE_CHINESE.is_match(&c.to_string()));
// 判断片段是否纯英文
let is_pure_en = seg.chars().all(|c| c.is_ascii_alphabetic());
// 策略1: 纯中文片段(2-6字)
if is_pure_cn && seg.len() >= 2 && seg.len() <= 6 {
return Some(clean_name(seg));
}
// 策略2: 尝试匹配纯中文人名模式
if let Some(cap) = RE_CN_NAME.captures(seg) {
let name = clean_name(&cap[1]);
if name.len() >= 2 {
@@ -259,10 +287,20 @@ fn extract_name_from_segment(segment: &str) -> Option<String> {
}
}
// 如果整个片段是纯中文(2-5字),也认为是人名
let is_pure_cn = seg.chars().all(|c| RE_CHINESE.is_match(&c.to_string()));
if is_pure_cn && seg.len() >= 2 && seg.len() <= 6 {
return Some(clean_name(seg));
// 策略3: 纯英文片段(2-15字)
if is_pure_en && seg.len() >= 2 && seg.len() <= 15 {
// 过滤停用词
if !STOP_WORDS_SET.contains(&seg.to_lowercase().as_str()) {
return Some(clean_name(seg));
}
}
// 策略4: 尝试匹配纯英文人名模式
if let Some(cap) = RE_EN_NAME.captures(seg) {
let name = clean_name(&cap[1]);
if name.len() >= 2 && !STOP_WORDS_SET.contains(&name.to_lowercase().as_str()) {
return Some(name);
}
}
None
@@ -482,13 +520,18 @@ pub fn extract_person_and_tags(title: &str) -> (Option<String>, Vec<(String, Str
}
/// 从 jieba 分词结果中提取人名
/// 返回完整的原始人名(可能含英文后缀),供 split_person_name_with_aliases 拆分为 主名+别名
/// 例如 jieba 拆出 "水淼/nr Aqua/eng" → 返回 "水淼Aqua"
fn extract_person_from_tags(tags: &[jieba_rs::Tag]) -> Option<String> {
let mut candidates: Vec<(String, usize)> = Vec::new();
for tag in tags {
for (i, tag) in tags.iter().enumerate() {
let word = tag.word.trim();
if word.len() < 2 { continue; }
// 过滤纯标点
if is_punctuation_only(word) { continue; }
let priority = match tag.tag {
"nr" => 0, // 人名(最高优先级)
"nrfg" => 1, // 古体人名
@@ -499,7 +542,33 @@ fn extract_person_from_tags(tags: &[jieba_rs::Tag]) -> Option<String> {
};
if is_likely_person_name(word) {
candidates.push((word.to_string(), priority));
// 如果是中文人名,检查后面是否紧跟英文词(如 水淼/nr Aqua/eng)
// 拼装成完整原始名,供 split_person_name_with_aliases 拆分为 主名+别名
let mut full_name = word.to_string();
let is_chinese = word.chars().any(|c| RE_CHINESE.is_match(&c.to_string()));
if is_chinese {
let mut j = i + 1;
while j < tags.len() {
let next_word = tags[j].word.trim();
let next_tag = tags[j].tag;
if next_word.is_empty() { j += 1; continue; }
// 英文词(eng)或纯英文字母,且不是停用词
if next_tag == "eng" ||
(next_word.chars().all(|c| c.is_ascii_alphabetic()) && next_word.len() >= 2 && next_word.len() <= 15) {
let lower = next_word.to_lowercase();
// 跳过停用词(如 photos、cosplay 等停在停用词之前)
let stop_words: std::collections::HashSet<&str> = STOP_WORDS.iter().copied().collect();
if !stop_words.contains(lower.as_str()) && !EXCLUDED_LOWER.contains(&lower) {
full_name.push_str(next_word);
j += 1;
continue;
}
}
break;
}
}
candidates.push((full_name, priority));
}
}
@@ -507,6 +576,18 @@ fn extract_person_from_tags(tags: &[jieba_rs::Tag]) -> Option<String> {
candidates.into_iter().next().map(|(name, _)| clean_name(&name))
}
/// 判断字符串是否全为标点符号(不含任何字、字母或数字)
fn is_punctuation_only(s: &str) -> bool {
if s.is_empty() { return true; }
// 检查是否包含任意"有效字符"(中文字、字母、数字)
!s.chars().any(|c| {
c.is_ascii_alphanumeric()
|| ('\u{4e00}'..='\u{9fff}').contains(&c)
|| ('\u{3400}'..='\u{4dbf}').contains(&c)
|| ('\u{f900}'..='\u{faff}').contains(&c)
})
}
/// 从 jieba 分词结果中提取标签(排除人名)
fn extract_tags_from_jieba_result(
tags: &[jieba_rs::Tag],
@@ -529,6 +610,13 @@ fn extract_tags_from_jieba_result(
}
if let Some(cn) = extract_cn(n) {
v.push(cn.to_lowercase());
// 中英文混合名的英文后缀也要排除(如 "水淼Aqua" → "aqua")
if let Some(rest) = n.strip_prefix(&cn) {
let suffix = rest.trim();
if !suffix.is_empty() {
v.push(suffix.to_lowercase());
}
}
}
v
})
@@ -540,6 +628,9 @@ fn extract_tags_from_jieba_result(
let word_lower = word.to_lowercase();
// 纯标点/符号跳过
if is_punctuation_only(word) { continue; }
// 纯数字跳过
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
@@ -558,14 +649,14 @@ fn extract_tags_from_jieba_result(
"n" | "ng" => "noun",
"nz" => "proper",
"ns" => "location",
"nt" => "organization",
"vn" => "noun_verb",
"a" | "ad" | "an" => "adj",
"v" | "vd" | "vg" => "verb",
"eng" => "english",
"eng" => "other",
"x" => "other",
// 排除的词性
// 排除的词性(这些不应该作为标签)
"nr" | "nrfg" | "nrt" => continue, // 人名
"nt" => continue, // 机构团体名(不是标签)
"vn" => continue, // 名动词(分类模糊)
"a" | "ad" | "an" => continue, // 形容词(不是标签)
"v" | "vd" | "vg" => continue, // 动词(不是标签)
"m" | "mq" => continue,
"q" | "qv" | "qt" => continue,
"r" | "rr" | "rz" => continue,
@@ -583,8 +674,6 @@ fn extract_tags_from_jieba_result(
_ => continue,
};
if category == "english" && word.len() < 3 { continue; }
result.push((word.to_string(), category.to_string()));
}
@@ -650,10 +739,13 @@ mod tests {
#[test]
fn test_extract_person_name() {
assert_eq!(extract_person_name_from_title("杨晨晨Yome 私房写真").unwrap(), "杨晨晨Yome");
assert_eq!(extract_person_name_from_title("王馨瑶yanni 比基尼").unwrap(), "王馨瑶yanni");
// 纯中文人名(中文优先级更高,英文后缀作为别名处理)
assert_eq!(extract_person_name_from_title("杨晨晨Yome 私房写真").unwrap(), "杨晨晨");
assert_eq!(extract_person_name_from_title("王馨瑶yanni 比基尼").unwrap(), "王馨瑶");
assert_eq!(extract_person_name_from_title("[XiuRen] 杨晨晨 性感").unwrap(), "杨晨晨");
assert_eq!(extract_person_name_from_title("美女模特 杨晨晨 私房").unwrap(), "杨晨晨");
// 纯英文人名也支持
assert!(extract_person_name_from_title("Yome sexy photos").is_some());
}
#[test]

View File

@@ -49,86 +49,56 @@ async fn main() -> std::io::Result<()> {
let image_processor = Arc::new(image_processor::ImageProcessor::new(config.storage.clone()));
// 后台任务1: 扫描导入新相册
let config_clone = config.clone();
let db_import = Arc::clone(&db);
tokio::spawn(async move {
info!("[后台任务] 开始扫描图片目录并导入新相册...");
match services::import::import_existing_data(&config_clone, db_import, None).await {
Ok(_) => info!("[后台任务] 相册导入完成"),
Err(e) => error!("[后台任务] 相册导入失败: {}", e),
}
});
// 后台任务2: 自动解析已有相册的 title(增量,针对老版本 db)
let db_parse = Arc::clone(&db);
tokio::spawn(async move {
// 等待 3 秒让导入任务先开始(导入后也会触发解析,避免重复)
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
info!("[后台任务] 检查是否有未解析的相册(针对老版本 db)...");
let (persons, tags) = engine::pipeline::process_all_albums(&db_parse).await;
if persons > 0 || tags > 0 {
info!("[后台任务] Title 解析完成:{} 个人名,{} 个标签", persons, tags);
} else {
info!("[后台任务] 所有相册已解析,无需处理");
}
// 解析完成后,执行一次人物合并
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
info!("[后台任务] 执行人物合并分析...");
let result = engine::person_merger::execute_merge(&db_parse).await;
if result.persons_merged > 0 {
info!("[后台任务] 人物合并完成:分析 {} 组,合并 {} 人", result.groups_analyzed, result.persons_merged);
} else {
info!("[后台任务] 无需要合并的人物");
}
});
// 相册内存缓存
// 相册内存缓存(必须在管线任务之前创建)
let albums_cache = Arc::new(tokio::sync::RwLock::new(AlbumsCache::new()));
// 初始加载缓存
let initial_cache = Arc::clone(&albums_cache);
let initial_db = Arc::clone(&db);
// ── 启动管线(单次):导入 → 解析 → 合并 → 缓存刷新 ──
let boot_config = config.clone();
let boot_db = Arc::clone(&db);
let boot_cache = Arc::clone(&albums_cache);
tokio::spawn(async move {
match initial_db.get_all_albums().await {
Ok(albums) => {
let mut cache = initial_cache.write().await;
cache.update(albums);
println!("✅ 初始相册缓存加载完成");
}
Err(e) => {
eprintln!("❌ 初始缓存加载失败: {}", e);
}
// ① 扫描导入新相册
info!("[启动管线] ① 扫描导入新相册...");
match services::import::import_existing_data(&boot_config, Arc::clone(&boot_db), None).await {
Ok(_) => info!("[启动管线] 导入完成"),
Err(e) => error!("[启动管线] 导入失败: {}", e),
}
// ② 全量解析 + 人物合并
info!("[启动管线] ② 全量解析 + 人物合并...");
let result = engine::pipeline::run_pipeline(&boot_db).await;
info!("[启动管线] 完成:清洗标点{},检测人名标签{},脏集{}相册,删除非人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected,
result.non_persons_deleted, result.new_albums_found,
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!("[启动管线] 启动管线执行完毕");
});
// 定时任务(每30分钟):刷新缓存 + 人物合并
let refresh_cache = Arc::clone(&albums_cache);
let refresh_db = Arc::clone(&db);
// ── 定时管线(每 30 分钟):解析 → 合并 → 缓存刷新 ──
let timer_cache = Arc::clone(&albums_cache);
let timer_db = Arc::clone(&db);
tokio::spawn(async move {
let mut interval = interval(Duration::from_secs(30 * 60));
loop {
interval.tick().await;
// 1. 执行人物合并
info!("[定时任务] 执行人物合并分析...");
let result = engine::person_merger::execute_merge(&refresh_db).await;
if result.persons_merged > 0 {
info!("[定时任务] 人物合并完成:分析 {} 组,合并 {} 人", result.groups_analyzed, result.persons_merged);
}
// ① 全量解析 + 人物合并(不重复 import)
info!("[定时管线] ① 全量解析 + 人物合并...");
let result = engine::pipeline::run_pipeline(&timer_db).await;
info!("[定时管线] 完成:清洗标点{},检测人名标签{},脏集{}相册,删除非人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected,
result.non_persons_deleted, result.new_albums_found,
result.persons_updated, result.tags_updated,
result.persons_merged, result.aliases_moved, result.tags_removed_person);
// 2. 刷新缓存
match refresh_db.get_all_albums().await {
Ok(albums) => {
let mut cache = refresh_cache.write().await;
cache.update(albums);
println!("🔄 相册缓存定时刷新完成");
}
Err(e) => {
eprintln!("❌ 缓存刷新失败: {}", e);
}
}
// ② 刷新内存缓存
info!("[定时管线] ② 刷新内存缓存...");
refresh_albums_cache(&timer_db, &timer_cache).await;
}
});
@@ -241,4 +211,17 @@ async fn main() -> std::io::Result<()> {
.bind(config.server_address())?
.run()
.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

@@ -3,13 +3,27 @@ use crate::engine::pipeline;
use actix_web::{HttpResponse, Responder, web};
use std::sync::Arc;
/// POST /api/pipeline/run — 处理所有相册元数据(提取人名+标签)
/// POST /api/pipeline/run — 执行完整管线:清洗→检测→清理→解析→合并→校验
pub async fn pipeline_run(db: web::Data<Arc<Database>>) -> impl Responder {
let (persons, tags) = pipeline::process_all_albums(db.get_ref()).await;
let result = pipeline::run_pipeline(db.get_ref()).await;
HttpResponse::Ok().json(serde_json::json!({
"success": true,
"persons_updated": persons,
"tags_updated": tags,
"message": format!("处理完成:{} 个人名,{} 个标签", persons, tags)
"tags_cleaned": result.tags_cleaned,
"person_like_tags_removed": result.person_like_tags_removed,
"dirty_albums_detected": result.dirty_albums_detected,
"non_persons_deleted": result.non_persons_deleted,
"new_albums_found": result.new_albums_found,
"persons_updated": result.persons_updated,
"tags_updated": result.tags_updated,
"persons_merged": result.persons_merged,
"aliases_moved": result.aliases_moved,
"tags_removed_person": result.tags_removed_person,
"message": format!(
"管线执行完毕:清洗标点{}相册,检测人名标签{},脏集{}相册,删除非人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected,
result.non_persons_deleted, result.new_albums_found,
result.persons_updated, result.tags_updated,
result.persons_merged, result.aliases_moved, result.tags_removed_person
)
}))
}