// src/api/papers.rs use axum::{ extract::{Query, State}, http::StatusCode, Json, }; use serde::{Deserialize, Serialize}; use sqlx::Row; use std::sync::Arc; use tokio::fs; use tracing::{error, info, warn}; use super::error::{ApiResult, AppError}; use super::helpers::{ check_paper_paths_in_db, convert_ads_doc_to_standard, get_paper_from_db, parse_paper_row, save_paper_to_db, SQLITE_PARAM_LIMIT, }; use super::{AppState, StandardPaper}; // 检索请求参数 #[derive(Debug, Deserialize)] pub struct SearchParams { pub q: String, pub source: Option, // "all" | "ads" | "arxiv" pub rows: Option, pub start: Option, // 分页起始偏移量 pub sort: Option, // 排序字段 } // ── GET /api/search ── // 统一检索接口,合并去重 ADS 和 arXiv 数据 pub async fn search_papers( State(state): State>, Query(params): Query, ) -> ApiResult>> { let source = params.source.unwrap_or_else(|| "all".to_string()); let rows = params.rows.unwrap_or(10); let start = params.start.unwrap_or(0); let sort = params.sort.as_deref().unwrap_or("relevance"); let results = crate::services::search::search_papers(&state, ¶ms.q, &source, start, rows, sort) .await .map_err(|e| AppError::internal(e.to_string()))?; Ok(Json(results)) } // ── POST /api/download ── #[derive(Deserialize)] pub struct DownloadRequest { pub bibcode: String, pub force: Option, // 强制重新下载,即使已存在本地文件 } // 一键双格式并行下载文献 (PDF + HTML),支持 force=true 强制重新下载 pub async fn download_paper( State(state): State>, Json(req): Json, ) -> ApiResult> { let force = req.force.unwrap_or(false); info!( "接收到文献下载指令,标识符: {}, 强制重下: {}", req.bibcode, force ); let paper = state .downloader .download_paper_service(&state.db, &state.config.library_dir, &req.bibcode, force) .await .map_err(|e| AppError::internal(e.to_string()))?; Ok(Json(paper)) } // ── POST /api/parse ── #[derive(Deserialize)] pub struct ParseRequest { pub bibcode: String, pub force: Option, } #[derive(Serialize)] pub struct ParseResponse { pub markdown: String, } // 将 HTML / PDF 转换为标准英文 Markdown 文本(HTML 优先,PDF 调用 MinerU 远程 API 且上传七牛云) pub async fn parse_paper( State(state): State>, Json(req): Json, ) -> ApiResult> { info!( "接收到文献结构化解析指令: {} (强制重新解析: {:?})", req.bibcode, req.force ); let force = req.force.unwrap_or(false); let markdown = crate::services::parser::parse_paper_service( &state.db, &state.config.library_dir, &state.qiniu, &state.config, &req.bibcode, force, ) .await .map_err(|e| { // 根据错误信息推断合适的错误类型(保留原有状态码语义) let msg = e.to_string(); if msg.contains("未注册") || msg.contains("未找到") || msg.contains("丢失") || msg.contains("未在数据库中注册") { AppError::not_found(msg) } else if msg.contains("请先下载") { AppError::bad_request(msg) } else { AppError::internal(msg) } })?; Ok(Json(ParseResponse { markdown })) } // ── POST /api/translate ── #[derive(Deserialize)] pub struct TranslateRequest { pub bibcode: String, pub force: Option, } #[derive(Serialize)] pub struct TranslateResponse { pub translation: String, } // 文献中英双栏对比翻译接口(包含词表注入与本地物理缓存) pub async fn translate_paper( State(state): State>, Json(req): Json, ) -> ApiResult> { let force = req.force.unwrap_or(false); info!( "接收到对比翻译请求: 文献={}, 强制重译={}", req.bibcode, force ); let (_, _, md_opt, tr_opt) = check_paper_paths_in_db(&state.db, &state.config.library_dir, &req.bibcode) .await .map_err(|e| AppError::not_found(format!("查询文献路径失败: {}", e)))? .ok_or_else(|| AppError::not_found("该文献未注册在数据库中"))?; // 若本地已存在翻译物理文件且未指明强制重译,直读本地缓存返回 if !force { if let Some(tr_rel) = tr_opt { let tr_abs = state.config.library_dir.join(&tr_rel); if tr_abs.exists() { if let Ok(content) = fs::read_to_string(&tr_abs).await { return Ok(Json(TranslateResponse { translation: content, })); } } } } // 检查英文解析文件是否存在 let md_rel = match md_opt { Some(rel) => rel, None => { error!( "文献 {} 翻译失败:文献未完成解析,缺少英文 Markdown 路径", req.bibcode ); return Err(AppError::bad_request("文献必须先完成解析方可翻译")); } }; let md_abs = state.config.library_dir.join(&md_rel); if !md_abs.exists() { error!( "文献 {} 翻译失败:解析的英文 Markdown 文件 {:?} 不存在", req.bibcode, md_abs ); return Err(AppError::bad_request("解析 Markdown 文件丢失")); } let english_markdown = fs::read_to_string(&md_abs).await.map_err(|e| { error!("文献 {} 翻译失败:读取解析内容失败: {}", req.bibcode, e); AppError::internal(format!("读取解析内容失败: {}", e)) })?; // 调用 LLM 翻译服务并注入对照词表 let translated_markdown = crate::services::translation::translate_markdown( &english_markdown, &state.dict, &state.medium_llm, ) .await .map_err(|e| { error!( "文献 {} 翻译失败:调用 LLM 翻译发生错误: {}", req.bibcode, e ); AppError::internal(format!("调用 LLM 翻译失败: {}", e)) })?; // 翻译结果持久化(C2:抽取为共享函数,与批量任务复用) crate::services::translation::persist_translation( &state.db, &state.config.library_dir, &req.bibcode, &translated_markdown, ) .await .map_err(|e| AppError::internal(format!("翻译结果持久化失败: {}", e)))?; Ok(Json(TranslateResponse { translation: translated_markdown, })) } #[derive(Debug, Serialize)] pub struct CitationsResponse { pub bibcode: String, pub title: String, pub citation_count: i32, pub reference_count: i32, pub references: Vec, // 该文献参考文献 bibcode 数组 pub citations: Vec, // 引用该文献的 bibcode 数组 pub citation_counts: std::collections::HashMap, // 相关文献与被引数映射 } // 从 SQLite 查询引用关联,生成引用星系关系树 pub async fn get_citation_network( State(state): State>, Query(params): Query, ) -> ApiResult> { let paper = match get_paper_from_db(&state.db, &state.config.library_dir, ¶ms.bibcode).await { Ok(p) => p, Err(_) => { // 如果本地数据库查不到,尝试从 ADS 在线 API 动态获取 if !state.config.ads_api_key.is_empty() { match state .ads .search(&format!("bibcode:{}", params.bibcode), 0, 1, "relevance") .await { Ok(docs) => { if let Some(doc) = docs.first() { let standard_paper = convert_ads_doc_to_standard(doc); // 保存至数据库缓存,并保存引用关联 if let Err(e) = save_paper_to_db(&state.db, &standard_paper).await { warn!("保存引用文献至数据库失败: {}", e); } if let Some(refs) = &doc.reference { for ref_bib in refs { if let Err(e) = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)") .bind(&standard_paper.bibcode) .bind(ref_bib) .execute(&state.db) .await { warn!("保存引用关系失败 ({} -> {}): {}", standard_paper.bibcode, ref_bib, e); } } } if let Some(cits) = &doc.citation { for cit_bib in cits { if let Err(e) = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)") .bind(cit_bib) .bind(&standard_paper.bibcode) .execute(&state.db) .await { warn!("保存被引关系失败 ({} -> {}): {}", cit_bib, standard_paper.bibcode, e); } } } standard_paper } else { return Err(AppError::not_found(format!( "在本地库及 ADS 中均未找到该文献: {}", params.bibcode ))); } } Err(e) => { return Err(AppError::internal(format!("在线检索文献元数据失败: {}", e))); } } } else { return Err(AppError::not_found(format!( "本地数据库未收录该文献,且未配置 ADS_API_KEY,无法在线加载: {}", params.bibcode ))); } } }; // 加载引用的文献 let refs_rows = sqlx::query("SELECT target_bibcode FROM citations_references WHERE source_bibcode = ?") .bind(¶ms.bibcode) .fetch_all(&state.db) .await .unwrap_or_else(|e| { warn!("查询引用关系失败: {}", e); Vec::new() }); let references: Vec = refs_rows.iter().map(|row| row.get(0)).collect(); // 加载被引用的文献 let cits_rows = sqlx::query("SELECT source_bibcode FROM citations_references WHERE target_bibcode = ?") .bind(¶ms.bibcode) .fetch_all(&state.db) .await .unwrap_or_else(|e| { warn!("查询被引关系失败: {}", e); Vec::new() }); let citations: Vec = cits_rows.iter().map(|row| row.get(0)).collect(); // 加载关联文献的被引数量 (从 SQLite papers 表获取) // 优化:用 WHERE IN 批量查询替代逐条 query_scalar,避免 N+1。 let mut citation_counts = std::collections::HashMap::new(); let mut all_related = references.clone(); all_related.extend(citations.clone()); // 去重,避免重复绑定与查询 let mut unique_bibs: Vec = all_related.clone(); unique_bibs.sort(); unique_bibs.dedup(); // SQLite 单条 SQL 参数上限 999,分批查询 for chunk in unique_bibs.chunks(SQLITE_PARAM_LIMIT) { if chunk.is_empty() { continue; } let placeholders = vec!["?"; chunk.len()].join(", "); let sql = format!( "SELECT bibcode, citation_count FROM papers WHERE bibcode IN ({})", placeholders ); let mut query = sqlx::query(&sql); for b in chunk { query = query.bind(b); } let rows = query.fetch_all(&state.db).await.unwrap_or_else(|e| { warn!("批量查询文献引用数失败: {}", e); Vec::new() }); for row in rows { let bib: String = row.get(0); let count: i32 = row.get(1); citation_counts.insert(bib, count); } } Ok(Json(CitationsResponse { bibcode: paper.bibcode, title: paper.title, citation_count: paper.citation_count, reference_count: paper.reference_count, references, citations, citation_counts, })) } // ── GET /api/paper ── #[derive(Serialize)] pub struct PaperDetailResponse { pub paper: StandardPaper, pub english_content: Option, pub translation_content: Option, } // 获取文献标准详情和中英双语内容文件数据 pub async fn get_paper_detail( State(state): State>, Query(params): Query, ) -> ApiResult> { let paper = get_paper_from_db(&state.db, &state.config.library_dir, ¶ms.bibcode) .await .map_err(|e| AppError::not_found(format!("未找到该文献数据: {}", e)))?; let (_, _, md_opt, tr_opt) = check_paper_paths_in_db(&state.db, &state.config.library_dir, ¶ms.bibcode) .await .map_err(|e| AppError::internal(e.to_string()))? .unwrap_or_default(); let english_content = match md_opt { Some(rel) => fs::read_to_string(state.config.library_dir.join(rel)) .await .ok(), None => None, }; let translation_content = match tr_opt { Some(rel) => fs::read_to_string(state.config.library_dir.join(rel)) .await .ok(), None => None, }; Ok(Json(PaperDetailResponse { paper, english_content, translation_content, })) } // ── GET /api/library ── // 获取本地图书馆文献列表 pub async fn get_library( State(state): State>, ) -> ApiResult>> { let rows = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype, EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode) FROM papers ORDER BY created_at DESC") .fetch_all(&state.db) .await .map_err(|e| AppError::internal(format!("访问本地数据库失败: {}", e)))?; let mut list = Vec::new(); for r in rows { list.push(parse_paper_row(&r, &state.config.library_dir)); } Ok(Json(list)) } // ── POST /api/export ── #[derive(Deserialize)] pub struct ExportRequest { pub bibcodes: Vec, } #[derive(Serialize)] pub struct ExportResponse { pub bibtex: String, } // 批量请求 ADS 接口,获取选中 Bibcode 的标准 BibTeX 引文段落 pub async fn export_citations( State(state): State>, Json(req): Json, ) -> ApiResult> { if state.config.ads_api_key.is_empty() { return Err(AppError::bad_request( "ADS API key 未在 .env 中配置,无法使用该接口", )); } let bibtex = state .ads .export_bibtex(req.bibcodes) .await .map_err(|e| AppError::internal(format!("批量引文导出失败: {}", e)))?; Ok(Json(ExportResponse { bibtex })) } // ── POST /api/upload ── // 允许手动上传/推入本地 PDF 或 HTML 文献文件 pub async fn upload_paper_file( State(state): State>, mut multipart: axum::extract::Multipart, ) -> ApiResult> { let mut bibcode = String::new(); let mut file_type = String::new(); // "pdf" 或 "html" let mut file_bytes = Vec::new(); let mut file_name = String::new(); while let Some(field) = multipart .next_field() .await .map_err(|e| AppError::bad_request(format!("解析文件分块失败: {}", e)))? { let name = field.name().unwrap_or("").to_string(); if name == "bibcode" { bibcode = field.text().await.unwrap_or_default(); } else if name == "type" { file_type = field.text().await.unwrap_or_default(); } else if name == "file" { file_name = field.file_name().unwrap_or("").to_string(); file_bytes = field .bytes() .await .map_err(|e| AppError::internal(format!("读取文件字节流失败: {}", e)))? .to_vec(); } } if bibcode.is_empty() { return Err(AppError::bad_request("缺少 bibcode 参数")); } // 校验 bibcode 字符安全:仅允许字母、数字、点号、冒号、连接符、斜杠 // 防止路径遍历攻击(如 "../../etc/passwd") if !bibcode .chars() .all(|c| c.is_ascii_alphanumeric() || ".:/-_".contains(c)) { return Err(AppError::bad_request("bibcode 包含非法字符")); } if file_bytes.is_empty() { return Err(AppError::bad_request("上传文件为空或读取失败")); } // 尝试将可能的 DOI 或 arXiv ID 解析为真实的 bibcode let mut resolved_bibcode = bibcode.clone(); let exists_as_bibcode = sqlx::query("SELECT bibcode FROM papers WHERE bibcode = ?") .bind(&resolved_bibcode) .fetch_optional(&state.db) .await .unwrap_or(None) .is_some(); if !exists_as_bibcode { // 尝试匹配 DOI let clean_doi = resolved_bibcode .trim_start_matches("doi:") .trim_start_matches("DOI:") .trim_start_matches("https://doi.org/") .trim_start_matches("http://doi.org/") .trim(); if let Some(row) = sqlx::query( "SELECT bibcode FROM papers WHERE doi = ? OR doi = ? OR LOWER(doi) = LOWER(?)", ) .bind(clean_doi) .bind(&resolved_bibcode) .bind(clean_doi) .fetch_optional(&state.db) .await .unwrap_or(None) { let found: String = row.get(0); info!( "上传接口:通过 DOI 匹配成功,将 '{}' 解析为 bibcode '{}'", bibcode, found ); resolved_bibcode = found; } else { // 尝试匹配 arXiv ID let clean_arxiv = resolved_bibcode .trim_start_matches("arxiv:") .trim_start_matches("arXiv:") .trim_start_matches("ARXIV:") .trim(); // 移除可能存在的版本号后缀(如 2303.12345v1 -> 2303.12345) let clean_arxiv_no_version = if let Some(pos) = clean_arxiv.find('v') { if clean_arxiv[pos + 1..].chars().all(|c| c.is_ascii_digit()) { &clean_arxiv[..pos] } else { clean_arxiv } } else { clean_arxiv }; if let Some(row) = sqlx::query( "SELECT bibcode FROM papers WHERE arxiv_id = ? OR arxiv_id = ? OR arxiv_id LIKE ? OR arxiv_id LIKE ?" ) .bind(clean_arxiv) .bind(clean_arxiv_no_version) .bind(format!("{}%", clean_arxiv_no_version)) .bind(format!("arXiv:{}%", clean_arxiv_no_version)) .fetch_optional(&state.db) .await .unwrap_or(None) { let found: String = row.get(0); info!("上传接口:通过 arXiv ID 匹配成功,将 '{}' 解析为 bibcode '{}'", bibcode, found); resolved_bibcode = found; } } } let bibcode = resolved_bibcode; // 从数据库读取该文献元数据 let _paper = get_paper_from_db(&state.db, &state.config.library_dir, &bibcode) .await .map_err(|e| AppError::not_found(format!("未找到该文献记录: {}", e)))?; // 校验并保存文件 let is_pdf = file_type == "pdf" || file_name.to_lowercase().ends_with(".pdf"); let relative_path = if is_pdf { crate::services::download::validate_pdf_content(&file_bytes) .map_err(|e| AppError::bad_request(format!("PDF 文件内容校验失败: {}", e)))?; let pdf_filename = format!("{}.pdf", bibcode); let pdf_dest = state.config.library_dir.join("PDF").join(&pdf_filename); if let Some(parent) = pdf_dest.parent() { std::fs::create_dir_all(parent).unwrap_or_default(); } std::fs::write(&pdf_dest, &file_bytes) .map_err(|e| AppError::internal(format!("无法写入 PDF 文件: {}", e)))?; format!("PDF/{}", pdf_filename) } else { let text_content = String::from_utf8(file_bytes) .map_err(|_| AppError::bad_request("上传的 HTML 文件不是有效的 UTF-8 文本"))?; crate::services::download::validate_html_content_lenient(&text_content) .map_err(|e| AppError::bad_request(format!("HTML 文件内容校验失败: {}", e)))?; let html_filename = format!("{}.html", bibcode); let html_dest = state.config.library_dir.join("HTML").join(&html_filename); if let Some(parent) = html_dest.parent() { std::fs::create_dir_all(parent).unwrap_or_default(); } std::fs::write(&html_dest, &text_content) .map_err(|e| AppError::internal(format!("无法写入 HTML 文件: {}", e)))?; format!("HTML/{}", html_filename) }; // 更新数据库路径状态 let path_field = if is_pdf { "pdf_path" } else { "html_path" }; let sql = format!("UPDATE papers SET {} = ? WHERE bibcode = ?", path_field); sqlx::query(&sql) .bind(&relative_path) .bind(&bibcode) .execute(&state.db) .await .map_err(|e| AppError::internal(format!("更新数据库状态失败: {}", e)))?; // 重新获取最新的文献信息以更新前端界面 let updated_paper = get_paper_from_db(&state.db, &state.config.library_dir, &bibcode) .await .map_err(|e| AppError::internal(format!("重读文献数据失败: {}", e)))?; Ok(Json(updated_paper)) } // ── POST /api/no_resource ── #[derive(Debug, Deserialize)] pub struct MarkNoResourceRequest { pub bibcode: String, pub clear: Option, } pub async fn mark_no_resource( State(state): State>, Json(req): Json, ) -> ApiResult> { let clear_flag = req.clear.unwrap_or(false); if clear_flag { info!("接收到清除文献无资源标记指令,标识符: {}", req.bibcode); sqlx::query("UPDATE papers SET pdf_path = NULL, html_path = NULL WHERE bibcode = ?") .bind(&req.bibcode) .execute(&state.db) .await .map_err(|e| AppError::internal(format!("清除无资源标记失败: {}", e)))?; } else { info!("接收到文献无资源标记指令,标识符: {}", req.bibcode); sqlx::query("UPDATE papers SET pdf_path = 'error:no_resource', html_path = 'error:no_resource' WHERE bibcode = ?") .bind(&req.bibcode) .execute(&state.db) .await .map_err(|e| AppError::internal(format!("更新数据库无资源标记失败: {}", e)))?; } // 重新获取最新的文献信息以更新前端界面 let updated_paper = get_paper_from_db(&state.db, &state.config.library_dir, &req.bibcode) .await .map_err(|e| AppError::internal(format!("重读文献数据失败: {}", e)))?; Ok(Json(updated_paper)) } // ── GET /api/active_bibcode ── #[derive(Debug, Serialize)] pub struct ActiveBibcodeResponse { pub bibcode: Option, } pub async fn get_active_bibcode(State(state): State>) -> Json { let active = state.active_bibcode.lock().await; Json(ActiveBibcodeResponse { bibcode: active.clone(), }) } // ── POST /api/active_bibcode ── #[derive(Debug, Deserialize)] pub struct SetActiveBibcodeRequest { pub bibcode: Option, } pub async fn set_active_bibcode( State(state): State>, Json(req): Json, ) -> StatusCode { let mut active = state.active_bibcode.lock().await; *active = req.bibcode; tracing::debug!("已更新当前活跃文献 Bibcode 标记为: {:?}", *active); StatusCode::OK } // ── POST /api/embed ── #[derive(Debug, Deserialize)] pub struct EmbedRequest { pub bibcode: String, } #[derive(Debug, Serialize)] pub struct EmbedResponse { pub chunk_count: usize, } pub async fn embed_paper( State(state): State>, Json(req): Json, ) -> ApiResult> { info!("接收到文献向量化分块入库指令: {}", req.bibcode); let (_, _, md_opt, _) = check_paper_paths_in_db(&state.db, &state.config.library_dir, &req.bibcode) .await .map_err(|e| AppError::not_found(format!("获取文献路径失败: {}", e)))? .ok_or_else(|| AppError::not_found("该文献未注册在数据库中"))?; let md_rel = md_opt.ok_or_else(|| AppError::bad_request("文献尚未解析为 Markdown,请先执行解析"))?; let md_abs = state.config.library_dir.join(&md_rel); if !md_abs.exists() { return Err(AppError::not_found("文献 Markdown 文件未找到,请重新解析")); } let markdown_content = fs::read_to_string(&md_abs) .await .map_err(|e| AppError::internal(format!("读取 Markdown 文件失败: {}", e)))?; let chunk_count = crate::services::rag::ingest_paper( &state.db, &state.embedding, &req.bibcode, &markdown_content, None, ) .await .map_err(|e| AppError::internal(format!("文献向量化失败: {}", e)))?; Ok(Json(EmbedResponse { chunk_count })) }