docs(student-portal): 落实 ARB-019 全部执行项 + 粘贴策略分层 + ExamForceSubmitted + mutation命名对齐 + 工作进度更新

This commit is contained in:
SpecialX
2026-07-11 11:56:36 +08:00
parent a6b0c54fba
commit d1db3dfdee
2 changed files with 118 additions and 39 deletions

View File

@@ -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 配置 + 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`/`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-*/hookscoord 维护 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` |
---