Files
Edu/apps/student-portal/docs/02-architecture-design.md

98 KiB
Raw Blame History

模块架构设计文档 — student-portal

AIai14TS/React · 学习场景域前端 remote 阶段:阶段 2 交付物v2 — ai14 接管审计与补全版) 初版日期2026-07-09ai07 起草) 审计日期2026-07-10ai14 修订:协议 REST→GraphQL、端口 3001→4001、所有权 ai07→ai14、MF 配置 ARB-002、路由前缀、错误码前缀、长远架构补全 关联:阶段 1 理解确认书004 架构影响地图 §5.4、pending-features P3teacher-portal 阶段 2 架构设计teacher-portal 阶段 3 长远架构known-issues §2.15coord 仲裁 ARB-001coord 仲裁 ARB-002student-bff 契约student-portal 契约 状态:待 coord 交叉审查

v2 审计修订摘要ai14 → ai07 初稿):

  1. 协议修订REST → GraphQL over HTTPARB-001 已裁决 student-bff 走 GraphQL Yoga + ActionState 信封 + DataLoader前端 all-in GraphQL复用 Shell 暴露的 urql client 单例)
  2. 端口修订3001 → 4001004 §1.2 强制 4 端 4000-4003port-allocation §4 硬约束3001 已被 classes 历史占用)
  3. 所有权修订ai07 → ai14ai-allocation.md §3.2 L94ai07=classes/core-edu 交接)
  4. MF 配置修订:按 ARB-002Shell 暴露 GraphQLProvider/useGraphQLClient/urql/graphql 单例student-portal 不再实现 ApiClient不重复创建 GraphQL client
  5. 路由修订/student/*/*(无前缀,与 student-portal_contract.md §1.2 对齐student-portal 是独立 dev server :4001
  6. 登录路径修订/iam/loginPOST /api/auth/loginiam 仅 gRPC登录由 api-gateway 聚合)
  7. 错误码前缀修订EXAMS_/HOMEWORK_/GRADES_CORE_EDU_known-issues §2.15STUDENT_BFF_BFF_STUDENT_
  8. 遗漏补全:考试作答架构设计(断网恢复/时间对齐/防作弊/IDB 队列、详细组件设计、跨标签同步、API 版本管理、监控与降级、模块演化、长远架构愿景
  9. 新增章节§14 考试作答架构设计、§15 详细组件设计、§16 跨标签同步实现、§17 API 版本管理、§18 监控与降级、§19 模块演化与解耦、§20 长远架构愿景

1. 模块内部分层图student-portal Remote 视角)

graph TB
    subgraph Browser["浏览器(学生)"]
        URL[URL 路由 /*<br/>无 /student 前缀]
    end

    subgraph Shell["teacher-portalShell 宿主 :4000"]
        AppShell[AppShell<br/>左栏导航 + 主内容区<br/>按 scope=student 过滤视口]
        RootLayout[RootLayout<br/>字体/令牌/i18n/GraphQL/Query Provider]
        GraphQLProvider["GraphQLProvider<br/>urql client 单例ARB-002<br/>含 exchanges: cache/auth/error/retry"]
        SharedDeps["共享依赖暴露singleton<br/>react/react-dom/urql/graphql/@tanstack/react-query<br/>zustand/nuqs/@edu/ui-components/@edu/ui-tokens<br/>@edu/contracts/@edu/hooks/@edu/shared-ts"]
        Rewrites["rewrites /api/v1/* + /api/auth/* → api-gateway"]
    end

    subgraph RemoteStudent["student-portalRemote :4001"]
        StudentPages["学习场景页面<br/>dashboard/my-homework/my-exams/take<br/>my-grades/my-attendance/learning-path<br/>diagnostic(P4)/weakness(P4)/notifications(P5)"]
        ExamTaking[ExamTaking 组件<br/>倒计时 + 自动保存 + 断网恢复 + 防作弊]
        StudentHooks["GraphQL Hooks<br/>useMyExams/useMyHomework/useExamTaking<br/>useSubmitHomework/useMyGrades 等"]
        IDBQueue["IDB 队列<br/>断网恢复重试队列<br/>考试草稿持久化"]
    end

    subgraph Shared["共享层packages/,由 Shell 暴露)"]
        UITokens[ui-tokens<br/>三层设计令牌]
        UIComponents[ui-components<br/>shadcn + A11y + ErrorBoundary + RequirePermission]
        Contracts[contracts<br/>Permissions 常量 + 类型]
        Hooks[hooks<br/>usePermission/useAuth/useViewports/useA11y]
        LibTS[shared-ts<br/>Logger/Tracer/通用工具]
    end

    subgraph Gateway["api-gateway :8080"]
        GW[Gin 路由/JWT 验签/限流]
    end

    subgraph BFF["student-bff :3009"]
        BFFGraphQL[GraphQL Yoga endpoint<br/>POST /graphql]
    end

    subgraph PushGW["push-gateway :8081P5"]
        WS[WebSocket /ws]
    end

    Browser --> URL
    URL --> RootLayout
    RootLayout --> AppShell
    RootLayout --> GraphQLProvider
    AppShell -->|动态加载 Remote| RemoteStudent

    RemoteStudent -->|消费 singleton| SharedDeps
    RemoteStudent --> StudentPages
    StudentPages --> ExamTaking
    StudentPages --> StudentHooks
    StudentHooks -->|复用 urql client| GraphQLProvider
    ExamTaking --> IDBQueue

    SharedDeps --> UITokens
    SharedDeps --> UIComponents
    SharedDeps --> Contracts
    SharedDeps --> Hooks
    SharedDeps --> LibTS

    StudentHooks -->|POST /api/v1/student/graphql| Rewrites
    Rewrites --> GW
    GW --> BFFGraphQL

    RemoteStudent -.->|P5 WebSocket| WS
    WS -.->|推送事件| RemoteStudent

1.1 Remote 与 Shell 的职责边界

职责 Shellteacher-portal Remotestudent-portal
RootLayout字体/令牌/Provider 提供 复用
AppShell左栏 + 主内容区) 提供 复用
GraphQLProviderurql client 提供ARB-002 复用单例
共享依赖暴露singleton 提供 消费
登录页 提供P2 不走 MF 复用
rewrites /api/v1/* 提供 依赖 Shell
路由表 /*(无 /student 前缀) 由 Remote 暴露 提供
学习场景页面
GraphQL 业务 Hooks
考试作答组件ExamTaking 等)
IDB 断网恢复队列

1.2 MF 配置student-portal/next.config.jsRemote 角色)

// apps/student-portal/next.config.jsRemote
const NextFederationPlugin = require("@module-federation/nextjs-mf");

module.exports = {
  reactStrictMode: true,
  transpilePackages: [
    "@edu/ui-tokens",
    "@edu/ui-components",
    "@edu/hooks",
    "@edu/contracts",
    "@edu/shared-ts",
  ],
  webpack(config, { isServer }) {
    config.plugins.push(
      new NextFederationPlugin({
        name: "student_app",
        filename: "static/chunks/remoteEntry.js",
        // Remote 暴露页面入口 + 考试作答组件(供 Shell 路由复用)
        exposes: {
          "./pages": "./src/pages",
          "./ExamTaking": "./src/components/ExamTaking",
        },
        // Remote 反向引用 Shell 暴露的共享组件 + GraphQL 单例(可选,多数通过 singleton shared 解决)
        remotes: {
          teacher: `teacher_app@http://localhost:4000/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
        },
        shared: {
          react: { singleton: true, requiredVersion: "^18.3.0" },
          "react-dom": { singleton: true, requiredVersion: "^18.3.0" },
          // ARB-002GraphQL 相关依赖由 Shell 暴露为 singleton
          urql: { singleton: true, requiredVersion: "^2.2.0" },
          graphql: { singleton: true, requiredVersion: "^16.8.0" },
          "@tanstack/react-query": { singleton: true, requiredVersion: "^5.0.0" },
          zustand: { singleton: true },
          nuqs: { singleton: true },
          "@edu/ui-tokens": { singleton: true },
          "@edu/ui-components": { singleton: true },
          "@edu/hooks": { singleton: true },
          "@edu/contracts": { singleton: true },
          "@edu/shared-ts": { singleton: true },
        },
        extraOptions: { exposePages: false },
      }),
    );
    return config;
  },
  // student-portal 不实现 rewrites依赖 teacher-portal Shell 的 rewrites 代理 /api/v1/* 与 /api/auth/*
};

说明ARB-002 对齐):

  • name: 'student_app'Remote 应用名,被 Shell 在 remotes 中引用为 student: 'student_app@http://localhost:4001/...'
  • Shell 端口 4000ARB-002 MF URLstudent-portal dev 端口 4001
  • urql/graphql 声明为 singleton: true,确保与 Shell 共享同一 GraphQL client 实例ARB-002 强制)
  • shared 全部 singleton: true,避免 React 多实例、Zustand store 分裂、GraphQL cache 分裂
  • transpilePackages 列出本地 packages/* 软链依赖,确保 SWC 正确编译
  • student-portal 不实现 rewrites,所有 /api/v1/*/api/auth/* 请求由 Shell 的 rewrites 代理到 api-gateway

2. 领域模型(前端视角)

前端不持有业务聚合根,仅持有"视图模型"ViewModel和"会话状态"。Session/Viewport/Permission 三模型与 Shell 共享,详见 teacher-portal 阶段 2 §2

2.1 会话状态Session

复用 Shell 的 Session 模型student-portal 不重复定义。详见 teacher-portal 文档 §2.1。

2.2 视口模型Viewport

复用 Shell 的 ViewportItem 模型,但 scope 字段值为 'student'。来源student-bff GraphQL viewports query聚合 iam 视口配置。AppShell 按 scope='student' 过滤渲染学生端导航。

2.3 权限模型Permission

复用 Shell 的 PermissionState 模型。来源:POST /api/v1/student/graphql query currentUser{ permissions, viewports, dataScope }。学生 dataScope 固定为 L0仅本人

2.4 student-portal 特有视图模型

// 考试作答草稿Zustand L3 + IDB断网恢复用
interface ExamTakingDraft {
  examId: string;
  studentId: string;
  answers: Record<string, AnswerInput>; // questionId → answer
  startedAt: number; // 客户端开始时间戳
  serverStartedAt: number; // 服务器开始时间戳(用于倒计时校正)
  lastSavedAt: number | null;
  durationSeconds: number; // 考试时长(秒,来自服务器)
  expiresAt: string; // ISO 8601 UTC考试截止时间来自服务器
  suspiciousBehaviors: SuspiciousBehaviorRecord[]; // 防作弊记录
}

// 答案输入(联合类型,按题型区分)
type AnswerInput =
  | { type: "single-choice"; optionId: string }
  | { type: "multiple-choice"; optionIds: string[] }
  | { type: "fill-blank"; values: string[] }
  | { type: "short-answer"; text: string }
  | { type: "essay"; text: string; attachments?: AttachmentRef[] };

// 可疑行为记录(防作弊,前端仅记录,服务端最终判定)
interface SuspiciousBehaviorRecord {
  type: "tab-switch" | "copy" | "paste" | "paste-essay" | "fullscreen-exit" | "multi-tab" | "window-blur";
  timestamp: number;
  duration?: number; // 持续时长ms
  details?: string;
}

// 作业提交表单react-hook-form L5
interface HomeworkSubmitForm {
  homeworkId: string;
  answers: AnswerInput[];
  attachments?: File[]; // 附件(图片/PDF
  note?: string; // 学生备注
}

// 学情诊断视图模型P4
interface DiagnosticViewModel {
  examId: string;
  examName: string;
  score: number;
  maxScore: number;
  knowledgePoints: Array<{
    id: string;
    name: string;
    mastery: number; // 0-1
    questionCount: number;
    correctCount: number;
  }>;
  radar: Array<{ axis: string; value: number }>; // 多维雷达图数据
  trend: Array<{ date: string; score: number }>; // 趋势线数据
}

// 错题本视图模型P4
interface WeaknessViewModel {
  questionId: string;
  questionContent: string; // 题目内容(脱敏后)
  knowledgePointId: string;
  knowledgePointName: string;
  mastery: number; // 0-1掌握度
  wrongCount: number;
  lastWrongAt: string; // ISO 8601
  recommendedAction: "review" | "practice" | "mastered";
}

// 学习路径节点P4
interface LearningPathNode {
  id: string;
  name: string;
  knowledgePointId: string;
  status: "locked" | "available" | "in-progress" | "completed";
  dependencies: string[]; // 前置节点 ID
  mastery: number;
  recommendedOrder: number;
}

3. 数据模型(前端缓存层)

student-portal 无数据库,仅有 GraphQL cacheurql+ TanStack Query cache + Zustand L3 + IDB。学生数据实时性要求高,缓存策略偏短

数据类型 存储 TTL 失效策略
Sessiontoken + user localStorage + Zustand 复用 Shellaccess 15min / refresh 7day401 自动 refresh
权限列表 urql cache 5min 复用 Shell角色变更事件 invalidate
视口列表scope=student urql cache 5min 复用 Shell
学生 Dashboard 聚合数据 urql cache 30s staleTime 30sWebSocket 事件触发 invalidate
我的作业列表 urql cache 30s staleTime 30s提交后 invalidate
我的考试列表 urql cache 30s staleTime 30sExamPublished 事件 invalidate
考试题目数据 urql cache 0s 考试作答页禁缓存(防预渲染泄露答案);进入时强制 refetch
考试作答草稿 Zustand L3 + IDB 卸载不销毁,自动保存(每 30s + blur 时);提交成功后清除 IDB
学情诊断数据 urql cache 30s staleTime 30s实时性由 BFF 决定)
错题本列表 urql cache 30s staleTime 30s
学习路径 urql cache 5min staleTime 5min低频变更
通知列表P5 urql cache 30s staleTime 30sWebSocket 事件 invalidate
服务器时间偏移 Zustand L3内存 每次进入考试页同步一次
断网重试队列 IDB 网络恢复后批量重试,成功后清除
URL 状态(分页/筛选) nuqs 永久(可分享);考试作答页 URL 不携带答案(防泄露)
表单临时态(作业提交) react-hook-form 卸载即销毁
防作弊行为记录 Zustand L3内存队列 100 队列满后批量上报,上报后清空

缓存策略说明:学生数据(作业/考试/成绩频变且实时性要求高30s 短缓存平衡新鲜度与 BFF 压力;考试题目数据强制 0s 禁缓存防止泄露;考试作答草稿用 IDB 持久化保证断网恢复。

4. API 设计(前端 → 后端)

前端不设计后端 API仅声明消费的 GraphQL 操作。详见 01-understanding.md §3.1

4.1 GraphQL 请求层(复用 Shell 暴露的 urql client 单例)

student-portal 不重复创建 GraphQL client,复用 Shell 暴露的 GraphQLProviderARB-002。urql client 配置详见 teacher-portal 阶段 2 §4.1

student-portal 仅封装业务 Hooks消费 Shell 的 useGraphQLClient()

// apps/student-portal/src/lib/graphql.ts
import { useGraphQLClient } from "@edu/hooks";
import { useQuery, useMutation } from "@tanstack/react-query";
import { gql } from "urql";

// 学生 Dashboard 聚合查询
const STUDENT_DASHBOARD = gql`
  query StudentDashboard {
    studentDashboard {
      upcomingHomework { id title dueAt subject { id name } status }
      upcomingExams { id name startsAt expiresAt durationSeconds subject { id name } status }
      recentGrades { id examName score maxScore grade submittedAt }
      attendanceRate
      learningStreakDays
    }
  }
`;

export function useStudentDashboard() {
  const client = useGraphQLClient();
  return useQuery({
    queryKey: ["student", "dashboard"],
    queryFn: () => client.query(STUDENT_DASHBOARD, {}).toPromise(),
    staleTime: 30 * 1000, // 30s
  });
}

// 考试作答相关
const MY_EXAMS = gql`
  query MyExams($status: ExamStatus) {
    myExams(status: $status) {
      id name startsAt expiresAt durationSeconds
      subject { id name }
      status questionCount
    }
  }
`;

const EXAM_DETAIL = gql`
  query ExamDetail($examId: ID!) {
    exam(id: $examId) {
      id name durationSeconds startsAt expiresAt
      questions {
        id type content options { id text }
        score maxScore
      }
    }
  }
`;

const SAVE_EXAM_DRAFT = gql`
  mutation SaveExamDraft($examId: ID!, $questionId: ID!, $answer: AnswerInput!) {
    saveExamDraft(examId: $examId, questionId: $questionId, answer: $answer) {
      savedAt
      ok
    }
  }
`;

const SUBMIT_EXAM = gql`
  mutation SubmitExam($examId: ID!) {
    submitExam(examId: $examId) {
      submissionId submittedAt
    }
  }
`;

export function useMyExams(status?: ExamStatus) {
  const client = useGraphQLClient();
  return useQuery({
    queryKey: ["student", "exams", { status }],
    queryFn: () => client.query(MY_EXAMS, { status }).toPromise(),
    staleTime: 30 * 1000,
  });
}

export function useExamDetail(examId: string) {
  const client = useGraphQLClient();
  return useQuery({
    queryKey: ["student", "exam", examId],
    queryFn: () => client.query(EXAM_DETAIL, { examId }).toPromise(),
    staleTime: 0, // 禁缓存,防泄露
  });
}

4.2 GraphQL 操作清单(对齐 student-bff 契约)

操作类型 操作名 用途 阶段
query currentUser 当前学生信息 + 权限 + 视口 P3
query myClasses 我的班级列表 P3
query myExams 我的考试列表 P3
query exam 考试详情(含题目) P3
query examSubmissionStatus 考试提交状态(防重复提交) P3
query examResult 考试结果 P3
query myHomework 我的作业列表 P3
query homeworkDetail 作业详情 P3
query myGrades 我的成绩列表 P3
query myAttendance 我的考勤 P3
query studentDashboard 学生仪表盘聚合 P3
query serverTime 服务器时间(倒计时校正) P3
mutation submitHomework 提交作业(含 attachmentUrls P3
mutation saveExamDraft 保存考试草稿(自动保存) P3
mutation submitExam 提交考试 P3
mutation recordExamViolation 记录防作弊违规ARB-019 §21.4 P3
mutation recordPasteEvent 记录粘贴事件ARB-019 §21.4 P3
query textbooks 教材列表 P4
query chapters 章节列表 P4
query learningPath 学习路径 P4
query myWeakness 错题本 P4
query myTrend 学习趋势 P4
query myNotifications 通知列表 P5
mutation markAsRead 标记通知已读 P5
mutation markAllAsRead 全部标记已读 P5
mutation updateNotificationPreference 更新通知偏好 P5

附件上传ISSUE-014-05 已裁决方案 A走 api-gateway REST POST /api/v1/student/uploadmultipart/form-data→ 对象存储 → 返回 signed URL → GraphQL submitHomework(input: { attachmentUrls }) 提交 URL。不使用 GraphQL multipart mutation。

4.3 Query Key 命名约定

// GraphQL operation → TanStack Query key
queryKey: ["student", "dashboard"];
queryKey: ["student", "exams", { status }];
queryKey: ["student", "exam", examId]; // 考试详情
queryKey: ["student", "homework", { status, page }];
queryKey: ["student", "homework", homeworkId];
queryKey: ["student", "grades", { subjectId }];
queryKey: ["student", "attendance", { month }];
queryKey: ["student", "diagnostic", { examId }];
queryKey: ["student", "weakness", { knowledgePointId }];
queryKey: ["student", "learning-path"];
queryKey: ["student", "notifications", { unreadOnly }];
queryKey: ["session", "effective-permissions"]; // 复用 Shell
queryKey: ["session", "viewports", "student"]; // 复用 Shell

5. 事件设计

前端不发布 Kafka 事件,仅消费 WebSocket 推送P5+ 浏览器原生事件(防作弊)。

5.1 WebSocket 推送事件P5

事件 触发 student-portal 前端动作
NotificationRequested msg 服务投递 toast 提示 + 通知中心未读数 +1 + ["student","notifications"] invalidate
ExamPublished 教师发布考试 toast + ["student","exams"] invalidate + ["student","dashboard"] invalidate
ExamExtended 教师延长考试 重新拉取 exam query → 更新 expiresAt → 重置倒计时ARB-019 §21.6 已确认)
ExamForceSubmitted 教师强制收卷 立即触发 submitExam mutation → 跳转结果页ARB-019 §21.6 已确认)
ExamQuestionReordered 教师调整题目顺序 P3 不实现P4 评估实时重排需求ARB-019 §21.6 已确认)
GradeRecorded 教师录入成绩 toast + ["student","grades"] invalidate + ["student","dashboard"] invalidate
HomeworkDeadlineApproaching 作业截止前提醒 toast + 作业列表高亮 + ["student","homework"] invalidate

5.2 WebSocket 连接管理P5

// apps/student-portal/src/lib/ws.tsP5 实现)
import { useAuth } from "@edu/hooks";
import { useQueryClient } from "@tanstack/react-query";

const RECONNECT_STRATEGY = {
  maxRetries: 5,
  backoff: "exponential", // 1s, 2s, 4s, 8s, 16s
  onMaxRetriesExceeded: "fallback-polling", // 降级为 HTTP 轮询 60s
};

export function useStudentWebSocket() {
  const { getToken } = useAuth();
  const queryClient = useQueryClient();
  const retryCount = useRef(0);

  useEffect(() => {
    const connect = () => {
      const ws = new WebSocket(
        `${process.env.NEXT_PUBLIC_PUSH_GATEWAY_URL}/ws?token=${getToken()}`,
      );

      ws.onmessage = (event) => {
        const msg = JSON.parse(event.data) as PushMessage;
        handlePushMessage(msg, queryClient);
      };

      ws.onclose = () => {
        if (retryCount.current < RECONNECT_STRATEGY.maxRetries) {
          const delay = Math.pow(2, retryCount.current) * 1000;
          setTimeout(connect, delay);
          retryCount.current += 1;
        } else {
          // 降级为 HTTP 轮询
          startPollingFallback(queryClient);
        }
      };

      ws.onerror = () => ws.close();

      return () => ws.close();
    };

    return connect();
  }, [getToken, queryClient]);
}

function handlePushMessage(msg: PushMessage, queryClient: QueryClient) {
  switch (msg.type) {
    case "NotificationRequested":
      toast.info(t(msg.payload.title));
      queryClient.invalidateQueries({ queryKey: ["student", "notifications"] });
      break;
    case "ExamPublished":
      toast.info(t("exam.published", { name: msg.payload.examName }));
      queryClient.invalidateQueries({ queryKey: ["student", "exams"] });
      queryClient.invalidateQueries({ queryKey: ["student", "dashboard"] });
      break;
    case "ExamExtended":
      // 重新拉取考试详情,更新 expiresAt
      queryClient.invalidateQueries({ queryKey: ["student", "exam", msg.payload.examId] });
      queryClient.invalidateQueries({ queryKey: ["student", "exams"] });
      break;
    case "ExamForceSubmitted":
      // 教师强制收卷:立即触发提交,跳转结果页
      toast.warning(t("exam.forceSubmitted"));
      queryClient.invalidateQueries({ queryKey: ["student", "exam", msg.payload.examId] });
      // 触发 submitExam mutation由 useExamTaking hook 内部处理)
      window.dispatchEvent(new CustomEvent("exam-force-submit", { detail: { examId: msg.payload.examId } }));
      break;
    case "ExamQuestionReordered":
      queryClient.invalidateQueries({ queryKey: ["student", "exam", msg.payload.examId] });
      break;
    case "GradeRecorded":
      toast.success(t("grade.recorded"));
      queryClient.invalidateQueries({ queryKey: ["student", "grades"] });
      queryClient.invalidateQueries({ queryKey: ["student", "dashboard"] });
      break;
    case "HomeworkDeadlineApproaching":
      toast.warning(t("homework.deadlineApproaching", { name: msg.payload.homeworkName }));
      queryClient.invalidateQueries({ queryKey: ["student", "homework"] });
      break;
  }
}

5.3 浏览器原生事件(防作弊,考试作答页)

事件 用途 处理
visibilitychange 切屏检测 记录切换次数 + 时长;超阈值警告;上报服务端
blur / focus 窗口失焦检测 同上
copy 复制检测 全局阻止 + 警告 + 记录
paste 粘贴检测(按题型分层) 客观题阻止 + 警告;主观题允许 + 记录长度作文题允许不记录ARB-019 §21.4
fullscreenchange 全屏退出检测 警告 + 记录
contextmenu 右键禁用 考试作答页 preventDefault()

6. 横切关注点对齐清单

6.1 权限(前端等价)

路由 requiredPermission
/dashboard STUDENT_DASHBOARD_VIEW
/my-homework HOMEWORK_READ_OWN
/my-homework/:id/submit HOMEWORK_SUBMIT
/my-exams EXAMS_READ_OWN
/my-exams/:id/take EXAMS_TAKE
/my-exams/:id/result EXAMS_RESULT_VIEW
/my-grades GRADES_READ_OWN
/my-attendance ATTENDANCE_READ_OWN
/learning-path LEARNING_PATH_VIEW
/diagnostic DIAGNOSTIC_READ_OWN
/weakness WEAKNESS_READ_OWN
/notifications NOTIFICATION_READ_OWN

路由前缀不带 /student/student-portal 是独立 dev server :4001。权限点常量集中在 packages/contracts/src/permissions.tscoord 维护。L3 组件级视口用 <RequirePermission perm="HOMEWORK_SUBMIT"><Button>提交作业</Button></RequirePermission>。权限点后缀 _OWN 强调学生仅能操作自己的数据DataScope L0

6.2 错误码清单(前端 i18n 路由)

前缀 来源服务 i18n key 模式
IAM_ iam iam.error.{{code}}
CORE_EDU_ core-edu coreEdu.error.{{code}}
BFF_STUDENT_ student-bff bffStudent.error.{{code}}
GW_ api-gateway gateway.error.{{code}}
NETWORK_ 前端网络层 network.error.{{code}}

错误码前缀统一known-issues §2.15core-edu 子域(考试/作业/成绩/考勤)统一用 CORE_EDU_,不再用 EXAMS_/HOMEWORK_/GRADES_student-bff 用 BFF_STUDENT_,不再用 STUDENT_BFF_。前端 GraphQL 请求层根据 extensions.code 前缀路由 i18n key。

6.3 Logger

复用 packages/shared-ts/src/logger.ts(同 teacher-portal开发环境 console + 结构化,生产环境 → SentryP6。必含字段trace_iduser_idscope=studentpath

// 使用示例student-portal 内)
import { createLogger } from "@edu/shared-ts";
const logger = createLogger({ scope: "student" });
logger.info("exam taking started", { examId, durationSeconds });
logger.error("homework submit failed", { homeworkId, error: err.code });

6.4 MetricsWeb Vitals

指标 类型 上报
student_portal_lcp_seconds LCP next/web-vitalsPOST /api/v1/admin/web-vitals
student_portal_cls CLS 同上
student_portal_fid_seconds FID 同上
student_portal_ttfb_seconds TTFB 同上
student_portal_exam_save_failed Counter 考试自动保存失败计数
student_portal_offline_duration Histogram 离线时长(秒)

P6 接入P3-P5 暂缓考试作答自动保存失败率除外P3 即上报)。

6.5 TracerOTel browser SDKP6

复用 packages/shared-ts/src/tracer.ts(同 teacher-portal。BatchSpanProcessor → OTLP exporter → collector → Tempo。自动埋点fetch、XMLHttpRequest、document load、user interaction。

6.6 健康检查

端点 用途 实现
GET /api/health Dockerfile HEALTHCHECK Next.js Route Handler返回 { status: 'ok', ts: Date.now() }
GET /api/ready K8s readinessProbe 检查 process.env.API_GATEWAY_URL 可达 + 内存 < 阈值 + Shell MF 可达

6.7 优雅关闭

Next.js 无长连接(除 WS无需特殊处理。WS 在 P5 由 push-gateway 管理,前端断线自动重连(指数退避,最多 5 次5 次后降级 HTTP 轮询)。

7. 共享组件库(复用 Shell 暴露 + student 特有)

7.1 复用 Shell 暴露的组件packages/ui-components/

组件 用途 来源
AppShell 左侧栏 + 主内容区布局(按 scope=student 过滤视口) teacher-portal Shell 暴露
RequirePermission L3 组件级视口控制(无权限不渲染 children Shell 暴露
ErrorBoundary React 渲染异常兜底fallback UI Shell 暴露
Loading / Skeleton 骨架屏 Shell 暴露
Empty 空态(插画 + 文案 + CTA Shell 暴露
Modal / Dialog 全局 ModalModalRoot + Zustand ui-store Shell 暴露shadcn/ui
Toast 全局 toast错误/成功/警告) Shell 暴露shadcn/ui sonner
Button / Input / Select / Textarea 基础表单 Shell 暴露shadcn/ui
DataTable 表格(排序/分页/筛选) Shell 暴露
Chart 图表封装recharts Shell 暴露
A11y 工具集 useA11yId / mergeA11yProps / describeInput / focus-trap / skip-link / visually-hidden / aria-status Shell 暴露
Form react-hook-form + zodResolver 封装 Shell 暴露
GraphQLProvider urql client 单例ARB-002 Shell 暴露

7.2 student-portal 特有组件(不暴露给 Shell除 ExamTaking

组件 用途 来源 是否 MF 暴露
ExamTaking 考试作答(倒计时 + 自动保存 + 断网恢复 + 防作弊) 新建 暴露 ./ExamTaking(供 Shell 路由复用)
HomeworkSubmit 作业提交表单react-hook-form + zodResolver 新建 内部使用
DiagnosticChart 学情诊断图表recharts 多维雷达 + 趋势线) 新建 内部使用
WeaknessList 错题本列表(按知识点聚合 + 掌握度标签) 新建 内部使用
LearningPathMap 学习路径图(知识点前置依赖可视化) 新建 内部使用
ExamResultView 考试结果页(得分 + 错题分析 + 知识点掌握度) 新建 内部使用
CountdownTimer 倒计时组件(基于服务器时间,避免客户端篡改) 新建 内部使用
DraftRecovery 草稿恢复弹窗(断网恢复后提示是否恢复作答) 新建 内部使用

7.3 不使用的组件(与 teacher-portal 差异)

  • 不使用 RichTextEditorTiptap:学生不作答富文本,作业提交用表单
  • 不使用 SSEViewer:学生不参与 AI 出题
  • 不使用 ChildSwitcher:学生无多子女切换(家长端特有)
  • 不使用 UserManagementTable:学生不管理用户
  • 不使用 RolePermissionMatrix:学生不管理权限

8. 共享 Hooks复用 Shell 暴露 + student 特有)

8.1 复用 Shell 暴露的 Hooks

Hook 职责 来源
useAuth() 会话状态user/token/refresh/login/logout Shell 暴露
usePermission() 权限查询hasPermission/hasAny/hasAll + dataScope Shell 暴露
useViewports(scope) 视口列表(按 scope 过滤) Shell 暴露
useGraphQLClient() urql client 实例ARB-002 单例) Shell 暴露
useA11yId() 唯一 ARIA ID 生成 Shell 暴露
useAriaLive() aria-live 区域管理 Shell 暴露
useToast() 全局 toastZustand ui-store Shell 暴露

8.2 student-portal 特有 Hooks

Hook 职责
useStudentDashboard() 学生仪表盘聚合查询
useMyExams(status?) 我的考试列表
useExamDetail(examId) 考试详情(含题目,禁缓存)
useExamTaking(examId) 考试作答状态(草稿 + 倒计时 + 自动保存 + 断网队列)
useSaveExamDraft() 保存草稿 mutation自动保存
useSubmitExam() 提交考试 mutation
useMyHomework(status?) 我的作业列表
useSubmitHomework() 提交作业 mutation
useMyGrades(subjectId?) 我的成绩列表
useMyAttendance(month?) 我的考勤
useDiagnostic(examId?) 学情诊断P4
useMyWeakness(kpId?) 错题本P4
useLearningPath() 学习路径P4
useMyNotifications() 通知列表P5
useServerTime() 服务器时间同步(倒计时校正)
useExamTabGuard(examId) 考试多标签检测BroadcastChannel
useAntiCheat(examId) 防作弊行为采集visibilitychange/copy/paste 等)
useOfflineQueue() 断网恢复队列IDB 读写 + 重连重试)

9. 设计令牌三层(复用 Shell 提供的 packages/ui-tokens/

student-portal 不独立维护设计令牌,复用 Shell 暴露的 packages/ui-tokens/(详见 teacher-portal 阶段 2 §9)。

强制规则project_rules §3.10,与 Shell 一致):

  • 禁止 #hex 字面量ESLint no-restricted-syntax
  • 禁止 'Inter'/'Fraunces'/'JetBrains Mono' 字面量ESLint design-tokens/no-hardcoded-fonts
  • 禁止 font-size: Npx(用 var(--font-size-1~9)
  • 禁止 Tailwind 任意值 w-[Npx](用 --space-* 或默认阶梯)

student-portal 的 ESLint flat config 与 Shell 共用,确保规则一致。

10. 与其他模块的交互点(契约清单)

方向 对方服务 协议 接口/事件 用途 阶段
调用 api-gateway GraphQL over HTTP POST /api/v1/student/graphql 全部业务请求 P3+
调用 api-gateway HTTP POST /api/auth/login 学生登录 P2+
调用 push-gateway WebSocket ws://push-gateway/ws 实时推送 P5
被调用 前端不暴露接口给其他服务
消费 student-bff GraphQL dashboard/myExams/myHomework 等 学生场景聚合 P3+
消费 iam GraphQL经 BFF currentUser权限/视口) 会话/权限 P3+
消费 core-edu GraphQL经 BFF exam/homework/grades 相关 教学核心(学生视角) P3+
消费 content GraphQL经 BFF textbooks/chapters/learningPath 内容资源 P4+
消费 data-ana GraphQL经 BFF myWeakness/myTrend/diagnostic 学情分析 P4+
消费 msg GraphQL经 BFF myNotifications/markAsRead 通知中心 P5+
依赖 coord 维护 packages/shared-proto TS 类型(仅 contracts 部分) P1+
依赖 coord 维护 packages/shared-ts Logger/Tracer/通用工具 P3+
依赖 coord 维护 packages/contracts Permissions 常量 + 类型 P3+
依赖 ai13 维护 packages/ui-tokens 三层设计令牌 P3+
依赖 ai13 维护 packages/ui-components shadcn + 共享组件 P3+
依赖 ai13 维护 packages/hooks usePermission/useAuth 等 P3+

proto 不直接消费:前端不调用 gRPCstudent-bff 把 gRPC 聚合为 GraphQL 暴露给前端。前端仅消费 packages/contracts/src/permissions.ts 中的权限点常量TS 文件,非 proto 生成)。

11. 风险与假设

11.1 假设

  1. 假设 coord 建立 packages/shared-tspackages/contracts:包含 Logger、Tracer、Permissions 常量、通用类型。若 coord 未建立ai14 自行在 apps/student-portal/src/shared/ 内实现,后续提取到 packages。
  2. 假设 ai13 teacher-portal Shell 按 ARB-002 暴露 GraphQL 单例GraphQLProvider/useGraphQLClient/urql/graphql singleton。若 Shell 未暴露student-portal 降级为自建 urql client违反 ARB-002需 coord 仲裁)。
  3. 假设 ai04 student-bff 提供 GraphQL endpoint POST /graphql:含 §4.2 全部 query/mutation。错误码前缀 BFF_STUDENT_ARB-001 已确认)。
  4. 假设 teacher-portal Shell 已就绪AppShell + 共享依赖暴露 + MF 配置 + GraphQLProviderP2 收尾完成。student-portal 作为 Remote 才能挂载。
  5. 假设 Next.js 14+ Module Federation 2.0 稳定@module-federation/nextjs-mf 在 Next.js App Router + urql singleton 下可用。若不稳定,降级为 4 端独立部署 + 各自 Shell重复实现 AppShell + GraphQLProvider
  6. 假设 ai10 msg 提供 WebSocket 事件ExamExtended/ExamForceSubmitted/ExamQuestionReordered 事件名已由 ARB-019 §21.6 裁决确认(见 issues/contracts

11.2 技术风险

风险 影响 缓解
MF Remote SSR 对齐复杂 Remote 在 SSR 时需 Shell 提供上下文 优先 CSRSSR 仅用于首屏 dashboard考试作答页强制 CSR防缓存
GraphQL singleton 分裂 Remote 与 Shell 的 urql client 不是同一实例cache 分裂 MF shared.singleton: true + ARB-002 强制 + CI 检查
共享依赖版本漂移 Remote 与 Shell 的 react/urql 版本不一致导致运行时错误 MF shared.singleton: true + requiredVersion + CI 检查版本对齐
考试作答断网丢失 学生作答过程中断网,草稿丢失 Zustand L3 + IDB 双写,每 30s + blur 时自动保存,重连后批量重试
考试倒计时不准 学生端时间与服务器时间偏差 倒计时基于服务器返回的 expiresAt,前端仅做展示,提交以服务器时间为准
考试题目缓存泄露 考试题目被缓存到 CDN/Service Worker泄露答案 考试作答页 ssr: false + urql cache staleTime 0 + Security Header 防缓存
缓存陈旧导致看到旧作业 学生看到已截止的作业 staleTime 30s + 截止时间客户端校验 + 提交时服务端二次校验
Token 刷新竞态 多请求同时 401 触发多次 refresh urql auth exchange 全局单例 + refresh promise 复用(复用 Shell
权限缓存陈旧 角色变更后前端 5min 内仍用旧权限 iam 角色变更发 Kafka 事件 → msg 推送 WebSocket → 前端 invalidate
IDB 存储空间不足 考试草稿 + 断网队列累积超过浏览器配额 提交成功后立即清除 IDB 草稿;定期清理 7 天前的未提交草稿
BroadcastChannel 兼容性 Safari < 15.4 不支持 降级为 localStorage 事件storage event listener

11.3 未决设计决策(需 coord 仲裁)

决策 当前方案 状态
GraphQL vs REST GraphQLARB-001 已裁决) 已裁决
MF Shell 暴露清单 GraphQLProvider + urql singleton 已裁决ARB-002
packages 归属 ai13 维护 ui-*/hookscoord 维护 shared-ts/contracts 已确认
考试作答防作弊事件名 ExamExtended/ExamForceSubmitted/ExamQuestionReordered 已裁决ARB-019 §21.6
附件上传协议 REST POST /api/v1/student/upload + 对象存储 + GraphQL 提交 URL 已裁决ARB-019 §21.5,方案 A
主观题粘贴策略 客观题禁止/主观题允许记录/作文题允许不记录 已裁决ARB-019 §21.4
考试作答页全屏策略 P3 不强制(提示"建议全屏作答"P4 评估升级 已裁决ARB-019 §21.4

12. coord 交叉审查所需信息

12.1 端口矩阵

dev 端口 生产端口 备注
student-portal 4001 4001 MF Remote 子应用P3 首个 Remote

port-allocation §4 对齐4000-4003 强制 4 端 portal

12.2 依赖的共享包(需 coord / ai13 建立)

路径 维护方 内容
shared-ts packages/shared-ts/ coord Logger、Tracer、通用工具
contracts packages/contracts/ coord Permissions 常量、ActionState 类型、UserInfo 类型
ui-tokens packages/ui-tokens/ ai13 三层设计令牌
ui-components packages/ui-components/ ai13 shadcn + ErrorBoundary + RequirePermission + GraphQLProvider
hooks packages/hooks/ ai13 usePermission、useAuth、useViewports、useGraphQLClient

12.3 依赖的后端契约(需对应 AI 确认)

契约 提供方 当前状态
POST /api/auth/login api-gateway 已实现
GraphQL POST /api/v1/student/graphql§4.2 全部操作) student-bff 📐 待 ai04 设计
/exams/* /homework/* /grades/*gRPC经 BFF core-edu 已实现P3
/analytics/*gRPC经 BFF data-ana 已实现P4
/notifications/* + WebSocket 推送 msg + push-gateway 📐 待 P5
WebSocket 事件 ExamExtended/ExamForceSubmitted msg + push-gateway 已裁决ARB-019 §21.6

12.4 错误码前缀(前端 i18n 路由依赖)

前端不产生错误码,仅消费。需各服务确认错误码前缀不重叠:

前缀 服务 状态
IAM_ iam 已用
CORE_EDU_ core-edu 已确认§2.15
BFF_STUDENT_ student-bff 已确认ARB-001
GW_ api-gateway 已用
NETWORK_ 前端 ai14 自有

12.5 不产生 Kafka 事件

前端不发布/消费 Kafka 事件。WebSocket 推送由 push-gateway 消费 Kafka 转发。

13. 实施路线ai14 自用)

P3student-portal 起步 — 核心教学)

  1. apps/student-portal/Remote 角色),配置 MFARB-002urql/graphql singleton
  2. 实现 GraphQL Hooks 层§8.2 全部 P3 Hooks复用 Shell useGraphQLClient()
  3. 实现 Dashboard + 我的作业 + 提交作业 + 我的考试 + 作答考试 + 考试结果 + 我的成绩 + 我的考勤
  4. 考试作答核心§14 详述CountdownTimer + 自动保存 + IDB 断网恢复 + 防作弊采集 + DraftRecovery
  5. 复用 Shell 的 AppShell + 共享组件RequirePermission/ErrorBoundary/Loading/Empty/DataTable/Form/Chart
  6. 配置 Dockerfile 多阶段构建 + /api/health + /api/ready route
  7. 补 Vitest 单测 + Playwright E2E覆盖率 ≥ 80%,含考试作答边界场景 6 个 E2E
  8. 接入 MSW MockGraphQL handlers + mock-socket

P4学情诊断 + 错题本 + 学习路径)

  1. 实现学情诊断页面DiagnosticChart 多维雷达 + 趋势线)
  2. 实现错题本页面WeaknessList 按知识点聚合 + 掌握度标签)
  3. 实现学习路径页面LearningPathMap 知识点前置依赖可视化)
  4. 接入 content 服务 GraphQLtextbooks/chapters/learningPath

P5推送 + 通知中心)

  1. student-portal 接入 WebSocketpush-gateway指数退避重连 + HTTP 轮询降级
  2. 实现通知中心页面(通知列表 + 未读数 + 标记已读)
  3. 处理 6 类推送事件NotificationRequested/ExamPublished/ExamExtended/ExamQuestionReordered/GradeRecorded/HomeworkDeadlineApproaching

P6硬化

  1. Web Vitals + OTel browser SDK 接入
  2. A11y WCAG 2.2 AA 审计(考试作答页键盘可操作 + 屏幕阅读器友好)
  3. 性能优化MF shared 单例验证、bundle 分析、代码分割)
  4. PWA 配置manifest + Service Worker考试作答页 Network Only
  5. 隐私合规完善PIPL/未成年人保护法强制项)
  6. 多语言en-US/zh-TW100% 完成度

14. 考试作答架构设计ai14 新增 — 核心复杂场景)

考试作答是学生端最复杂、最易出问题的场景,必须在架构中预留所有边界场景的处理。本节是 ai14 新增,对齐 01-understanding.md §11

14.1 考试作答状态机

stateDiagram-v2
    [*] --> NotStarted: 进入 /my-exams/:id/take早于 startsAt
    [*] --> InProgress: 进入startsAt ≤ now ≤ expiresAt
    NotStarted --> InProgress: 到达 startsAt
    
    InProgress --> AutoSaving: 每 30s / blur
    AutoSaving --> InProgress: 保存成功
    AutoSaving --> OfflineMode: 保存失败(网络中断)
    
    OfflineMode --> InProgress: 网络恢复 + 队列重试成功
    OfflineMode --> AutoSubmit: 倒计时归零
    
    InProgress --> ConfirmSubmit: 学生点击提交
    ConfirmSubmit --> Submitting: 确认提交
    ConfirmSubmit --> InProgress: 取消提交
    
    Submitting --> Submitted: 提交成功
    Submitting --> Submitting: 重试(查询提交状态,防重复)
    Submitting --> IDBQueue: 提交失败入队
    
    AutoSubmit --> Submitting: 倒计时归零自动提交
    
    IDBQueue --> Submitting: 网络恢复后重试
    
    Submitted --> [*]: 跳转 /my-exams/:id/result
    Submitted --> ClearDraft: 清除 IDB 草稿
    ClearDraft --> [*]

14.2 倒计时与服务器时间对齐

// apps/student-portal/src/lib/serverTimeSync.ts
import { useGraphQLClient } from "@edu/hooks";

const SERVER_TIME_QUERY = gql`
  query ServerTime {
    serverTime
  }
`;

export function useServerTimeSync() {
  const client = useGraphQLClient();
  const [offset, setOffset] = useState(0); // serverTime - clientTimems

  const sync = useCallback(async () => {
    const requestStart = Date.now();
    const result = await client.query(SERVER_TIME_QUERY, {}).toPromise();
    const requestEnd = Date.now();
    if (result.data?.serverTime) {
      const serverTime = new Date(result.data.serverTime).getTime();
      const roundTrip = requestEnd - requestStart;
      const estimatedClientTimeAtServer = requestStart + roundTrip / 2;
      setOffset(serverTime - estimatedClientTimeAtServer);
    }
  }, [client]);

  useEffect(() => {
    sync();
    // 每 5 分钟重新同步一次
    const interval = setInterval(sync, 5 * 60 * 1000);
    return () => clearInterval(interval);
  }, [sync]);

  // 获取校正后的当前时间
  const getCorrectedTime = () => Date.now() + offset;

  return { offset, sync, getCorrectedTime };
}

// 倒计时 Hook
export function useCountdownTimer(expiresAt: string, serverTimeOffset: number) {
  const [remaining, setRemaining] = useState(0);

  useEffect(() => {
    const calculate = () => {
      const correctedNow = Date.now() + serverTimeOffset;
      const expiry = new Date(expiresAt).getTime();
      const diff = expiry - correctedNow;
      setRemaining(Math.max(0, Math.floor(diff / 1000)));
    };
    calculate();
    const interval = setInterval(calculate, 1000);
    return () => clearInterval(interval);
  }, [expiresAt, serverTimeOffset]);

  return {
    remaining,
    formatted: formatDuration(remaining),
    isExpired: remaining === 0,
  };
}

function formatDuration(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}

14.3 自动保存与断网恢复IDB 队列)

// apps/student-portal/src/lib/examQueue.ts
import { openDB } from "idb";

const DB_NAME = "student-exam";
const DB_VERSION = 1;
const DRAFT_STORE = "drafts";
const QUEUE_STORE = "queue";

async function getDB() {
  return openDB(DB_NAME, DB_VERSION, {
    upgrade(db) {
      if (!db.objectStoreNames.contains(DRAFT_STORE)) {
        db.createObjectStore(DRAFT_STORE, { keyPath: "examId" });
      }
      if (!db.objectStoreNames.contains(QUEUE_STORE)) {
        db.createObjectStore(QUEUE_STORE, { keyPath: "id", autoIncrement: true });
      }
    },
  });
}

// 保存草稿到 IDB断网恢复用
export async function saveDraftToIDB(draft: ExamTakingDraft): Promise<void> {
  const db = await getDB();
  await db.put(DRAFT_STORE, draft);
}

// 从 IDB 加载草稿
export async function loadDraftFromIDB(examId: string): Promise<ExamTakingDraft | undefined> {
  const db = await getDB();
  return db.get(DRAFT_STORE, examId);
}

// 清除草稿(提交成功后)
export async function clearDraftFromIDB(examId: string): Promise<void> {
  const db = await getDB();
  await db.delete(DRAFT_STORE, examId);
}

// 入队失败的保存请求(断网重试用)
export async function enqueueFailedSave(item: {
  examId: string;
  questionId: string;
  answer: AnswerInput;
  queuedAt: number;
}): Promise<void> {
  const db = await getDB();
  await db.add(QUEUE_STORE, item);
}

// 出队并重试
export async function processQueue(
  retryFn: (item: { examId: string; questionId: string; answer: AnswerInput }) => Promise<boolean>,
): Promise<void> {
  const db = await getDB();
  const tx = db.transaction(QUEUE_STORE, "readwrite");
  const allItems = await tx.store.getAll();
  
  for (const item of allItems) {
    const success = await retryFn({
      examId: item.examId,
      questionId: item.questionId,
      answer: item.answer,
    });
    if (success) {
      await tx.store.delete(item.id);
    }
  }
  await tx.done;
}

// 监听网络恢复事件
export function useOfflineQueue_recovery(retryFn: (item: any) => Promise<boolean>) {
  useEffect(() => {
    const handleOnline = () => {
      processQueue(retryFn);
    };
    window.addEventListener("online", handleOnline);
    // 页面可见时也尝试处理队列
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible" && navigator.onLine) {
        processQueue(retryFn);
      }
    });
    return () => {
      window.removeEventListener("online", handleOnline);
    };
  }, [retryFn]);
}

14.4 考试作答自动保存策略

// apps/student-portal/src/hooks/useExamTaking.ts
export function useExamTaking(examId: string) {
  const client = useGraphQLClient();
  const { offset, getCorrectedTime } = useServerTimeSync();
  const [draft, setDraft] = useState<ExamTakingDraft | null>(null);
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  // 进入考试页时加载草稿
  useEffect(() => {
    loadDraftFromIDB(examId).then((saved) => {
      if (saved) {
        // 检测到未完成草稿,弹 DraftRecovery
        showDraftRecoveryDialog(saved).then((restore) => {
          if (restore) setDraft(saved);
          else setDraft(createNewDraft(examId));
        });
      } else {
        setDraft(createNewDraft(examId));
      }
    });
  }, [examId]);

  // 自动保存(每 30s + blur
  useEffect(() => {
    if (!draft) return;

    const autoSave = async () => {
      if (!isOnline) {
        // 离线:入 IDB 队列
        await saveDraftToIDB(draft);
        return;
      }
      try {
        const result = await client
          .mutation(SAVE_EXAM_DRAFT, {
            examId,
            questionId: "batch", // 批量保存
            answer: draft.answers,
          })
          .toPromise();
        if (result.data?.saveExamDraft?.ok) {
          setDraft((prev) => ({ ...prev!, lastSavedAt: Date.now() }));
          await saveDraftToIDB(draft); // 同步到 IDB
        }
      } catch (err) {
        // 保存失败,入队列
        await enqueueFailedSave({
          examId,
          questionId: "batch",
          answer: draft.answers as any,
          queuedAt: Date.now(),
        });
        logger.warn("exam auto-save failed, queued", { examId, error: err });
      }
    };

    const interval = setInterval(autoSave, 30 * 1000); // 每 30s
    const onBlur = () => autoSave(); // blur 时

    window.addEventListener("blur", onBlur);
    return () => {
      clearInterval(interval);
      window.removeEventListener("blur", onBlur);
    };
  }, [draft, client, examId, isOnline]);

  // 监听网络状态
  useEffect(() => {
    const updateOnline = () => setIsOnline(navigator.onLine);
    window.addEventListener("online", updateOnline);
    window.addEventListener("offline", updateOnline);
    return () => {
      window.removeEventListener("online", updateOnline);
      window.removeEventListener("offline", updateOnline);
    };
  }, []);

  // 网络恢复后处理队列
  useOfflineQueue_recovery(async (item) => {
    try {
      const result = await client
        .mutation(SAVE_EXAM_DRAFT, {
          examId: item.examId,
          questionId: item.questionId,
          answer: item.answer,
        })
        .toPromise();
      return result.data?.saveExamDraft?.ok ?? false;
    } catch {
      return false;
    }
  });

  // 更新答案
  const updateAnswer = useCallback((questionId: string, answer: AnswerInput) => {
    setDraft((prev) => {
      if (!prev) return prev;
      return {
        ...prev,
        answers: { ...prev.answers, [questionId]: answer },
      };
    });
  }, []);

  return {
    draft,
    isOnline,
    serverTimeOffset: offset,
    correctedNow: getCorrectedTime(),
    updateAnswer,
  };
}

14.5 防作弊采集(前端配合,服务端最终判定)

// apps/student-portal/src/hooks/useAntiCheat.ts
export function useAntiCheat(examId: string) {
  const behaviorsRef = useRef<SuspiciousBehaviorRecord[]>([]);
  const [warning, setWarning] = useState<string | null>(null);

  const record = useCallback(
    (type: SuspiciousBehaviorRecord["type"], details?: string) => {
      const record_item: SuspiciousBehaviorRecord = {
        type,
        timestamp: Date.now(),
        details,
      };
      behaviorsRef.current.push(record_item);
      
      // 队列满 100 条后批量上报
      if (behaviorsRef.current.length >= 100) {
        flushBehaviors();
      }
    },
    [examId],
  );

  const flushBehaviors = useCallback(async () => {
    if (behaviorsRef.current.length === 0) return;
    const batch = behaviorsRef.current.splice(0);
    try {
      await client
        .mutation(RECORD_EXAM_VIOLATION, {
          examId,
          behaviors: batch,
        })
        .toPromise();
    } catch (err) {
      // 上报失败,重新入队
      behaviorsRef.current.unshift(...batch);
    }
  }, [examId]);

  // 切屏检测
  useEffect(() => {
    let switchCount = 0;
    const handleVisibility = () => {
      if (document.visibilityState === "hidden") {
        switchCount += 1;
        record("tab-switch", `switch #${switchCount}`);
        if (switchCount >= 3) {
          setWarning(t("exam.antiCheat.tooManyTabSwitches"));
          toast.warning(t("exam.antiCheat.tooManyTabSwitches"));
        }
      }
    };
    document.addEventListener("visibilitychange", handleVisibility);
    return () => document.removeEventListener("visibilitychange", handleVisibility);
  }, [record]);

  // 复制检测(全局阻止)
  useEffect(() => {
    const handleCopy = (e: ClipboardEvent) => {
      e.preventDefault();
      record("copy", "copy attempted");
      toast.warning(t("exam.antiCheat.copyDisabled"));
    };
    document.addEventListener("copy", handleCopy);
    return () => document.removeEventListener("copy", handleCopy);
  }, [record]);

  // 粘贴检测按题型分层ARB-019 §21.4 ISSUE-014-04 已裁决)
  // - 客观题single-choice/multiple-choice/fill-blank禁止粘贴
  // - 主观题short-answer允许粘贴记录粘贴事件 + 内容长度
  // - 作文题essay允许粘贴不记录
  useEffect(() => {
    const handlePaste = (e: ClipboardEvent) => {
      const activeQuestion = getActiveQuestion(); // 当前焦点题目
      if (!activeQuestion) return;

      const questionType = activeQuestion.answerType;
      const pastedLength = e.clipboardData?.getData("text")?.length ?? 0;

      switch (questionType) {
        case "single-choice":
        case "multiple-choice":
        case "fill-blank":
          // 客观题:禁止粘贴
          e.preventDefault();
          record("paste", `paste blocked on ${questionType}, length=${pastedLength}`);
          toast.warning(t("exam.antiCheat.pasteDisabled"));
          break;
        case "short-answer":
          // 主观题:允许粘贴,记录事件 + 内容长度
          record("paste", `paste allowed on short-answer, length=${pastedLength}`);
          // 走 recordPasteEvent mutation 上报ARB-019 §21.4
          client.mutation(RECORD_PASTE_EVENT, {
            examId,
            questionId: activeQuestion.id,
            pasteLength: pastedLength,
          }).toPromise().catch(() => {});
          break;
        case "essay":
          // 作文题:允许粘贴,不记录
          break;
      }
    };
    document.addEventListener("paste", handlePaste);
    return () => document.removeEventListener("paste", handlePaste);
  }, [record, examId, client]);

  // 全屏退出检测
  useEffect(() => {
    const handleFullscreen = () => {
      if (!document.fullscreenElement) {
        record("fullscreen-exit");
        toast.warning(t("exam.antiCheat.fullscreenExit"));
      }
    };
    document.addEventListener("fullscreenchange", handleFullscreen);
    return () => document.removeEventListener("fullscreenchange", handleFullscreen);
  }, [record]);

  // 窗口失焦检测
  useEffect(() => {
    const handleBlur = () => {
      record("window-blur");
    };
    window.addEventListener("blur", handleBlur);
    return () => window.removeEventListener("blur", handleBlur);
  }, [record]);

  // 右键禁用
  useEffect(() => {
    const handleContext = (e: MouseEvent) => {
      e.preventDefault();
    };
    document.addEventListener("contextmenu", handleContext);
    return () => document.removeEventListener("contextmenu", handleContext);
  }, []);

  // 卸载时 flush
  useEffect(() => {
    return () => {
      flushBehaviors();
    };
  }, [flushBehaviors]);

  return { warning, flushBehaviors };
}

14.6 考试提交防重复

// apps/student-portal/src/hooks/useSubmitExam.ts
export function useSubmitExam(examId: string) {
  const client = useGraphQLClient();
  const queryClient = useQueryClient();
  const [submitting, setSubmitting] = useState(false);

  const submit = useCallback(async () => {
    setSubmitting(true);
    try {
      // 提交前先查询提交状态,防重复
      const statusResult = await client
        .query(EXAM_SUBMISSION_STATUS, { examId })
        .toPromise();
      
      if (statusResult.data?.examSubmissionStatus?.submitted) {
        // 已提交,跳转结果页
        toast.info(t("exam.alreadySubmitted"));
        router.push(`/my-exams/${examId}/result`);
        return;
      }

      // 执行提交
      const result = await client
        .mutation(SUBMIT_EXAM, { examId })
        .toPromise();

      if (result.data?.submitExam) {
        // 提交成功,清除 IDB 草稿
        await clearDraftFromIDB(examId);
        queryClient.invalidateQueries({ queryKey: ["student", "exams"] });
        queryClient.invalidateQueries({ queryKey: ["student", "dashboard"] });
        toast.success(t("exam.submitSuccess"));
        router.push(`/my-exams/${examId}/result`);
      } else if (result.error) {
        // 提交失败,入 IDB 队列
        toast.error(t("exam.submitFailed"));
        // 入队,稍后重试
      }
    } catch (err) {
      // 网络错误,入 IDB 队列
      toast.error(t("exam.submitFailedNetwork"));
    } finally {
      setSubmitting(false);
    }
  }, [examId, client, queryClient]);

  return { submit, submitting };
}

15. 详细组件设计ai14 新增)

15.1 ExamTaking 组件

// apps/student-portal/src/components/ExamTaking.tsx
interface ExamTakingProps {
  examId: string;
}

export function ExamTaking({ examId }: ExamTakingProps) {
  const { draft, isOnline, serverTimeOffset, updateAnswer } = useExamTaking(examId);
  const { data: exam } = useExamDetail(examId);
  const { remaining, formatted, isExpired } = useCountdownTimer(
    exam?.expiresAt ?? "",
    serverTimeOffset,
  );
  const { warning } = useAntiCheat(examId);
  const { submit, submitting } = useSubmitExam(examId);
  const [showConfirmSubmit, setShowConfirmSubmit] = useState(false);

  // 倒计时归零自动提交
  useEffect(() => {
    if (isExpired && !submitting) {
      toast.info(t("exam.timeUp"));
      submit();
    }
  }, [isExpired, submitting, submit]);

  // 考试延时事件
  useEffect(() => {
    // 由 WebSocket 事件触发 exam query invalidateexam.expiresAt 更新后倒计时自动重置
  }, [exam?.expiresAt]);

  if (!draft || !exam) return <Skeleton rows={20} />;

  const unansweredCount = exam.questions.filter((q) => !draft.answers[q.id]).length;

  return (
    <div className="exam-taking-container" data-testid="exam-taking">
      {/* 顶部固定:倒计时 + 在线状态 */}
      <div className="sticky top-0 z-10 bg-background border-b">
        <div className="flex items-center justify-between p-4">
          <CountdownTimer
            remaining={remaining}
            formatted={formatted}
            isExpired={isExpired}
          />
          <OnlineStatus isOnline={isOnline} />
        </div>
        {warning && <div className="bg-warning/10 p-2 text-sm">{warning}</div>}
      </div>

      {/* 题目区域 */}
      <div className="p-4 space-y-6">
        {exam.questions.map((q, index) => (
          <QuestionCard
            key={q.id}
            question={q}
            index={index}
            answer={draft.answers[q.id]}
            onChange={(answer) => updateAnswer(q.id, answer)}
          />
        ))}
      </div>

      {/* 底部固定:提交按钮 */}
      <div className="sticky bottom-0 bg-background border-t p-4">
        <Button
          onClick={() => setShowConfirmSubmit(true)}
          disabled={submitting}
          className="w-full"
        >
          {t("exam.submit")}
        </Button>
      </div>

      {/* 提交确认弹窗 */}
      <Dialog open={showConfirmSubmit} onOpenChange={setShowConfirmSubmit}>
        <DialogContent>
          {unansweredCount > 0 && (
            <p className="text-warning">
              {t("exam.unansweredWarning", { count: unansweredCount })}
            </p>
          )}
          <p>{t("exam.confirmSubmit")}</p>
          <div className="flex gap-2 justify-end">
            <Button variant="outline" onClick={() => setShowConfirmSubmit(false)}>
              {t("common.cancel")}
            </Button>
            <Button onClick={submit} disabled={submitting}>
              {t("common.confirm")}
            </Button>
          </div>
        </DialogContent>
      </Dialog>

      {/* 草稿恢复弹窗由 useExamTaking 内部触发 */}
      <DraftRecovery examId={examId} />
    </div>
  );
}

