修复游戏详情匿名读取导致已购买用户看不到游玩入口
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- gameDistributionClient.getGame 改用「可选鉴权」:已登录时带 Authorization: Bearer,未登录按匿名请求且不补票、不因 401 影响全局登录态
- 公开详情响应随查看者变化,读取加 cache: no-store,避免缓存串用他人购买态
- listGames 与评价等公开列表继续走跳过鉴权的匿名读取,行为不变
- 详情页在登录身份变化时重读公开详情,登录后不会再停在匿名的未购买结论
- 游玩页补充播放会话 401 的可读提示(未登录进付费作品)
- 补测试:详情已登录带 bearer / 未登录匿名且只发一次请求、列表不带 bearer、付费已购买显示立即玩并可进入游玩页、已购买且公开入口仍在时仍走播放会话、未登录直接进游玩页不挂载 iframe
This commit is contained in:
2026-10-05 17:38:26 +08:00
parent c732a3f851
commit 532c847620
5 changed files with 131 additions and 6 deletions
@@ -155,6 +155,8 @@ export function GameDetailPage({
const purchaseInFlightRef = useRef(false);
const purchaseKeyRef = useRef<string | null>(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`
@@ -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(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />,
);
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(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
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({
@@ -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
@@ -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(
+20 -2
View File
@@ -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<GameDistributionGame>(
`/games/${encodeURIComponent(normalizedId)}`,
{ method: 'GET' },
{ method: 'GET', cache: 'no-store' },
'读取游戏详情失败',
PUBLIC_GAME_REQUEST_OPTIONS,
OPTIONAL_AUTH_REQUEST_OPTIONS,
);
}