forked from bai/curriculum-project-hub
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
/**
|
|
* 导出(契约 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<string, unknown>;
|
|
readonly listFiles: (prefix?: string) => Promise<readonly { path: string; size: number }[]>;
|
|
readonly readFile: (path: string) => Promise<Buffer>;
|
|
}
|
|
|
|
export interface ExportArtifact {
|
|
readonly filename: string;
|
|
readonly content: Buffer;
|
|
}
|
|
|
|
export interface ExportAdapter {
|
|
readonly target: string;
|
|
run(input: ExportAdapterInput): Promise<ExportArtifact>;
|
|
}
|
|
|
|
/** 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<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[];
|
|
}
|
|
|
|
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<string, unknown>,
|
|
): Promise<ExportJobDto> {
|
|
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<string, unknown>,
|
|
): Promise<void> {
|
|
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<ExportJobDto> {
|
|
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<ExportArtifact> {
|
|
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,
|
|
};
|
|
}
|