7 Commits
Author SHA1 Message Date
admin 69e3f35a43 Merge pull request 'feat(review): P3b+P4 论文重排版支持编译 PDF(含 zip 项目)' (#4) from wjq/PlantAI-rag-new:feature/p3b-compile-ui into main
Reviewed-on: http://8.163.92.8:3000/admin/PlantAI-rag-new/pulls/4
2026-10-15 18:30:14 +08:00
potaser de0b9f92be fix(review): EPS batch conversion in zip compile (v2)
- v1 fell through because it required pre-converted PDF in zip
- v2: convert all .eps files post-extract via gs -dSAFER
- 15s timeout per file; fail per-file, not blocking
- Size guards: 20MB per file warning, 100MB total abort
- Conversion log written to output.log
- Verified on real MDPI official zip → 7-page PDF

v1 lesson: test sample had pre-converted PDF but real zip does not
2026-10-14 09:47:49 +08:00
potaser 2797145104 feat(review): P4 two-phase zip compile + XDV→PDF diagnostics + EPS fallback
- inspect/upload zip then compile project (main-file detection, zip safety,
  single-slot queue, failed-envelope carries compile_id + log_url)
- keep failed project/ scene 24h (failed_project_ttl_s; KEEP_FAILED_PROJECTS=1
  forever), success deletes immediately; two-tier compile_jobs sweep
- add tectonic --print and persist full engine chatter to output.log so
  XDV→PDF (xdvipdfmx) errors are visible (MDPI: logo EPS inclusion failure)
- EPS fallback: swap X.eps refs for *-eps-converted-to.pdf when present and
  materialize X.pdf for extension-less graphicx lookup; friendly error when
  no converted PDF exists
- frontend: ReformatPanel zip upload/inspect/run flow; one-off
  scripts/debug_compile.py (gitignored)
- tests: backend 169 passed (22 zip_safety + 34 compile_zip + P3a regression),
  frontend edu vitest 43 passed
2026-10-13 18:42:04 +08:00
potaser 4e19350115 docs: add P3b PR report 2026-10-13 11:23:01 +08:00
potaser e036301e1a feat(review): show format rules summary per target journal
- New summary row: shows the journal's rules (even if no diff applies)
- Fixes user confusion when switching journals yields same diff
- When all rules are null: 'no explicit format requirements'
- Vitest: add case for summary rendering
2026-10-13 11:22:52 +08:00
potaser da84dff9bb fix(review): persist uploaded .tex file in reformat panel
- File now stored in edu.js store (reformatFile)
- Switching target journal no longer requires re-upload
- Explicit reset button clears file
- Vitest: 4 cases
2026-10-13 11:20:33 +08:00
potaser 3a72d3a861 feat(review): P3b enable compile PDF in reformat panel
- ReformatPanel: compile tab state machine (idle/loading/success/error)
- Blob URL for PDF preview; backend path hidden
- First-compile 1-3min hint banner (dismissible)
- QUEUE_FULL 429 with fixed message
- Store: compileStatus/compileResult/compileError + actions
- prewarm_tectonic.sh: minimal .tex compilation to pre-cache TeX bundles
- Vitest: 6 new cases (guide / button / loading / success / error / queue-full)
2026-10-13 10:24:55 +08:00
18 changed files with 3400 additions and 39 deletions
+6
View File
@@ -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
@@ -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 行(折叠 `<pre>`);完整 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 行
```
+29 -1
View File
@@ -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("/")
+35
View File
@@ -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
+158
View File
@@ -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)
+634 -1
View File
@@ -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"(?<!\\)\\documentclass\s*(\[|\{)")
_IMG_LIKE_EXT = {".png", ".jpg", ".jpeg", ".pdf", ".eps", ".svg"}
def inspect_root() -> 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
+288
View File
@@ -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
+4 -1
View File
@@ -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)"
@@ -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
+639
View File
@@ -0,0 +1,639 @@
"""tests/test_compile_zip.py —— P4 项目 zip 两段式编译集成测试。
真实调用 tools/tectonic(宏包已预热缓存);二进制缺失时集成用例 skipif 跳过,
校验/消费/清扫等纯逻辑用例照常运行。
运行: cd review_service && <venv>/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()
+195
View File
@@ -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 && <venv>/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()
@@ -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 尾折叠 <pre> + 查看完整 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();
});
});
@@ -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);
});
});
@@ -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 处');
});
});
@@ -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);
});
});
+23 -1
View File
@@ -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 走降级横幅样式。 */
+567 -31
View File
@@ -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- 前缀,零新增依赖。 -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { getDirectory, reviewErrorMessage } from '../../../api/review.js';
@@ -28,7 +32,7 @@ const SOURCE_LABEL = {
};
const fileInput = ref(null);
const file = ref(null);
const file = ref(edu.reformatFile || null); // File 存 store(换期刊重排不丢;× 移除才清空)
const fileError = ref('');
const dragging = ref(false);
const tab = ref('source'); // 'source' | 'diff' | 'pdf'
@@ -117,6 +121,31 @@ const structWarnings = computed(() => result.value?.warnings || []);
const modifiedCount = computed(() => changes.value.filter((c) => c.action === 'modified').length);
const suggestCount = computed(() => changes.value.filter((c) => c.action === 'warning').length);
const sourceLabel = computed(() => SOURCE_LABEL[meta.value?.rules_source] || meta.value?.rules_source || '—');
// 目标期刊规则摘要:只显示有值字段(null 不显示);全 null → 明确说明无强制约束。
// 目的:diff=0 时(如 Frontiers / GB↔BMC 互切)也能看到"该刊要求什么",消除"没生效"错觉。
const _REFS_LABEL = { vancouver: 'Vancouver 引用', apa: 'APA 引用', author_year: '作者-年份引用' };
const rulesSummary = computed(() => {
const r = meta.value?.rules;
if (!r || typeof r !== 'object') return null;
const parts = [];
const ls = r.min_line_spacing;
if (typeof ls === 'number' && ls > 0) {
if (ls <= 1.05) parts.push('单倍行距');
else if (Math.abs(ls - 1.5) < 0.01) parts.push('1.5 倍行距');
else if (ls >= 1.95) parts.push('双倍行距');
else parts.push(`行距 ≥${ls} 倍`);
}
if (r.refs_style && _REFS_LABEL[r.refs_style]) parts.push(_REFS_LABEL[r.refs_style]);
if (typeof r.min_font_pt === 'number') parts.push(`字号 ≥${r.min_font_pt}pt`);
if (typeof r.min_margin_mm === 'number') parts.push(`边距 ≥${r.min_margin_mm}mm`);
if (!parts.length) return '该刊官网未规定明确格式要求,无强制约束';
return parts.join(' · ');
});
// 与 diff 的关系:有修改 → "已修改 N 处";0 修改 → "样本已满足该刊要求,无需修改"
const rulesStatusText = computed(() => {
if (modifiedCount.value > 0) return `已修改 ${modifiedCount.value} 处`;
return '样本已满足该刊要求,无需修改';
});
// ISO 时间戳 → "10-12 15:25"(内部字段不裸露;helper 规则见 UI 注释)
const generatedShort = computed(() => {
const s = meta.value?.generated_at;
@@ -129,6 +158,137 @@ function copyJobId() {
if (id && navigator.clipboard) navigator.clipboard.writeText(String(id)).catch(() => {});
}
/* —— P3b 编译 PDF(状态全在 store → 切菜单/切 Tab 编译继续跑)—— */
const compiling = computed(() => edu.compileStatus === 'loading');
const compileReady = computed(() => edu.compileStatus === 'ready' && !!edu.compileResult);
const compileErrored = computed(() => edu.compileStatus === 'error');
const compileMeta = computed(() => edu.compileResult?.meta || null);
const pdfSrc = computed(() => edu.compilePdfUrl || edu.compileResult?.pdf_url || '');
const pdfSizeText = computed(() => {
const b = compileMeta.value?.pdf_bytes;
if (b == null) return '';
return b >= 1048576 ? `${(b / 1048576).toFixed(1)} MB` : `${Math.ceil(b / 1024)} KB`;
});
const compileLog = computed(() => edu.compileLogTail || '');
const logCollapsed = ref(true); // log 折叠区(默认收起)
const firstHintDismissed = ref(false);
const showFirstHint = computed(() =>
edu.compileFirstHint && compiling.value && !firstHintDismissed.value,
);
function startCompile() {
const jid = result.value?.job_id;
if (!jid || compiling.value) return;
logCollapsed.value = true;
edu.submitCompile(jid); // 不 await:按钮/进度由 store 状态驱动,切面板不中断
}
function downloadPdf() {
const href = pdfSrc.value;
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.download = `${result.value?.job_id || 'paper'}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
}
function downloadFullLog() {
// 成功:信封带 log_url;失败信封不带——job_id 即 anchor_id,log 同目录可拼路径
const jid = result.value?.job_id;
const href = edu.compileResult?.log_url || (jid ? `/api/review/compile/${jid}/log` : '');
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.download = `${jid || 'compile'}.log`;
document.body.appendChild(a);
a.click();
a.remove();
}
/* —— P4 项目 zip(两段式;与单文件模式互斥,切换即清对侧状态)—— */
const uploadMode = ref('tex'); // 'tex' | 'zip'
const zipInput = ref(null);
const zipInspecting = computed(() => edu.zipInspectStatus === 'loading');
const zipInspected = computed(() => edu.zipInspectStatus === 'ready' && !!edu.zipInspect);
const zipError = computed(() => edu.zipInspectError || '');
const MAX_ZIP_SIZE = 50 * 1024 * 1024; // 与后端 REVIEW_MAX_ZIP_BYTES 一致
function switchMode(mode) {
if (uploadMode.value === mode) return;
uploadMode.value = mode;
fileError.value = '';
if (mode === 'zip') {
edu.clearReformat(); // 互斥:zip 编译结果与单文件编译共用 compile* 字段
} else {
edu.clearZip();
}
}
function acceptZip(f) {
if (!f) return;
if (!String(f.name || '').toLowerCase().endsWith('.zip')) {
edu.zipInspectStatus = 'error';
edu.zipInspectError = '仅支持 .zip 项目包';
return;
}
if (!f.size) {
edu.zipInspectStatus = 'error';
edu.zipInspectError = '上传文件为空';
return;
}
if (f.size > MAX_ZIP_SIZE) {
edu.zipInspectStatus = 'error';
edu.zipInspectError = 'zip 超过 50MB 上限';
return;
}
edu.zipFile = f;
edu.zipInspectStatus = 'idle'; // 换文件 → 旧 inspect 失效
edu.zipInspect = null;
edu.zipInspectError = null;
edu.clearCompile();
}
function onZipPick(e) {
acceptZip(e.target.files?.[0]);
e.target.value = '';
}
function onZipDrop(e) {
dragging.value = false;
acceptZip(e.dataTransfer?.files?.[0]);
}
function removeZip() {
edu.clearZip(); // 唯一清空入口:"×" 移除
}
async function startZipInspect() {
if (!edu.zipFile || zipInspecting.value) return;
await edu.submitZipInspect(); // 不 await 也可,但保持与其他面板一致写法
}
function startZipCompile() {
logCollapsed.value = true;
edu.submitZipCompile(); // 不 await:状态由 store 驱动,切面板不中断
}
function downloadZipPdf() {
const href = pdfSrc.value;
if (!href) return;
const cid = edu.compileResult?.compile_id || 'project';
const a = document.createElement('a');
a.href = href;
a.download = `${cid}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
}
function downloadZipLog() {
const cid = edu.compileResult?.compile_id;
const href = edu.compileResult?.log_url || (cid ? `/api/review/compile/zip/${cid}/log` : '');
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.download = `${cid || 'compile'}.log`;
document.body.appendChild(a);
a.click();
a.remove();
}
onMounted(() => {
if (edu.journalsStatus === 'idle') edu.fetchJournals(); // 精选期刊下拉数据源
// 恢复的 target 是目录刊但无选中记录(历史草稿)→ 回落通用规则
@@ -154,6 +314,7 @@ function acceptFile(f) {
return;
}
file.value = f;
edu.reformatFile = f; // 进 store:换期刊/切菜单重排不丢
}
function onPick(e) {
acceptFile(e.target.files?.[0]);
@@ -165,6 +326,7 @@ function onDrop(e) {
}
function removeFile() {
file.value = null;
edu.reformatFile = null; // 唯一清空入口:"×" 移除
}
async function submit() {
@@ -177,7 +339,7 @@ async function submit() {
}
function reset() {
edu.clearReformat();
file.value = null;
// 不清 file——换期刊重排时保留已上传文件(仅"×"移除才清)
fileError.value = '';
tab.value = 'source';
}
@@ -216,6 +378,11 @@ function download() {
复制任务 ID
</button>
</div>
<!-- 目标期刊规则摘要:diff=0 时也能看到"该刊要求什么"(次级文字,不抢 diff 主视觉) -->
<p v-if="rulesSummary" class="rf-rules" data-testid="rf-rules-summary">
📋 该刊规则:{{ rulesSummary }}
<span class="rf-rules-status">({{ rulesStatusText }})</span>
</p>
<p v-if="meta?.rules_note" class="rf-note" role="note">{{ meta.rules_note }}</p>
<!-- 汇总:已修改 / 建议(橙,不计入修改)/ 结构提示 -->
@@ -269,21 +436,95 @@ function download() {
</article>
</div>
<!-- 编译 PDF(P3 暂缓):「开发中」占位,不触发编译;后端 P3a API 保留备用 -->
<!-- ========== 编译 PDF(P3b) ========== -->
<div v-else class="rf-compile">
<div class="rf-wip" role="note">
<p class="rf-wip-main">
PDF 编译功能开发中,暂时请下载修改后的 .tex 文件,在 Overleaf 或本地编译。
后续将支持多文件工程和图片上传。
</p>
<p class="rf-wip-sub">已提供逐行修改对照(Diff 报告)与修改后源码下载。</p>
<a
class="rf-wip-feedback"
href="http://192.168.0.175:3000/admin/PlantAI-rag-new/issues"
target="_blank"
rel="noopener noreferrer"
>反馈需求 →</a>
<!-- 无 job_id(未完成重排版)→ 引导 -->
<div v-if="!result?.job_id" class="rf-wip" role="note" data-testid="rf-compile-guide">
<p class="rf-wip-main">请先完成重排版再编译。</p>
<p class="rf-wip-sub">编译基于重排版后的 .tex 源码,重排版成功后此页会出现「编译 PDF」按钮。</p>
</div>
<template v-else>
<!-- 首次编译慢提示(loading 期间显示,可手动关闭) -->
<div v-if="showFirstHint" class="rf-first-hint" role="note" data-testid="rf-first-hint">
<span>
首次编译需要 1-3 分钟(下载 TeX 环境),后续编译秒级。
可以切到其他面板,编译会在后台继续。
</span>
<button class="rf-hint-x" type="button" aria-label="关闭提示"
@click="firstHintDismissed = true">×</button>
</div>
<!-- 操作行:编译按钮 / 下载 / 重新编译 -->
<div class="rf-compile-bar">
<button
v-if="!compileReady && !compileErrored"
class="rf-compile-btn" type="button"
data-testid="rf-compile-btn"
:disabled="compiling"
@click="startCompile"
>
{{ compiling ? '编译中…(首次约 1-3 分钟)' : '编译 PDF' }}
</button>
<template v-if="compileReady">
<button class="rf-compile-btn" type="button" data-testid="rf-download-pdf"
@click="downloadPdf">下载 PDF</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-recompile"
:disabled="compiling" @click="startCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
</template>
<template v-if="compileErrored">
<button class="rf-compile-btn" type="button" data-testid="rf-recompile"
:disabled="compiling" @click="startCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-download-log" @click="downloadFullLog">查看完整 log</button>
</template>
<span v-if="compileReady && pdfSizeText" class="rf-compile-size"
data-testid="rf-pdf-size">{{ pdfSizeText }}</span>
</div>
<!-- loading:动画进度(后端无 stage,不确定时长) -->
<div v-if="compiling" class="rf-compile-loading" role="status"
data-testid="rf-compile-loading">
<span class="rf-compile-spin" aria-hidden="true" />
<p class="rf-state-msg">
编译中…(tectonic 单槽队列,常规 10-30 秒;首次 1-3 分钟)
可切换到其他面板,编译在后台继续。
</p>
</div>
<!-- 成功:内嵌 PDF 预览(blob URL) -->
<div v-else-if="compileReady" class="rf-compile-ready">
<iframe
class="rf-compile-frame" :src="pdfSrc" title="PDF 预览"
data-testid="rf-pdf-frame"
/>
<div class="rf-compile-foot">
<span class="rf-compile-secs" data-testid="rf-compile-secs">
编译耗时 {{ compileMeta?.seconds != null ? `${Number(compileMeta.seconds).toFixed(1)} 秒` : '—' }}
</span>
</div>
</div>
<!-- 失败:错误摘要 + log 尾折叠 + 完整 log 下载 -->
<div v-else-if="compileErrored" class="rf-compile-error" role="alert"
data-testid="rf-compile-error">
<p class="rf-compile-msg">{{ edu.compileError }}</p>
<details
v-if="compileLog" class="rf-log" :open="!logCollapsed"
data-testid="rf-log-details"
>
<summary class="rf-log-sum" @click="logCollapsed = !logCollapsed">
编译日志(尾 200 行)
</summary>
<pre class="rf-log-body" data-testid="rf-log-body">{{ compileLog }}</pre>
</details>
</div>
</template>
</div>
</template>
@@ -304,6 +545,145 @@ function download() {
<!-- ========== ④ 上传区 ========== -->
<div v-else class="rf-upload">
<!-- P4 模式切换:单文件 .tex(P3 重排版链路)| 项目 .zip(两段式编译,不经重排版) -->
<div class="rf-modes" role="tablist" aria-label="上传模式">
<button
class="rf-mode" :class="{ active: uploadMode === 'tex' }" type="button"
role="tab" :aria-selected="uploadMode === 'tex'" data-testid="rf-mode-tex"
@click="switchMode('tex')"
>单文件 .tex</button>
<button
class="rf-mode" :class="{ active: uploadMode === 'zip' }" type="button"
role="tab" :aria-selected="uploadMode === 'zip'" data-testid="rf-mode-zip"
@click="switchMode('zip')"
>项目 .zip</button>
</div>
<!-- ========== ④-zip 项目上传(P4 两段式) ========== -->
<template v-if="uploadMode === 'zip'">
<div
class="rf-drop"
:class="{ dragging, filled: !!edu.zipFile }"
role="button"
tabindex="0"
@click="zipInput.click()"
@keydown.enter="zipInput.click()"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onZipDrop"
>
<input
ref="zipInput"
class="rf-file-input"
type="file"
accept=".zip"
@click.stop
@change="onZipPick"
/>
<template v-if="edu.zipFile">
<span class="rf-file-name">🗜 {{ edu.zipFile.name }} · {{ (edu.zipFile.size / 1024 / 1024).toFixed(1) }} MB</span>
<button class="rf-remove" type="button" title="移除文件" @click.stop="removeZip">×</button>
</template>
<template v-else>
<span class="rf-drop-main">拖拽 LaTeX 项目(.zip)到此处,或点击选择</span>
<span class="rf-hint">打包建议:main.tex 放 zip 根目录,图片用相对路径引用,不要包含 .git / node_modules / 无关大文件。</span>
</template>
</div>
<p v-if="zipError" class="rf-file-error" data-testid="rf-zip-error">{{ zipError }}</p>
<button
class="rf-submit"
type="button"
:disabled="!edu.zipFile || zipInspecting"
data-testid="rf-zip-inspect-btn"
@click="startZipInspect"
>
{{ zipInspecting ? '检查中…' : '上传并检查' }}
</button>
<!-- inspect 通过:项目摘要 + 编译入口 + 编译结果(复用 P3b compile* 状态) -->
<template v-if="zipInspected">
<div class="rf-zip-meta" data-testid="rf-zip-meta">
📦 主文件:<code>{{ edu.zipInspect?.main }}</code>
· 文件 {{ edu.zipInspect?.stats?.files }} 个
· .tex {{ edu.zipInspect?.stats?.tex }}
<template v-if="edu.zipInspect?.stats?.images"> · 图片 {{ edu.zipInspect.stats.images }}</template>
<template v-if="edu.zipInspect?.stats?.cls"> · .cls {{ edu.zipInspect.stats.cls }}</template>
<template v-if="edu.zipInspect?.stats?.bib"> · .bib {{ edu.zipInspect.stats.bib }}</template>
<template v-if="edu.zipInspect?.skipped_count">
· <span class="rf-zip-skipped">{{ edu.zipInspect.skipped_count }} 个文件已跳过</span>
</template>
</div>
<p v-if="edu.zipInspect?.skipped_count" class="rf-zip-skipped-list" data-testid="rf-zip-skipped">
已跳过(不参与编译):{{ (edu.zipInspect?.skipped || []).map((s) => s.name).join('、') }}
</p>
<div class="rf-compile-bar">
<button
v-if="!compileReady && !compileErrored"
class="rf-compile-btn" type="button"
data-testid="rf-zip-compile-btn"
:disabled="compiling"
@click="startZipCompile"
>
{{ compiling ? '编译中…' : '编译 PDF' }}
</button>
<template v-if="compileReady">
<button class="rf-compile-btn" type="button" data-testid="rf-download-pdf"
@click="downloadZipPdf">下载 PDF</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-recompile" :disabled="compiling" @click="startZipCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
</template>
<template v-if="compileErrored">
<button class="rf-compile-btn" type="button" data-testid="rf-recompile"
:disabled="compiling" @click="startZipCompile">
{{ compiling ? '编译中…' : '重新编译' }}
</button>
<button class="rf-compile-btn rf-compile-ghost" type="button"
data-testid="rf-download-log" @click="downloadZipLog">查看完整 log</button>
</template>
<span v-if="compileReady && pdfSizeText" class="rf-compile-size"
data-testid="rf-pdf-size">{{ pdfSizeText }}</span>
</div>
<div v-if="compiling" class="rf-compile-loading" role="status"
data-testid="rf-compile-loading">
<span class="rf-compile-spin" aria-hidden="true" />
<p class="rf-state-msg">编译中…(tectonic 单槽队列,常规 10-30 秒;项目首次编译需下载宏包可能更慢)</p>
</div>
<div v-else-if="compileReady" class="rf-compile-ready">
<iframe
class="rf-compile-frame" :src="pdfSrc" title="PDF 预览"
data-testid="rf-pdf-frame"
/>
<div class="rf-compile-foot">
<span class="rf-compile-secs" data-testid="rf-compile-secs">
编译耗时 {{ compileMeta?.seconds != null ? `${Number(compileMeta.seconds).toFixed(1)} 秒` : '—' }}
</span>
</div>
</div>
<div v-else-if="compileErrored" class="rf-compile-error" role="alert"
data-testid="rf-compile-error">
<p class="rf-compile-msg">{{ edu.compileError }}</p>
<details
v-if="compileLog" class="rf-log" :open="!logCollapsed"
data-testid="rf-log-details"
>
<summary class="rf-log-sum" @click="logCollapsed = !logCollapsed">
编译日志(尾 200 行)
</summary>
<pre class="rf-log-body" data-testid="rf-log-body">{{ compileLog }}</pre>
</details>
</div>
</template>
</template>
<!-- ========== ④-tex 单文件上传(P3 链路,零改动) ========== -->
<template v-else>
<div
class="rf-drop"
:class="{ dragging, filled: !!file }"
@@ -387,6 +767,7 @@ function download() {
开始自动重排版
</button>
<p v-if="dirMode && !actualTarget" class="rf-dirhint">请从搜索结果中选择期刊后提交</p>
</template>
</div>
</section>
</template>
@@ -416,6 +797,15 @@ function download() {
font-size: 12.5px;
color: var(--pa-ink-muted);
}
/* 规则摘要:次级文字(同 rf-meta 色阶),与汇总条的计数视觉区分 */
.rf-rules {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.rf-rules-status {
color: var(--pa-ink-subtle);
}
.rf-copy-id {
margin-left: 8px;
padding: 1px 8px;
@@ -625,12 +1015,6 @@ function download() {
color: var(--pa-warning);
}
/* 编译 PDF(P3 暂缓:开发中占位) */
.rf-compile {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-wip {
display: flex;
flex-direction: column;
@@ -652,13 +1036,126 @@ function download() {
font-size: 12.5px;
color: var(--pa-ink-subtle);
}
.rf-wip-feedback {
font-size: 12.5px;
font-weight: 600;
color: var(--pa-g-from);
text-decoration: none;
/* 编译 PDF(P3b) */
.rf-compile {
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-first-hint {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--pa-warning) 45%, transparent);
border-radius: var(--pa-r-md);
background: color-mix(in srgb, var(--pa-warning) 12%, transparent);
color: var(--pa-ink);
font-size: 12.5px;
line-height: 1.6;
}
.rf-hint-x {
border: none;
background: transparent;
color: var(--pa-ink-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.rf-hint-x:hover { color: var(--pa-ink); }
.rf-compile-bar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.rf-compile-btn {
padding: 7px 18px;
border: none;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.rf-compile-btn:disabled { opacity: .6; cursor: not-allowed; }
.rf-compile-btn:not(:disabled):hover { filter: brightness(1.08); }
.rf-compile-ghost {
background: transparent;
border: 1px solid var(--pa-hairline-strong);
color: var(--pa-ink);
}
.rf-compile-size {
font-size: 12px;
color: var(--pa-ink-subtle);
}
.rf-compile-loading {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px;
background: var(--pa-surface-2);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.rf-compile-spin {
flex: none;
width: 18px;
height: 18px;
border: 2px solid var(--pa-hairline-strong);
border-top-color: var(--pa-g-from);
border-radius: 50%;
animation: rf-spin .8s linear infinite;
}
@keyframes rf-spin { to { transform: rotate(360deg); } }
.rf-compile-ready { display: flex; flex-direction: column; gap: 8px; }
.rf-compile-frame {
width: 100%;
height: 560px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
background: var(--pa-surface-2);
}
.rf-compile-foot { display: flex; gap: 12px; align-items: center; }
.rf-compile-secs { font-size: 12px; color: var(--pa-ink-subtle); }
.rf-compile-error {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
background: color-mix(in srgb, var(--pa-error) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--pa-error) 40%, transparent);
border-radius: var(--pa-r-lg);
}
.rf-compile-msg {
margin: 0;
font-size: 13px;
color: var(--pa-error);
}
.rf-log { border-top: 1px dashed var(--pa-hairline-strong); padding-top: 8px; }
.rf-log-sum {
font-size: 12.5px;
color: var(--pa-ink-muted);
cursor: pointer;
user-select: none;
}
.rf-log-body {
margin: 8px 0 0;
max-height: 300px;
overflow: auto;
padding: 10px 12px;
background: var(--pa-code-bg);
border-radius: var(--pa-r-md);
font-size: 12px;
line-height: 1.55;
color: var(--pa-ink-muted);
white-space: pre-wrap;
word-break: break-word;
}
.rf-wip-feedback:hover { text-decoration: underline; }
/* 状态区 */
.rf-state {
@@ -755,6 +1252,45 @@ function download() {
font-size: 12.5px;
color: var(--pa-error);
}
/* P4 模式切换(单文件 .tex | 项目 .zip) */
.rf-modes {
display: flex;
gap: 6px;
}
.rf-mode {
padding: 5px 14px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-pill);
background: transparent;
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
}
.rf-mode.active {
border-color: var(--pa-g-from);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-weight: 600;
}
/* P4 zip 项目摘要(次级文字,与 rf-meta 同色阶) */
.rf-zip-meta {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.rf-zip-meta code {
padding: 1px 6px;
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
font-size: 12px;
}
.rf-zip-skipped { color: var(--pa-warning); }
.rf-zip-skipped-list {
margin: 0;
font-size: 12px;
color: var(--pa-ink-subtle);
word-break: break-all;
}
.rf-label {
font-size: 12px;
font-weight: 500;
+89 -4
View File
@@ -4,7 +4,7 @@
// - 进行中的请求由 store action 持有 → 面板卸载不中断;
// - 表单草稿存 drafts → 切菜单不丢。
import { defineStore } from 'pinia';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse } from '../api/review.js';
import { compileTex, formatCheck, formatErrorMessage, getConferences, getDirectory, getJournals, inspectZip, presubmit, recommend, reformatTex, reviewErrorMessage, rebuttalConfirm, rebuttalCreateFromPdf, rebuttalCreateJob, rebuttalDeleteJob, rebuttalDraft, rebuttalLetter, rebuttalSaveResponse, runZipCompile } from '../api/review.js';
const STORAGE_KEY = 'plantai.edu.submissions'; // D3 台账持久化(tracker 面板已解除映射,state 保留)
// tracker 已砍(2026-10-12,仅解除 UI 映射;TrackerPanel.vue 保留在 panels/ 下可复用)
@@ -72,6 +72,9 @@ export const useEduStore = defineStore('edu', {
// ③ presubmit 已选 PDF(File 对象,仅内存:面板卸载/切菜单不丢,error 态可直接重试;
// 不可序列化 → 不进 localStorage;clearReport 一并清空)
presubmitFile: null,
// ⑤' reformat 已选 .tex(同 presubmitFile 模式:换期刊重排不丢文件;
// 面板内"×"移除才清空。File 不可序列化 → 不进 localStorage)
reformatFile: null,
recommend: null, // ② 推荐响应 {ok, tiers, meta}
recommendStatus: 'idle',
recommendError: null,
@@ -83,11 +86,19 @@ export const useEduStore = defineStore('edu', {
reformatResult: null,
reformatStatus: 'idle',
reformatError: null,
/* —— P3a 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta}
/* —— P3a/P3b 编译 PDF(tectonic;与 reformatResult 同生命周期,重新排版即失效)—— */
compileResult: null, // {ok, compile_id, pdf_url, log_url, meta:{seconds,pdf_bytes,...}}
compileStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
compileError: null,
compileLogTail: '', // COMPILE_FAILED/TIMEOUT 的 log 尾 200 行(err.detail)
compilePdfUrl: null, // PDF blob URL(预览/下载,不暴露 API 路径;失败回退 pdf_url)
compileFirstHint: false, // 首次编译黄色提示条(编译开始到 clearCompile 前保留)
/* —— P4 项目 zip 两段式(inspect 暂存 1h + run 编译;run 信封与 P3b 同构,
复用 compile* 字段展示 PDF/log——zip 编译结果与单文件编译互斥,切换模式即清)—— */
zipFile: null, // File(内存态:切菜单不丢,× 移除才清空)
zipInspect: null, // inspect meta {job_id, main, stats{}, skipped[], skipped_count}
zipInspectStatus: 'idle', // 'idle' | 'loading' | 'ready' | 'error'
zipInspectError: null,
/* —— 表单草稿(切菜单不丢;四字段并存:recommend 用 abstract+prefs、
presubmit 用 targetJournal、format 用 formatJournalId(期刊 id 下拉))—— */
@@ -426,18 +437,20 @@ export const useEduStore = defineStore('edu', {
this.reformatError = null;
this.clearCompile();
},
/* —— P3a 编译 PDF(tectonic 单槽队列;约 10-30 秒)—— */
/* —— P3b 编译 PDF(tectonic 单槽队列;约 10-30 秒,首次拉 bundle 1-3 分钟)—— */
async submitCompile(jobId) {
if (this.compileStatus === 'loading') return null;
this.compileStatus = 'loading';
this.compileError = null;
this.compileLogTail = '';
if (!this.compileResult) this.compileFirstHint = true; // 首次编译提示条
const fd = new FormData();
fd.append('job_id', jobId);
try {
const payload = await compileTex(fd);
this.compileResult = payload;
this.compileStatus = 'ready';
this._loadCompileBlob(payload.pdf_url); // 异步换 blob URL,失败回退直链
return payload;
} catch (err) {
this.compileStatus = 'error';
@@ -446,11 +459,83 @@ export const useEduStore = defineStore('edu', {
return null;
}
},
/** PDF → blob URL(预览/下载不暴露后端路径);失败静默回退 pdf_url 直链 */
async _loadCompileBlob(url) {
if (!url) return;
try {
const resp = await fetch(url);
if (!resp.ok) return;
const blob = await resp.blob();
if (this.compilePdfUrl) URL.revokeObjectURL(this.compilePdfUrl);
this.compilePdfUrl = URL.createObjectURL(blob);
} catch { /* 保持 pdf_url 直链 */ }
},
clearCompile() {
if (this.compilePdfUrl) {
try { URL.revokeObjectURL(this.compilePdfUrl); } catch { /* noop */ }
}
this.compileResult = null;
this.compileStatus = 'idle';
this.compileError = null;
this.compileLogTail = '';
this.compilePdfUrl = null;
this.compileFirstHint = false;
},
/* —— P4 项目 zip 两段式 —— */
/** 之① inspect:安全解压 + 主文件识别(1h TTL,run 前不占编译队列) */
async submitZipInspect() {
if (!this.zipFile || this.zipInspectStatus === 'loading') return null;
this.zipInspectStatus = 'loading';
this.zipInspectError = null;
this.zipInspect = null;
this.clearCompile(); // 新 zip 使旧 PDF 失效
const fd = new FormData();
fd.append('file', this.zipFile);
try {
const payload = await inspectZip(fd);
this.zipInspect = payload.meta;
this.zipInspectStatus = 'ready';
return payload;
} catch (err) {
this.zipInspectStatus = 'error';
this.zipInspectError = reviewErrorMessage(err); // ZIP_UNSAFE/BOMB/INVALID 走后端原话
return null;
}
},
/** 之② run:原子消费 inspect → 编译(信封与 P3b 同构 → 复用 compile* 字段) */
async submitZipCompile() {
const jid = this.zipInspect?.job_id;
if (!jid || this.compileStatus === 'loading') return null;
this.compileStatus = 'loading';
this.compileError = null;
this.compileLogTail = '';
if (!this.compileResult) this.compileFirstHint = true;
try {
const payload = await runZipCompile({ job_id: jid });
this.compileResult = payload;
this.compileStatus = 'ready';
this._loadCompileBlob(payload.pdf_url);
return payload;
} catch (err) {
this.compileStatus = 'error';
this.compileError = reviewErrorMessage(err);
// 失败信封带 compile_id + log_url(补充 3)→ log 尾仍走 err.detail
this.compileLogTail = err.detail || '';
// 失败也挂结果壳(仅 id/url):下载完整 log 用 /zip/{cid}/log(成功信封同构)
if (err.compileId) {
this.compileResult = {
ok: false, compile_id: err.compileId, log_url: err.logUrl || null,
};
}
return null;
}
},
clearZip() {
this.zipFile = null;
this.zipInspect = null;
this.zipInspectStatus = 'idle';
this.zipInspectError = null;
this.clearCompile();
},
},
});