AstroResearch/src/api/papers.rs
Asfmq c5fd5b0d66 refactor: 全栈质量硬化
后端:
  - 权限系统重写: 全局→按 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 状态批处理
2026-06-28 14:40:26 +08:00

749 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, &params.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, &params.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(&params.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(&params.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, &params.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, &params.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 }))
}