2026-08-17 12:25:42 +08:00
|
|
|
|
import { defineStore } from 'pinia'
|
|
|
|
|
|
import { ref, computed } from 'vue'
|
|
|
|
|
|
import { useRouter } from 'vue-router'
|
2026-09-22 09:17:06 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
2026-08-17 12:25:42 +08:00
|
|
|
|
import * as authApi from '@/api/auth'
|
|
|
|
|
|
|
2026-09-22 09:17:06 +08:00
|
|
|
|
// ==================== 登录自动重试策略(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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 12:25:42 +08:00
|
|
|
|
// ==================== 认证状态管理 ====================
|
|
|
|
|
|
|
|
|
|
|
|
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) ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-22 09:17:06 +08:00
|
|
|
|
* 参赛者登录(手机号 + 密码)—— 带自动重试(见文件头「登录自动重试策略」)
|
|
|
|
|
|
* @param {string} phone - 手机号
|
2026-08-17 12:25:42 +08:00
|
|
|
|
* @param {string} password - 密码
|
|
|
|
|
|
* @returns {Promise<object>} 用户信息
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function login(phone, password) {
|
2026-09-22 09:17:06 +08:00
|
|
|
|
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)
|
2026-08-17 12:25:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 管理员登录
|
|
|
|
|
|
* @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,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|