Files
Edu/apps/portal-shell/src/lib/config-fetcher.ts
SpecialX cfb7b005fd feat(portal-shell): v2.1 P0 auth + middleware + login + graphql proxy
- 新增 ARCHITECTURE.md v3.0:portal-shell 架构权威文档
  涵盖 §3.4 V3-A2/A3 认证链、§4 GraphQL 联邦、§5 安全、
  §6 部署、§10 P0-P3 验收清单
- 新增 middleware.ts:认证 + 路由门禁
  httpOnly cookie edu_session(JWT)读取
  DEV_MODE 合成 dev-user/teacher 身份(NODE_ENV!=production &&
  NEXT_PUBLIC_DEV_MODE=true)
  生产模式 jose JWKS RS256 验签(iss/aud 校验)
  路由权限位图注入 x-user-id/x-user-role/x-user-permissions 头
  /shell/** 强制 checkRoutePermission,拒绝跳 /shell/forbidden
- 新增 instrumentation.ts:生产环境 DEV_MODE 强制 false
  防止生产环境误开 DEV_MODE 合成身份
- 新增 app/api/auth/login/route.ts + logout/route.ts
  登录走 api-gateway /v1/iam/login
  设置 httpOnly + Secure + SameSite=Strict cookie
- 新增 app/api/graphql/route.ts:同域 GraphQL 代理
  转发到 apollo-router,注入 router-authorization 头
- 新增 app/login/page.tsx + login-form.tsx
  zod 表单校验,next 参数支持
- 新增 app/shell/forbidden/page.tsx:403 页面
- 更新 route-permissions.ts:补全 P0 路由权限映射
- 更新 permission-bitmap.ts(shared-ts):位图编码/解码
- 更新 apollo-client.ts:DEV_MODE APQ 关闭,错误处理
- 更新 config-fetcher.ts:config-service 直连降级
- 更新 ApolloProvider.tsx:SSR/RSC 兼容
- 更新 eslint.config.js:design-tokens/no-hardcoded-fonts
  白名单调整
2026-07-22 11:35:36 +08:00

393 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 配置获取(服务端 RSC 预取)
*
* 通过 apollo-router 查询 config-service 子图的 pluginConfig(userId, role)
* 返回三层合并后的 PluginConfigResponseM8 验收点:查询走 apollo-router
*
* 设计意图portal-shell spec §5.5
* - portal-shell 是 Next.js 前端,不直接调 config-service gRPC
* - 统一走 apollo-router GraphQL由 Router 路由到 config-service 子图
* - RSC 服务端预取消除 CSR 瀑布流Config 随 HTML 直出
*
* 开发态降级spec §5.5 容错):
* - 优先走 apollo-router生产路径
* - Router 不可用时降级直连 config-service /graphql仅开发态由 CONFIG_SERVICE_URL 触发)
* - 二者均失败时返回空默认配置,保证 Shell 可渲染
*
* 关联portal-shell spec §5.5、§6.2、M8 验收标准
*/
import { gql } from "@apollo/client";
import { createApolloClient } from "./apollo-client";
import type {
LayoutTemplateInfo,
PluginConfigResponse,
PluginPlacement,
PluginRegistryItem,
Role,
SlotConfig,
} from "./types";
/** 查询用户合并后的插件配置(走 apollo-router → config-service 子图) */
export const GET_PLUGIN_CONFIG = gql`
query GetPluginConfig($userId: ID!, $role: String) {
pluginConfig(userId: $userId, role: $role) {
activeLayout {
layoutId
displayName
description
availableSlots
layoutSchemaJson
}
slots {
slotName
navItems
}
plugins {
pluginId
slot
sortOrder
sizeJson
propsJson
isVisible
}
registry {
pluginId
category
version
displayName
description
requiredRoles
isBuiltin
isActive
}
}
}
`;
/**
* 获取用户合并后的插件配置(服务端调用)。
*
* 查询顺序(开发态容错):
* 1. apollo-router生产路径M8 验收点)
* 2. config-service 直连(仅当 CONFIG_SERVICE_URL 配置时启用,开发态降级)
* 3. 空默认配置(最后兜底)
*
* @param userId 用户 ID来自 RSC 的 x-user-id 头)
* @param role 用户角色(来自 RSC 的 x-user-role 头)
* @returns 三层合并后的 PluginConfigResponse查询失败时返回默认 classic 配置
*/
export async function fetchPluginConfig(
userId: string,
role: Role,
): Promise<PluginConfigResponse> {
// 1. 优先走 apollo-router生产路径
try {
const client = createApolloClient();
const { data, error } = await client.query<{
pluginConfig: PluginConfigResponse;
}>({
query: GET_PLUGIN_CONFIG,
variables: { userId, role },
});
if (error) {
console.warn(
`[portal-shell] fetchPluginConfig partial error from apollo-router: ${error.message}`,
);
}
if (data?.pluginConfig) {
return data.pluginConfig;
}
} catch (err) {
console.warn(
`[portal-shell] apollo-router query failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
// 2. 开发态降级:直连 config-service GraphQL
const configServiceUrl =
process.env.CONFIG_SERVICE_URL ||
process.env.NEXT_PUBLIC_CONFIG_SERVICE_URL;
if (configServiceUrl) {
try {
const result = await fetchPluginConfigDirect(
userId,
role,
configServiceUrl,
);
if (result) {
console.info(
`[portal-shell] fetchPluginConfig fallback to config-service direct`,
);
return result;
}
} catch (err) {
console.warn(
`[portal-shell] config-service direct fallback failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
// 3. 最终兜底内置默认配置按角色静态定义P0-4
console.warn(
`[portal-shell] fetchPluginConfig falling back to built-in default config (role=${role})`,
);
return getDefaultConfig(role);
}
/**
* 开发态降级:直连 config-service GraphQL 查询 pluginConfig。
*
* 当 apollo-router 不可用时(如本地开发未启动 Router
* 直接请求 config-service 的 /graphql 端点获取插件配置。
* 生产环境不应触发此路径apollo-router 必须可用)。
*/
async function fetchPluginConfigDirect(
userId: string,
role: string,
configServiceUrl: string,
): Promise<PluginConfigResponse | null> {
const endpoint = `${configServiceUrl.replace(/\/$/, "")}/graphql`;
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `
query GetPluginConfig($userId: ID!, $role: String) {
pluginConfig(userId: $userId, role: $role) {
activeLayout {
layoutId
displayName
description
availableSlots
layoutSchemaJson
}
slots { slotName navItems }
plugins { pluginId slot sortOrder sizeJson propsJson isVisible }
registry { pluginId category version displayName description requiredRoles isBuiltin isActive }
}
}
`,
variables: { userId, role },
}),
// RSC 服务端调用,不携带 cookie
cache: "no-store",
});
if (!response.ok) {
throw new Error(`config-service HTTP ${response.status}`);
}
const json = (await response.json()) as {
data?: { pluginConfig?: PluginConfigResponse };
errors?: unknown;
};
if (json.errors) {
throw new Error(
`config-service GraphQL errors: ${JSON.stringify(json.errors)}`,
);
}
return json.data?.pluginConfig ?? null;
}
/**
* 默认 classic 布局配置Router 未就绪 / 查询失败时降级)。
*
* P0-4ARCHITECTURE.md §10按角色静态定义内置默认仪表盘插件集
* 保证 config-service 不可用时仪表盘仍有内容fail-safe 而非空壳)。
*
* 角色映射依据各 widget 的 manifest `requiredRoles` 字段src/widgets 下各目录):
* - topbar 4 件全角色notification-bell/global-search/locale-switcher/user-menu
* - sidebarteacher → class-selector + term-switcher + quick-actions
* student → term-switcher + quick-actions
* parent → child-selector + term-switcher + quick-actions
* admin → 无 sidebar 上下文
* - mainuniversal 7 件按角色过滤 + 角色专属 widget
*
* @param role 用户角色;未提供时按 teacher 兜底(与现有 ShellPage 默认行为一致)
*/
export function getDefaultConfig(role?: Role): PluginConfigResponse {
const effectiveRole: Role = role ?? "teacher";
const layout: LayoutTemplateInfo = {
layoutId: "classic",
displayName: "经典三栏",
description: "TopBar + SideNav + Main",
availableSlots: ["top", "side", "main"],
layoutSchemaJson: JSON.stringify({
grid: { rows: 1, cols: 1, areas: [["main"]] },
}),
};
const slots: SlotConfig[] = [
{ slotName: "top", navItems: [] },
{ slotName: "side", navItems: [] },
{ slotName: "main", navItems: [] },
];
// ── top slot全角色共享通知铃 / 全局搜索 / 语言切换 / 用户菜单) ──
const topPlugins: PluginPlacement[] = [
placement("notification-bell", "top", 0, { colSpan: 1, rowSpan: 1 }),
placement("global-search", "top", 1, { colSpan: 1, rowSpan: 1 }),
placement("locale-switcher", "top", 2, { colSpan: 1, rowSpan: 1 }),
placement("user-menu", "top", 3, { colSpan: 1, rowSpan: 1 }),
];
// ── side slot按角色裁剪 ──
const sidePlugins: PluginPlacement[] = SIDE_DEFAULTS[effectiveRole].map(
(id, idx) => placement(id, "side", idx, { colSpan: 1, rowSpan: 1 }),
);
// ── main slotuniversal 按角色 + 角色专属) ──
const mainPlugins: PluginPlacement[] = MAIN_DEFAULTS[effectiveRole].map(
(id, idx) => placement(id, "main", idx, { colSpan: 2, rowSpan: 1 }),
);
// ── registry按角色聚合所有可见插件的元信息 ──
const registry: PluginRegistryItem[] = [
...topPlugins,
...sidePlugins,
...mainPlugins,
].map((p) => registryItem(p.pluginId, effectiveRole));
return {
activeLayout: layout,
slots,
plugins: [...topPlugins, ...sidePlugins, ...mainPlugins],
registry,
};
}
/** side slot 角色默认(按渲染顺序) */
const SIDE_DEFAULTS: Record<Role, string[]> = {
teacher: ["class-selector", "term-switcher", "quick-actions"],
student: ["term-switcher", "quick-actions"],
parent: ["child-selector", "term-switcher", "quick-actions"],
admin: [],
};
/** main slot 角色默认universal + 角色专属,按渲染顺序) */
const MAIN_DEFAULTS: Record<Role, string[]> = {
teacher: [
"schedule-widget",
"grades-widget",
"homework-widget",
"exams-widget",
"attendance-widget",
"announcements-widget",
"notifications-widget",
"lesson-plan-editor",
"question-bank",
"textbook-manager",
"scheduling-rules",
],
student: [
"schedule-widget",
"grades-widget",
"homework-widget",
"exams-widget",
"attendance-widget",
"announcements-widget",
"notifications-widget",
"error-book",
"learning-path",
"elective-selector",
"ai-tutor",
],
parent: [
"schedule-widget",
"grades-widget",
"homework-widget",
"exams-widget",
"attendance-widget",
"announcements-widget",
"notifications-widget",
"child-overview",
"leave-approval",
],
admin: [
"announcements-widget",
"notifications-widget",
"user-management",
"rbac-manager",
"plugin-manager",
"school-settings",
"audit-logs",
"invitation-codes",
],
};
/** 简易 PluginPlacement 构造器 */
function placement(
pluginId: string,
slot: string,
sortOrder: number,
size: { colSpan: number; rowSpan: number },
): PluginPlacement {
return {
pluginId,
slot,
sortOrder,
sizeJson: JSON.stringify(size),
propsJson: "{}",
isVisible: true,
};
}
/** 简易 PluginRegistryItem 构造器(基于内置 manifest 元数据) */
function registryItem(pluginId: string, role: Role): PluginRegistryItem {
// category 由 pluginId 前缀目录决定,与 src/widgets/<category>/ 对齐
const category = inferCategory(pluginId);
return {
pluginId,
category,
version: "0.1.0",
displayName: pluginId,
description: `Built-in ${category} plugin (default config fallback)`,
requiredRoles: [role],
isBuiltin: true,
isActive: true,
};
}
/** 由 pluginId 推断 category与目录结构 src/widgets/<category>/ 对齐) */
function inferCategory(pluginId: string): string {
if (pluginId.endsWith("-widget")) return "universal";
if (
pluginId === "notification-bell" ||
pluginId === "global-search" ||
pluginId === "locale-switcher" ||
pluginId === "user-menu"
)
return "topbar";
if (
pluginId === "class-selector" ||
pluginId === "child-selector" ||
pluginId === "term-switcher" ||
pluginId === "quick-actions"
)
return "sidebar";
if (
pluginId === "lesson-plan-editor" ||
pluginId === "question-bank" ||
pluginId === "textbook-manager" ||
pluginId === "scheduling-rules"
)
return "teacher";
if (
pluginId === "error-book" ||
pluginId === "learning-path" ||
pluginId === "elective-selector" ||
pluginId === "ai-tutor"
)
return "student";
if (pluginId === "child-overview" || pluginId === "leave-approval")
return "parent";
return "admin";
}