前端:点击开始游戏上报游玩次数
- gamePlayClientId.ts:新增匿名游玩身份,localStorage 持久并在不可用时退化为会话内存值 - gameDistributionClient.ts:新增 recordGamePlay,携带 clientId 且按后台请求处理,不刷新会话、不改全局登录态 - GamePlayPage.tsx:点击开始游戏即 fire-and-forget 上报,失败静默不阻断进入游戏 - 补 clientId 稳定性用例与点击上报断言,并把新服务文件加入 eslint 白名单
This commit is contained in:
@@ -219,6 +219,7 @@ module.exports = {
|
||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||
'!src/services/gameDistributionClient.ts',
|
||||
'!src/services/gameDistributionClient.test.ts',
|
||||
'!src/services/gamePlayClientId.ts',
|
||||
'!src/services/sseStream.ts',
|
||||
'!src/services/sseStream.test.ts',
|
||||
'src/AdventurePanel.tsx',
|
||||
|
||||
@@ -18,11 +18,13 @@ import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
|
||||
|
||||
const listGamesMock = vi.hoisted(() => vi.fn());
|
||||
const getGameMock = vi.hoisted(() => vi.fn());
|
||||
const recordGamePlayMock = vi.hoisted(() => vi.fn());
|
||||
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
listGames: listGamesMock,
|
||||
getGame: getGameMock,
|
||||
recordGamePlay: recordGamePlayMock,
|
||||
listGameReviews: vi.fn().mockResolvedValue({
|
||||
reviews: [],
|
||||
page: 1,
|
||||
@@ -114,6 +116,7 @@ beforeEach(() => {
|
||||
setViewportWidth(1024);
|
||||
listGamesMock.mockResolvedValue([]);
|
||||
getGameMock.mockResolvedValue(createGame());
|
||||
recordGamePlayMock.mockResolvedValue(undefined);
|
||||
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
|
||||
objectKey ? `https://assets.example.test/${objectKey}` : '',
|
||||
);
|
||||
@@ -229,6 +232,7 @@ describe('GamePlayPage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
|
||||
const iframe = await screen.findByTitle('星轨防线 在线游玩');
|
||||
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
|
||||
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
|
||||
});
|
||||
|
||||
it('rejects an unsafe release entry URL before rendering an iframe', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type GameDistributionGame,
|
||||
getGame,
|
||||
recordGamePlay,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
@@ -105,6 +106,11 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport;
|
||||
const startGame = () => {
|
||||
if (mobilePlaybackBlocked) return;
|
||||
// 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。
|
||||
const playGameId = game?.id ?? normalizeGameId(gameId);
|
||||
if (playGameId) {
|
||||
void recordGamePlay(playGameId).catch(() => {});
|
||||
}
|
||||
setHasStarted(true);
|
||||
setStartupState('loading');
|
||||
setFrameRunId((runId) => runId + 1);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
listGameReviews,
|
||||
listGames,
|
||||
listMyGames,
|
||||
recordGamePlay,
|
||||
saveMyGameReview,
|
||||
unpublishGame,
|
||||
uploadGamePackage,
|
||||
@@ -83,6 +84,38 @@ describe('gameDistributionClient', () => {
|
||||
expect(await getGame('')).toBeNull();
|
||||
});
|
||||
|
||||
it('游玩上报携带稳定的匿名 clientId,且不携带未登录 bearer', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ recorded: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await recordGamePlay('game-1');
|
||||
await recordGamePlay('game-1');
|
||||
|
||||
const playCalls = fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes('/plays'),
|
||||
);
|
||||
expect(playCalls).toHaveLength(2);
|
||||
expect(playCalls[0]?.[0]).toBe('/api/game-distribution/games/game-1/plays');
|
||||
expect(playCalls[0]?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
});
|
||||
expect(playCalls[0]?.[1]?.headers?.Authorization).toBeUndefined();
|
||||
const clientIds = playCalls.map(
|
||||
([, init]) =>
|
||||
(JSON.parse(String(init?.body)) as { clientId?: string }).clientId,
|
||||
);
|
||||
expect(clientIds[0]).toBeTruthy();
|
||||
expect(clientIds).toEqual([clientIds[0], clientIds[0]]);
|
||||
});
|
||||
|
||||
it('版本创建携带幂等键并保留后端失败状态', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type ApiRequestOptions,
|
||||
requestJson as requestApiJson,
|
||||
} from './apiClient';
|
||||
import { getGamePlayClientId } from './gamePlayClientId';
|
||||
|
||||
export type {
|
||||
GameDistributionAuthor,
|
||||
@@ -67,6 +68,17 @@ const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = {
|
||||
clearAuthOnUnauthorized: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401
|
||||
* 也不刷新会话、不改动全局登录态,避免一次计数把整站登录态带进刷新或登出流程。
|
||||
*/
|
||||
const PLAY_REPORT_REQUEST_OPTIONS: ApiRequestOptions = {
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
};
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
@@ -171,6 +183,24 @@ export async function getGame(gameId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。
|
||||
*/
|
||||
export function recordGamePlay(gameId: string) {
|
||||
const normalizedId = gameId.trim();
|
||||
if (!normalizedId) return Promise.resolve();
|
||||
return requestJson<{ recorded?: boolean }>(
|
||||
`/games/${encodeURIComponent(normalizedId)}/plays`,
|
||||
{
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ clientId: getGamePlayClientId() }),
|
||||
},
|
||||
'游玩上报失败',
|
||||
PLAY_REPORT_REQUEST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export function listGameReviews(gameId: string, page = 1) {
|
||||
return requestJson<GameDistributionReviewsResponse>(
|
||||
`/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 匿名游玩身份。
|
||||
*
|
||||
* 登录用户由后端从 bearer 取 `userId`,匿名用户只能靠前端持久一个随机 `clientId`。这里把
|
||||
* 生成、读取与降级收在一个小模块里:`localStorage` 不可用(隐私模式、SSR、被禁用)时退化为
|
||||
* 当前会话内存值,保证一次会话内多次点击仍去重。
|
||||
*/
|
||||
|
||||
const GAME_PLAY_CLIENT_ID_STORAGE_KEY = 'genarrative.game-play.client-id.v1';
|
||||
|
||||
let cachedClientId: string | null = null;
|
||||
|
||||
/** 返回稳定的匿名游玩身份;同一标签页内重复调用返回同一个值。 */
|
||||
export function getGamePlayClientId(): string {
|
||||
if (cachedClientId) return cachedClientId;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage
|
||||
.getItem(GAME_PLAY_CLIENT_ID_STORAGE_KEY)
|
||||
?.trim();
|
||||
if (stored) {
|
||||
cachedClientId = stored;
|
||||
return stored;
|
||||
}
|
||||
const created = createGamePlayClientId();
|
||||
window.localStorage.setItem(GAME_PLAY_CLIENT_ID_STORAGE_KEY, created);
|
||||
cachedClientId = created;
|
||||
return created;
|
||||
} catch {
|
||||
cachedClientId = cachedClientId ?? createGamePlayClientId();
|
||||
return cachedClientId;
|
||||
}
|
||||
}
|
||||
|
||||
function createGamePlayClientId(): string {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `gp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user