forked from admin/PlantAI-rag-new
feat(review): tectonic PDF compile API (P3a) + rename journals panel
- POST/GET /api/review/compile[/{id}[/pdf|/log]]: isolated workdir,
--untrusted, 60s timeout with process-group kill, single-slot queue
(429 QUEUE_FULL), 7-day retention sweep
- tools/install_tectonic.sh: pinned tectonic 0.17.0 (sha256) + README
- Rename menu/panel title to 学术期刊情报
- Compile UI stays a placeholder per product decision (P3 deferred)
This commit is contained in:
@@ -15,7 +15,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import get_settings
|
||||
from .db import close_db, init_db
|
||||
from .routers import conferences, format_check, journal_directory, journals, presubmit, reformat
|
||||
from .routers import (
|
||||
compile,
|
||||
conferences,
|
||||
format_check,
|
||||
journal_directory,
|
||||
journals,
|
||||
presubmit,
|
||||
reformat,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,6 +46,12 @@ def create_app() -> FastAPI:
|
||||
# journals 推荐依赖独立 SQLite:启动即建表(幂等;测试可先 set_path 注入
|
||||
# tmp 路径,本函数沿用注入路径)
|
||||
await init_db()
|
||||
# 编译产物过期清扫:启动跑一次(失败不阻塞启动)
|
||||
try:
|
||||
from .services.latex_compile import sweep_expired
|
||||
sweep_expired()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("compile sweep 启动清扫失败(忽略)", exc_info=True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -69,6 +83,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(conferences.router)
|
||||
app.include_router(journal_directory.router)
|
||||
app.include_router(reformat.router)
|
||||
app.include_router(compile.router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""routers/compile.py —— LaTeX 编译 PDF(P3a:tectonic)。
|
||||
|
||||
端点:
|
||||
POST /api/review/compile multipart:file(.tex 可选) | job_id(可选,二选一)
|
||||
→ {ok, compile_id, pdf_url, log_url, meta}
|
||||
GET /api/review/compile/{cid} 编译元信息(meta.json)
|
||||
GET /api/review/compile/{cid}/pdf output.pdf(FileResponse)
|
||||
GET /api/review/compile/{cid}/log output.log(text/plain)
|
||||
|
||||
信封:业务错误 = HTTP 200 + {ok:false,error:{code,message,detail?,degraded:false}}
|
||||
(QUEUE_FULL 例外:HTTP 429 + 同款信封体,前端仍按 ok/error 单一口径处理)。
|
||||
detail = log 尾 200 行(COMPILE_FAILED / TIMEOUT)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import get_settings
|
||||
from ..services import latex_compile as lc
|
||||
from ..services.latex_compile import (
|
||||
CompileError,
|
||||
QueueFullError,
|
||||
ToolUnavailableError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/review", tags=["review-compile"])
|
||||
|
||||
_CID_RE = re.compile(r"^[0-9a-f]{12}$")
|
||||
|
||||
|
||||
class CompileResponse(BaseModel):
|
||||
ok: bool
|
||||
compile_id: str | None = None
|
||||
pdf_url: str | None = None
|
||||
log_url: str | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _error(code: str, message: str, detail: str = "") -> CompileResponse:
|
||||
err: dict[str, Any] = {"code": code, "message": message, "degraded": False}
|
||||
if detail:
|
||||
err["detail"] = detail
|
||||
return CompileResponse(ok=False, error=err)
|
||||
|
||||
|
||||
def _jobs_root() -> Path:
|
||||
d = get_settings().reformat_dir_abs
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.post("/compile", response_model=CompileResponse)
|
||||
async def compile_tex(
|
||||
file: UploadFile | None = File(None, description="LaTeX 源码(.tex,与 job_id 二选一)"),
|
||||
job_id: str = Form("", description="P1 重排版任务 id(优先于 file)"),
|
||||
) -> CompileResponse:
|
||||
settings = get_settings()
|
||||
|
||||
# ---- 输入解析:job_id 优先,否则直接上传的 .tex ----
|
||||
jid = (job_id or "").strip()
|
||||
if jid:
|
||||
if not _CID_RE.fullmatch(jid):
|
||||
return _error("VALIDATION", "job_id 格式非法(应为 12 位 hex)")
|
||||
src = _jobs_root() / f"{jid}.tex"
|
||||
if not src.is_file():
|
||||
return _error("VALIDATION", "job_id 对应的重排版结果不存在,请重新排版")
|
||||
try:
|
||||
source = src.read_text("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return _error("VALIDATION", "任务源码不是合法 UTF-8")
|
||||
anchor_id = jid
|
||||
else:
|
||||
if file is None:
|
||||
return _error("VALIDATION", "必须提供 file(.tex)或 job_id")
|
||||
filename = file.filename or "paper.tex"
|
||||
if Path(filename).name != filename or ".." in filename:
|
||||
return _error("VALIDATION", "非法文件名")
|
||||
if not filename.lower().endswith(".tex"):
|
||||
return _error("VALIDATION", "仅支持 .tex 文件")
|
||||
content = await file.read()
|
||||
if not content:
|
||||
return _error("VALIDATION", "上传文件为空")
|
||||
if len(content) > settings.max_tex_bytes:
|
||||
return _error("VALIDATION", "源码超过 2MB 上限")
|
||||
try:
|
||||
source = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return _error("VALIDATION", "源码必须为 UTF-8 编码")
|
||||
anchor_id = uuid.uuid4().hex[:12]
|
||||
|
||||
# ---- 编译(隔离目录;失败信封带 log 尾)----
|
||||
lc.prepare_input(anchor_id, source)
|
||||
try:
|
||||
result = await lc.compile_tex(anchor_id)
|
||||
except ToolUnavailableError as exc:
|
||||
return _error("TOOL_UNAVAILABLE", str(exc))
|
||||
except QueueFullError as exc:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"ok": False,
|
||||
"compile_id": None, "pdf_url": None, "log_url": None, "meta": None,
|
||||
"error": {"code": "QUEUE_FULL", "message": str(exc), "degraded": False},
|
||||
},
|
||||
)
|
||||
except CompileError as exc:
|
||||
return _error(exc.code, str(exc), detail=exc.log_tail)
|
||||
|
||||
meta_path = lc.compile_dir(anchor_id) / "meta.json"
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
meta = {"compile_id": anchor_id, "seconds": result["seconds"]}
|
||||
return CompileResponse(
|
||||
ok=True,
|
||||
compile_id=anchor_id,
|
||||
pdf_url=f"/api/review/compile/{anchor_id}/pdf",
|
||||
log_url=f"/api/review/compile/{anchor_id}/log",
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
def _cid_dir(cid: str) -> Path | None:
|
||||
if not _CID_RE.fullmatch(cid or ""):
|
||||
return None
|
||||
d = lc.compile_dir(cid)
|
||||
return d if d.is_dir() else None
|
||||
|
||||
|
||||
@router.get("/compile/{compile_id}", response_model=CompileResponse)
|
||||
async def get_compile(compile_id: str) -> CompileResponse:
|
||||
cdir = _cid_dir(compile_id)
|
||||
meta_path = cdir / "meta.json" if cdir else None
|
||||
if meta_path is None or not meta_path.is_file():
|
||||
return _error("NOT_FOUND", "编译任务不存在或已过期,请重新编译")
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return _error("NOT_FOUND", "编译产物损坏,请重新编译")
|
||||
return CompileResponse(
|
||||
ok=True,
|
||||
compile_id=compile_id,
|
||||
pdf_url=f"/api/review/compile/{compile_id}/pdf",
|
||||
log_url=f"/api/review/compile/{compile_id}/log",
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/compile/{compile_id}/pdf", response_model=None)
|
||||
async def download_compile_pdf(compile_id: str) -> FileResponse | JSONResponse:
|
||||
cdir = _cid_dir(compile_id)
|
||||
pdf = cdir / "output.pdf" if cdir else None
|
||||
if pdf is None or not pdf.is_file():
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"ok": False, "error": {
|
||||
"code": "NOT_FOUND", "message": "PDF 不存在或已过期", "degraded": False}},
|
||||
)
|
||||
return FileResponse(pdf, media_type="application/pdf", filename=f"{compile_id}.pdf")
|
||||
|
||||
|
||||
@router.get("/compile/{compile_id}/log", response_model=None)
|
||||
async def download_compile_log(compile_id: str) -> PlainTextResponse | JSONResponse:
|
||||
cdir = _cid_dir(compile_id)
|
||||
log = cdir / "output.log" if cdir else None
|
||||
if log is None or not log.is_file():
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"ok": False, "error": {
|
||||
"code": "NOT_FOUND", "message": "日志不存在或已过期", "degraded": False}},
|
||||
)
|
||||
return PlainTextResponse(log.read_text("utf-8", errors="replace"))
|
||||
@@ -0,0 +1,236 @@
|
||||
"""services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a)。
|
||||
|
||||
隔离与安全(对齐设计清单):
|
||||
1. 一编译一目录:{reformat_dir}/{anchor_id}/compile/(input.tex/output.pdf/output.log/meta.json)
|
||||
2. 文件名永不落盘:用户文件名只做后缀校验,写入一律 input.tex
|
||||
3. tectonic --untrusted(官方旗标:禁用已知不安全特性,含 shell escape 路径)
|
||||
4. 子进程 start_new_session + 超时 killpg(SIGKILL)(整进程组)
|
||||
5. 单槽队列:asyncio.Lock + pending 计数,超出 compile_queue_limit → QueueFullError
|
||||
6. 产物 PDF > max_compile_pdf_bytes → 删文件 + OutputTooLargeError
|
||||
7. 启动/每次编译前机会性清扫 retention_days 天前的 job 目录(只删 12 位 hex 锚点目录)
|
||||
|
||||
tectonic 二进制缺失 → ToolUnavailableError(路由层回安装指引)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ANCHOR_ID_RE = re.compile(r"^[0-9a-f]{12}$")
|
||||
_ENGINE = "tectonic-p3/0.17.0"
|
||||
_LOG_TAIL_LINES = 200
|
||||
|
||||
# 单槽并发控制(进程内;重启归零可接受——本服务无持久队列需求)
|
||||
_lock = asyncio.Lock()
|
||||
_pending = 0
|
||||
|
||||
|
||||
class CompileError(Exception):
|
||||
"""编译失败(含 log 尾)。code ∈ {COMPILE_FAILED, TIMEOUT, OUTPUT_TOO_LARGE}。"""
|
||||
|
||||
def __init__(self, code: str, message: str, log_tail: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.log_tail = log_tail
|
||||
|
||||
|
||||
class QueueFullError(Exception):
|
||||
"""等待槽位超限。"""
|
||||
|
||||
|
||||
class ToolUnavailableError(Exception):
|
||||
"""tectonic 二进制缺失/不可执行。"""
|
||||
|
||||
|
||||
def _tectonic_bin() -> Path:
|
||||
return get_settings().tectonic_abs
|
||||
|
||||
|
||||
def _ensure_tool() -> Path:
|
||||
bin_path = _tectonic_bin()
|
||||
if not (bin_path.is_file() and os.access(bin_path, os.X_OK)):
|
||||
raise ToolUnavailableError(
|
||||
f"tectonic 未安装({bin_path})。"
|
||||
"请执行:cd review_service && bash tools/install_tectonic.sh"
|
||||
)
|
||||
return bin_path
|
||||
|
||||
|
||||
def _log_tail(path: Path, lines: int = _LOG_TAIL_LINES) -> str:
|
||||
try:
|
||||
text = path.read_text("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
return "\n".join(text.splitlines()[-lines:])
|
||||
|
||||
|
||||
def compile_dir(anchor_id: str) -> Path:
|
||||
"""{reformat_dir}/{anchor_id}/compile/(存在性由调用方按需 mkdir)。"""
|
||||
return get_settings().reformat_dir_abs / anchor_id / "compile"
|
||||
|
||||
|
||||
def prepare_input(anchor_id: str, source: str) -> Path:
|
||||
"""写入隔离目录 input.tex;返回其路径。"""
|
||||
cdir = compile_dir(anchor_id)
|
||||
cdir.mkdir(parents=True, exist_ok=True)
|
||||
input_tex = cdir / "input.tex"
|
||||
input_tex.write_text(source, encoding="utf-8")
|
||||
return input_tex
|
||||
|
||||
|
||||
async def compile_tex(anchor_id: str) -> dict[str, Any]:
|
||||
"""同步编译 anchor 的 input.tex(须先 prepare_input)。
|
||||
|
||||
→ {compile_id: anchor_id, pdf_path, log_path, seconds, pdf_bytes}
|
||||
失败抛 CompileError / QueueFullError / ToolUnavailableError。
|
||||
"""
|
||||
global _pending
|
||||
settings = get_settings()
|
||||
_ensure_tool() # 快速失败:不占队列
|
||||
sweep_expired()
|
||||
|
||||
if _pending >= settings.compile_queue_limit:
|
||||
raise QueueFullError(
|
||||
f"编译队列已满({_pending} 个任务排队),请稍后重试"
|
||||
)
|
||||
_pending += 1
|
||||
try:
|
||||
async with _lock:
|
||||
return await _run_once(anchor_id)
|
||||
finally:
|
||||
_pending -= 1
|
||||
|
||||
|
||||
async def _run_once(anchor_id: str) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
cdir = compile_dir(anchor_id)
|
||||
input_tex = cdir / "input.tex"
|
||||
pdf_path = cdir / "output.pdf"
|
||||
log_path = cdir / "output.log"
|
||||
if not input_tex.is_file():
|
||||
raise CompileError("COMPILE_FAILED", "编译输入不存在,请重新上传源码")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["TECTONIC_CACHE_DIR"] = str(settings.tectonic_cache_abs)
|
||||
cmd = [
|
||||
str(_tectonic_bin()), "-X", "compile", input_tex.name,
|
||||
"--untrusted", "--keep-logs", "--outdir", ".",
|
||||
]
|
||||
started = time.monotonic()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, cwd=cdir, env=env,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
proc.communicate(), timeout=settings.compile_timeout_s
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
_kill_group(proc)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
log_path.write_text(_log_tail(log_path) + "\n[timeout] 编译超时,进程已终止",
|
||||
encoding="utf-8")
|
||||
raise CompileError(
|
||||
"TIMEOUT",
|
||||
f"编译超过 {settings.compile_timeout_s:g} 秒未完成,已终止",
|
||||
_log_tail(log_path),
|
||||
) from None
|
||||
except FileNotFoundError as exc:
|
||||
raise ToolUnavailableError(f"tectonic 不可执行:{exc}") from exc
|
||||
finally:
|
||||
seconds = round(time.monotonic() - started, 2)
|
||||
|
||||
# tectonic --keep-logs 把日志写在 cwd 的 input.log
|
||||
produced_log = cdir / "input.log"
|
||||
if produced_log.is_file():
|
||||
shutil.move(str(produced_log), str(log_path))
|
||||
if not log_path.is_file():
|
||||
log_path.write_text("", encoding="utf-8")
|
||||
|
||||
produced_pdf = cdir / "input.pdf"
|
||||
if not produced_pdf.is_file():
|
||||
raise CompileError(
|
||||
"COMPILE_FAILED",
|
||||
"编译失败(未产出 PDF),请查看日志定位 LaTeX 错误",
|
||||
_log_tail(log_path),
|
||||
)
|
||||
pdf_bytes = produced_pdf.stat().st_size
|
||||
if pdf_bytes > settings.max_compile_pdf_bytes:
|
||||
produced_pdf.unlink(missing_ok=True)
|
||||
raise CompileError(
|
||||
"OUTPUT_TOO_LARGE",
|
||||
f"产出 PDF 超过 {settings.max_compile_pdf_bytes // (1024 * 1024)}MB 上限,已删除",
|
||||
_log_tail(log_path),
|
||||
)
|
||||
produced_pdf.rename(pdf_path)
|
||||
|
||||
meta = {
|
||||
"compile_id": anchor_id,
|
||||
"engine": _ENGINE,
|
||||
"seconds": seconds,
|
||||
"pdf_bytes": pdf_bytes,
|
||||
"returncode": 0,
|
||||
"compiled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
(cdir / "meta.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
logger.info("compile: %s → PDF %d bytes, %.2fs", anchor_id, pdf_bytes, seconds)
|
||||
return {
|
||||
"compile_id": anchor_id,
|
||||
"pdf_path": pdf_path,
|
||||
"log_path": log_path,
|
||||
"seconds": seconds,
|
||||
"pdf_bytes": pdf_bytes,
|
||||
}
|
||||
|
||||
|
||||
def _kill_group(proc: asyncio.subprocess.Process) -> None:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def sweep_expired() -> int:
|
||||
"""删除 mtime 超 retention_days 的 12-hex 锚点目录(编译产物+P1 落盘一并清)。"""
|
||||
settings = get_settings()
|
||||
base = settings.reformat_dir_abs
|
||||
if not base.is_dir():
|
||||
return 0
|
||||
cutoff = time.time() - settings.compile_retention_days * 86400
|
||||
removed = 0
|
||||
for child in base.iterdir():
|
||||
if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
|
||||
continue
|
||||
try:
|
||||
if child.stat().st_mtime < cutoff:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
if removed:
|
||||
logger.info("compile sweep: 清理 %d 个过期任务目录(>%d 天)",
|
||||
removed, settings.compile_retention_days)
|
||||
return removed
|
||||
@@ -0,0 +1,228 @@
|
||||
"""tests/test_compile.py —— P3a LaTeX 编译单元/集成测试。
|
||||
|
||||
真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过,
|
||||
校验/队列/清扫等纯逻辑用例照常运行。
|
||||
运行: cd review_service && <venv>/bin/python -m pytest -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.main import app
|
||||
from app.services import latex_compile as lc
|
||||
|
||||
MINIMAL = "\\documentclass[11pt]{article}\n\\begin{document}\nhello p3\n\\end{document}\n"
|
||||
|
||||
_TECTONIC_OK = get_settings().tectonic_abs.is_file()
|
||||
|
||||
needs_tectonic = pytest.mark.skipif(
|
||||
not _TECTONIC_OK, reason="tools/tectonic 未安装(bash tools/install_tectonic.sh)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
|
||||
# 缓存目录不注入 tmp:用部署预热好的 data/tectonic-cache(否则每次测试重拉 bundle)
|
||||
monkeypatch.setattr(settings, "compile_timeout_s", 60.0)
|
||||
monkeypatch.setattr(lc, "_pending", 0)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ================================================================ 校验(无需 tectonic)
|
||||
|
||||
def test_validation_no_input(client: TestClient):
|
||||
r = client.post("/api/review/compile", data={})
|
||||
body = r.json()
|
||||
assert r.status_code == 200 and body["ok"] is False
|
||||
assert body["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_bad_job_id(client: TestClient):
|
||||
r = client.post("/api/review/compile", data={"job_id": "../evil"})
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_job_not_found(client: TestClient):
|
||||
r = client.post("/api/review/compile", data={"job_id": "0123456789ab"})
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_path_traversal_filename(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("../evil.tex", b"x", "application/x-tex")},
|
||||
)
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_non_tex(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.pdf", b"x", "application/pdf")},
|
||||
)
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
def test_validation_oversize(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "max_tex_bytes", 10)
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.tex", b"01234567890", "application/x-tex")},
|
||||
)
|
||||
assert r.json()["error"]["code"] == "VALIDATION"
|
||||
|
||||
|
||||
# ================================================================ 编译(真实 tectonic)
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_happy_path(client: TestClient):
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200 and body["ok"] is True, body
|
||||
cid = body["compile_id"]
|
||||
assert len(cid) == 12
|
||||
|
||||
pdf = client.get(body["pdf_url"])
|
||||
assert pdf.status_code == 200
|
||||
assert pdf.headers["content-type"].startswith("application/pdf")
|
||||
assert pdf.content[:5] == b"%PDF-"
|
||||
|
||||
log = client.get(body["log_url"])
|
||||
assert log.status_code == 200
|
||||
assert "LaTeX2e" in log.text and "Output written" in log.text
|
||||
|
||||
meta = client.get(f"/api/review/compile/{cid}")
|
||||
assert meta.json()["ok"] is True
|
||||
assert meta.json()["meta"]["pdf_bytes"] > 0
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_by_job_id(client: TestClient):
|
||||
tex = (
|
||||
"\\documentclass[11pt]{article}\n\\begin{document}\nvia job\n\\end{document}\n"
|
||||
)
|
||||
rf = client.post(
|
||||
"/api/review/reformat",
|
||||
files={"file": ("paper.tex", tex.encode(), "application/x-tex")},
|
||||
data={"target_journal": "default"},
|
||||
).json()
|
||||
assert rf["ok"] is True
|
||||
r = client.post("/api/review/compile", data={"job_id": rf["job_id"]})
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["compile_id"] == rf["job_id"]
|
||||
pdf = client.get(body["pdf_url"])
|
||||
assert pdf.content[:5] == b"%PDF-"
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_malicious_write18(client: TestClient, tmp_path):
|
||||
canary = tmp_path / "canary_p3"
|
||||
evil = (
|
||||
"\\documentclass{article}\n"
|
||||
f"\\write18{{touch {canary}}}\n"
|
||||
"\\begin{document}\nno shell please\n\\end{document}\n"
|
||||
)
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("evil.tex", evil.encode(), "application/x-tex")},
|
||||
)
|
||||
body = r.json()
|
||||
# 系统无损即通过(信封 ok 与否均可):canary 绝不能出现
|
||||
assert not canary.exists(), r"\write18 竟然执行了!"
|
||||
if body["ok"]:
|
||||
pdf = client.get(body["pdf_url"])
|
||||
assert pdf.content[:5] == b"%PDF-"
|
||||
else:
|
||||
assert body["error"]["code"] in ("COMPILE_FAILED", "TIMEOUT")
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_failed_returns_log_tail(client: TestClient):
|
||||
bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("bad.tex", bad.encode(), "application/x-tex")},
|
||||
)
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "COMPILE_FAILED"
|
||||
assert "log" in body["error"]["message"].lower() or body["error"].get("detail")
|
||||
|
||||
|
||||
@needs_tectonic
|
||||
def test_compile_timeout(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "compile_timeout_s", 5.0)
|
||||
loop_tex = (
|
||||
"\\documentclass{article}\n\\begin{document}\n"
|
||||
"\\count255=0\n\\loop\\iftrue\\advance\\count255 by 1\\repeat\n"
|
||||
"\\end{document}\n"
|
||||
)
|
||||
started = time.monotonic()
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("loop.tex", loop_tex.encode(), "application/x-tex")},
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
body = r.json()
|
||||
assert body["ok"] is False
|
||||
assert body["error"]["code"] == "TIMEOUT"
|
||||
assert elapsed < 15.0, f"超时回收过慢:{elapsed:.1f}s"
|
||||
|
||||
|
||||
# ================================================================ 队列 / 清扫
|
||||
|
||||
def test_queue_full(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(lc, "_pending", 5)
|
||||
monkeypatch.setattr(get_settings(), "compile_queue_limit", 5)
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
|
||||
)
|
||||
assert r.status_code == 429
|
||||
body = r.json()
|
||||
assert body["ok"] is False and body["error"]["code"] == "QUEUE_FULL"
|
||||
|
||||
|
||||
def test_tool_unavailable(client: TestClient, monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "tectonic_path", "tools/definitely-missing")
|
||||
r = client.post(
|
||||
"/api/review/compile",
|
||||
files={"file": ("paper.tex", MINIMAL.encode(), "application/x-tex")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["error"]["code"] == "TOOL_UNAVAILABLE"
|
||||
assert "install_tectonic" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_sweep_expired(tmp_path, monkeypatch):
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
|
||||
base = tmp_path / "jobs"
|
||||
stale = base / "aabbccddeeff"
|
||||
stale.mkdir(parents=True)
|
||||
(stale / "output.pdf").write_bytes(b"%PDF-fake")
|
||||
old = time.time() - 8 * 86400
|
||||
os.utime(stale, (old, old))
|
||||
|
||||
fresh = base / "112233445566"
|
||||
fresh.mkdir()
|
||||
noise = base / "not-a-job"
|
||||
noise.mkdir()
|
||||
|
||||
removed = lc.sweep_expired()
|
||||
assert removed == 1
|
||||
assert not stale.exists()
|
||||
assert fresh.exists()
|
||||
assert noise.exists()
|
||||
@@ -0,0 +1,13 @@
|
||||
# tools/ —— 编译器工具链(P3)
|
||||
|
||||
## tectonic(LaTeX → PDF)
|
||||
|
||||
- 单文件 musl 静态二进制,无 root、无系统 TeX Live 依赖;版本与 sha256 钉死在
|
||||
`install_tectonic.sh`(本目录二进制不入 git)。
|
||||
- 安装:`cd review_service && bash tools/install_tectonic.sh`
|
||||
部署树(zh)同一条命令;或直接 scp `tools/tectonic` + `data/tectonic-cache/`。
|
||||
- 宏包缓存:`data/tectonic-cache/`(环境变量 `REVIEW_TECTONIC_CACHE_DIR` 覆盖)。
|
||||
首次编译会联网拉 bundle(~200MB),建议部署时用 hello.tex 预热:
|
||||
`TECTONIC_CACHE_DIR=$PWD/data/tectonic-cache tools/tectonic -X compile hello.tex --untrusted`
|
||||
- 安全:编译一律 `--untrusted`(tectonic 官方旗标,禁用已知不安全特性)+
|
||||
运行于隔离工作目录 + 60s 超时整组 kill + 禁 shell escape。
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# install_tectonic.sh —— 一键安装/校验 tectonic 编译器(P3a)。
|
||||
# 幂等:已装且 sha256 匹配则直接退出 0。部署树(zh)同一条命令。
|
||||
# 用法:cd review_service && bash tools/install_tectonic.sh
|
||||
set -euo pipefail
|
||||
|
||||
TECTONIC_VERSION="0.17.0"
|
||||
TECTONIC_TARBALL_SHA256="8533d07f9ccbd7a65824b9e0459041bca34af1eb33daba48f59215593753a3b7"
|
||||
TECTONIC_BIN_SHA256="a98aa59ad5c1df39a6c9e56cbfc5088f2b11d6c179c0130b97998e4bd46a46da"
|
||||
URL="https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40${TECTONIC_VERSION}/tectonic-${TECTONIC_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BIN="${HERE}/tectonic"
|
||||
|
||||
if [[ -x "$BIN" ]] && echo "$(sha256sum "$BIN" | cut -d' ' -f1)" = "$TECTONIC_BIN_SHA256"; then
|
||||
echo "[install_tectonic] 已安装且校验通过:$BIN ($("$BIN" --version))"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
echo "[install_tectonic] 下载 tectonic ${TECTONIC_VERSION}(musl 静态版)…"
|
||||
curl -fL --retry 3 -o "$tmp/tectonic.tar.gz" "$URL"
|
||||
|
||||
echo "[install_tectonic] 校验 tarball sha256…"
|
||||
echo "${TECTONIC_TARBALL_SHA256} $tmp/tectonic.tar.gz" | sha256sum -c -
|
||||
|
||||
tar -xzf "$tmp/tectonic.tar.gz" -C "$tmp" tectonic
|
||||
echo "[install_tectonic] 校验二进制 sha256…"
|
||||
echo "${TECTONIC_BIN_SHA256} $tmp/tectonic" | sha256sum -c -
|
||||
|
||||
install -m 0755 "$tmp/tectonic" "$BIN"
|
||||
echo "[install_tectonic] 完成:$BIN ($("$BIN" --version))"
|
||||
echo "[install_tectonic] 提示:首次真实编译会联网拉宏包 bundle(约 200MB),"
|
||||
echo " 服务端 TECTONIC_CACHE_DIR 默认 data/tectonic-cache,建议部署时预热。"
|
||||
@@ -10,7 +10,7 @@ const app = useAppStore();
|
||||
const edu = useEduStore();
|
||||
|
||||
const ITEMS = [
|
||||
{ key: 'journals', label: '学术期刊与会议情报', icon: '📰' },
|
||||
{ key: 'journals', label: '学术期刊情报', icon: '📰' },
|
||||
{ key: 'recommend', label: '智能投稿推荐', icon: '🎯' },
|
||||
{ key: 'presubmit', label: 'AI 预审稿系统', icon: '🔍' },
|
||||
{ key: 'report', label: '专业审稿意见报告', icon: '📝' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- JournalsPanel.vue —— ① 学术期刊与会议情报(步骤5 目录化改造,D2 严格只读)。
|
||||
<!-- JournalsPanel.vue —— ① 学术期刊情报(步骤5 目录化改造,D2 严格只读)。
|
||||
数据链:onMounted → edu.fetchDirectory() → review.js getDirectory()
|
||||
→ GET /api/review/journals/directory(相对路径 → vite proxy → BFF → 8100)。
|
||||
双 Tab 强制单语(🌍 en 779 本 / 🇨🇳 zh 277 本,后端 lang 过滤保证永不混排);
|
||||
@@ -34,12 +34,13 @@ const LOCAL_NOTE = {
|
||||
en: '分区:2025年中科院公开表(多表比对,2026年起停更);JIF:2025年度指标(2026年JCR发布,两份公开汇编一致或已核对出版社官方指标);整理日期2026-10-10;空白=来源未给值≠0',
|
||||
zh: '指标:国内自然科学期刊年报2025版(统计年2024,与JIF统计源不同、不混排);中文期刊不按中科院分区口径列示',
|
||||
};
|
||||
/* 底部数据源声明(表C 长版,拍板决策3) */
|
||||
/* 底部数据源声明(表C 长版,拍板决策3;末尾补来源口径说明) */
|
||||
const FOOT_NOTE =
|
||||
'JIF 取自 2025 年度指标(2026 年 JCR 发布)并经两份公开汇编交叉一致或出版社官方指标核对,' +
|
||||
'中科院分区采用 2025 版公开表(多表比对、2026 年起停更),' +
|
||||
'中文期刊复合/综合影响因子取自国内年报 2025 版(统计年 2024,与 JIF 统计源不同、不混排),' +
|
||||
'期刊官网均附核对状态(3 本中文刊未检得可靠官网留空待核实)。';
|
||||
'期刊官网均附核对状态(3 本中文刊未检得可靠官网留空待核实)。' +
|
||||
'分区为高校转载版,官方入口需机构登录;JIF/中文数据为公开汇编镜像,仅供参考核对。';
|
||||
|
||||
/* —— 凭证状态配色:可靠=绿 / 未逐站复核=浅绿 / 未检得·待核实=橙 —— */
|
||||
const JIF_OK = new Set(['两份公开汇编一致', '已核对出版社2025指标']);
|
||||
@@ -159,7 +160,7 @@ onUnmounted(() => clearTimeout(searchTimer));
|
||||
<section class="jp">
|
||||
<!-- 头部:标题 + 共 N 本(meta 与当前 Tab 匹配才回显,防切 Tab 瞬间旧计数) -->
|
||||
<header class="jp-head">
|
||||
<h1 class="jp-title">学术期刊与会议情报</h1>
|
||||
<h1 class="jp-title">学术期刊情报</h1>
|
||||
<span class="jp-count">共 {{ countText }} 本</span>
|
||||
</header>
|
||||
|
||||
@@ -338,14 +339,14 @@ onUnmounted(() => clearTimeout(searchTimer));
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">来源</span>
|
||||
<a v-if="j.jif_source_url" class="jp-link" :href="j.jif_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
JIF来源:{{ shortUrl(j.jif_source_url) }}
|
||||
</a>
|
||||
<template v-if="j.jif_source_url">
|
||||
<a class="jp-link" :href="j.jif_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
JIF来源:{{ shortUrl(j.jif_source_url) }}
|
||||
</a>
|
||||
<span class="jp-src-note">(汇编镜像,官方入口需机构订阅)</span>
|
||||
</template>
|
||||
<span v-else class="jp-dash">JIF来源:无</span>
|
||||
<a v-if="j.partition_source_url" class="jp-link" :href="j.partition_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
分区来源:{{ shortUrl(j.partition_source_url) }}
|
||||
</a>
|
||||
<span v-else class="jp-dash">分区来源:无</span>
|
||||
<span class="jp-src-note">分区来源:{{ j.partition_source_note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- zh 详情:年报口径指标 + 不可混排黄字 -->
|
||||
@@ -368,18 +369,21 @@ onUnmounted(() => clearTimeout(searchTimer));
|
||||
</div>
|
||||
<div class="jp-xp-group">
|
||||
<span class="jp-xp-label">来源</span>
|
||||
<a v-if="j.data_source_url" class="jp-link" :href="j.data_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
年报原页:{{ shortUrl(j.data_source_url) }}
|
||||
</a>
|
||||
<template v-if="j.data_source_url">
|
||||
<a class="jp-link" :href="j.data_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
年报原页:{{ shortUrl(j.data_source_url) }}
|
||||
</a>
|
||||
<span class="jp-src-note">(国内自然科学期刊年报 2025 版)</span>
|
||||
</template>
|
||||
<span v-else class="jp-dash">年报原页:无</span>
|
||||
<a v-if="j.jif_source_url" class="jp-link" :href="j.jif_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
JIF来源:{{ shortUrl(j.jif_source_url) }}
|
||||
</a>
|
||||
<template v-if="j.jif_source_url">
|
||||
<a class="jp-link" :href="j.jif_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
JIF来源:{{ shortUrl(j.jif_source_url) }}
|
||||
</a>
|
||||
<span class="jp-src-note">(汇编镜像,官方入口需机构订阅)</span>
|
||||
</template>
|
||||
<span v-else class="jp-dash">JIF来源:无</span>
|
||||
<a v-if="j.partition_source_url" class="jp-link" :href="j.partition_source_url" target="_blank" rel="noopener" @click.stop>
|
||||
分区来源:{{ shortUrl(j.partition_source_url) }}
|
||||
</a>
|
||||
<span v-else class="jp-dash">分区来源:无</span>
|
||||
<span class="jp-src-note">分区来源:{{ j.partition_source_note }}</span>
|
||||
</div>
|
||||
<p class="jp-xp-note">⚠ 国内IF与JIF统计源不同,不可混排</p>
|
||||
</div>
|
||||
@@ -585,6 +589,11 @@ onUnmounted(() => clearTimeout(searchTimer));
|
||||
.jp-dash {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
/* 来源口径说明(灰色小字):分区常量文案 + 链接后括号补充 */
|
||||
.jp-src-note {
|
||||
font-size: 12px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
/* —— 列宽白名单(table 自适应 + min-width,不硬截断)—— */
|
||||
.jp-c-name { min-width: 200px; }
|
||||
.jp-c-dir { min-width: 140px; }
|
||||
|
||||
Reference in New Issue
Block a user