diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 3efa3373b..3704408b3 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -155,6 +155,8 @@ export function GameDetailPage({ const purchaseInFlightRef = useRef(false); const purchaseKeyRef = useRef(null); const isMobileViewport = useIsMobileViewport(); + /** 公开详情是查看者相关响应:身份变化要重读,才能拿到真实的购买态与付费入口。 */ + const viewerUserId = authUi?.user?.id ?? null; const mudPointBalance = usePlatformWalletStore( (state) => state.mudPointBalance, ); @@ -167,14 +169,14 @@ export function GameDetailPage({ const walletBalance = mudPointBalance?.totalPoints ?? legacyWalletBalance ?? null; const balanceOwnerMatchesCurrentUser = usePlatformWalletStore( - (state) => state.ownerUserId === (authUi?.user?.id ?? null), + (state) => state.ownerUserId === viewerUserId, ); // 换账号或换作品时立刻丢弃上一份购买反馈与在途幂等键,避免把结论带到别的作品上。 useEffect(() => { setPurchaseNotice(null); setIsPurchaseDialogOpen(false); purchaseKeyRef.current = null; - }, [gameId, ownerView, authUi?.user?.id]); + }, [gameId, ownerView, viewerUserId]); const loadPublicGame = useCallback(() => { const requestSerial = ++requestSerialRef.current; @@ -228,7 +230,8 @@ export function GameDetailPage({ .finally(() => { if (requestSerial === requestSerialRef.current) setIsLoading(false); }); - }, [gameId]); + // 公开详情按查看者返回购买态与付费入口:换账号或登录后必须重读,否则会一直停在匿名结论上。 + }, [gameId, viewerUserId]); /** * 作者视角:读 owner 作用域条目,并只为还需要作者动作的版本补一次 `recoveryAction` diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index b9d426f60..46466cfd3 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -270,6 +270,24 @@ describe('GameDetailPage', () => { expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy(); }); + it('付费已购买时显示立即玩并可进入游玩页', async () => { + const onPlay = vi.fn(); + getGameMock.mockResolvedValueOnce( + createGame({ priceMudPoints: 120, purchased: true }), + ); + render( + , + ); + + const playButton = await screen.findByRole('button', { name: /立即玩/u }); + expect((playButton as HTMLButtonElement).disabled).toBe(false); + expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull(); + expect(screen.getByText('120 泥点')).toBeTruthy(); + + fireEvent.click(playButton); + expect(onPlay).toHaveBeenCalledWith('game-1'); + }); + function createLockedPaidGame(): GameDistributionGame { return createGame({ priceMudPoints: 120, @@ -624,12 +642,22 @@ describe('GamePlayPage', () => { expect(createGamePlaySessionMock).not.toHaveBeenCalled(); }); + /** 付费未购买:服务端不下发公开入口,只能靠播放会话判定能否游玩。 */ + function createLockedPlayGame(): GameDistributionGame { + return createGame({ + priceMudPoints: 120, + purchased: false, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }); + } + it('付费游戏先签发播放会话再挂载 iframe 并计数', async () => { getGameMock.mockResolvedValueOnce( createGame({ priceMudPoints: 120, purchased: true, - currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + // 已购买时公开入口也在,但付费作品的发行网关只认会话,必须走播放会话。 + currentVersion: { ...createGame().currentVersion! }, }), ); createGamePlaySessionMock.mockResolvedValue({ @@ -652,6 +680,28 @@ describe('GamePlayPage', () => { expect(recordGamePlayMock).toHaveBeenCalledWith('game-1'); }); + it('未登录直接进游玩页时提示登录且不挂载 iframe', async () => { + getGameMock.mockResolvedValueOnce(createLockedPlayGame()); + createGamePlaySessionMock.mockRejectedValueOnce( + new ApiClientError({ + message: '播放付费作品需要先登录', + status: 401, + code: 'UNAUTHORIZED', + }), + ); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + + expect( + await screen.findByText( + '这款游戏需要先登录才能游玩,请返回详情页登录后再试。', + ), + ).toBeTruthy(); + expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull(); + expect(recordGamePlayMock).not.toHaveBeenCalled(); + }); + it('未购买时播放会话被拒,不挂载 iframe 也不上报游玩', async () => { getGameMock.mockResolvedValueOnce( createGame({ diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx index 04cec5c31..f4e2124b3 100644 --- a/src/components/game-distribution/GamePlayPage.tsx +++ b/src/components/game-distribution/GamePlayPage.tsx @@ -159,6 +159,10 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { setPlayError('这款游戏需要先购买才能游玩,请返回详情页完成购买。'); return; } + if (sessionError instanceof ApiClientError && sessionError.status === 401) { + setPlayError('这款游戏需要先登录才能游玩,请返回详情页登录后再试。'); + return; + } setPlayError( sessionError instanceof Error && sessionError.message.trim() ? sessionError.message diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index 3f85160dc..4ea83bc00 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -84,6 +84,56 @@ describe('gameDistributionClient', () => { expect(await getGame('')).toBeNull(); }); + it('详情可选鉴权:已登录带 bearer,未登录匿名且不补票', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + id: 'game-1', + title: '星轨防线', + priceMudPoints: 120, + purchased: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + // 未登录:匿名读取,且不为了补票多发一次 refresh 请求。 + await getGame('game-1'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/api/game-distribution/games/game-1', + ); + expect(fetchMock.mock.calls[0]?.[1]?.headers?.Authorization).toBeUndefined(); + + // 已登录:带上 bearer,服务端才能算真实购买态与付费入口。 + setStoredAccessToken('viewer-token', { emit: false }); + await getGame('game-1'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[1]?.headers?.Authorization).toBe( + 'Bearer viewer-token', + ); + }); + + it('目录列表保持匿名公开读取,不带 bearer', async () => { + setStoredAccessToken('viewer-token', { emit: false }); + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ games: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await listGames(); + + expect(fetchMock.mock.calls[0]?.[1]?.headers?.Authorization).toBeUndefined(); + }); + it('游玩上报携带稳定的匿名 clientId,且不携带未登录 bearer', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index 7c6683582..07c615cd5 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -73,6 +73,18 @@ const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = { clearAuthOnUnauthorized: false, }; +/** + * 公开读接口的「可选鉴权」。 + * + * 已登录时公共封装配上 Bearer,服务端据此算真实购买态与是否下发付费入口;未登录时按匿名请求, + * 不主动补票、也不因 401 影响全局登录态——游客浏览详情不会被要求登录。 + */ +const OPTIONAL_AUTH_REQUEST_OPTIONS: ApiRequestOptions = { + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, +}; + /** * 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401 * 也不刷新会话、不改动全局登录态——直接复用后台请求已有的同一份鉴权降级策略。 @@ -308,14 +320,20 @@ export async function unpublishGame( ); } +/** + * 读取游戏公开详情。 + * + * 走「可选鉴权」:公开资料对所有人可见,但已登录时必须带上身份,否则服务端算不出 `purchased`, + * 已购买用户也会拿到空的付费入口。 + */ export async function getGame(gameId: string) { const normalizedId = gameId.trim(); if (!normalizedId) return null; return requestJson( `/games/${encodeURIComponent(normalizedId)}`, - { method: 'GET' }, + { method: 'GET', cache: 'no-store' }, '读取游戏详情失败', - PUBLIC_GAME_REQUEST_OPTIONS, + OPTIONAL_AUTH_REQUEST_OPTIONS, ); }