/** * 导出(契约 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; 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") }; }, }; } // v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。 const artifacts = new Map(); 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), }); 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 { 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, }; }