Merge remote-tracking branch 'origin/master'

# Conflicts:
#	.gitignore
#	Cargo.lock
This commit is contained in:
lychang
2026-07-20 06:57:18 +08:00
60 changed files with 9214 additions and 3494 deletions

3
.gitignore vendored
View File

@@ -1,4 +1 @@
/target
/albums
/.idea
/data/imager.db

380
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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
View 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": [
"美女模特", "性感女神", "清纯美女", "气质美女", "可爱女孩",
"甜美少女", "时尚达人", "女神", "美女", "模特", "小姐姐",
"小仙女", "御姐", "萝莉", "辣妹", "潮人"
]
}

View 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
View 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 → 全量解析 + 人物合并

View 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);

View 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
View 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
View 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);
}

View File

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

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

@@ -0,0 +1,5 @@
//! 服务层 — 导入、处理等业务逻辑
pub mod import;
#[allow(unused)] pub use import::*;