15.2 CountdownTimer 组件

// apps/student-portal/src/components/CountdownTimer.tsx
interface CountdownTimerProps {
  remaining: number; // 秒
  formatted: string; // HH:mm:ss
  isExpired: boolean;
}

export function CountdownTimer({ remaining, formatted, isExpired }: CountdownTimerProps) {
  const isUrgent = remaining <= 300 && remaining > 0; // 最后 5 分钟
  const isCritical = remaining <= 60 && remaining > 0; // 最后 1 分钟

  return (
    <div
      className={cn(
        "font-mono text-2xl tabular-nums",
        isExpired && "text-destructive",
        isCritical && "text-destructive animate-pulse",
        isUrgent && "text-warning",
        !isUrgent && !isCritical && !isExpired && "text-foreground",
      )}
      role="timer"
      aria-live="polite"
      aria-label={t("exam.timeRemaining", { formatted })}
    >
      {isExpired ? t("exam.timeUp") : formatted}
    </div>
  );
}

15.3 DraftRecovery 组件

// apps/student-portal/src/components/DraftRecovery.tsx
interface DraftRecoveryProps {
  examId: string;
}

export function DraftRecovery({ examId }: DraftRecoveryProps) {
  const [open, setOpen] = useState(false);
  const [savedDraft, setSavedDraft] = useState<ExamTakingDraft | null>(null);

  useEffect(() => {
    loadDraftFromIDB(examId).then((draft) => {
      if (draft && Object.keys(draft.answers).length > 0) {
        setSavedDraft(draft);
        setOpen(true);
      }
    });
  }, [examId]);

  const handleRestore = () => {
    // 父组件通过 context 接收恢复的草稿
    setOpen(false);
  };

  const handleDiscard = async () => {
    await clearDraftFromIDB(examId);
    setOpen(false);
  };

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogContent>
        <h2>{t("exam.draftRecovery.title")}</h2>
        <p>
          {t("exam.draftRecovery.description", {
            answeredCount: savedDraft ? Object.keys(savedDraft.answers).length : 0,
            lastSavedAt: savedDraft?.lastSavedAt,
          })}
        </p>
        <div className="flex gap-2 justify-end">
          <Button variant="outline" onClick={handleDiscard}>
            {t("exam.draftRecovery.discard")}
          </Button>
          <Button onClick={handleRestore}>
            {t("exam.draftRecovery.restore")}
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

15.4 HomeworkSubmit 组件

// apps/student-portal/src/components/HomeworkSubmit.tsx
interface HomeworkSubmitProps {
  homeworkId: string;
}

const homeworkSchema = z.object({
  answers: z.array(
    z.object({
      questionId: z.string(),
      answer: answerInputSchema,
    }),
  ),
  note: z.string().max(500).optional(),
});

export function HomeworkSubmit({ homeworkId }: HomeworkSubmitProps) {
  const { data: homework } = useHomeworkDetail(homeworkId);
  const { mutate, isPending } = useSubmitHomework();
  const form = useForm<HomeworkSubmitForm>({
    resolver: zodResolver(homeworkSchema),
  });

  const onSubmit = (data: HomeworkSubmitForm) => {
    mutate({ homeworkId, ...data });
  };

  if (!homework) return <Skeleton rows={10} />;

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        {homework.questions.map((q, index) => (
          <QuestionField
            key={q.id}
            question={q}
            index={index}
            control={form.control}
            name={`answers.${index}.answer`}
          />
        ))}

        {/* 附件上传 */}
        <AttachmentUpload
          maxFiles={5}
          maxSize={10 * 1024 * 1024} // 10MB
          accept={["image/jpeg", "image/png", "application/pdf"]}
        />

        {/* 学生备注 */}
        <Textarea
          {...form.register("note")}
          placeholder={t("homework.notePlaceholder")}
          maxLength={500}
        />

        <Button type="submit" disabled={isPending} className="w-full sticky bottom-0">
          {isPending ? t("common.submitting") : t("homework.submit")}
        </Button>
      </form>
    </Form>
  );
}

