From 57127dd2957c52c28c296ae851fcfefcd06e9748 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:57:55 +0800 Subject: [PATCH] fix(auth): align v3 profile and auth errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 AUTH-001 配对交付门禁合并:已完成与后端认证契约的真实联调,前端 format、lint 和单测全部通过;PR Agent 配额失败按约定忽略。 --- src/api/__tests__/client.spec.ts | 19 ++- src/api/__tests__/index.spec.ts | 9 +- src/api/client.ts | 18 ++- src/api/index.ts | 9 +- src/views/user/UserProfileView.vue | 80 ++++++------- .../user/__tests__/UserProfileView.spec.ts | 108 ++++++++++++++++-- 6 files changed, 178 insertions(+), 65 deletions(-) diff --git a/src/api/__tests__/client.spec.ts b/src/api/__tests__/client.spec.ts index 7a473e60..7e6963fc 100644 --- a/src/api/__tests__/client.spec.ts +++ b/src/api/__tests__/client.spec.ts @@ -6,7 +6,7 @@ import axios, { type AxiosResponse, type InternalAxiosRequestConfig, } from 'axios' -import { ApiRequestError, createApiClients, type ApiFeedbackNotifier } from '@/api/client' +import { ApiRequestError, createApiClients, getApiErrorMessage, type ApiFeedbackNotifier } from '@/api/client' import type { ApiResponse } from '@/api/types' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -314,6 +314,23 @@ describe('MoviePilot API client', () => { expect(notifier.error).toHaveBeenCalledWith('Cannot save') }) + it('从业务或 HTTP 错误提取 message/detail,网络错误交给连接 fallback', () => { + const businessError = new ApiRequestError('Cannot save', { + businessFailure: true, + payload: { message: '资料保存失败' }, + }) + const forbiddenPayload = { detail: '当前用户无权修改资料' } + const forbiddenError = new ApiRequestError('Forbidden', { + payload: forbiddenPayload, + response: createResponse({} as InternalAxiosRequestConfig, forbiddenPayload, 403), + }) + const networkError = new ApiRequestError('Network Error') + + expect(getApiErrorMessage(businessError)).toBe('资料保存失败') + expect(getApiErrorMessage(forbiddenError)).toBe('当前用户无权修改资料') + expect(getApiErrorMessage(networkError)).toBeUndefined() + }) + it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => { const reportConnectionFailure = vi.fn() const adapter: AxiosAdapter = async () => { diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index 47f55fd3..57920f3c 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -118,14 +118,15 @@ describe('API application wiring', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) - it('token 校验失败的 403 完成签退后不弹技术错误', async () => { + it('403 保留当前会话并通过请求反馈展示授权错误', async () => { mocks.authState.token = 'invalid-token' const module = await installFailingAdapter(403, { detail: 'token校验不通过' }) await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')]) - expect(mocks.logout).toHaveBeenCalledOnce() - expect(mocks.routerPush).toHaveBeenCalledWith('/login') - expect(mocks.toastError).not.toHaveBeenCalled() + expect(mocks.logout).not.toHaveBeenCalled() + expect(mocks.routerPush).not.toHaveBeenCalled() + expect(mocks.authState.token).toBe('invalid-token') + expect(mocks.toastError).toHaveBeenCalledWith('token校验不通过') }) }) diff --git a/src/api/client.ts b/src/api/client.ts index 22176960..1ce83a78 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -143,13 +143,23 @@ export class ApiRequestError extends AxiosError { } } +/** 返回 API 错误载荷中的业务消息,网络错误无响应时返回 undefined 供调用方使用连接 fallback。 */ +export function getApiErrorMessage(error: unknown): string | undefined { + if (!(error instanceof ApiRequestError)) return undefined + const payload = error.payload ?? error.response?.data + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined + const record = payload as Record + for (const key of ['message', 'detail']) { + const message = record[key] + if (typeof message === 'string' && message.trim()) return message + } + return undefined +} + /** 仅返回后端在成功 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 + return getApiErrorMessage(error) } /** 判断错误是否来自 HTTP 200 响应中的业务失败。 */ diff --git a/src/api/index.ts b/src/api/index.ts index a322d37d..23fb4f56 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,6 +8,7 @@ import i18n, { getCurrentLocale } from '@/plugins/i18n' import { ApiRequestError, createApiClients, + getApiErrorMessage, getApiBusinessErrorMessage, isApiBusinessFailure, isApiResponse, @@ -33,8 +34,8 @@ const fallbackMessageKeys: Record = { timeout: 'common.requestTimeout', } -/** 认证失效只负责代码签退;原始异常继续交给发起请求的业务界面处理。 */ -function handleAuthenticationFailure(): true { +/** 只有认证失效(401)才清理会话并回到登录页;授权拒绝(403)保留会话交给业务处理。 */ +function handleUnauthorized(): true { const authStore = useAuthStore() if (authStore.token) { authStore.logout() @@ -49,8 +50,7 @@ const { api, pluginApi } = createApiClients({ hooks: { markServerOnline: globalOfflineStatus.markServerOnline, reportConnectionFailure: globalOfflineStatus.reportNetworkError, - onForbidden: handleAuthenticationFailure, - onUnauthorized: handleAuthenticationFailure, + onUnauthorized: handleUnauthorized, }, notifier: { error: message => toast.error(message), @@ -98,6 +98,7 @@ if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi export { ApiRequestError, createPluginInstanceApi, + getApiErrorMessage, getApiBusinessErrorMessage, isApiBusinessFailure, isApiResponse, diff --git a/src/views/user/UserProfileView.vue b/src/views/user/UserProfileView.vue index 63b4dc03..ac193e68 100644 --- a/src/views/user/UserProfileView.vue +++ b/src/views/user/UserProfileView.vue @@ -2,7 +2,7 @@ import { useToast } from 'vue-toastification' import { VForm } from 'vuetify/lib/components/index.mjs' import api from '@/api' -import { getApiBusinessErrorMessage } from '@/api/client' +import { getApiErrorMessage } from '@/api/client' import type { User, PassKey } from '@/api/types' import avatar1 from '@images/avatars/avatar-1.png' import { useDisplay } from 'vuetify' @@ -197,13 +197,9 @@ async function fetchUserInfo() { isProfileLoading.value = true profileLoadFailed.value = false try { - const result: User = await api.get(`user/${userStore.userName}`) + const result: User = await api.get('user/current') if (result) { - accountInfo.value = result - accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1 - accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? '' - currentUserName.value = accountInfo.value.name - currentAvatar.value = accountInfo.value.avatar + applyUserProfile(result) // Passkey 数量是辅助信息,不阻塞已成功取得的个人资料。 void fetchPassKeyList() } @@ -238,47 +234,28 @@ async function saveAccountInfo() { } accountInfo.value.settings.nickname = accountInfo.value.nickname ?? '' - const oldUserName = accountInfo.value.name - const oldAvatar = accountInfo.value.avatar isSaving.value = true try { - // 请求数据独立于已确认快照,失败后保留当前输入供用户重试。 - const userData: User = { - ...accountInfo.value, + // 请求数据只包含自助资料字段,失败后保留当前输入供用户重试。 + const userData: CurrentUserUpdatePayload = { + email: accountInfo.value.email, avatar: currentAvatar.value, - name: currentUserName.value, + settings: accountInfo.value.settings, ...(newPassword.value ? { password: newPassword.value } : {}), } - await api.put('user/', userData, { feedback: 'silent' }) - - accountInfo.value.name = currentUserName.value - accountInfo.value.avatar = currentAvatar.value - if (oldUserName !== currentUserName.value) { - $toast.success(t('profile.usernameChangeSuccess', { oldName: oldUserName, newName: currentUserName.value })) - // 更新本地用户名显示 - userStore.setUserName(currentUserName.value) - } else { - $toast.success(t('profile.saveSuccess')) - } - // 更新本地头像显示 - if (oldAvatar !== currentAvatar.value) { - userStore.setAvatar(currentAvatar.value) - } + const updatedUser = await api.put('user/current', userData, { feedback: 'silent' }) + applyUserProfile(updatedUser) + userStore.setUserName(updatedUser.name) + userStore.setAvatar(currentAvatar.value) + // 凭据仅在服务端确认成功后清空,失败时保留输入便于修正或重试。 + newPassword.value = '' + confirmPassword.value = '' + $toast.success(t('profile.saveSuccess')) } catch (error) { console.log('保存失败:', error) - const message = getApiBusinessErrorMessage(error) || t('common.serverConnectionFailed') - if (oldUserName !== currentUserName.value) { - $toast.error( - t('profile.saveFailedWithNameChange', { - oldName: oldUserName, - newName: currentUserName.value, - message, - }), - ) - } else { - $toast.error(t('profile.saveFailed', { message })) - } + const message = getApiErrorMessage(error) || t('common.serverConnectionFailed') + $toast.error(t('profile.saveFailed', { message })) } finally { isSaving.value = false } @@ -291,6 +268,29 @@ interface VerifyPasswordPayload { callback: (password: string) => void } +/** 当前用户自助修改资料时允许提交的字段,权限与身份字段由服务端维护。 */ +interface CurrentUserUpdatePayload { + // 用户邮箱 + email: User['email'] + // 用户头像 + avatar: User['avatar'] + // 个性化设置及通知身份绑定 + settings: User['settings'] + // 非空时更新密码 + password?: string +} + +/** 将服务端用户资料投影到页面状态,避免把本地编辑状态当成保存成功的事实。 */ +function applyUserProfile(user: User) { + accountInfo.value = { + ...user, + settings: user.settings ?? {}, + nickname: user.settings?.nickname ?? '', + } + currentUserName.value = user.name + currentAvatar.value = user.avatar || avatar1 +} + // 密码验证并执行回调 function withPasswordVerification(title: string, text: string, callback: (password: string) => void) { verifyTitle.value = title diff --git a/src/views/user/__tests__/UserProfileView.spec.ts b/src/views/user/__tests__/UserProfileView.spec.ts index 3f648cf1..35741b10 100644 --- a/src/views/user/__tests__/UserProfileView.spec.ts +++ b/src/views/user/__tests__/UserProfileView.spec.ts @@ -1,5 +1,7 @@ import UserProfileView from '@/views/user/UserProfileView.vue' +import { type AxiosResponse } from 'axios' import type { PassKey } from '@/api/types' +import { ApiRequestError } from '@/api/client' import { useUserStore } from '@/stores' import { fireEvent, screen, waitFor } from '@testing-library/vue' import { createPassKey, createUser } from '@tests/support/factories/user' @@ -29,18 +31,28 @@ vi.mock('vue-toastification', () => ({ useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), })) -function currentUser() { +function currentUser(overrides: Partial> = {}) { return createUser({ avatar: 'saved-avatar.png', email: 'alice@example.com', is_otp: false, settings: { nickname: 'Alice' }, + ...overrides, + }) +} + +/** 构造带 HTTP 状态和 FastAPI detail 的统一请求错误。 */ +function createHttpError(status: number, detail: string) { + const payload = { detail } + return new ApiRequestError('Request failed', { + payload, + response: { data: payload, status } as AxiosResponse, }) } function mockSuccessfulLoad() { mocks.apiGet.mockImplementation((url: string) => { - if (url === 'user/alice') return Promise.resolve(currentUser()) + if (url === 'user/current') return Promise.resolve(currentUser()) if (url === 'mfa/passkey/list') return Promise.resolve({ data: [createPassKey()], success: true }) return Promise.reject(new Error(`unexpected GET ${url}`)) }) @@ -89,7 +101,7 @@ describe('UserProfileView', () => { expect(await screen.findByDisplayValue('alice@example.com')).toBeInTheDocument() expect(screen.getByDisplayValue('Alice')).toBeInTheDocument() - expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'user/alice') + expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'user/current') expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'mfa/passkey/list') await fireEvent.click(screen.getByRole('button', { name: '账号安全' })) @@ -98,7 +110,7 @@ describe('UserProfileView', () => { it('shows a retryable error instead of a blank profile after loading fails', async () => { mocks.apiGet.mockRejectedValueOnce(new Error('network')).mockImplementation((url: string) => { - if (url === 'user/alice') return Promise.resolve(currentUser()) + if (url === 'user/current') return Promise.resolve(currentUser()) return Promise.resolve({ data: [], success: true }) }) await renderProfile() @@ -113,7 +125,7 @@ describe('UserProfileView', () => { it('shows the loaded profile while the passkey badge request is still pending', async () => { const passkeyRequest = deferred<{ data: PassKey[]; success: boolean }>() mocks.apiGet.mockImplementation((url: string) => { - if (url === 'user/alice') return Promise.resolve(currentUser()) + if (url === 'user/current') return Promise.resolve(currentUser()) if (url === 'mfa/passkey/list') return passkeyRequest.promise return Promise.reject(new Error(`unexpected GET ${url}`)) }) @@ -159,7 +171,7 @@ describe('UserProfileView', () => { it('keeps notification identity fields editable in the profile payload', async () => { mockSuccessfulLoad() - mocks.apiPut.mockResolvedValue({ success: true }) + mocks.apiPut.mockResolvedValue(currentUser()) await renderProfile() await screen.findByDisplayValue('alice@example.com') @@ -178,6 +190,9 @@ describe('UserProfileView', () => { await fireEvent.click(screen.getByRole('button', { name: '保存' })) await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce()) + expect(mocks.apiPut.mock.calls[0][0]).toBe('user/current') + expect(Object.keys(mocks.apiPut.mock.calls[0][1]).sort()).toEqual(['avatar', 'email', 'settings']) + expect(mocks.apiPut.mock.calls[0][1]).not.toHaveProperty('password') expect(mocks.apiPut.mock.calls[0][1]).toMatchObject({ settings: { discord_userid: 'discord-user', @@ -195,7 +210,13 @@ describe('UserProfileView', () => { it('saves edited profile data and updates the current-user store only after success', async () => { mockSuccessfulLoad() - mocks.apiPut.mockResolvedValue({ success: true }) + mocks.apiPut.mockResolvedValue( + currentUser({ + avatar: 'server-avatar.png', + email: 'server@example.com', + settings: { nickname: 'Server Name' }, + }), + ) await renderProfile() await screen.findByDisplayValue('alice@example.com') @@ -205,15 +226,40 @@ describe('UserProfileView', () => { await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce()) expect(mocks.apiPut).toHaveBeenCalledWith( - 'user/', + 'user/current', expect.objectContaining({ email: 'updated@example.com', - name: 'alice', settings: expect.objectContaining({ nickname: 'Alice Updated' }), }), ) expect(mocks.toastSuccess).toHaveBeenCalledWith('用户信息保存成功!') expect(useUserStore().userName).toBe('alice') + await waitFor(() => expect(screen.getByDisplayValue('server@example.com')).toBeInTheDocument()) + expect(screen.getByDisplayValue('Server Name')).toBeInTheDocument() + }) + + it('仅在成功时提交非空密码并清空两次密码输入', async () => { + mockSuccessfulLoad() + mocks.apiPut.mockResolvedValue(currentUser({ email: 'updated@example.com' })) + await renderProfile() + await screen.findByDisplayValue('alice@example.com') + + await fireEvent.update(screen.getByLabelText('邮箱'), 'updated@example.com') + await fireEvent.update(screen.getByLabelText('密码'), 'new-password') + await fireEvent.update(screen.getByLabelText('确认密码'), 'new-password') + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledOnce()) + expect(mocks.apiPut.mock.calls[0][1]).toEqual( + expect.objectContaining({ + email: 'updated@example.com', + password: expect.any(String), + }), + ) + expect(mocks.apiPut.mock.calls[0][1].password).toBe('new-password') + expect(Object.keys(mocks.apiPut.mock.calls[0][1]).sort()).toEqual(['avatar', 'email', 'password', 'settings']) + expect(screen.getByLabelText('密码')).toHaveValue('') + expect(screen.getByLabelText('确认密码')).toHaveValue('') }) it('keeps edited input retryable after a business failure', async () => { @@ -223,16 +269,54 @@ describe('UserProfileView', () => { await screen.findByDisplayValue('alice@example.com') await fireEvent.update(screen.getByLabelText('邮箱'), 'retry@example.com') + await fireEvent.update(screen.getByLabelText('密码'), 'new-password') + await fireEvent.update(screen.getByLabelText('确认密码'), 'new-password') await fireEvent.click(screen.getByRole('button', { name: '保存' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:没有权限!')) expect(screen.getByDisplayValue('retry@example.com')).toBeInTheDocument() + expect(screen.getByLabelText('密码')).toHaveValue('new-password') + expect(screen.getByLabelText('确认密码')).toHaveValue('new-password') + expect(screen.getByRole('button', { name: '保存' })).toBeEnabled() + }) + + it('shows the HTTP 403 permission message once and keeps edited input', async () => { + mockSuccessfulLoad() + mocks.apiPut.mockRejectedValue(createHttpError(403, '当前用户无权修改资料')) + await renderProfile() + await screen.findByDisplayValue('alice@example.com') + + await fireEvent.update(screen.getByLabelText('邮箱'), 'forbidden@example.com') + await fireEvent.update(screen.getByLabelText('密码'), 'new-password') + await fireEvent.update(screen.getByLabelText('确认密码'), 'new-password') + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:当前用户无权修改资料!') + expect(screen.getByDisplayValue('forbidden@example.com')).toBeInTheDocument() + expect(screen.getByLabelText('密码')).toHaveValue('new-password') + expect(screen.getByLabelText('确认密码')).toHaveValue('new-password') + expect(screen.getByRole('button', { name: '保存' })).toBeEnabled() + }) + + it('shows the HTTP 400 business detail and keeps edited input', async () => { + mockSuccessfulLoad() + mocks.apiPut.mockRejectedValue(createHttpError(400, '密码格式不符合要求')) + await renderProfile() + await screen.findByDisplayValue('alice@example.com') + + await fireEvent.update(screen.getByLabelText('邮箱'), 'invalid@example.com') + await fireEvent.click(screen.getByRole('button', { name: '保存' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) + expect(mocks.toastError).toHaveBeenCalledWith('用户信息保存失败:密码格式不符合要求!') + expect(screen.getByDisplayValue('invalid@example.com')).toBeInTheDocument() expect(screen.getByRole('button', { name: '保存' })).toBeEnabled() }) it('shows an HTTP save failure and allows the same input to be retried', async () => { mockSuccessfulLoad() - mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ success: true }) + mocks.apiPut.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(currentUser()) await renderProfile() await screen.findByDisplayValue('alice@example.com') @@ -247,7 +331,7 @@ describe('UserProfileView', () => { it('prevents duplicate save submissions while a request is pending', async () => { mockSuccessfulLoad() - let resolveUpdate!: (value: { success: boolean }) => void + let resolveUpdate!: (value: ReturnType) => void mocks.apiPut.mockReturnValue( new Promise(resolve => { resolveUpdate = resolve @@ -261,7 +345,7 @@ describe('UserProfileView', () => { await fireEvent.click(save) expect(mocks.apiPut).toHaveBeenCalledOnce() - resolveUpdate({ success: true }) + resolveUpdate(currentUser()) await waitFor(() => expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()) })