合并 master 到跳一跳分支
合入 master 最新平台公共组件与后端更新 保留跳一跳长按蓄力与视觉顶面判定规则 解决跳一跳运行态导入和决策日志冲突
This commit is contained in:
@@ -66,6 +66,9 @@ describe('apiClient', () => {
|
||||
dispatchEvent: dispatchEventMock,
|
||||
localStorage: createLocalStorageMock(),
|
||||
});
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => '11111111-2222-3333-4444-555555555555',
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
dispatchEventMock.mockReset();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
@@ -121,6 +124,7 @@ describe('apiClient', () => {
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer expired-token',
|
||||
'x-request-id': 'web-11111111-2222-3333-4444-555555555555',
|
||||
'x-genarrative-response-envelope': 'v1',
|
||||
}),
|
||||
}),
|
||||
@@ -140,6 +144,7 @@ describe('apiClient', () => {
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fresh-token',
|
||||
'x-request-id': 'web-11111111-2222-3333-4444-555555555555',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -40,6 +40,8 @@ export type ApiRequestOptions = {
|
||||
notifyAuthStateChange?: boolean;
|
||||
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
|
||||
clearAuthOnUnauthorized?: boolean;
|
||||
// 同一次业务请求在客户端重试时复用 request id,后端据此做计费幂等。
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
|
||||
@@ -99,6 +101,22 @@ function normalizeHeaders(headers?: HeadersInit) {
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
function buildClientRequestId() {
|
||||
const randomId =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
return `web-${randomId}`;
|
||||
}
|
||||
|
||||
function resolveRequestIdHeader(headers: Record<string, string>, options: ApiRequestOptions) {
|
||||
const explicitRequestId = options.requestId?.trim();
|
||||
const existingRequestId = Object.entries(headers).find(
|
||||
([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(),
|
||||
)?.[1];
|
||||
return explicitRequestId || existingRequestId || buildClientRequestId();
|
||||
}
|
||||
|
||||
function coerceMeta(value: unknown): Partial<ApiMeta> {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
@@ -582,12 +600,14 @@ export async function fetchWithApiAuth(
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const authFailurePolicy = resolveAuthFailurePolicy(options);
|
||||
const requestSignal = init.signal ?? undefined;
|
||||
const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options);
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
let requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
let hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
@@ -603,6 +623,7 @@ export async function fetchWithApiAuth(
|
||||
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
||||
await ensureStoredAccessToken();
|
||||
requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
|
||||
@@ -415,4 +415,28 @@ describe('assetReadUrlService', () => {
|
||||
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-puzzle-assets/session/profile/covers/main.png?x-oss-signature=abc',
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fsession%2Fprofile%2Fcovers%2Fmain.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
} from '../../packages/shared/src/http';
|
||||
import {
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
type ApiRequestOptions,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from './apiClient';
|
||||
@@ -376,7 +376,11 @@ export async function readAssetBytes(
|
||||
throw new Error('资源路径不能为空');
|
||||
}
|
||||
|
||||
if (!isGeneratedLegacyPath(value)) {
|
||||
const legacyPath = isGeneratedLegacyPath(value)
|
||||
? value
|
||||
: resolveGeneratedLegacyPathFromUrl(value);
|
||||
|
||||
if (!legacyPath) {
|
||||
const response = await fetch(value, { signal: options.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error('读取资源内容失败');
|
||||
@@ -386,7 +390,7 @@ export async function readAssetBytes(
|
||||
|
||||
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
legacyPublicPath: value,
|
||||
legacyPublicPath: legacyPath,
|
||||
expireSeconds: options.expireSeconds,
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
|
||||
@@ -58,10 +58,14 @@ export function startBigFishRun(
|
||||
});
|
||||
}
|
||||
|
||||
export function getBigFishRun(runId: string) {
|
||||
export function getBigFishRun(
|
||||
runId: string,
|
||||
options: BigFishRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<BigFishRunResponse>({
|
||||
url: buildRuntimeApiPath(BIG_FISH_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取大鱼吃小鱼玩法失败',
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -139,4 +139,14 @@ test('jump hop work detail preserves flattened back button asset', async () => {
|
||||
const response = await jumpHopClient.getWorkDetail('profile-1');
|
||||
|
||||
expect(response.item.backButtonAsset).toEqual(backButtonAsset);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/jump-hop/works/profile-1',
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳作品详情失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
@@ -197,10 +198,19 @@ export async function getJumpHopWorkDetail(
|
||||
options.audience === 'creation'
|
||||
? JUMP_HOP_WORKS_API_BASE
|
||||
: `${JUMP_HOP_RUNTIME_API_BASE}/works`;
|
||||
const requestOptions: ApiRequestOptions =
|
||||
options.audience === 'creation'
|
||||
? {}
|
||||
: {
|
||||
retry: JUMP_HOP_RUNTIME_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
};
|
||||
const response = await requestJson<JumpHopWorkDetailResponse>(
|
||||
`${base}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取跳一跳作品详情失败',
|
||||
requestOptions,
|
||||
);
|
||||
return normalizeJumpHopWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
@@ -64,30 +64,63 @@ test('server Match3D runtime adapter forwards the full runtime seam lazily', asy
|
||||
stopRun: vi.fn().mockResolvedValue(stopResponse),
|
||||
};
|
||||
const adapter = createServerMatch3DRuntimeAdapter(dependencies);
|
||||
const runtimeRequestOptions = {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
skipRefresh: true,
|
||||
};
|
||||
|
||||
expect(await adapter.startRun('server-profile-1', { skipRefresh: true })).toBe(
|
||||
startResponse,
|
||||
expect(
|
||||
await adapter.startRun('server-profile-1', runtimeRequestOptions),
|
||||
).toBe(startResponse);
|
||||
expect(await adapter.getRun('server-run-start', runtimeRequestOptions)).toBe(
|
||||
getResponse,
|
||||
);
|
||||
expect(await adapter.getRun('server-run-start')).toBe(getResponse);
|
||||
expect(await adapter.clickItem('server-run-start', clickPayload)).toEqual({
|
||||
expect(
|
||||
await adapter.clickItem(
|
||||
'server-run-start',
|
||||
clickPayload,
|
||||
runtimeRequestOptions,
|
||||
),
|
||||
).toEqual({
|
||||
status: 'Accepted',
|
||||
run: buildMockRun('server-run-click'),
|
||||
});
|
||||
expect(await adapter.restartRun('server-run-start')).toBe(restartResponse);
|
||||
expect(await adapter.stopRun('server-run-restart')).toBe(stopResponse);
|
||||
expect(await adapter.finishTimeUp('server-run-start')).toBe(finishResponse);
|
||||
expect(
|
||||
await adapter.restartRun('server-run-start', runtimeRequestOptions),
|
||||
).toBe(restartResponse);
|
||||
expect(await adapter.stopRun('server-run-restart', runtimeRequestOptions)).toBe(
|
||||
stopResponse,
|
||||
);
|
||||
expect(
|
||||
await adapter.finishTimeUp('server-run-start', runtimeRequestOptions),
|
||||
).toBe(finishResponse);
|
||||
|
||||
expect(dependencies.startRun).toHaveBeenCalledWith('server-profile-1', {
|
||||
skipRefresh: true,
|
||||
});
|
||||
expect(dependencies.getRun).toHaveBeenCalledWith('server-run-start');
|
||||
expect(dependencies.startRun).toHaveBeenCalledWith(
|
||||
'server-profile-1',
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
expect(dependencies.getRun).toHaveBeenCalledWith(
|
||||
'server-run-start',
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
expect(dependencies.clickItem).toHaveBeenCalledWith(
|
||||
'server-run-start',
|
||||
clickPayload,
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
expect(dependencies.restartRun).toHaveBeenCalledWith(
|
||||
'server-run-start',
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
expect(dependencies.stopRun).toHaveBeenCalledWith(
|
||||
'server-run-restart',
|
||||
undefined,
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
expect(dependencies.finishTimeUp).toHaveBeenCalledWith(
|
||||
'server-run-start',
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
expect(dependencies.restartRun).toHaveBeenCalledWith('server-run-start');
|
||||
expect(dependencies.stopRun).toHaveBeenCalledWith('server-run-restart');
|
||||
expect(dependencies.finishTimeUp).toHaveBeenCalledWith('server-run-start');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter exposes the same runtime seam as the server client', async () => {
|
||||
|
||||
@@ -24,14 +24,27 @@ export type Match3DRuntimeAdapter = {
|
||||
profileId: string,
|
||||
options?: Match3DRuntimeRequestOptions,
|
||||
) => Promise<Match3DRunResponse>;
|
||||
getRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
getRun: (
|
||||
runId: string,
|
||||
options?: Match3DRuntimeRequestOptions,
|
||||
) => Promise<Match3DRunResponse>;
|
||||
clickItem: (
|
||||
runId: string,
|
||||
payload: Match3DClickItemRequest,
|
||||
options?: Match3DRuntimeRequestOptions,
|
||||
) => Promise<Match3DClickItemResult>;
|
||||
restartRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
stopRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
finishTimeUp: (runId: string) => Promise<Match3DRunResponse>;
|
||||
restartRun: (
|
||||
runId: string,
|
||||
options?: Match3DRuntimeRequestOptions,
|
||||
) => Promise<Match3DRunResponse>;
|
||||
stopRun: (
|
||||
runId: string,
|
||||
options?: Match3DRuntimeRequestOptions,
|
||||
) => Promise<Match3DRunResponse>;
|
||||
finishTimeUp: (
|
||||
runId: string,
|
||||
options?: Match3DRuntimeRequestOptions,
|
||||
) => Promise<Match3DRunResponse>;
|
||||
};
|
||||
|
||||
export type LocalMatch3DRuntimeAdapterOptions = {
|
||||
@@ -63,12 +76,13 @@ export function createServerMatch3DRuntimeAdapter(
|
||||
defaultServerMatch3DRuntimeAdapterDependencies,
|
||||
): Match3DRuntimeAdapter {
|
||||
return {
|
||||
clickItem: (runId, payload) => dependencies.clickItem(runId, payload),
|
||||
finishTimeUp: (runId) => dependencies.finishTimeUp(runId),
|
||||
getRun: (runId) => dependencies.getRun(runId),
|
||||
restartRun: (runId) => dependencies.restartRun(runId),
|
||||
clickItem: (runId, payload, options) =>
|
||||
dependencies.clickItem(runId, payload, options),
|
||||
finishTimeUp: (runId, options) => dependencies.finishTimeUp(runId, options),
|
||||
getRun: (runId, options) => dependencies.getRun(runId, options),
|
||||
restartRun: (runId, options) => dependencies.restartRun(runId, options),
|
||||
startRun: (profileId, options) => dependencies.startRun(profileId, options),
|
||||
stopRun: (runId) => dependencies.stopRun(runId),
|
||||
stopRun: (runId, options) => dependencies.stopRun(runId, undefined, options),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -89,11 +89,15 @@ export function startMatch3DRun(
|
||||
/**
|
||||
* 读取抓大鹅运行态快照。
|
||||
*/
|
||||
export function getMatch3DRun(runId: string) {
|
||||
export function getMatch3DRun(
|
||||
runId: string,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取抓大鹅运行快照失败',
|
||||
retry: MATCH3D_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +107,7 @@ export function getMatch3DRun(runId: string) {
|
||||
export async function clickMatch3DItem(
|
||||
runId: string,
|
||||
payload: Match3DClickItemRequest,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<Match3DClickResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'click'),
|
||||
@@ -113,6 +118,7 @@ export async function clickMatch3DItem(
|
||||
},
|
||||
fallbackMessage: '确认抓大鹅点击失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
|
||||
return mapClickConfirmation(payload, response.confirmation);
|
||||
@@ -126,6 +132,7 @@ export function stopMatch3DRun(
|
||||
payload: StopMatch3DRunRequest = {
|
||||
clientActionId: `match3d-stop-${Date.now()}`,
|
||||
},
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'stop'),
|
||||
@@ -133,30 +140,39 @@ export function stopMatch3DRun(
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '停止抓大鹅玩法失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于当前 run 重开一局。
|
||||
*/
|
||||
export function restartMatch3DRun(runId: string) {
|
||||
export function restartMatch3DRun(
|
||||
runId: string,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'restart'),
|
||||
method: 'POST',
|
||||
fallbackMessage: '重新开始抓大鹅玩法失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端倒计时归零后通知后端确认失败状态。
|
||||
*/
|
||||
export function finishMatch3DTimeUp(runId: string) {
|
||||
export function finishMatch3DTimeUp(
|
||||
runId: string,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<Match3DRunResponse>({
|
||||
url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'time-up'),
|
||||
method: 'POST',
|
||||
fallbackMessage: '同步抓大鹅倒计时失败',
|
||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('puzzleRuntimeClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps pause requests on account auth options instead of guest auth', async () => {
|
||||
it('uses runtime guest auth for pause requests when provided', async () => {
|
||||
await updatePuzzleRunPause(
|
||||
'run/1',
|
||||
{ paused: true },
|
||||
@@ -76,17 +76,19 @@ describe('puzzleRuntimeClient', () => {
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
},
|
||||
body: JSON.stringify({ paused: true }),
|
||||
}),
|
||||
);
|
||||
expect(init.headers).not.toHaveProperty('Authorization');
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
expect(options).not.toMatchObject({ skipAuth: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,7 @@ import type {
|
||||
UpdatePuzzleRuntimePauseRequest,
|
||||
UsePuzzleRuntimePropRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { type ApiRetryOptions } from '../apiClient';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
|
||||
@@ -52,11 +49,15 @@ export async function startPuzzleRun(
|
||||
/**
|
||||
* 读取拼图运行态快照。
|
||||
*/
|
||||
export async function getPuzzleRun(runId: string) {
|
||||
export async function getPuzzleRun(
|
||||
runId: string,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId),
|
||||
fallbackMessage: '读取拼图运行快照失败',
|
||||
retry: PUZZLE_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +67,7 @@ export async function getPuzzleRun(runId: string) {
|
||||
export async function swapPuzzlePieces(
|
||||
runId: string,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'swap'),
|
||||
@@ -73,6 +75,7 @@ export async function swapPuzzlePieces(
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '交换拼图块失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +85,7 @@ export async function swapPuzzlePieces(
|
||||
export async function dragPuzzlePieceOrGroup(
|
||||
runId: string,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'drag'),
|
||||
@@ -89,6 +93,7 @@ export async function dragPuzzlePieceOrGroup(
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '拖动拼图块失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,22 +146,14 @@ export async function updatePuzzleRunPause(
|
||||
payload: UpdatePuzzleRuntimePauseRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'更新拼图计时状态失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'pause'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '更新拼图计时状态失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,22 +164,14 @@ export async function usePuzzleRuntimeProp(
|
||||
payload: UsePuzzleRuntimePropRequest,
|
||||
options: PuzzleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'使用拼图道具失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
authImpact: options.authImpact,
|
||||
skipRefresh: options.skipRefresh,
|
||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||
},
|
||||
);
|
||||
return requestRuntimeJson<PuzzleRunResponse>({
|
||||
url: buildRuntimeApiPath(PUZZLE_RUNTIME_API_BASE, runId, 'props'),
|
||||
method: 'POST',
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '使用拼图道具失败',
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export const puzzleRuntimeClient = {
|
||||
|
||||
@@ -13,17 +13,56 @@ vi.mock('./apiClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient';
|
||||
import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient';
|
||||
import {
|
||||
finishBarkBattleRun,
|
||||
getBarkBattleRuntimeConfig,
|
||||
startBarkBattleRun,
|
||||
} from './bark-battle-runtime/barkBattleRuntimeClient';
|
||||
import {
|
||||
getBigFishRun,
|
||||
recordBigFishPlay,
|
||||
startBigFishRun,
|
||||
submitBigFishInput,
|
||||
} from './big-fish-runtime/bigFishRuntimeClient';
|
||||
import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient';
|
||||
import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
getMatch3DRun,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from './match3d-runtime/match3dRuntimeClient';
|
||||
import {
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
getPuzzleRun,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
usePuzzleRuntimeProp,
|
||||
} from './puzzle-runtime/puzzleRuntimeClient';
|
||||
import { startSquareHoleRun } from './square-hole-runtime/squareHoleRuntimeClient';
|
||||
import { startVisualNovelRun } from './visual-novel-runtime/visualNovelRuntimeClient';
|
||||
import { puzzleClearClient } from './puzzle-clear/puzzleClearClient';
|
||||
import {
|
||||
dropSquareHoleShape,
|
||||
finishSquareHoleTimeUp,
|
||||
getSquareHoleRun,
|
||||
restartSquareHoleRun,
|
||||
startSquareHoleRun,
|
||||
stopSquareHoleRun,
|
||||
} from './square-hole-runtime/squareHoleRuntimeClient';
|
||||
import {
|
||||
checkpointWoodenFishRun,
|
||||
finishWoodenFishRun,
|
||||
startWoodenFishRuntimeRun,
|
||||
} from './wooden-fish/woodenFishClient';
|
||||
import {
|
||||
getVisualNovelHistory,
|
||||
getVisualNovelRun,
|
||||
regenerateVisualNovelRun,
|
||||
startVisualNovelRun,
|
||||
} from './visual-novel-runtime/visualNovelRuntimeClient';
|
||||
|
||||
describe('recommended runtime guest launch clients', () => {
|
||||
beforeEach(() => {
|
||||
@@ -31,6 +70,25 @@ describe('recommended runtime guest launch clients', () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({ run: {} });
|
||||
});
|
||||
|
||||
function expectRuntimeGuestRequest(expectedUrl: string, expectedMethod: string) {
|
||||
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(url).toBe(expectedUrl);
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: expectedMethod,
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'jump-hop',
|
||||
@@ -82,6 +140,14 @@ describe('recommended runtime guest launch clients', () => {
|
||||
}),
|
||||
expectedUrl: '/api/runtime/bark-battle/works/bark-battle-work-1/runs',
|
||||
},
|
||||
{
|
||||
name: 'wooden-fish',
|
||||
start: () =>
|
||||
startWoodenFishRuntimeRun('wooden-fish-profile-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/wooden-fish/runs',
|
||||
},
|
||||
{
|
||||
name: 'puzzle',
|
||||
start: () =>
|
||||
@@ -187,4 +253,397 @@ describe('recommended runtime guest launch clients', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'puzzle get run',
|
||||
run: () =>
|
||||
getPuzzleRun('run-puzzle-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1',
|
||||
expectedMethod: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'puzzle swap',
|
||||
run: () =>
|
||||
swapPuzzlePieces(
|
||||
'run-puzzle-1',
|
||||
{ firstPieceId: 'piece-a', secondPieceId: 'piece-b' },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/swap',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'puzzle drag',
|
||||
run: () =>
|
||||
dragPuzzlePieceOrGroup(
|
||||
'run-puzzle-1',
|
||||
{ pieceId: 'piece-a', targetRow: 1, targetCol: 2 },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/drag',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'puzzle pause',
|
||||
run: () =>
|
||||
updatePuzzleRunPause(
|
||||
'run-puzzle-1',
|
||||
{ paused: true },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/pause',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'puzzle prop',
|
||||
run: () =>
|
||||
usePuzzleRuntimeProp(
|
||||
'run-puzzle-1',
|
||||
{ propKind: 'extendTime' },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle/runs/run-puzzle-1/props',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'puzzle-clear get run',
|
||||
run: () =>
|
||||
puzzleClearClient.getRun('puzzle-clear-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1',
|
||||
expectedMethod: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'puzzle-clear swap',
|
||||
run: () =>
|
||||
puzzleClearClient.swapCards(
|
||||
'puzzle-clear-run-1',
|
||||
{ fromRow: 0, fromCol: 0, toRow: 0, toCol: 1 },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/swap',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'puzzle-clear retry',
|
||||
run: () =>
|
||||
puzzleClearClient.retryLevel('puzzle-clear-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl:
|
||||
'/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/retry-level',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'puzzle-clear next',
|
||||
run: () =>
|
||||
puzzleClearClient.advanceNextLevel('puzzle-clear-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl:
|
||||
'/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/next-level',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'puzzle-clear time up',
|
||||
run: () =>
|
||||
puzzleClearClient.markTimeUp('puzzle-clear-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/puzzle-clear/runs/puzzle-clear-run-1/time-up',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'square-hole get run',
|
||||
run: () =>
|
||||
getSquareHoleRun('square-hole-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1',
|
||||
expectedMethod: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'square-hole drop',
|
||||
run: () =>
|
||||
dropSquareHoleShape(
|
||||
'square-hole-run-1',
|
||||
{
|
||||
holeId: 'hole-1',
|
||||
clientSnapshotVersion: 1,
|
||||
clientEventId: 'event-1',
|
||||
droppedAtMs: 1_700_000_000_000,
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/drop',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'square-hole stop',
|
||||
run: () =>
|
||||
stopSquareHoleRun(
|
||||
'square-hole-run-1',
|
||||
{ clientActionId: 'stop-1' },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/stop',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'square-hole restart',
|
||||
run: () =>
|
||||
restartSquareHoleRun('square-hole-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/restart',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'square-hole time up',
|
||||
run: () =>
|
||||
finishSquareHoleTimeUp('square-hole-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/square-hole/runs/square-hole-run-1/time-up',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'big-fish get run',
|
||||
run: () =>
|
||||
getBigFishRun('big-fish-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/big-fish/runs/big-fish-run-1',
|
||||
expectedMethod: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'big-fish input',
|
||||
run: () =>
|
||||
submitBigFishInput(
|
||||
'big-fish-run-1',
|
||||
{ x: 0.25, y: 0.75 },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/big-fish/runs/big-fish-run-1/input',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'big-fish play report',
|
||||
run: () =>
|
||||
recordBigFishPlay(
|
||||
'big-fish-session-1',
|
||||
{ elapsedMs: 1_000 },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl:
|
||||
'/api/runtime/big-fish/sessions/big-fish-session-1/play',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'bark-battle config',
|
||||
run: () =>
|
||||
getBarkBattleRuntimeConfig('bark-battle-work-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl:
|
||||
'/api/runtime/bark-battle/works/bark-battle-work-1/config',
|
||||
expectedMethod: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'bark-battle finish',
|
||||
run: () =>
|
||||
finishBarkBattleRun(
|
||||
'bark-battle-run-1',
|
||||
{
|
||||
runId: 'bark-battle-run-1',
|
||||
runToken: 'run-token',
|
||||
workId: 'bark-battle-work-1',
|
||||
configVersion: 1,
|
||||
rulesetVersion: 'v1',
|
||||
difficultyPreset: 'normal',
|
||||
clientStartedAt: '2026-06-10T00:00:00Z',
|
||||
clientFinishedAt: '2026-06-10T00:00:10Z',
|
||||
durationMs: 10_000,
|
||||
derivedMetrics: {
|
||||
triggerCount: 1,
|
||||
maxVolume: 0.8,
|
||||
averageVolume: 0.4,
|
||||
finalEnergy: 10,
|
||||
comboMax: 1,
|
||||
},
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/bark-battle/runs/bark-battle-run-1/finish',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'wooden-fish checkpoint',
|
||||
run: () =>
|
||||
checkpointWoodenFishRun(
|
||||
'wooden-fish-run-1',
|
||||
{ totalTapCount: 8, wordCounters: [] },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl:
|
||||
'/api/runtime/wooden-fish/runs/wooden-fish-run-1/checkpoint',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'wooden-fish finish',
|
||||
run: () =>
|
||||
finishWoodenFishRun(
|
||||
'wooden-fish-run-1',
|
||||
{ totalTapCount: 8, wordCounters: [] },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/wooden-fish/runs/wooden-fish-run-1/finish',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'visual-novel get run',
|
||||
run: () =>
|
||||
getVisualNovelRun('visual-novel-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/visual-novel/runs/visual-novel-run-1',
|
||||
expectedMethod: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'visual-novel history',
|
||||
run: () =>
|
||||
getVisualNovelHistory('visual-novel-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl:
|
||||
'/api/runtime/visual-novel/runs/visual-novel-run-1/history',
|
||||
expectedMethod: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'visual-novel regenerate',
|
||||
run: () =>
|
||||
regenerateVisualNovelRun(
|
||||
'visual-novel-run-1',
|
||||
{ historyEntryId: 'history-1', clientEventId: 'event-1' },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl:
|
||||
'/api/runtime/visual-novel/runs/visual-novel-run-1/regenerate',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
])(
|
||||
'$name uses the shared runtime guest bearer token without touching login auth',
|
||||
async ({ run, expectedUrl, expectedMethod }) => {
|
||||
await run();
|
||||
|
||||
expectRuntimeGuestRequest(expectedUrl, expectedMethod);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'get run',
|
||||
run: () =>
|
||||
getMatch3DRun('match3d-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/match3d/runs/match3d-run-1',
|
||||
expectedMethod: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'restart',
|
||||
run: () =>
|
||||
restartMatch3DRun('match3d-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/restart',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
run: () =>
|
||||
stopMatch3DRun(
|
||||
'match3d-run-1',
|
||||
{ clientActionId: 'stop-1' },
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
),
|
||||
expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/stop',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'time up',
|
||||
run: () =>
|
||||
finishMatch3DTimeUp('match3d-run-1', {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
expectedUrl: '/api/runtime/match3d/runs/match3d-run-1/time-up',
|
||||
expectedMethod: 'POST',
|
||||
},
|
||||
])(
|
||||
'match3d $name uses the runtime guest bearer token without touching login auth',
|
||||
async ({ run, expectedUrl, expectedMethod }) => {
|
||||
await run();
|
||||
|
||||
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(url).toBe(expectedUrl);
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: expectedMethod,
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('match3d click uses the runtime guest bearer token without touching login auth', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValueOnce({
|
||||
confirmation: {
|
||||
accepted: true,
|
||||
run: {},
|
||||
clearedItemInstanceIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
await clickMatch3DItem(
|
||||
'match3d-run-1',
|
||||
{
|
||||
runId: 'match3d-run-1',
|
||||
itemInstanceId: 'item-1',
|
||||
clientActionId: 'action-1',
|
||||
clientEventId: 'event-1',
|
||||
clickedAtMs: 1_700_000_000_000,
|
||||
clientSnapshotVersion: 1,
|
||||
},
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
|
||||
const [url, init, , options] = apiClientMocks.requestJson.mock.calls[0];
|
||||
expect(url).toBe('/api/runtime/match3d/runs/match3d-run-1/click');
|
||||
expect(init).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer runtime-guest-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,11 +49,15 @@ export function startSquareHoleRun(
|
||||
/**
|
||||
* 读取方洞挑战运行态快照。
|
||||
*/
|
||||
export function getSquareHoleRun(runId: string) {
|
||||
export function getSquareHoleRun(
|
||||
runId: string,
|
||||
options: SquareHoleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取方洞挑战运行快照失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +67,7 @@ export function getSquareHoleRun(runId: string) {
|
||||
export function dropSquareHoleShape(
|
||||
runId: string,
|
||||
payload: DropSquareHoleShapeRequest,
|
||||
options: SquareHoleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<SquareHoleDropResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
@@ -78,6 +83,7 @@ export function dropSquareHoleShape(
|
||||
},
|
||||
fallbackMessage: '确认方洞挑战投入失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,6 +95,7 @@ export function stopSquareHoleRun(
|
||||
payload: StopSquareHoleRunRequest = {
|
||||
clientActionId: `square-hole-stop-${Date.now()}`,
|
||||
},
|
||||
options: SquareHoleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId, 'stop'),
|
||||
@@ -96,13 +103,17 @@ export function stopSquareHoleRun(
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '停止方洞挑战失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于当前 run 重开一局。
|
||||
*/
|
||||
export function restartSquareHoleRun(runId: string) {
|
||||
export function restartSquareHoleRun(
|
||||
runId: string,
|
||||
options: SquareHoleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
@@ -113,13 +124,17 @@ export function restartSquareHoleRun(runId: string) {
|
||||
method: 'POST',
|
||||
fallbackMessage: '重新开始方洞挑战失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端倒计时归零后通知后端确认失败状态。
|
||||
*/
|
||||
export function finishSquareHoleTimeUp(runId: string) {
|
||||
export function finishSquareHoleTimeUp(
|
||||
runId: string,
|
||||
options: SquareHoleRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<SquareHoleRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
SQUARE_HOLE_RUNTIME_API_BASE,
|
||||
@@ -130,6 +145,7 @@ export function finishSquareHoleTimeUp(runId: string) {
|
||||
method: 'POST',
|
||||
fallbackMessage: '同步方洞挑战倒计时失败',
|
||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -135,15 +135,22 @@ export async function startVisualNovelRun(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getVisualNovelRun(runId: string) {
|
||||
export async function getVisualNovelRun(
|
||||
runId: string,
|
||||
options: VisualNovelRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<VisualNovelRunResponse>({
|
||||
url: buildRuntimeApiPath(VISUAL_NOVEL_RUNTIME_API_BASE, 'runs', runId),
|
||||
fallbackMessage: '读取视觉小说运行快照失败',
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVisualNovelHistory(runId: string) {
|
||||
export async function getVisualNovelHistory(
|
||||
runId: string,
|
||||
options: VisualNovelRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<VisualNovelHistoryResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
VISUAL_NOVEL_RUNTIME_API_BASE,
|
||||
@@ -153,6 +160,7 @@ export async function getVisualNovelHistory(runId: string) {
|
||||
),
|
||||
fallbackMessage: '读取视觉小说历史失败',
|
||||
retry: VISUAL_NOVEL_RUNTIME_READ_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,6 +193,7 @@ export async function streamVisualNovelRuntimeAction(
|
||||
export async function regenerateVisualNovelRun(
|
||||
runId: string,
|
||||
payload: VisualNovelRegenerateRequest,
|
||||
options: VisualNovelRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<VisualNovelRunResponse>({
|
||||
url: buildRuntimeApiPath(
|
||||
@@ -197,6 +206,7 @@ export async function regenerateVisualNovelRun(
|
||||
jsonBody: payload,
|
||||
fallbackMessage: '重生成视觉小说历史失败',
|
||||
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
||||
requestOptions: options,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { isWechatMiniProgramWebViewRuntime } from './authService';
|
||||
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const SHARE_GRID_PAGE_URL = '/pages/share-grid/index';
|
||||
|
||||
function loadWechatMiniProgramBridge() {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!isWechatMiniProgramWebViewRuntime()
|
||||
) {
|
||||
return Promise.reject(new Error('not_mini_program'));
|
||||
}
|
||||
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
return Promise.resolve(window.wx);
|
||||
}
|
||||
|
||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
||||
);
|
||||
const complete = () => {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
resolve(window.wx);
|
||||
} else {
|
||||
reject(new Error('wechat_js_sdk_unavailable'));
|
||||
}
|
||||
};
|
||||
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', complete, { once: true });
|
||||
existingScript.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('wechat_js_sdk_load_failed')),
|
||||
{ once: true },
|
||||
);
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = WECHAT_JS_SDK_URL;
|
||||
script.async = true;
|
||||
script.onload = complete;
|
||||
script.onerror = () => reject(new Error('wechat_js_sdk_load_failed'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function buildAbsoluteUrl(value: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new URL(value, window.location.origin).href;
|
||||
}
|
||||
|
||||
export function canUseWechatMiniProgramShareGrid() {
|
||||
return isWechatMiniProgramWebViewRuntime();
|
||||
}
|
||||
|
||||
export async function openWechatMiniProgramShareGridPage(params: {
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
publicWorkCode: string;
|
||||
}) {
|
||||
const imageUrl = params.imageUrl.trim();
|
||||
if (!imageUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wxBridge = await loadWechatMiniProgramBridge();
|
||||
const miniProgram = wxBridge.miniProgram;
|
||||
if (!miniProgram?.navigateTo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
imageUrl: buildAbsoluteUrl(imageUrl),
|
||||
title: params.title.trim() || '我的作品',
|
||||
publicWorkCode: params.publicWorkCode.trim(),
|
||||
});
|
||||
const url = `${SHARE_GRID_PAGE_URL}?${searchParams.toString()}`;
|
||||
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
miniProgram.navigateTo?.({
|
||||
url,
|
||||
success() {
|
||||
resolve(true);
|
||||
},
|
||||
fail() {
|
||||
resolve(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWechatMiniProgramShareTargetMessage,
|
||||
postWechatMiniProgramShareTarget,
|
||||
} from './wechatMiniProgramShareTarget';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(window, 'wx');
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('wechatMiniProgramShareTarget', () => {
|
||||
test('builds a compact share target message for mini program native share', () => {
|
||||
expect(
|
||||
buildWechatMiniProgramShareTargetMessage({
|
||||
targetPath: '/works/detail',
|
||||
work: ' BB-12345678 ',
|
||||
title: ' 汪汪声浪 ',
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'genarrative:share-target',
|
||||
payload: {
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
title: '汪汪声浪',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('posts the current recommended work to mini program web-view host', () => {
|
||||
const postMessage = vi.fn();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
postMessage,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
postWechatMiniProgramShareTarget({
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
title: '汪汪声浪',
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith({
|
||||
data: {
|
||||
type: 'genarrative:share-target',
|
||||
payload: {
|
||||
targetPath: '/works/detail',
|
||||
work: 'BB-12345678',
|
||||
title: '汪汪声浪',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { isWechatMiniProgramWebViewRuntime } from './authService';
|
||||
|
||||
const MESSAGE_TYPE = 'genarrative:share-target';
|
||||
|
||||
export type WechatMiniProgramShareTarget = {
|
||||
targetPath: '/works/detail';
|
||||
work: string;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
function normalizeShareTarget(
|
||||
target: WechatMiniProgramShareTarget | null | undefined,
|
||||
) {
|
||||
const work = target?.work?.trim() ?? '';
|
||||
if (!work) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetPath: '/works/detail' as const,
|
||||
work,
|
||||
title: target?.title?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWechatMiniProgramShareTargetMessage(
|
||||
target: WechatMiniProgramShareTarget | null | undefined,
|
||||
) {
|
||||
const normalizedTarget = normalizeShareTarget(target);
|
||||
return normalizedTarget
|
||||
? {
|
||||
type: MESSAGE_TYPE,
|
||||
payload: normalizedTarget,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function postWechatMiniProgramShareTarget(
|
||||
target: WechatMiniProgramShareTarget | null | undefined,
|
||||
) {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!isWechatMiniProgramWebViewRuntime() ||
|
||||
typeof window.wx?.miniProgram?.postMessage !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = buildWechatMiniProgramShareTargetMessage(target);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:微信 web-view 会在分享等时机把 postMessage 数据交给原生页,
|
||||
// 小程序页据此把右上角系统分享指向当前推荐作品。
|
||||
window.wx.miniProgram.postMessage({
|
||||
data: message,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -55,6 +55,30 @@ test('wooden fish list works uses creation works endpoint', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish runtime work detail reads public profile without auth refresh coupling', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
item: {
|
||||
summary: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await woodenFishClient.getWorkDetail('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/wooden-fish/works/profile-1',
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼作品详情失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish start run uses runtime guest json skeleton', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ run: { runId: 'run-1' } });
|
||||
|
||||
@@ -17,7 +17,11 @@ import type {
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
type ApiRetryOptions,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth';
|
||||
import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest';
|
||||
@@ -176,11 +180,19 @@ export function executeWoodenFishCreationAction(
|
||||
.then(normalizeWoodenFishActionResponse);
|
||||
}
|
||||
|
||||
export async function getWoodenFishWorkDetail(profileId: string) {
|
||||
export async function getWoodenFishWorkDetail(
|
||||
profileId: string,
|
||||
options: ApiRequestOptions = {
|
||||
retry: WOODEN_FISH_RUNTIME_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
) {
|
||||
const response = await requestJson<WoodenFishWorkDetailResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼作品详情失败',
|
||||
options,
|
||||
);
|
||||
return normalizeWoodenFishWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user