This commit is contained in:
2026-09-29 17:56:03 +08:00
parent ffbe3480a8
commit a427aca5c3
19 changed files with 219 additions and 221 deletions
+12 -5
View File
@@ -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,
+3 -3
View File
@@ -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\)/)
})
-91
View File
@@ -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 : '海外文件下载失败')
}
}
-20
View File
@@ -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')
}
}
+71
View File
@@ -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
})
}
})