15.5 DiagnosticChart 组件P4

// apps/student-portal/src/components/DiagnosticChart.tsx
interface DiagnosticChartProps {
  data: DiagnosticViewModel;
}

export function DiagnosticChart({ data }: DiagnosticChartProps) {
  return (
    <div className="space-y-6">
      {/* 多维雷达图:知识点掌握度 */}
      <Chart type="radar" data={data.radar} options={radarOptions} />

      {/* 趋势线:历次考试得分 */}
      <Chart type="line" data={data.trend} options={trendOptions} />

      {/* 知识点掌握度列表 */}
      <DataTable
        columns={[
          { key: "name", header: t("diagnostic.knowledgePoint") },
          {
            key: "mastery",
            header: t("diagnostic.mastery"),
            render: (row) => <MasteryBar value={row.mastery} />,
          },
          { key: "correctCount", header: t("diagnostic.correctRate") },
        ]}
        data={data.knowledgePoints}
      />
    </div>
  );
}

16. 跨标签同步实现ai14 新增)

16.1 考试作答防多标签BroadcastChannel

// apps/student-portal/src/lib/examTabGuard.ts
const EXAM_CHANNEL_NAME = "student-exam-guard";

export function useExamTabGuard(examId: string) {
  useEffect(() => {
    // 降级Safari < 15.4 不支持 BroadcastChannel
    if (typeof BroadcastChannel === "undefined") {
      // 降级为 localStorage 事件
      return useLocalStorageFallback(examId);
    }

    const channel = new BroadcastChannel(EXAM_CHANNEL_NAME);
    const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;

    // 广播:我正在作答此考试
    channel.postMessage({ type: "exam-entered", examId, tabId, ts: Date.now() });

    const onMessage = (event: MessageEvent) => {
      const msg = event.data;
      if (msg.tabId === tabId) return;
      if (msg.type === "exam-entered" && msg.examId === examId) {
        // 已有其他标签在作答,警告
        toast.warning(t("exam.multiTabDetected"));
        recordSuspiciousBehavior("multi-tab-exam");
        // 回应:我也在作答
        channel.postMessage({ type: "exam-exists", examId, tabId, ts: Date.now() });
      }
      if (msg.type === "exam-exists" && msg.examId === examId) {
        // 收到其他标签的回应,说明我也不是第一个
        toast.warning(t("exam.multiTabDetected"));
        recordSuspiciousBehavior("multi-tab-exam");
      }
    };
    channel.addEventListener("message", onMessage);

    return () => {
      channel.postMessage({ type: "exam-left", examId, tabId });
      channel.close();
    };
  }, [examId]);
}

