From 26555e66af3fc89c8f5b4d30ab9928874668bc44 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Mon, 27 Jul 2026 21:31:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAI=E6=B8=B8=E6=88=8F=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=89=8B=E6=9C=BA=E5=8F=B7=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 密码登录、验证码发送和验证码登录改用国家码与纯手机号字段 非 JSON 认证错误回退为中文操作提示 补充登录契约、错误回退测试和决策记录 --- .../src/services/clientAuth.ts | 50 +++++++--- .../tests/appSurface/auth.suite.ts | 95 ++++++++++++++++++- .../shared-memory/decision-log.md | 1 + 3 files changed, 129 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 8ae9207ee..bd4e66d9a 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,7 +1,11 @@ import type { + AuthEntryRequest, AuthEntryResponse, AuthMeResponse, + AuthPhoneLoginRequest, AuthPhoneLoginResponse, + AuthPhoneNumberInput, + AuthPhoneSendCodeRequest, AuthPhoneSendCodeResponse, AuthRefreshResponse, LogoutResponse, @@ -16,7 +20,17 @@ const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082'; export function normalizeAuthPhoneInput(phone: string) { - return phone.replace(/[^\d+]/gu, '').trim(); + const compactPhone = phone.replace(/[^\d+]/gu, '').trim(); + const mainlandChinaInternationalPhone = + compactPhone.match(/^\+?86(1\d{10})$/u); + return mainlandChinaInternationalPhone?.[1] ?? compactPhone; +} + +function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput { + return { + countryCode: '86', + purePhoneNumber: normalizeAuthPhoneInput(phone), + }; } export function getStoredAuthAccessToken() { @@ -89,8 +103,13 @@ async function readAuthErrorMessage(response: Response, fallback: string) { if (!text.trim()) { return fallback; } + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + return fallback; + } try { - const parsed = JSON.parse(text) as unknown; unwrapApiResponse(parsed); } catch (error) { return error instanceof Error ? error.message : fallback; @@ -164,15 +183,16 @@ export async function refreshClientAuthAccessToken() { } export async function loginClientWithPassword(phone: string, password: string) { + const request: AuthEntryRequest = { + ...buildClientAuthPhoneInput(phone), + password: password.trim(), + }; const response = await requestAuthJson( '/api/auth/entry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizeAuthPhoneInput(phone), - password: password.trim(), - }), + body: JSON.stringify(request), }, '登录失败', { skipAuth: true }, @@ -182,15 +202,16 @@ export async function loginClientWithPassword(phone: string, password: string) { } export async function sendClientPhoneLoginCode(phone: string) { + const request: AuthPhoneSendCodeRequest = { + ...buildClientAuthPhoneInput(phone), + scene: 'login', + }; return requestAuthJson( '/api/auth/phone/send-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizeAuthPhoneInput(phone), - scene: 'login', - }), + body: JSON.stringify(request), }, '发送验证码失败', { skipAuth: true }, @@ -198,15 +219,16 @@ export async function sendClientPhoneLoginCode(phone: string) { } export async function loginClientWithPhoneCode(phone: string, code: string) { + const request: AuthPhoneLoginRequest = { + ...buildClientAuthPhoneInput(phone), + code: code.trim(), + }; const response = await requestAuthJson( '/api/auth/phone/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizeAuthPhoneInput(phone), - code: code.trim(), - }), + body: JSON.stringify(request), }, '登录失败', { skipAuth: true }, 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 450464c9e..67f247cdd 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -94,7 +94,8 @@ export function registerAuthTests() { } if (url === '/api/auth/phone/send-code') { expect(JSON.parse(String(init?.body))).toMatchObject({ - phone: '13800000000', + countryCode: '86', + purePhoneNumber: '13800000000', scene: 'login', }); return new Response( @@ -109,7 +110,8 @@ export function registerAuthTests() { } if (url === '/api/auth/phone/login') { expect(JSON.parse(String(init?.body))).toMatchObject({ - phone: '13800000000', + countryCode: '86', + purePhoneNumber: '13800000000', code: '123456', }); return new Response( @@ -138,7 +140,7 @@ export function registerAuthTests() { await screen.findByRole('main', { name: '登录' }); fireEvent.change(screen.getByLabelText('手机号'), { - target: { value: '138 0000 0000' }, + target: { value: '+86 138 0000 0000' }, }); fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); expect( @@ -167,6 +169,93 @@ export function registerAuthTests() { ).toHaveLength(1); }); + it('logs in with the current password phone contract', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/entry') { + expect(JSON.parse(String(init?.body))).toEqual({ + countryCode: '86', + purePhoneNumber: '13800000000', + password: 'secret123', + }); + return new Response( + JSON.stringify({ + token: 'password-token', + user: { ...testAuthUser, loginMethod: 'password' }, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user }) => + React.createElement( + 'main', + { 'aria-label': '已登录' }, + user.loginMethod, + ), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '+86 138 0000 0000' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: ' secret123 ' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByLabelText('已登录')).not.toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('password-token'); + }); + + it('falls back to the localized action error for non-JSON auth failures', 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/phone/login') { + return new Response( + 'Failed to deserialize the JSON body into the target type', + { status: 422, headers: { 'Content-Type': 'text/plain' } }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('验证码'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('登录失败')).not.toBeNull(); + expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull(); + }); + it('shows a clear login service error instead of raw Load failed', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 805847e31..bbe14cb41 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5522,5 +5522,6 @@ ## 2026-07-23 手机号认证统一使用国家码与纯号码双字段 - 决策:普通手机号认证请求统一使用可选 `countryCode` 与必填 `purePhoneNumber`,省略国家码时默认中国大陆 `86`,直接替换旧 `phone` 字段。前端把浏览器 E.164 自动填充值拆成这两个字段;后端先验证国家码,再复用纯手机号规范化并生成 E.164 存储。 +- 2026-07-27 补齐:AI 游戏创作客户端的密码登录、验证码发送和验证码登录统一复用共享 TypeScript 请求契约,固定把中国大陆输入拆成 `countryCode=86 + purePhoneNumber`,不再发送旧 `phone`。认证 HTTP 错误只有在响应为合法 JSON envelope 时才展示后端安全消息;Axum 422 等非 JSON 正文回退到当前动作的中文错误,不向用户展示 JSON 解析器异常或原始反序列化文本。 - 微信边界:小程序客户端仍只上传 `wechatPhoneCode`;`platform-auth` 必须要求微信成功响应中的 `phoneNumber`、`countryCode` 与 `purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。 - 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。