diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index c0b5e0750..65d0650da 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -13,6 +13,8 @@ import type { import { API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION, + isApiResponse, + parseApiErrorMessage, unwrapApiResponse, } from '../../../../packages/shared/src/http'; import { @@ -138,6 +140,18 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } +/** + * 旧形态错误体:未带 `x-genarrative-response-envelope` 时后端返回 + * `{ error: { code, message }, meta }`,没有 `ok` 字段,但 message 同样是给用户看的原因。 + */ +function isLegacyApiErrorBody(value: unknown) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const record = value as Record; + return 'error' in record || 'message' in record || 'code' in record; +} + async function readAuthErrorMessage(response: Response, fallback: string) { const httpFallback = getClientAuthHttpErrorMessage(response.status, fallback); const text = await readClientHttpResponseText(response, { @@ -150,15 +164,27 @@ async function readAuthErrorMessage(response: Response, fallback: string) { try { parsed = JSON.parse(text) as unknown; } catch { + // 非 JSON(代理错误页、纯文本)不把内部英文原样抛给用户。 return httpFallback; } - try { - unwrapApiResponse(parsed); - } catch (error) { - const message = error instanceof Error ? error.message.trim() : ''; - return message && message !== '请求失败' ? message : httpFallback; + if (isApiResponse(parsed)) { + try { + unwrapApiResponse(parsed); + } catch (error) { + const message = error instanceof Error ? error.message.trim() : ''; + return message && message !== '请求失败' ? message : httpFallback; + } + return httpFallback; } - return httpFallback; + if (!isLegacyApiErrorBody(parsed)) { + return httpFallback; + } + // 旧形态错误体仍按共享契约解析,否则“手机号或密码错误”这类明确原因会退化成固定文案。 + const legacyMessage = parseApiErrorMessage(text, httpFallback).trim(); + // 共享解析器在认不出结构时会回显原始 JSON,这里不允许把它当成用户可见文案。 + return legacyMessage && legacyMessage !== text.trim() + ? legacyMessage + : httpFallback; } async function requestAuthJson( diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index bfb771cb0..586a4e0a7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1156,6 +1156,94 @@ export function registerAuthTests() { expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull(); }); + it('shows the backend reason when the password login is rejected', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/entry') { + return new Response( + JSON.stringify({ + ok: false, + data: null, + error: { code: 'unauthorized', message: '手机号或密码错误' }, + meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '15801783533' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect(screen.queryByText('登录失败')).toBeNull(); + }); + + it('keeps the backend reason when the error body carries no envelope', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/entry') { + return new Response( + JSON.stringify({ + error: { code: 'unauthorized', message: '手机号或密码错误' }, + meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '15801783533' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect(screen.queryByText('登录失败')).toBeNull(); + }); + it('shows a clear login service error instead of raw Load failed', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => {