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