合并最新 master 分支
Project CI / Repository checks (pull_request) Successful in 1m38s
Project CI / Backend tests (pull_request) Failing after 1m31s
Project CI / Frontend tests (pull_request) Failing after 2m2s
Project CI / Native shell tests (pull_request) Successful in 2m2s

带入手机号区域码前后端校验修复(#104)。触及 auth 契约与前端登录链路,
与 BgFilter 分支零冲突,自动合并。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 07:04:31 +00:00
25 changed files with 650 additions and 208 deletions
+26 -7
View File
@@ -39,10 +39,9 @@ const authMocks = vi.hoisted(() => ({
}));
vi.mock('../../services/apiClient', async () => {
const actual =
await vi.importActual<typeof import('../../services/apiClient')>(
'../../services/apiClient',
);
const actual = await vi.importActual<
typeof import('../../services/apiClient')
>('../../services/apiClient');
return {
...actual,
@@ -480,9 +479,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
const phoneInput = within(dialog).getByLabelText(
'手机号',
) as HTMLInputElement;
const codeInput = within(dialog).getByLabelText(
'验证码',
) as HTMLInputElement;
const codeInput = within(dialog).getByLabelText('验证码') as HTMLInputElement;
expect(phoneInput.className).toContain('platform-text-field');
expect(codeInput.className).toContain('platform-text-field');
@@ -937,6 +934,28 @@ test('auth gate shows sms send feedback in the login modal', async () => {
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});
test('auth gate shows mainland China phone validation errors', async () => {
const user = userEvent.setup();
authMocks.sendPhoneLoginCode.mockRejectedValueOnce(
new Error('仅支持中国大陆手机号(+86'),
);
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '+12025550123');
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
expect(
await within(dialog).findByText('仅支持中国大陆手机号(+86'),
).toBeTruthy();
});
test('login modal resets draft state every time it is reopened', async () => {
const user = userEvent.setup();
+6 -2
View File
@@ -530,7 +530,9 @@ function PhoneCodeForm({
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
onClick={() => {
void onSendCode().catch(() => undefined);
}}
>
{sendingCode
? '发送中'
@@ -624,7 +626,9 @@ function PasswordResetPanel({
tone="secondary"
size="lg"
className="shrink-0 text-sm"
onClick={() => void onSendCode()}
onClick={() => {
void onSendCode().catch(() => undefined);
}}
>
{sendingCode
? '发送中'
+68 -20
View File
@@ -20,10 +20,9 @@ vi.mock('./apiClient', async () => {
});
vi.mock('./host-bridge/hostBridge', async () => {
const actual =
await vi.importActual<typeof import('./host-bridge/hostBridge')>(
'./host-bridge/hostBridge',
);
const actual = await vi.importActual<
typeof import('./host-bridge/hostBridge')
>('./host-bridge/hostBridge');
return {
...actual,
openHostExternalUrl: hostBridgeMocks.openHostExternalUrl,
@@ -49,6 +48,7 @@ import {
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
normalizePhoneInput,
redeemRegistrationInviteCode,
requestWechatMiniProgramPhoneLogin,
revokeAuthSession,
@@ -57,6 +57,7 @@ import {
startWechatBind,
startWechatLogin,
updateAuthProfile,
validateAndNormalizeMainlandChinaPhoneInput,
} from './authService';
function createLocalStorageMock() {
@@ -104,6 +105,26 @@ describe('authService', () => {
clearStoredAccessToken({ emit: false });
});
it('normalizes mainland China browser autofill phone numbers to national format', () => {
expect(normalizePhoneInput('+86 198 7654 3210')).toBe('19876543210');
expect(normalizePhoneInput('86-198-7654-3210')).toBe('19876543210');
expect(normalizePhoneInput('198 7654 3210')).toBe('19876543210');
});
it('validates mainland China phone numbers before calling auth APIs', async () => {
expect(
validateAndNormalizeMainlandChinaPhoneInput('+86 198 7654 3210'),
).toBe('19876543210');
expect(validateAndNormalizeMainlandChinaPhoneInput('198 7654 3210')).toBe(
'19876543210',
);
await expect(sendPhoneLoginCode('+1 202 555 0123')).rejects.toThrow(
'仅支持中国大陆手机号(+86',
);
expect(apiClientMocks.requestJson).not.toHaveBeenCalled();
});
it('auth entry posts phone password credentials and 写入 access token', async () => {
apiClientMocks.requestJson.mockResolvedValue({
token: 'jwt-entry-token',
@@ -126,7 +147,8 @@ describe('authService', () => {
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
countryCode: '86',
purePhoneNumber: '13800138000',
password: 'secret123',
}),
}),
@@ -217,14 +239,15 @@ describe('authService', () => {
providerRequestId: 'mock-request-id',
});
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
const result = await sendPhoneLoginCode('+86 138 0013 8000');
expect(result.cooldownSeconds).toBe(60);
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
countryCode: '86',
purePhoneNumber: '13800138000',
scene: 'login',
}),
}),
@@ -277,7 +300,7 @@ describe('authService', () => {
});
const response = await loginWithPhoneCode(
'13800138000',
'+86 138 0013 8000',
'123456',
'spring-2026',
);
@@ -287,7 +310,8 @@ describe('authService', () => {
'/api/auth/phone/login',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
countryCode: '86',
purePhoneNumber: '13800138000',
code: '123456',
inviteCode: 'SPRING2026',
}),
@@ -356,6 +380,17 @@ describe('authService', () => {
const user = await bindWechatPhone('13800138000', '123456');
expect(user.wechatBound).toBe(true);
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/bind-phone',
expect.objectContaining({
body: JSON.stringify({
countryCode: '86',
purePhoneNumber: '13800138000',
code: '123456',
}),
}),
'绑定手机号失败',
);
expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
@@ -377,6 +412,17 @@ describe('authService', () => {
const user = await changePhoneNumber('13900139000', '123456');
expect(user.phoneNumberMasked).toBe('139****9000');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/change',
expect.objectContaining({
body: JSON.stringify({
countryCode: '86',
purePhoneNumber: '13900139000',
code: '123456',
}),
}),
'更换手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
});
@@ -504,9 +550,11 @@ describe('authService', () => {
});
it('requests mini program phone login by opening the native auth page', async () => {
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
const navigateTo = vi.fn(
(options: { url: string; success?: () => void }) => {
options.success?.();
},
);
vi.stubGlobal(
'window',
createWindowMock({
@@ -555,16 +603,16 @@ describe('authService', () => {
});
it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => {
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
const navigateTo = vi.fn(
(options: { url: string; success?: () => void }) => {
options.success?.();
},
);
const scriptListeners = new Map<string, EventListener>();
const existingScript = {
addEventListener: vi.fn(
(type: string, listener: EventListener) => {
scriptListeners.set(type, listener);
},
),
addEventListener: vi.fn((type: string, listener: EventListener) => {
scriptListeners.set(type, listener);
}),
};
vi.stubGlobal(
'window',
+37 -11
View File
@@ -67,9 +67,38 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = {
} satisfies ApiRequestOptions;
const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
const INVALID_MAINLAND_CHINA_PHONE_MESSAGE = '手机号格式不正确';
const UNSUPPORTED_PHONE_COUNTRY_CODE_MESSAGE = '仅支持中国大陆手机号(+86';
export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim();
const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, '');
const mainlandChinaInternationalPhone =
compactPhone.match(/^\+?86(1\d{10})$/u);
return mainlandChinaInternationalPhone?.[1] ?? compactPhone;
}
export function validateAndNormalizeMainlandChinaPhoneInput(
phoneInput: string,
) {
const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, '');
if (compactPhone.startsWith('+') && !compactPhone.startsWith('+86')) {
throw new Error(UNSUPPORTED_PHONE_COUNTRY_CODE_MESSAGE);
}
const normalizedPhone = normalizePhoneInput(phoneInput);
if (!/^1\d{10}$/u.test(normalizedPhone)) {
throw new Error(INVALID_MAINLAND_CHINA_PHONE_MESSAGE);
}
return normalizedPhone;
}
function buildMainlandChinaPhoneInput(phoneInput: string) {
return {
countryCode: '86',
purePhoneNumber: validateAndNormalizeMainlandChinaPhoneInput(phoneInput),
} as const;
}
export function normalizeInviteCodeInput(inviteCode: string | undefined) {
@@ -92,10 +121,7 @@ export function setStoredLastLoginPhone(phone: string) {
return;
}
const normalizedPhone = normalizePhoneInput(phone);
if (!normalizedPhone) {
return;
}
const normalizedPhone = validateAndNormalizeMainlandChinaPhoneInput(phone);
window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone);
}
@@ -146,7 +172,7 @@ export async function sendPhoneLoginCode(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizePhoneInput(phone),
...buildMainlandChinaPhoneInput(phone),
scene,
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
captchaAnswer: captcha?.answer?.trim() || undefined,
@@ -171,7 +197,7 @@ export async function loginWithPhoneCode(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizePhoneInput(phone),
...buildMainlandChinaPhoneInput(phone),
code: code.trim(),
...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}),
}),
@@ -200,7 +226,7 @@ export async function redeemRegistrationInviteCode(inviteCode: string) {
export async function bindWechatPhone(phone: string, code: string) {
const payload: AuthWechatBindPhoneRequest = {
phone: normalizePhoneInput(phone),
...buildMainlandChinaPhoneInput(phone),
code: code.trim(),
};
const response = await requestJson<AuthWechatBindPhoneResponse>(
@@ -224,7 +250,7 @@ export async function changePhoneNumber(phone: string, code: string) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizePhoneInput(phone),
...buildMainlandChinaPhoneInput(phone),
code: code.trim(),
}),
},
@@ -289,7 +315,7 @@ export async function authEntry(phone: string, password: string) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizePhoneInput(phone),
...buildMainlandChinaPhoneInput(phone),
password: password.trim(),
}),
},
@@ -350,7 +376,7 @@ export async function resetPassword(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizePhoneInput(phone),
...buildMainlandChinaPhoneInput(phone),
code: code.trim(),
newPassword: newPassword.trim(),
}),