Files
Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts
T
AIGameCreator App 97dc708576
Project CI / Repository checks (push) Failing after 55s
Project CI / Frontend tests (push) Successful in 2m49s
Project CI / Backend tests (push) Successful in 3m7s
Project CI / Native shell tests (push) Failing after 6m57s
修复AI游戏客户端手机号登录契约
密码登录、验证码发送和验证码登录改用国家码与纯手机号字段
非 JSON 认证错误回退为中文操作提示
补充登录契约、错误回退测试和决策记录
2026-07-27 21:31:35 +08:00

263 lines
6.9 KiB
TypeScript

import type {
AuthEntryRequest,
AuthEntryResponse,
AuthMeResponse,
AuthPhoneLoginRequest,
AuthPhoneLoginResponse,
AuthPhoneNumberInput,
AuthPhoneSendCodeRequest,
AuthPhoneSendCodeResponse,
AuthRefreshResponse,
LogoutResponse,
} from '../../../../packages/shared/src/contracts/auth';
import {
API_RESPONSE_ENVELOPE_HEADER,
API_RESPONSE_ENVELOPE_VERSION,
unwrapApiResponse,
} from '../../../../packages/shared/src/http';
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) {
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() {
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
}
function setStoredAuthAccessToken(token: string) {
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
function resolveClientAuthApiUrl(url: string) {
if (/^https?:\/\//iu.test(url)) {
return url;
}
if (import.meta.env.DEV) {
return url;
}
const isHttpPage =
window.location.protocol === 'http:' ||
window.location.protocol === 'https:';
if (!window.__TAURI__ && isHttpPage) {
return url;
}
return `${DEFAULT_CLIENT_AUTH_API_BASE_URL}${url}`;
}
let clientAuthRefreshPromise: Promise<string> | null = null;
class ClientAuthRequestError extends Error {
readonly status: number | null;
readonly networkError: boolean;
constructor(
message: string,
options: { status?: number | null; networkError?: boolean } = {},
) {
super(message);
this.name = 'ClientAuthRequestError';
this.status = options.status ?? null;
this.networkError = options.networkError ?? false;
}
}
function isClientAuthUnauthorizedError(error: unknown) {
return (
error instanceof ClientAuthRequestError &&
(error.status === 401 || error.status === 403)
);
}
export function isClientAuthRecoverableCheckError(error: unknown) {
return !isClientAuthUnauthorizedError(error);
}
export function getClientAuthErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
async function readAuthErrorMessage(response: Response, fallback: string) {
const text = await response.text();
if (!text.trim()) {
return fallback;
}
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return fallback;
}
try {
unwrapApiResponse(parsed);
} catch (error) {
return error instanceof Error ? error.message : fallback;
}
return fallback;
}
async function requestAuthJson<T>(
url: string,
init: RequestInit,
fallbackMessage: string,
options: { skipAuth?: boolean } = {},
) {
const headers = new Headers(init.headers);
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
if (!options.skipAuth) {
const token = getStoredAuthAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
}
let response: Response;
try {
response = await fetch(resolveClientAuthApiUrl(url), {
...init,
credentials: 'same-origin',
headers,
});
} catch {
throw new ClientAuthRequestError(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
{ networkError: true },
);
}
if (!response.ok) {
throw new ClientAuthRequestError(
await readAuthErrorMessage(response, fallbackMessage),
{ status: response.status },
);
}
const text = await response.text();
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
}
export async function getCurrentClientAuthUser() {
const response = await requestAuthJson<AuthMeResponse>(
'/api/auth/me',
{ method: 'GET' },
'读取当前用户失败',
);
return response.user;
}
export async function refreshClientAuthAccessToken() {
if (!clientAuthRefreshPromise) {
clientAuthRefreshPromise = requestAuthJson<AuthRefreshResponse>(
'/api/auth/refresh',
{ method: 'POST' },
'刷新登录状态失败',
{ skipAuth: true },
)
.then((response) => {
setStoredAuthAccessToken(response.token);
return response.token;
})
.finally(() => {
clientAuthRefreshPromise = null;
});
}
return clientAuthRefreshPromise;
}
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(request),
},
'登录失败',
{ skipAuth: true },
);
setStoredAuthAccessToken(response.token);
return response.user;
}
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(request),
},
'发送验证码失败',
{ skipAuth: true },
);
}
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(request),
},
'登录失败',
{ skipAuth: true },
);
setStoredAuthAccessToken(response.token);
return response.user;
}
export async function logoutClientAuthSession() {
try {
if (!getStoredAuthAccessToken()) {
await refreshClientAuthAccessToken().catch(() => '');
}
try {
await requestAuthJson<LogoutResponse>(
'/api/auth/logout',
{ method: 'POST' },
'退出登录失败',
);
} catch {
await refreshClientAuthAccessToken().catch(() => '');
await requestAuthJson<LogoutResponse>(
'/api/auth/logout',
{ method: 'POST' },
'退出登录失败',
);
}
} finally {
clearStoredAuthAccessToken();
}
}