update code level
This commit is contained in:
56
docs/api.md
56
docs/api.md
@@ -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`
|
||||
|
||||
**响应**:
|
||||
|
||||
|
||||
@@ -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 表)──
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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(&"写真"), "应包含 '写真'");
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user