mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
bug处理
This commit is contained in:
+21
-2
@@ -2,7 +2,7 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
|
||||
import type { RefreshOutcome } from '../utils/authRefresh'
|
||||
import { createTokenPreflight, type RefreshOutcome } from '../utils/authRefresh'
|
||||
|
||||
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
||||
// requests and refreshes it on 401. Only an explicit server-side rejection of
|
||||
@@ -48,8 +48,27 @@ function isRefreshRequest(config?: InternalAxiosRequestConfig | null): boolean {
|
||||
return Boolean(config?.url?.includes('/auth/refresh'))
|
||||
}
|
||||
|
||||
// 请求发出前的令牌预检。本地持久化的 token 往往已经过期,而首屏有多个各自
|
||||
// 独立的 useEffect 会并发发请求;不预检的话它们会一起拿到 401,控制台刷出
|
||||
// 一整屏红字(现场:2026-10-16 12:49,7 个请求同时 401),用户还要等拦截器
|
||||
// 刷新完才看到内容。预检把刷新提到请求之前,且刷新协调器本身是单飞的。
|
||||
const preflightAccessToken = createTokenPreflight({
|
||||
readAccessToken: () => useAuthStore.getState().token,
|
||||
refreshSession: () => useAuthStore.getState().refreshSession(),
|
||||
})
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
api.interceptors.request.use(async (config) => {
|
||||
// 刷新请求自己绝不能走预检,否则会递归刷新。
|
||||
if (!isRefreshRequest(config)) {
|
||||
try {
|
||||
await preflightAccessToken()
|
||||
} catch {
|
||||
// 会话已被服务端判定失效:直接失败,不发无意义的 401 请求。
|
||||
// 清空会话与跳转登录页由刷新协调器和 401 拦截器负责,保持一致。
|
||||
throw new axios.Cancel('session expired')
|
||||
}
|
||||
}
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {}
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { isAccessTokenStale } from '../utils/authRefresh'
|
||||
|
||||
// 前 5 次沿用原快速退避间隔;之后进入 60s 慢速重试并不再放弃,
|
||||
// 服务重启或网络恢复后仍能自动重连(清理函数可随时取消定时器)。
|
||||
const FAST_RECONNECT_ATTEMPTS = 5
|
||||
const SLOW_RECONNECT_INTERVAL = 60_000
|
||||
|
||||
// token 过期前 30 秒即视为失效,给刷新留出余量。
|
||||
const TOKEN_EXPIRY_SKEW_MS = 30_000
|
||||
|
||||
// decodeJwtExpiryMs 解析 JWT 的 exp 声明并换算成毫秒时间戳。
|
||||
// 解析失败(非 JWT / 结构异常)返回 null,调用方据此放行重连。
|
||||
function decodeJwtExpiryMs(token: string): number | null {
|
||||
const segment = token.split('.')[1]
|
||||
if (!segment) return null
|
||||
try {
|
||||
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
|
||||
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// isTokenExpired 判断 token 是否已过期或即将过期。
|
||||
function isTokenExpired(token: string): boolean {
|
||||
const expiry = decodeJwtExpiryMs(token)
|
||||
if (expiry === null) return false
|
||||
return Date.now() >= expiry - TOKEN_EXPIRY_SKEW_MS
|
||||
}
|
||||
|
||||
// useWebSocket opens a single connection to /api/ws and dispatches every
|
||||
// message to the supplied handler. Auto-reconnects with back-off while the
|
||||
// auth token is present; after the fast retries are exhausted it keeps a
|
||||
@@ -68,7 +43,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
|
||||
const open = () => {
|
||||
if (closed) return
|
||||
if (isTokenExpired(token)) {
|
||||
if (isAccessTokenStale(token)) {
|
||||
refreshExpiredToken()
|
||||
return
|
||||
}
|
||||
@@ -92,7 +67,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
ws.onclose = () => {
|
||||
if (closed) return
|
||||
// token 过期时不要继续退避重试,交给刷新流程处理。
|
||||
if (isTokenExpired(token)) {
|
||||
if (isAccessTokenStale(token)) {
|
||||
refreshExpiredToken()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReaderBook } from '../../api/reader'
|
||||
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel'
|
||||
import type { ReaderBook } from '../../api/reader.ts'
|
||||
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`bookshelfModel: ${name}`)
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
RefreshRequestError,
|
||||
createRefreshCoordinator,
|
||||
createSingleFlightRunner,
|
||||
createTokenPreflight,
|
||||
decodeJwtExpiryMs,
|
||||
isAccessTokenStale,
|
||||
refreshFailureOutcome,
|
||||
type RefreshOutcome,
|
||||
type RefreshTokens,
|
||||
@@ -30,6 +33,149 @@ check('timeout is transient', refreshFailureOutcome(408) === 'transient')
|
||||
check('429 is transient', refreshFailureOutcome(429) === 'transient')
|
||||
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
|
||||
|
||||
// ── JWT 过期判定:首屏放行前必须能识别出「已经过期的 access token」 ────────
|
||||
// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出
|
||||
// 7 个受保护请求,全部 401,控制台一次性刷出 7 行红字;约 2 秒后才由拦截器
|
||||
// 刷新成功并重放。修复要求首屏就认得过期 token,先刷新再放行。
|
||||
function jwtWithExp(exp: number | string | null): string {
|
||||
const encode = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64url')
|
||||
const claims: Record<string, unknown> = { uid: 'u1' }
|
||||
if (exp !== null) claims.exp = exp
|
||||
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}.signature`
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
|
||||
check('jwt expiry is decoded to milliseconds',
|
||||
decodeJwtExpiryMs(jwtWithExp(NOW / 1000 + 3600)) === NOW + 3600_000)
|
||||
check('jwt without exp decodes to null', decodeJwtExpiryMs(jwtWithExp(null)) === null)
|
||||
check('jwt with non-numeric exp decodes to null', decodeJwtExpiryMs(jwtWithExp('soon')) === null)
|
||||
check('opaque token decodes to null', decodeJwtExpiryMs('not-a-jwt') === null)
|
||||
check('empty token decodes to null', decodeJwtExpiryMs('') === null)
|
||||
check('malformed payload decodes to null', decodeJwtExpiryMs('aaa.!!!.ccc') === null)
|
||||
|
||||
// 已过期 / 即将过期的 token 必须在首屏就被拦下。
|
||||
check('expired token is stale', isAccessTokenStale(jwtWithExp(NOW / 1000 - 1), NOW))
|
||||
check('token expiring inside the skew is stale',
|
||||
isAccessTokenStale(jwtWithExp(NOW / 1000 + 20), NOW))
|
||||
check('token outside the skew is fresh',
|
||||
isAccessTokenStale(jwtWithExp(NOW / 1000 + 3600), NOW) === false)
|
||||
// 边界:正好落在 skew 上算过期(留出余量,避免刷新途中再次过期)。
|
||||
check('token exactly at the skew boundary is stale',
|
||||
isAccessTokenStale(jwtWithExp(NOW / 1000 + 30), NOW))
|
||||
|
||||
// 没有 token 或 token 无法解析时不能判定为「过期」:
|
||||
// 前者由路由守卫处理,后者强行刷新会变成无休止的刷新循环。
|
||||
check('missing token is not stale', isAccessTokenStale(null, NOW) === false)
|
||||
check('opaque token is not stale', isAccessTokenStale('opaque', NOW) === false)
|
||||
|
||||
// ── 发送前预检:本地 token 已过期就先刷新,再让请求带新令牌出去 ────────────
|
||||
// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出
|
||||
// 7 个受保护请求,全部 401,控制台一次刷出 7 行红字;约 2 秒后才由 401 拦截器
|
||||
// 刷新成功并重放。修复要求请求在发出前就认得过期 token,先刷新再发送。
|
||||
async function expectPreflight(
|
||||
name: string,
|
||||
run: Promise<void>,
|
||||
want: 'resolved' | 'rejected',
|
||||
) {
|
||||
let outcome: 'resolved' | 'rejected' = 'resolved'
|
||||
try {
|
||||
await run
|
||||
} catch {
|
||||
outcome = 'rejected'
|
||||
}
|
||||
if (outcome !== want) throw new Error(`authRefresh: ${name} = ${outcome}, want ${want}`)
|
||||
}
|
||||
|
||||
function preflightHarness(overrides: {
|
||||
accessToken?: string | null
|
||||
refresh?: () => Promise<RefreshOutcome>
|
||||
}) {
|
||||
const state = { token: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken }
|
||||
const calls = { refresh: 0 }
|
||||
const ensure = createTokenPreflight({
|
||||
readAccessToken: () => state.token,
|
||||
refreshSession: async () => {
|
||||
calls.refresh += 1
|
||||
if (overrides.refresh) return overrides.refresh()
|
||||
state.token = jwtWithExp(Date.now() / 1000 + 3600)
|
||||
return 'refreshed'
|
||||
},
|
||||
now: () => NOW,
|
||||
})
|
||||
return { state, calls, ensure }
|
||||
}
|
||||
|
||||
{
|
||||
// 令牌新鲜:不得产生任何刷新请求。
|
||||
const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 + 3600) })
|
||||
await expectPreflight('fresh token passes straight through', ensure(), 'resolved')
|
||||
check('fresh token skips the refresh', calls.refresh === 0)
|
||||
}
|
||||
|
||||
{
|
||||
// 核心回归:过期令牌必须先刷新,且刷新完成后才返回(调用方据此发请求)。
|
||||
const { state, calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) })
|
||||
await expectPreflight('stale token refreshes before sending', ensure(), 'resolved')
|
||||
check('stale token refreshed exactly once', calls.refresh === 1)
|
||||
check('preflight handed out a fresh token',
|
||||
!isAccessTokenStale(state.token, NOW))
|
||||
}
|
||||
|
||||
{
|
||||
// 并发首屏:7 个请求同时预检,只允许刷新一次(协调器本身是单飞,
|
||||
// 这里确认预检不会绕过它)。
|
||||
const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) })
|
||||
await Promise.all([ensure(), ensure(), ensure(), ensure(), ensure(), ensure(), ensure()])
|
||||
check('concurrent preflight refreshes once', calls.refresh === 1)
|
||||
}
|
||||
|
||||
{
|
||||
// 瞬时故障(部署/重启窗口):保留会话,照常发出请求——
|
||||
// 页面自己会重试,不该把人挡在门外。
|
||||
const { ensure } = preflightHarness({
|
||||
accessToken: jwtWithExp(NOW / 1000 - 60),
|
||||
refresh: async () => 'transient',
|
||||
})
|
||||
await expectPreflight('transient failure still sends the request', ensure(), 'resolved')
|
||||
}
|
||||
|
||||
{
|
||||
// 服务端明确判定凭证失效:不发请求,直接失败,避免刷出无意义的 401。
|
||||
const { ensure } = preflightHarness({
|
||||
accessToken: jwtWithExp(NOW / 1000 - 60),
|
||||
refresh: async () => 'invalid',
|
||||
})
|
||||
await expectPreflight('revoked session refuses to send', ensure(), 'rejected')
|
||||
}
|
||||
|
||||
{
|
||||
// 未登录:交给路由守卫和 401 路径处理,预检不替它们做决定。
|
||||
const { calls, ensure } = preflightHarness({ accessToken: null })
|
||||
await expectPreflight('anonymous request is not preflighted', ensure(), 'resolved')
|
||||
check('anonymous request does not refresh', calls.refresh === 0)
|
||||
}
|
||||
|
||||
{
|
||||
// 非 JWT 令牌(Emby 等外部客户端签发):读不出 exp,按新鲜处理,
|
||||
// 绝不能因此陷入刷新循环。
|
||||
const { calls, ensure } = preflightHarness({ accessToken: 'opaque-token' })
|
||||
await expectPreflight('opaque token passes through', ensure(), 'resolved')
|
||||
check('opaque token does not refresh', calls.refresh === 0)
|
||||
}
|
||||
|
||||
{
|
||||
// 刷新抛出异常(网络不可达):按瞬时故障处理,请求照发。
|
||||
const { ensure } = preflightHarness({
|
||||
accessToken: jwtWithExp(NOW / 1000 - 60),
|
||||
refresh: async () => {
|
||||
throw new Error('network down')
|
||||
},
|
||||
})
|
||||
await expectPreflight('throwing refresh still sends the request', ensure(), 'resolved')
|
||||
}
|
||||
|
||||
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
|
||||
{
|
||||
let calls = 0
|
||||
|
||||
@@ -15,6 +15,46 @@
|
||||
/** 一次刷新尝试的结果。 */
|
||||
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
|
||||
|
||||
/**
|
||||
* access token 过期前多久就视为失效。刷新要留出余量:刚好卡在 exp 上
|
||||
* 刷新,重放的原请求可能在服务端又已经过期。
|
||||
*/
|
||||
export const TOKEN_EXPIRY_SKEW_MS = 30_000
|
||||
|
||||
/**
|
||||
* 解析 JWT 的 exp 声明并换算成毫秒时间戳。
|
||||
*
|
||||
* 解析失败(非 JWT、base64url 损坏、缺少 exp、exp 不是数字)一律返回 null:
|
||||
* 调用方据此放行,绝不能把「读不懂」当成「已过期」,否则不透明的令牌会导致
|
||||
* 无休止的刷新循环。
|
||||
*/
|
||||
export function decodeJwtExpiryMs(token: string | null | undefined): number | null {
|
||||
if (!token) return null
|
||||
const segment = token.split('.')[1]
|
||||
if (!segment) return null
|
||||
try {
|
||||
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
|
||||
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* token 是否已过期或即将过期。
|
||||
*
|
||||
* 首屏放行前用它做一次判断:本地持久化的 token 往往已经过期,直接发请求只
|
||||
* 会一次性刷出一屏 401。读不出 exp 的令牌按「新鲜」处理(见 decodeJwtExpiryMs)。
|
||||
*/
|
||||
export function isAccessTokenStale(token: string | null | undefined, nowMs = Date.now()): boolean {
|
||||
const expiry = decodeJwtExpiryMs(token)
|
||||
if (expiry === null) return false
|
||||
return nowMs >= expiry - TOKEN_EXPIRY_SKEW_MS
|
||||
}
|
||||
|
||||
/** 刷新接口返回的令牌对。 */
|
||||
export interface RefreshTokens {
|
||||
token: string
|
||||
@@ -101,6 +141,44 @@ export function createBrowserExclusiveRunner(
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionGateDeps {
|
||||
/** 读取当前 access token(可能来自持久化存储,往往已经过期)。 */
|
||||
readAccessToken: () => string | null
|
||||
/** 刷新会话;语义与 useAuthStore.refreshSession 一致。 */
|
||||
refreshSession: () => Promise<RefreshOutcome>
|
||||
/** 可注入的时间源,便于测试。 */
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造请求前预检:每个请求发出前调用,必要时先把过期的令牌换新。
|
||||
*
|
||||
* 本地持久化的 access token 常常已经过期。此时若直接发请求,首屏那些各自
|
||||
* 独立的 useEffect 会在同一帧里并发打出受保护请求,全部拿到 401,控制台
|
||||
* 一次刷出一整屏红字,用户还要等 401 拦截器刷新完才能看到内容
|
||||
* (现场:2026-10-16 12:49,7 个请求同时 401)。
|
||||
*
|
||||
* 预检把「先刷新、再发送」放在请求发出之前:
|
||||
* - token 新鲜(或无法解析 exp)→ 立即返回;
|
||||
* - token 已过期 → 等待刷新(刷新协调器自带单飞,并发请求只发一次);
|
||||
* - 刷新遇到瞬时故障 → 照常发送(页面自己会重试);
|
||||
* - 服务端明确判定凭证失效 → 抛出,请求根本不发出去。
|
||||
*
|
||||
* 未登录(没有 token)直接返回:跳登录页是路由守卫的职责。
|
||||
*/
|
||||
export function createTokenPreflight(deps: SessionGateDeps): () => Promise<void> {
|
||||
return async () => {
|
||||
const token = deps.readAccessToken()
|
||||
if (!token) return
|
||||
const now = deps.now ?? Date.now
|
||||
if (!isAccessTokenStale(token, now())) return
|
||||
const outcome = await deps.refreshSession().catch((): RefreshOutcome => 'transient')
|
||||
if (outcome === 'invalid') {
|
||||
throw new Error('session expired')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RefreshCoordinatorDeps {
|
||||
readAccessToken: () => string | null
|
||||
readRefreshToken: () => string | null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups'
|
||||
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups.ts'
|
||||
|
||||
// 目录分段分组的纯逻辑回归测试(对齐 utils/readerBookGroups.test.ts 的写法)。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
normalizeBookGroups,
|
||||
reorderBookGroups,
|
||||
resolveSelectedGroupId,
|
||||
} from './readerBookGroups'
|
||||
} from './readerBookGroups.ts'
|
||||
|
||||
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user