feat(hub): migrate database admin pages to SPA (database-admin)

- scaffold hub/database-admin as SvelteKit 2 + Svelte 5 static SPA
  with aurora/glass visual style (paths.base='/database')
- add lib/{api,session,org}.ts + Aurora.svelte component
- add routes: root redirect, /admin login page, /dashboard (OWNER/ADMIN only)
- backend: replace server-rendered HTML routes with /database/config JSON endpoint
- add hub/src/database/static.ts to serve SPA under /database/*
- wire registerDatabaseSpa into plugin.ts
- exempt /database/* from silo rate-limit (same treatment as /admin/*)
- add database:dev + database:build npm scripts; update deploy scripts
- update hub/src/database/README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 21:51:53 +08:00
parent 5df1900ca8
commit 12628c9233
30 changed files with 3546 additions and 247 deletions
+35 -10
View File
@@ -3,13 +3,27 @@
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
所以服务器启动时能正确识别这些路由。
页面:
**前后端分离**:页面已迁到独立的 SvelteKit 静态 SPA `hub/database-admin/`
(与 `hub/admin-web/` 同一套框架)。本目录的后端只保留三件事:鉴权透传、一个
免鉴权配置端点、以及把 SPA 构建产物托管出去。页面全部由 SPA 客户端渲染。
- `/database/admin` —— 飞书登录页(唯一登录方式)
- `/database/dashboard` —— 左菜单 + 右内容的后台,未登录会跳回 `/database/admin`
后端路由:
登录走平台既有的飞书 OAuth:登录页的按钮指向 `/auth/feishu/<orgSlug>`
回调由 `src/admin/routes/authRoutes.ts` 处理并种下 session cookie
- `GET /database/config` —— 免鉴权。返回 `{ siloOrganizationSlug, devLoginEnabled }`
给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据
- `GET /database/dev-login` —— 仅开发。见下。
- `GET /database``GET /database/*` —— SPA shell / 客户端路由 fallback
`static.ts``registerDatabaseSpa`);资产在 `/database/_app/*`
SPA 页面(`database-admin``paths.base='/database'`):
- `/database/admin` —— 飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`slug 来自
`/database/config`),回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
- `/database/dashboard` —— 后台壳。未登录跳登录页;**登录但非 OWNER/ADMIN 显示无权提示**。
> **注册顺序要点**concrete 路由(`/database/config`、`/database/dev-login`)必须在
> `registerDatabaseSpa` 的 `/database/*` fallback 之前注册(已在 `plugin.ts` 保证),
> 否则通配会 shadow 它们。
## 开发模式:用环境变量开启一键登录
@@ -29,7 +43,8 @@ HUB_DEV_LOGIN_BYPASS="true"
开启后:
- `/database/admin` 登录页显示「⚡ 一键登录管理员」按钮
- `/database/config` 返回 `devLoginEnabled: true`SPA 登录页据此显示
「⚡ 一键登录管理员」按钮
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
回调完全一样的 session,然后跳到 `/database/dashboard`
@@ -57,12 +72,22 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
| 文件 | 职责 |
|------|------|
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` |
| `routes/databaseRoutes.ts` | 路由 + 页面渲染,**你主要在这里加内容** |
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()`;先挂 concrete 路由再挂 SPA |
| `routes/databaseRoutes.ts` | 后端 JSON / redirect 路由(`/database/config``/database/dev-login`),**数据端点加在这里** |
| `static.ts` | `registerDatabaseSpa`:托管 `database-admin/build` 的 SPA + `/database/*` fallback |
新增一类端点时:要么直接往 `databaseRoutes.ts``app.get("/database/...")`
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/...")`
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts``registerXxxRoutes(app, {...})`
注册一次。
注册一次。**页面**则加在 `database-admin/src/routes/` 下(SvelteKit 路由)。
## SPA 构建与托管
- 前端在 `hub/database-admin/``npm run build`(或 hub 根的 `npm run database:build`
产出到 `database-admin/build/`。hub 的 `npm run build` 会把两个 SPA 一起带出来。
- `static.ts` 默认从 `../../database-admin/build` 读产物;可用 `CPH_DATABASE_UI_DIR`
覆盖。产物缺失时降级:只 warn,不挂 SPA,`/database/config``/database/dev-login` 仍可用。
- 本地开发:hub 根 `npm run database:dev` 起 Vite,它把 `/api``/auth``/database`
代理到 `127.0.0.1:8788`
## 约定(与 admin 面一致)
+5
View File
@@ -16,6 +16,7 @@
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { registerDatabaseRoutes } from "./routes/databaseRoutes.js";
import { registerDatabaseSpa } from "./static.js";
export interface DatabasePluginConfig {
readonly prisma: PrismaClient;
@@ -42,4 +43,8 @@ export async function registerDatabasePlugin(
siloOrganizationSlug: config.siloOrganizationSlug,
allowDevLoginBypass,
});
// SPA shell + fallback, registered after the concrete /database/* routes above
// so the /database/* wildcard does not shadow them.
await registerDatabaseSpa(app);
}
+26 -227
View File
@@ -5,23 +5,30 @@
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
* literal string it serves.
*
* /database/admin — Feishu-only login page (+ dev one-click button)
* /database/dashboard — sidebar + content admin shell, session-gated
* The login page and dashboard are now served by the `database-admin` SvelteKit
* SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the
* concrete JSON/redirect routes the SPA depends on:
*
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
*
* The dev bypass (button + /database/dev-login) is self-contained here and
* gated by allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS
* + NODE_ENV). Production requires real Feishu OAuth.
* The dev bypass (/database/dev-login) is self-contained here and gated by
* allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS +
* NODE_ENV). Production requires real Feishu OAuth.
*
* NOTE: concrete routes here MUST be registered before the SPA fallback
* (registerDatabaseSpa serves /database and /database/*), or the wildcard would
* shadow them.
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
export interface DatabaseRouteConfig {
readonly prisma: PrismaClient;
/** HMAC secret for the signed session cookie — reused from the admin plane. */
readonly sessionSecret: string;
/** Silo Organization slug — builds the org-scoped Feishu login link. */
/** Silo Organization slug — the SPA builds the org-scoped Feishu login link from it. */
readonly siloOrganizationSlug: string;
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
readonly allowDevLoginBypass: boolean;
@@ -31,18 +38,14 @@ export async function registerDatabaseRoutes(
app: FastifyInstance,
config: DatabaseRouteConfig,
): Promise<void> {
app.get("/database/admin", async (request, reply) => {
// Already signed in → straight to the dashboard.
if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) {
return reply.redirect("/database/dashboard");
}
return reply.type("text/html").send(renderLoginPage(config));
});
app.get("/database/dashboard", async (request, reply) => {
const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config);
if (user === null) return reply.redirect("/database/admin");
return reply.type("text/html").send(renderDashboard(user.displayName));
// Unauthenticated bootstrap for the static SPA login page. Exposes only what
// the page needs to build the Feishu login link and toggle the dev button —
// no secrets, no user data.
app.get("/database/config", async () => {
return {
siloOrganizationSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
};
});
// DEV ONLY bypass — self-contained here, registered only when the flag is on
@@ -96,212 +99,8 @@ export async function registerDatabaseRoutes(
});
}
// Add more /database/* routes here. Guard data routes with requireSession /
// requireOrgRole (../../admin/auth/guards.js) and scope every query to the
// caller's org (ADR-0020). Access the DB via config.prisma.
}
/** Verify the session cookie and load the user, or null if not signed in. */
async function resolveUser(
rawCookie: string | undefined,
config: DatabaseRouteConfig,
): Promise<{ displayName: string } | null> {
if (rawCookie === undefined || rawCookie === "") return null;
const session = verifySession(rawCookie, config.sessionSecret);
if (session === null) return null;
const user = await config.prisma.user.findUnique({
where: { id: session.userId },
select: { displayName: true },
});
return user;
}
/**
* Shared document head: Tailwind CDN + a small design system (fonts, keyframes
* for the aurora background, fade-in, shimmer). Both pages import it so the
* look stays consistent.
*/
function pageHead(title: string): string {
return `<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${title}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<style>
:root { font-family: 'Inter', system-ui, sans-serif; }
@keyframes aurora {
0% { transform: translate(0,0) scale(1); }
33% { transform: translate(6%, -8%) scale(1.15); }
66% { transform: translate(-8%, 6%) scale(0.9); }
100% { transform: translate(0,0) scale(1); }
}
@keyframes rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.aurora { filter: blur(80px); animation: aurora 18s ease-in-out infinite; }
.rise { animation: rise .7s cubic-bezier(.16,1,.3,1) both; }
.rise-2 { animation: rise .7s cubic-bezier(.16,1,.3,1) .1s both; }
.rise-3 { animation: rise .7s cubic-bezier(.16,1,.3,1) .2s both; }
.grad-text {
background: linear-gradient(120deg,#7c3aed,#0891b2,#4f46e5);
background-size: 200% auto;
-webkit-background-clip: text; background-clip: text; color: transparent;
animation: shimmer 6s linear infinite;
}
.glass { background: rgba(255,255,255,.7); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); }
.glow-btn { box-shadow: 0 12px 32px -10px rgba(124,58,237,.45); }
</style>
</head>`;
}
/** The animated aurora background blobs, shared by both pages (soft pastels on light). */
const AURORA = `
<div class="pointer-events-none fixed inset-0 overflow-hidden">
<div class="aurora absolute -left-32 -top-32 h-96 w-96 rounded-full bg-violet-300/50"></div>
<div class="aurora absolute right-0 top-1/4 h-96 w-96 rounded-full bg-cyan-300/40" style="animation-delay:-6s"></div>
<div class="aurora absolute bottom-0 left-1/3 h-96 w-96 rounded-full bg-indigo-300/40" style="animation-delay:-12s"></div>
</div>`;
function renderLoginPage(config: DatabaseRouteConfig): string {
const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`;
const devButton = config.allowDevLoginBypass
? `
<div class="relative my-6 rise-3">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center"><span class="bg-white/70 px-3 text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">开发模式</span></div>
</div>
<a href="/database/dev-login"
class="rise-3 group flex w-full items-center justify-center gap-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:bg-amber-100">
<span>⚡</span> 一键登录管理员
</a>
<p class="rise-3 mt-2 text-center text-xs text-slate-400">仅开发环境可见 · 跳过飞书 OAuth</p>`
: "";
return `<!doctype html>
<html lang="zh-CN">
${pageHead("Database Admin · 登录")}
<body class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-800 flex items-center justify-center p-4">
${AURORA}
<main class="rise glass relative w-full max-w-sm rounded-3xl border border-white/80 p-8 shadow-2xl shadow-indigo-200/50">
<div class="mb-7 text-center">
<div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-cyan-400 text-2xl font-bold text-white glow-btn">D</div>
<h1 class="text-2xl font-bold tracking-tight grad-text">Database Admin</h1>
<p class="mt-2 text-sm text-slate-500">使用飞书登录以管理数据库</p>
</div>
<a href="${feishuHref}"
class="rise-2 glow-btn group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-3.5 text-sm font-semibold text-white transition hover:from-violet-400 hover:to-indigo-400">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3Z"/></svg>
使用飞书登录
</a>
${devButton}
</main>
</body>
</html>`;
}
/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */
const NAV_ITEMS: ReadonlyArray<{ label: string; icon: string; href: string; active: boolean }> = [
{ label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z", href: "/database/dashboard", active: true },
{ label: "数据表", icon: "M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z", href: "#", active: false },
{ label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", href: "#", active: false },
{ label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2-1l1-2h4l1 2a7 7 0 0 1 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z", href: "#", active: false },
];
function renderDashboard(displayName: string): string {
const nav = NAV_ITEMS.map((item) => {
const cls = item.active
? "group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50"
: "group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900";
return `<a href="${item.href}" class="${cls}">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${item.icon}"/></svg>
${item.label}
</a>`;
}).join("\n ");
const stats = [
{ label: "数据表", value: "—", accent: "from-violet-200/60 to-transparent" },
{ label: "记录数", value: "—", accent: "from-cyan-200/60 to-transparent" },
{ label: "最近查询", value: "—", accent: "from-indigo-200/60 to-transparent" },
].map((s, i) => `
<div class="rise-${i + 1} group relative overflow-hidden rounded-2xl border border-white/80 glass p-5 shadow-lg shadow-slate-200/50 transition hover:-translate-y-0.5 hover:shadow-xl hover:shadow-indigo-200/50">
<div class="absolute inset-0 bg-gradient-to-br ${s.accent} opacity-0 transition group-hover:opacity-100"></div>
<p class="relative text-sm text-slate-500">${s.label}</p>
<p class="relative mt-2 text-3xl font-bold text-slate-900">${s.value}</p>
</div>`).join("");
const initial = escapeHtml(displayName.slice(0, 1) || "U");
return `<!doctype html>
<html lang="zh-CN">
${pageHead("Database Admin · 概览")}
<body class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-700">
${AURORA}
<div class="relative flex min-h-screen">
<!-- 左侧菜单栏 -->
<aside class="flex w-64 shrink-0 flex-col border-r border-slate-200/80 glass">
<div class="flex items-center gap-3 px-5 py-6">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 text-lg font-bold text-white glow-btn">D</div>
<span class="text-base font-bold grad-text">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-1.5 px-3 py-2">
${nav}
</nav>
<div class="m-3 flex items-center gap-3 rounded-xl border border-slate-200 bg-white/60 px-3 py-3">
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-500 text-sm font-semibold text-white">${initial}</div>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-wider text-slate-400">已登录</p>
<p class="truncate text-sm font-medium text-slate-700">${escapeHtml(displayName)}</p>
</div>
</div>
</aside>
<!-- 右侧内容 -->
<div class="flex flex-1 flex-col">
<header class="flex items-center justify-between border-b border-slate-200/80 glass px-8 py-4">
<div>
<h1 class="text-lg font-bold text-slate-900">概览</h1>
<p class="text-xs text-slate-400">欢迎回来,这里是数据库管理台</p>
</div>
<button id="logout"
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
退出登录
</button>
</header>
<main class="flex-1 p-8">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
${stats}
</div>
<div class="rise-3 mt-6 flex h-64 items-center justify-center rounded-2xl border border-dashed border-slate-300 glass text-sm text-slate-400">
内容区占位 · 在 src/database/routes/databaseRoutes.ts 里继续搭建
</div>
</main>
</div>
</div>
<script>
document.getElementById("logout").addEventListener("click", async () => {
await fetch("/auth/logout", { method: "POST" });
window.location.href = "/database/admin";
});
</script>
</body>
</html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
// Add more /database/* JSON routes here. Guard data routes with requireSession
// / requireOrgRole (../../admin/auth/guards.js) and scope every query to the
// caller's org (ADR-0020). Access the DB via config.prisma. Register concrete
// routes before registerDatabaseSpa's /database/* fallback (done in ./plugin.ts).
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`)
* and the SPA index fallback for client-side routes under `/database/*`.
*
* 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.
*
* 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.
*
* Mirrors src/admin/static.ts.
*/
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",
};
function resolveUiDir(): string {
const override = process.env["CPH_DATABASE_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "database-admin", "build"));
}
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.",
);
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" } });
}
});
// 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();
}
});
// 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);
});
}