From 3b544d99f4529b642a51d97a0094cb23a67a97e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <3401797899@qq.com> Date: Mon, 27 Jul 2026 20:25:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(filelib):=20=E5=AF=BC=E5=87=BA=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=B8=8B=E8=BD=BD=20cph=20=E7=BC=96=E8=AF=91=E7=9A=84?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/.env.example | 10 ++ hub/filelib-web/src/lib/Icon.svelte | 1 + hub/filelib-web/src/lib/OverviewPanel.svelte | 71 ++++++--- hub/src/database/filelib/exportService.ts | 147 ++++++++++++++++++- hub/src/database/routes/databaseRoutes.ts | 4 +- hub/src/database/routes/fileRoutes.ts | 6 +- 6 files changed, 216 insertions(+), 23 deletions(-) diff --git a/hub/.env.example b/hub/.env.example index d2d16a8..94e28af 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -51,8 +51,18 @@ HUB_SYSTEMD_UNIT="cph-hub-example.service" # Absolute path to the `cph` binary (ADR-0016). Production preflight requires # the file to be executable and `cph --version` to succeed. +# +# Always set this explicitly. `cph` is also the command name of the unrelated +# PyPI package conda-package-handling, so on any host with miniconda on PATH a +# bare `cph` resolves to the wrong tool and exports fail with an argparse +# "invalid choice: 'build'" that gives no hint about the name collision. CPH_BIN="/usr/local/bin/cph" +# The `cph-render` typst package directory (the folder holding lib.typ / +# typst.toml). Needed by PDF export: when unset, cph falls back to resolving the +# repo-relative `render/`, which does not exist in a deployed layout. +CPH_RENDER_DIR="/opt/curriculum-project-hub/render" + # Hub bind address and port. Production defaults to loopback for a local TLS # reverse proxy; both values are validated and honored by the HTTP server. HOST="127.0.0.1" diff --git a/hub/filelib-web/src/lib/Icon.svelte b/hub/filelib-web/src/lib/Icon.svelte index 7c98aed..95e14ec 100644 --- a/hub/filelib-web/src/lib/Icon.svelte +++ b/hub/filelib-web/src/lib/Icon.svelte @@ -19,6 +19,7 @@ // 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。 archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6", restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5", + download: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3", } as const; export type IconName = keyof typeof ICONS; diff --git a/hub/filelib-web/src/lib/OverviewPanel.svelte b/hub/filelib-web/src/lib/OverviewPanel.svelte index 3c3b31d..8e1974f 100644 --- a/hub/filelib-web/src/lib/OverviewPanel.svelte +++ b/hub/filelib-web/src/lib/OverviewPanel.svelte @@ -4,12 +4,14 @@ import { currentNode } from "./browser.js"; import type { ExportJob, NodeDetail } from "./types.js"; import Modal from "./Modal.svelte"; + import Icon from "./Icon.svelte"; let { node }: { node: NodeDetail } = $props(); let showEditDesc = $state(false); let descDraft = $state(""); let exportJob = $state(null); + let exporting = $state(false); const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT"); const canManage = $derived(node.role === "MANAGE"); @@ -34,6 +36,7 @@ $effect(() => { void node.id; exportJob = null; + exporting = false; }); function openEditDesc(): void { @@ -57,31 +60,63 @@ } } - async function submitExport(): Promise { + /** + * 导出 PDF:提交 job → 轮询 → 完成即自动触发浏览器下载。 + * + * 只有一个导出目标,所以不给目标选择器 —— target 由后端 adapter 固定。 + * 下载走 而非 fetch+blob:接口是 same-origin cookie 认证, + * 浏览器直接带上会话,不需要在 JS 里搬一遍字节。 + */ + async function exportPdf(): Promise { + if (exporting) return; + exporting = true; + exportJob = null; try { const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, { method: "POST", - body: { target: "manifest" }, + body: { target: "pdf" }, }); - toastOk("导出已提交"); - void pollExport(r.jobId); + await pollExport(r.jobId); } catch (e) { toastErr(e instanceof Error ? e.message : String(e)); + exporting = false; } } async function pollExport(jobId: string): Promise { for (;;) { await new Promise((r) => setTimeout(r, 800)); + let job: ExportJob; try { - const job = await api(`/database/api/exports/${jobId}`); - exportJob = job; - if (job.status === "DONE" || job.status === "FAILED") break; - } catch { - break; + job = await api(`/database/api/exports/${jobId}`); + } catch (e) { + exporting = false; + toastErr(e instanceof Error ? e.message : String(e)); + return; + } + exportJob = job; + if (job.status === "DONE") { + exporting = false; + toastOk("导出完成,开始下载"); + triggerDownload(`/database/api/exports/${job.id}/download`); + return; + } + if (job.status === "FAILED") { + exporting = false; + toastErr(`导出失败:${job.error ?? "未知原因"}`); + return; } } } + + function triggerDownload(url: string): void { + const a = document.createElement("a"); + a.href = url; + a.download = ""; + document.body.appendChild(a); + a.click(); + a.remove(); + }
@@ -124,18 +159,16 @@
导出
- - - {#if exportJob} + + {#if exportJob?.status === "DONE"} - {#if exportJob.status === "DONE"} - 完成 · 下载 - {:else if exportJob.status === "FAILED"} - 失败:{exportJob.error ?? ""} - {:else} - {exportJob.status}… - {/if} + 完成 · 重新下载 + {:else if exportJob?.status === "FAILED"} + 失败:{exportJob.error ?? "未知原因"} {/if}
{/if} diff --git a/hub/src/database/filelib/exportService.ts b/hub/src/database/filelib/exportService.ts index 0ba4ec0..de9ef41 100644 --- a/hub/src/database/filelib/exportService.ts +++ b/hub/src/database/filelib/exportService.ts @@ -7,11 +7,18 @@ */ import { randomUUID } from "node:crypto"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; import { FileLibError } from "./model.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; import { requireAccessInTx, type FileLibActor } from "./treeService.js"; import type { FileDeps } from "./fileService.js"; +const execFileAsync = promisify(execFile); + export interface ExportAdapterInput { readonly storageDir: string; readonly target: string; @@ -49,8 +56,146 @@ export function createManifestStubAdapter(versionStore: FileDeps["versionStore"] }; } +/** `cph` 可执行文件位置;生产由 preflight 校验为绝对路径(见 deployment/preflight.ts)。 */ +const CPH_BIN = process.env["CPH_BIN"] ?? "cph"; +/** typst 渲染包目录;未设时由 cph 自行解析(仓库内 `render/`)。 */ +const CPH_RENDER_DIR = process.env["CPH_RENDER_DIR"]; +const CPH_BUILD_TIMEOUT_MS = 120_000; +/** + * 默认导出 target。 + * + * UI 只给一个「导出 PDF」按钮,不让用户选 target;`student` 是 cph 自身在 + * 工程文件未声明 `[targets.*]` 时的默认值(cph-check DEFAULT_TARGET), + * 与之保持一致,避免两端各有一套默认。 + */ +const DEFAULT_PDF_TARGET = "student"; + +/** + * 真导出适配器:把项目物化到临时目录,跑 `cph build`,取回 PDF 字节。 + * + * 为什么不直接在 `storageDir`(git worktree)里跑构建:那是项目的版本库工作区, + * 构建产物会变成未跟踪文件混进去,后续 `list`/`commit` 的语义会被污染。 + * 物化到临时目录让构建对版本库完全无副作用,代价是一次文件拷贝。 + */ +export function createCphPdfAdapter(): ExportAdapter { + return { + target: "pdf", + async run(input) { + await requireCourswareCph(); + const target = typeof input.params["target"] === "string" ? input.params["target"] : DEFAULT_PDF_TARGET; + const workDir = await mkdtemp(path.join(tmpdir(), "cph-export-")); + try { + await materialize(input, workDir); + const outRel = path.join("build", `${target}.pdf`); + const args = ["build", ".", "--target", target, "-o", outRel]; + if (CPH_RENDER_DIR !== undefined && CPH_RENDER_DIR !== "") { + args.unshift("--render-dir", CPH_RENDER_DIR); + } + await runCphBuild(args, workDir); + const content = await readFile(path.join(workDir, outRel)); + return { filename: `${target}.pdf`, content }; + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }, + }; +} + +/** + * 确认 CPH_BIN 指向的是 Courseware 检查器,而不是同名的其它工具。 + * + * `cph` 这个名字在 PyPI 上已被 conda 的 conda-package-handling 占用,装了 + * miniconda 的机器上 PATH 里的 `cph` 就是它。直接拿它跑 `build` 会得到 + * 一句 argparse 的 "invalid choice: 'build'",根本看不出是撞名 —— 所以这里先用 + * `--version` 探一下,把撞名变成一条能直接终止排查的错误。 + * + * 结果缓存:探测只为拦配置错误,没必要每次导出都多起一个进程。 + */ +let cphIdentityCheck: Promise | null = null; + +function requireCourswareCph(): Promise { + cphIdentityCheck ??= (async () => { + let stdout: string; + try { + ({ stdout } = await execFileAsync(CPH_BIN, ["--version"], { timeout: 10_000 })); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + throw new FileLibError( + 500, + "cph_not_found", + `cph binary not found at "${CPH_BIN}" (set CPH_BIN to the Courseware cph)`, + ); + } + throw new FileLibError(500, "cph_unusable", `cph --version failed at "${CPH_BIN}": ${String(err.message)}`); + } + if (!/^cph\s+\d+\.\d+\.\d+/.test(stdout.trim())) { + throw new FileLibError( + 500, + "cph_wrong_binary", + `"${CPH_BIN}" is not the Courseware cph checker (--version said: ${stdout.trim().split("\n")[0] ?? ""}). ` + + `Set CPH_BIN to the Courseware cph binary.`, + ); + } + })().catch((error: unknown) => { + // 不缓存失败:改完 CPH_BIN 重启前,下一次导出应该重新探测。 + cphIdentityCheck = null; + throw error; + }); + return cphIdentityCheck; +} + +/** 把版本库当前内容写进 workDir。路径已由 versionStore 的 safeRelPath 约束。 */ +async function materialize(input: ExportAdapterInput, workDir: string): Promise { + const files = await input.listFiles(); + if (files.length === 0) { + throw new FileLibError(409, "export_empty_project", "project has no files to export"); + } + for (const f of files) { + const abs = path.join(workDir, f.path); + await mkdir(path.dirname(abs), { recursive: true }); + await writeFile(abs, await input.readFile(f.path)); + } +} + +/** + * 跑 `cph build`。cph 的约定是诊断走 stderr、退出码非零表示构建失败(ADR-0010), + * 所以失败时把 stderr 原样带进错误信息 —— 老师需要看到是哪个诊断挡住了导出。 + */ +async function runCphBuild(args: readonly string[], cwd: string): Promise { + try { + await execFileAsync(CPH_BIN, args, { cwd, timeout: CPH_BUILD_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 }); + } catch (error) { + const err = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string }; + if (err.code === "ENOENT") { + throw new FileLibError(500, "cph_not_found", `cph binary not found at "${CPH_BIN}"`); + } + const detail = (err.stderr ?? "").trim() || (err.stdout ?? "").trim() || err.message; + throw new FileLibError(422, "cph_build_failed", `cph build failed: ${detail}`); + } +} + // v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。 const artifacts = new Map(); +/** + * 内存里最多保留的产物份数。 + * + * 产物不做持久化也不复用 —— 每次导出都按仓库当前内容重新编译,内存副本只为 + * 支撑「提交完成后那一次下载」。因此这里可以无条件淘汰最旧的:被淘汰的 job 再点 + * 下载会拿到 export_not_ready,重新导出即可,不存在数据丢失。 + * 没有上限的话每次导出都会永久占住一份 PDF(数百 KB 级),进程内存只增不减。 + */ +const MAX_RETAINED_ARTIFACTS = 32; + +/** Map 迭代顺序即插入顺序,首个 key 就是最旧的产物。 */ +function retainArtifact(jobId: string, artifact: ExportArtifact): void { + artifacts.set(jobId, artifact); + while (artifacts.size > MAX_RETAINED_ARTIFACTS) { + const oldest = artifacts.keys().next(); + if (oldest.done === true) break; + artifacts.delete(oldest.value); + } +} export interface ExportDeps extends FileDeps { readonly adapters: readonly ExportAdapter[]; @@ -136,7 +281,7 @@ async function runExportJob( listFiles: (prefix) => deps.versionStore.list(storageDir, prefix), readFile: (path) => deps.versionStore.read(storageDir, path), }); - artifacts.set(jobId, artifact); + retainArtifact(jobId, artifact); await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` }, diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index cdfe9b7..3935878 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -33,7 +33,7 @@ import { createGitVersionStore } from "../filelib/gitVersionStore.js"; import { resolveMaxFileBytes } from "../filelib/fileService.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js"; -import { createManifestStubAdapter } from "../filelib/exportService.js"; +import { createCphPdfAdapter, createManifestStubAdapter } from "../filelib/exportService.js"; import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js"; import { actorOrNull, sendRouteError } from "../filelib/routeShared.js"; import type { FileLibRouteDeps } from "../filelib/routeShared.js"; @@ -160,7 +160,7 @@ export async function registerDatabaseRoutes( ? createMemberGroupResolver(config.prisma) : createHttpGroupResolver({ baseUrl: groupServiceUrl }), versionStore, - exportAdapters: [createManifestStubAdapter(versionStore)], + exportAdapters: [createCphPdfAdapter(), createManifestStubAdapter(versionStore)], maxFileBytes: resolveMaxFileBytes(), }; await registerFileLibRoutes(app, filelibDeps); diff --git a/hub/src/database/routes/fileRoutes.ts b/hub/src/database/routes/fileRoutes.ts index 8660594..334e1be 100644 --- a/hub/src/database/routes/fileRoutes.ts +++ b/hub/src/database/routes/fileRoutes.ts @@ -225,9 +225,13 @@ export async function registerFileRoutes( try { const { jobId } = request.params as { jobId: string }; const artifact = await downloadExport(exportDeps, actor, jobId); + // PDF 报真实 MIME,浏览器才能内联预览/正确命名;其余产物保守为二进制流。 + const contentType = artifact.filename.toLowerCase().endsWith(".pdf") + ? "application/pdf" + : "application/octet-stream"; return reply .header("Content-Disposition", `attachment; filename="${artifact.filename}"`) - .type("application/octet-stream") + .type(contentType) .send(artifact.content); } catch (error) { return sendRouteError(reply, error);