Files
36-character-flower/src/lib/api/api-client.ts
JiaJun 901ad1c30b refactor(constants): 提取常量并优化国际化配置
- 创建API相关常量文件,包括响应码、HTTP状态码、请求头等
- 将认证相关常量从auth模块提取到独立的常量文件
- 在API客户端中使用新定义的常量替换硬编码值
- 更新认证API和服务中对常量的引用
- 在国际化配置中创建统一的文案常量以减少重复
- 将认证表单验证规则改为使用常量配置
2026-06-02 12:03:29 +08:00

452 lines
10 KiB
TypeScript

import ky, { HTTPError, type Options, TimeoutError } from 'ky'
import {
ACCESS_TOKEN_REFRESH_SKEW_MS,
API_ERROR_MESSAGES,
API_SUCCESS_CODE,
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
AUTH_REFRESH_ENDPOINT,
AUTH_RELOGIN_REQUIRED_CODES,
AUTH_SKIP_REFRESH_CONTEXT_KEY,
AUTH_TOKEN_CACHE_SKEW_MS,
AUTH_TOKEN_ENDPOINT,
AUTHORIZATION_BEARER_PREFIX,
CONTENT_TYPE_JSON,
DEFAULT_REQUEST_ACCEPT_HEADER,
DEFAULT_REQUEST_TIMEOUT_MS,
HTTP_STATUS,
REQUEST_HEADERS,
} from '@/constants'
import type { AuthTokenDto } from '@/features/auth/api/types'
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
import { ApiError } from '@/lib/api/api-error.ts'
import {
handleInvalidTokenSession,
handleUnauthorizedSession,
tryRefreshAuthSession,
} from '@/lib/auth/auth-session'
import { md5 } from '@/lib/crypto/md5'
import {
getAuthDeviceId,
getStoredAppLanguage,
useAuthStore,
} from '@/store/auth'
import type { ApiResponse } from '@/type'
type RequestOptions = Omit<Options, 'json'>
type JsonRequestOptions<TBody> = RequestOptions & {
json?: TBody
}
const appEnv = import.meta.env.VITE_APP_ENV
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
function getRequestLanguage() {
const storedLanguage = getStoredAppLanguage()
if (isSupportedLanguage(storedLanguage)) {
return storedLanguage
}
return getPreferredLanguage()
}
function normalizeApiBaseUrl(baseUrl: string | undefined) {
const candidate = baseUrl?.trim()
if (!candidate) {
throw new Error('VITE_API_BASE_URL 未配置')
}
if (candidate === '/') {
return '/'
}
if (/^https?:\/\//.test(candidate)) {
return candidate.replace(/\/+$/, '')
}
return candidate.replace(/^\/+/, '').replace(/\/+$/, '')
}
async function parseResponseBody(response: Response) {
if (response.status === HTTP_STATUS.noContent) {
return null
}
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes(CONTENT_TYPE_JSON)) {
return response.json()
}
return response.text()
}
function getErrorMessage(response: Response, data: unknown) {
if (data && typeof data === 'object') {
const message =
'message' in data ? data.message : 'msg' in data ? data.msg : null
if (typeof message === 'string' && message.length > 0) {
return message
}
}
return `Request failed with status ${response.status}`
}
async function toApiError(error: unknown) {
if (error instanceof ApiError) {
return error
}
if (error instanceof HTTPError) {
const data = error.data
return new ApiError({
message: getErrorMessage(error.response, data),
status: error.response.status,
data,
url: error.response.url,
})
}
if (error instanceof TimeoutError) {
return new ApiError({
message: API_ERROR_MESSAGES.timeout,
status: HTTP_STATUS.requestTimeout,
})
}
if (error instanceof Error) {
return new ApiError({
message: error.message,
})
}
return new ApiError({
message: API_ERROR_MESSAGES.unexpected,
})
}
export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
const authTokenClient = ky.create({
prefix: apiBaseUrl,
retry: 0,
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
headers: {
Accept: DEFAULT_REQUEST_ACCEPT_HEADER,
},
})
const apiClient = ky.create({
prefix: apiBaseUrl,
retry: 0,
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
hooks: {
beforeRequest: [
({ request }) => {
request.headers.set(
REQUEST_HEADERS.accept,
DEFAULT_REQUEST_ACCEPT_HEADER,
)
request.headers.set(REQUEST_HEADERS.lang, getRequestLanguage())
const token = useAuthStore.getState().accessToken
if (token) {
request.headers.set(
REQUEST_HEADERS.authorization,
`${AUTHORIZATION_BEARER_PREFIX}${token}`,
)
request.headers.set(REQUEST_HEADERS.userToken, token)
}
if (shouldLogRequests) {
console.info(`[api:${appEnv}] ${request.method} ${request.url}`)
}
},
],
afterResponse: [
({ request, response }) => {
if (shouldLogRequests) {
console.info(
`[api:${appEnv}] ${request.method} ${response.url} -> ${response.status}`,
)
}
},
],
},
})
function shouldAttachAuthToken(input: string) {
return input !== AUTH_TOKEN_ENDPOINT
}
function shouldTryRefreshAccessToken(input: string, options?: Options) {
if (
input === AUTH_REFRESH_ENDPOINT ||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] === true
) {
return false
}
const authState = useAuthStore.getState()
return Boolean(
authState.accessToken &&
authState.accessTokenExpiresAt &&
authState.accessTokenExpiresAt <=
Date.now() + ACCESS_TOKEN_REFRESH_SKEW_MS,
)
}
function isApiEnvelope(value: unknown): value is ApiResponse<unknown> {
return Boolean(
value &&
typeof value === 'object' &&
'code' in value &&
typeof value.code === 'number',
)
}
function getApiEnvelopeMessage(response: ApiResponse<unknown>) {
return 'msg' in response && typeof response.msg === 'string'
? response.msg
: 'message' in response && typeof response.message === 'string'
? response.message
: API_ERROR_MESSAGES.unexpected
}
function assertValidAuthEnvelope(data: unknown) {
if (
!isApiEnvelope(data) ||
!AUTH_RELOGIN_REQUIRED_CODES.includes(data.code)
) {
return
}
handleInvalidTokenSession()
throw new ApiError({
data,
message: getApiEnvelopeMessage(data),
status: HTTP_STATUS.unauthorized,
})
}
function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
assertValidAuthEnvelope(response)
if (response.code === API_SUCCESS_CODE) {
return response.data
}
throw new ApiError({
data: response,
message:
'msg' in response && typeof response.msg === 'string'
? response.msg
: 'message' in response && typeof response.message === 'string'
? response.message
: API_ERROR_MESSAGES.unexpected,
})
}
async function fetchAuthToken() {
try {
const authState = useAuthStore.getState()
if (
authState.apiAuthToken &&
authState.apiAuthTokenExpiresAt &&
authState.apiAuthTokenExpiresAt > Date.now() + AUTH_TOKEN_CACHE_SKEW_MS
) {
return authState.apiAuthToken
}
if (!authSecret) {
throw new ApiError({
message: 'auth.errors.authTokenConfigMissing',
})
}
const deviceId = getAuthDeviceId()
const timestamp = Math.floor(Date.now() / 1000)
const signature = md5(
`device_id=${deviceId}&secret=${authSecret}&timestamp=${timestamp}`,
).toUpperCase()
const response = await authTokenClient
.get(AUTH_TOKEN_ENDPOINT, {
searchParams: {
device_id: deviceId,
secret: authSecret,
signature,
timestamp: String(timestamp),
},
})
.json<ApiResponse<AuthTokenDto>>()
const data = unwrapEnvelopeData(response)
const expiresAt = Date.now() + data.expires_in * 1000
useAuthStore.getState().setApiAuthToken({
expiresAt,
serverTime: data.server_time,
value: data.auth_token,
})
return data.auth_token
} catch (error) {
throw await toApiError(error)
}
}
export async function prefetchAuthToken() {
await fetchAuthToken()
}
function createHeaders(headersInit?: Options['headers']) {
const headers = new Headers()
if (!headersInit) {
return headers
}
if (headersInit instanceof Headers) {
headersInit.forEach((value, key) => {
headers.set(key, value)
})
return headers
}
if (Array.isArray(headersInit)) {
for (const [key, value] of headersInit) {
headers.set(key, value)
}
return headers
}
for (const [key, value] of Object.entries(headersInit)) {
if (typeof value === 'string') {
headers.set(key, value)
}
}
return headers
}
async function buildRequestOptions(input: string, options?: Options) {
const headers = createHeaders(options?.headers)
if (shouldAttachAuthToken(input) && !headers.has(REQUEST_HEADERS.authToken)) {
headers.set(REQUEST_HEADERS.authToken, await fetchAuthToken())
}
return {
...options,
headers,
} satisfies Options
}
async function request<TResponse>(input: string, options?: Options) {
try {
if (shouldTryRefreshAccessToken(input, options)) {
await tryRefreshAuthSession()
}
const response = await apiClient(
input,
await buildRequestOptions(input, options),
)
const data = await parseResponseBody(response)
assertValidAuthEnvelope(data)
return data as TResponse
} catch (error) {
if (
error instanceof HTTPError &&
error.response.status === HTTP_STATUS.unauthorized &&
input !== AUTH_REFRESH_ENDPOINT &&
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
) {
const refreshed = await tryRefreshAuthSession()
if (refreshed) {
return request<TResponse>(input, {
...options,
context: {
...options?.context,
[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY]: true,
},
})
}
}
if (
error instanceof HTTPError &&
error.response.status === HTTP_STATUS.unauthorized
) {
handleUnauthorizedSession()
}
throw await toApiError(error)
}
}
async function requestRest<TData>(input: string, options?: Options) {
return request<ApiResponse<TData>>(input, options)
}
export const api = {
get<TData>(input: string, options?: RequestOptions) {
return requestRest<TData>(input, {
...options,
method: 'get',
})
},
post<TData, TBody = unknown>(
input: string,
{ json, ...options }: JsonRequestOptions<TBody> = {},
) {
return requestRest<TData>(input, {
...options,
json,
method: 'post',
})
},
put<TData, TBody = unknown>(
input: string,
{ json, ...options }: JsonRequestOptions<TBody> = {},
) {
return requestRest<TData>(input, {
...options,
json,
method: 'put',
})
},
patch<TData, TBody = unknown>(
input: string,
{ json, ...options }: JsonRequestOptions<TBody> = {},
) {
return requestRest<TData>(input, {
...options,
json,
method: 'patch',
})
},
delete<TData>(input: string, options?: RequestOptions) {
return requestRest<TData>(input, {
...options,
method: 'delete',
})
},
}