- src/ 按 clients/services/api 分层,Config 提升至 crate 根 - 新增 batch_sync.rs(双源并行收割)、query_parser.rs(多平台检索式转换) - build.rs 自动触发前端 npm install & build - SearchPanel 支持分页/排序/每页条数/高级检索构建器,前端加入搜索缓存 - 新增 SyncPanel 替换 SettingsPanel;新增 live_search 集成测试
1272 lines
6.9 KiB
Rust
1272 lines
6.9 KiB
Rust
// src/handlers.rs
|
||
use axum::{
|
||
extract::{Query, State},
|
||
http::StatusCode,
|
||
Json,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::sync::Arc;
|
||
use std::fs;
|
||
use tracing::{info, warn, error};
|
||
use sqlx::{SqlitePool, Row};
|
||
|
||
use crate::config::Config;
|
||
use crate::translation::Dictionary;
|
||
use crate::qiniu::QiniuClient;
|
||
use crate::ads::{AdsClient, AdsPaperDoc};
|
||
use crate::arxiv::{ArxivClient, ArxivPaper};
|
||
use crate::download::Downloader;
|
||
|
||
// 全局共享的 Axum 应用上下文状态
|
||
pub struct AppState {
|
||
pub config: Config,
|
||
pub db: SqlitePool,
|
||
pub dict: Dictionary,
|
||
pub qiniu: QiniuClient,
|
||
pub ads: AdsClient,
|
||
pub arxiv: ArxivClient,
|
||
pub downloader: Downloader,
|
||
pub harvest_status: Arc<tokio::sync::Mutex<crate::harvester::HarvestStatus>>,
|
||
pub process_status: Arc<tokio::sync::Mutex<crate::processor::ProcessStatus>>,
|
||
}
|
||
|
||
// 检索请求参数
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct SearchParams {
|
||
pub q: String,
|
||
pub source: Option<String>, // "all" | "ads" | "arxiv"
|
||
pub rows: Option<i32>,
|
||
pub start: Option<i32>, // 分页起始偏移量
|
||
pub sort: Option<String>, // 排序字段,例如 "date_desc", "citations_desc", "relevance"
|
||
}
|
||
|
||
// 统一标准化的文献格式,用于向前端传输
|
||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||
pub struct StandardPaper {
|
||
pub bibcode: String,
|
||
pub title: String,
|
||
pub authors: Vec<String>,
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
let doi = doc.doi.as_ref()
|
||
.and_then(|v: &Vec<String>| v.first())
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
|
||
let mut arxiv_id = String::new();
|
||
if let Some(identifiers) = &doc.identifier {
|
||
for id in identifiers {
|
||
if id.starts_with("arXiv:") {
|
||
arxiv_id = id.replace("arXiv:", "").trim().to_string();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if arxiv_id.is_empty() {
|
||
if doc.bibcode.starts_with("arXiv") {
|
||
arxiv_id = doc.bibcode.replace("arXiv", "").trim().to_string();
|
||
}
|
||
}
|
||
|
||
StandardPaper {
|
||
bibcode: doc.bibcode.clone(),
|
||
title,
|
||
authors,
|
||
year: doc.year.clone().unwrap_or_default(),
|
||
pub_journal: doc.pub_journal.clone().unwrap_or_default(),
|
||
keywords,
|
||
abstract_text: doc.abstract_text.clone().unwrap_or_default(),
|
||
doi,
|
||
arxiv_id,
|
||
citation_count: doc.citation_count.unwrap_or(0),
|
||
reference_count: doc.reference_count.unwrap_or(0),
|
||
is_downloaded: false,
|
||
has_markdown: false,
|
||
has_translation: false,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn convert_arxiv_to_standard(doc: &ArxivPaper) -> StandardPaper {
|
||
StandardPaper {
|
||
bibcode: doc.id.clone(),
|
||
title: doc.title.clone(),
|
||
authors: doc.authors.clone(),
|
||
year: doc.year.clone(),
|
||
pub_journal: "arXiv Preprint".to_string(),
|
||
keywords: Vec::new(),
|
||
abstract_text: doc.abstract_text.clone(),
|
||
doi: doc.doi.clone().unwrap_or_default(),
|
||
arxiv_id: doc.id.clone(),
|
||
citation_count: 0,
|
||
reference_count: 0,
|
||
is_downloaded: false,
|
||
has_markdown: false,
|
||
has_translation: false,
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyhow::Result<()> {
|
||
let authors_json = serde_json::to_string(&p.authors)?;
|
||
let keywords_json = serde_json::to_string(&p.keywords)?;
|
||
|
||
// 1. 如果存在 arxiv_id,检查是否有已存在的相同 arxiv_id 记录以防 duplicate
|
||
if !p.arxiv_id.is_empty() {
|
||
let existing_opt: Option<(String, Option<String>, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
|
||
"SELECT bibcode, pdf_path, html_path, markdown_path, translation_path FROM papers WHERE arxiv_id = ?"
|
||
)
|
||
.bind(&p.arxiv_id)
|
||
.fetch_optional(db)
|
||
.await?;
|
||
|
||
if let Some((existing_bibcode, _pdf, _html, _md, _tr)) = existing_opt {
|
||
if existing_bibcode != p.bibcode {
|
||
// 发现不同 bibcode 标识的同一篇文献记录,需要进行合并合并
|
||
// 如果已存在的记录使用的是临时 arXiv ID 作为 bibcode,且新记录使用的是正式 ADS bibcode,我们升级 bibcode 主键
|
||
let is_existing_temp = existing_bibcode == p.arxiv_id;
|
||
let is_new_formal = p.bibcode != p.arxiv_id;
|
||
|
||
if is_existing_temp && is_new_formal {
|
||
info!("发现相同 arXiv ID 的
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
.await?;
|
||
|
||
// 运行迁移
|
||
sqlx::migrate!("./migrations")
|
||
.run(&pool)
|
||
.await?;
|
||
|
||
let paper = StandardPaper {
|
||
bibcode: "2026A&A...123..456X".to_string(),
|
||
title: "A Test Title".to_string(),
|
||
authors: vec!["Author A".to_string()],
|
||
year: "2026".to_string(),
|
||
pub_journal: "Astronomy & Astrophysics".to_string(),
|
||
keywords: vec!["Keyword 1".to_string()],
|
||
abstract_text: "This is abstract".to_string(),
|
||
doi: "10.1000/test.doi".to_string(),
|
||
arxiv_id: "".to_string(),
|
||
citation_count: 5,
|
||
reference_count: 10,
|
||
is_downloaded: false,
|
||
has_markdown: false,
|
||
has_translation: false,
|
||
};
|
||
|
||
// 保存
|
||
save_paper_to_db(&pool, &paper).await?;
|
||
|
||
// 读取
|
||
let retrieved = get_paper_from_db(&pool, std::path::Path::new(""), "2026A&A...123..456X").await?;
|
||
assert_eq!(retrieved.title, paper.title);
|
||
assert_eq!(retrieved.authors, paper.authors);
|
||
assert_eq!(retrieved.keywords, paper.keywords);
|
||
|
||
// 检查路径状态(初始为 None)
|
||
let paths = check_paper_paths_in_db(&pool, std::path::Path::new(""), "2026A&A...123..456X").await?;
|
||
assert!(paths.is_some());
|
||
let (pdf, html, md, tr) = paths.unwrap();
|
||
assert!(pdf.is_none());
|
||
assert!(html.is_none());
|
||
assert!(md.is_none());
|
||
assert!(tr.is_none());
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
|