This commit is contained in:
2026-05-09 17:15:23 +08:00
parent 80a4183b45
commit a0ed128bde
43 changed files with 2573 additions and 381 deletions

View File

@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
import {
clearStoredAccessToken,
getStoredAccessToken,
setStoredAccessToken,
} from './apiClient';
import {
clearSignedAssetReadUrlCache,
getSignedAssetReadUrl,
@@ -259,4 +263,42 @@ describe('assetReadUrlService', () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
test('getSignedAssetReadUrl 401 不会清空全局登录态', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: false,
data: null,
error: {
code: 'UNAUTHORIZED',
message: '登录状态已失效',
},
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
},
},
),
);
await expect(
getSignedAssetReadUrl({
legacyPublicPath:
'/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
}),
).rejects.toThrow('登录状态已失效');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(getStoredAccessToken()).toBe('test-access-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,8 @@
import { ApiClientError, requestJson } from './apiClient';
import {
ApiClientError,
type ApiRequestOptions,
requestJson,
} from './apiClient';
export type AssetReadUrlRequest = {
objectKey?: string;
@@ -41,6 +45,11 @@ type CachedReadUrlFailureEntry = {
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 ASSET_READ_URL_BACKGROUND_OPTIONS = {
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
} satisfies ApiRequestOptions;
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
@@ -165,6 +174,10 @@ export async function getSignedAssetReadUrl(
signal,
},
'获取资源访问地址失败',
{
// 中文注释:图片换签属于展示层后台请求,失败只影响当前图片,不应刷新或清空全局登录态。
...ASSET_READ_URL_BACKGROUND_OPTIONS,
},
);
const payload = resolveSignedReadPayload(response);
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);

View File

@@ -0,0 +1,72 @@
import type {
Hyper3dDownloadRequest,
Hyper3dDownloadResponse,
Hyper3dImageToModelRequest,
Hyper3dTaskStatusRequest,
Hyper3dTaskStatusResponse,
Hyper3dTaskSubmitResponse,
Hyper3dTextToModelRequest,
} from '../../packages/shared/src/contracts/hyper3d';
import { requestJson } from './apiClient';
const HYPER3D_API_BASE = '/api/assets/hyper3d';
const GENERATION_REQUEST_TIMEOUT_MS = 180_000;
function postHyper3dJson<TResponse>(
path: string,
payload: unknown,
fallbackMessage: string,
timeoutMs = GENERATION_REQUEST_TIMEOUT_MS,
) {
return requestJson<TResponse>(
`${HYPER3D_API_BASE}${path}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
fallbackMessage,
{
timeoutMs,
},
);
}
export function submitHyper3dTextToModel(
payload: Hyper3dTextToModelRequest,
) {
return postHyper3dJson<Hyper3dTaskSubmitResponse>(
'/text-to-model',
payload,
'提交文生 3D 模型任务失败',
);
}
export function submitHyper3dImageToModel(
payload: Hyper3dImageToModelRequest,
) {
return postHyper3dJson<Hyper3dTaskSubmitResponse>(
'/image-to-model',
payload,
'提交图生 3D 模型任务失败',
);
}
export function getHyper3dTaskStatus(payload: Hyper3dTaskStatusRequest) {
return postHyper3dJson<Hyper3dTaskStatusResponse>(
'/status',
payload,
'查询 3D 模型任务状态失败',
60_000,
);
}
export function getHyper3dDownloads(payload: Hyper3dDownloadRequest) {
return postHyper3dJson<Hyper3dDownloadResponse>(
'/download',
payload,
'获取 3D 模型下载列表失败',
60_000,
);
}

View File

@@ -119,6 +119,7 @@ export async function dragPuzzlePieceOrGroup(
export async function advancePuzzleNextLevel(
runId: string,
payload: AdvancePuzzleNextLevelRequest = {},
options: PuzzleRuntimeRequestOptions = {},
) {
const targetProfileId = payload.targetProfileId?.trim() ?? '';
return requestJson<PuzzleRunResponse>(
@@ -135,6 +136,9 @@ export async function advancePuzzleNextLevel(
'进入下一关失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
@@ -145,6 +149,7 @@ export async function advancePuzzleNextLevel(
export async function submitPuzzleLeaderboard(
runId: string,
payload: SubmitPuzzleLeaderboardRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
@@ -156,6 +161,9 @@ export async function submitPuzzleLeaderboard(
'提交拼图排行榜失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
@@ -166,6 +174,7 @@ export async function submitPuzzleLeaderboard(
export async function updatePuzzleRunPause(
runId: string,
payload: UpdatePuzzleRuntimePauseRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
@@ -177,6 +186,9 @@ export async function updatePuzzleRunPause(
'更新拼图计时状态失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
@@ -187,6 +199,7 @@ export async function updatePuzzleRunPause(
export async function usePuzzleRuntimeProp(
runId: string,
payload: UsePuzzleRuntimePropRequest,
options: PuzzleRuntimeRequestOptions = {},
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
@@ -198,6 +211,9 @@ export async function usePuzzleRuntimeProp(
'使用拼图道具失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}