From d1db3dfdee817be8a30c7693ad310b45159d312a Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:56:36 +0800 Subject: [PATCH] =?UTF-8?q?docs(student-portal):=20=E8=90=BD=E5=AE=9E=20AR?= =?UTF-8?q?B-019=20=E5=85=A8=E9=83=A8=E6=89=A7=E8=A1=8C=E9=A1=B9=20+=20?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E7=AD=96=E7=95=A5=E5=88=86=E5=B1=82=20+=20Ex?= =?UTF-8?q?amForceSubmitted=20+=20mutation=E5=91=BD=E5=90=8D=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20+=20=E5=B7=A5=E4=BD=9C=E8=BF=9B=E5=BA=A6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/02-architecture-design.md | 116 ++++++++++++------ .../worklines/student-portal_workline.md | 41 ++++++- 2 files changed, 118 insertions(+), 39 deletions(-) diff --git a/apps/student-portal/docs/02-architecture-design.md b/apps/student-portal/docs/02-architecture-design.md index 2477aa2..346624e 100644 --- a/apps/student-portal/docs/02-architecture-design.md +++ b/apps/student-portal/docs/02-architecture-design.md @@ -211,7 +211,7 @@ type AnswerInput = // 可疑行为记录(防作弊,前端仅记录,服务端最终判定) interface SuspiciousBehaviorRecord { - type: "tab-switch" | "copy-paste" | "fullscreen-exit" | "multi-tab" | "window-blur"; + type: "tab-switch" | "copy" | "paste" | "paste-essay" | "fullscreen-exit" | "multi-tab" | "window-blur"; timestamp: number; duration?: number; // 持续时长(ms) details?: string; @@ -353,9 +353,9 @@ const EXAM_DETAIL = gql` } `; -const SAVE_EXAM_ANSWER = gql` - mutation SaveExamAnswer($examId: ID!, $questionId: ID!, $answer: AnswerInput!) { - saveExamAnswer(examId: $examId, questionId: $questionId, answer: $answer) { +const SAVE_EXAM_DRAFT = gql` + mutation SaveExamDraft($examId: ID!, $questionId: ID!, $answer: AnswerInput!) { + saveExamDraft(examId: $examId, questionId: $questionId, answer: $answer) { savedAt ok } @@ -401,15 +401,15 @@ export function useExamDetail(examId: string) { | query | `examResult` | 考试结果 | P3 | | query | `myHomework` | 我的作业列表 | P3 | | query | `homeworkDetail` | 作业详情 | P3 | -| mutation | `submitHomework` | 提交作业 | P3 | | query | `myGrades` | 我的成绩列表 | P3 | | query | `myAttendance` | 我的考勤 | P3 | | query | `studentDashboard` | 学生仪表盘聚合 | P3 | | query | `serverTime` | 服务器时间(倒计时校正) | P3 | -| mutation | `saveExamAnswer` | 保存考试答案(自动保存) | P3 | +| mutation | `submitHomework` | 提交作业(含 attachmentUrls) | P3 | +| mutation | `saveExamDraft` | 保存考试草稿(自动保存) | P3 | | mutation | `submitExam` | 提交考试 | P3 | -| mutation | `uploadAttachment` | 上传附件 | P3 | -| mutation | `recordExamSuspiciousBehavior` | 记录可疑行为 | P3 | +| mutation | `recordExamViolation` | 记录防作弊违规(ARB-019 §21.4) | P3 | +| mutation | `recordPasteEvent` | 记录粘贴事件(ARB-019 §21.4) | P3 | | query | `textbooks` | 教材列表 | P4 | | query | `chapters` | 章节列表 | P4 | | query | `learningPath` | 学习路径 | P4 | @@ -417,6 +417,10 @@ export function useExamDetail(examId: string) { | 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/upload`(multipart/form-data)→ 对象存储 → 返回 signed URL → GraphQL `submitHomework(input: { attachmentUrls })` 提交 URL。不使用 GraphQL multipart mutation。 ### 4.3 Query Key 命名约定 @@ -447,8 +451,9 @@ queryKey: ["session", "viewports", "student"]; // 复用 Shell | ----------------------------- | -------------- | ------------------------------------------------------------------------------------ | | `NotificationRequested` | msg 服务投递 | toast 提示 + 通知中心未读数 +1 + `["student","notifications"]` invalidate | | `ExamPublished` | 教师发布考试 | toast + `["student","exams"]` invalidate + `["student","dashboard"]` invalidate | -| `ExamExtended` | 教师延长考试 | 重新拉取 `exam` query → 更新 `expiresAt` → 重置倒计时(待 ai10 msg 确认事件名) | -| `ExamQuestionReordered` | 教师调整题目顺序 | 重新拉取 `exam` query → 草稿按 questionId 映射(不依赖序号)(待 ai10 确认) | +| `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 | @@ -517,6 +522,13 @@ function handlePushMessage(msg: PushMessage, queryClient: QueryClient) { 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; @@ -539,7 +551,8 @@ function handlePushMessage(msg: PushMessage, queryClient: QueryClient) { | ------------------- | --------------------------------- | --------------------------------------------------- | | `visibilitychange` | 切屏检测 | 记录切换次数 + 时长;超阈值警告;上报服务端 | | `blur` / `focus` | 窗口失焦检测 | 同上 | -| `copy` / `paste` | 复制粘贴检测 | 阻止默认行为 + 警告;主观题允许粘贴自己输入(待确认)| +| `copy` | 复制检测 | 全局阻止 + 警告 + 记录 | +| `paste` | 粘贴检测(按题型分层) | 客观题阻止 + 警告;主观题允许 + 记录长度;作文题允许不记录(ARB-019 §21.4) | | `fullscreenchange` | 全屏退出检测 | 警告 + 记录 | | `contextmenu` | 右键禁用 | 考试作答页 `preventDefault()` | @@ -679,7 +692,7 @@ Next.js 无长连接(除 WS),无需特殊处理。WS 在 P5 由 push-gatew | `useMyExams(status?)` | 我的考试列表 | | `useExamDetail(examId)` | 考试详情(含题目,禁缓存) | | `useExamTaking(examId)` | 考试作答状态(草稿 + 倒计时 + 自动保存 + 断网队列) | -| `useSaveExamAnswer()` | 保存答案 mutation(自动保存) | +| `useSaveExamDraft()` | 保存草稿 mutation(自动保存) | | `useSubmitExam()` | 提交考试 mutation | | `useMyHomework(status?)` | 我的作业列表 | | `useSubmitHomework()` | 提交作业 mutation | @@ -739,7 +752,7 @@ student-portal 的 ESLint flat config 与 Shell 共用,确保规则一致。 3. **假设 ai04 student-bff 提供 GraphQL endpoint `POST /graphql`**:含 §4.2 全部 query/mutation。错误码前缀 `BFF_STUDENT_`(ARB-001 已确认)。 4. **假设 teacher-portal Shell 已就绪**:AppShell + 共享依赖暴露 + MF 配置 + GraphQLProvider(P2 收尾完成)。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`/`ExamQuestionReordered` 事件名待 ai10 确认(见 issues/contracts)。 +6. **假设 ai10 msg 提供 WebSocket 事件**:`ExamExtended`/`ExamForceSubmitted`/`ExamQuestionReordered` 事件名已由 ARB-019 §21.6 裁决确认(见 issues/contracts)。 ### 11.2 技术风险 @@ -764,10 +777,10 @@ student-portal 的 ESLint flat config 与 Shell 共用,确保规则一致。 | GraphQL vs REST | **GraphQL**(ARB-001 已裁决) | ✅ 已裁决 | | MF Shell 暴露清单 | **GraphQLProvider + urql singleton** | ✅ 已裁决(ARB-002)| | packages 归属 | ai13 维护 ui-*/hooks,coord 维护 shared-ts/contracts | ✅ 已确认 | -| 考试作答防作弊事件名 | `ExamExtended`/`ExamQuestionReordered`| ⚠️ 待 ai10 msg 确认 | -| 附件上传协议 | GraphQL mutation `uploadAttachment` | ⚠️ 待 ai04 确认是否走 multipart | -| 主观题是否允许粘贴 | 阻止粘贴(待产品确认) | ⚠️ 待产品决策 | -| 考试作答页是否强制全屏 | 不强制(P6+ lockdown 浏览器才强制) | ⚠️ 待产品决策 | +| 考试作答防作弊事件名 | `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 交叉审查所需信息 @@ -798,7 +811,7 @@ student-portal 的 ESLint flat config 与 Shell 共用,确保规则一致。 | `/exams/*` `/homework/*` `/grades/*`(gRPC,经 BFF) | core-edu | ✅ 已实现(P3) | | `/analytics/*`(gRPC,经 BFF) | data-ana | ✅ 已实现(P4) | | `/notifications/*` + WebSocket 推送 | msg + push-gateway | 📐 待 P5 | -| WebSocket 事件 `ExamExtended`/`ExamQuestionReordered` | msg | ⚠️ 待 ai10 确认 | +| WebSocket 事件 `ExamExtended`/`ExamForceSubmitted` | msg + push-gateway | ✅ 已裁决(ARB-019 §21.6) | ### 12.4 错误码前缀(前端 i18n 路由依赖) @@ -1092,13 +1105,13 @@ export function useExamTaking(examId: string) { } try { const result = await client - .mutation(SAVE_EXAM_ANSWER, { + .mutation(SAVE_EXAM_DRAFT, { examId, questionId: "batch", // 批量保存 answer: draft.answers, }) .toPromise(); - if (result.data?.saveExamAnswer?.ok) { + if (result.data?.saveExamDraft?.ok) { setDraft((prev) => ({ ...prev!, lastSavedAt: Date.now() })); await saveDraftToIDB(draft); // 同步到 IDB } @@ -1139,13 +1152,13 @@ export function useExamTaking(examId: string) { useOfflineQueue_recovery(async (item) => { try { const result = await client - .mutation(SAVE_EXAM_ANSWER, { + .mutation(SAVE_EXAM_DRAFT, { examId: item.examId, questionId: item.questionId, answer: item.answer, }) .toPromise(); - return result.data?.saveExamAnswer?.ok ?? false; + return result.data?.saveExamDraft?.ok ?? false; } catch { return false; } @@ -1202,7 +1215,7 @@ export function useAntiCheat(examId: string) { const batch = behaviorsRef.current.splice(0); try { await client - .mutation(RECORD_SUSPICIOUS_BEHAVIOR, { + .mutation(RECORD_EXAM_VIOLATION, { examId, behaviors: batch, }) @@ -1230,26 +1243,57 @@ export function useAntiCheat(examId: string) { return () => document.removeEventListener("visibilitychange", handleVisibility); }, [record]); - // 复制粘贴检测 + // 复制检测(全局阻止) useEffect(() => { const handleCopy = (e: ClipboardEvent) => { e.preventDefault(); - record("copy-paste", "copy attempted"); + record("copy", "copy attempted"); toast.warning(t("exam.antiCheat.copyDisabled")); }; - const handlePaste = (e: ClipboardEvent) => { - e.preventDefault(); - record("copy-paste", "paste attempted"); - toast.warning(t("exam.antiCheat.pasteDisabled")); - }; document.addEventListener("copy", handleCopy); - document.addEventListener("paste", handlePaste); - return () => { - document.removeEventListener("copy", handleCopy); - document.removeEventListener("paste", handlePaste); - }; + 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 = () => { @@ -2039,7 +2083,7 @@ graph LR | MF Remote 加载失败的兜底 | 待实现 | 中 | P3 实现期落地 Shell 内置兜底页 | | 跨 tab 同步在 Safari 的兼容性 | 待验证 | 低 | P6 硬化期验证;降级为 storage 事件 | | 考试作答 IDB 存储空间管理 | 待实现 | 中 | P3 实现期落地定期清理逻辑 | -| WebSocket 事件名待 ai10 确认 | 待 ai10 | 中 | P5 启动前确认 `ExamExtended`/`ExamQuestionReordered` | +| WebSocket 事件名已裁决 | 已裁决 | 中 | ✅ ARB-019 §21.6 确认 `ExamExtended`/`ExamForceSubmitted` | --- diff --git a/docs/architecture/issues/worklines/student-portal_workline.md b/docs/architecture/issues/worklines/student-portal_workline.md index be54908..831bc4f 100644 --- a/docs/architecture/issues/worklines/student-portal_workline.md +++ b/docs/architecture/issues/worklines/student-portal_workline.md @@ -260,9 +260,44 @@ gantt | student-bff GraphQL schema 未就绪(ai04 P3 延迟) | 高 | MSW mock 全量 query/mutation,schema 就绪后切换;ai14 自行维护 mock schema 用于 codegen | | 考试作答断网恢复逻辑复杂(IDB 队列 + 服务器时间对齐)| 高 | 02 §14 已设计完整状态机 + 时间同步算法;P3 优先实现核心链路,P6 验证降级场景 | | MF Remote 加载失败(Shell 未就绪或版本不兼容) | 中 | 02 §3.2 已设计独立壳回退(`NEXT_PUBLIC_MF_ENABLED=false` 时独立渲染) | -| 防作弊策略未仲裁(ISSUE-014-03/04) | 中 | P3 先实现采集 + 本地记录,P6 根据仲裁结果补全上报逻辑 | -| 附件上传协议未仲裁(ISSUE-014-05) | 中 | P3 先实现文本作业提交,附件上传 P6 根据仲裁结果补全 | -| 实时事件命名未确认(ISSUE-014-06) | 低 | P3 不依赖实时事件(考试作答页基于本地倒计时),P5 根据仲裁结果接入 WebSocket 实时事件 | +| 防作弊策略未仲裁(ISSUE-014-03/04) | 中 | ✅ 已裁决(ARB-019 §21.4);02 §14.5 已补全全屏策略 + visibilitychange + 多标签 + 粘贴题型分层 | +| 附件上传协议未仲裁(ISSUE-014-05) | 中 | ✅ 已裁决(ARB-019 §21.5 方案 A);02 §4.2 已更新为 REST upload + GraphQL 提交 URL | +| 实时事件命名未确认(ISSUE-014-06) | 低 | ✅ 已裁决(ARB-019 §21.6);02 §5.1/§5.2 已补全 ExamExtended + ExamForceSubmitted | + +--- + +## §8 工作进度跟踪 + +### §8.1 ARB-019 §21.8 ai14 执行项落实情况(2026-07-11 审计) + +| # | 执行项 | ISSUE | 状态 | 说明 | +|---|--------|-------|------|------| +| 1 | contract.md §2.3 路径统一为 `POST /api/v1/student/graphql` | 014-01 | ✅ 已落实 | contract.md v2 已包含 | +| 2 | 02 §4.2 GraphQL query 移除所有 `studentId` 参数 | 014-07 | ✅ 已落实 | 全部 query 无 studentId 参数 | +| 3 | 02 §14 防作弊策略补全(全屏 + visibilitychange + 多标签 + 事件上报) | 014-03 | ✅ 已落实 | §14.5 已补全;mutation 名对齐为 `recordExamViolation` | +| 4 | 02 §14 粘贴策略题型分层(客观题禁止/主观题允许记录/作文题允许不记录) | 014-04 | ✅ 已落实 | §14.5 已补全分层策略 + `recordPasteEvent` mutation | +| 5 | 02 §5.1 实时事件补 ExamExtended + ExamForceSubmitted 命名 | 014-06 | ✅ 已落实 | §5.1 事件表 + §5.2 handlePushMessage 已补全 | +| 6 | 02 §4.2 mutation 域确认 recordExamViolation + recordPasteEvent 参数 | 014-03/04 | ✅ 已落实 | §4.2 已对齐 contract.md,补全 markAllAsRead + updateNotificationPreference | + +### §8.2 额外修复项(2026-07-11) + +| # | 修复项 | 说明 | +|---|--------|------| +| 1 | `SAVE_EXAM_ANSWER` → `SAVE_EXAM_DRAFT` | 对齐 contract.md §2.4.2 mutation 命名 | +| 2 | `RECORD_SUSPICIOUS_BEHAVIOR` → `RECORD_EXAM_VIOLATION` | 对齐 contract.md §2.4.2 mutation 命名 | +| 3 | `uploadAttachment` mutation 移除 | 改为 REST 端点(ARB-019 §21.5 方案 A) | +| 4 | `SuspiciousBehaviorRecord.type` 更新 | `copy-paste` 拆分为 `copy`/`paste`/`paste-essay` | +| 5 | §11.3 未决设计决策表更新 | 4 项 ⚠️ → ✅ 已裁决 | +| 6 | §12.3 契约表更新 | WebSocket 事件名已裁决 | +| 7 | §11.1 假设 6 更新 | 事件名已由 ARB-019 §21.6 裁决确认 | +| 8 | §20.5 技术债务表更新 | WebSocket 事件名已裁决 | + +### §8.3 当前阶段状态 + +- **文档阶段**:✅ 完成(01-understanding + 02-architecture-design + contract + workline + issue 全部对齐 ARB-019 裁决) +- **代码实现阶段**:✅ 已完成(通过 `606acb6` 合并到 main,包含 P2-P6 全部代码) +- **集成测试阶段**:✅ 已完成(通过 `61d8249` + `a6b0c54` 修复全部集成测试 bug) +- **待办**:无未完成的架构工作项 ---