Files
Genarrative/src/services/authService.ts
T
k88936 44748b7846
Project CI / Frontend tests (push) Successful in 34s
Project CI / Backend tests (push) Failing after 39s
Project CI / Repository checks (push) Successful in 52s
Project CI / Native shell tests (push) Successful in 2m4s
fix:前后端校验手机号区域码 (#104)
来自今早群友反映
before:
![shotmd-1784692399.jpg](/attachments/3ab36252-78f4-44e2-bb51-fca84badc79e)
and failed
after:
![shotmd-1784702966.jpg](/attachments/3eea7ec9-8aca-43f6-a675-1316162619d4)
![shotmd-1784702843.jpg](/attachments/29182a41-26cf-4954-be16-de060d844d9c)
![shotmd-1784787134.jpg](/attachments/c04a1981-c6bf-4a26-b5ad-d2af51d74a29)

* https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html#Res-phone-info-Object-Payload 参考微信这个文档修改了微信返回的struct 手机号 区域码字段不应是Optional

* 登录注册改密码改绑定等 api 把单一 phone字段改成 country_code(可选,缺省为86) + pure_phone_number 分别进行了前后端校验

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/104
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-23 14:52:57 +08:00

571 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AuthAuditLogEntry,
AuthAuditLogsResponse,
AuthCaptchaChallenge,
AuthEntryResponse,
AuthLiftRiskBlockResponse,
AuthLoginMethod,
AuthLoginOptionsResponse,
AuthLogoutAllResponse,
AuthMeResponse,
AuthPasswordChangeResponse,
AuthPasswordResetResponse,
AuthPhoneChangeResponse,
AuthPhoneLoginResponse,
AuthPhoneSendCodeResponse,
AuthProfileUpdateRequest,
AuthProfileUpdateResponse,
AuthRevokeSessionResponse,
AuthRiskBlocksResponse,
AuthRiskBlockSummary,
AuthSessionsResponse,
AuthSessionSummary,
AuthWechatBindPhoneRequest,
AuthWechatBindPhoneResponse,
AuthWechatStartResponse,
LogoutResponse,
PublicUserSearchResponse,
} from '../../packages/shared/src/contracts/auth';
import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime';
import {
ApiClientError,
type ApiRequestOptions,
clearStoredAccessToken,
emitAuthStateChange,
requestJson,
setStoredAccessToken,
} from './apiClient';
import { openHostExternalUrl } from './host-bridge/hostBridge';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
export {
isWechatMiniProgramWebViewRuntime,
requestHostLogin as requestWechatMiniProgramPhoneLogin,
} from './host-bridge/hostBridge';
export type AuthSessionSnapshot = {
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
availableLoginMethods: AuthLoginMethod[];
};
export type { AuthSessionSummary };
export type { AuthCaptchaChallenge };
export type { AuthAuditLogEntry };
export type { AuthRiskBlockSummary };
export type ConsumedAuthCallback = {
provider: 'wechat' | 'unknown';
bindingStatus: string | null;
error: string | null;
};
// 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测,
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
const PUBLIC_AUTH_REQUEST_OPTIONS = {
skipAuth: true,
skipRefresh: true,
} 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);
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()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
}
export function getStoredLastLoginPhone() {
if (typeof window === 'undefined') {
return '';
}
return window.localStorage.getItem(LAST_LOGIN_PHONE_STORAGE_KEY) ?? '';
}
export function setStoredLastLoginPhone(phone: string) {
if (typeof window === 'undefined') {
return;
}
const normalizedPhone = validateAndNormalizeMainlandChinaPhoneInput(phone);
window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone);
}
export function getCaptchaChallengeFromError(
error: unknown,
): AuthCaptchaChallenge | null {
if (
error instanceof ApiClientError &&
error.code === 'CAPTCHA_REQUIRED' &&
error.details &&
typeof error.details === 'object' &&
'captchaChallenge' in error.details
) {
const challenge = (error.details as { captchaChallenge?: unknown })
.captchaChallenge;
if (
challenge &&
typeof challenge === 'object' &&
'challengeId' in challenge &&
'promptText' in challenge &&
'imageDataUrl' in challenge &&
'expiresInSeconds' in challenge
) {
return challenge as AuthCaptchaChallenge;
}
}
return null;
}
export function clearAuthSession() {
clearStoredAccessToken({ emit: false });
emitAuthStateChange();
}
export async function sendPhoneLoginCode(
phone: string,
scene: 'login' | 'bind_phone' | 'change_phone' | 'reset_password' = 'login',
captcha?: {
challengeId?: string;
answer?: string;
},
) {
const response = await requestJson<AuthPhoneSendCodeResponse>(
'/api/auth/phone/send-code',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...buildMainlandChinaPhoneInput(phone),
scene,
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
captchaAnswer: captcha?.answer?.trim() || undefined,
}),
},
'发送验证码失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
return response;
}
export async function loginWithPhoneCode(
phone: string,
code: string,
inviteCode?: string,
) {
const normalizedInviteCode = normalizeInviteCodeInput(inviteCode);
const response = await requestJson<AuthPhoneLoginResponse>(
'/api/auth/phone/login',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...buildMainlandChinaPhoneInput(phone),
code: code.trim(),
...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}),
}),
},
'登录失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
setStoredAccessToken(response.token, { emit: false });
return response;
}
export async function redeemRegistrationInviteCode(inviteCode: string) {
return requestJson<RedeemProfileReferralInviteCodeResponse>(
'/api/profile/referrals/redeem-code',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inviteCode: normalizeInviteCodeInput(inviteCode),
}),
},
'填写邀请码失败',
);
}
export async function bindWechatPhone(phone: string, code: string) {
const payload: AuthWechatBindPhoneRequest = {
...buildMainlandChinaPhoneInput(phone),
code: code.trim(),
};
const response = await requestJson<AuthWechatBindPhoneResponse>(
'/api/auth/wechat/bind-phone',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'绑定手机号失败',
);
setStoredAccessToken(response.token, { emit: false });
return response.user;
}
export async function changePhoneNumber(phone: string, code: string) {
const response = await requestJson<AuthPhoneChangeResponse>(
'/api/auth/phone/change',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...buildMainlandChinaPhoneInput(phone),
code: code.trim(),
}),
},
'更换手机号失败',
);
return response.user;
}
export async function startWechatLogin() {
const redirectPath = `${window.location.pathname}${window.location.search}`;
const response = await requestJson<AuthWechatStartResponse>(
`/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`,
{
method: 'GET',
},
'微信登录暂不可用',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
const openedByHost = await openHostExternalUrl({
url: response.authorizationUrl,
});
if (!openedByHost) {
window.location.assign(response.authorizationUrl);
}
}
export async function startWechatBind() {
const redirectPath = `${window.location.pathname}${window.location.search}`;
const response = await requestJson<AuthWechatStartResponse>(
`/api/auth/wechat/bind-start?redirectPath=${encodeURIComponent(redirectPath)}`,
{
method: 'GET',
},
'微信授权暂不可用',
);
const openedByHost = await openHostExternalUrl({
url: response.authorizationUrl,
});
if (!openedByHost) {
window.location.assign(response.authorizationUrl);
}
}
export async function getAuthLoginOptions() {
return requestJson<AuthLoginOptionsResponse>(
'/api/auth/login-options',
{
method: 'GET',
},
'读取登录方式失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
}
export async function authEntry(phone: string, password: string) {
const response = await requestJson<AuthEntryResponse>(
'/api/auth/entry',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...buildMainlandChinaPhoneInput(phone),
password: password.trim(),
}),
},
'登录失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
setStoredAccessToken(response.token, { emit: false });
return response.user;
}
export async function changePassword(
currentPassword: string,
newPassword: string,
) {
const response = await requestJson<AuthPasswordChangeResponse>(
'/api/auth/password/change',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword: currentPassword.trim() || undefined,
newPassword: newPassword.trim(),
}),
},
'修改密码失败',
);
clearAuthSession();
return response.user;
}
export async function updateAuthProfile(payload: AuthProfileUpdateRequest) {
const response = await requestJson<AuthProfileUpdateResponse>(
'/api/profile/me',
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: payload.displayName?.trim() || undefined,
avatarDataUrl: payload.avatarDataUrl?.trim() || undefined,
}),
},
'更新资料失败',
);
return response.user;
}
export async function resetPassword(
phone: string,
code: string,
newPassword: string,
) {
const response = await requestJson<AuthPasswordResetResponse>(
'/api/auth/password/reset',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...buildMainlandChinaPhoneInput(phone),
code: code.trim(),
newPassword: newPassword.trim(),
}),
},
'重置密码失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
setStoredAccessToken(response.token, { emit: false });
return response.user;
}
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
if (typeof window === 'undefined') {
return null;
}
const hash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash;
if (!hash) {
return null;
}
const params = new URLSearchParams(hash);
const authToken = params.get('auth_token');
const authError = params.get('auth_error');
const providerValue = params.get('auth_provider');
const bindingStatus = params.get('auth_binding_status');
if (!authToken && !authError) {
return null;
}
if (authToken) {
setStoredAccessToken(authToken, { emit: false });
}
if (typeof window.history?.replaceState === 'function') {
window.history.replaceState(
null,
'',
`${window.location.pathname}${window.location.search}`,
);
}
return {
provider: providerValue === 'wechat' ? 'wechat' : 'unknown',
bindingStatus,
error: authError,
};
}
export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
const response = await requestJson<AuthMeResponse>(
'/api/auth/me',
{
method: 'GET',
},
'读取当前用户失败',
{
// 会话恢复阶段允许 401 作为“未登录”信号,不应再广播一次全局鉴权事件。
notifyAuthStateChange: false,
},
);
return {
user: response.user,
availableLoginMethods: response.availableLoginMethods,
};
}
export async function getPublicAuthUserByCode(code: string) {
const response = await requestJson<PublicUserSearchResponse>(
`/api/auth/public-users/by-code/${encodeURIComponent(code.trim())}`,
{
method: 'GET',
},
'读取用户信息失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
return response.user;
}
export async function getPublicAuthUserById(userId: string) {
const response = await requestJson<PublicUserSearchResponse>(
`/api/auth/public-users/by-id/${encodeURIComponent(userId.trim())}`,
{
method: 'GET',
},
'读取用户信息失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
return response.user;
}
export async function getAuthSessions() {
const response = await requestJson<AuthSessionsResponse>(
'/api/auth/sessions',
{
method: 'GET',
},
'读取登录设备失败',
);
return response.sessions;
}
export async function revokeAuthSession(sessionId: string) {
await requestJson<AuthRevokeSessionResponse>(
`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`,
{
method: 'POST',
},
'移除登录设备失败',
);
}
export async function revokeAuthSessions(sessionIds: string[]) {
const uniqueSessionIds = Array.from(
new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)),
);
await Promise.all(
uniqueSessionIds.map((sessionId) => revokeAuthSession(sessionId)),
);
}
export async function getAuthAuditLogs() {
const response = await requestJson<AuthAuditLogsResponse>(
'/api/auth/audit-logs',
{
method: 'GET',
},
'读取账号操作记录失败',
);
return response.logs;
}
export async function getAuthRiskBlocks() {
const response = await requestJson<AuthRiskBlocksResponse>(
'/api/auth/risk-blocks',
{
method: 'GET',
},
'读取安全状态失败',
);
return response.blocks;
}
export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') {
await requestJson<AuthLiftRiskBlockResponse>(
`/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`,
{
method: 'POST',
},
'解除保护失败',
);
}
export async function logoutAuthUser() {
try {
await requestJson<LogoutResponse>(
'/api/auth/logout',
{
method: 'POST',
},
'退出登录失败',
);
} finally {
clearAuthSession();
}
}
export async function logoutAllAuthSessions() {
try {
await requestJson<AuthLogoutAllResponse>(
'/api/auth/logout-all',
{
method: 'POST',
},
'退出全部设备失败',
);
} finally {
clearAuthSession();
}
}