From b4bf37671d937c5aa7d8188626ad06c517321624 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 02:18:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B9=B0=E6=96=AD=E5=88=B6=E4=BB=98=E8=B4=B9=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=88flaky=20=E7=94=A8=E4=BE=8B=E3=80=81?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E6=81=A2=E5=A4=8D=E5=AE=9A=E4=BB=B7=E3=80=81?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E5=8F=A3=E5=BE=84=E3=80=81=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E4=B8=8E=E6=B5=8B=E8=AF=95=E8=B4=A8=E9=87=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameDistributionPages.test.tsx:作者补查断言包进 waitFor,消除并发下的 0 调用 flaky - GameDistributionPages.test.tsx:购买确认改为双击只发一次请求,按格式断言幂等键而非仅非空 - GameDistributionPages.test.tsx:新增 409「价格已变化」UI 分支与「旧响应缺价格且无入口」可操作说明用例 - GameDetailPage.tsx:禁用「立即玩」且无入口时补原因说明并用 aria-describedby 关联按钮 - GamePublishPage.tsx:草稿恢复按版本冻结价预填付费模式与价格,恢复提示写明「本次将沿用版本冻结价 N 泥点 / 免费」 - GamePublishPage.test.tsx:删除与共享组件重复的定价纯函数用例,改为草稿恢复定价(游戏行 999 / 版本冻结 240)网页独有用例 - gameDistributionGuards.ts:播放会话 token 段收窄为 [A-Za-z0-9-],拒绝 `..` 逃逸 - gameDistributionGuards.test.ts:补 UUID 令牌、`..`、超长 URL、控制字符与字母表外字符用例 - gameDistributionClient.test.ts:补 purchaseGame / createGamePlaySession 路径、幂等键、expectedPriceMudPoints 序列化与 401/403/409/余额不足错误码映射 - PlatformGamePricingField.tsx:错误行加 id,价格输入补 aria-invalid / aria-describedby - PlatformGamePricingField.test.tsx:禁用态改用「买断制 + 禁用」以覆盖价格输入;新增错误关联 a11y 用例 - packages/shared 契约与 admin-web DTO:priceMudPoints / purchased 改为可选,与 Rust #[serde(default)] 口径对齐 - AdminGameDistributionReviewPage.tsx:详情价格按「版本冻结价优先、游戏行价兜底」取值(与服务端 version_detail_payload 同口径) - AdminGameDistributionReviewPage.test.tsx:详情价格改发散值(game=999 / frozen=30,断言 30);价格列按列名定位、去掉全局 NaN 弱断言 - adminGameDistributionReview.css:价格列 96px nowrap 放宽为 120px 并允许换行,避免最大价被硬裁 - AGC 发布面板与发布服务:深相对路径改走 @genarrative/shared/components alias - AGC 壳 game_distribution_publish.rs:修正预填价注释为「最近版本冻结价优先,可能仍待审/被拒」 --- apps/admin-web/src/api/adminApiTypes.ts | 8 +- .../AdminGameDistributionReviewPage.test.tsx | 23 +- .../pages/AdminGameDistributionReviewPage.tsx | 18 +- .../src/pages/adminGameDistributionReview.css | 7 +- .../src/game_distribution_publish.rs | 6 +- .../GameDistributionPublishPanel.tsx | 10 +- .../src/services/gameDistributionPublish.ts | 3 +- .../PlatformGamePricingField.test.tsx | 43 +++- .../components/PlatformGamePricingField.tsx | 14 +- .../shared/src/contracts/gameDistribution.ts | 13 +- .../game-distribution/GameDetailPage.tsx | 23 +- .../GameDistributionPages.test.tsx | 73 ++++++- .../GamePublishPage.test.tsx | 72 ++++--- .../game-distribution/GamePublishPage.tsx | 20 ++ .../gameDistributionGuards.test.ts | 60 ++++++ .../gameDistributionGuards.ts | 5 +- src/services/gameDistributionClient.test.ts | 200 +++++++++++++++++- 17 files changed, 518 insertions(+), 80 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 91544e8cc..cc3ea7076 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1138,8 +1138,8 @@ export interface AdminGameDistributionReviewEntry { packageBytes: number; status: string; publicationRevision: number; - /** 买断制泥点价格;0 表示免费。旧响应可能缺该字段。 */ - priceMudPoints: number; + /** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */ + priceMudPoints?: number; reviewReason: string | null; createdAt: string; updatedAt: string; @@ -1168,8 +1168,8 @@ export interface AdminGameDistributionGameDetail { orientation: string; status: string; publicationRevision: number; - /** 买断制泥点价格;0 表示免费。旧响应可能缺该字段。 */ - priceMudPoints: number; + /** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */ + priceMudPoints?: number; playCount: number; createdAt: string; } diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 98fc9e60a..07299c9ab 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -271,17 +271,25 @@ test('价格列按买断制口径展示,免费与缺失价格都不显示 NaN' ); await screen.findByText(gameTitle); + const table = screen.getByText(gameTitle).closest('table'); + expect(table).not.toBeNull(); + // 按列名定位:绑「第 4 个格子」的话,任何插列或换序都会读到别的字段,断言随之失效。 + const priceColumnIndex = within(table as HTMLElement) + .getAllByRole('columnheader') + .findIndex((header) => header.textContent?.trim() === '价格'); + expect(priceColumnIndex).toBeGreaterThanOrEqual(0); + const cellAt = (text: string) => { const row = screen.getByText(text).closest('tr'); expect(row).not.toBeNull(); - // 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。 - return within(row as HTMLElement).getAllByRole('cell')[3]?.textContent; + return within(row as HTMLElement).getAllByRole('cell')[priceColumnIndex] + ?.textContent; }; + // 三行三态都按精确文案钉死:付费、免费与旧响应缺字段都不能渲染成 NaN。 expect(cellAt(gameTitle)).toBe('30 泥点'); expect(cellAt('game_2')).toBe('免费'); expect(cellAt('game_3')).toBe('免费'); - expect(screen.queryByText(/NaN/u)).toBeNull(); }); test('取消理由输入时不做审核操作', async () => { @@ -464,12 +472,11 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async } }); -test('详情价格以后端下发的版本冻结价为准,客户端不再本地叠加', async () => { +test('详情价格取版本冻结价,不回退游戏行当前价', async () => { vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({ - // 后端 version_detail_payload 已按「待审版本冻结价优先、缺字段按 0」算好 - // game.priceMudPoints(此处待审版本冻结为 30 泥点,游戏行现价为 0); - // 审核页只渲染该字段,不再从 frozenMetadata 里二次取价。 - game: { ...detailResponse.game, priceMudPoints: 30 }, + // 游戏行现价与待审版本冻结价刻意取不同值:两者相同的话,无论实现取哪个字段 + // 用例都会绿,无法证明审核页展示的是这一版审核通过后真正生效的价格。 + game: { ...detailResponse.game, priceMudPoints: 999 }, version: { ...detailResponse.version, frozenMetadata: { title: '测试游戏', priceMudPoints: 30 }, diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 5455b7cf2..9410b369d 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -100,11 +100,25 @@ function applyFrozenGameMetadata( typeof metadata.orientation === 'string' ? metadata.orientation : game.orientation, - // 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、 - // 缺字段按 0」把结果写进 `game.priceMudPoints`,客户端直接沿用该字段。 + // 价格口径与服务端 `version_detail_payload` 对齐,并在客户端再兜一次:审核页展示的是 + // 这一版审核通过后会生效的价格,所以版本冻结价优先;快照没冻结价才回落游戏行价, + // 两者都缺时交给 `formatPriceMudPoints` 按免费展示。 + priceMudPoints: resolveFrozenPriceMudPoints(game, metadata), }; } +/** 版本冻结价优先、游戏行价兜底;只取值,不改「`0` 表示免费」的展示口径。 */ +function resolveFrozenPriceMudPoints( + game: AdminGameDistributionGameDetail, + metadata: Record, +) { + const frozenPriceMudPoints = metadata.priceMudPoints; + return typeof frozenPriceMudPoints === 'number' && + Number.isFinite(frozenPriceMudPoints) + ? frozenPriceMudPoints + : game.priceMudPoints; +} + function formatBytes(value: number) { if (value >= 1024 * 1024) { return `${(value / (1024 * 1024)).toFixed(1)} MiB`; diff --git a/apps/admin-web/src/pages/adminGameDistributionReview.css b/apps/admin-web/src/pages/adminGameDistributionReview.css index 28e6ef8cd..48d48ccc6 100644 --- a/apps/admin-web/src/pages/adminGameDistributionReview.css +++ b/apps/admin-web/src/pages/adminGameDistributionReview.css @@ -27,11 +27,12 @@ table.admin-game-review-table td:nth-child(3) { width: 124px; } -/* 价格列:只展示「免费」或「N 泥点」,窄列即可。 */ +/* 价格列:只展示「免费」或「N 泥点」,上限 1000000 泥点最长;给足宽度并允许换行, + 固定列宽 + overflow:hidden 下保持 nowrap 会把最大价硬裁掉。 */ table.admin-game-review-table th:nth-child(4), table.admin-game-review-table td:nth-child(4) { - width: 96px; - white-space: nowrap; + width: 120px; + white-space: normal; } table.admin-game-review-table th:nth-child(5), diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 5f22e6d7c..dceef09c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -104,8 +104,10 @@ pub(crate) struct GameDistributionPublicationDraft { pub(crate) input_modes: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) orientation: Option, - /// 线上作品当前生效的买断价(整数泥点,`0` 表示免费)。面板据此预填,作者不改就不会 - /// 把已上线的付费作品静默改成免费;旧原生响应缺该字段时按免费兜底。 + /// 更新面板预填的买断价(整数泥点,`0` 表示免费):取该作品**最近版本的冻结价** + /// (该版本可能仍处于 `pending_review` / `rejected`,因此预填值未必已生效);该版本 + /// 冻结价缺失或为 `0` 时回落游戏行当前生效价,两条来源都取不到才按 `0`(免费), + /// 避免把已上线的付费作品预填成免费。旧原生响应缺该字段时按免费兜底。 #[serde(default)] pub(crate) price_mud_points: u64, } diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index f9ce9a26b..666dc09b1 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -1,11 +1,11 @@ +import { + PlatformGamePricingField, + type PlatformGamePricingMode, + resolvePlatformGamePriceMudPoints, +} from '@genarrative/shared/components'; import { Eye, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { PlatformGamePricingField } from '../../../../../packages/shared/src/components/PlatformGamePricingField'; -import { - type PlatformGamePricingMode, - resolvePlatformGamePriceMudPoints, -} from '../../../../../packages/shared/src/components/platformGamePricingModel'; import { type GameCreationAppManifest, resolveGameCreationAppProjectVersion, diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index bdb809439..4430e9cc0 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -1,4 +1,5 @@ -import { PLATFORM_GAME_MAX_PRICE_MUD_POINTS } from '../../../../packages/shared/src/components/platformGamePricingModel'; +import { PLATFORM_GAME_MAX_PRICE_MUD_POINTS } from '@genarrative/shared/components'; + import { type GameCreationAppManifest, type GameCreationAppPublicationBinding, diff --git a/packages/shared/src/components/PlatformGamePricingField.test.tsx b/packages/shared/src/components/PlatformGamePricingField.test.tsx index 29e4a5712..8a97a3030 100644 --- a/packages/shared/src/components/PlatformGamePricingField.test.tsx +++ b/packages/shared/src/components/PlatformGamePricingField.test.tsx @@ -13,12 +13,16 @@ import { function PricingHarness({ disabled = false, error = '', + initialMode = 'free', + initialPriceInput = '', }: { disabled?: boolean; error?: string; + initialMode?: 'free' | 'paid'; + initialPriceInput?: string; }) { - const [mode, setMode] = useState<'free' | 'paid'>('free'); - const [priceInput, setPriceInput] = useState(''); + const [mode, setMode] = useState<'free' | 'paid'>(initialMode); + const [priceInput, setPriceInput] = useState(initialPriceInput); return ( { - render(); +test('禁用态锁住单选与买断价输入', () => { + // 免费模式下价格输入根本不渲染,只断言两个 radio 等于没测到输入框; + // 必须用「买断制 + 禁用」才能覆盖价格输入被锁住的路径。 + render( + , + ); expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty( 'disabled', true, ); - expect(screen.getByRole('radio', { name: '买断制' })).toHaveProperty( - 'disabled', - true, - ); + const paidRadio = screen.getByRole('radio', { name: '买断制' }); + expect(paidRadio).toHaveProperty('disabled', true); + expect(paidRadio).toHaveProperty('checked', true); + const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement; + expect(priceInput.disabled).toBe(true); + expect(priceInput.value).toBe('120'); }); test('使用方传入的校验错误在字段内以 alert 呈现', () => { @@ -80,6 +90,23 @@ test('使用方传入的校验错误在字段内以 alert 呈现', () => { expect(screen.getByRole('alert').textContent).toBe('买断价必须是整数泥点'); }); +test('买断价校验错误通过 aria-describedby 关联到价格输入', () => { + render( + , + ); + + const priceInput = screen.getByLabelText(/买断价/u); + const alert = screen.getByRole('alert'); + expect(alert.textContent).toBe('买断价必须是整数泥点'); + expect(alert.id).toBeTruthy(); + expect(priceInput.getAttribute('aria-describedby')).toBe(alert.id); + expect(priceInput.getAttribute('aria-invalid')).toBe('true'); +}); + test('价格解析与提交口径一致:免费为 0,买断制要求 1..=1000000 整数', () => { expect(resolvePlatformGamePriceMudPoints('free', 'abc')).toEqual({ priceMudPoints: 0, diff --git a/packages/shared/src/components/PlatformGamePricingField.tsx b/packages/shared/src/components/PlatformGamePricingField.tsx index ab3fc3d94..22682c934 100644 --- a/packages/shared/src/components/PlatformGamePricingField.tsx +++ b/packages/shared/src/components/PlatformGamePricingField.tsx @@ -1,5 +1,7 @@ import './PlatformGamePricingField.css'; +import { useId } from 'react'; + import { PLATFORM_GAME_MAX_PRICE_MUD_POINTS, type PlatformGamePricingMode, @@ -34,6 +36,9 @@ export function PlatformGamePricingField({ onPriceInputChange, radioName = 'platform-game-pricing-mode', }: PlatformGamePricingFieldProps) { + // 错误行与价格输入必须显式关联:读屏用户只听到「买断价(泥点)」时不知道哪里非法。 + const errorId = `${useId()}-price-error`; + const priceInputId = `${useId()}-price-input`; return (
付费方式 @@ -63,10 +68,13 @@ export function PlatformGamePricingField({ ) : null} {error ? ( - + {error} ) : null} diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index be4f27506..4f3d1ab0d 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -143,10 +143,15 @@ export type GameDistributionGame = { currentVersion?: GameDistributionVersionSummary | null; /** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */ ratingSummary?: GameDistributionRatingSummary; - /** 买断价(整数泥点,`0` 表示免费)。旧响应可省略,读取方按 `0`(免费)兜底。 */ - priceMudPoints: number; - /** 当前查看者是否已拥有;匿名与未登录恒为 `false`。 */ - purchased: boolean; + /** + * 买断价(整数泥点,`0` 表示免费)。后端公开投影始终下发;作者侧与旧响应可省略 + * (Rust 侧 `#[serde(default)]` 同样允许缺字段),读取方必须按 `0`(免费)兜底。 + */ + priceMudPoints?: number; + /** + * 当前查看者是否已拥有;匿名与未登录恒为 `false`。旧响应可省略,读取方按未购买兜底。 + */ + purchased?: boolean; playCount: number; createdAt: string; }; diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 56959f984..80aa79e9d 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -521,6 +521,16 @@ function GameDetailContent({ const playbackBlocked = ownerView ? !version || mobilePlaybackBlocked : !hasPlayableEntry || mobilePlaybackBlocked; + /** + * 付费作品在旧响应缺 `priceMudPoints` 时会被判成免费,同时服务端又没下发入口, + * 于是「立即玩」只是灰着——必须说明原因,否则用户只看到一个点不动的按钮。 + */ + const isEntryMissing = !isPaidLocked && !ownerView && !hasPlayableEntry; + const playbackBlockedReasonId = mobilePlaybackBlocked + ? 'game-mobile-playback-warning' + : isEntryMissing + ? 'game-entry-missing-warning' + : undefined; const priceInfoCards = typeof game.priceMudPoints === 'number' ? [ @@ -584,9 +594,7 @@ function GameDetailContent({ onClick={() => onPlay(game.id)} disabled={playbackBlocked} aria-describedby={ - mobilePlaybackBlocked - ? 'game-mobile-playback-warning' - : undefined + playbackBlocked ? playbackBlockedReasonId : undefined } > 立即玩 @@ -601,6 +609,15 @@ function GameDetailContent({ 请在电脑上游玩 ) : null} + {isEntryMissing && !mobilePlaybackBlocked ? ( + + 暂时拿不到可玩入口,请刷新页面后重试。 + + ) : null} {ownerView && !publishedVersion ? ( 作品尚未公开,暂时没有在线可玩版本 diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index a3f7b3ed4..13df59011 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -259,7 +259,10 @@ describe('GameDetailPage', () => { const link = await screen.findByRole('link', { name: '查看创作者主页' }); expect(link.getAttribute('href')).toBe('/creators?id=author-2'); expect(link.querySelector('strong')?.textContent).toBe(''); - expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2'); + // 占位昵称的补查发生在渲染后的 effect 里:并发负载下首帧可能还没触发,必须等。 + await waitFor(() => + expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2'), + ); await waitFor(() => { expect( (screen.getByRole('button', { name: '关注' }) as HTMLButtonElement) @@ -432,15 +435,25 @@ describe('GameDetailPage', () => { expect(within(dialog).getByText('消耗 120 泥点')).toBeTruthy(); expect(within(dialog).getByText('当前泥点余额:500 泥点')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', { name: '确认购买' })); + const confirmButton = within(dialog).getByRole('button', { + name: '确认购买', + }); + // 双击必须只扣一次:第二次点击落在同一次购买的请求窗口内,不得再发一次购买请求。 + fireEvent.click(confirmButton); + fireEvent.click(confirmButton); 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(''); + // 同一次购买只用一个幂等键:键为空或每次点击换键都意味着服务端会当新购买扣费。 + const purchaseKeys = purchaseGameMock.mock.calls.map((call) => + String(call[2] ?? ''), + ); + expect(purchaseKeys).toEqual([ + expect.stringMatching(/^web-game-purchase-[0-9a-f-]{36}$/u), + ]); await waitFor(() => expect(walletMocks.onWalletBalanceMayHaveChanged).toHaveBeenCalled(), ); @@ -484,6 +497,58 @@ describe('GameDetailPage', () => { expect(screen.queryByText('购买成功,现在可以直接开始游玩。')).toBeNull(); expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy(); }); + + it('服务端返回 409 价格已变化时提示已刷新并保留购买入口', async () => { + getGameMock.mockResolvedValue(createLockedPaidGame()); + purchaseGameMock.mockRejectedValueOnce( + new ApiClientError({ + message: '价格已变化,请按最新价格重新确认', + status: 409, + code: 'CONFLICT', + }), + ); + render( + , + ); + + fireEvent.click( + await screen.findByRole('button', { name: /120 泥点购买/u }), + ); + fireEvent.click(await screen.findByRole('button', { name: '确认购买' })); + + expect( + await screen.findByText( + '价格已更新,已为你刷新最新价格,请重新确认购买。', + ), + ).toBeTruthy(); + // CAS 失败必须回读详情拿最新价,但绝不按旧价扣费,也不伪造购买成功。 + await waitFor(() => expect(getGameMock).toHaveBeenCalledTimes(2)); + expect(screen.queryByText('购买成功,现在可以直接开始游玩。')).toBeNull(); + expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy(); + }); + + it('旧响应缺价格且没有可玩入口时说明「立即玩」为何不可用', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + // 旧响应没有买断价:按免费兜底会被判成可玩,但服务端并没有下发入口。 + priceMudPoints: undefined, + purchased: false, + currentVersion: { ...createGame().currentVersion!, entryUrl: null }, + }), + ); + render( + , + ); + + const playButton = await screen.findByRole('button', { name: /立即玩/u }); + await waitFor(() => + expect((playButton as HTMLButtonElement).disabled).toBe(true), + ); + // 禁用按钮必须带可操作说明,否则用户只看到一个点不动的按钮。 + const reason = screen.getByText('暂时拿不到可玩入口,请刷新页面后重试。'); + expect(reason.id).toBe('game-entry-missing-warning'); + expect(playButton.getAttribute('aria-describedby')).toBe(reason.id); + }); }); describe('GameDetailPage 作者视角', () => { diff --git a/src/components/game-distribution/GamePublishPage.test.tsx b/src/components/game-distribution/GamePublishPage.test.tsx index c7479c722..6bfe3d94c 100644 --- a/src/components/game-distribution/GamePublishPage.test.tsx +++ b/src/components/game-distribution/GamePublishPage.test.tsx @@ -18,10 +18,7 @@ import { resolveGamePublishImagePreview, uploadGamePublishImageAsset, } from './gamePublishAssets'; -import { - resolveGamePublishPriceMudPoints, - resolvePublishMetadataError, -} from './gamePublishMetadata'; +import { resolvePublishMetadataError } from './gamePublishMetadata'; import { GamePublishPage } from './GamePublishPage'; const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn()); @@ -232,36 +229,45 @@ test('元数据校验与服务端口径一致', () => { ).toBe(''); }); -test('定价校验与服务端口径一致', () => { - expect(resolveGamePublishPriceMudPoints('free', '')).toEqual({ - priceMudPoints: 0, - error: '', - }); - // 免费模式忽略输入框内容,等价于 0 泥点。 - expect(resolveGamePublishPriceMudPoints('free', 'abc')).toEqual({ - priceMudPoints: 0, - error: '', - }); - expect(resolveGamePublishPriceMudPoints('paid', '120')).toEqual({ - priceMudPoints: 120, - error: '', - }); +test('草稿恢复按版本冻结价预填并锁住定价,提示写明将沿用的价格', async () => { + writeDraft('user-1'); + vi.mocked(getGameVersion).mockResolvedValue({ + game: { + id: 'game-1', + title: '上次的游戏', + summary: '上次的简介', + description: '上次的详细介绍', + publicationRevision: 2, + // 游戏行现价与版本冻结价刻意取不同值:恢复必须取版本冻结价。 + priceMudPoints: 999, + }, + version: { + versionId: 'gamever-1', + versionNumber: 1, + status: 'uploaded', + recoveryAction: 'upload', + priceMudPoints: 240, + frozenMetadata: { coverAssetId: 'asset-cover-old' }, + }, + } as never); + + await renderPage(); + fireEvent.click(await screen.findByRole('button', { name: '继续上传' })); + + // 恢复态定价字段被禁用,所以提示必须直接写出将要沿用的冻结价,作者不必回表单里找。 expect( - resolveGamePublishPriceMudPoints('paid', '1000000').priceMudPoints, - ).toBe(1000000); - for (const invalid of ['', ' ', '-1', '1.5', 'abc', '1e3']) { - expect(resolveGamePublishPriceMudPoints('paid', invalid)).toEqual({ - priceMudPoints: 0, - error: '买断价必须是整数泥点', - }); - } - for (const outOfRange of ['0', '1000001']) { - expect(resolveGamePublishPriceMudPoints('paid', outOfRange)).toEqual({ - priceMudPoints: 0, - error: '买断价必须是 1 到 1000000 之间的整数泥点', - }); - } - // 元数据校验是同一口径的服务端对齐兜底。 + await screen.findByText(/本次将沿用版本冻结价 240 泥点/u), + ).toBeTruthy(); + const paidRadio = screen.getByLabelText('买断制') as HTMLInputElement; + expect(paidRadio.checked).toBe(true); + expect(paidRadio.disabled).toBe(true); + const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement; + expect(priceInput.value).toBe('240'); + expect(priceInput.disabled).toBe(true); +}); + +test('越界价格在网页元数据兜底里被拦下', () => { + // 纯函数解析口径已由共享组件用例覆盖;这里只保留网页独有的元数据兜底。 const desktop = { desktop: true, mobile: false, touch: false }; expect( resolvePublishMetadataError({ diff --git a/src/components/game-distribution/GamePublishPage.tsx b/src/components/game-distribution/GamePublishPage.tsx index 0b7a027ab..9dc2cc97d 100644 --- a/src/components/game-distribution/GamePublishPage.tsx +++ b/src/components/game-distribution/GamePublishPage.tsx @@ -250,6 +250,17 @@ export function GamePublishPage({ setTitle(detail.game.title); setSummary(detail.game.summary); setDescription(detail.game.description); + // 草稿恢复的字段是禁用的,但定价必须显示这一版真正会沿用的价格: + // 否则已付费作品会以「免费」示人,作者会以为价格被改掉了。 + const frozenPriceMudPoints = + detail.version.priceMudPoints ?? detail.game.priceMudPoints ?? 0; + if (frozenPriceMudPoints > 0) { + setPriceMode('paid'); + setPriceInput(String(frozenPriceMudPoints)); + } else { + setPriceMode('free'); + setPriceInput(''); + } void applyVersionMediaDefaults(detail, { setCover: setCoverAsset, setScreenshots: setScreenshotAssets, @@ -269,6 +280,12 @@ export function GamePublishPage({ isResumingDraft && draftDetail !== null && draftDetail.version.recoveryAction !== 'submit'; + /** 恢复态定价字段被锁住,这里把将要沿用的冻结价直接写进提示,作者不必回表单里找。 */ + const resumeFrozenPriceMudPoints = draftDetail + ? (draftDetail.version.priceMudPoints ?? + draftDetail.game.priceMudPoints ?? + 0) + : 0; /** 展示中的线上封面没有素材 ID 时无法冻结进新版本,必须先本地拦一次并说明原因。 */ function resolveMediaSubmitError() { @@ -697,6 +714,9 @@ export function GamePublishPage({ 继续发布沿用上次冻结的资料与版本号,资料要改就撤回后在「我的游戏」重新发布。 + {resumeFrozenPriceMudPoints > 0 + ? `本次将沿用版本冻结价 ${resumeFrozenPriceMudPoints} 泥点。` + : '本次将沿用版本冻结价,作品为免费。'} ) : null} diff --git a/src/components/game-distribution/gameDistributionGuards.test.ts b/src/components/game-distribution/gameDistributionGuards.test.ts index ff10fbd7c..bb76c75be 100644 --- a/src/components/game-distribution/gameDistributionGuards.test.ts +++ b/src/components/game-distribution/gameDistributionGuards.test.ts @@ -6,6 +6,8 @@ import { normalizeGameEntryUrl } from './gameDistributionGuards'; const GAME_ID = `game_${'a'.repeat(32)}`; const GAME_ENTRY_PATH = `/games/${GAME_ID}/`; +/** 与 `gameDistributionGuards.ts` 的 `MAX_ENTRY_URL_LENGTH` 对齐;守卫内部常量不导出。 */ +const MAX_ENTRY_URL_LENGTH = 4096; describe('normalizeGameEntryUrl', () => { it('resolves a release gateway relative path against the current origin', () => { @@ -60,6 +62,64 @@ describe('normalizeGameEntryUrl', () => { ).toBe(new URL(sessionPath, window.location.origin).href); }); + it('accepts a uuid v4 play session token with and without the trailing slash', () => { + const token = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; + const sessionPath = `/api/game-distribution/play-sessions/${token}/`; + + expect(normalizeGameEntryUrl(sessionPath)).toBe( + new URL(sessionPath, window.location.origin).href, + ); + // 不带尾斜杠时按原样接受:是否带尾斜杠由服务端签发的 playUrl 决定,守卫不代它补。 + const withoutSlash = `/api/game-distribution/play-sessions/${token}`; + expect(normalizeGameEntryUrl(withoutSlash)).toBe( + new URL(withoutSlash, window.location.origin).href, + ); + }); + + it('refuses a play session token that escapes the prefix with dot segments', () => { + // `[A-Za-z0-9._~/-]` 会放过 `..`,`new URL` 随后把它归一成同源其它路径。 + expect( + normalizeGameEntryUrl('/api/game-distribution/play-sessions/../../x/'), + ).toBeNull(); + expect( + normalizeGameEntryUrl( + `${window.location.origin}/api/game-distribution/play-sessions/../../x/`, + ), + ).toBeNull(); + expect( + normalizeGameEntryUrl( + '/api/game-distribution/play-sessions/..%2F..%2Fx/', + ), + ).toBeNull(); + }); + + it.each(['token.1', 'token_1', 'token~1', 'token%2F1', 'token 1'])( + 'refuses a play session token outside the uuid alphabet: %s', + (token) => { + expect( + normalizeGameEntryUrl(`/api/game-distribution/play-sessions/${token}/`), + ).toBeNull(); + }, + ); + + it('refuses an entry URL longer than the guard limit', () => { + expect( + normalizeGameEntryUrl( + `/api/game-distribution/play-sessions/${'a'.repeat(MAX_ENTRY_URL_LENGTH)}/`, + ), + ).toBeNull(); + expect( + normalizeGameEntryUrl(`https://play.example.test/${'a'.repeat(4200)}`), + ).toBeNull(); + }); + + it.each([ + '/api/game-distribution/play-sessions/tok\u0001en/', + '/api/game-distribution/play-sessions/token-1/\u007f', + ])('refuses an entry URL carrying control characters: %j', (entryUrl) => { + expect(normalizeGameEntryUrl(entryUrl)).toBeNull(); + }); + it.each([ '/api/game-distribution/play-sessions/', '/api/game-distribution/games/game-1/purchase', diff --git a/src/components/game-distribution/gameDistributionGuards.ts b/src/components/game-distribution/gameDistributionGuards.ts index 385b316c4..42552afec 100644 --- a/src/components/game-distribution/gameDistributionGuards.ts +++ b/src/components/game-distribution/gameDistributionGuards.ts @@ -6,9 +6,12 @@ const GAME_ENTRY_PATH_PATTERN = /^\/games\/(game_[0-9a-f]{32})\/?$/; /** * 付费游戏的播放会话入口由服务端签发,形状为 * `/api/game-distribution/play-sessions//`;包内资源经同一前缀相对读取。 + * + * token 段只允许 `[A-Za-z0-9-]`(令牌是 UUID v4):放开 `.` 与 `/` 会让 + * `…/play-sessions/../../x` 这类输入被 `new URL` 归一成同源其它路径。 */ const GAME_PLAY_SESSION_PATH_PATTERN = - /^\/api\/game-distribution\/play-sessions\/[A-Za-z0-9._~/-]+$/; + /^\/api\/game-distribution\/play-sessions\/[A-Za-z0-9-]+\/?$/; function normalizeGameEntryPath(pathname: string) { const gameMatch = GAME_ENTRY_PATH_PATTERN.exec(pathname); diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index 8b9c57709..edc8dd0cb 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -1,9 +1,14 @@ /* @vitest-environment jsdom */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { clearStoredAccessToken, setStoredAccessToken } from './apiClient'; +import { + ApiClientError, + clearStoredAccessToken, + setStoredAccessToken, +} from './apiClient'; import { cancelGameVersion, + createGamePlaySession, createGameVersion, getGame, getGameVersion, @@ -11,6 +16,7 @@ import { listGameReviews, listGames, listMyGames, + purchaseGame, recordGamePlay, saveMyGameReview, unpublishGame, @@ -408,3 +414,195 @@ describe('gameDistributionClient 作者接口', () => { expect(cancelCalls()).toHaveLength(1); }); }); + +describe('gameDistributionClient 购买与播放会话', () => { + beforeEach(() => { + // Node 新版提供的原生 localStorage 不等于浏览器存储;测试使用独立内存存储。 + const values = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }); + clearStoredAccessToken({ emit: false }); + }); + + it('购买请求带路径、幂等键与 expectedPriceMudPoints,响应按契约解析', async () => { + setStoredAccessToken('buyer-token', { emit: false }); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + ok: true, + data: { + purchase: { + purchaseId: 'gdpurchase_1', + gameId: 'game-1', + priceMudPoints: 120, + createdAt: '2026-10-05T00:00:00Z', + }, + walletBalance: 380, + replayed: false, + }, + error: null, + meta: { apiVersion: 'v1' }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await purchaseGame( + 'game-1', + { expectedPriceMudPoints: 120 }, + 'purchase-key-1', + ); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/game-distribution/games/game-1/purchase', + expect.objectContaining({ + method: 'POST', + cache: 'no-store', + headers: expect.objectContaining({ + 'Idempotency-Key': 'purchase-key-1', + }), + }), + ); + // 序列化名必须是服务端契约的 expectedPriceMudPoints,不能退化成客户端本地字段名。 + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + expectedPriceMudPoints: 120, + }); + expect(result.walletBalance).toBe(380); + expect(result.replayed).toBe(false); + expect(result.purchase.priceMudPoints).toBe(120); + }); + + it('播放会话请求路径固定,并把 playUrl / expiresAt 原样解析出来', async () => { + setStoredAccessToken('buyer-token', { emit: false }); + const playUrl = + '/api/game-distribution/play-sessions/3f2504e0-4f89-41d3-9a0c-0305e82c3301/'; + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + playUrl, + expiresAt: '2026-10-05T00:10:00Z', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const session = await createGamePlaySession('game-1'); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/api/game-distribution/games/game-1/play-session', + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + cache: 'no-store', + }); + expect(session.playUrl).toBe(playUrl); + expect(session.expiresAt).toBe('2026-10-05T00:10:00Z'); + }); + + it('购买与播放会话的本地标识缺失时失败关闭且不发出请求', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + purchaseGame(' ', { expectedPriceMudPoints: 1 }, 'key'), + ).rejects.toThrow('购买游戏缺少作品编号'); + await expect( + purchaseGame('game-1', { expectedPriceMudPoints: 1 }, ' '), + ).rejects.toThrow('购买游戏缺少幂等键'); + await expect(createGamePlaySession(' ')).rejects.toThrow( + '创建播放会话缺少作品编号', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [401, 'UNAUTHORIZED', '播放付费作品需要先登录'], + [403, 'GAME_PURCHASE_ADMIN_NOT_ALLOWED', '管理员无需购买作品'], + [409, 'CONFLICT', '价格已变化,请按最新价格重新确认'], + ])( + '购买失败把 HTTP %i / %s 映射成 ApiClientError', + async (status, code, message) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation( + () => + new Response( + JSON.stringify({ + ok: false, + error: { code, message }, + meta: { apiVersion: 'v1' }, + }), + { status, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + + const error = await purchaseGame( + 'game-1', + { expectedPriceMudPoints: 120 }, + 'purchase-key-1', + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ApiClientError); + expect((error as ApiClientError).status).toBe(status); + expect((error as ApiClientError).code).toBe(code); + }, + ); + + it('余额不足映射成 400 与 INSUFFICIENT_MUD_POINTS,供调用方引导充值', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation( + () => + new Response( + JSON.stringify({ + ok: false, + error: { + code: 'INSUFFICIENT_MUD_POINTS', + message: '泥点余额不足', + }, + meta: { apiVersion: 'v1' }, + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + + await expect( + purchaseGame('game-1', { expectedPriceMudPoints: 120 }, 'purchase-key-1'), + ).rejects.toMatchObject({ + name: 'ApiClientError', + status: 400, + code: 'INSUFFICIENT_MUD_POINTS', + }); + }); + + it('播放会话被拒绝时同样抛 ApiClientError,而不是普通 Error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation( + () => + new Response( + JSON.stringify({ + ok: false, + error: { code: 'FORBIDDEN', message: '尚未购买这款游戏' }, + meta: { apiVersion: 'v1' }, + }), + { status: 403, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + + await expect(createGamePlaySession('game-1')).rejects.toMatchObject({ + name: 'ApiClientError', + status: 403, + code: 'FORBIDDEN', + }); + }); +});