修复游戏详情匿名读取导致已购买用户看不到游玩入口
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
@@ -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,
);
}