forked from bai/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 导出(契约 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user