forked from bai/curriculum-project-hub
196 lines
6.1 KiB
TypeScript
196 lines
6.1 KiB
TypeScript
/**
|
|
* 导出(契约 D10):异步任务 + 状态机 QUEUED → RUNNING → DONE/FAILED。
|
|
*
|
|
* ExportAdapter 是外部导出工具的 port(参数清单 OPEN-6,真身到位后替换)。
|
|
* 当前 stub 适配器产出"文件清单 manifest"文本,证明状态机端到端可跑;
|
|
* 产物存进程内存(v1 stub;生产应落对象存储/磁盘 —— 见 OPEN 清单)。
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
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";
|
|
|
|
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") };
|
|
},
|
|
};
|
|
}
|
|
|
|
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
|
|
const artifacts = new Map<string, ExportArtifact>();
|
|
|
|
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),
|
|
});
|
|
artifacts.set(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,
|
|
};
|
|
}
|