// localStorage 降级方案
function useLocalStorageFallback(examId: string) {
  useEffect(() => {
    const key = `exam-active-${examId}`;
    const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    localStorage.setItem(key, JSON.stringify({ tabId, ts: Date.now() }));

    const onStorage = (e: StorageEvent) => {
      if (e.key === key && e.newValue) {
        const otherTab = JSON.parse(e.newValue);
        if (otherTab.tabId !== tabId) {
          toast.warning(t("exam.multiTabDetected"));
          recordSuspiciousBehavior("multi-tab-exam");
        }
      }
    };
    window.addEventListener("storage", onStorage);

    return () => {
      localStorage.removeItem(key);
      window.removeEventListener("storage", onStorage);
    };
  }, [examId]);
}

16.2 通知未读数跨标签同步

// apps/student-portal/src/lib/notificationSync.ts
const NOTIFICATION_CHANNEL = "student-notifications";

export function useNotificationSync() {
  const queryClient = useQueryClient();

  useEffect(() => {
    if (typeof BroadcastChannel === "undefined") return;

    const channel = new BroadcastChannel(NOTIFICATION_CHANNEL);
    const onMessage = (event: MessageEvent) => {
      if (event.data.type === "unread-count-updated") {
        queryClient.invalidateQueries({ queryKey: ["student", "notifications"] });
      }
    };
    channel.addEventListener("message", onMessage);

    return () => channel.close();
  }, [queryClient]);

  const broadcastUnreadUpdate = useCallback(() => {
    if (typeof BroadcastChannel !== "undefined") {
      const channel = new BroadcastChannel(NOTIFICATION_CHANNEL);
      channel.postMessage({ type: "unread-count-updated", ts: Date.now() });
      channel.close();
    }
  }, []);

  return { broadcastUnreadUpdate };
}

