/** * /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 { 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 | 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); } }); }