/** * 老师端后端支撑(标准前后端分离): * GET /database/api/login-info — 登录页配置(org slug / dev 开关) * GET /app/dev-login-teacher — DEV ONLY 一键登录(普通老师) * * 服务端不渲染任何页面。`/app/*` 的静态托管与 SPA 回退在 ../static.ts —— 那里 * 与管理后台 `/database/*` 共用同一份 filelib-web 构建产物,资源路由只注册一次。 */ import type { FastifyInstance } from "fastify"; import type { PrismaClient } from "@prisma/client"; import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js"; export interface TeacherAppConfig { readonly prisma: PrismaClient; readonly sessionSecret: string; /** 飞书 OAuth 链接按 silo org slug 构造。 */ readonly siloOrganizationSlug: string; /** DEV ONLY(双重门禁,见 database/plugin.ts):一键登录端点与按钮同进同退。 */ readonly allowDevLoginBypass: boolean; } export async function registerTeacherApp( app: FastifyInstance, config: TeacherAppConfig, ): Promise { // 登录页配置(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。 app.get("/database/api/login-info", async () => ({ orgSlug: config.siloOrganizationSlug, devLoginEnabled: config.allowDevLoginBypass, })); if (!config.allowDevLoginBypass) return; registerDevLogins(app, config); } /** DEV ONLY:普通老师一键登录端点(双重门禁见 plugin.ts)。 * 老师端不提供管理员登录 —— 管理员从 /database/admin 进。 */ function registerDevLogins(app: FastifyInstance, config: TeacherAppConfig): void { app.get("/app/dev-login-teacher", async (_request, reply) => { const prisma = config.prisma; const organization = await prisma.organization.findFirst({ where: { status: "ACTIVE" }, select: { id: true }, }); if (organization === null) { return reply.status(404).send({ error: { code: "no_org", message: "no active organization" } }); } let membership = await prisma.organizationMembership.findFirst({ where: { organizationId: organization.id, role: "MEMBER", revokedAt: null }, select: { userId: true, organizationId: true }, }); if (membership === null) { const teacher = await prisma.user.upsert({ where: { feishuOpenId: "ou_dev_teacher" }, update: {}, create: { feishuOpenId: "ou_dev_teacher", displayName: "测试老师" }, }); await prisma.organizationMembership.create({ data: { organizationId: organization.id, userId: teacher.id, role: "MEMBER" }, }); membership = { userId: teacher.id, organizationId: organization.id }; } const connection = await prisma.organizationFeishuApplicationConnection.findFirst({ where: { organizationId: membership.organizationId, status: "ACTIVE" }, select: { id: true, organizationId: true }, }); if (connection === null) { return reply.status(404).send({ error: { code: "no_connection", message: "no active Feishu connection for org" } }); } let identity = await prisma.feishuUserIdentity.findFirst({ where: { userId: membership.userId, connectionId: connection.id }, select: { id: true, connectionId: true }, }); if (identity === null) { identity = await prisma.feishuUserIdentity.create({ data: { connectionId: connection.id, userId: membership.userId, openId: "ou_dev_teacher" }, select: { id: true, connectionId: true }, }); } setSessionCookie(reply, config.sessionSecret, membership.userId, identity.id, identity.connectionId, connection.organizationId); reply.log.warn({ userId: membership.userId }, "DEV teacher-app login bypass (regular teacher) used"); return reply.redirect("/app"); }); } function setSessionCookie( reply: { setCookie: (name: string, value: string, opts: Record) => void }, secret: string, userId: string, feishuIdentityId: string, feishuConnectionId: string, feishuOrganizationId: string, ): void { const token = signSession({ userId, feishuIdentityId, feishuConnectionId, feishuOrganizationId }, secret); reply.setCookie(SESSION_COOKIE_NAME, token, { path: "/", httpOnly: true, sameSite: "lax", secure: false, maxAge: 7 * 24 * 60 * 60, }); }