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:
potaser
2026-10-11 12:19:18 +08:00
parent 42aeb8fa4c
commit 649e66f41d
8 changed files with 744 additions and 23 deletions
+16 -1
View File
@@ -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
+184
View File
@@ -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
+228
View File
@@ -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()
+13
View File
@@ -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。
+36
View File
@@ -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,建议部署时预热。"
+1 -1
View File
@@ -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: '📝' },
+30 -21
View File
@@ -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; }