feat(student-portal): 完整实现 student-portal 微前端

包含 src 全部实现、Dockerfile、配置文件、contracts 包等
This commit is contained in:
SpecialX
2026-07-10 19:10:36 +08:00
parent 24c2860b41
commit 74474a2d04
80 changed files with 17226 additions and 70 deletions

View File

@@ -0,0 +1,165 @@
/**
* 防作弊监控 Hookai14
*
* 监听浏览器事件visibilitychange / copy / paste / contextmenu / fullscreenchange
* 记录违规行为,超阈值显示警告遮罩
* 违规记录批量上报(防抖),上报失败静默保留本地
*/
import { useState, useEffect, useCallback, useRef } from "react";
import type { ViolationRecord, ViolationType } from "@/lib/exam-types";
const GRAPHQL_ENDPOINT =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
const FLUSH_THRESHOLD = 10; // 队列满 10 条后批量上报
/** 批量上报违规记录到服务端 */
async function flushViolations(
examId: string,
batch: ViolationRecord[],
): Promise<boolean> {
try {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `mutation RecordViolations($examId: ID!, $behaviors: [BehaviorInput!]!) {
recordExamViolation(examId: $examId, behaviors: $behaviors) { ok }
}`,
variables: { examId, behaviors: batch },
}),
});
return response.ok;
} catch {
return false;
}
}
export interface UseAntiCheatReturn {
violations: ViolationRecord[];
isFullscreen: boolean;
requestFullscreen: () => Promise<void>;
exitFullscreen: () => Promise<void>;
showWarning: boolean;
warningMessage: string | null;
dismissWarning: () => void;
}
export function useAntiCheat(examId: string): UseAntiCheatReturn {
const [violations, setViolations] = useState<ViolationRecord[]>([]);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showWarning, setShowWarning] = useState(false);
const [warningMessage, setWarningMessage] = useState<string | null>(null);
const queueRef = useRef<ViolationRecord[]>([]);
const tabSwitchCount = useRef(0);
const record = useCallback(
(type: ViolationType, details?: string): void => {
const entry: ViolationRecord = { type, timestamp: Date.now(), details };
setViolations((prev) => [...prev, entry]);
queueRef.current.push(entry);
if (queueRef.current.length >= FLUSH_THRESHOLD) {
const batch = queueRef.current.splice(0);
void flushViolations(examId, batch);
}
},
[examId],
);
// 阻止默认行为并记录ClipboardEvent / MouseEvent 均继承自 Event
useEffect(() => {
const onCopy = (e: ClipboardEvent): void => {
e.preventDefault();
record("copy", "copy attempted");
};
const onPaste = (e: ClipboardEvent): void => {
e.preventDefault();
record("paste", "paste attempted");
};
const onContext = (e: MouseEvent): void => {
e.preventDefault();
record("contextmenu", "contextmenu attempted");
};
document.addEventListener("copy", onCopy);
document.addEventListener("paste", onPaste);
document.addEventListener("contextmenu", onContext);
return () => {
document.removeEventListener("copy", onCopy);
document.removeEventListener("paste", onPaste);
document.removeEventListener("contextmenu", onContext);
};
}, [record]);
// 切屏检测
useEffect(() => {
const onVisibility = (): void => {
if (document.visibilityState === "hidden") {
tabSwitchCount.current += 1;
record("tab-switch", `${tabSwitchCount.current} 次切换`);
if (tabSwitchCount.current >= 3) {
setShowWarning(true);
setWarningMessage("检测到多次切屏,已记录违规行为");
}
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [record]);
// 全屏状态检测
useEffect(() => {
const onFullscreen = (): void => {
const isFs = document.fullscreenElement !== null;
setIsFullscreen(isFs);
if (!isFs) {
record("fullscreen-exit", "退出全屏");
}
};
document.addEventListener("fullscreenchange", onFullscreen);
return () => document.removeEventListener("fullscreenchange", onFullscreen);
}, [record]);
// 卸载时 flush 剩余记录
useEffect(() => {
return () => {
if (queueRef.current.length > 0) {
const batch = queueRef.current.splice(0);
void flushViolations(examId, batch);
}
};
}, [examId]);
const requestFullscreen = useCallback(async (): Promise<void> => {
try {
await document.documentElement.requestFullscreen();
} catch {
// 全屏请求失败,静默处理
}
}, []);
const exitFullscreen = useCallback(async (): Promise<void> => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
} catch {
// 退出全屏失败,静默处理
}
}, []);
const dismissWarning = useCallback((): void => {
setShowWarning(false);
setWarningMessage(null);
}, []);
return {
violations,
isFullscreen,
requestFullscreen,
exitFullscreen,
showWarning,
warningMessage,
dismissWarning,
};
}

