This commit is contained in:
2026-04-22 23:44:57 +08:00
parent 76ac9d22a5
commit 84dc92646a
484 changed files with 9598 additions and 9135 deletions

View File

@@ -6,6 +6,7 @@ import {
clearStoredAccessToken,
fetchWithApiAuth,
getStoredAccessToken,
isTimeoutError,
requestJson,
setStoredAccessToken,
} from './apiClient';
@@ -150,6 +151,68 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('fresh-token');
});
it('hydrates a missing local bearer token before the first protected request', async () => {
fetchMock
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
)
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
value: 9,
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
);
const result = await requestJson<{ value: number }>(
'/api/runtime/protected',
{ method: 'GET' },
'读取受保护数据失败',
);
expect(result).toEqual({ value: 9 });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/runtime/protected',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fresh-token',
}),
}),
);
expect(getStoredAccessToken()).toBe('fresh-token');
});
it('does not emit auth change events when 401 probe requests opt into silent mode', async () => {
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
@@ -185,6 +248,38 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('');
});
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
ok: true,
token: 'fresh-token',
},
error: null,
meta: {
apiVersion: '2026-04-08',
},
}),
}),
)
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/runtime/puzzle/works', {
method: 'GET',
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(getStoredAccessToken()).toBe('fresh-token');
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
});
it('rejects refresh responses that do not return a renewed bearer token', async () => {
setStoredAccessToken('expired-token', { emit: false });
fetchMock
@@ -280,7 +375,43 @@ describe('apiClient', () => {
expect(result).toEqual({ value: 42 });
});
it('aborts requests when timeoutMs is reached', async () => {
setStoredAccessToken('timeout-token', { emit: false });
fetchMock.mockImplementation(
async (_input: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => {
reject(init.signal?.reason);
},
{ once: true },
);
}),
);
let capturedError: unknown;
try {
await requestJson(
'/api/runtime/protected',
{ method: 'POST' },
'创建会话失败',
{
timeoutMs: 20,
skipRefresh: true,
},
);
} catch (error) {
capturedError = error;
}
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(isTimeoutError(capturedError)).toBe(true);
expect(capturedError).toBeInstanceOf(Error);
});
it('surfaces response metadata through ApiClientError', async () => {
setStoredAccessToken('metadata-token', { emit: false });
fetchMock.mockResolvedValueOnce(
createResponseMock({
status: 503,

View File

@@ -30,6 +30,7 @@ export type ApiRetryOptions = {
export type ApiRequestOptions = {
retry?: ApiRetryOptions;
timeoutMs?: number;
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
@@ -172,6 +173,57 @@ function createAbortError() {
return error;
}
function createTimeoutError(timeoutMs: number) {
const error = new Error(`请求超时:${timeoutMs}ms`);
error.name = 'TimeoutError';
return error;
}
function composeAbortSignal(
signal: AbortSignal | undefined,
timeoutMs: number | undefined,
) {
const shouldUseTimeout =
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0;
if (!shouldUseTimeout) {
return {
signal,
cleanup: () => {},
};
}
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort(createTimeoutError(timeoutMs));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeoutId);
signal?.removeEventListener('abort', onAbort);
};
const onAbort = () => {
controller.abort(signal?.reason ?? createAbortError());
};
if (signal?.aborted) {
cleanup();
controller.abort(signal.reason ?? createAbortError());
return {
signal: controller.signal,
cleanup,
};
}
signal?.addEventListener('abort', onAbort, { once: true });
return {
signal: controller.signal,
cleanup,
};
}
async function waitForRetry(ms: number, signal?: AbortSignal) {
if (ms <= 0) {
return;
@@ -268,6 +320,10 @@ export function isAbortError(error: unknown) {
);
}
export function isTimeoutError(error: unknown) {
return error instanceof Error && error.name === 'TimeoutError';
}
function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) {
if (attempt >= retry.maxRetries || isAbortError(error)) {
return false;
@@ -505,21 +561,48 @@ export async function fetchWithApiAuth(
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
const requestSignal = init.signal ?? undefined;
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
let requestHeaders = withAuthorizationHeaders(init.headers, options);
let hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, {
credentials: 'same-origin',
...init,
headers: requestHeaders,
});
if (!hasAuthHeader && !options.skipAuth && !options.skipRefresh) {
try {
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
await ensureStoredAccessToken();
requestHeaders = withAuthorizationHeaders(init.headers, options);
hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
} catch {
// 补票失败时继续走原始请求,让调用方按真实 401 分支处理。
}
}
const timedRequest = composeAbortSignal(
requestSignal,
options.timeoutMs,
);
let response: Response;
try {
response = await fetch(input, {
credentials: 'same-origin',
...init,
signal: timedRequest.signal,
headers: requestHeaders,
});
} finally {
timedRequest.cleanup();
}
if (
response.status === 401 &&
@@ -543,7 +626,12 @@ export async function fetchWithApiAuth(
emitAuthStateChange();
}
}
} else if (response.status === 401 && hasAuthHeader && !options.skipAuth) {
} else if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!refreshAttempted
) {
clearStoredAccessToken({ emit: false });
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
@@ -562,7 +650,7 @@ export async function fetchWithApiAuth(
attempt += 1;
await waitForRetry(
buildRetryDelayMs(attempt, retry),
init.signal ?? undefined,
requestSignal,
);
}
}

View File

@@ -1,18 +1,45 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
import {
clearSignedAssetReadUrlCache,
getSignedAssetReadUrl,
resolveAssetReadUrl,
} from './assetReadUrlService';
function createLocalStorageMock() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
removeItem(key: string) {
store.delete(key);
},
clear() {
store.clear();
},
};
}
describe('assetReadUrlService', () => {
beforeEach(() => {
vi.stubGlobal('window', {
localStorage: createLocalStorageMock(),
dispatchEvent: vi.fn(),
});
clearSignedAssetReadUrlCache();
clearStoredAccessToken({ emit: false });
setStoredAccessToken('test-access-token', { emit: false });
vi.restoreAllMocks();
});
afterEach(() => {
clearStoredAccessToken({ emit: false });
vi.useRealTimers();
});
@@ -110,4 +137,44 @@ describe('assetReadUrlService', () => {
expect(second).toBe(first);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
test('getSignedAssetReadUrl caches not-found failures for the same legacy path', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: false,
data: null,
error: {
code: 'NOT_FOUND',
message: '对象不存在',
},
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 404,
headers: {
'Content-Type': 'application/json',
},
},
),
);
await expect(
getSignedAssetReadUrl({
legacyPublicPath: '/generated-characters/hero/missing/master.png',
}),
).rejects.toThrow();
await expect(
getSignedAssetReadUrl({
legacyPublicPath: '/generated-characters/hero/missing/master.png',
}),
).rejects.toThrow('资源不存在或暂时不可读取');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,4 +1,4 @@
import { requestJson } from './apiClient';
import { ApiClientError, requestJson } from './apiClient';
export type AssetReadUrlRequest = {
objectKey?: string;
@@ -22,9 +22,15 @@ type CachedReadUrlEntry = {
expiresAtMs: number;
};
type CachedReadUrlFailureEntry = {
expiresAtMs: number;
};
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
export function isGeneratedLegacyPath(value: string) {
@@ -81,6 +87,16 @@ function shouldReuseCachedReadUrl(entry: CachedReadUrlEntry | undefined) {
return entry.expiresAtMs - DEFAULT_CACHE_SAFETY_WINDOW_MS > Date.now();
}
function shouldReuseCachedReadUrlFailure(
entry: CachedReadUrlFailureEntry | undefined,
) {
if (!entry) {
return false;
}
return entry.expiresAtMs > Date.now();
}
export async function getSignedAssetReadUrl(
request: AssetReadUrlRequest,
signal?: AbortSignal,
@@ -91,6 +107,13 @@ export async function getSignedAssetReadUrl(
return cached.signedUrl;
}
const cachedFailure = cacheKey
? signedReadUrlFailureCache.get(cacheKey)
: undefined;
if (cachedFailure && shouldReuseCachedReadUrlFailure(cachedFailure)) {
throw new Error('资源不存在或暂时不可读取');
}
if (cacheKey) {
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
if (pendingRequest) {
@@ -117,25 +140,42 @@ export async function getSignedAssetReadUrl(
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
}
const response = await requestJson<AssetReadUrlResponse>(
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal,
},
'获取资源访问地址失败',
);
const payload = resolveSignedReadPayload(response);
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
try {
const response = await requestJson<AssetReadUrlResponse>(
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal,
},
'获取资源访问地址失败',
);
const payload = resolveSignedReadPayload(response);
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
if (cacheKey && expiresAtMs > 0) {
signedReadUrlCache.set(cacheKey, {
signedUrl: payload.signedUrl,
expiresAtMs,
});
if (cacheKey) {
signedReadUrlFailureCache.delete(cacheKey);
}
if (cacheKey && expiresAtMs > 0) {
signedReadUrlCache.set(cacheKey, {
signedUrl: payload.signedUrl,
expiresAtMs,
});
}
return payload.signedUrl;
} catch (error) {
if (
cacheKey &&
error instanceof ApiClientError &&
error.status === 404
) {
signedReadUrlFailureCache.set(cacheKey, {
expiresAtMs: Date.now() + DEFAULT_FAILURE_CACHE_WINDOW_MS,
});
}
throw error;
}
return payload.signedUrl;
})();
if (cacheKey) {
@@ -187,5 +227,6 @@ export async function resolveAssetReadUrl(
export function clearSignedAssetReadUrlCache() {
signedReadUrlCache.clear();
signedReadUrlFailureCache.clear();
pendingSignedReadUrlRequests.clear();
}

View File

@@ -116,6 +116,10 @@ describe('authService', () => {
}),
}),
'登录失败',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(getStoredAccessToken()).toBe('jwt-entry-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
@@ -195,6 +199,10 @@ describe('authService', () => {
}),
}),
'发送验证码失败',
{
skipAuth: true,
skipRefresh: true,
},
);
});
@@ -249,6 +257,10 @@ describe('authService', () => {
}),
}),
'登录失败',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(getStoredAccessToken()).toBe('jwt-phone-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
@@ -319,6 +331,10 @@ describe('authService', () => {
method: 'GET',
}),
'微信登录暂不可用',
{
skipAuth: true,
skipRefresh: true,
},
);
expect(assignMock).toHaveBeenCalledWith(
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
@@ -339,6 +355,10 @@ describe('authService', () => {
method: 'GET',
}),
'读取登录方式失败',
{
skipAuth: true,
skipRefresh: true,
},
);
});

