统一手机号国家码与纯号码校验
Project CI / Frontend tests (pull_request) Successful in 30s
Project CI / Native shell tests (pull_request) Successful in 2m10s
Project CI / Backend tests (pull_request) Failing after 7s
Project CI / Repository checks (pull_request) Failing after 8s

认证请求以 countryCode 与 purePhoneNumber 替换旧 phone 字段
后端默认国家码 86 并拒绝其他国家码,复用纯手机号规范化生成 E.164
微信手机号绑定强制使用 provider 返回的国家码和纯号码并补齐前后端测试文档
This commit is contained in:
2026-07-23 13:37:23 +08:00
parent 2a80fa179a
commit 994b7119ec
25 changed files with 552 additions and 192 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
? '发送中'
+59 -18
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,
@@ -58,6 +57,7 @@ import {
startWechatBind,
startWechatLogin,
updateAuthProfile,
validateAndNormalizeMainlandChinaPhoneInput,
} from './authService';
function createLocalStorageMock() {
@@ -111,6 +111,20 @@ describe('authService', () => {
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',
@@ -133,7 +147,8 @@ describe('authService', () => {
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
countryCode: '86',
purePhoneNumber: '13800138000',
password: 'secret123',
}),
}),
@@ -231,7 +246,8 @@ describe('authService', () => {
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
countryCode: '86',
purePhoneNumber: '13800138000',
scene: 'login',
}),
}),
@@ -294,7 +310,8 @@ describe('authService', () => {
'/api/auth/phone/login',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
countryCode: '86',
purePhoneNumber: '13800138000',
code: '123456',
inviteCode: 'SPRING2026',
}),
@@ -363,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();
});
@@ -384,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();
});
@@ -511,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({
@@ -562,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',
+34 -13
View File
@@ -67,16 +67,40 @@ 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) {
const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, '');
const mainlandChinaInternationalPhone = compactPhone.match(
/^\+?86(1\d{10})$/u,
);
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) {
return (inviteCode ?? '')
.trim()
@@ -97,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);
}
@@ -151,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,
@@ -176,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 } : {}),
}),
@@ -205,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>(
@@ -229,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(),
}),
},
@@ -294,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(),
}),
},
@@ -355,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(),
}),