forked from bai/curriculum-project-hub
d159e372d2
删掉约 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。
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
/**
|
|
* `/database/*` route aggregator.
|
|
*
|
|
* Owns the `/database` HTTP surface (single place new sub-routes get wired in).
|
|
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
|
|
* literal string it serves.
|
|
*
|
|
* 标准前后端分离:本文件**不渲染任何 HTML**。登录页与管理后台六个 tab 全部由
|
|
* `hub/filelib-web` 这一个 SvelteKit SPA 提供(同一份产物挂 /app 与 /database,
|
|
* 见 ../static.ts / registerDatabaseSpa)。此处只留 SPA 依赖的 JSON/跳转端点:
|
|
*
|
|
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle
|
|
* /database/api/stats — 概览页统计(需登录 + silo org OWNER/ADMIN)
|
|
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
|
|
*
|
|
* 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 path from "node:path";
|
|
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
|
|
import { registerFileLibRoutes } from "./filelibRoutes.js";
|
|
import { registerFileRoutes } from "./fileRoutes.js";
|
|
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
|
|
import { registerTeacherApp } from "./teacherApp.js";
|
|
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
|
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
|
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
|
import { createManifestStubAdapter } from "../filelib/exportService.js";
|
|
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
|
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
|
|
import type { FileLibRouteDeps } from "../filelib/routeShared.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 — 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;
|
|
}
|
|
|
|
export async function registerDatabaseRoutes(
|
|
app: FastifyInstance,
|
|
config: DatabaseRouteConfig,
|
|
): Promise<void> {
|
|
// 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。
|
|
let filelibDepsForStats: FileLibRouteDeps | null = null;
|
|
|
|
// 登录页/前端 bootstrap(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
|
|
// 与老师端 /database/api/login-info 同形状 —— 后者由 teacherApp.ts 注册,
|
|
// 两处并存是为了兼容既有前端调用点。
|
|
app.get("/database/config", async () => ({
|
|
orgSlug: config.siloOrganizationSlug,
|
|
devLoginEnabled: config.allowDevLoginBypass,
|
|
}));
|
|
|
|
// 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的
|
|
// 计数与审计流,不是某个节点的授权视图,所以不走 per-node 的 role 判定。
|
|
app.get("/database/api/stats", async (request, reply) => {
|
|
if (filelibDepsForStats === null) {
|
|
return reply.status(503).send({ error: { code: "unavailable", message: "filelib not ready" } });
|
|
}
|
|
const actor = await actorOrNull(request, reply, filelibDepsForStats);
|
|
if (actor === null) return reply;
|
|
if (!actor.isWebsiteAdmin) {
|
|
return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } });
|
|
}
|
|
try {
|
|
return await loadDashboardStats(config.prisma, filelibDepsForStats);
|
|
} catch (error) {
|
|
return sendRouteError(reply, error);
|
|
}
|
|
});
|
|
|
|
// DEV ONLY bypass — self-contained here, registered only when the flag is on
|
|
// (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the
|
|
// scoped SessionIdentity shape the Feishu OAuth callback produces so the
|
|
// session guard behaves identically. Never registered in production.
|
|
if (config.allowDevLoginBypass) {
|
|
app.get("/database/dev-login", async (_request, reply) => {
|
|
const membership = await config.prisma.organizationMembership.findFirst({
|
|
where: {
|
|
revokedAt: null,
|
|
role: { in: ["OWNER", "ADMIN"] },
|
|
organization: { status: "ACTIVE" },
|
|
},
|
|
select: { userId: true, organizationId: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
if (membership === null) {
|
|
return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } });
|
|
}
|
|
const identity = await config.prisma.feishuUserIdentity.findFirst({
|
|
where: {
|
|
userId: membership.userId,
|
|
connection: { organizationId: membership.organizationId, status: "ACTIVE" },
|
|
},
|
|
select: { id: true, connectionId: true, connection: { select: { organizationId: true } } },
|
|
});
|
|
if (identity === null) {
|
|
return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } });
|
|
}
|
|
const token = signSession(
|
|
{
|
|
userId: membership.userId,
|
|
feishuIdentityId: identity.id,
|
|
feishuConnectionId: identity.connectionId,
|
|
feishuOrganizationId: identity.connection.organizationId,
|
|
},
|
|
config.sessionSecret,
|
|
);
|
|
// Local dev is http://127.0.0.1, so secure:false. This route only ever
|
|
// runs outside production (double-gated in ./plugin.ts).
|
|
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
|
path: "/",
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: false,
|
|
maxAge: 7 * 24 * 60 * 60,
|
|
});
|
|
reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used");
|
|
return reply.redirect("/database/dashboard");
|
|
});
|
|
}
|
|
|
|
// 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。
|
|
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
|
|
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
|
|
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
|
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
|
const siloOrg = await config.prisma.organization.findUnique({
|
|
where: { slug: config.siloOrganizationSlug },
|
|
select: { id: true },
|
|
});
|
|
if (siloOrg === null) {
|
|
app.log.warn({ slug: config.siloOrganizationSlug }, "filelib: silo organization not found, routes not registered");
|
|
return;
|
|
}
|
|
const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos");
|
|
const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json"));
|
|
const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"];
|
|
const filelibDeps: FileLibRouteDeps = {
|
|
prisma: config.prisma,
|
|
sessionSecret: config.sessionSecret,
|
|
organizationId: siloOrg.id,
|
|
storageRoot,
|
|
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
|
|
? createMemberGroupResolver(config.prisma)
|
|
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
|
versionStore,
|
|
exportAdapters: [createManifestStubAdapter(versionStore)],
|
|
};
|
|
await registerFileLibRoutes(app, filelibDeps);
|
|
await registerFileRoutes(app, filelibDeps);
|
|
await registerMemberGroupRoutes(app, filelibDeps);
|
|
await registerTeacherApp(app, {
|
|
prisma: config.prisma,
|
|
sessionSecret: config.sessionSecret,
|
|
siloOrganizationSlug: config.siloOrganizationSlug,
|
|
allowDevLoginBypass: config.allowDevLoginBypass,
|
|
});
|
|
|
|
// 独立文件库页已并入后台「文件库」tab;旧地址跳转保留兼容。
|
|
// SPA 化后目标是真路由(不再是 #library 锚点)。
|
|
app.get("/database/library", async (_request, reply) =>
|
|
reply.redirect("/database/dashboard/library"),
|
|
);
|
|
|
|
filelibDepsForStats = filelibDeps;
|
|
}
|
|
|
|
/* ------------------------------------------------------------ 概览页统计 */
|
|
|
|
interface DashboardStats {
|
|
readonly folders: number;
|
|
readonly projects: number;
|
|
readonly files: number;
|
|
readonly grants: number;
|
|
readonly recent: ReadonlyArray<{
|
|
readonly action: string;
|
|
readonly actor: string;
|
|
readonly label: string;
|
|
readonly when: Date;
|
|
}>;
|
|
}
|
|
|
|
/** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */
|
|
async function loadDashboardStats(
|
|
prisma: PrismaClient,
|
|
deps: FileLibRouteDeps,
|
|
): Promise<DashboardStats> {
|
|
const organizationId = deps.organizationId;
|
|
const [folders, projects, grants] = await Promise.all([
|
|
prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }),
|
|
prisma.fileLibNode.count({ where: { organizationId, kind: "PROJECT", deletedAt: null } }),
|
|
prisma.fileLibGrant.count({ where: { organizationId, revokedAt: null } }),
|
|
]);
|
|
// 文件计数:遍历 READY 项目问版本库(demo 规模;真版本包到位后应换成存储侧统计)
|
|
const readyProjects = await prisma.fileLibNode.findMany({
|
|
where: {
|
|
organizationId, kind: "PROJECT", deletedAt: null,
|
|
provisionStatus: "READY", storageDir: { not: null },
|
|
},
|
|
select: { storageDir: true },
|
|
});
|
|
let files = 0;
|
|
for (const project of readyProjects) {
|
|
if (project.storageDir === null) continue;
|
|
try {
|
|
files += (await deps.versionStore.list(project.storageDir)).length;
|
|
} catch { /* repo 缺失(如重启未恢复)不计 */ }
|
|
}
|
|
const entries = await prisma.auditEntry.findMany({
|
|
where: { organizationId, action: { in: Object.values(FILE_LIB_AUDIT_ACTIONS) } },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 8,
|
|
});
|
|
const actorIds = [...new Set(entries.map((e) => e.actorUserId).filter((x): x is string => x !== null))];
|
|
const users = actorIds.length === 0
|
|
? []
|
|
: await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, displayName: true } });
|
|
const nameById = new Map(users.map((u) => [u.id, u.displayName]));
|
|
const recent = entries.map((entry) => {
|
|
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
|
|
const label =
|
|
(typeof meta["name"] === "string" ? meta["name"] : undefined) ??
|
|
(typeof meta["to"] === "string" ? meta["to"] : undefined) ??
|
|
(typeof meta["path"] === "string" ? meta["path"] : undefined) ??
|
|
(typeof meta["objectId"] === "string" ? meta["objectId"].slice(0, 8) : "");
|
|
return {
|
|
action: entry.action,
|
|
actor: nameById.get(entry.actorUserId ?? "") ?? entry.actorUserId ?? "unknown",
|
|
label,
|
|
when: entry.createdAt,
|
|
};
|
|
});
|
|
return { folders, projects, files, grants, recent };
|
|
}
|