forked from bai/curriculum-project-hub
feat(filelib): 导出改为下载 cph 编译的真实 PDF
This commit is contained in:
@@ -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` },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user