合并最新 master 修复策划入口 CI
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m58s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m36s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m47s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m3s
Project CI / Frontend tests (pull_request) Successful in 5m33s
Project CI / Repository checks (pull_request) Successful in 3m55s
Project CI / Native shell tests (pull_request) Successful in 6m50s
Project CI / Backend tests (pull_request) Successful in 7m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m49s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m58s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m36s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m47s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m3s
Project CI / Frontend tests (pull_request) Successful in 5m33s
Project CI / Repository checks (pull_request) Successful in 3m55s
Project CI / Native shell tests (pull_request) Successful in 6m50s
Project CI / Backend tests (pull_request) Successful in 7m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m49s
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<T>(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user