diff --git a/dict/tags.json b/dict/tags.json index c3f6063..e63b4c9 100644 --- a/dict/tags.json +++ b/dict/tags.json @@ -1,6 +1,6 @@ { - "version": 2, - "description": "标签词典 v2 — 9 大分类 + 人物规则 + 停用词", + "version": 3, + "description": "标签词典 v3 — 合并扩充 + 高频人名 include", "tags": { "style": { "label": "风格", @@ -42,7 +42,10 @@ "sweet", "wild", "pure", - "fashion" + "fashion", + "微博运动达人", + "运动内衣", + "运动服饰" ] }, "outfit": { @@ -50,7 +53,10 @@ "color_bg": "#fdf2f8", "color_fg": "#db2777", "words": [ + "黑色礼服", + "女秘书", "比基尼", + "兔女郎", "泳装", "泳衣", "旗袍", @@ -102,7 +108,33 @@ "shirt", "sweater", "coat", - "heels" + "heels", + "OL", + "吊带短裙", + "吊带长裙", + "和服", + "女仆装", + "学生装", + "护士服", + "抹胸服饰", + "抹胸礼裙", + "水手服", + "白色吊带上衣", + "白衬衫职场", + "空姐制服", + "蕾丝内衣", + "蕾丝吊袜", + "蕾丝吊裙", + "蕾丝睡衣", + "镂空内衣", + "镂空服饰", + "露肩上衣", + "露肩服饰", + "露肩短裙", + "露肩礼裙", + "露肩连衣短裙", + "露肩连衣裙", + "露肩长裙" ] }, "scene": { @@ -110,6 +142,7 @@ "color_bg": "#f0fdf4", "color_fg": "#15803d", "words": [ + "办公室", "室内", "户外", "街拍", @@ -153,7 +186,9 @@ "balcony", "car", "cafe", - "hotel" + "hotel", + "室外泳池", + "浴室场景写真" ] }, "body": { @@ -183,7 +218,17 @@ "shoulders", "waist", "skin", - "tattoo" + "tattoo", + "丝袜美腿写真", + "丝袜美腿职场", + "丝足美腿", + "修长美腿", + "小蛮腰", + "美腿宝贝", + "美腿系列写真", + "黑丝美腿", + "黑丝美腿写真", + "黑丝美腿职场" ] }, "mood": { @@ -218,7 +263,9 @@ "gentle", "wild", "lazy", - "sweet" + "sweet", + "模特程开心", + "魅惑黑丝" ] }, "topic": { @@ -226,6 +273,8 @@ "color_bg": "#f0f9ff", "color_fg": "#0369a1", "words": [ + "Genshin", + "姐妹花", "写真", "私房", "街拍", @@ -241,7 +290,21 @@ "portrait", "selfie", "travel", - "wedding" + "wedding", + "制服", + "制服性感写真", + "制服系列写真", + "制服诱惑写真", + "女警制服", + "女警官制服", + "学生制服", + "情趣制服", + "护士制服", + "海军制服", + "白色制服", + "空乘制服", + "职场制服", + "警花制服" ] }, "position": { @@ -283,6 +346,7 @@ "color_bg": "#f0fdfa", "color_fg": "#0f766e", "words": [ + "苏梅岛", "日本", "韩国", "中国", @@ -379,7 +443,79 @@ "花漾", "BoLoli", "波萝社", - "MyGirl" + "MyGirl", + "RUISG", + "瑞丝馆", + "Pure Media", + "PureMedia", + "XiuRen秀人网", + "秀人", + "IMiss爱蜜社", + "尤蜜", + "YouMi尤蜜", + "YOUMI尤蜜荟", + "XIAOYU语画界", + "MFStar模范学院", + "MFStar", + "MFStar模特", + "TGOD推女神", + "DKGirl御女郎", + "XINGYAN星颜社", + "XINGYAN", + "FEILIN嗲囡囡", + "FEILIN", + "Feiling", + "飞灵", + "CANDY糖果画报", + "CANDY", + "YouWu尤物馆", + "YouWu", + "尤物馆", + "UXING优星馆", + "UXING", + "RuiSG瑞丝馆", + "SIW斯文传媒", + "SIW", + "SLADY猎女神", + "SLADY", + "SSA丝社", + "SSA", + "SJA佳爷", + "SJA", + "TASHA系列", + "TASHA", + "Ugirls爱尤物", + "Ugirls", + "爱尤物", + "V Girls", + "VGIRL", + "VGIRLS", + "WingS影私荟", + "WING", + "YALAYI雅拉伊", + "YALAYI", + "ISHOW爱秀", + "ISHOW", + "BlueCake", + "MiiTao蜜桃社", + "MiiTao", + "蜜桃社", + "108TV", + "108酱", + "撸图", + "情欲萝莉", + "头条女神", + "尤果网", + "尤果圈", + "虎牙", + "星颜社", + "绯月樱", + "青豆客", + "Beautyleg", + "MFModel", + "LeiLei", + "Tristana", + "Victoria Secret" ] } }, @@ -489,10 +625,67 @@ "MFModel", "LeiLei", "Tristana", - "Av" + "Av", + "写真", + "情趣", + "性感", + "Impact", + "宅男", + "黑丝", + "美腿", + "真空", + "诱惑", + "私房", + "气质", + "女神", + "丝袜", + "黑色", + "礼服", + "Girl", + "丝袜美腿", + "诱惑写真", + "分析师", + "头条女神", + "尤果网", + "尤果圈", + "波萝社", + "BoLoli", + "绯月樱", + "青豆客", + "Beautyleg", + "108TV", + "108酱", + "撸图", + "情欲萝莉", + "星颜社", + "爱尤物", + "尤物馆" + ], + "include": [ + "杨晨晨", + "水淼", + "王馨瑶", + "轩美宣", + "顾雪儿", + "曼曼", + "小乔", + "佳佳", + "安可", + "舒淇", + "林志玲", + "刘亦菲", + "蠢沫沫", + "绯月樱", + "鱼子酱", + "陈小纭", + "陆萱萱", + "安然", + "林星阑", + "朱可儿", + "张馨予", + "程开心", + "赵小米", + "小齿优喵" ] } -} - - - +} \ No newline at end of file diff --git a/src/db/album.rs b/src/db/album.rs index cf78940..b67cc32 100644 --- a/src/db/album.rs +++ b/src/db/album.rs @@ -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, 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::("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, sqlx::Error> { let row = sqlx::query("SELECT * FROM albums WHERE id = ?") .bind(album_id.to_string()) diff --git a/src/engine/tag_dict.rs b/src/engine/tag_dict.rs index e3f1c7e..eeb723a 100644 --- a/src/engine/tag_dict.rs +++ b/src/engine/tag_dict.rs @@ -52,6 +52,8 @@ pub struct PersonRulesDef { pub prefixes: Vec, #[serde(default)] pub excluded: Vec, + #[serde(default)] + pub include: Vec, } // ── 运行时词典结构 ── @@ -79,6 +81,8 @@ pub struct TagDict { pub person_prefixes: Vec, /// 排除的人物名(工作室/平台名,匹配上的人和包含这些词的人直接删除重解析) pub excluded_person_names: HashSet, + /// 强制包含的人物名(已知模特/演员名,只要出现就优先识别为人名) + pub included_person_names: HashSet, } 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 { + &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 = 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(), ) } diff --git a/src/engine/title_parser.rs b/src/engine/title_parser.rs index b07784b..aac0b37 100644 --- a/src/engine/title_parser.rs +++ b/src/engine/title_parser.rs @@ -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; } diff --git a/src/routes/album.rs b/src/routes/album.rs index 10424ee..747d8c4 100644 --- a/src/routes/album.rs +++ b/src/routes/album.rs @@ -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>>) -> 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>, cache: web::Data>>, query: web::Query>, ) -> 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>>) -> impl Responder { @@ -62,4 +92,4 @@ pub async fn create_album(db: web::Data>, body: web::Json HttpResponse::Created().json(serde_json::json!({"id": id})), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})), } -} \ No newline at end of file +} diff --git a/src/routes/dict.rs b/src/routes/dict.rs index db4c4ce..3ccfdfd 100644 --- a/src/routes/dict.rs +++ b/src/routes/dict.rs @@ -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::>(), + "included_person_names": dict.included_person_names.iter().collect::>(), })) }