mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
fix(api): 重启期间抑制离线提示并收敛连接类错误反馈
- 网关错误(502/503/504)归为连接失败上报离线探测,不再逐请求弹Toast - 仅成功响应标记服务在线,避免重启期间干扰离线阈值累计 - 新增全局重启状态,重启期间不累计离线阈值且不弹统一离线提示
This commit is contained in:
@@ -31,6 +31,7 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
|
|||||||
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||||
|
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||||
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
|
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
|
||||||
import {
|
import {
|
||||||
@@ -156,6 +157,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { initializePWA } = usePWA()
|
const { initializePWA } = usePWA()
|
||||||
const offlineStatus = useGlobalOfflineStatus()
|
const offlineStatus = useGlobalOfflineStatus()
|
||||||
|
const { isRestarting: isSystemRestarting } = useSystemRestartStatus()
|
||||||
|
|
||||||
// 全局设置store
|
// 全局设置store
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
@@ -440,6 +442,9 @@ async function probeServerConnection(showChecking = false): Promise<boolean> {
|
|||||||
connectionProbeFailures += 1
|
connectionProbeFailures += 1
|
||||||
const failureReason = resolveProbeFailureReason(error)
|
const failureReason = resolveProbeFailureReason(error)
|
||||||
|
|
||||||
|
// 重启期间服务不可达属预期行为,由重启进度弹窗承载反馈,不累计离线阈值。
|
||||||
|
if (isSystemRestarting.value) return false
|
||||||
|
|
||||||
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
|
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
|
||||||
offlineStatus.markServerOffline(failureReason)
|
offlineStatus.markServerOffline(failureReason)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -195,6 +195,49 @@ describe('MoviePilot API client', () => {
|
|||||||
expect(reportConnectionFailure).not.toHaveBeenCalled()
|
expect(reportConnectionFailure).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([502, 503, 504])('网关错误 %d 上报连接失败且不弹请求层 Toast', async status => {
|
||||||
|
const reportConnectionFailure = vi.fn()
|
||||||
|
const { api } = createApiClients({
|
||||||
|
adapter: rejectWith({ message: 'Gateway unavailable' }, status),
|
||||||
|
hooks: { reportConnectionFailure },
|
||||||
|
notifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
|
||||||
|
|
||||||
|
expect(error.status).toBe(status)
|
||||||
|
expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable')
|
||||||
|
expect(notifier.error).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('网络错误仅上报离线状态,不弹请求层 Toast', async () => {
|
||||||
|
const reportConnectionFailure = vi.fn()
|
||||||
|
const adapter: AxiosAdapter = async () => {
|
||||||
|
throw new AxiosError('Network Error', AxiosError.ERR_NETWORK)
|
||||||
|
}
|
||||||
|
const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier })
|
||||||
|
|
||||||
|
await api.get('/resource').catch(reason => reason)
|
||||||
|
|
||||||
|
expect(reportConnectionFailure).toHaveBeenCalledWith('network-error')
|
||||||
|
expect(notifier.error).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('网关错误不算服务在线证据,仅成功响应恢复在线状态', async () => {
|
||||||
|
const markServerOnline = vi.fn()
|
||||||
|
const reportConnectionFailure = vi.fn()
|
||||||
|
const { api } = createApiClients({
|
||||||
|
adapter: rejectWith({ message: 'Bad Gateway' }, 502),
|
||||||
|
hooks: { markServerOnline, reportConnectionFailure },
|
||||||
|
notifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
await api.get('/resource').catch(reason => reason)
|
||||||
|
|
||||||
|
expect(markServerOnline).not.toHaveBeenCalled()
|
||||||
|
expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable')
|
||||||
|
})
|
||||||
|
|
||||||
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
|
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
|
||||||
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
|
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
|
||||||
|
|
||||||
|
|||||||
+18
-6
@@ -78,7 +78,7 @@ export interface ApiFeedbackNotifier {
|
|||||||
export interface ApiClientHooks {
|
export interface ApiClientHooks {
|
||||||
markServerOnline?(): void
|
markServerOnline?(): void
|
||||||
onForbidden?(error: ApiRequestError): void
|
onForbidden?(error: ApiRequestError): void
|
||||||
reportConnectionFailure?(reason: 'network-error' | 'timeout'): void
|
reportConnectionFailure?(reason: 'network-error' | 'timeout' | 'server-unreachable'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建内部数据客户端与插件原始协议客户端时所需的配置。 */
|
/** 创建内部数据客户端与插件原始协议客户端时所需的配置。 */
|
||||||
@@ -158,12 +158,21 @@ export function isApiResponse<T = unknown>(value: unknown): value is ApiResponse
|
|||||||
return typeof record.success === 'boolean' && typeof record.message === 'string' && 'data' in record
|
return typeof record.success === 'boolean' && typeof record.message === 'string' && 'data' in record
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
/**
|
||||||
export function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
|
* 将 Axios 连接错误归类为全局服务探测可识别的原因。
|
||||||
|
*
|
||||||
|
* 网关不可用状态码(502/503/504)同样视为服务不可达:后端重启或崩溃时网关
|
||||||
|
* 会返回这类响应,若只按“无响应”判断会漏掉重启场景的离线检测。
|
||||||
|
*/
|
||||||
|
export function resolveConnectionFailureReason(
|
||||||
|
error: AxiosError,
|
||||||
|
): 'network-error' | 'timeout' | 'server-unreachable' | null {
|
||||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||||
return 'network-error'
|
return 'network-error'
|
||||||
}
|
}
|
||||||
|
const status = error.response?.status
|
||||||
|
if (status === 502 || status === 503 || status === 504) return 'server-unreachable'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +250,8 @@ function installResponseInterceptors(
|
|||||||
|
|
||||||
const original = reason instanceof AxiosError ? reason : undefined
|
const original = reason instanceof AxiosError ? reason : undefined
|
||||||
const response = original?.response ? await normalizeErrorResponse(original.response) : undefined
|
const response = original?.response ? await normalizeErrorResponse(original.response) : undefined
|
||||||
if (response) hooks?.markServerOnline?.()
|
// 只有成功响应才算服务在线证据;网关错误响应说明后端当前不可达,不能恢复在线状态。
|
||||||
|
if (response && response.status >= 200 && response.status < 300) hooks?.markServerOnline?.()
|
||||||
|
|
||||||
const payload = response?.data
|
const payload = response?.data
|
||||||
const error = new ApiRequestError(resolveErrorMessage(payload, original, resolveFallbackMessage), {
|
const error = new ApiRequestError(resolveErrorMessage(payload, original, resolveFallbackMessage), {
|
||||||
@@ -255,12 +265,14 @@ function installResponseInterceptors(
|
|||||||
|
|
||||||
const requestConfig = original?.config
|
const requestConfig = original?.config
|
||||||
const failureReason = original ? resolveConnectionFailureReason(original) : null
|
const failureReason = original ? resolveConnectionFailureReason(original) : null
|
||||||
if (!response && !requestConfig?.skipConnectionTracking && failureReason) {
|
if (!requestConfig?.skipConnectionTracking && failureReason) {
|
||||||
hooks?.reportConnectionFailure?.(failureReason)
|
hooks?.reportConnectionFailure?.(failureReason)
|
||||||
}
|
}
|
||||||
if (response?.status === 403) hooks?.onForbidden?.(error)
|
if (response?.status === 403) hooks?.onForbidden?.(error)
|
||||||
|
|
||||||
notifyFailure(requestConfig?.feedback, notifier, error.message)
|
// 连接类失败(无响应、超时、网关不可用)统一交给离线状态系统按阈值提示,
|
||||||
|
// 不在请求层逐个弹出,避免后端重启时刷屏。
|
||||||
|
if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message)
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||||
|
|
||||||
|
describe('useSystemRestartStatus', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 每个用例从非重启状态开始,避免模块级状态在用例间残留。
|
||||||
|
useSystemRestartStatus().finishSystemRestart()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重启状态在多个调用方之间共享', () => {
|
||||||
|
const first = useSystemRestartStatus()
|
||||||
|
const second = useSystemRestartStatus()
|
||||||
|
|
||||||
|
first.startSystemRestart()
|
||||||
|
expect(second.isRestarting.value).toBe(true)
|
||||||
|
|
||||||
|
second.finishSystemRestart()
|
||||||
|
expect(first.isRestarting.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认处于非重启状态', () => {
|
||||||
|
expect(useSystemRestartStatus().isRestarting.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** 全局系统重启状态,重启入口写入,离线探测与连接提示按此状态抑制。 */
|
||||||
|
const isRestarting = ref(false)
|
||||||
|
|
||||||
|
/** 管理 MoviePilot 系统重启的全局状态。 */
|
||||||
|
export function useSystemRestartStatus() {
|
||||||
|
/** 标记系统进入重启流程(此后服务不可达属预期行为)。 */
|
||||||
|
function startSystemRestart() {
|
||||||
|
isRestarting.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结束重启流程,恢复常规连接状态提示。 */
|
||||||
|
function finishSystemRestart() {
|
||||||
|
isRestarting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRestarting,
|
||||||
|
startSystemRestart,
|
||||||
|
finishSystemRestart,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||||
|
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
|
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
|
||||||
|
const { isRestarting } = useSystemRestartStatus()
|
||||||
const shownConnectionPromptKeys = new Set<string>()
|
const shownConnectionPromptKeys = new Set<string>()
|
||||||
|
|
||||||
const isChecking = computed(() => connectionStatus.value === 'checking')
|
const isChecking = computed(() => connectionStatus.value === 'checking')
|
||||||
@@ -38,6 +40,9 @@ function showConnectionPrompt() {
|
|||||||
|
|
||||||
/** 在同一轮连接异常内按状态去重提示,并在恢复在线后允许下一轮提示重新出现。 */
|
/** 在同一轮连接异常内按状态去重提示,并在恢复在线后允许下一轮提示重新出现。 */
|
||||||
function handleConnectionStatusChange() {
|
function handleConnectionStatusChange() {
|
||||||
|
// 重启期间由重启进度弹窗承载反馈,避免离线提示与进度提示叠加。
|
||||||
|
if (isRestarting.value) return
|
||||||
|
|
||||||
if (connectionStatus.value === 'online') {
|
if (connectionStatus.value === 'online') {
|
||||||
shownConnectionPromptKeys.clear()
|
shownConnectionPromptKeys.clear()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
THEME_CUSTOMIZER_OPEN_EVENT,
|
THEME_CUSTOMIZER_OPEN_EVENT,
|
||||||
type ThemeCustomizerSettings,
|
type ThemeCustomizerSettings,
|
||||||
} from '@/composables/useThemeCustomizer'
|
} from '@/composables/useThemeCustomizer'
|
||||||
|
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
|
||||||
@@ -68,7 +69,7 @@ const isGlassTheme = computed(() => currentThemeName.value === 'glass')
|
|||||||
|
|
||||||
// 重启轮询控制标识
|
// 重启轮询控制标识
|
||||||
const restartPollingId = ref<number | null>(null)
|
const restartPollingId = ref<number | null>(null)
|
||||||
const isRestarting = ref(false)
|
const { isRestarting, startSystemRestart, finishSystemRestart } = useSystemRestartStatus()
|
||||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
let siteAuthDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let siteAuthDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
let customCssDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let customCssDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
@@ -79,7 +80,7 @@ const { createConfirm } = useConfirm()
|
|||||||
// 执行注销操作
|
// 执行注销操作
|
||||||
function logout() {
|
function logout() {
|
||||||
// 清理重启相关状态
|
// 清理重启相关状态
|
||||||
isRestarting.value = false
|
finishSystemRestart()
|
||||||
if (restartPollingId.value) {
|
if (restartPollingId.value) {
|
||||||
clearTimeout(restartPollingId.value)
|
clearTimeout(restartPollingId.value)
|
||||||
restartPollingId.value = null
|
restartPollingId.value = null
|
||||||
@@ -137,7 +138,7 @@ async function pollServiceStatus() {
|
|||||||
|
|
||||||
if (isServiceUp) {
|
if (isServiceUp) {
|
||||||
// 服务已恢复,清理状态并执行注销
|
// 服务已恢复,清理状态并执行注销
|
||||||
isRestarting.value = false
|
finishSystemRestart()
|
||||||
closeRestartProgress()
|
closeRestartProgress()
|
||||||
restartPollingId.value = null
|
restartPollingId.value = null
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ async function pollServiceStatus() {
|
|||||||
|
|
||||||
if (retryCount >= maxRetries) {
|
if (retryCount >= maxRetries) {
|
||||||
// 超时未恢复,清理状态并提示用户
|
// 超时未恢复,清理状态并提示用户
|
||||||
isRestarting.value = false
|
finishSystemRestart()
|
||||||
closeRestartProgress()
|
closeRestartProgress()
|
||||||
restartPollingId.value = null
|
restartPollingId.value = null
|
||||||
$toast.error(t('app.restartTimeout'))
|
$toast.error(t('app.restartTimeout'))
|
||||||
@@ -168,8 +169,8 @@ async function pollServiceStatus() {
|
|||||||
async function restart() {
|
async function restart() {
|
||||||
if (!canAdmin.value) return
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
// 设置重启状态
|
// 设置重启状态(全局共享,供离线探测和连接提示抑制)
|
||||||
isRestarting.value = true
|
startSystemRestart()
|
||||||
|
|
||||||
// 调用API重启
|
// 调用API重启
|
||||||
try {
|
try {
|
||||||
@@ -178,8 +179,9 @@ async function restart() {
|
|||||||
await api.get<null>('system/restart')
|
await api.get<null>('system/restart')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 重启失败,清理状态
|
// 重启失败,清理状态
|
||||||
isRestarting.value = false
|
finishSystemRestart()
|
||||||
closeRestartProgress()
|
closeRestartProgress()
|
||||||
|
$toast.error(t('app.restartFailed'))
|
||||||
console.error(error)
|
console.error(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -551,7 +553,7 @@ onUnmounted(() => {
|
|||||||
clearTimeout(restartPollingId.value)
|
clearTimeout(restartPollingId.value)
|
||||||
restartPollingId.value = null
|
restartPollingId.value = null
|
||||||
}
|
}
|
||||||
isRestarting.value = false
|
finishSystemRestart()
|
||||||
closeRestartProgress()
|
closeRestartProgress()
|
||||||
siteAuthDialogController?.close()
|
siteAuthDialogController?.close()
|
||||||
customCssDialogController?.close()
|
customCssDialogController?.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user