修复 AGC 客户端登录失败原因提示
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m7s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m38s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m38s
Project CI / AI game creator shell Rust crates (push) Successful in 2m29s
Project CI / Frontend tests (push) Successful in 4m42s
Project CI / Repository checks (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 7m45s
Project CI / Backend tests (push) Successful in 8m39s
Project CI / AI game creator shell web tests (push) Successful in 3m0s

- clientAuth 错误响应解析兼容未带 envelope 的旧形态错误体,密码错误时显示接口返回原因而不是固定“登录失败”
- 非 JSON 与结构无法识别的响应继续使用本地化兜底,不回显内部英文和原始 JSON
- appSurface 认证用例补充 envelope 与旧形态两条回归测试
This commit is contained in:
kdletters
2026-09-16 16:05:59 +08:00
parent b3e9d0a906
commit 9663bbf911
2 changed files with 120 additions and 6 deletions
@@ -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) => {