合并最新 master 分支
带入手机号区域码前后端校验修复(#104)。触及 auth 契约与前端登录链路, 与 BgFilter 分支零冲突,自动合并。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
? '发送中'
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user