diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index a18adce79..514c40987 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -20,6 +20,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = { redeem_code_reward: '兑换码奖励', puzzle_author_incentive_claim: '拼图作者奖励', daily_task_reward: '每日任务奖励', + game_purchase: '购买游戏', } satisfies Record; export type ProfileWalletLedgerEntryPresentation = { diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index f7803ebdb..be4f27506 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -104,8 +104,10 @@ export type GameDistributionVersionSummary = { /** * 发行入口:平台同源路径 `/games//`,客户端按当前 origin 解析后再交给 iframe。 * 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。 + * + * 买断制作品在未购买时不下发公开入口,这一项为 `null`;游玩入口改由播放会话接口签发。 */ - entryUrl: string; + entryUrl: string | null; sha256: string; publishedAt: string; controls: string[]; @@ -141,6 +143,10 @@ export type GameDistributionGame = { currentVersion?: GameDistributionVersionSummary | null; /** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */ ratingSummary?: GameDistributionRatingSummary; + /** 买断价(整数泥点,`0` 表示免费)。旧响应可省略,读取方按 `0`(免费)兜底。 */ + priceMudPoints: number; + /** 当前查看者是否已拥有;匿名与未登录恒为 `false`。 */ + purchased: boolean; playCount: number; createdAt: string; }; @@ -176,6 +182,13 @@ export type GameDistributionCreateVersionRequest = { * 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。 */ versionNumber?: number | null; + /** + * 作者提交的买断价(整数泥点,`0` 表示免费)。 + * + * 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。 + * 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。 + */ + priceMudPoints?: number; packageSha256: string; packageBytes: number; packageFileCount: number; @@ -214,6 +227,8 @@ export type GameDistributionPrivateVersion = { status: GameDistributionVersionStatus; /** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */ publicationRevision: number; + /** 该版本冻结的买断价(整数泥点,`0` 表示免费);无价格的历史版本按免费口径处理。 */ + priceMudPoints?: number; reviewReason?: string | null; /** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */ entryUrl: string | null; @@ -276,3 +291,36 @@ export type GameDistributionCancelVersionResponse = { version: GameDistributionPrivateVersion; replayed: boolean; }; + +/** + * 买断制购买请求体。 + * + * 客户端带上自己看到的价格:与服务端当前价格不一致时返回 409,不会按旧价扣费。 + */ +export type GameDistributionPurchaseRequest = { + expectedPriceMudPoints: number; +}; + +/** 购买记录快照:每账号每作品最多一条,成交价随购买冻结,之后调价不影响既有所有权。 */ +export type GameDistributionPurchase = { + purchaseId: string; + gameId: string; + priceMudPoints: number; + createdAt: string; +}; + +/** 购买响应:`walletBalance` 是扣费后的泥点余额,`replayed` 表示服务端按同一幂等键重放。 */ +export type GameDistributionPurchaseResponse = { + purchase: GameDistributionPurchase; + walletBalance: number; + replayed: boolean; +}; + +/** + * 播放会话响应:`playUrl` 是绑定「`gameId` + 用户 + 短时效」的发行入口。 + * 结算入口地址后交给 iframe,会话过期或作品下架后不可继续播放。 + */ +export type GameDistributionPlaySessionResponse = { + playUrl: string; + expiresAt: string; +}; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index eee7978ed..033d5b171 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -82,7 +82,8 @@ export type ProfileWalletLedgerEntry = { | 'recharge_refund_recovery' | 'redeem_code_reward' | 'puzzle_author_incentive_claim' - | 'daily_task_reward'; + | 'daily_task_reward' + | 'game_purchase'; createdAt: string; reason?: string; }; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index a149af079..0a05d36c5 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -54,6 +54,10 @@ const PAIRS = [ 'GameDistributionUpdateGameMetadataRequest', ], ['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'], + ['GameDistributionPurchaseRequest', 'GameDistributionPurchaseRequest'], + ['GameDistributionPurchase', 'GameDistributionPurchase'], + ['GameDistributionPurchaseResponse', 'GameDistributionPurchaseResponse'], + ['GameDistributionPlaySessionResponse', 'GameDistributionPlaySessionResponse'], ['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'], ['AdminGameReviewGame', 'AdminGameReviewGame'], ['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'], diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 1fee3520d..3efa3373b 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -12,6 +12,7 @@ import type { GameDistributionVersionSummary, } from '../../../packages/shared/src/contracts/gameDistribution'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { ApiClientError } from '../../services/apiClient'; import { type GameDistributionGame, type GameDistributionInputMode, @@ -20,9 +21,12 @@ import { getGame, getMyGame, loadVersionRecoveryActions, + purchaseGame, } from '../../services/gameDistributionClient'; +import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { normalizeGameEntryUrl, @@ -45,6 +49,11 @@ type GameDetailPageProps = { gameId: string | null; onBack: () => void; onPlay: (gameId: string) => void; + /** + * 打开平台充值面板;余额不足以购买付费作品时由宿主提供入口。 + * 宿主没有充值能力时(例如独立嵌入)传入空值,页面只保留余额提示。 + */ + onOpenRecharge?: () => void; /** * 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。 * @@ -55,6 +64,24 @@ type GameDetailPageProps = { ownerView?: boolean; }; +/** 购买后的页面级反馈:成功、余额不足(带充值入口)与失败共用一套展示。 */ +type GamePurchaseNotice = { + tone: 'info' | 'error'; + message: string; + canRecharge: boolean; +}; + +let purchaseKeySeed = 0; +/** 一次购买的幂等键:双击、响应丢失重试复用同一个键,服务端据此只扣一次。 */ +function createPurchaseKey() { + purchaseKeySeed += 1; + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${purchaseKeySeed}`; + return `web-game-purchase-${random}`; +} + /** * 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`), * 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。 @@ -105,6 +132,7 @@ export function GameDetailPage({ gameId, onBack, onPlay, + onOpenRecharge, ownerView = false, }: GameDetailPageProps) { const authUi = useAuthUi(); @@ -119,8 +147,34 @@ export function GameDetailPage({ >({}); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); + const [isPurchaseDialogOpen, setIsPurchaseDialogOpen] = useState(false); + const [isPurchasing, setIsPurchasing] = useState(false); + const [purchaseNotice, setPurchaseNotice] = + useState(null); const requestSerialRef = useRef(0); + const purchaseInFlightRef = useRef(false); + const purchaseKeyRef = useRef(null); const isMobileViewport = useIsMobileViewport(); + const mudPointBalance = usePlatformWalletStore( + (state) => state.mudPointBalance, + ); + const legacyWalletBalance = usePlatformWalletStore( + (state) => state.legacyWalletBalance, + ); + const onWalletBalanceMayHaveChanged = usePlatformWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); + const walletBalance = + mudPointBalance?.totalPoints ?? legacyWalletBalance ?? null; + const balanceOwnerMatchesCurrentUser = usePlatformWalletStore( + (state) => state.ownerUserId === (authUi?.user?.id ?? null), + ); + // 换账号或换作品时立刻丢弃上一份购买反馈与在途幂等键,避免把结论带到别的作品上。 + useEffect(() => { + setPurchaseNotice(null); + setIsPurchaseDialogOpen(false); + purchaseKeyRef.current = null; + }, [gameId, ownerView, authUi?.user?.id]); const loadPublicGame = useCallback(() => { const requestSerial = ++requestSerialRef.current; @@ -136,20 +190,34 @@ export function GameDetailPage({ void getGame(normalizedGameId) .then((value) => { if (requestSerial !== requestSerialRef.current) return; - if (value?.currentVersion) { - const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl); - if (!entryUrl) { - setError('游戏入口地址无效'); - return; - } + if (!value) { + setPublicGame(null); + setError('找不到这款游戏'); + return; + } + const currentVersion = value.currentVersion ?? null; + if (!currentVersion) { + setPublicGame(value); + return; + } + const rawEntryUrl = currentVersion.entryUrl; + // 买断制作品在未购买时服务端不下发公开入口:保留空入口,主行动作改由购买接手。 + if (!rawEntryUrl) { setPublicGame({ ...value, - currentVersion: { ...value.currentVersion, entryUrl }, + currentVersion: { ...currentVersion, entryUrl: null }, }); return; } - setPublicGame(value); - if (!value) setError('找不到这款游戏'); + const entryUrl = normalizeGameEntryUrl(rawEntryUrl); + if (!entryUrl) { + setError('游戏入口地址无效'); + return; + } + setPublicGame({ + ...value, + currentVersion: { ...currentVersion, entryUrl }, + }); }) .catch((loadError: unknown) => { if (requestSerial !== requestSerialRef.current) return; @@ -221,6 +289,80 @@ export function GameDetailPage({ const resolvedGame = ownerView ? ownerGame : publicGame; const reload = ownerView ? loadOwnerGame : loadPublicGame; + /** + * 打开购买确认。未登录先走平台登录;幂等键在打开弹窗时生成, + * 同一次购买(含失败后重试)复用同一个键,避免重复扣费。 + */ + function requestPurchase() { + if (!authUi?.user) { + authUi?.openLoginModal(); + return; + } + purchaseKeyRef.current = createPurchaseKey(); + setPurchaseNotice(null); + setIsPurchaseDialogOpen(true); + } + + async function handleConfirmPurchase() { + if (!resolvedGame || purchaseInFlightRef.current) return; + const priceMudPoints = resolvedGame.priceMudPoints ?? 0; + if (priceMudPoints <= 0) return; + purchaseInFlightRef.current = true; + setIsPurchasing(true); + try { + if (!purchaseKeyRef.current) purchaseKeyRef.current = createPurchaseKey(); + await purchaseGame( + resolvedGame.id, + { expectedPriceMudPoints: priceMudPoints }, + purchaseKeyRef.current, + ); + purchaseKeyRef.current = null; + setIsPurchaseDialogOpen(false); + // 扣费成功先刷新平台钱包 store,再回读详情拿 `purchased` 与可播放入口。 + await onWalletBalanceMayHaveChanged(); + setPurchaseNotice({ + tone: 'info', + message: '购买成功,现在可以直接开始游玩。', + canRecharge: false, + }); + reload(); + } catch (purchaseError: unknown) { + purchaseKeyRef.current = null; + setIsPurchaseDialogOpen(false); + const insufficient = + purchaseError instanceof ApiClientError && + purchaseError.code === 'INSUFFICIENT_MUD_POINTS'; + const priceChanged = + purchaseError instanceof ApiClientError && purchaseError.status === 409; + if (insufficient) { + setPurchaseNotice({ + tone: 'error', + message: '泥点不足,充值后即可购买这款游戏。', + canRecharge: true, + }); + } else if (priceChanged) { + setPurchaseNotice({ + tone: 'error', + message: '价格已更新,已为你刷新最新价格,请重新确认购买。', + canRecharge: false, + }); + reload(); + } else { + setPurchaseNotice({ + tone: 'error', + message: + purchaseError instanceof Error && purchaseError.message.trim() + ? purchaseError.message + : '购买失败,请稍后重试。', + canRecharge: false, + }); + } + } finally { + purchaseInFlightRef.current = false; + setIsPurchasing(false); + } + } + if ( isLoading || (resolvedGame && resolvedGame.id !== normalizeGameId(gameId)) @@ -250,19 +392,45 @@ export function GameDetailPage({ ); const canReview = Boolean(authUi?.user && authUi.canAccessProtectedData); + const purchasePriceMudPoints = resolvedGame.priceMudPoints ?? 0; return ( - authUi?.openLoginModal()} - onBack={onBack} - onPlay={onPlay} - /> + <> + authUi?.openLoginModal()} + onBack={onBack} + onPlay={onPlay} + /> + { + if (isPurchasing) return; + setIsPurchaseDialogOpen(false); + }} + onConfirm={() => void handleConfirmPurchase()} + > +

+ 当前泥点余额: + {balanceOwnerMatchesCurrentUser && walletBalance !== null + ? `${walletBalance} 泥点` + : '读取中…'} +

+
+ ); } @@ -273,6 +441,10 @@ function GameDetailContent({ recoveryActions, isMobileViewport, canReview, + isPurchasing, + purchaseNotice, + onOpenPurchase, + onOpenRecharge, onLogin, onBack, onPlay, @@ -283,6 +455,10 @@ function GameDetailContent({ recoveryActions: Record; isMobileViewport: boolean; canReview: boolean; + isPurchasing: boolean; + purchaseNotice: GamePurchaseNotice | null; + onOpenPurchase: () => void; + onOpenRecharge?: () => void; onLogin: () => void; onBack: () => void; onPlay: (gameId: string) => void; @@ -317,7 +493,30 @@ function GameDetailContent({ : resolveNonPublicCoverLabel(game.status, latestVersion?.status); const latestReviewReason = latestVersion?.reviewReason?.trim() ?? ''; const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile; - const playbackBlocked = !version || mobilePlaybackBlocked; + /** + * 买断价与购买态:缺字段按免费/未购买兜底,付费且未购买时后端不下发可播放入口, + * 前端也不再展示「立即玩」,改由购买动作接手。 + */ + const priceMudPoints = game.priceMudPoints ?? 0; + const isPaidLocked = + !ownerView && priceMudPoints > 0 && game.purchased !== true; + const hasPlayableEntry = Boolean(version?.entryUrl); + /** + * 公开视角按「后端是否下发入口」判定可否开玩:付费未购买时入口为空。 + * 作者视角的版本条目不带公开入口,沿用「有已公开版本即可开玩」的既有口径。 + */ + const playbackBlocked = ownerView + ? !version || mobilePlaybackBlocked + : !hasPlayableEntry || mobilePlaybackBlocked; + const priceInfoCards = + typeof game.priceMudPoints === 'number' + ? [ + { + label: '定价', + value: game.priceMudPoints > 0 ? `${game.priceMudPoints} 泥点` : '免费', + }, + ] + : []; return (
+ ) : null} + + ) : null} {ownerView && ownerGame ? (
diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 4ecd4ebc3..b9d426f60 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -12,6 +12,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution'; +import { ApiClientError } from '../../services/apiClient'; import type { GameDistributionGame, GameDistributionMyGame, @@ -30,6 +31,8 @@ const recordGamePlayMock = vi.hoisted(() => vi.fn()); const resolvedAssetUrlMock = vi.hoisted(() => vi.fn()); const listGameReviewsMock = vi.hoisted(() => vi.fn()); const getMyGameReviewMock = vi.hoisted(() => vi.fn()); +const purchaseGameMock = vi.hoisted(() => vi.fn()); +const createGamePlaySessionMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/gameDistributionClient', () => ({ listGames: listGamesMock, @@ -39,6 +42,21 @@ vi.mock('../../services/gameDistributionClient', () => ({ recordGamePlay: recordGamePlayMock, listGameReviews: listGameReviewsMock, getMyGameReview: getMyGameReviewMock, + purchaseGame: purchaseGameMock, + createGamePlaySession: createGamePlaySessionMock, +})); + +// 详情页购买后要刷新平台钱包;这里只提供选择器需要的状态与刷新钩子,避免真实取余额请求。 +const walletMocks = vi.hoisted(() => ({ + ownerUserId: 'author-1', + mudPointBalance: { totalPoints: 500, permanentPoints: 500, limitedPoints: 0 }, + legacyWalletBalance: 500, + onWalletBalanceMayHaveChanged: vi.fn(async () => {}), +})); + +vi.mock('../../stores/usePlatformWalletStore', () => ({ + usePlatformWalletStore: (selector: (state: typeof walletMocks) => T) => + selector(walletMocks), })); // 作者视角详情页要按登录身份渲染评价区,这里固定一个可访问受保护数据的用户。 @@ -101,6 +119,8 @@ function createGame( }, playCount: 12, createdAt: '2026-09-18T00:00:00.000Z', + priceMudPoints: 0, + purchased: true, ...overrides, }; } @@ -143,6 +163,9 @@ beforeEach(() => { }); getMyGameReviewMock.mockResolvedValue({ review: null }); recordGamePlayMock.mockResolvedValue(undefined); + purchaseGameMock.mockReset(); + createGamePlaySessionMock.mockReset(); + walletMocks.onWalletBalanceMayHaveChanged.mockClear(); resolvedAssetUrlMock.mockImplementation((objectKey: string) => objectKey ? `https://assets.example.test/${objectKey}` : '', ); @@ -246,6 +269,120 @@ describe('GameDetailPage', () => { expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy(); }); + + function createLockedPaidGame(): GameDistributionGame { + return createGame({ + priceMudPoints: 120, + purchased: false, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }); + } + + it('付费未购买时展示价格与购买主动作,不再给出游玩入口', async () => { + getGameMock.mockResolvedValueOnce(createLockedPaidGame()); + render( + , + ); + + const buyButton = await screen.findByRole('button', { + name: /120 泥点购买/u, + }); + expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull(); + // 定价资料卡展示价格;入口为空是服务端口径,不是「入口地址无效」。 + expect(screen.getByText('120 泥点')).toBeTruthy(); + expect(screen.queryByText('游戏入口地址无效')).toBeNull(); + expect((buyButton as HTMLButtonElement).disabled).toBe(false); + }); + + it('购买走二次确认并展示余额,成功后刷新钱包并回到可玩状态', async () => { + getGameMock + .mockResolvedValueOnce(createLockedPaidGame()) + .mockResolvedValueOnce( + createGame({ priceMudPoints: 120, purchased: true }), + ); + purchaseGameMock.mockResolvedValue({ + purchase: { + purchaseId: 'gdpurchase_1', + gameId: 'game-1', + priceMudPoints: 120, + createdAt: '2026-10-05T00:00:00Z', + }, + walletBalance: 380, + replayed: false, + }); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: /120 泥点购买/u }), + ); + const dialog = await screen.findByRole('dialog', { + name: '确认消耗泥点', + }); + expect(within(dialog).getByText('消耗 120 泥点')).toBeTruthy(); + expect(within(dialog).getByText('当前泥点余额:500 泥点')).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '确认购买' })); + + await waitFor(() => expect(purchaseGameMock).toHaveBeenCalledTimes(1)); + expect(purchaseGameMock.mock.calls[0]?.[0]).toBe('game-1'); + expect(purchaseGameMock.mock.calls[0]?.[1]).toEqual({ + expectedPriceMudPoints: 120, + }); + // 幂等键必须非空,双击与重试才会被服务端按同一键重放。 + expect(String(purchaseGameMock.mock.calls[0]?.[2] ?? '')).not.toBe(''); + await waitFor(() => + expect(walletMocks.onWalletBalanceMayHaveChanged).toHaveBeenCalled(), + ); + expect( + await screen.findByText('购买成功,现在可以直接开始游玩。'), + ).toBeTruthy(); + expect( + await screen.findByRole('button', { name: /立即玩/u }), + ).toBeTruthy(); + }); + + it('余额不足时失败关闭并给出充值入口', async () => { + const onOpenRecharge = vi.fn(); + getGameMock.mockResolvedValue(createLockedPaidGame()); + purchaseGameMock.mockRejectedValueOnce( + new ApiClientError({ + message: '泥点余额不足', + status: 400, + code: 'INSUFFICIENT_MUD_POINTS', + }), + ); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: /120 泥点购买/u }), + ); + fireEvent.click( + await screen.findByRole('button', { name: '确认购买' }), + ); + + expect( + await screen.findByText('泥点不足,充值后即可购买这款游戏。'), + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '去充值' })); + expect(onOpenRecharge).toHaveBeenCalledTimes(1); + // 失败关闭:既没有游玩入口,也没有伪造成功。 + expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull(); + expect( + screen.queryByText('购买成功,现在可以直接开始游玩。'), + ).toBeNull(); + expect( + screen.getByRole('button', { name: /120 泥点购买/u }), + ).toBeTruthy(); + }); }); describe('GameDetailPage 作者视角', () => { @@ -478,6 +615,70 @@ describe('GamePlayPage', () => { expect(await screen.findByText('游戏入口地址无效')).toBeTruthy(); expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull(); }); + + it('免费游戏不请求播放会话', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + await screen.findByTitle('星轨防线 在线游玩'); + expect(createGamePlaySessionMock).not.toHaveBeenCalled(); + }); + + it('付费游戏先签发播放会话再挂载 iframe 并计数', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + priceMudPoints: 120, + purchased: true, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }), + ); + createGamePlaySessionMock.mockResolvedValue({ + playUrl: '/api/game-distribution/play-sessions/token-1/', + expiresAt: '2026-10-05T00:05:00Z', + }); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + + const iframe = await screen.findByTitle('星轨防线 在线游玩'); + expect(iframe.getAttribute('src')).toBe( + new URL( + '/api/game-distribution/play-sessions/token-1/', + window.location.origin, + ).href, + ); + expect(iframe.getAttribute('sandbox')).toBe('allow-scripts'); + expect(createGamePlaySessionMock).toHaveBeenCalledWith('game-1'); + expect(recordGamePlayMock).toHaveBeenCalledWith('game-1'); + }); + + it('未购买时播放会话被拒,不挂载 iframe 也不上报游玩', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + priceMudPoints: 120, + purchased: false, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }), + ); + createGamePlaySessionMock.mockRejectedValueOnce( + new ApiClientError({ + message: '尚未购买这款游戏', + status: 403, + code: 'FORBIDDEN', + }), + ); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u })); + + expect( + await screen.findByText( + '这款游戏需要先购买才能游玩,请返回详情页完成购买。', + ), + ).toBeTruthy(); + expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull(); + expect(recordGamePlayMock).not.toHaveBeenCalled(); + }); }); describe('GameGalleryPage 筛选与返回上下文', () => { diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx index f4f1efebd..04cec5c31 100644 --- a/src/components/game-distribution/GamePlayPage.tsx +++ b/src/components/game-distribution/GamePlayPage.tsx @@ -9,8 +9,10 @@ import { } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiClientError } from '../../services/apiClient'; import { type GameDistributionGame, + createGamePlaySession, getGame, recordGamePlay, } from '../../services/gameDistributionClient'; @@ -29,7 +31,7 @@ type GamePlayPageProps = { gameId: string | null; onBack: () => void }; /** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */ export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000; -type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout'; +type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout' | 'failed'; export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const frameRef = useRef(null); @@ -38,6 +40,10 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const [startupState, setStartupState] = useState('idle'); const [hasStarted, setHasStarted] = useState(false); const [frameRunId, setFrameRunId] = useState(0); + /** 实际交给 iframe 的发行入口:免费游戏来自详情,付费游戏来自播放会话。 */ + const [playUrl, setPlayUrl] = useState(null); + const [isPreparingPlayback, setIsPreparingPlayback] = useState(false); + const [playError, setPlayError] = useState(''); const [error, setError] = useState(''); const requestSerialRef = useRef(0); const isMobileViewport = useIsMobileViewport(); @@ -49,6 +55,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { setGame(null); setHasStarted(false); setStartupState('idle'); + setPlayUrl(null); + setPlayError(''); setError(''); if (!normalizedGameId) { setError('缺少游戏编号'); @@ -59,14 +67,25 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { void getGame(normalizedGameId) .then((value) => { if (requestSerial !== requestSerialRef.current) return; - if (!value?.currentVersion?.entryUrl) { + const currentVersion = value?.currentVersion; + if (!value || !currentVersion) { throw new Error('这款游戏暂时没有可玩的版本'); } - const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl); + const priceMudPoints = value.priceMudPoints ?? 0; + const rawEntryUrl = currentVersion.entryUrl; + if (!rawEntryUrl) { + // 付费作品的入口由播放会话签发;免费游戏没有入口说明服务端确实没有可玩版本。 + if (priceMudPoints <= 0) { + throw new Error('这款游戏暂时没有可玩的版本'); + } + setGame({ ...value, currentVersion: { ...currentVersion, entryUrl: null } }); + return; + } + const entryUrl = normalizeGameEntryUrl(rawEntryUrl); if (!entryUrl) throw new Error('游戏入口地址无效'); setGame({ ...value, - currentVersion: { ...value.currentVersion, entryUrl }, + currentVersion: { ...currentVersion, entryUrl }, }); }) .catch((loadError: unknown) => { @@ -104,9 +123,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { const mobilePlaybackBlocked = isMobileViewport && !game?.deviceSupport.mobile; const showRotationHint = Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport; - const startGame = () => { - if (mobilePlaybackBlocked) return; - // 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。 + /** 进入游戏:计数是 fire-and-forget,失败也不阻断;付费作品在会话签发成功后才计数。 */ + const startFrame = () => { const playGameId = game?.id ?? normalizeGameId(gameId); if (playGameId) { void recordGamePlay(playGameId).catch(() => {}); @@ -116,8 +134,47 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) { setFrameRunId((runId) => runId + 1); }; - /** 重试只重挂载发行页,不改变已开始的会话,也不会在退出后自动重启。 */ + const startGame = () => { + if (mobilePlaybackBlocked || !game || isPreparingPlayback) return; + setPlayError(''); + const priceMudPoints = game.priceMudPoints ?? 0; + if (priceMudPoints <= 0) { + // 免费游戏维持原来的公开入口链路,不经过播放会话。 + setPlayUrl(game.currentVersion?.entryUrl ?? null); + startFrame(); + return; + } + setIsPreparingPlayback(true); + void createGamePlaySession(game.id) + .then((session) => { + const sessionUrl = normalizeGameEntryUrl(session.playUrl); + if (!sessionUrl) throw new Error('播放地址无效'); + setPlayUrl(sessionUrl); + startFrame(); + }) + .catch((sessionError: unknown) => { + setHasStarted(true); + setStartupState('failed'); + if (sessionError instanceof ApiClientError && sessionError.status === 403) { + setPlayError('这款游戏需要先购买才能游玩,请返回详情页完成购买。'); + return; + } + setPlayError( + sessionError instanceof Error && sessionError.message.trim() + ? sessionError.message + : '创建播放会话失败,请稍后重试。', + ); + }) + .finally(() => setIsPreparingPlayback(false)); + }; + + /** 重试:付费作品在会话缺失时重新签发,其余情况只重挂载发行页。 */ const retryGame = () => { + setPlayError(''); + if ((game?.priceMudPoints ?? 0) > 0 && !playUrl) { + startGame(); + return; + } setStartupState('loading'); setFrameRunId((runId) => runId + 1); }; @@ -182,7 +239,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {

) : null}
- {!hasStarted ? ( + {!hasStarted && !isPreparingPlayback ? (
@@ -211,8 +268,26 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
) : null} - {hasStarted && startupState === 'loading' ? ( -
游戏正在启动…
+ {isPreparingPlayback || + (hasStarted && startupState === 'loading' && !playError) ? ( +
+ {isPreparingPlayback ? '正在准备游戏…' : '游戏正在启动…'} +
+ ) : null} + {playError ? ( + + {playError} + + + ) : null} {hasStarted && startupState === 'timeout' ? ( ) : null} - {hasStarted ? ( + {hasStarted && playUrl ? (