/** * `/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 { registerBinRoutes } from "./binRoutes.js"; import { registerTeacherApp } from "./teacherApp.js"; import { createGitVersionStore } from "../filelib/gitVersionStore.js"; import { resolveMaxFileBytes } from "../filelib/fileService.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js"; import { createCphPdfAdapter, createManifestStubAdapter } from "../filelib/exportService.js"; import { registerAuditRoutes } from "../audit/index.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 { // 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。 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, // 上传上限由后端下发,前端不再写死 —— 两处各自硬编码会随配置漂。 maxFileBytes: resolveMaxFileBytes(), })); // 概览页统计。登录 + 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 是真 git —— 一项目一仓库 /, // VersionId = commit hash(ADR-0030); // GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0038), // 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 = createGitVersionStore(); 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: [createCphPdfAdapter(), createManifestStubAdapter(versionStore)], maxFileBytes: resolveMaxFileBytes(), }; await registerFileLibRoutes(app, filelibDeps); await registerFileRoutes(app, filelibDeps); await registerMemberGroupRoutes(app, filelibDeps); await registerBinRoutes(app, filelibDeps); // 审计日志模块(横切):自带 /database/api/audit/*。它只认 prisma + org + // 组解析口 + 一个 actor 门禁函数,不依赖任何 filelib service。 await registerAuditRoutes(app, { prisma: config.prisma, organizationId: siloOrg.id, resolveMemberGroupIds: (userId) => filelibDeps.groupResolver.resolveMemberGroupIds(userId), actorOrNull: async (request, reply) => actorOrNull(request, reply, 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; readonly result: string; }>; } /** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */ async function loadDashboardStats( prisma: PrismaClient, deps: FileLibRouteDeps, ): Promise { 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 缺失(如重启未恢复)不计 */ } } // 最近活动读审计日志模块的表(FileLibAuditLog)。它自带操作人姓名快照与 // 对象名称,不需要再回查 User —— 这也是审计字段结构化后的直接收益。 const entries = await prisma.fileLibAuditLog.findMany({ where: { organizationId, archivedAt: null }, orderBy: [{ occurredAt: "desc" }, { seq: "desc" }], take: 8, select: { action: true, actorName: true, objectName: true, occurredAt: true, result: true }, }); const recent = entries.map((entry) => ({ action: entry.action, actor: entry.actorName, label: entry.objectName, when: entry.occurredAt, result: entry.result, })); return { folders, projects, files, grants, recent }; }