前端:修复买断制付费评审问题(flaky 用例、草稿恢复定价、契约口径、守卫与测试质量)
Project CI / Backend 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 / 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

- 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:修正预填价注释为「最近版本冻结价优先,可能仍待审/被拒」
This commit is contained in:
2026-10-06 02:18:21 +08:00
parent d18998b712
commit b4bf37671d
17 changed files with 518 additions and 80 deletions
+4 -4
View File
@@ -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;
}
@@ -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 },
@@ -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<string, unknown>,
) {
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`;
@@ -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),
@@ -104,8 +104,10 @@ pub(crate) struct GameDistributionPublicationDraft {
pub(crate) input_modes: Vec<GameDistributionInputMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) orientation: Option<GameDistributionOrientation>,
/// 线上作品当前生效的买断价(整数泥点,`0` 表示免费)。面板据此预填,作者不改就不会
/// 把已上线的付费作品静默改成免费;旧原生响应缺该字段时按免费兜底。
/// 更新面板预填的买断价(整数泥点,`0` 表示免费):取该作品**最近版本的冻结价**
/// (该版本可能仍处于 `pending_review` / `rejected`,因此预填值未必已生效);该版本
/// 冻结价缺失或为 `0` 时回落游戏行当前生效价,两条来源都取不到才按 `0`(免费),
/// 避免把已上线的付费作品预填成免费。旧原生响应缺该字段时按免费兜底。
#[serde(default)]
pub(crate) price_mud_points: u64,
}
@@ -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,
@@ -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,
@@ -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 (
<PlatformGamePricingField
mode={mode}
@@ -61,17 +65,23 @@ test('切到买断制后显示价格输入、上限提示,并回传输入值',
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
});
test('禁用态锁住单选与价格输入', () => {
render(<PricingHarness disabled />);
test('禁用态锁住单选与买断价输入', () => {
// 免费模式下价格输入根本不渲染,只断言两个 radio 等于没测到输入框;
// 必须用「买断制 + 禁用」才能覆盖价格输入被锁住的路径。
render(
<PricingHarness disabled initialMode="paid" initialPriceInput="120" />,
);
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(
<PricingHarness
initialMode="paid"
initialPriceInput="0"
error="买断价必须是整数泥点"
/>,
);
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,
@@ -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 (
<fieldset className="platform-game-pricing-field" disabled={disabled}>
<legend>付费方式</legend>
@@ -63,10 +68,13 @@ export function PlatformGamePricingField({
<label className="platform-game-pricing-field__price">
买断价(泥点)
<input
id={priceInputId}
type="number"
inputMode="numeric"
value={priceInput}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onChange={(event) => onPriceInputChange(event.target.value)}
/>
<span className="platform-game-pricing-field__hint">
@@ -76,7 +84,11 @@ export function PlatformGamePricingField({
</label>
) : null}
{error ? (
<span className="platform-game-pricing-field__error" role="alert">
<span
id={errorId}
className="platform-game-pricing-field__error"
role="alert"
>
{error}
</span>
) : null}
@@ -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;
};
@@ -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
}
>
<Gamepad2 /> 立即玩
@@ -601,6 +609,15 @@ function GameDetailContent({
请在电脑上游玩
</span>
) : null}
{isEntryMissing && !mobilePlaybackBlocked ? (
<span
id="game-entry-missing-warning"
className="game-device-warning"
role="status"
>
暂时拿不到可玩入口,请刷新页面后重试。
</span>
) : null}
{ownerView && !publishedVersion ? (
<span className="game-device-warning" role="status">
作品尚未公开,暂时没有在线可玩版本
@@ -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(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
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 作者视角', () => {
@@ -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({
@@ -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({
<PlatformStatusMessage tone="info" surface="platform">
<span>
继续发布沿用上次冻结的资料与版本号,资料要改就撤回后在「我的游戏」重新发布。
{resumeFrozenPriceMudPoints > 0
? `本次将沿用版本冻结价 ${resumeFrozenPriceMudPoints} 泥点。`
: '本次将沿用版本冻结价,作品为免费。'}
</span>
</PlatformStatusMessage>
) : null}
@@ -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',
@@ -6,9 +6,12 @@ const GAME_ENTRY_PATH_PATTERN = /^\/games\/(game_[0-9a-f]{32})\/?$/;
/**
* 付费游戏的播放会话入口由服务端签发,形状为
* `/api/game-distribution/play-sessions/<token>/`;包内资源经同一前缀相对读取。
*
* 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);
+199 -1
View File
@@ -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<string, string>();
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',
});
});
});