diff --git a/admin-frontend-vue/src/api/envelope.ts b/admin-frontend-vue/src/api/envelope.ts new file mode 100644 index 00000000..44766548 --- /dev/null +++ b/admin-frontend-vue/src/api/envelope.ts @@ -0,0 +1,62 @@ +/** API 响应解包与错误归一化(任务 22):纯函数,无框架依赖。 */ + +export const REQUEST_FALLBACK_MESSAGE = '网络异常,请稍后重试' + +function textOf(value: Record): string { + const message = value.message + const error = value.error + if (typeof message === 'string' && message.trim()) return message.trim() + if (typeof error === 'string' && error.trim()) return error.trim() + return '' +} + +/** + * 统一解包 Java 响应: + * - success=false -> 抛错(取 message/error,空则“请求失败”); + * - 带非空 data -> 返回 data; + * - 否则原样返回(允许 {item}/{items} 等信封由调用方再解)。 + */ +export function unwrap(payload: unknown): T { + const value = payload as Record | null + if (!value || typeof value !== 'object') return payload as T + if (value.success === false) { + throw new Error(textOf(value) || '请求失败') + } + if ('data' in value && value.data !== null && value.data !== undefined) { + return value.data as T + } + return payload as T +} + +/** 从任意负载取错误文案;缺省回退。 */ +export function errorTextOf(payload: unknown): string { + if (payload instanceof Error) return payload.message.trim() || REQUEST_FALLBACK_MESSAGE + if (payload && typeof payload === 'object') { + const text = textOf(payload as Record) + if (text) return text + } + if (typeof payload === 'string' && payload.trim()) return payload.trim() + return REQUEST_FALLBACK_MESSAGE +} + +/** 负载/状态码是否 401(未登录)。 */ +export function isUnauthorized(payload: unknown): boolean { + const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null + if (!record || typeof record !== 'object') return false + return [record.status, record.statusCode, record.code].some((v) => v === 401) +} + +/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */ +export function requestErrorMessage(error: unknown): string { + const response = (error as { response?: { data?: unknown; status?: number } })?.response + if (response) { + const text = errorTextOf(response.data) + if (text) return text + } + return errorTextOf(error) +} + +/** 当前地址作为登录跳转的 redirect 参数(encodeURIComponent 后)。 */ +export function loginRedirectTarget(location: { pathname: string; search: string }): string { + return encodeURIComponent(`${location.pathname}${location.search}`) +} diff --git a/admin-frontend-vue/src/api/http.ts b/admin-frontend-vue/src/api/http.ts new file mode 100644 index 00000000..fee76c3c --- /dev/null +++ b/admin-frontend-vue/src/api/http.ts @@ -0,0 +1,28 @@ +import axios from 'axios' +import { isUnauthorized, loginRedirectTarget } from './envelope' + +export { unwrap } from './envelope' + +export const http = axios.create({ + baseURL: '/', + withCredentials: true, + timeout: 30_000, +}) + +function redirectToLogin(): void { + if (typeof window === 'undefined') return + const target = loginRedirectTarget(window.location) + window.location.assign(`/login?redirect=${target}`) +} + +http.interceptors.response.use( + (response) => { + // Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理。 + if (isUnauthorized(response.data)) redirectToLogin() + return response + }, + (error) => { + if (error?.response?.status === 401 || isUnauthorized(error?.response?.data)) redirectToLogin() + return Promise.reject(error) + }, +) diff --git a/admin-frontend-vue/tests/task-22.test.ts b/admin-frontend-vue/tests/task-22.test.ts new file mode 100644 index 00000000..46040f50 --- /dev/null +++ b/admin-frontend-vue/tests/task-22.test.ts @@ -0,0 +1,71 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + errorTextOf, + isUnauthorized, + loginRedirectTarget, + requestErrorMessage, + REQUEST_FALLBACK_MESSAGE, + unwrap, +} from '../src/api/envelope.ts' + +test('test_task_022_envelope_rules_normal_primary_path', () => { + // 正常主路径:带 data 的信封解包出业务数据。 + const out = unwrap<{ item: { id: number } }>({ success: true, data: { item: { id: 1 } } }) + assert.equal(out.item.id, 1) +}) + +test('test_task_022_envelope_rules_normal_variant_input', () => { + // 正常变体:{items}/{item} 信封(无 data)原样返回由调用方再解。 + const payload = { success: true, items: [{ key: 'a' }] } + const out = unwrap<{ items: Array<{ key: string }> }>(payload) + assert.equal(out.items[0].key, 'a') +}) + +test('test_task_022_envelope_rules_normal_repeated_operation_is_idempotent', () => { + // 正常重复:解包结果稳定、不修改输入。 + const payload = { success: true, data: [1, 2] } + const a = JSON.stringify(unwrap(payload)) + const b = JSON.stringify(unwrap(payload)) + assert.equal(a, b) + assert.deepEqual(payload, { success: true, data: [1, 2] }) +}) + +test('test_task_022_envelope_rules_boundary_empty_input', () => { + // 边界空值:空/非对象负载按原样返回或回落网络提示。 + assert.equal(unwrap(undefined as unknown as null), undefined) + assert.equal(requestErrorMessage(undefined), REQUEST_FALLBACK_MESSAGE) +}) + +test('test_task_022_envelope_rules_boundary_single_item', () => { + // 边界单元素:错误文案优先取响应 message。 + const error = { response: { data: { message: '未登录', error: 'old' } } } + assert.equal(requestErrorMessage(error), '未登录') +}) + +test('test_task_022_envelope_rules_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:success=false 且 message 缺失回落到通用失败。 + assert.throws(() => unwrap({ success: false }), /请求失败/) + assert.equal(errorTextOf({ error: '权限不足' }), '权限不足') + assert.equal(errorTextOf({ message: '', error: 'X' }), 'X') +}) + +test('test_task_022_envelope_rules_invalid_input_rejected', () => { + // 异常输入:401 判定覆盖 code/status/statusCode;非 401 不误判。 + assert.equal(isUnauthorized({ code: 401 }), true) + assert.equal(isUnauthorized({ status: 401 }), true) + assert.equal(isUnauthorized({ statusCode: 401 }), true) + assert.equal(isUnauthorized({ code: 403 }), false) + assert.equal(isUnauthorized(null), false) + assert.equal(requestErrorMessage(new Error('Network Error')), 'Network Error') +}) + +test('test_task_022_envelope_rules_dependency_failure_returns_actionable_message', () => { + // 依赖失败:http 层复用 envelope 并统一处理 401 跳登录。 + const http = readSource('src/api/http.ts') + assert.match(http, /export \{ unwrap \} from '\.\/envelope'/) + assert.match(http, /isUnauthorized/) + assert.match(http, /login\?redirect=/) + assert.equal(loginRedirectTarget({ pathname: '/admin-vue/x', search: '?a=1' }), encodeURIComponent('/admin-vue/x?a=1')) +})