This commit is contained in:
lychang
2026-07-20 03:39:31 +08:00
parent 61176cf2a3
commit d186aee171
6 changed files with 387 additions and 36 deletions

View File

@@ -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": [
"杨晨晨",
"水淼",
"王馨瑶",
"轩美宣",
"顾雪儿",
"曼曼",
"小乔",
"佳佳",
"安可",
"舒淇",
"林志玲",
"刘亦菲",
"蠢沫沫",
"绯月樱",
"鱼子酱",
"陈小纭",
"陆萱萱",
"安然",
"林星阑",
"朱可儿",
"张馨予",
"程开心",
"赵小米",
"小齿优喵"
]
}
}
}

View File

@@ -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())

View File

@@ -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(),
)
}

View File

@@ -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; }

View File

@@ -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)})),
}
}
}

View File

@@ -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<_>>(),
}))
}