forked from EduCraft/curriculum-project-hub
236 lines
8.5 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|