View File

@@ -22,6 +22,7 @@ import type {
LogoutResponse,
} from '../../packages/shared/src/contracts/auth';
import {
type ApiRequestOptions,
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
@@ -60,6 +61,13 @@ let pendingAutoAuthUser: Promise<{
credentials: AutoAuthCredentials;
}> | null = null;
// 登录前公开认证入口不能误带旧 token也不能先触发 refresh 探测,
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
const PUBLIC_AUTH_REQUEST_OPTIONS = {
skipAuth: true,
skipRefresh: true,
} satisfies ApiRequestOptions;
export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim();
}
@@ -147,6 +155,7 @@ export async function sendPhoneLoginCode(
}),
},
'发送验证码失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
return response;
@@ -164,6 +173,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
}),
},
'登录失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
setStoredAccessToken(response.token, { emit: false });
@@ -212,6 +222,7 @@ export async function startWechatLogin() {
method: 'GET',
},
'微信登录暂不可用',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
window.location.assign(response.authorizationUrl);
@@ -224,6 +235,7 @@ export async function getAuthLoginOptions() {
method: 'GET',
},
'读取登录方式失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
}
@@ -237,6 +249,7 @@ export async function authEntry(username: string, password: string) {
body: JSON.stringify(credentials),
},
'登录失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
setStoredAccessToken(response.token, { emit: false });

