优化登录,优化阅读

This commit is contained in:
truewhile
2026-10-03 21:00:16 +08:00
parent 5242d54a06
commit b0f1ab39b9
22 changed files with 1513 additions and 155 deletions
+188
View File
@@ -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')
+148
View File
@@ -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
}
})
})
}