mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-02 05:57:14 +08:00
refactor(api): adopt unified responses and restore media config hints
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosHeaders,
|
||||
CanceledError,
|
||||
type AxiosAdapter,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
type RawAxiosHeaders,
|
||||
} from 'axios'
|
||||
import { ApiRequestError, createApiClients, type ApiFeedbackNotifier } from '@/api/client'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const notifier: ApiFeedbackNotifier = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}
|
||||
|
||||
/** 构造 Axios 适配器响应,避免客户端单测依赖真实网络。 */
|
||||
function createResponse<T>(
|
||||
config: InternalAxiosRequestConfig,
|
||||
data: T,
|
||||
status = 200,
|
||||
headers: Record<string, string> = {},
|
||||
): AxiosResponse<T> {
|
||||
return {
|
||||
config,
|
||||
data,
|
||||
headers: new AxiosHeaders(headers),
|
||||
status,
|
||||
statusText: status >= 400 ? 'Error' : 'OK',
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建始终成功返回指定数据的 Axios 适配器。 */
|
||||
function resolveWith<T>(data: T, headers: Record<string, string> = {}): AxiosAdapter {
|
||||
return async config => createResponse(config, data, 200, headers)
|
||||
}
|
||||
|
||||
/** 创建带 Axios 响应元数据的 HTTP 失败适配器。 */
|
||||
function rejectWith<T>(data: T, status: number, headers: Record<string, string> = {}): AxiosAdapter {
|
||||
return async config => {
|
||||
const response = createResponse(config, data, status, headers)
|
||||
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
|
||||
}
|
||||
}
|
||||
|
||||
/** 断言并收窄统一 API 错误,避免测试绕过 unknown 错误边界。 */
|
||||
function requireApiRequestError(reason: unknown): ApiRequestError {
|
||||
expect(reason).toBeInstanceOf(ApiRequestError)
|
||||
if (!(reason instanceof ApiRequestError)) throw reason
|
||||
return reason
|
||||
}
|
||||
|
||||
describe('MoviePilot API client', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(notifier.error).mockReset()
|
||||
vi.mocked(notifier.success).mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['object', { id: 7 }],
|
||||
['array', ['one', 'two']],
|
||||
['scalar', 42],
|
||||
['null', null],
|
||||
])('仅向内部调用方返回成功 envelope 的 data:%s', async (_label, data) => {
|
||||
const envelope: ApiResponse<typeof data> = { success: true, message: '', data }
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await expect(api.get<typeof data>('/resource')).resolves.toEqual(data)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('只在 feedback=all 时展示后端成功消息', async () => {
|
||||
const envelope: ApiResponse<{ saved: boolean }> = {
|
||||
success: true,
|
||||
message: 'Saved',
|
||||
data: { saved: true },
|
||||
}
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await api.post('/setting', {}, { feedback: 'default' })
|
||||
await api.post('/setting', {}, { feedback: 'all' })
|
||||
|
||||
expect(notifier.success).toHaveBeenCalledOnce()
|
||||
expect(notifier.success).toHaveBeenCalledWith('Saved')
|
||||
})
|
||||
|
||||
it('将 success=false 转为保留完整响应元数据的 ApiRequestError', async () => {
|
||||
const envelope: ApiResponse<{ reason: string }> = {
|
||||
success: false,
|
||||
message: 'Cannot save',
|
||||
data: { reason: 'locked' },
|
||||
}
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith(envelope, { 'x-request-id': 'request-1' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.post('/setting', {}).catch(reason => reason))
|
||||
|
||||
expect(axios.isAxiosError(error)).toBe(true)
|
||||
expect(error).toMatchObject({
|
||||
message: 'Cannot save',
|
||||
payload: envelope,
|
||||
status: 200,
|
||||
})
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
expect(AxiosHeaders.from(error.headers as RawAxiosHeaders | undefined).get('x-request-id')).toBe('request-1')
|
||||
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
|
||||
})
|
||||
|
||||
it('将 HTTP 错误统一转换为 ApiRequestError 并保留 payload、status 和 headers', async () => {
|
||||
const envelope: ApiResponse<{ mfa_methods: string[] }> = {
|
||||
success: false,
|
||||
message: 'Additional verification required',
|
||||
data: { mfa_methods: ['otp'] },
|
||||
}
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith(envelope, 403, { 'x-mfa-required': 'true' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.post('/login/access-token', {}).catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Additional verification required')
|
||||
expect(error.status).toBe(403)
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
expect(AxiosHeaders.from(error.response?.headers as RawAxiosHeaders | undefined).get('x-mfa-required')).toBe('true')
|
||||
})
|
||||
|
||||
it('silent 关闭失败 Toast,但不丢失错误元数据', async () => {
|
||||
const envelope: ApiResponse<null> = { success: false, message: 'Invalid credentials', data: null }
|
||||
const { api } = createApiClients({ adapter: rejectWith(envelope, 401), notifier })
|
||||
|
||||
const error = requireApiRequestError(
|
||||
await api.post('/login/access-token', {}, { feedback: 'silent' }).catch(reason => reason),
|
||||
)
|
||||
|
||||
expect(error.status).toBe(401)
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('插件客户端保留完整 envelope,同时沿用统一失败反馈', async () => {
|
||||
const envelope: ApiResponse<{ plugin: string }> = {
|
||||
success: false,
|
||||
message: 'Plugin rejected request',
|
||||
data: { plugin: 'demo' },
|
||||
}
|
||||
const { pluginApi } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await expect(pluginApi.get<unknown, ApiResponse<{ plugin: string }>>('/plugin/demo')).resolves.toEqual(envelope)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Plugin rejected request')
|
||||
})
|
||||
|
||||
it('Blob 成功响应绕过 envelope 解包', async () => {
|
||||
const blob = new Blob(['moviepilot'], { type: 'application/octet-stream' })
|
||||
const { api } = createApiClients({ adapter: resolveWith(blob), notifier })
|
||||
|
||||
await expect(api.get<Blob>('/download', { responseType: 'blob' })).resolves.toBe(blob)
|
||||
})
|
||||
|
||||
it('解析 Blob 中的 JSON HTTP 错误后再构造 ApiRequestError', async () => {
|
||||
const envelope: ApiResponse<{ file: string }> = {
|
||||
success: false,
|
||||
message: 'File is unavailable',
|
||||
data: { file: 'movie.mkv' },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(envelope)], { type: 'application/json' })
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith(blob, 404, { 'content-type': 'application/json' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/download', { responseType: 'blob' }).catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('File is unavailable')
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
})
|
||||
|
||||
it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => {
|
||||
const reportConnectionFailure = vi.fn()
|
||||
const adapter: AxiosAdapter = async config => {
|
||||
throw new CanceledError('Request cancelled', config)
|
||||
}
|
||||
const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier })
|
||||
|
||||
const error = await api.get('/slow').catch(reason => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(CanceledError)
|
||||
expect(error).not.toBeInstanceOf(ApiRequestError)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
expect(reportConnectionFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
|
||||
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Invalid API response envelope')
|
||||
})
|
||||
|
||||
it('拒绝带旧顶层 message_i18n 的 envelope', async () => {
|
||||
const legacyEnvelope = {
|
||||
success: false,
|
||||
message: 'Failed',
|
||||
message_i18n: '旧版错误',
|
||||
data: null,
|
||||
}
|
||||
const { api } = createApiClients({ adapter: resolveWith(legacyEnvelope), notifier })
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Invalid API response envelope')
|
||||
expect(error.payload).toEqual(legacyEnvelope)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '服务器返回了无效响应'],
|
||||
['en-US', 'The server returned an invalid response'],
|
||||
])('使用应用注入的 %s fallback 文案提示协议异常', async (_locale, translatedMessage) => {
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith({ value: 1 }),
|
||||
notifier,
|
||||
resolveFallbackMessage: key => (key === 'invalid-envelope' ? translatedMessage : key),
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe(translatedMessage)
|
||||
expect(notifier.error).toHaveBeenCalledWith(translatedMessage)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18nT: vi.fn((key: string) => `translated:${key}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({ logout: vi.fn(), token: null }),
|
||||
}))
|
||||
vi.mock('@/utils/requestOptimizer', () => ({
|
||||
initializeRequestOptimizer: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/composables/useOfflineStatus', () => ({
|
||||
useGlobalOfflineStatus: () => ({
|
||||
markServerOnline: vi.fn(),
|
||||
reportNetworkError: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/plugins/i18n', () => ({
|
||||
default: { global: { t: mocks.i18nT } },
|
||||
getCurrentLocale: () => 'zh-CN',
|
||||
}))
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: vi.fn(), success: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('API application wiring', () => {
|
||||
it('向 window 暴露插件 envelope 客户端,而内部默认导出数据客户端', async () => {
|
||||
const module = await import('@/api')
|
||||
|
||||
expect(window.MoviePilotAPI).toBe(module.pluginApi)
|
||||
expect(module.default).not.toBe(module.pluginApi)
|
||||
})
|
||||
|
||||
it('通过 i18n 实例解析请求层 fallback 文案', async () => {
|
||||
const module = await import('@/api')
|
||||
module.default.defaults.adapter = async config => ({
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
data: { legacy: true },
|
||||
headers: new AxiosHeaders(),
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
|
||||
const error = await module.default.get('/legacy').catch(reason => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(module.ApiRequestError)
|
||||
expect((error as Error).message).toBe('translated:common.invalidApiResponse')
|
||||
expect(mocks.i18nT).toHaveBeenCalledWith('common.invalidApiResponse')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
type AxiosInstance,
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
type CreateAxiosDefaults,
|
||||
} from 'axios'
|
||||
import type { ApiResponse } from './types'
|
||||
|
||||
/** 控制请求层是否展示成功或失败反馈。 */
|
||||
export type ApiFeedbackMode = 'silent' | 'default' | 'all'
|
||||
|
||||
/** 请求层无法从后端取得 message 时使用的本地化文案类别。 */
|
||||
export type ApiFallbackMessageKey = 'invalid-envelope' | 'network-error' | 'request-failed' | 'timeout'
|
||||
|
||||
/** 将请求层 fallback 类别解析为当前语言文案,避免基础客户端依赖 Vue i18n。 */
|
||||
export type ApiFallbackMessageResolver = (key: ApiFallbackMessageKey) => string
|
||||
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
feedback?: ApiFeedbackMode
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** 普通 API 客户端在响应拦截器解包后返回业务数据,而不是 AxiosResponse。 */
|
||||
export interface DataApiClient extends Omit<
|
||||
AxiosInstance,
|
||||
'delete' | 'get' | 'head' | 'options' | 'patch' | 'patchForm' | 'post' | 'postForm' | 'put' | 'putForm' | 'request'
|
||||
> {
|
||||
<T = unknown, R = T, D = AxiosRequestConfig['data']>(config: AxiosRequestConfig<D>): Promise<R>
|
||||
<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
request<T = unknown, R = T, D = AxiosRequestConfig['data']>(config: AxiosRequestConfig<D>): Promise<R>
|
||||
get<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
delete<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
head<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
options<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
post<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
put<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
patch<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
postForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
putForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
patchForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
}
|
||||
|
||||
/** 请求层使用的反馈出口,由应用入口接到全局 Toast。 */
|
||||
export interface ApiFeedbackNotifier {
|
||||
error(message: string): void
|
||||
success(message: string): void
|
||||
}
|
||||
|
||||
/** 请求生命周期钩子用于隔离认证和离线状态等应用级副作用。 */
|
||||
export interface ApiClientHooks {
|
||||
markServerOnline?(): void
|
||||
onForbidden?(error: ApiRequestError): void
|
||||
reportConnectionFailure?(reason: 'network-error' | 'timeout'): void
|
||||
}
|
||||
|
||||
/** 创建内部数据客户端与插件原始协议客户端时所需的配置。 */
|
||||
export interface CreateApiClientsOptions extends CreateAxiosDefaults {
|
||||
hooks?: ApiClientHooks
|
||||
notifier?: ApiFeedbackNotifier
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver
|
||||
setupInstance?(instance: AxiosInstance): void
|
||||
}
|
||||
|
||||
const defaultFallbackMessages: Record<ApiFallbackMessageKey, string> = {
|
||||
'invalid-envelope': 'Invalid API response envelope',
|
||||
'network-error': 'Network connection failed',
|
||||
'request-failed': 'API request failed',
|
||||
timeout: 'Request timeout',
|
||||
}
|
||||
|
||||
interface ApiRequestErrorOptions<T> {
|
||||
businessFailure?: boolean
|
||||
cause?: unknown
|
||||
code?: string
|
||||
config?: AxiosRequestConfig
|
||||
payload?: T
|
||||
request?: unknown
|
||||
response?: AxiosResponse<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一表示业务失败、HTTP 失败和请求协议错误。
|
||||
*
|
||||
* 该类型继承 AxiosError,确保认证流程和既有调用方仍可读取 response、status、headers 与 config。
|
||||
*/
|
||||
export class ApiRequestError<T = unknown> extends AxiosError<T> {
|
||||
readonly businessFailure: boolean
|
||||
readonly headers?: AxiosResponse<T>['headers']
|
||||
readonly payload?: T
|
||||
|
||||
/** 保留原始 Axios 元数据,并额外暴露已解析的响应载荷。 */
|
||||
constructor(message: string, options: ApiRequestErrorOptions<T> = {}) {
|
||||
super(
|
||||
message,
|
||||
options.code,
|
||||
options.config as AxiosResponse<T>['config'] | undefined,
|
||||
options.request,
|
||||
options.response,
|
||||
)
|
||||
this.name = 'ApiRequestError'
|
||||
this.businessFailure = options.businessFailure ?? false
|
||||
this.headers = options.response?.headers
|
||||
this.payload = options.payload
|
||||
this.status = options.response?.status
|
||||
if (options.cause instanceof Error) this.cause = options.cause
|
||||
Object.setPrototypeOf(this, new.target.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅返回后端在成功 HTTP 响应中声明的业务失败消息。 */
|
||||
export function getApiBusinessErrorMessage(error: unknown): string | undefined {
|
||||
if (!(error instanceof ApiRequestError) || !error.businessFailure) return undefined
|
||||
const payload = error.payload
|
||||
if (!payload || typeof payload !== 'object') return undefined
|
||||
const message = (payload as { message?: unknown }).message
|
||||
return typeof message === 'string' && message.trim() ? message : undefined
|
||||
}
|
||||
|
||||
/** 判断错误是否来自 HTTP 200 响应中的业务失败。 */
|
||||
export function isApiBusinessFailure(error: unknown): error is ApiRequestError {
|
||||
return error instanceof ApiRequestError && error.businessFailure
|
||||
}
|
||||
|
||||
/** 判断未知值是否为 MoviePilot 标准响应 envelope。 */
|
||||
export function isApiResponse<T = unknown>(value: unknown): value is ApiResponse<T> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
const keys = Object.keys(record)
|
||||
if (keys.length !== 3 || !keys.every(key => key === 'success' || key === 'message' || key === 'data')) return false
|
||||
return typeof record.success === 'boolean' && typeof record.message === 'string' && 'data' in record
|
||||
}
|
||||
|
||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
||||
export function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||
return 'network-error'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 创建普通数据客户端和保持插件 ABI 的原始 envelope 客户端。 */
|
||||
export function createApiClients(options: CreateApiClientsOptions = {}): {
|
||||
api: DataApiClient
|
||||
pluginApi: AxiosInstance
|
||||
} {
|
||||
const { hooks, notifier, resolveFallbackMessage, setupInstance, ...axiosConfig } = options
|
||||
const api = axios.create(axiosConfig)
|
||||
const pluginApi = axios.create(axiosConfig)
|
||||
|
||||
// 优化器等底层拦截器必须先安装,确保它们在业务数据解包前仍能看到完整 AxiosResponse。
|
||||
setupInstance?.(api)
|
||||
setupInstance?.(pluginApi)
|
||||
installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage)
|
||||
installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage)
|
||||
|
||||
return {
|
||||
api: api as DataApiClient,
|
||||
pluginApi,
|
||||
}
|
||||
}
|
||||
|
||||
/** 为客户端安装统一的可达性、反馈、解包和错误标准化处理。 */
|
||||
function installResponseInterceptors(
|
||||
instance: AxiosInstance,
|
||||
responseMode: 'data' | 'envelope',
|
||||
hooks?: ApiClientHooks,
|
||||
notifier?: ApiFeedbackNotifier,
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver,
|
||||
) {
|
||||
instance.interceptors.response.use(
|
||||
response => {
|
||||
hooks?.markServerOnline?.()
|
||||
|
||||
if (isBinarySuccess(response)) return response.data
|
||||
|
||||
const payload: unknown = response.data
|
||||
if (!isApiResponse(payload)) {
|
||||
if (responseMode === 'envelope') return payload
|
||||
const error = new ApiRequestError(resolveFallback('invalid-envelope', resolveFallbackMessage), {
|
||||
code: AxiosError.ERR_BAD_RESPONSE,
|
||||
config: response.config,
|
||||
payload,
|
||||
request: response.request,
|
||||
response,
|
||||
})
|
||||
notifyFailure(response.config.feedback, notifier, error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
notifyFailure(response.config.feedback, notifier, payload.message)
|
||||
if (responseMode === 'envelope') return payload
|
||||
return Promise.reject(
|
||||
new ApiRequestError(payload.message || resolveFallback('request-failed', resolveFallbackMessage), {
|
||||
businessFailure: true,
|
||||
code: AxiosError.ERR_BAD_RESPONSE,
|
||||
config: response.config,
|
||||
payload,
|
||||
request: response.request,
|
||||
response,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
notifySuccess(response.config.feedback, notifier, payload.message)
|
||||
return responseMode === 'envelope' ? payload : payload.data
|
||||
},
|
||||
async (reason: unknown) => {
|
||||
if (isCancellation(reason)) return Promise.reject(reason)
|
||||
// fulfilled 分支生成的协议错误已经携带完整上下文,不应再次按响应 message 包装。
|
||||
if (reason instanceof ApiRequestError) return Promise.reject(reason)
|
||||
|
||||
const original = reason instanceof AxiosError ? reason : undefined
|
||||
const response = original?.response ? await normalizeErrorResponse(original.response) : undefined
|
||||
if (response) hooks?.markServerOnline?.()
|
||||
|
||||
const payload = response?.data
|
||||
const error = new ApiRequestError(resolveErrorMessage(payload, original, resolveFallbackMessage), {
|
||||
cause: reason,
|
||||
code: original?.code,
|
||||
config: original?.config,
|
||||
payload,
|
||||
request: original?.request,
|
||||
response,
|
||||
})
|
||||
|
||||
const requestConfig = original?.config
|
||||
const failureReason = original ? resolveConnectionFailureReason(original) : null
|
||||
if (!response && !requestConfig?.skipConnectionTracking && failureReason) {
|
||||
hooks?.reportConnectionFailure?.(failureReason)
|
||||
}
|
||||
if (response?.status === 403) hooks?.onForbidden?.(error)
|
||||
|
||||
notifyFailure(requestConfig?.feedback, notifier, error.message)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 二进制成功响应不参与 JSON envelope 校验和解包。 */
|
||||
function isBinarySuccess(response: AxiosResponse): boolean {
|
||||
if (typeof Blob !== 'undefined' && response.data instanceof Blob) return true
|
||||
return response.config.responseType === 'arraybuffer' || response.config.responseType === 'stream'
|
||||
}
|
||||
|
||||
/** 将 Blob 形式的 JSON 错误响应解析为结构化载荷,便于调用方读取错误元数据。 */
|
||||
async function normalizeErrorResponse(response: AxiosResponse): Promise<AxiosResponse> {
|
||||
if (typeof Blob === 'undefined' || !(response.data instanceof Blob) || !isJsonBlob(response)) return response
|
||||
|
||||
try {
|
||||
const data = JSON.parse(await readBlobText(response.data)) as unknown
|
||||
return { ...response, data }
|
||||
} catch {
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先使用现代 Blob API,并为测试环境和旧浏览器保留 FileReader 回退。 */
|
||||
function readBlobText(blob: Blob): Promise<string> {
|
||||
if (typeof blob.text === 'function') return blob.text()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read response Blob'))
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
||||
reader.readAsText(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据响应头或 Blob 自身类型识别 JSON 错误载荷。 */
|
||||
function isJsonBlob(response: AxiosResponse): boolean {
|
||||
const blobType = response.data instanceof Blob ? response.data.type : ''
|
||||
const headers = response.headers as AxiosResponse['headers'] & { get?: (name: string) => unknown }
|
||||
const rawContentType = typeof headers.get === 'function' ? headers.get('content-type') : headers['content-type']
|
||||
const contentType = typeof rawContentType === 'string' ? rawContentType : ''
|
||||
return blobType.includes('json') || contentType.includes('json')
|
||||
}
|
||||
|
||||
/** 取消属于正常控制流,保持原始取消对象且不触发 Toast 或离线探测。 */
|
||||
function isCancellation(reason: unknown): boolean {
|
||||
if (axios.isCancel(reason)) return true
|
||||
if (!(reason instanceof Error)) return false
|
||||
return reason.name === 'AbortError' || reason.name === 'CanceledError'
|
||||
}
|
||||
|
||||
/** 优先使用后端 message,并兼容尚未进入统一 envelope 的 detail 错误。 */
|
||||
function resolveErrorMessage(
|
||||
payload: unknown,
|
||||
error?: AxiosError,
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver,
|
||||
): string {
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const record = payload as Record<string, unknown>
|
||||
if (typeof record.message === 'string' && record.message) return record.message
|
||||
if (typeof record.detail === 'string' && record.detail) return record.detail
|
||||
}
|
||||
if (error?.code === 'ECONNABORTED' || error?.code === 'ETIMEDOUT') {
|
||||
return resolveFallback('timeout', resolveFallbackMessage)
|
||||
}
|
||||
if (error?.code === 'NETWORK_ERROR' || error?.code === 'ERR_NETWORK') {
|
||||
return resolveFallback('network-error', resolveFallbackMessage)
|
||||
}
|
||||
if (error?.message) return error.message
|
||||
return resolveFallback('request-failed', resolveFallbackMessage)
|
||||
}
|
||||
|
||||
/** 调用应用注入的本地化 resolver,并为独立使用保留稳定默认值。 */
|
||||
function resolveFallback(key: ApiFallbackMessageKey, resolver?: ApiFallbackMessageResolver): string {
|
||||
return resolver?.(key) || defaultFallbackMessages[key]
|
||||
}
|
||||
|
||||
/** 默认模式只提示失败,silent 模式完全关闭请求层反馈。 */
|
||||
function notifyFailure(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) {
|
||||
if (mode !== 'silent' && message) notifier?.error(message)
|
||||
}
|
||||
|
||||
/** all 模式用于显式要求请求层展示后端成功消息。 */
|
||||
function notifySuccess(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) {
|
||||
if (mode === 'all' && message) notifier?.success(message)
|
||||
}
|
||||
+59
-104
@@ -1,124 +1,79 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
})
|
||||
import i18n, { getCurrentLocale } from '@/plugins/i18n'
|
||||
import {
|
||||
ApiRequestError,
|
||||
createApiClients,
|
||||
getApiBusinessErrorMessage,
|
||||
isApiResponse,
|
||||
type ApiFeedbackMode,
|
||||
type ApiFallbackMessageKey,
|
||||
type DataApiClient,
|
||||
} from './client'
|
||||
|
||||
/** 带连接探测和反馈策略的 MoviePilot 请求配置。 */
|
||||
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
||||
feedback?: ApiFeedbackMode
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
interface Window {
|
||||
MoviePilotAPI: typeof api
|
||||
}
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
const toast = useToast()
|
||||
const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
|
||||
'invalid-envelope': 'common.invalidApiResponse',
|
||||
'network-error': 'common.networkConnectionFailed',
|
||||
'request-failed': 'common.apiRequestFailed',
|
||||
timeout: 'common.requestTimeout',
|
||||
}
|
||||
|
||||
// 将 API 实例暴露到全局,供插件使用
|
||||
window.MoviePilotAPI = api
|
||||
|
||||
// 初始化请求优化器(必须在其他拦截器之前)
|
||||
initializeRequestOptimizer(api)
|
||||
|
||||
// 添加请求拦截器
|
||||
api.interceptors.request.use(config => {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 在请求头中添加token
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
const locale = getCurrentLocale()
|
||||
config.headers['X-MoviePilot-Locale'] = locale
|
||||
config.headers['Accept-Language'] = locale
|
||||
return config
|
||||
const { api, pluginApi } = createApiClients({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
setupInstance: initializeClient,
|
||||
hooks: {
|
||||
markServerOnline: globalOfflineStatus.markServerOnline,
|
||||
reportConnectionFailure: globalOfflineStatus.reportNetworkError,
|
||||
onForbidden: () => {
|
||||
const authStore = useAuthStore()
|
||||
// 未登录的 403 可能是登录或 MFA 流程的一部分,不应触发全局登出跳转。
|
||||
if (!authStore.token) return
|
||||
authStore.logout()
|
||||
void router.push('/login')
|
||||
},
|
||||
},
|
||||
notifier: {
|
||||
error: message => toast.error(message),
|
||||
success: message => toast.success(message),
|
||||
},
|
||||
resolveFallbackMessage: key => i18n.global.t(fallbackMessageKeys[key]),
|
||||
})
|
||||
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
|
||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
||||
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||
return 'network-error'
|
||||
declare global {
|
||||
interface Window {
|
||||
MoviePilotAPI: AxiosInstance
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface LocalizedApiPayload {
|
||||
detail?: unknown
|
||||
detail_i18n?: unknown
|
||||
message?: unknown
|
||||
message_i18n?: unknown
|
||||
/** 为两个客户端安装同一套取消、认证和语言请求头。 */
|
||||
function initializeClient(instance: AxiosInstance | DataApiClient) {
|
||||
initializeRequestOptimizer(instance)
|
||||
instance.interceptors.request.use(config => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
|
||||
const locale = getCurrentLocale()
|
||||
config.headers['X-MoviePilot-Locale'] = locale
|
||||
config.headers['Accept-Language'] = locale
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
|
||||
function normalizeLocalizedMessage(payload: any): any {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
|
||||
// 插件远程组件依赖完整 envelope ABI,内部页面则默认使用已解包的数据客户端。
|
||||
if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
|
||||
|
||||
const localizedPayload = payload as LocalizedApiPayload
|
||||
if (typeof localizedPayload.message_i18n === 'string' && localizedPayload.message_i18n) {
|
||||
localizedPayload.message = localizedPayload.message_i18n
|
||||
}
|
||||
if (typeof localizedPayload.detail_i18n === 'string' && localizedPayload.detail_i18n) {
|
||||
localizedPayload.detail = localizedPayload.detail_i18n
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
|
||||
globalOfflineStatus.markServerOnline()
|
||||
return normalizeLocalizedMessage(response.data)
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (!error.response) {
|
||||
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
|
||||
const failureReason = resolveConnectionFailureReason(error)
|
||||
|
||||
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
|
||||
if (!requestConfig?.skipConnectionTracking && failureReason) {
|
||||
globalOfflineStatus.reportNetworkError(failureReason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
// 网络连接问题
|
||||
return Promise.reject(new Error('Network connection failed, please check your network status'))
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
// 请求被中止(路由切换等)
|
||||
return Promise.reject(new Error('Request cancelled'))
|
||||
}
|
||||
// 其他网络错误
|
||||
return Promise.reject(new Error(error.message || 'Network error'))
|
||||
} else if (error.response.status === 403) {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
} else {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
export { ApiRequestError, getApiBusinessErrorMessage, isApiResponse, pluginApi }
|
||||
export type { ApiFeedbackMode, DataApiClient }
|
||||
|
||||
export default api
|
||||
|
||||
+4
-5
@@ -2210,12 +2210,11 @@ export interface SubscribeShareStatistics {
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
// 通用API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
/** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
message_i18n?: string
|
||||
data: T
|
||||
message: string
|
||||
data: T | null
|
||||
}
|
||||
|
||||
// 分类规则
|
||||
|
||||
Reference in New Issue
Block a user