refactor(database)!: 后端不再渲染任何 HTML,只出 JSON

删掉约 1770 行服务端模板拼接:renderDashboard / renderLoginPage
(databaseRoutes)、adminPanels、libraryBrowser、uiTheme,以及
libraryPage —— 后者迁移前已是无人引用的死代码。

新增两个端点承接原先在 page handler 里 inline 算的东西:
  GET /database/config      免鉴权 bootstrap(org slug + dev 开关);
                            注册位置刻意早于 silo org 的提前返回,
                            org 未就绪时登录页仍要能渲染。
  GET /database/api/stats   概览统计,要求 silo org OWNER/ADMIN ——
                            它聚合的是 org 级计数与审计流,不是
                            单节点权限视图。

静态托管收敛到 static.ts:一份 filelib-web 构建产物挂 /app 与
/database 两个前缀,资源路由只注册一次。并发症是路由顺序成了硬约束
—— 具体页面路由必须先于 SPA 通配注册,否则重演 /database/dashboard
盖住 SPA 的老 bug(ADR-0029)。

/database/library 改为 302 到 /database/dashboard/library。

BREAKING: 部署需先构建 filelib-web,否则 static.ts 的 existsSync
守卫会让 /app 与 /database 全部 404。
This commit is contained in:
2026-07-26 20:17:59 +08:00
parent 3d0f4e5c2d
commit d159e372d2
8 changed files with 109 additions and 2630 deletions
+43 -70
View File
@@ -1,98 +1,71 @@
/**
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`)
* and the SPA index fallback for client-side routes under `/database/*`.
* 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web`
* 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。
*
* The SvelteKit project lives in `hub/database-admin/` and is built with
* `paths.base = '/database'`, so its assets are emitted under `/database/_app/*`
* (not the root `/_app/*` that admin-web owns — that keeps the two SPAs from
* colliding). On disk the files still live at `build/_app/*`; this handler maps
* the `/database`-prefixed URLs back to those files.
* 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html
* 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*
*
* Run `npm run build` in database-admin/ to produce the static output. In
* development, `npm run dev` there proxies `/api`, `/auth`, and `/database` to
* the Hub. Override the UI directory with `CPH_DATABASE_UI_DIR` if needed.
* 三类路由:
* /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为
* `_filelib`,以避开 admin-web 在根上注册的 /_app/*
* (见 ../admin/static.ts)—— 同名会让 Fastify 启动即抛重复路由。
* /app, /app/* — 老师端 SPA 回退
* /database, /database/* — 管理后台 SPA 回退
*
* Mirrors src/admin/static.ts.
* 具体路由(/database/api/*、/database/dev-login、/app/dev-login-teacher 等)由
* databaseRoutes.ts / teacherApp.ts 先注册;Fastify 按具体度匹配,通配不遮蔽它们。
*
* Override the UI directory with `CPH_FILELIB_UI_DIR` if needed.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, extname, join, resolve as resolvePath } from "node:path";
import type { FastifyInstance } from "fastify";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".json": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
};
import { dirname, join, resolve as resolvePath } from "node:path";
import fastifyStatic from "@fastify/static";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
function resolveUiDir(): string {
const override = process.env["CPH_DATABASE_UI_DIR"];
const override = process.env["CPH_FILELIB_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "database-admin", "build"));
return resolvePath(join(here, "..", "..", "filelib-web", "build"));
}
/** 构建产物根目录下的顶层静态文件(SvelteKit 把 static/ 原样拷到这里)。 */
const TOP_LEVEL_FILES = ["favicon.svg", "favicon.ico", "robots.txt"] as const;
export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn(
{ uiDir },
"database-admin/build not found; /database SPA shell disabled. Run `npm run build` in database-admin/ to enable. /database/config and /database/dev-login remain functional.",
"filelib-web/build not found; /app and /database SPA shells disabled. Run `npm run build --prefix filelib-web` to enable. JSON APIs remain fully functional.",
);
return;
}
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// SvelteKit build assets. base='/database' emits them at /database/_app/*,
// but on disk they're under build/_app/*.
app.get("/database/_app/*", async (request, reply) => {
const rel = (request.params as { "*": string })["*"];
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
try {
const buf = await readFile(join(uiDir, "_app", safe));
const mime = MIME[extname(safe)] ?? "application/octet-stream";
return reply.type(mime).send(buf);
} catch {
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
}
// 构建资源。@fastify/static 负责 MIME、ETag/Last-Modified 与目录穿越防护。
// 只注册一次 —— /app 与 /database 下的页面引用的都是这同一组绝对路径
// (filelib-web 的 paths.relative=false 保证了这点)。
await app.register(fastifyStatic, {
root: join(uiDir, "_filelib"),
prefix: "/_filelib/",
decorateReply: true,
});
// Top-level static files emitted under the base path (favicon.svg, robots.txt).
app.get("/database/favicon.svg", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "favicon.svg"));
return reply.type("image/svg+xml").send(buf);
} catch {
return reply.status(404).send();
}
});
app.get("/database/robots.txt", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "robots.txt"));
return reply.type("text/plain; charset=utf-8").send(buf);
} catch {
return reply.status(404).send();
}
});
for (const name of TOP_LEVEL_FILES) {
if (!existsSync(join(uiDir, name))) continue;
app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir));
}
// SPA client-side route fallback. Concrete /database/* routes (/database/config,
// /database/dev-login, and the asset routes above) are more specific, so
// Fastify's router matches them before this wildcard. Everything else under
// /database serves index.html so SvelteKit's client router can resolve the view.
app.get("/database", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
app.get("/database/*", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
// SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以
// 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。
const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
reply.type("text/html; charset=utf-8").send(indexHtml);
app.get("/app", sendIndex);
app.get("/app/*", sendIndex);
app.get("/database", sendIndex);
app.get("/database/*", sendIndex);
}