feat(filelib): 导出改为下载 cph 编译的真实 PDF

This commit is contained in:
2026-07-27 20:25:29 +08:00
parent ce5fbfb9a6
commit 3b544d99f4
6 changed files with 216 additions and 23 deletions
+146 -1
View File
@@ -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<void> | null = null;
function requireCourswareCph(): Promise<void> {
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<void> {
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<void> {
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<string, ExportArtifact>();
/**
* 内存里最多保留的产物份数。
*
* 产物不做持久化也不复用 —— 每次导出都按仓库当前内容重新编译,内存副本只为
* 支撑「提交完成后那一次下载」。因此这里可以无条件淘汰最旧的:被淘汰的 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` },
+2 -2
View File
@@ -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);
+5 -1
View File
@@ -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);