View File

@@ -0,0 +1,160 @@
/**
* 考试草稿管理 Hookai14
*
* 使用 idb-keyval 持久化草稿到 IndexedDB
* 支持断网恢复:进入考试页时检测已有草稿
* 自动保存:每 NEXT_PUBLIC_EXAM_AUTOSAVE_INTERVAL 秒 + 组件卸载时
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { get, set, del } from "idb-keyval";
import type { AnswerInput, ExamTakingDraft } from "@/lib/exam-types";
import { getStudentId, buildDraftKey } from "@/lib/exam-types";
const DEFAULT_AUTOSAVE_INTERVAL = 30; // 默认 30 秒
/** 获取自动保存间隔(毫秒) */
function getAutosaveIntervalMs(): number {
const seconds = Number(process.env.NEXT_PUBLIC_EXAM_AUTOSAVE_INTERVAL);
return (
(Number.isFinite(seconds) && seconds > 0
? seconds
: DEFAULT_AUTOSAVE_INTERVAL) * 1000
);
}
export interface UseExamDraftReturn {
draft: ExamTakingDraft | null;
saveDraft: (answers: Record<string, AnswerInput>) => Promise<void>;
/** 更新内部答案引用(不触发保存,供自动保存定时器使用) */
setAnswers: (answers: Record<string, AnswerInput>) => void;
clearDraft: () => Promise<void>;
isSaving: boolean;
hasDraft: boolean;
lastSavedAt: number | null;
/** 手动加载草稿(用于恢复确认后) */
loadDraft: () => Promise<ExamTakingDraft | null>;
}
export function useExamDraft(
examId: string,
expiresAt?: string,
): UseExamDraftReturn {
const [draft, setDraft] = useState<ExamTakingDraft | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const answersRef = useRef<Record<string, AnswerInput>>({});
const expiresAtRef = useRef<string>(expiresAt ?? "");
// 当外部传入新的 expiresAt 时更新引用
useEffect(() => {
if (expiresAt) {
expiresAtRef.current = expiresAt;
}
}, [expiresAt]);
/** 保存草稿到 IDB */
const saveDraft = useCallback(
async (answers: Record<string, AnswerInput>): Promise<void> => {
const studentId = getStudentId();
const key = buildDraftKey(examId, studentId);
setIsSaving(true);
try {
const now = Date.now();
const updated: ExamTakingDraft = draft
? { ...draft, answers, lastSavedAt: now }
: {
examId,
studentId,
answers,
startedAt: now,
serverStartedAt: now,
lastSavedAt: now,
durationSeconds: 0,
expiresAt: expiresAtRef.current,
};
await set(key, updated);
setDraft(updated);
setLastSavedAt(now);
answersRef.current = answers;
} catch {
// IDB 写入失败,静默处理(内存中仍保留)
} finally {
setIsSaving(false);
}
},
[examId, draft],
);
/** 从 IDB 加载草稿 */
const loadDraft = useCallback(async (): Promise<ExamTakingDraft | null> => {
const studentId = getStudentId();
const key = buildDraftKey(examId, studentId);
try {
const saved = (await get(key)) as ExamTakingDraft | undefined;
return saved ?? null;
} catch {
return null;
}
}, [examId]);
/** 清除草稿 */
const clearDraft = useCallback(async (): Promise<void> => {
const studentId = getStudentId();
const key = buildDraftKey(examId, studentId);
try {
await del(key);
setDraft(null);
setLastSavedAt(null);
} catch {
// 清除失败静默处理
}
}, [examId]);
/** 更新内部答案引用(不触发保存,供自动保存定时器使用) */
const setAnswers = useCallback(
(answers: Record<string, AnswerInput>): void => {
answersRef.current = answers;
},
[],
);
/** 自动保存(定时器 + 卸载时) */
const autoSave = useCallback(async (): Promise<void> => {
if (Object.keys(answersRef.current).length === 0) return;
await saveDraft(answersRef.current);
}, [saveDraft]);
// 进入时加载已有草稿
useEffect(() => {
loadDraft().then((saved) => {
if (saved) {
setDraft(saved);
setLastSavedAt(saved.lastSavedAt);
answersRef.current = saved.answers;
expiresAtRef.current = saved.expiresAt;
}
});
}, [loadDraft]);
// 定时自动保存
useEffect(() => {
const interval = setInterval(autoSave, getAutosaveIntervalMs());
return () => {
clearInterval(interval);
// 卸载时保存草稿
autoSave();
};
}, [autoSave]);
return {
draft,
saveDraft,
setAnswers,
clearDraft,
isSaving,
hasDraft: draft !== null && Object.keys(draft.answers).length > 0,
lastSavedAt,
loadDraft,
};
}

