前端:修复买断制付费评审问题(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
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:
@@ -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,
|
||||
}
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user