mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 20:46:37 +08:00
优化登录,优化阅读
This commit is contained in:
+23
-23
@@ -2,9 +2,12 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
|
||||
import type { RefreshOutcome } from '../utils/authRefresh'
|
||||
|
||||
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
||||
// requests and routes 401s back to the login page.
|
||||
// requests and refreshes it on 401. Only an explicit server-side rejection of
|
||||
// the refresh token sends the user back to the login page; transient failures
|
||||
// (deploy/restart window, gateway errors, timeouts) keep the stored session.
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
@@ -92,33 +95,30 @@ api.interceptors.response.use(
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const refreshed = await useAuthStore.getState().tokenRefresh()
|
||||
if (refreshed) {
|
||||
const newToken = useAuthStore.getState().token
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
onTokenRefreshed(newToken || '')
|
||||
isRefreshing = false
|
||||
return api(originalRequest)
|
||||
// 刷新可能因为部署/重启窗口的瞬时故障失败。只有服务端明确判定
|
||||
// 凭证失效(401/403)时才清空会话并跳登录页;网络错误、网关 502、
|
||||
// 超时只是暂时不可用,保留本地令牌等下一次重试。
|
||||
const outcome: RefreshOutcome = await useAuthStore
|
||||
.getState()
|
||||
.refreshSession()
|
||||
.catch((): RefreshOutcome => 'transient')
|
||||
isRefreshing = false
|
||||
|
||||
if (outcome === 'refreshed') {
|
||||
const newToken = useAuthStore.getState().token
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false
|
||||
onTokenRefreshFailed(refreshError)
|
||||
onTokenRefreshed(newToken || '')
|
||||
return api(originalRequest)
|
||||
}
|
||||
|
||||
onTokenRefreshFailed(err)
|
||||
if (outcome === 'invalid') {
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
|
||||
// Refresh failed, logout
|
||||
isRefreshing = false
|
||||
onTokenRefreshFailed(err)
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
@@ -366,9 +366,15 @@ export const readerAPI = {
|
||||
.then((r) => r.data.chapters ?? []),
|
||||
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
||||
bookContent: (id: string, chapter: number) =>
|
||||
// signal:换章/换源/离开页面时取消在途请求——慢源上一章要等十几秒,不取消的话
|
||||
// 旧源的响应会白等,换源后还可能把旧源的正文写进新书的缓存。
|
||||
bookContent: (id: string, chapter: number, signal?: AbortSignal) =>
|
||||
api
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, {
|
||||
params: { chapter },
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
signal,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 替换净化规则 ──
|
||||
|
||||
+50
-31
@@ -1,37 +1,56 @@
|
||||
// 令牌刷新 API 模块
|
||||
import { api } from './client'
|
||||
// 令牌刷新 API 模块。
|
||||
import { RefreshRequestError, type RefreshTokens } from '../utils/authRefresh'
|
||||
|
||||
// 刷新令牌请求/响应
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string
|
||||
}
|
||||
const REFRESH_ENDPOINT = '/api/auth/refresh'
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
// 刷新是短请求:网关/服务重启时尽快失败并让调用方重试,
|
||||
// 不要用默认的长超时把页面卡在等待里。
|
||||
const REFRESH_TIMEOUT_MS = 15_000
|
||||
|
||||
// 刷新访问令牌。
|
||||
//
|
||||
// 后端响应封装在 { code, message, data } 里,需解包 .data。
|
||||
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
|
||||
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
|
||||
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
const resp = await api.post<{
|
||||
code: number
|
||||
message: string
|
||||
data: RefreshTokenResponse
|
||||
}>('/auth/refresh', { refresh_token: refreshToken })
|
||||
const body = resp.data
|
||||
if (!body || !body.data || !body.data.token) {
|
||||
throw new Error(body?.message || 'refresh failed')
|
||||
/**
|
||||
* 用 refresh token 换取新的令牌对。
|
||||
*
|
||||
* 刻意使用 fetch 而不是共享的 axios 实例:
|
||||
* - 刷新请求的失败不能进入 401 拦截器,否则会递归触发刷新/登出;
|
||||
* - 调用方需要拿到 HTTP 状态码,以区分「凭证失效」和「服务暂时不可用」。
|
||||
*
|
||||
* 服务端响应形如 { code, message, data: { token, refresh_token, ... } }。
|
||||
*/
|
||||
export async function requestRefreshTokens(refreshToken: string): Promise<RefreshTokens> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(REFRESH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch {
|
||||
// 网络不可达、被中止或超时:没有 HTTP 状态码,按「暂时不可用」处理。
|
||||
throw new RefreshRequestError('refresh request failed')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/me/logout')
|
||||
const body = (await resp.json().catch(() => null)) as
|
||||
| { code?: number; message?: string; data?: Partial<RefreshTokens> }
|
||||
| null
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new RefreshRequestError(body?.message ?? 'refresh failed', resp.status)
|
||||
}
|
||||
|
||||
const token = body?.data?.token
|
||||
const nextRefreshToken = body?.data?.refresh_token
|
||||
if (!token || !nextRefreshToken) {
|
||||
// 200 但没有可用令牌(例如被网关/代理改写了响应):同样按暂时不可用处理,
|
||||
// 不要据此清空用户会话。
|
||||
throw new RefreshRequestError('malformed refresh response')
|
||||
}
|
||||
return { token, refresh_token: nextRefreshToken }
|
||||
}
|
||||
|
||||
@@ -52,13 +52,24 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
let timer: number | undefined
|
||||
let reconnectAttempts = 0
|
||||
|
||||
// 过期的 token 握手必然 401。先走刷新流程:成功会更新 token 并让本
|
||||
// effect 重建连接;「暂时不可用」(部署/重启窗口的网络故障)时保留
|
||||
// 会话继续退避重试;只有服务端明确判定凭证失效才会停止重连。
|
||||
const refreshExpiredToken = () => {
|
||||
void useAuthStore
|
||||
.getState()
|
||||
.tokenRefresh()
|
||||
.then((refreshed) => {
|
||||
if (closed || refreshed) return
|
||||
if (!useAuthStore.getState().refreshToken) return
|
||||
timer = window.setTimeout(open, SLOW_RECONNECT_INTERVAL)
|
||||
})
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
if (closed) return
|
||||
// 过期的 token 握手必然 401。此前这里会以 60s 间隔无限重试,服务端
|
||||
// 日志里表现为每分钟一条 401。改为先走刷新流程:成功会更新 token 并
|
||||
// 让本 effect 重建连接,失败则清空会话停止重连。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
refreshExpiredToken()
|
||||
return
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
@@ -82,7 +93,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
if (closed) return
|
||||
// token 过期时不要继续退避重试,交给刷新流程处理。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
refreshExpiredToken()
|
||||
return
|
||||
}
|
||||
reconnectAttempts += 1
|
||||
|
||||
@@ -109,6 +109,8 @@ export default function ReaderViewPage() {
|
||||
const [switchLoading, setSwitchLoading] = useState(false)
|
||||
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
// 正文重载计数:错误态的「重试」只重取本章正文,不重载整本书(换源用 reloadKey)。
|
||||
const [contentReloadKey, setContentReloadKey] = useState(0)
|
||||
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
|
||||
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
|
||||
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
||||
@@ -248,20 +250,33 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}, [bookId, reloadKey])
|
||||
|
||||
// 正文缓存的键带上书源标识(origin + book_url):换源后在途的旧源响应即使晚到,
|
||||
// 也只会落在旧键上,不会被新源读到。聚合源的不同子源共用同一个 origin(子源写在
|
||||
// book_url 里),所以不能只用 origin。
|
||||
const contentCacheKey = useCallback(
|
||||
(index: number) => `${book?.origin ?? ''}\u0000${book?.book_url ?? ''}\u0000${index}`,
|
||||
[book?.origin, book?.book_url],
|
||||
)
|
||||
|
||||
// ── 加载章节正文(带缓存与下一章预取) ──
|
||||
useEffect(() => {
|
||||
if (chapterIndex === null || !book || chapters.length === 0) return
|
||||
const ch = chapters[chapterIndex]
|
||||
if (!ch) return
|
||||
let cancelled = false
|
||||
// 换章/换源/离开页面时取消在途请求:慢源一章要等十几秒,不取消就会白等旧源的
|
||||
// 响应,换源后还可能把旧源的正文塞进新书的缓存。
|
||||
const ac = new AbortController()
|
||||
;(async () => {
|
||||
setContent(null)
|
||||
setLoadingStage('content')
|
||||
try {
|
||||
const cacheKey = String(chapterIndex)
|
||||
const cacheKey = contentCacheKey(chapterIndex)
|
||||
let ct = contentCache.current.get(cacheKey)
|
||||
if (!ct) {
|
||||
ct = await readerAPI.bookContent(book.id, chapterIndex)
|
||||
ct = await readerAPI.bookContent(book.id, chapterIndex, ac.signal)
|
||||
// 请求期间切了章/换了源:结果已作废,既不用也不必入缓存。
|
||||
if (cancelled) return
|
||||
contentCache.current.set(cacheKey, ct)
|
||||
}
|
||||
if (cancelled) return
|
||||
@@ -300,10 +315,14 @@ export default function ReaderViewPage() {
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
|
||||
.catch(() => undefined)
|
||||
// 预取下一章
|
||||
if (!contentCache.current.has(String(chapterIndex + 1))) {
|
||||
if (!contentCache.current.has(contentCacheKey(chapterIndex + 1))) {
|
||||
readerAPI
|
||||
.bookContent(book.id, chapterIndex + 1)
|
||||
.then((c) => contentCache.current.set(String(chapterIndex + 1), c))
|
||||
.bookContent(book.id, chapterIndex + 1, ac.signal)
|
||||
.then((c) => {
|
||||
// 预取是在「旧源」发起、在换源后才回来的话,结果属于脏数据,丢掉。
|
||||
if (cancelled) return
|
||||
contentCache.current.set(contentCacheKey(chapterIndex + 1), c)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -317,8 +336,9 @@ export default function ReaderViewPage() {
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
ac.abort()
|
||||
}
|
||||
}, [chapterIndex, book, chapters])
|
||||
}, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
|
||||
|
||||
// ── 分页排版(CSS 多栏 + 平移) ──
|
||||
const relayout = useCallback(() => {
|
||||
@@ -953,8 +973,10 @@ export default function ReaderViewPage() {
|
||||
onClick={() => {
|
||||
setError('')
|
||||
if (chapterIndex !== null) {
|
||||
contentCache.current.delete(String(chapterIndex))
|
||||
setChapterIndex(chapterIndex)
|
||||
contentCache.current.delete(contentCacheKey(chapterIndex))
|
||||
// 用 reload 计数触发重取:把 chapterIndex 设成同一个值不会让
|
||||
// effect 重跑,以前点「重试」只是把错误提示清掉了。
|
||||
setContentReloadKey((v) => v + 1)
|
||||
}
|
||||
}}
|
||||
className="rounded-xl border px-4 py-1.5 text-xs font-bold"
|
||||
|
||||
+70
-40
@@ -2,7 +2,12 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
import type { User } from '../types'
|
||||
import { refreshToken } from '../api/refresh'
|
||||
import { requestRefreshTokens } from '../api/refresh'
|
||||
import {
|
||||
createBrowserExclusiveRunner,
|
||||
createRefreshCoordinator,
|
||||
type RefreshOutcome,
|
||||
} from '../utils/authRefresh'
|
||||
|
||||
// Single source of truth for the authenticated user + JWT.
|
||||
// Persisted to localStorage so a page reload does not drop the session.
|
||||
@@ -16,50 +21,65 @@ interface AuthState {
|
||||
setToken: (token: string) => void
|
||||
setRefreshToken: (refreshToken: string) => void
|
||||
logout: () => void
|
||||
/**
|
||||
* 刷新会话的唯一入口(同标签页 + 跨标签页收敛成一次请求)。
|
||||
*
|
||||
* 返回的 outcome 决定调用方怎么处理:
|
||||
* - 'refreshed' 已拿到新令牌,可重试原请求;
|
||||
* - 'invalid' 服务端明确判定凭证失效,本地会话已清空,需重新登录;
|
||||
* - 'transient' 网络/服务暂时不可用,本地会话保留,稍后重试即可。
|
||||
*/
|
||||
refreshSession: () => Promise<RefreshOutcome>
|
||||
/** 兼容旧调用方:只在真的换到新令牌时返回 true。 */
|
||||
tokenRefresh: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export const AUTH_STORAGE_KEY = 'mebox-auth'
|
||||
|
||||
// 跨标签页互斥:多个标签页共享同一份 refresh token,
|
||||
// 只允许其中一个真正发起刷新。
|
||||
const runRefreshExclusive = createBrowserExclusiveRunner('mebox-auth-refresh')
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
tier: 'free',
|
||||
setSession: (token, refreshToken, user) => set({
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
tier: user.tier || 'free'
|
||||
}),
|
||||
setUser: (user) => set({ user, tier: user.tier || 'free' }),
|
||||
setToken: (token) => set({ token }),
|
||||
setRefreshToken: (refreshToken) => set({ refreshToken }),
|
||||
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
tokenRefresh: async () => {
|
||||
const rt = get().refreshToken
|
||||
if (!rt) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const resp = await refreshToken(rt)
|
||||
set({
|
||||
token: resp.token,
|
||||
refreshToken: resp.refresh_token
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// Refresh failed, need to logout
|
||||
set({ token: null, refreshToken: null, user: null, tier: 'free' })
|
||||
return false
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mebox-auth',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
(set, get) => {
|
||||
const refreshSession = createRefreshCoordinator({
|
||||
readAccessToken: () => get().token,
|
||||
readRefreshToken: () => get().refreshToken,
|
||||
applyTokens: (tokens) =>
|
||||
set({ token: tokens.token, refreshToken: tokens.refresh_token }),
|
||||
clearSession: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
request: requestRefreshTokens,
|
||||
runExclusive: runRefreshExclusive,
|
||||
// 其他标签页刷新成功后会写回 localStorage;重新读进来,避免本标签页
|
||||
// 继续持有已被服务端轮换作废的旧 refresh token。
|
||||
syncFromStorage: () => useAuthStore.persist.rehydrate(),
|
||||
})
|
||||
|
||||
return {
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
tier: 'free',
|
||||
setSession: (token, refreshToken, user) => set({
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
tier: user.tier || 'free'
|
||||
}),
|
||||
setUser: (user) => set({ user, tier: user.tier || 'free' }),
|
||||
setToken: (token) => set({ token }),
|
||||
setRefreshToken: (refreshToken) => set({ refreshToken }),
|
||||
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
refreshSession,
|
||||
tokenRefresh: async () => (await refreshSession()) === 'refreshed',
|
||||
}
|
||||
},
|
||||
{
|
||||
name: AUTH_STORAGE_KEY,
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
user: state.user,
|
||||
tier: state.tier
|
||||
}),
|
||||
@@ -67,6 +87,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
),
|
||||
)
|
||||
|
||||
// storage 事件只在其他标签页写入时触发:登出/轮换后同步本地会话,
|
||||
// 否则本标签页会继续用已失效的令牌发请求。
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === AUTH_STORAGE_KEY) {
|
||||
void useAuthStore.persist.rehydrate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if user is authenticated
|
||||
export function isAuthenticated(): boolean {
|
||||
return useAuthStore.getState().token !== null
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// 刷新令牌并发保护与失败分类的回归测试。
|
||||
// 纯逻辑,用 node 直接跑:node src/utils/authRefresh.test.ts
|
||||
// (对齐 utils/mediaVersion.test.ts 的写法)。
|
||||
import {
|
||||
RefreshRequestError,
|
||||
createRefreshCoordinator,
|
||||
createSingleFlightRunner,
|
||||
refreshFailureOutcome,
|
||||
type RefreshOutcome,
|
||||
type RefreshTokens,
|
||||
} from './authRefresh.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`authRefresh: ${name}`)
|
||||
}
|
||||
|
||||
async function expectOutcome(name: string, promise: Promise<RefreshOutcome>, want: RefreshOutcome) {
|
||||
const got = await promise
|
||||
if (got !== want) throw new Error(`authRefresh: ${name} = ${got}, want ${want}`)
|
||||
}
|
||||
|
||||
// ── 失败分类:只有服务端明确拒绝凭证才算会话失效 ──────────────────────────
|
||||
check('401 is invalid', refreshFailureOutcome(401) === 'invalid')
|
||||
check('403 is invalid', refreshFailureOutcome(403) === 'invalid')
|
||||
check('400 is invalid', refreshFailureOutcome(400) === 'invalid')
|
||||
check('network failure is transient', refreshFailureOutcome(undefined) === 'transient')
|
||||
check('502 gateway is transient', refreshFailureOutcome(502) === 'transient')
|
||||
check('503 gateway is transient', refreshFailureOutcome(503) === 'transient')
|
||||
check('timeout is transient', refreshFailureOutcome(408) === 'transient')
|
||||
check('429 is transient', refreshFailureOutcome(429) === 'transient')
|
||||
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
|
||||
|
||||
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
|
||||
{
|
||||
let calls = 0
|
||||
let release: (() => void) | null = null
|
||||
const run = createSingleFlightRunner(async () => {
|
||||
calls += 1
|
||||
const current = calls
|
||||
// 只有第一次执行停在闸门上,用来观察「在途期间不会重复执行」。
|
||||
if (current === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
}
|
||||
return current
|
||||
})
|
||||
|
||||
const first = run()
|
||||
const second = run()
|
||||
check('single flight shares one promise', first === second)
|
||||
// 任务体在下一个微任务才开始,排在其后放行即可确保 release 已就绪。
|
||||
queueMicrotask(() => release?.())
|
||||
check('single flight result', (await first) === 1)
|
||||
check('single flight stayed single', calls === 1)
|
||||
check('single flight re-runs after settle', (await run()) === 2)
|
||||
}
|
||||
|
||||
// ── 协调器 ────────────────────────────────────────────────────────────────
|
||||
function coordinatorHarness(overrides: {
|
||||
refreshToken?: string | null
|
||||
accessToken?: string | null
|
||||
request?: (token: string) => Promise<RefreshTokens>
|
||||
onApply?: (tokens: RefreshTokens) => void
|
||||
onClear?: () => void
|
||||
syncFromStorage?: () => void
|
||||
}) {
|
||||
const state = {
|
||||
refreshToken: overrides.refreshToken === undefined ? 'rt-1' : overrides.refreshToken,
|
||||
accessToken: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken,
|
||||
applied: [] as RefreshTokens[],
|
||||
cleared: 0,
|
||||
requests: [] as string[],
|
||||
}
|
||||
const refreshSession = createRefreshCoordinator({
|
||||
readAccessToken: () => state.accessToken,
|
||||
readRefreshToken: () => state.refreshToken,
|
||||
applyTokens: (tokens) => {
|
||||
state.applied.push(tokens)
|
||||
state.accessToken = tokens.token
|
||||
state.refreshToken = tokens.refresh_token
|
||||
overrides.onApply?.(tokens)
|
||||
},
|
||||
clearSession: () => {
|
||||
state.cleared += 1
|
||||
state.accessToken = null
|
||||
state.refreshToken = null
|
||||
overrides.onClear?.()
|
||||
},
|
||||
request: async (token) => {
|
||||
state.requests.push(token)
|
||||
return overrides.request ? overrides.request(token) : { token: 'at-2', refresh_token: 'rt-2' }
|
||||
},
|
||||
runExclusive: (task) => task(),
|
||||
syncFromStorage: () => overrides.syncFromStorage?.(),
|
||||
})
|
||||
return { state, refreshSession }
|
||||
}
|
||||
|
||||
// 并发刷新只发一次请求,且都拿到成功结果(部署后 WS 重连 + 拦截器同时刷新)。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({})
|
||||
const [a, b] = await Promise.all([refreshSession(), refreshSession()])
|
||||
check('concurrent refresh both succeed', a === 'refreshed' && b === 'refreshed')
|
||||
check('concurrent refresh sends one request', state.requests.length === 1)
|
||||
check('concurrent refresh applies once', state.applied.length === 1)
|
||||
}
|
||||
|
||||
// 服务暂时不可用(502 / 网络错误)时保留会话,不清空本地令牌。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('bad gateway', 502)
|
||||
},
|
||||
})
|
||||
await expectOutcome('gateway error', refreshSession(), 'transient')
|
||||
check('gateway error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
|
||||
}
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('network down')
|
||||
},
|
||||
})
|
||||
await expectOutcome('network error', refreshSession(), 'transient')
|
||||
check('network error keeps session', state.cleared === 0 && state.refreshToken === 'rt-1')
|
||||
}
|
||||
|
||||
// 服务端明确判定凭证失效(401 revoked)时才清空会话。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
throw new RefreshRequestError('refresh token revoked', 401)
|
||||
},
|
||||
})
|
||||
await expectOutcome('revoked token', refreshSession(), 'invalid')
|
||||
check('revoked token clears session', state.cleared === 1 && state.refreshToken === null)
|
||||
}
|
||||
|
||||
// 没有 refresh token 时直接判定失效,不发请求。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({ refreshToken: null })
|
||||
await expectOutcome('missing refresh token', refreshSession(), 'invalid')
|
||||
check('missing refresh token sends no request', state.requests.length === 0)
|
||||
}
|
||||
|
||||
// 另一个标签页已经换过令牌:直接复用,不能拿旧 token 再刷(那会被判 401)。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
syncFromStorage: () => {
|
||||
state.refreshToken = 'rt-other-tab'
|
||||
state.accessToken = 'at-other-tab'
|
||||
},
|
||||
})
|
||||
await expectOutcome('other tab already rotated', refreshSession(), 'refreshed')
|
||||
check('other tab rotation sends no request', state.requests.length === 0)
|
||||
check('other tab rotation keeps new session', state.refreshToken === 'rt-other-tab')
|
||||
}
|
||||
|
||||
// 另一个标签页登出了:本标签页也应判定失效。
|
||||
{
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
syncFromStorage: () => {
|
||||
state.refreshToken = null
|
||||
state.accessToken = null
|
||||
},
|
||||
})
|
||||
await expectOutcome('other tab logged out', refreshSession(), 'invalid')
|
||||
check('other tab logout sends no request', state.requests.length === 0)
|
||||
}
|
||||
|
||||
// 瞬时故障后仍可重试:会话没被清空,下一次刷新能成功。
|
||||
{
|
||||
let attempt = 0
|
||||
const { state, refreshSession } = coordinatorHarness({
|
||||
request: async () => {
|
||||
attempt += 1
|
||||
if (attempt === 1) throw new RefreshRequestError('network down')
|
||||
return { token: 'at-retry', refresh_token: 'rt-retry' }
|
||||
},
|
||||
})
|
||||
await expectOutcome('first transient attempt', refreshSession(), 'transient')
|
||||
await expectOutcome('retry after transient', refreshSession(), 'refreshed')
|
||||
check('retry used the same token', state.requests[1] === 'rt-1')
|
||||
check('retry applied new tokens', state.refreshToken === 'rt-retry')
|
||||
}
|
||||
|
||||
console.log('authRefresh.test.ts ok')
|
||||
@@ -0,0 +1,148 @@
|
||||
// 刷新令牌的并发保护与失败分类。
|
||||
//
|
||||
// 这里是纯逻辑(不 import 任何模块),便于用 node 直接跑回归测试
|
||||
// (对齐 utils/mediaVersion.test.ts 的写法)。
|
||||
//
|
||||
// 背景:refresh token 是一次性凭证,服务端刷新成功即作废旧 token。真实
|
||||
// 客户端却天然会并发使用同一个令牌——同一标签页里 WebSocket 重连与 401
|
||||
// 拦截器、多个标签页共享一份 localStorage、部署/重启后多个页面同时刷新。
|
||||
// 此前第二个请求必然拿到 401,前端把 401 当成会话失效并清空本地令牌,
|
||||
// 外部表现就是「每次部署之后都要重新登录」。本模块负责:
|
||||
//
|
||||
// 1. 把刷新收敛成单次执行(single-flight + 浏览器跨标签页锁);
|
||||
// 2. 只在服务端明确拒绝凭证时才清空会话,网络/网关故障保留会话等重试。
|
||||
|
||||
/** 一次刷新尝试的结果。 */
|
||||
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
|
||||
|
||||
/** 刷新接口返回的令牌对。 */
|
||||
export interface RefreshTokens {
|
||||
token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
/** 刷新请求失败;status 缺失表示网络不可达/超时等非 HTTP 失败。 */
|
||||
export class RefreshRequestError extends Error {
|
||||
readonly status?: number
|
||||
|
||||
constructor(message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'RefreshRequestError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断刷新失败属于「会话确实失效」还是「暂时不可用」。
|
||||
*
|
||||
* 只有服务端明确答复凭证无效才允许清空会话。部署/容器重启窗口里的连接
|
||||
* 失败、网关 502、超时都只是暂时不可用:把 token 留在本地等下一次重试,
|
||||
* 否则一次瞬时故障就会把用户永久踢到登录页。
|
||||
*/
|
||||
export function refreshFailureOutcome(status?: number): RefreshOutcome {
|
||||
if (typeof status !== 'number' || !Number.isFinite(status) || status <= 0) {
|
||||
return 'transient'
|
||||
}
|
||||
if (status === 408 || status === 429 || status >= 500) {
|
||||
return 'transient'
|
||||
}
|
||||
if (status === 400 || status === 401 || status === 403) {
|
||||
return 'invalid'
|
||||
}
|
||||
// 其他状态码(含网关/代理的非标准应答)都无法证明会话失效。
|
||||
return 'transient'
|
||||
}
|
||||
|
||||
/** 从抛出的错误里取出 HTTP 状态码。 */
|
||||
export function httpStatusOf(error: unknown): number | undefined {
|
||||
if (error instanceof RefreshRequestError) return error.status
|
||||
const status = (error as { status?: unknown } | null)?.status
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 让并发调用共享同一次执行,结束后允许下一次重新执行。
|
||||
*
|
||||
* 这是同标签页内的收敛点:无论是 401 拦截器还是 WebSocket 重连触发刷新,
|
||||
* 都复用同一个在途请求,不会拿同一个一次性凭证发两次。
|
||||
*/
|
||||
export function createSingleFlightRunner<T>(task: () => Promise<T>): () => Promise<T> {
|
||||
let inflight: Promise<T> | null = null
|
||||
return () => {
|
||||
if (inflight) return inflight
|
||||
inflight = Promise.resolve()
|
||||
.then(task)
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨标签页互斥执行(Web Locks API)。
|
||||
*
|
||||
* 多个标签页共享同一份 localStorage 里的 refresh token,各自刷新的结果就是
|
||||
* 互相把对方打成 401。拿锁后只有第一个标签页真正发请求,其余标签页在锁内
|
||||
* 重新读取 localStorage,直接复用刚轮换出来的会话。
|
||||
* 浏览器不支持 Web Locks 时退化成纯单标签页收敛。
|
||||
*/
|
||||
export function createBrowserExclusiveRunner(
|
||||
lockName: string,
|
||||
): <T>(task: () => Promise<T>) => Promise<T> {
|
||||
return <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
|
||||
if (!locks || typeof locks.request !== 'function') return task()
|
||||
try {
|
||||
return locks.request(lockName, () => task()) as Promise<T>
|
||||
} catch {
|
||||
return task()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RefreshCoordinatorDeps {
|
||||
readAccessToken: () => string | null
|
||||
readRefreshToken: () => string | null
|
||||
applyTokens: (tokens: RefreshTokens) => void
|
||||
clearSession: () => void
|
||||
request: (refreshToken: string) => Promise<RefreshTokens>
|
||||
runExclusive: <T>(task: () => Promise<T>) => Promise<T>
|
||||
/** 重新读取持久化存储,用于拿到其他标签页写回的令牌。 */
|
||||
syncFromStorage: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造全局唯一的刷新入口。返回的函数可以被任意并发调用,
|
||||
* 结果只会是 'refreshed' | 'invalid' | 'transient'。
|
||||
*/
|
||||
export function createRefreshCoordinator(
|
||||
deps: RefreshCoordinatorDeps,
|
||||
): () => Promise<RefreshOutcome> {
|
||||
return createSingleFlightRunner(async (): Promise<RefreshOutcome> => {
|
||||
const tokenAtEntry = deps.readRefreshToken()
|
||||
// 没有 refresh token 就无从恢复会话。
|
||||
if (!tokenAtEntry) return 'invalid'
|
||||
|
||||
return deps.runExclusive(async () => {
|
||||
// 其他标签页可能刚完成轮换:先同步它写回的令牌,
|
||||
// 否则本标签页会拿已经被服务端作废的旧 token 去刷新。
|
||||
await deps.syncFromStorage()
|
||||
const current = deps.readRefreshToken()
|
||||
if (!current) return 'invalid'
|
||||
if (current !== tokenAtEntry) {
|
||||
// 令牌已被其他标签页换新:直接用新会话,不再发请求。
|
||||
return deps.readAccessToken() ? 'refreshed' : 'invalid'
|
||||
}
|
||||
|
||||
try {
|
||||
deps.applyTokens(await deps.request(current))
|
||||
return 'refreshed'
|
||||
} catch (error) {
|
||||
const outcome = refreshFailureOutcome(httpStatusOf(error))
|
||||
if (outcome === 'invalid') deps.clearSession()
|
||||
return outcome
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user