View File

@@ -16,6 +16,7 @@ import {
import { readCreationAgentSessionFromSse } from '../creation-agent';
const BIG_FISH_AGENT_API_BASE = '/api/runtime/big-fish/agent/sessions';
const BIG_FISH_SESSION_START_TIMEOUT_MS = 15000;
const BIG_FISH_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
@@ -41,6 +42,7 @@ export async function createBigFishCreationSession(
'创建大鱼吃小鱼共创会话失败',
{
retry: BIG_FISH_WRITE_RETRY,
timeoutMs: BIG_FISH_SESSION_START_TIMEOUT_MS,
},
);
}

View File

@@ -18,6 +18,7 @@ import {
import { readCreationAgentSessionFromSse } from '../creation-agent';
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
const PUZZLE_AGENT_SESSION_START_TIMEOUT_MS = 15000;
const PUZZLE_AGENT_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
@@ -47,6 +48,7 @@ export async function createPuzzleAgentSession(
'创建拼图共创会话失败',
{
retry: PUZZLE_AGENT_WRITE_RETRY,
timeoutMs: PUZZLE_AGENT_SESSION_START_TIMEOUT_MS,
},
);
}

View File

@@ -17,6 +17,7 @@ import {
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
const RPG_AGENT_API_BASE = '/custom-world/agent/sessions';
const CREATION_SESSION_START_TIMEOUT_MS = 15000;
export async function createRpgCreationSession(
payload: CreateRpgAgentSessionRequest,
@@ -29,6 +30,9 @@ export async function createRpgCreationSession(
body: JSON.stringify(payload),
},
'创建世界共创会话失败',
{
timeoutMs: CREATION_SESSION_START_TIMEOUT_MS,
},
);
}