Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec52439622 | ||
|
|
7c9c80fe39 | ||
|
|
f6c7baee5e | ||
|
|
d86cce34bc | ||
|
|
4321cfa62f | ||
|
|
d64b85bc9a | ||
|
|
f1dba9f24d | ||
|
|
49c0a0d307 | ||
|
|
4a80e3272f | ||
|
|
992ee32e9b | ||
|
|
12880dd480 | ||
|
|
cff2eb2886 | ||
|
|
005688b58f | ||
|
|
f572c7370d | ||
|
|
9c3ce4ec0d | ||
|
|
c8778c0ed1 | ||
|
|
284e610140 | ||
|
|
f68a8f880d | ||
|
|
90c493d41b | ||
|
|
9d81c5077c | ||
|
|
a85b4c869e | ||
|
|
81bbc67432 | ||
|
|
0a5dd1c920 | ||
|
|
ee1092c5a9 | ||
|
|
56dd82fb63 | ||
|
|
84409207e2 | ||
|
|
923f9bd7cf | ||
|
|
faa5b416da | ||
|
|
c6639778a4 | ||
|
|
a33ef65bd7 | ||
|
|
5e6b27f26d | ||
|
|
76cf668c08 | ||
|
|
9879aab614 | ||
|
|
94af7695ff | ||
|
|
390b548935 | ||
|
|
a6bfe7eda2 | ||
|
|
00af6e8528 | ||
|
|
9093f08441 | ||
|
|
d186aee171 | ||
|
|
61176cf2a3 | ||
|
|
8cf4bfe3d0 | ||
|
|
d958655c1e | ||
|
|
c96680fae8 | ||
|
|
be581977f3 | ||
|
|
1700585a36 | ||
|
|
97c2c208ba | ||
|
|
8d2f3361f8 | ||
|
|
5b803cc178 | ||
|
|
6b2f19ef47 | ||
|
|
da412c1c76 | ||
|
|
b1f0c2f36a | ||
|
|
36969a4fa0 | ||
|
|
34bcd67682 | ||
|
|
6ae3e3d1de | ||
|
|
847a80f470 | ||
|
|
d42f2c358e | ||
|
|
34fdb18919 | ||
|
|
879598585b | ||
|
|
cb09f2caac | ||
|
|
093924ea99 | ||
|
|
82653c1a6c | ||
|
|
b915e69222 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1 +1,5 @@
|
||||
/target
|
||||
/albums
|
||||
/.idea
|
||||
/data
|
||||
/scripts
|
||||
3660
Cargo.lock
generated
Normal file
3660
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,12 +11,15 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio", "migrate"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
|
||||
image = { version = "0.24", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["blocking","rustls-tls"] }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"] }
|
||||
config = "0.13"
|
||||
rand = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
thiserror = "1.0"
|
||||
mimalloc = "0.1.48"
|
||||
regex = "1"
|
||||
lazy_static = "1.4"
|
||||
jieba-rs = "0.7"
|
||||
rayon = "1"
|
||||
@@ -15,8 +15,11 @@
|
||||
<i class="fas fa-arrow-left"></i> 返回首页
|
||||
</a>
|
||||
<h1 class="album-title" id="album-title">加载中...</h1>
|
||||
<div class="album-info" id="album-info">
|
||||
<i class="fas fa-images"></i> 正在加载...
|
||||
<div class="album-header-row">
|
||||
<div class="album-info" id="album-info">
|
||||
正在加载...
|
||||
</div>
|
||||
<div class="album-meta-tags" id="album-meta-tags"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,13 +40,6 @@
|
||||
<span class="preview-info" id="lightbox-album">来自: 相册名称</span>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="preview-nav preview-prev" onclick="prevImage()" id="lightbox-prev">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="preview-counter" id="lightbox-counter">1 / 1</span>
|
||||
<button class="preview-nav preview-next" onclick="nextImage()" id="lightbox-next">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<button class="preview-close" onclick="closeLightbox()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@@ -69,7 +65,19 @@
|
||||
0 KB
|
||||
</span>
|
||||
</div>
|
||||
<div class="preview-center-controls">
|
||||
<button class="preview-nav preview-prev" onclick="prevImage()" id="lightbox-prev">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="preview-counter" id="lightbox-counter">1 / 1</span>
|
||||
<button class="preview-nav preview-next" onclick="nextImage()" id="lightbox-next">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="preview-controls">
|
||||
<button class="preview-action preview-action-close" onclick="closeLightbox()">
|
||||
<i class="fas fa-times"></i> 关闭
|
||||
</button>
|
||||
<button class="preview-action" onclick="addToFavoritesFromLightbox()" id="lightbox-favorite-btn">
|
||||
<i class="fas fa-heart"></i> 加入收藏
|
||||
</button>
|
||||
@@ -78,6 +86,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/album.js"></script>
|
||||
<script src="/assets/js/album.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
1256
assets/css/album.css
1256
assets/css/album.css
File diff suppressed because it is too large
Load Diff
2051
assets/css/style.css
2051
assets/css/style.css
File diff suppressed because it is too large
Load Diff
@@ -8,39 +8,152 @@
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1><i class="fas fa-images"></i> <span>流云图库</span></h1>
|
||||
<p>发现精彩瞬间,浏览精美图片</p>
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<div class="search-box">
|
||||
<label for="search-input"></label><input type="text" id="search-input" placeholder="搜索图片标题...">
|
||||
<button onclick="searchAlbums()"><i class="fas fa-search"></i></button>
|
||||
<div class="app-layout">
|
||||
|
||||
<!-- ============ 左侧边栏(可折叠) ============ -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<!-- Logo 区域 -->
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-icon"><i class="fas fa-images"></i></div>
|
||||
<div class="brand-text">流云图库</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="random-button" onclick="openRandomAlbum()">
|
||||
<i class="fas fa-random"></i> 随机相册
|
||||
</button>
|
||||
<button class="favorites-button" onclick="showFavorites()">
|
||||
<i class="fas fa-heart"></i> 收藏夹
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-item active" data-view="home" onclick="switchView('home')">
|
||||
<i class="fas fa-home"></i>
|
||||
<span>首页</span>
|
||||
</div>
|
||||
<div class="nav-item" data-view="favorites" onclick="showFavorites()">
|
||||
<i class="fas fa-heart"></i>
|
||||
<span>收藏夹</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="openRandomAlbum()">
|
||||
<i class="fas fa-random"></i>
|
||||
<span>随机相册</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="sidebar-divider"></div>
|
||||
|
||||
<!-- 热门人物 -->
|
||||
<div class="sidebar-section-group">
|
||||
<div class="sidebar-section-title collapsible" data-section="people" onclick="toggleSidebarSection('people')">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>热门人物</span>
|
||||
<i class="fas fa-chevron-down section-arrow"></i>
|
||||
</div>
|
||||
<div class="sidebar-section-content" id="sidebar-people-content">
|
||||
<div class="sidebar-people" id="sidebar-people">
|
||||
<!-- 动态填充 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门标签 -->
|
||||
<div class="sidebar-section-group">
|
||||
<div class="sidebar-section-title collapsible" data-section="tags" onclick="toggleSidebarSection('tags')">
|
||||
<i class="fas fa-tags"></i>
|
||||
<span>热门标签</span>
|
||||
<i class="fas fa-chevron-down section-arrow"></i>
|
||||
</div>
|
||||
<div class="sidebar-section-content" id="sidebar-tags-content">
|
||||
<div class="sidebar-tags" id="sidebar-tags">
|
||||
<!-- 动态填充 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部折叠按钮 -->
|
||||
<div class="sidebar-footer">
|
||||
<button class="collapse-btn" onclick="toggleSidebar()" id="collapse-btn" title="收起侧边栏">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats">
|
||||
正在加载相册数据...
|
||||
</div>
|
||||
|
||||
<div class="albums-grid" id="albums-container">
|
||||
<div class="loading">
|
||||
<i class="fas fa-spinner fa-spin"></i><br>加载中...
|
||||
</aside>
|
||||
|
||||
<!-- ============ 主内容区 ============ -->
|
||||
<main class="main-content">
|
||||
|
||||
<!-- 顶部工具栏 -->
|
||||
<header class="topbar">
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-bar">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<label for="search-input"></label>
|
||||
<input type="text" id="search-input" placeholder="搜索相册标题、人物、标签..." onkeydown="if(event.key==='Enter') searchAlbums()">
|
||||
<button class="search-clear" id="search-clear" onclick="clearSearch()" style="display:none;">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧快捷按钮 -->
|
||||
<div class="topbar-actions">
|
||||
<button class="action-btn" onclick="openRandomAlbum()" title="随机相册">
|
||||
<i class="fas fa-random"></i>
|
||||
</button>
|
||||
<button class="action-btn" onclick="showFavorites()" title="收藏夹">
|
||||
<i class="fas fa-heart"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 筛选状态条 -->
|
||||
<div class="filter-bar" id="filter-bar" style="display:none;">
|
||||
<span class="filter-indicator">
|
||||
<i class="fas fa-filter"></i>
|
||||
当前筛选:
|
||||
</span>
|
||||
<span class="filter-pill" id="current-filter-pill"></span>
|
||||
<button class="filter-remove" onclick="clearFilter()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="content-area">
|
||||
|
||||
<!-- 统计条 -->
|
||||
<div class="stats-bar" id="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon"><i class="fas fa-folder-open"></i></span>
|
||||
<span class="stat-value" id="stat-albums">0</span>
|
||||
<span class="stat-label">个相册</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon"><i class="fas fa-image"></i></span>
|
||||
<span class="stat-value" id="stat-images">0</span>
|
||||
<span class="stat-label">张图片</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-icon"><i class="fas fa-user"></i></span>
|
||||
<span class="stat-value" id="stat-people">0</span>
|
||||
<span class="stat-label">位人物</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相册网格 -->
|
||||
<div class="albums-grid" id="albums-container">
|
||||
<div class="loading">
|
||||
<i class="fas fa-spinner fa-spin"></i><br>加载中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/app.js"></script>
|
||||
|
||||
<!-- 移动端遮罩 -->
|
||||
<div class="mobile-overlay" id="mobile-overlay" onclick="toggleMobileSidebar()"></div>
|
||||
|
||||
<script src="/assets/js/app.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -235,7 +235,7 @@ async function loadAlbum(albumId) {
|
||||
// 显示骨架屏
|
||||
showImageSkeleton();
|
||||
|
||||
const response = await fetch(`/api/album/${albumId}`);
|
||||
const response = await fetch(`${API_BASE}/album/${albumId}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('相册不存在');
|
||||
@@ -245,6 +245,9 @@ async function loadAlbum(albumId) {
|
||||
|
||||
const albumData = await response.json();
|
||||
currentAlbum = albumData.album;
|
||||
// tags/persons 在 albumData 顶层(AlbumWithImages),存到 currentAlbum 方便渲染
|
||||
currentAlbum.tags = albumData.tags || [];
|
||||
currentAlbum.persons = albumData.persons || [];
|
||||
images = albumData.images;
|
||||
|
||||
updateAlbumInfo();
|
||||
@@ -261,11 +264,122 @@ async function loadAlbum(albumId) {
|
||||
// 更新相册信息
|
||||
function updateAlbumInfo() {
|
||||
document.getElementById('album-title').textContent = currentAlbum.title;
|
||||
document.getElementById('album-info').innerHTML =
|
||||
`<i class="fas fa-images"></i> 共 ${images.length} 张图片`;
|
||||
|
||||
let infoHtml = `${images.length} 张图片`;
|
||||
document.getElementById('album-info').innerHTML = infoHtml;
|
||||
|
||||
// 显示相册元数据(人名 + 标签)
|
||||
updateAlbumMeta();
|
||||
|
||||
document.title = `${currentAlbum.title} - 图片展示服务`;
|
||||
}
|
||||
|
||||
// 标签分类映射(与后端 tag_extractor.rs 保持一致)
|
||||
const TAG_CATEGORIES = {
|
||||
style: ['日系', '韩系', '森系', '复古', '甜美', '性感', '清新', '冷艳',
|
||||
'优雅', '知性', '可爱', '俏皮', '温柔', '妩媚', '野性', '清纯',
|
||||
'时尚', '潮流', '街头', '学院', '文艺', '古典', '民族', '异域',
|
||||
'japanese', 'korean', 'retro', 'vintage', 'cute', 'sexy',
|
||||
'elegant', 'fresh', 'sweet', 'wild', 'pure', 'fashion'],
|
||||
outfit: ['比基尼', '泳装', '泳衣', '旗袍', '制服', '丝袜', '内衣', '文胸',
|
||||
'包臀裙', '紧身裙', '超短裙', '连衣裙', '半身裙', '短裙', '长裙',
|
||||
'牛仔裤', '热裤', '短裤', '休闲裤', '西装裤',
|
||||
'T恤', '衬衫', '毛衣', '针织衫', '外套', '风衣', '大衣', '西装',
|
||||
'吊带', '抹胸', '露背', '透视', '蕾丝', '镂空', '开叉',
|
||||
'高跟鞋', '运动鞋', '凉鞋', '拖鞋', '靴子',
|
||||
'bikini', 'swimsuit', 'stockings', 'lingerie', 'dress',
|
||||
'skirt', 'jeans', 'shirt', 'sweater', 'coat', 'heels'],
|
||||
scene: ['室内', '户外', '街拍', '浴室', '浴缸', '床上', '床照', '沙发',
|
||||
'厨房', '阳台', '窗边', '楼梯', '走廊', '电梯', '车内', '驾驶座',
|
||||
'海边', '沙滩', '泳池', '温泉', '森林', '草地', '花园', '公园',
|
||||
'街道', '巷弄', '建筑', '天台', '屋顶', '咖啡馆', '餐厅', '酒店',
|
||||
'indoor', 'outdoor', 'street', 'beach', 'pool', 'bathroom',
|
||||
'bedroom', 'kitchen', 'balcony', 'car', 'cafe', 'hotel'],
|
||||
body: ['美腿', '美胸', '翘臀', '玉足', '美背', '香肩', '蛮腰', '细腰',
|
||||
'长腿', '酥胸', '丰臀', '白皙', '肌肤', '纹身',
|
||||
'legs', 'breasts', 'ass', 'feet', 'back', 'shoulders', 'waist',
|
||||
'skin', 'tattoo'],
|
||||
mood: ['清纯', '诱惑', '唯美', '清新', '冷艳', '妩媚', '娇羞', '俏皮',
|
||||
'温柔', '野性', '冷傲', '热情', '慵懒', '迷离', '甜美', '知性',
|
||||
'pure', 'seductive', 'beautiful', 'fresh', 'cool', 'charming',
|
||||
'shy', 'cute', 'gentle', 'wild', 'lazy', 'sweet'],
|
||||
topic: ['写真', '私房', '街拍', '旅拍', '婚纱照', '艺术照', '生活照',
|
||||
'自拍', '跟拍', '约拍', '商拍',
|
||||
'photoshoot', 'portrait', 'selfie', 'travel', 'wedding'],
|
||||
};
|
||||
|
||||
// 获取标签的分类
|
||||
function getTagCategory(tag) {
|
||||
const tagLower = tag.toLowerCase();
|
||||
for (const [category, tags] of Object.entries(TAG_CATEGORIES)) {
|
||||
if (tags.some(t => t.toLowerCase() === tagLower)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
// 更新相册元数据(人名 + 标签)简洁版:只用颜色区分,不写分类名
|
||||
function updateAlbumMeta() {
|
||||
const metaContainer = document.getElementById('album-meta-tags');
|
||||
if (!metaContainer) return;
|
||||
|
||||
let chips = [];
|
||||
|
||||
// 添加人名标签(从 search.db 返回的 persons 数组)
|
||||
if (currentAlbum.persons && Array.isArray(currentAlbum.persons)) {
|
||||
currentAlbum.persons.forEach(name => {
|
||||
chips.push({
|
||||
text: name,
|
||||
type: 'person',
|
||||
category: 'person',
|
||||
query: name
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 添加标签(从 search.db 返回的 tags 数组)
|
||||
if (currentAlbum.tags && Array.isArray(currentAlbum.tags)) {
|
||||
currentAlbum.tags.forEach(tag => {
|
||||
chips.push({
|
||||
text: tag,
|
||||
type: 'tag',
|
||||
category: getTagCategory(tag),
|
||||
query: tag
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (chips.length === 0) {
|
||||
metaContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
metaContainer.style.display = 'flex';
|
||||
metaContainer.innerHTML = chips.map(chip => {
|
||||
const icon = chip.type === 'person' ? 'fa-user' : 'fa-tag';
|
||||
const cssClass = chip.type === 'person' ? 'meta-chip-person' : `meta-chip-tag-${chip.category || 'default'}`;
|
||||
return `<span class="meta-chip ${cssClass}" data-meta-type="${chip.type}" data-meta-query="${encodeURIComponent(chip.query)}" title="点击搜索: ${chip.text}">
|
||||
<i class="fas ${icon}"></i> ${chip.text}
|
||||
</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 事件委托:点击元数据标签
|
||||
document.addEventListener('click', function(e) {
|
||||
const chip = e.target.closest('.meta-chip');
|
||||
if (!chip) return;
|
||||
const query = chip.dataset.metaQuery;
|
||||
if (query) {
|
||||
searchByMeta(query);
|
||||
}
|
||||
});
|
||||
|
||||
// 点击元数据标签搜索
|
||||
function searchByMeta(encodedQuery) {
|
||||
window.location.href = `/?search=${encodedQuery}`;
|
||||
}
|
||||
|
||||
// 智能判断图片方向 - 更精确的判断逻辑
|
||||
function getImageOrientation(width, height) {
|
||||
if (!width || !height) return 'square';
|
||||
@@ -299,7 +413,7 @@ function displayImages() {
|
||||
let dataSrc = '';
|
||||
|
||||
if (image.id) {
|
||||
dataSrc = `/api/image/${image.id}`;
|
||||
dataSrc = `${API_BASE}/image/${image.id}`;
|
||||
imageUrl = '/assets/placeholder.svg'; // 初始使用占位图
|
||||
} else if (image.original_url) {
|
||||
dataSrc = image.original_url;
|
||||
@@ -311,7 +425,7 @@ function displayImages() {
|
||||
// 检查图片是否已被收藏
|
||||
isImageFavorited(image.id || image.original_url);
|
||||
return `
|
||||
<div class="image-item ${orientation}" onclick="openLightbox(${index})">
|
||||
<div class="image-item ${orientation}">
|
||||
<img
|
||||
src="${imageUrl}"
|
||||
${image.id ? `data-image-id="${image.id}"` : ''}
|
||||
@@ -319,6 +433,7 @@ function displayImages() {
|
||||
class="lazy"
|
||||
loading="lazy"
|
||||
onerror="this.src='/assets/placeholder.svg'"
|
||||
onclick="openLightbox(${index})"
|
||||
>
|
||||
<div class="image-caption">
|
||||
${image.width ? `<small>${image.width}×${image.height}</small>` : ''}
|
||||
@@ -412,7 +527,7 @@ function updateLightboxContent(newIndex) {
|
||||
// 在lightbox中优先使用原始图片以获得更好的显示效果
|
||||
let imageUrl = '/assets/placeholder.svg';
|
||||
if (image.id) {
|
||||
imageUrl = `/api/image/${image.id}`;
|
||||
imageUrl = `${API_BASE}/image/${image.id}`;
|
||||
} else if (image.original_url) {
|
||||
imageUrl = image.original_url;
|
||||
}
|
||||
@@ -708,8 +823,12 @@ function toggleFavorite(event, imageIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 找到按钮元素(event.target 可能是 i 图标)
|
||||
const btn = event.target.closest('.favorite-btn');
|
||||
if (!btn) return;
|
||||
|
||||
// 检查当前收藏状态
|
||||
const isFavorited = event.target.classList.contains('favorited');
|
||||
const isFavorited = btn.classList.contains('favorited');
|
||||
|
||||
if (isFavorited) {
|
||||
// 取消收藏
|
||||
@@ -723,7 +842,7 @@ function toggleFavorite(event, imageIndex) {
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
event.target.classList.remove('favorited');
|
||||
btn.classList.remove('favorited');
|
||||
showFavoriteToast('已取消收藏', false);
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -750,7 +869,7 @@ function toggleFavorite(event, imageIndex) {
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
event.target.classList.add('favorited');
|
||||
btn.classList.add('favorited');
|
||||
showFavoriteToast('已添加到收藏夹', true);
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -835,4 +954,5 @@ window.openLightbox = openLightbox;
|
||||
window.closeLightbox = closeLightbox;
|
||||
window.prevImage = prevImage;
|
||||
window.nextImage = nextImage;
|
||||
window.toggleFavorite = toggleFavorite;
|
||||
window.toggleFavorite = toggleFavorite;
|
||||
window.addToFavoritesFromLightbox = addToFavoritesFromLightbox;
|
||||
630
assets/js/app.js
630
assets/js/app.js
@@ -1,6 +1,7 @@
|
||||
// 全局变量
|
||||
let allAlbums = []; // 存储所有相册数据
|
||||
let displayedAlbums = []; // 当前显示的相册数据
|
||||
let filteredAlbums = null; // 过滤后的相册数据(有筛选时使用,null=无筛选)
|
||||
let searchTimeout = null;
|
||||
let currentDisplayPage = 1; // 前端显示的分页
|
||||
let albumsPerPage = 50; // 每次显示50个相册
|
||||
@@ -10,6 +11,13 @@ let lazyLoadObserver = null;
|
||||
let isAllAlbumsLoaded = false; // 标记是否已加载所有相册数据
|
||||
let isInFavoritesPage = false; // 标记当前是否在收藏页
|
||||
|
||||
// 标签和人物筛选
|
||||
let currentFilter = null; // { type: 'tag'|'person', value: string }
|
||||
let collapsedSections = { people: false, tags: false };
|
||||
|
||||
// 侧边栏状态(持久化)
|
||||
let sidebarCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
|
||||
|
||||
// API基础URL
|
||||
const API_BASE = '/api';
|
||||
|
||||
@@ -99,10 +107,22 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
document.body.classList.add('touch-device');
|
||||
}
|
||||
|
||||
loadAlbums();
|
||||
initSearch();
|
||||
initInfiniteScroll();
|
||||
initLazyLoading(); // 初始化图片懒加载
|
||||
initSidebarSections(); // 初始化侧边栏区块折叠状态
|
||||
|
||||
// 检查URL中是否有search参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const searchQuery = urlParams.get('search');
|
||||
if (searchQuery) {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
searchInput.value = searchQuery;
|
||||
// 直接搜索,不先加载全部相册
|
||||
searchAlbums();
|
||||
} else {
|
||||
loadAlbums();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化搜索功能
|
||||
@@ -232,26 +252,28 @@ function handleScroll() {
|
||||
async function loadMoreAlbums() {
|
||||
// 如果在收藏页,不加载更多数据
|
||||
if (isInFavoritesPage) return;
|
||||
|
||||
if (isLoadingMore || !hasMoreAlbums || !isAllAlbumsLoaded) return;
|
||||
|
||||
if (isLoadingMore || !hasMoreAlbums) return;
|
||||
// 全量模式下(无筛选)需等 isAllAlbumsLoaded;过滤模式下直接使用 filteredAlbums
|
||||
if (!currentFilter && !isAllAlbumsLoaded) return;
|
||||
|
||||
isLoadingMore = true;
|
||||
try {
|
||||
// 显示加载更多指示器
|
||||
showLoadingMore();
|
||||
|
||||
|
||||
// 增加当前显示页数
|
||||
currentDisplayPage++;
|
||||
|
||||
|
||||
// 显示下一页数据
|
||||
displayCurrentPage();
|
||||
|
||||
|
||||
// 初始化图片懒加载
|
||||
initLazyLoading();
|
||||
|
||||
|
||||
// 预加载下一批图片
|
||||
preloadNextBatchImages();
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载更多相册失败:', error);
|
||||
showLoadMoreError();
|
||||
@@ -347,7 +369,7 @@ function appendAlbums(albums) {
|
||||
let dataSrc = '';
|
||||
|
||||
if (album.cover_image_id) {
|
||||
dataSrc = `/api/image/${album.cover_image_id}`;
|
||||
dataSrc = `${API_BASE}/image/${album.cover_image_id}`;
|
||||
coverImageUrl = '/assets/placeholder.svg'; // 初始使用占位图
|
||||
}
|
||||
|
||||
@@ -355,7 +377,7 @@ function appendAlbums(albums) {
|
||||
const createdDate = new Date(album.created_at).toLocaleDateString('zh-CN');
|
||||
|
||||
return `
|
||||
<div class="album-card" data-album-id="${album.id}">
|
||||
<div class="album-card" data-album-id="${album.id}" onclick="openAlbum('${album.id}')">
|
||||
<div class="album-cover">
|
||||
<img
|
||||
src="${coverImageUrl}"
|
||||
@@ -371,9 +393,6 @@ function appendAlbums(albums) {
|
||||
<span><i class="fas fa-images"></i> ${album.image_count} 张图片</span>
|
||||
<span><i class="fas fa-calendar"></i> ${createdDate}</span>
|
||||
</div>
|
||||
<a href="/album?id=${album.id}" class="view-button">
|
||||
<i class="fas fa-eye"></i> 查看相册
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -429,6 +448,9 @@ async function loadAlbums(reset = false) {
|
||||
// 预加载下一批图片
|
||||
preloadNextBatchImages();
|
||||
|
||||
// 加载热门标签和人物
|
||||
loadDiscoverData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载相册失败:', error);
|
||||
showError('加载相册数据失败,请刷新页面重试');
|
||||
@@ -437,37 +459,49 @@ async function loadAlbums(reset = false) {
|
||||
|
||||
// 显示当前页的相册数据
|
||||
function displayCurrentPage() {
|
||||
// 有筛选时用 filteredAlbums,否则用 allAlbums
|
||||
const source = filteredAlbums || allAlbums;
|
||||
const startIndex = (currentDisplayPage - 1) * albumsPerPage;
|
||||
const endIndex = startIndex + albumsPerPage;
|
||||
|
||||
|
||||
// 获取当前页要显示的相册
|
||||
displayedAlbums = allAlbums.slice(startIndex, endIndex);
|
||||
|
||||
displayedAlbums = source.slice(startIndex, endIndex);
|
||||
|
||||
// 如果是第一页,直接显示;否则追加显示
|
||||
if (currentDisplayPage === 1) {
|
||||
displayAlbums(displayedAlbums);
|
||||
} else {
|
||||
appendAlbums(displayedAlbums);
|
||||
}
|
||||
|
||||
|
||||
// 检查是否还有更多数据可以显示
|
||||
hasMoreAlbums = (currentDisplayPage * albumsPerPage) < allAlbums.length;
|
||||
hasMoreAlbums = (currentDisplayPage * albumsPerPage) < source.length;
|
||||
}
|
||||
|
||||
// 搜索相册
|
||||
// 防抖搜索函数
|
||||
async function debouncedSearch() {
|
||||
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
||||
|
||||
|
||||
if (searchTerm === '') {
|
||||
// 显示所有已加载的相册
|
||||
// 搜索框为空 → 清除过滤状态,显示所有相册
|
||||
currentFilter = null;
|
||||
filteredAlbums = null;
|
||||
displayAlbums(allAlbums);
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 直接使用后端搜索API进行全站搜索
|
||||
await performServerSearch(searchTerm);
|
||||
const results = await performServerSearchWithResult(searchTerm);
|
||||
if (results && results.length > 0) {
|
||||
filteredAlbums = results;
|
||||
currentDisplayPage = 1;
|
||||
hasMoreAlbums = results.length > albumsPerPage;
|
||||
displayAlbums(results);
|
||||
updateStats(results);
|
||||
initLazyLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 在当前已加载的数据集中搜索
|
||||
@@ -503,11 +537,22 @@ function showSearchHint(searchTerm) {
|
||||
|
||||
// 执行服务器端搜索(全站搜索)
|
||||
async function performServerSearch(searchTerm) {
|
||||
try {
|
||||
// 显示搜索中的骨架屏
|
||||
showSkeleton(4);
|
||||
const results = await performServerSearchWithResult(searchTerm);
|
||||
if (results && results.length > 0) {
|
||||
displayAlbums(results);
|
||||
const oldStats = document.getElementById('stats');
|
||||
if (oldStats) {
|
||||
oldStats.innerHTML = `在全站找到 ${results.length} 个包含 "${searchTerm}" 的相册`;
|
||||
}
|
||||
updateStats(results);
|
||||
initLazyLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(searchTerm)}&page=1&limit=${albumsPerPage}`);
|
||||
/// 执行服务器端搜索并返回结果数组(不直接渲染)
|
||||
async function performServerSearchWithResult(searchTerm) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(searchTerm)}&page=1&limit=200`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
@@ -515,20 +560,13 @@ async function performServerSearch(searchTerm) {
|
||||
const serverResults = await response.json();
|
||||
|
||||
if (serverResults.length === 0) {
|
||||
showError(`没有找到包含 "${searchTerm}" 的相册`);
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
// 显示服务器搜索结果
|
||||
displayAlbums(serverResults);
|
||||
document.getElementById('stats').innerHTML = `在全站找到 ${serverResults.length} 个包含 "${searchTerm}" 的相册`;
|
||||
|
||||
// 初始化图片懒加载
|
||||
initLazyLoading();
|
||||
|
||||
return serverResults;
|
||||
} catch (error) {
|
||||
console.error('全站搜索失败:', error);
|
||||
showError('全站搜索失败,请重试');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,7 +585,7 @@ function displayAlbums(albums) {
|
||||
let dataSrc = '';
|
||||
|
||||
if (album.cover_image_id) {
|
||||
dataSrc = `/api/image/${album.cover_image_id}`;
|
||||
dataSrc = `${API_BASE}/image/${album.cover_image_id}`;
|
||||
coverImageUrl = '/assets/placeholder.svg'; // 初始使用占位图
|
||||
}
|
||||
|
||||
@@ -555,7 +593,7 @@ function displayAlbums(albums) {
|
||||
const createdDate = new Date(album.created_at).toLocaleDateString('zh-CN');
|
||||
|
||||
return `
|
||||
<div class="album-card" data-album-id="${album.id}">
|
||||
<div class="album-card" data-album-id="${album.id}" onclick="openAlbum('${album.id}')">
|
||||
<div class="album-cover">
|
||||
<img
|
||||
src="${coverImageUrl}"
|
||||
@@ -571,9 +609,6 @@ function displayAlbums(albums) {
|
||||
<span><i class="fas fa-images"></i> ${album.image_count} 张图片</span>
|
||||
<span><i class="fas fa-calendar"></i> ${createdDate}</span>
|
||||
</div>
|
||||
<a href="/album?id=${album.id}" class="view-button">
|
||||
<i class="fas fa-eye"></i> 查看相册
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -610,14 +645,32 @@ function updateStats(albums) {
|
||||
albums = allAlbums;
|
||||
}
|
||||
const totalImages = albums.reduce((sum, album) => sum + album.image_count, 0);
|
||||
document.getElementById('stats').innerHTML = `共发现 ${albums.length} 个相册,包含 ${totalImages} 张图片`;
|
||||
|
||||
// 更新统计条
|
||||
const statAlbums = document.getElementById('stat-albums');
|
||||
const statImages = document.getElementById('stat-images');
|
||||
if (statAlbums) statAlbums.textContent = albums.length.toLocaleString();
|
||||
if (statImages) statImages.textContent = totalImages.toLocaleString();
|
||||
|
||||
// 兼容旧版 stats
|
||||
const oldStats = document.getElementById('stats');
|
||||
if (oldStats) oldStats.innerHTML = `共发现 ${albums.length} 个相册,包含 ${totalImages} 张图片`;
|
||||
|
||||
// 获取人物数量(用 metadata stats API)
|
||||
fetch(`${API_BASE}/metadata/stats`).then(r => r.ok && r.json()).then(data => {
|
||||
if (data && data.total_persons !== undefined) {
|
||||
const statPeople = document.getElementById('stat-people');
|
||||
if (statPeople) statPeople.textContent = data.total_persons.toLocaleString();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// 显示错误信息
|
||||
function showError(message) {
|
||||
const container = document.getElementById('albums-container');
|
||||
container.innerHTML = `<div class="no-results"><i class="fas fa-exclamation-triangle"></i><br>${message}</div>`;
|
||||
document.getElementById('stats').innerHTML = '';
|
||||
const oldStats = document.getElementById('stats');
|
||||
if (oldStats) oldStats.innerHTML = '';
|
||||
}
|
||||
|
||||
// 搜索相册函数
|
||||
@@ -759,16 +812,27 @@ preloadCriticalResources();
|
||||
// 优化滚动性能
|
||||
optimizeScrollPerformance();
|
||||
|
||||
// 打开相册详情
|
||||
function openAlbum(albumId) {
|
||||
window.location.href = `/album?id=${albumId}`;
|
||||
}
|
||||
|
||||
// 随机打开相册
|
||||
function openRandomAlbum() {
|
||||
// 添加随机动画效果
|
||||
const randomButton = document.querySelector('.random-button');
|
||||
randomButton.style.transform = 'scale(0.95)';
|
||||
randomButton.disabled = true;
|
||||
|
||||
// 显示加载状态
|
||||
const originalText = randomButton.textContent;
|
||||
randomButton.textContent = '随机加载中...';
|
||||
// 添加随机动画效果 - 兼容侧边栏导航项
|
||||
const navItems = document.querySelectorAll('.nav-item');
|
||||
let randomButton = null;
|
||||
navItems.forEach(item => {
|
||||
if (item.textContent.includes('随机')) randomButton = item;
|
||||
});
|
||||
// 也试试顶部操作按钮
|
||||
if (!randomButton) {
|
||||
randomButton = document.querySelector('.action-btn[onclick*="openRandomAlbum"]');
|
||||
}
|
||||
|
||||
if (randomButton) {
|
||||
randomButton.style.transform = 'scale(0.95)';
|
||||
}
|
||||
|
||||
// 调用新的随机相册API
|
||||
fetch(`${API_BASE}/album/random`)
|
||||
@@ -783,22 +847,22 @@ function openRandomAlbum() {
|
||||
})
|
||||
.then(randomAlbum => {
|
||||
// 恢复按钮状态
|
||||
setTimeout(() => {
|
||||
randomButton.style.transform = '';
|
||||
randomButton.disabled = false;
|
||||
randomButton.textContent = originalText;
|
||||
}, 200);
|
||||
if (randomButton) {
|
||||
setTimeout(() => {
|
||||
randomButton.style.transform = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// 跳转到随机相册
|
||||
window.location.href = `/album?id=${randomAlbum.id}`;
|
||||
})
|
||||
.catch(error => {
|
||||
// 恢复按钮状态
|
||||
setTimeout(() => {
|
||||
randomButton.style.transform = '';
|
||||
randomButton.disabled = false;
|
||||
randomButton.textContent = originalText;
|
||||
}, 200);
|
||||
if (randomButton) {
|
||||
setTimeout(() => {
|
||||
randomButton.style.transform = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
alert(error.message);
|
||||
});
|
||||
@@ -809,6 +873,11 @@ function showFavorites() {
|
||||
// 设置当前在收藏页
|
||||
isInFavoritesPage = true;
|
||||
|
||||
// 切换侧边栏选中状态
|
||||
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
|
||||
const favNav = document.querySelector('.nav-item[data-view="favorites"]');
|
||||
if (favNav) favNav.classList.add('active');
|
||||
|
||||
// 从后端API获取收藏列表
|
||||
fetch(`${API_BASE}/favorites`)
|
||||
.then(response => {
|
||||
@@ -818,12 +887,7 @@ function showFavorites() {
|
||||
return response.json();
|
||||
})
|
||||
.then(favoriteImages => {
|
||||
if (favoriteImages.length === 0) {
|
||||
alert('收藏夹为空,快去收藏一些喜欢的图片吧!');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建收藏夹页面或跳转到收藏夹视图
|
||||
// 始终显示收藏夹视图(包括空状态)
|
||||
displayFavorites(favoriteImages);
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -886,8 +950,10 @@ function displayFavorites(favoriteImages) {
|
||||
`}
|
||||
`;
|
||||
|
||||
// 更新统计信息 - 显示收藏数量
|
||||
document.getElementById('stats').innerHTML = `<div class="favorites-header">
|
||||
// 更新统计信息 - 显示收藏数量(兼容新旧版布局)
|
||||
const statsEl = document.getElementById('stats');
|
||||
if (statsEl) {
|
||||
statsEl.innerHTML = `<div class="favorites-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="favorites-icon">
|
||||
@@ -907,6 +973,7 @@ function displayFavorites(favoriteImages) {
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 初始化图片懒加载,确保收藏的图片被加载
|
||||
initLazyLoading();
|
||||
@@ -926,8 +993,12 @@ function formatDate(dateString) {
|
||||
function backToHome() {
|
||||
// 重置收藏页标记
|
||||
isInFavoritesPage = false;
|
||||
// 重新加载首页相册
|
||||
loadAlbums(true);
|
||||
// 切换导航高亮到首页
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.view === 'home');
|
||||
});
|
||||
// 清除筛选
|
||||
clearFilter();
|
||||
}
|
||||
|
||||
// 全局变量存储收藏列表和当前预览索引
|
||||
@@ -1194,7 +1265,422 @@ function openFavoriteImage(favoriteId) {
|
||||
window.location.href = `/album?id=${favoriteId}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 标签和人物发现功能
|
||||
// ============================================================
|
||||
|
||||
// 加载热门标签和人物数据
|
||||
async function loadDiscoverData() {
|
||||
try {
|
||||
// 并行加载
|
||||
const [tagsResp, peopleResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/tags/popular?limit=40`),
|
||||
fetch(`${API_BASE}/recommend/popular-persons?limit=30`)
|
||||
]);
|
||||
|
||||
if (tagsResp.ok) {
|
||||
const tagsData = await tagsResp.json();
|
||||
// 兼容两种格式:直接数组 或 { total, tags }
|
||||
const tags = Array.isArray(tagsData) ? tagsData : (tagsData.tags || tagsData.items || []);
|
||||
if (tags && tags.length > 0) {
|
||||
renderTagCloud(tags);
|
||||
}
|
||||
}
|
||||
|
||||
if (peopleResp.ok) {
|
||||
const people = await peopleResp.json();
|
||||
if (people && people.length > 0) {
|
||||
renderPeopleCloud(people);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载发现数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染标签(侧边栏列表)
|
||||
function renderTagCloud(tags) {
|
||||
// 新版:侧边栏标签列表
|
||||
const sidebarTags = document.getElementById('sidebar-tags');
|
||||
if (sidebarTags && tags && tags.length) {
|
||||
sidebarTags.innerHTML = tags.slice(0, 25).map(tag => {
|
||||
const count = tag.album_cnt || tag.count || 1;
|
||||
const name = tag.tag || tag.name || '';
|
||||
const isActive = currentFilter && currentFilter.type === 'tag' && currentFilter.value === name;
|
||||
return `<div class="sidebar-tag ${isActive ? 'active' : ''}" onclick="filterByTag('${name}')">
|
||||
<span class="sidebar-tag-name">${name}</span>
|
||||
<span class="sidebar-tag-count">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 兼容旧版:标签云区域
|
||||
const section = document.getElementById('tags-section');
|
||||
const cloud = document.getElementById('tag-cloud');
|
||||
if (section && cloud && tags && tags.length) {
|
||||
section.style.display = 'block';
|
||||
const maxCount = Math.max(...tags.map(t => t.album_cnt || t.count || 1));
|
||||
const minCount = Math.min(...tags.map(t => t.album_cnt || t.count || 1));
|
||||
cloud.innerHTML = tags.map(tag => {
|
||||
const count = tag.album_cnt || tag.count || 1;
|
||||
const name = tag.tag || tag.name || '';
|
||||
const ratio = maxCount === minCount ? 0.5 : (count - minCount) / (maxCount - minCount);
|
||||
const fontSize = 12 + ratio * 10;
|
||||
const hue = 210 - ratio * 50;
|
||||
const opacity = 0.6 + ratio * 0.4;
|
||||
return `<span class="tag-chip"
|
||||
style="font-size:${fontSize}px; color:hsl(${hue}, 70%, 45%); opacity:${opacity};"
|
||||
onclick="filterByTag('${name}')"
|
||||
title="${count} 个相册">
|
||||
${name} <small>(${count})</small>
|
||||
</span>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染热门人物(横向滚动条)
|
||||
function renderPeopleCloud(people) {
|
||||
// 侧边栏人物列表
|
||||
const sidebarPeople = document.getElementById('sidebar-people');
|
||||
if (sidebarPeople && people && people.length) {
|
||||
sidebarPeople.innerHTML = people.slice(0, 25).map(p => {
|
||||
const name = p.name || p.display_name || '';
|
||||
const albumCount = p.total_albums || 0;
|
||||
const isActive = currentFilter && currentFilter.type === 'person' && currentFilter.value === name;
|
||||
return `<div class="sidebar-person ${isActive ? 'active' : ''}" onclick="filterByPerson('${name}')" title="${albumCount} 个相册">
|
||||
<div class="sidebar-person-avatar">${name.charAt(0)}</div>
|
||||
<div class="sidebar-person-info">
|
||||
<div class="sidebar-person-name">${name}</div>
|
||||
<div class="sidebar-person-count">${albumCount} 相册</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 横向滚动条
|
||||
const strip = document.getElementById('people-strip');
|
||||
const scroll = document.getElementById('people-scroll');
|
||||
if (strip && scroll && people && people.length) {
|
||||
strip.style.display = 'flex';
|
||||
scroll.innerHTML = people.slice(0, 20).map(p => {
|
||||
const name = p.name || p.display_name || '';
|
||||
const albumCount = p.total_albums || 0;
|
||||
const isActive = currentFilter && currentFilter.type === 'person' && currentFilter.value === name;
|
||||
return `<div class="person-chip-scroll ${isActive ? 'active' : ''}" onclick="filterByPerson('${name}')" title="${albumCount} 个相册">
|
||||
<div class="person-chip-avatar">${name.charAt(0)}</div>
|
||||
<div class="person-chip-info">
|
||||
<div class="person-chip-name">${name}</div>
|
||||
<div class="person-chip-count">${albumCount} 相册</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 兼容旧版:人物卡片区域
|
||||
const section = document.getElementById('people-section');
|
||||
const cloud = document.getElementById('people-cloud');
|
||||
if (section && cloud && people && people.length) {
|
||||
section.style.display = 'block';
|
||||
cloud.innerHTML = people.map(p => {
|
||||
const name = p.name || p.display_name || '';
|
||||
const albumCount = p.total_albums || 0;
|
||||
const imageCount = p.total_images || 0;
|
||||
return `<div class="person-chip" onclick="filterByPerson('${name}')" title="${albumCount} 个相册, ${imageCount} 张图片">
|
||||
<div class="person-avatar">${name.charAt(0)}</div>
|
||||
<div class="person-info">
|
||||
<div class="person-name">${name}</div>
|
||||
<div class="person-meta">${albumCount} 相册 · ${imageCount} 图</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// 侧边栏区块折叠/展开
|
||||
function toggleSidebarSection(section) {
|
||||
const group = document.querySelector(`.sidebar-section-group [data-section="${section}"]`)?.closest('.sidebar-section-group');
|
||||
if (!group) return;
|
||||
group.classList.toggle('collapsed');
|
||||
// 保存状态到 localStorage
|
||||
try {
|
||||
const state = JSON.parse(localStorage.getItem('sidebarSections') || '{}');
|
||||
state[section] = group.classList.contains('collapsed');
|
||||
localStorage.setItem('sidebarSections', JSON.stringify(state));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// 初始化侧边栏区块折叠状态
|
||||
function initSidebarSections() {
|
||||
try {
|
||||
const state = JSON.parse(localStorage.getItem('sidebarSections') || '{}');
|
||||
Object.keys(state).forEach(section => {
|
||||
if (state[section]) {
|
||||
const group = document.querySelector(`.sidebar-section-group [data-section="${section}"]`)?.closest('.sidebar-section-group');
|
||||
if (group) group.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// 按标签筛选
|
||||
async function filterByTag(tag) {
|
||||
currentFilter = { type: 'tag', value: tag };
|
||||
showFilterStatus(`标签: ${tag}`);
|
||||
renderTagCloud(await fetchTags().catch(() => []));
|
||||
|
||||
try {
|
||||
showSkeleton(6);
|
||||
const response = await fetch(`${API_BASE}/tags/search?tag=${encodeURIComponent(tag)}&limit=200`);
|
||||
if (!response.ok) throw new Error('搜索失败');
|
||||
const results = await response.json();
|
||||
|
||||
// 保存过滤结果到 filteredAlbums,重置分页
|
||||
filteredAlbums = results;
|
||||
currentDisplayPage = 1;
|
||||
hasMoreAlbums = filteredAlbums.length > albumsPerPage;
|
||||
|
||||
displayAlbums(results);
|
||||
updateStats(results);
|
||||
initLazyLoading();
|
||||
} catch (error) {
|
||||
console.error('标签筛选失败:', error);
|
||||
showError('标签筛选失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取标签(辅助函数)
|
||||
async function fetchTags() {
|
||||
const resp = await fetch(`${API_BASE}/tags/popular?limit=25`);
|
||||
if (!resp.ok) return [];
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// 按人物筛选
|
||||
async function filterByPerson(personName) {
|
||||
currentFilter = { type: 'person', value: personName };
|
||||
showFilterStatus(`人物: ${personName}`);
|
||||
|
||||
// 刷新侧边栏人物高亮
|
||||
try {
|
||||
const peopleResp = await fetch(`${API_BASE}/recommend/popular-persons?limit=30`);
|
||||
if (peopleResp.ok) {
|
||||
const peopleData = await peopleResp.json();
|
||||
renderPeopleCloud(peopleData);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
try {
|
||||
showSkeleton(6);
|
||||
const response = await fetch(`${API_BASE}/people/search?q=${encodeURIComponent(personName)}`);
|
||||
if (!response.ok) throw new Error('搜索失败');
|
||||
const personData = await response.json();
|
||||
const persons = personData.persons || personData;
|
||||
|
||||
if (persons && persons.length > 0) {
|
||||
try {
|
||||
const detailResp = await fetch(`${API_BASE}/people/${encodeURIComponent(personName)}`);
|
||||
if (detailResp.ok) {
|
||||
const detail = await detailResp.json();
|
||||
if (detail && detail.albums) {
|
||||
// 保存过滤结果到 filteredAlbums,重置分页
|
||||
filteredAlbums = detail.albums;
|
||||
currentDisplayPage = 1;
|
||||
hasMoreAlbums = filteredAlbums.length > albumsPerPage;
|
||||
|
||||
displayAlbums(detail.albums);
|
||||
updateStats(detail.albums);
|
||||
initLazyLoading();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 回退 */ }
|
||||
}
|
||||
// 回退到全站搜索
|
||||
filteredAlbums = await performServerSearchWithResult(personName);
|
||||
} catch (error) {
|
||||
console.error('人物筛选失败:', error);
|
||||
filteredAlbums = await performServerSearchWithResult(personName);
|
||||
}
|
||||
|
||||
// 无论回退搜索是否找到结果,都要渲染页面(移除骨架屏)
|
||||
if (filteredAlbums !== null) {
|
||||
currentDisplayPage = 1;
|
||||
hasMoreAlbums = filteredAlbums.length > albumsPerPage;
|
||||
displayAlbums(filteredAlbums);
|
||||
updateStats(filteredAlbums);
|
||||
initLazyLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// 显示筛选状态
|
||||
function showFilterStatus(text) {
|
||||
// 新版筛选条
|
||||
const bar = document.getElementById('filter-bar');
|
||||
const pill = document.getElementById('current-filter-pill');
|
||||
if (bar && pill) {
|
||||
pill.textContent = text;
|
||||
bar.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 兼容旧版
|
||||
const status = document.getElementById('filter-status');
|
||||
const label = document.getElementById('current-filter');
|
||||
if (status && label) {
|
||||
label.textContent = text;
|
||||
status.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
// 清除筛选
|
||||
function clearFilter() {
|
||||
currentFilter = null;
|
||||
filteredAlbums = null; // 清除过滤结果缓存
|
||||
currentDisplayPage = 1;
|
||||
|
||||
// 新版筛选条
|
||||
const bar = document.getElementById('filter-bar');
|
||||
if (bar) bar.style.display = 'none';
|
||||
|
||||
// 兼容旧版
|
||||
const status = document.getElementById('filter-status');
|
||||
if (status) status.style.display = 'none';
|
||||
|
||||
// 清空搜索框
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
updateSearchClear();
|
||||
}
|
||||
|
||||
// 重新渲染标签和人物(清除高亮)
|
||||
loadDiscoverData();
|
||||
|
||||
// 重新加载所有相册
|
||||
loadAlbums(true);
|
||||
}
|
||||
|
||||
// ============ 侧边栏交互 ============
|
||||
|
||||
// 初始化侧边栏状态
|
||||
function initSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && sidebarCollapsed) {
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换侧边栏折叠
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar) return;
|
||||
sidebarCollapsed = !sidebarCollapsed;
|
||||
sidebar.classList.toggle('collapsed', sidebarCollapsed);
|
||||
localStorage.setItem('sidebarCollapsed', sidebarCollapsed);
|
||||
}
|
||||
|
||||
// 移动端切换侧边栏
|
||||
function toggleMobileSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('mobile-overlay');
|
||||
if (!sidebar || !overlay) return;
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
overlay.classList.toggle('active');
|
||||
}
|
||||
|
||||
// 切换视图(首页/收藏夹)
|
||||
function switchView(view) {
|
||||
// 更新导航高亮
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.view === view);
|
||||
});
|
||||
|
||||
// 移动端关闭侧边栏
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('mobile-overlay');
|
||||
if (sidebar && sidebar.classList.contains('mobile-open')) {
|
||||
sidebar.classList.remove('mobile-open');
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
|
||||
if (view === 'home') {
|
||||
backToHome();
|
||||
} else if (view === 'favorites') {
|
||||
showFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框清除按钮
|
||||
function updateSearchClear() {
|
||||
const input = document.getElementById('search-input');
|
||||
const clear = document.getElementById('search-clear');
|
||||
if (input && clear) {
|
||||
clear.style.display = input.value.length > 0 ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
function clearSearch() {
|
||||
const input = document.getElementById('search-input');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
updateSearchClear();
|
||||
if (!currentFilter) {
|
||||
loadAlbums(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 折叠/展开区域(兼容旧版)
|
||||
function toggleSection(name) {
|
||||
collapsedSections[name] = !collapsedSections[name];
|
||||
const cloudId = name === 'people' ? 'people-cloud' : 'tag-cloud';
|
||||
const cloud = document.getElementById(cloudId);
|
||||
const btn = document.querySelector(`#${name}-section .section-toggle i`);
|
||||
if (cloud) {
|
||||
if (collapsedSections[name]) {
|
||||
cloud.style.display = 'none';
|
||||
btn.className = 'fas fa-chevron-down';
|
||||
} else {
|
||||
cloud.style.display = '';
|
||||
btn.className = 'fas fa-chevron-up';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 初始化 ============
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initSidebar();
|
||||
|
||||
// 搜索框输入事件
|
||||
const searchInput = document.getElementById('search-input');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', updateSearchClear);
|
||||
}
|
||||
|
||||
// 键盘快捷键:ESC 关闭筛选/搜索
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (currentFilter) clearFilter();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 导出函数供全局使用
|
||||
window.searchAlbums = searchAlbums;
|
||||
window.openRandomAlbum = openRandomAlbum;
|
||||
window.showFavorites = showFavorites;
|
||||
window.showFavorites = showFavorites;
|
||||
window.openAlbum = openAlbum;
|
||||
window.backToHome = backToHome;
|
||||
window.loadAlbums = loadAlbums;
|
||||
window.removeFavorite = removeFavorite;
|
||||
window.previewFavorite = previewFavorite;
|
||||
window.openFavoriteImage = openFavoriteImage;
|
||||
window.removeFavoriteFromPreview = removeFavoriteFromPreview;
|
||||
window.filterByTag = filterByTag;
|
||||
window.filterByPerson = filterByPerson;
|
||||
window.clearFilter = clearFilter;
|
||||
window.toggleSection = toggleSection;
|
||||
window.toggleSidebar = toggleSidebar;
|
||||
window.toggleMobileSidebar = toggleMobileSidebar;
|
||||
window.switchView = switchView;
|
||||
window.clearSearch = clearSearch;
|
||||
661
dict/tags.json
Normal file
661
dict/tags.json
Normal file
@@ -0,0 +1,661 @@
|
||||
{
|
||||
"version": 4,
|
||||
"description": "标签词典 v4 — 全量 keymap + stopwords(人物规则已融入 keymap)",
|
||||
"metadata": {
|
||||
"style": {
|
||||
"label": "风格",
|
||||
"color_bg": "#f0f9ff",
|
||||
"color_fg": "#0284c7"
|
||||
},
|
||||
"outfit": {
|
||||
"label": "服装",
|
||||
"color_bg": "#fdf2f8",
|
||||
"color_fg": "#db2777"
|
||||
},
|
||||
"scene": {
|
||||
"label": "场景",
|
||||
"color_bg": "#f0fdf4",
|
||||
"color_fg": "#15803d"
|
||||
},
|
||||
"body": {
|
||||
"label": "身体",
|
||||
"color_bg": "#fef3c7",
|
||||
"color_fg": "#b45309"
|
||||
},
|
||||
"mood": {
|
||||
"label": "情绪",
|
||||
"color_bg": "#faf5ff",
|
||||
"color_fg": "#7c3aed"
|
||||
},
|
||||
"topic": {
|
||||
"label": "主题",
|
||||
"color_bg": "#f0f9ff",
|
||||
"color_fg": "#0369a1"
|
||||
},
|
||||
"position": {
|
||||
"label": "姿势",
|
||||
"color_bg": "#fefce8",
|
||||
"color_fg": "#a16207"
|
||||
},
|
||||
"location": {
|
||||
"label": "地点",
|
||||
"color_bg": "#f0fdfa",
|
||||
"color_fg": "#0f766e"
|
||||
},
|
||||
"brand": {
|
||||
"label": "品牌/工作室",
|
||||
"color_bg": "#fff7ed",
|
||||
"color_fg": "#c2410c"
|
||||
},
|
||||
"person": {
|
||||
"label": "人物",
|
||||
"color_bg": "#ecfdf5",
|
||||
"color_fg": "#059669"
|
||||
}
|
||||
},
|
||||
"keymap": {
|
||||
"嫩模": "topic",
|
||||
"108TV": "brand",
|
||||
"108酱": "brand",
|
||||
"Adidas": "brand",
|
||||
"Agent Provocateur": "brand",
|
||||
"Apple": "brand",
|
||||
"Av": "brand",
|
||||
"Beautyleg": "brand",
|
||||
"BlueCake": "brand",
|
||||
"BoLoli": "brand",
|
||||
"CK": "brand",
|
||||
"Calvin Klein": "brand",
|
||||
"CANDY": "brand",
|
||||
"CANDY糖果画报": "brand",
|
||||
"Canon": "brand",
|
||||
"Chanel": "brand",
|
||||
"DKGirl": "brand",
|
||||
"DKGirl御女郎": "brand",
|
||||
"Dior": "brand",
|
||||
"FEILIN": "brand",
|
||||
"FEILIN嗲囡囡": "brand",
|
||||
"Fantasy Factory": "brand",
|
||||
"FeiLin": "brand",
|
||||
"Feiling": "brand",
|
||||
"Gucci": "brand",
|
||||
"Hermes": "brand",
|
||||
"HuaYang": "brand",
|
||||
"IMISS": "brand",
|
||||
"IMiss": "brand",
|
||||
"IMiss爱蜜社": "brand",
|
||||
"ISHOW": "brand",
|
||||
"ISHOW爱秀": "brand",
|
||||
"La Perla": "brand",
|
||||
"LeiLei": "brand",
|
||||
"Louis Vuitton": "brand",
|
||||
"MFModel": "brand",
|
||||
"MFStar": "brand",
|
||||
"MFStar模特": "brand",
|
||||
"MFStar模范学院": "brand",
|
||||
"MiiTao": "brand",
|
||||
"MiiTao蜜桃社": "brand",
|
||||
"MiStar": "brand",
|
||||
"Nike": "brand",
|
||||
"OnlyFans": "brand",
|
||||
"Prada": "brand",
|
||||
"Puma": "brand",
|
||||
"Pure Media": "brand",
|
||||
"PureMedia": "brand",
|
||||
"RuiSG": "brand",
|
||||
"RuiSG瑞丝馆": "brand",
|
||||
"SIW": "brand",
|
||||
"SIW斯文传媒": "brand",
|
||||
"SJA": "brand",
|
||||
"SJA佳爷": "brand",
|
||||
"SLADY": "brand",
|
||||
"SLADY猎女神": "brand",
|
||||
"SSA": "brand",
|
||||
"SSA丝社": "brand",
|
||||
"Shiseido": "brand",
|
||||
"Sony": "brand",
|
||||
"TASHA": "brand",
|
||||
"TASHA系列": "brand",
|
||||
"TGOD": "brand",
|
||||
"TGOD推女神": "brand",
|
||||
"Tristana": "brand",
|
||||
"UXING": "brand",
|
||||
"UXING优星馆": "brand",
|
||||
"Ugirls": "brand",
|
||||
"Ugirls爱尤物": "brand",
|
||||
"V Girls": "brand",
|
||||
"VGIRL": "brand",
|
||||
"VGIRLS": "brand",
|
||||
"VS": "brand",
|
||||
"Victoria Secret": "brand",
|
||||
"Victoria's Secret": "brand",
|
||||
"Victorias Secret": "brand",
|
||||
"WING": "brand",
|
||||
"WingS": "brand",
|
||||
"WingS影私荟": "brand",
|
||||
"XIAOYU": "brand",
|
||||
"XIAOYU语画界": "brand",
|
||||
"XINGYAN": "brand",
|
||||
"XINGYAN星颜社": "brand",
|
||||
"XiuRen": "brand",
|
||||
"XiuRen秀人网": "brand",
|
||||
"YALAYI": "brand",
|
||||
"YALAYI雅拉伊": "brand",
|
||||
"YOUMI尤蜜荟": "brand",
|
||||
"YouMi": "brand",
|
||||
"YouMi尤蜜": "brand",
|
||||
"YouWu": "brand",
|
||||
"YouWu尤物馆": "brand",
|
||||
"爱尤物": "brand",
|
||||
"爱蜜社": "brand",
|
||||
"波萝社": "brand",
|
||||
"飞灵": "brand",
|
||||
"绯月樱": "brand",
|
||||
"花洋": "brand",
|
||||
"花漾": "brand",
|
||||
"美媛馆": "brand",
|
||||
"魅妍社": "brand",
|
||||
"蜜桃社": "brand",
|
||||
"撸图": "brand",
|
||||
"情欲萝莉": "brand",
|
||||
"青豆客": "brand",
|
||||
"瑞丝馆": "brand",
|
||||
"头条女神": "brand",
|
||||
"推女神": "brand",
|
||||
"星颜社": "brand",
|
||||
"秀人": "brand",
|
||||
"秀人网": "brand",
|
||||
"语画界": "brand",
|
||||
"尤果网": "brand",
|
||||
"尤果圈": "brand",
|
||||
"尤蜜": "brand",
|
||||
"尤蜜荟": "brand",
|
||||
"尤物馆": "brand",
|
||||
"旗袍": "outfit",
|
||||
"比基尼": "outfit",
|
||||
"泳装": "outfit",
|
||||
"泳衣": "outfit",
|
||||
"制服": "outfit",
|
||||
"丝袜": "outfit",
|
||||
"内衣": "outfit",
|
||||
"文胸": "outfit",
|
||||
"包臀裙": "outfit",
|
||||
"紧身裙": "outfit",
|
||||
"超短裙": "outfit",
|
||||
"连衣裙": "outfit",
|
||||
"半身裙": "outfit",
|
||||
"短裙": "outfit",
|
||||
"丁字裤": "outfit",
|
||||
"长裙": "outfit",
|
||||
"牛仔裤": "outfit",
|
||||
"热裤": "outfit",
|
||||
"短裤": "outfit",
|
||||
"休闲裤": "outfit",
|
||||
"T恤": "outfit",
|
||||
"衬衫": "outfit",
|
||||
"毛衣": "outfit",
|
||||
"针织衫": "outfit",
|
||||
"西装": "outfit",
|
||||
"外套": "outfit",
|
||||
"风衣": "outfit",
|
||||
"大衣": "outfit",
|
||||
"马甲": "outfit",
|
||||
"卫衣": "outfit",
|
||||
"吊带": "outfit",
|
||||
"抹胸": "outfit",
|
||||
"露背": "outfit",
|
||||
"透视": "outfit",
|
||||
"蕾丝": "outfit",
|
||||
"镂空": "outfit",
|
||||
"开衩": "outfit",
|
||||
"一字肩": "outfit",
|
||||
"露肩": "outfit",
|
||||
"深V": "outfit",
|
||||
"低胸": "outfit",
|
||||
"高开衩": "outfit",
|
||||
"学生服": "outfit",
|
||||
"水手服": "outfit",
|
||||
"护士服": "outfit",
|
||||
"女仆装": "outfit",
|
||||
"洛丽塔": "outfit",
|
||||
"和服": "outfit",
|
||||
"汉服": "outfit",
|
||||
"浴衣": "outfit",
|
||||
"校服": "outfit",
|
||||
"白衬衫": "outfit",
|
||||
"黑衬衫": "outfit",
|
||||
"白裙": "outfit",
|
||||
"黑裙": "outfit",
|
||||
"红裙": "outfit",
|
||||
"裹胸": "outfit",
|
||||
"吊带裙": "outfit",
|
||||
"修身": "outfit",
|
||||
"长靴": "outfit",
|
||||
"高跟鞋": "outfit",
|
||||
"凉鞋": "outfit",
|
||||
"运动鞋": "outfit",
|
||||
"ol套装": "outfit",
|
||||
"工作服": "outfit",
|
||||
"空姐": "outfit",
|
||||
"警服": "outfit",
|
||||
"军装": "outfit",
|
||||
"黑丝": "outfit",
|
||||
"白丝": "outfit",
|
||||
"肉丝": "outfit",
|
||||
"网袜": "outfit",
|
||||
"bikini": "outfit",
|
||||
"swimsuit": "outfit",
|
||||
"stockings": "outfit",
|
||||
"lingerie": "outfit",
|
||||
"dress": "outfit",
|
||||
"skirt": "outfit",
|
||||
"jeans": "outfit",
|
||||
"shirt": "outfit",
|
||||
"coat": "outfit",
|
||||
"heels": "outfit",
|
||||
"室内": "scene",
|
||||
"户外": "scene",
|
||||
"棚拍": "scene",
|
||||
"外景": "scene",
|
||||
"街景": "scene",
|
||||
"夜景": "scene",
|
||||
"海边": "scene",
|
||||
"海滩": "scene",
|
||||
"沙滩": "scene",
|
||||
"泳池": "scene",
|
||||
"浴室": "scene",
|
||||
"浴缸": "scene",
|
||||
"厨房": "scene",
|
||||
"客厅": "scene",
|
||||
"卧室": "scene",
|
||||
"阳台": "scene",
|
||||
"花园": "scene",
|
||||
"庭院": "scene",
|
||||
"天台": "scene",
|
||||
"楼顶": "scene",
|
||||
"屋顶": "scene",
|
||||
"咖啡厅": "scene",
|
||||
"酒店": "scene",
|
||||
"民宿": "scene",
|
||||
"教室": "scene",
|
||||
"图书馆": "scene",
|
||||
"办公室": "scene",
|
||||
"楼梯": "scene",
|
||||
"电梯": "scene",
|
||||
"草地": "scene",
|
||||
"森林": "scene",
|
||||
"树林": "scene",
|
||||
"竹林": "scene",
|
||||
"花丛": "scene",
|
||||
"花海": "scene",
|
||||
"公园": "scene",
|
||||
"操场": "scene",
|
||||
"马路": "scene",
|
||||
"公路": "scene",
|
||||
"天桥": "scene",
|
||||
"隧道": "scene",
|
||||
"床上": "scene",
|
||||
"indoor": "scene",
|
||||
"outdoor": "scene",
|
||||
"street": "scene",
|
||||
"beach": "scene",
|
||||
"pool": "scene",
|
||||
"bathroom": "scene",
|
||||
"bedroom": "scene",
|
||||
"kitchen": "scene",
|
||||
"car": "scene",
|
||||
"cafe": "scene",
|
||||
"hotel": "scene",
|
||||
"美腿": "body",
|
||||
"美胸": "body",
|
||||
"翘臀": "body",
|
||||
"玉足": "body",
|
||||
"美背": "body",
|
||||
"香肩": "body",
|
||||
"酥胸": "body",
|
||||
"纤腰": "body",
|
||||
"蛮腰": "body",
|
||||
"蜂腰": "body",
|
||||
"小蛮腰": "body",
|
||||
"事业线": "body",
|
||||
"锁骨": "body",
|
||||
"马甲线": "body",
|
||||
"腹肌": "body",
|
||||
"长腿": "body",
|
||||
"美足": "body",
|
||||
"白皙": "body",
|
||||
"肌肤": "body",
|
||||
"纹身": "body",
|
||||
"legs": "body",
|
||||
"breasts": "body",
|
||||
"ass": "body",
|
||||
"feet": "body",
|
||||
"back": "body",
|
||||
"shoulders": "body",
|
||||
"waist": "body",
|
||||
"skin": "body",
|
||||
"tattoo": "body",
|
||||
"诱惑": "mood",
|
||||
"唯美": "mood",
|
||||
"娇羞": "mood",
|
||||
"冷傲": "mood",
|
||||
"热情": "mood",
|
||||
"慵懒": "mood",
|
||||
"迷离": "mood",
|
||||
"神秘": "mood",
|
||||
"忧郁": "mood",
|
||||
"伤感": "mood",
|
||||
"快乐": "mood",
|
||||
"开心": "mood",
|
||||
"微笑": "mood",
|
||||
"大笑": "mood",
|
||||
"闭眼": "mood",
|
||||
"凝视": "mood",
|
||||
"魅惑": "mood",
|
||||
"撩人": "mood",
|
||||
"高冷": "mood",
|
||||
"呆萌": "mood",
|
||||
"天真": "mood",
|
||||
"无辜": "mood",
|
||||
"纯真": "mood",
|
||||
"lazy": "mood",
|
||||
"写真": "topic",
|
||||
"cos": "topic",
|
||||
"私房": "topic",
|
||||
"街拍": "topic",
|
||||
"旅拍": "topic",
|
||||
"婚纱照": "topic",
|
||||
"艺术照": "topic",
|
||||
"生活照": "topic",
|
||||
"自拍": "topic",
|
||||
"cosplay": "topic",
|
||||
"角色扮演": "topic",
|
||||
"动漫": "topic",
|
||||
"游戏": "topic",
|
||||
"水着": "topic",
|
||||
"竞泳": "topic",
|
||||
"温泉": "topic",
|
||||
"入浴": "topic",
|
||||
"photoshoot": "topic",
|
||||
"portrait": "topic",
|
||||
"selfie": "topic",
|
||||
"travel": "topic",
|
||||
"wedding": "topic",
|
||||
"Genshin": "topic",
|
||||
"站立": "position",
|
||||
"坐姿": "position",
|
||||
"躺姿": "position",
|
||||
"跪姿": "position",
|
||||
"趴姿": "position",
|
||||
"侧躺": "position",
|
||||
"侧卧": "position",
|
||||
"仰卧": "position",
|
||||
"俯卧": "position",
|
||||
"站姿": "position",
|
||||
"站": "position",
|
||||
"坐": "position",
|
||||
"躺": "position",
|
||||
"跪": "position",
|
||||
"趴": "position",
|
||||
"蹲": "position",
|
||||
"回眸": "position",
|
||||
"背影": "position",
|
||||
"正面": "position",
|
||||
"侧面": "position",
|
||||
"standing": "position",
|
||||
"sitting": "position",
|
||||
"lying": "position",
|
||||
"kneeling": "position",
|
||||
"日本": "location",
|
||||
"韩国": "location",
|
||||
"中国": "location",
|
||||
"台湾": "location",
|
||||
"香港": "location",
|
||||
"泰国": "location",
|
||||
"越南": "location",
|
||||
"东京": "location",
|
||||
"首尔": "location",
|
||||
"北京": "location",
|
||||
"上海": "location",
|
||||
"广州": "location",
|
||||
"深圳": "location",
|
||||
"成都": "location",
|
||||
"重庆": "location",
|
||||
"三亚": "location",
|
||||
"厦门": "location",
|
||||
"丽江": "location",
|
||||
"大理": "location",
|
||||
"曼谷": "location",
|
||||
"芭提雅": "location",
|
||||
"普吉": "location",
|
||||
"济州": "location",
|
||||
"巴厘岛": "location",
|
||||
"马尔代夫": "location",
|
||||
"沙巴": "location",
|
||||
"塞班": "location",
|
||||
"苏梅岛": "location",
|
||||
"普吉岛": "location",
|
||||
"冲绳": "location",
|
||||
"京都": "location",
|
||||
"大阪": "location",
|
||||
"北海道": "location",
|
||||
"阳朔": "location",
|
||||
"japan": "location",
|
||||
"korea": "location",
|
||||
"china": "location",
|
||||
"thailand": "location",
|
||||
"tokyo": "location",
|
||||
"seoul": "location",
|
||||
"bangkok": "location",
|
||||
"phuket": "location",
|
||||
"bali": "location",
|
||||
"日系": "style",
|
||||
"韩系": "style",
|
||||
"森系": "style",
|
||||
"复古": "style",
|
||||
"甜美": "style",
|
||||
"性感": "style",
|
||||
"清新": "style",
|
||||
"冷艳": "style",
|
||||
"优雅": "style",
|
||||
"知性": "style",
|
||||
"可爱": "style",
|
||||
"俏皮": "style",
|
||||
"温柔": "style",
|
||||
"妩媚": "style",
|
||||
"野性": "style",
|
||||
"清纯": "style",
|
||||
"时尚": "style",
|
||||
"潮流": "style",
|
||||
"街头": "style",
|
||||
"学院": "style",
|
||||
"文艺": "style",
|
||||
"古典": "style",
|
||||
"民族": "style",
|
||||
"异域": "style",
|
||||
"简约": "style",
|
||||
"自然": "style",
|
||||
"运动": "style",
|
||||
"sexy": "style",
|
||||
"wild": "style",
|
||||
"retro": "style",
|
||||
"korean": "style",
|
||||
"fashion": "style",
|
||||
"sweet": "style",
|
||||
"pure": "style",
|
||||
"fresh": "style",
|
||||
"elegant": "style",
|
||||
"cute": "style",
|
||||
"vintage": "style",
|
||||
"japanese": "style",
|
||||
"杨晨晨": "person",
|
||||
"水淼": "person",
|
||||
"轩美宣": "person",
|
||||
"王馨瑶": "person",
|
||||
"雪儿": "person",
|
||||
"顾雪儿": "person",
|
||||
"曼曼": "person",
|
||||
"佳佳": "person",
|
||||
"小乔": "person",
|
||||
"周于希": "person",
|
||||
"王雨纯": "person",
|
||||
"绮里嘉": "person",
|
||||
"陆萱萱": "person",
|
||||
"唐安琪": "person",
|
||||
"鱼子酱": "person",
|
||||
"安然": "person",
|
||||
"林星阑": "person",
|
||||
"筱慧": "person",
|
||||
"蠢沫沫": "person",
|
||||
"冯木木": "person",
|
||||
"许诺": "person",
|
||||
"言沫": "person",
|
||||
"丽莎": "person",
|
||||
"易阳": "person",
|
||||
"陈良玲": "person",
|
||||
"蓝夏": "person",
|
||||
"凯竹": "person",
|
||||
"郑颖姗": "person",
|
||||
"刘飞儿": "person",
|
||||
"赵小米": "person",
|
||||
"于姬": "person",
|
||||
"玉兔": "person",
|
||||
"穆菲菲": "person",
|
||||
"夏沫沫": "person",
|
||||
"雪晴": "person",
|
||||
"小夕": "person",
|
||||
"谢芷馨": "person",
|
||||
"顾奈奈": "person",
|
||||
"唐琪儿": "person",
|
||||
"艾小青": "person",
|
||||
"李雅": "person",
|
||||
"晓茜": "person",
|
||||
"李宓儿": "person",
|
||||
"林乐": "person",
|
||||
"雪千寻": "person",
|
||||
"艾静香": "person",
|
||||
"阿薰": "person",
|
||||
"李承美": "person",
|
||||
"葛征": "person",
|
||||
"沈佳熹": "person",
|
||||
"陈思琪": "person",
|
||||
"刘娅": "person",
|
||||
"小瑶": "person",
|
||||
"李梓": "person",
|
||||
"程小烦": "person",
|
||||
"马吉": "person",
|
||||
"叶佳": "person",
|
||||
"冯雪娇": "person",
|
||||
"栗子": "person",
|
||||
"丁筱南": "person",
|
||||
"张思允": "person",
|
||||
"小齿优喵": "person",
|
||||
"KaYa Huang": "person",
|
||||
"Miley": "person",
|
||||
"Alice": "person",
|
||||
"Zia": "person",
|
||||
"Jooa": "person",
|
||||
"Yome": "person",
|
||||
"石川澪": "person",
|
||||
"朱可儿": "person",
|
||||
"苏雨": "person",
|
||||
"熊吖": "person"
|
||||
},
|
||||
"stop_words": [
|
||||
"re",
|
||||
"photos",
|
||||
"photo",
|
||||
"pictures",
|
||||
"picture",
|
||||
"images",
|
||||
"image",
|
||||
"video",
|
||||
"videos",
|
||||
"cos",
|
||||
"and",
|
||||
"the",
|
||||
"of",
|
||||
"in",
|
||||
"a",
|
||||
"an",
|
||||
"is",
|
||||
"to",
|
||||
"for",
|
||||
"part",
|
||||
"vol",
|
||||
"collection",
|
||||
"special",
|
||||
"extra",
|
||||
"bonus",
|
||||
"new",
|
||||
"old",
|
||||
"selfie",
|
||||
"official",
|
||||
"original",
|
||||
"set",
|
||||
"gallery",
|
||||
"album",
|
||||
"page",
|
||||
"issue",
|
||||
"model",
|
||||
"idol",
|
||||
"star",
|
||||
"actor",
|
||||
"actress",
|
||||
"singer",
|
||||
"网",
|
||||
"新人",
|
||||
"图",
|
||||
"套",
|
||||
"合集",
|
||||
"系列",
|
||||
"作品",
|
||||
"高清",
|
||||
"原版",
|
||||
"无水印",
|
||||
"番号",
|
||||
"编号",
|
||||
"with",
|
||||
"without",
|
||||
"preview",
|
||||
"patreon",
|
||||
"no",
|
||||
"site",
|
||||
"web",
|
||||
"online",
|
||||
"digital",
|
||||
"exclusive",
|
||||
"premium",
|
||||
"classic",
|
||||
"photography",
|
||||
"shoot",
|
||||
"shot",
|
||||
"xxx",
|
||||
"com",
|
||||
"www",
|
||||
"https",
|
||||
"http",
|
||||
"Impact"
|
||||
],
|
||||
"person_prefixes": [
|
||||
|
||||
"写真",
|
||||
"系列",
|
||||
"美女模特",
|
||||
"性感女神",
|
||||
"清纯美女",
|
||||
"气质美女",
|
||||
"可爱女孩",
|
||||
"甜美少女",
|
||||
"时尚达人",
|
||||
"女神",
|
||||
"美女",
|
||||
"模特",
|
||||
"小姐姐",
|
||||
"小仙女",
|
||||
"御姐",
|
||||
"萝莉",
|
||||
"辣妹",
|
||||
"潮人"
|
||||
]
|
||||
}
|
||||
687
docs/api.md
Normal file
687
docs/api.md
Normal file
@@ -0,0 +1,687 @@
|
||||
# Imager API 文档
|
||||
|
||||
Base URL: `http://<host>:<port>/api`
|
||||
|
||||
---
|
||||
|
||||
## 相册
|
||||
|
||||
### GET /api/albums
|
||||
获取全量相册列表(从内存缓存)。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:`AlbumInfo[]`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "相册标题",
|
||||
"document": "文件夹名",
|
||||
"description": "描述",
|
||||
"cover_image_id": "uuid",
|
||||
"image_count": 100,
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"tags": ["标签1", "标签2"],
|
||||
"persons": ["人物1"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/search
|
||||
组合搜索(关键词/标签/人物过滤)。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| q | string | - | 标题关键词模糊匹配 |
|
||||
| tag | string | - | 标签精确匹配 |
|
||||
| person | string | - | 人物精确或模糊匹配 |
|
||||
| page | int | 1 | 页码 |
|
||||
| limit | int | 50 | 每页条数(最大 200) |
|
||||
|
||||
**响应**:`AlbumInfo[]`
|
||||
|
||||
---
|
||||
|
||||
### GET /api/album/random
|
||||
从缓存随机返回一个相册。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:`AlbumInfo`
|
||||
|
||||
---
|
||||
|
||||
### GET /api/album/{id}
|
||||
获取相册详情(含图片列表、标签、人物)。
|
||||
|
||||
**参数**(path):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uuid | 相册 ID |
|
||||
|
||||
**响应**:`AlbumWithImages`
|
||||
|
||||
```json
|
||||
{
|
||||
"album": {
|
||||
"id": "uuid",
|
||||
"title": "标题",
|
||||
"description": "描述",
|
||||
"cover_image_id": "uuid",
|
||||
"image_count": 100,
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
},
|
||||
"images": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"album_id": "uuid",
|
||||
"filename": "1.jpg",
|
||||
"original_url": "...",
|
||||
"title": "图片标题",
|
||||
"description": null,
|
||||
"file_size": 123456,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"mime_type": "image/jpeg",
|
||||
"created_at": "..."
|
||||
}
|
||||
],
|
||||
"image_count": 100,
|
||||
"tags": ["标签1", "标签2"],
|
||||
"persons": ["人物1"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/albums
|
||||
创建新相册。
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "相册标题",
|
||||
"document": "文件夹名",
|
||||
"description": "描述(可选)"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**(201 Created):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 图片
|
||||
|
||||
### GET /api/image/{id}
|
||||
获取图片原始二进制数据。
|
||||
|
||||
**参数**(path):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uuid | 图片 ID |
|
||||
|
||||
**响应**:二进制图片数据,Content-Type 按实际 mime 类型。带 ETag,Cache-Control: public, max-age=31536000。
|
||||
|
||||
---
|
||||
|
||||
## 收藏
|
||||
|
||||
### GET /api/favorites
|
||||
获取当前用户的收藏列表。
|
||||
|
||||
**参数**:无(用户固定为 default_user)
|
||||
|
||||
**响应**:`FavoriteWithImage[]`
|
||||
|
||||
---
|
||||
|
||||
### GET /api/favorites/status/{id}
|
||||
检查某图片的收藏状态。
|
||||
|
||||
**参数**(path):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uuid | 图片 ID |
|
||||
|
||||
---
|
||||
|
||||
### POST /api/favorites
|
||||
添加收藏。
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"image_id": "uuid",
|
||||
"album_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**(201 Created):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/favorites/{id}
|
||||
按收藏 ID 取消收藏。
|
||||
|
||||
**参数**(path):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uuid | 收藏 ID |
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/favorites/image/{id}
|
||||
按图片 ID 取消收藏。
|
||||
|
||||
**参数**(path):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | uuid | 图片 ID |
|
||||
|
||||
---
|
||||
|
||||
## 人物
|
||||
|
||||
### GET /api/people
|
||||
从内存缓存获取所有人名列表。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 100,
|
||||
"persons": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "人物名",
|
||||
"display_name": "显示名",
|
||||
"total_albums": 10,
|
||||
"total_images": 50,
|
||||
"aliases": ["别名1", "别名2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/people/search
|
||||
从内存缓存搜索人名。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| q | string | 搜索关键词 |
|
||||
|
||||
**响应**:同上 `PersonListResponse`
|
||||
|
||||
---
|
||||
|
||||
### GET /api/people/{name}
|
||||
获取人物详情及关联相册。
|
||||
|
||||
**参数**(path):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| name | string | 人物名称 |
|
||||
|
||||
**响应**:`PersonWithAlbums`
|
||||
|
||||
```json
|
||||
{
|
||||
"album_count": 10,
|
||||
"person": {
|
||||
"id": 1,
|
||||
"name": "人物名",
|
||||
"display_name": "显示名",
|
||||
"total_albums": 10,
|
||||
"total_images": 50,
|
||||
"original_names": null,
|
||||
"created_at": "",
|
||||
"updated_at": ""
|
||||
},
|
||||
"albums": [/* AlbumInfo[] */]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/people/{id}/aliases
|
||||
查询人物别名。
|
||||
|
||||
**参数**(path):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 人物 ID |
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
从内存缓存计算元数据统计。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total_persons": 100,
|
||||
"total_aliases": 50,
|
||||
"total_albums": 10000,
|
||||
"total_images": 50000,
|
||||
"total_tags": 2000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/tags/search
|
||||
按标签搜索相册。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| tag | string | **必填**,标签名 |
|
||||
|
||||
**响应**:`AlbumInfo[]`
|
||||
|
||||
---
|
||||
|
||||
### GET /api/tags/popular
|
||||
获取热门标签。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| limit | int | 50 | 返回数量 |
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 50,
|
||||
"tags": [
|
||||
{ "tag": "标签名", "count": 1000, "album_count": 1000 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
标签自动补全推荐。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| q | string | - | 输入前缀 |
|
||||
| limit | int | 10 | 返回数量 |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/recommend/persons
|
||||
人物自动补全推荐。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| q | string | - | 输入名称 |
|
||||
| limit | int | 10 | 返回数量 |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/recommend/related
|
||||
关联推荐(标签共现 + 共现人物)。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| context | string | **必填**,标签或人名 |
|
||||
| limit | int | 默认 15 |
|
||||
|
||||
---
|
||||
|
||||
### GET /api/recommend/popular-persons
|
||||
获取热门人物排行榜。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| limit | int | 20(最大 50) | 返回数量 |
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "人物名",
|
||||
"display_name": "显示名",
|
||||
"total_albums": 10,
|
||||
"total_images": 50,
|
||||
"top_tags": ["标签1", "标签2"],
|
||||
"aliases": ["别名1"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 管线
|
||||
|
||||
### POST /api/pipeline/run
|
||||
手动触发完整管线(dict 优先匹配解析)。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"tags_cleaned": 0,
|
||||
"short_tags_removed": 0,
|
||||
"person_like_tags_removed": 0,
|
||||
"dirty_albums_detected": 0,
|
||||
"non_persons_deleted": 0,
|
||||
"stop_word_persons_removed": 0,
|
||||
"new_albums_found": 0,
|
||||
"persons_updated": 0,
|
||||
"tags_updated": 0,
|
||||
"empty_albums_deleted": 0,
|
||||
"dup_albums_deleted": 0,
|
||||
"search_db_synced": 0,
|
||||
"message": "管线执行完毕:..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/pipeline/reparse
|
||||
重置所有相册为未处理状态,重新解析全部标签和人物并同步到 search.db。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"albums_parsed": 100,
|
||||
"persons_updated": 80,
|
||||
"tags_updated": 200,
|
||||
"synced_to_search_db": 100,
|
||||
"message": "重新解析完成:..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/pipeline/reparse-persons
|
||||
重置所有相册,重新解析全部人物(清空 search.db 的人物关联后运行完整管线)。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"albums_parsed": 100,
|
||||
"persons_updated": 80,
|
||||
"non_persons_deleted": 2,
|
||||
"stop_word_persons_removed": 1,
|
||||
"synced_to_search_db": 100,
|
||||
"message": "人物重新解析完成:..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/pipeline/reparse-tags
|
||||
重置所有相册,重新解析全部标签(清空 search.db 的标签关联后运行完整管线)。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"albums_parsed": 100,
|
||||
"tags_updated": 200,
|
||||
"short_tags_removed": 5,
|
||||
"synced_to_search_db": 100,
|
||||
"message": "标签重新解析完成:..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 词典
|
||||
|
||||
### GET /api/dict/stats
|
||||
查看标签词典状态。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"stats": "标签词典: ...",
|
||||
"categories": [
|
||||
{ "name": "style", "label": "风格", "count": 50 }
|
||||
],
|
||||
"total_tags": 2000,
|
||||
"total_stop_words": 20,
|
||||
"person_prefixes": ["prefix1"],
|
||||
"excluded_person_names": ["name1"],
|
||||
"included_person_names": ["name2"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/dict/reload
|
||||
从 search.db 的 dict 表热重载词典到内存。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "词典已从 search.db 重载: ..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/dict/update
|
||||
批量更新 dict 表条目,并自动重载内存词典。
|
||||
|
||||
**请求体**(数组):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"word": "写真",
|
||||
"type": "tag",
|
||||
"category": "topic",
|
||||
"freq": null,
|
||||
"merged_to": null
|
||||
},
|
||||
{
|
||||
"word": "张三",
|
||||
"type": "person",
|
||||
"category": null,
|
||||
"freq": null,
|
||||
"merged_to": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**type 可选值**:`tag`、`person`、`stop_word`、`person_prefix`、`excluded_person`
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "更新 2 条字典条目,词典已从 search.db 重载: ...",
|
||||
"updated": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 缓存
|
||||
|
||||
### POST /api/cache/refresh
|
||||
手动刷新所有内存缓存(AlbumsCache / TagCache / PersonCache)。
|
||||
|
||||
**参数**:无
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "缓存已刷新"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 迁移
|
||||
|
||||
### GET /api/migration/stats
|
||||
查看当前数据库迁移状态。
|
||||
|
||||
**参数**:无
|
||||
|
||||
---
|
||||
|
||||
### POST /api/migration/run
|
||||
执行从旧数据库的数据迁移。
|
||||
|
||||
**参数**(query):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| path | string | **必填**,旧数据库文件路径 |
|
||||
|
||||
---
|
||||
|
||||
## 队列
|
||||
|
||||
### GET /api/queue/stats
|
||||
获取任务队列状态。
|
||||
|
||||
**参数**:无
|
||||
|
||||
---
|
||||
|
||||
## 静态页面
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 首页(相册列表) |
|
||||
| GET | `/album` | 相册详情页(需 ?id=xxx 参数) |
|
||||
| GET | `/assets/*` | 静态资源文件 |
|
||||
|
||||
---
|
||||
|
||||
> 所有 API 响应均为 JSON 格式(仅 `/api/image/{id}` 返回二进制)。无认证鉴权。
|
||||
226
docs/pipeline-architecture.md
Normal file
226
docs/pipeline-architecture.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# 流云图库 — 元数据管线架构文档
|
||||
|
||||
> 版本:v2.0 · 更新:2026-07-19
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
流云图库是一个 Rust 编写的图片管理应用,核心功能是从相册标题中自动提取**人物名称**和**分类标签**,并建立人物-相册关联关系。所有提取和关联逻辑由一套三阶段的**处理管线(Pipeline)** 驱动。
|
||||
|
||||
```
|
||||
相册标题(如 "水淼Aqua cosplay Zani – Wuthering Waves 69 photos")
|
||||
│
|
||||
▼
|
||||
┌─ Pipeline ──────────────────────────────────────┐
|
||||
│ Phase 1 全量解析 提取人名 + 提取标签 │
|
||||
│ Phase 2 人物合并 按中文核心合并重复人物 │
|
||||
│ Phase 3 缓存刷新 重新加载内存缓存 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
│
|
||||
├──→ albums 表(person_name_original, tags 等)
|
||||
├──→ persons 表(人物主表)
|
||||
├──→ person_aliases 表(别名关联)
|
||||
└──→ album_tags 表(标签关联)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 启动流程与触发时机
|
||||
|
||||
应用启动后,管线任务按顺序串行执行三个阶段:
|
||||
|
||||
```
|
||||
启动管线(单次,后台任务 A)
|
||||
│
|
||||
├── ① 扫描导入新相册(import)
|
||||
├── ② 全量解析 + 人物合并(run_pipeline)
|
||||
└── ③ 刷新 AlbumsCache
|
||||
```
|
||||
|
||||
- **启动管线**:import → run_pipeline → refreshCache,三件事**顺序串行**,不并发
|
||||
- **定时管线**:每 30 分钟触发一次,执行 run_pipeline → refreshCache(不重复 import)
|
||||
- **API 触发**:POST /api/pipeline/run 可手动触发 run_pipeline
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:全量解析
|
||||
|
||||
**入口函数:** `phase_parse_all(db)` → `(person_updated, tag_updated)`
|
||||
|
||||
对数据库中**每一个相册**独立执行以下流程:
|
||||
|
||||
```
|
||||
for each album:
|
||||
│
|
||||
├── jieba 分词(词性标注)
|
||||
│ ├──→ 提取推荐人名(词性 nr/nrfg/nrt/nz)
|
||||
│ └──→ 提取推荐标签(词性 n/nz/ns/eng/x)
|
||||
│
|
||||
├── 确定最佳人名
|
||||
│ ├── 有人物库匹配?→ 用库匹配结果
|
||||
│ └── 否则 → 用 jieba 推荐
|
||||
│
|
||||
├── 写入 DB(强制覆盖,不比对旧值)
|
||||
│
|
||||
├── 提取标签
|
||||
│ ├── 三步提取:分类词库 → jieba → 正则兜底
|
||||
│ ├── unique_tags() 去重+排序+坏标签过滤
|
||||
│ └── 写入 DB(强制覆盖,不比对旧值)
|
||||
│
|
||||
└── 下一个相册
|
||||
```
|
||||
|
||||
### 人名提取策略
|
||||
|
||||
按优先级依次尝试:
|
||||
|
||||
1. **人物库精确匹配** — `person_merger::match_title()`,在已有 persons 表中查询标题子串
|
||||
2. **jieba 词性标注** — `extract_person_from_tags()`,优先取 nr(人名)类词
|
||||
3. **标题模式匹配** — `extract_person_name_from_title()`,尝试 cosplay 格式、分隔符切分、正则扫描
|
||||
|
||||
### 人名处理规则
|
||||
|
||||
- **禁止中英混杂**:人名主体为纯中文或纯英文(中文优先级更高)
|
||||
- 中英文混合(如"水淼Aqua")由 `split_person_name_with_aliases` 拆分为:主名="水淼" + 别名="Aqua"
|
||||
- 别名保存到 `person_aliases` 表,用于后续搜索匹配
|
||||
|
||||
### 标签分类体系
|
||||
|
||||
| 分类 | 标识 | 示例词 |
|
||||
|------|------|--------|
|
||||
| 风格 | style | 日系、韩系、复古、甜美、sexy |
|
||||
| 服装 | outfit | 比基尼、旗袍、制服、丝袜 |
|
||||
| 场景 | scene | 室内、户外、街拍、浴室、beach |
|
||||
| 身体 | body | 美腿、翘臀、玉足、美背 |
|
||||
| 情绪 | mood | 清纯、诱惑、唯美、冷艳 |
|
||||
| 主题 | topic | 写真、私房、旅拍、cosplay |
|
||||
| 名词 | noun | jieba 提取的普通名词 |
|
||||
| 专名 | proper | jieba 提取的专有名词 |
|
||||
| 地名 | location | jieba 提取的地名 |
|
||||
| 其他 | other | 正则兜底提取的剩余关键词 |
|
||||
|
||||
### 标签过滤链(从外到内共 5 层)
|
||||
|
||||
```
|
||||
1. is_punctuation_only() → 检测不含任何字/字母/数字,则跳过
|
||||
2. 停用词表过滤 → photos、cosplay、美女、模特 等 50+ 词
|
||||
3. 人名排除 → 排除标签提取时确定的人名
|
||||
4. 词性排除 → 形容词(a)、动词(v)、名动词(vn)、机构(nt) 等不产出标签
|
||||
5. unique_tags() 最终过滤 → 调用 is_bad_tag() 二次确认
|
||||
```
|
||||
|
||||
### 强制覆盖写入
|
||||
|
||||
每次管线执行都是**完全重新解析**,提取到人名就写入覆盖、提取到标签就写入覆盖。不做"新旧好坏"比对:
|
||||
|
||||
- **人名**:jieba/fuzzy 提取到 → 直接写入 `albums.person_name_*`,创建/更新 `persons` 记录及别名关联
|
||||
- **标签**:经过 5 层过滤链后仍有结果 → 直接写入 `albums.tags`,重建 `album_tags` 表
|
||||
|
||||
这样每次启动或定时触发时,标签和人名都会用最新的解析逻辑重新计算并强制覆盖,解析逻辑改进后老数据自动受益。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:人物合并
|
||||
|
||||
**入口函数:** `phase_merge_persons(db)` → `merged_count`
|
||||
|
||||
按**中文核心**自动合并重复人物:
|
||||
|
||||
```
|
||||
1. 扫描所有 persons,提取其中文核心(开头的连续中文字符)
|
||||
2. 将中文核心相同的 person 分为一组
|
||||
3. 每组选相册数最多的作为目标(dst)
|
||||
4. 其余合并到目标:
|
||||
- 移动所有相册关联
|
||||
- 移动所有别名
|
||||
- 源人物原名也作为目标别名
|
||||
- 删除源人物
|
||||
5. 更新目标的统计计数
|
||||
```
|
||||
|
||||
示例:`杨晨晨Yome` 和 `杨晨晨Sugar` 的中文核心都是 "杨晨晨" → 合并为一个人物
|
||||
|
||||
---
|
||||
|
||||
## 核心数据表结构
|
||||
|
||||
### albums 表(关键字段)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | TEXT | UUID |
|
||||
| title | TEXT | 相册标题,来源是文件夹名或 index.json |
|
||||
| person_name_original | TEXT | 提取的原始人名(可能带英文后缀) |
|
||||
| person_name_cleaned | TEXT | 清洗后的人名(仅中文主名) |
|
||||
| tags | TEXT | JSON 数组,如 `["比基尼","室内","清纯"]` |
|
||||
| all_words | TEXT | JSON 数组,如 `[["比基尼","outfit"],["室内","scene"]]` |
|
||||
| processed_at | TEXT | 最后处理时间 |
|
||||
|
||||
### persons 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 自增主键 |
|
||||
| name | TEXT | 人物名(通常是中文主名) |
|
||||
| display_name | TEXT | 显示名 |
|
||||
| total_albums | INTEGER | 关联相册数 |
|
||||
| total_images | INTEGER | 关联图片数 |
|
||||
|
||||
### person_aliases 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 自增主键 |
|
||||
| person_id | INTEGER | 关联 persons.id |
|
||||
| alias_type | TEXT | 别名类型(english/nickname/chinese/original) |
|
||||
| alias_name | TEXT | 别名文本 |
|
||||
|
||||
### album_tags 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 自增主键 |
|
||||
| album_id | TEXT | 关联 albums.id |
|
||||
| tag | TEXT | 标签文本 |
|
||||
|
||||
---
|
||||
|
||||
## 定时任务
|
||||
|
||||
每 30 分钟触发一次,顺序执行:
|
||||
|
||||
```
|
||||
1. run_pipeline() → Phase 1(全量解析)+ Phase 2(人物合并)
|
||||
2. 刷新 AlbumsCache(内存缓存,为 API 提供快速查询)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/pipeline/run | 手动触发一次完整管线 |
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"persons_updated": 12,
|
||||
"tags_updated": 45,
|
||||
"persons_merged": 3,
|
||||
"message": "管线执行完毕:更新人名 12 个,更新标签 45 个,合并人物 3 人"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计原则
|
||||
|
||||
1. **幂等性**:管线可反复执行,每次都是完整重新解析 + 强制覆盖写入
|
||||
2. **渐进改进**:解析逻辑改进后,老数据在下次管线执行时自动受益
|
||||
3. **强制覆盖**:提取到即写入,不做新旧比对,确保数据始终使用最新逻辑
|
||||
4. **中文优先**:人名提取优先中文,中英文混合被拆分为主名+别名
|
||||
5. **多层过滤**:标签经过 5 层过滤链,确保质量
|
||||
6. **纯中文/纯英文**:人名主体不允许中英混杂
|
||||
124
docs/pipeline-spec.md
Normal file
124
docs/pipeline-spec.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# 元数据管线 — 开发规格
|
||||
|
||||
> 2026-07-19
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求概述
|
||||
|
||||
系统在启动和运行时,需要自动完成三个任务:
|
||||
|
||||
| # | 任务 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | **新相册解析** | 扫描磁盘导入的新相册 → 提取人名+标签 → 写入 DB |
|
||||
| 2 | **老数据重解析** | 已有数据的相册用最新逻辑重新提取,强制覆盖 |
|
||||
| 3 | **人物合并** | 按中文核心合并同一人的不同记录 |
|
||||
|
||||
这三件事通过**同一个管道**完成,管道在**启动时跑一次** + **定时每 30 分钟跑一次**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 启动流程(时序)
|
||||
|
||||
当前问题:import 和 pipeline 是两个并发的 `tokio::spawn`,pipeline 固定等 3s,import 可能耗时更长,导致刚导入的相册没被解析。
|
||||
|
||||
**目标时序:**
|
||||
|
||||
```
|
||||
服务启动
|
||||
│
|
||||
├── DB 初始化
|
||||
│
|
||||
├─── 后台任务 A:启动管线(单次)─────┐
|
||||
│ ├── ① 扫描导入新相册 │
|
||||
│ ├── ② Phase 1:全量解析(强制覆盖) │
|
||||
│ ├── ③ Phase 2:人物合并 │
|
||||
│ └── ④ 刷新内存缓存 │
|
||||
│ │
|
||||
├─── 后台任务 B:定时管线(每 30 分钟)──┤
|
||||
│ ├── ① Phase 1:全量解析(强制覆盖) │
|
||||
│ ├── ② Phase 2:人物合并 │
|
||||
│ └── ③ 刷新内存缓存 │
|
||||
│ │
|
||||
└─── HTTP 服务启动 │
|
||||
│
|
||||
注:后台任务 A 和 B 互斥,不会同时执行
|
||||
```
|
||||
|
||||
### 变化要点
|
||||
|
||||
- **启动管线 = import + parse + merge + refreshCache** 依次串行
|
||||
- **定时管线 = parse + merge + refreshCache**(不重复 import)
|
||||
- 管线内部顺序固定不可并发
|
||||
|
||||
---
|
||||
|
||||
## 3. 管线内部结构
|
||||
|
||||
```
|
||||
run_pipeline(db, with_import: bool)
|
||||
│
|
||||
├── [with_import=true] 扫描导入新相册
|
||||
│
|
||||
├── Phase 1:全量解析
|
||||
│ for each album:
|
||||
│ ├── jieba 分词 → 候选人名 + 候选标签
|
||||
│ ├── 人物库匹配 / jieba → 最佳人名
|
||||
│ ├── 强制写入 albums.person_name_* + persons + aliases
|
||||
│ ├── 标签三步提取(词库→jieba→正则)
|
||||
│ ├── unique_tags(去重+排序+坏标签过滤)
|
||||
│ └── 强制写入 albums.tags + album_tags
|
||||
│
|
||||
├── Phase 2:人物合并
|
||||
│ ├── 提取所有人名的中文核心
|
||||
│ ├── 分组 → 每组选最多相册的为目标
|
||||
│ └── 合并相册关联 / 别名,删除源
|
||||
│
|
||||
└── Phase 3:刷新缓存
|
||||
└── 重新加载 albums 到 AlbumsCache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 模块职责
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Pipeline 引擎 | `engine/pipeline.rs` | `phase_parse_all()`、`phase_merge_persons()`、`run_pipeline()` |
|
||||
| 标题解析 | `engine/title_parser.rs` | jieba 分词、人名提取、中英文拆分、别名生成 |
|
||||
| 标签提取 | `engine/tag_extractor.rs` | 分类词库匹配、jieba 名词提取、标点/停用词/坏标签过滤 |
|
||||
| 人物合并 | `engine/person_merger.rs` | 中文核心提取、分组分析、合并执行 |
|
||||
| 导入 | `services/import.rs` | 扫描磁盘目录、创建相册和图片记录 |
|
||||
| DB | `db/metadata.rs` | persons、aliases、album_tags 表的读写 |
|
||||
| 缓存 | `cache/mod.rs` | AlbumsCache + TagCache + PersonCache |
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键设计约束
|
||||
|
||||
1. **强制覆盖**:每次管线执行都是完整重解,直接覆盖 DB 数据
|
||||
2. **幂等**:管线可反复执行,不影响正确性
|
||||
3. **串行**:管线内部各阶段顺序执行,不并发
|
||||
4. **多级过滤**:标签经过 5 层过滤才入库,确保质量
|
||||
5. **中文优先**:人名优先中文,中英混杂拆为"主名+别名"
|
||||
6. **纯中/纯英**:人名主体不允许中英混杂
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现清单
|
||||
|
||||
### 6.1 Pipeline 引擎
|
||||
|
||||
- [x] `phase_parse_all()` — 全量解析
|
||||
- [x] `phase_merge_persons()` — 人物合并
|
||||
- [x] `phase_refresh_cache()` — 刷新缓存(在 main.rs 实现)
|
||||
- [x] `run_pipeline(db)` — 整合解析+合并
|
||||
|
||||
### 6.2 main.rs 启动逻辑
|
||||
|
||||
- [x] 后台任务 A:顺序执行 import → run_pipeline → cache refresh
|
||||
- [x] 后台任务 B:定时每 30 分钟 run_pipeline → cache refresh
|
||||
|
||||
### 6.3 API
|
||||
|
||||
- [x] POST /api/pipeline/run → 全量解析 + 人物合并
|
||||
52
migrations/002_metadata.sql
Normal file
52
migrations/002_metadata.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- 相册元数据扩展字段
|
||||
ALTER TABLE albums ADD COLUMN person_name_original TEXT;
|
||||
ALTER TABLE albums ADD COLUMN person_name_cleaned TEXT;
|
||||
ALTER TABLE albums ADD COLUMN tags TEXT; -- JSON: [tag, ...]
|
||||
ALTER TABLE albums ADD COLUMN all_words TEXT; -- JSON: [[word, pos], ...]
|
||||
ALTER TABLE albums ADD COLUMN raw_title TEXT;
|
||||
ALTER TABLE albums ADD COLUMN processed_at TEXT;
|
||||
|
||||
-- 标签索引表(倒排索引)
|
||||
CREATE TABLE IF NOT EXISTS album_tags (
|
||||
album_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (album_id, tag),
|
||||
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_album_tags_tag ON album_tags(tag);
|
||||
|
||||
-- 人物表
|
||||
CREATE TABLE IF NOT EXISTS persons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
total_albums INTEGER DEFAULT 0,
|
||||
total_images INTEGER DEFAULT 0,
|
||||
original_names TEXT, -- JSON: [raw_name, ...]
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_persons_name ON persons(name);
|
||||
|
||||
-- 别名表
|
||||
CREATE TABLE IF NOT EXISTS person_aliases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_id INTEGER NOT NULL,
|
||||
alias_type TEXT NOT NULL, -- prefix / english / nickname / chinese
|
||||
alias_name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_aliases_name ON person_aliases(alias_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_aliases_person ON person_aliases(person_id);
|
||||
|
||||
-- 人物-相册关联表
|
||||
CREATE TABLE IF NOT EXISTS person_albums (
|
||||
person_id INTEGER NOT NULL,
|
||||
album_id TEXT NOT NULL,
|
||||
PRIMARY KEY (person_id, album_id),
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id),
|
||||
FOREIGN KEY (album_id) REFERENCES albums(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_cleaned ON albums(person_name_cleaned);
|
||||
19
migrations/003_metadata_fix.sql
Normal file
19
migrations/003_metadata_fix.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 别名表增加唯一约束,防止同一个人名下同名别名重复
|
||||
--
|
||||
-- 注意:需要先清理重复数据,再创建索引。
|
||||
-- 如果先 CREATE UNIQUE INDEX,已有重复行时会直接报错。
|
||||
|
||||
-- 1. 清理可能存在的重复别名(保留 id 最小的那条)
|
||||
DELETE FROM person_aliases WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM person_aliases GROUP BY person_id, alias_name
|
||||
);
|
||||
|
||||
-- 2. 创建唯一索引(此时已无重复数据,不会失败)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_aliases_unique ON person_aliases(person_id, alias_name);
|
||||
|
||||
-- 相册关联表上增加唯一索引(已有 PRIMARY KEY,添加显式唯一索引加速)
|
||||
-- 同时增加每张专辑只能有一个 person 的约束
|
||||
-- 先清理旧的重复关联(同一album_id关联了多个person_id的情况)
|
||||
DELETE FROM person_albums WHERE rowid NOT IN (
|
||||
SELECT MIN(rowid) FROM person_albums GROUP BY album_id, person_id
|
||||
);
|
||||
3
migrations/004_person_albums_indexes.sql
Normal file
3
migrations/004_person_albums_indexes.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 为 person_albums 添加 album_id 索引,优化 JOIN 查询和按相册查找的性能
|
||||
-- person_id 已有主键索引 (person_id, album_id) 覆盖,无需重复创建
|
||||
CREATE INDEX IF NOT EXISTS idx_pa_album ON person_albums(album_id);
|
||||
6
migrations/005_cleanup.sql
Normal file
6
migrations/005_cleanup.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 清理不再使用的索引
|
||||
-- 注意:不重建 albums 表,因为 4 张子表(images/favorites/album_tags/person_albums)
|
||||
-- 通过 FOREIGN KEY 引用了 albums(id),DROP TABLE 即使关闭外键检查也会失败。
|
||||
-- 未使用的列(person_name_original/all_words/tags)已由应用层忽略,不影响功能。
|
||||
|
||||
DROP INDEX IF EXISTS idx_albums_cleaned;
|
||||
284
scripts/import_test_data.py
Normal file
284
scripts/import_test_data.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
从 192.168.1.100:4500 API 拉取指定数量相册作为测试数据(多线程并行版)
|
||||
导入到 Rust imager 项目的 SQLite 数据库和 albums 目录
|
||||
"""
|
||||
import requests
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
|
||||
API_BASE = "http://192.168.1.100:4500"
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "imager.db")
|
||||
ALBUMS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "albums")
|
||||
|
||||
# 全局 DB 写锁
|
||||
db_lock = threading.Lock()
|
||||
# 进度计数器
|
||||
progress_lock = threading.Lock()
|
||||
completed_count = [0]
|
||||
total_count = [0]
|
||||
|
||||
# 线程本地 Session(复用连接)
|
||||
thread_local = threading.local()
|
||||
|
||||
|
||||
def get_session():
|
||||
if not hasattr(thread_local, "session"):
|
||||
thread_local.session = requests.Session()
|
||||
thread_local.session.timeout = 60
|
||||
return thread_local.session
|
||||
|
||||
|
||||
def fetch_albums(limit=100):
|
||||
"""获取相册列表"""
|
||||
url = f"{API_BASE}/api/albums"
|
||||
print(f"[1/4] 获取相册列表: {url}")
|
||||
resp = requests.get(url, timeout=30)
|
||||
data = resp.json()
|
||||
|
||||
if isinstance(data, dict) and "albums" in data:
|
||||
albums = data["albums"]
|
||||
elif isinstance(data, list):
|
||||
albums = data
|
||||
else:
|
||||
print(f"未知响应格式: {type(data)}")
|
||||
return []
|
||||
|
||||
albums = albums[:limit]
|
||||
print(f" 获取到 {len(albums)} 个相册")
|
||||
return albums
|
||||
|
||||
|
||||
def fetch_album_images(album_id):
|
||||
"""获取相册的图片列表"""
|
||||
url = f"{API_BASE}/api/album/{album_id}"
|
||||
try:
|
||||
resp = requests.get(url, timeout=30)
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and "images" in data:
|
||||
return data["images"]
|
||||
elif isinstance(data, list):
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f" 获取图片列表失败 {album_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def download_single_image(img):
|
||||
"""下载单张图片(线程安全)"""
|
||||
img_id = img.get("id", str(uuid.uuid4()))
|
||||
mime = img.get("mime_type", "image/jpeg")
|
||||
ext = "jpg"
|
||||
if mime and "/" in mime:
|
||||
ext = mime.split("/")[-1]
|
||||
|
||||
album_id = str(img.get("_album_id", ""))
|
||||
filename = f"{img_id}.{ext}"
|
||||
db_filename = f"{album_id}/{filename}" # 数据库路径:{album_id}/{img_id}.ext
|
||||
save_path = os.path.join(ALBUMS_DIR, album_id, filename)
|
||||
|
||||
# 跳过已下载
|
||||
if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
|
||||
file_size = os.path.getsize(save_path)
|
||||
width, height = get_image_dimensions(save_path)
|
||||
return (img_id, db_filename, file_size, width, height, mime)
|
||||
|
||||
url = f"{API_BASE}/api/image/{img_id}"
|
||||
try:
|
||||
session = get_session()
|
||||
resp = session.get(url, timeout=60)
|
||||
if resp.status_code == 200:
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
file_size = len(resp.content)
|
||||
width, height = get_image_dimensions(save_path)
|
||||
return (img_id, db_filename, file_size, width, height, resp.headers.get("content-type", mime))
|
||||
except Exception as e:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_image_dimensions(path):
|
||||
"""获取图片尺寸(二进制头解析)"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(32)
|
||||
if header[:2] == b"\xff\xd8":
|
||||
f.seek(2)
|
||||
while True:
|
||||
chunk = f.read(4)
|
||||
if len(chunk) < 4:
|
||||
break
|
||||
if chunk[0] != 0xFF:
|
||||
break
|
||||
seg_type = chunk[1]
|
||||
if seg_type == 0xDA:
|
||||
break
|
||||
seg_len = int.from_bytes(chunk[2:4], "big")
|
||||
if seg_type in (0xC0, 0xC1, 0xC2):
|
||||
data = f.read(seg_len - 2)
|
||||
if len(data) >= 5:
|
||||
return int.from_bytes(data[1:3], "big"), int.from_bytes(data[3:5], "big")
|
||||
f.seek(seg_len - 2, 1)
|
||||
return 0, 0
|
||||
elif header[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big")
|
||||
elif header[:4] == b"RIFF" and header[8:12] == b"WEBP":
|
||||
return int.from_bytes(header[24:26], "little"), int.from_bytes(header[26:28], "little")
|
||||
elif header[:6] in (b"GIF87a", b"GIF89a"):
|
||||
return int.from_bytes(header[6:8], "little"), int.from_bytes(header[8:10], "little")
|
||||
return 0, 0
|
||||
except Exception:
|
||||
return 0, 0
|
||||
|
||||
|
||||
def sync_album_to_db(conn, album, images_info):
|
||||
"""将相册和图片写入数据库(调用方持有 db_lock)"""
|
||||
cursor = conn.cursor()
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
album_id = album["id"]
|
||||
title = album.get("title", "Untitled")
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT OR REPLACE INTO albums
|
||||
(id, title, document, description, cover_image_id, image_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
album_id, title, album_id,
|
||||
album.get("description"),
|
||||
images_info[0][0] if images_info else None,
|
||||
len(images_info),
|
||||
album.get("created_at", now),
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
for img_id, filename, file_size, width, height, mime_type in images_info:
|
||||
cursor.execute(
|
||||
"""INSERT OR REPLACE INTO images
|
||||
(id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(img_id, album_id, filename, f"{API_BASE}/api/image/{img_id}",
|
||||
f"图片 {img_id[:8]}", None, file_size, width, height, mime_type or "image/jpeg", now),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def process_album(album, conn, dl_workers):
|
||||
"""处理单个相册:下载所有图片并写入数据库"""
|
||||
aid = album["id"]
|
||||
title = album.get("title", "Untitled")[:60]
|
||||
|
||||
# 获取图片列表
|
||||
images = fetch_album_images(aid)
|
||||
if not images:
|
||||
with progress_lock:
|
||||
completed_count[0] += 1
|
||||
print(f"[{completed_count[0]}/{total_count[0]}] {title} — 无图片,跳过")
|
||||
return 0
|
||||
|
||||
# 为每个图片打上 album_id 标记
|
||||
for img in images:
|
||||
img["_album_id"] = aid
|
||||
|
||||
# 并行下载
|
||||
downloaded = []
|
||||
with ThreadPoolExecutor(max_workers=dl_workers) as pool:
|
||||
futures = {pool.submit(download_single_image, img): img for img in images}
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
downloaded.append(result)
|
||||
|
||||
if not downloaded:
|
||||
with progress_lock:
|
||||
completed_count[0] += 1
|
||||
print(f"[{completed_count[0]}/{total_count[0]}] {title} — 0 张下载成功")
|
||||
return 0
|
||||
|
||||
# 写入数据库
|
||||
with db_lock:
|
||||
sync_album_to_db(conn, album, downloaded)
|
||||
|
||||
n = len(downloaded)
|
||||
with progress_lock:
|
||||
completed_count[0] += 1
|
||||
print(f"[{completed_count[0]}/{total_count[0]}] {title} — {n} 张图片")
|
||||
|
||||
return n
|
||||
|
||||
|
||||
def main():
|
||||
total = int(sys.argv[1]) if len(sys.argv) > 1 else 100
|
||||
dl_workers = int(sys.argv[2]) if len(sys.argv) > 2 else 8
|
||||
album_workers = int(sys.argv[3]) if len(sys.argv) > 3 else 4
|
||||
|
||||
print(f"=== 从 API 导入 {total} 个相册(并行下载: {dl_workers}线程/相册, 并行相册: {album_workers})===\n")
|
||||
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
os.makedirs(ALBUMS_DIR, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
|
||||
# 获取已有相册
|
||||
existing = set()
|
||||
for row in conn.execute("SELECT id FROM albums"):
|
||||
existing.add(row[0])
|
||||
print(f" 数据库中已有 {len(existing)} 个相册")
|
||||
|
||||
# 获取相册列表
|
||||
albums = fetch_albums(total)
|
||||
if not albums:
|
||||
print("未获取到相册,退出")
|
||||
return
|
||||
|
||||
new_albums = [a for a in albums if a["id"] not in existing]
|
||||
skipped = len(albums) - len(new_albums)
|
||||
if skipped > 0:
|
||||
print(f" 跳过 {skipped} 个已存在的相册")
|
||||
print(f" 需要导入 {len(new_albums)} 个新相册\n")
|
||||
|
||||
if not new_albums:
|
||||
print("没有新相册需要导入")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
total_count[0] = len(new_albums)
|
||||
total_images = 0
|
||||
t0 = time.time()
|
||||
|
||||
# 并行处理相册
|
||||
with ThreadPoolExecutor(max_workers=album_workers) as pool:
|
||||
futures = {pool.submit(process_album, album, conn, dl_workers): album for album in new_albums}
|
||||
for future in as_completed(futures):
|
||||
n = future.result()
|
||||
total_images += n
|
||||
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# 重建 image_count
|
||||
print(f"\n[4/4] 更新相册图片计数...")
|
||||
conn.execute("""UPDATE albums SET image_count = (SELECT COUNT(*) FROM images WHERE images.album_id = albums.id)""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"\n=== 导入完成 ===")
|
||||
print(f" 成功导入: {len(new_albums)} 个相册")
|
||||
print(f" 总图片数: {total_images} 张")
|
||||
print(f" 耗时: {elapsed:.1f} 秒")
|
||||
print(f" 数据库: {DB_PATH}")
|
||||
print(f" 图片目录: {ALBUMS_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
295
src/cache/mod.rs
vendored
Normal file
295
src/cache/mod.rs
vendored
Normal file
@@ -0,0 +1,295 @@
|
||||
//! 缓存层 — 内存缓存管理
|
||||
|
||||
use crate::search_db::SearchDb;
|
||||
use crate::db::Database;
|
||||
use crate::models::*;
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
||||
/// 相册缓存
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlbumsCache {
|
||||
pub albums: Vec<AlbumInfo>,
|
||||
pub search_index: HashMap<String, Vec<AlbumInfo>>,
|
||||
pub last_updated: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl AlbumsCache {
|
||||
pub fn new() -> Self {
|
||||
Self { albums: Vec::new(), search_index: HashMap::new(), last_updated: chrono::Utc::now() }
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
(chrono::Utc::now() - self.last_updated).num_minutes() >= 30
|
||||
}
|
||||
|
||||
pub fn update(&mut self, albums: Vec<AlbumInfo>) {
|
||||
self.search_index.clear();
|
||||
for album in &albums {
|
||||
let title_lower = album.title.to_lowercase();
|
||||
for word in title_lower.split_whitespace() {
|
||||
if word.len() >= 2 {
|
||||
self.search_index.entry(word.to_string()).or_default().push(album.clone());
|
||||
}
|
||||
}
|
||||
self.search_index.entry(title_lower).or_default().push(album.clone());
|
||||
}
|
||||
self.albums = albums;
|
||||
self.last_updated = chrono::Utc::now();
|
||||
info!("相册缓存已更新,共 {} 个相册,{} 个搜索索引项", self.albums.len(), self.search_index.len());
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str) -> Vec<AlbumInfo> {
|
||||
let q = query.to_lowercase().trim().to_string();
|
||||
if q.is_empty() { return Vec::new(); }
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 1. 完整匹配
|
||||
if let Some(matches) = self.search_index.get(&q) {
|
||||
for album in matches {
|
||||
if seen.insert(album.id) { results.push(album.clone()); }
|
||||
}
|
||||
}
|
||||
// 2. 单词匹配
|
||||
for word in q.split_whitespace() {
|
||||
if word.len() >= 2 {
|
||||
if let Some(matches) = self.search_index.get(word) {
|
||||
for album in matches {
|
||||
if seen.insert(album.id) { results.push(album.clone()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. 模糊搜索
|
||||
if results.is_empty() {
|
||||
for album in &self.albums {
|
||||
let tl = album.title.to_lowercase();
|
||||
if tl.contains(&q) {
|
||||
if seen.insert(album.id) { results.push(album.clone()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. 部分匹配
|
||||
if results.is_empty() && q.len() >= 2 {
|
||||
for album in &self.albums {
|
||||
let tl = album.title.to_lowercase();
|
||||
if q.split_whitespace().any(|w| w.len() >= 2 && tl.contains(w)) {
|
||||
if seen.insert(album.id) { results.push(album.clone()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
/// 标签缓存
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TagCache {
|
||||
pub popular_tags: Vec<TagStat>,
|
||||
/// tag → 包含该标签的相册列表(倒排索引,供按标签搜索用)
|
||||
pub tag_albums: HashMap<String, Vec<AlbumInfo>>,
|
||||
pub last_updated: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl TagCache {
|
||||
pub fn update(&mut self, tags: Vec<TagStat>, tag_albums: HashMap<String, Vec<AlbumInfo>>) {
|
||||
self.popular_tags = tags;
|
||||
self.tag_albums = tag_albums;
|
||||
self.last_updated = chrono::Utc::now();
|
||||
info!("标签缓存已更新,共 {} 个热门标签,{} 个标签倒排索引项",
|
||||
self.popular_tags.len(), self.tag_albums.len());
|
||||
}
|
||||
|
||||
/// 按标签搜索相册(从缓存中检索)
|
||||
pub fn search_albums(&self, tag: &str) -> Vec<AlbumInfo> {
|
||||
self.tag_albums.get(tag).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 人物缓存
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PersonCache {
|
||||
pub persons: Vec<PersonWithCounts>,
|
||||
/// 搜索索引:关键词小写 → person_id 集合
|
||||
pub search_index: HashMap<String, Vec<String>>,
|
||||
/// person_id → person 投影(用于按 id 快速访问)
|
||||
pub id_map: HashMap<String, PersonWithCounts>,
|
||||
pub last_updated: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl PersonCache {
|
||||
pub fn update(&mut self, persons: Vec<PersonWithCounts>) {
|
||||
self.search_index.clear();
|
||||
self.id_map.clear();
|
||||
|
||||
for p in &persons {
|
||||
// 构建搜索索引(姓名 + 所有别名)
|
||||
let name_lower = p.name.to_lowercase();
|
||||
for word in name_lower.split_whitespace() {
|
||||
if word.len() >= 2 {
|
||||
self.search_index.entry(word.to_string()).or_default().push(p.id.clone());
|
||||
}
|
||||
}
|
||||
self.search_index.entry(name_lower.clone()).or_default().push(p.id.clone());
|
||||
// 显示名
|
||||
if let Some(ref dn) = p.display_name {
|
||||
let dn_lower = dn.to_lowercase();
|
||||
for word in dn_lower.split_whitespace() {
|
||||
if word.len() >= 2 {
|
||||
self.search_index.entry(word.to_string()).or_default().push(p.id.clone());
|
||||
}
|
||||
}
|
||||
self.search_index.entry(dn_lower).or_default().push(p.id.clone());
|
||||
}
|
||||
// 别名
|
||||
for alias in &p.aliases {
|
||||
let alias_lower = alias.to_lowercase();
|
||||
for word in alias_lower.split_whitespace() {
|
||||
if word.len() >= 2 {
|
||||
self.search_index.entry(word.to_string()).or_default().push(p.id.clone());
|
||||
}
|
||||
}
|
||||
self.search_index.entry(alias_lower).or_default().push(p.id.clone());
|
||||
}
|
||||
self.id_map.insert(p.id.clone(), p.clone());
|
||||
}
|
||||
|
||||
self.persons = persons;
|
||||
self.last_updated = chrono::Utc::now();
|
||||
info!("人物缓存已更新,共 {} 人,{} 个搜索索引项",
|
||||
self.persons.len(), self.search_index.len());
|
||||
}
|
||||
|
||||
/// 从缓存搜索人物(按姓名/别名)
|
||||
pub fn search(&self, query: &str) -> Vec<PersonWithCounts> {
|
||||
let q = query.to_lowercase().trim().to_string();
|
||||
if q.is_empty() || q.len() < 2 { return Vec::new(); }
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
// 1. 完整匹配
|
||||
if let Some(ids) = self.search_index.get(&q) {
|
||||
for id in ids {
|
||||
if seen.insert(id.clone()) {}
|
||||
}
|
||||
}
|
||||
// 2. 单词匹配
|
||||
for word in q.split_whitespace() {
|
||||
if word.len() >= 2 {
|
||||
if let Some(ids) = self.search_index.get(word) {
|
||||
for id in ids {
|
||||
seen.insert(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. 模糊匹配
|
||||
if seen.is_empty() {
|
||||
for p in &self.persons {
|
||||
let full = format!("{} {} {}", p.name.to_lowercase(),
|
||||
p.display_name.as_deref().unwrap_or(""),
|
||||
p.aliases.join(" ").to_lowercase());
|
||||
if full.contains(&q) {
|
||||
seen.insert(p.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen.iter()
|
||||
.filter_map(|id| self.id_map.get(id).cloned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新所有缓存(相册 + 标签 + 人物)
|
||||
/// 相册从主库读取,标签/人物从 search.db 读取
|
||||
pub async fn refresh_all_caches(
|
||||
db: &Database,
|
||||
search_db: &SearchDb,
|
||||
albums_cache: &Arc<RwLock<AlbumsCache>>,
|
||||
persons_cache: &Arc<RwLock<PersonCache>>,
|
||||
tags_cache: &Arc<RwLock<TagCache>>,
|
||||
) {
|
||||
// 1. 从主库读取相册(tags 和 persons 从 search.db 关联)
|
||||
if let Ok(albums) = db.get_all_albums().await {
|
||||
// 从 search.db 批量获取 album → tags 映射
|
||||
let tag_rows = sqlx::query(
|
||||
r#"SELECT at.album_id, t.name FROM album_tags at JOIN tags t ON at.tag_id = t.id"#
|
||||
).fetch_all(&search_db.pool).await.unwrap_or_default();
|
||||
let mut album_tags: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
||||
for r in &tag_rows {
|
||||
let aid: String = r.get("album_id");
|
||||
let tag: String = r.get("name");
|
||||
album_tags.entry(aid).or_default().push(tag);
|
||||
}
|
||||
|
||||
// 从 search.db 批量获取 album → persons 映射
|
||||
let person_rows = sqlx::query(
|
||||
r#"SELECT ap.album_id, p.name FROM album_persons ap JOIN persons p ON ap.person_id = p.id"#
|
||||
).fetch_all(&search_db.pool).await.unwrap_or_default();
|
||||
let mut album_persons: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
||||
for r in &person_rows {
|
||||
let aid: String = r.get("album_id");
|
||||
let name: String = r.get("name");
|
||||
album_persons.entry(aid).or_default().push(name);
|
||||
}
|
||||
|
||||
let enriched: Vec<AlbumInfo> = albums.into_iter().map(|mut a| {
|
||||
let id_str = a.id.to_string();
|
||||
a.tags = album_tags.remove(&id_str).unwrap_or_default();
|
||||
a.persons = album_persons.remove(&id_str).unwrap_or_default();
|
||||
a
|
||||
}).collect();
|
||||
|
||||
// 2. 刷新标签(tag→albums 倒排索引直接从已富化的 album 列表构建,零 DB 查询)
|
||||
let popular_tags = search_db.get_popular_tags(200).await;
|
||||
let mut tag_albums: HashMap<String, Vec<AlbumInfo>> = HashMap::new();
|
||||
for album in &enriched {
|
||||
for tag in &album.tags {
|
||||
tag_albums.entry(tag.clone()).or_default().push(album.clone());
|
||||
}
|
||||
}
|
||||
let mut tc = tags_cache.write().await;
|
||||
tc.update(popular_tags, tag_albums);
|
||||
|
||||
// 3. 刷新相册缓存(依赖 enriched 数据,必须在标签刷新之后执行)
|
||||
let mut c = albums_cache.write().await;
|
||||
c.update(enriched);
|
||||
}
|
||||
|
||||
// 3. 刷新人物(从 search.db,修正 total_albums/total_images)
|
||||
let mut persons = search_db.get_all_persons_with_aliases().await;
|
||||
// 从 album_persons 重新计算实际相册数(修复缓存中 total_albums 可能为 0 的问题)
|
||||
for p in &mut persons {
|
||||
let actual: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM album_persons WHERE person_id = ?"
|
||||
).bind(&p.id).fetch_one(&search_db.pool).await.unwrap_or(0);
|
||||
if actual > 0 {
|
||||
p.total_albums = actual;
|
||||
}
|
||||
}
|
||||
let mut pc = persons_cache.write().await;
|
||||
pc.update(persons);
|
||||
|
||||
info!("全部缓存已刷新");
|
||||
}
|
||||
|
||||
/// 仅刷新人物缓存(管线执行后调用)
|
||||
pub async fn refresh_persons_cache(search_db: &SearchDb, persons_cache: &Arc<RwLock<PersonCache>>) {
|
||||
let mut persons = search_db.get_all_persons_with_aliases().await;
|
||||
for p in &mut persons {
|
||||
let actual: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM album_persons WHERE person_id = ?"
|
||||
).bind(&p.id).fetch_one(&search_db.pool).await.unwrap_or(0);
|
||||
if actual > 0 {
|
||||
p.total_albums = actual;
|
||||
}
|
||||
}
|
||||
let mut pc = persons_cache.write().await;
|
||||
pc.update(persons);
|
||||
}
|
||||
@@ -8,6 +8,9 @@ pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
pub storage: StorageConfig,
|
||||
pub queue: QueueConfig,
|
||||
pub api_server: Option<ApiServerConfig>,
|
||||
#[serde(default)]
|
||||
pub old_db: Option<OldDbConfig>,
|
||||
}
|
||||
|
||||
|
||||
@@ -35,15 +38,37 @@ pub struct QueueConfig {
|
||||
pub idle_request_rate_threshold: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ApiServerConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct OldDbConfig {
|
||||
pub path: String,
|
||||
#[serde(default = "default_auto_migrate")]
|
||||
pub auto_migrate: bool,
|
||||
#[serde(default = "default_auto_parse")]
|
||||
pub auto_parse: bool,
|
||||
}
|
||||
|
||||
fn default_auto_migrate() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_auto_parse() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self, config::ConfigError> {
|
||||
let config = config::Config::builder()
|
||||
.add_source(config::File::with_name("config"))
|
||||
.build()?;
|
||||
|
||||
|
||||
config.try_deserialize()
|
||||
}
|
||||
|
||||
|
||||
pub fn server_address(&self) -> String {
|
||||
format!("{}:{}", self.server.host, self.server.port)
|
||||
}
|
||||
|
||||
846
src/database.rs
846
src/database.rs
@@ -1,846 +0,0 @@
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, Row};
|
||||
use crate::config::Config;
|
||||
use crate::models::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Database {
|
||||
pub pool: SqlitePool,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn new(config: &Config) -> Result<Self, sqlx::Error> {
|
||||
// 从URL中提取文件路径
|
||||
let url = &config.database.url;
|
||||
let file_path = if url.starts_with("sqlite:") {
|
||||
&url[7..] // 移除 "sqlite:" 前缀
|
||||
} else {
|
||||
url
|
||||
};
|
||||
|
||||
// 确保数据目录存在
|
||||
if let Some(parent) = std::path::Path::new(file_path).parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果数据库文件不存在,创建一个空文件
|
||||
if !std::path::Path::new(file_path).exists() {
|
||||
std::fs::File::create(file_path)?;
|
||||
println!("创建新的数据库文件: {}", file_path);
|
||||
}
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(10) // 优化:减少最大连接数,避免资源浪费
|
||||
.min_connections(2) // 优化:减少最小连接数
|
||||
.acquire_timeout(std::time::Duration::from_secs(5)) // 优化:减少获取连接超时时间
|
||||
.idle_timeout(std::time::Duration::from_secs(300)) // 优化:减少空闲超时时间
|
||||
.max_lifetime(std::time::Duration::from_secs(1800)) // 优化:减少最大生命周期
|
||||
.connect(url)
|
||||
.await?;
|
||||
|
||||
// 在迁移之前设置SQLite性能优化参数
|
||||
// 这些PRAGMA语句必须在事务外执行
|
||||
sqlx::query("PRAGMA journal_mode=WAL;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA wal_autocheckpoint=4000;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA synchronous=NORMAL;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA cache_size=-64000;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA mmap_size=268435456;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA temp_store=MEMORY;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA foreign_keys=ON;").execute(&pool).await?;
|
||||
|
||||
// 创建表
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
// 确保image_count字段存在并正确初始化
|
||||
let db = Database {
|
||||
pool,
|
||||
config: config.clone(), // 保存配置副本
|
||||
};
|
||||
db.ensure_image_count_field().await?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
// pub async fn get_albums(&self, page: Option<usize>, limit: Option<usize>) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
// let page = page.unwrap_or(1);
|
||||
// let limit = limit.unwrap_or(50); // 减少默认加载数量,提高响应速度
|
||||
// let offset = (page - 1) * limit;
|
||||
//
|
||||
// let albums = sqlx::query(
|
||||
// r#"
|
||||
// SELECT
|
||||
// id,
|
||||
// title,
|
||||
// document,
|
||||
// description,
|
||||
// cover_image_id,
|
||||
// image_count,
|
||||
// created_at
|
||||
// FROM albums
|
||||
// ORDER BY created_at DESC
|
||||
// LIMIT ? OFFSET ?
|
||||
// "#
|
||||
// )
|
||||
// .bind(limit as i64)
|
||||
// .bind(offset as i64)
|
||||
// .fetch_all(&self.pool)
|
||||
// .await?;
|
||||
//
|
||||
// let mut result = Vec::new();
|
||||
// for row in albums {
|
||||
// let id_str = row.get::<String, _>("id");
|
||||
// let id = match Uuid::parse_str(&id_str) {
|
||||
// Ok(uuid) => uuid,
|
||||
// Err(e) => {
|
||||
// eprintln!("无效的UUID格式: {} - 错误: {}", id_str, e);
|
||||
// continue; // 跳过无效的记录
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// result.push(AlbumInfo {
|
||||
// id,
|
||||
// title: row.get("title"),
|
||||
// document: row.get("document"),
|
||||
// description: row.get("description"),
|
||||
// cover_image_id: row.get("cover_image_id"),
|
||||
// image_count: row.get("image_count"),
|
||||
// created_at: row.get::<String, _>("created_at"),
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// Ok(result)
|
||||
// }
|
||||
// 相册操作
|
||||
pub async fn get_all_albums(&self) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
|
||||
let albums = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
document,
|
||||
description,
|
||||
cover_image_id,
|
||||
image_count,
|
||||
created_at
|
||||
FROM albums
|
||||
ORDER BY created_at DESC
|
||||
"#
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for row in albums {
|
||||
let id_str = row.get::<String, _>("id");
|
||||
let id = match Uuid::parse_str(&id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的UUID格式: {} - 错误: {}", id_str, e);
|
||||
continue; // 跳过无效的记录
|
||||
}
|
||||
};
|
||||
|
||||
result.push(AlbumInfo {
|
||||
id,
|
||||
title: row.get("title"),
|
||||
document: row.get("document"),
|
||||
description: row.get("description"),
|
||||
cover_image_id: row.get("cover_image_id"),
|
||||
image_count: row.get("image_count"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// pub async fn search_albums(&self, query: &str, page: Option<usize>, limit: Option<usize>) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
// let search_pattern = format!("%{}%", query);
|
||||
// let page = page.unwrap_or(1);
|
||||
// let limit = limit.unwrap_or(50); // 减少默认加载数量,提高响应速度
|
||||
// let offset = (page - 1) * limit;
|
||||
//
|
||||
// let albums = sqlx::query(
|
||||
// r#"
|
||||
// SELECT
|
||||
// id,
|
||||
// title,
|
||||
// document,
|
||||
// description,
|
||||
// cover_image_id,
|
||||
// image_count,
|
||||
// created_at
|
||||
// FROM albums
|
||||
// WHERE title LIKE ?
|
||||
// ORDER BY created_at DESC
|
||||
// LIMIT ? OFFSET ?
|
||||
// "#
|
||||
// )
|
||||
// .bind(&search_pattern)
|
||||
// .bind(limit as i64)
|
||||
// .bind(offset as i64)
|
||||
// .fetch_all(&self.pool)
|
||||
// .await?;
|
||||
//
|
||||
// let mut result = Vec::new();
|
||||
// for row in albums {
|
||||
// let id_str = row.get::<String, _>("id");
|
||||
// let id = match Uuid::parse_str(&id_str) {
|
||||
// Ok(uuid) => uuid,
|
||||
// Err(e) => {
|
||||
// eprintln!("无效的UUID格式: {} - 错误: {}", id_str, e);
|
||||
// continue; // 跳过无效的记录
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// result.push(AlbumInfo {
|
||||
// id,
|
||||
// title: row.get("title"),
|
||||
// document: row.get("document"),
|
||||
// description: row.get("description"),
|
||||
// cover_image_id: row.get("cover_image_id"),
|
||||
// image_count: row.get("image_count"),
|
||||
// created_at: row.get::<String, _>("created_at"),
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// Ok(result)
|
||||
// }
|
||||
|
||||
pub async fn get_album_with_images(&self, album_id: Uuid) -> Result<Option<AlbumWithImages>, sqlx::Error> {
|
||||
// 获取相册基本信息
|
||||
let album_row = sqlx::query(
|
||||
"SELECT * FROM albums WHERE id = ?"
|
||||
)
|
||||
.bind(album_id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = album_row {
|
||||
let id_str = row.get::<String, _>("id");
|
||||
let id = match Uuid::parse_str(&id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的相册UUID格式: {} - 错误: {}", id_str, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let cover_image_id = match row.get::<Option<String>, _>("cover_image_id") {
|
||||
Some(s) => match Uuid::parse_str(&s) {
|
||||
Ok(uuid) => Some(uuid),
|
||||
Err(e) => {
|
||||
eprintln!("无效的封面图片UUID格式: {} - 错误: {}", s, e);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None
|
||||
};
|
||||
|
||||
let album = Album {
|
||||
id,
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
cover_image_id,
|
||||
image_count: row.get("image_count"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
updated_at: row.get::<String, _>("updated_at"),
|
||||
};
|
||||
|
||||
// 获取相册中的图片
|
||||
let image_rows = sqlx::query(
|
||||
"SELECT * FROM images WHERE album_id = ?"
|
||||
)
|
||||
.bind(album_id.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut images = Vec::new();
|
||||
for row in image_rows {
|
||||
let image_id_str = row.get::<String, _>("id");
|
||||
let image_id = match Uuid::parse_str(&image_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的图片UUID格式: {} - 错误: {}", image_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let album_id_str = row.get::<String, _>("album_id");
|
||||
let album_id = match Uuid::parse_str(&album_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的相册UUID格式: {} - 错误: {}", album_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
images.push(Image {
|
||||
id: image_id,
|
||||
album_id,
|
||||
filename: row.get("filename"),
|
||||
original_url: row.get("original_url"),
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
file_size: row.get("file_size"),
|
||||
width: row.get("width"),
|
||||
height: row.get("height"),
|
||||
mime_type: row.get("mime_type"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
});
|
||||
}
|
||||
|
||||
let image_count = images.len() as i64;
|
||||
|
||||
Ok(Some(AlbumWithImages {
|
||||
album,
|
||||
images,
|
||||
image_count,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_album(&self, album: CreateAlbum) -> Result<Uuid, sqlx::Error> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO albums (id, title, document, description, image_count, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?)"
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(album.title)
|
||||
.bind(album.document)
|
||||
.bind(album.description)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
// 图片操作
|
||||
// pub async fn add_image(&self, image: CreateImage) -> Result<Uuid, sqlx::Error> {
|
||||
// let id = Uuid::new_v4();
|
||||
// let now = chrono::Utc::now().to_rfc3339();
|
||||
//
|
||||
// sqlx::query(
|
||||
// "INSERT INTO images (id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
// )
|
||||
// .bind(id.to_string())
|
||||
// .bind(image.album_id.to_string())
|
||||
// .bind(image.filename)
|
||||
// .bind(image.original_url)
|
||||
// .bind(image.title)
|
||||
// .bind(image.description)
|
||||
// .bind(image.file_size)
|
||||
// .bind(image.width)
|
||||
// .bind(image.height)
|
||||
// .bind(image.mime_type)
|
||||
// .bind(&now)
|
||||
// .execute(&self.pool)
|
||||
// .await?;
|
||||
//
|
||||
// // 更新相册的图片数量
|
||||
// self.update_album_image_count(image.album_id).await?;
|
||||
//
|
||||
// // 如果这是相册的第一张图片,设置为封面
|
||||
// let image_count = sqlx::query_scalar::<_, i64>(
|
||||
// "SELECT COUNT(*) FROM images WHERE album_id = ?"
|
||||
// )
|
||||
// .bind(image.album_id.to_string())
|
||||
// .fetch_one(&self.pool)
|
||||
// .await?;
|
||||
//
|
||||
// if image_count == 1 {
|
||||
// sqlx::query(
|
||||
// "UPDATE albums SET cover_image_id = ? WHERE id = ?"
|
||||
// )
|
||||
// .bind(id.to_string())
|
||||
// .bind(image.album_id.to_string())
|
||||
// .execute(&self.pool)
|
||||
// .await?;
|
||||
// }
|
||||
//
|
||||
// Ok(id)
|
||||
// }
|
||||
|
||||
/// 更新相册的图片数量
|
||||
pub async fn update_album_image_count(&self, album_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE albums
|
||||
SET image_count = (
|
||||
SELECT COUNT(*) FROM images WHERE album_id = ?
|
||||
),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
"#
|
||||
)
|
||||
.bind(album_id.to_string())
|
||||
.bind(album_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_images(&self, images: Vec<CreateImage>) -> Result<Vec<Uuid>, sqlx::Error> {
|
||||
if images.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// 由于生命周期问题,简化实现,不使用重试机制
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut ids = Vec::new();
|
||||
|
||||
// 保存第一个相册ID用于封面设置
|
||||
let first_album_id = images.first().map(|img| img.album_id);
|
||||
|
||||
// 使用事务确保批量插入的原子性
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
for image in &images {
|
||||
let id = Uuid::new_v4();
|
||||
ids.push(id);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO images (id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(image.album_id.to_string())
|
||||
.bind(&image.filename)
|
||||
.bind(&image.original_url)
|
||||
.bind(&image.title)
|
||||
.bind(&image.description)
|
||||
.bind(image.file_size)
|
||||
.bind(image.width)
|
||||
.bind(image.height)
|
||||
.bind(&image.mime_type)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
tx.commit().await?;
|
||||
|
||||
// 批量设置封面图片(只对每个相册的第一批图片设置)
|
||||
if let Some(album_id) = first_album_id {
|
||||
// 检查相册是否已有图片
|
||||
let existing_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM images WHERE album_id = ?"
|
||||
)
|
||||
.bind(album_id.to_string())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
// 如果这是相册的第一批图片,设置第一张为封面
|
||||
if existing_count == images.len() as i64 {
|
||||
if let Some(first_id) = ids.first() {
|
||||
sqlx::query(
|
||||
"UPDATE albums SET cover_image_id = ? WHERE id = ?"
|
||||
)
|
||||
.bind(first_id.to_string())
|
||||
.bind(album_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新相册的图片数量
|
||||
self.update_album_image_count(album_id).await?;
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// 获取相册中的图片数量(直接从image_count字段读取)
|
||||
pub async fn get_album_image_count(&self, album_id: Uuid) -> Result<i64, sqlx::Error> {
|
||||
let count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM images WHERE album_id = ?"
|
||||
)
|
||||
.bind(album_id.to_string())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 确保image_count字段存在并正确初始化
|
||||
pub async fn ensure_image_count_field(&self) -> Result<(), sqlx::Error> {
|
||||
// 检查字段是否存在
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('albums') WHERE name = 'image_count'"
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if exists == 0 {
|
||||
// 字段不存在,添加字段
|
||||
sqlx::query("ALTER TABLE albums ADD COLUMN image_count INTEGER NOT NULL DEFAULT 0")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// 更新现有数据的image_count
|
||||
sqlx::query(
|
||||
"UPDATE albums SET image_count = (
|
||||
SELECT COUNT(*) FROM images WHERE images.album_id = albums.id
|
||||
)"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
println!("✅ image_count字段已成功添加并初始化");
|
||||
} else {
|
||||
println!("ℹ️ image_count字段已存在,跳过初始化");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除相册及其关联的图片
|
||||
pub async fn delete_album(&self, album_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// 删除相册关联的图片
|
||||
sqlx::query("DELETE FROM images WHERE album_id = ?")
|
||||
.bind(album_id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 删除相册
|
||||
sqlx::query("DELETE FROM albums WHERE id = ?")
|
||||
.bind(album_id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// pub async fn delete_image(&self, image_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||
// // 先获取图片所属的相册ID
|
||||
// let album_id_result = sqlx::query_scalar::<_, String>(
|
||||
// r#"
|
||||
// SELECT album_id FROM images WHERE id = ?
|
||||
// "#
|
||||
// )
|
||||
// .bind(image_id.to_string())
|
||||
// .fetch_optional(&self.pool)
|
||||
// .await?;
|
||||
//
|
||||
// let result = sqlx::query(
|
||||
// r#"
|
||||
// DELETE FROM images
|
||||
// WHERE id = ?
|
||||
// "#
|
||||
// )
|
||||
// .bind(image_id.to_string())
|
||||
// .execute(&self.pool)
|
||||
// .await?;
|
||||
//
|
||||
// // 如果删除成功且找到了相册ID,更新相册图片数量
|
||||
// if result.rows_affected() > 0 {
|
||||
// if let Some(album_id_str) = album_id_result {
|
||||
// if let Ok(album_id) = Uuid::parse_str(&album_id_str) {
|
||||
// self.update_album_image_count(album_id).await?;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Ok(result.rows_affected() > 0)
|
||||
// }
|
||||
|
||||
pub async fn update_album_cover(&self, album_id: Uuid, cover_image_id: Option<Uuid>) -> Result<(), sqlx::Error> {
|
||||
let cover_id_str = cover_image_id.map(|id| id.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE albums SET cover_image_id = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(cover_id_str)
|
||||
.bind(album_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_image(&self, image_id: Uuid) -> Result<Option<Image>, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
"SELECT * FROM images WHERE id = ?"
|
||||
)
|
||||
.bind(image_id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let image_id_str = row.get::<String, _>("id");
|
||||
let image_id = match Uuid::parse_str(&image_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的图片UUID格式: {} - 错误: {}", image_id_str, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let album_id_str = row.get::<String, _>("album_id");
|
||||
let album_id = match Uuid::parse_str(&album_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的相册UUID格式: {} - 错误: {}", album_id_str, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(Image {
|
||||
id: image_id,
|
||||
album_id,
|
||||
filename: row.get("filename"),
|
||||
original_url: row.get("original_url"),
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
file_size: row.get("file_size"),
|
||||
width: row.get("width"),
|
||||
height: row.get("height"),
|
||||
mime_type: row.get("mime_type"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// 收藏功能数据库操作
|
||||
|
||||
// 获取用户的收藏列表
|
||||
pub async fn get_user_favorites(&self, user_id: &str) -> Result<Vec<FavoriteWithImage>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
f.id as favorite_id,
|
||||
f.user_id,
|
||||
f.image_id,
|
||||
f.album_id,
|
||||
f.created_at as favorite_created_at,
|
||||
i.id as image_id,
|
||||
i.album_id,
|
||||
i.filename,
|
||||
i.original_url,
|
||||
i.title,
|
||||
i.description,
|
||||
i.file_size,
|
||||
i.width,
|
||||
i.height,
|
||||
i.mime_type,
|
||||
i.created_at as image_created_at,
|
||||
a.title as album_title
|
||||
FROM favorites f
|
||||
JOIN images i ON f.image_id = i.id
|
||||
JOIN albums a ON f.album_id = a.id
|
||||
WHERE f.user_id = ?
|
||||
ORDER BY f.created_at DESC
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut favorites = Vec::new();
|
||||
for row in rows {
|
||||
let favorite_id_str = row.get::<String, _>("favorite_id");
|
||||
let favorite_id = match Uuid::parse_str(&favorite_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的收藏UUID格式: {} - 错误: {}", favorite_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let image_id_str = row.get::<String, _>("image_id");
|
||||
let image_id = match Uuid::parse_str(&image_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的图片UUID格式: {} - 错误: {}", image_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let album_id_str = row.get::<String, _>("album_id");
|
||||
let album_id = match Uuid::parse_str(&album_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的相册UUID格式: {} - 错误: {}", album_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let favorite = Favorite {
|
||||
id: favorite_id,
|
||||
user_id: row.get("user_id"),
|
||||
image_id,
|
||||
album_id,
|
||||
created_at: row.get::<String, _>("favorite_created_at"),
|
||||
};
|
||||
|
||||
let image = Image {
|
||||
id: image_id,
|
||||
album_id,
|
||||
filename: row.get("filename"),
|
||||
original_url: row.get("original_url"),
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
file_size: row.get("file_size"),
|
||||
width: row.get("width"),
|
||||
height: row.get("height"),
|
||||
mime_type: row.get("mime_type"),
|
||||
created_at: row.get::<String, _>("image_created_at"),
|
||||
};
|
||||
|
||||
favorites.push(FavoriteWithImage {
|
||||
favorite,
|
||||
image,
|
||||
album_title: row.get("album_title"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(favorites)
|
||||
}
|
||||
|
||||
// 检查图片是否被用户收藏
|
||||
pub async fn get_favorite_status(&self, user_id: &str, image_id: Uuid) -> Result<FavoriteStatus, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id FROM favorites WHERE user_id = ? AND image_id = ?"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(image_id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let favorite_id_str = row.get::<String, _>("id");
|
||||
let favorite_id = match Uuid::parse_str(&favorite_id_str) {
|
||||
Ok(uuid) => Some(uuid),
|
||||
Err(e) => {
|
||||
eprintln!("无效的收藏UUID格式: {} - 错误: {}", favorite_id_str, e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FavoriteStatus {
|
||||
is_favorited: true,
|
||||
favorite_id,
|
||||
})
|
||||
} else {
|
||||
Ok(FavoriteStatus {
|
||||
is_favorited: false,
|
||||
favorite_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 添加收藏
|
||||
pub async fn add_favorite(&self, user_id: &str, favorite_data: CreateFavorite) -> Result<Uuid, sqlx::Error> {
|
||||
let favorite_id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO favorites (id, user_id, image_id, album_id, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(favorite_id.to_string())
|
||||
.bind(user_id)
|
||||
.bind(favorite_data.image_id.to_string())
|
||||
.bind(favorite_data.album_id.to_string())
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(favorite_id)
|
||||
}
|
||||
|
||||
// 移除收藏(通过收藏ID)
|
||||
pub async fn remove_favorite(&self, favorite_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"DELETE FROM favorites WHERE id = ?"
|
||||
)
|
||||
.bind(favorite_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 移除收藏(通过用户ID和图片ID)
|
||||
pub async fn remove_favorite_by_image(&self, user_id: &str, image_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"DELETE FROM favorites WHERE user_id = ? AND image_id = ?"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(image_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取配置
|
||||
pub fn get_config(&self) -> &Config {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 获取未处理的图片(即缩略图可能不存在的图片)
|
||||
pub async fn get_unprocessed_images(&self, limit: i64) -> Result<Vec<Image>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT * FROM images
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
"#
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut images = Vec::new();
|
||||
for row in rows {
|
||||
let image_id_str = row.get::<String, _>("id");
|
||||
let image_id = match Uuid::parse_str(&image_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的图片UUID格式: {} - 错误: {}", image_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let album_id_str = row.get::<String, _>("album_id");
|
||||
let album_id = match Uuid::parse_str(&album_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的相册UUID格式: {} - 错误: {}", album_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
images.push(Image {
|
||||
id: image_id,
|
||||
album_id,
|
||||
filename: row.get("filename"),
|
||||
original_url: row.get("original_url"),
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
file_size: row.get("file_size"),
|
||||
width: row.get("width"),
|
||||
height: row.get("height"),
|
||||
mime_type: row.get("mime_type"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(images)
|
||||
}
|
||||
}
|
||||
321
src/db/album.rs
Normal file
321
src/db/album.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use super::Database;
|
||||
use crate::models::*;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
impl Database {
|
||||
// ── 查询 ──
|
||||
|
||||
pub async fn get_all_albums(&self) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT id, title, document, description, cover_image_id, image_count, created_at
|
||||
FROM albums ORDER BY created_at DESC"#
|
||||
).fetch_all(&self.pool).await?;
|
||||
|
||||
Ok(rows.iter().map(|r| {
|
||||
let id_str = r.get::<String, _>("id");
|
||||
AlbumInfo {
|
||||
id: Uuid::parse_str(&id_str).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"),
|
||||
tags: vec![],
|
||||
persons: vec![],
|
||||
}
|
||||
}).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"),
|
||||
tags: vec![],
|
||||
persons: vec![],
|
||||
}
|
||||
}).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())
|
||||
.fetch_optional(&self.pool).await?;
|
||||
|
||||
let Some(row) = row else { return Ok(None) };
|
||||
|
||||
let id_str = row.get::<String, _>("id");
|
||||
let id = Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil());
|
||||
let cover = row.get::<Option<String>, _>("cover_image_id")
|
||||
.and_then(|s| Uuid::parse_str(&s).ok());
|
||||
|
||||
let album = Album {
|
||||
id,
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
cover_image_id: cover,
|
||||
image_count: row.get("image_count"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
};
|
||||
|
||||
let img_rows = sqlx::query("SELECT * FROM images WHERE album_id = ?")
|
||||
.bind(album_id.to_string()).fetch_all(&self.pool).await?;
|
||||
|
||||
let mut images = Vec::new();
|
||||
for r in &img_rows {
|
||||
let img_id = Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil());
|
||||
let aid = Uuid::parse_str(&r.get::<String, _>("album_id")).unwrap_or_else(|_| Uuid::nil());
|
||||
images.push(super::image::Image {
|
||||
id: img_id, album_id: aid,
|
||||
filename: r.get("filename"),
|
||||
original_url: r.get("original_url"),
|
||||
title: r.get("title"),
|
||||
description: r.get("description"),
|
||||
file_size: r.get("file_size"),
|
||||
width: r.get("width"),
|
||||
height: r.get("height"),
|
||||
mime_type: r.get("mime_type"),
|
||||
created_at: r.get("created_at"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Some(AlbumWithImages { album, images: images.clone(), image_count: images.len() as i64, tags: vec![], persons: vec![] }))
|
||||
}
|
||||
|
||||
pub async fn get_all_albums_full(&self) -> Vec<AlbumMeta> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT id, title, cover_image_id, image_count
|
||||
FROM albums"#
|
||||
).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
rows.iter().map(|r| AlbumMeta {
|
||||
id: r.get("id"), title: r.get("title"),
|
||||
cover_image_id: r.get("cover_image_id"),
|
||||
image_count: r.get("image_count"),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// 按 ID 列表批量查询相册元数据
|
||||
pub async fn get_albums_by_ids(&self, ids: &[String]) -> Vec<AlbumMeta> {
|
||||
if ids.is_empty() { return Vec::new(); }
|
||||
|
||||
let placeholders: Vec<String> = (0..ids.len()).map(|_| "?".to_string()).collect();
|
||||
let sql = format!(
|
||||
"SELECT id, title, cover_image_id, image_count
|
||||
FROM albums WHERE id IN ({})",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
for id in ids {
|
||||
query = query.bind(id);
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.unwrap_or_default();
|
||||
rows.iter().map(|r| AlbumMeta {
|
||||
id: r.get("id"), title: r.get("title"),
|
||||
cover_image_id: r.get("cover_image_id"),
|
||||
image_count: r.get("image_count"),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// 获取所有未处理的相册 ID(processed_at IS NULL),用于管线收集新相册
|
||||
pub async fn get_unprocessed_album_ids(&self) -> Vec<String> {
|
||||
let rows = sqlx::query_scalar::<_, String>(
|
||||
"SELECT id FROM albums WHERE processed_at IS NULL"
|
||||
).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
rows
|
||||
}
|
||||
|
||||
// ── 写入 ──
|
||||
|
||||
pub async fn create_album(&self, album: CreateAlbum) -> Result<Uuid, sqlx::Error> {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
let id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO albums (id, title, document, description, image_count, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?)"
|
||||
).bind(id.to_string()).bind(album.title).bind(album.document).bind(album.description).bind(&now).bind(&now)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 将所有相册重置为未处理状态(processed_at = NULL),下次管线会重新解析全部相册
|
||||
pub async fn reset_all_processed_at(&self) -> usize {
|
||||
sqlx::query("UPDATE albums SET processed_at = NULL")
|
||||
.execute(&self.pool).await.unwrap_or_default()
|
||||
.rows_affected() as usize
|
||||
}
|
||||
|
||||
pub async fn delete_album(&self, album_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
let aid = album_id.to_string();
|
||||
// 先删除 person_albums(无 CASCADE)
|
||||
sqlx::query("DELETE FROM person_albums WHERE album_id = ?").bind(&aid).execute(&self.pool).await?;
|
||||
// 再删除 albums(images/favorites/album_tags 由 CASCADE 自动处理)
|
||||
sqlx::query("DELETE FROM albums WHERE id = ?").bind(&aid).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量删除相册(级联清理所有关联数据)
|
||||
pub async fn delete_albums_batch(&self, album_ids: &[Uuid]) -> usize {
|
||||
if album_ids.is_empty() { return 0; }
|
||||
let _guard = self.write_lock.lock().await;
|
||||
for id in album_ids {
|
||||
let aid = id.to_string();
|
||||
sqlx::query("DELETE FROM person_albums WHERE album_id = ?").bind(&aid).execute(&self.pool).await.ok();
|
||||
sqlx::query("DELETE FROM albums WHERE id = ?").bind(&aid).execute(&self.pool).await.ok();
|
||||
}
|
||||
album_ids.len()
|
||||
}
|
||||
|
||||
/// 查询 image_count = 0 的空相册
|
||||
pub async fn get_empty_albums(&self) -> Vec<AlbumInfo> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT id, title, document, description, cover_image_id, image_count, created_at
|
||||
FROM albums WHERE image_count IS NULL OR image_count = 0
|
||||
ORDER BY created_at DESC"#
|
||||
).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
rows.iter().map(|r| {
|
||||
let id_str = r.get::<String, _>("id");
|
||||
AlbumInfo {
|
||||
id: Uuid::parse_str(&id_str).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"),
|
||||
tags: vec![], persons: vec![],
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// 查找重名相册,返回结构:每个元素为 (keep_id, keep_document, [(dup_id, dup_document)])
|
||||
pub async fn find_duplicate_albums(&self) -> Vec<(Uuid, String, Vec<(Uuid, String)>)> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT id, title, document FROM albums WHERE title != '' ORDER BY created_at ASC"#
|
||||
).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
// title → [(id, document)]
|
||||
let mut title_map: std::collections::HashMap<String, Vec<(Uuid, String)>> = std::collections::HashMap::new();
|
||||
for r in &rows {
|
||||
let title: String = r.get("title");
|
||||
let id_str: String = r.get("id");
|
||||
let doc: String = r.get("document");
|
||||
if let Ok(id) = Uuid::parse_str(&id_str) {
|
||||
title_map.entry(title).or_default().push((id, doc));
|
||||
}
|
||||
}
|
||||
|
||||
title_map.into_iter()
|
||||
.filter(|(_, items)| items.len() > 1)
|
||||
.map(|(_, items)| {
|
||||
let keep = items[0].clone(); // (keep_id, keep_document)
|
||||
let dups = items[1..].to_vec();
|
||||
(keep.0, keep.1, dups)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn update_album_cover(&self, album_id: Uuid, image_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE albums SET cover_image_id = ? WHERE id = ?")
|
||||
.bind(image_id.to_string()).bind(album_id.to_string()).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_album_image_count(&self, album_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"UPDATE albums SET image_count = (SELECT COUNT(*) FROM images WHERE album_id = ?), updated_at = datetime('now') WHERE id = ?"#
|
||||
).bind(album_id.to_string()).bind(album_id.to_string()).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_album_image_count(&self, album_id: Uuid) -> Result<i64, sqlx::Error> {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM images WHERE album_id = ?")
|
||||
.bind(album_id.to_string()).fetch_one(&self.pool).await
|
||||
}
|
||||
}
|
||||
83
src/db/batch.rs
Normal file
83
src/db/batch.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! 批量写入 — 在单个事务中写入多个相册的解析结果
|
||||
//!
|
||||
//! 配合 Phase 1a(并行解析)使用,所有脏相册在一个事务内写入,
|
||||
//! 减少 round-trip 和 WAL fsync 次数。
|
||||
|
||||
use super::Database;
|
||||
use crate::models::parse::ParseResult;
|
||||
use sqlx::Transaction;
|
||||
use sqlx::sqlite::Sqlite;
|
||||
|
||||
impl Database {
|
||||
/// 批量写入多个相册的解析结果(Phase 1b)
|
||||
///
|
||||
/// 全部在一个事务中完成:
|
||||
/// 1. 批量 upsert persons
|
||||
/// 2. 批量 update 相册 person_name
|
||||
/// 3. 批量 insert person_album 关联
|
||||
/// 4. 批量 insert 别名
|
||||
/// 5. 批量 update 标签
|
||||
/// 6. 批量 update person_counts
|
||||
pub async fn batch_write_parse_results(
|
||||
&self,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
results: &[ParseResult],
|
||||
) -> Result<(), sqlx::Error> {
|
||||
for result in results {
|
||||
let album_id = &result.album_id;
|
||||
|
||||
// ── 标记处理时间(无论是否有人物,只要进了解析管线就标记)──
|
||||
sqlx::query(
|
||||
"UPDATE albums SET processed_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(album_id)
|
||||
.execute(&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?;
|
||||
|
||||
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 表)──
|
||||
if !result.tags.is_empty() {
|
||||
sqlx::query("DELETE FROM album_tags WHERE album_id = ?")
|
||||
.bind(album_id).execute(&mut **tx).await?;
|
||||
for (t, _) in &result.tags {
|
||||
sqlx::query("INSERT INTO album_tags (album_id, tag) VALUES (?, ?)")
|
||||
.bind(album_id).bind(t).execute(&mut **tx).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
83
src/db/connection.rs
Normal file
83
src/db/connection.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||
use crate::config::Config;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub struct Database {
|
||||
pub(crate) pool: SqlitePool,
|
||||
pub config: Config,
|
||||
/// 写互斥锁:单协程串行化所有写操作,消除 WAL 锁争用
|
||||
pub(crate) write_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn new(config: &Config) -> Result<Self, sqlx::Error> {
|
||||
let url = &config.database.url;
|
||||
let file_path = if url.starts_with("sqlite:") { &url[7..] } else { url };
|
||||
|
||||
if let Some(parent) = std::path::Path::new(file_path).parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
if !std::path::Path::new(file_path).exists() {
|
||||
std::fs::File::create(file_path)?;
|
||||
println!("创建新的数据库文件: {}", file_path);
|
||||
}
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(2) // 写串行化后无需过多连接
|
||||
.min_connections(1)
|
||||
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||
.idle_timeout(std::time::Duration::from_secs(300))
|
||||
.max_lifetime(std::time::Duration::from_secs(1800))
|
||||
.connect(url)
|
||||
.await?;
|
||||
|
||||
// SQLite 高并发优化 PRAGMA
|
||||
sqlx::query("PRAGMA journal_mode=WAL;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA synchronous=NORMAL;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA busy_timeout=5000;").execute(&pool).await?; // 等待 5s 避免 SQLITE_BUSY
|
||||
sqlx::query("PRAGMA wal_autocheckpoint=1000;").execute(&pool).await?; // 小 WAL → 短 checkpoint
|
||||
sqlx::query("PRAGMA journal_size_limit=67108864;").execute(&pool).await?; // WAL 上限 64MB
|
||||
sqlx::query("PRAGMA cache_size=-64000;").execute(&pool).await?; // 64KB 页缓存
|
||||
sqlx::query("PRAGMA mmap_size=268435456;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA temp_store=MEMORY;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA foreign_keys=ON;").execute(&pool).await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
let db = Database { pool, config: config.clone(), write_lock: Mutex::new(()) };
|
||||
db.ensure_image_count_field().await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// 执行 WAL checkpoint(TRUNCATE),将 WAL 完全合并回主文件并清理 -wal / -shm 文件
|
||||
///
|
||||
/// 应在服务优雅关闭时调用,确保数据库文件处于干净一致的状态,
|
||||
/// 避免下次启动时 WAL 恢复带来的额外开销。
|
||||
pub async fn wal_checkpoint(&self) {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
// wal_checkpoint 返回一行 (busy, log, checkpointed),只关注结果忽略返回值
|
||||
match sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.execute(&self.pool).await
|
||||
{
|
||||
Ok(_) => println!("[DB] WAL checkpoint(TRUNCATE) 完成,数据库文件已合并"),
|
||||
Err(e) => eprintln!("[DB] WAL checkpoint 失败: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保 image_count 字段存在
|
||||
async fn ensure_image_count_field(&self) -> Result<(), sqlx::Error> {
|
||||
let has_col = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('albums') WHERE name = 'image_count'"
|
||||
).fetch_one(&self.pool).await.unwrap_or(0);
|
||||
|
||||
if has_col == 0 {
|
||||
sqlx::query("ALTER TABLE albums ADD COLUMN image_count INTEGER DEFAULT 0").execute(&self.pool).await?;
|
||||
println!("✅ image_count 字段已添加");
|
||||
} else {
|
||||
println!("ℹ️ image_count字段已存在,跳过初始化");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
70
src/db/favorite.rs
Normal file
70
src/db/favorite.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use super::Database;
|
||||
use crate::models::*;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_user_favorites(&self, user_id: &str) -> Result<Vec<FavoriteWithImage>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT f.id, f.user_id, f.image_id, f.album_id, f.created_at,
|
||||
i.album_id as img_album_id, i.filename, i.original_url, i.title, i.description,
|
||||
i.file_size, i.width, i.height, i.mime_type, i.created_at as img_created_at,
|
||||
a.title as album_title
|
||||
FROM favorites f
|
||||
JOIN images i ON f.image_id = i.id
|
||||
JOIN albums a ON f.album_id = a.id
|
||||
WHERE f.user_id = ? ORDER BY f.created_at DESC"#
|
||||
).bind(user_id).fetch_all(&self.pool).await?;
|
||||
|
||||
Ok(rows.iter().map(|r| {
|
||||
let fav_id = Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil());
|
||||
let img_id = Uuid::parse_str(&r.get::<String, _>("image_id")).unwrap_or_else(|_| Uuid::nil());
|
||||
let aid = Uuid::parse_str(&r.get::<String, _>("album_id")).unwrap_or_else(|_| Uuid::nil());
|
||||
let img_aid = Uuid::parse_str(&r.get::<String, _>("img_album_id")).unwrap_or_else(|_| Uuid::nil());
|
||||
FavoriteWithImage {
|
||||
favorite: Favorite { id: fav_id, user_id: r.get("user_id"), image_id: img_id, album_id: aid, created_at: r.get("created_at") },
|
||||
image: crate::models::image::Image {
|
||||
id: img_id, album_id: img_aid,
|
||||
filename: r.get("filename"), original_url: r.get("original_url"),
|
||||
title: r.get("title"), description: r.get("description"),
|
||||
file_size: r.get("file_size"), width: r.get("width"),
|
||||
height: r.get("height"), mime_type: r.get("mime_type"),
|
||||
created_at: r.get("img_created_at"),
|
||||
},
|
||||
album_title: r.get("album_title"),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn get_favorite_status(&self, user_id: &str, image_id: Uuid) -> Result<FavoriteStatus, sqlx::Error> {
|
||||
let row = sqlx::query("SELECT id FROM favorites WHERE user_id = ? AND image_id = ?")
|
||||
.bind(user_id).bind(image_id.to_string()).fetch_optional(&self.pool).await?;
|
||||
Ok(FavoriteStatus {
|
||||
is_favorited: row.is_some(),
|
||||
favorite_id: row.map(|r| Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil())),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn add_favorite(&self, user_id: &str, fav: CreateFavorite) -> Result<Uuid, sqlx::Error> {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
let id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("INSERT INTO favorites (id, user_id, image_id, album_id, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(id.to_string()).bind(user_id).bind(fav.image_id.to_string()).bind(fav.album_id.to_string()).bind(&now)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn remove_favorite(&self, favorite_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
sqlx::query("DELETE FROM favorites WHERE id = ?").bind(favorite_id.to_string()).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_favorite_by_image(&self, user_id: &str, image_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
sqlx::query("DELETE FROM favorites WHERE user_id = ? AND image_id = ?")
|
||||
.bind(user_id).bind(image_id.to_string()).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
75
src/db/image.rs
Normal file
75
src/db/image.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use super::Database;
|
||||
use crate::models::*;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 重新导出用于 cross-module access
|
||||
pub use crate::models::image::Image;
|
||||
|
||||
impl Database {
|
||||
pub async fn add_images(&self, images: Vec<CreateImage>) -> Result<Vec<Uuid>, sqlx::Error> {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
if images.is_empty() { return Ok(Vec::new()); }
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut ids = Vec::new();
|
||||
let first_album_id = images.first().map(|img| img.album_id);
|
||||
|
||||
for img in &images {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO images (id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).bind(id.to_string()).bind(img.album_id.to_string()).bind(&img.filename)
|
||||
.bind(&img.original_url).bind(&img.title).bind(&img.description)
|
||||
.bind(img.file_size).bind(img.width).bind(img.height).bind(&img.mime_type).bind(&now)
|
||||
.execute(&self.pool).await?;
|
||||
ids.push(id);
|
||||
}
|
||||
|
||||
if let Some(aid) = first_album_id {
|
||||
self.update_album_image_count(aid).await?;
|
||||
let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM images WHERE album_id = ?")
|
||||
.bind(aid.to_string()).fetch_one(&self.pool).await?;
|
||||
if count == images.len() as i64 {
|
||||
self.update_album_cover(aid, ids[0]).await?;
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub async fn get_image(&self, image_id: Uuid) -> Result<Option<Image>, sqlx::Error> {
|
||||
let row = sqlx::query("SELECT * FROM images WHERE id = ?")
|
||||
.bind(image_id.to_string()).fetch_optional(&self.pool).await?;
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
Ok(Some(Image {
|
||||
id: image_id,
|
||||
album_id: Uuid::parse_str(&r.get::<String, _>("album_id")).unwrap_or_else(|_| Uuid::nil()),
|
||||
filename: r.get("filename"),
|
||||
original_url: r.get("original_url"),
|
||||
title: r.get("title"),
|
||||
description: r.get("description"),
|
||||
file_size: r.get("file_size"),
|
||||
width: r.get("width"),
|
||||
height: r.get("height"),
|
||||
mime_type: r.get("mime_type"),
|
||||
created_at: r.get("created_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_unprocessed_images(&self, limit: i64) -> Result<Vec<Image>, sqlx::Error> {
|
||||
let rows = sqlx::query("SELECT * FROM images WHERE file_size > 0 LIMIT ?")
|
||||
.bind(limit).fetch_all(&self.pool).await?;
|
||||
Ok(rows.iter().map(|r| Image {
|
||||
id: Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil()),
|
||||
album_id: Uuid::parse_str(&r.get::<String, _>("album_id")).unwrap_or_else(|_| Uuid::nil()),
|
||||
filename: r.get("filename"),
|
||||
original_url: r.get("original_url"),
|
||||
title: r.get("title"),
|
||||
description: r.get("description"),
|
||||
file_size: r.get("file_size"),
|
||||
width: r.get("width"),
|
||||
height: r.get("height"),
|
||||
mime_type: r.get("mime_type"),
|
||||
created_at: r.get("created_at"),
|
||||
}).collect())
|
||||
}
|
||||
}
|
||||
464
src/db/metadata.rs
Normal file
464
src/db/metadata.rs
Normal file
@@ -0,0 +1,464 @@
|
||||
use super::Database;
|
||||
use crate::models::*;
|
||||
use sqlx::{Row, Sqlite, Transaction};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::DerefMut;
|
||||
use uuid::Uuid;
|
||||
|
||||
impl Database {
|
||||
// ── 人员查询 ──
|
||||
|
||||
pub async fn get_all_person_names(&self) -> Vec<(String, i64)> {
|
||||
sqlx::query("SELECT p.name, p.total_albums FROM persons p ORDER BY p.total_albums DESC")
|
||||
.fetch_all(&self.pool).await.unwrap_or_default()
|
||||
.iter().map(|r| (r.get::<String, _>("name"), r.get::<i64, _>("total_albums"))).collect()
|
||||
}
|
||||
|
||||
pub async fn get_all_aliases(&self) -> Vec<(String, String)> {
|
||||
sqlx::query("SELECT a.alias_name, p.name as person_name FROM person_aliases a JOIN persons p ON a.person_id = p.id")
|
||||
.fetch_all(&self.pool).await.unwrap_or_default()
|
||||
.iter().map(|r| (r.get::<String, _>("alias_name"), r.get::<String, _>("person_name"))).collect()
|
||||
}
|
||||
|
||||
pub async fn get_all_persons_with_counts(&self) -> Vec<(i64, String, i64, i64)> {
|
||||
sqlx::query("SELECT id, name, total_albums, total_images FROM persons ORDER BY total_albums DESC")
|
||||
.fetch_all(&self.pool).await.unwrap_or_default()
|
||||
.iter().map(|r| (r.get::<i64, _>("id"), r.get::<String, _>("name"), r.get::<i64, _>("total_albums"), r.get::<i64, _>("total_images"))).collect()
|
||||
}
|
||||
|
||||
pub async fn get_person_by_name(&self, name: &str) -> Option<Person> {
|
||||
let r = sqlx::query("SELECT * FROM persons WHERE name = ?").bind(name).fetch_optional(&self.pool).await.ok()??;
|
||||
Some(Person {
|
||||
id: r.get("id"), name: r.get("name"), display_name: r.get("display_name"),
|
||||
total_albums: r.get("total_albums"), total_images: r.get("total_images"),
|
||||
original_names: r.get("original_names"), created_at: r.get("created_at"), updated_at: r.get("updated_at"),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_person_albums(&self, person_id: i64) -> Vec<AlbumInfo> {
|
||||
sqlx::query(
|
||||
r#"SELECT a.id, a.title, a.document, a.description, a.cover_image_id, a.image_count, a.created_at
|
||||
FROM albums a JOIN person_albums pa ON a.id = pa.album_id WHERE pa.person_id = ? ORDER BY a.created_at DESC"#
|
||||
).bind(person_id).fetch_all(&self.pool).await.unwrap_or_default()
|
||||
.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"),
|
||||
tags: vec![],
|
||||
persons: vec![],
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub async fn get_person_aliases(&self, person_id: i64) -> Vec<PersonAlias> {
|
||||
sqlx::query("SELECT * FROM person_aliases WHERE person_id = ?").bind(person_id)
|
||||
.fetch_all(&self.pool).await.unwrap_or_default()
|
||||
.iter().map(|r| PersonAlias {
|
||||
id: r.get("id"), person_id: r.get("person_id"),
|
||||
alias_type: r.get("alias_type"), alias_name: r.get("alias_name"),
|
||||
created_at: r.get("created_at"),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub async fn search_persons(&self, query: &str) -> Vec<PersonWithCounts> {
|
||||
let pattern = format!("%{}%", query);
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT DISTINCT p.id, p.name, p.display_name, p.total_albums, p.total_images
|
||||
FROM persons p LEFT JOIN person_aliases a ON p.id = a.person_id
|
||||
WHERE p.name LIKE ? OR a.alias_name LIKE ? ORDER BY p.total_albums DESC"#
|
||||
).bind(&pattern).bind(&pattern).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
let pids: Vec<i64> = rows.iter().map(|r| r.get::<i64, _>("id")).collect();
|
||||
let aliases = self.batch_get_person_aliases(&pids).await;
|
||||
|
||||
rows.iter().map(|r| {
|
||||
let pid = r.get::<i64, _>("id");
|
||||
PersonWithCounts {
|
||||
id: pid.to_string(), name: r.get("name"), display_name: r.get("display_name"),
|
||||
total_albums: r.get("total_albums"), total_images: r.get("total_images"),
|
||||
aliases: aliases.get(&pid).cloned().unwrap_or_default(),
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub async fn get_all_persons_with_aliases(&self) -> Vec<PersonWithCounts> {
|
||||
let rows = sqlx::query("SELECT id, name, display_name, total_albums, total_images FROM persons ORDER BY total_albums DESC")
|
||||
.fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
let pids: Vec<i64> = rows.iter().map(|r| r.get::<i64, _>("id")).collect();
|
||||
let aliases = self.batch_get_person_aliases(&pids).await;
|
||||
|
||||
rows.iter().map(|r| {
|
||||
let pid = r.get::<i64, _>("id");
|
||||
PersonWithCounts {
|
||||
id: pid.to_string(), name: r.get("name"), display_name: r.get("display_name"),
|
||||
total_albums: r.get("total_albums"), total_images: r.get("total_images"),
|
||||
aliases: aliases.get(&pid).cloned().unwrap_or_default(),
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// 批量查询多个人物的别名(避免 N+1)
|
||||
async fn batch_get_person_aliases(&self, pids: &[i64]) -> HashMap<i64, Vec<String>> {
|
||||
if pids.is_empty() { return HashMap::new(); }
|
||||
let placeholders: Vec<String> = pids.iter().map(|_| "?".to_string()).collect();
|
||||
let sql = format!(
|
||||
"SELECT person_id, alias_name FROM person_aliases WHERE person_id IN ({})",
|
||||
placeholders.join(",")
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for pid in pids {
|
||||
q = q.bind(pid);
|
||||
}
|
||||
let rows = q.fetch_all(&self.pool).await.unwrap_or_default();
|
||||
let mut map: HashMap<i64, Vec<String>> = HashMap::new();
|
||||
for r in &rows {
|
||||
let pid: i64 = r.get("person_id");
|
||||
let alias: String = r.get("alias_name");
|
||||
map.entry(pid).or_default().push(alias);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// ── 标签查询 ──
|
||||
|
||||
pub async fn get_popular_tags(&self, limit: i64) -> Vec<TagStat> {
|
||||
// 从 tag_dict 获取过滤词(停用词、品牌词、排除名)
|
||||
// 注意:必须 clone 到本地变量,不能持 RwLockReadGuard 跨 await
|
||||
let (stop_words, excluded, included) = {
|
||||
let dict = crate::engine::tag_dict::get_tag_dict();
|
||||
(
|
||||
dict.stop_words.clone(),
|
||||
dict.excluded_person_names.clone(),
|
||||
dict.included_person_names.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
// 标签自身小写的集合用于快速过滤
|
||||
let filtered_tags: Vec<TagStat> = sqlx::query(
|
||||
r#"SELECT tag, COUNT(*) as cnt, COUNT(DISTINCT album_id) as album_cnt
|
||||
FROM album_tags GROUP BY tag ORDER BY cnt DESC LIMIT ?"#
|
||||
).bind(limit * 3).fetch_all(&self.pool).await.unwrap_or_default()
|
||||
.iter().filter_map(|r| {
|
||||
let tag: String = r.get("tag");
|
||||
let tag_lower = tag.to_lowercase();
|
||||
|
||||
// 过滤停用词
|
||||
if stop_words.contains(&tag_lower) || stop_words.contains(&tag) {
|
||||
return None;
|
||||
}
|
||||
// 过滤品牌/工作室名
|
||||
if excluded.contains(&tag_lower) || excluded.contains(&tag) {
|
||||
return None;
|
||||
}
|
||||
// 过滤包含人名(如果标签本身就是个人名,也不应该出现在标签中)
|
||||
if included.contains(&tag_lower) || included.contains(&tag) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(TagStat {
|
||||
tag: r.get("tag"),
|
||||
count: r.get("cnt"),
|
||||
album_count: r.get("album_cnt"),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// 只保留 top limit 个
|
||||
let mut result: Vec<TagStat> = filtered_tags;
|
||||
result.truncate(limit as usize);
|
||||
result
|
||||
}
|
||||
|
||||
/// 批量获取所有 tag→albums 映射(供缓存使用)
|
||||
pub async fn get_all_tag_albums(&self) -> HashMap<String, Vec<AlbumInfo>> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT t.tag, a.id, a.title, a.document, a.description, a.cover_image_id, a.image_count, a.created_at
|
||||
FROM album_tags t JOIN albums a ON t.album_id = a.id ORDER BY a.created_at DESC"#
|
||||
).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
let mut map: HashMap<String, Vec<AlbumInfo>> = HashMap::new();
|
||||
for r in &rows {
|
||||
let tag: String = r.get("tag");
|
||||
let album = 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"),
|
||||
tags: vec![], persons: vec![],
|
||||
};
|
||||
map.entry(tag).or_default().push(album);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
pub async fn get_tag_co_occurrences(&self, tag: &str, limit: i64) -> Vec<TagCoOccurrence> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT t2.tag as co_tag, COUNT(*) as cnt
|
||||
FROM album_tags t1 JOIN album_tags t2 ON t1.album_id = t2.album_id
|
||||
WHERE t1.tag = ? AND t2.tag != ? GROUP BY t2.tag ORDER BY cnt DESC LIMIT ?"#
|
||||
).bind(tag).bind(tag).bind(limit).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
let total = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM album_tags WHERE tag = ?")
|
||||
.bind(tag).fetch_one(&self.pool).await.unwrap_or(1);
|
||||
rows.iter().map(|r| {
|
||||
let cnt: i64 = r.get("cnt");
|
||||
TagCoOccurrence {
|
||||
tag: tag.to_string(), co_tag: r.get("co_tag"),
|
||||
count: cnt, score: cnt as f64 / total as f64,
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub async fn search_albums_by_tag(&self, tag: &str) -> Vec<AlbumInfo> {
|
||||
sqlx::query(
|
||||
r#"SELECT a.id, a.title, a.document, a.description, a.cover_image_id, a.image_count, a.created_at
|
||||
FROM albums a JOIN album_tags t ON a.id = t.album_id WHERE t.tag = ? ORDER BY a.created_at DESC"#
|
||||
).bind(tag).fetch_all(&self.pool).await.unwrap_or_default()
|
||||
.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"),
|
||||
tags: vec![], persons: vec![],
|
||||
}).collect()
|
||||
}
|
||||
|
||||
// ── 写入 ──
|
||||
|
||||
pub async fn insert_person(&self, name: &str, display_name: Option<&str>) -> i64 {
|
||||
if let Some(p) = self.get_person_by_name_internal(name).await { return p; }
|
||||
sqlx::query("INSERT OR IGNORE INTO persons (name, display_name) VALUES (?, ?)")
|
||||
.bind(name).bind(display_name).execute(&self.pool).await.ok();
|
||||
self.get_person_by_name_internal(name).await.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 内部方法:按名称查找人员,直接返回 i64 id(避免 String 转换开销)
|
||||
async fn get_person_by_name_internal(&self, name: &str) -> Option<i64> {
|
||||
sqlx::query_scalar::<_, i64>("SELECT id FROM persons WHERE name = ?")
|
||||
.bind(name).fetch_optional(&self.pool).await.ok().flatten()
|
||||
}
|
||||
|
||||
/// 与 insert_person 相同,但使用已有的事务
|
||||
pub async fn insert_person_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, name: &str, display_name: Option<&str>) -> i64 {
|
||||
if let Ok(Some(r)) = sqlx::query("SELECT id FROM persons WHERE name = ?")
|
||||
.bind(name).fetch_optional(tx.deref_mut()).await {
|
||||
return r.get::<i64, _>("id");
|
||||
}
|
||||
sqlx::query("INSERT OR IGNORE INTO persons (name, display_name) VALUES (?, ?)")
|
||||
.bind(name).bind(display_name).execute(tx.deref_mut()).await.ok();
|
||||
if let Ok(Some(r)) = sqlx::query("SELECT id FROM persons WHERE name = ?")
|
||||
.bind(name).fetch_optional(tx.deref_mut()).await {
|
||||
return r.get::<i64, _>("id");
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
pub async fn insert_person_alias(&self, person_id: i64, alias_type: &str, alias_name: &str) {
|
||||
sqlx::query("INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)")
|
||||
.bind(person_id).bind(alias_type).bind(alias_name).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
pub async fn insert_person_alias_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, person_id: i64, alias_type: &str, alias_name: &str) {
|
||||
sqlx::query("INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)")
|
||||
.bind(person_id).bind(alias_type).bind(alias_name).execute(tx.deref_mut()).await.ok();
|
||||
}
|
||||
|
||||
pub async fn insert_person_album(&self, person_id: i64, album_id: &str) {
|
||||
sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)")
|
||||
.bind(person_id).bind(album_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
pub async fn insert_person_album_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, person_id: i64, album_id: &str) {
|
||||
sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)")
|
||||
.bind(person_id).bind(album_id).execute(tx.deref_mut()).await.ok();
|
||||
}
|
||||
|
||||
pub async fn delete_person_albums(&self, person_id: i64) {
|
||||
sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
/// 删除某相册的所有人物关联,返回被影响的旧 person_id 列表
|
||||
pub async fn delete_album_person_albums(&self, album_id: &str) -> Vec<i64> {
|
||||
let old_ids: Vec<i64> = sqlx::query_scalar("SELECT person_id FROM person_albums WHERE album_id = ?")
|
||||
.bind(album_id).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
sqlx::query("DELETE FROM person_albums WHERE album_id = ?").bind(album_id).execute(&self.pool).await.ok();
|
||||
old_ids
|
||||
}
|
||||
|
||||
/// 删除某相册的所有人物关联(事务版),返回被影响的旧 person_id 列表
|
||||
pub async fn delete_album_person_albums_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, album_id: &str) -> Vec<i64> {
|
||||
let old_ids: Vec<i64> = sqlx::query_scalar("SELECT person_id FROM person_albums WHERE album_id = ?")
|
||||
.bind(album_id).fetch_all(tx.deref_mut()).await.unwrap_or_default();
|
||||
sqlx::query("DELETE FROM person_albums WHERE album_id = ?").bind(album_id).execute(tx.deref_mut()).await.ok();
|
||||
old_ids
|
||||
}
|
||||
|
||||
/// 删除某人物下所有别名(写入新别名前清旧别名,配合 UNIQUE INDEX 防止重复)
|
||||
pub async fn delete_person_aliases(&self, person_id: i64) {
|
||||
sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
pub async fn delete_person_aliases_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, person_id: i64) {
|
||||
sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(person_id).execute(tx.deref_mut()).await.ok();
|
||||
}
|
||||
|
||||
/// 彻底删除一个人物(包括别名、相册关联、自身)
|
||||
pub async fn delete_person_completely(&self, person_id: i64) {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
sqlx::query("DELETE FROM persons WHERE id = ?").bind(person_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
/// 把 src_id 的所有相册关联、别名移动到 dst_id,然后删除 src_id
|
||||
pub async fn merge_person_into(&self, src_id: i64, dst_id: i64) {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
|
||||
// 整个合并过程是一个原子操作,用事务包裹避免逐条 autocommit
|
||||
let mut tx = match self.pool.begin().await {
|
||||
Ok(t) => t,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// 1. 移动相册关联
|
||||
sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) SELECT ?, album_id FROM person_albums WHERE person_id = ?")
|
||||
.bind(dst_id).bind(src_id).execute(tx.deref_mut()).await.ok();
|
||||
|
||||
// 2. 移动别名
|
||||
sqlx::query("INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) SELECT ?, alias_type, alias_name FROM person_aliases WHERE person_id = ?")
|
||||
.bind(dst_id).bind(src_id).execute(tx.deref_mut()).await.ok();
|
||||
|
||||
// 3. 把 src 的原名也作为别名加入 dst(使用事务连接,避免额外占用连接池)
|
||||
if let Some(r) = sqlx::query("SELECT id, name, display_name FROM persons WHERE id = ?")
|
||||
.bind(src_id).fetch_optional(tx.deref_mut()).await.ok().flatten()
|
||||
{
|
||||
let src_name: String = r.get("name");
|
||||
let src_display: Option<String> = r.get("display_name");
|
||||
self.insert_person_alias_with_tx(&mut tx, dst_id, "merged_original", &src_name).await;
|
||||
if let Some(ref dn) = src_display {
|
||||
self.insert_person_alias_with_tx(&mut tx, dst_id, "merged_display", dn).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 删除 src 的关联和别名
|
||||
sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(src_id).execute(tx.deref_mut()).await.ok();
|
||||
sqlx::query("DELETE FROM person_aliases WHERE person_id = ?").bind(src_id).execute(tx.deref_mut()).await.ok();
|
||||
|
||||
// 5. 删除 src 人物
|
||||
sqlx::query("DELETE FROM persons WHERE id = ?").bind(src_id).execute(tx.deref_mut()).await.ok();
|
||||
|
||||
// 6. 更新 dst 的统计数据
|
||||
self.update_person_counts_with_tx(&mut tx, dst_id).await;
|
||||
|
||||
tx.commit().await.ok();
|
||||
}
|
||||
|
||||
pub async fn get_person_by_id(&self, id: i64) -> Option<Person> {
|
||||
let r = sqlx::query("SELECT * FROM persons WHERE id = ?").bind(id).fetch_optional(&self.pool).await.ok()??;
|
||||
let db_id: i64 = r.get("id");
|
||||
Some(Person {
|
||||
id: db_id.to_string(), name: r.get("name"), display_name: r.get("display_name"),
|
||||
total_albums: r.get("total_albums"), total_images: r.get("total_images"),
|
||||
original_names: r.get("original_names"), created_at: r.get("created_at"), updated_at: r.get("updated_at"),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_album_person(&self, album_id: &str, _person_name_original: &str, _person_name_cleaned: &str) {
|
||||
sqlx::query("UPDATE albums SET processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(album_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
pub async fn update_album_person_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, album_id: &str, _person_name_original: &str, _person_name_cleaned: &str) {
|
||||
sqlx::query("UPDATE albums SET processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(album_id).execute(tx.deref_mut()).await.ok();
|
||||
}
|
||||
|
||||
/// 清空该人物所有关联相册的 person 字段(当该人物被确认为非人物后调用)
|
||||
pub async fn update_album_person_by_person_id(&self, person_id: i64) {
|
||||
sqlx::query(
|
||||
"UPDATE albums SET processed_at = datetime('now') WHERE id IN (SELECT album_id FROM person_albums WHERE person_id = ?)"
|
||||
).bind(person_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
pub async fn update_album_tags(&self, album_id: &str, tags_json: &str, _all_words_json: &str) {
|
||||
sqlx::query("UPDATE albums SET processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(album_id).execute(&self.pool).await.ok();
|
||||
|
||||
// 用一个事务批量替换 album_tags,避免逐条 autocommit 导致巨慢
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(tags_json) {
|
||||
let mut tx = match self.pool.begin().await {
|
||||
Ok(t) => t,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// 单条 DELETE + 一条多值 INSERT
|
||||
sqlx::query("DELETE FROM album_tags WHERE album_id = ?")
|
||||
.bind(album_id)
|
||||
.execute(tx.deref_mut())
|
||||
.await.ok();
|
||||
|
||||
// 构建多值 INSERT, 避免逐条执行
|
||||
if !tags.is_empty() {
|
||||
let placeholders: Vec<String> = tags.iter().map(|_| "(?, ?)".to_string()).collect();
|
||||
let sql = format!(
|
||||
"INSERT OR IGNORE INTO album_tags (album_id, tag) VALUES {}",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for tag in &tags {
|
||||
q = q.bind(album_id).bind(tag);
|
||||
}
|
||||
q.execute(tx.deref_mut()).await.ok();
|
||||
}
|
||||
|
||||
tx.commit().await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 update_album_tags 相同,但使用已有的事务(不启动内部子事务)
|
||||
pub async fn update_album_tags_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, album_id: &str, tags_json: &str, _all_words_json: &str) {
|
||||
sqlx::query("UPDATE albums SET processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(album_id).execute(tx.deref_mut()).await.ok();
|
||||
|
||||
sqlx::query("DELETE FROM album_tags WHERE album_id = ?")
|
||||
.bind(album_id)
|
||||
.execute(tx.deref_mut())
|
||||
.await.ok();
|
||||
|
||||
if let Ok(tags) = serde_json::from_str::<Vec<String>>(tags_json) {
|
||||
if !tags.is_empty() {
|
||||
let placeholders: Vec<String> = tags.iter().map(|_| "(?, ?)".to_string()).collect();
|
||||
let sql = format!(
|
||||
"INSERT OR IGNORE INTO album_tags (album_id, tag) VALUES {}",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for tag in &tags {
|
||||
q = q.bind(album_id).bind(tag);
|
||||
}
|
||||
q.execute(tx.deref_mut()).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_person_counts(&self, person_id: i64) {
|
||||
sqlx::query(
|
||||
r#"UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?),
|
||||
total_images = (SELECT COALESCE(SUM(a.image_count), 0) FROM albums a JOIN person_albums pa ON a.id = pa.album_id WHERE pa.person_id = ?),
|
||||
updated_at = datetime('now') WHERE id = ?"#
|
||||
).bind(person_id).bind(person_id).bind(person_id).execute(&self.pool).await.ok();
|
||||
}
|
||||
|
||||
pub async fn update_person_counts_with_tx(&self, tx: &mut Transaction<'_, Sqlite>, person_id: i64) {
|
||||
sqlx::query(
|
||||
r#"UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?),
|
||||
total_images = (SELECT COALESCE(SUM(a.image_count), 0) FROM albums a JOIN person_albums pa ON a.id = pa.album_id WHERE pa.person_id = ?),
|
||||
updated_at = datetime('now') WHERE id = ?"#
|
||||
).bind(person_id).bind(person_id).bind(person_id).execute(tx.deref_mut()).await.ok();
|
||||
}
|
||||
|
||||
/// 获取所有相册 ID 集合(用于迁移)
|
||||
pub async fn get_all_album_ids(&self) -> Vec<String> {
|
||||
sqlx::query_scalar::<_, String>("SELECT id FROM albums").fetch_all(&self.pool).await.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
164
src/db/migration.rs
Normal file
164
src/db/migration.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
//! 从老数据库(Python 项目 persons.db)迁移元数据到新数据库
|
||||
//!
|
||||
//! 迁移策略:
|
||||
//! 1. 读取老 DB 的 persons/aliases/albums 数据
|
||||
//! 2. 按 album_id 对齐新 DB 中的相册
|
||||
//! 3. 批量插入人员、别名、标签、关联
|
||||
|
||||
use super::Database;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::Row;
|
||||
|
||||
/// 迁移结果统计
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MigrationStats {
|
||||
pub persons_imported: usize,
|
||||
pub persons_skipped: usize,
|
||||
pub aliases_imported: usize,
|
||||
pub albums_updated: usize,
|
||||
pub albums_missing: usize,
|
||||
pub tags_imported: usize,
|
||||
pub person_albums_created: usize,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 从老数据库迁移元数据
|
||||
pub async fn migrate_from_old_db(&self, old_db_path: &str) -> Result<MigrationStats, String> {
|
||||
// 串行化写入:迁移过程中禁止其他写入操作
|
||||
let _guard = self.write_lock.lock().await;
|
||||
|
||||
if !std::path::Path::new(old_db_path).exists() {
|
||||
return Err(format!("老数据库不存在: {}", old_db_path));
|
||||
}
|
||||
|
||||
println!("[迁移] 打开老数据库: {}", old_db_path);
|
||||
let old_pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&format!("sqlite:{}", old_db_path))
|
||||
.await
|
||||
.map_err(|e| format!("连接老数据库失败: {}", e))?;
|
||||
|
||||
// 先获取新 DB 中所有相册 ID
|
||||
let new_album_ids: std::collections::HashSet<String> = self.get_all_album_ids().await.into_iter().collect();
|
||||
println!("[迁移] 新数据库中有 {} 个相册", new_album_ids.len());
|
||||
|
||||
let mut stats = MigrationStats::default();
|
||||
|
||||
// ── 1. 迁移人员 ──
|
||||
println!("[迁移] 步骤 1/4: 迁移人员...");
|
||||
let old_persons: Vec<(i64, String, Option<String>, i64, i64, Option<String>)> = sqlx::query(
|
||||
"SELECT id, name, display_name, total_albums, total_images, original_names FROM persons"
|
||||
).fetch_all(&old_pool).await.map_err(|e| format!("读取老人员失败: {}", e))?
|
||||
.iter().map(|r| (
|
||||
r.get("id"), r.get("name"), r.get("display_name"),
|
||||
r.get("total_albums"), r.get("total_images"), r.get("original_names"),
|
||||
)).collect();
|
||||
|
||||
let mut old_to_new_person: std::collections::HashMap<i64, i64> = std::collections::HashMap::new();
|
||||
|
||||
for (old_pid, name, display_name, _albums, _images, original_names) in &old_persons {
|
||||
let new_pid = self.insert_person(name, display_name.as_deref()).await;
|
||||
if new_pid > 0 {
|
||||
old_to_new_person.insert(*old_pid, new_pid);
|
||||
// 更新 original_names
|
||||
if let Some(on) = original_names {
|
||||
sqlx::query("UPDATE persons SET original_names = ? WHERE id = ?")
|
||||
.bind(on).bind(new_pid).execute(&self.pool).await.ok();
|
||||
}
|
||||
stats.persons_imported += 1;
|
||||
} else {
|
||||
stats.persons_skipped += 1;
|
||||
}
|
||||
}
|
||||
println!("[迁移] 导入 {} 人,跳过 {} 人", stats.persons_imported, stats.persons_skipped);
|
||||
|
||||
// ── 2. 迁移别名 ──
|
||||
println!("[迁移] 步骤 2/4: 迁移别名...");
|
||||
let old_aliases: Vec<(i64, String, String)> = sqlx::query(
|
||||
"SELECT person_id, alias_type, alias_name FROM person_aliases"
|
||||
).fetch_all(&old_pool).await.unwrap_or_default()
|
||||
.iter().map(|r| (r.get("person_id"), r.get("alias_type"), r.get("alias_name"))).collect();
|
||||
|
||||
for (old_pid, alias_type, alias_name) in &old_aliases {
|
||||
if let Some(&new_pid) = old_to_new_person.get(old_pid) {
|
||||
self.insert_person_alias(new_pid, alias_type, alias_name).await;
|
||||
stats.aliases_imported += 1;
|
||||
}
|
||||
}
|
||||
println!("[迁移] 导入 {} 个别名", stats.aliases_imported);
|
||||
|
||||
// ── 3. 迁移相册人员标签 ──
|
||||
println!("[迁移] 步骤 3/4: 迁移相册人员+标签...");
|
||||
let old_albums: Vec<(String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = sqlx::query(
|
||||
"SELECT id, title, person_name_original, person_name_cleaned, tags, all_words FROM albums"
|
||||
).fetch_all(&old_pool).await.unwrap_or_default()
|
||||
.iter().map(|r| (
|
||||
r.get("id"), r.get("title"), r.get("person_name_original"),
|
||||
r.get("person_name_cleaned"), r.get("tags"), r.get("all_words"),
|
||||
)).collect();
|
||||
|
||||
for (album_id, _title, person_orig, person_cleaned, tags, all_words) in &old_albums {
|
||||
if !new_album_ids.contains(album_id) {
|
||||
stats.albums_missing += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新人员信息
|
||||
if let (Some(po), Some(pc)) = (person_orig, person_cleaned) {
|
||||
if !po.is_empty() {
|
||||
self.update_album_person(album_id, po, pc).await;
|
||||
stats.albums_updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新标签
|
||||
if let Some(t) = tags {
|
||||
if t != "[]" && !t.is_empty() {
|
||||
let aw = all_words.clone().unwrap_or_else(|| "[]".to_string());
|
||||
self.update_album_tags(album_id, t, &aw).await;
|
||||
stats.tags_imported += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("[迁移] 更新 {} 个相册,{} 个不在新库中,{} 个标签导入",
|
||||
stats.albums_updated, stats.albums_missing, stats.tags_imported);
|
||||
|
||||
// ── 4. 迁移人物-相册关联 ──
|
||||
println!("[迁移] 步骤 4/4: 迁移人物-相册关联...");
|
||||
let old_person_albums: Vec<(i64, String)> = sqlx::query(
|
||||
"SELECT person_id, album_id FROM person_albums"
|
||||
).fetch_all(&old_pool).await.unwrap_or_default()
|
||||
.iter().map(|r| (r.get("person_id"), r.get("album_id"))).collect();
|
||||
|
||||
for (old_pid, album_id) in &old_person_albums {
|
||||
if let Some(&new_pid) = old_to_new_person.get(old_pid) {
|
||||
if new_album_ids.contains(album_id) {
|
||||
self.insert_person_album(new_pid, album_id).await;
|
||||
stats.person_albums_created += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("[迁移] 创建 {} 个关联", stats.person_albums_created);
|
||||
|
||||
// 更新所有人员的计数
|
||||
println!("[迁移] 更新人员统计...");
|
||||
for &new_pid in old_to_new_person.values() {
|
||||
self.update_person_counts(new_pid).await;
|
||||
}
|
||||
|
||||
println!("[迁移] ✅ 完成!");
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// 获取迁移统计摘要
|
||||
pub fn migration_summary(&self, stats: &MigrationStats) -> String {
|
||||
format!(
|
||||
"迁移完成:\n - 人员: {} 导入, {} 跳过\n - 别名: {} 导入\n - 相册: {} 更新, {} 缺失\n - 标签: {} 导入\n - 关联: {} 创建",
|
||||
stats.persons_imported, stats.persons_skipped,
|
||||
stats.aliases_imported,
|
||||
stats.albums_updated, stats.albums_missing,
|
||||
stats.tags_imported,
|
||||
stats.person_albums_created,
|
||||
)
|
||||
}
|
||||
}
|
||||
16
src/db/mod.rs
Normal file
16
src/db/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! 数据库操作层 — 按领域拆分
|
||||
|
||||
mod connection;
|
||||
pub mod album;
|
||||
pub mod image;
|
||||
pub mod favorite;
|
||||
pub mod metadata;
|
||||
pub mod migration;
|
||||
pub mod batch;
|
||||
|
||||
pub use connection::Database;
|
||||
#[allow(unused)] pub use album::*;
|
||||
#[allow(unused)] pub use image::*;
|
||||
#[allow(unused)] pub use favorite::*;
|
||||
#[allow(unused)] pub use metadata::*;
|
||||
pub use migration::MigrationStats;
|
||||
176
src/engine/crawler.rs
Normal file
176
src/engine/crawler.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
//! API 数据获取
|
||||
//! 从 http://192.168.1.100:4500 获取相册和图片数据
|
||||
//! 对应 Python 的 fetch_from_api.py
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 从 API 获取的相册信息
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiAlbum {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub image_count: i64,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 API 获取的图片详情
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiImage {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub filename: Option<String>,
|
||||
#[serde(default)]
|
||||
pub original_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub width: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub height: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub file_size: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 相册详情(含图片列表)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiAlbumDetail {
|
||||
#[serde(default)]
|
||||
pub images: Vec<ApiImage>,
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// API 数据获取器
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crawler {
|
||||
api_base: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Crawler {
|
||||
/// 创建 Crawler 实例
|
||||
pub fn new(api_base: &str) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.user_agent("imager-rs/1.0")
|
||||
.build()
|
||||
.expect("创建 HTTP 客户端失败");
|
||||
Self {
|
||||
api_base: api_base.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有相册列表
|
||||
/// GET /api/albums
|
||||
pub async fn get_albums(&self) -> Result<Vec<ApiAlbum>, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/albums", self.api_base);
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
|
||||
let albums: Vec<ApiAlbum> = if let Some(_arr) = data.as_array() {
|
||||
serde_json::from_value(data)?
|
||||
} else if let Some(arr) = data.get("albums").and_then(|v: &serde_json::Value| v.as_array()) {
|
||||
serde_json::from_value(serde_json::Value::Array(arr.clone()))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(albums)
|
||||
}
|
||||
|
||||
/// 获取相册详情(含图片列表)
|
||||
/// GET /api/album/{id}
|
||||
pub async fn get_album_detail(&self, album_id: &str) -> Result<ApiAlbumDetail, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/album/{}", self.api_base, album_id);
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
|
||||
let detail = if let Some(images) = data.get("images").and_then(|v: &serde_json::Value| v.as_array()) {
|
||||
ApiAlbumDetail {
|
||||
images: serde_json::from_value(serde_json::Value::Array(images.clone()))?,
|
||||
id: data.get("id").and_then(|v: &serde_json::Value| v.as_str()).map(|s| s.to_string()),
|
||||
title: data.get("title").and_then(|v: &serde_json::Value| v.as_str()).map(|s| s.to_string()),
|
||||
}
|
||||
} else if let Some(_arr) = data.as_array() {
|
||||
ApiAlbumDetail {
|
||||
images: serde_json::from_value(data)?,
|
||||
id: None,
|
||||
title: None,
|
||||
}
|
||||
} else {
|
||||
ApiAlbumDetail {
|
||||
images: Vec::new(),
|
||||
id: None,
|
||||
title: None,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
/// 获取图片二进制数据
|
||||
/// GET /api/image/{id}
|
||||
pub async fn get_image_data(&self, image_id: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/image/{}", self.api_base, image_id);
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
let bytes = resp.bytes().await?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// 获取图片的下载 URL(不下载,只返回 URL 字符串)
|
||||
pub fn get_image_url(&self, image_id: &str) -> String {
|
||||
format!("{}/api/image/{}", self.api_base, image_id)
|
||||
}
|
||||
|
||||
/// 获取多个相册的详情(批量)
|
||||
pub async fn get_album_details_batch(
|
||||
&self,
|
||||
album_ids: &[String],
|
||||
) -> Result<Vec<(String, ApiAlbumDetail)>, Box<dyn std::error::Error>> {
|
||||
let mut results = Vec::new();
|
||||
for aid in album_ids {
|
||||
match self.get_album_detail(aid).await {
|
||||
Ok(detail) => results.push((aid.clone(), detail)),
|
||||
Err(e) => {
|
||||
eprintln!("[Crawler] 获取相册 {} 详情失败: {}", aid, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 获取所有相册详情(含图片)
|
||||
pub async fn fetch_all_albums_with_images(
|
||||
&self,
|
||||
) -> Result<Vec<(ApiAlbum, ApiAlbumDetail)>, Box<dyn std::error::Error>> {
|
||||
let albums = self.get_albums().await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for album in &albums {
|
||||
match self.get_album_detail(&album.id).await {
|
||||
Ok(detail) => results.push((album.clone(), detail)),
|
||||
Err(e) => {
|
||||
eprintln!("[Crawler] 获取相册 {} 详情失败: {}", album.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 获取指定相册的图片列表(仅返回 id 列表)
|
||||
pub async fn get_album_image_ids(&self, album_id: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let detail = self.get_album_detail(album_id).await?;
|
||||
Ok(detail.images.into_iter().map(|img| img.id).collect())
|
||||
}
|
||||
}
|
||||
15
src/engine/mod.rs
Normal file
15
src/engine/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! 核心引擎层 — 标题解析、标签提取、推荐计算
|
||||
|
||||
pub mod title_parser;
|
||||
pub mod tag_dict;
|
||||
pub mod tag_extractor;
|
||||
pub mod recommend;
|
||||
pub mod pipeline;
|
||||
pub mod crawler;
|
||||
|
||||
#[allow(unused)] pub use title_parser::*;
|
||||
#[allow(unused)] pub use tag_dict::*;
|
||||
#[allow(unused)] pub use tag_extractor::*;
|
||||
pub use recommend::*;
|
||||
#[allow(unused)] pub use pipeline::*;
|
||||
#[allow(unused)] pub use crawler::*;
|
||||
420
src/engine/pipeline.rs
Normal file
420
src/engine/pipeline.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
//! 处理管线 — 批量处理相册元数据
|
||||
//!
|
||||
//! 核心架构:脏集追踪 + 增量解析,dict 优先匹配
|
||||
//!
|
||||
//! 流程:
|
||||
//! Phase Z0 删除空相册
|
||||
//! Phase Z1 去重同名相册
|
||||
//! Phase ① 收集新相册(未处理)
|
||||
//! Phase ② 合并人物清理(非人物/停用词人物)→ 1次人物扫描
|
||||
//! Phase ③ dict 优先解析脏集相册 → 只扫脏集
|
||||
//! Phase C 全量重计人物计数
|
||||
//!
|
||||
//! 脏集(DirtySet)贯穿各阶段,每个阶段发现的"需要重新解析"的相册都会加入脏集,
|
||||
//! 最终只处理脏集里的相册,而非全库扫描,大幅提升效率。
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::engine::title_parser;
|
||||
use crate::engine::tag_dict;
|
||||
use crate::search_db::SearchDb;
|
||||
use std::collections::HashSet;
|
||||
use rayon::prelude::*;
|
||||
use sqlx;
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// 脏集 — 追踪需要重新解析的相册 ID
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 脏集:记录哪些相册需要重新解析
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirtySet {
|
||||
ids: HashSet<String>,
|
||||
}
|
||||
|
||||
impl DirtySet {
|
||||
pub fn new() -> Self { Self { ids: HashSet::new() } }
|
||||
pub fn add(&mut self, id: &str) { self.ids.insert(id.to_string()); }
|
||||
pub fn extend(&mut self, ids: impl IntoIterator<Item = String>) { self.ids.extend(ids); }
|
||||
pub fn contains(&self, id: &str) -> bool { self.ids.contains(id) }
|
||||
pub fn len(&self) -> usize { self.ids.len() }
|
||||
pub fn is_empty(&self) -> bool { self.ids.is_empty() }
|
||||
pub fn into_inner(self) -> HashSet<String> { self.ids }
|
||||
}
|
||||
|
||||
impl Default for DirtySet {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
/// 判断一个标签是否"过短"——有效字符(汉字+字母)少于 2 个
|
||||
fn is_short_tag(tag: &str) -> bool {
|
||||
let mut valid_count = 0usize;
|
||||
for ch in tag.chars() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
valid_count += 1;
|
||||
} else if ('\u{4e00}'..='\u{9fff}').contains(&ch) {
|
||||
valid_count += 1;
|
||||
}
|
||||
// 数字、符号等不算有效字符
|
||||
}
|
||||
valid_count < 2
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A3:收集新相册 + 半成品
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_collect_new_albums(db: &Database) -> DirtySet {
|
||||
let ids = db.get_unprocessed_album_ids().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
for id in &ids { dirty.add(id); }
|
||||
println!("[管线·PhaseA3] 收集相册:{} 个未处理", dirty.len());
|
||||
dirty
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase 1:增量解析 — 只处理脏集中的相册
|
||||
// ┌─ Phase 1a:并行解析(CPU 密集,rayon par_iter)
|
||||
// └─ Phase 1b:批量写库(IO 密集,一个事务)
|
||||
|
||||
pub async fn phase_parse_dirty(db: &Database, search_db: &SearchDb, dirty_set: &DirtySet) -> (usize, usize) {
|
||||
if dirty_set.is_empty() {
|
||||
println!("[管线·Phase3] dict 优先解析跳过:脏集为空");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let dirty_ids: Vec<String> = dirty_set.clone().into_inner().into_iter().collect();
|
||||
let albums = db.get_albums_by_ids(&dirty_ids).await;
|
||||
|
||||
// 加载 search.db 的 dict 条目(提供 merged_to 别名解析)
|
||||
let dict_entries = search_db.load_dict_all().await.unwrap_or_default();
|
||||
|
||||
// ── Phase 3a:并行解析(CPU bound,rayon) ──
|
||||
let dirty_albums: Vec<&crate::models::AlbumMeta> = albums.iter()
|
||||
.filter(|a| dirty_set.contains(&a.id))
|
||||
.collect();
|
||||
|
||||
let skipped = dirty_albums.iter().filter(|a| {
|
||||
a.title.as_deref().map(|t| t.is_empty()).unwrap_or(true)
|
||||
}).count();
|
||||
let total = dirty_albums.len();
|
||||
|
||||
let parsed: Vec<crate::models::parse::ParseResult> = dirty_albums
|
||||
.par_iter()
|
||||
.filter_map(|album| {
|
||||
let title = album.title.as_deref()?;
|
||||
if title.is_empty() { return None; }
|
||||
// 使用 dict 优先匹配解析
|
||||
Some(title_parser::parse_album_title_with_dict(&album.id, title, &dict_entries))
|
||||
})
|
||||
.collect();
|
||||
|
||||
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 3b:批量写入 imager.db ──
|
||||
let _guard = db.write_lock.lock().await;
|
||||
let mut tx = db.pool.begin().await.expect("begin tx");
|
||||
db.batch_write_parse_results(&mut tx, &parsed).await.unwrap_or_else(|e| {
|
||||
eprintln!("[管线·Phase3b] imager.db 批量写入失败: {}", e);
|
||||
});
|
||||
tx.commit().await.expect("commit tx");
|
||||
|
||||
// ── Phase 3c:直接写入 search.db(使用相同解析结果,无需重解析) ──
|
||||
match search_db.batch_sync_parse_results(&parsed).await {
|
||||
Ok(n) => println!("[管线·Phase3c] search.db 同步完成:{} 个相册", n),
|
||||
Err(e) => eprintln!("[管线·Phase3c] search.db 同步失败: {}", e),
|
||||
}
|
||||
|
||||
println!("[管线·Phase3] dict 优先解析+批量写入完成: 脏集 {} 个, 有效 {} 个 (跳过 {} 无标题), 更新人名 {} 个, 更新标签 {} 个",
|
||||
total, parsed.len(), skipped, person_count, tag_count);
|
||||
(person_count, tag_count)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase C:全量重计 — 确保所有 person 的计数准确
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// Phase C:全量重计所有人物相册和图片计数
|
||||
///
|
||||
/// 遍历所有 persons,用 person_albums 实际关联数更新 total_albums,
|
||||
/// 用 JOIN albums 获取实际 image_count 更新 total_images。
|
||||
/// 修复管线过程中因删除/迁移关联未更新旧 person 计数的遗留问题。
|
||||
pub async fn phase_recount_all(db: &Database) -> usize {
|
||||
let persons = db.get_all_persons_with_counts().await;
|
||||
let mut fixed = 0usize;
|
||||
for (pid, name, old_albums, _) in &persons {
|
||||
let actual = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM person_albums WHERE person_id = ?"
|
||||
).bind(pid).fetch_one(&db.pool).await.unwrap_or(0);
|
||||
if actual != *old_albums {
|
||||
db.update_person_counts(*pid).await;
|
||||
fixed += 1;
|
||||
}
|
||||
}
|
||||
if fixed > 0 {
|
||||
println!("[管线·PhaseC] 全量重计完成:修正 {} 个人物的计数", fixed);
|
||||
} else {
|
||||
println!("[管线·PhaseC] 全量重计完成:所有计数正确");
|
||||
}
|
||||
fixed
|
||||
}
|
||||
// ════════════════════════════════════════════════
|
||||
// Preprocess + Dirty Tracking
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 合并预处理结果
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct PreprocessResult {
|
||||
tags_cleaned: usize,
|
||||
short_tags_removed: usize,
|
||||
person_like_tags_removed: usize,
|
||||
new_albums_found: usize,
|
||||
dirty: DirtySet,
|
||||
}
|
||||
|
||||
/// 合并预处理:只做收集新相册,不再做标签清洗(已迁移到 search.db)
|
||||
async fn phase_preprocess_combined(db: &Database) -> PreprocessResult {
|
||||
let mut result = PreprocessResult::default();
|
||||
let dirty = phase_collect_new_albums(db).await;
|
||||
result.dirty = dirty;
|
||||
result.new_albums_found = result.dirty.len();
|
||||
println!("[管线·Phase①] 合并预处理完成(精简模式):新相册{},脏集合计{}",
|
||||
result.new_albums_found, result.dirty.len());
|
||||
result
|
||||
}
|
||||
|
||||
/// 合并人物清理:一次人物遍历完成 2 件事
|
||||
/// 1. 清理非人物
|
||||
/// 2. 清理停用词重叠人物
|
||||
async fn phase_clean_persons_combined(db: &Database) -> (DirtySet, usize, usize) {
|
||||
let persons = db.get_all_persons_with_counts().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut non_persons_deleted = 0usize;
|
||||
let mut stop_word_persons_removed = 0usize;
|
||||
|
||||
for (pid, name, _albums, _images) in &persons {
|
||||
// 判断是否应该删除
|
||||
let is_non_person = title_parser::is_excluded_name(name) || !title_parser::is_likely_person_name(name);
|
||||
|
||||
let is_stop_word = {
|
||||
let excluded = tag_dict::get_tag_dict().is_excluded_person(name);
|
||||
let cn_check = title_parser::extract_cn(name)
|
||||
.map(|cn| tag_dict::get_tag_dict().is_excluded_person(&cn))
|
||||
.unwrap_or(false);
|
||||
excluded || cn_check
|
||||
};
|
||||
|
||||
if !is_non_person && !is_stop_word { continue; }
|
||||
|
||||
let album_list = db.get_person_albums(*pid).await;
|
||||
let album_ids: Vec<String> = album_list.into_iter().map(|a| a.id.to_string()).collect();
|
||||
for aid in &album_ids { dirty.add(aid); }
|
||||
|
||||
if is_stop_word {
|
||||
println!("[管线·Phase②] 排除人名命中:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
|
||||
stop_word_persons_removed += 1;
|
||||
} else {
|
||||
println!("[管线·Phase②] 删除非人物:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
|
||||
non_persons_deleted += 1;
|
||||
}
|
||||
|
||||
db.update_album_person_by_person_id(*pid).await;
|
||||
db.delete_person_completely(*pid).await;
|
||||
}
|
||||
|
||||
if non_persons_deleted > 0 || stop_word_persons_removed > 0 {
|
||||
println!("[管线·Phase②] 人物清理完成:删除非人物{},移除停用词人物{},脏相册{}",
|
||||
non_persons_deleted, stop_word_persons_removed, dirty.len());
|
||||
} else {
|
||||
println!("[管线·Phase②] 人物清理完成:无异常");
|
||||
}
|
||||
|
||||
(dirty, non_persons_deleted, stop_word_persons_removed)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase Z0:删除空相册(image_count = 0)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 删除所有图片数为 0 的空相册
|
||||
pub async fn phase_delete_empty_albums(db: &Database) -> usize {
|
||||
let empty = db.get_empty_albums().await;
|
||||
let count = empty.len();
|
||||
if count == 0 {
|
||||
println!("[管线·PhaseZ0] 无空相册需要删除");
|
||||
return 0;
|
||||
}
|
||||
let ids: Vec<uuid::Uuid> = empty.iter().map(|a| a.id).collect();
|
||||
let deleted = db.delete_albums_batch(&ids).await;
|
||||
println!("[管线·PhaseZ0] 删除 {} 个空相册(无图片)", deleted);
|
||||
deleted
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase Z1:去重同名相册
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 查找同名的相册,只保留第一个(最早创建的),删除其余(包括文件夹和 search.db 数据)
|
||||
pub async fn phase_dedup_albums(db: &Database, search_db: &SearchDb) -> usize {
|
||||
let dups = db.find_duplicate_albums().await;
|
||||
let images_dir = &db.config.storage.images_dir;
|
||||
let mut total_deleted = 0usize;
|
||||
for (keep_id, keep_document, to_delete) in &dups {
|
||||
let dup_ids: Vec<uuid::Uuid> = to_delete.iter().map(|(id, _)| *id).collect();
|
||||
// 删除数据库记录
|
||||
db.delete_albums_batch(&dup_ids).await;
|
||||
// 删除 search.db 数据
|
||||
for (id, _) in to_delete {
|
||||
search_db.delete_album(&id.to_string()).await.ok();
|
||||
}
|
||||
// 删除磁盘文件夹
|
||||
for (_, doc) in to_delete {
|
||||
let folder = images_dir.join(doc);
|
||||
if folder.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&folder) {
|
||||
eprintln!("[管线·PhaseZ1] 删除文件夹失败 {}: {}", folder.display(), e);
|
||||
} else {
|
||||
println!("[管线·PhaseZ1] 删除文件夹 {}", folder.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
total_deleted += to_delete.len();
|
||||
for (id, doc) in to_delete {
|
||||
println!("[管线·PhaseZ1] 删除重复相册 {} ({}),保留 {} ({})", id, doc, keep_id, keep_document);
|
||||
}
|
||||
}
|
||||
if total_deleted == 0 {
|
||||
println!("[管线·PhaseZ1] 无重复相册");
|
||||
} else {
|
||||
println!("[管线·PhaseZ1] 去重完成:删除 {} 个重复相册", total_deleted);
|
||||
}
|
||||
total_deleted
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Pipeline 一键执行
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PipelineResult {
|
||||
pub tags_cleaned: usize, // Phase ①
|
||||
pub short_tags_removed: usize, // Phase ①
|
||||
pub person_like_tags_removed: usize, // Phase ①
|
||||
pub dirty_albums_detected: usize, // 总脏集
|
||||
pub non_persons_deleted: usize, // Phase ②
|
||||
pub stop_word_persons_removed: usize, // Phase ②
|
||||
pub new_albums_found: usize, // Phase ①
|
||||
pub persons_updated: usize, // Phase ③
|
||||
pub tags_updated: usize, // Phase ③
|
||||
pub empty_albums_deleted: usize, // Phase Z0
|
||||
pub dup_albums_deleted: usize, // Phase Z1
|
||||
}
|
||||
|
||||
/// 一键执行精简管线(dict 优先匹配)
|
||||
///
|
||||
/// Phase Z0 删除空相册
|
||||
/// Phase Z1 去重同名相册
|
||||
/// Phase ① 收集新相册
|
||||
/// Phase ② 合并人物清理(非人物+停用词人物) → 1次人物扫描
|
||||
/// Phase ③ dict 优先解析脏集 → 只扫脏集
|
||||
/// Phase C 全量重计
|
||||
pub async fn run_pipeline(db: &Database, search_db: &SearchDb) -> PipelineResult {
|
||||
// Phase Z0:删除空相册
|
||||
let empty_albums_deleted = phase_delete_empty_albums(db).await;
|
||||
|
||||
// Phase Z1:去重同名相册(含删除文件夹和 search.db 数据)
|
||||
let dup_albums_deleted = phase_dedup_albums(db, search_db).await;
|
||||
|
||||
// Phase ①:收集新相册
|
||||
let pre = phase_preprocess_combined(db).await;
|
||||
let tags_cleaned = pre.tags_cleaned;
|
||||
let short_tags_removed = pre.short_tags_removed;
|
||||
let person_like_tags_removed = pre.person_like_tags_removed;
|
||||
let new_albums_found = pre.new_albums_found;
|
||||
|
||||
// Phase ②:合并人物清理(1 次人物扫描)
|
||||
let (dirty_persons, non_persons_deleted, stop_word_persons_removed) =
|
||||
phase_clean_persons_combined(db).await;
|
||||
|
||||
// 合并脏集
|
||||
let mut master_dirty = pre.dirty;
|
||||
master_dirty.extend(dirty_persons.into_inner());
|
||||
let dirty_albums_detected = master_dirty.len();
|
||||
|
||||
// Phase ③:dict 优先解析脏集
|
||||
let (persons_updated, tags_updated) = phase_parse_dirty(db, search_db, &master_dirty).await;
|
||||
|
||||
// Phase C:全量重计(修正计数)
|
||||
let _recounted = phase_recount_all(db).await;
|
||||
|
||||
PipelineResult {
|
||||
tags_cleaned, short_tags_removed, person_like_tags_removed, dirty_albums_detected,
|
||||
non_persons_deleted, stop_word_persons_removed, new_albums_found,
|
||||
persons_updated, tags_updated,
|
||||
empty_albums_deleted, dup_albums_deleted,
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase S:同步管线结果到 search.db
|
||||
// 只同步 tags/persons/aliases 到 search.db 的关联表,通过 album_id 关联
|
||||
// 不同步 albums 表数据(albums 由 imager.db 管理)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 全量同步:将所有相册的 tags/persons 解析结果写入 search.db 的关联表(单事务批量)
|
||||
pub async fn sync_albums_to_search_db(db: &Database, search_db: &SearchDb) -> usize {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
|
||||
// 并行解析所有相册
|
||||
let parsed: Vec<crate::models::parse::ParseResult> = albums.par_iter()
|
||||
.filter_map(|album| {
|
||||
let title = album.title.as_deref()?;
|
||||
if title.is_empty() { return None; }
|
||||
Some(title_parser::parse_album_title(&album.id, title))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 单事务批量写入 search.db
|
||||
match search_db.batch_sync_parse_results(&parsed).await {
|
||||
Ok(n) => {
|
||||
println!("[Sync->search.db] 全量同步完成:{} 个相册", n);
|
||||
n
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Sync->search.db] 批量同步失败: {}", e);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 增量同步:只处理脏集中的相册 ID(单事务批量)
|
||||
pub async fn sync_dirty_albums(db: &Database, search_db: &SearchDb, dirty: &DirtySet) -> usize {
|
||||
if dirty.is_empty() {
|
||||
println!("[Sync->search.db·增量] 脏集为空,跳过");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let dirty_ids: Vec<String> = dirty.clone().into_inner().into_iter().collect();
|
||||
let albums = db.get_albums_by_ids(&dirty_ids).await;
|
||||
|
||||
// 并行解析脏集相册
|
||||
let parsed: Vec<crate::models::parse::ParseResult> = albums.par_iter()
|
||||
.filter_map(|album| {
|
||||
let title = album.title.as_deref()?;
|
||||
if title.is_empty() { return None; }
|
||||
Some(title_parser::parse_album_title(&album.id, title))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 单事务批量写入 search.db
|
||||
match search_db.batch_sync_parse_results(&parsed).await {
|
||||
Ok(n) => {
|
||||
println!("[Sync->search.db·增量] 脏集同步完成:{} 个相册", n);
|
||||
n
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Sync->search.db·增量] 批量同步失败: {}", e);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
206
src/engine/recommend.rs
Normal file
206
src/engine/recommend.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
//! 推荐引擎 — 标签推荐、人物推荐、关联推荐
|
||||
//!
|
||||
//! 推荐策略:
|
||||
//! 1. 标签推荐:基于输入前缀匹配 + 热门度排序
|
||||
//! 2. 人物推荐:基于名称匹配 + 相册数排序
|
||||
//! 3. 关联推荐:基于标签共现关系 + 人物共现关系
|
||||
|
||||
use crate::search_db::SearchDb;
|
||||
use crate::models::*;
|
||||
use crate::engine::tag_dict;
|
||||
use sqlx::Row;
|
||||
|
||||
/// 推荐引擎
|
||||
pub struct RecommendEngine;
|
||||
|
||||
impl RecommendEngine {
|
||||
/// 标签自动补全推荐(从缓存的热门标签列表匹配)
|
||||
pub fn recommend_tags(
|
||||
popular_tags: &[TagStat],
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> RecommendResponse {
|
||||
let limit = limit.min(20);
|
||||
let q = query.trim().to_lowercase();
|
||||
|
||||
let mut items: Vec<RecommendItem> = Vec::new();
|
||||
|
||||
for t in popular_tags {
|
||||
let tag_lower = t.tag.to_lowercase();
|
||||
let score = if q.is_empty() {
|
||||
t.count as f64
|
||||
} else if tag_lower == q {
|
||||
1000.0 + t.count as f64
|
||||
} else if tag_lower.starts_with(&q) {
|
||||
500.0 + t.count as f64
|
||||
} else if tag_lower.contains(&q) {
|
||||
100.0 + t.count as f64
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
items.push(RecommendItem {
|
||||
value: t.tag.clone(),
|
||||
label: format!("{} ({})", t.tag, t.count),
|
||||
score,
|
||||
count: Some(t.count),
|
||||
source: "tag".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let total = items.len();
|
||||
items.truncate(limit);
|
||||
|
||||
RecommendResponse { items, total, category: "tags".to_string() }
|
||||
}
|
||||
|
||||
/// 人物推荐(从缓存的人物列表匹配)
|
||||
pub fn recommend_persons(
|
||||
persons: &[PersonWithCounts],
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> RecommendResponse {
|
||||
let limit = limit.min(20);
|
||||
let q = query.trim().to_lowercase();
|
||||
|
||||
let mut items: Vec<RecommendItem> = Vec::new();
|
||||
|
||||
for p in persons {
|
||||
let name_lower = p.name.to_lowercase();
|
||||
let score = if q.is_empty() {
|
||||
p.total_albums as f64
|
||||
} else if name_lower == q {
|
||||
1000.0 + p.total_albums as f64
|
||||
} else if name_lower.starts_with(&q) {
|
||||
500.0 + p.total_albums as f64
|
||||
} else if name_lower.contains(&q) {
|
||||
100.0 + p.total_albums as f64
|
||||
} else {
|
||||
let alias_match = p.aliases.iter().any(|a| a.to_lowercase().contains(&q));
|
||||
if alias_match { 200.0 + p.total_albums as f64 } else { continue }
|
||||
};
|
||||
|
||||
let label = if p.aliases.is_empty() {
|
||||
format!("{} ({}相册)", p.name, p.total_albums)
|
||||
} else {
|
||||
format!("{} [{}] ({}相册)", p.name, p.aliases.join(", "), p.total_albums)
|
||||
};
|
||||
|
||||
items.push(RecommendItem {
|
||||
value: p.name.clone(),
|
||||
label,
|
||||
score,
|
||||
count: Some(p.total_albums),
|
||||
source: "person".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let total = items.len();
|
||||
items.truncate(limit);
|
||||
|
||||
RecommendResponse { items, total, category: "persons".to_string() }
|
||||
}
|
||||
|
||||
/// 关联推荐(共现标签 + 共现人物)
|
||||
pub async fn recommend_related(
|
||||
search_db: &SearchDb,
|
||||
context: &str,
|
||||
limit: usize,
|
||||
) -> TagRecommendation {
|
||||
let limit = limit.min(15);
|
||||
|
||||
let co_tags = search_db.get_tag_co_occurrences(context, limit as i64).await;
|
||||
let related_tags: Vec<RecommendItem> = co_tags.iter().map(|c| {
|
||||
RecommendItem {
|
||||
value: c.co_tag.clone(),
|
||||
label: format!("{} (共现{}次)", c.co_tag, c.count),
|
||||
score: c.score,
|
||||
count: Some(c.count),
|
||||
source: "co-occurrence".to_string(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let albums = search_db.search_albums_by_tag(context).await;
|
||||
let mut person_scores: std::collections::HashMap<String, (i64, f64)> = std::collections::HashMap::new();
|
||||
let total = albums.len() as f64;
|
||||
for album in &albums {
|
||||
let aid = album.id.to_string();
|
||||
let person_rows = sqlx::query(
|
||||
r#"SELECT p.name FROM persons p
|
||||
JOIN album_persons ap ON p.id = ap.person_id
|
||||
WHERE ap.album_id = ?"#
|
||||
).bind(&aid).fetch_all(&search_db.pool).await.unwrap_or_default();
|
||||
for row in &person_rows {
|
||||
let name: String = row.get("name");
|
||||
let entry = person_scores.entry(name).or_insert((0, 0.0));
|
||||
entry.0 += 1;
|
||||
entry.1 = entry.0 as f64 / total;
|
||||
}
|
||||
}
|
||||
|
||||
let mut similar_persons: Vec<RecommendItem> = person_scores.into_iter()
|
||||
.filter(|(_, (cnt, _))| *cnt >= 1)
|
||||
.map(|(name, (cnt, score))| RecommendItem {
|
||||
value: name.clone(),
|
||||
label: format!("{} ({}次)", name, cnt),
|
||||
score,
|
||||
count: Some(cnt),
|
||||
source: "co-occurrence".to_string(),
|
||||
}).collect();
|
||||
similar_persons.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
similar_persons.truncate(limit);
|
||||
|
||||
TagRecommendation {
|
||||
tag: context.to_string(),
|
||||
related_tags,
|
||||
similar_persons,
|
||||
}
|
||||
}
|
||||
|
||||
/// 热门人物(从缓存的人物列表过滤,top_tags 保留 search.db 查询)
|
||||
pub async fn popular_persons(
|
||||
search_db: &SearchDb,
|
||||
persons_cache: &[PersonWithCounts],
|
||||
limit: usize,
|
||||
) -> Vec<PopularPerson> {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
|
||||
let all_tag_words: std::collections::HashSet<String> = dict.tag_to_category.keys()
|
||||
.map(|k| k.to_lowercase()).collect();
|
||||
|
||||
let filtered: Vec<_> = persons_cache.iter()
|
||||
.filter(|p| {
|
||||
if dict.is_excluded_person(&p.name) { return false; }
|
||||
if !crate::engine::title_parser::is_likely_person_name(&p.name) {
|
||||
return false;
|
||||
}
|
||||
let name_lower = p.name.to_lowercase();
|
||||
if all_tag_words.contains(&name_lower) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut results: Vec<PopularPerson> = filtered.iter().take(limit).map(|p| {
|
||||
PopularPerson {
|
||||
name: p.name.clone(),
|
||||
display_name: p.display_name.clone(),
|
||||
total_albums: p.total_albums,
|
||||
total_images: p.total_images,
|
||||
top_tags: Vec::new(),
|
||||
aliases: p.aliases.clone(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
for rp in &mut results {
|
||||
if let Some(person) = search_db.get_person_by_name(&rp.name).await {
|
||||
let top_tags = search_db.get_person_top_tags(&person.id, 5).await;
|
||||
rp.top_tags = top_tags.into_iter().map(|(t, _)| t).collect();
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
629
src/engine/tag_dict.rs
Normal file
629
src/engine/tag_dict.rs
Normal file
@@ -0,0 +1,629 @@
|
||||
//! 标签词典 — 从外部 JSON 文件加载分类标签、停用词、人物规则
|
||||
//!
|
||||
//! 词典文件格式(dict/tags.json v4):
|
||||
//! ```json
|
||||
//! {
|
||||
//! "version": 4,
|
||||
//! "metadata": { "style": {"label":"风格","color_bg":"...","color_fg":"..."}, ... },
|
||||
//! "keymap": { "日系": "style", "比基尼": "outfit", ... },
|
||||
//! "stop_words": [...],
|
||||
//! "person_rules": { "prefixes": [...], "excluded": [...], "include": [...] }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use serde::Deserialize;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::RwLock;
|
||||
|
||||
// ── JSON 反序列化结构 (v4) ──
|
||||
|
||||
/// v4 分类元数据(label + 颜色)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CategoryMeta {
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
#[serde(default = "default_color_bg")]
|
||||
pub color_bg: String,
|
||||
#[serde(default = "default_color_fg")]
|
||||
pub color_fg: String,
|
||||
}
|
||||
|
||||
fn default_color_bg() -> String { "#f1f5f9".to_string() }
|
||||
fn default_color_fg() -> String { "#64748b".to_string() }
|
||||
|
||||
/// 词典文件的顶层结构 (v4)
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TagDictFile {
|
||||
pub version: u32,
|
||||
#[allow(unused)]
|
||||
pub description: Option<String>,
|
||||
/// v4: 分类元数据 { "style": {"label":"风格","color_bg":"...","color_fg":"..."}, ... }
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, CategoryMeta>,
|
||||
/// v4: 扁平化匹配串→分类名 { "日系": "style", "比基尼": "outfit", ... }
|
||||
#[serde(default)]
|
||||
pub keymap: HashMap<String, String>,
|
||||
/// 停用词列表
|
||||
#[serde(default)]
|
||||
pub stop_words: Vec<String>,
|
||||
/// 人物称呼前缀(顶层字段,从 JSON 文件读取)
|
||||
#[serde(default)]
|
||||
pub person_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
// ── 运行时词典结构 ──
|
||||
|
||||
/// 分类信息(运行时使用,兼容旧版 API)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CategoryInfo {
|
||||
pub name: String,
|
||||
pub label: String,
|
||||
pub color_bg: String,
|
||||
pub color_fg: String,
|
||||
pub words: Vec<String>,
|
||||
}
|
||||
|
||||
/// 运行时标签词典
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagDict {
|
||||
/// 分类名称 → 分类详情(兼容旧版 API)
|
||||
pub categories: HashMap<String, CategoryInfo>,
|
||||
/// 标签 → 分类名称(兼容旧版 API,基于 keymap 构建)
|
||||
pub tag_to_category: HashMap<String, String>,
|
||||
/// 停用词集合
|
||||
pub stop_words: HashSet<String>,
|
||||
/// 人物称呼前缀(提取人名时直接 replace 移除)
|
||||
pub person_prefixes: Vec<String>,
|
||||
/// 排除的人物名(工作室/平台名,匹配上的人和包含这些词的人直接删除重解析)
|
||||
pub excluded_person_names: HashSet<String>,
|
||||
/// 强制包含的人物名(已知模特/演员名,只要出现就优先识别为人名)
|
||||
pub included_person_names: HashSet<String>,
|
||||
// ── v4 新字段 ──
|
||||
/// 扁平化匹配串 → 分类名(v4 keymap)
|
||||
pub keymap: HashMap<String, String>,
|
||||
/// 分类元数据(v4 metadata)
|
||||
pub metadata: HashMap<String, CategoryMeta>,
|
||||
}
|
||||
|
||||
impl Default for TagDict {
|
||||
fn default() -> Self {
|
||||
let mut dict = TagDict {
|
||||
categories: HashMap::new(),
|
||||
tag_to_category: HashMap::new(),
|
||||
stop_words: HashSet::new(),
|
||||
person_prefixes: Vec::new(),
|
||||
excluded_person_names: HashSet::new(),
|
||||
included_person_names: HashSet::new(),
|
||||
keymap: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
dict.fill_builtin();
|
||||
dict
|
||||
}
|
||||
}
|
||||
|
||||
impl TagDict {
|
||||
/// 从默认路径加载词典(`dict/tags.json`)
|
||||
pub fn load() -> Self {
|
||||
Self::load_from("dict/tags.json")
|
||||
}
|
||||
|
||||
/// 从指定路径加载词典
|
||||
pub fn load_from(path: impl AsRef<Path>) -> Self {
|
||||
let path = path.as_ref();
|
||||
if !path.exists() {
|
||||
eprintln!("[词典] 词典文件不存在 {:?},使用内建回退", path);
|
||||
return TagDict::default();
|
||||
}
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("[词典] 无法读取 {:?}: {},使用内建回退", path, e);
|
||||
return TagDict::default();
|
||||
}
|
||||
};
|
||||
|
||||
let file: TagDictFile = match serde_json::from_str(&content) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("[词典] 解析 {:?} 失败: {},使用内建回退", path, e);
|
||||
return TagDict::default();
|
||||
}
|
||||
};
|
||||
|
||||
// ── 人物前缀直接从顶层字段读取 ──
|
||||
let person_prefixes = file.person_prefixes.clone();
|
||||
|
||||
let mut dict = TagDict {
|
||||
categories: HashMap::new(),
|
||||
tag_to_category: HashMap::new(),
|
||||
stop_words: file.stop_words.into_iter().collect(),
|
||||
person_prefixes,
|
||||
excluded_person_names: HashSet::new(),
|
||||
included_person_names: HashSet::new(),
|
||||
keymap: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
|
||||
// ── 加载 metadata(分类元数据) ──
|
||||
dict.metadata = file.metadata.clone();
|
||||
|
||||
// ── 加载 keymap(扁平化匹配串→分类名) ──
|
||||
dict.keymap = file.keymap.clone();
|
||||
|
||||
// ── 构建 tag_to_category(基于 keymap) ──
|
||||
for (word, cat_name) in &file.keymap {
|
||||
dict.tag_to_category.insert(word.to_lowercase(), cat_name.clone());
|
||||
}
|
||||
|
||||
// ── 构建 categories(基于 metadata + keymap 提取 words) ──
|
||||
// 从 keymap 中按分类收集所有词
|
||||
let mut words_by_cat: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (word, cat_name) in &file.keymap {
|
||||
words_by_cat.entry(cat_name.clone())
|
||||
.or_default()
|
||||
.push(word.clone());
|
||||
}
|
||||
|
||||
// 按 metadata 中的分类顺序构建
|
||||
for (cat_name, meta) in &file.metadata {
|
||||
let words = words_by_cat.remove(cat_name).unwrap_or_default();
|
||||
dict.categories.insert(cat_name.clone(), CategoryInfo {
|
||||
name: cat_name.clone(),
|
||||
label: meta.label.clone(),
|
||||
color_bg: meta.color_bg.clone(),
|
||||
color_fg: meta.color_fg.clone(),
|
||||
words,
|
||||
});
|
||||
}
|
||||
// keymap 中有但 metadata 中没有的分类(兜底)
|
||||
for (cat_name, words) in words_by_cat {
|
||||
dict.categories.insert(cat_name.clone(), CategoryInfo {
|
||||
name: cat_name.clone(),
|
||||
label: cat_name.clone(),
|
||||
color_bg: "#f1f5f9".to_string(),
|
||||
color_fg: "#64748b".to_string(),
|
||||
words,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 从 keymap 构建排除人名和包含人名 ──
|
||||
// person 类型的 key 加入 included_person_names
|
||||
// 其他类型(style/outfit/scene/body/mood/topic/position/location/brand)加入 excluded_person_names
|
||||
for (word, cat_name) in &file.keymap {
|
||||
if cat_name == "person" {
|
||||
dict.included_person_names.insert(word.to_lowercase());
|
||||
} else {
|
||||
dict.excluded_person_names.insert(word.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
if !path.as_os_str().is_empty() {
|
||||
println!("[词典] v{} 已加载: {} 分类, {} 标签词(keymap), {} 停用词, {} 前缀, 排除+标签共 {} 词",
|
||||
file.version, dict.categories.len(), dict.keymap.len(),
|
||||
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len());
|
||||
}
|
||||
|
||||
dict
|
||||
}
|
||||
|
||||
/// 热重载词典
|
||||
pub fn reload(&mut self) {
|
||||
*self = Self::load();
|
||||
}
|
||||
|
||||
/// 获取所有分类名称(固定顺序:9 个标准分类优先)
|
||||
pub fn get_category_order(&self) -> Vec<&str> {
|
||||
let preferred = ["style", "outfit", "scene", "body", "mood", "topic", "position", "location", "brand"];
|
||||
let mut result: Vec<&str> = preferred.iter()
|
||||
.filter(|c| self.categories.contains_key(**c))
|
||||
.map(|c| *c)
|
||||
.collect();
|
||||
for name in self.categories.keys() {
|
||||
if !result.contains(&name.as_str()) {
|
||||
result.push(name);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 获取分类信息
|
||||
pub fn get_category(&self, name: &str) -> Option<&CategoryInfo> {
|
||||
self.categories.get(name)
|
||||
}
|
||||
|
||||
/// 获取分类的中文标签
|
||||
pub fn get_category_label(&self, name: &str) -> String {
|
||||
self.categories.get(name)
|
||||
.map(|c| c.label.clone())
|
||||
.unwrap_or_else(|| "其他".to_string())
|
||||
}
|
||||
|
||||
/// 获取分类的颜色
|
||||
pub fn get_category_color(&self, name: &str) -> (String, String) {
|
||||
match self.categories.get(name) {
|
||||
Some(c) => (c.color_bg.clone(), c.color_fg.clone()),
|
||||
None => ("#f1f5f9".to_string(), "#64748b".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断一个词属于哪个已知分类(基于 tag_to_category,由 keymap 构建)
|
||||
pub fn classify(&self, word: &str) -> Option<&str> {
|
||||
self.tag_to_category.get(&word.to_lowercase()).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// 在 keymap 中查找 word,返回分类名(与 classify 相同,v4 新增 API)
|
||||
pub fn match_keymap(&self, word: &str) -> Option<&str> {
|
||||
self.keymap.get(&word.to_lowercase()).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// 获取 v4 元数据
|
||||
pub fn get_metadata(&self) -> &HashMap<String, CategoryMeta> {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// 判断是否是停用词
|
||||
pub fn is_stop_word(&self, word: &str) -> bool {
|
||||
self.stop_words.contains(&word.to_lowercase())
|
||||
}
|
||||
|
||||
/// 获取所有标签→分类映射
|
||||
pub fn get_tag_category_map(&self) -> &HashMap<String, String> {
|
||||
&self.tag_to_category
|
||||
}
|
||||
|
||||
/// 获取停用词集合
|
||||
pub fn get_stop_words(&self) -> &HashSet<String> {
|
||||
&self.stop_words
|
||||
}
|
||||
|
||||
/// ── 人物规则 ──
|
||||
|
||||
/// 判断人名是否应被排除(精确匹配 或 被排除词包含)。
|
||||
/// 注意:标签词典中的所有词汇已自动加入排除名单,因此标签词永不为人名。
|
||||
pub fn is_excluded_person(&self, person_name: &str) -> bool {
|
||||
let name_lower = person_name.to_lowercase();
|
||||
// 精确匹配
|
||||
if self.excluded_person_names.contains(person_name) {
|
||||
return true;
|
||||
}
|
||||
if self.excluded_person_names.contains(&name_lower) {
|
||||
return true;
|
||||
}
|
||||
// 包含匹配:排除词是 person_name 的子串
|
||||
for excluded in &self.excluded_person_names {
|
||||
if name_lower.contains(&excluded.to_lowercase()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 获取人物称呼前缀列表
|
||||
pub fn get_person_prefixes(&self) -> &[String] {
|
||||
&self.person_prefixes
|
||||
}
|
||||
|
||||
/// 获取排除人名集合
|
||||
pub fn get_excluded_persons(&self) -> &HashSet<String> {
|
||||
&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)
|
||||
}
|
||||
|
||||
/// 在 include 列表中查找以 name 开头的最长匹配。
|
||||
///
|
||||
/// 例如 include 中有 "杨晨晨Yome", "杨晨晨Sugar",
|
||||
/// 传入 "杨晨晨" 会返回 "杨晨晨Yome"(取最长的那条)。
|
||||
/// 传入 "Yome" 也返回 "杨晨晨Yome"(取包含该词的最长的那条)。
|
||||
pub fn find_longest_included_match(&self, name: &str) -> Option<String> {
|
||||
let name_lower = name.to_lowercase();
|
||||
self.included_person_names.iter()
|
||||
.filter(|included| {
|
||||
let inc_lower = included.to_lowercase();
|
||||
// include 以 name 开头 ? 或 name 以 include 开头 ?
|
||||
inc_lower.starts_with(&name_lower) || name_lower.starts_with(&inc_lower)
|
||||
|| inc_lower.contains(&name_lower) // include 包含 name(中文核心→全名)
|
||||
})
|
||||
.max_by_key(|s| s.len())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 获取强制包含人名集合
|
||||
pub fn get_included_persons(&self) -> &HashSet<String> {
|
||||
&self.included_person_names
|
||||
}
|
||||
|
||||
/// 内置回退
|
||||
fn fill_builtin(&mut self) {
|
||||
// ── 元数据(与 v4 JSON metadata 同构) ──
|
||||
let builtin_metadata: Vec<( &str, &str, &str, &str)> = vec![
|
||||
("style", "风格", "#f0f9ff", "#0284c7"),
|
||||
("outfit", "服装", "#fdf2f8", "#db2777"),
|
||||
("scene", "场景", "#f0fdf4", "#15803d"),
|
||||
("body", "身体", "#fef3c7", "#b45309"),
|
||||
("mood", "情绪", "#faf5ff", "#7c3aed"),
|
||||
("topic", "主题", "#f0f9ff", "#0369a1"),
|
||||
("position", "姿势", "#fefce8", "#a16207"),
|
||||
("location", "地点", "#f0fdfa", "#0f766e"),
|
||||
("brand", "品牌", "#fff7ed", "#c2410c"),
|
||||
];
|
||||
|
||||
for (name, label, bg, fg) in &builtin_metadata {
|
||||
self.metadata.insert(name.to_string(), CategoryMeta {
|
||||
label: label.to_string(),
|
||||
color_bg: bg.to_string(),
|
||||
color_fg: fg.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── keymap ──
|
||||
let builtin_keymap: Vec<(&str, &str)> = vec![
|
||||
// style
|
||||
("日系", "style"), ("韩系", "style"), ("复古", "style"),
|
||||
("甜美", "style"), ("性感", "style"), ("清新", "style"),
|
||||
("冷艳", "style"), ("优雅", "style"), ("知性", "style"),
|
||||
("可爱", "style"), ("俏皮", "style"), ("温柔", "style"),
|
||||
("时尚", "style"), ("文艺", "style"), ("古典", "style"),
|
||||
("elegant", "style"), ("fashion", "style"), ("japanese", "style"),
|
||||
("korean", "style"), ("retro", "style"), ("sexy", "style"),
|
||||
("vintage", "style"),
|
||||
// outfit
|
||||
("比基尼", "outfit"), ("泳装", "outfit"), ("旗袍", "outfit"),
|
||||
("制服", "outfit"), ("丝袜", "outfit"), ("内衣", "outfit"),
|
||||
("连衣裙", "outfit"), ("短裙", "outfit"), ("长裙", "outfit"),
|
||||
("牛仔裤", "outfit"), ("热裤", "outfit"), ("吊带", "outfit"),
|
||||
("抹胸", "outfit"), ("蕾丝", "outfit"), ("镂空", "outfit"),
|
||||
("高跟鞋", "outfit"), ("T恤", "outfit"),
|
||||
// scene
|
||||
("室内", "scene"), ("户外", "scene"), ("浴室", "scene"),
|
||||
("床上", "scene"), ("海边", "scene"), ("沙滩", "scene"),
|
||||
("泳池", "scene"), ("花园", "scene"),
|
||||
// body
|
||||
("美腿", "body"), ("美胸", "body"), ("翘臀", "body"),
|
||||
("玉足", "body"), ("美背", "body"), ("香肩", "body"),
|
||||
("ass", "body"), ("back", "body"), ("breasts", "body"),
|
||||
("feet", "body"), ("legs", "body"), ("shoulders", "body"),
|
||||
("skin", "body"), ("tattoo", "body"), ("waist", "body"),
|
||||
// mood
|
||||
("清纯", "mood"), ("诱惑", "mood"), ("唯美", "mood"),
|
||||
("妩媚", "mood"), ("beautiful", "mood"), ("charming", "mood"),
|
||||
("cool", "mood"), ("cute", "mood"), ("fresh", "mood"),
|
||||
("gentle", "mood"), ("lazy", "mood"), ("pure", "mood"),
|
||||
("seductive", "mood"), ("shy", "mood"), ("sweet", "mood"),
|
||||
("wild", "mood"),
|
||||
// topic
|
||||
("写真", "topic"), ("私房", "topic"), ("街拍", "topic"),
|
||||
("旅拍", "topic"), ("婚纱照", "topic"),
|
||||
("cosplay", "topic"), ("photoshoot", "topic"),
|
||||
("portrait", "topic"), ("selfie", "topic"), ("travel", "topic"),
|
||||
("wedding", "topic"),
|
||||
// position
|
||||
("站立", "position"), ("坐姿", "position"), ("躺姿", "position"),
|
||||
("跪姿", "position"), ("侧卧", "position"),
|
||||
("kneeling", "position"), ("lying", "position"),
|
||||
("sitting", "position"), ("standing", "position"),
|
||||
// location
|
||||
("日本", "location"), ("韩国", "location"), ("中国", "location"),
|
||||
("泰国", "location"), ("东京", "location"), ("首尔", "location"),
|
||||
("北京", "location"), ("上海", "location"),
|
||||
("japan", "location"), ("korea", "location"), ("china", "location"),
|
||||
("bali", "location"), ("bangkok", "location"),
|
||||
("phuket", "location"), ("seoul", "location"),
|
||||
("thailand", "location"), ("tokyo", "location"),
|
||||
// brand
|
||||
("Victoria's Secret", "brand"), ("维密", "brand"),
|
||||
("Calvin Klein", "brand"), ("CK", "brand"),
|
||||
("Gucci", "brand"), ("Chanel", "brand"),
|
||||
("Louis Vuitton", "brand"),
|
||||
];
|
||||
|
||||
for (word, cat) in &builtin_keymap {
|
||||
self.keymap.insert(word.to_string(), cat.to_string());
|
||||
self.tag_to_category.insert(word.to_lowercase(), cat.to_string());
|
||||
}
|
||||
|
||||
// ── categories(基于 metadata + keymap 派生) ──
|
||||
let mut words_by_cat: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (word, cat_name) in &self.keymap {
|
||||
words_by_cat.entry(cat_name.clone())
|
||||
.or_default()
|
||||
.push(word.clone());
|
||||
}
|
||||
for (name, meta) in &self.metadata {
|
||||
let words = words_by_cat.remove(name).unwrap_or_default();
|
||||
self.categories.insert(name.clone(), CategoryInfo {
|
||||
name: name.clone(),
|
||||
label: meta.label.clone(),
|
||||
color_bg: meta.color_bg.clone(),
|
||||
color_fg: meta.color_fg.clone(),
|
||||
words,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 停用词 ──
|
||||
let stop_words: Vec<&str> = vec![
|
||||
"photos", "photo", "pictures", "picture", "images", "image",
|
||||
"video", "videos", "cosplay", "cos",
|
||||
"and", "the", "of", "in", "a", "an", "is", "to", "for",
|
||||
"part", "vol", "collection", "special", "extra", "bonus",
|
||||
"new", "old", "selfie", "official", "original",
|
||||
"set", "gallery", "album", "page", "issue",
|
||||
"model", "idol", "star", "actor", "actress", "singer",
|
||||
"秀人", "网", "图", "套", "合集", "系列", "作品",
|
||||
"美女", "模特",
|
||||
"高清", "原版", "无水印", "番号", "编号",
|
||||
];
|
||||
for w in &stop_words { self.stop_words.insert(w.to_string()); }
|
||||
|
||||
// ── 人物前缀 ──
|
||||
let person_prefixes: Vec<&str> = vec![
|
||||
"美女模特", "性感女神", "清纯美女", "气质美女", "可爱女孩",
|
||||
"甜美少女", "时尚达人", "女神", "美女", "模特", "小姐姐",
|
||||
"小仙女", "御姐", "萝莉", "辣妹", "潮人",
|
||||
];
|
||||
self.person_prefixes = person_prefixes.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
// ── 排除人名(从 keymap 非 person 类型派生)──
|
||||
for (word, cat) in &builtin_keymap {
|
||||
if cat == &"person" {
|
||||
self.included_person_names.insert(word.to_lowercase());
|
||||
} else {
|
||||
self.excluded_person_names.insert(word.to_lowercase());
|
||||
}
|
||||
}
|
||||
// 额外内置排除(keymap 中未覆盖的工作室名)
|
||||
let extra_excluded: Vec<&str> = vec![
|
||||
"XiuRen", "xiuren", "秀人", "秀人网",
|
||||
"MFStar", "MiiTao", "蜜桃社",
|
||||
"YouMi", "尤蜜", "HuaYang", "花洋",
|
||||
"FeiLin", "Feiling", "飞灵", "MFModel",
|
||||
"LeiLei", "Tristana", "Av",
|
||||
];
|
||||
for n in &extra_excluded { self.excluded_person_names.insert(n.to_string()); }
|
||||
|
||||
// ── 包含人名(从 keymap person 类型已派生,再加几个默认) ──
|
||||
let default_included: Vec<&str> = vec![
|
||||
"杨晨晨", "水淼", "轩美宣", "王馨瑶",
|
||||
"雪儿", "曼曼", "佳佳", "小乔",
|
||||
];
|
||||
for n in &default_included { self.included_person_names.insert(n.to_string()); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── lazy_static 全局词典 ──
|
||||
|
||||
lazy_static! {
|
||||
static ref TAG_DICT: RwLock<TagDict> = RwLock::new(TagDict::load());
|
||||
}
|
||||
|
||||
/// 获取全局词典只读引用
|
||||
pub fn get_tag_dict() -> std::sync::RwLockReadGuard<'static, TagDict> {
|
||||
TAG_DICT.read().expect("tag_dict 锁被污染")
|
||||
}
|
||||
|
||||
/// 热重载词典
|
||||
pub fn reload_tag_dict() -> String {
|
||||
let mut dict = TAG_DICT.write().expect("tag_dict 锁被污染");
|
||||
dict.reload();
|
||||
format!(
|
||||
"词典已重载: {} 分类, {} 标签(keymap), {} 停用词, {} 前缀, {} 排除人名, {} 包含人名",
|
||||
dict.categories.len(), dict.keymap.len(),
|
||||
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len(),
|
||||
dict.included_person_names.len(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取词典统计信息
|
||||
pub fn get_tag_dict_stats() -> String {
|
||||
let dict = get_tag_dict();
|
||||
format!(
|
||||
"标签词典: {} 分类, {} 标签(keymap), {} 停用词, {} 前缀, {} 排除人名, {} 包含人名 | 文件: dict/tags.json",
|
||||
dict.categories.len(), dict.keymap.len(),
|
||||
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len(),
|
||||
dict.included_person_names.len(),
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// 从 search.db 的 dict 表加载
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
use crate::search_db::DictEntry;
|
||||
|
||||
impl TagDict {
|
||||
/// 从 search.db 的 dict 表条目加载词典数据
|
||||
///
|
||||
/// dict 表条目类型(通过 type 字段区分):
|
||||
/// - `tag` — word 是标签,category 是分类名 → 加入 keymap
|
||||
/// - `person` — word 是人名 → 加入 included_person_names
|
||||
/// - `stop_word` — word 是停用词
|
||||
/// - `person_prefix` — word 是人物前缀
|
||||
/// - `excluded_person` — word 是排除人名
|
||||
///
|
||||
/// 派生规则:
|
||||
/// - 所有 tag 类型的 word 自动加入 excluded_person_names
|
||||
/// - categories 结构基于 metadata(内建)和 keymap(从 tag 条目构建)
|
||||
pub fn load_from_dict_entries(entries: &[DictEntry]) -> Self {
|
||||
let mut dict = TagDict::default();
|
||||
|
||||
for entry in entries {
|
||||
match entry.type_.as_str() {
|
||||
"tag" => {
|
||||
let cat = entry.category.as_deref().unwrap_or("other");
|
||||
dict.keymap.insert(entry.word.clone(), cat.to_string());
|
||||
dict.tag_to_category.insert(entry.word.to_lowercase(), cat.to_string());
|
||||
dict.excluded_person_names.insert(entry.word.to_lowercase());
|
||||
}
|
||||
"person" => {
|
||||
dict.included_person_names.insert(entry.word.to_lowercase());
|
||||
}
|
||||
"stop_word" => {
|
||||
dict.stop_words.insert(entry.word.to_lowercase());
|
||||
}
|
||||
"person_prefix" => {
|
||||
dict.person_prefixes.push(entry.word.clone());
|
||||
}
|
||||
"excluded_person" => {
|
||||
dict.excluded_person_names.insert(entry.word.to_lowercase());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 构建 categories(基于 metadata + keymap) ──
|
||||
let mut words_by_cat: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for (word, cat_name) in &dict.keymap {
|
||||
words_by_cat.entry(cat_name.clone()).or_default().push(word.clone());
|
||||
}
|
||||
for (name, meta) in &dict.metadata {
|
||||
let words = words_by_cat.remove(name).unwrap_or_default();
|
||||
dict.categories.insert(name.clone(), CategoryInfo {
|
||||
name: name.clone(),
|
||||
label: meta.label.clone(),
|
||||
color_bg: meta.color_bg.clone(),
|
||||
color_fg: meta.color_fg.clone(),
|
||||
words,
|
||||
});
|
||||
}
|
||||
for (cat_name, words) in words_by_cat {
|
||||
dict.categories.insert(cat_name.clone(), CategoryInfo {
|
||||
name: cat_name.clone(),
|
||||
label: cat_name.clone(),
|
||||
color_bg: "#f1f5f9".to_string(),
|
||||
color_fg: "#64748b".to_string(),
|
||||
words,
|
||||
});
|
||||
}
|
||||
|
||||
println!("[词典] 从 search.db dict 表加载: {} 分类, {} 标签(keymap), {} 停用词, {} 前缀, {} 排除人名, {} 包含人名",
|
||||
dict.categories.len(), dict.keymap.len(),
|
||||
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len(),
|
||||
dict.included_person_names.len());
|
||||
|
||||
dict
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 search.db 的 dict 表重新加载标签词典
|
||||
pub async fn reload_from_search_db(search_db: &crate::search_db::SearchDb) -> String {
|
||||
let entries = match search_db.load_dict_all().await {
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("从 search.db 加载 dict 失败: {}", e),
|
||||
};
|
||||
let mut dict = TAG_DICT.write().expect("tag_dict 锁被污染");
|
||||
*dict = TagDict::load_from_dict_entries(&entries);
|
||||
format!(
|
||||
"词典已从 search.db 重载: {} 分类, {} 标签(keymap), {} 停用词, {} 前缀, {} 排除人名, {} 包含人名",
|
||||
dict.categories.len(), dict.keymap.len(),
|
||||
dict.stop_words.len(), dict.person_prefixes.len(), dict.excluded_person_names.len(),
|
||||
dict.included_person_names.len(),
|
||||
)
|
||||
}
|
||||
373
src/engine/tag_extractor.rs
Normal file
373
src/engine/tag_extractor.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! 标签提取器 — 从标题中提取分类明确的标签
|
||||
//!
|
||||
//! 标签分类体系(外部词典 dict/tags.json v4 定义):
|
||||
//! style outfit scene body mood topic position location brand person
|
||||
//!
|
||||
//! 核心设计:纯词典 keymap substring 匹配 + 正则兜底,无 jieba 依赖。
|
||||
//! Person 类型由 title_parser 单独处理,此处不纳入标签。
|
||||
|
||||
use regex::Regex;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashSet;
|
||||
use crate::engine::tag_dict;
|
||||
|
||||
lazy_static! {
|
||||
/// 匹配英文单词、数字、中文字符(用于正则兜底分词)
|
||||
static ref WORD_RE: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap();
|
||||
/// 去除头尾标点符号
|
||||
static ref TAG_CORE_RE: Regex = Regex::new(
|
||||
r#"^[\s.,;:!?'\"‘’“”\-–—()\[\]{}【】《》〈〉、,。;:?!…~·•]+|[\s.,;:!?'\"‘’“”\-–—()\[\]{}【】《》〈〉、,。;:?!…~·•]+$"#
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
/// 判断字符串是否全为标点符号
|
||||
pub fn is_punctuation_only(s: &str) -> bool {
|
||||
if s.is_empty() { return true; }
|
||||
!s.chars().any(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| ('\u{4e00}'..='\u{9fff}').contains(&c)
|
||||
|| ('\u{3400}'..='\u{4dbf}').contains(&c)
|
||||
|| ('\u{f900}'..='\u{faff}').contains(&c)
|
||||
})
|
||||
}
|
||||
|
||||
/// 清洗单个标签:去除头尾标点符号。
|
||||
/// 清洗后如果只剩下标点或为空,则返回 None。
|
||||
pub fn clean_tag(tag: &str) -> Option<String> {
|
||||
let cleaned = TAG_CORE_RE.replace_all(tag.trim(), "").trim().to_string();
|
||||
if cleaned.is_empty() || is_punctuation_only(&cleaned) || cleaned.len() < 2 {
|
||||
None
|
||||
} else {
|
||||
Some(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取分类优先级索引(从词典动态获取)。
|
||||
fn category_order_index(category: &str) -> usize {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
let order = dict.get_category_order();
|
||||
order.iter().position(|c| c == &category).unwrap_or(999)
|
||||
}
|
||||
|
||||
/// 判断一个词属于哪个分类(在词典 keymap 中查找)。
|
||||
/// 如果在 keymap 中找不到,返回 "other"。
|
||||
pub fn classify_tag(word: &str) -> String {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
dict.classify(word)
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
}
|
||||
|
||||
/// 获取标签的中文分类名。
|
||||
pub fn get_category_label(category: &str) -> String {
|
||||
tag_dict::get_tag_dict().get_category_label(category)
|
||||
}
|
||||
|
||||
/// 获取标签分类的颜色 (bg, fg)。
|
||||
pub fn get_category_color(category: &str) -> (String, String) {
|
||||
tag_dict::get_tag_dict().get_category_color(category)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 词典 keymap substring 匹配(最长匹配优先)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/// 从标题中通过词典 keymap substring 匹配提取分类标签,**最长匹配优先**。
|
||||
///
|
||||
/// 策略:
|
||||
/// 1. 先从 `title_lower` 中移除 `person_name` 中的词(防止人名命中标签)
|
||||
/// 2. keymap 中所有标签按长度降序排列(最长的先匹配)
|
||||
/// 3. 匹配到某个标签后,将该标签从 `title_lower` 中**替换为空格**,
|
||||
/// 防止其子串/短标签再次命中,实现最长匹配覆盖
|
||||
/// 4. 跳过已被 `seen` 包含的词
|
||||
/// 5. 跳过 `person` 类型的标签(由 title_parser 处理)
|
||||
pub fn extract_dict_tags(
|
||||
title: &str,
|
||||
person_name: &[String],
|
||||
seen: &mut HashSet<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
let mut tags = Vec::new();
|
||||
let mut title_lower = title.to_lowercase();
|
||||
|
||||
// 移除人名(防止人名中的词命中标签)
|
||||
for p in person_name {
|
||||
title_lower = title_lower.replace(&p.to_lowercase(), " ");
|
||||
}
|
||||
|
||||
let tag_category_map = dict.get_tag_category_map();
|
||||
let mut sorted: Vec<(String, String)> = tag_category_map.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
// 长度降序排列:保证最长匹配优先
|
||||
sorted.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
|
||||
|
||||
for (tag, category) in &sorted {
|
||||
// 跳过 person 类型(由 title_parser 单独处理)
|
||||
if category == "person" { continue; }
|
||||
if seen.contains(tag) { continue; }
|
||||
if !title_lower.contains(tag.as_str()) { continue; }
|
||||
|
||||
seen.insert(tag.clone());
|
||||
tags.push((tag.clone(), category.clone()));
|
||||
|
||||
// 将匹配到的长标签从标题中移除(替换为空格),
|
||||
// 避免其子串/短标签再次命中,实现最长匹配覆盖
|
||||
title_lower = title_lower.replace(tag.as_str(), " ");
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 正则兜底标签提取
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/// 正则分词提取剩余关键词(兜底),全部归为 "other" 类。
|
||||
///
|
||||
/// 过滤规则:
|
||||
/// - 长度 < 2、纯标点、纯数字 → 跳过
|
||||
/// - 停用词 → 跳过
|
||||
/// - 已被 `seen` 包含的词 → 跳过
|
||||
/// - 人名 → 跳过
|
||||
/// - 已在 keymap 中分类过的词 → 跳过(避免重复)
|
||||
pub fn extract_regex_tags(
|
||||
title: &str,
|
||||
person_name: &[String],
|
||||
stop_words: &HashSet<String>,
|
||||
seen: &mut HashSet<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut tags = Vec::new();
|
||||
let title_lower = title.to_lowercase();
|
||||
|
||||
for cap in WORD_RE.find_iter(&title_lower) {
|
||||
let word = cap.as_str().trim();
|
||||
let word_lower = word.to_lowercase();
|
||||
|
||||
if word.len() < 2 { continue; }
|
||||
if is_punctuation_only(word) { continue; }
|
||||
if word.chars().all(|c| c.is_ascii_digit()) { continue; }
|
||||
if stop_words.contains(&word_lower) { continue; }
|
||||
if seen.contains(&word_lower) { continue; }
|
||||
|
||||
// 排除人名相关部分
|
||||
if person_name.iter().any(|p| word_lower.contains(&p.to_lowercase())) {
|
||||
continue;
|
||||
}
|
||||
// 已在词典分类中匹配过的跳过
|
||||
if tag_dict::get_tag_dict().classify(&word_lower).is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.insert(word_lower);
|
||||
tags.push((word.to_string(), "other".to_string()));
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// extract_tags_from_title(完整流程:词典匹配 → 正则兜底)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/// 从标题中提取分类标签(完整流程)。
|
||||
///
|
||||
/// 两步流程:
|
||||
/// 1. **词典 keymap substring 匹配** — 在 `title_lower` 中搜索所有 keymap 中的 key,
|
||||
/// 最长匹配优先,匹配后从标题中移除已匹配文本,避免短标签重复命中。
|
||||
/// 跳过 person 类型标签。
|
||||
/// 2. **正则兜底** — 用 WORD_RE 提取剩余单词,归为 "other" 类。
|
||||
///
|
||||
/// 过滤规则:
|
||||
/// - 跳过停用词
|
||||
/// - 跳过 person 类型(由 title_parser 处理)
|
||||
/// - 跳过人名部分
|
||||
///
|
||||
/// # 参数
|
||||
/// - `title` — 原始标题
|
||||
/// - `person_name` — 已提取的人名(可选),用于从标题中排除,防止命中标签
|
||||
pub fn extract_tags_from_title(
|
||||
title: &str,
|
||||
person_name: Option<&str>,
|
||||
) -> Vec<(String, String)> {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
let stop_words = dict.get_stop_words();
|
||||
|
||||
// 构建人名排除列表:人名本身 + 按空白切分的各部分
|
||||
let person_exclude: Vec<String> = person_name
|
||||
.map(|n| {
|
||||
let mut v = vec![n.to_lowercase()];
|
||||
for part in n.split_whitespace() {
|
||||
v.push(part.to_lowercase());
|
||||
}
|
||||
v
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut tags = Vec::new();
|
||||
|
||||
// Step 1: 词典 keymap substring 匹配(最长匹配优先)
|
||||
tags.extend(extract_dict_tags(title, &person_exclude, &mut seen));
|
||||
|
||||
// Step 2: 正则兜底(剩余未匹配的词,归 "other" 类)
|
||||
tags.extend(extract_regex_tags(title, &person_exclude, stop_words, &mut seen));
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
/// 纯 keymap substring 匹配(无正则兜底),用于快速标签提取。
|
||||
///
|
||||
/// 只做词典匹配,不做正则兜底。适合在不需要"其他"类标签的场景下使用。
|
||||
///
|
||||
/// # 参数
|
||||
/// - `title` — 原始标题
|
||||
/// - `person_name` — 已提取的人名(可选),用于从标题中排除人名部分
|
||||
pub fn extract_tags_with_keymap(title: &str, person_name: Option<&str>) -> Vec<(String, String)> {
|
||||
let person_exclude: Vec<String> = person_name
|
||||
.map(|n| {
|
||||
let mut v = vec![n.to_lowercase()];
|
||||
for part in n.split_whitespace() {
|
||||
v.push(part.to_lowercase());
|
||||
}
|
||||
v
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
extract_dict_tags(title, &person_exclude, &mut seen)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 标签去重、排序、过滤
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/// 标签去重并排序(分类标签优先,按词典中的 category_order 排序)。
|
||||
///
|
||||
/// 去重逻辑:
|
||||
/// 1. 先按分类优先级排序(style < outfit < scene < ... < other)
|
||||
/// 2. 逐个清洗标签(去头尾标点)
|
||||
/// 3. 过滤坏标签(纯数字、纯标点、停用词等)
|
||||
/// 4. 小写去重
|
||||
pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
let mut sorted_tags = tags;
|
||||
sorted_tags.sort_by(|a, b| {
|
||||
let idx_a = category_order_index(&a.1);
|
||||
let idx_b = category_order_index(&b.1);
|
||||
idx_a.cmp(&idx_b)
|
||||
});
|
||||
|
||||
for (tag, tag_type) in sorted_tags {
|
||||
let cleaned_tag = match clean_tag(&tag) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
};
|
||||
if is_bad_tag(&cleaned_tag) { continue; }
|
||||
let key = cleaned_tag.to_lowercase();
|
||||
if seen.insert(key) {
|
||||
result.push((cleaned_tag, tag_type));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 判断一个标签是否"坏标签"(应被过滤掉)。
|
||||
///
|
||||
/// 规则:
|
||||
/// - 空串或长度 < 2
|
||||
/// - 纯数字
|
||||
/// - 纯标点符号
|
||||
/// - clean_tag 后变短(说明含不可见标点)
|
||||
/// - 停用词
|
||||
pub fn is_bad_tag(tag: &str) -> bool {
|
||||
let tag = tag.trim();
|
||||
if tag.is_empty() || tag.len() < 2 { return true; }
|
||||
if tag.chars().all(|c| c.is_ascii_digit()) { return true; }
|
||||
if is_punctuation_only(tag) { return true; }
|
||||
if let Some(cleaned) = clean_tag(tag) {
|
||||
if cleaned != tag { return true; }
|
||||
}
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
if dict.is_stop_word(tag.to_lowercase().as_str()) { return true; }
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_tags() {
|
||||
// 杨晨晨Yome 是 person 类型,由 title_parser 处理,不应出现在标签中
|
||||
// photos 是停用词,不应出现在标签中
|
||||
let tags = extract_tags_from_title(
|
||||
"XiuRen秀人网 – 杨晨晨Yome – Night Dress '81 photos'",
|
||||
Some("杨晨晨Yome"),
|
||||
);
|
||||
let names: Vec<&str> = tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(!names.contains(&"photos"), "tags 不应包含停用词 'photos'");
|
||||
assert!(!names.contains(&"杨晨晨"), "tags 不应包含 person 类型词 '杨晨晨'");
|
||||
// 验证 keymap 中的词正确匹配
|
||||
assert!(names.contains(&"dress"), "tags 应包含 keymap 中的 'dress'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_longest_match_priority() {
|
||||
// "连衣裙" (3字) 在内建词典 outfit 中。标题包含 "连衣裙",
|
||||
// 匹配后无子串可再匹配。
|
||||
let tags = extract_tags_from_title(
|
||||
"粉色连衣裙 2025",
|
||||
None,
|
||||
);
|
||||
let names: Vec<&str> = tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(names.contains(&"连衣裙"), "'连衣裙' 应在内建词典的结果中");
|
||||
// "连衣裙" 被移除后,其子串不会再次匹配
|
||||
assert_eq!(names.iter().filter(|t| **t == "连衣裙").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_tag() {
|
||||
// v4 JSON keymap 中 "日系":"style"
|
||||
assert_eq!(classify_tag("日系"), "style");
|
||||
assert_eq!(classify_tag("比基尼"), "outfit");
|
||||
assert_eq!(classify_tag("浴室"), "scene");
|
||||
assert_eq!(classify_tag("美腿"), "body");
|
||||
assert_eq!(classify_tag("写真"), "topic");
|
||||
assert_eq!(classify_tag("unknown"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_category_colors() {
|
||||
let (bg, fg) = get_category_color("style");
|
||||
assert_eq!(bg, "#f0f9ff");
|
||||
assert_eq!(fg, "#0284c7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tags_with_keymap() {
|
||||
// 纯 keymap 匹配,不包含正则兜底的词
|
||||
let tags = extract_tags_with_keymap(
|
||||
"日系 比基尼 甜美 沙滩 2025",
|
||||
None,
|
||||
);
|
||||
let names: Vec<&str> = tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(names.contains(&"日系"), "日系 应在 keymap 匹配结果中");
|
||||
assert!(names.contains(&"比基尼"), "比基尼 应在 keymap 匹配结果中");
|
||||
assert!(names.contains(&"沙滩"), "沙滩 应在 keymap 匹配结果中");
|
||||
// "2025" 不在 keymap 中,不应出现(纯 keymap 无正则兜底)
|
||||
assert!(!names.contains(&"2025"), "纯 keymap 匹配不应包含 '2025'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_type_excluded_from_dict() {
|
||||
// 杨晨晨 在 v4 keymap 中是 person 类型,不应被提取为标签
|
||||
let tags = extract_tags_with_keymap(
|
||||
"杨晨晨 日系 比基尼",
|
||||
None,
|
||||
);
|
||||
let names: Vec<&str> = tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(!names.contains(&"杨晨晨"), "person 类型的 '杨晨晨' 不应出现在标签中");
|
||||
assert!(names.contains(&"日系"), "非 person 类型词应正常匹配");
|
||||
}
|
||||
}
|
||||
693
src/engine/title_parser.rs
Normal file
693
src/engine/title_parser.rs
Normal file
@@ -0,0 +1,693 @@
|
||||
//! 标题解析 — 统一最大匹配管线(v5)
|
||||
//!
|
||||
//! 核心思路:一次扫描同时识别 person 和 tags,两者互斥,不会混淆。
|
||||
//!
|
||||
//! 流程:
|
||||
//! 1. 预处理:移除照片数后缀、[工作室]前缀、称呼前缀、编号后缀
|
||||
//! 2. 切分:中文最大匹配 + 英文按空格切分
|
||||
//! 3. 角色分类:每个片段有明确分类(person / style / outfit / ... / stop / other)
|
||||
//! 4. 选人名:优先级 = keymap(person) > include 列表 > 启发式
|
||||
//! 5. 其余分类即为标签
|
||||
//!
|
||||
//! 相比 v4 的改进:
|
||||
//! - 中文连续文本也能正确切分(不再依赖空格)
|
||||
//! - person 和 tag 在同一 pass 中确定,不会混淆
|
||||
//! - 最长匹配优先,避免"性感"和"性感女神"都匹配的问题
|
||||
//! - 英文只按空格切分,不逐字匹配,不移除英文介词等停用词
|
||||
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
use lazy_static::lazy_static;
|
||||
use crate::engine::tag_dict;
|
||||
|
||||
// ── 正则 ──
|
||||
|
||||
lazy_static! {
|
||||
static ref RE_BRACKET_PREFIX: Regex = Regex::new(r"^\[([^\]]+)\]\s*").unwrap();
|
||||
static ref RE_NUMBER_SUFFIX: Regex = Regex::new(r"[\s#]*(?:no\.?\s*|vol\.?\s*|#)?\d+[\s]*$").unwrap();
|
||||
static ref RE_PHOTOS_SUFFIX: Regex = Regex::new(r"(?i)\s*\d+\s*photos?\s*(?:and\s*\d+\s*videos?)?\s*$").unwrap();
|
||||
static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap();
|
||||
static ref RE_ENGLISH_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap();
|
||||
static ref RE_TRAILING_PUNCT: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap();
|
||||
static ref RE_PAREN_CONTENT: Regex = Regex::new(r"[((]([^))]*)[))]").unwrap();
|
||||
/// 分隔符:空白、常见标点、中英文之间的边界
|
||||
static ref RE_SEPARATOR: Regex = Regex::new(r"[\s\-–—_,.;:,。;:、/\\|·]+").unwrap();
|
||||
}
|
||||
|
||||
// ── 预处理 ──
|
||||
|
||||
/// 统一标题预处理:移除照片数后缀、[工作室] 前缀、称呼前缀、编号后缀
|
||||
pub fn preprocess_title(title: &str) -> String {
|
||||
let t = title.trim();
|
||||
if t.is_empty() { return String::new(); }
|
||||
|
||||
let mut t = RE_PHOTOS_SUFFIX.replace(t, "").trim().to_string();
|
||||
t = RE_BRACKET_PREFIX.replace(&t, "").trim().to_string();
|
||||
|
||||
for prefix in tag_dict::get_tag_dict().get_person_prefixes() {
|
||||
if t.starts_with(prefix) {
|
||||
t = t[prefix.len()..].trim().to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
t = RE_NUMBER_SUFFIX.replace(&t, "").trim().to_string();
|
||||
t
|
||||
}
|
||||
|
||||
// ── 切分片段:带分类 ──
|
||||
|
||||
/// 一个切分片段及其分类
|
||||
#[derive(Debug, Clone)]
|
||||
struct Segment {
|
||||
text: String,
|
||||
category: String,
|
||||
}
|
||||
|
||||
// ── 切分核心算法 ──
|
||||
|
||||
/// 判断字符是否是中文字符
|
||||
fn is_chinese_char(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
|
||||
/// 对标题做切分,返回带分类的片段列表。
|
||||
///
|
||||
/// 算法分两路:
|
||||
/// - 中文:从左到右最大匹配(逐字尝试,最长优先)
|
||||
/// - 英文/数字:按空格/分隔符直接切分为完整单词,查词典分类
|
||||
///
|
||||
/// 匹配来源优先级:
|
||||
/// keymap(person) > keymap(其他) > stop_words > include_person
|
||||
///
|
||||
/// 未匹配到的作为 "other" 片段保留。
|
||||
fn max_match_segment(title: &str) -> Vec<Segment> {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
|
||||
// 收集所有可匹配的词及分类
|
||||
let mut all_words: Vec<(String, String)> = Vec::new();
|
||||
|
||||
// 1. keymap 中的所有词
|
||||
for (word, cat) in dict.get_tag_category_map() {
|
||||
all_words.push((word.clone(), cat.clone()));
|
||||
}
|
||||
|
||||
// 2. include 列表中的人名 → "person_include"
|
||||
for name in dict.get_included_persons() {
|
||||
if !dict.get_tag_category_map().contains_key(name) {
|
||||
all_words.push((name.clone(), "person_include".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 停用词 → "stop"
|
||||
for word in dict.get_stop_words() {
|
||||
all_words.push((word.clone(), "stop".to_string()));
|
||||
}
|
||||
|
||||
// 计算最大词长(中文最大匹配用)
|
||||
let max_len = all_words.iter().map(|(w, _)| w.chars().count()).max().unwrap_or(8);
|
||||
|
||||
// 快速查找函数
|
||||
let find_category = |word: &str| -> Option<String> {
|
||||
let word_lower = word.to_lowercase();
|
||||
for (w, cat) in &all_words {
|
||||
if w.to_lowercase() == word_lower {
|
||||
return Some(cat.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let mut segments: Vec<Segment> = Vec::new();
|
||||
let chars: Vec<char> = title.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < n {
|
||||
let ch = chars[i];
|
||||
|
||||
// 跳过分隔符
|
||||
if RE_SEPARATOR.is_match(&ch.to_string()) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 英文/数字:直接按分隔符切分整个单词,不逐字匹配 ──
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
let start = i;
|
||||
while i < n && chars[i].is_ascii_alphanumeric() {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = chars[start..i].iter().collect();
|
||||
let cat = find_category(&word).unwrap_or_else(|| "other".to_string());
|
||||
segments.push(Segment { text: word, category: cat });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 中文:最大匹配 ──
|
||||
if is_chinese_char(ch) {
|
||||
let mut found = false;
|
||||
let try_max = max_len.min(n - i);
|
||||
|
||||
'outer: for len in (1..=try_max).rev() {
|
||||
let candidate: String = chars[i..i + len].iter().collect();
|
||||
if let Some(cat) = find_category(&candidate) {
|
||||
segments.push(Segment { text: candidate, category: cat });
|
||||
i += len;
|
||||
found = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// 未匹配:收集连续中文字符作为 other
|
||||
let start = i;
|
||||
while i < n && is_chinese_char(chars[i]) {
|
||||
// 前瞻:如果下一个位置开始能匹配词典,就停在这里
|
||||
if i > start {
|
||||
let remaining = n - i;
|
||||
let peek_max = max_len.min(remaining);
|
||||
let mut matched = false;
|
||||
for plen in (1..=peek_max).rev() {
|
||||
let peek: String = chars[i..i + plen].iter().collect();
|
||||
if find_category(&peek).is_some() {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if matched { break; }
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if i > start {
|
||||
let other: String = chars[start..i].iter().collect();
|
||||
segments.push(Segment { text: other, category: "other".to_string() });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 其他字符跳过
|
||||
i += 1;
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
// ── 人名启发式判断 ──
|
||||
|
||||
/// 判断一个词是否"看起来像人名"
|
||||
///
|
||||
/// 注意:调用方应已排除 keymap 中的非 person 类型词、停用词等。
|
||||
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;
|
||||
}
|
||||
|
||||
// 字符数限制
|
||||
let char_count = name.chars().count();
|
||||
if char_count < 2 { return false; }
|
||||
|
||||
// keymap 中的非 person 类型词一定不是人名
|
||||
if let Some(cat) = dict.classify(name) {
|
||||
if cat != "person" { return false; }
|
||||
}
|
||||
|
||||
// 排除已知非人名
|
||||
if dict.is_excluded_person(name) { return false; }
|
||||
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
// 纯数字排除
|
||||
if name.chars().all(|c| c.is_ascii_digit()) { return false; }
|
||||
|
||||
// 含数字太多的排除
|
||||
let digit_count = name.chars().filter(|c| c.is_ascii_digit()).count();
|
||||
if digit_count * 2 > name.len() { return false; }
|
||||
|
||||
// 纯英文且过长排除
|
||||
let has_cn = RE_CHINESE.is_match(name);
|
||||
if !has_cn && name.len() > 15 { return false; }
|
||||
|
||||
// 以数字开头排除
|
||||
if name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { return false; }
|
||||
|
||||
// 含特殊字符过多排除
|
||||
let special_count = name.chars().filter(|c| {
|
||||
!c.is_alphanumeric() && !RE_CHINESE.is_match(&c.to_string())
|
||||
}).count();
|
||||
if special_count > 2 { return false; }
|
||||
|
||||
// 已知后缀排除
|
||||
let known_suffix = ["photos", "series", "collection", "edition", "set", "vol", "album"];
|
||||
for suffix in known_suffix {
|
||||
if name_lower.ends_with(suffix) { return false; }
|
||||
}
|
||||
|
||||
// 全大写且长度 > 6 排除(缩写/工作室名)
|
||||
if name.chars().all(|c| c.is_ascii_uppercase()) && name.len() > 6 { return false; }
|
||||
|
||||
// 中文字符数判断:2-5 个中文字符更像人名
|
||||
let cn_count: usize = name.chars().filter(|c| RE_CHINESE.is_match(&c.to_string())).count();
|
||||
if has_cn && (cn_count < 2 || cn_count > 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// ── 核心解析入口 ──
|
||||
|
||||
/// 统一相册标题解析入口(v5 最大匹配版本)
|
||||
///
|
||||
/// 流程:预处理 → 切分 → 选人 → 其余为标签
|
||||
///
|
||||
/// 返回 ParseResult 可直接用于批量写库。
|
||||
pub fn parse_album_title(album_id: &str, title: &str) -> crate::models::parse::ParseResult {
|
||||
let mut result = crate::models::parse::ParseResult::new(album_id.to_string());
|
||||
|
||||
let title = title.trim();
|
||||
if title.is_empty() { return result; }
|
||||
|
||||
let cleaned = preprocess_title(title);
|
||||
if cleaned.is_empty() { return result; }
|
||||
|
||||
// Step 1: 切分
|
||||
let segments = max_match_segment(&cleaned);
|
||||
|
||||
// Step 2: 收集所有人名候选(按优先级排序,全部保留)
|
||||
let mut person_candidates: Vec<(String, u8)> = Vec::new();
|
||||
|
||||
for seg in &segments {
|
||||
match seg.category.as_str() {
|
||||
"person" => {
|
||||
person_candidates.push((seg.text.clone(), 0)); // 优先级 0 = 最高
|
||||
}
|
||||
"person_include" => {
|
||||
person_candidates.push((seg.text.clone(), 1)); // 优先级 1
|
||||
}
|
||||
"other" => {
|
||||
if is_likely_person_name(&seg.text) {
|
||||
person_candidates.push((seg.text.clone(), 2)); // 优先级 2 = 启发式
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级排序
|
||||
person_candidates.sort_by_key(|(_, p)| *p);
|
||||
|
||||
// 收集最终人名列表(去重)
|
||||
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 && 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();
|
||||
|
||||
let mut tags: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for seg in &segments {
|
||||
let cat = &seg.category;
|
||||
|
||||
// 跳过停用词
|
||||
if cat == "stop" { continue; }
|
||||
|
||||
// 跳过 person 相关片段
|
||||
if cat == "person" || cat == "person_include" { continue; }
|
||||
|
||||
// 如果是 other 类,检查是否与任何人名重叠
|
||||
if cat == "other" {
|
||||
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; }
|
||||
// 太短的英文 other 也跳过(噪声)
|
||||
if !RE_CHINESE.is_match(&seg.text) && seg.text.len() < 3 { continue; }
|
||||
}
|
||||
|
||||
// 有效分类标签
|
||||
let display_cat = if cat == "other" { "other".to_string() } else { cat.clone() };
|
||||
tags.push((seg.text.clone(), display_cat));
|
||||
}
|
||||
|
||||
// 去重(小写)
|
||||
let mut seen = HashSet::new();
|
||||
tags.retain(|(t, _)| seen.insert(t.to_lowercase()));
|
||||
|
||||
result.tags = tags;
|
||||
result
|
||||
}
|
||||
|
||||
// ── 人名清洗工具函数 ──
|
||||
|
||||
/// 提取中文核心(开头的连续中文字符)
|
||||
pub fn extract_cn(name: &str) -> Option<String> {
|
||||
let re = Regex::new(r"^([\u{4e00}-\u{9fff}]+)").unwrap();
|
||||
re.captures(name).map(|c| c[1].to_string())
|
||||
}
|
||||
|
||||
/// 清理人名:移除前缀、括号内容、末尾标点
|
||||
pub fn clean_person_name(raw_name: &str) -> (String, Vec<(String, String)>) {
|
||||
let mut aliases = Vec::new();
|
||||
let mut name = raw_name.to_string();
|
||||
|
||||
for prefix in tag_dict::get_tag_dict().get_person_prefixes() {
|
||||
if name.starts_with(prefix) {
|
||||
aliases.push(("prefix".to_string(), prefix.to_string()));
|
||||
name = name[prefix.len()..].trim().to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut cleaned = String::new();
|
||||
let mut last_end = 0;
|
||||
for cap in RE_PAREN_CONTENT.captures_iter(&name) {
|
||||
let m = cap.get(0).unwrap();
|
||||
let content = cap.get(1).map_or("", |c| c.as_str());
|
||||
cleaned.push_str(&name[last_end..m.start()]);
|
||||
last_end = m.end();
|
||||
if !content.is_empty() {
|
||||
if RE_CHINESE.is_match(content) {
|
||||
aliases.push(("nickname".to_string(), content.to_string()));
|
||||
} else {
|
||||
aliases.push(("english".to_string(), content.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
cleaned.push_str(&name[last_end..]);
|
||||
let name = RE_TRAILING_PUNCT.replace(&cleaned.trim(), "").trim().to_string();
|
||||
|
||||
aliases.push(("original".to_string(), raw_name.trim().to_string()));
|
||||
|
||||
if let Some(caps) = RE_ENGLISH_SUFFIX.captures(&name) {
|
||||
let chinese_part = caps[1].to_string();
|
||||
let english_part = caps[2].to_string();
|
||||
if !english_part.is_empty() {
|
||||
aliases.push(("chinese".to_string(), chinese_part));
|
||||
aliases.push(("english".to_string(), english_part));
|
||||
}
|
||||
}
|
||||
|
||||
if name.is_empty() { (raw_name.to_string(), aliases) } else { (name, aliases) }
|
||||
}
|
||||
|
||||
/// 拆分人名为「中文主名 + 别名列表」
|
||||
pub fn split_person_name_with_aliases(raw_name: &str) -> (String, Vec<(String, String)>) {
|
||||
let (cleaned, mut aliases) = clean_person_name(raw_name);
|
||||
let mut primary_name = cleaned.clone();
|
||||
|
||||
if let Some(cn_part) = extract_cn(&cleaned) {
|
||||
if !cn_part.is_empty() && cn_part != cleaned {
|
||||
primary_name = cn_part.clone();
|
||||
if let Some(rest) = cleaned.strip_prefix(&cn_part) {
|
||||
let english_suffix = rest.trim().to_string();
|
||||
if !english_suffix.is_empty() {
|
||||
let already_has = aliases.iter().any(|(_, a)| a == &english_suffix);
|
||||
if !already_has {
|
||||
aliases.push(("english".to_string(), english_suffix));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned != primary_name {
|
||||
let already_has = aliases.iter().any(|(_, a)| a == &cleaned);
|
||||
if !already_has {
|
||||
aliases.push(("cleaned".to_string(), cleaned));
|
||||
}
|
||||
}
|
||||
|
||||
(primary_name, aliases)
|
||||
}
|
||||
|
||||
/// 判断是否为工作室/平台名(需排除)
|
||||
pub fn is_excluded_name(name: &str) -> bool {
|
||||
tag_dict::get_tag_dict().is_excluded_person(name)
|
||||
}
|
||||
|
||||
/// 判断后缀是否有效
|
||||
pub fn is_valid_suffix(suffix: &str) -> bool {
|
||||
if suffix.is_empty() { return true; }
|
||||
!RE_CHINESE.is_match(suffix)
|
||||
}
|
||||
|
||||
/// 标签归一化
|
||||
pub fn normalize_tag(tag: &str) -> String {
|
||||
tag.trim().to_lowercase()
|
||||
}
|
||||
|
||||
// ── dict 优先匹配 ──
|
||||
|
||||
/// 使用 dict 表优先匹配 + title_parser 回退的混合解析
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. dict 最长匹配提取 person 和 tags(带 merged_to 解析)
|
||||
/// 2. 从标题移除已匹配的 dict 词
|
||||
/// 3. 剩余文本走 title_parser 解析(tag_dict 回退)
|
||||
/// 4. 合并:dict 结果优先,parser 补充
|
||||
pub fn parse_album_title_with_dict(
|
||||
album_id: &str,
|
||||
title: &str,
|
||||
dict_entries: &[crate::search_db::DictEntry],
|
||||
) -> crate::models::parse::ParseResult {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return crate::models::parse::ParseResult::new(album_id.to_string());
|
||||
}
|
||||
|
||||
// Phase 1: dict 优先匹配
|
||||
let (dict_person, dict_tags, remaining) = extract_with_dict(title, dict_entries);
|
||||
|
||||
// Phase 2: title_parser 回退(解析剩余文本)
|
||||
let parse_result = if remaining.is_empty() {
|
||||
crate::models::parse::ParseResult::new(album_id.to_string())
|
||||
} else {
|
||||
parse_album_title(album_id, &remaining)
|
||||
};
|
||||
|
||||
// Phase 3: 合并结果 — dict 优先,parser 补充
|
||||
let mut result = crate::models::parse::ParseResult::new(album_id.to_string());
|
||||
|
||||
// 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();
|
||||
for (t, _) in &dict_tags {
|
||||
seen.insert(t.to_lowercase());
|
||||
result.tags.push((t.clone(), "other".to_string()));
|
||||
}
|
||||
for (t, c) in &parse_result.tags {
|
||||
if seen.insert(t.to_lowercase()) {
|
||||
result.tags.push((t.clone(), c.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// dict 条目按词长降序匹配标题,返回 (persons, tags, 剩余文本)
|
||||
fn extract_with_dict(
|
||||
title: &str,
|
||||
dict_entries: &[crate::search_db::DictEntry],
|
||||
) -> (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 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();
|
||||
|
||||
for entry in &sorted {
|
||||
let word_lower = entry.word.to_lowercase();
|
||||
|
||||
// 在当前标题中查找该词(忽略大小写)
|
||||
let mut search_start = 0;
|
||||
while let Some(pos) = title_lower[search_start..].find(&word_lower) {
|
||||
let abs_start = search_start + pos;
|
||||
let abs_end = abs_start + entry.word.len();
|
||||
|
||||
// 检查是否已被更长的 dict 词覆盖
|
||||
if matched_ranges.iter().any(|&(s, e)| s <= abs_start && abs_end <= e) {
|
||||
break;
|
||||
}
|
||||
|
||||
matched_ranges.push((abs_start, abs_end));
|
||||
|
||||
match entry.type_.as_str() {
|
||||
"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()) {
|
||||
tags.push((entry.word.clone(), entry.category.clone().unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
break; // 每个词只匹配一次
|
||||
}
|
||||
}
|
||||
|
||||
// 构建剩余文本:去掉已匹配的部分
|
||||
if matched_ranges.is_empty() {
|
||||
return (persons, tags, title.to_string());
|
||||
}
|
||||
|
||||
matched_ranges.sort_by_key(|&(s, _)| s);
|
||||
let mut remaining = String::new();
|
||||
let mut last = 0;
|
||||
for &(s, e) in &matched_ranges {
|
||||
if s > last {
|
||||
remaining.push_str(&title[last..s]);
|
||||
}
|
||||
remaining.push(' ');
|
||||
last = e;
|
||||
}
|
||||
if last < title.len() {
|
||||
remaining.push_str(&title[last..]);
|
||||
}
|
||||
|
||||
// 合并连续空格
|
||||
let remaining = remaining.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
|
||||
(persons, tags, remaining)
|
||||
}
|
||||
|
||||
// ── 兼容函数(供 pipeline / person_merger 调用) ──
|
||||
|
||||
/// 从标题中提取人物名列表(兼容函数)
|
||||
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) -> (Vec<String>, Vec<(String, String)>) {
|
||||
let result = parse_album_title("", title);
|
||||
(result.person_names, result.tags)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_album_title_with_person() {
|
||||
let r = parse_album_title("id1", "杨晨晨Yome 私房写真");
|
||||
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(&"私房"), "应包含 '私房' 标签");
|
||||
assert!(tag_names.contains(&"写真"), "应包含 '写真' 标签");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_album_title_no_person() {
|
||||
let r = parse_album_title("id2", "浴室 可爱 性感 写真");
|
||||
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(&"浴室"));
|
||||
assert!(tag_names.contains(&"性感"));
|
||||
assert!(tag_names.contains(&"写真"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_album_title_with_prefix() {
|
||||
let r = parse_album_title("id3", "美女模特 杨晨晨 私房");
|
||||
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_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(&"写真"), "应包含 '写真'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cn() {
|
||||
assert_eq!(extract_cn("杨晨晨Yome").unwrap(), "杨晨晨");
|
||||
assert_eq!(extract_cn("王馨瑶yanni").unwrap(), "王馨瑶");
|
||||
assert!(extract_cn("Yome").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_person_name_with_aliases() {
|
||||
let (name, aliases) = split_person_name_with_aliases("杨晨晨Yome");
|
||||
assert_eq!(name, "杨晨晨");
|
||||
let alias_names: Vec<&str> = aliases.iter().map(|(_, a)| a.as_str()).collect();
|
||||
assert!(alias_names.contains(&"Yome"));
|
||||
|
||||
let (name2, _) = split_person_name_with_aliases("杨晨晨");
|
||||
assert_eq!(name2, "杨晨晨");
|
||||
|
||||
let (name3, _) = split_person_name_with_aliases("Yome");
|
||||
assert_eq!(name3, "Yome");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_person_not_in_tags() {
|
||||
// 人名不应出现在标签中
|
||||
let r = parse_album_title("id5", "杨晨晨 日系 比基尼");
|
||||
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(!tag_names.contains(&"杨晨晨"), "人名 '杨晨晨' 不应出现在标签中");
|
||||
assert!(tag_names.contains(&"日系"));
|
||||
assert!(tag_names.contains(&"比基尼"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_words_removed() {
|
||||
// 停用词不应出现在标签或人名中
|
||||
let r = parse_album_title("id6", "杨晨晨 photos collection");
|
||||
let tag_names: Vec<&str> = r.tags.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert!(!tag_names.contains(&"photos"), "停用词 'photos' 不应在标签中");
|
||||
assert!(!tag_names.contains(&"collection"), "停用词 'collection' 不应在标签中");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::config::StorageConfig;
|
||||
use image::{GenericImageView, ImageFormat};
|
||||
use image::GenericImageView;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
|
||||
360
src/main.rs
360
src/main.rs
@@ -1,25 +1,36 @@
|
||||
mod config;
|
||||
mod database;
|
||||
mod image_processor;
|
||||
mod import;
|
||||
mod models;
|
||||
mod queue;
|
||||
mod db;
|
||||
mod engine;
|
||||
mod cache;
|
||||
mod routes;
|
||||
mod services;
|
||||
mod image_processor;
|
||||
mod queue;
|
||||
mod search_db;
|
||||
|
||||
use crate::models::AlbumsCache;
|
||||
use crate::cache::AlbumsCache;
|
||||
use crate::search_db::SearchDb;
|
||||
use actix_files as afs;
|
||||
use actix_web::{App, HttpResponse, HttpServer, middleware, web};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::{Duration, interval};
|
||||
use tracing::{error, info};
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// 初始化结构化日志
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let export_to_search = args.iter().any(|a| a == "--export-search");
|
||||
let search_db_cli_path = args.iter().find(|a| a.starts_with("--search-db="))
|
||||
.map(|s| s.strip_prefix("--search-db=").unwrap_or("search.db"))
|
||||
.unwrap_or("search.db");
|
||||
let clear_search = args.iter().any(|a| a == "--clear-search");
|
||||
let wal_checkpoint = args.iter().any(|a| a == "--wal-checkpoint");
|
||||
|
||||
// 加载配置
|
||||
let config = match config::Config::load() {
|
||||
Ok(cfg) => cfg,
|
||||
@@ -29,14 +40,18 @@ async fn main() -> std::io::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
info!("启动图片展示服务...");
|
||||
info!("服务地址: http://localhost:{} http://{}", config.server.port, config.server_address());
|
||||
info!("启动图片展示服务 v2.0...");
|
||||
info!("服务地址: http://{}", config.server_address());
|
||||
|
||||
// 初始化数据库
|
||||
let db = match database::Database::new(&config).await {
|
||||
let db = match db::Database::new(&config).await {
|
||||
Ok(db) => {
|
||||
info!("数据库连接成功");
|
||||
Arc::new(db)
|
||||
let db = Arc::new(db);
|
||||
// 启动时先合并上次未完成的 WAL,确保数据库干净
|
||||
info!("正在合并数据库 WAL...");
|
||||
db.wal_checkpoint().await;
|
||||
db
|
||||
}
|
||||
Err(e) => {
|
||||
error!("数据库连接失败: {}", e);
|
||||
@@ -44,115 +59,237 @@ async fn main() -> std::io::Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let image_processor = Arc::new(image_processor::ImageProcessor::new(config.storage.clone()));
|
||||
|
||||
// 在后台线程中自动导入新相册(不阻塞HTTP服务启动)
|
||||
let config_clone = config.clone();
|
||||
let db_clone = Arc::clone(&db);
|
||||
tokio::spawn(async move {
|
||||
info!("[后台任务] 开始扫描图片目录并导入新相册...");
|
||||
match import::import_existing_data(&config_clone, db_clone, None).await {
|
||||
Ok(_) => info!("[后台任务] 相册导入完成"),
|
||||
Err(e) => error!("[后台任务] 相册导入失败: {}", e),
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化相册内存缓存
|
||||
let albums_cache = Arc::new(tokio::sync::RwLock::new(AlbumsCache::new()));
|
||||
|
||||
// 初始加载相册数据到缓存
|
||||
let initial_cache = Arc::clone(&albums_cache);
|
||||
let initial_db = Arc::clone(&db);
|
||||
tokio::spawn(async move {
|
||||
match initial_db.get_all_albums().await {
|
||||
Ok(albums) => {
|
||||
let mut cache = initial_cache.write().await;
|
||||
cache.update(albums);
|
||||
println!("✅ 初始相册缓存加载完成");
|
||||
if export_to_search {
|
||||
info!("[搜索导出] 开始将元数据导出到 search.db...");
|
||||
match services::search_export::export_to_search_db(&db, search_db_cli_path, clear_search).await {
|
||||
Ok(result) => {
|
||||
info!("[搜索导出] 导出完成: 相册{}, 标签{}, 人物{}, 别名{}, 关联{}",
|
||||
result.albums_processed, result.tags_created, result.persons_created,
|
||||
result.aliases_created, result.album_tags_created + result.album_persons_created);
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ 初始缓存加载失败: {}", e);
|
||||
error!("[搜索导出] 导出失败: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if wal_checkpoint {
|
||||
info!("[WAL合并] 主数据库 WAL checkpoint 已在初始化时完成");
|
||||
info!("[WAL合并] 正在合并 search.db WAL...");
|
||||
let search_db = SearchDb::new("data/search.db").await
|
||||
.expect("search.db 连接失败");
|
||||
search_db.wal_checkpoint().await;
|
||||
info!("[WAL合并] 两个数据库均已合并完成");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let image_processor = Arc::new(image_processor::ImageProcessor::new(config.storage.clone()));
|
||||
|
||||
// ── 初始化 search.db(运行时搜索专用)──
|
||||
let search_db_path = "data/search.db";
|
||||
let search_db = Arc::new(
|
||||
SearchDb::new(search_db_path).await
|
||||
.expect("search.db 连接失败")
|
||||
);
|
||||
info!("search.db 连接成功");
|
||||
search_db.wal_checkpoint().await;
|
||||
|
||||
// ── 从 search.db 的 dict 表加载标签词典 ──
|
||||
match engine::tag_dict::reload_from_search_db(&search_db).await {
|
||||
msg => info!("{}", msg),
|
||||
}
|
||||
|
||||
// ── 内存缓存(必须在管线任务之前创建)──
|
||||
let albums_cache = Arc::new(tokio::sync::RwLock::new(AlbumsCache::new()));
|
||||
let persons_cache = Arc::new(tokio::sync::RwLock::new(cache::PersonCache::default()));
|
||||
let tags_cache = Arc::new(tokio::sync::RwLock::new(cache::TagCache::default()));
|
||||
|
||||
// ── 启动管线(单次):导入 → 增量解析 → 条件全量 sync → 缓存刷新 ──
|
||||
// 不做全量 reparse/merge,只处理新发现的相册
|
||||
let boot_config = config.clone();
|
||||
let boot_db = Arc::clone(&db);
|
||||
let boot_search_db = Arc::clone(&search_db);
|
||||
let boot_albums_cache = Arc::clone(&albums_cache);
|
||||
let boot_persons_cache = Arc::clone(&persons_cache);
|
||||
let boot_tags_cache = Arc::clone(&tags_cache);
|
||||
tokio::spawn(async move {
|
||||
// ① 扫描导入新相册
|
||||
info!("[启动] ① 扫描导入新相册...");
|
||||
match services::import::import_existing_data(&boot_config, Arc::clone(&boot_db), None).await {
|
||||
Ok(_) => info!("[启动] 导入完成"),
|
||||
Err(e) => error!("[启动] 导入失败: {}", e),
|
||||
}
|
||||
|
||||
// ② 清理:删除空相册 + 去重
|
||||
info!("[启动] ② 清理空相册和重复相册...");
|
||||
let empty = engine::pipeline::phase_delete_empty_albums(&boot_db).await;
|
||||
let dedup = engine::pipeline::phase_dedup_albums(&boot_db, &boot_search_db).await;
|
||||
if empty > 0 || dedup > 0 {
|
||||
info!("[启动] 清理完成:删除空相册 {},去重删除 {}", empty, dedup);
|
||||
}
|
||||
|
||||
// ③ 查找新相册 / 半成品
|
||||
info!("[启动] ③ 查找新相册...");
|
||||
let dirty = engine::pipeline::phase_collect_new_albums(&boot_db).await;
|
||||
let new_count = dirty.len();
|
||||
if new_count > 0 {
|
||||
info!("[启动] 发现 {} 个新相册,同步 tags/persons 到 search.db...", new_count);
|
||||
let synced = engine::pipeline::sync_dirty_albums(&boot_db, &boot_search_db, &dirty).await;
|
||||
info!("[启动] 增量同步完成:{} 个相册(仅 tags/persons,不同步 albums 表)", synced);
|
||||
} else {
|
||||
info!("[启动] 没有新相册");
|
||||
}
|
||||
|
||||
// ④ 刷新所有内存缓存(从 search.db 读取)
|
||||
info!("[启动] ④ 刷新所有内存缓存...");
|
||||
cache::refresh_all_caches(&boot_db, &boot_search_db, &boot_albums_cache, &boot_persons_cache, &boot_tags_cache).await;
|
||||
info!("[启动] 启动管线执行完毕(新相册={})", new_count);
|
||||
});
|
||||
|
||||
// 设置定时刷新缓存任务(每30分钟)
|
||||
let refresh_cache = Arc::clone(&albums_cache);
|
||||
let refresh_db = Arc::clone(&db);
|
||||
// ── 定时管线(每 30 分钟):导入 → 增量解析(有新相册时)→ 缓存刷新(始终执行)──
|
||||
let timer_cache = Arc::clone(&albums_cache);
|
||||
let timer_db = Arc::clone(&db);
|
||||
let timer_search_db = Arc::clone(&search_db);
|
||||
let timer_persons_cache = Arc::clone(&persons_cache);
|
||||
let timer_tags_cache = Arc::clone(&tags_cache);
|
||||
tokio::spawn(async move {
|
||||
let mut interval = interval(Duration::from_secs(30 * 60)); // 30分钟
|
||||
|
||||
let mut interval = interval(Duration::from_secs(30 * 60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match refresh_db.get_all_albums().await {
|
||||
Ok(albums) => {
|
||||
let mut cache = refresh_cache.write().await;
|
||||
cache.update(albums);
|
||||
println!("🔄 相册缓存定时刷新完成");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ 缓存刷新失败: {}", e);
|
||||
}
|
||||
// ① 扫描导入新相册
|
||||
info!("[定时] ① 扫描导入新相册...");
|
||||
match services::import::import_existing_data(&timer_db.config, Arc::clone(&timer_db), None).await {
|
||||
Ok(_) => {},
|
||||
Err(e) => error!("[定时] 导入失败: {}", e),
|
||||
}
|
||||
|
||||
// ② 查找新相册并增量解析
|
||||
let new_albums = engine::pipeline::phase_collect_new_albums(&timer_db).await;
|
||||
if !new_albums.is_empty() {
|
||||
info!("[定时] 发现 {} 个新相册,增量解析(同时写入 imager.db + search.db)...", new_albums.len());
|
||||
engine::pipeline::phase_parse_dirty(&timer_db, &timer_search_db, &new_albums).await;
|
||||
}
|
||||
|
||||
// ③ 缓存刷新(始终执行)
|
||||
info!("[定时] 刷新缓存...");
|
||||
cache::refresh_all_caches(&timer_db, &timer_search_db, &timer_cache, &timer_persons_cache, &timer_tags_cache).await;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 任务队列
|
||||
let task_queue = Arc::new(queue::TaskQueue::new(
|
||||
config.queue.max_concurrent_tasks,
|
||||
config.queue.queue_size_limit,
|
||||
config.queue.high_request_rate_threshold,
|
||||
config.queue.idle_request_rate_threshold,
|
||||
));
|
||||
|
||||
println!("API接口:");
|
||||
println!(" - GET /api/albums - 获取所有相册");
|
||||
println!(" - GET /api/search?q=关键词 - 搜索相册");
|
||||
println!(" - GET /api/album/random - 随机相册");
|
||||
println!(" - GET /api/album/:id - 获取相册详情");
|
||||
println!(" - POST /api/albums - 创建新相册");
|
||||
println!(" - GET /api/image/:id - 获取图片(已移除缩略图功能)");
|
||||
println!(" - GET /api/favorites - 获取收藏列表");
|
||||
println!(" - GET /api/favorites/status/:id - 检查收藏状态");
|
||||
println!(" - GET /api/image/:id - 获取图片");
|
||||
println!(" - GET /api/favorites - 收藏列表");
|
||||
println!(" - POST /api/favorites - 添加收藏");
|
||||
println!(" - DELETE /api/favorites/:id - 取消收藏");
|
||||
println!(" - DELETE /api/favorites/image/:id - 通过图片ID取消收藏");
|
||||
println!("元数据接口:");
|
||||
println!(" - GET /api/people - 人员列表");
|
||||
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 - 人物推荐");
|
||||
println!(" - GET /api/recommend/related?context=xxx - 关联推荐");
|
||||
println!(" - GET /api/recommend/popular-persons - 热门人物");
|
||||
println!("迁移接口:");
|
||||
println!(" - GET /api/migration/stats - 迁移状态");
|
||||
println!(" - POST /api/migration/run?path=persons.db - 执行迁移");
|
||||
println!(" - GET /api/queue/stats - 队列状态");
|
||||
println!("管线接口:");
|
||||
println!(" - POST /api/pipeline/run - 执行管线(增量)");
|
||||
println!(" - POST /api/pipeline/reparse - 重置并重新解析全部相册(标签+人物)");
|
||||
println!(" - POST /api/pipeline/reparse-persons - 重置并重新解析全部人物");
|
||||
println!(" - POST /api/pipeline/reparse-tags - 重置并重新解析全部标签");
|
||||
println!("词典管理:");
|
||||
println!(" - GET /api/dict/stats - 词典状态");
|
||||
println!(" - POST /api/dict/reload - 热重载词典");
|
||||
println!(" - POST /api/dict/update - 批量更新 dict 表(自动重载内存词典)");
|
||||
println!("缓存管理:");
|
||||
println!(" - POST /api/cache/refresh - 手动刷新内存缓存");
|
||||
println!("按 Ctrl+C 停止服务");
|
||||
|
||||
// 创建数据目录
|
||||
fs::create_dir_all(&config.storage.images_dir).unwrap_or(());
|
||||
|
||||
// 在闭包外提取需要的路径
|
||||
let server_address = config.server_address();
|
||||
|
||||
HttpServer::new(move || {
|
||||
// 在 move 闭包之前 clone 一份 db 用于关闭信号处理
|
||||
let shutdown_db = Arc::clone(&db);
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
// 启用压缩中间件
|
||||
.wrap(middleware::Compress::default())
|
||||
// 启用日志中间件
|
||||
.wrap(middleware::Logger::default())
|
||||
.app_data(web::Data::new(Arc::clone(&db)))
|
||||
.app_data(web::Data::new(Arc::clone(&search_db)))
|
||||
.app_data(web::Data::new(Arc::clone(&image_processor)))
|
||||
.app_data(web::Data::new(Arc::clone(&albums_cache)))
|
||||
// API路由
|
||||
.app_data(web::Data::new(Arc::clone(&persons_cache)))
|
||||
.app_data(web::Data::new(Arc::clone(&tags_cache)))
|
||||
.app_data(web::Data::new(Arc::clone(&task_queue)))
|
||||
// 相册 API
|
||||
.route("/api/albums", web::get().to(routes::get_albums))
|
||||
.route("/api/search", web::get().to(routes::search_albums))
|
||||
.route("/api/album/random", web::get().to(routes::get_random_album))
|
||||
.route("/api/album/{id}", web::get().to(routes::get_album_detail))
|
||||
.route("/api/albums", web::post().to(routes::create_album))
|
||||
.route("/api/image/{id}", web::get().to(routes::get_image))
|
||||
// 收藏功能API路由
|
||||
// 收藏 API
|
||||
.route("/api/favorites", web::get().to(routes::get_favorites))
|
||||
.route(
|
||||
"/api/favorites/status/{id}",
|
||||
web::get().to(routes::check_favorite_status),
|
||||
)
|
||||
.route("/api/favorites/status/{id}", web::get().to(routes::check_favorite_status))
|
||||
.route("/api/favorites", web::post().to(routes::add_favorite))
|
||||
.route(
|
||||
"/api/favorites/{id}",
|
||||
web::delete().to(routes::remove_favorite),
|
||||
)
|
||||
.route(
|
||||
"/api/favorites/image/{id}",
|
||||
web::delete().to(routes::remove_favorite_by_image),
|
||||
)
|
||||
// 队列状态API路由
|
||||
.route("/api/favorites/{id}", web::delete().to(routes::remove_favorite))
|
||||
.route("/api/favorites/image/{id}", web::delete().to(routes::remove_favorite_by_image))
|
||||
// 人员 API
|
||||
.route("/api/people", web::get().to(routes::get_people))
|
||||
.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))
|
||||
.route("/api/recommend/related", web::get().to(routes::recommend_related))
|
||||
.route("/api/recommend/popular-persons", web::get().to(routes::popular_persons))
|
||||
// 迁移 API
|
||||
.route("/api/migration/stats", web::get().to(routes::migration_stats))
|
||||
.route("/api/migration/run", web::post().to(routes::migration_run))
|
||||
// 管线 API
|
||||
.route("/api/pipeline/run", web::post().to(routes::pipeline_run))
|
||||
.route("/api/pipeline/reparse", web::post().to(routes::pipeline_reparse))
|
||||
.route("/api/pipeline/reparse-persons", web::post().to(routes::pipeline_reparse_persons))
|
||||
.route("/api/pipeline/reparse-tags", web::post().to(routes::pipeline_reparse_tags))
|
||||
// 队列 API
|
||||
.route("/api/queue/stats", web::get().to(routes::get_queue_stats))
|
||||
// 静态文件服务 - 启用缓存
|
||||
// 词典 API
|
||||
.route("/api/dict/stats", web::get().to(routes::dict_stats))
|
||||
.route("/api/dict/reload", web::post().to(routes::dict_reload))
|
||||
.route("/api/dict/update", web::post().to(routes::dict_update))
|
||||
// 缓存管理 API
|
||||
.route("/api/cache/refresh", web::post().to(routes::cache_refresh))
|
||||
// 静态文件
|
||||
.service(
|
||||
afs::Files::new("/assets", "./assets")
|
||||
.show_files_listing()
|
||||
@@ -160,33 +297,38 @@ async fn main() -> std::io::Result<()> {
|
||||
.use_last_modified(true)
|
||||
.use_etag(true),
|
||||
)
|
||||
// HTML页面路由
|
||||
.route(
|
||||
"/",
|
||||
web::get().to(|| async {
|
||||
match fs::read_to_string("./assets/index.html") {
|
||||
Ok(content) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(content),
|
||||
Err(_) => HttpResponse::NotFound().body("首页文件不存在"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/album",
|
||||
web::get().to(|| async {
|
||||
match fs::read_to_string("./assets/album.html") {
|
||||
Ok(content) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(content),
|
||||
Err(_) => HttpResponse::NotFound().body("相册页面文件不存在"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route("/", web::get().to(|| async {
|
||||
match fs::read_to_string("./assets/index.html") {
|
||||
Ok(content) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(content),
|
||||
Err(_) => HttpResponse::NotFound().body("首页文件不存在"),
|
||||
}
|
||||
}))
|
||||
.route("/album", web::get().to(|| async {
|
||||
match fs::read_to_string("./assets/album.html") {
|
||||
Ok(content) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(content),
|
||||
Err(_) => HttpResponse::NotFound().body("相册页面不存在"),
|
||||
}
|
||||
}))
|
||||
})
|
||||
.bind(server_address)?
|
||||
// 限制worker数量为2个,减少系统资源占用
|
||||
.workers(3)
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
.bind(config.server_address())?
|
||||
.run();
|
||||
|
||||
// ── 信号监听:捕获 Ctrl+C / SIGINT,优雅关闭 ──
|
||||
let server_handle = server.handle();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("监听关闭信号失败");
|
||||
println!("\n[关闭] 收到关闭信号,正在优雅停止服务器...");
|
||||
// 停止接受新请求,等待正在处理中的请求完成(最多 10s)
|
||||
server_handle.stop(true).await;
|
||||
// 服务器已停止 → 合并 WAL
|
||||
println!("[关闭] 服务已停止,正在合并数据库 WAL...");
|
||||
shutdown_db.wal_checkpoint().await;
|
||||
println!("[关闭] 数据库文件已合并,再见!");
|
||||
});
|
||||
|
||||
server.await
|
||||
}
|
||||
|
||||
230
src/models.rs
230
src/models.rs
@@ -1,230 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Album {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image_id: Option<Uuid>,
|
||||
pub image_count: i64, // 新增图片数量字段
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Image {
|
||||
pub id: Uuid,
|
||||
pub album_id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub file_size: i64,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub mime_type: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AlbumWithImages {
|
||||
pub album: Album,
|
||||
pub images: Vec<Image>,
|
||||
pub image_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AlbumInfo {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub document: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image_id: Option<String>,
|
||||
pub image_count: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAlbum {
|
||||
pub title: String,
|
||||
pub document: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CreateImage {
|
||||
pub album_id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub file_size: i64,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Favorite {
|
||||
pub id: Uuid,
|
||||
pub user_id: String,
|
||||
pub image_id: Uuid,
|
||||
pub album_id: Uuid,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateFavorite {
|
||||
pub image_id: Uuid,
|
||||
pub album_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FavoriteWithImage {
|
||||
pub favorite: Favorite,
|
||||
pub image: Image,
|
||||
pub album_title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FavoriteStatus {
|
||||
pub is_favorited: bool,
|
||||
pub favorite_id: Option<Uuid>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AlbumIndex {
|
||||
pub title: String,
|
||||
pub images: Vec<String>,
|
||||
}
|
||||
// 内存缓存结构
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlbumsCache {
|
||||
pub albums: Vec<AlbumInfo>,
|
||||
pub search_index: HashMap<String, Vec<AlbumInfo>>,
|
||||
pub last_updated: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl AlbumsCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
albums: Vec::new(),
|
||||
search_index: HashMap::new(),
|
||||
last_updated: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = chrono::Utc::now();
|
||||
let duration = now - self.last_updated;
|
||||
duration.num_minutes() >= 30 // 30分钟过期
|
||||
}
|
||||
|
||||
pub fn update(&mut self, albums: Vec<AlbumInfo>) {
|
||||
self.albums = albums; // 不再克隆整个向量,而是移动所有权
|
||||
self.last_updated = chrono::Utc::now();
|
||||
|
||||
// 构建搜索索引
|
||||
self.search_index.clear();
|
||||
for album in &self.albums {
|
||||
let title_lower = album.title.to_lowercase();
|
||||
|
||||
// 为每个单词创建索引
|
||||
for word in title_lower.split_whitespace() {
|
||||
if word.len() >= 2 {
|
||||
// 只索引长度>=2的单词
|
||||
self.search_index
|
||||
.entry(word.to_string())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(album.clone()); // 这里仍需要克隆单个元素
|
||||
}
|
||||
}
|
||||
|
||||
// 为完整标题创建索引
|
||||
self.search_index
|
||||
.entry(title_lower)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(album.clone()); // 这里仍需要克隆单个元素
|
||||
}
|
||||
|
||||
info!(
|
||||
"相册缓存已更新,共 {} 个相册,{} 个搜索索引项",
|
||||
self.albums.len(),
|
||||
self.search_index.len()
|
||||
);
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str) -> Vec<AlbumInfo> {
|
||||
let query_lower = query.to_lowercase().trim().to_string();
|
||||
|
||||
if query_lower.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
|
||||
// 1. 搜索完整匹配(最高优先级)
|
||||
if let Some(matches) = self.search_index.get(&query_lower) {
|
||||
for album in matches {
|
||||
if seen_ids.insert(album.id) {
|
||||
results.push(album.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 搜索单词匹配
|
||||
for word in query_lower.split_whitespace() {
|
||||
if word.len() >= 2 {
|
||||
// 只搜索长度>=2的单词
|
||||
if let Some(matches) = self.search_index.get(word) {
|
||||
for album in matches {
|
||||
if seen_ids.insert(album.id) {
|
||||
results.push(album.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 模糊搜索:在相册标题和描述中搜索包含查询词的结果
|
||||
if results.is_empty() {
|
||||
for album in &self.albums {
|
||||
let title_lower = album.title.to_lowercase();
|
||||
let description_lower = album
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|d| d.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 检查标题或描述是否包含搜索词
|
||||
if title_lower.contains(&query_lower) || description_lower.contains(&query_lower) {
|
||||
if seen_ids.insert(album.id) {
|
||||
results.push(album.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 如果还没有结果,尝试部分匹配(更宽松的搜索)
|
||||
if results.is_empty() && query_lower.len() >= 2 {
|
||||
for album in &self.albums {
|
||||
let title_lower = album.title.to_lowercase();
|
||||
|
||||
// 检查标题是否包含搜索词的任何部分
|
||||
if query_lower
|
||||
.split_whitespace()
|
||||
.any(|word| word.len() >= 2 && title_lower.contains(word))
|
||||
{
|
||||
if seen_ids.insert(album.id) {
|
||||
results.push(album.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
}
|
||||
57
src/models/album.rs
Normal file
57
src/models/album.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Album {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image_id: Option<Uuid>,
|
||||
pub image_count: i64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AlbumInfo {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub document: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image_id: Option<String>,
|
||||
pub image_count: i64,
|
||||
pub created_at: String,
|
||||
pub tags: Vec<String>,
|
||||
pub persons: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AlbumWithImages {
|
||||
pub album: Album,
|
||||
pub images: Vec<super::image::Image>,
|
||||
pub image_count: i64,
|
||||
pub tags: Vec<String>,
|
||||
pub persons: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAlbum {
|
||||
pub title: String,
|
||||
pub document: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AlbumIndex {
|
||||
pub title: String,
|
||||
pub images: Vec<String>,
|
||||
}
|
||||
|
||||
/// 相册完整元数据(精简版:仅保留 albums 表原始字段)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AlbumMeta {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub cover_image_id: Option<String>,
|
||||
pub image_count: Option<i64>,
|
||||
}
|
||||
30
src/models/favorite.rs
Normal file
30
src/models/favorite.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Favorite {
|
||||
pub id: Uuid,
|
||||
pub user_id: String,
|
||||
pub image_id: Uuid,
|
||||
pub album_id: Uuid,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateFavorite {
|
||||
pub image_id: Uuid,
|
||||
pub album_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FavoriteWithImage {
|
||||
pub favorite: Favorite,
|
||||
pub image: super::image::Image,
|
||||
pub album_title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FavoriteStatus {
|
||||
pub is_favorited: bool,
|
||||
pub favorite_id: Option<Uuid>,
|
||||
}
|
||||
30
src/models/image.rs
Normal file
30
src/models/image.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Image {
|
||||
pub id: Uuid,
|
||||
pub album_id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub file_size: i64,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub mime_type: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CreateImage {
|
||||
pub album_id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub file_size: i64,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub mime_type: String,
|
||||
}
|
||||
14
src/models/mod.rs
Normal file
14
src/models/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
pub mod album;
|
||||
pub mod image;
|
||||
pub mod person;
|
||||
pub mod tag;
|
||||
pub mod favorite;
|
||||
pub mod recommend;
|
||||
pub mod parse;
|
||||
|
||||
pub use album::*;
|
||||
pub use image::*;
|
||||
pub use person::*;
|
||||
pub use tag::*;
|
||||
pub use favorite::*;
|
||||
pub use recommend::*;
|
||||
25
src/models/parse.rs
Normal file
25
src/models/parse.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! 相册解析结果 — 单个相册标题的一次性解析产物
|
||||
//! 用于串联并行解析(Phase 1a)和批量写库(Phase 1b)
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// 一次 title 解析的完整结果
|
||||
///
|
||||
/// 在一个相册上通过一次分词,同时提取:
|
||||
/// - 人名列表(支持多个)
|
||||
/// - 标签列表(含分类)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParseResult {
|
||||
/// 相册 UUID
|
||||
pub album_id: 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_names: Vec::new(), tags: Vec::new() }
|
||||
}
|
||||
}
|
||||
53
src/models/person.rs
Normal file
53
src/models/person.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::album::AlbumInfo;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Person {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub display_name: Option<String>,
|
||||
pub total_albums: i64,
|
||||
pub total_images: i64,
|
||||
pub original_names: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PersonAlias {
|
||||
pub id: i64,
|
||||
pub person_id: String,
|
||||
pub alias_type: String,
|
||||
pub alias_name: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PersonWithAlbums {
|
||||
pub person: Person,
|
||||
pub albums: Vec<AlbumInfo>,
|
||||
pub album_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PersonWithCounts {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub display_name: Option<String>,
|
||||
pub total_albums: i64,
|
||||
pub total_images: i64,
|
||||
pub aliases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PersonListResponse {
|
||||
pub persons: Vec<PersonWithCounts>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchPersonResponse {
|
||||
pub person: Option<PersonWithCounts>,
|
||||
pub albums: Vec<AlbumInfo>,
|
||||
pub total_albums: usize,
|
||||
}
|
||||
46
src/models/recommend.rs
Normal file
46
src/models/recommend.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 推荐请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecommendQuery {
|
||||
pub q: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
pub context: Option<String>, // 上下文标签(用于共现推荐)
|
||||
}
|
||||
|
||||
/// 推荐条目(通用)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RecommendItem {
|
||||
pub value: String,
|
||||
pub label: String,
|
||||
pub score: f64,
|
||||
pub count: Option<i64>,
|
||||
pub source: String, // "tag" | "person" | "co-occurrence" | "popular"
|
||||
}
|
||||
|
||||
/// 推荐响应
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RecommendResponse {
|
||||
pub items: Vec<RecommendItem>,
|
||||
pub total: usize,
|
||||
pub category: String, // "tags" | "persons" | "related"
|
||||
}
|
||||
|
||||
/// 标签推荐(带上下文)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TagRecommendation {
|
||||
pub tag: String,
|
||||
pub related_tags: Vec<RecommendItem>,
|
||||
pub similar_persons: Vec<RecommendItem>,
|
||||
}
|
||||
|
||||
/// 热门人物
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PopularPerson {
|
||||
pub name: String,
|
||||
pub display_name: Option<String>,
|
||||
pub total_albums: i64,
|
||||
pub total_images: i64,
|
||||
pub top_tags: Vec<String>,
|
||||
pub aliases: Vec<String>,
|
||||
}
|
||||
35
src/models/tag.rs
Normal file
35
src/models/tag.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 标签统计
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TagStat {
|
||||
pub tag: String,
|
||||
pub count: i64,
|
||||
pub album_count: i64,
|
||||
}
|
||||
|
||||
/// 标签列表响应
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TagListResponse {
|
||||
pub tags: Vec<TagStat>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// 元数据统计
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MetadataStats {
|
||||
pub total_persons: i64,
|
||||
pub total_aliases: i64,
|
||||
pub total_albums: i64,
|
||||
pub total_images: i64,
|
||||
pub total_tags: i64,
|
||||
}
|
||||
|
||||
/// 标签共现关系
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TagCoOccurrence {
|
||||
pub tag: String,
|
||||
pub co_tag: String,
|
||||
pub count: i64,
|
||||
pub score: f64,
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicBool};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Notify;
|
||||
use tracing::{info, debug};
|
||||
use tracing::info;
|
||||
|
||||
/// 队列统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -34,7 +34,7 @@ impl TaskQueue {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_db(_max_concurrent_tasks: usize, _queue_size_limit: usize, _high_request_rate_threshold: f64, _idle_request_rate_threshold: f64, _db: Arc<crate::database::Database>) -> Self {
|
||||
pub fn new_with_db(_max_concurrent_tasks: usize, _queue_size_limit: usize, _high_request_rate_threshold: f64, _idle_request_rate_threshold: f64, _db: Arc<crate::db::Database>) -> Self {
|
||||
Self {
|
||||
// HTTP请求频率监控
|
||||
request_count: AtomicU64::new(0),
|
||||
|
||||
365
src/routes.rs
365
src/routes.rs
@@ -1,365 +0,0 @@
|
||||
use crate::database::Database;
|
||||
use crate::image_processor::ImageProcessor;
|
||||
use crate::models::*;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
use crate::queue;
|
||||
|
||||
|
||||
|
||||
// 收藏功能API路由
|
||||
|
||||
// 获取用户的收藏列表
|
||||
pub async fn get_favorites(db: web::Data<Arc<Database>>) -> impl Responder {
|
||||
// 这里使用固定用户ID,实际项目中应该从认证信息中获取
|
||||
let user_id = "default_user".to_string();
|
||||
|
||||
let favorites = match db.get_user_favorites(&user_id).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
error!("获取收藏列表失败: {}", e);
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "获取收藏列表失败"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponse::Ok().json(favorites)
|
||||
}
|
||||
|
||||
// 图片质量检查功能已移除,直接返回原始图片
|
||||
|
||||
// 检查图片是否被收藏
|
||||
pub async fn check_favorite_status(
|
||||
db: web::Data<Arc<Database>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let image_id = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "无效的图片ID"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 这里使用固定用户ID,实际项目中应该从认证信息中获取
|
||||
let user_id = "default_user".to_string();
|
||||
|
||||
let status = match db.get_favorite_status(&user_id, image_id).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
error!("检查收藏状态失败: {}", e);
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "检查收藏状态失败"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponse::Ok().json(status)
|
||||
}
|
||||
|
||||
// 添加收藏
|
||||
pub async fn add_favorite(
|
||||
db: web::Data<Arc<Database>>,
|
||||
favorite_data: web::Json<CreateFavorite>,
|
||||
) -> impl Responder {
|
||||
// 这里使用固定用户ID,实际项目中应该从认证信息中获取
|
||||
let user_id = "default_user".to_string();
|
||||
|
||||
match db.add_favorite(&user_id, favorite_data.into_inner()).await {
|
||||
Ok(favorite_id) => HttpResponse::Created().json(serde_json::json!({
|
||||
"id": favorite_id,
|
||||
"message": "收藏成功"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("添加收藏失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "添加收藏失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 取消收藏
|
||||
pub async fn remove_favorite(
|
||||
db: web::Data<Arc<Database>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let favorite_id = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "无效的收藏ID"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match db.remove_favorite(favorite_id).await {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"message": "取消收藏成功"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("取消收藏失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "取消收藏失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务队列状态
|
||||
pub async fn get_queue_stats(
|
||||
task_queue: web::Data<Arc<queue::TaskQueue>>,
|
||||
) -> impl Responder {
|
||||
// 记录HTTP请求频率
|
||||
task_queue.record_http_request().await;
|
||||
|
||||
let stats = task_queue.get_stats().await;
|
||||
HttpResponse::Ok().json(stats)
|
||||
}
|
||||
|
||||
// 通过图片ID取消收藏
|
||||
pub async fn remove_favorite_by_image(
|
||||
db: web::Data<Arc<Database>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let image_id = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "无效的图片ID"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 这里使用固定用户ID,实际项目中应该从认证信息中获取
|
||||
let user_id = "default_user".to_string();
|
||||
|
||||
match db.remove_favorite_by_image(&user_id, image_id).await {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"message": "取消收藏成功"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("取消收藏失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "取消收藏失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub async fn get_albums(albums_cache: web::Data<Arc<RwLock<AlbumsCache>>>) -> impl Responder {
|
||||
// 从内存缓存获取数据
|
||||
let cache = albums_cache.read().await;
|
||||
let total_albums = cache.albums.len();
|
||||
|
||||
// 应用分页
|
||||
let start = 0;
|
||||
let end = total_albums;
|
||||
let paginated_albums = if start < end {
|
||||
cache.albums[start..end].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
drop(cache); // 释放读锁
|
||||
|
||||
HttpResponse::Ok().json(paginated_albums)
|
||||
}
|
||||
|
||||
pub async fn search_albums(
|
||||
albums_cache: web::Data<Arc<RwLock<AlbumsCache>>>,
|
||||
query: web::Query<std::collections::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": "搜索查询过长,最多100个字符"
|
||||
}));
|
||||
}
|
||||
|
||||
// 允许Unicode字符(包括中文)、ASCII图形字符和空白字符
|
||||
// 只阻止控制字符和其他潜在危险字符
|
||||
if q.chars().any(|c| c.is_control() || c == '\0') {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "搜索查询包含无效字符"
|
||||
}));
|
||||
}
|
||||
|
||||
let page = query
|
||||
.get("page")
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(1);
|
||||
let limit = query
|
||||
.get("limit")
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(50);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
if q.is_empty() {
|
||||
// 如果没有搜索词,返回所有相册(带分页)
|
||||
let cache = albums_cache.read().await;
|
||||
let total_albums = cache.albums.len();
|
||||
|
||||
// 应用分页
|
||||
let start = offset.min(total_albums);
|
||||
let end = (offset + limit).min(total_albums);
|
||||
let paginated_albums = if start < end {
|
||||
cache.albums[start..end].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
drop(cache);
|
||||
return HttpResponse::Ok().json(paginated_albums);
|
||||
}
|
||||
|
||||
// 从内存缓存搜索
|
||||
let cache = albums_cache.read().await;
|
||||
let mut search_results = cache.search(q);
|
||||
|
||||
// 应用分页到搜索结果
|
||||
let total_results = search_results.len();
|
||||
let start = offset.min(total_results);
|
||||
let end = (offset + limit).min(total_results);
|
||||
let paginated_results = if start < end {
|
||||
search_results.drain(start..end).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
drop(cache);
|
||||
|
||||
HttpResponse::Ok().json(paginated_results)
|
||||
}
|
||||
|
||||
pub async fn get_random_album(
|
||||
albums_cache: web::Data<Arc<RwLock<AlbumsCache>>>,
|
||||
) -> impl Responder {
|
||||
// 从内存缓存获取数据
|
||||
let cache = albums_cache.read().await;
|
||||
|
||||
if cache.albums.is_empty() {
|
||||
drop(cache);
|
||||
return HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "没有可用的相册"
|
||||
}));
|
||||
}
|
||||
|
||||
// 使用随机数生成器选择一个随机相册
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let random_index = rng.gen_range(0..cache.albums.len());
|
||||
let random_album = cache.albums[random_index].clone();
|
||||
|
||||
drop(cache);
|
||||
|
||||
HttpResponse::Ok().json(random_album)
|
||||
}
|
||||
|
||||
pub async fn get_album_detail(
|
||||
db: web::Data<Arc<Database>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let album_id = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "无效的相册ID"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match db.get_album_with_images(album_id).await {
|
||||
Ok(Some(album)) => HttpResponse::Ok().json(album),
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "相册不存在"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("获取相册详情失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "获取相册详情失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_album(
|
||||
db: web::Data<Arc<Database>>,
|
||||
album_data: web::Json<CreateAlbum>,
|
||||
) -> impl Responder {
|
||||
match db.create_album(album_data.into_inner()).await {
|
||||
Ok(album_id) => HttpResponse::Created().json(serde_json::json!({
|
||||
"id": album_id,
|
||||
"message": "相册创建成功"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("创建相册失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "创建相册失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取原始图片
|
||||
pub async fn get_image(
|
||||
db: web::Data<Arc<Database>>,
|
||||
image_processor: web::Data<Arc<ImageProcessor>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let image_id = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "无效的图片ID"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match db.get_image(image_id).await {
|
||||
Ok(Some(image)) => {
|
||||
let original_path = image_processor.config.images_dir.join(&image.filename);
|
||||
|
||||
if original_path.exists() {
|
||||
match fs::read(&original_path) {
|
||||
Ok(data) => {
|
||||
HttpResponse::Ok()
|
||||
.content_type(image.mime_type.clone())
|
||||
.insert_header(("Cache-Control", "public, max-age=31536000"))
|
||||
.insert_header(("ETag", format!("\"{}\"", image_id)))
|
||||
.body(data)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("读取原始图片失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "读取图片失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "图片文件不存在"
|
||||
}))
|
||||
}
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "图片不存在"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("获取图片信息失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "获取图片信息失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/routes/album.rs
Normal file
116
src/routes/album.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use crate::cache::AlbumsCache;
|
||||
use crate::db::Database;
|
||||
use crate::search_db::SearchDb;
|
||||
use crate::models::*;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::collections::HashMap;
|
||||
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(
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
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("");
|
||||
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 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 search_db.search_albums(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 total = c.albums.len();
|
||||
let start = offset.min(total);
|
||||
let end = (offset + limit).min(total);
|
||||
let results = c.albums[start..end].to_vec();
|
||||
|
||||
HttpResponse::Ok().json(results)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_random_album(cache: web::Data<Arc<RwLock<AlbumsCache>>>) -> impl Responder {
|
||||
let c = cache.read().await;
|
||||
if c.albums.is_empty() { return HttpResponse::NotFound().json(serde_json::json!({"error": "没有相册"})); }
|
||||
let idx = rand::random::<usize>() % c.albums.len();
|
||||
HttpResponse::Ok().json(&c.albums[idx])
|
||||
}
|
||||
|
||||
pub async fn get_album_detail(
|
||||
db: web::Data<Arc<Database>>,
|
||||
cache: web::Data<Arc<RwLock<AlbumsCache>>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
|
||||
let id_str = id.to_string();
|
||||
|
||||
// 从内存缓存查 tags/persons
|
||||
let (tags, persons) = {
|
||||
let c = cache.read().await;
|
||||
let found = c.albums.iter().find(|a| a.id == id);
|
||||
match found {
|
||||
Some(a) => (a.tags.clone(), a.persons.clone()),
|
||||
None => (vec![], vec![]),
|
||||
}
|
||||
};
|
||||
|
||||
match db.get_album_with_images(id).await {
|
||||
Ok(Some(mut album)) => {
|
||||
album.tags = tags;
|
||||
album.persons = persons;
|
||||
HttpResponse::Ok().json(album)
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "相册不存在"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_album(db: web::Data<Arc<Database>>, body: web::Json<CreateAlbum>) -> impl Responder {
|
||||
match db.create_album(body.into_inner()).await {
|
||||
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
}
|
||||
27
src/routes/cache.rs
Normal file
27
src/routes/cache.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::cache;
|
||||
use crate::db::Database;
|
||||
use crate::search_db::SearchDb;
|
||||
use crate::cache::{AlbumsCache, TagCache, PersonCache};
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
||||
/// POST /api/cache/refresh — 手动刷新所有内存缓存
|
||||
pub async fn cache_refresh(
|
||||
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>>>,
|
||||
) -> impl Responder {
|
||||
info!("[API] 手动触发缓存刷新...");
|
||||
cache::refresh_all_caches(
|
||||
db.get_ref(),
|
||||
search_db.get_ref(),
|
||||
albums_cache.get_ref(),
|
||||
persons_cache.get_ref(),
|
||||
tags_cache.get_ref(),
|
||||
).await;
|
||||
HttpResponse::Ok().json(serde_json::json!({"success": true, "message": "缓存已刷新"}))
|
||||
}
|
||||
83
src/routes/dict.rs
Normal file
83
src/routes/dict.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! 标签词典管理 API — 查看/热重载
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
|
||||
use crate::engine::tag_dict;
|
||||
use crate::search_db::SearchDb;
|
||||
|
||||
/// GET /api/dict/stats — 查看词典状态
|
||||
pub async fn dict_stats() -> impl Responder {
|
||||
let stats = tag_dict::get_tag_dict_stats();
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
let mut categories: Vec<serde_json::Value> = Vec::new();
|
||||
for name in dict.get_category_order() {
|
||||
if let Some(cat) = dict.get_category(name) {
|
||||
categories.push(serde_json::json!({
|
||||
"name": name,
|
||||
"label": cat.label,
|
||||
"count": cat.words.len(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"stats": stats,
|
||||
"categories": categories,
|
||||
"total_tags": dict.get_tag_category_map().len(),
|
||||
"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<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/dict/reload — 热重载词典
|
||||
pub async fn dict_reload(search_db: web::Data<Arc<SearchDb>>) -> impl Responder {
|
||||
let msg = tag_dict::reload_from_search_db(search_db.get_ref()).await;
|
||||
println!("[API] 词典热重载: {}", msg);
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": msg,
|
||||
}))
|
||||
}
|
||||
|
||||
/// dict 条目请求体
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct DictEntryInput {
|
||||
pub word: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub category: Option<String>,
|
||||
pub freq: Option<i64>,
|
||||
pub merged_to: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /api/dict/update — 更新 dict 表(新增或覆盖条目)
|
||||
pub async fn dict_update(
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
body: web::Json<Vec<DictEntryInput>>,
|
||||
) -> impl Responder {
|
||||
let entries = body.into_inner();
|
||||
let mut inserted = 0usize;
|
||||
for e in &entries {
|
||||
match search_db.upsert_dict_entry(
|
||||
&e.word, &e.type_, e.category.as_deref(), e.freq, e.merged_to.as_deref(),
|
||||
).await {
|
||||
Ok(_) => inserted += 1,
|
||||
Err(err) => {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"success": false,
|
||||
"message": format!("更新词条 '{}' 失败: {}", e.word, err),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 自动重载内存词典
|
||||
let msg = tag_dict::reload_from_search_db(search_db.get_ref()).await;
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": format!("更新 {} 条字典条目,{}", inserted, msg),
|
||||
"updated": inserted,
|
||||
}))
|
||||
}
|
||||
45
src/routes/favorite.rs
Normal file
45
src/routes/favorite.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use crate::db::Database;
|
||||
use crate::models::*;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
static USER_ID: &str = "default_user";
|
||||
|
||||
pub async fn get_favorites(db: web::Data<Arc<Database>>) -> impl Responder {
|
||||
match db.get_user_favorites(USER_ID).await {
|
||||
Ok(favs) => HttpResponse::Ok().json(favs),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_favorite_status(db: web::Data<Arc<Database>>, path: web::Path<String>) -> impl Responder {
|
||||
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
|
||||
match db.get_favorite_status(USER_ID, id).await {
|
||||
Ok(s) => HttpResponse::Ok().json(s),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_favorite(db: web::Data<Arc<Database>>, body: web::Json<CreateFavorite>) -> impl Responder {
|
||||
match db.add_favorite(USER_ID, body.into_inner()).await {
|
||||
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_favorite(db: web::Data<Arc<Database>>, path: web::Path<String>) -> impl Responder {
|
||||
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
|
||||
match db.remove_favorite(id).await {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "已取消"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_favorite_by_image(db: web::Data<Arc<Database>>, path: web::Path<String>) -> impl Responder {
|
||||
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
|
||||
match db.remove_favorite_by_image(USER_ID, id).await {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "已取消"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
}
|
||||
29
src/routes/image.rs
Normal file
29
src/routes/image.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::db::Database;
|
||||
use crate::image_processor::ImageProcessor;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_image(
|
||||
db: web::Data<Arc<Database>>,
|
||||
ip: web::Data<Arc<ImageProcessor>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) };
|
||||
match db.get_image(id).await {
|
||||
Ok(Some(img)) => {
|
||||
let fp = ip.config.images_dir.join(&img.filename);
|
||||
if fp.exists() {
|
||||
match fs::read(&fp) {
|
||||
Ok(data) => HttpResponse::Ok().content_type(img.mime_type)
|
||||
.insert_header(("Cache-Control", "public, max-age=31536000"))
|
||||
.insert_header(("ETag", format!("\"{}\"", id))).body(data),
|
||||
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "读取失败"})),
|
||||
}
|
||||
} else { HttpResponse::NotFound().json(serde_json::json!({"error": "文件不存在"})) }
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "图片不存在"})),
|
||||
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "查询失败"})),
|
||||
}
|
||||
}
|
||||
48
src/routes/migration.rs
Normal file
48
src/routes/migration.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::db::Database;
|
||||
#[allow(unused)] use crate::db::MigrationStats;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// GET /api/migration/stats — 查看迁移状态
|
||||
pub async fn migration_stats(db: web::Data<Arc<Database>>) -> impl Responder {
|
||||
let persons = db.get_all_persons_with_counts().await.len();
|
||||
let aliases = db.get_all_aliases().await.len();
|
||||
let albums = db.get_all_albums().await.unwrap_or_default().len();
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"persons": persons,
|
||||
"aliases": aliases,
|
||||
"albums": albums,
|
||||
"message": if persons == 0 { "数据库为空,建议执行迁移" } else { "数据已就绪" }
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/migration/run — 执行从老数据库迁移
|
||||
pub async fn migration_run(db: web::Data<Arc<Database>>, query: web::Query<HashMap<String, String>>) -> impl Responder {
|
||||
let old_db_path = match query.get("path") {
|
||||
Some(p) if !p.is_empty() => p.clone(),
|
||||
_ => return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "请提供老数据库路径: ?path=persons.db"
|
||||
})),
|
||||
};
|
||||
|
||||
match db.migrate_from_old_db(&old_db_path).await {
|
||||
Ok(stats) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"summary": db.migration_summary(&stats),
|
||||
"stats": {
|
||||
"persons_imported": stats.persons_imported,
|
||||
"persons_skipped": stats.persons_skipped,
|
||||
"aliases_imported": stats.aliases_imported,
|
||||
"albums_updated": stats.albums_updated,
|
||||
"albums_missing": stats.albums_missing,
|
||||
"tags_imported": stats.tags_imported,
|
||||
"person_albums_created": stats.person_albums_created,
|
||||
}
|
||||
})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("迁移失败: {}", e)
|
||||
})),
|
||||
}
|
||||
}
|
||||
25
src/routes/mod.rs
Normal file
25
src/routes/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! API 路由层 — 按领域拆分
|
||||
|
||||
pub mod album;
|
||||
pub mod cache;
|
||||
pub mod image;
|
||||
pub mod favorite;
|
||||
pub mod person;
|
||||
pub mod tag;
|
||||
pub mod recommend;
|
||||
pub mod queue;
|
||||
pub mod migration;
|
||||
pub mod pipeline;
|
||||
pub mod dict;
|
||||
|
||||
pub use album::*;
|
||||
pub use cache::*;
|
||||
pub use image::*;
|
||||
pub use favorite::*;
|
||||
pub use person::*;
|
||||
pub use tag::*;
|
||||
pub use recommend::*;
|
||||
pub use queue::*;
|
||||
pub use migration::*;
|
||||
pub use pipeline::*;
|
||||
pub use dict::*;
|
||||
190
src/routes/person.rs
Normal file
190
src/routes/person.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
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};
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// GET /api/people — 从内存缓存获取所有人名列表
|
||||
pub async fn get_people(cache: web::Data<Arc<RwLock<PersonCache>>>) -> impl Responder {
|
||||
let c = cache.read().await;
|
||||
HttpResponse::Ok().json(PersonListResponse {
|
||||
total: c.persons.len(),
|
||||
persons: c.persons.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// GET /api/people/search?q=关键词 — 从内存缓存搜索人名
|
||||
pub async fn search_people(
|
||||
cache: web::Data<Arc<RwLock<PersonCache>>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
|
||||
let c = cache.read().await;
|
||||
if q.is_empty() {
|
||||
return HttpResponse::Ok().json(PersonListResponse {
|
||||
total: c.persons.len(),
|
||||
persons: c.persons.clone(),
|
||||
});
|
||||
}
|
||||
let persons = c.search(q);
|
||||
HttpResponse::Ok().json(PersonListResponse {
|
||||
total: persons.len(),
|
||||
persons,
|
||||
})
|
||||
}
|
||||
|
||||
/// GET /api/people/{name} — 查找人物详情
|
||||
/// 人物基本信息从内存缓存获取,相册列表从 imager.db 查询
|
||||
pub async fn get_person_detail(
|
||||
db: web::Data<Arc<Database>>,
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
cache: web::Data<Arc<RwLock<PersonCache>>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let name = path.into_inner();
|
||||
let c = cache.read().await;
|
||||
let found = c.search(&name);
|
||||
let p = found.into_iter().find(|p| p.name.to_lowercase() == name.to_lowercase());
|
||||
match p {
|
||||
Some(p) => {
|
||||
// 从 search.db 获取相册 ID 列表
|
||||
let album_ids = search_db.get_person_album_ids(&p.id).await;
|
||||
// 从 imager.db 查询相册详情
|
||||
let albums = if album_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let placeholders: Vec<String> = (0..album_ids.len()).map(|_| "?".to_string()).collect();
|
||||
let sql = format!(
|
||||
"SELECT id, title, document, description, cover_image_id, image_count, created_at \
|
||||
FROM albums WHERE id IN ({}) ORDER BY created_at DESC",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let mut query = sqlx::query(&sql);
|
||||
for id in &album_ids {
|
||||
query = query.bind(id);
|
||||
}
|
||||
query.fetch_all(&db.pool).await.unwrap_or_default()
|
||||
.iter().map(|r| {
|
||||
let id_str: String = r.get("id");
|
||||
AlbumInfo {
|
||||
id: Uuid::parse_str(&id_str).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"),
|
||||
tags: vec![],
|
||||
persons: vec![],
|
||||
}
|
||||
}).collect()
|
||||
};
|
||||
|
||||
HttpResponse::Ok().json(PersonWithAlbums {
|
||||
album_count: albums.len(),
|
||||
person: Person {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
display_name: p.display_name,
|
||||
total_albums: p.total_albums,
|
||||
total_images: p.total_images,
|
||||
original_names: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
},
|
||||
albums,
|
||||
})
|
||||
}
|
||||
None => HttpResponse::NotFound().json(serde_json::json!({"error": format!("人员 '{}' 不存在", name)})),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/people/{id}/aliases — 从 search.db 查别名
|
||||
pub async fn get_person_aliases(search_db: web::Data<Arc<SearchDb>>, path: web::Path<String>) -> impl Responder {
|
||||
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)
|
||||
// person.id 来自 imager.db,存储为 i64,模型层转为 String,这里转回 i64
|
||||
if let Some(person) = db.get_person_by_name(&person_name).await {
|
||||
if let Ok(pid) = person.id.parse::<i64>() {
|
||||
db.delete_person_completely(pid).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, &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,
|
||||
"message": format!(
|
||||
"人物 '{}' 已删除,影响 {} 个相册,重新解析完成(更新人物 {},更新标签 {})",
|
||||
person_name, album_ids.len(), persons_updated, tags_updated,
|
||||
)
|
||||
}))
|
||||
} 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),
|
||||
}))
|
||||
}
|
||||
}
|
||||
144
src/routes/pipeline.rs
Normal file
144
src/routes/pipeline.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use crate::db::Database;
|
||||
use crate::engine;
|
||||
use crate::search_db::SearchDb;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn pipeline_run(
|
||||
db: web::Data<Arc<Database>>,
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
) -> impl Responder {
|
||||
let result = engine::pipeline::run_pipeline(db.get_ref(), search_db.get_ref()).await;
|
||||
let synced = engine::pipeline::sync_albums_to_search_db(db.get_ref(), search_db.get_ref()).await;
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"tags_cleaned": result.tags_cleaned,
|
||||
"short_tags_removed": result.short_tags_removed,
|
||||
"person_like_tags_removed": result.person_like_tags_removed,
|
||||
"dirty_albums_detected": result.dirty_albums_detected,
|
||||
"non_persons_deleted": result.non_persons_deleted,
|
||||
"stop_word_persons_removed": result.stop_word_persons_removed,
|
||||
"new_albums_found": result.new_albums_found,
|
||||
"persons_updated": result.persons_updated,
|
||||
"tags_updated": result.tags_updated,
|
||||
"empty_albums_deleted": result.empty_albums_deleted,
|
||||
"dup_albums_deleted": result.dup_albums_deleted,
|
||||
"search_db_synced": synced,
|
||||
"message": format!(
|
||||
"管线执行完毕:删除空相册{},去重{},清洗标点{}相册,移除短标签{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},同步标签/人物到search.db{}",
|
||||
result.empty_albums_deleted, result.dup_albums_deleted,
|
||||
result.tags_cleaned, result.short_tags_removed, result.person_like_tags_removed, result.dirty_albums_detected,
|
||||
result.non_persons_deleted, result.stop_word_persons_removed, result.new_albums_found,
|
||||
result.persons_updated, result.tags_updated, synced
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/pipeline/reparse — 重置所有相册为未处理状态,重新解析全部标签和人物并同步到 search.db
|
||||
pub async fn pipeline_reparse(
|
||||
db: web::Data<Arc<Database>>,
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
) -> impl Responder {
|
||||
// 1. 清除 search.db 中的旧关联(不影响 tags/persons 字典)
|
||||
sqlx::query("DELETE FROM album_tags").execute(&search_db.pool).await.ok();
|
||||
sqlx::query("DELETE FROM album_persons").execute(&search_db.pool).await.ok();
|
||||
|
||||
// 2. 重置所有相册为未处理
|
||||
let reset_count = db.reset_all_processed_at().await;
|
||||
tracing::info!("[reparse] 重置 {} 个相册的 processed_at", reset_count);
|
||||
|
||||
// 3. 重新运行完整管线+同步
|
||||
let result = engine::pipeline::run_pipeline(db.get_ref(), search_db.get_ref()).await;
|
||||
let synced = engine::pipeline::sync_albums_to_search_db(db.get_ref(), search_db.get_ref()).await;
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"albums_parsed": result.new_albums_found,
|
||||
"persons_updated": result.persons_updated,
|
||||
"tags_updated": result.tags_updated,
|
||||
"synced_to_search_db": synced,
|
||||
"message": format!(
|
||||
"重新解析完成:重置{}个相册,解析{}个,更新人物{}个,标签{}个,同步{}个到search.db",
|
||||
reset_count, result.new_albums_found,
|
||||
result.persons_updated, result.tags_updated, synced,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/pipeline/reparse-persons — 重置所有相册,重新解析全部人物
|
||||
///
|
||||
/// 流程:
|
||||
/// 清空 search.db 的 album_persons → 重置所有相册 processed_at →
|
||||
/// 运行完整管线(人物相关逻辑全部执行)→ 全量同步到 search.db
|
||||
pub async fn pipeline_reparse_persons(
|
||||
db: web::Data<Arc<Database>>,
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
) -> impl Responder {
|
||||
// 1. 清空 search.db 中的人物相关表(album_persons + persons + person_aliases)
|
||||
sqlx::query("DELETE FROM album_persons").execute(&search_db.pool).await.ok();
|
||||
sqlx::query("DELETE FROM person_aliases").execute(&search_db.pool).await.ok();
|
||||
sqlx::query("DELETE FROM persons").execute(&search_db.pool).await.ok();
|
||||
|
||||
// 2. 重置所有相册为未处理(触发管线重新解析)
|
||||
let reset_count = db.reset_all_processed_at().await;
|
||||
tracing::info!("[reparse-persons] 重置 {} 个相册的 processed_at", reset_count);
|
||||
|
||||
// 3. 重新运行完整管线(Phase ② 人物清理 + Phase ③ 解析 + Phase ④ 人物合并 + Phase C 重计)
|
||||
let result = engine::pipeline::run_pipeline(db.get_ref(), search_db.get_ref()).await;
|
||||
|
||||
// 4. 全量同步到 search.db(tags 和 persons 都会同步,但 album_persons 已清空确保了干净状态)
|
||||
let synced = engine::pipeline::sync_albums_to_search_db(db.get_ref(), search_db.get_ref()).await;
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"albums_parsed": result.new_albums_found,
|
||||
"persons_updated": result.persons_updated,
|
||||
"non_persons_deleted": result.non_persons_deleted,
|
||||
"stop_word_persons_removed": result.stop_word_persons_removed,
|
||||
"synced_to_search_db": synced,
|
||||
"message": format!(
|
||||
"人物重新解析完成:重置{}个相册,解析{}个,更新人物{}个,删除非人物{},清除重叠{},同步{}个到search.db",
|
||||
reset_count, result.new_albums_found,
|
||||
result.persons_updated,
|
||||
result.non_persons_deleted, result.stop_word_persons_removed, synced,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/pipeline/reparse-tags — 重置所有相册,重新解析全部标签
|
||||
///
|
||||
/// 流程:
|
||||
/// 清空 search.db 的 album_tags → 重置所有相册 processed_at →
|
||||
/// 运行完整管线(标签相关逻辑全部执行)→ 全量同步到 search.db
|
||||
pub async fn pipeline_reparse_tags(
|
||||
db: web::Data<Arc<Database>>,
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
) -> impl Responder {
|
||||
// 1. 清空 search.db 中的标签相关表(album_tags + tags)
|
||||
sqlx::query("DELETE FROM album_tags").execute(&search_db.pool).await.ok();
|
||||
sqlx::query("DELETE FROM tags").execute(&search_db.pool).await.ok();
|
||||
|
||||
// 2. 重置所有相册为未处理(触发管线重新解析)
|
||||
let reset_count = db.reset_all_processed_at().await;
|
||||
tracing::info!("[reparse-tags] 重置 {} 个相册的 processed_at", reset_count);
|
||||
|
||||
// 3. 重新运行完整管线(Phase ③ 解析会重新提取全部标签)
|
||||
let result = engine::pipeline::run_pipeline(db.get_ref(), search_db.get_ref()).await;
|
||||
|
||||
// 4. 全量同步到 search.db
|
||||
let synced = engine::pipeline::sync_albums_to_search_db(db.get_ref(), search_db.get_ref()).await;
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"albums_parsed": result.new_albums_found,
|
||||
"tags_updated": result.tags_updated,
|
||||
"short_tags_removed": result.short_tags_removed,
|
||||
"synced_to_search_db": synced,
|
||||
"message": format!(
|
||||
"标签重新解析完成:重置{}个相册,解析{}个,更新标签{}个,移除短标签{},同步{}个到search.db",
|
||||
reset_count, result.new_albums_found,
|
||||
result.tags_updated, result.short_tags_removed, synced,
|
||||
)
|
||||
}))
|
||||
}
|
||||
8
src/routes/queue.rs
Normal file
8
src/routes/queue.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use crate::queue::TaskQueue;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn get_queue_stats(q: web::Data<Arc<TaskQueue>>) -> impl Responder {
|
||||
q.record_http_request().await;
|
||||
HttpResponse::Ok().json(q.get_stats().await)
|
||||
}
|
||||
54
src/routes/recommend.rs
Normal file
54
src/routes/recommend.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::cache::{TagCache, PersonCache};
|
||||
use crate::search_db::SearchDb;
|
||||
use crate::engine::RecommendEngine;
|
||||
use crate::models::*;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 标签推荐(自动补全)— 从 TagCache 匹配
|
||||
pub async fn recommend_tags(
|
||||
cache: web::Data<Arc<RwLock<TagCache>>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
|
||||
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(10);
|
||||
let c = cache.read().await;
|
||||
HttpResponse::Ok().json(RecommendEngine::recommend_tags(&c.popular_tags, q, limit))
|
||||
}
|
||||
|
||||
/// 人物推荐(自动补全)— 从 PersonCache 匹配
|
||||
pub async fn recommend_persons(
|
||||
cache: web::Data<Arc<RwLock<PersonCache>>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
|
||||
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(10);
|
||||
let c = cache.read().await;
|
||||
HttpResponse::Ok().json(RecommendEngine::recommend_persons(&c.persons, q, limit))
|
||||
}
|
||||
|
||||
/// 关联推荐(标签共现 + 共现人物)
|
||||
pub async fn recommend_related(
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let context = match query.get("context") {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return HttpResponse::BadRequest().json(serde_json::json!({"error": "请提供context参数"})),
|
||||
};
|
||||
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(15);
|
||||
HttpResponse::Ok().json(RecommendEngine::recommend_related(&search_db, context, limit).await)
|
||||
}
|
||||
|
||||
/// 热门人物 — 从 PersonCache 过滤,top_tags 保留 search.db 查询
|
||||
pub async fn popular_persons(
|
||||
search_db: web::Data<Arc<SearchDb>>,
|
||||
cache: web::Data<Arc<RwLock<PersonCache>>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(20).min(50);
|
||||
let c = cache.read().await;
|
||||
HttpResponse::Ok().json(RecommendEngine::popular_persons(&search_db, &c.persons, limit).await)
|
||||
}
|
||||
111
src/routes/tag.rs
Normal file
111
src/routes/tag.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// GET /api/metadata/stats — 从内存缓存计算统计数据
|
||||
pub async fn get_metadata_stats(
|
||||
albums_cache: web::Data<Arc<RwLock<AlbumsCache>>>,
|
||||
persons_cache: web::Data<Arc<RwLock<PersonCache>>>,
|
||||
tags_cache: web::Data<Arc<RwLock<TagCache>>>,
|
||||
) -> impl Responder {
|
||||
let ac = albums_cache.read().await;
|
||||
let pc = persons_cache.read().await;
|
||||
let tc = tags_cache.read().await;
|
||||
|
||||
let stats = MetadataStats {
|
||||
total_persons: pc.persons.len() as i64,
|
||||
total_aliases: pc.persons.iter().map(|p| p.aliases.len() as i64).sum(),
|
||||
total_albums: ac.albums.len() as i64,
|
||||
total_images: ac.albums.iter().map(|a| a.image_count).sum(),
|
||||
total_tags: tc.tag_albums.len() as i64,
|
||||
};
|
||||
HttpResponse::Ok().json(stats)
|
||||
}
|
||||
|
||||
/// GET /api/tags/search?tag=xxx — 从缓存按标签搜索相册
|
||||
pub async fn search_by_tag(
|
||||
cache: web::Data<Arc<RwLock<TagCache>>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let tag = match query.get("tag") {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return HttpResponse::BadRequest().json(serde_json::json!({"error": "请提供tag参数"})),
|
||||
};
|
||||
let c = cache.read().await;
|
||||
let albums = c.search_albums(tag);
|
||||
HttpResponse::Ok().json(albums)
|
||||
}
|
||||
|
||||
/// GET /api/tags/popular?limit=50 — 从缓存获取热门标签
|
||||
pub async fn get_popular_tags(
|
||||
cache: web::Data<Arc<RwLock<TagCache>>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50);
|
||||
let c = cache.read().await;
|
||||
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. 重新解析受影响相册(同时写入 imager.db + search.db)
|
||||
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, &search_db, &dirty).await;
|
||||
|
||||
// 4. 重载内存词典(dict 表已更新)
|
||||
engine::tag_dict::reload_from_search_db(&search_db).await;
|
||||
|
||||
// 5. 刷新内存缓存
|
||||
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,
|
||||
"message": format!(
|
||||
"标签 '{}' 已删除,影响 {} 个相册,重新解析完成(更新人物 {},更新标签 {})",
|
||||
tag_name, album_ids.len(), persons_updated, tags_updated,
|
||||
)
|
||||
}))
|
||||
}
|
||||
557
src/search_db/mod.rs
Normal file
557
src/search_db/mod.rs
Normal file
@@ -0,0 +1,557 @@
|
||||
mod search;
|
||||
|
||||
pub use search::*;
|
||||
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 从 name 计算确定性 tag UUID(无需查数据库即可得到 id)
|
||||
pub fn tag_uuid(name: &str) -> String {
|
||||
Uuid::new_v5(
|
||||
&Uuid::NAMESPACE_URL,
|
||||
format!("https://imager.dev/tag/{}", name).as_bytes(),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 从 name 计算确定性 person UUID(无需查数据库即可得到 id)
|
||||
pub fn person_uuid(name: &str) -> String {
|
||||
Uuid::new_v5(
|
||||
&Uuid::NAMESPACE_URL,
|
||||
format!("https://imager.dev/person/{}", name).as_bytes(),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DictEntry {
|
||||
pub word: String,
|
||||
pub type_: String,
|
||||
pub category: Option<String>,
|
||||
pub freq: Option<i64>,
|
||||
pub merged_to: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SearchDb {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SearchDb {
|
||||
pub async fn new(db_path: &str) -> Result<Self, sqlx::Error> {
|
||||
// 确保父目录存在
|
||||
let path = std::path::Path::new(db_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为绝对路径并创建文件(如果不存在)
|
||||
let abs_path = std::fs::canonicalize(db_path)
|
||||
.unwrap_or_else(|_| {
|
||||
// canonicalize 要求文件存在,所以先创建再转换
|
||||
std::fs::File::create(db_path).ok();
|
||||
std::fs::canonicalize(db_path).unwrap_or_else(|_| path.to_path_buf())
|
||||
});
|
||||
|
||||
// 使用 sqlite:/// 前缀 + 绝对路径,避免相对路径解析错误
|
||||
let url = format!("sqlite:///{}", abs_path.to_string_lossy().replace('\\', "/"));
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await?;
|
||||
|
||||
sqlx::query("PRAGMA journal_mode=WAL;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA synchronous=NORMAL;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA busy_timeout=5000;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA wal_autocheckpoint=1000;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA journal_size_limit=67108864;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA cache_size=-256000;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA temp_store=MEMORY;").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA mmap_size=268435456;").execute(&pool).await?;
|
||||
|
||||
Self::create_tables(&pool).await?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// 在批量写入前临时调低 synchronous 级别提升性能
|
||||
pub async fn set_sync_off(&self) {
|
||||
let _ = sqlx::query("PRAGMA synchronous=OFF;").execute(&self.pool).await;
|
||||
}
|
||||
|
||||
/// 批量写入后恢复 synchronous 级别
|
||||
pub async fn set_sync_normal(&self) {
|
||||
let _ = sqlx::query("PRAGMA synchronous=NORMAL;").execute(&self.pool).await;
|
||||
}
|
||||
|
||||
/// 执行 WAL checkpoint(TRUNCATE),将 WAL 完全合并回主文件并清理 -wal / -shm 文件
|
||||
///
|
||||
/// 在启动时调用,确保 search.db 文件干净,避免下次启动时 WAL 恢复开销。
|
||||
pub async fn wal_checkpoint(&self) {
|
||||
match sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.execute(&self.pool).await
|
||||
{
|
||||
Ok(_) => println!("[search.db] WAL checkpoint(TRUNCATE) 完成,数据库文件已合并"),
|
||||
Err(e) => eprintln!("[search.db] WAL checkpoint 失败: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_tables(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
// ── 迁移检测:如果 tags 表存在且 id 类型是 INTEGER(旧版),重建表 ──
|
||||
let has_old_schema: bool = sqlx::query(
|
||||
"SELECT type FROM pragma_table_info('tags') WHERE name = 'id'"
|
||||
).fetch_optional(pool).await.ok()
|
||||
.flatten()
|
||||
.map(|r| {
|
||||
let t: String = r.get("type");
|
||||
// 旧版用 INTEGER,新版用 TEXT
|
||||
t.contains("INTEGER")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_old_schema {
|
||||
println!("[search.db] 检测到旧版 INTEGER ID 模式,重建表为 TEXT UUID 模式...");
|
||||
sqlx::query("DROP TABLE IF EXISTS album_tags").execute(pool).await?;
|
||||
sqlx::query("DROP TABLE IF EXISTS album_persons").execute(pool).await?;
|
||||
sqlx::query("DROP TABLE IF EXISTS person_aliases").execute(pool).await?;
|
||||
sqlx::query("DROP TABLE IF EXISTS tags").execute(pool).await?;
|
||||
sqlx::query("DROP TABLE IF EXISTS persons").execute(pool).await?;
|
||||
println!("[search.db] 旧表已删除,即将重建");
|
||||
}
|
||||
|
||||
// ── 标签表(TEXT UUID 主键) ──
|
||||
sqlx::query(r#"
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
category TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"#).execute(pool).await?;
|
||||
|
||||
// ── 人物表(TEXT UUID 主键) ──
|
||||
sqlx::query(r#"
|
||||
CREATE TABLE IF NOT EXISTS persons (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
total_albums INTEGER DEFAULT 0,
|
||||
total_images INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"#).execute(pool).await?;
|
||||
|
||||
// ── 人物别名 ──
|
||||
sqlx::query(r#"
|
||||
CREATE TABLE IF NOT EXISTS person_aliases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
person_id TEXT NOT NULL,
|
||||
alias_type TEXT NOT NULL,
|
||||
alias_name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
|
||||
)
|
||||
"#).execute(pool).await?;
|
||||
|
||||
// ── 相册-标签关联 ──
|
||||
sqlx::query(r#"
|
||||
CREATE TABLE IF NOT EXISTS album_tags (
|
||||
album_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (album_id, tag_id),
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
)
|
||||
"#).execute(pool).await?;
|
||||
|
||||
// ── 相册-人物关联 ──
|
||||
sqlx::query(r#"
|
||||
CREATE TABLE IF NOT EXISTS album_persons (
|
||||
album_id TEXT NOT NULL,
|
||||
person_id TEXT NOT NULL,
|
||||
PRIMARY KEY (album_id, person_id),
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
|
||||
)
|
||||
"#).execute(pool).await?;
|
||||
|
||||
// ── albums 表:搜索返回需要的相册基础信息 ──
|
||||
sqlx::query(r#"
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
cover_image_id TEXT,
|
||||
image_count INTEGER DEFAULT 0,
|
||||
created_at TEXT
|
||||
)
|
||||
"#).execute(pool).await?;
|
||||
|
||||
// ── dict 表 ──
|
||||
sqlx::query(r#"
|
||||
CREATE TABLE IF NOT EXISTS dict (
|
||||
word TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
category TEXT,
|
||||
freq INTEGER,
|
||||
merged_to TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"#).execute(pool).await?;
|
||||
|
||||
// ── 索引 ──
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums(title)").execute(pool).await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name)").execute(pool).await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_persons_name ON persons(name)").execute(pool).await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_album_tags_album ON album_tags(album_id)").execute(pool).await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_album_tags_tag ON album_tags(tag_id)").execute(pool).await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_album_persons_album ON album_persons(album_id)").execute(pool).await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_album_persons_person ON album_persons(person_id)").execute(pool).await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_person_aliases_name ON person_aliases(alias_name)").execute(pool).await?;
|
||||
|
||||
// 兼容旧表:尝试添加 merged_to 列(已有则忽略)
|
||||
let _ = sqlx::query("ALTER TABLE dict ADD COLUMN merged_to TEXT").execute(pool).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── upsert 写入方法 ──
|
||||
|
||||
pub async fn upsert_album(&self, album_id: &str, title: &str,
|
||||
cover_image_id: Option<&str>, image_count: i64,
|
||||
created_at: Option<&str>) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(r#"
|
||||
INSERT INTO albums (id, title, cover_image_id, image_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
cover_image_id = excluded.cover_image_id,
|
||||
image_count = excluded.image_count,
|
||||
created_at = excluded.created_at
|
||||
"#)
|
||||
.bind(album_id).bind(title).bind(cover_image_id)
|
||||
.bind(image_count).bind(created_at)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_album(&self, album_id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM album_tags WHERE album_id = ?").bind(album_id).execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM album_persons WHERE album_id = ?").bind(album_id).execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM albums WHERE id = ?").bind(album_id).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入标签(使用确定性 UUID,无需 SELECT 回查)
|
||||
pub async fn insert_tag(&self, name: &str, category: Option<&str>) -> Result<String, sqlx::Error> {
|
||||
let id = tag_uuid(name);
|
||||
sqlx::query(r#"INSERT OR IGNORE INTO tags (id, name, category) VALUES (?, ?, ?)"#)
|
||||
.bind(&id).bind(name).bind(category)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 插入人物(使用确定性 UUID,无需 SELECT 回查)
|
||||
pub async fn insert_person(&self, name: &str, display_name: Option<&str>) -> Result<String, sqlx::Error> {
|
||||
let id = person_uuid(name);
|
||||
sqlx::query(r#"INSERT OR IGNORE INTO persons (id, name, display_name) VALUES (?, ?, ?)"#)
|
||||
.bind(&id).bind(name).bind(display_name)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn update_person_counts(&self, person_id: &str, total_albums: i64, total_images: i64) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE persons SET total_albums = ?, total_images = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(total_albums).bind(total_images).bind(person_id)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_person_alias(&self, person_id: &str, alias_type: &str, alias_name: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(r#"
|
||||
INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)
|
||||
"#)
|
||||
.bind(person_id)
|
||||
.bind(alias_type)
|
||||
.bind(alias_name)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_album_tag(&self, album_id: &str, tag_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(r#"
|
||||
INSERT OR IGNORE INTO album_tags (album_id, tag_id) VALUES (?, ?)
|
||||
"#)
|
||||
.bind(album_id)
|
||||
.bind(tag_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_album_person(&self, album_id: &str, person_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(r#"
|
||||
INSERT OR IGNORE INTO album_persons (album_id, person_id) VALUES (?, ?)
|
||||
"#)
|
||||
.bind(album_id)
|
||||
.bind(person_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清空某相册的所有关联标签
|
||||
pub async fn clear_album_tags(&self, album_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("DELETE FROM album_tags WHERE album_id = ?")
|
||||
.bind(album_id).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清空某相册的所有关联人物
|
||||
pub async fn clear_album_persons(&self, album_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("DELETE FROM album_persons WHERE album_id = ?")
|
||||
.bind(album_id).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_dict(&self) -> Result<Vec<DictEntry>, sqlx::Error> {
|
||||
let rows = sqlx::query("SELECT word, type, category, freq, merged_to FROM dict")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let entries = rows.iter().map(|r| DictEntry {
|
||||
word: r.get("word"),
|
||||
type_: r.get("type"),
|
||||
category: r.get("category"),
|
||||
freq: r.get("freq"),
|
||||
merged_to: r.get("merged_to"),
|
||||
}).collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 加载 dict 表全部条目(不限类型),用于初始化 TagDict
|
||||
pub async fn load_dict_all(&self) -> Result<Vec<DictEntry>, sqlx::Error> {
|
||||
self.load_dict().await
|
||||
}
|
||||
|
||||
/// 插入或更新一条 dict 条目(word 为 PK,相同 word 会覆盖)
|
||||
pub async fn upsert_dict_entry(&self, word: &str, type_: &str, category: Option<&str>, freq: Option<i64>, merged_to: Option<&str>) -> Result<(), sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT COUNT(*) as cnt FROM dict WHERE word = ?"#
|
||||
).bind(word).fetch_one(&self.pool).await?;
|
||||
let exists: i64 = rows.get("cnt");
|
||||
if exists > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE dict SET type = ?, category = ?, freq = ?, merged_to = ? WHERE word = ?"
|
||||
).bind(type_).bind(category).bind(freq).bind(merged_to).bind(word)
|
||||
.execute(&self.pool).await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"INSERT INTO dict (word, type, category, freq, merged_to) VALUES (?, ?, ?, ?, ?)"
|
||||
).bind(word).bind(type_).bind(category).bind(freq).bind(merged_to)
|
||||
.execute(&self.pool).await?;
|
||||
}
|
||||
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 UUID
|
||||
let tag_id = tag_uuid(tag_name);
|
||||
|
||||
// 2. 查找关联 album_ids
|
||||
let album_ids: Vec<String> = sqlx::query_scalar::<_, String>(
|
||||
"SELECT DISTINCT album_id FROM album_tags WHERE tag_id = ?"
|
||||
).bind(&tag_id).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
// 3. 删除 album_tags 关联
|
||||
sqlx::query("DELETE FROM album_tags WHERE tag_id = ?")
|
||||
.bind(&tag_id).execute(&self.pool).await?;
|
||||
|
||||
// 4. 删除 tags 表条目
|
||||
sqlx::query("DELETE FROM tags WHERE id = ?").bind(&tag_id).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 UUID
|
||||
let person_id = person_uuid(person_name);
|
||||
|
||||
// 2. 查找关联 album_ids
|
||||
let album_ids: Vec<String> = sqlx::query_scalar::<_, String>(
|
||||
"SELECT DISTINCT album_id FROM album_persons WHERE person_id = ?"
|
||||
).bind(&person_id).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
// 3. 删除关联表
|
||||
sqlx::query("DELETE FROM album_persons WHERE person_id = ?")
|
||||
.bind(&person_id).execute(&self.pool).await?;
|
||||
sqlx::query("DELETE FROM person_aliases WHERE person_id = ?")
|
||||
.bind(&person_id).execute(&self.pool).await?;
|
||||
sqlx::query("DELETE FROM persons WHERE id = ?")
|
||||
.bind(&person_id).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)
|
||||
}
|
||||
|
||||
/// 批量同步解析结果到 search.db(单事务 + 预计算 UUID + 批量操作)
|
||||
///
|
||||
/// 核心优化:
|
||||
/// 1. 预加载全部 tags/persons 到 HashMap(仅 2 次 SELECT)
|
||||
/// 2. 预计算所有 tag/person 的 UUID(零数据库查询得到 ID)
|
||||
/// 3. 全量 `DELETE WHERE album_id IN (...)` 代替逐条 DELETE
|
||||
/// 4. 逐条 INSERT 但无需 SELECT 查 ID(UUID 直接写入)
|
||||
/// 5. 每事务一次 person counts 刷新
|
||||
pub async fn batch_sync_parse_results(&self, results: &[crate::models::parse::ParseResult]) -> Result<usize, sqlx::Error> {
|
||||
use std::collections::HashMap;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// 1. 预加载现有 tags/persons(仅 2 次 SELECT)
|
||||
let existing_tags: HashMap<String, String> = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT name, id FROM tags"
|
||||
).fetch_all(&mut *tx).await?.into_iter().collect();
|
||||
|
||||
let existing_persons: HashMap<String, String> = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT name, id FROM persons"
|
||||
).fetch_all(&mut *tx).await?.into_iter().collect();
|
||||
|
||||
// 2. 收集所有相册 ID,用于批量 DELETE
|
||||
let album_ids: Vec<&str> = results.iter().map(|r| r.album_id.as_str()).collect();
|
||||
if !album_ids.is_empty() {
|
||||
let ph: Vec<String> = album_ids.iter().map(|_| "?".to_string()).collect();
|
||||
let sql = format!("DELETE FROM album_tags WHERE album_id IN ({})", ph.join(","));
|
||||
let mut q = sqlx::query(&sql);
|
||||
for id in &album_ids { q = q.bind(id); }
|
||||
q.execute(&mut *tx).await?;
|
||||
|
||||
let ph2: Vec<String> = album_ids.iter().map(|_| "?".to_string()).collect();
|
||||
let sql2 = format!("DELETE FROM album_persons WHERE album_id IN ({})", ph2.join(","));
|
||||
let mut q2 = sqlx::query(&sql2);
|
||||
for id in &album_ids { q2 = q2.bind(id); }
|
||||
q2.execute(&mut *tx).await?;
|
||||
}
|
||||
|
||||
// 3. 收集去重后的 tag 和 person 名称
|
||||
let mut unique_tags: Vec<(String, Option<String>)> = Vec::new();
|
||||
let mut seen_tags = std::collections::HashSet::new();
|
||||
let mut unique_persons: Vec<String> = Vec::new();
|
||||
let mut seen_persons = std::collections::HashSet::new();
|
||||
|
||||
for result in results {
|
||||
for (tag_name, category) in &result.tags {
|
||||
if seen_tags.insert(tag_name.clone()) {
|
||||
unique_tags.push((tag_name.clone(), Some(category.clone())));
|
||||
}
|
||||
}
|
||||
for person_name in &result.person_names {
|
||||
if seen_persons.insert(person_name.clone()) {
|
||||
unique_persons.push(person_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4a. 写入标签:对不存在的标签 INSERT(使用预计算 UUID)
|
||||
for (tag_name, category) in &unique_tags {
|
||||
if !existing_tags.contains_key(tag_name) {
|
||||
let id = tag_uuid(tag_name);
|
||||
sqlx::query("INSERT OR IGNORE INTO tags (id, name, category) VALUES (?, ?, ?)")
|
||||
.bind(&id).bind(tag_name).bind(category)
|
||||
.execute(&mut *tx).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. 写入人物:对不存在的人物 INSERT(使用预计算 UUID)
|
||||
for person_name in &unique_persons {
|
||||
if !existing_persons.contains_key(person_name) {
|
||||
let id = person_uuid(person_name);
|
||||
sqlx::query("INSERT OR IGNORE INTO persons (id, name) VALUES (?, ?)")
|
||||
.bind(&id).bind(person_name)
|
||||
.execute(&mut *tx).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 写入批量关联数据(无 SELECT 查找,直接使用预计算的 UUID)
|
||||
let mut tag_id_cache: HashMap<String, String> = existing_tags.clone();
|
||||
let mut person_id_cache: HashMap<String, String> = existing_persons.clone();
|
||||
|
||||
for result in results {
|
||||
let album_id = &result.album_id;
|
||||
|
||||
// ── album_tags ──
|
||||
for (tag_name, _category) in &result.tags {
|
||||
let tid = tag_id_cache.get(tag_name.as_str())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tag_uuid(tag_name));
|
||||
// 缓存新写入的 tag(即使上一个缓存中已更新,这里确保缓存完整)
|
||||
tag_id_cache.entry(tag_name.clone()).or_insert_with(|| tid.clone());
|
||||
sqlx::query("INSERT OR IGNORE INTO album_tags (album_id, tag_id) VALUES (?, ?)")
|
||||
.bind(album_id).bind(&tid).execute(&mut *tx).await?;
|
||||
}
|
||||
|
||||
// ── album_persons ──
|
||||
for person_name in &result.person_names {
|
||||
let pid = person_id_cache.get(person_name.as_str())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| person_uuid(person_name));
|
||||
person_id_cache.entry(person_name.clone()).or_insert_with(|| pid.clone());
|
||||
sqlx::query("INSERT OR IGNORE INTO album_persons (album_id, person_id) VALUES (?, ?)")
|
||||
.bind(album_id).bind(&pid).execute(&mut *tx).await?;
|
||||
|
||||
// 别名写入
|
||||
let (primary, aliases) = crate::engine::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 {
|
||||
sqlx::query("INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)")
|
||||
.bind(&pid).bind(alias_type).bind(alias_name).execute(&mut *tx).await?;
|
||||
}
|
||||
}
|
||||
if primary != person_name.as_str() && !primary.is_empty() {
|
||||
sqlx::query("INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)")
|
||||
.bind(&pid).bind("primary").bind(&primary).execute(&mut *tx).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 更新 person counts(一次批量)
|
||||
for (_name, pid) in &person_id_cache {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM album_persons WHERE person_id = ?"
|
||||
).bind(pid).fetch_one(&mut *tx).await.unwrap_or(0);
|
||||
sqlx::query("UPDATE persons SET total_albums = ?, total_images = 0 WHERE id = ?")
|
||||
.bind(count).bind(pid).execute(&mut *tx).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
let total = results.len();
|
||||
println!("[batch_sync] 单事务同步完成:{} 个相册,{} 个标签(key),{} 个人物(key)",
|
||||
total, tag_id_cache.len(), person_id_cache.len());
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
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?;
|
||||
sqlx::query("DELETE FROM album_persons").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM person_aliases").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM tags").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM persons").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM albums").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM dict").execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
350
src/search_db/search.rs
Normal file
350
src/search_db/search.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! 搜索读取方法 — SearchDb 的只读接口
|
||||
//!
|
||||
//! 所有搜索相关的数据读取都从这里进行,完全不碰主库。
|
||||
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::*;
|
||||
use super::SearchDb;
|
||||
|
||||
impl SearchDb {
|
||||
// ═══════════════════════════════════════
|
||||
// 相册搜索
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/// 组合搜索:按 tag/person/标题关键词 任意组合过滤(AND)
|
||||
pub async fn search_albums(
|
||||
&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;
|
||||
}
|
||||
|
||||
let mut sql = String::from(
|
||||
"SELECT DISTINCT a.id, a.title, a.cover_image_id, a.image_count, a.created_at
|
||||
FROM albums a"
|
||||
);
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
|
||||
if has_tag {
|
||||
sql.push_str(" JOIN album_tags at ON a.id = at.album_id JOIN tags t ON at.tag_id = t.id");
|
||||
conditions.push("t.name = ?".to_string());
|
||||
}
|
||||
if has_person {
|
||||
sql.push_str(" JOIN album_persons ap ON a.id = ap.album_id JOIN persons p ON ap.person_id = p.id");
|
||||
conditions.push("(p.name = ? OR LOWER(p.name) LIKE ?)".to_string());
|
||||
}
|
||||
|
||||
if !conditions.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&conditions.join(" AND "));
|
||||
}
|
||||
if has_q {
|
||||
if conditions.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
} else {
|
||||
sql.push_str(" AND ");
|
||||
}
|
||||
sql.push_str("a.title LIKE ?");
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY a.created_at DESC LIMIT ? OFFSET ?");
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
|
||||
if has_tag { query = query.bind(tag.unwrap()); }
|
||||
if has_person {
|
||||
let p = person.unwrap();
|
||||
query = query.bind(p).bind(format!("%{}%", p));
|
||||
}
|
||||
if has_q { query = query.bind(format!("%{}%", title_q.unwrap())); }
|
||||
|
||||
query = query.bind(limit).bind(offset);
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await?;
|
||||
Ok(rows.iter().map(|r| {
|
||||
let id_str: String = r.get("id");
|
||||
AlbumInfo {
|
||||
id: Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()),
|
||||
title: r.get("title"),
|
||||
document: String::new(),
|
||||
description: None,
|
||||
cover_image_id: r.get("cover_image_id"),
|
||||
image_count: r.get("image_count"),
|
||||
created_at: r.get("created_at"),
|
||||
tags: vec![],
|
||||
persons: vec![],
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// 获取全量相册列表(缓存烘焙用,含 tags 和 persons)
|
||||
pub async fn get_all_albums(&self) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, title, cover_image_id, image_count, created_at FROM albums ORDER BY created_at DESC"
|
||||
).fetch_all(&self.pool).await?;
|
||||
|
||||
// 批量获取所有 album-tag 关系
|
||||
let tag_rows = sqlx::query(
|
||||
r#"SELECT at.album_id, t.name FROM album_tags at JOIN tags t ON at.tag_id = t.id"#
|
||||
).fetch_all(&self.pool).await?;
|
||||
let mut album_tags: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
||||
for r in &tag_rows {
|
||||
let aid: String = r.get("album_id");
|
||||
let name: String = r.get("name");
|
||||
album_tags.entry(aid).or_default().push(name);
|
||||
}
|
||||
|
||||
// 批量获取所有 album-person 关系
|
||||
let person_rows = sqlx::query(
|
||||
r#"SELECT ap.album_id, p.name FROM album_persons ap JOIN persons p ON ap.person_id = p.id"#
|
||||
).fetch_all(&self.pool).await?;
|
||||
let mut album_persons: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
||||
for r in &person_rows {
|
||||
let aid: String = r.get("album_id");
|
||||
let name: String = r.get("name");
|
||||
album_persons.entry(aid).or_default().push(name);
|
||||
}
|
||||
|
||||
Ok(rows.iter().map(|r| {
|
||||
let id_str: String = r.get("id");
|
||||
AlbumInfo {
|
||||
id: Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()),
|
||||
title: r.get("title"),
|
||||
document: String::new(),
|
||||
description: None,
|
||||
cover_image_id: r.get("cover_image_id"),
|
||||
image_count: r.get("image_count"),
|
||||
created_at: r.get("created_at"),
|
||||
tags: album_tags.remove(&id_str).unwrap_or_default(),
|
||||
persons: album_persons.remove(&id_str).unwrap_or_default(),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 标签搜索
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/// 获取热门标签(按使用次数排序)
|
||||
pub async fn get_popular_tags(&self, limit: usize) -> Vec<TagStat> {
|
||||
let limit = limit.min(500);
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT t.name, COUNT(at.album_id) as cnt
|
||||
FROM tags t
|
||||
JOIN album_tags at ON t.id = at.tag_id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY cnt DESC LIMIT ?"#
|
||||
).bind(limit as i64).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
rows.iter().map(|r| TagStat {
|
||||
tag: r.get("name"),
|
||||
count: r.get("cnt"),
|
||||
album_count: r.get("cnt"),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// 按标签搜索相册
|
||||
pub async fn search_albums_by_tag(&self, tag: &str) -> Vec<AlbumInfo> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT a.id, a.title, a.cover_image_id, a.image_count, a.created_at
|
||||
FROM albums a
|
||||
JOIN album_tags at ON a.id = at.album_id
|
||||
JOIN tags t ON at.tag_id = t.id
|
||||
WHERE t.name = ?
|
||||
ORDER BY a.created_at DESC"#
|
||||
).bind(tag).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
rows.iter().map(|r| {
|
||||
let id_str: String = r.get("id");
|
||||
AlbumInfo {
|
||||
id: Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()),
|
||||
title: r.get("title"),
|
||||
document: String::new(),
|
||||
description: None,
|
||||
cover_image_id: r.get("cover_image_id"),
|
||||
image_count: r.get("image_count"),
|
||||
created_at: r.get("created_at"),
|
||||
tags: vec![],
|
||||
persons: vec![],
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// 获取标签共现数据(关联推荐用)
|
||||
pub async fn get_tag_co_occurrences(&self, tag: &str, limit: i64) -> Vec<TagCoOccurrence> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT t2.name as co_tag, COUNT(*) as cnt
|
||||
FROM album_tags at1
|
||||
JOIN album_tags at2 ON at1.album_id = at2.album_id AND at1.tag_id != at2.tag_id
|
||||
JOIN tags t1 ON at1.tag_id = t1.id
|
||||
JOIN tags t2 ON at2.tag_id = t2.id
|
||||
WHERE t1.name = ?
|
||||
GROUP BY t2.name
|
||||
ORDER BY cnt DESC LIMIT ?"#
|
||||
).bind(tag).bind(limit).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM album_tags at JOIN tags t ON at.tag_id = t.id WHERE t.name = ?"
|
||||
).bind(tag).fetch_one(&self.pool).await.unwrap_or(1);
|
||||
|
||||
rows.iter().map(|r| TagCoOccurrence {
|
||||
tag: tag.to_string(),
|
||||
co_tag: r.get("co_tag"),
|
||||
count: r.get("cnt"),
|
||||
score: r.get::<i64, _>("cnt") as f64 / total as f64,
|
||||
}).collect()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 人物搜索
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/// 获取人物别名列表
|
||||
pub async fn get_person_aliases(&self, person_id: &str) -> Vec<PersonAlias> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, person_id, alias_type, alias_name, created_at FROM person_aliases WHERE person_id = ?"
|
||||
).bind(person_id).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
rows.iter().map(|r| PersonAlias {
|
||||
id: r.get("id"),
|
||||
person_id: r.get("person_id"),
|
||||
alias_type: r.get("alias_type"),
|
||||
alias_name: r.get("alias_name"),
|
||||
created_at: r.get("created_at"),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// 获取所有人物(含别名、排序)
|
||||
pub async fn get_all_persons_with_aliases(&self) -> Vec<PersonWithCounts> {
|
||||
let persons = sqlx::query(
|
||||
"SELECT id, name, display_name, total_albums, total_images FROM persons ORDER BY total_albums DESC"
|
||||
).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
let mut results = Vec::new();
|
||||
for r in &persons {
|
||||
let id: String = r.get("id");
|
||||
let name: String = r.get("name");
|
||||
let aliases: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT alias_name FROM person_aliases WHERE person_id = ? ORDER BY alias_type"
|
||||
).bind(&id).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
results.push(PersonWithCounts {
|
||||
id,
|
||||
name,
|
||||
display_name: r.get("display_name"),
|
||||
total_albums: r.get("total_albums"),
|
||||
total_images: r.get("total_images"),
|
||||
aliases,
|
||||
});
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// 按名称查找人物
|
||||
pub async fn get_person_by_name(&self, name: &str) -> Option<PersonWithCounts> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, display_name, total_albums, total_images FROM persons WHERE name = ?"
|
||||
).bind(name).fetch_optional(&self.pool).await.ok()??;
|
||||
|
||||
let id: String = row.get("id");
|
||||
let aliases: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT alias_name FROM person_aliases WHERE person_id = ? ORDER BY alias_type"
|
||||
).bind(&id).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
Some(PersonWithCounts {
|
||||
id,
|
||||
name: row.get("name"),
|
||||
display_name: row.get("display_name"),
|
||||
total_albums: row.get("total_albums"),
|
||||
total_images: row.get("total_images"),
|
||||
aliases,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取人物的所有相册 ID(从 search.db 的 album_persons 关联表查询)
|
||||
pub async fn get_person_album_ids(&self, person_id: &str) -> Vec<String> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT album_id FROM album_persons WHERE person_id = ?"
|
||||
).bind(person_id).fetch_all(&self.pool).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 获取人物的 top 标签(用于详情页)
|
||||
pub async fn get_person_top_tags(&self, person_id: &str, limit: usize) -> Vec<(String, i64)> {
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT t.name, COUNT(*) as cnt
|
||||
FROM tags t
|
||||
JOIN album_tags at ON t.id = at.tag_id
|
||||
JOIN album_persons ap ON at.album_id = ap.album_id
|
||||
WHERE ap.person_id = ?
|
||||
GROUP BY t.name
|
||||
ORDER BY cnt DESC LIMIT ?"#
|
||||
).bind(person_id).bind(limit as i64)
|
||||
.fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
rows.iter().map(|r| (r.get("name"), r.get("cnt"))).collect()
|
||||
}
|
||||
|
||||
/// 搜索人物(按名称/别名模糊匹配,内存)
|
||||
pub fn search_persons_inline(persons: &[PersonWithCounts], query: &str) -> Vec<PersonWithCounts> {
|
||||
let q = query.to_lowercase().trim().to_string();
|
||||
if q.is_empty() || q.len() < 2 { return Vec::new(); }
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for p in persons {
|
||||
let name_lower = p.name.to_lowercase();
|
||||
let alias_matches = p.aliases.iter().any(|a| a.to_lowercase().contains(&q));
|
||||
|
||||
let matched = name_lower == q
|
||||
|| name_lower.starts_with(&q)
|
||||
|| name_lower.contains(&q)
|
||||
|| alias_matches;
|
||||
|
||||
if matched && seen.insert(p.id.clone()) {
|
||||
results.push(p.clone());
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| b.total_albums.cmp(&a.total_albums));
|
||||
results
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// 元数据统计
|
||||
// ═══════════════════════════════════════
|
||||
|
||||
/// 获取全局统计信息
|
||||
pub async fn get_metadata_stats(&self) -> MetadataStats {
|
||||
let total_persons: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM persons")
|
||||
.fetch_one(&self.pool).await.unwrap_or(0);
|
||||
let total_aliases: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM person_aliases")
|
||||
.fetch_one(&self.pool).await.unwrap_or(0);
|
||||
let total_albums: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM albums")
|
||||
.fetch_one(&self.pool).await.unwrap_or(0);
|
||||
let total_images: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(image_count), 0) FROM albums")
|
||||
.fetch_one(&self.pool).await.unwrap_or(0);
|
||||
let total_tags: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM album_tags")
|
||||
.fetch_one(&self.pool).await.unwrap_or(0);
|
||||
|
||||
MetadataStats {
|
||||
total_persons,
|
||||
total_aliases,
|
||||
total_albums,
|
||||
total_images,
|
||||
total_tags,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,40 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use image::GenericImageView;
|
||||
use serde::Deserialize;
|
||||
use ::image::GenericImageView;
|
||||
use tokio::task;
|
||||
use uuid::Uuid;
|
||||
use crate::config::Config;
|
||||
use crate::database::Database;
|
||||
use crate::db::Database;
|
||||
use crate::models::*;
|
||||
|
||||
/// 批量插入图片并设置封面缩略图的辅助函数
|
||||
/// 批量插入图片并设置封面的辅助函数
|
||||
async fn batch_insert_images_and_set_cover(
|
||||
db: &Database,
|
||||
images_to_insert: Vec<CreateImage>,
|
||||
album_id: Uuid,
|
||||
album_path: &Path,
|
||||
folder_name: &str,
|
||||
_album_path: &Path,
|
||||
_folder_name: &str,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
if images_to_insert.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
|
||||
println!(" 批量插入 {} 张图片...", images_to_insert.len());
|
||||
match db.add_images(images_to_insert).await {
|
||||
Ok(ids) => {
|
||||
println!(" 批量插入成功,共插入 {} 张图片", ids.len());
|
||||
|
||||
// 设置第一张图片为封面图片并生成缩略图
|
||||
|
||||
if let Some(first_image_id) = ids.first() {
|
||||
println!(" 设置第一张图片为封面图片 (ID: {})...", first_image_id);
|
||||
|
||||
// 更新相册的封面图片ID
|
||||
if let Err(e) = db.update_album_cover(album_id, Some(*first_image_id)).await {
|
||||
if let Err(e) = db.update_album_cover(album_id, *first_image_id).await {
|
||||
eprintln!(" 更新相册封面失败: {}", e);
|
||||
}
|
||||
|
||||
// 获取第一张图片的信息
|
||||
if let Ok(Some(first_image)) = db.get_image(*first_image_id).await {
|
||||
println!(" 封面图片已设置: {}", first_image.filename);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(ids.len())
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -50,36 +44,30 @@ async fn batch_insert_images_and_set_cover(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn import_existing_data(config: &Config, db: Arc<Database>, _task_queue: Option<Arc<crate::queue::TaskQueue>>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("开始扫描图片目录并导入新相册...");
|
||||
|
||||
|
||||
let albums_dir = Path::new(&config.storage.images_dir);
|
||||
|
||||
|
||||
if !albums_dir.exists() {
|
||||
println!("albums目录不存在,跳过导入");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 获取数据库中现有的相册列表
|
||||
|
||||
let existing_albums = db.get_all_albums().await?;
|
||||
let existing_titles: Vec<String> = existing_albums.iter()
|
||||
.map(|album| album.document.to_lowercase())
|
||||
.collect();
|
||||
|
||||
|
||||
println!("数据库中现有相册数量: {}", existing_albums.len());
|
||||
|
||||
// 第一阶段:检查并重新导入失败的相册
|
||||
println!("🔍 检查需要重新导入的相册...");
|
||||
|
||||
let mut reimported_count = 0;
|
||||
let mut deleted_count = 0;
|
||||
|
||||
|
||||
for album in &existing_albums {
|
||||
let album_folder_path = albums_dir.join(&album.document);
|
||||
|
||||
// 检查相册文件夹是否存在
|
||||
|
||||
if !album_folder_path.exists() {
|
||||
// 文件夹不存在,删除数据库中的相册记录
|
||||
println!("🗑️ 相册文件夹不存在,删除相册: {}", album.document);
|
||||
if let Err(e) = db.delete_album(album.id).await {
|
||||
eprintln!("❌ 删除相册失败: {}", e);
|
||||
@@ -88,11 +76,9 @@ pub async fn import_existing_data(config: &Config, db: Arc<Database>, _task_queu
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查相册是否有图片
|
||||
|
||||
let image_count = db.get_album_image_count(album.id).await.unwrap_or(0);
|
||||
if image_count == 0 {
|
||||
// 相册存在但没有图片,尝试重新导入
|
||||
println!("🔄 重新导入相册: {} (图片数量: 0)", album.document);
|
||||
match reimport_single_album(&db, &album.document, &album_folder_path, album.id).await {
|
||||
Ok(count) => {
|
||||
@@ -109,20 +95,18 @@ pub async fn import_existing_data(config: &Config, db: Arc<Database>, _task_queu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if reimported_count > 0 || deleted_count > 0 {
|
||||
println!("🎯 重新导入完成: 重新导入 {} 个相册,删除 {} 个相册", reimported_count, deleted_count);
|
||||
}
|
||||
|
||||
// 第二阶段:快速扫描所有相册文件夹,导入新相册
|
||||
|
||||
let entries: Vec<_> = fs::read_dir(albums_dir)?.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut album_folders = Vec::new();
|
||||
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(folder_name) = path.file_name().and_then(|s| s.to_str()) {
|
||||
// 检查是否已存在同名相册
|
||||
if existing_titles.contains(&folder_name.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
@@ -130,23 +114,22 @@ pub async fn import_existing_data(config: &Config, db: Arc<Database>, _task_queu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
println!("发现 {} 个新相册需要导入", album_folders.len());
|
||||
|
||||
|
||||
if album_folders.is_empty() {
|
||||
println!("没有新相册需要导入");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 使用简单的串行处理方式导入相册
|
||||
|
||||
println!("📁 开始导入新相册...");
|
||||
|
||||
|
||||
let mut total_imported = 0;
|
||||
let mut successful_albums = 0;
|
||||
|
||||
|
||||
for (folder_name, path) in album_folders {
|
||||
println!("📁 开始导入相册: {}", folder_name);
|
||||
|
||||
|
||||
match import_single_album(&db, &folder_name, &path).await {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
@@ -159,21 +142,18 @@ pub async fn import_existing_data(config: &Config, db: Arc<Database>, _task_queu
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ 相册 {} 导入失败: {}", folder_name, e);
|
||||
// 继续处理下一个相册,不中断整个导入过程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
println!("🎉 导入完成!成功导入 {} 个相册,共 {} 张图片", successful_albums, total_imported);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导入单个相册的辅助函数
|
||||
async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
println!("📁 开始导入相册: {}", folder_name);
|
||||
|
||||
// 读取index.json文件
|
||||
|
||||
let index_file_path = path.join("index.json");
|
||||
let album_title = if index_file_path.exists() {
|
||||
match fs::read_to_string(&index_file_path) {
|
||||
@@ -198,18 +178,16 @@ async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> R
|
||||
println!(" index.json不存在,使用文件夹名作为标题");
|
||||
folder_name.to_string()
|
||||
};
|
||||
|
||||
// 创建新相册
|
||||
|
||||
let new_album = CreateAlbum {
|
||||
title: album_title.clone(),
|
||||
document: folder_name.to_string(),
|
||||
description: Some(format!("从文件夹 {} 自动导入的相册", folder_name)),
|
||||
};
|
||||
|
||||
|
||||
let album_id = db.create_album(new_album).await?;
|
||||
println!(" 创建相册: {} (ID: {})", album_title, album_id);
|
||||
|
||||
// 读取图片URL列表(如果有index.json)
|
||||
|
||||
let image_urls = if index_file_path.exists() {
|
||||
match fs::read_to_string(&index_file_path) {
|
||||
Ok(content) => {
|
||||
@@ -226,20 +204,18 @@ async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> R
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 快速扫描图片文件
|
||||
|
||||
let image_files = scan_image_files(path, folder_name)?;
|
||||
|
||||
|
||||
if image_files.is_empty() {
|
||||
println!(" 相册 {} 中没有找到图片文件", folder_name);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
|
||||
println!(" 发现 {} 张图片,开始处理...", image_files.len());
|
||||
|
||||
// 并行处理图片信息获取
|
||||
|
||||
let mut image_tasks = Vec::new();
|
||||
|
||||
|
||||
for (index, (filename, image_path)) in image_files.iter().enumerate() {
|
||||
let image_url = image_urls.as_ref().and_then(|urls| {
|
||||
if urls.len() > index {
|
||||
@@ -248,14 +224,13 @@ async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> R
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
let filename_clone = filename.clone();
|
||||
let image_path_clone = image_path.clone();
|
||||
let album_id_clone = album_id;
|
||||
let folder_name_clone = folder_name.to_string();
|
||||
|
||||
|
||||
let task = task::spawn_blocking(move || {
|
||||
// 在阻塞线程中处理图片信息
|
||||
let (file_size, width, height) = match get_image_info(&image_path_clone) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
@@ -263,7 +238,7 @@ async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> R
|
||||
(0, 0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
CreateImage {
|
||||
album_id: album_id_clone,
|
||||
filename: format!("{}/{}", folder_name_clone, filename_clone),
|
||||
@@ -283,11 +258,10 @@ async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> R
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
image_tasks.push(task);
|
||||
}
|
||||
|
||||
// 收集所有图片信息
|
||||
|
||||
let mut images_to_insert = Vec::new();
|
||||
for task in image_tasks {
|
||||
match task.await {
|
||||
@@ -295,70 +269,62 @@ async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> R
|
||||
Err(e) => eprintln!("图片处理任务失败: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// 使用辅助函数批量插入图片并设置封面
|
||||
|
||||
batch_insert_images_and_set_cover(&db, images_to_insert, album_id, path, folder_name).await
|
||||
}
|
||||
|
||||
/// 快速扫描图片文件
|
||||
pub(crate) fn scan_image_files(path: &Path, _folder_name: &str) -> Result<Vec<(String, std::path::PathBuf)>, Box<dyn std::error::Error>> {
|
||||
let mut image_files = Vec::new();
|
||||
|
||||
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let image_path = entry.path();
|
||||
|
||||
|
||||
if image_path.is_file() {
|
||||
if let Some(extension) = image_path.extension().and_then(|s| s.to_str()) {
|
||||
let valid_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
|
||||
|
||||
|
||||
if valid_extensions.contains(&extension.to_lowercase().as_str()) {
|
||||
let filename = image_path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown.jpg")
|
||||
.to_string();
|
||||
|
||||
|
||||
image_files.push((filename, image_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按数字顺序排序文件名
|
||||
|
||||
image_files.sort_by(|a, b| {
|
||||
// 提取文件名中的数字部分进行排序
|
||||
let num_a = a.0.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
|
||||
|
||||
let num_b = b.0.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
|
||||
|
||||
num_a.cmp(&num_b)
|
||||
});
|
||||
|
||||
|
||||
Ok(image_files)
|
||||
}
|
||||
|
||||
/// 获取图片的实际信息(文件大小、宽度、高度)
|
||||
fn get_image_info(image_path: &Path) -> Result<(i64, i32, i32), Box<dyn std::error::Error>> {
|
||||
// 获取文件大小
|
||||
let metadata = fs::metadata(image_path)?;
|
||||
let file_size = metadata.len() as i64;
|
||||
|
||||
// 使用image库获取图片尺寸
|
||||
let img = image::open(image_path)?;
|
||||
|
||||
let img = ::image::open(image_path)?;
|
||||
let (width, height) = img.dimensions();
|
||||
|
||||
|
||||
Ok((file_size, width as i32, height as i32))
|
||||
}
|
||||
|
||||
/// 重新导入单个相册(使用现有的相册ID)
|
||||
async fn reimport_single_album(
|
||||
db: &Database,
|
||||
folder_name: &str,
|
||||
@@ -366,8 +332,7 @@ async fn reimport_single_album(
|
||||
album_id: Uuid,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
println!("📁 重新导入相册: {} (ID: {})", folder_name, album_id);
|
||||
|
||||
// 读取index.json文件
|
||||
|
||||
let index_file_path = path.join("index.json");
|
||||
let album_title = if index_file_path.exists() {
|
||||
match fs::read_to_string(&index_file_path) {
|
||||
@@ -382,27 +347,24 @@ async fn reimport_single_album(
|
||||
} else {
|
||||
folder_name.to_string()
|
||||
};
|
||||
|
||||
|
||||
println!(" 相册标题: {}", album_title);
|
||||
|
||||
// 扫描图片文件
|
||||
|
||||
let mut images_to_insert = Vec::new();
|
||||
let image_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
|
||||
|
||||
|
||||
let entries: Vec<_> = fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
|
||||
for entry in entries {
|
||||
let file_path = entry.path();
|
||||
if file_path.is_file() {
|
||||
if let Some(extension) = file_path.extension().and_then(|ext| ext.to_str()) {
|
||||
if image_extensions.contains(&extension.to_lowercase().as_str()) {
|
||||
if let Some(filename) = file_path.file_name().and_then(|s| s.to_str()) {
|
||||
// 跳过index.json文件
|
||||
if filename == "index.json" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取图片信息
|
||||
|
||||
let (file_size, width, height) = match get_image_info(&file_path) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
@@ -410,7 +372,7 @@ async fn reimport_single_album(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let image = CreateImage {
|
||||
album_id,
|
||||
filename: format!("{}/{}", folder_name, filename),
|
||||
@@ -429,18 +391,15 @@ async fn reimport_single_album(
|
||||
_ => "application/octet-stream".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
images_to_insert.push(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(" 发现 {} 张图片需要导入", images_to_insert.len());
|
||||
|
||||
// 使用辅助函数批量插入图片并设置封面
|
||||
batch_insert_images_and_set_cover(&db, images_to_insert, album_id, path, folder_name).await
|
||||
}
|
||||
|
||||
// 缩略图生成功能已移除
|
||||
println!(" 发现 {} 张图片需要导入", images_to_insert.len());
|
||||
|
||||
batch_insert_images_and_set_cover(&db, images_to_insert, album_id, path, folder_name).await
|
||||
}
|
||||
7
src/services/mod.rs
Normal file
7
src/services/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! 服务层 — 导入、处理等业务逻辑
|
||||
|
||||
pub mod import;
|
||||
pub mod search_export;
|
||||
|
||||
#[allow(unused)] pub use import::*;
|
||||
#[allow(unused)] pub use search_export::*;
|
||||
192
src/services/search_export.rs
Normal file
192
src/services/search_export.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use crate::db::Database;
|
||||
use crate::engine::title_parser;
|
||||
use crate::search_db::SearchDb;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct SearchExportResult {
|
||||
pub albums_processed: usize,
|
||||
pub tags_created: usize,
|
||||
pub persons_created: usize,
|
||||
pub aliases_created: usize,
|
||||
pub album_tags_created: usize,
|
||||
pub album_persons_created: usize,
|
||||
pub dict_matched_persons: usize,
|
||||
pub dict_matched_tags: usize,
|
||||
pub dict_resolved_aliases: usize,
|
||||
}
|
||||
|
||||
impl Default for SearchExportResult {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
albums_processed: 0,
|
||||
tags_created: 0,
|
||||
persons_created: 0,
|
||||
aliases_created: 0,
|
||||
album_tags_created: 0,
|
||||
album_persons_created: 0,
|
||||
dict_matched_persons: 0,
|
||||
dict_matched_tags: 0,
|
||||
dict_resolved_aliases: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn export_to_search_db(
|
||||
source_db: &Database,
|
||||
search_db_path: &str,
|
||||
clear_existing: bool,
|
||||
) -> Result<SearchExportResult, String> {
|
||||
let search_db = SearchDb::new(search_db_path).await.map_err(|e| format!("创建 search.db 失败: {}", e))?;
|
||||
|
||||
if clear_existing {
|
||||
search_db.clear_all().await.map_err(|e| format!("清空 search.db 失败: {}", e))?;
|
||||
println!("[搜索导出] 已清空 search.db 现有数据");
|
||||
}
|
||||
|
||||
// ── 加载 dict 表 ──
|
||||
let dict_entries = search_db
|
||||
.load_dict()
|
||||
.await
|
||||
.map_err(|e| format!("加载 dict 表失败: {}", e))?;
|
||||
|
||||
let person_count = dict_entries.iter().filter(|e| e.type_ == "person").count();
|
||||
let tag_count = dict_entries.iter().filter(|e| e.type_ == "tag").count();
|
||||
let alias_count = dict_entries.iter().filter(|e| e.merged_to.is_some()).count();
|
||||
|
||||
println!(
|
||||
"[搜索导出] dict 表加载完成: {} 条 (person={}, tag={}, 别名映射={})",
|
||||
dict_entries.len(),
|
||||
person_count,
|
||||
tag_count,
|
||||
alias_count,
|
||||
);
|
||||
|
||||
let albums = source_db.get_all_albums().await.map_err(|e| format!("读取相册列表失败: {}", e))?;
|
||||
println!("[搜索导出] 共读取 {} 个相册(来自 imager.db)", albums.len());
|
||||
|
||||
let mut tag_cache: HashMap<String, String> = HashMap::new();
|
||||
let mut person_cache: HashMap<String, String> = HashMap::new();
|
||||
let mut result = SearchExportResult::default();
|
||||
|
||||
for album in &albums {
|
||||
let album_id = album.id.to_string();
|
||||
let title = &album.title;
|
||||
|
||||
// ── 1. dict 优先解析(dict 优先匹配 → tag_dict 回退 → 合并) ──
|
||||
let parse_result = title_parser::parse_album_title_with_dict(&album_id, title, &dict_entries);
|
||||
|
||||
// 统计 dict 命中
|
||||
let dict_matched_person = parse_result.person_names.iter().any(|pn| {
|
||||
dict_entries.iter().any(|e| {
|
||||
e.type_ == "person"
|
||||
&& (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 ──
|
||||
for (tag_name, category) in &parse_result.tags {
|
||||
// 检查是否是 dict 命中的 tag
|
||||
let from_dict = dict_entries.iter().any(|e| {
|
||||
e.type_ == "tag" && e.word.to_lowercase() == tag_name.to_lowercase()
|
||||
});
|
||||
if from_dict {
|
||||
result.dict_matched_tags += 1;
|
||||
}
|
||||
|
||||
// 使用确定性 UUID,无需查数据库得到 id
|
||||
let tag_id = match tag_cache.get(tag_name) {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
let id = search_db
|
||||
.insert_tag(tag_name, Some(category))
|
||||
.await
|
||||
.map_err(|e| format!("插入标签 {} 失败: {}", tag_name, e))?;
|
||||
tag_cache.insert(tag_name.clone(), id.clone());
|
||||
result.tags_created += 1;
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
search_db
|
||||
.insert_album_tag(&album_id, &tag_id)
|
||||
.await
|
||||
.map_err(|e| format!("关联相册标签 {} → {} 失败: {}", album_id, tag_name, e))?;
|
||||
result.album_tags_created += 1;
|
||||
}
|
||||
|
||||
for person_name in &parse_result.person_names {
|
||||
// 检查是否有 merged_to 别名解析
|
||||
let resolved_from = dict_entries.iter().find(|e| {
|
||||
e.type_ == "person"
|
||||
&& e.merged_to.as_ref().map_or(false, |m| m.to_lowercase() == person_name.to_lowercase())
|
||||
});
|
||||
if resolved_from.is_some() {
|
||||
result.dict_resolved_aliases += 1;
|
||||
}
|
||||
|
||||
// 使用确定性 UUID,无需查数据库得到 id
|
||||
let person_id = match person_cache.get(person_name) {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
let id = search_db
|
||||
.insert_person(person_name, None)
|
||||
.await
|
||||
.map_err(|e| format!("插入人物 {} 失败: {}", person_name, e))?;
|
||||
person_cache.insert(person_name.clone(), id.clone());
|
||||
result.persons_created += 1;
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
search_db
|
||||
.insert_album_person(&album_id, &person_id)
|
||||
.await
|
||||
.map_err(|e| format!("关联相册人物 {} → {} 失败: {}", album_id, person_name, e))?;
|
||||
result.album_persons_created += 1;
|
||||
|
||||
// 同步别名
|
||||
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)
|
||||
.await
|
||||
.map_err(|e| format!("插入别名 {} → {} 失败: {}", person_name, primary, e))?;
|
||||
result.aliases_created += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.albums_processed += 1;
|
||||
|
||||
if result.albums_processed % 100 == 0 {
|
||||
println!("[搜索导出] 已处理 {} / {} 相册", result.albums_processed, albums.len());
|
||||
}
|
||||
}
|
||||
|
||||
println!("[搜索导出] 完成:");
|
||||
println!(" - 相册: {}", result.albums_processed);
|
||||
println!(" - 标签: {}", result.tags_created);
|
||||
println!(" - 人物: {}", result.persons_created);
|
||||
println!(" - 别名: {}", result.aliases_created);
|
||||
println!(" - 相册标签关联: {}", result.album_tags_created);
|
||||
println!(" - 相册人物关联: {}", result.album_persons_created);
|
||||
println!(" - dict 命中 person: {}", result.dict_matched_persons);
|
||||
println!(" - dict 命中 tag: {}", result.dict_matched_tags);
|
||||
println!(" - dict 别名解析: {}", result.dict_resolved_aliases);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
Reference in New Issue
Block a user