/** * 导出(契约 D10):异步任务 + 状态机 QUEUED → RUNNING → DONE/FAILED。 * * ExportAdapter 是外部导出工具的 port(参数清单 OPEN-6,真身到位后替换)。 * 当前 stub 适配器产出"文件清单 manifest"文本,证明状态机端到端可跑; * 产物存进程内存(v1 stub;生产应落对象存储/磁盘 —— 见 OPEN 清单)。 */ 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; readonly params: Record; readonly listFiles: (prefix?: string) => Promise; readonly readFile: (path: string) => Promise; } export interface ExportArtifact { readonly filename: string; readonly content: Buffer; } export interface ExportAdapter { readonly target: string; run(input: ExportAdapterInput): Promise; } /** stub 适配器:生成项目文件清单,端到端验证 job 状态机。OPEN-6 后换真导出工具。 */ export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]): ExportAdapter { return { target: "manifest", async run(input) { const files = await input.listFiles(); const lines = [ `# Export manifest (stub adapter)`, `target: ${input.target}`, `storageDir: ${input.storageDir}`, `files: ${files.length}`, ``, ...files.map((f) => `${String(f.size).padStart(10)} ${f.path}`), ]; return { filename: "manifest.txt", content: Buffer.from(lines.join("\n"), "utf8") }; }, }; } /** `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[]; } export interface ExportJobDto { readonly id: string; readonly nodeId: string; readonly target: string; readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED"; readonly error: string | null; readonly createdAt: Date; } /** 提交导出(需 VIEW):建行(QUEUED)+ export.run 审计,同事务;异步执行。 */ export async function submitExport( deps: ExportDeps, actor: FileLibActor, projectId: string, target: string, params: Record, ): Promise { const { node } = await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, projectId, "VIEW"), ); if (node.kind !== "PROJECT") { throw new FileLibError(400, "invalid_node_kind", "export applies to projects only"); } const adapter = deps.adapters.find((a) => a.target === target); if (adapter === undefined) { throw new FileLibError(400, "unknown_target", `no export adapter for target "${target}"`); } if (node.storageDir === null) { throw new FileLibError(409, "project_not_ready", "project repository is not ready"); } const jobId = randomUUID(); const job = await deps.prisma.$transaction(async (tx) => { const created = await tx.fileLibExportJob.create({ data: { id: jobId, organizationId: deps.organizationId, nodeId: node.id, target, params: params as never, status: "QUEUED", createdByUserId: actor.userId, }, }); await writeFileLibAudit(tx, { action: FILE_LIB_AUDIT_ACTIONS.exportRun, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "export_job", objectId: jobId, objectPath: node.pathIds, detail: { target, params }, }); return created; }); const storageDir = node.storageDir; setImmediate(() => { void runExportJob(deps, adapter, jobId, storageDir, target, params).catch(() => undefined); }); return toDto(job); } async function runExportJob( deps: ExportDeps, adapter: ExportAdapter, jobId: string, storageDir: string, target: string, params: Record, ): Promise { await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "RUNNING" } }); try { const artifact = await adapter.run({ storageDir, target, params, listFiles: (prefix) => deps.versionStore.list(storageDir, prefix), readFile: (path) => deps.versionStore.read(storageDir, path), }); retainArtifact(jobId, artifact); await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` }, }); } catch (error) { await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(error) }, }); } } export async function getExportJob( deps: ExportDeps, actor: FileLibActor, jobId: string, ): Promise { const job = await deps.prisma.fileLibExportJob.findFirst({ where: { id: jobId, organizationId: deps.organizationId }, }); if (job === null) throw new FileLibError(404, "export_not_found", "export job not found"); // D8:对源项目无 View → 404(不泄露 job 存在性)。 await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, job.nodeId, "VIEW")); return toDto(job); } export async function downloadExport( deps: ExportDeps, actor: FileLibActor, jobId: string, ): Promise { await getExportJob(deps, actor, jobId); const artifact = artifacts.get(jobId); if (artifact === undefined) { throw new FileLibError(409, "export_not_ready", "export artifact is not available"); } return artifact; } function toDto(job: { id: string; nodeId: string; target: string; status: "QUEUED" | "RUNNING" | "DONE" | "FAILED"; error: string | null; createdAt: Date; }): ExportJobDto { return { id: job.id, nodeId: job.nodeId, target: job.target, status: job.status, error: job.error, createdAt: job.createdAt, }; }