- 新增 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 白名单调整
393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
/**
|
||
* 配置获取(服务端 RSC 预取)
|
||
*
|
||
* 通过 apollo-router 查询 config-service 子图的 pluginConfig(userId, role),
|
||
* 返回三层合并后的 PluginConfigResponse(M8 验收点:查询走 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-4(ARCHITECTURE.md §10):按角色静态定义内置默认仪表盘插件集,
|
||
* 保证 config-service 不可用时仪表盘仍有内容(fail-safe 而非空壳)。
|
||
*
|
||
* 角色映射依据各 widget 的 manifest `requiredRoles` 字段(src/widgets 下各目录):
|
||
* - topbar 4 件:全角色(notification-bell/global-search/locale-switcher/user-menu)
|
||
* - sidebar:teacher → class-selector + term-switcher + quick-actions;
|
||
* student → term-switcher + quick-actions;
|
||
* parent → child-selector + term-switcher + quick-actions;
|
||
* admin → 无 sidebar 上下文
|
||
* - main:universal 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 slot(universal 按角色 + 角色专属) ──
|
||
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";
|
||
}
|