diff --git a/review_service/app/main.py b/review_service/app/main.py index df24b64..b130fcc 100644 --- a/review_service/app/main.py +++ b/review_service/app/main.py @@ -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 diff --git a/review_service/app/routers/compile.py b/review_service/app/routers/compile.py new file mode 100644 index 0000000..86d61ea --- /dev/null +++ b/review_service/app/routers/compile.py @@ -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")) diff --git a/review_service/app/services/latex_compile.py b/review_service/app/services/latex_compile.py new file mode 100644 index 0000000..b396769 --- /dev/null +++ b/review_service/app/services/latex_compile.py @@ -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 diff --git a/review_service/tests/test_compile.py b/review_service/tests/test_compile.py new file mode 100644 index 0000000..e761b26 --- /dev/null +++ b/review_service/tests/test_compile.py @@ -0,0 +1,228 @@ +"""tests/test_compile.py —— P3a LaTeX 编译单元/集成测试。 + +真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过, +校验/队列/清扫等纯逻辑用例照常运行。 +运行: cd review_service && /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() diff --git a/review_service/tools/README.md b/review_service/tools/README.md new file mode 100644 index 0000000..1c847d9 --- /dev/null +++ b/review_service/tools/README.md @@ -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。 diff --git a/review_service/tools/install_tectonic.sh b/review_service/tools/install_tectonic.sh new file mode 100644 index 0000000..0d05e9b --- /dev/null +++ b/review_service/tools/install_tectonic.sh @@ -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,建议部署时预热。" diff --git a/ui/src/components/edu/EduSideNav.vue b/ui/src/components/edu/EduSideNav.vue index ea68aaa..4e89d4f 100644 --- a/ui/src/components/edu/EduSideNav.vue +++ b/ui/src/components/edu/EduSideNav.vue @@ -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: '📝' }, diff --git a/ui/src/components/edu/panels/JournalsPanel.vue b/ui/src/components/edu/panels/JournalsPanel.vue index c0fef9d..983a7ed 100644 --- a/ui/src/components/edu/panels/JournalsPanel.vue +++ b/ui/src/components/edu/panels/JournalsPanel.vue @@ -1,4 +1,4 @@ -
-

学术期刊与会议情报

+

学术期刊情报

@@ -338,14 +339,14 @@ onUnmounted(() => clearTimeout(searchTimer));
- + - - +
@@ -368,18 +369,21 @@ onUnmounted(() => clearTimeout(searchTimer));
- + - + - - +

⚠ 国内IF与JIF统计源不同,不可混排

@@ -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; }