优化
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Admin Shared API Instructions
|
||||
|
||||
## 适用范围与入口
|
||||
|
||||
- 本文件适用于 `src/shared/api/` 及其测试。业务模块只通过领域 `api/` 适配层调用这里的客户端,不在页面和组件直接创建 Axios 实例。
|
||||
- 国内 API 客户端为 `http.ts`;海外 API 客户端为 `overseasHttp.ts`。认证会话编排在 `../../core/auth/auth.ts`,不得在本目录复制登录状态。
|
||||
|
||||
## 国内与海外请求
|
||||
|
||||
- `http` 使用 `VITE_API_BASE_URL`,默认走国内代理;国内登录、刷新和退出仍由统一 Auth 处理。
|
||||
- `overseasHttp` 使用 `VITE_OVERSEAS_API_BASE_URL`,默认 `/overseas-api`;Vite 将该前缀改写为海外 Gateway 的 `/api`。代理上游由开发环境配置,不在源码或指引中记录真实地址。
|
||||
- 登录和刷新响应一次性返回国内 Token、`overseasAccessToken`、`overseasExpiresIn`;`core/auth/auth.ts` 必须原子替换两套访问凭据。不得恢复 Token 兑换端点或让业务模块单独刷新海外 Token。
|
||||
- 海外 Token 只由 `replaceOverseasAccessToken`、`clearOverseasAccessToken` 管理;业务模块不得读写缓存、LocalStorage 或手工拼装 `Authorization`。
|
||||
- 国内、海外 401 共用统一会话刷新编排。海外请求最多统一刷新一次并重放一次;刷新失败按会话失效处理,不循环重试或回退假数据。
|
||||
|
||||
## 领域调用边界
|
||||
|
||||
- 国内业务领域 API 使用 `http`;四期海外尾程、海外仓资源和海外本土业务使用 `overseasHttp`,不得经国内 Gateway 中转。
|
||||
- 页面只调用所属模块的 API 封装;模块 API 负责路径、DTO 和错误适配,不把 Axios 响应或区域选择泄漏到页面。
|
||||
- 跨区域 Token、Audience、JWKS 和服务通信公共契约读取 `../../../../../docs/跨区域认证与服务通信契约.md`。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 修改客户端、拦截器、刷新、Token 生命周期或 Base URL 时,联查 `http.test.ts`、`overseasHttp.test.ts`、`core/auth/auth.test.ts`、Vite 代理、`.env.example` 和全部实际消费者。
|
||||
- 至少运行相关 API/Auth 测试和 `pnpm typecheck`;交付前按项目指引运行完整测试与构建。
|
||||
+18
-13
@@ -61,6 +61,23 @@ export function registerAuthSessionExpiredHandler(handler: AuthSessionExpiredHan
|
||||
authSessionExpiredHandler = handler
|
||||
}
|
||||
|
||||
export async function refreshAuthenticatedSession(): Promise<string> {
|
||||
if (!authRefreshHandler) throw new Error('认证刷新处理器尚未初始化')
|
||||
if (!activeRefresh) {
|
||||
activeRefresh = authRefreshHandler()
|
||||
.catch(async (refreshError) => {
|
||||
try {
|
||||
await authSessionExpiredHandler?.()
|
||||
} catch {
|
||||
// 会话已清理,跳转失败不应覆盖原始认证错误。
|
||||
}
|
||||
throw refreshError
|
||||
})
|
||||
.finally(() => { activeRefresh = null })
|
||||
}
|
||||
return activeRefresh
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('cc-admin-token')
|
||||
const isAnonymousAuthEndpoint = typeof config.url === 'string'
|
||||
@@ -82,19 +99,7 @@ http.interceptors.response.use(
|
||||
|
||||
config._authRetry = true
|
||||
try {
|
||||
if (!activeRefresh) {
|
||||
activeRefresh = authRefreshHandler()
|
||||
.catch(async (refreshError) => {
|
||||
try {
|
||||
await authSessionExpiredHandler?.()
|
||||
} catch {
|
||||
// 会话已清理,跳转失败不应覆盖原始认证错误。
|
||||
}
|
||||
throw refreshError
|
||||
})
|
||||
.finally(() => { activeRefresh = null })
|
||||
}
|
||||
const nextToken = await activeRefresh
|
||||
const nextToken = await refreshAuthenticatedSession()
|
||||
config.headers.Authorization = `Bearer ${nextToken}`
|
||||
return http.request(config)
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { registerAuthRefreshHandler } from './http'
|
||||
import { clearOverseasAccessToken, overseasHttp, replaceOverseasAccessToken } from './overseasHttp'
|
||||
|
||||
const originalOverseasAdapter = overseasHttp.defaults.adapter
|
||||
|
||||
function success(config: InternalAxiosRequestConfig, data: unknown) {
|
||||
return Promise.resolve({ status: 200, statusText: 'OK', headers: {}, config, data })
|
||||
}
|
||||
|
||||
describe('overseas HTTP client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('cc-admin-token', 'domestic-token')
|
||||
clearOverseasAccessToken()
|
||||
registerAuthRefreshHandler(async () => 'domestic-token')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
overseasHttp.defaults.adapter = originalOverseasAdapter
|
||||
localStorage.clear()
|
||||
clearOverseasAccessToken()
|
||||
})
|
||||
|
||||
it('uses the overseas token supplied by the login session for concurrent requests', async () => {
|
||||
replaceOverseasAccessToken('overseas-token', 900)
|
||||
const authorizations: unknown[] = []
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorizations.push(config.headers.Authorization)
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
overseasHttp.get('/platform/v1/last-leg/orders'),
|
||||
overseasHttp.get('/platform/v1/warehouse-admin'),
|
||||
])
|
||||
|
||||
expect(authorizations).toEqual(['Bearer overseas-token', 'Bearer overseas-token'])
|
||||
})
|
||||
|
||||
it('refreshes the unified session once and replays once after an overseas 401', async () => {
|
||||
replaceOverseasAccessToken('overseas-1', 900)
|
||||
const refresh = vi.fn(async () => {
|
||||
replaceOverseasAccessToken('overseas-2', 900)
|
||||
return 'domestic-2'
|
||||
})
|
||||
registerAuthRefreshHandler(refresh)
|
||||
let requests = 0
|
||||
const authorizations: unknown[] = []
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorizations.push(config.headers.Authorization)
|
||||
if (requests++ === 0) {
|
||||
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: {},
|
||||
config,
|
||||
data: { code: 'A0401', message: '海外令牌失效' },
|
||||
})
|
||||
}
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await overseasHttp.get('/platform/v1/last-leg/orders')
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(requests).toBe(2)
|
||||
expect(authorizations).toEqual(['Bearer overseas-1', 'Bearer overseas-2'])
|
||||
})
|
||||
|
||||
it('refreshes the unified session when no usable overseas token is present', async () => {
|
||||
const refresh = vi.fn(async () => {
|
||||
replaceOverseasAccessToken('overseas-refreshed', 900)
|
||||
return 'domestic-refreshed'
|
||||
})
|
||||
registerAuthRefreshHandler(refresh)
|
||||
let authorization: unknown
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorization = config.headers.Authorization
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await overseasHttp.get('/platform/v1/last-leg/orders')
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(authorization).toBe('Bearer overseas-refreshed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { refreshAuthenticatedSession } from './http'
|
||||
|
||||
interface CachedOverseasToken {
|
||||
accessToken: string
|
||||
refreshAfter: number
|
||||
}
|
||||
|
||||
type RetryableOverseasRequestConfig = InternalAxiosRequestConfig & { _overseasAuthRetry?: boolean }
|
||||
|
||||
const TOKEN_REFRESH_SAFETY_WINDOW_MS = 30_000
|
||||
|
||||
let cachedToken: CachedOverseasToken | null = null
|
||||
|
||||
export const overseasHttp = axios.create({
|
||||
baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api',
|
||||
timeout: 15_000,
|
||||
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
|
||||
})
|
||||
|
||||
function currentOverseasAccessToken() {
|
||||
return cachedToken && cachedToken.refreshAfter > Date.now() ? cachedToken.accessToken : ''
|
||||
}
|
||||
|
||||
async function requireOverseasAccessToken() {
|
||||
const current = currentOverseasAccessToken()
|
||||
if (current) return current
|
||||
await refreshAuthenticatedSession()
|
||||
const refreshed = currentOverseasAccessToken()
|
||||
if (!refreshed) throw new Error('当前环境未签发海外访问令牌')
|
||||
return refreshed
|
||||
}
|
||||
|
||||
export function replaceOverseasAccessToken(accessToken: string | null, expiresIn: number) {
|
||||
if (!accessToken && expiresIn === 0) {
|
||||
cachedToken = null
|
||||
return
|
||||
}
|
||||
if (!accessToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
||||
throw new Error('海外访问令牌响应无效')
|
||||
}
|
||||
cachedToken = {
|
||||
accessToken,
|
||||
refreshAfter: Date.now() + Math.max(0, expiresIn * 1000 - TOKEN_REFRESH_SAFETY_WINDOW_MS),
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOverseasAccessToken() {
|
||||
cachedToken = null
|
||||
}
|
||||
|
||||
overseasHttp.interceptors.request.use(async (config) => {
|
||||
const accessToken = await requireOverseasAccessToken()
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
return config
|
||||
})
|
||||
|
||||
overseasHttp.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const config = error.config as RetryableOverseasRequestConfig | undefined
|
||||
if (error.response?.status !== 401 || !config || config._overseasAuthRetry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
config._overseasAuthRetry = true
|
||||
clearOverseasAccessToken()
|
||||
try {
|
||||
await refreshAuthenticatedSession()
|
||||
const accessToken = await requireOverseasAccessToken()
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
return overseasHttp.request(config)
|
||||
} catch {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user