16.3 考试草稿跨标签恢复

若学生在 Tab A 开始作答,关闭 Tab A 后在 Tab B 重新打开:

  1. IDB 草稿跨标签共享(同源策略保证)
  2. Tab B 进入考试页时检测 IDB 草稿
  3. DraftRecovery 提示"检测到未完成的作答草稿,是否恢复?"
  4. 用户确认后从 IDB 加载草稿
// 在 ExamTaking 组件内
useEffect(() => {
  loadDraftFromIDB(examId).then((saved) => {
    if (saved && Object.keys(saved.answers).length > 0) {
      // 检测到草稿,触发恢复弹窗
      setPendingDraft(saved);
    }
  });
}, [examId]);

17. API 契约版本管理ai14 新增)

17.1 版本兼容矩阵

API 版本 引入阶段 弃用阶段 移除阶段 student-portal 兼容性
v1 P3 - - P3+ 必须支持
v2 P6+ - - ⚠️ P6+ 通过 Feature Flag 切换
字段裁剪 P5+ - - ⚠️ 可选,移动端低带宽时启用

17.2 GraphQL schema 演进策略

变更类型 策略 前端处理
新增字段 非破坏性,直接新增 前端忽略未知字段Zod 默认行为)
废弃字段 @deprecated 标注 前端监控使用率,< 1% 后移除调用
新增 Query/Mutation 非破坏性 前端按需消费
废弃 Query/Mutation @deprecated → 1 个迭代周期后移除 前端迁移到替代操作
破坏性字段变更 新增字段 + 废弃旧字段 → 1 个迭代周期后移除旧字段 前端 Feature Flag 切换
Enum 值新增 非破坏性 前端 default 分支兜底

