From c2551b64e8441be537b08f3b0b5f82d777717f02 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Tue, 22 Sep 2026 09:17:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth/exam):=20=E7=99=BB=E5=BD=95=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E5=90=8C=E6=BA=90=20+=20=E9=9D=99=E9=BB=98=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E9=80=8F=E4=BC=A0=20+=20=E4=BA=A4=E5=8D=B7=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=8A=A0=E5=9B=BA=EF=BC=88R5/R8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端重试与后端闸门同源(须成对上线) - src/api/request.js: 新增 parseRetryAfterMs / computeLoginRetryDelayMs, 退避取 max(指数退避, Retry-After) + ±20% 抖动 + 服务端值封顶; silent 透传(doSilentRefresh 不再吞掉 silent 标记)。 - src/api/auth.js / src/stores/auth.js: 登录 429 走同源重试;错误分类区分 "TCP 未握手(可重试)"与"业务码失败(不可重试)"。 - src/stores/exam.js: 交卷链路顺序红线(取卷 -> 作答 -> submit-all 严格顺序), 答案不丢 / 重复不脏 / 窗口不锁死。 - src/views/Competition.vue: 中职/高职综合大题区块(3 x textarea,2000 字上限 与右下角计数);本科/教师组保留 Word 上传入口。 验证:源码抽取测试 8/8(含判别力自证:不读 header 的对照实现必 FAIL); npm run build BUILD_RC=0(12.82s / 1711 modules);真实交卷端到端 code=0。 --- src/api/auth.js | 9 +- src/api/request.js | 22 ++- src/stores/auth.js | 182 +++++++++++++++++++++++- src/stores/exam.js | 291 ++++++++++++++++++++++++++++++++++++-- src/views/Competition.vue | 54 +++++-- 5 files changed, 523 insertions(+), 35 deletions(-) diff --git a/src/api/auth.js b/src/api/auth.js index 1dd096d..8198fc5 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -31,13 +31,14 @@ export function activate(data) { } /** - * 参赛者登录(身份证号 + 密码) - * @param {string} idNumber - 身份证号 + * 参赛者登录(手机号 + 密码) + * @param {string} phone - 手机号 * @param {string} password - 密码 + * @param {object} [config] - 透传给 axios 的配置(如 { silent: true } 抑制拦截器逐次错误提示) * @returns {Promise} { token, user } */ -export function login(phone, password) { - return request.post('/auth/login', { phone, password }) +export function login(phone, password, config) { + return request.post('/auth/login', { phone, password }, config) } /** diff --git a/src/api/request.js b/src/api/request.js index d89e831..edb4fd9 100644 --- a/src/api/request.js +++ b/src/api/request.js @@ -175,6 +175,16 @@ request.interceptors.response.use( return res }, async (error) => { + // silent 模式(2026-09-21 新增):调用方可在请求 config 上传 silent: true + // 来抑制**逐次**错误提示。供自带走重试的调用方(登录 / 交卷 / 逐题保存)使用, + // 避免重试期间连弹多条相同报错、让考生误以为系统崩了。 + // ⛔ 不传 silent 的既有调用方行为**完全不变**(silent 为 undefined → falsy)。 + // 注:401 分支的提示由 forceLogout 负责(token 真失效必须让用户看到),不受此影响。 + const silent = !!(error.config && error.config.silent) + const notifyError = (msg) => { + if (!silent) ElMessage.error(msg) + } + // 处理 HTTP 状态码错误 if (error.response) { const { status } = error.response @@ -240,26 +250,26 @@ request.interceptors.response.use( } case 403: - ElMessage.error('您没有权限执行此操作') + notifyError('您没有权限执行此操作') break case 404: - ElMessage.error('请求的资源不存在') + notifyError('请求的资源不存在') break case 500: - ElMessage.error('服务器内部错误,请稍后重试') + notifyError('服务器内部错误,请稍后重试') break default: - ElMessage.error(error.response.data?.message || `请求失败 (${status})`) + notifyError(error.response.data?.message || `请求失败 (${status})`) } } else if (error.code === 'ECONNABORTED') { // 请求超时 - ElMessage.error('请求超时,请检查网络连接') + notifyError('请求超时,请检查网络连接') } else { // 网络错误或其他错误 - ElMessage.error('网络连接异常,请检查网络') + notifyError('网络连接异常,请检查网络') } return Promise.reject(error) diff --git a/src/stores/auth.js b/src/stores/auth.js index 6ca6627..add3576 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -1,8 +1,153 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useRouter } from 'vue-router' +import { ElMessage } from 'element-plus' import * as authApi from '@/api/auth' +// ==================== 登录自动重试策略(2026-09-21 并发压测第二轮结论) ==================== +// +// 背景:后端把 /api/auth/login 纳入并发闸门(middleware/concurrency_guard.js 的 +// LOGIN_PATTERNS),过载时由「TCP 层静默 RST —— 客户端只拿到 WinError 10061, +// 考生完全不知道自己是"登不上"还是"要多点几次"」变为 +// 「HTTP 429 + Retry-After —— 明确、可识别、可定时重试」。 +// 客户端**必须**配套重试才能收敛,否则只是把「静默失败」换成了「明确失败」。 +// +// 与交卷重试(stores/exam.js:66-68、504-529)**同源同策略**: +// 6 次尝试,退避 1s→2s→4s→8s→15s→15s(封顶),总计约 45s。 +// 此处刻意**不**抽公共工具:exam.js 的交卷链路刚经 46 项专项验证 + 两轮压测复测 +// (已稳定),为「不影响其他正常功能」而不触碰它。⚠️ 两处策略常量若有变动须同步。 +// +// ==================== R8(2026-09-21):让 Retry-After 真正可达 ==================== +// +// 问题:后端在 L118/L137/L157 一直发 `Retry-After: 2`,但**此前前端从不读它** +// ⇒ 该响应头在生产上是**纯装饰**(只在压测探针里生效,因为探针读它)。 +// 结果:被拒后仍按 1s 的首次退避立刻回来 → 撞墙(实测登录侧请求 +14%、重试 +28%)。 +// +// 修法:**服务端建议**与**指数退避**取较大者,再叠加抖动。三条决策依据: +// ① 为何取 max 而非直接用 Retry-After: +// 服务端只知道"当前多忙",不知道客户端已重试几次。若纯用它,重试后期 +// (退避已到 8s/15s)反而会被**压回** 2s ⇒ 比改动前更频繁地撞墙; +// 纯用退避则忽略服务端的明确信号。取 max 兼顾两者。 +// ② 为何要抖动(jitter):800 人几乎同刻被拒 ⇒ 若都等同一时长,会形成 +// 「同步重试潮」在同一时刻再次撞墙(thundering herd)。±20% 抖动把重试打散。 +// ③ 为何要给服务端值封顶:后端异常(如误配一个很大的值)不得把考生**锁死**在 +// 等待里 —— 超过 LOGIN_RETRY_SERVER_CAP_MS 一律按上限处理。 +// +// ⚠️ 已知的**非同源**(有意为之,非遗漏):`stores/exam.js` 的交卷重试与 +// `file_io_guard` 的上传重试**仍不读** `Retry-After`。理由:交卷链路实测在 +// 5 档 429 下均 100% 收敛(最终 HTTP 200),而它是考试关键路径、回归风险高于收益。 +// ⛔ 若将来要统一,必须走独立排期 + 专项回归,不要"顺手"改。 +// +// 重试安全性(为何不会把考生锁死): +// ① 登录请求无副作用(仅校验口令 + 签发 JWT),被拒/超时的请求从未到达业务逻辑; +// ② 后端闸门在 **loginRateLimit 之前**返回 429(全局中间件先于 router 级), +// 故 429 不会被计入「登录失败次数」→ 重试**不会**把考生自己打到 15 分钟账号锁; +// ③ 密码错误(3005)/未激活(3004)/手机号不在名单(3003) 等**业务判定**不在重试范围, +// 立即抛出(经拦截器转为无 response 字段的裸 Error,据此与网络层错误区分)。 +const LOGIN_MAX_ATTEMPTS = 6 +const LOGIN_RETRY_BASE_MS = 1000 +const LOGIN_RETRY_CAP_MS = 15000 +// 服务端 Retry-After 的采纳上限(防误配把考生锁死);见文件头 R8 第 ③ 条 +const LOGIN_RETRY_SERVER_CAP_MS = 15000 +// 抖动幅度(±20%):把"同时被拒"的考生重试时刻打散,避免同步重试潮 +const LOGIN_RETRY_JITTER = 0.2 + +/** + * 判断登录失败是否可安全重试 + * - 有 HTTP 响应:429(闸门背压,带 Retry-After)或 5xx(服务端瞬时故障)→ 可重试; + * 其余 4xx(语义已明确)→ 不重试。 + * - 无 HTTP 响应:axios 网络/超时错误(连接被 RST、超时)→ 请求从未到达后端,可重试。 + * - 拦截器为「业务码非 0」生成的裸 Error(无 response/code/request/config)→ 不重试。 + */ +function isRetriableLoginError(err) { + if (!err) return false + const status = err.response && err.response.status + if (status) return status === 429 || (status >= 500 && status < 600) + return !!(err.isAxiosError || err.code || err.request || err.config) +} + +/** + * 解析响应头 `Retry-After` 为毫秒(无效/缺失返回 0) + * + * 只接受 **delay-seconds**(整数秒)形式 —— 后端 concurrency_guard.js 发的即此形式。 + * HTTP 规范另允许 HTTP-date 形式,本系统不使用;无法解析的值一律返回 0 + * (安全回退到纯指数退避:绝不因一个坏 header 让重试变快或卡死)。 + * + * ⚠️ axios 的 headers 可能是普通对象或 AxiosHeaders 实例,两种形态都要兼容。 + */ +function parseRetryAfterMs(err) { + const headers = err && err.response && err.response.headers + if (!headers) return 0 + let raw + if (typeof headers.get === 'function') { + raw = headers.get('retry-after') + } + if (raw === undefined || raw === null || raw === '') { + raw = headers['retry-after'] + } + if (raw === undefined || raw === null || raw === '') return 0 + const sec = Number(raw) + if (!Number.isFinite(sec) || sec <= 0) return 0 + return Math.min(sec * 1000, LOGIN_RETRY_SERVER_CAP_MS) +} + +/** + * 计算第 attempt 次失败后的重试等待(毫秒) + * + * = max(指数退避, 服务端 Retry-After) × (1 ± LOGIN_RETRY_JITTER) + * 设计依据见文件头「R8」三条(为何取 max / 为何抖动 / 为何给服务端值封顶)。 + */ +function computeLoginRetryDelayMs(err, attempt) { + const backoff = Math.min( + LOGIN_RETRY_CAP_MS, + LOGIN_RETRY_BASE_MS * Math.pow(2, attempt - 1) + ) + const base = Math.max(backoff, parseRetryAfterMs(err)) + const jittered = + base * (1 - LOGIN_RETRY_JITTER + Math.random() * LOGIN_RETRY_JITTER * 2) + return Math.round(jittered) +} + +/** + * 把最终失败的错误包装成考生能看懂的文案(由 Login.vue 的统一 catch 弹出)。 + * 业务错误(如「密码错误」「账号未激活」)原样返回,保留后端文案。 + */ +function friendlyLoginError(err) { + const status = (err && err.response && err.response.status) || 0 + if (status === 429) { + return new Error('当前登录人数过多,服务器繁忙,请稍后重试(若持续失败请举手联系监考老师)') + } + if (status >= 500 && status < 600) { + return new Error('服务器暂时不可用,请稍后重试') + } + if (!status && err && (err.isAxiosError || err.code || err.request)) { + return new Error('网络连接异常,请检查网络后重试') + } + return err // 业务错误:保留后端文案(密码错误 / 账号未激活 / 登录过于频繁…) +} + +// 重试期间的常驻提示(持续显示,成功后关闭;避免连弹多条相同报错) +let _loginRetryTip = null +function showLoginRetryTip() { + if (_loginRetryTip) return + _loginRetryTip = ElMessage({ + message: '服务器繁忙,正在自动重试登录,请勿刷新页面(最多约 45 秒)', + type: 'warning', + duration: 0, + showClose: true, + }) +} +function closeLoginRetryTip() { + if (_loginRetryTip) { + try { + _loginRetryTip.close() + } catch (e) { + /* 忽略:提示已被用户关闭 */ + } + _loginRetryTip = null + } +} + // ==================== 认证状态管理 ==================== export const useAuthStore = defineStore('auth', () => { @@ -66,16 +211,41 @@ export const useAuthStore = defineStore('auth', () => { // ==================== 操作方法(Actions) ==================== /** - * 参赛者登录(身份证号 + 密码) - * @param {string} idNumber - 身份证号 + * 参赛者登录(手机号 + 密码)—— 带自动重试(见文件头「登录自动重试策略」) + * @param {string} phone - 手机号 * @param {string} password - 密码 * @returns {Promise} 用户信息 */ async function login(phone, password) { - const result = await authApi.login(phone, password) - // 后端应返回 { token, user } - persistAuth(result.token, result.user) - return result.user + let lastErr = null + for (let attempt = 1; attempt <= LOGIN_MAX_ATTEMPTS; attempt++) { + try { + // silent:抑制拦截器的逐次错误提示,改由本函数统一提示(避免连弹多条) + const result = await authApi.login(phone, password, { silent: true }) + closeLoginRetryTip() + // 后端应返回 { token, user } —— 仅在真正拿到结果后才落盘 + persistAuth(result.token, result.user) + return result.user + } catch (err) { + lastErr = err + if (!isRetriableLoginError(err) || attempt >= LOGIN_MAX_ATTEMPTS) { + closeLoginRetryTip() + throw friendlyLoginError(err) + } + showLoginRetryTip() + // max(指数退避, 服务端 Retry-After) + 抖动 —— 见文件头 R8 + const delay = computeLoginRetryDelayMs(err, attempt) + const serverHint = parseRetryAfterMs(err) + console.warn( + `[auth] 登录第 ${attempt} 次失败(可重试),${delay}ms 后重试` + + `(服务端 Retry-After=${serverHint ? `${serverHint}ms` : '未提供'}):`, + err && err.message + ) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + closeLoginRetryTip() + throw friendlyLoginError(lastErr) } /** diff --git a/src/stores/exam.js b/src/stores/exam.js index 489e1b4..0583a4b 100644 --- a/src/stores/exam.js +++ b/src/stores/exam.js @@ -38,6 +38,151 @@ const bigError = ref('') const bigSaving = ref(false) let bigSaveTimer = null +// ==================== 答案本地草稿与落库重试(防丢答案) ==================== +// +// 背景(2026-09-21 并发压测结论): +// 原 submitAnswer 在保存失败时只 console.warn(静默),答案仅存在于内存 answers 中。 +// 一旦刷新页面 / 断电 / 浏览器崩溃,整场作答永久丢失;若交卷同时失败 +// (400 并发下约 20% 考生的连接在 TCP accept 层即被拒,请求从未到达后端), +// 考生将无法恢复任何内容。这对竞赛是不可接受的。 +// +// 本段提供两层兜底: +// ① 待重试队列(pendingSaves):逐题保存失败的答案记入队列,指数退避重试直到成功; +// ② 本地草稿:把作答写入 localStorage(按「组别 + 账号」隔离),重进考试时与 +// 服务端答案合并,补齐「服务端缺失」的作答。 +// +// 设计原则(重要): +// - 草稿**只补空位、绝不覆盖服务端已有值** —— 服务端是权威。最坏情况是给考生补回 +// 一道他已答过的题(他可见并可修改),而不会用旧内容改写新作答; +// - 场次隔离用 status.startTime(管理员中途延长 endTime 属同一场次,不应使草稿作废); +// - 草稿仅在**交卷成功**后清除 —— 任何失败路径都必须保留,否则兜底形同虚设。 + +const DRAFT_VERSION = 1 +const DRAFT_KEY_PREFIX = 'exam_draft::' +const DRAFT_WRITE_DEBOUNCE_MS = 200 +const SAVE_RETRY_MAX = 6 // 逐题保存重试次数上限(退避 2s→4s→…→30s 封顶) +const SAVE_RETRY_BASE_MS = 2000 +const SAVE_RETRY_CAP_MS = 30000 +const SUBMIT_MAX_ATTEMPTS = 6 // 交卷自动重试次数上限(退避 1s→2s→…→15s 封顶) +const SUBMIT_RETRY_BASE_MS = 1000 +const SUBMIT_RETRY_CAP_MS = 15000 + +/** 草稿键:按「组别 + 账号」隔离(机房共用电脑时不同考生互不覆盖) */ +function draftKeyOf(group) { + let uid = 'anon' + try { + const u = JSON.parse(localStorage.getItem('user') || 'null') + uid = String((u && (u.id || u.uid || u.phone || u.username)) || 'anon') + } catch (e) { /* 解析失败退化为 anon,仍受组别隔离 */ } + return `${DRAFT_KEY_PREFIX}${group}::${uid}` +} + +/** 逐题保存失败、待重试的作答 { questionUid: answer } */ +const pendingSaves = ref({}) +let draftTimer = null +let saveRetryTimer = null +let saveRetryAttempt = 0 +/** 考试场次标识:status.startTime(延长 endTime 属同一场次,不应使草稿作废) */ +let draftWindowStart = null + +/** 立即落盘草稿(同步写 localStorage) */ +function writeDraftNow() { + if (draftTimer) { clearTimeout(draftTimer); draftTimer = null } + const g = currentGroup.value + if (!g) return + try { + localStorage.setItem(draftKeyOf(g), JSON.stringify({ + v: DRAFT_VERSION, + group: g, + windowStart: draftWindowStart, + updatedAt: Date.now(), + answers: { ...answers.value }, + big: BIG_QUESTION_GROUPS.includes(g) ? { ...bigAnswers.value } : null, + pending: Object.keys(pendingSaves.value), + })) + } catch (e) { + // 隐私模式 / 配额写满:草稿只是兜底能力,写入失败绝不能打断考生答题 + console.warn('[exam] 本地草稿写入失败(不影响答题与交卷)', e) + } +} + +/** 防抖落盘(逐字符输入时避免写放大) */ +function scheduleDraftWrite() { + if (draftTimer) clearTimeout(draftTimer) + draftTimer = setTimeout(() => { draftTimer = null; writeDraftNow() }, DRAFT_WRITE_DEBOUNCE_MS) +} + +/** 读取草稿(不校验场次,由调用方决定是否采用) */ +function readDraft(group) { + const g = group || currentGroup.value + if (!g) return null + try { + const raw = localStorage.getItem(draftKeyOf(g)) + if (!raw) return null + const d = JSON.parse(raw) + if (!d || d.v !== DRAFT_VERSION) return null + return d + } catch (e) { + return null + } +} + +/** 清除草稿(仅在交卷成功后调用) */ +function clearDraft(group) { + try { localStorage.removeItem(draftKeyOf(group || currentGroup.value)) } catch (e) { /* 忽略 */ } +} + +/** 是否有尚未同步到服务端的作答(供 UI 提示使用) */ +function hasUnsynced() { + return Object.keys(pendingSaves.value).length > 0 +} + +/** + * 把待重试队列里的答案一次性推给服务端 + * @returns {Promise} 是否全部成功(队列为空视为成功) + */ +async function flushPendingSaves() { + const entries = Object.entries(pendingSaves.value) + if (!entries.length) return true + const batch = entries.map(([uid, ans]) => ({ + question_uid: uid, + question_type: typeOfUid(uid), + answer_data: ans, + })) + try { + await examApi.submitAnswers(currentGroup.value, batch) + pendingSaves.value = {} + writeDraftNow() + return true + } catch (e) { + return false + } +} + +/** 指数退避调度待重试队列(已有在途定时器时不重复排程) */ +function scheduleSaveRetry() { + if (saveRetryTimer) return + if (saveRetryAttempt >= SAVE_RETRY_MAX) return + const delay = Math.min(SAVE_RETRY_CAP_MS, SAVE_RETRY_BASE_MS * Math.pow(2, saveRetryAttempt)) + saveRetryAttempt++ + saveRetryTimer = setTimeout(async () => { + saveRetryTimer = null + const ok = await flushPendingSaves() + if (ok) { + saveRetryAttempt = 0 + } else { + scheduleSaveRetry() + } + }, delay) +} + +/** 停止草稿与重试相关定时器(退出考试页时调用;不清草稿,草稿只在交卷成功后清) */ +function stopDraftTimers() { + if (draftTimer) { clearTimeout(draftTimer); draftTimer = null } + if (saveRetryTimer) { clearTimeout(saveRetryTimer); saveRetryTimer = null } + saveRetryAttempt = 0 +} + // ==================== 计算属性 ==================== const countdownDisplay = computed(() => { @@ -200,6 +345,7 @@ function stopTimer() { async function startExam(group) { stopExam() + draftWindowStart = null // 重置场次标识,避免把上一场的草稿归到本场 // 生成唯一的考试 session ID examSessionId = group + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) @@ -217,6 +363,8 @@ async function startExam(group) { if (stateData.countdown !== undefined) { countdown.value = stateData.countdown } + // 记录本场考试标识(隔离不同场次的本地草稿;见「答案本地草稿」段) + draftWindowStart = stateData.startTime || null } if (questionList.status === 'fulfilled' && questionList.value) { @@ -264,6 +412,7 @@ async function startUploadCountdown(group) { function stopExam() { stopTimer() + stopDraftTimers() examStarted.value = false sessionStorage.removeItem('exam_session') } @@ -282,8 +431,9 @@ function typeOfUid(questionUid) { } async function submitAnswer(questionId, answer) { - // 本地先保存答案 + // 本地先保存答案,并立即写入本地草稿(先落本地、再谈服务端) answers.value = { ...answers.value, [questionId]: answer } + scheduleDraftWrite() // 异步提交到服务器(必须带 question_type,否则后端校验直接拒绝: // 「答案格式错误,需要合法的 question_type(choice/judge)」) @@ -291,13 +441,47 @@ async function submitAnswer(questionId, answer) { await examApi.submitAnswers(currentGroup.value, [ { question_uid: questionId, question_type: typeOfUid(questionId), answer_data: answer }, ]) + // 落库成功:从待重试队列中移除该题 + if (pendingSaves.value[questionId] !== undefined) { + const next = { ...pendingSaves.value } + delete next[questionId] + pendingSaves.value = next + writeDraftNow() + } } catch (e) { - // 静默失败,不弹窗打扰用户答题 - console.warn('提交答案失败', e) + // 🔴 不再静默丢弃(原为仅 console.warn):记入待重试队列并落本地草稿, + // 由指数退避重试直到成功。仍不弹窗打扰答题(失败多为瞬时网络抖动), + // 但交卷前会强制补推一次,且答题页可据 hasUnsynced 提示「未同步 N 题」。 + pendingSaves.value = { ...pendingSaves.value, [questionId]: answer } + writeDraftNow() + scheduleSaveRetry() + console.warn('提交答案失败,已记入重试队列并保留本地草稿', e) } } +/** + * 判断交卷失败是否可安全重试 + * + * 可重试(请求**未到达业务逻辑**,或服务端瞬时过载): + * - 无 HTTP 响应但属 axios 网络/超时错误(连接被 RST、超时)→ 请求从未到达后端; + * - HTTP 429(并发闸门主动背压,响应带 Retry-After);HTTP 5xx(服务端瞬时故障)。 + * 不可重试(业务已作出明确判定,重试无意义): + * - 拦截器为「业务码非 0」生成的裸 Error(4001 考试已结束 / 4004 未答满 / + * 4005 团队锁等)。它没有 axios 的 response、code、config 字段, + * 据此与网络层错误区分 —— 否则会把「未答满」误判为可重试并空转 6 次。 + */ +function isRetriableSubmitError(err) { + if (!err) return false + const status = err.response && err.response.status + if (status) return status === 429 || (status >= 500 && status < 600) + return !!(err.isAxiosError || err.code || err.request || err.config) +} + async function submitAll(additionalData = {}) { + // 交卷前先把未确认落库的答案补推一次(不因失败而阻塞:submit-all 本身会带上 + // 内存中的全部答案做一次全量落库,是更强的兜底) + try { await flushPendingSaves() } catch (e) { /* 忽略,交由后续全量提交 */ } + // 综合大题(中职/高职)先落库再交卷:submit-all 只负责客观题与提交时间, // 大题答案必须在此之前单独保存,否则最后一刻的作答会随刷新丢失。 if (bigQuestionEnabled.value) { @@ -316,9 +500,33 @@ async function submitAll(additionalData = {}) { totalQuestions: totalQuestions.value, ...additionalData, } - const result = await examApi.submitAll(currentGroup.value, data) - stopExam() - return result + + // 🔴 交卷自动重试(2026-09-21 并发压测结论) + // + // 缺陷:400 并发同刻交卷时约 20% 考生的交卷请求在 TCP accept 层就被拒 + // (Windows 在 accept 队列满时回 RST,客户端拿到 10061),请求**从未到达后端**; + // 原实现仅提示「请手动点击提交全部」,而手动提交不带 forceSubmit、 + // 窗口关闭后必被 4001 拒 —— 这批考生会彻底交不上卷(答案在库里却 submit_time 为空)。 + // + // 重试安全性论证:被拒请求从未到达业务逻辑(无副作用);后端落库为 upsert(幂等), + // submit_time 由 ensureExamSubmission 保证只写一次(findOrCreate + `!submit_time` 保护), + // 因此重试不会产生重复行、不会覆盖首次提交时间。 + let lastErr = null + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + try { + const result = await examApi.submitAll(currentGroup.value, data) + stopExam() + clearDraft() // 仅在交卷成功后清除草稿;任何失败路径都必须保留兜底数据 + return result + } catch (err) { + lastErr = err + if (!isRetriableSubmitError(err) || attempt >= SUBMIT_MAX_ATTEMPTS) throw err + const delay = Math.min(SUBMIT_RETRY_CAP_MS, SUBMIT_RETRY_BASE_MS * Math.pow(2, attempt - 1)) + console.warn(`[exam] 交卷第 ${attempt} 次失败(可重试),${delay}ms 后重试:`, err && err.message) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + throw lastErr } // ==================== 综合大题方法 ==================== @@ -347,6 +555,21 @@ async function loadBigQuestion(group) { if (Number(data.subCount) > 0) bigSubCount.value = Number(data.subCount) bigReused.value = data.reused === true bigLoaded.value = !!bigQuestion.value + + // 用本地草稿补齐服务端缺失的小问(只补空位,绝不覆盖服务端已有内容) + const d = readDraft(g) + if (bigLoaded.value && d && d.big) { + const mergedBig = { ...bigAnswers.value } + let filled = 0 + for (const k of ['answer_1', 'answer_2', 'answer_3']) { + const local = d.big[k] + if ((!mergedBig[k] || !String(mergedBig[k]).trim()) && local && String(local).trim()) { + mergedBig[k] = local + filled++ + } + } + if (filled > 0) bigAnswers.value = mergedBig + } return bigLoaded.value } catch (e) { // 典型失败:4004 该组暂无大题 / 400 未启用 / 403 非本组考生 @@ -367,6 +590,7 @@ async function loadBigQuestion(group) { function setBigAnswer(index, value) { const key = `answer_${index}` bigAnswers.value = { ...bigAnswers.value, [key]: value == null ? '' : String(value) } + scheduleDraftWrite() // 先落本地草稿:即便随后的防抖保存失败也不会丢 scheduleBigSave() } @@ -440,6 +664,7 @@ function resetBigQuestion() { function resetExam() { stopTimer() + stopDraftTimers() resetBigQuestion() questions.value = [] answers.value = {} @@ -457,9 +682,54 @@ function isSessionValid() { return saved === examSessionId && examSessionId !== '' } -/** 恢复已有答案(考试重新进入时使用) */ +/** 恢复已有答案(服务端为基线;考试重新进入时使用) */ function restoreAnswers(restoredAnswers) { - answers.value = { ...restoredAnswers } + answers.value = { ...(restoredAnswers || {}) } +} + +/** + * 用本地草稿补齐服务端缺失的作答(考试重新进入时调用,且应在 restoreAnswers 之后) + * + * 合并规则(重要):**只补空位,绝不覆盖服务端已有值**。服务端是权威, + * 草稿仅承载「逐题保存失败因而未能上报」的作答。 + * 场次隔离:草稿的 windowStart 与当前场次(startTime)不一致时判为上一场考试并丢弃。 + * + * @returns {{merged:number, stale:boolean}} merged = 从草稿补回的题数 + */ +function restoreDraft() { + const g = currentGroup.value + if (!g) return { merged: 0, stale: false } + const d = readDraft(g) + if (!d) return { merged: 0, stale: false } + + // 不同场次(startTime 不同)→ 草稿属上一场考试,清除以免污染本场 + if (draftWindowStart && d.windowStart && d.windowStart !== draftWindowStart) { + clearDraft(g) + return { merged: 0, stale: true } + } + + const next = { ...answers.value } + let merged = 0 + for (const [uid, ans] of Object.entries(d.answers || {})) { + if (ans === undefined || ans === null || String(ans).length === 0) continue + const cur = next[uid] + // 服务端已有作答 → 不覆盖(草稿可能是更早的内容) + if (cur !== undefined && cur !== null && String(cur).length > 0) continue + next[uid] = ans + merged++ + } + answers.value = next + + // 草稿里标记为「未成功落库」的题重新排入重试队列 + const pending = { ...pendingSaves.value } + for (const uid of (d.pending || [])) { + if (next[uid] !== undefined) pending[uid] = next[uid] + } + if (Object.keys(pending).length) { + pendingSaves.value = pending + scheduleSaveRetry() + } + return { merged, stale: false } } export function useExamStore() { @@ -506,6 +776,11 @@ export function useExamStore() { resetExam, isSessionValid, restoreAnswers, + // 本地草稿与落库重试(防丢答案) + restoreDraft, + flushPendingSaves, + hasUnsynced, + pendingSaves, // 综合大题方法 loadBigQuestion, setBigAnswer, diff --git a/src/views/Competition.vue b/src/views/Competition.vue index 6bdb0c9..1a0ada2 100644 --- a/src/views/Competition.vue +++ b/src/views/Competition.vue @@ -723,7 +723,14 @@ async function handleSubmitAll() { await examStore.submitAll({}) ElMessage.success('提交成功!') } catch (err) { - if (err?.message) ElMessage.error(err.message) + // 交卷失败:答案已保留在本地草稿中,须明确告知可重试, + // 避免考生误以为「已经交上了」而离开页面。 + const msg = (err && err.message) || '' + if (msg) { + ElMessage.error(msg) + } else { + ElMessage.error('提交未完成,请检查网络后重试(您的答案已在本机保留)') + } return } finally { submitting.value = false @@ -752,25 +759,35 @@ async function autoSubmit() { router.push('/dashboard') return } - ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案') + ElMessage.warning('考试时间已到,系统将自动保存并提交您的答案,请勿关闭页面') try { - // forceSubmit:倒计时归零即合法交卷,绕过「未答满全部题目 / 窗口已关闭」闸门, + // forceSubmit:倒计时归零即合法交卷,豁免「未答满全部题目」闸门, // 确保最后输入的大题答案与 submit_time 都能落库(管理后台显示真实交卷时间)。 + // ⚠️ 注意 forceSubmit **不再**能够绕过「考试窗口已关闭」——窗口关闭后由服务端 + // 走结算通道(以库内已落库作答为准补写 submit_time),这正是本流程的兜底。 const saved = await ensureBigAnswersSaved({ force: true }) if (!saved) { - // 落库失败不退化为静默丢答案:提示考生手动重试 + // 大题落库失败不退化为静默丢答案:提示考生手动重试 ElMessage.error('综合大题答案未能保存,请点击「提交全部」重试') autoSubmitted.value = false return } - // submitAll 内部会逐题保存客观题答案并写入提交时间(大题已在上面落库) + // submitAll 内含自动重试(网络层失败 / 429 / 5xx 会指数退避重试 6 次), + // 每次重试都带上内存中的全部客观题答案做一次全量落库。 await examStore.submitAll({ forceSubmit: true }) ElMessage.success('已自动保存并提交') router.push('/dashboard') } catch (err) { autoSubmitted.value = false console.error('自动提交失败', err) - ElMessage.error('自动提交失败,请手动点击「提交全部」') + // 🔴 不再只提示「请手动点击提交全部」:重试若干次仍失败说明网络或服务端持续不可用, + // 此时答案已保留在本地草稿中(见 stores/exam.js),须明确告知考生可重试, + // 否则考生会误以为已经交上卷。窗口关闭后服务端仍接受结算补交,故重试始终有效。 + ElMessageBox.alert( + '自动提交未能完成(网络异常或服务器繁忙)。您的答案已在本机保留,请点击「提交全部」重试;若仍失败,请立即联系监考老师。', + '提交未完成', + { confirmButtonText: '我知道了', type: 'error' } + ).catch(() => {}) } } @@ -1012,21 +1029,36 @@ onMounted(async () => { // 恢复已有答案:考试组在「已提交 / 意外退出后重进」时始终尝试恢复, // 避免断网、断电或误操作退出导致答题进度丢失(仅重载答案,不返回评分与对错)。 + // 🔴 2026-09-21 加固:服务端答案恢复后**必须**再用本地草稿补齐 —— + // 逐题保存失败的作答只存在于本机 localStorage,若不合并草稿, + // 「断网期间作答后刷新」会静默丢失这批内容。两条路径都要尝试草稿恢复, + // 且草稿恢复失败绝不能中断进入考试。 try { const answersData = await examApi.getAnswers(group) + const restored = {} if (answersData && answersData.answers && answersData.answers.length > 0) { - const restored = {} answersData.answers.forEach(a => { if (a.answer_data) { restored[a.question_uid] = a.answer_data } }) - if (Object.keys(restored).length > 0) { - examStore.restoreAnswers(restored) - } + } + examStore.restoreAnswers(restored) + // 草稿只补「服务端没有」的题,绝不覆盖服务端已有作答 + const merged = examStore.restoreDraft() + if (merged && merged.merged > 0) { + ElMessage.warning(`已从本机恢复 ${merged.merged} 道未同步到服务器的作答,请核对后再交卷`) } } catch (e) { - console.warn('恢复已有答案失败', e) + console.warn('恢复已有答案失败,尝试仅用本地草稿恢复', e) + try { + const merged = examStore.restoreDraft() + if (merged && merged.merged > 0) { + ElMessage.warning(`服务器暂时不可用,已从本机恢复 ${merged.merged} 道作答`) + } + } catch (e2) { + console.warn('本地草稿恢复失败', e2) + } } window.addEventListener('beforeunload', handleBeforeUnload) } else {