755bf80858
新增 Godot 项目选择、校验、导入和项目上下文切换入口。 只在所选项目根保留 .agent 元数据,并让总控使用标准运行档。 修复发布版原生 API 访问和 React runtime 重复加载问题。 收紧 Provider 工具 Schema 子集并增加回归门禁。 补充 Rust、前端测试及 PRD、技术方案和共享决策记录。
247 lines
6.5 KiB
TypeScript
247 lines
6.5 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 } from './clientHttp';
|
|
|
|
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);
|
|
}
|
|
|
|
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 fetchClientHttp(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();
|
|
}
|
|
}
|