323 lines
14 KiB
Rust
323 lines
14 KiB
Rust
mod config;
|
||
mod models;
|
||
mod db;
|
||
mod engine;
|
||
mod cache;
|
||
mod routes;
|
||
mod services;
|
||
mod image_processor;
|
||
mod queue;
|
||
mod search_db;
|
||
|
||
use crate::cache::AlbumsCache;
|
||
use crate::search_db::SearchDb;
|
||
use actix_files as afs;
|
||
use actix_web::{App, HttpResponse, HttpServer, middleware, web};
|
||
use std::env;
|
||
use std::fs;
|
||
use std::sync::Arc;
|
||
use tokio::time::{Duration, interval};
|
||
use tracing::{error, info};
|
||
|
||
#[actix_web::main]
|
||
async fn main() -> std::io::Result<()> {
|
||
tracing_subscriber::fmt::init();
|
||
|
||
let args: Vec<String> = env::args().collect();
|
||
let export_to_search = args.iter().any(|a| a == "--export-search");
|
||
let search_db_cli_path = args.iter().find(|a| a.starts_with("--search-db="))
|
||
.map(|s| s.strip_prefix("--search-db=").unwrap_or("search.db"))
|
||
.unwrap_or("search.db");
|
||
let clear_search = args.iter().any(|a| a == "--clear-search");
|
||
let wal_checkpoint = args.iter().any(|a| a == "--wal-checkpoint");
|
||
|
||
// 加载配置
|
||
let config = match config::Config::load() {
|
||
Ok(cfg) => cfg,
|
||
Err(e) => {
|
||
error!("加载配置失败: {}", e);
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
info!("启动图片展示服务 v2.0...");
|
||
info!("服务地址: http://{}", config.server_address());
|
||
|
||
// 初始化数据库
|
||
let db = match db::Database::new(&config).await {
|
||
Ok(db) => {
|
||
info!("数据库连接成功");
|
||
let db = Arc::new(db);
|
||
// 启动时先合并上次未完成的 WAL,确保数据库干净
|
||
info!("正在合并数据库 WAL...");
|
||
db.wal_checkpoint().await;
|
||
db
|
||
}
|
||
Err(e) => {
|
||
error!("数据库连接失败: {}", e);
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
|
||
if export_to_search {
|
||
info!("[搜索导出] 开始将元数据导出到 search.db...");
|
||
match services::search_export::export_to_search_db(&db, search_db_cli_path, clear_search).await {
|
||
Ok(result) => {
|
||
info!("[搜索导出] 导出完成: 相册{}, 标签{}, 人物{}, 别名{}, 关联{}",
|
||
result.albums_processed, result.tags_created, result.persons_created,
|
||
result.aliases_created, result.album_tags_created + result.album_persons_created);
|
||
std::process::exit(0);
|
||
}
|
||
Err(e) => {
|
||
error!("[搜索导出] 导出失败: {}", e);
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
if wal_checkpoint {
|
||
info!("[WAL合并] 主数据库 WAL checkpoint 已在初始化时完成");
|
||
info!("[WAL合并] 正在合并 search.db WAL...");
|
||
let search_db = SearchDb::new("data/search.db").await
|
||
.expect("search.db 连接失败");
|
||
search_db.wal_checkpoint().await;
|
||
info!("[WAL合并] 两个数据库均已合并完成");
|
||
std::process::exit(0);
|
||
}
|
||
|
||
let image_processor = Arc::new(image_processor::ImageProcessor::new(config.storage.clone()));
|
||
|
||
// ── 初始化 search.db(运行时搜索专用)──
|
||
let search_db_path = "data/search.db";
|
||
let search_db = Arc::new(
|
||
SearchDb::new(search_db_path).await
|
||
.expect("search.db 连接失败")
|
||
);
|
||
info!("search.db 连接成功");
|
||
search_db.wal_checkpoint().await;
|
||
|
||
// ── 从 search.db 的 dict 表加载标签词典 ──
|
||
match engine::tag_dict::reload_from_search_db(&search_db).await {
|
||
msg => info!("{}", msg),
|
||
}
|
||
|
||
// ── 内存缓存(必须在管线任务之前创建)──
|
||
let albums_cache = Arc::new(tokio::sync::RwLock::new(AlbumsCache::new()));
|
||
let persons_cache = Arc::new(tokio::sync::RwLock::new(cache::PersonCache::default()));
|
||
let tags_cache = Arc::new(tokio::sync::RwLock::new(cache::TagCache::default()));
|
||
|
||
// ── 启动管线(单次):导入 → 增量解析 → 条件全量 sync → 缓存刷新 ──
|
||
// 不做全量 reparse/merge,只处理新发现的相册
|
||
let boot_config = config.clone();
|
||
let boot_db = Arc::clone(&db);
|
||
let boot_search_db = Arc::clone(&search_db);
|
||
let boot_albums_cache = Arc::clone(&albums_cache);
|
||
let boot_persons_cache = Arc::clone(&persons_cache);
|
||
let boot_tags_cache = Arc::clone(&tags_cache);
|
||
tokio::spawn(async move {
|
||
// ① 扫描导入新相册
|
||
info!("[启动] ① 扫描导入新相册...");
|
||
match services::import::import_existing_data(&boot_config, Arc::clone(&boot_db), None).await {
|
||
Ok(_) => info!("[启动] 导入完成"),
|
||
Err(e) => error!("[启动] 导入失败: {}", e),
|
||
}
|
||
|
||
// ② 清理:删除空相册 + 去重
|
||
info!("[启动] ② 清理空相册和重复相册...");
|
||
let empty = engine::pipeline::phase_delete_empty_albums(&boot_db).await;
|
||
let dedup = engine::pipeline::phase_dedup_albums(&boot_db, &boot_search_db).await;
|
||
if empty > 0 || dedup > 0 {
|
||
info!("[启动] 清理完成:删除空相册 {},去重删除 {}", empty, dedup);
|
||
}
|
||
|
||
// ③ 查找新相册 / 半成品
|
||
info!("[启动] ③ 查找新相册...");
|
||
let dirty = engine::pipeline::phase_collect_new_albums(&boot_db).await;
|
||
let new_count = dirty.len();
|
||
if new_count > 0 {
|
||
info!("[启动] 发现 {} 个新相册,同步到 search.db...", new_count);
|
||
let synced = engine::pipeline::sync_dirty_albums(&boot_db, &boot_search_db, &dirty).await;
|
||
info!("[启动] 增量同步完成:{} 个相册(仅 tags/persons)", synced);
|
||
} else {
|
||
info!("[启动] 没有新相册");
|
||
}
|
||
|
||
// ④ 刷新所有内存缓存(从 search.db 读取)
|
||
info!("[启动] ④ 刷新所有内存缓存...");
|
||
cache::refresh_all_caches(&boot_db, &boot_search_db, &boot_albums_cache, &boot_persons_cache, &boot_tags_cache).await;
|
||
info!("[启动] 启动管线执行完毕(新相册={})", new_count);
|
||
});
|
||
|
||
// ── 定时管线(每 30 分钟):导入 → 增量解析 → 缓存刷新 ──
|
||
// 不做全量 reparse/merge
|
||
let timer_cache = Arc::clone(&albums_cache);
|
||
let timer_db = Arc::clone(&db);
|
||
let timer_search_db = Arc::clone(&search_db);
|
||
let timer_persons_cache = Arc::clone(&persons_cache);
|
||
let timer_tags_cache = Arc::clone(&tags_cache);
|
||
tokio::spawn(async move {
|
||
let mut interval = interval(Duration::from_secs(30 * 60));
|
||
loop {
|
||
interval.tick().await;
|
||
|
||
// ① 扫描导入新相册
|
||
info!("[定时] ① 扫描导入新相册...");
|
||
match services::import::import_existing_data(&timer_db.config, Arc::clone(&timer_db), None).await {
|
||
Ok(_) => {},
|
||
Err(e) => error!("[定时] 导入失败: {}", e),
|
||
}
|
||
|
||
// ② 查找新相册
|
||
let new_albums = engine::pipeline::phase_collect_new_albums(&timer_db).await;
|
||
if new_albums.is_empty() {
|
||
continue; // 无新增,跳过
|
||
}
|
||
|
||
info!("[定时] 发现 {} 个新相册,增量解析...", new_albums.len());
|
||
engine::pipeline::phase_parse_dirty(&timer_db, &new_albums).await;
|
||
|
||
info!("[定时] 同步到 search.db...");
|
||
engine::pipeline::sync_dirty_albums(&timer_db, &timer_search_db, &new_albums).await;
|
||
|
||
// ③ 刷新缓存
|
||
info!("[定时] 刷新缓存...");
|
||
cache::refresh_all_caches(&timer_db, &timer_search_db, &timer_cache, &timer_persons_cache, &timer_tags_cache).await;
|
||
}
|
||
});
|
||
|
||
// 任务队列
|
||
let task_queue = Arc::new(queue::TaskQueue::new(
|
||
config.queue.max_concurrent_tasks,
|
||
config.queue.queue_size_limit,
|
||
config.queue.high_request_rate_threshold,
|
||
config.queue.idle_request_rate_threshold,
|
||
));
|
||
|
||
println!("API接口:");
|
||
println!(" - GET /api/albums - 获取所有相册");
|
||
println!(" - GET /api/search?q=关键词 - 搜索相册");
|
||
println!(" - GET /api/album/random - 随机相册");
|
||
println!(" - GET /api/album/:id - 获取相册详情");
|
||
println!(" - POST /api/albums - 创建新相册");
|
||
println!(" - GET /api/image/:id - 获取图片");
|
||
println!(" - GET /api/favorites - 收藏列表");
|
||
println!(" - POST /api/favorites - 添加收藏");
|
||
println!(" - DELETE /api/favorites/: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(());
|
||
|
||
// 在 move 闭包之前 clone 一份 db 用于关闭信号处理
|
||
let shutdown_db = Arc::clone(&db);
|
||
let server = HttpServer::new(move || {
|
||
App::new()
|
||
.wrap(middleware::Compress::default())
|
||
.wrap(middleware::Logger::default())
|
||
.app_data(web::Data::new(Arc::clone(&db)))
|
||
.app_data(web::Data::new(Arc::clone(&search_db)))
|
||
.app_data(web::Data::new(Arc::clone(&image_processor)))
|
||
.app_data(web::Data::new(Arc::clone(&albums_cache)))
|
||
.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
|
||
.route("/api/favorites", web::get().to(routes::get_favorites))
|
||
.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/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()
|
||
.prefer_utf8(true)
|
||
.use_last_modified(true)
|
||
.use_etag(true),
|
||
)
|
||
.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(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
|
||
}
|