Files
competition-website/src/stores/auth.js
qubiaobiao c2551b64e8 feat(auth/exam): 登录重试同源 + 静默刷新透传 + 交卷链路加固(R5/R8)
前端重试与后端闸门同源(须成对上线)
- 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。
2026-09-22 09:17:06 +08:00

327 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', () => {
// ==================== 状态 ====================
// 当前用户信息
const currentUser = ref(
JSON.parse(localStorage.getItem('user') || 'null')
)
// 认证令牌
const token = ref(localStorage.getItem('token') || '')
// ==================== 计算属性(Getters) ====================
// 是否已登录
const isLoggedIn = computed(() => !!token.value)
// 是否为管理员
const isAdmin = computed(() => {
if (!currentUser.value) return false
return (
currentUser.value.role === 'admin' ||
currentUser.value.isAdmin === true
)
})
// 用户名称(优先显示姓名,其次用户名)
const userName = computed(() => {
if (!currentUser.value) return ''
return currentUser.value.name || currentUser.value.username || ''
})
// 用户所属组别
const userGroup = computed(() => {
return currentUser.value?.group || ''
})
// ==================== 私有辅助方法 ====================
/**
* 持久化存储 token 和用户信息到 localStorage
*/
function persistAuth(tokenValue, userData) {
token.value = tokenValue
currentUser.value = userData
localStorage.setItem('token', tokenValue)
localStorage.setItem('user', JSON.stringify(userData))
}
/**
* 清除本地存储的认证信息
*/
function clearAuth() {
token.value = ''
currentUser.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
// ==================== 操作方法(Actions) ====================
/**
* 参赛者登录(手机号 + 密码)—— 带自动重试(见文件头「登录自动重试策略」)
* @param {string} phone - 手机号
* @param {string} password - 密码
* @returns {Promise<object>} 用户信息
*/
async function login(phone, password) {
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)
}
/**
* 管理员登录
* @param {string} username - 管理员用户名
* @param {string} password - 密码
* @returns {Promise<object>} 管理员信息
*/
async function adminLogin(username, password) {
const result = await authApi.adminLogin(username, password)
// 后端应返回 { token, user }
persistAuth(result.token, result.user)
return result.user
}
/**
* 激活账号(设置密码)
* @param {object} data - { phone, password, smsCode }
* @returns {Promise}
*/
async function activate(data) {
const result = await authApi.activate(data)
// 激活成功后自动登录
if (result.token && result.user) {
persistAuth(result.token, result.user)
}
return result
}
/**
* 退出登录
*/
async function logout() {
try {
// 尝试通知后端登出
await authApi.logout()
} catch (e) {
// 即使后端请求失败,也清除本地状态
console.warn('登出请求失败,仍将清除本地状态', e)
}
clearAuth()
// 跳转到登录页
window.location.href = '/login'
}
/**
* 从服务端获取最新用户信息
* @returns {Promise<object>} 用户信息
*/
async function fetchCurrentUser() {
const user = await authApi.getCurrentUser()
currentUser.value = user
localStorage.setItem('user', JSON.stringify(user))
return user
}
// ==================== 导出 ====================
return {
// 状态
currentUser,
token,
// 计算属性
isLoggedIn,
isAdmin,
userName,
userGroup,
// 操作
login,
adminLogin,
activate,
logout,
fetchCurrentUser,
// 工具方法
clearAuth,
persistAuth,
}
})