refactor(api): adopt unified responses and restore media config hints

This commit is contained in:
jxxghp
2026-08-12 23:15:13 +08:00
parent 4fce4cd171
commit 0ea12f75f7
164 changed files with 4080 additions and 3629 deletions
+237
View File
@@ -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)
})
})
+55
View File
@@ -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')
})
})
+348
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
// 分类规则