diff --git a/admin-frontend-vue/src/api/envelope.ts b/admin-frontend-vue/src/api/envelope.ts index 44766548..a2368b50 100644 --- a/admin-frontend-vue/src/api/envelope.ts +++ b/admin-frontend-vue/src/api/envelope.ts @@ -60,3 +60,36 @@ export function requestErrorMessage(error: unknown): string { export function loginRedirectTarget(location: { pathname: string; search: string }): string { return encodeURIComponent(`${location.pathname}${location.search}`) } + +/** 登录页统一入口;401 一律跳此路径。 */ +export const LOGIN_PATH = '/login' + +/** 当前 pathname 是否处于登录页(去掉可能携带的查询串后比较)。 */ +export function isLoginLocation(pathname: string): boolean { + if (typeof pathname !== 'string') return false + const queryAt = pathname.indexOf('?') + return (queryAt >= 0 ? pathname.slice(0, queryAt) : pathname) === LOGIN_PATH +} + +/** 把请求 URL(相对或绝对)规整为相对路径,便于与登录入口比较。 */ +export function requestRelativePath(url: string): string { + if (typeof url !== 'string') return '' + const noQuery = url.split('?')[0] + const match = noQuery.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(.*)$/) + return match ? match[1] : noQuery +} + +/** 401 失败请求本身是否指向登录/认证端点(登录提交失败不应再被踢回登录页)。 */ +export function isAuthEndpointRequest(url: string | undefined): boolean { + return requestRelativePath(url || '') === LOGIN_PATH +} + +/** + * 401 是否应执行“跳 /login”:已处于登录页或失败请求即登录端点时不应再跳, + * 否则会在登录页循环跳转或吞掉登录失败反馈。 + */ +export function shouldRedirectUnauthorized(currentPathname: string, requestUrl?: string): boolean { + if (isLoginLocation(currentPathname)) return false + if (isAuthEndpointRequest(requestUrl)) return false + return true +} diff --git a/admin-frontend-vue/src/api/http.ts b/admin-frontend-vue/src/api/http.ts index fee76c3c..92f21795 100644 --- a/admin-frontend-vue/src/api/http.ts +++ b/admin-frontend-vue/src/api/http.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { isUnauthorized, loginRedirectTarget } from './envelope' +import { isUnauthorized, loginRedirectTarget, shouldRedirectUnauthorized } from './envelope' export { unwrap } from './envelope' @@ -9,8 +9,10 @@ export const http = axios.create({ timeout: 30_000, }) -function redirectToLogin(): void { +function redirectToLogin(requestUrl?: string): void { if (typeof window === 'undefined') return + // 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。 + if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return const target = loginRedirectTarget(window.location) window.location.assign(`/login?redirect=${target}`) } @@ -18,11 +20,13 @@ function redirectToLogin(): void { http.interceptors.response.use( (response) => { // Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理。 - if (isUnauthorized(response.data)) redirectToLogin() + if (isUnauthorized(response.data)) redirectToLogin(response.config?.url) return response }, (error) => { - if (error?.response?.status === 401 || isUnauthorized(error?.response?.data)) redirectToLogin() + if (error?.response?.status === 401 || isUnauthorized(error?.response?.data)) { + redirectToLogin(error?.config?.url) + } return Promise.reject(error) }, ) diff --git a/admin-frontend-vue/tests/task-26.test.ts b/admin-frontend-vue/tests/task-26.test.ts new file mode 100644 index 00000000..e647e4f4 --- /dev/null +++ b/admin-frontend-vue/tests/task-26.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + isAuthEndpointRequest, + isLoginLocation, + shouldRedirectUnauthorized, +} from '../src/api/envelope.ts' + +test('test_task_026_unauthorized_redirect_normal_primary_path', () => { + // 正常主路径:普通业务页内某受保护接口 401,应跳转登录(携带目标路径)。 + assert.equal( + shouldRedirectUnauthorized('/admin-vue/account/users', '/api/admin/current-user'), + true, + '业务页内受保护请求 401 应触发跳登录', + ) +}) + +test('test_task_026_unauthorized_redirect_normal_variant_input', () => { + // 正常变体:已经在登录页(含 redirect 查询)时任何 401 不再二次踢回,避免循环。 + assert.equal(shouldRedirectUnauthorized('/login', '/api/admin/current-user'), false) + assert.equal(shouldRedirectUnauthorized('/login?redirect=%2Fadmin-vue%2Faccount%2Fusers', '/api/admin/current-user'), false) +}) + +test('test_task_026_unauthorized_redirect_normal_repeated_operation_is_idempotent', () => { + // 正常重复:纯函数重复求值结果稳定,无副作用。 + const once = shouldRedirectUnauthorized('/login', '/login') + const twice = shouldRedirectUnauthorized('/login', '/login') + assert.equal(once, false) + assert.equal(twice, once) + assert.equal(isLoginLocation('/login?redirect=/x'), isLoginLocation('/login?redirect=/x')) +}) + +test('test_task_026_unauthorized_redirect_boundary_empty_input', () => { + // 边界空值:当前路径为空/请求 URL 缺省时不误判为登录场景,按应跳转处理。 + assert.equal(shouldRedirectUnauthorized('', undefined), true) + assert.equal(shouldRedirectUnauthorized('/admin-vue/', ''), true) + assert.equal(isLoginLocation(''), false) + assert.equal(isAuthEndpointRequest(undefined), false) +}) + +test('test_task_026_unauthorized_redirect_boundary_single_item', () => { + // 边界单元素:401 请求本身是登录提交时不得再跳登录(否则吞掉登录失败反馈)。 + assert.equal(shouldRedirectUnauthorized('/admin-vue/account/users', '/login'), false) + assert.equal(isAuthEndpointRequest('/login'), true) +}) + +test('test_task_026_unauthorized_redirect_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:登录端点支持绝对 URL 与带查询串的受保护请求两种形态。 + assert.equal(isAuthEndpointRequest('http://api.aishufu.top/login'), true) + assert.equal(isAuthEndpointRequest('https://aishufu.top/login?device=web'), true) + assert.equal(shouldRedirectUnauthorized('/admin-vue/', '/api/admin/current-user?x=1'), true) + assert.equal(isAuthEndpointRequest('/api/admin/current-user?x=1'), false) +}) + +test('test_task_026_unauthorized_redirect_invalid_input_rejected', () => { + // 异常输入:非法/非字符串输入不抛错,且默认按“应跳转”处理而非误放行。 + assert.equal(shouldRedirectUnauthorized(undefined as unknown as string, undefined), true) + assert.equal(shouldRedirectUnauthorized(null as unknown as string, null as unknown as string), true) + assert.equal(isAuthEndpointRequest(42 as unknown as string), false) + assert.equal(isLoginLocation('/admin-vue/login'), false) +}) + +test('test_task_026_unauthorized_redirect_dependency_failure_returns_actionable_message', () => { + // 依赖失败:http 层 success/error 两支都要先过守卫再带目标路径跳 /login。 + const http = readSource('src/api/http.ts') + assert.match(http, /shouldRedirectUnauthorized/, '401 跳转必须经守卫避免登录页循环') + assert.match(http, /login\?redirect=/, '跳转仍携带目标路径参数') + assert.match(http, /interceptors\.response/) + assert.match(http, /response\.config\?\.url/, '成功分支以失败请求 URL 判定登录请求') + assert.match(http, /error\?\.config\?\.url|error\.config\.url/, '异常分支同样传入请求 URL') +})