update
This commit is contained in:
@@ -26,6 +26,98 @@ impl Database {
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// 组合过滤搜索(懒加载):仅返回符合条件的相册,避免全量加载
|
||||
///
|
||||
/// 支持按 tag / person / 标题关键词 任意组合过滤(AND 关系)。
|
||||
/// 参数传空字符串或 None 表示不过滤该维度。
|
||||
/// 全参数为空时等价于 get_all_albums。
|
||||
pub async fn search_albums_with_filters(
|
||||
&self,
|
||||
tag: Option<&str>,
|
||||
person: Option<&str>,
|
||||
title_q: Option<&str>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
let has_tag = tag.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let has_person = person.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let has_q = title_q.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
if !has_tag && !has_person && !has_q {
|
||||
// 无过滤条件 → 走缓存路径,这里回退到全量查询
|
||||
return self.get_all_albums().await;
|
||||
}
|
||||
|
||||
// 动态构建 SQL
|
||||
let sql = {
|
||||
let mut s = String::from(
|
||||
"SELECT DISTINCT a.id, a.title, a.document, a.description, \
|
||||
a.cover_image_id, a.image_count, a.created_at \
|
||||
FROM albums a"
|
||||
);
|
||||
let mut joins = Vec::new();
|
||||
let mut wheres = Vec::new();
|
||||
|
||||
if has_tag {
|
||||
joins.push("JOIN album_tags t ON a.id = t.album_id");
|
||||
wheres.push("t.tag = ?");
|
||||
}
|
||||
if has_person {
|
||||
joins.push("JOIN person_albums pa ON a.id = pa.album_id");
|
||||
joins.push("JOIN persons p ON pa.person_id = p.id");
|
||||
wheres.push("(p.name = ? OR p.name LIKE ?)");
|
||||
}
|
||||
if has_q {
|
||||
wheres.push("a.title LIKE ?");
|
||||
}
|
||||
|
||||
for j in &joins {
|
||||
s.push(' ');
|
||||
s.push_str(j);
|
||||
}
|
||||
if !wheres.is_empty() {
|
||||
s.push_str(" WHERE ");
|
||||
s.push_str(&wheres.join(" AND "));
|
||||
}
|
||||
s.push_str(" ORDER BY a.created_at DESC");
|
||||
|
||||
if limit > 0 {
|
||||
s.push_str(" LIMIT ? OFFSET ?");
|
||||
}
|
||||
|
||||
s
|
||||
};
|
||||
|
||||
let mut q = sqlx::query(&sql);
|
||||
|
||||
if has_tag {
|
||||
q = q.bind(tag.unwrap());
|
||||
}
|
||||
if has_person {
|
||||
let p = person.unwrap();
|
||||
q = q.bind(p).bind(format!("%{}%", p));
|
||||
}
|
||||
if has_q {
|
||||
q = q.bind(format!("%{}%", title_q.unwrap()));
|
||||
}
|
||||
if limit > 0 {
|
||||
q = q.bind(limit).bind(offset);
|
||||
}
|
||||
|
||||
let rows = q.fetch_all(&self.pool).await?;
|
||||
Ok(rows.iter().map(|r| {
|
||||
AlbumInfo {
|
||||
id: Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil()),
|
||||
title: r.get("title"),
|
||||
document: r.get("document"),
|
||||
description: r.get("description"),
|
||||
cover_image_id: r.get("cover_image_id"),
|
||||
image_count: r.get("image_count"),
|
||||
created_at: r.get("created_at"),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn get_album_with_images(&self, album_id: Uuid) -> Result<Option<AlbumWithImages>, sqlx::Error> {
|
||||
let row = sqlx::query("SELECT * FROM albums WHERE id = ?")
|
||||
.bind(album_id.to_string())
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct PersonRulesDef {
|
||||
pub prefixes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub excluded: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub include: Vec<String>,
|
||||
}
|
||||
|
||||
// ── 运行时词典结构 ──
|
||||
@@ -79,6 +81,8 @@ pub struct TagDict {
|
||||
pub person_prefixes: Vec<String>,
|
||||
/// 排除的人物名(工作室/平台名,匹配上的人和包含这些词的人直接删除重解析)
|
||||
pub excluded_person_names: HashSet<String>,
|
||||
/// 强制包含的人物名(已知模特/演员名,只要出现就优先识别为人名)
|
||||
pub included_person_names: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Default for TagDict {
|
||||
@@ -89,6 +93,7 @@ impl Default for TagDict {
|
||||
stop_words: HashSet::new(),
|
||||
person_prefixes: Vec::new(),
|
||||
excluded_person_names: HashSet::new(),
|
||||
included_person_names: HashSet::new(),
|
||||
};
|
||||
dict.fill_builtin();
|
||||
dict
|
||||
@@ -124,9 +129,13 @@ impl TagDict {
|
||||
}
|
||||
};
|
||||
|
||||
let (person_prefixes, excluded_person_names) = match file.person_rules {
|
||||
Some(rules) => (rules.prefixes, rules.excluded.into_iter().collect()),
|
||||
None => (Vec::new(), HashSet::new()),
|
||||
let (person_prefixes, excluded_person_names, included_person_names) = match file.person_rules {
|
||||
Some(rules) => (
|
||||
rules.prefixes,
|
||||
rules.excluded.into_iter().collect(),
|
||||
rules.include.into_iter().collect(),
|
||||
),
|
||||
None => (Vec::new(), HashSet::new(), HashSet::new()),
|
||||
};
|
||||
|
||||
let mut dict = TagDict {
|
||||
@@ -135,6 +144,7 @@ impl TagDict {
|
||||
stop_words: file.stop_words.into_iter().collect(),
|
||||
person_prefixes,
|
||||
excluded_person_names,
|
||||
included_person_names,
|
||||
};
|
||||
|
||||
for (cat_name, def) in file.tags {
|
||||
@@ -258,6 +268,18 @@ impl TagDict {
|
||||
&self.excluded_person_names
|
||||
}
|
||||
|
||||
/// 判断人名是否在强制包含列表中(应优先识别为人名)
|
||||
pub fn is_included_person(&self, name: &str) -> bool {
|
||||
let name_lower = name.to_lowercase();
|
||||
self.included_person_names.contains(name)
|
||||
|| self.included_person_names.contains(&name_lower)
|
||||
}
|
||||
|
||||
/// 获取强制包含人名集合
|
||||
pub fn get_included_persons(&self) -> &HashSet<String> {
|
||||
&self.included_person_names
|
||||
}
|
||||
|
||||
/// 内置回退
|
||||
fn fill_builtin(&mut self) {
|
||||
let builtin = vec![
|
||||
@@ -328,9 +350,15 @@ impl TagDict {
|
||||
"LeiLei", "Tristana", "Av",
|
||||
];
|
||||
|
||||
let included: Vec<&str> = vec![
|
||||
"杨晨晨", "水淼", "轩美宣", "王馨瑶",
|
||||
"雪儿", "曼曼", "佳佳", "小乔",
|
||||
];
|
||||
|
||||
for w in &stop_words { self.stop_words.insert(w.to_string()); }
|
||||
self.person_prefixes = person_prefixes.iter().map(|s| s.to_string()).collect();
|
||||
for n in &excluded { self.excluded_person_names.insert(n.to_string()); }
|
||||
for n in &included { self.included_person_names.insert(n.to_string()); }
|
||||
|
||||
for (name, label, bg, fg, words) in builtin {
|
||||
let ws: Vec<String> = words.iter().map(|w| w.to_string()).collect();
|
||||
@@ -359,9 +387,10 @@ pub fn reload_tag_dict() -> String {
|
||||
let mut dict = TAG_DICT.write().expect("tag_dict 锁被污染");
|
||||
dict.reload();
|
||||
format!(
|
||||
"词典已重载: {} 分类, {} 标签, {} 停用词, {} 前缀, {} 排除人名",
|
||||
"词典已重载: {} 分类, {} 标签, {} 停用词, {} 前缀, {} 排除人名, {} 包含人名",
|
||||
dict.categories.len(), dict.tag_to_category.len(),
|
||||
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len(),
|
||||
dict.included_person_names.len(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -369,8 +398,9 @@ pub fn reload_tag_dict() -> String {
|
||||
pub fn get_tag_dict_stats() -> String {
|
||||
let dict = get_tag_dict();
|
||||
format!(
|
||||
"标签词典: {} 分类, {} 标签, {} 停用词, {} 前缀, {} 排除人名 | 文件: dict/tags.json",
|
||||
"标签词典: {} 分类, {} 标签, {} 停用词, {} 前缀, {} 排除人名, {} 包含人名 | 文件: dict/tags.json",
|
||||
dict.categories.len(), dict.tag_to_category.len(),
|
||||
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len(),
|
||||
dict.included_person_names.len(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -289,6 +289,11 @@ pub fn is_excluded_name(name: &str) -> bool {
|
||||
pub fn is_likely_person_name(name: &str) -> bool {
|
||||
let name = name.trim();
|
||||
if name.is_empty() { return false; }
|
||||
|
||||
// 强制包含名单优先放行(已知模特名,跳过所有启发式检查)
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
if dict.is_included_person(name) { return true; }
|
||||
|
||||
// 字符数必须 >= 3(人名至少需要 2 个中文字或 3 个英文字母)
|
||||
if name.chars().count() <= 2 { return false; }
|
||||
|
||||
|
||||
@@ -7,38 +7,68 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// GET /api/albums — 从缓存返回全量相册(无过滤时)
|
||||
pub async fn get_albums(cache: web::Data<Arc<RwLock<AlbumsCache>>>) -> impl Responder {
|
||||
let c = cache.read().await;
|
||||
HttpResponse::Ok().json(&c.albums)
|
||||
}
|
||||
|
||||
/// GET /api/search — 组合搜索(支持 tag/person/q 过滤)
|
||||
///
|
||||
/// 参数:
|
||||
/// - q: 关键词(标题模糊匹配)
|
||||
/// - tag: 标签名(精确匹配)
|
||||
/// - person: 人物名(精确或模糊匹配)
|
||||
/// - page: 页码(默认 1)
|
||||
/// - limit: 每页条数(默认 50,最大 200)
|
||||
///
|
||||
/// 规则:有任意过滤条件时 → 走 DB 懒加载(只查符合条件的相册)
|
||||
/// 无过滤条件时 → 走缓存全量返回
|
||||
pub async fn search_albums(
|
||||
db: web::Data<Arc<Database>>,
|
||||
cache: web::Data<Arc<RwLock<AlbumsCache>>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
|
||||
if q.len() > 100 { return HttpResponse::BadRequest().json(serde_json::json!({"error": "搜索查询过长"})); }
|
||||
if q.chars().any(|c| c.is_control() || c == '\0') { return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效字符"})); }
|
||||
let tag = query.get("tag").map(|s| s.as_str()).unwrap_or("");
|
||||
let person = query.get("person").map(|s| s.as_str()).unwrap_or("");
|
||||
|
||||
let page: usize = query.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
|
||||
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(200);
|
||||
let offset = (page - 1) * limit;
|
||||
// 校验
|
||||
let err = |msg: &str| HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
if q.len() > 100 { return err("搜索查询过长"); }
|
||||
if q.chars().any(|c| c.is_control() || c == '\0') { return err("无效字符"); }
|
||||
|
||||
let has_filter = !q.is_empty() || !tag.is_empty() || !person.is_empty();
|
||||
|
||||
if has_filter {
|
||||
// ── 有过滤条件:走 DB 懒加载 ──
|
||||
let page: i64 = query.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
|
||||
let limit: i64 = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(200);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
let tag_arg = if tag.is_empty() { None } else { Some(tag) };
|
||||
let person_arg = if person.is_empty() { None } else { Some(person) };
|
||||
let q_arg = if q.is_empty() { None } else { Some(q) };
|
||||
|
||||
match db.search_albums_with_filters(tag_arg, person_arg, q_arg, limit, offset).await {
|
||||
Ok(results) => HttpResponse::Ok().json(results),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
} else {
|
||||
// ── 无过滤条件:走缓存(现有行为) ──
|
||||
let c = cache.read().await;
|
||||
|
||||
let page: usize = query.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
|
||||
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(200);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
let c = cache.read().await;
|
||||
let results = if q.is_empty() {
|
||||
let total = c.albums.len();
|
||||
let start = offset.min(total);
|
||||
let end = (offset + limit).min(total);
|
||||
c.albums[start..end].to_vec()
|
||||
} else {
|
||||
let mut results = c.search(q);
|
||||
let total = results.len();
|
||||
let start = offset.min(total);
|
||||
let end = (offset + limit).min(total);
|
||||
if start < end { results.drain(start..end).collect() } else { Vec::new() }
|
||||
};
|
||||
let results = c.albums[start..end].to_vec();
|
||||
|
||||
HttpResponse::Ok().json(results)
|
||||
HttpResponse::Ok().json(results)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_random_album(cache: web::Data<Arc<RwLock<AlbumsCache>>>) -> impl Responder {
|
||||
@@ -62,4 +92,4 @@ pub async fn create_album(db: web::Data<Arc<Database>>, body: web::Json<CreateAl
|
||||
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub async fn dict_stats() -> impl Responder {
|
||||
"total_stop_words": dict.get_stop_words().len(),
|
||||
"person_prefixes": dict.get_person_prefixes(),
|
||||
"excluded_person_names": dict.excluded_person_names.iter().collect::<Vec<_>>(),
|
||||
"included_person_names": dict.included_person_names.iter().collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user