17.3 urql client 版本协商

// urql client 自动处理 GraphQL schema 演进
// 通过 @deprecated 标注 + schema registry 监控
const client = createClient({
  url: "/api/v1/student/graphql",
  exchanges: [
    dedupExchange,
    cacheExchange,
    authExchange,
    errorExchange({
      onError: (error) => {
        // 检测 deprecation 警告
        const deprecationWarnings = error.graphQLErrors?.filter(
          (e) => e.extensions?.code === "DEPRECATED_FIELD",
        );
        if (deprecationWarnings?.length) {
          logger.warn("GraphQL deprecated field used", {
            fields: deprecationWarnings.map((w) => w.path),
          });
          // 上报埋点,跟踪使用率
          metrics.increment("graphql.deprecated_field_used", {
            fields: deprecationWarnings.map((w) => w.path?.join(".")),
          });
        }
      },
    }),
    fetchExchange,
  ],
});

17.4 字段裁剪使用场景P5+

场景 裁剪策略 节省带宽
移动端 4G 查看考试列表 myExams { id name startsAt expiresAt status } ~60%
通知列表快速浏览 myNotifications { id title read createdAt } ~50%
Dashboard 概览 studentDashboard { upcomingHomework { id title dueAt } } ~40%
完整考试详情(默认) 不裁剪 0%

