Merge remote-tracking branch 'origin/master'
# Conflicts: # .gitignore # Cargo.lock
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1 @@
|
||||
/target
|
||||
/albums
|
||||
/.idea
|
||||
/data/imager.db
|
||||
380
Cargo.lock
generated
380
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,14 @@ sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio", "migrate"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "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"
|
||||
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>
|
||||
|
||||
@@ -261,11 +261,143 @@ 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 = [];
|
||||
|
||||
// 添加人名标签
|
||||
if (currentAlbum.person_name_cleaned) {
|
||||
chips.push({
|
||||
text: currentAlbum.person_name_cleaned,
|
||||
type: 'person',
|
||||
category: 'person',
|
||||
query: currentAlbum.person_name_cleaned
|
||||
});
|
||||
}
|
||||
|
||||
// 添加标签(支持多种格式)
|
||||
if (currentAlbum.tags) {
|
||||
let tagList = [];
|
||||
try {
|
||||
const parsed = JSON.parse(currentAlbum.tags);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(item => {
|
||||
if (Array.isArray(item)) {
|
||||
tagList.push({ text: item[0], category: item[1] || 'default' });
|
||||
} else if (typeof item === 'string') {
|
||||
tagList.push({ text: item, category: getTagCategory(item) });
|
||||
} else if (item.tag || item.name) {
|
||||
const t = item.tag || item.name;
|
||||
const c = item.category || item.type || getTagCategory(t);
|
||||
tagList.push({ text: t, category: c });
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
currentAlbum.tags.split(',').forEach(tag => {
|
||||
const t = tag.trim();
|
||||
if (t) tagList.push({ text: t, category: getTagCategory(t) });
|
||||
});
|
||||
}
|
||||
|
||||
tagList.forEach(t => {
|
||||
chips.push({
|
||||
text: t.text,
|
||||
type: 'tag',
|
||||
category: t.category,
|
||||
query: t.text
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -311,7 +443,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 +451,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>` : ''}
|
||||
@@ -708,8 +841,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 +860,7 @@ function toggleFavorite(event, imageIndex) {
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
event.target.classList.remove('favorited');
|
||||
btn.classList.remove('favorited');
|
||||
showFavoriteToast('已取消收藏', false);
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -750,7 +887,7 @@ function toggleFavorite(event, imageIndex) {
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
event.target.classList.add('favorited');
|
||||
btn.classList.add('favorited');
|
||||
showFavoriteToast('已添加到收藏夹', true);
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -835,4 +972,5 @@ window.openLightbox = openLightbox;
|
||||
window.closeLightbox = closeLightbox;
|
||||
window.prevImage = prevImage;
|
||||
window.nextImage = nextImage;
|
||||
window.toggleFavorite = toggleFavorite;
|
||||
window.toggleFavorite = toggleFavorite;
|
||||
window.addToFavoritesFromLightbox = addToFavoritesFromLightbox;
|
||||
616
assets/js/app.js
616
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();
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,412 @@ 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 persons = await response.json();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示筛选状态
|
||||
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;
|
||||
561
dict/tags.json
Normal file
561
dict/tags.json
Normal file
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"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": {
|
||||
"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",
|
||||
"大笑": "mood",
|
||||
"闭眼": "mood",
|
||||
"凝视": "mood",
|
||||
"魅惑": "mood",
|
||||
"撩人": "mood",
|
||||
"高冷": "mood",
|
||||
"呆萌": "mood",
|
||||
"天真": "mood",
|
||||
"无辜": "mood",
|
||||
"纯真": "mood",
|
||||
"pure": "mood",
|
||||
"sweet": "mood",
|
||||
"fresh": "mood",
|
||||
"elegant": "mood",
|
||||
"cute": "mood",
|
||||
"wild": "mood",
|
||||
"lazy": "mood",
|
||||
"sweet": "mood",
|
||||
"写真": "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": [
|
||||
"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",
|
||||
"秀人", "网", "图", "套", "合集", "系列", "作品",
|
||||
"美女", "模特", "女神", "小姐姐", "高清", "原版", "无水印",
|
||||
"番号", "编号", "with", "without", "preview", "patreon",
|
||||
"no", "site", "web", "online", "digital", "exclusive", "premium",
|
||||
"classic", "photography", "shoot", "shot",
|
||||
"xxx", "com", "www", "https", "http",
|
||||
"Impact", "写真", "系列"
|
||||
|
||||
],
|
||||
"person_prefixes": [
|
||||
"美女模特", "性感女神", "清纯美女", "气质美女", "可爱女孩",
|
||||
"甜美少女", "时尚达人", "女神", "美女", "模特", "小姐姐",
|
||||
"小仙女", "御姐", "萝莉", "辣妹", "潮人"
|
||||
]
|
||||
}
|
||||
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
|
||||
);
|
||||
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()
|
||||
239
src/cache/mod.rs
vendored
Normal file
239
src/cache/mod.rs
vendored
Normal file
@@ -0,0 +1,239 @@
|
||||
//! 缓存层 — 内存缓存管理
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::*;
|
||||
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<i64>>,
|
||||
/// person_id → person 投影(用于按 id 快速访问)
|
||||
pub id_map: HashMap<i64, 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);
|
||||
}
|
||||
}
|
||||
self.search_index.entry(name_lower.clone()).or_default().push(p.id);
|
||||
// 显示名
|
||||
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);
|
||||
}
|
||||
}
|
||||
self.search_index.entry(dn_lower).or_default().push(p.id);
|
||||
}
|
||||
// 别名
|
||||
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);
|
||||
}
|
||||
}
|
||||
self.search_index.entry(alias_lower).or_default().push(p.id);
|
||||
}
|
||||
self.id_map.insert(p.id, 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) {}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen.iter()
|
||||
.filter_map(|id| self.id_map.get(id).cloned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新所有缓存(相册 + 标签 + 人物)
|
||||
pub async fn refresh_all_caches(
|
||||
db: &Database,
|
||||
albums_cache: &Arc<RwLock<AlbumsCache>>,
|
||||
persons_cache: &Arc<RwLock<PersonCache>>,
|
||||
tags_cache: &Arc<RwLock<TagCache>>,
|
||||
) {
|
||||
// 1. 刷新相册
|
||||
if let Ok(albums) = db.get_all_albums().await {
|
||||
let mut c = albums_cache.write().await;
|
||||
c.update(albums.clone());
|
||||
|
||||
// 2. 刷新标签
|
||||
let popular_tags = db.get_popular_tags(200).await;
|
||||
let tag_albums = db.get_all_tag_albums().await;
|
||||
let mut tc = tags_cache.write().await;
|
||||
tc.update(popular_tags, tag_albums);
|
||||
}
|
||||
|
||||
// 3. 刷新人物
|
||||
let persons = db.get_all_persons_with_aliases().await;
|
||||
let mut pc = persons_cache.write().await;
|
||||
pc.update(persons);
|
||||
|
||||
info!("全部缓存已刷新");
|
||||
}
|
||||
|
||||
/// 仅刷新人物缓存(管线执行后调用)
|
||||
pub async fn refresh_persons_cache(db: &Database, persons_cache: &Arc<RwLock<PersonCache>>) {
|
||||
let persons = db.get_all_persons_with_aliases().await;
|
||||
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)
|
||||
}
|
||||
}
|
||||
226
src/db/album.rs
Normal file
226
src/db/album.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
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"),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// 组合过滤搜索(懒加载):仅返回符合条件的相册,避免全量加载
|
||||
///
|
||||
/// 支持按 tag / person / 标题关键词 任意组合过滤(AND 关系)。
|
||||
/// 参数传空字符串或 None 表示不过滤该维度。
|
||||
/// 全参数为空时等价于 get_all_albums。
|
||||
pub async fn search_albums_with_filters(
|
||||
&self,
|
||||
tag: Option<&str>,
|
||||
person: Option<&str>,
|
||||
title_q: Option<&str>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
let has_tag = tag.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let has_person = person.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let has_q = title_q.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
if !has_tag && !has_person && !has_q {
|
||||
// 无过滤条件 → 走缓存路径,这里回退到全量查询
|
||||
return self.get_all_albums().await;
|
||||
}
|
||||
|
||||
// 动态构建 SQL
|
||||
let sql = {
|
||||
let mut s = String::from(
|
||||
"SELECT DISTINCT a.id, a.title, a.document, a.description, \
|
||||
a.cover_image_id, a.image_count, a.created_at \
|
||||
FROM albums a"
|
||||
);
|
||||
let mut joins = Vec::new();
|
||||
let mut wheres = Vec::new();
|
||||
|
||||
if has_tag {
|
||||
joins.push("JOIN album_tags t ON a.id = t.album_id");
|
||||
wheres.push("t.tag = ?");
|
||||
}
|
||||
if has_person {
|
||||
joins.push("JOIN person_albums pa ON a.id = pa.album_id");
|
||||
joins.push("JOIN persons p ON pa.person_id = p.id");
|
||||
wheres.push("(p.name = ? OR p.name LIKE ?)");
|
||||
}
|
||||
if has_q {
|
||||
wheres.push("a.title LIKE ?");
|
||||
}
|
||||
|
||||
for j in &joins {
|
||||
s.push(' ');
|
||||
s.push_str(j);
|
||||
}
|
||||
if !wheres.is_empty() {
|
||||
s.push_str(" WHERE ");
|
||||
s.push_str(&wheres.join(" AND "));
|
||||
}
|
||||
s.push_str(" ORDER BY a.created_at DESC");
|
||||
|
||||
if limit > 0 {
|
||||
s.push_str(" LIMIT ? OFFSET ?");
|
||||
}
|
||||
|
||||
s
|
||||
};
|
||||
|
||||
let mut q = sqlx::query(&sql);
|
||||
|
||||
if has_tag {
|
||||
q = q.bind(tag.unwrap());
|
||||
}
|
||||
if has_person {
|
||||
let p = person.unwrap();
|
||||
q = q.bind(p).bind(format!("%{}%", p));
|
||||
}
|
||||
if has_q {
|
||||
q = q.bind(format!("%{}%", title_q.unwrap()));
|
||||
}
|
||||
if limit > 0 {
|
||||
q = q.bind(limit).bind(offset);
|
||||
}
|
||||
|
||||
let rows = q.fetch_all(&self.pool).await?;
|
||||
Ok(rows.iter().map(|r| {
|
||||
AlbumInfo {
|
||||
id: Uuid::parse_str(&r.get::<String, _>("id")).unwrap_or_else(|_| Uuid::nil()),
|
||||
title: r.get("title"),
|
||||
document: r.get("document"),
|
||||
description: r.get("description"),
|
||||
cover_image_id: r.get("cover_image_id"),
|
||||
image_count: r.get("image_count"),
|
||||
created_at: r.get("created_at"),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn get_album_with_images(&self, album_id: Uuid) -> Result<Option<AlbumWithImages>, sqlx::Error> {
|
||||
let row = sqlx::query("SELECT * FROM albums WHERE id = ?")
|
||||
.bind(album_id.to_string())
|
||||
.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"),
|
||||
person_name_cleaned: row.get("person_name_cleaned"),
|
||||
tags: row.get("tags"),
|
||||
};
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
pub async fn get_all_albums_full(&self) -> Vec<AlbumMeta> {
|
||||
let has_col = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('albums') WHERE name = 'person_name_original'"
|
||||
).fetch_one(&self.pool).await.unwrap_or(0);
|
||||
if has_col == 0 { return Vec::new(); }
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT id, title, person_name_original, person_name_cleaned, tags, all_words, raw_title, processed_at, image_count
|
||||
FROM albums ORDER BY created_at DESC"#
|
||||
).fetch_all(&self.pool).await.unwrap_or_default();
|
||||
|
||||
rows.iter().map(|r| AlbumMeta {
|
||||
id: r.get("id"), title: r.get("title"),
|
||||
person_name_original: r.get("person_name_original"),
|
||||
person_name_cleaned: r.get("person_name_cleaned"),
|
||||
tags: r.get("tags"), all_words: r.get("all_words"),
|
||||
raw_title: r.get("raw_title"), processed_at: r.get("processed_at"),
|
||||
image_count: r.get("image_count"),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
// ── 写入 ──
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn delete_album(&self, album_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
let _guard = self.write_lock.lock().await;
|
||||
sqlx::query("DELETE FROM albums WHERE id = ?").bind(album_id.to_string()).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
91
src/db/batch.rs
Normal file
91
src/db/batch.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! 批量写入 — 在单个事务中写入多个相册的解析结果
|
||||
//!
|
||||
//! 配合 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;
|
||||
let person_name = result.person_name.as_deref();
|
||||
|
||||
if let Some(name) = person_name {
|
||||
// ── update 相册 person 字段 ──
|
||||
sqlx::query(
|
||||
"UPDATE albums SET person_name_original = ?, person_name_cleaned = ?, processed_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(name).bind(name).bind(album_id)
|
||||
.execute(&mut **tx).await?;
|
||||
|
||||
// ── 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("DELETE FROM person_albums WHERE album_id = ?")
|
||||
.bind(album_id).execute(&mut **tx).await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)")
|
||||
.bind(pid).bind(album_id).execute(&mut **tx).await?;
|
||||
|
||||
// ── 更新计数 ──
|
||||
sqlx::query(
|
||||
"UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?), \
|
||||
total_images = COALESCE((SELECT SUM(image_count) FROM albums \
|
||||
JOIN person_albums ON albums.id = person_albums.album_id \
|
||||
WHERE person_albums.person_id = ?), 0) \
|
||||
WHERE id = ?"
|
||||
)
|
||||
.bind(pid).bind(pid).bind(pid)
|
||||
.execute(&mut **tx).await?;
|
||||
}
|
||||
|
||||
// ── 写标签 ──
|
||||
if !result.tags.is_empty() {
|
||||
let tags_json = serde_json::to_string(
|
||||
&result.tags.iter().map(|(t, _)| t).collect::<Vec<_>>()
|
||||
).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words: Vec<[String; 2]> = result.tags.iter()
|
||||
.map(|(t, c)| [t.clone(), c.clone()])
|
||||
.collect();
|
||||
let words_json = serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE albums SET tags = ?, all_words = ? WHERE id = ?"
|
||||
)
|
||||
.bind(&tags_json).bind(&words_json).bind(album_id)
|
||||
.execute(&mut **tx).await?;
|
||||
|
||||
// ── 同步 album_tags 表 ──
|
||||
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"),
|
||||
}).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, 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, 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"),
|
||||
};
|
||||
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"),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
// ── 统计 ──
|
||||
|
||||
pub async fn get_metadata_stats(&self) -> MetadataStats {
|
||||
MetadataStats {
|
||||
total_persons: sqlx::query_scalar("SELECT COUNT(*) FROM persons").fetch_one(&self.pool).await.unwrap_or(0),
|
||||
total_aliases: sqlx::query_scalar("SELECT COUNT(*) FROM person_aliases").fetch_one(&self.pool).await.unwrap_or(0),
|
||||
total_albums_with_person: sqlx::query_scalar("SELECT COUNT(*) FROM albums WHERE person_name_original IS NOT NULL AND person_name_original != ''").fetch_one(&self.pool).await.unwrap_or(0),
|
||||
total_albums_without_person: sqlx::query_scalar("SELECT COUNT(*) FROM albums WHERE person_name_original IS NULL OR person_name_original = ''").fetch_one(&self.pool).await.unwrap_or(0),
|
||||
total_tags: sqlx::query_scalar("SELECT COUNT(*) FROM album_tags").fetch_one(&self.pool).await.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 写入 ──
|
||||
|
||||
pub async fn insert_person(&self, name: &str, display_name: Option<&str>) -> i64 {
|
||||
if let Some(p) = self.get_person_by_name(name).await { return p.id; }
|
||||
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(name).await.map(|p| p.id).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 与 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) {
|
||||
// 整个合并过程是一个原子操作,用事务包裹避免逐条 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(&self.pool).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()??;
|
||||
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 update_album_person(&self, album_id: &str, person_name_original: &str, person_name_cleaned: &str) {
|
||||
sqlx::query("UPDATE albums SET person_name_original = ?, person_name_cleaned = ?, processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(person_name_original).bind(person_name_cleaned).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 person_name_original = ?, person_name_cleaned = ?, processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(person_name_original).bind(person_name_cleaned).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 person_name_original = NULL, person_name_cleaned = NULL, \
|
||||
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 tags = ?, all_words = ?, processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(tags_json).bind(all_words_json).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 tags = ?, all_words = ?, processed_at = datetime('now') WHERE id = ?")
|
||||
.bind(tags_json).bind(all_words_json).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())
|
||||
}
|
||||
}
|
||||
17
src/engine/mod.rs
Normal file
17
src/engine/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! 核心引擎层 — 标题解析、人名匹配、标签提取、推荐计算
|
||||
|
||||
pub mod title_parser;
|
||||
pub mod person_merger;
|
||||
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 person_merger::*;
|
||||
#[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::*;
|
||||
434
src/engine/person_merger.rs
Normal file
434
src/engine/person_merger.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
//! 人名匹配与合并
|
||||
//! 对应 Python 的 match_person_from_title.py + merge_persons_by_core.py
|
||||
//! 所有函数均为 async,因为底层 Database 操作是异步的
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::engine::title_parser::{extract_cn, is_excluded_name};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// 匹配模式
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pattern {
|
||||
pub text: String,
|
||||
pub person_name: String,
|
||||
pub source: String, // "name" | "alias"
|
||||
pub length: usize,
|
||||
}
|
||||
|
||||
/// 匹配结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchResult {
|
||||
pub matched: bool,
|
||||
pub method: String,
|
||||
pub person: Option<String>,
|
||||
pub matched_text: Option<String>,
|
||||
pub all_persons: Vec<String>,
|
||||
pub multi_person: bool,
|
||||
}
|
||||
|
||||
/// 深度匹配结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeepMatchResult {
|
||||
pub matched: bool,
|
||||
pub method: String, // "jieba_extract" | "exact_name_match" | "alias_fuzzy_match"
|
||||
pub person: Option<String>,
|
||||
pub matched_text: Option<String>,
|
||||
pub all_persons: Vec<String>,
|
||||
pub multi_person: bool,
|
||||
}
|
||||
|
||||
// ── Pass1: 收集模式 ──
|
||||
|
||||
/// 收集所有人名和别名,构建匹配模式列表
|
||||
/// 按长度降序排列,确保长名优先匹配
|
||||
pub async fn collect_person_patterns(db: &Database) -> Vec<Pattern> {
|
||||
let mut patterns = Vec::new();
|
||||
|
||||
// 1. 收集所有人名
|
||||
let rows = db.get_all_person_names().await;
|
||||
for (name, _albums) in &rows {
|
||||
if is_excluded_name(name) {
|
||||
continue;
|
||||
}
|
||||
patterns.push(Pattern {
|
||||
text: name.clone(),
|
||||
person_name: name.clone(),
|
||||
source: "name".to_string(),
|
||||
length: name.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 收集所有别名
|
||||
let aliases = db.get_all_aliases().await;
|
||||
for (alias_name, person_name) in &aliases {
|
||||
if is_excluded_name(person_name) || is_excluded_name(alias_name) {
|
||||
continue;
|
||||
}
|
||||
if alias_name != person_name {
|
||||
patterns.push(Pattern {
|
||||
text: alias_name.clone(),
|
||||
person_name: person_name.clone(),
|
||||
source: "alias".to_string(),
|
||||
length: alias_name.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 去重
|
||||
let mut best_map: HashMap<String, Pattern> = HashMap::new();
|
||||
for p in patterns {
|
||||
let key = p.text.to_lowercase();
|
||||
best_map.entry(key).or_insert(p);
|
||||
}
|
||||
let mut patterns: Vec<Pattern> = best_map.into_values().collect();
|
||||
|
||||
// 4. 按长度降序排列
|
||||
patterns.sort_by(|a, b| b.length.cmp(&a.length));
|
||||
|
||||
patterns
|
||||
}
|
||||
|
||||
/// Pass1: 简单子串匹配
|
||||
pub fn match_title(title: &str, patterns: &[Pattern]) -> Option<(String, String, String)> {
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
for p in patterns {
|
||||
if p.text.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if title.contains(&p.text) {
|
||||
return Some((p.text.clone(), p.person_name.clone(), p.source.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Pass2: 深度匹配(多重策略)
|
||||
pub fn deep_match_title(
|
||||
title: &str,
|
||||
known_names: &HashSet<String>,
|
||||
known_aliases: &HashSet<String>,
|
||||
) -> DeepMatchResult {
|
||||
let mut result = DeepMatchResult {
|
||||
matched: false,
|
||||
method: String::new(),
|
||||
person: None,
|
||||
matched_text: None,
|
||||
all_persons: Vec::new(),
|
||||
multi_person: false,
|
||||
};
|
||||
|
||||
if title.is_empty() {
|
||||
return result;
|
||||
}
|
||||
|
||||
let title_clean = title.trim();
|
||||
|
||||
// 策略1: 用正则提取人名
|
||||
let extracted = crate::engine::title_parser::extract_person_name_from_title(title_clean);
|
||||
if let Some(ref e) = extracted {
|
||||
if known_names.contains(e) {
|
||||
result.matched = true;
|
||||
result.method = "jieba_extract".to_string();
|
||||
result.person = Some(e.clone());
|
||||
result.matched_text = Some(e.clone());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2: 标题中精确子串匹配已知人名(长名优先)
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
let mut sorted_names: Vec<&String> = known_names.iter().collect();
|
||||
sorted_names.sort_by(|a, b| b.len().cmp(&a.len()));
|
||||
|
||||
for name in &sorted_names {
|
||||
if name.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if title_clean.contains(name.as_str()) {
|
||||
found.push(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !found.is_empty() {
|
||||
found.sort_by(|a, b| b.len().cmp(&a.len()));
|
||||
let best = found[0].clone();
|
||||
result.matched = true;
|
||||
result.method = "exact_name_match".to_string();
|
||||
result.person = Some(best.clone());
|
||||
result.matched_text = Some(best);
|
||||
result.all_persons = found.clone();
|
||||
result.multi_person = found.len() > 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
// 策略3: 别名模糊匹配
|
||||
let title_lower = title_clean.to_lowercase();
|
||||
let mut sorted_aliases: Vec<&String> = known_aliases.iter().collect();
|
||||
sorted_aliases.sort_by(|a, b| b.len().cmp(&a.len()));
|
||||
|
||||
for alias in &sorted_aliases {
|
||||
if alias.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if title_lower.contains(alias.to_lowercase().as_str()) {
|
||||
result.matched = true;
|
||||
result.method = "alias_fuzzy_match".to_string();
|
||||
result.person = Some(alias.to_string());
|
||||
result.matched_text = Some(alias.to_string());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 收集所有已知人名和别名
|
||||
pub async fn collect_known_names(db: &Database) -> (HashSet<String>, HashSet<String>) {
|
||||
let mut known_names = HashSet::new();
|
||||
let mut known_aliases = HashSet::new();
|
||||
|
||||
for (name, _) in db.get_all_person_names().await {
|
||||
known_names.insert(name);
|
||||
}
|
||||
for (alias_name, _) in db.get_all_aliases().await {
|
||||
known_aliases.insert(alias_name);
|
||||
}
|
||||
|
||||
(known_names, known_aliases)
|
||||
}
|
||||
|
||||
/// 检测含多个人名的相册
|
||||
pub async fn detect_multi_person_titles(
|
||||
db: &Database,
|
||||
known_names: Option<&HashSet<String>>,
|
||||
) -> Vec<(String, String, Vec<String>)> {
|
||||
let names = match known_names {
|
||||
Some(n) => n.clone(),
|
||||
None => collect_known_names(db).await.0,
|
||||
};
|
||||
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut multi_person = Vec::new();
|
||||
|
||||
for album in &albums {
|
||||
let title = album.title.as_deref().unwrap_or("");
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
let mut sorted_names: Vec<&String> = names.iter().collect();
|
||||
sorted_names.sort_by(|a, b| b.len().cmp(&a.len()));
|
||||
|
||||
for name in &sorted_names {
|
||||
if name.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if title.contains(name.as_str()) {
|
||||
found.push(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if found.len() > 1 {
|
||||
multi_person.push((
|
||||
album.id.clone(),
|
||||
title.to_string(),
|
||||
found,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
multi_person
|
||||
}
|
||||
|
||||
// ── 中文核心合并分析 ──
|
||||
|
||||
/// 分析所有人名的中文核心分组
|
||||
/// 返回: {core: [(id, name, albums, images), ...]}
|
||||
pub async fn analyze_groups(db: &Database) -> HashMap<String, Vec<(i64, String, i64, i64)>> {
|
||||
let persons = db.get_all_persons_with_counts().await;
|
||||
let mut groups: HashMap<String, Vec<(i64, String, i64, i64)>> = HashMap::new();
|
||||
|
||||
for (pid, name, albums, images) in persons {
|
||||
if let Some(cn) = extract_cn(&name) {
|
||||
if cn.len() >= 2 {
|
||||
groups.entry(cn).or_default().push((pid, name, albums, images));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for items in groups.values_mut() {
|
||||
items.sort_by(|a, b| b.2.cmp(&a.2));
|
||||
}
|
||||
|
||||
groups.retain(|_, v| v.len() >= 2);
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
/// 合并结果统计
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MergeResult {
|
||||
pub groups_analyzed: usize,
|
||||
pub persons_merged: usize,
|
||||
pub aliases_moved: usize,
|
||||
}
|
||||
|
||||
/// 执行合并:扫描所有可合并的人物组,合并到相册数最多的那个
|
||||
/// 返回合并统计
|
||||
pub async fn execute_merge(db: &Database) -> MergeResult {
|
||||
let groups = analyze_groups(db).await;
|
||||
let mut result = MergeResult::default();
|
||||
result.groups_analyzed = groups.len();
|
||||
|
||||
for (_core, items) in &groups {
|
||||
if items.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 第一个(相册数最多)作为目标
|
||||
let dst_id = items[0].0;
|
||||
let dst_name = &items[0].1;
|
||||
|
||||
// 其余的合并到目标
|
||||
for (src_id, src_name, _, _) in &items[1..] {
|
||||
println!("[合并] {} (id={}) → {} (id={})", src_name, src_id, dst_name, dst_id);
|
||||
|
||||
// 合并前统计 src 的别名数
|
||||
let src_aliases = db.get_person_aliases(*src_id).await;
|
||||
let alias_count_before = src_aliases.len();
|
||||
|
||||
db.merge_person_into(*src_id, dst_id).await;
|
||||
|
||||
// 合并后统计新增的别名数 (= src 别名数 + src 原名)
|
||||
result.persons_merged += 1;
|
||||
// src 原名 + src 的 display_name + src 原有的别名
|
||||
let moved = alias_count_before + 1; // +1 因为原名会成为 merged_original 别名
|
||||
result.aliases_moved += moved;
|
||||
}
|
||||
}
|
||||
|
||||
if result.persons_merged > 0 {
|
||||
println!("[合并] 完成:分析 {} 组,合并 {} 个人物,迁移 {} 个别名",
|
||||
result.groups_analyzed, result.persons_merged, result.aliases_moved);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 统计匹配分析
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchStats {
|
||||
pub total_albums: usize,
|
||||
pub matched: usize,
|
||||
pub unmatched: usize,
|
||||
pub already_set: usize,
|
||||
pub changed: usize,
|
||||
pub by_source: HashMap<String, usize>,
|
||||
pub name_frequency: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
/// 执行 Pass1 匹配分析
|
||||
pub async fn analyze_matches(db: &Database, patterns: &[Pattern]) -> MatchStats {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut stats = MatchStats {
|
||||
total_albums: albums.len(),
|
||||
matched: 0,
|
||||
unmatched: 0,
|
||||
already_set: 0,
|
||||
changed: 0,
|
||||
by_source: HashMap::new(),
|
||||
name_frequency: HashMap::new(),
|
||||
};
|
||||
|
||||
for album in &albums {
|
||||
let title = album.title.as_deref().unwrap_or("");
|
||||
let current = album.person_name_original.as_deref().unwrap_or("");
|
||||
|
||||
if let Some((_text, person, source)) = match_title(title, patterns) {
|
||||
*stats.by_source.entry(source).or_insert(0) += 1;
|
||||
*stats.name_frequency.entry(person.clone()).or_insert(0) += 1;
|
||||
|
||||
if current.is_empty() {
|
||||
stats.matched += 1;
|
||||
} else if current != person {
|
||||
stats.changed += 1;
|
||||
} else {
|
||||
stats.already_set += 1;
|
||||
}
|
||||
} else {
|
||||
stats.unmatched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
/// 深度匹配分析统计
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeepMatchStats {
|
||||
pub total_albums: usize,
|
||||
pub pass1_matched: usize,
|
||||
pub pass1_unmatched: usize,
|
||||
pub pass2_jieba_match: usize,
|
||||
pub pass2_exact_match: usize,
|
||||
pub pass2_alias_match: usize,
|
||||
pub pass2_still_unmatched: usize,
|
||||
pub pass2_multi_person: usize,
|
||||
}
|
||||
|
||||
/// 执行 Pass2 深度匹配分析
|
||||
pub async fn analyze_deep_matches(db: &Database) -> DeepMatchStats {
|
||||
let (known_names, known_aliases) = collect_known_names(db).await;
|
||||
let albums = db.get_all_albums_full().await;
|
||||
|
||||
let mut stats = DeepMatchStats {
|
||||
total_albums: albums.len(),
|
||||
pass1_matched: 0,
|
||||
pass1_unmatched: 0,
|
||||
pass2_jieba_match: 0,
|
||||
pass2_exact_match: 0,
|
||||
pass2_alias_match: 0,
|
||||
pass2_still_unmatched: 0,
|
||||
pass2_multi_person: 0,
|
||||
};
|
||||
|
||||
for album in &albums {
|
||||
let title = album.title.as_deref().unwrap_or("");
|
||||
let current = album.person_name_original.as_deref().unwrap_or("");
|
||||
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !current.is_empty() && known_names.contains(current) {
|
||||
stats.pass1_matched += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.pass1_unmatched += 1;
|
||||
|
||||
let result = deep_match_title(title, &known_names, &known_aliases);
|
||||
if !result.matched {
|
||||
stats.pass2_still_unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match result.method.as_str() {
|
||||
"jieba_extract" => stats.pass2_jieba_match += 1,
|
||||
"exact_name_match" => stats.pass2_exact_match += 1,
|
||||
"alias_fuzzy_match" => stats.pass2_alias_match += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if result.multi_person {
|
||||
stats.pass2_multi_person += 1;
|
||||
}
|
||||
}
|
||||
|
||||
stats
|
||||
}
|
||||
514
src/engine/pipeline.rs
Normal file
514
src/engine/pipeline.rs
Normal file
@@ -0,0 +1,514 @@
|
||||
//! 处理管线 — 批量处理相册元数据
|
||||
//!
|
||||
//! 核心架构:脏集追踪 + 增量解析
|
||||
//!
|
||||
//! Phase 0 清洗标签标点
|
||||
//! Phase A1 检测标签中的人名并直接提取到 person 字段 → 加入脏集
|
||||
//! Phase A2 清理非人物 → 加入脏集
|
||||
//! Phase A3 收集新相册/半成品 → 加入脏集
|
||||
//! Phase 1 解析脏集相册(增量解析,不扫全库)
|
||||
//! Phase C 全量重计所有 person 的相册计数
|
||||
//! Phase 2 人物合并
|
||||
//! Phase B 后校验标签中的人名
|
||||
//!
|
||||
//! 脏集(DirtySet)贯穿各阶段,每个阶段发现的"需要重新解析"的相册都会加入脏集,
|
||||
//! 最终只处理脏集里的相册,而非全库扫描,大幅提升效率。
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::engine::title_parser;
|
||||
use crate::engine::tag_extractor;
|
||||
use crate::engine::person_merger;
|
||||
use crate::engine::tag_dict;
|
||||
use std::collections::HashSet;
|
||||
use rayon::prelude::*;
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// 脏集 — 追踪需要重新解析的相册 ID
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// 脏集:记录哪些相册需要重新解析
|
||||
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 }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase 0:清洗已有标签中的头尾标点
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_clean_tags(db: &Database) -> usize {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut cleaned_count = 0usize;
|
||||
let mut removed_tags = HashSet::new();
|
||||
|
||||
for album in &albums {
|
||||
let tags_json = match album.tags.as_deref() {
|
||||
Some(t) if !t.is_empty() && t != "[]" => t,
|
||||
_ => continue,
|
||||
};
|
||||
let old_tags: Vec<String> = match serde_json::from_str(tags_json) {
|
||||
Ok(t) => t, Err(_) => continue,
|
||||
};
|
||||
if old_tags.is_empty() { continue; }
|
||||
|
||||
let mut changed = false;
|
||||
let mut new_tags = Vec::new();
|
||||
for tag in old_tags {
|
||||
let cleaned = match tag_extractor::clean_tag(&tag) {
|
||||
Some(t) => t,
|
||||
None => { removed_tags.insert(tag); changed = true; continue; }
|
||||
};
|
||||
if cleaned != tag { removed_tags.insert(tag); changed = true; }
|
||||
new_tags.push(cleaned);
|
||||
}
|
||||
if !changed { continue; }
|
||||
cleaned_count += 1;
|
||||
|
||||
let tags_json_new = serde_json::to_string(&new_tags).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words_json = album.all_words.as_deref().unwrap_or("[]");
|
||||
let all_words_new: Vec<[String; 2]> = serde_json::from_str::<Vec<[String; 2]>>(all_words_json)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|pair| { let cleaned = tag_extractor::clean_tag(&pair[0])?; Some([cleaned, pair[1].clone()]) })
|
||||
.collect();
|
||||
let all_words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json_new, &all_words_json_new).await;
|
||||
}
|
||||
|
||||
if cleaned_count > 0 {
|
||||
println!("[管线·Phase0] 标签清洗完成:清洗 {} 个相册,移除标签: {:?}",
|
||||
cleaned_count, removed_tags.iter().collect::<Vec<_>>());
|
||||
} else {
|
||||
println!("[管线·Phase0] 标签清洗完成:无需要清洗的标签");
|
||||
}
|
||||
cleaned_count
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A1:检测标签中的人名并直接提取到 person 字段
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// Phase A1:检测标签中可能的人名并直接提取到 person_name
|
||||
///
|
||||
/// 核心变化:不再只标记脏集等待 Phase 1,而是直接从 tags 中提取人名写入 person 字段。
|
||||
/// 因为 tags 中的人名比从 title 重解析更直接、更准确。
|
||||
///
|
||||
/// 两层检测 + 最终写入:
|
||||
/// 1. 逐个标签 — 标签是已知人物或像人名 → 收集为候选人名(中文加权)
|
||||
/// 2. jieba 合并(空格分隔)— 发现跨标签拆分的人名(["杨晨","晨Yome"])
|
||||
/// 3. 最佳候选人名 → 写入 person_name,从标签中移除关联标签
|
||||
///
|
||||
/// 返回 (脏集, 从标签中移除的人名标签数)
|
||||
pub async fn phase_detect_person_in_tags(db: &Database) -> (DirtySet, usize) {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut person_like_removed = 0usize;
|
||||
|
||||
let known_persons: HashSet<String> = db.get_all_person_names().await
|
||||
.into_iter().map(|(n, _)| n.to_lowercase()).collect();
|
||||
|
||||
for album in &albums {
|
||||
let tags_json = match album.tags.as_deref() {
|
||||
Some(t) if !t.is_empty() && t != "[]" => t,
|
||||
_ => continue,
|
||||
};
|
||||
let old_tags: Vec<String> = match serde_json::from_str(tags_json) {
|
||||
Ok(t) => t, Err(_) => continue,
|
||||
};
|
||||
|
||||
let has_person = album.person_name_original.as_deref()
|
||||
.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
|
||||
// 快速判断是否需要处理
|
||||
let has_known_tag = old_tags.iter().any(|t| known_persons.contains(&t.to_lowercase()));
|
||||
let has_like_tag = !has_person && old_tags.iter().any(|t|
|
||||
title_parser::is_likely_person_name(t) && !title_parser::is_excluded_name(t));
|
||||
if has_person && !has_known_tag && !has_like_tag { continue; }
|
||||
|
||||
// ── 第一层:逐个标签收集候选人名 ──
|
||||
let mut known_person_found: Option<String> = None;
|
||||
let mut person_like_tags: Vec<String> = Vec::new();
|
||||
|
||||
for tag in &old_tags {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
if known_persons.contains(&tag_lower) {
|
||||
known_person_found = Some(tag.clone());
|
||||
continue;
|
||||
}
|
||||
if !has_person && title_parser::is_likely_person_name(tag) && !title_parser::is_excluded_name(tag) {
|
||||
person_like_tags.push(tag.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 从 person_like_tags 中选最优(中文加权)
|
||||
let best_layer1 = person_like_tags.into_iter().max_by_key(|t| {
|
||||
if let Some(cn) = title_parser::extract_cn(t) {
|
||||
if cn.len() >= 2 { cn.len() * 10 + t.len() } else { t.len() }
|
||||
} else { t.len() }
|
||||
});
|
||||
|
||||
// ── 第二层:空格连接所有标签,用 jieba 提取人名 ──
|
||||
let combined = old_tags.join(" ");
|
||||
let (jieba_name, _) = title_parser::extract_person_and_tags(&combined);
|
||||
|
||||
// ── 确定最终人名 ──
|
||||
let final_person = known_person_found.or_else(|| {
|
||||
match (&jieba_name, &best_layer1) {
|
||||
(Some(j), Some(b)) => {
|
||||
// jieba 结果更完整 → 用 jieba
|
||||
let j_len: usize = j.len();
|
||||
let b_len: usize = b.len();
|
||||
if j_len > b_len || title_parser::is_excluded_name(b) { Some(j.clone()) } else { Some(b.clone()) }
|
||||
}
|
||||
(Some(j), None) => Some(j.clone()),
|
||||
(None, Some(b)) => Some(b.clone()),
|
||||
(None, None) => None,
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(ref person_name) = final_person {
|
||||
// ── 从标签中移除所有人名相关标签 ──
|
||||
let mut changed = false;
|
||||
let mut clean_tags: Vec<String> = Vec::new();
|
||||
let person_lower = person_name.to_lowercase();
|
||||
let (primary_cleaned, _) = title_parser::split_person_name_with_aliases(person_name);
|
||||
let primary_lower = primary_cleaned.to_lowercase();
|
||||
|
||||
for tag in &old_tags {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let should_remove =
|
||||
tag_lower == person_lower // 完全匹配
|
||||
|| tag_lower == primary_lower // 中文核心匹配
|
||||
|| (tag.len() >= 2 && tag.chars().all(|c| c.is_ascii_alphabetic())
|
||||
&& !tag_lower.starts_with("cos")
|
||||
&& person_lower.contains(&tag_lower)) // 英文后缀
|
||||
|| (tag_lower.len() >= 2 && person_lower.contains(&tag_lower)); // 人名包含
|
||||
|
||||
if should_remove {
|
||||
person_like_removed += 1;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
clean_tags.push(tag.clone());
|
||||
}
|
||||
|
||||
if changed || !has_person {
|
||||
// ── 只清理标签中的人名,不写入 person 字段 ──
|
||||
// 人名提取统一由 Phase 1 从标题解析完成,保证一个相册只有一个 pid
|
||||
let tags_json_new = serde_json::to_string(&clean_tags).unwrap_or_else(|_| "[]".to_string());
|
||||
let all_words_json = album.all_words.as_deref().unwrap_or("[]");
|
||||
let all_words_new: Vec<[String; 2]> = serde_json::from_str::<Vec<[String; 2]>>(all_words_json)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|pair| !known_persons.contains(&pair[0].to_lowercase()))
|
||||
.collect();
|
||||
let words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json_new, &words_json_new).await;
|
||||
|
||||
dirty.add(&album.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if person_like_removed > 0 {
|
||||
println!("[管线·PhaseA1] 从标签中检测并提取人名:移除 {} 个人名标签,写入 {} 个相册的 person 字段",
|
||||
person_like_removed, dirty.len());
|
||||
} else {
|
||||
println!("[管线·PhaseA1] 检测标签中的人名:无异常");
|
||||
}
|
||||
(dirty, person_like_removed)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A2:清理 persons 表中的非人物
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_clean_persons(db: &Database) -> (DirtySet, usize) {
|
||||
let persons = db.get_all_persons_with_counts().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut deleted = 0usize;
|
||||
|
||||
for (pid, name, _albums, _images) in &persons {
|
||||
if title_parser::is_excluded_name(name) || !title_parser::is_likely_person_name(name) {
|
||||
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); }
|
||||
println!("[管线·PhaseA2] 删除非人物:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
|
||||
db.update_album_person_by_person_id(*pid).await;
|
||||
db.delete_person_completely(*pid).await;
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
println!("[管线·PhaseA2] 清理完成:删除 {} 个非人物,标记 {} 个脏相册", deleted, dirty.len());
|
||||
} else {
|
||||
println!("[管线·PhaseA2] 清理完成:无非人物需要删除");
|
||||
}
|
||||
(dirty, deleted)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A2B:清理 persons 表中与停用词重叠的人物
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// Phase A2B:检查 persons 表中的人名是否在停用词词典中。
|
||||
///
|
||||
/// 当用户在词典中添加了新的停用词,而这些词恰好已被作为人物创建时,
|
||||
/// 需要删除该人物并释放对应的相册,让它们在后续 Phase 1 中重新解析。
|
||||
/// 这样可以防止停用词(如 "photo"、"model" 等通用词)被错误识别为人物。
|
||||
pub async fn phase_clean_stop_word_persons(db: &Database) -> (DirtySet, usize) {
|
||||
let persons = db.get_all_persons_with_counts().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut deleted = 0usize;
|
||||
|
||||
for (pid, name, _albums, _images) in &persons {
|
||||
// 使用 tag_dict 的 is_excluded_person 进行判断(含包含匹配)
|
||||
let is_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);
|
||||
|
||||
if is_excluded || cn_check {
|
||||
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); }
|
||||
println!("[管线·PhaseA2B] 排除人名命中:{} (id={}),释放 {} 个相册", name, pid, album_ids.len());
|
||||
// 清空相册的 person 字段
|
||||
db.update_album_person_by_person_id(*pid).await;
|
||||
// 彻底删除该人物
|
||||
db.delete_person_completely(*pid).await;
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
println!("[管线·PhaseA2B] 清理完成:移除 {} 个被排除的人物,标记 {} 个脏相册", deleted, dirty.len());
|
||||
} else {
|
||||
println!("[管线·PhaseA2B] 清理完成:无被排除的人物");
|
||||
}
|
||||
(dirty, deleted)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase A3:收集新相册 + 半成品
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_collect_new_albums(db: &Database) -> DirtySet {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let mut dirty = DirtySet::new();
|
||||
let mut new_count = 0usize;
|
||||
let mut partial_count = 0usize;
|
||||
|
||||
for album in &albums {
|
||||
let tags_empty = album.tags.as_deref()
|
||||
.map(|t| t.is_empty() || t == "[]" || t == "null").unwrap_or(true);
|
||||
if tags_empty { dirty.add(&album.id); new_count += 1; continue; }
|
||||
|
||||
let has_person = album.person_name_original.as_deref()
|
||||
.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
if !has_person { dirty.add(&album.id); partial_count += 1; }
|
||||
}
|
||||
|
||||
println!("[管线·PhaseA3] 收集相册:{} 个全新,{} 个半成品,脏集合计 {} 个", new_count, partial_count, dirty.len());
|
||||
dirty
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase 1:增量解析 — 只处理脏集中的相册
|
||||
// ┌─ Phase 1a:并行解析(CPU 密集,rayon par_iter)
|
||||
// └─ Phase 1b:批量写库(IO 密集,一个事务)
|
||||
|
||||
pub async fn phase_parse_dirty(db: &Database, dirty_set: &DirtySet) -> (usize, usize) {
|
||||
if dirty_set.is_empty() {
|
||||
println!("[管线·Phase1] 增量解析跳过:脏集为空");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let albums = db.get_all_albums_full().await;
|
||||
|
||||
// ── Phase 1a:并行解析(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; }
|
||||
Some(title_parser::parse_album_title(&album.id, title))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let person_count = parsed.iter().filter(|r| r.person_name.is_some()).count();
|
||||
let tag_count = parsed.iter().filter(|r| !r.tags.is_empty()).count();
|
||||
|
||||
// ── Phase 1b:批量写入 ──
|
||||
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!("[管线·Phase1b] 批量写入失败: {}", e);
|
||||
});
|
||||
tx.commit().await.expect("commit tx");
|
||||
|
||||
println!("[管线·Phase1] 并行解析+批量写入完成: 脏集 {} 个, 有效 {} 个 (跳过 {} 无标题), 更新人名 {} 个, 更新标签 {} 个",
|
||||
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
|
||||
}
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_merge_persons(db: &Database) -> (usize, usize) {
|
||||
let result = person_merger::execute_merge(db).await;
|
||||
if result.persons_merged > 0 {
|
||||
println!("[管线·Phase2] 人物合并完成:分析 {} 组,合并 {} 人,迁移 {} 个别名",
|
||||
result.groups_analyzed, result.persons_merged, result.aliases_moved);
|
||||
} else {
|
||||
println!("[管线·Phase2] 人物合并完成:无需要合并的人物");
|
||||
}
|
||||
(result.persons_merged, result.aliases_moved)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Phase B:后校验标签 — 用合并后的人名检查
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
pub async fn phase_clean_tags_after_parse(db: &Database) -> usize {
|
||||
let albums = db.get_all_albums_full().await;
|
||||
let all_person_names: HashSet<String> = db.get_all_person_names().await
|
||||
.into_iter().map(|(n, _)| n.to_lowercase()).collect();
|
||||
let all_aliases: HashSet<String> = db.get_all_aliases().await
|
||||
.into_iter().map(|(a, _)| a.to_lowercase()).collect();
|
||||
let person_or_alias: HashSet<String> = all_person_names.into_iter().chain(all_aliases).collect();
|
||||
|
||||
if person_or_alias.is_empty() {
|
||||
println!("[管线·PhaseB] 后校验标签完成:无人物数据,跳过");
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut tag_removed = 0usize;
|
||||
for album in &albums {
|
||||
let tags_json = match album.tags.as_deref() {
|
||||
Some(t) if !t.is_empty() && t != "[]" => t, _ => continue,
|
||||
};
|
||||
let old_tags: Vec<String> = match serde_json::from_str(tags_json) { Ok(t) => t, Err(_) => continue, };
|
||||
|
||||
let mut album_removed = 0usize;
|
||||
let good_tags: Vec<String> = old_tags.into_iter().filter(|t| {
|
||||
if person_or_alias.contains(&t.to_lowercase()) { album_removed += 1; false } else { true }
|
||||
}).collect();
|
||||
|
||||
if album_removed == 0 { continue; }
|
||||
tag_removed += album_removed;
|
||||
|
||||
let all_words_json = album.all_words.as_deref().unwrap_or("[]");
|
||||
let all_words_new: Vec<[String; 2]> = serde_json::from_str::<Vec<[String; 2]>>(all_words_json)
|
||||
.unwrap_or_default().into_iter().filter(|pair| !person_or_alias.contains(&pair[0].to_lowercase())).collect();
|
||||
|
||||
let tags_json_new = serde_json::to_string(&good_tags).unwrap_or_else(|_| "[]".to_string());
|
||||
let words_json_new = serde_json::to_string(&all_words_new).unwrap_or_else(|_| "[]".to_string());
|
||||
db.update_album_tags(&album.id, &tags_json_new, &words_json_new).await;
|
||||
}
|
||||
|
||||
if tag_removed > 0 {
|
||||
println!("[管线·PhaseB] 后校验标签完成:移除 {} 个人名/别名", tag_removed);
|
||||
} else {
|
||||
println!("[管线·PhaseB] 后校验标签完成:无需要修复的标签");
|
||||
}
|
||||
tag_removed
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Pipeline 一键执行(7 阶段)
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PipelineResult {
|
||||
pub tags_cleaned: usize, // Phase 0
|
||||
pub person_like_tags_removed: usize, // Phase A1
|
||||
pub dirty_albums_detected: usize, // A1+A2+A2B+A3
|
||||
pub non_persons_deleted: usize, // Phase A2
|
||||
pub stop_word_persons_removed: usize, // Phase A2B
|
||||
pub new_albums_found: usize, // Phase A3
|
||||
pub persons_updated: usize, // Phase 1
|
||||
pub tags_updated: usize, // Phase 1
|
||||
pub persons_merged: usize, // Phase 2
|
||||
pub aliases_moved: usize, // Phase 2
|
||||
pub tags_removed_person: usize, // Phase B
|
||||
}
|
||||
|
||||
/// 一键执行完整管线(8 阶段顺序串行)
|
||||
pub async fn run_pipeline(db: &Database) -> PipelineResult {
|
||||
let tags_cleaned = phase_clean_tags(db).await;
|
||||
let (dirty_a1, person_like_tags_removed) = phase_detect_person_in_tags(db).await;
|
||||
let (dirty_a2, non_persons_deleted) = phase_clean_persons(db).await;
|
||||
let (dirty_a2b, stop_word_persons_removed) = phase_clean_stop_word_persons(db).await;
|
||||
let dirty_a3 = phase_collect_new_albums(db).await;
|
||||
let new_albums_found = dirty_a3.len();
|
||||
|
||||
let mut master_dirty = DirtySet::new();
|
||||
master_dirty.extend(dirty_a1.into_inner());
|
||||
master_dirty.extend(dirty_a2.into_inner());
|
||||
master_dirty.extend(dirty_a2b.into_inner());
|
||||
master_dirty.extend(dirty_a3.into_inner());
|
||||
let dirty_albums_detected = master_dirty.len();
|
||||
|
||||
let (persons_updated, tags_updated) = phase_parse_dirty(db, &master_dirty).await;
|
||||
// Phase C:全量重计(在合并前修正所有计数,让合并看到准确数值)
|
||||
let _recounted = phase_recount_all(db).await;
|
||||
let (persons_merged, aliases_moved) = phase_merge_persons(db).await;
|
||||
let tags_removed_person = phase_clean_tags_after_parse(db).await;
|
||||
|
||||
PipelineResult {
|
||||
tags_cleaned, person_like_tags_removed, dirty_albums_detected,
|
||||
non_persons_deleted, stop_word_persons_removed, new_albums_found,
|
||||
persons_updated, tags_updated, persons_merged,
|
||||
aliases_moved, tags_removed_person,
|
||||
}
|
||||
}
|
||||
228
src/engine/recommend.rs
Normal file
228
src/engine/recommend.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
//! 推荐引擎 — 标签推荐、人物推荐、关联推荐
|
||||
//!
|
||||
//! 推荐策略:
|
||||
//! 1. 标签推荐:基于输入前缀匹配 + 热门度排序
|
||||
//! 2. 人物推荐:基于名称匹配 + 相册数排序
|
||||
//! 3. 关联推荐:基于标签共现关系 + 人物共现关系
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::*;
|
||||
use crate::engine::tag_dict;
|
||||
use sqlx::Row;
|
||||
|
||||
/// 推荐引擎
|
||||
pub struct RecommendEngine;
|
||||
|
||||
impl RecommendEngine {
|
||||
/// 标签自动补全推荐
|
||||
pub async fn recommend_tags(
|
||||
db: &Database,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> RecommendResponse {
|
||||
let limit = limit.min(20);
|
||||
let q = query.trim().to_lowercase();
|
||||
|
||||
let tags = db.get_popular_tags(200).await;
|
||||
let mut items: Vec<RecommendItem> = Vec::new();
|
||||
|
||||
for t in &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 async fn recommend_persons(
|
||||
db: &Database,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
) -> RecommendResponse {
|
||||
let limit = limit.min(20);
|
||||
let q = query.trim().to_lowercase();
|
||||
|
||||
let persons = db.get_all_persons_with_aliases().await;
|
||||
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(
|
||||
db: &Database,
|
||||
context: &str, // 标签或人名
|
||||
limit: usize,
|
||||
) -> TagRecommendation {
|
||||
let limit = limit.min(15);
|
||||
|
||||
// 获取共现标签
|
||||
let co_tags = 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 = 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 meta = db.get_all_albums_full().await;
|
||||
for m in &meta {
|
||||
if m.id == album.id.to_string() {
|
||||
if let Some(ref name) = m.person_name_cleaned {
|
||||
let entry = person_scores.entry(name.clone()).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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 热门人物
|
||||
pub async fn popular_persons(db: &Database, limit: usize) -> Vec<PopularPerson> {
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
let persons = db.get_all_persons_with_aliases().await;
|
||||
|
||||
// 收集所有标签分类词(非人名候选)
|
||||
let all_tag_words: std::collections::HashSet<String> = dict.tag_to_category.keys()
|
||||
.map(|k| k.to_lowercase()).collect();
|
||||
|
||||
// 过滤非人物:三重检查(excluded + is_likely_person_name + 非标签词)
|
||||
let filtered: Vec<_> = persons.into_iter()
|
||||
.filter(|p| {
|
||||
// 1. 跳过排除名单中的人物
|
||||
if dict.is_excluded_person(&p.name) { return false; }
|
||||
// 2. 只保留 is_likely_person_name 返回 true 的人名
|
||||
if !crate::engine::title_parser::is_likely_person_name(&p.name) {
|
||||
return false;
|
||||
}
|
||||
// 3. 跳过和标签分类词重叠的非人名(如 "女模特"、"内衣秀")
|
||||
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();
|
||||
|
||||
// 为热门人物获取 top tags
|
||||
// (简化:从 person_albums 查标签)
|
||||
for rp in &mut results {
|
||||
if let Some(person) = db.get_person_by_name(&rp.name).await {
|
||||
let albums = db.get_person_albums(person.id).await;
|
||||
let mut tag_counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
|
||||
for album in &albums {
|
||||
let aid = album.id.to_string();
|
||||
let rows = sqlx::query("SELECT tag FROM album_tags WHERE album_id = ?")
|
||||
.bind(&aid).fetch_all(&db.pool).await.unwrap_or_default();
|
||||
for row in &rows {
|
||||
*tag_counts.entry(row.get::<String, _>("tag")).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let mut tags: Vec<(String, i64)> = tag_counts.into_iter().collect();
|
||||
tags.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
rp.top_tags = tags.into_iter().take(5).map(|(t, _)| t).collect();
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
533
src/engine/tag_dict.rs
Normal file
533
src/engine/tag_dict.rs
Normal file
@@ -0,0 +1,533 @@
|
||||
//! 标签词典 — 从外部 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(),
|
||||
)
|
||||
}
|
||||
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 类型词应正常匹配");
|
||||
}
|
||||
}
|
||||
386
src/engine/title_parser.rs
Normal file
386
src/engine/title_parser.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
//! 标题解析、人名提取、标签提取
|
||||
//!
|
||||
//! v4 版本 — 基于 keymap 的纯匹配方案,无 jieba 依赖。
|
||||
//! 核心流程:预处理 → token 分割 → 对每个 token 依次检查:
|
||||
//! keymap(person) → 人物
|
||||
//! keymap(其他) → 标签
|
||||
//! stopwords → 跳过
|
||||
//! 人名启发式 → 人物
|
||||
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
use lazy_static::lazy_static;
|
||||
use crate::engine::tag_dict;
|
||||
use crate::engine::tag_extractor;
|
||||
|
||||
// ── 正则 ──
|
||||
|
||||
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_PAREN_CONTENT: Regex = Regex::new(r"[((]([^))]*)[))]").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();
|
||||
/// 中文词(2-5字)
|
||||
static ref RE_CN_WORD: Regex = Regex::new(r"([\u{4e00}-\u{9fff}]{2,5})").unwrap();
|
||||
/// 英文词(2-15字母)
|
||||
static ref RE_EN_WORD: Regex = Regex::new(r"([A-Za-z][A-Za-z]{1,14})").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
|
||||
}
|
||||
|
||||
// ── Token 提取 ──
|
||||
|
||||
/// 将标题分割为独立的 token(用于 Key 匹配)
|
||||
fn tokenize(title: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
|
||||
// 先按空白分割(简单 token)
|
||||
for segment in title.split_whitespace() {
|
||||
// 跳过纯标点
|
||||
if tag_extractor::is_punctuation_only(segment) { continue; }
|
||||
|
||||
// 尝试按常见连接符进一步分割(如 "性感女神" 在 keymap 中找不到 → 拆开)
|
||||
tokens.push(segment.to_string());
|
||||
}
|
||||
|
||||
// 去重
|
||||
let mut seen = HashSet::new();
|
||||
tokens.retain(|t| seen.insert(t.to_lowercase()));
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
/// 对 token 按长度降序排序(最长匹配优先)
|
||||
fn sort_by_length(tokens: &mut Vec<String>) {
|
||||
tokens.sort_by(|a, b| b.len().cmp(&a.len()));
|
||||
}
|
||||
|
||||
// ── 核心解析:统一入口 ──
|
||||
|
||||
/// 统一相册标题解析入口
|
||||
///
|
||||
/// 流程:预处理 → token 分割 → 对每个 token:
|
||||
/// 1. keymap 匹配 → person → 人物;其他 → 标签
|
||||
/// 2. stopwords → 跳过
|
||||
/// 3. 人名启发式 → 人物
|
||||
///
|
||||
/// 返回 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; }
|
||||
|
||||
let dict = tag_dict::get_tag_dict();
|
||||
|
||||
// ── 收集所有 token(空白分割)──
|
||||
let raw_tokens = tokenize(&cleaned);
|
||||
|
||||
// ── 用于标签提取的已处理 title(移除人名后)──
|
||||
let mut person_found: Option<(String, String)> = None;
|
||||
let mut tag_parts: Vec<String> = Vec::new();
|
||||
|
||||
for token in &raw_tokens {
|
||||
let token_lower = token.to_lowercase();
|
||||
|
||||
// 跳过过短 token
|
||||
if token.len() < 2 {
|
||||
tag_parts.push(token.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过纯数字
|
||||
if token.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过停用词
|
||||
if dict.is_stop_word(&token_lower) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查 keymap(精确匹配)
|
||||
if let Some(category) = dict.match_keymap(&token_lower) {
|
||||
if category == "person" && person_found.is_none() {
|
||||
person_found = Some((token.clone(), token_lower.clone()));
|
||||
} else if category != "person" {
|
||||
tag_parts.push(token.clone());
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否为已知人名(include/启发式)
|
||||
if person_found.is_none() && is_likely_person_name(token) {
|
||||
person_found = Some((token.clone(), token_lower.clone()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 剩余内容放入 tag 池
|
||||
tag_parts.push(token.clone());
|
||||
}
|
||||
|
||||
// ── 处理人物 ──
|
||||
if let Some((name, _)) = &person_found {
|
||||
// include 列表中的最长匹配扩展
|
||||
let expanded = dict.find_longest_included_match(name)
|
||||
.filter(|longer| longer.len() > name.len());
|
||||
let effective_name = expanded.as_deref().unwrap_or(name.as_str());
|
||||
|
||||
let (primary, _aliases) = split_person_name_with_aliases(effective_name);
|
||||
let final_name = if primary.is_empty() { effective_name.to_string() } else { primary };
|
||||
|
||||
if final_name.chars().count() > 2 {
|
||||
result.person_name = Some(final_name);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 提取标签 ──
|
||||
let person_exclude = result.person_name.as_deref();
|
||||
let raw_tags = tag_extractor::extract_tags_from_title(title, person_exclude);
|
||||
let unique = tag_extractor::unique_tags(raw_tags);
|
||||
|
||||
result.tags = unique;
|
||||
result
|
||||
}
|
||||
|
||||
// ── 人名启发式判断 ──
|
||||
|
||||
/// 判断一个词是否"看起来像人名"(v4 简化版,不再查 tag_dict classify,因为 keymap 已覆盖)
|
||||
/// 返回 true = 像人名;false = 不像
|
||||
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) || dict.find_longest_included_match(name).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 字符数限制
|
||||
if name.chars().count() <= 2 { return false; }
|
||||
|
||||
// keymap 中的词一定不是人名(已在 parse_album_title 中提前检查过,双保险)
|
||||
if dict.match_keymap(&name.to_lowercase()).is_some() { 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; }
|
||||
|
||||
// 纯英文且长度 > 15 排除
|
||||
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; }
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// ── 人名清洗 ──
|
||||
|
||||
/// 提取中文核心(开头的连续中文字符)
|
||||
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()
|
||||
}
|
||||
|
||||
// ── 兼容函数(供 pipeline / person_merger 调用)──
|
||||
|
||||
/// 从标题中提取人物名(兼容函数)
|
||||
pub fn extract_person_name_from_title(title: &str) -> Option<String> {
|
||||
parse_album_title("", title).person_name
|
||||
}
|
||||
|
||||
/// 从标题中提取人物名和标签(兼容函数)
|
||||
pub fn extract_person_and_tags(title: &str) -> (Option<String>, Vec<(String, String)>) {
|
||||
let result = parse_album_title("", title);
|
||||
(result.person_name, 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_name.as_deref(), Some("杨晨晨"));
|
||||
assert!(!r.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_album_title_no_person() {
|
||||
let r = parse_album_title("id2", "浴室 可爱 性感 写真");
|
||||
assert!(r.person_name.is_none());
|
||||
assert!(!r.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_album_title_with_prefix() {
|
||||
let r = parse_album_title("id3", "美女模特 杨晨晨 私房");
|
||||
assert_eq!(r.person_name.as_deref(), Some("杨晨晨"));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
268
src/main.rs
268
src/main.rs
@@ -1,23 +1,26 @@
|
||||
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;
|
||||
|
||||
use crate::models::AlbumsCache;
|
||||
use crate::cache::AlbumsCache;
|
||||
use actix_files as afs;
|
||||
use actix_web::{App, HttpResponse, HttpServer, middleware, web};
|
||||
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();
|
||||
|
||||
// 加载配置
|
||||
@@ -29,14 +32,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);
|
||||
@@ -46,113 +53,157 @@ 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 persons_cache = Arc::new(tokio::sync::RwLock::new(cache::PersonCache::default()));
|
||||
let tags_cache = Arc::new(tokio::sync::RwLock::new(cache::TagCache::default()));
|
||||
|
||||
// 初始加载相册数据到缓存
|
||||
let initial_cache = Arc::clone(&albums_cache);
|
||||
let initial_db = Arc::clone(&db);
|
||||
// ── 启动管线(单次):导入 → 解析 → 合并 → 缓存刷新 ──
|
||||
let boot_config = config.clone();
|
||||
let boot_db = Arc::clone(&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 {
|
||||
match initial_db.get_all_albums().await {
|
||||
Ok(albums) => {
|
||||
let mut cache = initial_cache.write().await;
|
||||
cache.update(albums);
|
||||
println!("✅ 初始相册缓存加载完成");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("❌ 初始缓存加载失败: {}", e);
|
||||
}
|
||||
// ① 扫描导入新相册
|
||||
info!("[启动管线] ① 扫描导入新相册...");
|
||||
match services::import::import_existing_data(&boot_config, Arc::clone(&boot_db), None).await {
|
||||
Ok(_) => info!("[启动管线] 导入完成"),
|
||||
Err(e) => error!("[启动管线] 导入失败: {}", e),
|
||||
}
|
||||
|
||||
// ② 全量解析 + 人物合并
|
||||
info!("[启动管线] ② 全量解析 + 人物合并...");
|
||||
let result = engine::pipeline::run_pipeline(&boot_db).await;
|
||||
info!("[启动管线] 完成:清洗标点{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
|
||||
result.tags_cleaned, 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,
|
||||
result.persons_merged, result.aliases_moved, result.tags_removed_person);
|
||||
|
||||
// ③ 刷新所有内存缓存
|
||||
info!("[启动管线] ③ 刷新所有内存缓存...");
|
||||
cache::refresh_all_caches(&boot_db, &boot_albums_cache, &boot_persons_cache, &boot_tags_cache).await;
|
||||
info!("[启动管线] 启动管线执行完毕");
|
||||
});
|
||||
|
||||
// 设置定时刷新缓存任务(每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_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);
|
||||
}
|
||||
}
|
||||
// ① 全量解析 + 人物合并(不重复 import)
|
||||
info!("[定时管线] ① 全量解析 + 人物合并...");
|
||||
let result = engine::pipeline::run_pipeline(&timer_db).await;
|
||||
info!("[定时管线] 完成:清洗标点{},检测人名标签{},脏集{}相册,删除非人物{},清除停用词重叠人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
|
||||
result.tags_cleaned, 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,
|
||||
result.persons_merged, result.aliases_moved, result.tags_removed_person);
|
||||
|
||||
// ② 刷新所有内存缓存
|
||||
info!("[定时管线] ② 刷新所有内存缓存...");
|
||||
cache::refresh_all_caches(&timer_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!(" - GET /api/metadata/stats - 元数据统计");
|
||||
println!(" - GET /api/tags/search?tag=标签 - 按标签搜索");
|
||||
println!(" - GET /api/tags/popular?limit=50 - 热门标签");
|
||||
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!(" - GET /api/dict/stats - 词典状态");
|
||||
println!(" - POST /api/dict/reload - 热重载词典");
|
||||
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(&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))
|
||||
// 标签 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))
|
||||
// 推荐 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))
|
||||
// 队列 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))
|
||||
// 静态文件
|
||||
.service(
|
||||
afs::Files::new("/assets", "./assets")
|
||||
.show_files_listing()
|
||||
@@ -160,33 +211,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
|
||||
}
|
||||
}
|
||||
62
src/models/album.rs
Normal file
62
src/models/album.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub person_name_cleaned: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<String>, // JSON: [tag, ...]
|
||||
}
|
||||
|
||||
#[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 AlbumWithImages {
|
||||
pub album: Album,
|
||||
pub images: Vec<super::image::Image>,
|
||||
pub image_count: i64,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// 相册完整元数据(含扩展字段)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AlbumMeta {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub person_name_original: Option<String>,
|
||||
pub person_name_cleaned: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub all_words: Option<String>,
|
||||
pub raw_title: Option<String>,
|
||||
pub processed_at: 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::*;
|
||||
27
src/models/parse.rs
Normal file
27
src/models/parse.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! 相册解析结果 — 单个相册标题的一次性解析产物
|
||||
//! 用于串联并行解析(Phase 1a)和批量写库(Phase 1b)
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// 一次 title 解析的完整结果
|
||||
///
|
||||
/// 在一个相册上通过一次 jieba 分词,同时提取:
|
||||
/// - 人名 + 别名(含 include 扩展、prefix 移除)
|
||||
/// - 标签列表(含分类)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParseResult {
|
||||
/// 相册 UUID
|
||||
pub album_id: String,
|
||||
/// 最终人名 | None = 未找到
|
||||
pub person_name: Option<String>,
|
||||
/// 中文主名(split_person_name_with_aliases 后的 primary)
|
||||
pub person_primary: Option<String>,
|
||||
/// 标签列表 (tag, category)
|
||||
pub tags: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl ParseResult {
|
||||
pub fn new(album_id: String) -> Self {
|
||||
Self { album_id, person_name: None, person_primary: None, tags: Vec::new() }
|
||||
}
|
||||
}
|
||||
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: i64,
|
||||
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: i64,
|
||||
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: i64,
|
||||
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_with_person: i64,
|
||||
pub total_albums_without_person: 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": "获取图片信息失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/routes/album.rs
Normal file
95
src/routes/album.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use crate::cache::AlbumsCache;
|
||||
use crate::db::Database;
|
||||
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(
|
||||
db: web::Data<Arc<Database>>,
|
||||
cache: web::Data<Arc<RwLock<AlbumsCache>>>,
|
||||
query: web::Query<HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
|
||||
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 db.search_albums_with_filters(tag_arg, person_arg, q_arg, limit, offset).await {
|
||||
Ok(results) => HttpResponse::Ok().json(results),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})),
|
||||
}
|
||||
} else {
|
||||
// ── 无过滤条件:走缓存(现有行为) ──
|
||||
let c = cache.read().await;
|
||||
|
||||
let page: usize = query.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
|
||||
let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(200);
|
||||
let offset = (page - 1) * limit;
|
||||
|
||||
let 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>>, 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_album_with_images(id).await {
|
||||
Ok(Some(album)) => 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)})),
|
||||
}
|
||||
}
|
||||
40
src/routes/dict.rs
Normal file
40
src/routes/dict.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! 标签词典管理 API — 查看/热重载
|
||||
|
||||
use actix_web::{HttpResponse, Responder};
|
||||
|
||||
use crate::engine::tag_dict;
|
||||
|
||||
/// 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() -> impl Responder {
|
||||
let msg = tag_dict::reload_tag_dict();
|
||||
println!("[API] 词典热重载: {}", msg);
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": msg,
|
||||
}))
|
||||
}
|
||||
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)
|
||||
})),
|
||||
}
|
||||
}
|
||||
23
src/routes/mod.rs
Normal file
23
src/routes/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! API 路由层 — 按领域拆分
|
||||
|
||||
pub mod album;
|
||||
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 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::*;
|
||||
77
src/routes/person.rs
Normal file
77
src/routes/person.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use crate::cache::PersonCache;
|
||||
use crate::db::Database;
|
||||
use crate::models::*;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// GET /api/people — 从缓存获取所有人名列表
|
||||
pub async fn get_people(cache: web::Data<Arc<RwLock<PersonCache>>>) -> impl Responder {
|
||||
let c = cache.read().await;
|
||||
// 缓存中已按 total_albums DESC 排序
|
||||
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} — 从缓存查找人物详情(需要 DB 获取相册列表)
|
||||
pub async fn get_person_detail(
|
||||
db: web::Data<Arc<Database>>,
|
||||
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) => {
|
||||
// 相册列表仍需查 DB
|
||||
let albums = db.get_person_albums(p.id).await;
|
||||
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 — 查 DB(别名表记录数可能较大,不适合缓存)
|
||||
pub async fn get_person_aliases(db: web::Data<Arc<Database>>, path: web::Path<i64>) -> impl Responder {
|
||||
let aliases = db.get_person_aliases(path.into_inner()).await;
|
||||
HttpResponse::Ok().json(aliases)
|
||||
}
|
||||
29
src/routes/pipeline.rs
Normal file
29
src/routes/pipeline.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::db::Database;
|
||||
use crate::engine::pipeline;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// POST /api/pipeline/run — 执行完整管线:清洗→检测→清理→解析→合并→校验
|
||||
pub async fn pipeline_run(db: web::Data<Arc<Database>>) -> impl Responder {
|
||||
let result = pipeline::run_pipeline(db.get_ref()).await;
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"success": true,
|
||||
"tags_cleaned": result.tags_cleaned,
|
||||
"person_like_tags_removed": result.person_like_tags_removed,
|
||||
"dirty_albums_detected": result.dirty_albums_detected,
|
||||
"non_persons_deleted": result.non_persons_deleted,
|
||||
"new_albums_found": result.new_albums_found,
|
||||
"persons_updated": result.persons_updated,
|
||||
"tags_updated": result.tags_updated,
|
||||
"persons_merged": result.persons_merged,
|
||||
"aliases_moved": result.aliases_moved,
|
||||
"tags_removed_person": result.tags_removed_person,
|
||||
"message": format!(
|
||||
"管线执行完毕:清洗标点{}相册,检测人名标签{},脏集{}相册,删除非人物{},发现新相册{},更新人名{},更新标签{},合并人物{},迁移别名{},后移除人名标签{}",
|
||||
result.tags_cleaned, result.person_like_tags_removed, result.dirty_albums_detected,
|
||||
result.non_persons_deleted, result.new_albums_found,
|
||||
result.persons_updated, result.tags_updated,
|
||||
result.persons_merged, result.aliases_moved, result.tags_removed_person
|
||||
)
|
||||
}))
|
||||
}
|
||||
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)
|
||||
}
|
||||
36
src/routes/recommend.rs
Normal file
36
src/routes/recommend.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::db::Database;
|
||||
use crate::engine::RecommendEngine;
|
||||
#[allow(unused)] use crate::models::*;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 标签推荐(自动补全)
|
||||
pub async fn recommend_tags(db: web::Data<Arc<Database>>, 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);
|
||||
HttpResponse::Ok().json(RecommendEngine::recommend_tags(&db, q, limit).await)
|
||||
}
|
||||
|
||||
/// 人物推荐(自动补全)
|
||||
pub async fn recommend_persons(db: web::Data<Arc<Database>>, 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);
|
||||
HttpResponse::Ok().json(RecommendEngine::recommend_persons(&db, q, limit).await)
|
||||
}
|
||||
|
||||
/// 关联推荐(标签共现 + 共现人物)
|
||||
pub async fn recommend_related(db: web::Data<Arc<Database>>, 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(&db, context, limit).await)
|
||||
}
|
||||
|
||||
/// 热门人物
|
||||
pub async fn popular_persons(db: web::Data<Arc<Database>>, 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);
|
||||
HttpResponse::Ok().json(RecommendEngine::popular_persons(&db, limit).await)
|
||||
}
|
||||
37
src/routes/tag.rs
Normal file
37
src/routes/tag.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::cache::TagCache;
|
||||
use crate::db::Database;
|
||||
use crate::models::*;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// GET /api/metadata/stats — 仍查 DB(需要实时统计数据)
|
||||
pub async fn get_metadata_stats(db: web::Data<Arc<Database>>) -> impl Responder {
|
||||
HttpResponse::Ok().json(db.get_metadata_stats().await)
|
||||
}
|
||||
|
||||
/// 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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
5
src/services/mod.rs
Normal file
5
src/services/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! 服务层 — 导入、处理等业务逻辑
|
||||
|
||||
pub mod import;
|
||||
|
||||
#[allow(unused)] pub use import::*;
|
||||
Reference in New Issue
Block a user