View File

@@ -0,0 +1,70 @@
/**
* 考试作答状态机 Hookai14
*
* 状态流转NotStarted → InProgress → AutoSaving → Submitting → Submitted
* 防止非法状态转移,控制提交时机
*/
import { useReducer, useMemo } from "react";
export type ExamState =
"NotStarted" | "InProgress" | "AutoSaving" | "Submitting" | "Submitted";
type ExamAction =
| { type: "start" }
| { type: "beginAutoSave" }
| { type: "endAutoSave" }
| { type: "beginSubmit" }
| { type: "completeSubmit" };
/** 状态转移函数(纯函数,校验合法转移) */
function reducer(state: ExamState, action: ExamAction): ExamState {
switch (action.type) {
case "start":
return state === "NotStarted" ? "InProgress" : state;
case "beginAutoSave":
return state === "InProgress" ? "AutoSaving" : state;
case "endAutoSave":
return state === "AutoSaving" ? "InProgress" : state;
case "beginSubmit":
return state === "InProgress" || state === "AutoSaving"
? "Submitting"
: state;
case "completeSubmit":
return state === "Submitting" ? "Submitted" : state;
default:
return state;
}
}
export interface UseExamStateMachineReturn {
state: ExamState;
actions: {
start: () => void;
beginAutoSave: () => void;
endAutoSave: () => void;
beginSubmit: () => void;
completeSubmit: () => void;
};
canSubmit: boolean;
}
/** 考试作答状态机 */
export function useExamStateMachine(): UseExamStateMachineReturn {
const [state, dispatch] = useReducer(reducer, "NotStarted");
const actions = useMemo(
() => ({
start: () => dispatch({ type: "start" }),
beginAutoSave: () => dispatch({ type: "beginAutoSave" }),
endAutoSave: () => dispatch({ type: "endAutoSave" }),
beginSubmit: () => dispatch({ type: "beginSubmit" }),
completeSubmit: () => dispatch({ type: "completeSubmit" }),
}),
[],
);
const canSubmit = state === "InProgress" || state === "AutoSaving";
return { state, actions, canSubmit };
}

View File

@@ -0,0 +1,109 @@
/**
* 多标签页检测 Hookai14
*
* 使用 BroadcastChannel 检测同一考试是否已在其他标签页打开
* 降级方案Safari < 15.4 不支持时使用 localStorage 事件
*/
import { useState, useEffect, useCallback } from "react";
const CHANNEL_NAME = "student-exam-guard";
type GuardMessage =
| { type: "tab-opened"; examId: string; tabId: string; ts: number }
| { type: "tab-exists"; examId: string; tabId: string; ts: number }
| { type: "tab-closed"; examId: string; tabId: string; ts: number };
export interface UseMultiTabGuardReturn {
/** 是否检测到重复标签页 */
isDuplicate: boolean;
/** 关闭警告(用户确认后) */
dismissWarning: () => void;
}
function generateTabId(): string {
return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function useMultiTabGuard(examId: string): UseMultiTabGuardReturn {
const [isDuplicate, setIsDuplicate] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const tabId = generateTabId();
// 降级方案:不支持 BroadcastChannel 时使用 localStorage
if (typeof BroadcastChannel === "undefined") {
const storageKey = `exam-active-${examId}`;
window.localStorage.setItem(
storageKey,
JSON.stringify({ tabId, ts: Date.now() }),
);
const onStorage = (e: StorageEvent): void => {
if (e.key === storageKey && e.newValue) {
try {
const other = JSON.parse(e.newValue) as { tabId: string };
if (other.tabId !== tabId && !dismissed) {
setIsDuplicate(true);
}
} catch {
// 解析失败忽略
}
}
};
window.addEventListener("storage", onStorage);
return () => {
window.localStorage.removeItem(storageKey);
window.removeEventListener("storage", onStorage);
};
}
// 主方案BroadcastChannel
const channel = new BroadcastChannel(CHANNEL_NAME);
const msg: GuardMessage = {
type: "tab-opened",
examId,
tabId,
ts: Date.now(),
};
channel.postMessage(msg);
const onMessage = (event: MessageEvent): void => {
const data = event.data as GuardMessage;
if (!data || data.tabId === tabId || data.examId !== examId) return;
if (
(data.type === "tab-opened" || data.type === "tab-exists") &&
!dismissed
) {
setIsDuplicate(true);
// 回应:我也在作答
channel.postMessage({
type: "tab-exists",
examId,
tabId,
ts: Date.now(),
} satisfies GuardMessage);
}
};
channel.addEventListener("message", onMessage);
return () => {
channel.postMessage({
type: "tab-closed",
examId,
tabId,
ts: Date.now(),
} satisfies GuardMessage);
channel.removeEventListener("message", onMessage);
channel.close();
};
}, [examId, dismissed]);
const dismissWarning = useCallback(() => {
setDismissed(true);
setIsDuplicate(false);
}, []);
return { isDuplicate, dismissWarning };
}

