34503990f2
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
按 HTTP 状态展示认证服务不可用、超时和维护提示 增加 503 非 JSON 维护页认证界面回归测试 同步 AGC 启动认证错误提示技术方案
355 lines
9.9 KiB
TypeScript
355 lines
9.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';
|
|
import {
|
|
fetchClientHttp,
|
|
getClientServerBaseUrl,
|
|
readClientHttpResponseText,
|
|
} from './clientHttp';
|
|
import {
|
|
type ClientOperation,
|
|
createClientOperation,
|
|
transitionClientOperation,
|
|
} from './clientOperation';
|
|
|
|
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
|
|
|
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);
|
|
}
|
|
|
|
const clientAuthRefreshPromises = new Map<string, Promise<string>>();
|
|
const clientAuthRefreshOperations = new Map<
|
|
string,
|
|
ClientOperation<'auth-refresh', { apiBaseUrl: string }>
|
|
>();
|
|
|
|
export function getClientAuthRefreshOperation(apiBaseUrl: string) {
|
|
return clientAuthRefreshOperations.get(apiBaseUrl) ?? null;
|
|
}
|
|
|
|
const CLIENT_AUTH_NETWORK_ERROR_MESSAGE =
|
|
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
|
|
|
|
function getClientAuthHttpErrorMessage(status: number, fallback: string) {
|
|
switch (status) {
|
|
case 408:
|
|
case 504:
|
|
return '登录服务响应超时,请检查服务器地址和网络后重试';
|
|
case 429:
|
|
return '登录请求过于频繁,请稍后重试';
|
|
case 500:
|
|
return '登录服务内部错误(HTTP 500),请稍后重试';
|
|
case 502:
|
|
return '登录服务暂不可用:上游服务请求失败,请稍后重试';
|
|
case 503:
|
|
return '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试';
|
|
default:
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function getClientAuthNetworkErrorMessage(error: unknown) {
|
|
const detail =
|
|
error instanceof Error ? error.message.trim() : String(error).trim();
|
|
if (/timed? ?out|timeout|超时/iu.test(detail)) {
|
|
return '无法连接登录服务:连接超时,请检查服务器地址和网络后重试';
|
|
}
|
|
if (/econnrefused|connection refused|拒绝连接/iu.test(detail)) {
|
|
return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口';
|
|
}
|
|
if (/dns|resolve|name or service not known|无法解析/iu.test(detail)) {
|
|
return '无法连接登录服务:服务器地址无法解析,请检查服务器选择';
|
|
}
|
|
if (/certificate|tls|ssl|证书/iu.test(detail)) {
|
|
return '无法连接登录服务:安全连接失败,请检查服务器地址和证书';
|
|
}
|
|
return CLIENT_AUTH_NETWORK_ERROR_MESSAGE;
|
|
}
|
|
|
|
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 httpFallback = getClientAuthHttpErrorMessage(response.status, fallback);
|
|
const text = await readClientHttpResponseText(response, {
|
|
url: 'auth error response',
|
|
});
|
|
if (!text.trim()) {
|
|
return httpFallback;
|
|
}
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(text) as unknown;
|
|
} catch {
|
|
return httpFallback;
|
|
}
|
|
try {
|
|
unwrapApiResponse(parsed);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message.trim() : '';
|
|
return message && message !== '请求失败' ? message : httpFallback;
|
|
}
|
|
return httpFallback;
|
|
}
|
|
|
|
async function requestAuthJson<T>(
|
|
url: string,
|
|
init: RequestInit,
|
|
fallbackMessage: string,
|
|
options: { skipAuth?: boolean; apiBaseUrl?: string } = {},
|
|
) {
|
|
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 fetchClientHttp(
|
|
url,
|
|
{
|
|
...init,
|
|
credentials: 'same-origin',
|
|
headers,
|
|
},
|
|
{ serverBaseUrl: options.apiBaseUrl },
|
|
);
|
|
} catch (error) {
|
|
throw new ClientAuthRequestError(getClientAuthNetworkErrorMessage(error), {
|
|
networkError: true,
|
|
});
|
|
}
|
|
if (!response.ok) {
|
|
throw new ClientAuthRequestError(
|
|
await readAuthErrorMessage(response, fallbackMessage),
|
|
{ status: response.status },
|
|
);
|
|
}
|
|
const text = await readClientHttpResponseText(response, {
|
|
url,
|
|
});
|
|
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
|
|
}
|
|
|
|
export async function getCurrentClientAuthUser(
|
|
apiBaseUrl = getClientServerBaseUrl(),
|
|
) {
|
|
const response = await requestAuthJson<AuthMeResponse>(
|
|
'/api/auth/me',
|
|
{ method: 'GET' },
|
|
'读取当前用户失败',
|
|
{ apiBaseUrl },
|
|
);
|
|
return response.user;
|
|
}
|
|
|
|
export async function refreshClientAuthAccessToken(
|
|
apiBaseUrl = getClientServerBaseUrl(),
|
|
) {
|
|
const current = clientAuthRefreshPromises.get(apiBaseUrl);
|
|
if (current) return current;
|
|
const operation = createClientOperation(
|
|
'auth-refresh',
|
|
{ apiBaseUrl },
|
|
{ scope: { apiBaseUrl }, deadlineMs: 15_000 },
|
|
);
|
|
clientAuthRefreshOperations.set(
|
|
apiBaseUrl,
|
|
transitionClientOperation(operation, 'network'),
|
|
);
|
|
const refreshPromise = requestAuthJson<AuthRefreshResponse>(
|
|
'/api/auth/refresh',
|
|
{ method: 'POST' },
|
|
'刷新登录状态失败',
|
|
{ skipAuth: true, apiBaseUrl },
|
|
)
|
|
.then((response) => {
|
|
clientAuthRefreshOperations.set(
|
|
apiBaseUrl,
|
|
transitionClientOperation(operation, 'success'),
|
|
);
|
|
setStoredAuthAccessToken(response.token);
|
|
return response.token;
|
|
})
|
|
.catch((error) => {
|
|
clientAuthRefreshOperations.set(
|
|
apiBaseUrl,
|
|
transitionClientOperation(operation, 'retryable-failure'),
|
|
);
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
if (clientAuthRefreshPromises.get(apiBaseUrl) === refreshPromise) {
|
|
clientAuthRefreshPromises.delete(apiBaseUrl);
|
|
}
|
|
});
|
|
clientAuthRefreshPromises.set(apiBaseUrl, refreshPromise);
|
|
return refreshPromise;
|
|
}
|
|
|
|
export async function loginClientWithPassword(
|
|
phone: string,
|
|
password: string,
|
|
apiBaseUrl = getClientServerBaseUrl(),
|
|
) {
|
|
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, apiBaseUrl },
|
|
);
|
|
setStoredAuthAccessToken(response.token);
|
|
return response.user;
|
|
}
|
|
|
|
export async function sendClientPhoneLoginCode(
|
|
phone: string,
|
|
apiBaseUrl = getClientServerBaseUrl(),
|
|
) {
|
|
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, apiBaseUrl },
|
|
);
|
|
}
|
|
|
|
export async function loginClientWithPhoneCode(
|
|
phone: string,
|
|
code: string,
|
|
apiBaseUrl = getClientServerBaseUrl(),
|
|
) {
|
|
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, apiBaseUrl },
|
|
);
|
|
setStoredAuthAccessToken(response.token);
|
|
return response.user;
|
|
}
|
|
|
|
export async function logoutClientAuthSession(
|
|
apiBaseUrl = getClientServerBaseUrl(),
|
|
) {
|
|
try {
|
|
if (!getStoredAuthAccessToken()) {
|
|
await refreshClientAuthAccessToken(apiBaseUrl).catch(() => '');
|
|
}
|
|
try {
|
|
await requestAuthJson<LogoutResponse>(
|
|
'/api/auth/logout',
|
|
{ method: 'POST' },
|
|
'退出登录失败',
|
|
{ apiBaseUrl },
|
|
);
|
|
} catch {
|
|
await refreshClientAuthAccessToken(apiBaseUrl).catch(() => '');
|
|
await requestAuthJson<LogoutResponse>(
|
|
'/api/auth/logout',
|
|
{ method: 'POST' },
|
|
'退出登录失败',
|
|
{ apiBaseUrl },
|
|
);
|
|
}
|
|
} finally {
|
|
clearStoredAuthAccessToken();
|
|
}
|
|
}
|