18. 监控与降级ai14 新增)

18.1 前端监控指标

指标 类型 采集方式 告警阈值
student_portal_lcp_seconds LCP next/web-vitals P95 > 2.5s
student_portal_cls CLS 同上 P95 > 0.1
student_portal_ttfb_seconds TTFB 同上 P95 > 0.8s
student_portal_mf_load_failed Counter MF Remote 加载错误捕获 > 1%
student_portal_api_error_rate Counter urql errorExchange 拦截 5xx > 1%
student_portal_ws_reconnect Counter WebSocket 重连计数 单用户 > 5 次/小时
student_portal_exam_save_failed Counter 考试自动保存失败计数 > 5%
student_portal_exam_submit_failed Counter 考试提交失败计数 > 1%
student_portal_offline_duration Histogram 网络中断时长 P95 > 30s
student_portal_draft_recovery_used Counter 草稿恢复使用次数 异常增长告警
student_portal_anti_cheat_triggered Counter 防作弊触发次数 单考试 > 10 次
student_portal_cache_hit_rate Gauge urql cache 命中率 < 60%(异常)
student_portal_a11y_violations Counter axe-core 自动扫描 严重违规 > 0

18.2 降级策略矩阵

触发条件 降级动作 用户感知
MF Remote 加载失败10s 超时) 显示 Shell 内置的最小化静态引导页 "学生端加载失败,请稍后重试"
BFF 5xx 错误率 > 5% 隐藏 mutation 按钮(提交作业/提交考试);展示只读模式 banner 顶部黄色 banner"只读模式"
BFF P95 延迟 > 5s 缩短缓存 TTL 至 5s展示"加载缓慢"提示 顶部提示条
WebSocket 连接失败 5 次 降级为 HTTP 轮询60s 拉取通知列表) 通知延迟最多 60s
考试列表加载失败 3 次 显示错误页 + 重试按钮 错误页
考试作答自动保存失败 入 IDB 队列UI 显示"离线模式"标识 顶部黄色标识
考试提交失败 自动重试 + 入 IDB 队列3 次失败后人工介入提示 "提交失败,正在重试"
i18n message 加载失败 Fallback 到 key 本身作为文案 显示英文 key不影响功能
设计令牌加载失败 Fallback 到 Tailwind 默认色板 视觉风格降级,不影响功能
BroadcastChannel 不支持Safari < 15.4 降级为 storage 事件 跨 tab 同步延迟 ~500ms
IDB 存储空间不足 清理 7 天前的未提交草稿;提示用户 toast 提示
服务器时间同步失败 使用客户端时间(偏差可能较大);提示"时间同步失败" 顶部警告条

