后端: - 权限系统重写: 全局→按 Session 隔离, 新增规则查询 API - 安全加固: 登录 IP 限流, Token 仅存 Cookie, bibcode 白名单校验 - SSE 超时保护, 异步 I/O 迁移, 10+ 处静默 DB 错误改为显式日志 - ar5iv 下标解析修复, parse_paper_row 去重, 优雅关闭 前端: - useSyncScroll 重写: 段落 ID 映射修复中英错位 - 全局竞态修复 (active 标志), libraryRef 闭包过期修复 - ErrorBoundary + vitest 测试基础设施 - Logo 组件提取, CustomSelect 泛型化, TabId 类型统一 - ReaderPanel 自动视图模式, AIAssistantPanel 状态批处理
749 lines
26 KiB
Rust
749 lines
26 KiB
Rust
// 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<String>, // "all" | "ads" | "arxiv"
|
||
pub rows: Option<i32>,
|
||
pub start: Option<i32>, // 分页起始偏移量
|
||
pub sort: Option<String>, // 排序字段
|
||
}
|
||
|
||
// ── GET /api/search ──
|
||
// 统一检索接口,合并去重 ADS 和 arXiv 数据
|
||
pub async fn search_papers(
|
||
State(state): State<Arc<AppState>>,
|
||
Query(params): Query<SearchParams>,
|
||
) -> ApiResult<Json<Vec<StandardPaper>>> {
|
||
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<bool>, // 强制重新下载,即使已存在本地文件
|
||
}
|
||
|
||
// 一键双格式并行下载文献 (PDF + HTML),支持 force=true 强制重新下载
|
||
pub async fn download_paper(
|
||
State(state): State<Arc<AppState>>,
|
||
Json(req): Json<DownloadRequest>,
|
||
) -> ApiResult<Json<StandardPaper>> {
|
||
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<bool>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ParseResponse {
|
||
pub markdown: String,
|
||
}
|
||
|
||
// 将 HTML / PDF 转换为标准英文 Markdown 文本(HTML 优先,PDF 调用 MinerU 远程 API 且上传七牛云)
|
||
pub async fn parse_paper(
|
||
State(state): State<Arc<AppState>>,
|
||
Json(req): Json<ParseRequest>,
|
||
) -> ApiResult<Json<ParseResponse>> {
|
||
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<bool>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct TranslateResponse {
|
||
pub translation: String,
|
||
}
|
||
|
||
// 文献中英双栏对比翻译接口(包含词表注入与本地物理缓存)
|
||
pub async fn translate_paper(
|
||
State(state): State<Arc<AppState>>,
|
||
Json(req): Json<TranslateRequest>,
|
||
) -> ApiResult<Json<TranslateResponse>> {
|
||
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<String>, // 该文献参考文献 bibcode 数组
|
||
pub citations: Vec<String>, // 引用该文献的 bibcode 数组
|
||
pub citation_counts: std::collections::HashMap<String, i32>, // 相关文献与被引数映射
|
||
}
|
||
|
||
// 从 SQLite 查询引用关联,生成引用星系关系树
|
||
pub async fn get_citation_network(
|
||
State(state): State<Arc<AppState>>,
|
||
Query(params): Query<DownloadRequest>,
|
||
) -> ApiResult<Json<CitationsResponse>> {
|
||
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<String> = 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<String> = 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<String> = 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<String>,
|
||
pub translation_content: Option<String>,
|
||
}
|
||
|
||
// 获取文献标准详情和中英双语内容文件数据
|
||
pub async fn get_paper_detail(
|
||
State(state): State<Arc<AppState>>,
|
||
Query(params): Query<DownloadRequest>,
|
||
) -> ApiResult<Json<PaperDetailResponse>> {
|
||
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<Arc<AppState>>,
|
||
) -> ApiResult<Json<Vec<StandardPaper>>> {
|
||
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<String>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ExportResponse {
|
||
pub bibtex: String,
|
||
}
|
||
|
||
// 批量请求 ADS 接口,获取选中 Bibcode 的标准 BibTeX 引文段落
|
||
pub async fn export_citations(
|
||
State(state): State<Arc<AppState>>,
|
||
Json(req): Json<ExportRequest>,
|
||
) -> ApiResult<Json<ExportResponse>> {
|
||
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<Arc<AppState>>,
|
||
mut multipart: axum::extract::Multipart,
|
||
) -> ApiResult<Json<StandardPaper>> {
|
||
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<bool>,
|
||
}
|
||
|
||
pub async fn mark_no_resource(
|
||
State(state): State<Arc<AppState>>,
|
||
Json(req): Json<MarkNoResourceRequest>,
|
||
) -> ApiResult<Json<StandardPaper>> {
|
||
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<String>,
|
||
}
|
||
|
||
pub async fn get_active_bibcode(State(state): State<Arc<AppState>>) -> Json<ActiveBibcodeResponse> {
|
||
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<String>,
|
||
}
|
||
|
||
pub async fn set_active_bibcode(
|
||
State(state): State<Arc<AppState>>,
|
||
Json(req): Json<SetActiveBibcodeRequest>,
|
||
) -> 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<Arc<AppState>>,
|
||
Json(req): Json<EmbedRequest>,
|
||
) -> ApiResult<Json<EmbedResponse>> {
|
||
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 }))
|
||
}
|