修复AI游戏客户端手机号登录契约

密码登录、验证码发送和验证码登录改用国家码与纯手机号字段
非 JSON 认证错误回退为中文操作提示
补充登录契约、错误回退测试和决策记录
This commit is contained in:
AIGameCreator App
2026-07-27 21:31:35 +08:00
parent 6bcd176bdf
commit 26555e66af
3 changed files with 129 additions and 17 deletions
@@ -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<AuthEntryResponse>(
'/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<AuthPhoneSendCodeResponse>(
'/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<AuthPhoneLoginResponse>(
'/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 },
@@ -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) => {
@@ -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 生成。