安全页重构:免密登录与活跃会话;按钮规范统一
CI / test (push) Successful in 53s

This commit is contained in:
2026-07-30 12:23:16 +08:00
parent d0fbecbd43
commit d675b950ee
43 changed files with 1944 additions and 475 deletions
+126 -24
View File
@@ -20,8 +20,27 @@ interface RequestOptions {
query?: Record<string, string | number | boolean | undefined>
/** 附加请求头 */
headers?: Record<string, string>
/** 成功响应会换发当前会话;并发 401 须等待其落入 Store 后再决定是否登出 */
refreshesSession?: boolean
}
interface RefreshWaiter {
resolve: (waited: boolean) => void
}
interface RefreshState {
count: number
waiters: RefreshWaiter[]
}
interface ErrorDetails {
message: string
ociCode?: string
rateLimited: boolean
}
const sessionRefreshes = new Map<string, RefreshState>()
function buildUrl(path: string, query?: RequestOptions['query']): string {
if (!query) return BASE + path
const qs = new URLSearchParams()
@@ -32,40 +51,46 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
return s ? `${BASE}${path}?${s}` : BASE + path
}
async function parseError(resp: Response, sentToken: string): Promise<never> {
let message = `请求失败(${resp.status})`
let ociCode: string | undefined
async function parseError(
resp: Response,
sentToken: string,
): Promise<never> {
const details = await readErrorDetails(resp)
if (resp.status === 401) await handleUnauthorized(sentToken)
if (resp.status === 429 && details.rateLimited && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, details.message, details.ociCode)
}
async function readErrorDetails(resp: Response): Promise<ErrorDetails> {
const fallback = { message: `请求失败(${resp.status})`, rateLimited: false }
try {
const body = (await resp.json()) as {
error?: string
hint?: string
errors?: unknown[]
ociCode?: string
code?: string
}
ociCode = body.ociCode
if (body.error) {
message = body.hint ? `${body.hint}|${body.error}` : body.error
} else {
// 批量接口(如创建实例)全部失败时返回 errors 数组,逐行合并;
// 调用方以「短标题 + detail」经 useToast 展示,多行在详情块中逐行可读
const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string')
if (list.length) message = list.join('\n')
}
const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string')
const message = body.error
? body.hint
? `${body.hint}|${body.error}`
: body.error
: list.join('\n') || fallback.message
return { message, ociCode: body.ociCode, rateLimited: body.code === 'RateLimited' }
} catch {
/* 非 JSON 响应体,保留默认消息 */
return fallback
}
if (resp.status === 401) handleUnauthorized(sentToken)
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, message, ociCode)
}
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
function handleUnauthorized(sentToken: string) {
async function handleUnauthorized(sentToken: string) {
const waited = await waitForSessionRefresh(sentToken)
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
const auth = useAuthStore()
if (sentToken !== auth.token) return
auth.logout()
@@ -75,8 +100,80 @@ function handleUnauthorized(sentToken: string) {
}
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
/** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */
const inflightWrites = new Map<string, Promise<unknown>>()
function beginSessionRefresh(token: string) {
const state = sessionRefreshes.get(token)
if (state) {
state.count++
return
}
sessionRefreshes.set(token, { count: 1, waiters: [] })
}
function endSessionRefresh(token: string) {
const state = sessionRefreshes.get(token)
if (!state) return
state.count--
state.waiters = state.waiters.filter((waiter) => {
if (state.count > 0) return true
waiter.resolve(true)
return false
})
if (state.count === 0) sessionRefreshes.delete(token)
}
function waitForSessionRefresh(token: string): Promise<boolean> {
const state = sessionRefreshes.get(token)
if (!state || state.count === 0) return Promise.resolve(false)
return new Promise((resolve) => state.waiters.push({ resolve }))
}
function trackSessionRefresh(token: string): () => void {
beginSessionRefresh(token)
let active = true
return () => {
if (!active) return
active = false
endSessionRefresh(token)
}
}
function applySessionRefresh(value: unknown) {
if (!value || typeof value !== 'object') return
const refresh = value as { token?: unknown; expiresAt?: unknown }
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return
useAuthStore().setSession(refresh.token, refresh.expiresAt)
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError;
* 非 GET 请求按 token+method+url+body 做 in-flight 去重——前一发未返回时复用同一 Promise,
* 防连点造成重复提交;完成(无论成败)即移除。GET 不去重(useAsync 已有后发优先语义)。
* 键含认证上下文:token 换发/切换账号后不得复用旧会话在飞行中的请求 */
export function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const method = (opts.method ?? 'GET').toUpperCase()
if (method === 'GET') return doRequest<T>(path, opts)
const body = opts.body === undefined ? '' : JSON.stringify(opts.body)
const sentToken = useAuthStore().token ?? ''
const key = `${sentToken} ${method} ${buildUrl(path, opts.query)} ${body}`
const existing = inflightWrites.get(key)
if (existing) return existing as Promise<T>
const tracksRefresh = opts.refreshesSession === true && sentToken !== ''
const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {}
const p = doRequest<T>(path, opts, finishRefresh).finally(() => {
inflightWrites.delete(key)
finishRefresh()
})
inflightWrites.set(key, p)
return p
}
async function doRequest<T>(
path: string,
opts: RequestOptions,
finishRefresh: () => void = () => {},
): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
@@ -86,10 +183,15 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
})
if (!resp.ok) await parseError(resp, sentToken)
if (!resp.ok) {
if (resp.status === 401) finishRefresh()
await parseError(resp, sentToken)
}
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T
const result = (text ? JSON.parse(text) : undefined) as T
if (opts.refreshesSession && sentToken) applySessionRefresh(result)
return result
}
/** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */