From f6cd2127ae6f05a1dc68c537ee5fa246bdf1151b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 18:18:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E6=B8=B8=E6=88=8F=E4=B8=8A=E6=8A=A5=E6=B8=B8?= =?UTF-8?q?=E7=8E=A9=E6=AC=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gamePlayClientId.ts:新增匿名游玩身份,localStorage 持久并在不可用时退化为会话内存值 - gameDistributionClient.ts:新增 recordGamePlay,携带 clientId 且按后台请求处理,不刷新会话、不改全局登录态 - GamePlayPage.tsx:点击开始游戏即 fire-and-forget 上报,失败静默不阻断进入游戏 - 补 clientId 稳定性用例与点击上报断言,并把新服务文件加入 eslint 白名单 --- .eslintrc.cjs | 1 + .../GameDistributionPages.test.tsx | 4 ++ .../game-distribution/GamePlayPage.tsx | 6 +++ src/services/gameDistributionClient.test.ts | 33 ++++++++++++++ src/services/gameDistributionClient.ts | 30 +++++++++++++ src/services/gamePlayClientId.ts | 43 +++++++++++++++++++ 6 files changed, 117 insertions(+) create mode 100644 src/services/gamePlayClientId.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ae8275e7c..d7c198f3e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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', diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 879a4b223..471613f14 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.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 () => { diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx index ecd1df9d5..f4f1efebd 100644 --- a/src/components/game-distribution/GamePlayPage.tsx +++ b/src/components/game-distribution/GamePlayPage.tsx @@ -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); diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index 67318f59b..3f85160dc 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -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( diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index a1b7567c9..ebbbcc10e 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -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( 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( `/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`, diff --git a/src/services/gamePlayClientId.ts b/src/services/gamePlayClientId.ts new file mode 100644 index 000000000..c56c4b732 --- /dev/null +++ b/src/services/gamePlayClientId.ts @@ -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)}`; +}