fix(auth): align v3 profile and auth errors

按 AUTH-001 配对交付门禁合并:已完成与后端认证契约的真实联调,前端 format、lint 和单测全部通过;PR Agent 配额失败按约定忽略。
This commit is contained in:
InfinityPacer
2026-08-31 04:57:55 +08:00
committed by GitHub
parent 31ec7d3ad6
commit 57127dd295
6 changed files with 178 additions and 65 deletions
+18 -1
View File
@@ -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 () => {
+5 -4
View File
@@ -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校验不通过')
})
})
+14 -4
View File
@@ -143,13 +143,23 @@ export class ApiRequestError<T = unknown> extends AxiosError<T> {
}
}
/** 返回 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<string, unknown>
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 响应中的业务失败。 */
+5 -4
View File
@@ -8,6 +8,7 @@ import i18n, { getCurrentLocale } from '@/plugins/i18n'
import {
ApiRequestError,
createApiClients,
getApiErrorMessage,
getApiBusinessErrorMessage,
isApiBusinessFailure,
isApiResponse,
@@ -33,8 +34,8 @@ const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
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,
+40 -40
View File
@@ -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<null>('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>('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
@@ -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<ReturnType<typeof createUser>> = {}) {
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<typeof currentUser>) => 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())
})