View File

@@ -0,0 +1,141 @@
"use client";
/**
* 通知 WebSocket Hookai14P5
*
* - 连接 push-gatewayws://localhost:8081/wsNEXT_PUBLIC_PUSH_GATEWAY_URL
* - 指数退避重连1s → 2s → 4s → 8s → … 上限 30s
* - 收到消息:派发 CustomEvent 通知页面重新查询 + BroadcastChannel 跨标签广播
* - Mock 模式NEXT_PUBLIC_API_MOCKING=enabled使用 mock-socket 模拟推送
* - 返回:{ isConnected, lastMessage, reconnect }
*
* 注意urql 不支持 react-query 的 invalidateQueries改用 CustomEvent 解耦。
* 通知页面监听 'edu-notifications-updated' 事件后调用 reexecute()。
*/
import { useCallback, useEffect, useRef, useState } from "react";
/** 通知更新事件名(供页面监听) */
export const NOTIFICATION_UPDATED_EVENT = "edu-notifications-updated";
export interface NotificationMessage {
type: string;
payload: Record<string, unknown>;
}
export interface UseNotificationsWebSocketResult {
isConnected: boolean;
lastMessage: NotificationMessage | null;
reconnect: () => void;
}
const CHANNEL_NAME = "edu-notification";
const MAX_DELAY_MS = 30000;
export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<NotificationMessage | null>(
null,
);
const wsRef = useRef<WebSocket | null>(null);
const retryRef = useRef(0);
const reconnectRef = useRef<() => void>(() => {});
useEffect(() => {
const base =
process.env.NEXT_PUBLIC_PUSH_GATEWAY_URL ?? "ws://localhost:8081";
const url = `${base}/ws`;
const channel =
typeof BroadcastChannel !== "undefined"
? new BroadcastChannel(CHANNEL_NAME)
: null;
const notifyUpdate = (): void => {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(NOTIFICATION_UPDATED_EVENT));
};
const onMessage = (raw: string): void => {
let msg: NotificationMessage;
try {
msg = JSON.parse(raw) as NotificationMessage;
} catch {
return;
}
setLastMessage(msg);
notifyUpdate();
channel?.postMessage(msg);
};
const connect = (): void => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
retryRef.current = 0;
setIsConnected(true);
};
ws.onmessage = (e: MessageEvent) => {
const raw = typeof e.data === "string" ? e.data : "";
if (raw) onMessage(raw);
};
ws.onclose = () => {
setIsConnected(false);
const delay = Math.min(1000 * 2 ** retryRef.current, MAX_DELAY_MS);
retryRef.current += 1;
connectTimer = setTimeout(connect, delay);
};
ws.onerror = () => ws.close();
};
let connectTimer: ReturnType<typeof setTimeout>;
let mockServer: { stop: () => void } | null = null;
// 跨标签广播监听:其他标签的 WebSocket 消息也会触发本标签更新
const onChannelMessage = (): void => {
notifyUpdate();
};
channel?.addEventListener("message", onChannelMessage);
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
// Mock 模式mock-socket 拦截 WebSocket周期性推送通知
void import("mock-socket").then(({ Server }) => {
const server = new Server(url);
server.on("connection", (socket) => {
const timer = setInterval(() => {
socket.send(
JSON.stringify({
type: "NotificationRequested",
payload: { ts: Date.now() },
}),
);
}, 60000);
socket.on("close", () => clearInterval(timer));
});
mockServer = server;
connect();
});
} else {
connect();
}
reconnectRef.current = (): void => {
retryRef.current = 0;
clearTimeout(connectTimer);
wsRef.current?.close();
};
return () => {
clearTimeout(connectTimer);
mockServer?.stop();
wsRef.current?.close();
channel?.removeEventListener("message", onChannelMessage);
channel?.close();
};
}, []);
const reconnect = useCallback((): void => {
reconnectRef.current();
}, []);
return { isConnected, lastMessage, reconnect };
}

