Files
curriculum-project-hub/hub/src/database/routes/fileRoutes.ts
T

236 lines
8.5 KiB
TypeScript

/**
* /database/api/* 文件内容与导出端点(契约 9.3/9.4)。
* 冲突流:POST commits 返回 200 {version} 或 409 {currentVersion}(编辑 UI 拉 diff 后重提)。
*/
import type { FastifyInstance, FastifyRequest } from "fastify";
import {
commitFile,
deleteFile,
diffFile,
fileHistory,
listFiles,
readFile,
readFileRaw,
type FileContentEncoding,
} from "../filelib/fileService.js";
import {
createManifestStubAdapter,
downloadExport,
getExportJob,
submitExport,
} from "../filelib/exportService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerFileRoutes(
app: FastifyInstance,
deps: FileLibRouteDeps,
): Promise<void> {
const fileDeps = {
prisma: deps.prisma,
organizationId: deps.organizationId,
groupResolver: deps.groupResolver,
versionStore: deps.versionStore,
};
const exportDeps = { ...fileDeps, adapters: deps.exportAdapters };
function pathParam(request: FastifyRequest): string {
const path = (request.query as { path?: string }).path;
if (path === undefined) throw new FileLibError(400, "invalid_request", "missing query: path");
return path;
}
function encodingParam(raw: unknown): FileContentEncoding {
if (raw === undefined || raw === "utf8") return "utf8";
if (raw === "base64") return "base64";
throw new FileLibError(400, "invalid_request", "encoding must be utf8 or base64");
}
app.get("/database/api/projects/:id/files", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const prefix = (request.query as { prefix?: string }).prefix;
return { files: await listFiles(fileDeps, actor, id, prefix) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return await readFile(fileDeps, actor, id, pathParam(request));
} catch (error) {
return sendRouteError(reply, error);
}
});
// 原始字节下载(Content-Disposition: attachment;浏览器直接 save-as)
app.get("/database/api/projects/:id/file/raw", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const raw = await readFileRaw(fileDeps, actor, id, pathParam(request));
return reply
.header("Content-Disposition",
`attachment; filename="${encodeURIComponent(raw.filename)}"; filename*=UTF-8''${encodeURIComponent(raw.filename)}`)
.header("X-Content-Version", raw.version)
.type("application/octet-stream")
.send(raw.buffer);
} catch (error) {
return sendRouteError(reply, error);
}
});
// 新建/上传(baseVersion 恒 null;已存在 → 409)
app.put("/database/api/projects/:id/file", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const result = await commitFile(fileDeps, actor, id, {
path: requireString(body, "path"),
baseVersion: null,
content: requireString(body, "content"),
encoding: encodingParam(body["encoding"]),
message: optionalString(body, "message"),
});
return reply.status(201).send(result);
} catch (error) {
return sendRouteError(reply, error);
}
});
// 提交编辑(乐观并发;409 → details.currentVersion + file.conflict_detected 审计)
app.post("/database/api/projects/:id/file/commits", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const baseVersion = body["baseVersion"];
if (typeof baseVersion !== "string" || baseVersion === "") {
throw new FileLibError(400, "invalid_request", "baseVersion must be a non-empty string");
}
return await commitFile(fileDeps, actor, id, {
path: requireString(body, "path"),
baseVersion,
content: requireString(body, "content"),
encoding: encodingParam(body["encoding"]),
message: optionalString(body, "message"),
});
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/projects/:id/file", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
await deleteFile(fileDeps, actor, id, pathParam(request), requireString(body, "baseVersion"));
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file/diff", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const query = request.query as { from?: string; to?: string };
if (query.from === undefined || query.to === undefined) {
throw new FileLibError(400, "invalid_request", "missing query: from / to");
}
return await diffFile(fileDeps, actor, id, pathParam(request), query.from, query.to);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file/history", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const rawLimit = (request.query as { limit?: string }).limit;
const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
throw new FileLibError(400, "invalid_request", "limit must be a positive integer");
}
return { history: await fileHistory(fileDeps, actor, id, pathParam(request), limit) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 导出(D10 异步) */
app.post("/database/api/projects/:id/exports", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const params = body["params"];
if (params !== undefined && (typeof params !== "object" || params === null || Array.isArray(params))) {
throw new FileLibError(400, "invalid_request", "params must be an object");
}
const job = await submitExport(
exportDeps,
actor,
id,
requireString(body, "target"),
(params as Record<string, unknown> | undefined) ?? {},
);
return reply.status(202).send({ jobId: job.id, status: job.status });
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/exports/:jobId", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { jobId } = request.params as { jobId: string };
return await getExportJob(exportDeps, actor, jobId);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/exports/:jobId/download", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { jobId } = request.params as { jobId: string };
const artifact = await downloadExport(exportDeps, actor, jobId);
return reply
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
.type("application/octet-stream")
.send(artifact.content);
} catch (error) {
return sendRouteError(reply, error);
}
});
}