优化
This commit is contained in:
+12
-5
@@ -2,7 +2,7 @@ import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestCo
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
||||
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
|
||||
import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
|
||||
import { ApiError, isApiEnvelope, isApiFailure, type ApiEnvelope } from '@/api/types'
|
||||
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
|
||||
|
||||
const transport = axios.create({
|
||||
@@ -16,6 +16,13 @@ interface RetryableRequest extends InternalAxiosRequestConfig {
|
||||
}
|
||||
|
||||
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
||||
if (!isApiEnvelope(envelope)) {
|
||||
const invalid: unknown = envelope
|
||||
if (isApiFailure(invalid)) {
|
||||
throw new ApiError(invalid.message, invalid.code, undefined, undefined, invalid.traceId, invalid.requestId)
|
||||
}
|
||||
throw new ApiError('接口响应格式错误', 'INVALID_RESPONSE')
|
||||
}
|
||||
if (envelope.code !== '200') {
|
||||
const fieldErrors = asFieldErrors(envelope.data)
|
||||
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
|
||||
@@ -28,8 +35,8 @@ function mapError(error: unknown) {
|
||||
if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败')
|
||||
|
||||
const body = error.response?.data
|
||||
if (isApiEnvelope(body)) {
|
||||
const fieldErrors = asFieldErrors(body.data)
|
||||
if (isApiEnvelope(body) || isApiFailure(body)) {
|
||||
const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined)
|
||||
return new ApiError(
|
||||
apiErrorMessage(body.message, fieldErrors),
|
||||
body.code,
|
||||
@@ -170,8 +177,8 @@ async function throwBlobApiError(error: unknown, responseStatus?: number) {
|
||||
if (!blob || !blob.type.toLowerCase().includes('json')) return
|
||||
try {
|
||||
const body = JSON.parse(await blob.text()) as unknown
|
||||
if (!isApiEnvelope(body)) return
|
||||
const fieldErrors = asFieldErrors(body.data)
|
||||
if (!isApiEnvelope(body) && !isApiFailure(body)) return
|
||||
const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined)
|
||||
throw new ApiError(
|
||||
apiErrorMessage(body.message, fieldErrors),
|
||||
body.code,
|
||||
|
||||
@@ -26,8 +26,8 @@ test('does not accept malformed or data-less successful responses', () => {
|
||||
assert.equal(isApiEnvelope({ code: '200', message: 'OK', data: [] }), true)
|
||||
})
|
||||
|
||||
test('overseas transport handles business-code and HTTP security failures', async () => {
|
||||
const transport = await readFile(new URL('./overseasHttp.ts', import.meta.url), 'utf8')
|
||||
assert.match(transport, /if \(isApiFailure\(response.data\)\)/)
|
||||
test('unified transport handles business-code and HTTP security failures', async () => {
|
||||
const transport = await readFile(new URL('./http.ts', import.meta.url), 'utf8')
|
||||
assert.match(transport, /if \(isApiFailure\(invalid\)\)/)
|
||||
assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/)
|
||||
})
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { ApiError, isApiEnvelope, isApiFailure } from '@/api/types'
|
||||
import { getOverseasAccessToken } from '@/api/session'
|
||||
import { refreshAuthenticatedSession } from '@/api/http'
|
||||
|
||||
interface RetryableRequest extends InternalAxiosRequestConfig { _retriedOverseasAuth?: boolean }
|
||||
|
||||
const transport = axios.create({
|
||||
baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api',
|
||||
timeout: 15_000,
|
||||
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
|
||||
})
|
||||
|
||||
async function requireToken() {
|
||||
const current = getOverseasAccessToken()
|
||||
if (current) return current
|
||||
await refreshAuthenticatedSession()
|
||||
const refreshed = getOverseasAccessToken()
|
||||
if (!refreshed) throw new ApiError('当前环境未签发海外访问令牌', 'OVERSEAS_TOKEN_UNAVAILABLE')
|
||||
return refreshed
|
||||
}
|
||||
|
||||
transport.interceptors.request.use(async (config) => {
|
||||
config.headers.Authorization = `Bearer ${await requireToken()}`
|
||||
return config
|
||||
})
|
||||
|
||||
transport.interceptors.response.use((response) => response, async (error) => {
|
||||
const request = error.config as RetryableRequest | undefined
|
||||
if (error.response?.status !== 401 || !request || request._retriedOverseasAuth) throw error
|
||||
request._retriedOverseasAuth = true
|
||||
await refreshAuthenticatedSession()
|
||||
request.headers.Authorization = `Bearer ${await requireToken()}`
|
||||
return transport.request(request)
|
||||
})
|
||||
|
||||
export async function overseasApiRequest<T>(config: AxiosRequestConfig) {
|
||||
try {
|
||||
const response = await transport.request<unknown>(config)
|
||||
if (isApiFailure(response.data)) {
|
||||
throw new ApiError(response.data.message, response.data.code, response.status, undefined,
|
||||
response.data.traceId, response.data.requestId)
|
||||
}
|
||||
if (!isApiEnvelope(response.data) || response.data.code !== '200') {
|
||||
throw new ApiError(
|
||||
isApiEnvelope(response.data) ? response.data.message : '海外接口响应格式错误',
|
||||
isApiEnvelope(response.data) ? response.data.code : 'INVALID_RESPONSE',
|
||||
undefined,
|
||||
undefined,
|
||||
isApiEnvelope(response.data) ? response.data.traceId : null,
|
||||
isApiEnvelope(response.data) ? response.data.requestId : null,
|
||||
)
|
||||
}
|
||||
return response.data.data as T
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error
|
||||
if (axios.isAxiosError(error)) {
|
||||
const body = error.response?.data
|
||||
if (isApiEnvelope(body) || isApiFailure(body)) {
|
||||
throw new ApiError(body.message, body.code, error.response?.status, undefined, body.traceId, body.requestId)
|
||||
}
|
||||
throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status)
|
||||
}
|
||||
throw new ApiError(error instanceof Error ? error.message : '海外接口请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
export async function overseasBlobRequest(config: AxiosRequestConfig) {
|
||||
try {
|
||||
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error
|
||||
if (axios.isAxiosError(error)) {
|
||||
const body = error.response?.data
|
||||
if (body instanceof Blob && body.type.includes('json')) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await body.text())
|
||||
if (isApiEnvelope(parsed) || isApiFailure(parsed)) {
|
||||
throw new ApiError(parsed.message, parsed.code, error.response?.status, undefined, parsed.traceId, parsed.requestId)
|
||||
}
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof ApiError) throw parseError
|
||||
}
|
||||
}
|
||||
throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status)
|
||||
}
|
||||
throw new ApiError(error instanceof Error ? error.message : '海外文件下载失败')
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
export const ACCESS_TOKEN_KEY = 'cc-client-access-token'
|
||||
export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token'
|
||||
export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token'
|
||||
export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at'
|
||||
export const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id'
|
||||
|
||||
export interface TokenPair {
|
||||
@@ -10,14 +8,8 @@ export interface TokenPair {
|
||||
expiresIn: number
|
||||
refreshToken: string
|
||||
refreshExpiresIn: number
|
||||
overseasAccessToken?: string | null
|
||||
overseasExpiresIn?: number
|
||||
}
|
||||
|
||||
function activeStorage() {
|
||||
if (localStorage.getItem(ACCESS_TOKEN_KEY)) return localStorage
|
||||
return sessionStorage
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY) || sessionStorage.getItem(ACCESS_TOKEN_KEY) || ''
|
||||
@@ -27,21 +19,11 @@ export function getRefreshToken() {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
export function getOverseasAccessToken() {
|
||||
const storage = activeStorage()
|
||||
const expiresAt = Number(storage.getItem(OVERSEAS_EXPIRES_AT_KEY) || 0)
|
||||
if (expiresAt <= Date.now() + 30_000) return ''
|
||||
return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
export function persistTokenPair(tokens: TokenPair) {
|
||||
clearSessionTokens()
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||
if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) {
|
||||
localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken)
|
||||
localStorage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000))
|
||||
}
|
||||
}
|
||||
|
||||
export function getRememberedLoginId() {
|
||||
@@ -62,8 +44,6 @@ export function clearSessionTokens() {
|
||||
for (const storage of [localStorage, sessionStorage]) {
|
||||
storage.removeItem(ACCESS_TOKEN_KEY)
|
||||
storage.removeItem(REFRESH_TOKEN_KEY)
|
||||
storage.removeItem(OVERSEAS_ACCESS_TOKEN_KEY)
|
||||
storage.removeItem(OVERSEAS_EXPIRES_AT_KEY)
|
||||
storage.removeItem('cc-client-token')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import axios from 'axios'
|
||||
|
||||
function compile(name, dependencies = {}) {
|
||||
const source = readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||
.replace('import.meta.env.VITE_API_BASE_URL', "'/api'")
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
||||
}).outputText
|
||||
const exports = {}
|
||||
new Function('require', 'exports', code)((id) => {
|
||||
assert.ok(id in dependencies, `unexpected dependency ${id}`)
|
||||
return dependencies[id]
|
||||
}, exports)
|
||||
return exports
|
||||
}
|
||||
|
||||
function harness(adapter) {
|
||||
let token = 'old-access'
|
||||
let refreshToken = 'old-refresh'
|
||||
const http = compile('./http.ts', {
|
||||
axios: { ...axios, create: (config) => axios.create({ ...config, adapter }) },
|
||||
'@chongchong/shared': { parseApiJsonResponse: (data) => data },
|
||||
'@/api/session': {
|
||||
getAccessToken: () => token, getRefreshToken: () => refreshToken,
|
||||
persistTokenPair: (tokens) => { token = tokens.accessToken; refreshToken = tokens.refreshToken },
|
||||
clearSessionTokens: () => { token = ''; refreshToken = '' },
|
||||
},
|
||||
'@/api/errorDetails': compile('./errorDetails.ts'),
|
||||
'@/api/types': compile('./types.ts'),
|
||||
'@/api/crossTabRefresh': compile('./crossTabRefresh.ts'),
|
||||
})
|
||||
return http
|
||||
}
|
||||
|
||||
test('order and lastleg 401s share one refresh and replay the unified access token', async () => {
|
||||
let refreshes = 0
|
||||
const calls = []
|
||||
const http = harness(async (config) => {
|
||||
calls.push({ url: config.url, base: config.baseURL, token: config.headers.Authorization })
|
||||
if (config.url === '/auth/refresh') {
|
||||
refreshes++
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: 'new-access', refreshToken: 'new-refresh' } } }
|
||||
}
|
||||
if (config.headers.Authorization === 'Bearer old-access') {
|
||||
throw new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } })
|
||||
}
|
||||
return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: config.url } }
|
||||
})
|
||||
const urls = ['/merchant/v1/orders', '/merchant/v1/last-leg/inbound-plans']
|
||||
assert.deepEqual(await Promise.all(urls.map((url) => http.apiRequest({ url }))), urls)
|
||||
assert.equal(refreshes, 1)
|
||||
assert.ok(calls.every((call) => call.base === '/api'))
|
||||
assert.equal(calls.find((call) => call.url === '/auth/refresh').token, undefined)
|
||||
assert.equal(calls.filter((call) => call.token === 'Bearer new-access').length, 2)
|
||||
})
|
||||
|
||||
test('data-less security failures preserve diagnostics and malformed success is rejected', async () => {
|
||||
for (const data of [{ code: 'A0403', message: 'Denied', traceId: 'trace-1', requestId: 'request-1' }, { code: '200', message: 'OK' }]) {
|
||||
const http = harness(async (config) => ({ config, status: 200, headers: {}, data }))
|
||||
await assert.rejects(http.apiRequest({ url: '/merchant/v1/last-leg/inbound-plans' }), (error) => {
|
||||
assert.equal(error.code, data.code === '200' ? 'INVALID_RESPONSE' : 'A0403')
|
||||
if (data.code !== '200') { assert.equal(error.traceId, 'trace-1'); assert.equal(error.requestId, 'request-1') }
|
||||
return true
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user