View File

@@ -0,0 +1,89 @@
/**
* 服务器时间同步 Hookai14
*
* 定期轮询服务器时间,计算客户端与服务器的时间偏移
* 用于考试倒计时校正,防止客户端篡改时间
* 同步失败时保持上次偏移,标记为降级模式
*/
import { useState, useEffect, useCallback, useRef } from "react";
import type { ServerTimeSyncState } from "@/lib/exam-types";
const DEFAULT_SYNC_INTERVAL = 300; // 默认 5 分钟(秒)
const GRAPHQL_ENDPOINT =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
/** 获取同步间隔(毫秒) */
function getSyncIntervalMs(): number {
const seconds = Number(process.env.NEXT_PUBLIC_EXAM_TIME_SYNC_INTERVAL);
return (
(Number.isFinite(seconds) && seconds > 0
? seconds
: DEFAULT_SYNC_INTERVAL) * 1000
);
}
/** 查询服务器时间 */
async function fetchServerTime(): Promise<number> {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "{ serverTime }" }),
});
if (!response.ok) {
throw new Error(`服务器时间查询失败: ${response.status}`);
}
const result = (await response.json()) as { data?: { serverTime?: string } };
const serverTimeStr = result.data?.serverTime;
if (!serverTimeStr) {
throw new Error("服务器时间响应格式错误");
}
return new Date(serverTimeStr).getTime();
}
export function useServerTimeSync(): ServerTimeSyncState {
const [state, setState] = useState<ServerTimeSyncState>({
serverTime: Date.now(),
offset: 0,
isSyncing: false,
lastSyncAt: null,
isDegraded: false,
});
const offsetRef = useRef(0);
const sync = useCallback(async () => {
setState((prev) => ({ ...prev, isSyncing: true }));
const requestStart = Date.now();
try {
const serverTime = await fetchServerTime();
const requestEnd = Date.now();
const roundTrip = requestEnd - requestStart;
// 估算服务器响应时的客户端时间(取往返中点)
const estimatedClientAtServer = requestStart + roundTrip / 2;
const newOffset = serverTime - estimatedClientAtServer;
offsetRef.current = newOffset;
setState({
serverTime,
offset: newOffset,
isSyncing: false,
lastSyncAt: Date.now(),
isDegraded: false,
});
} catch {
// 同步失败:保持上次偏移,标记降级
setState((prev) => ({
...prev,
isSyncing: false,
isDegraded: true,
}));
}
}, []);
useEffect(() => {
sync();
const interval = setInterval(sync, getSyncIntervalMs());
return () => clearInterval(interval);
}, [sync]);
return state;
}

View File

@@ -0,0 +1,63 @@
/**
* 提交去重 Hookai14
*
* 生成幂等 key防止快速点击导致的重复提交
* 支持基于 crypto.randomUUID 的 key 生成与降级方案
*/
import { useState, useCallback, useRef } from "react";
export interface UseSubmitDedupReturn {
/** 幂等 key每次新提交生成新的 */
idempotencyKey: string;
/** 是否正在提交中 */
isSubmitting: boolean;
/** 标记开始提交(生成新 key + 锁定) */
markSubmitting: () => void;
/** 标记提交完成(解锁,生成新 key 供下次使用) */
markComplete: () => void;
/** 是否允许提交(未在提交中) */
canSubmit: boolean;
}
/** 生成幂等 key优先 crypto.randomUUID降级 Date+Random */
function generateIdempotencyKey(examId: string): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return `exam-${examId}-${crypto.randomUUID()}`;
}
return `exam-${examId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function useSubmitDedup(examId: string): UseSubmitDedupReturn {
const [isSubmitting, setIsSubmitting] = useState(false);
const [idempotencyKey, setIdempotencyKey] = useState(() =>
generateIdempotencyKey(examId),
);
const lockRef = useRef(false);
const markSubmitting = useCallback(() => {
if (lockRef.current) return;
lockRef.current = true;
setIdempotencyKey(generateIdempotencyKey(examId));
setIsSubmitting(true);
}, [examId]);
const markComplete = useCallback(() => {
lockRef.current = false;
setIsSubmitting(false);
setIdempotencyKey(generateIdempotencyKey(examId));
}, [examId]);
const canSubmit = !lockRef.current;
return {
idempotencyKey,
isSubmitting,
markSubmitting,
markComplete,
canSubmit,
};
}