diff --git a/.gitignore b/.gitignore index b246617..8d3182d 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,12 @@ review_service/data/reformat_jobs/ review_service/tools/tectonic review_service/data/tectonic-cache/ review_service/data/format_cache/ +review_service/data/compile_jobs/ +review_service/data/compile_inspect/ +review_service/prewarm.log # 部署方案文档(含主机凭据,不入库) docs/DEPLOY_PAPER_176.md + +# 一次性诊断脚本(不入库) +scripts/debug_compile.py diff --git a/docs/pr-reports/2026-10-13-p3b-compile-ui.md b/docs/pr-reports/2026-10-13-p3b-compile-ui.md new file mode 100644 index 0000000..04e661b --- /dev/null +++ b/docs/pr-reports/2026-10-13-p3b-compile-ui.md @@ -0,0 +1,111 @@ +# PR 报告:P3b 编译 PDF Tab(论文重排版面板) + +- 日期:2026-10-13 +- 分支:`feature/p3b-compile-ui`(自最新 main 新开;未 commit,按用户约定统一处理) +- 范围:`ui/` 面板 + store/api 扩展;`review_service/` 仅 restart.sh 调起预热脚本 + 新增 + scripts/prewarm_tectonic.sh。**后端 compile.py / latex_compile.py(P3a)零改动**; + 无 sdk/、agent/、vstore/ 改动;无新增依赖。 + +## 概要 + +P3b:论文重排版面板启用"编译 PDF" Tab,实现"上传 .tex → 服务器编译 → 下载 PDF"的完整闭环。 + +## 修改内容 + +| 文件 | 变更 | +|---|---| +| ui/src/components/edu/panels/ReformatPanel.vue | 编译 Tab 状态机:无 job_id 引导 / 编译按钮 / loading 动画 + 首次 1-3 分钟黄条(可关闭)/ 成功 blob URL iframe 预览 + 下载 + 耗时 + 重新编译 / 失败错误摘要 + log 尾 200 行折叠 + 完整 log 下载;按钮不 await,状态全由 store 驱动(切 Tab/切面板编译继续) | +| ui/src/stores/edu.js | compileStatus/compileResult/compileError/compileLogTail + compilePdfUrl(blob URL,fetch 失败静默回退 pdf_url 直链)+ compileFirstHint;submitCompile(loading 重入保护)/ clearCompile(revoke blob);重新排版自动 clearCompile | +| ui/src/api/review.js | CODE_HINTS 增 QUEUE_FULL 固定文案"编译队列已满(最多 5 个任务),请稍后重试"(后端 message 带动态 pending 数,规范统一口径);compileTex() 为 P3a 已有封装 | +| review_service/restart.sh | 启动后后台调起 prewarm_tectonic.sh(nohup 非阻塞,日志 prewarm.log) | +| review_service/scripts/prewarm_tectonic.sh(新增) | 最小 hello.tex 实编译以缓存 TeX 环境。**注**:tectonic v0.17.0 无 `download` 子命令(实测 `no subcommand download`),改用 tools/README 的最小 .tex 预编译法;失败仅告警不影响服务启动 | +| tests/frontend/unit/edu/reformat.compile.test.js(新增) | 6 个用例(真实 reviewErrorMessage 经 importOriginal 保留,QUEUE_FULL 走 CODE_HINTS) | + +## 关键实现决策 + +1. **无异步轮询**:后端编译同步阻塞返回(单槽队列,常规 10-30s,上限 `compile_queue_limit=5`), + 无 stage 可显示 → loading 只做不确定时长旋转动画 + 文案说明。 +2. **QUEUE_FULL**:HTTP 429 + 同款信封,http.js 抛 ApiError(code=QUEUE_FULL); + 前端 CODE_HINTS 固定文案,报错后"重新编译"按钮立即可用(store finally 解锁)。 +3. **PDF blob URL**:成功后异步 `fetch(pdf_url) → blob → createObjectURL`, + 预览/下载不暴露后端路径;jsdom/网络失败回退 pdf_url 直链,不阻塞渲染。 +4. **失败 log**:信封 detail = log 尾 200 行(折叠 `
`);完整 log 下载兜底拼
+ `/api/review/compile/{job_id}/log`(失败信封不带 log_url,但 job_id 即 anchor_id)。
+5. **首次编译提示条**:`compileFirstHint` 在首次 submitCompile 置位,loading 期间显示,
+ 可手动关闭;`clearCompile`(重新排版)复位。
+6. **预热命令修正**:任务原文 `./tools/tectonic -X download default` 在钉死版本
+ v0.17.0 不存在——实测后改为最小 .tex 实编译预热(同 tools/README.md 建议)。
+
+## 测试结果
+
+- 后端 pytest(review_service,后端未动):**113 passed**
+- 前端 vitest 全量:**403 passed / 5 failed**(5F = 既有 labels.js 基线失败
+ benchpanel×4 + Composer pill×1,与本 PR 无关)
+- vitest 新增 reformat.compile.test.js:**6/6 passed**
+ (无 job_id 引导 / 按钮可点 / 编译中+黄条+重入保护 / 成功 iframe+下载+耗时 /
+ 失败 log 折叠 / QUEUE_FULL 文案+重试可用)
+- npm run build:✓ 3.12s
+
+## 手工冒烟(8100 真实链路)
+
+- reformat /tmp/opencode/reformat_sample.tex → ok=True job_id=b3631727af68
+- POST /compile {job_id=b3631727af68} → ok=True seconds=0.61 pdf_bytes=3596
+- GET /compile/{cid}/pdf → 200,3596 bytes,application/pdf(file: PDF 1.5)
+- GET /compile/{cid}/log → 200,3533 bytes
+- job_id 不存在 / 格式非法 → VALIDATION 信封
+- 坏 tex(\thisCommandDoesNotExist)→ COMPILE_FAILED + detail 尾 200 行
+ (含 "! Undefined control sequence")
+- QUEUE_FULL → 429 + 信封(后端路径实测于 P3a;前端文案 vitest 6/6 覆盖)
+- scripts/prewarm_tectonic.sh → exit=0 OK,宏包缓存就绪
+
+## EPS 兼容性修复 v2(2026-10-14 追加)
+
+背景:真实 MDPI 官方 zip(v6.5a)上传编译在 XDV→PDF 阶段失败:
+`xdvipdfmx: image inclusion failed for "Definitions/logo-mdpi.eps"`。
+v1 回退逻辑 gate 在"zip 自带 `*-eps-converted-to.pdf` 预转换版"——真实官方 zip
+**不带**该文件(v1 本地 e2e 通过是因自造样本带了;测试样本与真实样本不一致,教训已记录)。
+
+v2 方案:**不依赖扫描引用与 zip 自带预转换版**,解压后扫描项目内所有 `.eps`
+批量现场转同名 `.pdf`(`latex_compile.process_eps_images`):
+
+1. 工具优先级 **gs → epstopdf → ps2pdf**(`shutil.which` 逐个 fallback);gs 命令
+ 约束1 强制 `-dSAFER -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -dPDFSETTINGS=/prepress`
+ (-dSAFER 防 EPS 内嵌任意 PostScript 执行);无新 Python 依赖(subprocess)
+2. 约束2:单文件转换 **15s 超时** → warning 行 `[eps-convert] xxx.eps 转换超时`
+ 并跳过该文件继续
+3. 约束3:单文件转换后 >20MB → warning;累计转换产物 >100MB →
+ `InspectError("ZIP_TOO_LARGE")` 中止(信封原样透出该 code)
+4. 约束4:`[eps-convert] {src} → {dst} ({in} → {out}, {elapsed}s, {tool})`
+ 写入 `output.log`(正常/超时两条路径都落盘,引擎 log 之后、chatter 之前)
+5. 转换成功 → 显式 `X.eps` 引用字节替换为 `X.pdf`(.tex/.cls/.sty/.cfg/.def 全扫,
+ 不碰编码、不改正文);无扩展名引用 `{.../X}` 由 graphicx 命中新 `X.pdf`
+ (解析顺序 .pdf→…→.eps,规避 .eps)
+6. 全工具失败 → log 行 `[eps-convert] 全部工具失败,图片 xxx.eps 未转换` →
+ 编译自然失败 → 前端提示「项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试」
+
+真实官方 zip 实测(`scripts/debug_compile.py` 重打包 fba37ae3c853 现场 + 8100 HTTP 链路):
+
+| 指标 | 结果 |
+|---|---|
+| 转换 EPS 数 | 2(logo-mdpi.eps、logo-updates.eps,均 gs) |
+| 每个耗时 | 563678→13057B 0.27s;287998→16588B 0.27s(合计 0.54s) |
+| 显式引用改写 | 1 个文件(Definitions/mdpi.cls) |
+| 编译 | 2.83s(debug 脚本)/ 2.99s(HTTP) |
+| 产出 PDF | 70688 / 70687 bytes,7 页,`%PDF-` ✓ |
+
+测试:pytest **175 passed**(原 169 全保留,移除 3 个 v1 语义已变的 EPS 用例,
+新增 9 个 v2 用例:preset 免转换 / 真实 gs 转换 / 工具 fallback / 全失败 /
+超时 warning / 20MB warning / 100MB 中止 / 端到端成功 / 友好报错)。
+
+教训(关键):**外部下载的模板类场景,e2e 必须用真实官方文件**——v1 的自造样本
+带了预转换版,真实官方 zip 不带,导致修复上线后失效。
+
+## 部署验证(部署后执行)
+
+```bash
+bash review_service/restart.sh
+# 观察日志:prewarm_tectonic.sh 后台跑(非阻塞),prewarm.log 出现 "[prewarm] OK"
+# 浏览器 → 教育科研 → 论文自动重排版 → 上传 .tex → 选期刊 → 重排版
+# → 切"编译 PDF" → 点编译
+# 期望:首次 1-3 分钟(若预热已完成则秒级)、后续秒回;失败可见 log 尾 200 行
+```
diff --git a/review_service/app/config.py b/review_service/app/config.py
index 6485685..8b57c5b 100644
--- a/review_service/app/config.py
+++ b/review_service/app/config.py
@@ -62,6 +62,23 @@ class Settings(BaseSettings):
compile_queue_limit: int = 5 # 等待槽位上限(>5 → QUEUE_FULL 429)
compile_retention_days: int = 7 # 编译产物保留天数(后台清扫)
+ # ---- LaTeX 项目 zip 编译(P4:两段式 inspect + run)----
+ max_zip_bytes: int = 50 * 1024 * 1024 # 上传 zip 上限 50MB
+ max_extract_bytes: int = 200 * 1024 * 1024 # 单项目解压总大小硬顶 200MB(实写字节计数)
+ max_zip_entries: int = 2000 # 条目数上限(预扫第一件事)
+ max_zip_entry_bytes: int = 50 * 1024 * 1024 # 单条目解压上限 50MB
+ max_zip_ratio: float = 100.0 # 单条目压缩比 file_size/compress_size 上限
+ max_zip_path_depth: int = 16 # 路径深度上限(深层嵌套拒绝)
+ max_zip_path_len: int = 200 # 单条目路径长度上限
+ compile_project_timeout_s: float = 120.0 # 项目 zip 编译墙钟上限(单文件仍 compile_timeout_s)
+ inspect_dir: str = "data/compile_inspect" # zip 解压暂存(1 小时 TTL,相对 review_service/)
+ inspect_ttl_s: int = 3600 # inspect 暂存过期秒数
+ inspect_sweep_interval_s: float = 300.0 # inspect 后台清扫周期(lifespan 定时任务)
+ compile_jobs_dir: str = "data/compile_jobs" # 项目编译产物(pdf/log/meta,7 天 sweep)
+ failed_project_ttl_s: int = 24 * 3600 # 编译失败保留 project/ 现场秒数(诊断用)
+ keep_failed_projects: bool = False # 失败 project/ 永久保留(调试开关;
+ # 亦读裸环境变量 KEEP_FAILED_PROJECTS=1)
+
@property
def _service_root(self) -> "Path":
# review_service/app/config.py → review_service/(相对本文件定位,不依赖 cwd)
@@ -91,10 +108,21 @@ class Settings(BaseSettings):
return p if p.is_absolute() else self._service_root / p
@property
- def tectonic_cache_abs(self) -> "Path":
+ def tectonic_cache_abs(self) -> Path:
p = Path(self.tectonic_cache_dir)
return p if p.is_absolute() else self._service_root / p
+ @property
+ def inspect_dir_abs(self) -> Path:
+ p = Path(self.inspect_dir)
+ return p if p.is_absolute() else self._service_root / p
+
+ @property
+ def compile_jobs_dir_abs(self) -> Path:
+ p = Path(self.compile_jobs_dir)
+ return p if p.is_absolute() else self._service_root / p
+
+
@property
def vstore_base(self) -> str:
return self.vstore_base_url.rstrip("/")
diff --git a/review_service/app/main.py b/review_service/app/main.py
index 6d3b2c6..c378384 100644
--- a/review_service/app/main.py
+++ b/review_service/app/main.py
@@ -61,6 +61,33 @@ def create_app() -> FastAPI:
sweep_expired()
except Exception: # noqa: BLE001
logger.warning("compile sweep 启动清扫失败(忽略)", exc_info=True)
+ # P4:inspect 暂存(1h TTL)+ 项目编译产物(7 天)启动清扫
+ try:
+ from .services.latex_compile import (
+ sweep_compile_jobs_expired,
+ sweep_inspect_expired,
+ )
+ sweep_inspect_expired()
+ sweep_compile_jobs_expired()
+ except Exception: # noqa: BLE001
+ logger.warning("P4 sweep 启动清扫失败(忽略)", exc_info=True)
+ # P4:inspect 暂存后台定时清扫(默认 300s;补充 1——上传不点编译不堆积)
+ sweep_task: asyncio.Task[None] | None = None
+ try:
+ from .services.latex_compile import sweep_inspect_expired as _sweep_insp
+
+ async def _inspect_sweep_loop() -> None:
+ interval = get_settings().inspect_sweep_interval_s
+ while True:
+ await asyncio.sleep(interval)
+ try:
+ await asyncio.to_thread(_sweep_insp)
+ except Exception: # noqa: BLE001
+ logger.warning("inspect 后台清扫失败(忽略)", exc_info=True)
+
+ sweep_task = asyncio.create_task(_inspect_sweep_loop())
+ except Exception: # noqa: BLE001
+ logger.warning("inspect 后台清扫任务启动失败(忽略)", exc_info=True)
# journal_directory 自动 seed:空表才导(xlsx 解析约 1-3s,不堵 event loop;
# 路径沿用 db.path 注入链——生产=settings.db_path_abs,测试=tmp)
if settings.seed_on_startup:
@@ -88,6 +115,14 @@ def create_app() -> FastAPI:
try:
yield
finally:
+ if sweep_task is not None:
+ sweep_task.cancel()
+ try:
+ await sweep_task
+ except asyncio.CancelledError:
+ pass
+ except Exception: # noqa: BLE001
+ pass
await close_db()
app.router.lifespan_context = _lifespan
diff --git a/review_service/app/routers/compile.py b/review_service/app/routers/compile.py
index 86d61ea..12d1ae5 100644
--- a/review_service/app/routers/compile.py
+++ b/review_service/app/routers/compile.py
@@ -132,6 +132,107 @@ async def compile_tex(
)
+# ================================================================ P4:项目 zip(两段式)
+
+def _p4_error(code: str, message: str, detail: str = "",
+ compile_id: str | None = None,
+ log_url: str | None = None) -> CompileResponse:
+ """P4 错误信封(补充 3:失败也带 compile_id + log_url,前端可下载完整 log)。"""
+ err: dict[str, Any] = {"code": code, "message": message, "degraded": False}
+ if detail:
+ err["detail"] = detail
+ return CompileResponse(
+ ok=False, compile_id=compile_id, log_url=log_url, error=err)
+
+
+def _project_pdf_url(cid: str) -> str:
+ return f"/api/review/compile/zip/{cid}/pdf"
+
+
+def _project_log_url(cid: str) -> str:
+ return f"/api/review/compile/zip/{cid}/log"
+
+
+@router.post("/compile/inspect", response_model=CompileResponse)
+async def inspect_zip_project(
+ file: UploadFile = File(..., description="LaTeX 项目 zip(含 main.tex/.cls/图片/.bib)"),
+) -> CompileResponse:
+ """两段式之①:安全解压 + 主文件识别 → job_id(1 小时 TTL,run 前不占编译队列)。
+
+ 暂用 request.body() 整体缓冲(≤50MB);Phase 2 改流式上传
+ """
+ settings = get_settings()
+ filename = file.filename or "project.zip"
+ if Path(filename).name != filename or ".." in filename:
+ return _p4_error("VALIDATION", "非法文件名")
+ if not filename.lower().endswith(".zip"):
+ return _p4_error("VALIDATION", "仅支持 .zip 文件")
+ # 暂用 request.body() 整体缓冲(≤50MB);Phase 2 改流式上传
+ content = await file.read()
+ if not content:
+ return _p4_error("VALIDATION", "上传文件为空")
+ if len(content) > settings.max_zip_bytes:
+ return _p4_error(
+ "VALIDATION",
+ f"zip 超过 {settings.max_zip_bytes // (1024 * 1024)}MB 上限")
+ try:
+ meta = await lc.inspect_zip(content)
+ except lc.InspectError as exc:
+ return _p4_error(exc.code, exc.message)
+ return CompileResponse(ok=True, meta=meta)
+
+
+class RunZipRequest(BaseModel):
+ job_id: str
+
+
+@router.post("/compile/run", response_model=CompileResponse)
+async def run_zip_compile(payload: RunZipRequest) -> CompileResponse:
+ """两段式之②:原子消费 inspect(rename 防并发双跑)→ tectonic 编译 → pdf/log。
+
+ 队列/工具失败发生在消费**之前** → inspect 保留可重试;
+ 编译失败(COMPILE_FAILED/TIMEOUT)信封仍带 compile_id + log_url(补充 3)。
+ """
+ jid = (payload.job_id or "").strip()
+ if not _CID_RE.fullmatch(jid):
+ return _p4_error(
+ "VALIDATION", "job_id 格式非法(应为 12 位 hex)")
+ try:
+ result = await lc.compile_project_job(jid)
+ except lc.InspectError as exc:
+ return _p4_error(exc.code, exc.message)
+ except lc.ToolUnavailableError as exc:
+ return _p4_error("TOOL_UNAVAILABLE", str(exc))
+ except lc.QueueFullError as exc:
+ # 队列满发生在 consume_inspect 之前 → job 未消费,前端可直接重试
+ 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 lc.CompileError as exc:
+ # 补充 3:失败也带 compile_id + log_url(cid 由 compile_project_job 附加)
+ cid = getattr(exc, "compile_id", None)
+ return _p4_error(
+ exc.code, str(exc), detail=exc.log_tail,
+ compile_id=cid,
+ log_url=_project_log_url(cid) if cid else None)
+ return CompileResponse(
+ ok=True,
+ compile_id=result["compile_id"],
+ pdf_url=_project_pdf_url(result["compile_id"]),
+ log_url=_project_log_url(result["compile_id"]),
+ meta=json.loads((lc.compile_job_dir(result["compile_id"]) / "meta.json")
+ .read_text("utf-8"))
+ if (lc.compile_job_dir(result["compile_id"]) / "meta.json").is_file()
+ else {"compile_id": result["compile_id"], "seconds": result["seconds"]},
+ )
+
+
def _cid_dir(cid: str) -> Path | None:
if not _CID_RE.fullmatch(cid or ""):
return None
@@ -139,6 +240,63 @@ def _cid_dir(cid: str) -> Path | None:
return d if d.is_dir() else None
+def _zip_cid_dir(cid: str) -> Path | None:
+ """P4 项目产物目录 data/compile_jobs/{cid}/(独立于 P3a reformat_dir 树)。"""
+ if not _CID_RE.fullmatch(cid or ""):
+ return None
+ d = lc.compile_job_dir(cid)
+ return d if d.is_dir() else None
+
+
+@router.get("/compile/zip/{compile_id}", response_model=CompileResponse)
+async def get_zip_compile(compile_id: str) -> CompileResponse:
+ cdir = _zip_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 _p4_error("NOT_FOUND", "编译任务不存在或已过期,请重新编译")
+ try:
+ meta = json.loads(meta_path.read_text("utf-8"))
+ except (OSError, ValueError):
+ return _p4_error("NOT_FOUND", "编译产物损坏,请重新编译")
+ return CompileResponse(
+ ok=True,
+ compile_id=compile_id,
+ pdf_url=_project_pdf_url(compile_id),
+ log_url=_project_log_url(compile_id),
+ meta=meta,
+ )
+
+
+@router.get("/compile/zip/{compile_id}/pdf", response_model=None)
+async def download_zip_pdf(compile_id: str) -> FileResponse | JSONResponse:
+ cdir = _zip_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/zip/{compile_id}/log", response_model=None)
+async def download_zip_log(compile_id: str) -> PlainTextResponse | JSONResponse:
+ """补充 3:成功/失败都可下载 output.log。"""
+ cdir = _zip_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"))
+
+
@router.get("/compile/{compile_id}", response_model=CompileResponse)
async def get_compile(compile_id: str) -> CompileResponse:
cdir = _cid_dir(compile_id)
diff --git a/review_service/app/services/latex_compile.py b/review_service/app/services/latex_compile.py
index b396769..ab101a0 100644
--- a/review_service/app/services/latex_compile.py
+++ b/review_service/app/services/latex_compile.py
@@ -1,4 +1,4 @@
-"""services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a)。
+"""services/latex_compile.py —— tectonic 编译 .tex → PDF(P3a + P4 项目 zip)。
隔离与安全(对齐设计清单):
1. 一编译一目录:{reformat_dir}/{anchor_id}/compile/(input.tex/output.pdf/output.log/meta.json)
@@ -9,6 +9,13 @@
6. 产物 PDF > max_compile_pdf_bytes → 删文件 + OutputTooLargeError
7. 启动/每次编译前机会性清扫 retention_days 天前的 job 目录(只删 12 位 hex 锚点目录)
+P4 项目 zip(两段式 inspect + run,与 P3a 共享单槽队列/清扫/错误类):
+ - inspect_zip:安全解压(zip_safety)→ 主文件识别 → compile_inspect/{job_id}/(1h TTL)
+ - compile_project_job:原子 rename 消费 inspect → compile_jobs/{cid}/ → tectonic →
+ finally 删 project 树(只留 output.pdf/output.log/meta.json)
+ - 失败也保证 output.log 落盘(补充 3);classify_compile_error 给出用户友好文案
+ - P3a 单文件路径(prepare_input/compile_tex/_run_once)零改动
+
tectonic 二进制缺失 → ToolUnavailableError(路由层回安装指引)。
"""
from __future__ import annotations
@@ -20,7 +27,10 @@ import os
import re
import shutil
import signal
+import subprocess
import time
+import uuid
+from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -40,6 +50,9 @@ _pending = 0
class CompileError(Exception):
"""编译失败(含 log 尾)。code ∈ {COMPILE_FAILED, TIMEOUT, OUTPUT_TOO_LARGE}。"""
+ # compile_id:P4 项目编译失败时由 compile_project_job 附加(P3a 单文件为 None),
+ # 路由层据此在失败信封里回 compile_id + log_url(补充 3)。
+ compile_id: str | None = None
def __init__(self, code: str, message: str, log_tail: str = "") -> None:
super().__init__(message)
@@ -234,3 +247,623 @@ def sweep_expired() -> int:
logger.info("compile sweep: 清理 %d 个过期任务目录(>%d 天)",
removed, settings.compile_retention_days)
return removed
+
+
+# ================================================================ P4:项目 zip(inspect + run)
+#
+# 与 P3a 共用:_lock/_pending 单槽队列、_ensure_tool、CompileError/QueueFullError/
+# ToolUnavailableError、_kill_group、_log_tail、12-hex 目录与 mtime sweep 约定。
+# P3a 函数(prepare_input/compile_tex/_run_once)零改动。
+
+
+class InspectError(Exception):
+ """inspect/消费阶段业务失败。
+ code ∈ {VALIDATION, ZIP_UNSAFE, ZIP_BOMB, ZIP_INVALID, ZIP_TOO_LARGE}。
+ """
+
+ def __init__(self, code: str, message: str) -> None:
+ super().__init__(message)
+ self.code = code
+ self.message = message
+
+
+_DOCCLASS_RE = re.compile(r"(? Path:
+ """data/compile_inspect/(1 小时 TTL 暂存根,存在性由调用方按需 mkdir)。"""
+ return get_settings().inspect_dir_abs
+
+
+def compile_job_dir(cid: str) -> Path:
+ """data/compile_jobs/{cid}/(产物根;不走 reformat_dir,P4 独立清扫)。"""
+ return get_settings().compile_jobs_dir_abs / cid
+
+
+def _strip_tex_comments(text: str) -> str:
+ """粗剥注释:行内 % 截断(\\% 误伤可接受,MVP 设计决策)。"""
+ return "\n".join(ln.split("%", 1)[0] for ln in text.splitlines())
+
+
+def _read_tex(path: Path) -> str:
+ """utf-8 → gb18030 → latin-1 降级读(中文模板常见 GBK)。"""
+ raw = path.read_bytes()
+ for enc in ("utf-8", "gb18030", "latin-1"):
+ try:
+ return raw.decode(enc)
+ except UnicodeDecodeError:
+ continue
+ return raw.decode("utf-8", errors="replace")
+
+
+def find_main_tex(project_dir: Path) -> tuple[str | None, list[str], int]:
+ """主文件识别(补充 2)→ (main_rel | None, 含 documentclass 的相对路径清单, .tex 总数)。
+
+ 场景:唯一候选 → main;0 个 .tex / 0 候选 / ≥2 候选 → None + 清单(调用方报错)。
+ 返回的相对路径用 posix 分隔符(与 zip 条目名一致)。
+ """
+ tex_files = sorted(
+ p for p in project_dir.rglob("*")
+ if p.is_file() and p.suffix.lower() == ".tex"
+ )
+ if not tex_files:
+ return None, [], 0
+ candidates: list[str] = []
+ for p in tex_files:
+ if _DOCCLASS_RE.search(_strip_tex_comments(_read_tex(p))):
+ candidates.append(p.relative_to(project_dir).as_posix())
+ if len(candidates) == 1:
+ return candidates[0], candidates, len(tex_files)
+ return None, candidates, len(tex_files)
+
+
+_MISSING_FILE_RE = re.compile(
+ r"LaTeX Error: File [`']([^`']+)' not found"
+ r"|I can't find file [`']([^`']+)'"
+)
+_IMG_INCLUSION_RE = re.compile(r"""image inclusion failed for ["']([^"']+)["']""")
+
+_EPS_CONVERTED_SUFFIX = "-eps-converted-to.pdf"
+_REF_TEXT_EXTS = {".tex", ".cls", ".sty", ".cfg", ".def"}
+
+# EPS 现场转换 v2(约束 1-4):xdvipdfmx 无法内嵌 EPS → 解压后批量转同名 .pdf
+_EPS_CONVERT_TIMEOUT_S = 15 # 约束2:单文件转换超时(秒)
+_EPS_CONVERT_MAX_FILE = 20 * 1024 * 1024 # 约束3:单文件转换后上限(warning)
+_EPS_CONVERT_MAX_TOTAL = 100 * 1024 * 1024 # 约束3:转换产物总上限(中止)
+
+
+def _eps_tool_commands(eps: Path, pdf: Path) -> list[tuple[str, list[str]]]:
+ """转换工具优先级:gs(约束1 必须 -dSAFER)→ epstopdf → ps2pdf;只列 PATH 里可用的。"""
+ tools: list[tuple[str, list[str]]] = []
+ gs = shutil.which("gs")
+ if gs:
+ tools.append(("gs", [
+ gs, "-dSAFER", "-dNOPAUSE", "-dBATCH", "-sDEVICE=pdfwrite",
+ "-dPDFSETTINGS=/prepress",
+ f"-sOutputFile={pdf}", str(eps),
+ ]))
+ epstopdf = shutil.which("epstopdf")
+ if epstopdf:
+ # epstopdf 默认输出到同目录同名 .pdf(即 pdf 路径)
+ tools.append(("epstopdf", [epstopdf, str(eps)]))
+ ps2pdf = shutil.which("ps2pdf")
+ if ps2pdf:
+ tools.append(("ps2pdf", [ps2pdf, str(eps), str(pdf)]))
+ return tools
+
+
+def _convert_eps_file(eps: Path, pdf: Path) -> tuple[str, str]:
+ """逐工具尝试把 eps 转成同名 pdf → ("ok", tool) | ("timeout", tool) | ("fail", "")。
+
+ 约束2:单工具 15s 超时 → 放弃该文件(继续下一个 eps,不再试后续工具)。
+ 成功判定:退出后 pdf 存在且非空(防半成品)。
+ """
+ tools = _eps_tool_commands(eps, pdf)
+ if not tools:
+ return "fail", ""
+ for tool, cmd in tools:
+ pdf.unlink(missing_ok=True) # 清上一个工具的半成品
+ try:
+ subprocess.run(
+ cmd, timeout=_EPS_CONVERT_TIMEOUT_S,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ pdf.unlink(missing_ok=True)
+ return "timeout", tool
+ except OSError:
+ continue
+ if pdf.is_file() and pdf.stat().st_size > 0:
+ return "ok", tool
+ pdf.unlink(missing_ok=True)
+ return "fail", ""
+
+
+def process_eps_images(project: Path) -> list[str]:
+ """EPS 兼容 v2:解压后**批量转换项目内所有 .eps**(不依赖 zip 自带预转换版)。
+
+ 对每个 X.eps(原 .eps 保留):
+ 1. 已有同名 X.pdf → 跳过转换(引用照常切换为 .pdf);
+ 2. zip 自带 X-eps-converted-to.pdf 预转换版 → 复制为 X.pdf(免转换);
+ 3. 否则现场转换:gs(-dSAFER) → epstopdf → ps2pdf 逐个 fallback(约束1),
+ 单文件 15s 超时 → warning 后继续下一个(约束2)。
+ 转换成功 → 显式 `X.eps` 引用字节替换为 `X.pdf`(.tex/.cls/.sty/.cfg/.def 全扫,
+ 纯字节 ASCII/UTF-8 替换不碰编码);无扩展名引用 `{.../X}` 由 graphicx 直接
+ 命中新生成的 X.pdf(解析顺序 .pdf→…→.eps)。
+ 约束3:单文件转换后 >20MB → warning;转换产物累计 >100MB → 抛
+ InspectError("ZIP_TOO_LARGE") 中止;约束4:返回值为写入 output.log 的
+ "[eps-convert]" 日志行(由 _run_project 落盘)。
+ → 日志行列表(无 .eps 项目 → 空)。
+ """
+ eps_files = sorted(
+ p for p in project.rglob("*")
+ if p.is_file() and p.suffix.lower() == ".eps"
+ )
+ if not eps_files:
+ return []
+ lines: list[str] = []
+ handled: list[tuple[bytes, bytes]] = []
+ total_out = 0
+
+ def _rel(p: Path) -> str:
+ return p.relative_to(project).as_posix()
+
+ for eps in eps_files:
+ pdf = eps.with_suffix(".pdf")
+ src_rel, dst_rel = _rel(eps), _rel(pdf)
+ if pdf.is_file():
+ handled.append((eps.name.encode("utf-8"), pdf.name.encode("utf-8")))
+ continue
+ converted = eps.with_name(eps.stem + _EPS_CONVERTED_SUFFIX)
+ if converted.is_file():
+ t0 = time.monotonic()
+ shutil.copyfile(converted, pdf)
+ elapsed = time.monotonic() - t0
+ tool = "preset"
+ else:
+ t0 = time.monotonic()
+ status, tool = _convert_eps_file(eps, pdf)
+ elapsed = time.monotonic() - t0
+ if status == "timeout":
+ lines.append(f"[eps-convert] {src_rel} 转换超时") # 约束2
+ continue
+ if status != "ok":
+ lines.append(
+ f"[eps-convert] 全部工具失败,图片 {src_rel} 未转换")
+ continue
+ # 约束4:转换日志(成功路径)
+ lines.append(
+ f"[eps-convert] {src_rel} → {dst_rel} "
+ f"({eps.stat().st_size} → {pdf.stat().st_size}, "
+ f"{elapsed:.2f}s, {tool})")
+ size_out = pdf.stat().st_size
+ if size_out > _EPS_CONVERT_MAX_FILE: # 约束3
+ lines.append(
+ f"[eps-convert] 警告:{dst_rel} 转换后 {size_out} 字节"
+ f"超过 {_EPS_CONVERT_MAX_FILE // (1024 * 1024)}MB 上限")
+ total_out += size_out
+ if total_out > _EPS_CONVERT_MAX_TOTAL: # 约束3
+ raise InspectError(
+ "ZIP_TOO_LARGE",
+ f"EPS 转换后总大小超过 {_EPS_CONVERT_MAX_TOTAL // (1024 * 1024)}MB 上限"
+ f"(已达 {total_out // (1024 * 1024)}MB),请压缩图片后重试")
+ handled.append((eps.name.encode("utf-8"), pdf.name.encode("utf-8")))
+
+ # 显式 .eps 引用 → .pdf(仅对转换成功者;失败的保持 .eps → 编译兜底友好报错)
+ changed = 0
+ if handled:
+ for f in project.rglob("*"):
+ if not (f.is_file() and f.suffix.lower() in _REF_TEXT_EXTS):
+ continue
+ data = f.read_bytes()
+ new = data
+ for old_b, new_b in handled:
+ new = new.replace(old_b, new_b)
+ if new != data:
+ f.write_bytes(new)
+ changed += 1
+ if changed:
+ lines.append(f"[eps-convert] {changed} 个文件的显式 .eps 引用已改写为 .pdf")
+ if lines:
+ logger.info("eps v2: %s", " | ".join(lines))
+ return lines
+
+
+def _append_eps_lines(log_path: Path, lines: list[str] | None) -> None:
+ """约束4:把 [eps-convert] 转换日志写入 output.log(引擎 log 之后、chatter 之前)。"""
+ if not lines:
+ return
+ with log_path.open("a", encoding="utf-8") as fh:
+ fh.write("\n".join(lines) + "\n")
+
+
+def classify_compile_error(log_tail: str) -> str:
+ """COMPILE_FAILED 的日志 → 用户友好文案(Q4/§4.4)。"""
+ m = _MISSING_FILE_RE.search(log_tail)
+ if m:
+ name = next(g for g in m.groups() if g)
+ ext = Path(name).suffix.lower()
+ if ext == ".cls":
+ return f"项目缺少 {name},请从期刊官网下载模板后重新上传"
+ if ext in (".sty", ".def", ".clo"):
+ return f"缺少 LaTeX 宏包 {name},可能是模板不完整"
+ if ext in _IMG_LIKE_EXT:
+ return f"图片 {name} 未在 zip 里,请检查引用路径与大小写"
+ if ext == ".bib":
+ return f"缺少文献库 {name},请确认 .bib 已包含在 zip 中"
+ return f"编译找不到文件 {name},请检查 zip 内文件路径"
+ m = _IMG_INCLUSION_RE.search(log_tail)
+ if m:
+ name = m.group(1)
+ if Path(name).suffix.lower() == ".eps":
+ # EPS v2 已现场转换;仍失败 = 服务器转换没成功(工具缺失/超时/格式怪异)
+ return "项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试"
+ return f"图片 {name} 无法内嵌,请检查图片格式与路径(建议使用 PDF/PNG)"
+ if re.search(r"Emergency stop|Undefined control sequence", log_tail):
+ return "LaTeX 源码有语法错误,请展开日志定位出错行"
+ return "编译失败,请查看日志定位 LaTeX 错误"
+
+
+async def inspect_zip(zip_bytes: bytes) -> dict[str, Any]:
+ """两段式之①:安全解压 + 主文件识别 + 落盘 inspect 暂存(1h TTL)。
+
+ → {job_id, main, stats{files,tex,images,cls,bib,other,bytes}, skipped[], skipped_count}
+ 失败抛 InspectError(路由层转信封);任何失败 → rmtree 暂存目录,磁盘零残留。
+ """
+ from .zip_safety import ZipSafetyError, safe_extract
+
+ settings = get_settings()
+ sweep_inspect_expired() # 机会性清扫(同 P3a 模式)
+ job_id = uuid.uuid4().hex[:12]
+ jdir = inspect_root() / job_id
+ project = jdir / "project"
+ try:
+ try:
+ result = await asyncio.to_thread(safe_extract, zip_bytes, project, settings)
+ except ZipSafetyError as exc:
+ raise InspectError(exc.code, exc.message) from exc
+ main, candidates, tex_total = find_main_tex(project)
+ if tex_total == 0:
+ raise InspectError("VALIDATION", "zip 中没有 .tex 文件")
+ if not candidates:
+ raise InspectError(
+ "VALIDATION", "未找到含 \\documentclass 的 .tex 文件")
+ if main is None:
+ shown = ", ".join(Path(c).name for c in candidates)
+ raise InspectError(
+ "VALIDATION",
+ f"检测到多个主文件({shown}),请合并为单一入口后重新上传")
+ meta = {
+ "job_id": job_id,
+ "main": main,
+ "stats": {
+ "files": result.files, "tex": result.tex, "images": result.images,
+ "cls": result.cls, "bib": result.bib, "other": result.other,
+ "bytes": result.bytes,
+ },
+ "skipped": [{"name": s.name, "reason": s.reason} for s in result.skipped],
+ "skipped_count": result.skipped_count,
+ "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ }
+ jdir.mkdir(parents=True, exist_ok=True)
+ (jdir / "inspect.json").write_text(
+ json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
+ return meta
+ except BaseException:
+ shutil.rmtree(jdir, ignore_errors=True)
+ raise
+
+
+def consume_inspect(job_id: str, cid: str) -> tuple[Path, dict[str, Any]]:
+ """两段式之②前置:**原子 rename 消费** inspect 暂存 → compile_jobs/{cid}/project。
+
+ 并发正确性(防同一 job 双跑):
+ os.rename(src, dst) 在同一文件系统内是原子的,且 src 不存在 → FileNotFoundError。
+ 两个并发 run 拿同一 job_id 时,只有一个能成功 rename;后到者拿到
+ FileNotFoundError → 路由层转 VALIDATION"已过期/不存在",**绝不可能双编译**。
+ inspect 残壳(inspect.json)在 rename 成功后立即删除,也杜绝二跑读到半状态。
+ """
+ settings = get_settings()
+ if not _ANCHOR_ID_RE.fullmatch(job_id or ""):
+ raise InspectError("VALIDATION", "job_id 格式非法(应为 12 位 hex)")
+ src_root = inspect_root() / job_id
+ meta_path = src_root / "inspect.json"
+ src_project = src_root / "project"
+ if not (meta_path.is_file() and src_project.is_dir()):
+ raise InspectError(
+ "VALIDATION", "上传任务不存在或已过期(1 小时),请重新上传 zip")
+ try:
+ meta = json.loads(meta_path.read_text("utf-8"))
+ except (OSError, ValueError) as exc:
+ raise InspectError(
+ "VALIDATION", "上传任务数据损坏,请重新上传 zip") from exc
+ dst_root = compile_job_dir(cid)
+ dst_project = dst_root / "project"
+ dst_root.mkdir(parents=True, exist_ok=True)
+ try:
+ os.rename(src_project, dst_project)
+ except FileNotFoundError as exc:
+ raise InspectError(
+ "VALIDATION", "上传任务不存在或已过期(1 小时),请重新上传 zip") from exc
+ except OSError as exc: # 跨设备等罕见失败 → 保持 inspect 原样可重试
+ raise InspectError(
+ "VALIDATION", "任务消费失败,请重试或重新上传") from exc
+ shutil.rmtree(src_root, ignore_errors=True) # 消费成功 → 删残壳
+ return dst_project, meta
+
+
+async def compile_project_job(job_id: str) -> dict[str, Any]:
+ """两段式之②:原子消费 inspect → tectonic 编译 → 产物留 pdf/log/meta。
+
+ 队列/工具检查**先于消费**(inspect 保留 → 失败可直接重试);
+ EPS v2:解压后批量转换所有 .eps → 同名 .pdf(gs -dSAFER → epstopdf → ps2pdf,
+ 单文件 15s 超时;产物累计 >100MB → ZIP_TOO_LARGE 中止),转换日志进 output.log;
+ 成功 → 立即删 project/;失败(COMPILE_FAILED/TIMEOUT)→ **保留 project/ 现场 24h**
+ 供诊断(sweep_compile_jobs_expired 按 failed_project_ttl_s 清理;
+ KEEP_FAILED_PROJECTS=1 永久保留);
+ 失败必落完整 output.log(含 --print 的 XDV→PDF 阶段输出,补充 3)。
+
+ → {compile_id, pdf_path, log_path, seconds, pdf_bytes}
+ """
+ global _pending
+ settings = get_settings()
+ _ensure_tool() # 快速失败:不消费 inspect、不占队列
+ sweep_inspect_expired()
+ sweep_compile_jobs_expired()
+ if _pending >= settings.compile_queue_limit:
+ raise QueueFullError(
+ f"编译队列已满({_pending} 个任务排队),请稍后重试")
+ _pending += 1 # 占位先于消费:QUEUE_FULL 时 inspect 未动
+ cid = uuid.uuid4().hex[:12]
+ project: Path | None = None
+ meta: dict[str, Any] = {}
+ try:
+ project, meta = await asyncio.to_thread(consume_inspect, job_id, cid)
+ try:
+ # EPS v2:解压后批量转换所有 .eps → 同名 .pdf 并切换显式引用
+ # (不依赖 zip 自带预转换版;失败现场保留转换后的改写结果)
+ eps_lines = await asyncio.to_thread(process_eps_images, project)
+ async with _lock:
+ result = await _run_project(
+ cid, project, str(meta.get("main", "")), eps_lines)
+ # 成功 → 立即清理 project 树(失败路径在 finally 保留现场)
+ shutil.rmtree(project, ignore_errors=True)
+ project = None
+ return result
+ except CompileError as exc:
+ # 补充 3:失败信封也要能下载 log → 异常携带产物 cid(log 落盘于 compile_jobs/{cid}/)
+ exc.compile_id = cid
+ raise
+ finally:
+ _pending -= 1
+ if project is not None:
+ # 编译失败 → 保留 compile_jobs/{cid}/project/ 现场供诊断;
+ # touch 目录使 24h 保留窗从失败时刻起算
+ try:
+ os.utime(project, None)
+ except OSError:
+ pass
+
+
+async def _run_project(
+ cid: str,
+ project: Path,
+ main_rel: str,
+ eps_log_lines: list[str] | None = None,
+) -> dict[str, Any]:
+ """单槽内执行项目编译:cwd=main 所在目录(相对路径解析),outdir=compile_jobs/{cid}。
+
+ eps_log_lines:EPS v2 的 [eps-convert] 转换日志(约束4),正常/超时两条路径
+ 都会写进 output.log(引擎 log 之后、chatter/[timeout] 之前)。
+ """
+ settings = get_settings()
+ outdir = compile_job_dir(cid)
+ outdir.mkdir(parents=True, exist_ok=True)
+ main_path = project / main_rel
+ if not main_rel or not main_path.is_file():
+ raise InspectError("VALIDATION", "主文件缺失,请重新上传 zip")
+ cwd = main_path.parent
+ stem = main_path.stem
+
+ env = dict(os.environ)
+ env["TECTONIC_CACHE_DIR"] = str(settings.tectonic_cache_abs)
+ # --print:打印引擎完整 chatter(含 XDV→PDF/xdvipdfmx 阶段输出);
+ # 不加时 xdvipdfmx 报错只进 tectonic stderr、不落 {stem}.log,导致"LaTeX 成功但
+ # 无 PDF"且 log 无错误行(MDPI 模板实测)。stdout/stderr 统一落盘 output.log。
+ cmd = [
+ str(_tectonic_bin()), "-X", "compile", main_path.name,
+ "--untrusted", "--keep-logs", "--print", "--outdir", str(outdir),
+ ]
+ timeout = settings.compile_project_timeout_s
+ log_path = outdir / "output.log"
+ started = time.monotonic()
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd, cwd=str(cwd), env=env,
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
+ start_new_session=True,
+ )
+ try:
+ stdout_bytes, _ = await asyncio.wait_for(
+ proc.communicate(), timeout=timeout)
+ except asyncio.TimeoutError:
+ _kill_group(proc)
+ try:
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
+ except asyncio.TimeoutError:
+ pass
+ # 超时后 communicate 已取消,chatter 不可取;.log(--keep-logs)仍完整
+ _ensure_project_log(outdir, stem, cwd)
+ # 约束4:转换日志先落(转换发生在编译之前),再追加超时标记
+ _append_eps_lines(log_path, eps_log_lines)
+ # 追加而非覆盖:保留完整引擎输出(诊断 XDV→PDF 阶段必需)
+ with log_path.open("a", encoding="utf-8") as fh:
+ fh.write(f"\n[timeout] 编译超时({timeout:g}s),进程已终止\n")
+ raise CompileError(
+ "TIMEOUT",
+ f"编译超过 {timeout: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)
+
+ _ensure_project_log(outdir, stem, cwd)
+
+ # 产物归一:{stem}.pdf/.log → output.pdf/.log(补充 3:失败也必有 output.log)
+ produced_pdf = outdir / f"{stem}.pdf"
+ produced_log = outdir / f"{stem}.log"
+ if produced_log.is_file():
+ shutil.move(str(produced_log), str(log_path))
+ if not log_path.is_file():
+ log_path.write_text(
+ "[no log] tectonic 未产出日志(进程异常退出)", encoding="utf-8")
+ # 约束4:[eps-convert] 转换日志(引擎 log 之后、chatter 之前)
+ _append_eps_lines(log_path, eps_log_lines)
+ # --print 的进程输出(含 XDV→PDF 阶段)追加到 output.log 全文之后;
+ # 信封 detail 仍只取尾 200 行,磁盘保留完整 log 供"查看完整 log"下载
+ _append_chatter(log_path, stdout_bytes)
+
+ if not produced_pdf.is_file():
+ raise CompileError(
+ "COMPILE_FAILED",
+ classify_compile_error(_log_tail(log_path)),
+ _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(outdir / "output.pdf")
+
+ meta = {
+ "compile_id": cid,
+ "project": True,
+ "main": main_rel,
+ "engine": _ENGINE,
+ "seconds": seconds,
+ "pdf_bytes": pdf_bytes,
+ "returncode": 0,
+ "compiled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
+ }
+ (outdir / "meta.json").write_text(
+ json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
+ logger.info("compile(project): %s main=%s → PDF %d bytes, %.2fs",
+ cid, main_rel, pdf_bytes, seconds)
+ return {
+ "compile_id": cid,
+ "pdf_path": outdir / "output.pdf",
+ "log_path": log_path,
+ "seconds": seconds,
+ "pdf_bytes": pdf_bytes,
+ }
+
+
+def _ensure_project_log(outdir: Path, stem: str, cwd: Path) -> None:
+ """tectonic 落 log 到 outdir 或 cwd → 统一挪到 outdir/output.log;都没有则占位。"""
+ log_path = outdir / "output.log"
+ if log_path.is_file():
+ return
+ for cand in (outdir / f"{stem}.log", cwd / f"{stem}.log"):
+ if cand.is_file():
+ shutil.move(str(cand), str(log_path))
+ return
+
+
+def _append_chatter(log_path: Path, stdout_bytes: bytes | None) -> None:
+ """把 tectonic --print 的进程 stdout(含 XDV→PDF/xdvipdfmx 阶段)追加到 output.log。
+
+ {stem}.log 只有 LaTeX 引擎内容(止于 "Output written on … .xdv");
+ XDV→PDF 转换错误只出现在进程 chatter 里——不落盘则"无 PDF 且 log 无错误行"。
+ """
+ if not stdout_bytes:
+ return
+ chatter = stdout_bytes.decode("utf-8", errors="replace").strip()
+ if not chatter:
+ return
+ with log_path.open("a", encoding="utf-8") as fh:
+ fh.write("\n\n===== tectonic --print(进程输出,含 XDV→PDF 阶段)=====\n")
+ fh.write(chatter)
+ fh.write("\n")
+
+
+def sweep_inspect_expired() -> int:
+ """删 mtime 超 inspect_ttl_s(默认 1h)的 12-hex inspect 暂存目录。"""
+ settings = get_settings()
+ base = settings.inspect_dir_abs
+ if not base.is_dir():
+ return 0
+ cutoff = time.time() - settings.inspect_ttl_s
+ 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("inspect sweep: 清理 %d 个过期 inspect 暂存(>%d 秒)",
+ removed, settings.inspect_ttl_s)
+ return removed
+
+
+def _keep_failed_projects() -> bool:
+ """失败现场是否永久保留(调试):REVIEW_KEEP_FAILED_PROJECTS=1 或裸 KEEP_FAILED_PROJECTS=1。"""
+ if get_settings().keep_failed_projects:
+ return True
+ return os.environ.get("KEEP_FAILED_PROJECTS", "").strip() == "1"
+
+
+def sweep_compile_jobs_expired() -> int:
+ """编译产物清扫,两级:
+ ① 失败现场 project/ 子目录:mtime 超 failed_project_ttl_s(默认 24h)→ 删子目录
+ (compile_jobs/{cid} 本体与 output.log/meta 保留,供"查看完整 log");
+ KEEP_FAILED_PROJECTS=1 跳过(永久保留,调试用)。
+ ② 整个 compile_jobs/{cid}:mtime 超 compile_retention_days(7 天)→ 整目录删。
+ """
+ settings = get_settings()
+ base = settings.compile_jobs_dir_abs
+ if not base.is_dir():
+ return 0
+ removed = 0
+ keep_failed = _keep_failed_projects()
+ # ① 失败现场 24h 清理(成功编译的 project/ 已在 compile_project_job 立即删)
+ if not keep_failed:
+ proj_cutoff = time.time() - settings.failed_project_ttl_s
+ for child in base.iterdir():
+ if not child.is_dir() or not _ANCHOR_ID_RE.fullmatch(child.name):
+ continue
+ proj = child / "project"
+ if not proj.is_dir():
+ continue
+ try:
+ if proj.stat().st_mtime < proj_cutoff:
+ shutil.rmtree(proj, ignore_errors=True)
+ removed += 1
+ except OSError:
+ continue
+ # ② 整目录 7 天清理
+ cutoff = time.time() - settings.compile_retention_days * 86400
+ 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_jobs sweep: 清理 %d 个过期项目产物/失败现场", removed)
+ return removed
diff --git a/review_service/app/services/zip_safety.py b/review_service/app/services/zip_safety.py
new file mode 100644
index 0000000..0608eee
--- /dev/null
+++ b/review_service/app/services/zip_safety.py
@@ -0,0 +1,288 @@
+r"""services/zip_safety.py —— LaTeX 项目 zip 安全校验与解压(P4,纯函数模块)。
+
+安全清单(每条攻击 → 防御位置,对应设计 §2):
+ #1 路径穿越 ../../etc/passwd → sanitize_entry_name:拒绝 .. 段/绝对路径/盘符/空段
+ (\ 已先归一为 /,Windows 反斜杠变体同规则)
+ #2 反斜杠变体 ..\\..\\x → 同上(name.replace("\\\\", "/") 后再校验)
+ #3 符号链接条目 → _is_symlink:external_attr 的 S_IFMT == S_IFLNK → 整包拒绝
+ #4 可执行扩展名 .exe/.sh/.so → classify_entry:DENY_EXT → 整包拒绝(点名条目)
+ #5 Unix 可执行位 0o111 → _has_exec_bit:普通文件带 x 位 → 整包拒绝(目录 0o755 例外)
+ #6 无害非白名单 .docx/.log/.zip → classify_entry:skip(记 skipped[];.zip 跳过=天然防嵌套递归)
+ #7 zip bomb 压缩比 → 预扫 file_size/compress_size > max_zip_ratio → ZIP_BOMB
+ #8 zip bomb 总量 → 预扫声明累计 > max_extract_bytes → ZIP_BOMB;
+ 流式写盘按**实际字节**再计数(不信头部声明)超限即中止
+ #9 zip bomb 单条目 → 预扫 file_size > max_entry_bytes → ZIP_BOMB;实写计数兜底
+ #10 zip bomb 条目数 → 预扫 len(infolist) > max_zip_entries → ZIP_BOMB(先于一切写盘)
+ #11 深嵌套/超长路径 → 路径深度 > max_path_depth 或总长 > max_path_len → ZIP_UNSAFE
+ #12 损坏/加密 zip → zipfile.BadZipFile / RuntimeError → ZIP_INVALID
+ #13 头部谎报小文件 → 实写计数兜底(#8);CRC 错 → ZIP_INVALID
+ #14 恶意 .tex shell escape → 不在本模块范围(tectonic --untrusted 防护,P3a 已实证)
+ #15 解压阻塞事件循环 → 调用方 asyncio.to_thread 包裹;本模块上限 200MB/2000 条封顶
+
+设计要点:
+ - 两段式:①纯预扫(所有结构性检查,全部通过才开始写)→ ②流式写盘(8KB 块 + 实字节计数)。
+ 任何异常 → 调用方 rmtree(dest),磁盘零残留。
+ - 绝不 extractall;绝不保留可执行位(写盘一律 0o644)。
+ - 目录条目只建不校验扩展名;SKIP_DIRS 整目录跳过(补充 4 提示对应)。
+
+纯函数:输入 bytes/路径/Settings,输出 ExtractResult 或抛 ZipSafetyError,无 FastAPI/asyncio 依赖。
+"""
+from __future__ import annotations
+
+import io
+import re
+import stat
+import zipfile
+from dataclasses import dataclass, field
+from pathlib import Path, PurePosixPath
+from typing import Literal
+
+# 危险类型(deny → 整包拒绝)
+DENY_EXT = {
+ ".exe", ".com", ".bat", ".cmd", ".sh", ".bash", ".ps1", ".vbs",
+ ".dll", ".so", ".dylib", ".msi", ".jar", ".py", ".rb", ".pl", ".php", ".js",
+}
+# LaTeX 项目白名单(allow → 解压)
+ALLOW_EXT = {
+ ".tex", ".cls", ".sty", ".bst", ".bib", ".bbl", ".blg", ".def", ".clo",
+ ".fd", ".cfg", ".ist", ".dtx", ".ins",
+ ".png", ".jpg", ".jpeg", ".pdf", ".eps", ".svg",
+ ".txt", ".md", ".csv", ".dat", ".ttf", ".otf",
+}
+# 无扩展名白名单
+ALLOW_BARE = {"README", "LICENSE", "Makefile", ".gitignore"}
+# 整目录跳过(不进 skipped 计数之外的拒绝路径,直接 skip)
+SKIP_DIRS = {".git", "node_modules", "__MACOSX"}
+
+
+class ZipSafetyError(Exception):
+ """zip 不安全/超限/损坏。code ∈ {ZIP_UNSAFE, ZIP_BOMB, ZIP_INVALID}。"""
+
+ def __init__(self, code: str, message: str, entry: str = "") -> None:
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.entry = entry
+
+
+@dataclass
+class SkippedEntry:
+ name: str
+ reason: str
+
+
+@dataclass
+class ExtractResult:
+ files: int = 0
+ tex: int = 0
+ images: int = 0
+ cls: int = 0
+ bib: int = 0
+ other: int = 0
+ bytes: int = 0
+ skipped: list[SkippedEntry] = field(default_factory=list)
+ skipped_count: int = 0
+
+
+_IMG_EXT = {".png", ".jpg", ".jpeg", ".eps", ".svg"}
+_BARE_RE = re.compile(r"^[A-Za-z]:") # 盘符
+_CTRL_RE = re.compile(r"[\x00-\x1f]") # 控制字符
+
+
+def sanitize_entry_name(raw: str) -> str:
+ """归一 + 校验条目名,返回 posix 相对路径;不安全 → ZIP_UNSAFE。
+
+ 拒绝:绝对路径(/ 或 \\ 开头)、盘符(C:)、任一段 `..`、空段、NUL/控制字符。
+ """
+ name = raw.replace("\\", "/")
+ if not name or name in ("/", "."):
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 含空条目名:{raw!r}", raw)
+ if name.startswith("/") or _BARE_RE.match(name):
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 含绝对路径条目:{raw}", raw)
+ if _CTRL_RE.search(name):
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目名含控制字符:{raw!r}", raw)
+ segs = name.split("/")
+ if any(s == ".." for s in segs):
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 含路径穿越条目:{raw}", raw)
+ # 空段(//)拒绝;尾段为 ""(目录 / 结尾)在除末位外的段上已由 split 覆盖
+ if any(s == "" for s in segs[:-1]) or (segs and segs[0] == ""):
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 含非法路径条目:{raw}", raw)
+ depth = len([s for s in segs if s])
+ if depth > 16:
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目路径过深({depth} 层):{raw}", raw)
+ if len(name) > 200:
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 条目路径过长({len(name)} 字符):{raw}", raw)
+ return "/".join(s for s in segs if s)
+
+
+def classify_entry(name: str, is_dir: bool) -> Literal["allow", "skip", "deny"]:
+ """条目分类:目录 → allow(只建);DENY_EXT → deny(整包拒绝);白名单 → allow;其余 → skip。"""
+ if is_dir:
+ return "allow"
+ segs = name.replace("\\", "/").split("/")
+ # 中间目录段命中 SKIP_DIRS → skip(.git/node_modules 整树跳过)
+ if any(seg in SKIP_DIRS for seg in segs[:-1]):
+ return "skip"
+ p = PurePosixPath(name)
+ ext = p.suffix.lower()
+ # 多重扩展如 .synctex.gz:取最后一段即可(gz 不在白名单 → skip)
+ if ext in DENY_EXT:
+ return "deny"
+ if ext in ALLOW_EXT:
+ return "allow"
+ if p.name.upper() in {b.upper() for b in ALLOW_BARE}:
+ return "allow"
+ return "skip"
+
+
+def _is_symlink(info: zipfile.ZipInfo) -> bool:
+ """external_attr 高 16 位为 Unix mode;S_IFMT == S_IFLNK(0xA000) → 符号链接。"""
+ mode = (info.external_attr >> 16) & 0xFFFF
+ return stat.S_IFMT(mode) == stat.S_IFLNK
+
+
+def _has_exec_bit(info: zipfile.ZipInfo) -> bool:
+ mode = (info.external_attr >> 16) & 0xFFFF
+ # Windows 造的 zip(external_attr 无 Unix mode)mode 可能为 0 → 无 x 位
+ return bool(mode & 0o111)
+
+
+def _prescan(zf: zipfile.ZipFile, settings) -> list[tuple[zipfile.ZipInfo, str]]:
+ """预扫中央目录:条目数 → 路径 → 符号链接/可执行位 → 分类 → 声明大小/压缩比。
+
+ 全部通过才允许写盘(两段式第一段)。
+ """
+ infos = zf.infolist()
+ if len(infos) > settings.max_zip_entries:
+ raise ZipSafetyError(
+ "ZIP_BOMB", f"zip 条目数超限({len(infos)} > {settings.max_zip_entries})")
+
+ plan: list[tuple[zipfile.ZipInfo, str]] = []
+ declared_total = 0
+ for info in infos:
+ is_dir = info.is_dir()
+ name = sanitize_entry_name(info.filename)
+ if is_dir:
+ continue
+ # #3 符号链接 → 整包拒绝
+ if _is_symlink(info):
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 含符号链接条目:{info.filename}", info.filename)
+ # #4/#6 分类
+ kind = classify_entry(name, is_dir=False)
+ if kind == "deny":
+ raise ZipSafetyError("ZIP_UNSAFE", f"zip 含危险条目:{info.filename}", info.filename)
+ if kind == "skip":
+ continue
+ # #5 可执行位 → 整包拒绝(仅普通文件;目录已 continue)
+ if _has_exec_bit(info):
+ raise ZipSafetyError(
+ "ZIP_UNSAFE", f"zip 含可执行权限条目:{info.filename}", info.filename)
+ # #9 单条目声明大小
+ if info.file_size > settings.max_zip_entry_bytes:
+ raise ZipSafetyError(
+ "ZIP_BOMB",
+ f"zip 条目过大:{info.filename}({info.file_size} 字节)", info.filename)
+ # #7 压缩比(compress_size=0 且 file_size>0 视为异常)
+ if info.file_size > 0:
+ cs = info.compress_size
+ if cs == 0 or info.file_size / cs > settings.max_zip_ratio:
+ raise ZipSafetyError(
+ "ZIP_BOMB",
+ f"zip 条目压缩比异常(疑似 zip bomb):{info.filename}", info.filename)
+ declared_total += info.file_size
+ # #8 声明累计
+ if declared_total > settings.max_extract_bytes:
+ raise ZipSafetyError(
+ "ZIP_BOMB",
+ f"zip 解压总大小超限(声明 {declared_total} > "
+ f"{settings.max_extract_bytes} 字节)")
+ plan.append((info, name))
+ return plan
+
+
+def safe_extract(zip_bytes: bytes, dest: Path, settings) -> ExtractResult:
+ """安全解压 zip_bytes → dest(新建目录)。
+
+ 两段式:①预扫(结构性检查全过)②流式写盘(8KB 块 + 实字节计数)。
+ 任何 ZipSafetyError → 调用方负责 rmtree(dest) 清残留。
+ """
+ try:
+ zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
+ except zipfile.BadZipFile as exc:
+ raise ZipSafetyError("ZIP_INVALID", f"zip 文件损坏或不是有效 zip:{exc}") from exc
+
+ result = ExtractResult()
+ with zf:
+ # ---- 第一段:纯预扫 ----
+ plan = _prescan(zf, settings)
+
+ # ---- 第二段:流式写盘 ----
+ dest.mkdir(parents=True, exist_ok=True)
+ written_total = 0
+ for info, rel in plan:
+ target = dest / rel
+ # #1 双保险:resolve 后必须仍在 dest 内
+ resolved_parent = target.parent.resolve()
+ if not str(resolved_parent).startswith(str(dest.resolve())):
+ raise ZipSafetyError(
+ "ZIP_UNSAFE", f"zip 条目逃逸目标目录:{info.filename}", info.filename)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ src = zf.open(info)
+ except RuntimeError as exc: # 加密条目
+ raise ZipSafetyError("ZIP_INVALID", f"zip 条目受密码保护:{info.filename}") from exc
+ try:
+ with src, open(target, "wb") as out:
+ while True:
+ chunk = src.read(8192)
+ if not chunk:
+ break
+ # #8/#13 实写字节计数(不信头部声明)
+ written_total += len(chunk)
+ if written_total > settings.max_extract_bytes:
+ raise ZipSafetyError(
+ "ZIP_BOMB",
+ f"zip 实际解压超限(> {settings.max_extract_bytes} 字节),已中止")
+ out.write(chunk)
+ except zipfile.BadZipFile as exc: # CRC 校验失败(#12/#13)
+ raise ZipSafetyError(
+ "ZIP_INVALID", f"zip 条目损坏(CRC 校验失败):{info.filename}",
+ info.filename) from exc
+ # #5 绝不保留可执行位
+ target.chmod(0o644)
+ result.files += 1
+ _tally(result, rel)
+ result.bytes = written_total # 实写累计(#8 计数器终值)
+
+ # 跳过统计(限 20 条 + 总数)
+ skipped: list[SkippedEntry] = []
+ skipped_count = 0
+ for info in zf.infolist():
+ if info.is_dir():
+ continue
+ try:
+ name = sanitize_entry_name(info.filename)
+ except ZipSafetyError:
+ continue # 预扫已拒绝,不会到这里
+ if classify_entry(name, is_dir=False) == "skip":
+ skipped_count += 1
+ if len(skipped) < 20:
+ skipped.append(SkippedEntry(name=name, reason="unsupported"))
+ result.skipped = skipped
+ result.skipped_count = skipped_count
+
+ return result
+
+
+def _tally(result: ExtractResult, rel: str) -> None:
+ ext = PurePosixPath(rel).suffix.lower()
+ if ext == ".tex":
+ result.tex += 1
+ elif ext in _IMG_EXT or ext == ".png":
+ result.images += 1
+ elif ext == ".cls":
+ result.cls += 1
+ elif ext == ".bib":
+ result.bib += 1
+ else:
+ result.other += 1
diff --git a/review_service/restart.sh b/review_service/restart.sh
index 80b522d..1e6be33 100755
--- a/review_service/restart.sh
+++ b/review_service/restart.sh
@@ -30,4 +30,7 @@ set +a
echo "[restart] 启动审稿服务 :$PORT($PYTHON)"
nohup "$PYTHON" -m uvicorn "$APP_MODULE" --host "$HOST" --port "$PORT" \
> "$SCRIPT_DIR/review.log" 2>&1 &
-echo "[restart] 完成;日志 review_service/review.log"
+# P3b:后台预热 tectonic TeX bundle(非阻塞;仅首次需 1-3 分钟,失败不影响服务)
+nohup bash "$SCRIPT_DIR/scripts/prewarm_tectonic.sh" \
+ > "$SCRIPT_DIR/prewarm.log" 2>&1 &
+echo "[restart] 完成;日志 review_service/review.log(预热 prewarm.log)"
diff --git a/review_service/scripts/prewarm_tectonic.sh b/review_service/scripts/prewarm_tectonic.sh
new file mode 100644
index 0000000..54d5aae
--- /dev/null
+++ b/review_service/scripts/prewarm_tectonic.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# prewarm_tectonic.sh —— P3b:预下载 tectonic TeX bundle(宏包缓存)
+# 目的:服务启动时预热 data/tectonic-cache/,用户首次编译不必等 1-3 分钟下载。
+# 用法: bash scripts/prewarm_tectonic.sh (后台跑,非阻塞;restart.sh 自动调起)
+# 注:tectonic v0.17.0 无 `download` 子命令——预热 = 用最小 hello.tex 实编译一次,
+# 让 bundle/宏包落进 TECTONIC_CACHE_DIR(同 tools/README.md 预热法)。
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$SCRIPT_DIR"
+
+TECTONIC="$SCRIPT_DIR/tools/tectonic"
+[[ -x "$TECTONIC" ]] || { echo "[prewarm] tectonic 不存在,跳过:$TECTONIC"; exit 0; }
+
+export TECTONIC_CACHE_DIR="${REVIEW_TECTONIC_CACHE_DIR:-$SCRIPT_DIR/data/tectonic-cache}"
+mkdir -p "$TECTONIC_CACHE_DIR"
+
+TMPDIR_PREWARM="$(mktemp -d)"
+trap 'rm -rf "$TMPDIR_PREWARM"' EXIT
+cat > "$TMPDIR_PREWARM/hello.tex" <<'EOF'
+\documentclass{article}
+\begin{document}
+prewarm
+\end{document}
+EOF
+
+echo "[prewarm] 预下载 TeX bundle 至 $TECTONIC_CACHE_DIR(首次 1-3 分钟,仅一次)"
+if "$TECTONIC" -X compile "$TMPDIR_PREWARM/hello.tex" --untrusted \
+ --outdir "$TMPDIR_PREWARM" >/dev/null 2>&1; then
+ echo "[prewarm] OK:宏包缓存就绪,用户首编可秒级命中"
+else
+ echo "[prewarm] 预热失败(网络/磁盘):不影响服务启动,首编时仍会现场拉取" >&2
+ exit 0
+fi
diff --git a/review_service/tests/test_compile_zip.py b/review_service/tests/test_compile_zip.py
new file mode 100644
index 0000000..248c2f3
--- /dev/null
+++ b/review_service/tests/test_compile_zip.py
@@ -0,0 +1,639 @@
+"""tests/test_compile_zip.py —— P4 项目 zip 两段式编译集成测试。
+
+真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过,
+校验/消费/清扫等纯逻辑用例照常运行。
+运行: cd review_service && /bin/python -m pytest -q
+"""
+from __future__ import annotations
+
+import io
+import json
+import os
+import shutil
+import time
+import zipfile
+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 p4\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)"
+)
+
+
+def _zip_bytes(entries: dict[str, str | bytes]) -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ for name, data in entries.items():
+ zf.writestr(name, data)
+ return buf.getvalue()
+
+
+def _upload(client: TestClient, payload: bytes, name: str = "project.zip"):
+ return client.post(
+ "/api/review/compile/inspect",
+ files={"file": (name, payload, "application/zip")},
+ )
+
+
+def _tiny_pdf() -> bytes:
+ """极小合法 PDF(1 页 20x20 蓝色方块),供图片内嵌集成用例。"""
+ stream = b"0 0 1 rg 2 2 10 10 re f\n"
+ objs = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20]"
+ b" /Contents 4 0 R /Resources << >> >>",
+ b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n"
+ + stream + b"endstream",
+ ]
+ out = bytearray(b"%PDF-1.4\n")
+ offsets = []
+ for i, body in enumerate(objs, 1):
+ offsets.append(len(out))
+ out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n"
+ xref_pos = len(out)
+ out += f"xref\n0 {len(objs) + 1}\n".encode()
+ out += b"0000000000 65535 f \n"
+ for off in offsets:
+ out += f"{off:010d} 00000 n \n".encode()
+ out += (f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n"
+ f"startxref\n{xref_pos}\n%%EOF\n").encode()
+ return bytes(out)
+
+
+@pytest.fixture()
+def client(tmp_path, monkeypatch):
+ settings = get_settings()
+ monkeypatch.setattr(settings, "reformat_dir", str(tmp_path / "jobs"))
+ monkeypatch.setattr(settings, "inspect_dir", str(tmp_path / "inspect"))
+ monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "compile_jobs"))
+ monkeypatch.setattr(settings, "compile_timeout_s", 60.0)
+ monkeypatch.setattr(settings, "compile_project_timeout_s", 60.0)
+ monkeypatch.setattr(lc, "_pending", 0)
+ with TestClient(app) as c:
+ yield c
+
+
+# ================================================================ inspect 校验
+
+def test_inspect_not_zip(client: TestClient):
+ r = client.post(
+ "/api/review/compile/inspect",
+ files={"file": ("x.zip", b"notazip", "application/zip")},
+ )
+ assert r.status_code == 200 and r.json()["error"]["code"] == "ZIP_INVALID"
+
+
+def test_inspect_empty(client: TestClient):
+ r = _upload(client, b"")
+ assert r.json()["error"]["code"] == "VALIDATION"
+
+
+def test_inspect_oversize(client: TestClient, monkeypatch):
+ monkeypatch.setattr(get_settings(), "max_zip_bytes", 10)
+ r = _upload(client, _zip_bytes({"main.tex": MINIMAL}))
+ assert r.json()["error"]["code"] == "VALIDATION"
+ assert "上限" in r.json()["error"]["message"]
+
+
+def test_inspect_bad_filename(client: TestClient):
+ r = _upload(client, _zip_bytes({"main.tex": MINIMAL}), name="../evil.zip")
+ assert r.json()["error"]["code"] == "VALIDATION"
+
+
+def test_inspect_path_traversal(client: TestClient):
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("main.tex", MINIMAL)
+ info = zipfile.ZipInfo("../../evil.txt")
+ zf.writestr(info, "x")
+ r = _upload(client, buf.getvalue())
+ assert r.json()["error"]["code"] == "ZIP_UNSAFE"
+
+
+def test_inspect_denied_ext(client: TestClient):
+ r = _upload(client, _zip_bytes({"main.tex": MINIMAL, "evil.sh": "#!/bin/sh\n"}))
+ assert r.json()["error"]["code"] == "ZIP_UNSAFE"
+ assert "evil.sh" in r.json()["error"]["message"]
+
+
+def test_inspect_ratio_bomb(client: TestClient, monkeypatch):
+ monkeypatch.setattr(get_settings(), "max_zip_ratio", 1.5)
+ r = _upload(client, _zip_bytes({"main.tex": "a" * 100_000}))
+ assert r.json()["error"]["code"] == "ZIP_BOMB"
+
+
+def test_inspect_no_tex(client: TestClient):
+ r = _upload(client, _zip_bytes({"readme.txt": "hi"}))
+ assert r.json()["error"]["code"] == "VALIDATION"
+ assert "没有 .tex" in r.json()["error"]["message"]
+
+
+def test_inspect_no_documentclass(client: TestClient):
+ r = _upload(client, _zip_bytes({"chapters/a.tex": "just text\n"}))
+ assert r.json()["error"]["code"] == "VALIDATION"
+ assert "documentclass" in r.json()["error"]["message"]
+
+
+def test_inspect_multiple_mains(client: TestClient):
+ r = _upload(client, _zip_bytes({
+ "main.tex": MINIMAL, "appendix.tex": MINIMAL}))
+ body = r.json()
+ assert body["error"]["code"] == "VALIDATION"
+ assert "多个主文件" in body["error"]["message"]
+
+
+# ================================================================ inspect 正常 + 卫生
+
+def test_inspect_happy_path(client: TestClient):
+ payload = _zip_bytes({
+ "main.tex": MINIMAL,
+ "figs/fig1.png": b"\x89PNG\r\n\x1a\nfake",
+ "refs.bib": "@article{a,\n title={t}\n}\n",
+ "journal.cls": "% fake cls\n",
+ "notes.docx": "skip me",
+ ".DS_Store": "junk",
+ "__MACOSX/._main.tex": "resource fork junk",
+ })
+ r = _upload(client, payload)
+ body = r.json()
+ assert body["ok"] is True, body
+ meta = body["meta"]
+ assert meta["main"] == "main.tex"
+ stats = meta["stats"]
+ assert stats["tex"] == 1 and stats["images"] == 1
+ assert stats["cls"] == 1 and stats["bib"] == 1
+ assert meta["skipped_count"] >= 2
+ names = {s["name"] for s in meta["skipped"]}
+ assert any("notes.docx" in n for n in names)
+
+ job_id = meta["job_id"]
+ assert len(job_id) == 12
+ jdir = get_settings().inspect_dir_abs / job_id
+ assert (jdir / "inspect.json").is_file()
+ assert (jdir / "project" / "main.tex").is_file()
+
+
+def test_inspect_fail_leaves_no_residue(client: TestClient):
+ r = _upload(client, _zip_bytes({"main.tex": MINIMAL, "evil.sh": "x"}))
+ assert r.json()["ok"] is False
+ base = get_settings().inspect_dir_abs
+ if base.is_dir():
+ assert not any(base.iterdir()), "inspect 失败后磁盘有残留"
+
+
+# ================================================================ run:校验 / 原子消费
+
+def test_run_bad_job_id(client: TestClient):
+ r = client.post("/api/review/compile/run", json={"job_id": "zz"})
+ assert r.json()["error"]["code"] == "VALIDATION"
+
+
+def test_run_unknown_job(client: TestClient):
+ r = client.post("/api/review/compile/run", json={"job_id": "0123456789ab"})
+ assert r.json()["error"]["code"] == "VALIDATION"
+
+
+def test_run_double_consume_rejected(client: TestClient):
+ """补充:原子消费——同一 job 第二次 run 必须 VALIDATION(防并发双跑)。"""
+ job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
+ first = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ first_err = first.get("error") or {}
+ if first_err.get("code") == "TOOL_UNAVAILABLE":
+ pytest.skip("tectonic 未安装,消费逻辑未触达")
+ # 首次无论编译成败(消费已发生),二次必失败
+ assert first["ok"] is True or first["error"]["code"] in (
+ "COMPILE_FAILED", "TIMEOUT")
+ second = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ assert second["ok"] is False
+ assert second["error"]["code"] == "VALIDATION"
+ # inspect 残壳也不应存在
+ assert not (get_settings().inspect_dir_abs / job_id / "project").exists()
+
+
+def test_run_tool_unavailable_keeps_job(client: TestClient, monkeypatch):
+ """队列/工具检查先于消费 → TOOL_UNAVAILABLE 时 inspect 保留可重试。"""
+ job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
+ with monkeypatch.context() as m:
+ m.setattr(get_settings(), "tectonic_path", "tools/definitely-missing")
+ r = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ assert r["error"]["code"] == "TOOL_UNAVAILABLE"
+ assert (get_settings().inspect_dir_abs / job_id / "inspect.json").is_file()
+
+
+def test_run_queue_full_keeps_job(client: TestClient, monkeypatch):
+ job_id = _upload(client, _zip_bytes({"main.tex": MINIMAL})).json()["meta"]["job_id"]
+ monkeypatch.setattr(lc, "_pending", 5)
+ monkeypatch.setattr(get_settings(), "compile_queue_limit", 5)
+ r = client.post("/api/review/compile/run", json={"job_id": job_id})
+ assert r.status_code == 429
+ assert r.json()["error"]["code"] == "QUEUE_FULL"
+ assert (get_settings().inspect_dir_abs / job_id / "inspect.json").is_file()
+
+
+# ================================================================ run:真实 tectonic
+
+@needs_tectonic
+def test_run_project_happy_path(client: TestClient):
+ payload = _zip_bytes({"main.tex": MINIMAL})
+ job_id = _upload(client, payload).json()["meta"]["job_id"]
+ r = client.post("/api/review/compile/run", json={"job_id": job_id})
+ body = r.json()
+ assert 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/zip/{cid}")
+ m = meta.json()
+ assert m["ok"] is True
+ assert m["meta"]["project"] is True
+ assert m["meta"]["main"] == "main.tex"
+ assert m["meta"]["pdf_bytes"] > 0
+
+ # project 树已删,只留产物
+ cdir = get_settings().compile_jobs_dir_abs / cid
+ assert not (cdir / "project").exists()
+ assert (cdir / "output.pdf").is_file()
+
+
+@needs_tectonic
+def test_run_failed_envelope_has_log_url(client: TestClient):
+ """补充 3:编译失败信封带 compile_id + log_url,且 log 可下载。"""
+ bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
+ job_id = _upload(client, _zip_bytes({"main.tex": bad})).json()["meta"]["job_id"]
+ body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ assert body["ok"] is False
+ assert body["error"]["code"] == "COMPILE_FAILED"
+ cid = body.get("compile_id")
+ assert cid and len(cid) == 12
+ assert body["log_url"] == f"/api/review/compile/zip/{cid}/log"
+ log = client.get(body["log_url"])
+ assert log.status_code == 200 and log.text.strip()
+
+
+@needs_tectonic
+def test_run_failed_keeps_project_scene(client: TestClient):
+ """失败保留现场(诊断):COMPILE_FAILED → compile_jobs/{cid}/project/ 不删。"""
+ bad = "\\documentclass{article}\n\\begin{document}\n\\undefinedcommandxyz\n"
+ job_id = _upload(client, _zip_bytes({"main.tex": bad})).json()["meta"]["job_id"]
+ body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
+ cid = body["compile_id"]
+ cdir = get_settings().compile_jobs_dir_abs / cid
+ assert (cdir / "project" / "main.tex").is_file() # 现场保留
+ assert (cdir / "output.log").is_file()
+
+
+@needs_tectonic
+def test_run_missing_cls_friendly_message(client: TestClient):
+ """classify_compile_error:缺 .cls → 期刊模板提示(Q4/§4.4)。"""
+ tex = "\\documentclass{noexist-cls}\n\\begin{document}\nx\n"
+ job_id = _upload(client, _zip_bytes({"main.tex": tex})).json()["meta"]["job_id"]
+ body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
+ assert "noexist-cls" in body["error"]["message"]
+
+
+@needs_tectonic
+def test_run_project_malicious_write18(client: TestClient, tmp_path):
+ canary = tmp_path / "canary_p4"
+ evil = (
+ "\\documentclass{article}\n"
+ f"\\write18{{touch {canary}}}\n"
+ "\\begin{document}\nno shell please\n\\end{document}\n"
+ )
+ job_id = _upload(client, _zip_bytes({"main.tex": evil})).json()["meta"]["job_id"]
+ client.post("/api/review/compile/run", json={"job_id": job_id})
+ assert not canary.exists(), r"\write18 竟然执行了!"
+
+
+@needs_tectonic
+def test_run_project_timeout(client: TestClient, monkeypatch):
+ monkeypatch.setattr(get_settings(), "compile_project_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"
+ )
+ job_id = _upload(client, _zip_bytes({"main.tex": loop_tex})).json()["meta"]["job_id"]
+ started = time.monotonic()
+ body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ elapsed = time.monotonic() - started
+ assert body["ok"] is False and body["error"]["code"] == "TIMEOUT"
+ assert elapsed < 15.0, f"超时回收过慢:{elapsed:.1f}s"
+ # 超时也必落 log(补充 3)
+ log = client.get(body["log_url"])
+ assert log.status_code == 200
+ assert "[timeout]" in log.text
+
+
+# ================================================================ EPS 兼容 v2(批量现场转换)
+
+_MIN_EPS = (
+ b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n"
+ b"0 0 1 setrgbcolor 0 0 10 10 rectfill\nshowpage\n%%EOF\n"
+)
+
+
+def _eps_project(tmp_path, tex_body: str = "") -> Path:
+ proj = tmp_path / "p"
+ proj.mkdir()
+ (proj / "fig.eps").write_bytes(_MIN_EPS)
+ (proj / "main.tex").write_text(
+ "\\documentclass{article}\n\\usepackage{graphicx}\n"
+ "\\begin{document}\n" + tex_body + "\n\\end{document}\n",
+ encoding="utf-8")
+ return proj
+
+
+def test_eps_v2_preset_converted(tmp_path, monkeypatch):
+ """zip 自带 *-eps-converted-to.pdf → 免转换复制为 X.pdf + 显式引用改写。"""
+ # other.eps 无转换版 → mock 工具为空使其失败,隔离 preset 逻辑(不真跑 gs)
+ monkeypatch.setattr(lc, "_eps_tool_commands", lambda eps, pdf: [])
+ proj = _eps_project(tmp_path)
+ defs = proj / "Definitions"
+ defs.mkdir()
+ (defs / "logo-mdpi.eps").write_bytes(_MIN_EPS)
+ (defs / "logo-mdpi-eps-converted-to.pdf").write_bytes(_tiny_pdf())
+ (defs / "other.eps").write_bytes(b"%!PS\n") # 无转换版 → 走现场转换(被 mock 挡下)
+ (defs / "mdpi.cls").write_text(
+ "\\includegraphics{Definitions/logo-mdpi.eps}\n"
+ "\\includegraphics{Definitions/other.eps}\n", encoding="utf-8")
+
+ lines = lc.process_eps_images(proj)
+
+ cls = (defs / "mdpi.cls").read_text(encoding="utf-8")
+ assert "Definitions/logo-mdpi.pdf" in cls # 显式引用已改写为 .pdf
+ assert "Definitions/other.eps" in cls # 转换失败的保持原样
+ assert (defs / "logo-mdpi.eps").is_file() # 原文件保留
+ assert (defs / "logo-mdpi.pdf").is_file() # 免转换复制
+ assert not (defs / "other.pdf").exists()
+ assert any(", preset)" in l for l in lines), lines
+ assert any("全部工具失败,图片 Definitions/other.eps 未转换" in l
+ for l in lines), lines
+
+
+def test_eps_v2_real_gs_convert(tmp_path):
+ """无预转换版 → 现场 gs 转换(真实工具链;测试点:显式 + 缺省扩展名引用都就位)。"""
+ if not shutil.which("gs"):
+ pytest.skip("gs 未安装")
+ proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}\n\\includegraphics{fig}")
+ lines = lc.process_eps_images(proj)
+ assert any("[eps-convert] fig.eps → fig.pdf" in l and ", gs)" in l for l in lines)
+ pdf = proj / "fig.pdf"
+ assert pdf.is_file() and pdf.read_bytes()[:5] == b"%PDF-"
+ tex = (proj / "main.tex").read_text(encoding="utf-8")
+ assert "{fig.pdf}" in tex # 显式 .eps 引用已改写
+ assert "{fig}" in tex # 缺省扩展名引用由 X.pdf 命中
+ assert (proj / "fig.eps").is_file() # 原文件保留
+
+
+def test_eps_v2_tool_fallback(tmp_path, monkeypatch):
+ """工具优先级 gs → epstopdf → ps2pdf:gs 失败 → epstopdf 成功。"""
+ def fake_tools(eps: Path, pdf: Path):
+ return [
+ ("gs", ["sh", "-c", "exit 1"]),
+ ("epstopdf", ["sh", "-c", f"echo %PDF-fake > {pdf}"]),
+ ]
+ monkeypatch.setattr(lc, "_eps_tool_commands", fake_tools)
+ proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}")
+ lines = lc.process_eps_images(proj)
+ assert any(l.startswith("[eps-convert] fig.eps → fig.pdf") and ", epstopdf)" in l
+ for l in lines), lines
+ assert (proj / "fig.pdf").is_file()
+ assert "{fig.pdf}" in (proj / "main.tex").read_text(encoding="utf-8")
+
+
+def test_eps_v2_all_tools_fail(tmp_path, monkeypatch):
+ """全工具失败 → log 行「全部工具失败…未转换」,引用保持 .eps。"""
+ monkeypatch.setattr(
+ lc, "_eps_tool_commands",
+ lambda eps, pdf: [("gs", ["sh", "-c", "exit 1"])])
+ proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}")
+ lines = lc.process_eps_images(proj)
+ assert "[eps-convert] 全部工具失败,图片 fig.eps 未转换" in lines
+ assert not (proj / "fig.pdf").exists()
+ assert "{fig.eps}" in (proj / "main.tex").read_text(encoding="utf-8")
+
+
+def test_eps_v2_timeout_warns(tmp_path, monkeypatch):
+ """约束2:单文件转换 15s 超时 → warning 行,放弃该文件继续。"""
+ monkeypatch.setattr(lc, "_EPS_CONVERT_TIMEOUT_S", 1)
+ monkeypatch.setattr(
+ lc, "_eps_tool_commands",
+ lambda eps, pdf: [("gs", ["sh", "-c", "sleep 5"])])
+ proj = _eps_project(tmp_path)
+ lines = lc.process_eps_images(proj)
+ assert "[eps-convert] fig.eps 转换超时" in lines
+ assert not (proj / "fig.pdf").exists()
+
+
+def test_eps_v2_file_size_warning(tmp_path, monkeypatch):
+ """约束3a:单文件转换后超上限 → warning 行(不中止)。"""
+ monkeypatch.setattr(lc, "_EPS_CONVERT_MAX_FILE", 10)
+ monkeypatch.setattr(
+ lc, "_eps_tool_commands",
+ lambda eps, pdf: [("gs", ["sh", "-c", f"head -c 1024 /dev/zero > {pdf}"])])
+ proj = _eps_project(tmp_path)
+ lines = lc.process_eps_images(proj)
+ assert any("警告" in l and "超过" in l for l in lines), lines
+
+
+def test_eps_v2_total_size_aborts(tmp_path, monkeypatch):
+ """约束3b:转换产物累计超上限 → InspectError(ZIP_TOO_LARGE) 中止。"""
+ monkeypatch.setattr(lc, "_EPS_CONVERT_MAX_TOTAL", 100)
+ monkeypatch.setattr(
+ lc, "_eps_tool_commands",
+ lambda eps, pdf: [("gs", ["sh", "-c", f"head -c 1024 /dev/zero > {pdf}"])])
+ proj = _eps_project(tmp_path)
+ with pytest.raises(lc.InspectError) as ei:
+ lc.process_eps_images(proj)
+ assert ei.value.code == "ZIP_TOO_LARGE"
+ assert "MB" in ei.value.message
+
+
+@needs_tectonic
+def test_run_eps_with_converted_pdf_succeeds(client: TestClient):
+ """EPS + 预转换 PDF(preset 路径)→ 显式与缺省扩展名引用都编译成功。"""
+ tex = (
+ "\\documentclass{article}\n\\usepackage{graphicx}\n"
+ "\\begin{document}\n"
+ "\\includegraphics[width=1cm]{fig.eps}\n" # 显式 .eps → 文本替换
+ "\\includegraphics[width=1cm]{fig}\n" # 缺省扩展名 → X.pdf 命中
+ "\\end{document}\n"
+ )
+ payload = _zip_bytes({
+ "main.tex": tex,
+ "fig.eps": _MIN_EPS,
+ "fig-eps-converted-to.pdf": _tiny_pdf(),
+ })
+ job_id = _upload(client, payload).json()["meta"]["job_id"]
+ body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ assert body["ok"] is True, body
+ pdf = client.get(body["pdf_url"])
+ assert pdf.content[:5] == b"%PDF-"
+
+
+@needs_tectonic
+def test_run_eps_real_gs_conversion_succeeds(client: TestClient):
+ """核心 v2 场景:真实 EPS 无预转换版 → 服务器现场转换 → 端到端编译成功。"""
+ if not shutil.which("gs"):
+ pytest.skip("gs 未安装")
+ tex = (
+ "\\documentclass{article}\n\\usepackage{graphicx}\n"
+ "\\begin{document}\n"
+ "\\includegraphics[width=1cm]{fig.eps}\n"
+ "\\includegraphics[width=1cm]{fig}\n"
+ "\\end{document}\n"
+ )
+ payload = _zip_bytes({"main.tex": tex, "fig.eps": _MIN_EPS})
+ job_id = _upload(client, payload).json()["meta"]["job_id"]
+ body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ assert body["ok"] is True, body
+ assert client.get(body["pdf_url"]).content[:5] == b"%PDF-"
+ # 约束4:转换日志落 output.log
+ log = client.get(body["log_url"]).text
+ assert "[eps-convert] fig.eps → fig.pdf" in log and ", gs)" in log
+
+
+@needs_tectonic
+def test_run_eps_all_tools_fail_friendly_message(
+ client: TestClient, monkeypatch):
+ """测试点2+3:全工具失败 → log 记「全部工具失败」→ 编译失败 → 前端友好提示。"""
+ monkeypatch.setattr(lc, "_eps_tool_commands", lambda eps, pdf: [])
+ tex = (
+ "\\documentclass{article}\n\\usepackage{graphicx}\n"
+ "\\begin{document}\n\\includegraphics[width=1cm]{fig.eps}\n\\end{document}\n"
+ )
+ payload = _zip_bytes({"main.tex": tex, "fig.eps": _MIN_EPS})
+ job_id = _upload(client, payload).json()["meta"]["job_id"]
+ body = client.post("/api/review/compile/run", json={"job_id": job_id}).json()
+ assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED"
+ assert body["error"]["message"] == \
+ "项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试"
+ log = client.get(body["log_url"]).text
+ assert "[eps-convert] 全部工具失败,图片 fig.eps 未转换" in log
+
+
+# ================================================================ zip 只读端点 404
+
+def test_zip_meta_404(client: TestClient):
+ r = client.get("/api/review/compile/zip/0123456789ab")
+ assert r.json()["error"]["code"] == "NOT_FOUND"
+
+
+def test_zip_pdf_404(client: TestClient):
+ r = client.get("/api/review/compile/zip/0123456789ab/pdf")
+ assert r.json()["error"]["code"] == "NOT_FOUND"
+
+
+def test_zip_log_404(client: TestClient):
+ r = client.get("/api/review/compile/zip/0123456789ab/log")
+ assert r.json()["error"]["code"] == "NOT_FOUND"
+
+
+# ================================================================ 清扫
+
+def test_sweep_inspect_expired(tmp_path, monkeypatch):
+ settings = get_settings()
+ monkeypatch.setattr(settings, "inspect_dir", str(tmp_path / "inspect"))
+ base = tmp_path / "inspect"
+ stale = base / "aabbccddeeff"
+ stale.mkdir(parents=True)
+ (stale / "inspect.json").write_text("{}", encoding="utf-8")
+ (stale / "project").mkdir()
+ old = time.time() - 2 * 3600
+ os.utime(stale, (old, old))
+
+ fresh = base / "112233445566"
+ fresh.mkdir()
+ noise = base / "not-a-job"
+ noise.mkdir()
+
+ removed = lc.sweep_inspect_expired()
+ assert removed == 1
+ assert not stale.exists()
+ assert fresh.exists()
+ assert noise.exists()
+
+
+def test_sweep_compile_jobs_expired(tmp_path, monkeypatch):
+ settings = get_settings()
+ monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
+ base = tmp_path / "cj"
+ 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()
+ (fresh / "output.pdf").write_bytes(b"%PDF-fake")
+ noise = base / "not-a-job"
+ noise.mkdir()
+
+ removed = lc.sweep_compile_jobs_expired()
+ assert removed == 1
+ assert not stale.exists()
+ assert fresh.exists()
+ assert noise.exists()
+
+
+def test_sweep_failed_project_ttl(tmp_path, monkeypatch):
+ """两级清扫①:失败现场 project/ 超 failed_project_ttl_s → 只删子目录,log 保留。"""
+ settings = get_settings()
+ monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
+ monkeypatch.delenv("KEEP_FAILED_PROJECTS", raising=False)
+ base = tmp_path / "cj"
+ stale = base / "aabbccddeeff"
+ (stale / "project").mkdir(parents=True)
+ (stale / "output.log").write_text("log", encoding="utf-8")
+ old = time.time() - settings.failed_project_ttl_s - 3600
+ os.utime(stale / "project", (old, old))
+ os.utime(stale, (old, old)) # 整目录也旧,但未超 7 天 → ② 不触发
+
+ fresh = base / "112233445566"
+ (fresh / "project").mkdir(parents=True)
+ (fresh / "output.log").write_text("log", encoding="utf-8")
+
+ removed = lc.sweep_compile_jobs_expired()
+ assert removed == 1
+ assert not (stale / "project").exists() # 现场删
+ assert (stale / "output.log").is_file() # log 留(完整 log 下载)
+ assert (fresh / "project").exists()
+
+
+def test_sweep_failed_project_kept_when_flag(tmp_path, monkeypatch):
+ """KEEP_FAILED_PROJECTS=1:失败现场永久保留(跳过①)。"""
+ settings = get_settings()
+ monkeypatch.setattr(settings, "compile_jobs_dir", str(tmp_path / "cj"))
+ monkeypatch.setenv("KEEP_FAILED_PROJECTS", "1")
+ base = tmp_path / "cj"
+ stale = base / "aabbccddeeff"
+ (stale / "project").mkdir(parents=True)
+ old = time.time() - settings.failed_project_ttl_s - 3600
+ os.utime(stale / "project", (old, old))
+
+ removed = lc.sweep_compile_jobs_expired()
+ assert removed == 0
+ assert (stale / "project").exists()
diff --git a/review_service/tests/test_zip_safety.py b/review_service/tests/test_zip_safety.py
new file mode 100644
index 0000000..25bb186
--- /dev/null
+++ b/review_service/tests/test_zip_safety.py
@@ -0,0 +1,195 @@
+"""tests/test_zip_safety.py —— P4 zip 安全模块纯逻辑测试(无需 tectonic)。
+
+覆盖设计 §2 安全清单 #1-#13(#14 tectonic --untrusted 归 test_compile.py,
+#15 由调用方 to_thread 承担,模块本身只测上限封顶行为)。
+
+运行: cd review_service && /bin/python -m pytest -q tests/test_zip_safety.py
+"""
+from __future__ import annotations
+
+import io
+import stat
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from app.config import get_settings
+from app.services.zip_safety import (
+ ExtractResult,
+ ZipSafetyError,
+ classify_entry,
+ safe_extract,
+ sanitize_entry_name,
+)
+
+
+def _settings(**over):
+ """取真实 settings 并覆盖上限(dataclass 风格 monkeypatch)。"""
+ s = get_settings()
+ for k, v in over.items():
+ setattr(s, k, v)
+ return s
+
+
+def _make_zip(entries: dict[str, bytes], exec_bits: dict[str, int] | None = None) -> bytes:
+ """entries: name → content;exec_bits: name → unix mode(如 0o100755)。
+
+ 必须显式 ZIP_DEFLATED:手动构造的 ZipInfo 默认 ZIP_STORED(ratio=1,测不出压缩比炸弹)。
+ """
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ for name, data in entries.items():
+ info = zipfile.ZipInfo(name)
+ info.compress_type = zipfile.ZIP_DEFLATED
+ if exec_bits and name in exec_bits:
+ info.external_attr = exec_bits[name] << 16
+ zf.writestr(info, data)
+ return buf.getvalue()
+
+
+def _symlink_zip(name: str = "link") -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ info = zipfile.ZipInfo(name)
+ info.external_attr = (stat.S_IFLNK | 0o777) << 16 # 0xA1FF
+ zf.writestr(info, "../../etc/passwd")
+ return buf.getvalue()
+
+
+# ================================================================ sanitize(#1/#2/#11)
+
+class TestSanitize:
+ def test_normal_path_ok(self):
+ assert sanitize_entry_name("main.tex") == "main.tex"
+ assert sanitize_entry_name("figs/a.png") == "figs/a.png"
+ assert sanitize_entry_name("a/b/c/") == "a/b/c" # 目录尾斜杠归一
+
+ def test_path_traversal_rejected(self): # #1
+ with pytest.raises(ZipSafetyError) as e:
+ sanitize_entry_name("../../etc/passwd")
+ assert e.value.code == "ZIP_UNSAFE"
+
+ def test_backslash_traversal_rejected(self): # #2 Windows 变体
+ with pytest.raises(ZipSafetyError) as e:
+ sanitize_entry_name("..\\..\\evil.sh")
+ assert e.value.code == "ZIP_UNSAFE"
+
+ def test_absolute_path_rejected(self):
+ with pytest.raises(ZipSafetyError):
+ sanitize_entry_name("/etc/passwd")
+ with pytest.raises(ZipSafetyError):
+ sanitize_entry_name("C:\\Windows\\evil")
+
+ def test_control_char_rejected(self):
+ with pytest.raises(ZipSafetyError):
+ sanitize_entry_name("ma\x00in.tex")
+
+ def test_depth_limit(self): # #11
+ deep = "/".join(f"d{i}" for i in range(20)) + "/x.tex"
+ with pytest.raises(ZipSafetyError) as e:
+ sanitize_entry_name(deep)
+ assert e.value.code == "ZIP_UNSAFE"
+
+ def test_length_limit(self):
+ with pytest.raises(ZipSafetyError):
+ sanitize_entry_name("a" * 250 + ".tex")
+
+
+# ================================================================ classify(#4/#6)
+
+class TestClassify:
+ def test_allow(self):
+ for n in ("main.tex", "journal.cls", "refs.bib", "figs/a.png", "x.sty"):
+ assert classify_entry(n, False) == "allow"
+ assert classify_entry("figs/", True) == "allow"
+
+ def test_deny_dangerous(self): # #4
+ for n in ("run.sh", "evil.exe", "x.so", "x.dll", "a.py", "b.js"):
+ assert classify_entry(n, False) == "deny"
+
+ def test_skip_harmless_non_whitelist(self): # #6
+ for n in ("notes.docx", "old.log", "archive.zip", "img.gif"):
+ assert classify_entry(n, False) == "skip"
+
+ def test_skip_dirs(self): # #6 附属
+ assert classify_entry("node_modules/x.js", False) == "skip"
+ assert classify_entry(".git/config", False) == "skip"
+ assert classify_entry("__MACOSX/._a.tex", False) == "skip"
+
+
+# ================================================================ safe_extract(#3/#5/#7-#10/#12/#13)
+
+class TestSafeExtract:
+ def test_happy_path(self, tmp_path: Path):
+ zb = _make_zip({"main.tex": b"hi", "figs/a.png": b"\x89PNG"})
+ r = safe_extract(zb, tmp_path / "p", _settings())
+ assert isinstance(r, ExtractResult)
+ assert r.files == 2 and r.tex == 1 and r.images == 1
+ assert (tmp_path / "p" / "main.tex").read_text() == "hi"
+ assert (tmp_path / "p" / "figs" / "a.png").exists()
+
+ def test_symlink_rejected(self, tmp_path: Path): # #3
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(_symlink_zip(), tmp_path / "p", _settings())
+ assert e.value.code == "ZIP_UNSAFE"
+ assert not (tmp_path / "p").exists() # 预扫拒绝 → 未写盘
+
+ def test_deny_ext_rejects_whole_zip(self, tmp_path: Path): # #4
+ zb = _make_zip({"main.tex": b"x", "run.sh": b"#!/bin/sh"})
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(zb, tmp_path / "p", _settings())
+ assert e.value.code == "ZIP_UNSAFE" and "run.sh" in e.value.message
+ assert not (tmp_path / "p").exists()
+
+ def test_exec_bit_rejects(self, tmp_path: Path): # #5
+ zb = _make_zip({"main.tex": b"x"}, exec_bits={"main.tex": 0o100755})
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(zb, tmp_path / "p", _settings())
+ assert e.value.code == "ZIP_UNSAFE"
+
+ def test_harmless_skipped_not_rejected(self, tmp_path: Path): # #6 决策1
+ zb = _make_zip({"main.tex": b"x", "notes.docx": b"DOC", "old.zip": b"PK"})
+ r = safe_extract(zb, tmp_path / "p", _settings())
+ assert r.files == 1
+ assert r.skipped_count == 2
+ assert {s.name for s in r.skipped} == {"notes.docx", "old.zip"}
+ assert not (tmp_path / "p" / "notes.docx").exists()
+
+ def test_ratio_bomb_rejected(self, tmp_path: Path): # #7
+ zb = _make_zip({"big.txt": b"\x00" * (2 * 1024 * 1024)}) # 全零 → ratio ≫100
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(zb, tmp_path / "p", _settings())
+ assert e.value.code == "ZIP_BOMB"
+
+ def test_total_bytes_rejected_runtime(self, tmp_path: Path): # #8 声明上限
+ zb = _make_zip({"a.txt": b"x" * 5000, "b.txt": b"y" * 5000})
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(zb, tmp_path / "p", _settings(max_extract_bytes=6000))
+ assert e.value.code == "ZIP_BOMB"
+
+ def test_entry_bytes_rejected(self, tmp_path: Path): # #9
+ zb = _make_zip({"a.txt": b"x" * 10000})
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(zb, tmp_path / "p", _settings(max_zip_entry_bytes=1000))
+ assert e.value.code == "ZIP_BOMB"
+
+ def test_entry_count_rejected(self, tmp_path: Path): # #10
+ zb = _make_zip({f"f{i}.txt": b"x" for i in range(30)})
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(zb, tmp_path / "p", _settings(max_zip_entries=10))
+ assert e.value.code == "ZIP_BOMB"
+ assert not (tmp_path / "p").exists() # 预扫先于写盘
+
+ def test_corrupt_zip(self, tmp_path: Path):
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(b"not a zip at all", tmp_path / "p", _settings())
+ assert e.value.code == "ZIP_INVALID"
+
+ def test_traversal_entry_rejected_end_to_end(self, tmp_path: Path): # #1 端到端
+ zb = _make_zip({"../evil.txt": b"pwned"})
+ with pytest.raises(ZipSafetyError) as e:
+ safe_extract(zb, tmp_path / "p", _settings())
+ assert e.value.code == "ZIP_UNSAFE"
+ assert not (tmp_path / "evil.txt").exists() # 磁盘无残留
+ assert not (tmp_path / "p").exists()
diff --git a/tests/frontend/unit/edu/reformat.compile.test.js b/tests/frontend/unit/edu/reformat.compile.test.js
new file mode 100644
index 0000000..fbb97c7
--- /dev/null
+++ b/tests/frontend/unit/edu/reformat.compile.test.js
@@ -0,0 +1,178 @@
+// tests/frontend/unit/edu/reformat.compile.test.js —— ReformatPanel 编译 PDF Tab(P3b)
+// 1) 无 job_id → "请先完成重排版再编译" 引导
+// 2) 有 job_id → "编译 PDF" 按钮可点
+// 3) 点击后按钮变 "编译中…"(loading 期间禁用;store 状态驱动,切面板不中断)
+// 4) 成功状态:iframe 预览 + 下载按钮 + 耗时
+// 5) 失败状态:错误摘要 + log 尾折叠 + 查看完整 log
+// 6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createPinia, setActivePinia } from 'pinia';
+import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
+
+const compileTexMock = vi.fn();
+vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
+ const actual = await importOriginal(); // 保留真实 reviewErrorMessage(QUEUE_FULL 文案走 CODE_HINTS)
+ return {
+ ...actual,
+ getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
+ getDirectory: vi.fn(),
+ reformatTex: vi.fn(),
+ compileTex: (...args) => compileTexMock(...args),
+ };
+});
+
+import { useEduStore } from '../../../../ui/src/stores/edu.js';
+import { ApiError } from '../../../../ui/src/api/http.js';
+
+function seedResult(edu, job_id = 'abc123def456') {
+ edu.reformatResult = {
+ ok: true,
+ job_id,
+ modified_tex: '\\documentclass{article}\\begin{document}Hi\\end{document}',
+ changes: [],
+ warnings: [],
+ meta: { filename: 'paper.tex', journal_name: 'Plant Cell', rules_source: 'official' },
+ };
+ edu.reformatStatus = 'ready';
+}
+
+async function mountPdfTab(edu) {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ edu = useEduStore();
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ // 切到"编译 PDF" Tab
+ const tabs = wrapper.findAll('.rf-tab');
+ const pdfTab = tabs.find((t) => t.text() === '编译 PDF');
+ await pdfTab.trigger('click');
+ return { wrapper, edu };
+}
+
+describe('ReformatPanel 编译 PDF Tab(P3b)', () => {
+ beforeEach(() => {
+ compileTexMock.mockReset();
+ // jsdom 无 URL.createObjectURL:store blob 加载走 catch 回退,不致挂
+ if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
+ if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
+ global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
+ });
+
+ it('1) 无 job_id → "请先完成重排版再编译" 引导', async () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const edu = useEduStore();
+ edu.reformatResult = { ok: true, job_id: null, changes: [], meta: {} };
+ edu.reformatStatus = 'ready';
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
+ const guide = wrapper.find('[data-testid="rf-compile-guide"]');
+ expect(guide.exists()).toBe(true);
+ expect(guide.text()).toContain('请先完成重排版再编译');
+ expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false);
+ });
+
+ it('2) 有 job_id → "编译 PDF" 按钮可点', async () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const edu = useEduStore();
+ seedResult(edu);
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
+ const btn = wrapper.find('[data-testid="rf-compile-btn"]');
+ expect(btn.exists()).toBe(true);
+ expect(btn.text()).toBe('编译 PDF');
+ expect(btn.attributes('disabled')).toBeUndefined();
+ });
+
+ it('3) 点击后按钮变"编译中…"并禁用;首次编译黄条出现', async () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const edu = useEduStore();
+ seedResult(edu);
+ compileTexMock.mockReturnValue(new Promise(() => {})); // 挂起,模拟编译中
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
+ await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
+ await wrapper.vm.$nextTick();
+ const btn = wrapper.find('[data-testid="rf-compile-btn"]');
+ expect(btn.text()).toContain('编译中');
+ expect(btn.attributes('disabled')).toBeDefined();
+ expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="rf-first-hint"]').exists()).toBe(true);
+ expect(compileTexMock).toHaveBeenCalledTimes(1);
+ // loading 期间再点不重复提交
+ await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
+ expect(compileTexMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('4) 成功状态:iframe 预览 + 下载按钮 + 耗时', async () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const edu = useEduStore();
+ seedResult(edu);
+ edu.compileResult = {
+ ok: true, compile_id: 'abc123def456',
+ pdf_url: '/api/review/compile/abc123def456/pdf',
+ log_url: '/api/review/compile/abc123def456/log',
+ meta: { seconds: 12.34, pdf_bytes: 1048576, engine: 'tectonic' },
+ };
+ edu.compileStatus = 'ready';
+ edu.compilePdfUrl = ''; // blob 失败回退直链
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
+ await wrapper.vm.$nextTick();
+ const frame = wrapper.find('[data-testid="rf-pdf-frame"]');
+ expect(frame.exists()).toBe(true);
+ expect(frame.attributes('src')).toContain('/pdf');
+ expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="rf-pdf-size"]').text()).toContain('MB');
+ expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('12.3 秒');
+ expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="rf-compile-btn"]').exists()).toBe(false); // 操作行换成下载/重编
+ });
+
+ it('5) 失败状态:错误摘要 + log 尾折叠区 + 完整 log 按钮', async () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const edu = useEduStore();
+ seedResult(edu);
+ edu.compileStatus = 'error';
+ edu.compileError = 'LaTeX 编译失败:缺少宏包';
+ edu.compileLogTail = 'This is tectonic\n! LaTeX Error: File `foo.sty\' not found.\n68 lines omitted...';
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
+ await wrapper.vm.$nextTick();
+ const err = wrapper.find('[data-testid="rf-compile-error"]');
+ expect(err.exists()).toBe(true);
+ expect(err.text()).toContain('缺少宏包');
+ const details = wrapper.find('[data-testid="rf-log-details"]');
+ expect(details.exists()).toBe(true);
+ expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('foo.sty');
+ expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="rf-recompile"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(false);
+ });
+
+ it('6) QUEUE_FULL → 固定文案"编译队列已满(最多 5 个任务)"', async () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const edu = useEduStore();
+ seedResult(edu);
+ compileTexMock.mockRejectedValue(new ApiError('编译队列已满(7 个任务排队),请稍后重试', {
+ code: 'QUEUE_FULL', status: 429,
+ }));
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ await wrapper.findAll('.rf-tab').find((t) => t.text() === '编译 PDF').trigger('click');
+ await wrapper.find('[data-testid="rf-compile-btn"]').trigger('click');
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ const err = wrapper.find('[data-testid="rf-compile-error"]');
+ expect(err.exists()).toBe(true);
+ expect(err.text()).toContain('编译队列已满(最多 5 个任务),请稍后重试');
+ // 报错后按钮立即恢复可点(重新编译)
+ const retry = wrapper.find('[data-testid="rf-recompile"]');
+ expect(retry.exists()).toBe(true);
+ expect(retry.attributes('disabled')).toBeUndefined();
+ });
+});
diff --git a/tests/frontend/unit/edu/reformat.file.test.js b/tests/frontend/unit/edu/reformat.file.test.js
new file mode 100644
index 0000000..e8cd43b
--- /dev/null
+++ b/tests/frontend/unit/edu/reformat.file.test.js
@@ -0,0 +1,99 @@
+// tests/frontend/unit/edu/reformat.file.test.js —— ReformatPanel 文件持久化(P3b 修复)
+// bug:上传 .tex → 重排版 → 点"重新排版"换期刊 → 本地 file 被清空 → 要求重传。
+// 修复:File 进 store(reformatFile,同 presubmitFile 模式);reset() 不清 file;
+// 仅"×" removeFile 清空。
+// 1) store 有 reformatFile → 挂载即回显文件名
+// 2) 选择文件 → 切换目标期刊 → 文件仍在(store + DOM)
+// 3) 有结果时点"重新排版"(reset)→ 回上传区且文件保留
+// 4) "×" 移除 → store + 本地都清空
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createPinia, setActivePinia } from 'pinia';
+import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
+
+vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getJournals: vi.fn().mockResolvedValue({
+ ok: true,
+ items: [{ id: 'gb', name: 'Genome Biology' }, { id: 'mp', name: 'Molecular Plant' }],
+ }),
+ getDirectory: vi.fn(),
+ reformatTex: vi.fn(),
+ compileTex: vi.fn(),
+ };
+});
+
+import { useEduStore } from '../../../../ui/src/stores/edu.js';
+
+const TEX = new File(['\\documentclass{article}\\begin{document}Hi\\end{document}'], 'paper.tex', {
+ type: 'application/x-tex',
+});
+
+/** 先建 pinia → seed store → 再挂载(保证 mount 时读到 store 初值) */
+function setup(seedFn) {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const edu = useEduStore();
+ if (seedFn) seedFn(edu);
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ return { wrapper, edu };
+}
+
+describe('ReformatPanel 文件持久化(换期刊不丢文件)', () => {
+ beforeEach(() => {
+ global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
+ if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
+ if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
+ });
+
+ it('1) store 有 reformatFile → 挂载即回显文件名', async () => {
+ const { wrapper } = setup((edu) => { edu.reformatFile = TEX; });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
+ });
+
+ it('2) 选择文件后切目标期刊 → 文件仍在(store + DOM)', async () => {
+ const { wrapper, edu } = setup();
+ const input = wrapper.find('input.rf-file-input');
+ Object.defineProperty(input.element, 'files', { value: [TEX], configurable: true });
+ await input.trigger('change');
+ await wrapper.vm.$nextTick();
+ expect(edu.reformatFile).toBe(TEX);
+ expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
+ // 切换目标期刊(watch 只写 drafts,不清 file)
+ await wrapper.find('#rf-target').setValue('mp');
+ await wrapper.vm.$nextTick();
+ expect(edu.reformatFile).toBe(TEX);
+ expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex');
+ expect(edu.drafts.reformatTarget).toBe('mp');
+ });
+
+ it('3) 有结果时点"重新排版" → 回上传区且文件保留', async () => {
+ const { wrapper, edu } = setup((e) => {
+ e.reformatFile = TEX;
+ e.reformatResult = {
+ ok: true, job_id: 'abc123def456', modified_tex: 'x', changes: [], warnings: [],
+ meta: { filename: 'paper.tex' },
+ };
+ e.reformatStatus = 'ready';
+ });
+ await wrapper.vm.$nextTick();
+ const again = wrapper.findAll('button').find((b) => b.text() === '重新排版');
+ expect(again).toBeTruthy();
+ await again.trigger('click');
+ await wrapper.vm.$nextTick();
+ expect(edu.reformatResult).toBeNull();
+ expect(edu.reformatFile).toBe(TEX); // store 保留
+ expect(wrapper.find('.rf-file-name').text()).toContain('paper.tex'); // DOM 回显
+ });
+
+ it('4) "×" 移除 → store + 本地都清空', async () => {
+ const { wrapper, edu } = setup((e) => { e.reformatFile = TEX; });
+ await wrapper.vm.$nextTick();
+ await wrapper.find('.rf-remove').trigger('click');
+ expect(edu.reformatFile).toBeNull();
+ expect(wrapper.find('.rf-file-name').exists()).toBe(false);
+ });
+});
diff --git a/tests/frontend/unit/edu/reformat.rules-summary.test.js b/tests/frontend/unit/edu/reformat.rules-summary.test.js
new file mode 100644
index 0000000..cad1ea0
--- /dev/null
+++ b/tests/frontend/unit/edu/reformat.rules-summary.test.js
@@ -0,0 +1,123 @@
+// tests/frontend/unit/edu/reformat.rules-summary.test.js
+// 目标期刊规则摘要行(data-testid="rf-rules-summary"):
+// 1) 有规则字段 + 有 diff → 摘要 + "已修改 N 处"
+// 2) 规则全 null + diff=0 → "无强制约束" + "样本已满足该刊要求,无需修改"
+// 背景:GB/BMC PB/Rice 规则相同、Frontiers 几乎无规则,用户切换期刊看不到 diff 差异,
+// 即使 diff=0 也要展示"该刊要求什么",消除"没生效"错觉。
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createPinia, setActivePinia } from 'pinia';
+import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
+
+vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
+ getDirectory: vi.fn(),
+ reformatTex: vi.fn(),
+ compileTex: vi.fn(),
+ };
+});
+
+import { useEduStore } from '../../../../ui/src/stores/edu.js';
+
+function mountWithResult(eduSeed) {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const edu = useEduStore();
+ Object.assign(edu, eduSeed);
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ return { wrapper, edu };
+}
+
+function resultWith(rules, changes) {
+ return {
+ ok: true,
+ job_id: 'abc123def456',
+ modified_tex: 'x',
+ changes,
+ warnings: [],
+ meta: {
+ filename: 'paper.tex',
+ journal_name: 'Molecular Plant',
+ rules_source: 'official',
+ rules_note: 'journals.json 期刊专属规则',
+ rules,
+ },
+ };
+}
+
+describe('ReformatPanel 目标期刊规则摘要', () => {
+ beforeEach(() => {
+ global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
+ if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
+ if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
+ });
+
+ it('1) 有规则字段 + 有 diff → 摘要列出字段且标注"已修改 N 处"', async () => {
+ // Molecular Plant 真实规则形态:12pt / 1.5 / author_year(font/margin null 不显示)
+ const { wrapper } = mountWithResult({
+ reformatStatus: 'ready',
+ reformatResult: resultWith(
+ {
+ min_font_pt: 12, min_margin_mm: null, min_line_spacing: 1.5,
+ refs_style: 'author_year', max_pages: null, min_figures: null, max_fonts: null,
+ },
+ [
+ { line: 1, original: 'a', modified: 'b', reason: 'r', rule_key: 'font_pt', action: 'modified' },
+ { line: 3, original: 'a', modified: 'b', reason: 'r', rule_key: 'line_spacing', action: 'modified' },
+ { line: 4, original: 'a', modified: 'b', reason: 'r', rule_key: 'bib_style', action: 'modified' },
+ ],
+ ),
+ });
+ await wrapper.vm.$nextTick();
+ const el = wrapper.find('[data-testid="rf-rules-summary"]');
+ expect(el.exists()).toBe(true);
+ const text = el.text();
+ expect(text).toContain('1.5 倍行距');
+ expect(text).toContain('作者-年份引用');
+ expect(text).toContain('字号 ≥12pt');
+ expect(text).not.toContain('null');
+ expect(text).not.toContain('边距'); // min_margin_mm=null → 不显示
+ expect(text).toContain('已修改 3 处');
+ });
+
+ it('2) 规则全 null + diff=0 → "无强制约束" + "样本已满足该刊要求,无需修改"', async () => {
+ const { wrapper } = mountWithResult({
+ reformatStatus: 'ready',
+ reformatResult: resultWith(
+ {
+ min_font_pt: null, min_margin_mm: null, min_line_spacing: null,
+ refs_style: null, max_pages: null, min_figures: null, max_fonts: null,
+ },
+ [],
+ ),
+ });
+ await wrapper.vm.$nextTick();
+ const el = wrapper.find('[data-testid="rf-rules-summary"]');
+ expect(el.exists()).toBe(true);
+ const text = el.text();
+ expect(text).toContain('该刊官网未规定明确格式要求,无强制约束');
+ expect(text).toContain('样本已满足该刊要求,无需修改');
+ expect(text).not.toContain('已修改');
+ });
+
+ it('3) 双倍行距 + Vancouver(GB/BMC/Rice 形态)→ 正确映射中文标签', async () => {
+ const { wrapper } = mountWithResult({
+ reformatStatus: 'ready',
+ reformatResult: resultWith(
+ {
+ min_font_pt: null, min_margin_mm: null, min_line_spacing: 2.0,
+ refs_style: 'vancouver', max_pages: null, min_figures: null, max_fonts: null,
+ },
+ [{ line: 3, original: 'a', modified: 'b', reason: 'r', rule_key: 'line_spacing', action: 'modified' }],
+ ),
+ });
+ await wrapper.vm.$nextTick();
+ const text = wrapper.find('[data-testid="rf-rules-summary"]').text();
+ expect(text).toContain('双倍行距');
+ expect(text).toContain('Vancouver 引用');
+ expect(text).toContain('已修改 1 处');
+ });
+});
diff --git a/tests/frontend/unit/edu/reformat.zip.test.js b/tests/frontend/unit/edu/reformat.zip.test.js
new file mode 100644
index 0000000..5eeb377
--- /dev/null
+++ b/tests/frontend/unit/edu/reformat.zip.test.js
@@ -0,0 +1,188 @@
+// tests/frontend/unit/edu/reformat.zip.test.js —— ReformatPanel 项目 zip 两段式(P4)
+// 1) 默认 .tex 模式;切"项目 .zip"→ zip 上传区 + 打包建议文案 + inspect 按钮
+// 2) 选 .zip → inspect 成功 → 摘要(main/统计/跳过)+ 编译按钮
+// 3) inspect 失败(ZIP_UNSAFE 后端原话)→ 错误展示
+// 4) 点编译 → loading → 成功:iframe 预览 + 耗时
+// 5) 编译失败:错误摘要 + log 折叠区 + 查看完整 log(失败信封带 log_url)
+// 6) 模式互斥:切回 .tex 清 zip 状态
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createPinia, setActivePinia } from 'pinia';
+import ReformatPanel from '../../../../ui/src/components/edu/panels/ReformatPanel.vue';
+
+const inspectZipMock = vi.fn();
+const runZipCompileMock = vi.fn();
+vi.mock('../../../../ui/src/api/review.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getJournals: vi.fn().mockResolvedValue({ ok: true, items: [] }),
+ getDirectory: vi.fn(),
+ reformatTex: vi.fn(),
+ compileTex: vi.fn(),
+ inspectZip: (...args) => inspectZipMock(...args),
+ runZipCompile: (...args) => runZipCompileMock(...args),
+ };
+});
+
+import { useEduStore } from '../../../../ui/src/stores/edu.js';
+import { ApiError } from '../../../../ui/src/api/http.js';
+
+const INSPECT_OK = {
+ ok: true,
+ meta: {
+ job_id: 'abc123def456',
+ main: 'main.tex',
+ stats: { files: 5, tex: 1, images: 1, cls: 1, bib: 1, other: 1, bytes: 4096 },
+ skipped: [{ name: '__MACOSX/._main.tex', reason: 'skip_dir' }],
+ skipped_count: 1,
+ created_at: '2026-10-13T00:00:00+00:00',
+ },
+};
+
+function zipFile(name = 'project.zip') {
+ return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
+ type: 'application/zip',
+ });
+}
+
+async function mountZipPanel(edu) {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ edu = useEduStore();
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ await wrapper.find('[data-testid="rf-mode-zip"]').trigger('click');
+ return { wrapper, edu };
+}
+
+async function pickZip(wrapper, edu, file = zipFile()) {
+ const input = wrapper.find('input[type="file"]');
+ Object.defineProperty(input.element, 'files', { value: [file], configurable: true });
+ await input.trigger('change');
+ await wrapper.vm.$nextTick();
+}
+
+describe('ReformatPanel 项目 zip 两段式(P4)', () => {
+ beforeEach(() => {
+ inspectZipMock.mockReset();
+ runZipCompileMock.mockReset();
+ if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:mock');
+ if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn();
+ global.fetch = vi.fn().mockRejectedValue(new Error('no network'));
+ });
+
+ it('1) 默认 .tex 模式;切 zip → zip 上传区 + 打包建议 + inspect 按钮', async () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const wrapper = mount(ReformatPanel, { global: { plugins: [pinia] } });
+ expect(wrapper.find('[data-testid="rf-mode-tex"]').classes()).toContain('active');
+ expect(wrapper.find('[data-testid="rf-zip-inspect-btn"]').exists()).toBe(false);
+ // tex 提交按钮仍在
+ expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(true);
+
+ await wrapper.find('[data-testid="rf-mode-zip"]').trigger('click');
+ const zipBtn = wrapper.find('[data-testid="rf-zip-inspect-btn"]');
+ expect(zipBtn.exists()).toBe(true);
+ expect(zipBtn.attributes('disabled')).toBeDefined(); // 未选文件不可点
+ expect(wrapper.text()).toContain('打包建议:main.tex 放 zip 根目录');
+ // tex 提交按钮消失(互斥)
+ expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(false);
+ });
+
+ it('2) 选 .zip → inspect 成功 → 摘要 + 编译按钮', async () => {
+ inspectZipMock.mockResolvedValue(INSPECT_OK);
+ const { wrapper, edu } = await mountZipPanel(null);
+ await pickZip(wrapper, edu);
+ await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
+ await vi.waitFor(() => {
+ expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(true);
+ });
+ expect(inspectZipMock).toHaveBeenCalledTimes(1);
+ const meta = wrapper.find('[data-testid="rf-zip-meta"]');
+ expect(meta.text()).toContain('main.tex');
+ expect(meta.text()).toContain('文件 5 个');
+ expect(meta.text()).toContain('1 个文件已跳过');
+ expect(wrapper.find('[data-testid="rf-zip-skipped"]').text()).toContain('__MACOSX');
+ expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
+ });
+
+ it('3) inspect 失败(ZIP_UNSAFE)→ 错误展示', async () => {
+ inspectZipMock.mockRejectedValue(
+ new ApiError('zip 含危险条目:evil.sh', { code: 'ZIP_UNSAFE', status: 200 }),
+ );
+ const { wrapper, edu } = await mountZipPanel(null);
+ await pickZip(wrapper, edu);
+ await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
+ await vi.waitFor(() => {
+ expect(wrapper.find('[data-testid="rf-zip-error"]').exists()).toBe(true);
+ });
+ expect(wrapper.find('[data-testid="rf-zip-error"]').text()).toContain('evil.sh');
+ expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(false);
+ });
+
+ it('4) 编译 → loading → 成功:iframe 预览 + 耗时', async () => {
+ inspectZipMock.mockResolvedValue(INSPECT_OK);
+ let resolveRun;
+ runZipCompileMock.mockReturnValue(new Promise((r) => { resolveRun = r; }));
+ const { wrapper, edu } = await mountZipPanel(null);
+ await pickZip(wrapper, edu);
+ await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
+ await vi.waitFor(() => {
+ expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
+ });
+ await wrapper.find('[data-testid="rf-zip-compile-btn"]').trigger('click');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('[data-testid="rf-compile-loading"]').exists()).toBe(true);
+
+ resolveRun({
+ ok: true,
+ compile_id: 'aabbccddeeff',
+ pdf_url: '/api/review/compile/zip/aabbccddeeff/pdf',
+ log_url: '/api/review/compile/zip/aabbccddeeff/log',
+ meta: { seconds: 3.2, pdf_bytes: 2048, project: true },
+ });
+ await vi.waitFor(() => {
+ expect(wrapper.find('[data-testid="rf-pdf-frame"]').exists()).toBe(true);
+ });
+ expect(wrapper.find('[data-testid="rf-compile-secs"]').text()).toContain('3.2 秒');
+ expect(wrapper.find('[data-testid="rf-download-pdf"]').exists()).toBe(true);
+ });
+
+ it('5) 编译失败:错误摘要 + log 折叠区 + 查看完整 log', async () => {
+ inspectZipMock.mockResolvedValue(INSPECT_OK);
+ const compileErr = new ApiError('LaTeX 源码有语法错误', {
+ code: 'COMPILE_FAILED', status: 200,
+ });
+ compileErr.detail = 'l.12 \\badcmd'; // unwrap 从 error.detail 挂载(log 尾)
+ runZipCompileMock.mockRejectedValue(compileErr);
+ const { wrapper, edu } = await mountZipPanel(null);
+ await pickZip(wrapper, edu);
+ await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
+ await vi.waitFor(() => {
+ expect(wrapper.find('[data-testid="rf-zip-compile-btn"]').exists()).toBe(true);
+ });
+ await wrapper.find('[data-testid="rf-zip-compile-btn"]').trigger('click');
+ await vi.waitFor(() => {
+ expect(wrapper.find('[data-testid="rf-compile-error"]').exists()).toBe(true);
+ });
+ expect(wrapper.find('[data-testid="rf-compile-error"]').text()).toContain('语法错误');
+ expect(wrapper.find('[data-testid="rf-log-body"]').text()).toContain('l.12');
+ expect(wrapper.find('[data-testid="rf-download-log"]').exists()).toBe(true);
+ });
+
+ it('6) 模式互斥:切回 .tex 清 zip 状态(inspect 摘要消失)', async () => {
+ inspectZipMock.mockResolvedValue(INSPECT_OK);
+ const { wrapper, edu } = await mountZipPanel(null);
+ await pickZip(wrapper, edu);
+ await wrapper.find('[data-testid="rf-zip-inspect-btn"]').trigger('click');
+ await vi.waitFor(() => {
+ expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(true);
+ });
+ await wrapper.find('[data-testid="rf-mode-tex"]').trigger('click');
+ expect(wrapper.find('[data-testid="rf-zip-meta"]').exists()).toBe(false);
+ expect(edu.zipInspect).toBeNull();
+ expect(edu.zipFile).toBeNull();
+ // tex 上传区回来了
+ expect(wrapper.findAll('button').some((b) => b.text() === '开始自动重排版')).toBe(true);
+ });
+});
diff --git a/ui/src/api/review.js b/ui/src/api/review.js
index ca4bddb..d0cdd5d 100644
--- a/ui/src/api/review.js
+++ b/ui/src/api/review.js
@@ -19,6 +19,9 @@ function unwrap(promise) {
degraded: !!payload.error.degraded,
});
if (payload.error.detail) err.detail = payload.error.detail; // 编译失败的 log 尾等
+ // P4:项目编译失败信封带 compile_id + log_url(补充 3,前端"查看完整 log"用)
+ if (payload.compile_id) err.compileId = payload.compile_id;
+ if (payload.log_url) err.logUrl = payload.log_url;
throw err;
}
return payload;
@@ -104,6 +107,23 @@ export function compileTex(formData, opts = {}) {
return unwrap(post(`${BASE}/compile`, formData, opts));
}
+/** P4 POST /api/review/compile/inspect(multipart .zip ≤50MB,两段式之①)
+ * → {ok, meta:{job_id, main, stats:{files,tex,images,cls,bib,other,bytes},
+ * skipped:[{name,reason}], skipped_count, created_at}}
+ * 错误信封:VALIDATION / ZIP_UNSAFE / ZIP_BOMB / ZIP_INVALID(HTTP 200)。
+ * job_id 1 小时 TTL;run 前不占编译队列。 */
+export function inspectZip(formData, opts = {}) {
+ return unwrap(post(`${BASE}/compile/inspect`, formData, opts));
+}
+
+/** P4 POST /api/review/compile/run(JSON {job_id},两段式之②)
+ * → 与 compileTex 同构信封 {ok, compile_id, pdf_url, log_url, meta};
+ * pdf/log 路径带 /zip/ 前缀。失败(COMPILE_FAILED/TIMEOUT)也带 compile_id+log_url。
+ * QUEUE_FULL → HTTP 429(此时 job 未消费,可直接重试)。 */
+export function runZipCompile(payload, opts = {}) {
+ return unwrap(post(`${BASE}/compile/run`, payload, opts));
+}
+
/** ⑤ format-check 专用错误文案:解档/校验类 PARSE_FAILED/VALIDATION 用后端原话
* (与 presubmit 的"扫描件"提示是不同语境,不能复用 CODE_HINTS),其余走统一口径。 */
export function formatErrorMessage(err) {
@@ -119,9 +139,11 @@ const CODE_HINTS = {
VALIDATION: null,
PARSE_FAILED: '论文解析失败:可能是扫描件/图片型 PDF,请换文本型 PDF。',
UPSTREAM_DOWN: '审稿模型暂时不可用,请稍后重试。',
- SEED_UNAVAILABLE: '期刊库未就绪(种子数据缺失),请联系管理员。',
+ SEED_UNAVAILABLE: '期刊库就绪中(种子数据缺失),请稍后再试。',
TIMEOUT: '请求超时,请稍后重试。',
NETWORK: '网络异常,请检查连接后重试。',
+ // P3b 编译:固定口径(后端 message 带动态 pending 数,此处按规范统一为"最多 5 个")
+ QUEUE_FULL: '编译队列已满(最多 5 个任务),请稍后重试',
};
/** ApiError(或任意错误)→ 用户可读文案。degraded 由调用方读 err.degraded 走降级横幅样式。 */
diff --git a/ui/src/components/edu/panels/ReformatPanel.vue b/ui/src/components/edu/panels/ReformatPanel.vue
index 8627302..2c92013 100644
--- a/ui/src/components/edu/panels/ReformatPanel.vue
+++ b/ui/src/components/edu/panels/ReformatPanel.vue
@@ -9,10 +9,14 @@
选定后 target = 目录 id,后端经 journal_directory 回链)。
结果三 Tab:修改后源码(Blob 下载)/ Diff 报告(按 rule_key 着色、
按 action 计数:modified=已修改、warning=建议·橙色·不计入)/
- 编译 PDF(P3 决策暂缓:仅「开发中」占位,不触发编译;后端 tectonic
- API(P3a)保留备用,前端不暴露编译入口,引导下载 .tex 去 Overleaf/
- 本地编译)。
- 结果跨菜单保留(store reformatResult);样式 rf- 前缀,零新增依赖。 -->
+ 编译 PDF(P3b):POST /compile {job_id}(tectonic 单槽队列,约 10-30 秒,
+ 首次拉 bundle 1-3 分钟——store.compileFirstHint 黄条提示可切面板后台跑);
+ 成功→blob URL 内嵌 iframe 预览 + 下载 + 耗时/重编;失败→错误摘要 +
+ log 尾 200 行折叠 + 完整 log 下载;QUEUE_FULL→固定文案。
+ P4 项目 zip(上传区模式切换,与单文件互斥):POST /compile/inspect(≤50MB,
+ 1h TTL,run 前不占编译队列)→ 主文件/统计/跳过文件摘要 → POST /compile/run
+ (原子消费 inspect;信封与 P3b 同构复用 compile* 字段;失败也带 log_url)。
+ 结果跨菜单保留(store reformatResult/compile*);样式 rf- 前缀,零新增依赖。 -->