Files
Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts
T
kdletters efbdf7031e
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / Backend tests (push) Successful in 3m54s
Project CI / Frontend tests (push) Successful in 2m5s
Project CI / Native shell tests (push) Successful in 6m4s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m14s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m19s
Project CI / AI game creator shell web tests (push) Successful in 1m23s
Project CI / Repository checks (push) Successful in 1m55s
统一客户端维护态错误弹窗
客户端维护响应统一识别并广播维护事件

AGC 与网页端根部展示维护大弹窗,收口发布、上传、资源换签和登录错误

补充维护态弹窗测试、类型检查与实施计划说明
2026-09-23 23:16:23 +08:00

426 lines
13 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,
isApiResponse,
parseApiErrorMessage,
unwrapApiResponse,
} from '../../../../packages/shared/src/http';
import {
fetchClientHttp,
getClientServerBaseUrl,
readClientHttpResponseText,
} from './clientHttp';
import { emitClientMaintenanceEvent } from './clientMaintenance';
import {
type ClientOperation,
createClientOperation,
transitionClientOperation,
} from './clientOperation';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
const ACCESS_TOKEN_ORIGIN_STORAGE_KEY =
'genarrative.auth.access-token-origin.v1';
const LEGACY_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.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(
apiBaseUrl = getClientServerBaseUrl(),
) {
const token =
window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
if (!token) return '';
const storedOrigin = window.localStorage.getItem(
ACCESS_TOKEN_ORIGIN_STORAGE_KEY,
);
if (storedOrigin === apiBaseUrl) return token;
// Old preferences were editable independently of the token, so they cannot
// establish an unmarked session's origin. A marked token from another server
// is discarded, while the user's current debug server preference remains.
clearStoredAuthAccessToken();
if (!storedOrigin) {
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
}
return '';
}
export function setStoredAuthAccessToken(
token: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
window.localStorage.setItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY, apiBaseUrl);
return;
}
clearStoredAuthAccessToken();
}
export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(ACCESS_TOKEN_ORIGIN_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;
}
}
/**
* 服务端明确否认当前身份(401/403)才算权威失效。
*
* 网络错误、5xx、网关错误和响应契约异常都属于"刷新暂时不可用":调用方必须保留既有
* 会话与 access token,不能把一次瞬时失败放大成登出。
*/
export function isClientAuthAuthorityFailure(error: unknown) {
return (
error instanceof ClientAuthRequestError &&
(error.status === 401 || error.status === 403)
);
}
export function isClientAuthRecoverableCheckError(error: unknown) {
return !isClientAuthAuthorityFailure(error);
}
export function getClientAuthErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
/**
* 旧形态错误体:未带 `x-genarrative-response-envelope` 时后端返回
* `{ error: { code, message }, meta }`,没有 `ok` 字段,但 message 同样是给用户看的原因。
*/
function isLegacyApiErrorBody(value: unknown) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return 'error' in record || 'message' in record || 'code' in record;
}
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 {
// 非 JSON(代理错误页、纯文本)不把内部英文原样抛给用户。
return httpFallback;
}
if (isApiResponse(parsed)) {
try {
unwrapApiResponse(parsed);
} catch (error) {
const message = error instanceof Error ? error.message.trim() : '';
return message && message !== '请求失败' ? message : httpFallback;
}
return httpFallback;
}
if (!isLegacyApiErrorBody(parsed)) {
return httpFallback;
}
// 旧形态错误体仍按共享契约解析,否则“手机号或密码错误”这类明确原因会退化成固定文案。
const legacyMessage = parseApiErrorMessage(text, httpFallback).trim();
// 共享解析器在认不出结构时会回显原始 JSON,这里不允许把它当成用户可见文案。
return legacyMessage && legacyMessage !== text.trim()
? legacyMessage
: 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(options.apiBaseUrl);
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) {
const message = await readAuthErrorMessage(response, fallbackMessage);
if (response.status === 503 && message.includes('维护')) {
emitClientMaintenanceEvent({ status: response.status });
}
throw new ClientAuthRequestError(message, { 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 performRefresh = () =>
requestAuthJson<AuthRefreshResponse>(
'/api/auth/refresh',
{ method: 'POST' },
'刷新登录状态失败',
{ skipAuth: true, apiBaseUrl },
);
const refreshWithConvergenceRetry = async () => {
try {
return await performRefresh();
} catch (error) {
if (!isClientAuthAuthorityFailure(error)) throw error;
// 并发轮换收敛:另一个窗口 / 实例可能刚刚轮换过 refresh cookie,用当前 cookie
// 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算登录态权威失效。
return await performRefresh();
}
};
const refreshPromise = refreshWithConvergenceRetry()
.then((response) => {
clientAuthRefreshOperations.set(
apiBaseUrl,
transitionClientOperation(operation, 'success'),
);
setStoredAuthAccessToken(response.token, apiBaseUrl);
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, apiBaseUrl);
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, apiBaseUrl);
return response.user;
}
export async function logoutClientAuthSession(
apiBaseUrl = getClientServerBaseUrl(),
) {
try {
if (!getStoredAuthAccessToken(apiBaseUrl)) {
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();
}
}