From bbdedfcea73a4e4dd89724a0e3d516ec8732e56a Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 19:10:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=8E=9F=E5=9B=A0=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一错误解析器支持字符串 error 响应 补充登录错误原因回归测试 同步认证排障记忆 --- docs/project-memory/shared-memory/pitfalls.md | 7 +++++ packages/shared/src/http.ts | 30 ++++++++++++++----- src/services/apiClient.test.ts | 26 ++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 00c1fa366..35e266485 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5596,3 +5596,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 原因:健康检查只能证明“有服务响应”,不能证明服务属于当前工作树;旧 `.app/dev-stack.json` 可能没有当前 `repoRoot`、`instanceId` 和服务级 dataDir 身份。 - 处理:先读取 `.app/dev-stack.json`,核对顶层 `repoRoot + instanceId`,再核对服务 `repoRoot + instanceId + dataDir + pid + port`;AGC Vite marker 还必须带 `repoRoot + processId + port`。任何字段缺失或不匹配都拒绝静默复用,改为启动当前工作树自己的服务或明确提示清理。 - 验证:`scripts/dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 覆盖 snapshot identity 和旧状态拒绝复用;运行时记录实际端口、进程命令行和 dataDir,不要只记录 HTTP 200。 + +## 2026-09-15 登录失败提示必须保留接口返回原因 + +- **现象**:账号登录失败时页面只显示“登录失败”,用户无法判断是手机号、验证码、密码还是服务状态问题。 +- **原因**:统一错误解析器只处理标准 `error.message/details` 结构;部分网关或旧兼容响应使用字符串 `error`,解析失败后回落到登录接口传入的通用文案。 +- **处理**:`parseApiErrorMessage` 同时支持字符串 `error`,标准嵌套结构保持原有优先级;未知或空响应继续使用通用兜底。 +- **验证**:`src/services/apiClient.test.ts` 新增字符串错误响应回归用例,定向测试 32 项通过,`npm run typecheck` 通过。 diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index 9aa01fafa..84e73fa8e 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -177,23 +177,34 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) { const parsed = JSON.parse(rawText) as | ApiErrorResponse | { - error?: { - message?: string; - code?: string; - details?: Record | null; - }; + error?: + | string + | { + message?: string; + code?: string; + details?: Record | null; + }; message?: string; code?: string; }; - const detailMessage = readApiErrorDetailMessage(parsed.error?.details); + const detailMessage = + typeof parsed.error === 'object' && parsed.error !== null + ? readApiErrorDetailMessage(parsed.error.details) + : ''; if (detailMessage) { return detailMessage; } + if (typeof parsed.error === 'string' && parsed.error.trim()) { + return parsed.error.trim(); + } + if ( - typeof parsed.error?.message === 'string' && + typeof parsed.error === 'object' && + parsed.error !== null && + typeof parsed.error.message === 'string' && parsed.error.message.trim() ) { return parsed.error.message.trim(); @@ -209,7 +220,10 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) { } const errorCode = - typeof parsed.error?.code === 'string' && parsed.error.code.trim() + typeof parsed.error === 'object' && + parsed.error !== null && + typeof parsed.error.code === 'string' && + parsed.error.code.trim() ? parsed.error.code.trim() : 'code' in parsed && typeof parsed.code === 'string' && diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index c1021f0d0..f4149b26e 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -1031,6 +1031,32 @@ describe('apiClient', () => { }); }); + it('preserves a concrete reason from legacy string error responses', async () => { + fetchMock.mockResolvedValueOnce( + createResponseMock({ + status: 401, + body: JSON.stringify({ error: '手机号或密码错误' }), + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + + await expect( + requestJson( + '/api/auth/entry', + { + method: 'POST', + }, + '登录失败', + { skipAuth: true, skipRefresh: true }, + ), + ).rejects.toMatchObject({ + message: '手机号或密码错误', + status: 401, + }); + }); + it('prefers api error details.reason over details.message for diagnostics', async () => { setStoredAccessToken('details-reason-first-token', { emit: false }); fetchMock.mockResolvedValueOnce( -- 2.52.0