18.3 错误恢复流程

flowchart TD
    Error[发生错误] --> Classify{错误类型}
    Classify -->|Network| Retry[指数退避重试 3 次]
    Classify -->|401 Unauthorized| Refresh[刷新 token]
    Classify -->|403 Forbidden| Toast[toast 提示无权限]
    Classify -->|404 Not Found| Empty[显示空态]
    Classify -->|5xx Server Error| Fallback[降级策略]
    Classify -->|MF Load Failed| ShellFallback[Shell 兜底页]
    Classify -->|GraphQL Partial| PartialRender[展示部分数据 + 降级标识]

    Retry -->|成功| Recover[恢复正常]
    Retry -->|失败| Fallback

    Refresh -->|成功| Recover
    Refresh -->|失败| Logout[跳转登录页]

    Fallback --> ReadOnlyMode[只读模式]
    Fallback --> CachedData[展示缓存数据]
    Fallback --> ErrorPage[错误页 + 重试]
    Fallback --> IDBQueue[入 IDB 队列等待恢复]

    Toast --> Continue[继续当前操作]
    Empty --> Continue
    ShellFallback --> RetryMF[10s 后重试 MF]
    ReadOnlyMode --> WaitForRecover[等待 BFF 恢复]
    WaitForRecover --> Recover
    IDBQueue --> Recover

18.4 GraphQL 降级模式ARB-001 §3.4 方案 B

student-bff 支持降级模式ARB-001部分上游服务不可用时返回部分数据 + extensions.degraded: true

// urql errorExchange 处理降级标记
errorExchange({
  onError: (error) => {
    const degradedErrors = error.graphQLErrors?.filter(
      (e) => e.extensions?.degraded,
    );
    if (degradedErrors?.length) {
      // 展示部分数据 + 降级标识
      toast.warning(t("common.degradedMode"));
      // UI 顶部显示"部分数据可能不是最新"
    }
  },
}),

19. 模块演化与解耦ai14 新增)

19.1 拆分触发条件

触发条件 拆分方向 阶段
student-portal bundle > 200KBgzipped 按场景域拆分为 student-core-remote + student-exam-remote P7+
团队规模 > 5 人同时维护 student-portal 同上 P7+
考试作答功能复杂度提升lockdown/AI 监考) 拆出 student-exam-remote(考试作答专用) P7+
AI 辅导功能引入 拆出 student-ai-remoteAI 辅导 + 个性化推荐) P7+
多租户支持 URL 前缀 /{tenantId}/student/*TanStack Query key 加 tenantId P8+

19.2 拆分后的 MF 配置

// 拆分后student-core-remote核心场景dashboard/homework/grades
new NextFederationPlugin({
  name: "student_core_app",
  filename: "static/chunks/remoteEntry.js",
  exposes: {
    "./pages": "./src/pages", // dashboard/my-homework/my-grades/my-attendance
  },
  // ... shared 同原配置
});

// 拆分后student-exam-remote考试作答专用
new NextFederationPlugin({
  name: "student_exam_app",
  filename: "static/chunks/remoteEntry.js",
  exposes: {
    "./pages": "./src/pages", // my-exams/take/result
    "./ExamTaking": "./src/components/ExamTaking",
  },
  // ... shared 同原配置
});

19.3 技术栈演化路径

演化方向 触发条件 迁移策略
student-portal 拆分为多个 Remote bundle > 200KB 或团队规模 > 5 人 按场景域拆分student-core-remote + student-exam-remote
MF 2.0 → 3.0 升级 MF 3.0 稳定且解决 SSR 问题 Shell 端 @module-federation/nextjs-mf 升级student-portal 仅改 name/filename 字段
切换为原生 SSR脱离 MF SEO 需求强烈或 MF 维护成本过高 保留 GraphQL 请求层和组件库;移除 MF 配置;独立部署为完整 Next.js 应用
状态管理迁移Zustand → Jotai Zustand 性能瓶颈或团队偏好 逐 slice 迁移Hook 接口保持不变
urql → Apollo Client urql 功能不足 替换 GraphQLProvider业务 Hook 接口保持不变
React 18 → 19 React 19 稳定 跟随 Shell 升级use hook / Suspense 改进可简化 TanStack Query 集成

20. 长远架构愿景ai14 新增)

20.1 三年架构演进路线

graph LR
    Y1[Year 1<br/>P3-P6 单体 Remote<br/>MF 2.0 + GraphQL] --> Y2[Year 2<br/>P7+ 多 Remote 拆分<br/>AI 辅导+错题推荐+学习计划]
    Y2 --> Y3[Year 3<br/>P8+ 多租户+多区域<br/>MF 3.0 / 原生 SSR / 移动端原生壳]

20.2 架构原则(始终不变)

  1. Remote 角色不变student-portal 始终作为 teacher-portal Shell 的 Remote不独立 Shell
  2. 契约先行:所有 API 变更先 proto/contracts后实现
  3. 无状态前端:所有持久化状态走 localStorage/IDB不依赖服务端 session
  4. 复用优先:组件/Hook/工具优先复用 Shell 暴露的,避免重复实现
  5. 可降级任何依赖BFF/WebSocket/Shell故障都有降级方案
  6. 可观测:所有用户行为、错误、性能指标都可观测
  7. 可测试:所有组件/Hook/页面都有单测 + E2E 覆盖
  8. 可演化:模块可拆分、状态管理可迁移、框架可升级
  9. 考试作答可靠性:断网不丢草稿、时间对齐服务器、防作弊采集、防重复提交
  10. 学生隐私保护PIPL/未成年人保护法合规,敏感数据最小化

20.3 与其他 portal 的协同演化

演化方向 student-portal 角色 协同端
共享组件抽取到 packages/ 消费方(不再在 Remote 内自建组件) teacher-portalShell+ 其他 Remote
共享 Hook 抽取到 packages/hooks/ 消费方 同上
共享业务模型抽取到 packages/contracts/ 消费方(类型定义) 同上
多 portal 间通信(如教师↔学生) 通过后端 BFF/gRPC不直连其他 Remote teacher-portal / parent-portal
跨 portal 路由跳转 通过 Shell 统一路由表 所有 portal

20.4 与后端架构的协同演化

后端演化 student-portal 适配
BFF GraphQL schema 演进 @deprecated 标注 + Feature Flag 切换
BFF → BFF + 微前端 API Gateway student-portal 不感知urql client URL 不变
引入 Service MeshIstio student-portal 不感知;网络层透明
引入 Feature Flag 服务 集成 FeatureFlagProvider组件按 flag 渲染
引入 A/B Testing 平台 集成 ABTestProviderUI 按 variant 渲染
后端多租户 URL 加 /{tenantId}/student/*Query key 加维度
后端多区域 urql client 按 region 路由CDN 边缘缓存
AI 辅导服务P7 新增 SSE 流式消费;复用 teacher-portal AI 模式

20.5 关键技术债务预警

债务项 当前状态 紧急度 清理建议
ai07 初稿协议错误REST vs GraphQL 已修订 v2 全面对齐 ARB-001
ai07 初稿端口错误3001 vs 4001 已修订 v2 对齐 port-allocation §4
ai07 初稿所有权错误ai07 vs ai14 已修订 004 §15 文档矩阵已记录 ai14 = student-portal
MF SSR 对齐 待验证 P3 启动前 PoC 验证 MF 2.0 SSR若失败降级 CSR
GraphQL singleton 分裂风险 待验证 P3 启动前验证 ARB-002 singleton 生效
shared-ts / contracts 未建立 待 coord P3 启动前必须建立;否则 ai14 自行实现后续提取
ui-tokens / ui-components / hooks 未建立 待 ai13 P3 启动前必须建立ai13 在 P2 收尾交付
MF Remote 加载失败的兜底 待实现 P3 实现期落地 Shell 内置兜底页
跨 tab 同步在 Safari 的兼容性 待验证 P6 硬化期验证;降级为 storage 事件
考试作答 IDB 存储空间管理 待实现 P3 实现期落地定期清理逻辑
WebSocket 事件名已裁决 已裁决 ARB-019 §21.6 确认 ExamExtended/ExamForceSubmitted

AI Agent: ai14 (student-portal remote) Branch: feat-review-student-portal-docs-9yN6Av Coordinator: coord-ai Predecessor: ai07初版起草ai14 接管审计与补全)