- 创建API相关常量文件,包括响应码、HTTP状态码、请求头等 - 将认证相关常量从auth模块提取到独立的常量文件 - 在API客户端中使用新定义的常量替换硬编码值 - 更新认证API和服务中对常量的引用 - 在国际化配置中创建统一的文案常量以减少重复 - 将认证表单验证规则改为使用常量配置
452 lines
10 KiB
TypeScript
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}×tamp=${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',
|
|
})
|
|
},
|
|
}
|