update code level

This commit is contained in:
lychang
2026-07-21 03:33:35 +08:00
parent f1dba9f24d
commit d64b85bc9a
12 changed files with 402 additions and 84 deletions

View File

@@ -287,6 +287,31 @@ Base URL: `http://<host>:<port>/api`
---
### POST /api/people/delete/{name}
删除人物(从 imager.db + search.db 中清除),并重新解析受影响的相册。
**参数**(path):
| 参数 | 类型 | 说明 |
|------|------|------|
| name | string | 人物名(URL 编码) |
**响应**:
```json
{
"success": true,
"person": "人物名",
"albums_affected": 5,
"persons_updated": 3,
"tags_updated": 8,
"synced": 5,
"message": "人物 '人物名' 已删除,影响 5 个相册,重新解析完成..."
}
```
---
## 标签 / 元数据
### GET /api/metadata/stats
@@ -343,6 +368,31 @@ Base URL: `http://<host>:<port>/api`
---
### POST /api/tags/delete/{name}
删除标签(从 dict/album_tags/tags 表中清除),并重新解析受影响的相册。
**参数**(path):
| 参数 | 类型 | 说明 |
|------|------|------|
| name | string | 标签名(URL 编码) |
**响应**:
```json
{
"success": true,
"tag": "标签名",
"albums_affected": 5,
"persons_updated": 3,
"tags_updated": 8,
"synced": 5,
"message": "标签 '标签名' 已删除,影响 5 个相册,重新解析完成..."
}
```
---
## 推荐
### GET /api/recommend/tags
@@ -553,14 +603,14 @@ Base URL: `http://<host>:<port>/api`
[
{
"word": "写真",
"type_": "tag",
"type": "tag",
"category": "topic",
"freq": null,
"merged_to": null
},
{
"word": "张三",
"type_": "person",
"type": "person",
"category": null,
"freq": null,
"merged_to": null
@@ -568,7 +618,7 @@ Base URL: `http://<host>:<port>/api`
]
```
**type_ 可选值**:`tag`、`person`、`stop_word`、`person_prefix`、`excluded_person`
**type 可选值**:`tag`、`person`、`stop_word`、`person_prefix`、`excluded_person`
**响应**:

View File

@@ -25,7 +25,6 @@ impl Database {
) -> Result<(), sqlx::Error> {
for result in results {
let album_id = &result.album_id;
let person_name = result.person_name.as_deref();
// ── 标记处理时间(无论是否有人物,只要进了解析管线就标记)──
sqlx::query(
@@ -34,30 +33,38 @@ impl Database {
.bind(album_id)
.execute(&mut **tx).await?;
if let Some(name) = person_name {
// ── insert or ignore 人物 ──
let pid = sqlx::query_scalar::<_, i64>(
"INSERT INTO persons (name, display_name) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET name = name RETURNING id"
)
.bind(name).bind(name)
.fetch_one(&mut **tx).await?;
// ── 清旧关联 + 写新关联 ──
// ── 先清空旧人物关联,再写新关联 ──
if !result.person_names.is_empty() {
sqlx::query("DELETE FROM person_albums WHERE album_id = ?")
.bind(album_id).execute(&mut **tx).await?;
sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)")
.bind(pid).bind(album_id).execute(&mut **tx).await?;
// ── 更新计数 ──
sqlx::query(
"UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?), \
total_images = COALESCE((SELECT SUM(image_count) FROM albums \
JOIN person_albums ON albums.id = person_albums.album_id \
WHERE person_albums.person_id = ?), 0) \
WHERE id = ?"
)
.bind(pid).bind(pid).bind(pid)
.execute(&mut **tx).await?;
for name in &result.person_names {
// ── insert or ignore 人物 ──
let pid = sqlx::query_scalar::<_, i64>(
"INSERT INTO persons (name, display_name) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET name = name RETURNING id"
)
.bind(name).bind(name)
.fetch_one(&mut **tx).await?;
// ── 写新关联 ──
sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)")
.bind(pid).bind(album_id).execute(&mut **tx).await?;
// ── 更新该人物的计数 ──
sqlx::query(
"UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?), \
total_images = COALESCE((SELECT SUM(image_count) FROM albums \
JOIN person_albums ON albums.id = person_albums.album_id \
WHERE person_albums.person_id = ?), 0) \
WHERE id = ?"
)
.bind(pid).bind(pid).bind(pid)
.execute(&mut **tx).await?;
}
} else {
// 无人名时也清空旧关联(避免残留)
sqlx::query("DELETE FROM person_albums WHERE album_id = ?")
.bind(album_id).execute(&mut **tx).await?;
}
// ── 写标签(tags/all_words 列已迁移到 search.db,只维护 album_tags 表)──

View File

@@ -129,7 +129,7 @@ pub fn deep_match_title(
// 策略1: 用正则提取人名
let extracted = crate::engine::title_parser::extract_person_name_from_title(title_clean);
if let Some(ref e) = extracted {
if let Some(e) = extracted.first() {
if known_names.contains(e) {
result.matched = true;
result.method = "jieba_extract".to_string();

View File

@@ -104,7 +104,7 @@ pub async fn phase_parse_dirty(db: &Database, dirty_set: &DirtySet) -> (usize, u
})
.collect();
let person_count = parsed.iter().filter(|r| r.person_name.is_some()).count();
let person_count = parsed.iter().filter(|r| !r.person_names.is_empty()).count();
let tag_count = parsed.iter().filter(|r| !r.tags.is_empty()).count();
// ── Phase 1b:批量写入 ──
@@ -419,7 +419,7 @@ pub async fn sync_albums_to_search_db(db: &Database, search_db: &SearchDb) -> us
if let Some(title) = album.title.as_deref() {
if !title.is_empty() {
let parse_result = title_parser::parse_album_title(&album.id, title);
if let Some(ref person_name) = parse_result.person_name {
for person_name in &parse_result.person_names {
let person_id = match person_cache.get(person_name) {
Some(&id) => id,
None => {
@@ -507,7 +507,7 @@ pub async fn sync_dirty_albums(db: &Database, search_db: &SearchDb, dirty: &Dirt
if let Some(title) = album.title.as_deref() {
if !title.is_empty() {
let parse_result = title_parser::parse_album_title(&album.id, title);
if let Some(ref person_name) = parse_result.person_name {
for person_name in &parse_result.person_names {
let person_id = match person_cache.get(person_name) {
Some(&id) => id,
None => {

View File

@@ -281,8 +281,8 @@ pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::P
// Step 1: 切分
let segments = max_match_segment(&cleaned);
// Step 2: 从 segments 中选人(优先级最高的)
let mut person_candidates: Vec<(String, u8)> = Vec::new(); // (name, priority)
// Step 2: 收集所有人名候选(按优先级排序,全部保留)
let mut person_candidates: Vec<(String, u8)> = Vec::new();
for seg in &segments {
match seg.category.as_str() {
@@ -301,21 +301,33 @@ pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::P
}
}
// 按优先级排序,取最优
// 按优先级排序
person_candidates.sort_by_key(|(_, p)| *p);
if let Some((name, _)) = person_candidates.first() {
let (primary, _aliases) = split_person_name_with_aliases(name);
// 收集最终人名列表(去重)
let mut person_names: Vec<String> = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
for (name, _) in &person_candidates {
let (primary, _) = split_person_name_with_aliases(name);
let final_name = if primary.is_empty() { name.clone() } else { primary };
if final_name.chars().count() >= 2 {
result.person_name = Some(final_name);
if final_name.chars().count() >= 2 && seen_names.insert(final_name.to_lowercase()) {
person_names.push(final_name);
}
}
result.person_names = person_names;
// Step 3: 收集标签(跳过所有 person 片段)
// 构建所有已识别 person 片段的小写集合(用于过滤 other 类中与 person 重叠的片段)
let person_segment_lower: HashSet<String> = segments.iter()
.filter(|s| s.category == "person" || s.category == "person_include")
.map(|s| s.text.to_lowercase())
.collect();
let person_name_lower: HashSet<String> = result.person_names.iter()
.map(|n| n.to_lowercase())
.collect();
// Step 3: 收集标签(非 person / stop / other 类的片段)
let mut tags: Vec<(String, String)> = Vec::new();
let person_lower = result.person_name.as_ref().map(|n| n.to_lowercase());
for seg in &segments {
let cat = &seg.category;
@@ -323,16 +335,19 @@ pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::P
// 跳过停用词
if cat == "stop" { continue; }
// 跳过 person 相关
// 跳过 person 相关片段
if cat == "person" || cat == "person_include" { continue; }
// 如果是 other 类,检查是否被选为人名
// 如果是 other 类,检查是否与任何人名重叠
if cat == "other" {
if let Some(ref pl) = person_lower {
if seg.text.to_lowercase().contains(pl) || pl.contains(&seg.text.to_lowercase()) {
continue;
}
}
let seg_lower = seg.text.to_lowercase();
let overlaps_person = person_segment_lower.iter().any(|pl| {
seg_lower.contains(pl) || pl.contains(&seg_lower)
}) || person_name_lower.iter().any(|pl| {
seg_lower.contains(pl) || pl.contains(&seg_lower)
});
if overlaps_person { continue; }
// other 类也做个简单过滤:太短、纯数字等跳过
if seg.text.chars().count() < 2 { continue; }
if seg.text.chars().all(|c| c.is_ascii_digit()) { continue; }
@@ -484,8 +499,13 @@ pub fn parse_album_title_with_dict(
// Phase 3: 合并结果 — dict 优先,parser 补充
let mut result = crate::models::parse::ParseResult::new(album_id.to_string());
// person: dict 命中 > parser 命中
result.person_name = dict_person.or(parse_result.person_name);
// persons: dict 命中 + parser 结果合并去重
let mut seen_persons = HashSet::new();
for name in dict_person.iter().chain(parse_result.person_names.iter()) {
if seen_persons.insert(name.to_lowercase()) {
result.person_names.push(name.clone());
}
}
// tags: dict 标签优先,parser 标签补充去重
let mut seen = HashSet::new();
@@ -502,18 +522,19 @@ pub fn parse_album_title_with_dict(
result
}
/// dict 条目按词长降序匹配标题,返回 (person, tags, 剩余文本)
/// dict 条目按词长降序匹配标题,返回 (persons, tags, 剩余文本)
fn extract_with_dict(
title: &str,
dict_entries: &[crate::search_db::DictEntry],
) -> (Option<String>, Vec<(String, String)>, String) {
) -> (Vec<String>, Vec<(String, String)>, String) {
// 按词长降序排列
let mut sorted: Vec<&crate::search_db::DictEntry> = dict_entries.iter().collect();
sorted.sort_by(|a, b| b.word.len().cmp(&a.word.len()));
let title_lower = title.to_lowercase();
let mut matched_ranges: Vec<(usize, usize)> = Vec::new();
let mut person: Option<String> = None;
let mut persons: Vec<String> = Vec::new();
let mut seen_persons: HashSet<String> = HashSet::new();
let mut tags: Vec<(String, String)> = Vec::new();
let mut seen_tag_words = HashSet::new();
@@ -534,8 +555,11 @@ fn extract_with_dict(
matched_ranges.push((abs_start, abs_end));
match entry.type_.as_str() {
"person" if person.is_none() => {
person = Some(entry.merged_to.as_ref().unwrap_or(&entry.word).clone());
"person" => {
let resolved = entry.merged_to.as_ref().unwrap_or(&entry.word).clone();
if seen_persons.insert(resolved.to_lowercase()) {
persons.push(resolved);
}
}
"tag" => {
if seen_tag_words.insert(entry.word.clone()) {
@@ -550,7 +574,7 @@ fn extract_with_dict(
// 构建剩余文本:去掉已匹配的部分
if matched_ranges.is_empty() {
return (person, tags, title.to_string());
return (persons, tags, title.to_string());
}
matched_ranges.sort_by_key(|&(s, _)| s);
@@ -570,20 +594,20 @@ fn extract_with_dict(
// 合并连续空格
let remaining = remaining.split_whitespace().collect::<Vec<_>>().join(" ");
(person, tags, remaining)
(persons, tags, remaining)
}
// ── 兼容函数(供 pipeline / person_merger 调用) ──
/// 从标题中提取人物名(兼容函数)
pub fn extract_person_name_from_title(title: &str) -> Option<String> {
parse_album_title("", title).person_name
/// 从标题中提取人物名列表(兼容函数)
pub fn extract_person_name_from_title(title: &str) -> Vec<String> {
parse_album_title("", title).person_names
}
/// 从标题中提取人物名和标签(兼容函数)
pub fn extract_person_and_tags(title: &str) -> (Option<String>, Vec<(String, String)>) {
pub fn extract_person_and_tags(title: &str) -> (Vec<String>, Vec<(String, String)>) {
let result = parse_album_title("", title);
(result.person_name, result.tags)
(result.person_names, result.tags)
}
#[cfg(test)]
@@ -593,7 +617,7 @@ mod tests {
#[test]
fn test_parse_album_title_with_person() {
let r = parse_album_title("id1", "杨晨晨Yome 私房写真");
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
assert_eq!(r.person_names.first().map(|s| s.as_str()), Some("杨晨晨"));
assert!(!r.tags.is_empty());
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(tag_names.contains(&"私房"), "应包含 '私房' 标签");
@@ -603,7 +627,7 @@ mod tests {
#[test]
fn test_parse_album_title_no_person() {
let r = parse_album_title("id2", "浴室 可爱 性感 写真");
assert!(r.person_name.is_none());
assert!(r.person_names.is_empty());
assert!(!r.tags.is_empty());
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(tag_names.contains(&"浴室"));
@@ -614,14 +638,14 @@ mod tests {
#[test]
fn test_parse_album_title_with_prefix() {
let r = parse_album_title("id3", "美女模特 杨晨晨 私房");
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
assert_eq!(r.person_names.first().map(|s| s.as_str()), Some("杨晨晨"));
}
#[test]
fn test_parse_continuous_chinese() {
// 连续中文:杨晨晨私房写真 — 应能切分出人名和标签
let r = parse_album_title("id4", "杨晨晨私房写真");
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
assert_eq!(r.person_names.first().map(|s| s.as_str()), Some("杨晨晨"));
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
assert!(tag_names.contains(&"私房"), "应包含 '私房'");
assert!(tag_names.contains(&"写真"), "应包含 '写真'");

View File

@@ -204,9 +204,11 @@ async fn main() -> std::io::Result<()> {
println!(" - GET /api/people/search?q=关键词 - 搜索人员");
println!(" - GET /api/people/{{name}} - 人员详情");
println!(" - GET /api/people/{{id}}/aliases - 人员别名");
println!(" - POST /api/people/delete/{{name}} - 删除人员并重解析受影响相册");
println!(" - GET /api/metadata/stats - 元数据统计");
println!(" - GET /api/tags/search?tag=标签 - 按标签搜索");
println!(" - GET /api/tags/popular?limit=50 - 热门标签");
println!(" - POST /api/tags/delete/{{name}} - 删除标签并重解析受影响相册");
println!("推荐接口:");
println!(" - GET /api/recommend/tags?q=xxx - 标签推荐");
println!(" - GET /api/recommend/persons?q=xxx - 人物推荐");
@@ -262,10 +264,12 @@ async fn main() -> std::io::Result<()> {
.route("/api/people/search", web::get().to(routes::search_people))
.route("/api/people/{name}", web::get().to(routes::get_person_detail))
.route("/api/people/{id}/aliases", web::get().to(routes::get_person_aliases))
.route("/api/people/delete/{name}", web::post().to(routes::delete_person))
// 标签 API
.route("/api/metadata/stats", web::get().to(routes::get_metadata_stats))
.route("/api/tags/search", web::get().to(routes::search_by_tag))
.route("/api/tags/popular", web::get().to(routes::get_popular_tags))
.route("/api/tags/delete/{name}", web::post().to(routes::delete_tag))
// 推荐 API
.route("/api/recommend/tags", web::get().to(routes::recommend_tags))
.route("/api/recommend/persons", web::get().to(routes::recommend_persons))

View File

@@ -5,23 +5,21 @@ use serde::{Serialize, Deserialize};
/// 一次 title 解析的完整结果
///
/// 在一个相册上通过一次 jieba 分词,同时提取:
/// - 人名 + 别名(含 include 扩展、prefix 移除)
/// 在一个相册上通过一次分词,同时提取:
/// - 人名列表(支持多个)
/// - 标签列表(含分类)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParseResult {
/// 相册 UUID
pub album_id: String,
/// 最终人名 | None = 未找到
pub person_name: Option<String>,
/// 中文主名(split_person_name_with_aliases 后的 primary)
pub person_primary: Option<String>,
/// 最终识别的人名列表 | 空 = 未找到
pub person_names: Vec<String>,
/// 标签列表 (tag, category)
pub tags: Vec<(String, String)>,
}
impl ParseResult {
pub fn new(album_id: String) -> Self {
Self { album_id, person_name: None, person_primary: None, tags: Vec::new() }
Self { album_id, person_names: Vec::new(), tags: Vec::new() }
}
}

View File

@@ -46,6 +46,7 @@ pub async fn dict_reload(search_db: web::Data<Arc<SearchDb>>) -> impl Responder
#[derive(serde::Deserialize)]
pub struct DictEntryInput {
pub word: String,
#[serde(rename = "type")]
pub type_: String,
pub category: Option<String>,
pub freq: Option<i64>,

View File

@@ -1,5 +1,6 @@
use crate::cache::PersonCache;
use crate::cache::{PersonCache, AlbumsCache, TagCache};
use crate::db::Database;
use crate::engine;
use crate::search_db::SearchDb;
use crate::models::*;
use actix_web::{HttpResponse, Responder, web};
@@ -109,3 +110,80 @@ pub async fn get_person_aliases(search_db: web::Data<Arc<SearchDb>>, path: web::
let aliases = search_db.get_person_aliases(path.into_inner()).await;
HttpResponse::Ok().json(aliases)
}
/// POST /api/people/delete/{name} — 删除 person(从 imager.db + search.db)并重解析受影响的相册
pub async fn delete_person(
db: web::Data<Arc<Database>>,
search_db: web::Data<Arc<SearchDb>>,
albums_cache: web::Data<Arc<RwLock<AlbumsCache>>>,
persons_cache: web::Data<Arc<RwLock<PersonCache>>>,
tags_cache: web::Data<Arc<RwLock<TagCache>>>,
path: web::Path<String>,
) -> impl Responder {
let person_name = path.into_inner().trim().to_string();
if person_name.is_empty() {
return HttpResponse::BadRequest().json(serde_json::json!({
"success": false, "message": "人名不能为空"
}));
}
// 1. 从 search.db 删除并获取受影响 album_ids
let album_ids = match search_db.delete_person_and_get_albums(&person_name).await {
Ok(ids) => ids,
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"message": format!("从 search.db 删除人物 '{}' 失败: {}", person_name, e),
})),
};
// 2. 从 imager.db 删除(persons + person_aliases + person_albums)
if let Some(person) = db.get_person_by_name(&person_name).await {
db.delete_person_completely(person.id).await;
}
// 3. 重置受影响相册的 processed_at(触发重新解析)
for aid in &album_ids {
sqlx::query("UPDATE albums SET processed_at = NULL WHERE id = ?")
.bind(aid)
.execute(&db.pool)
.await
.ok();
}
// 4. 重新解析受影响相册,同步到 search.db
if !album_ids.is_empty() {
let mut dirty = engine::pipeline::DirtySet::new();
for aid in &album_ids { dirty.add(aid); }
let (persons_updated, tags_updated) = engine::pipeline::phase_parse_dirty(&db, &dirty).await;
let synced = engine::pipeline::sync_dirty_albums(&db, &search_db, &dirty).await;
// 5. 重载内存词典
engine::tag_dict::reload_from_search_db(&search_db).await;
// 6. 刷新缓存
crate::cache::refresh_all_caches(&db, &search_db, &albums_cache, &persons_cache, &tags_cache).await;
HttpResponse::Ok().json(serde_json::json!({
"success": true,
"person": person_name,
"albums_affected": album_ids.len(),
"persons_updated": persons_updated,
"tags_updated": tags_updated,
"synced": synced,
"message": format!(
"人物 '{}' 已删除,影响 {} 个相册,重新解析完成(更新人物 {},更新标签 {},同步 {} 个)",
person_name, album_ids.len(), persons_updated, tags_updated, synced,
)
}))
} else {
// 无受影响相册,只需刷新缓存
crate::cache::refresh_all_caches(&db, &search_db, &albums_cache, &persons_cache, &tags_cache).await;
HttpResponse::Ok().json(serde_json::json!({
"success": true,
"person": person_name,
"albums_affected": 0,
"message": format!("人物 '{}' 已删除,无关联相册", person_name),
}))
}
}

View File

@@ -1,5 +1,7 @@
use crate::cache::{TagCache, AlbumsCache, PersonCache};
use crate::engine;
use crate::models::*;
use crate::search_db::SearchDb;
use actix_web::{HttpResponse, Responder, web};
use std::collections::HashMap;
use std::sync::Arc;
@@ -49,3 +51,65 @@ pub async fn get_popular_tags(
let tags: Vec<TagStat> = c.popular_tags.iter().take(limit).cloned().collect();
HttpResponse::Ok().json(TagListResponse { total: tags.len(), tags })
}
/// POST /api/tags/delete/{name} — 删除 tag(从 dict/album_tags/tags)并重解析受影响的相册
pub async fn delete_tag(
db: web::Data<Arc<crate::db::Database>>,
search_db: web::Data<Arc<SearchDb>>,
albums_cache: web::Data<Arc<RwLock<AlbumsCache>>>,
persons_cache: web::Data<Arc<RwLock<PersonCache>>>,
tags_cache: web::Data<Arc<RwLock<TagCache>>>,
path: web::Path<String>,
) -> impl Responder {
let tag_name = path.into_inner().trim().to_string();
if tag_name.is_empty() {
return HttpResponse::BadRequest().json(serde_json::json!({
"success": false, "message": "标签名不能为空"
}));
}
// 1. 删除 tag(dict/album_tags/tags),获取受影响 album_ids
let album_ids = match search_db.delete_tag_and_get_albums(&tag_name).await {
Ok(ids) => ids,
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
"success": false,
"message": format!("删除标签 '{}' 失败: {}", tag_name, e),
})),
};
// 2. 重置受影响相册的 processed_at,触发重新解析
for aid in &album_ids {
sqlx::query("UPDATE albums SET processed_at = NULL WHERE id = ?")
.bind(aid)
.execute(&db.pool)
.await
.ok();
}
// 3. 重新解析受影响相册
let mut dirty = engine::pipeline::DirtySet::new();
for aid in &album_ids { dirty.add(aid); }
let (persons_updated, tags_updated) = engine::pipeline::phase_parse_dirty(&db, &dirty).await;
// 4. 同步到 search.db
let synced = engine::pipeline::sync_dirty_albums(&db, &search_db, &dirty).await;
// 5. 重载内存词典(dict 表已更新)
engine::tag_dict::reload_from_search_db(&search_db).await;
// 6. 刷新内存缓存
crate::cache::refresh_all_caches(&db, &search_db, &albums_cache, &persons_cache, &tags_cache).await;
HttpResponse::Ok().json(serde_json::json!({
"success": true,
"tag": tag_name,
"albums_affected": album_ids.len(),
"persons_updated": persons_updated,
"tags_updated": tags_updated,
"synced": synced,
"message": format!(
"标签 '{}' 已删除,影响 {} 个相册,重新解析完成(更新人物 {},更新标签 {},同步 {} 个)",
tag_name, album_ids.len(), persons_updated, tags_updated, synced,
)
}))
}

View File

@@ -315,6 +315,87 @@ impl SearchDb {
Ok(())
}
/// 删除 tag:从 dict/album_tags/tags 表中删除,返回受影响的 album_id 列表
pub async fn delete_tag_and_get_albums(&self, tag_name: &str) -> Result<Vec<String>, sqlx::Error> {
// 1. 查找 tag_id(tags 表中可能存在多个同名 tag,但 name 应唯一)
let tag_ids: Vec<i64> = sqlx::query_scalar("SELECT id FROM tags WHERE name = ?")
.bind(tag_name).fetch_all(&self.pool).await.unwrap_or_default();
// 2. 查找关联 album_ids
let album_ids: Vec<String> = if tag_ids.is_empty() {
Vec::new()
} else {
// 用参数占位构建 IN 查询
let placeholders: Vec<String> = tag_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!("SELECT DISTINCT album_id FROM album_tags WHERE tag_id IN ({})", placeholders.join(","));
let mut q = sqlx::query(&sql);
for id in &tag_ids { q = q.bind(*id); }
q.fetch_all(&self.pool).await?
.iter().map(|r| r.get::<String, _>("album_id")).collect()
};
// 3. 删除 album_tags 关联
if !tag_ids.is_empty() {
let placeholders: Vec<String> = tag_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!("DELETE FROM album_tags WHERE tag_id IN ({})", placeholders.join(","));
let mut q = sqlx::query(&sql);
for id in &tag_ids { q = q.bind(*id); }
q.execute(&self.pool).await?;
}
// 4. 删除 tags 表条目
sqlx::query("DELETE FROM tags WHERE name = ?").bind(tag_name).execute(&self.pool).await?;
// 5. 删除 dict 表条目
sqlx::query("DELETE FROM dict WHERE word = ? AND type = 'tag'").bind(tag_name).execute(&self.pool).await?;
Ok(album_ids)
}
/// 删除 person:从 search.db 的 album_persons/persons/person_aliases 中删除,返回受影响的 album_id 列表
pub async fn delete_person_and_get_albums(&self, person_name: &str) -> Result<Vec<String>, sqlx::Error> {
// 1. 查找 person_id
let person_ids: Vec<i64> = sqlx::query_scalar("SELECT id FROM persons WHERE name = ?")
.bind(person_name).fetch_all(&self.pool).await.unwrap_or_default();
// 2. 查找关联 album_ids
let album_ids: Vec<String> = if person_ids.is_empty() {
Vec::new()
} else {
let placeholders: Vec<String> = person_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!("SELECT DISTINCT album_id FROM album_persons WHERE person_id IN ({})", placeholders.join(","));
let mut q = sqlx::query(&sql);
for id in &person_ids { q = q.bind(*id); }
q.fetch_all(&self.pool).await?
.iter().map(|r| r.get::<String, _>("album_id")).collect()
};
// 3. 删除关联表
if !person_ids.is_empty() {
let placeholders: Vec<String> = person_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!("DELETE FROM album_persons WHERE person_id IN ({})", placeholders.join(","));
let mut q = sqlx::query(&sql);
for id in &person_ids { q = q.bind(*id); }
q.execute(&self.pool).await?;
let sql2 = format!("DELETE FROM person_aliases WHERE person_id IN ({})", placeholders.join(","));
let mut q2 = sqlx::query(&sql2);
for id in &person_ids { q2 = q2.bind(*id); }
q2.execute(&self.pool).await?;
let sql3 = format!("DELETE FROM persons WHERE id IN ({})", placeholders.join(","));
let mut q3 = sqlx::query(&sql3);
for id in &person_ids { q3 = q3.bind(*id); }
q3.execute(&self.pool).await?;
}
// 4. 删除 dict 表中的对应条目(如果存在)
sqlx::query("DELETE FROM dict WHERE word = ? AND type = 'person'")
.bind(person_name).execute(&self.pool).await?;
Ok(album_ids)
}
pub async fn clear_all(&self) -> Result<(), sqlx::Error> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM album_tags").execute(&mut *tx).await?;

View File

@@ -76,15 +76,15 @@ pub async fn export_to_search_db(
let parse_result = title_parser::parse_album_title_with_dict(&album_id, title, &dict_entries);
// 统计 dict 命中
if let Some(ref person_name) = parse_result.person_name {
let from_dict = dict_entries.iter().any(|e| {
let dict_matched_person = parse_result.person_names.iter().any(|pn| {
dict_entries.iter().any(|e| {
e.type_ == "person"
&& (e.word.to_lowercase() == person_name.to_lowercase()
|| e.merged_to.as_ref().map_or(false, |m| m.to_lowercase() == person_name.to_lowercase()))
});
if from_dict {
result.dict_matched_persons += 1;
}
&& (e.word.to_lowercase() == pn.to_lowercase()
|| e.merged_to.as_ref().map_or(false, |m| m.to_lowercase() == pn.to_lowercase()))
})
});
if dict_matched_person {
result.dict_matched_persons += 1;
}
// ── 2. 写入 search.db ──
@@ -117,7 +117,7 @@ pub async fn export_to_search_db(
result.album_tags_created += 1;
}
if let Some(person_name) = &parse_result.person_name {
for person_name in &parse_result.person_names {
// 检查是否有 merged_to 别名解析
let resolved_from = dict_entries.iter().find(|e| {
e.type_ == "person"
@@ -146,10 +146,21 @@ pub async fn export_to_search_db(
.map_err(|e| format!("关联相册人物 {} → {} 失败: {}", album_id, person_name, e))?;
result.album_persons_created += 1;
if let Some(primary) = &parse_result.person_primary {
if primary != person_name {
// 同步别名
let (primary, aliases) = title_parser::split_person_name_with_aliases(person_name);
if !aliases.is_empty() {
for (alias_type, alias_name) in &aliases {
if alias_name != person_name {
search_db
.insert_person_alias(person_id, alias_type, alias_name)
.await
.map_err(|e| format!("插入别名 {} → {} 失败: {}", person_name, alias_name, e))?;
result.aliases_created += 1;
}
}
if primary != person_name.as_str() && !primary.is_empty() {
search_db
.insert_person_alias(person_id, "primary", primary)
.insert_person_alias(person_id, "primary", &primary)
.await
.map_err(|e| format!("插入别名 {} → {} 失败: {}", person_name, primary, e))?;
result.aliases_created += 1;