前端:点击开始游戏上报游玩次数

- gamePlayClientId.ts:新增匿名游玩身份,localStorage 持久并在不可用时退化为会话内存值
- gameDistributionClient.ts:新增 recordGamePlay,携带 clientId 且按后台请求处理,不刷新会话、不改全局登录态
- GamePlayPage.tsx:点击开始游戏即 fire-and-forget 上报,失败静默不阻断进入游戏
- 补 clientId 稳定性用例与点击上报断言,并把新服务文件加入 eslint 白名单
This commit is contained in:
2026-10-03 18:18:39 +08:00
parent d12ff72425
commit f6cd2127ae
6 changed files with 117 additions and 0 deletions
@@ -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);