网页端接入游戏买断制泥点付费
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- packages/shared 游戏契约补买断价与购买态:GameDistributionGame 增 priceMudPoints/purchased,版本摘要 entryUrl 可为 null
- packages/shared 版本请求增 priceMudPoints,私有版本回读增同名字段;新增购买请求、购买记录、购买响应与播放会话 DTO
- packages/shared 钱包流水来源类型增 game_purchase,网页与共享账单文案增「购买游戏」标签
- 游戏分发客户端新增 purchaseGame(Idempotency-Key + 期望价格)与 createGamePlaySession
- 发布页新增「付费方式」免费/买断制表单,定价校验与服务端 0..=1000000 口径一致,价格随版本请求提交
- 详情页展示定价、付费未购买时主动作改为「N 泥点购买」,复用泥点确认弹窗展示余额,成功后刷新钱包并回到可玩状态,余额不足给出充值入口
- 游玩页付费作品先签发播放会话再挂载 iframe,免费作品链路不变;会话被拒时不挂载 iframe 也不上报游玩
- 发行入口守卫接受播放会话前缀路径,拒绝其它非发行路径
- 定向契约测试登记 4 组购买/播放会话 DTO,补发布定价、未购买不可玩、购买确认与播放会话挂载用例
This commit is contained in:
2026-10-05 16:59:45 +08:00
parent d27e204f21
commit 7b38ef9623
15 changed files with 972 additions and 57 deletions
@@ -20,6 +20,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
redeem_code_reward: '兑换码奖励',
puzzle_author_incentive_claim: '拼图作者奖励',
daily_task_reward: '每日任务奖励',
game_purchase: '购买游戏',
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
export type ProfileWalletLedgerEntryPresentation = {
@@ -104,8 +104,10 @@ export type GameDistributionVersionSummary = {
/**
* 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
* 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。
*
* 买断制作品在未购买时不下发公开入口,这一项为 `null`;游玩入口改由播放会话接口签发。
*/
entryUrl: string;
entryUrl: string | null;
sha256: string;
publishedAt: string;
controls: string[];
@@ -141,6 +143,10 @@ export type GameDistributionGame = {
currentVersion?: GameDistributionVersionSummary | null;
/** 公开列表和详情返回有效评价摘要;作者侧或旧响应可省略。 */
ratingSummary?: GameDistributionRatingSummary;
/** 买断价(整数泥点,`0` 表示免费)。旧响应可省略,读取方按 `0`(免费)兜底。 */
priceMudPoints: number;
/** 当前查看者是否已拥有;匿名与未登录恒为 `false`。 */
purchased: boolean;
playCount: number;
createdAt: string;
};
@@ -176,6 +182,13 @@ export type GameDistributionCreateVersionRequest = {
* 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。
*/
versionNumber?: number | null;
/**
* 作者提交的买断价(整数泥点,`0` 表示免费)。
*
* 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。
* 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。
*/
priceMudPoints?: number;
packageSha256: string;
packageBytes: number;
packageFileCount: number;
@@ -214,6 +227,8 @@ export type GameDistributionPrivateVersion = {
status: GameDistributionVersionStatus;
/** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */
publicationRevision: number;
/** 该版本冻结的买断价(整数泥点,`0` 表示免费);无价格的历史版本按免费口径处理。 */
priceMudPoints?: number;
reviewReason?: string | null;
/** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */
entryUrl: string | null;
@@ -276,3 +291,36 @@ export type GameDistributionCancelVersionResponse = {
version: GameDistributionPrivateVersion;
replayed: boolean;
};
/**
* 买断制购买请求体。
*
* 客户端带上自己看到的价格:与服务端当前价格不一致时返回 409,不会按旧价扣费。
*/
export type GameDistributionPurchaseRequest = {
expectedPriceMudPoints: number;
};
/** 购买记录快照:每账号每作品最多一条,成交价随购买冻结,之后调价不影响既有所有权。 */
export type GameDistributionPurchase = {
purchaseId: string;
gameId: string;
priceMudPoints: number;
createdAt: string;
};
/** 购买响应:`walletBalance` 是扣费后的泥点余额,`replayed` 表示服务端按同一幂等键重放。 */
export type GameDistributionPurchaseResponse = {
purchase: GameDistributionPurchase;
walletBalance: number;
replayed: boolean;
};
/**
* 播放会话响应:`playUrl` 是绑定「`gameId` + 用户 + 短时效」的发行入口。
* 结算入口地址后交给 iframe,会话过期或作品下架后不可继续播放。
*/
export type GameDistributionPlaySessionResponse = {
playUrl: string;
expiresAt: string;
};
+2 -1
View File
@@ -82,7 +82,8 @@ export type ProfileWalletLedgerEntry = {
| 'recharge_refund_recovery'
| 'redeem_code_reward'
| 'puzzle_author_incentive_claim'
| 'daily_task_reward';
| 'daily_task_reward'
| 'game_purchase';
createdAt: string;
reason?: string;
};
@@ -54,6 +54,10 @@ const PAIRS = [
'GameDistributionUpdateGameMetadataRequest',
],
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
['GameDistributionPurchaseRequest', 'GameDistributionPurchaseRequest'],
['GameDistributionPurchase', 'GameDistributionPurchase'],
['GameDistributionPurchaseResponse', 'GameDistributionPurchaseResponse'],
['GameDistributionPlaySessionResponse', 'GameDistributionPlaySessionResponse'],
['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'],
['AdminGameReviewGame', 'AdminGameReviewGame'],
['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'],
@@ -12,6 +12,7 @@ import type {
GameDistributionVersionSummary,
} from '../../../packages/shared/src/contracts/gameDistribution';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { ApiClientError } from '../../services/apiClient';
import {
type GameDistributionGame,
type GameDistributionInputMode,
@@ -20,9 +21,12 @@ import {
getGame,
getMyGame,
loadVersionRecoveryActions,
purchaseGame,
} from '../../services/gameDistributionClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
normalizeGameEntryUrl,
@@ -45,6 +49,11 @@ type GameDetailPageProps = {
gameId: string | null;
onBack: () => void;
onPlay: (gameId: string) => void;
/**
* 打开平台充值面板;余额不足以购买付费作品时由宿主提供入口。
* 宿主没有充值能力时(例如独立嵌入)传入空值,页面只保留余额提示。
*/
onOpenRecharge?: () => void;
/**
* 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。
*
@@ -55,6 +64,24 @@ type GameDetailPageProps = {
ownerView?: boolean;
};
/** 购买后的页面级反馈:成功、余额不足(带充值入口)与失败共用一套展示。 */
type GamePurchaseNotice = {
tone: 'info' | 'error';
message: string;
canRecharge: boolean;
};
let purchaseKeySeed = 0;
/** 一次购买的幂等键:双击、响应丢失重试复用同一个键,服务端据此只扣一次。 */
function createPurchaseKey() {
purchaseKeySeed += 1;
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${purchaseKeySeed}`;
return `web-game-purchase-${random}`;
}
/**
* 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`),
* 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。
@@ -105,6 +132,7 @@ export function GameDetailPage({
gameId,
onBack,
onPlay,
onOpenRecharge,
ownerView = false,
}: GameDetailPageProps) {
const authUi = useAuthUi();
@@ -119,8 +147,34 @@ export function GameDetailPage({
>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [isPurchaseDialogOpen, setIsPurchaseDialogOpen] = useState(false);
const [isPurchasing, setIsPurchasing] = useState(false);
const [purchaseNotice, setPurchaseNotice] =
useState<GamePurchaseNotice | null>(null);
const requestSerialRef = useRef(0);
const purchaseInFlightRef = useRef(false);
const purchaseKeyRef = useRef<string | null>(null);
const isMobileViewport = useIsMobileViewport();
const mudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const legacyWalletBalance = usePlatformWalletStore(
(state) => state.legacyWalletBalance,
);
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
const walletBalance =
mudPointBalance?.totalPoints ?? legacyWalletBalance ?? null;
const balanceOwnerMatchesCurrentUser = usePlatformWalletStore(
(state) => state.ownerUserId === (authUi?.user?.id ?? null),
);
// 换账号或换作品时立刻丢弃上一份购买反馈与在途幂等键,避免把结论带到别的作品上。
useEffect(() => {
setPurchaseNotice(null);
setIsPurchaseDialogOpen(false);
purchaseKeyRef.current = null;
}, [gameId, ownerView, authUi?.user?.id]);
const loadPublicGame = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
@@ -136,20 +190,34 @@ export function GameDetailPage({
void getGame(normalizedGameId)
.then((value) => {
if (requestSerial !== requestSerialRef.current) return;
if (value?.currentVersion) {
const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl);
if (!entryUrl) {
setError('游戏入口地址无效');
return;
}
if (!value) {
setPublicGame(null);
setError('找不到这款游戏');
return;
}
const currentVersion = value.currentVersion ?? null;
if (!currentVersion) {
setPublicGame(value);
return;
}
const rawEntryUrl = currentVersion.entryUrl;
// 买断制作品在未购买时服务端不下发公开入口:保留空入口,主行动作改由购买接手。
if (!rawEntryUrl) {
setPublicGame({
...value,
currentVersion: { ...value.currentVersion, entryUrl },
currentVersion: { ...currentVersion, entryUrl: null },
});
return;
}
setPublicGame(value);
if (!value) setError('找不到这款游戏');
const entryUrl = normalizeGameEntryUrl(rawEntryUrl);
if (!entryUrl) {
setError('游戏入口地址无效');
return;
}
setPublicGame({
...value,
currentVersion: { ...currentVersion, entryUrl },
});
})
.catch((loadError: unknown) => {
if (requestSerial !== requestSerialRef.current) return;
@@ -221,6 +289,80 @@ export function GameDetailPage({
const resolvedGame = ownerView ? ownerGame : publicGame;
const reload = ownerView ? loadOwnerGame : loadPublicGame;
/**
* 打开购买确认。未登录先走平台登录;幂等键在打开弹窗时生成,
* 同一次购买(含失败后重试)复用同一个键,避免重复扣费。
*/
function requestPurchase() {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
purchaseKeyRef.current = createPurchaseKey();
setPurchaseNotice(null);
setIsPurchaseDialogOpen(true);
}
async function handleConfirmPurchase() {
if (!resolvedGame || purchaseInFlightRef.current) return;
const priceMudPoints = resolvedGame.priceMudPoints ?? 0;
if (priceMudPoints <= 0) return;
purchaseInFlightRef.current = true;
setIsPurchasing(true);
try {
if (!purchaseKeyRef.current) purchaseKeyRef.current = createPurchaseKey();
await purchaseGame(
resolvedGame.id,
{ expectedPriceMudPoints: priceMudPoints },
purchaseKeyRef.current,
);
purchaseKeyRef.current = null;
setIsPurchaseDialogOpen(false);
// 扣费成功先刷新平台钱包 store,再回读详情拿 `purchased` 与可播放入口。
await onWalletBalanceMayHaveChanged();
setPurchaseNotice({
tone: 'info',
message: '购买成功,现在可以直接开始游玩。',
canRecharge: false,
});
reload();
} catch (purchaseError: unknown) {
purchaseKeyRef.current = null;
setIsPurchaseDialogOpen(false);
const insufficient =
purchaseError instanceof ApiClientError &&
purchaseError.code === 'INSUFFICIENT_MUD_POINTS';
const priceChanged =
purchaseError instanceof ApiClientError && purchaseError.status === 409;
if (insufficient) {
setPurchaseNotice({
tone: 'error',
message: '泥点不足,充值后即可购买这款游戏。',
canRecharge: true,
});
} else if (priceChanged) {
setPurchaseNotice({
tone: 'error',
message: '价格已更新,已为你刷新最新价格,请重新确认购买。',
canRecharge: false,
});
reload();
} else {
setPurchaseNotice({
tone: 'error',
message:
purchaseError instanceof Error && purchaseError.message.trim()
? purchaseError.message
: '购买失败,请稍后重试。',
canRecharge: false,
});
}
} finally {
purchaseInFlightRef.current = false;
setIsPurchasing(false);
}
}
if (
isLoading ||
(resolvedGame && resolvedGame.id !== normalizeGameId(gameId))
@@ -250,19 +392,45 @@ export function GameDetailPage({
);
const canReview = Boolean(authUi?.user && authUi.canAccessProtectedData);
const purchasePriceMudPoints = resolvedGame.priceMudPoints ?? 0;
return (
<GameDetailContent
key={`${resolvedGame.id}:${authUi?.user?.id ?? 'guest'}:${canReview}`}
game={resolvedGame}
ownerView={ownerView}
ownerGame={ownerGame}
recoveryActions={recoveryActions}
isMobileViewport={isMobileViewport}
canReview={canReview}
onLogin={() => authUi?.openLoginModal()}
onBack={onBack}
onPlay={onPlay}
/>
<>
<GameDetailContent
key={`${resolvedGame.id}:${authUi?.user?.id ?? 'guest'}:${canReview}`}
game={resolvedGame}
ownerView={ownerView}
ownerGame={ownerGame}
recoveryActions={recoveryActions}
isMobileViewport={isMobileViewport}
canReview={canReview}
isPurchasing={isPurchasing}
purchaseNotice={purchaseNotice}
onOpenPurchase={requestPurchase}
onOpenRecharge={onOpenRecharge}
onLogin={() => authUi?.openLoginModal()}
onBack={onBack}
onPlay={onPlay}
/>
<PlatformMudPointConfirmDialog
open={isPurchaseDialogOpen}
points={purchasePriceMudPoints}
confirmDisabled={isPurchasing}
confirmLabel={isPurchasing ? '购买中…' : '确认购买'}
description={`买断《${resolvedGame.title}》后永久拥有,可反复游玩。`}
onClose={() => {
if (isPurchasing) return;
setIsPurchaseDialogOpen(false);
}}
onConfirm={() => void handleConfirmPurchase()}
>
<p>
当前泥点余额:
{balanceOwnerMatchesCurrentUser && walletBalance !== null
? `${walletBalance} 泥点`
: '读取中…'}
</p>
</PlatformMudPointConfirmDialog>
</>
);
}
@@ -273,6 +441,10 @@ function GameDetailContent({
recoveryActions,
isMobileViewport,
canReview,
isPurchasing,
purchaseNotice,
onOpenPurchase,
onOpenRecharge,
onLogin,
onBack,
onPlay,
@@ -283,6 +455,10 @@ function GameDetailContent({
recoveryActions: Record<string, GameDistributionRecoveryAction>;
isMobileViewport: boolean;
canReview: boolean;
isPurchasing: boolean;
purchaseNotice: GamePurchaseNotice | null;
onOpenPurchase: () => void;
onOpenRecharge?: () => void;
onLogin: () => void;
onBack: () => void;
onPlay: (gameId: string) => void;
@@ -317,7 +493,30 @@ function GameDetailContent({
: resolveNonPublicCoverLabel(game.status, latestVersion?.status);
const latestReviewReason = latestVersion?.reviewReason?.trim() ?? '';
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
const playbackBlocked = !version || mobilePlaybackBlocked;
/**
* 买断价与购买态:缺字段按免费/未购买兜底,付费且未购买时后端不下发可播放入口,
* 前端也不再展示「立即玩」,改由购买动作接手。
*/
const priceMudPoints = game.priceMudPoints ?? 0;
const isPaidLocked =
!ownerView && priceMudPoints > 0 && game.purchased !== true;
const hasPlayableEntry = Boolean(version?.entryUrl);
/**
* 公开视角按「后端是否下发入口」判定可否开玩:付费未购买时入口为空。
* 作者视角的版本条目不带公开入口,沿用「有已公开版本即可开玩」的既有口径。
*/
const playbackBlocked = ownerView
? !version || mobilePlaybackBlocked
: !hasPlayableEntry || mobilePlaybackBlocked;
const priceInfoCards =
typeof game.priceMudPoints === 'number'
? [
{
label: '定价',
value: game.priceMudPoints > 0 ? `${game.priceMudPoints} 泥点` : '免费',
},
]
: [];
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
@@ -340,23 +539,35 @@ function GameDetailContent({
}
controlsLabel={resolveControlsLabel(version, game.inputModes)}
versionLabel={versionLabel}
infoCards={priceInfoCards}
rating={publiclyVisible ? <GameRatingSummary state={reviews} /> : null}
actions={
<>
<PlatformActionButton
size="lg"
shape="pill"
onClick={() => onPlay(game.id)}
disabled={playbackBlocked}
aria-describedby={
mobilePlaybackBlocked
? 'game-mobile-playback-warning'
: undefined
}
>
<Gamepad2 /> 立即玩
</PlatformActionButton>
{mobilePlaybackBlocked ? (
{isPaidLocked ? (
<PlatformActionButton
size="lg"
shape="pill"
onClick={onOpenPurchase}
disabled={isPurchasing}
>
<Gamepad2 /> {priceMudPoints} 泥点购买
</PlatformActionButton>
) : (
<PlatformActionButton
size="lg"
shape="pill"
onClick={() => onPlay(game.id)}
disabled={playbackBlocked}
aria-describedby={
mobilePlaybackBlocked
? 'game-mobile-playback-warning'
: undefined
}
>
<Gamepad2 /> 立即玩
</PlatformActionButton>
)}
{!isPaidLocked && mobilePlaybackBlocked ? (
<span
id="game-mobile-playback-warning"
className="game-device-warning"
@@ -373,6 +584,20 @@ function GameDetailContent({
</>
}
>
{purchaseNotice ? (
<PlatformStatusMessage
tone={purchaseNotice.tone}
surface="platform"
className="game-status-message"
>
<span>{purchaseNotice.message}</span>
{purchaseNotice.canRecharge && onOpenRecharge ? (
<button type="button" onClick={onOpenRecharge}>
去充值
</button>
) : null}
</PlatformStatusMessage>
) : null}
{ownerView && ownerGame ? (
<section className="game-owner-panel" aria-label="作品状态">
<div className="game-owner-panel__heading">
@@ -12,6 +12,7 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution';
import { ApiClientError } from '../../services/apiClient';
import type {
GameDistributionGame,
GameDistributionMyGame,
@@ -30,6 +31,8 @@ const recordGamePlayMock = vi.hoisted(() => vi.fn());
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
const listGameReviewsMock = vi.hoisted(() => vi.fn());
const getMyGameReviewMock = vi.hoisted(() => vi.fn());
const purchaseGameMock = vi.hoisted(() => vi.fn());
const createGamePlaySessionMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/gameDistributionClient', () => ({
listGames: listGamesMock,
@@ -39,6 +42,21 @@ vi.mock('../../services/gameDistributionClient', () => ({
recordGamePlay: recordGamePlayMock,
listGameReviews: listGameReviewsMock,
getMyGameReview: getMyGameReviewMock,
purchaseGame: purchaseGameMock,
createGamePlaySession: createGamePlaySessionMock,
}));
// 详情页购买后要刷新平台钱包;这里只提供选择器需要的状态与刷新钩子,避免真实取余额请求。
const walletMocks = vi.hoisted(() => ({
ownerUserId: 'author-1',
mudPointBalance: { totalPoints: 500, permanentPoints: 500, limitedPoints: 0 },
legacyWalletBalance: 500,
onWalletBalanceMayHaveChanged: vi.fn(async () => {}),
}));
vi.mock('../../stores/usePlatformWalletStore', () => ({
usePlatformWalletStore: <T,>(selector: (state: typeof walletMocks) => T) =>
selector(walletMocks),
}));
// 作者视角详情页要按登录身份渲染评价区,这里固定一个可访问受保护数据的用户。
@@ -101,6 +119,8 @@ function createGame(
},
playCount: 12,
createdAt: '2026-09-18T00:00:00.000Z',
priceMudPoints: 0,
purchased: true,
...overrides,
};
}
@@ -143,6 +163,9 @@ beforeEach(() => {
});
getMyGameReviewMock.mockResolvedValue({ review: null });
recordGamePlayMock.mockResolvedValue(undefined);
purchaseGameMock.mockReset();
createGamePlaySessionMock.mockReset();
walletMocks.onWalletBalanceMayHaveChanged.mockClear();
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
objectKey ? `https://assets.example.test/${objectKey}` : '',
);
@@ -246,6 +269,120 @@ describe('GameDetailPage', () => {
expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy();
});
function createLockedPaidGame(): GameDistributionGame {
return createGame({
priceMudPoints: 120,
purchased: false,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
});
}
it('付费未购买时展示价格与购买主动作,不再给出游玩入口', async () => {
getGameMock.mockResolvedValueOnce(createLockedPaidGame());
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const buyButton = await screen.findByRole('button', {
name: /120 泥点购买/u,
});
expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull();
// 定价资料卡展示价格;入口为空是服务端口径,不是「入口地址无效」。
expect(screen.getByText('120 泥点')).toBeTruthy();
expect(screen.queryByText('游戏入口地址无效')).toBeNull();
expect((buyButton as HTMLButtonElement).disabled).toBe(false);
});
it('购买走二次确认并展示余额,成功后刷新钱包并回到可玩状态', async () => {
getGameMock
.mockResolvedValueOnce(createLockedPaidGame())
.mockResolvedValueOnce(
createGame({ priceMudPoints: 120, purchased: true }),
);
purchaseGameMock.mockResolvedValue({
purchase: {
purchaseId: 'gdpurchase_1',
gameId: 'game-1',
priceMudPoints: 120,
createdAt: '2026-10-05T00:00:00Z',
},
walletBalance: 380,
replayed: false,
});
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(
await screen.findByRole('button', { name: /120 泥点购买/u }),
);
const dialog = await screen.findByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(dialog).getByText('消耗 120 泥点')).toBeTruthy();
expect(within(dialog).getByText('当前泥点余额:500 泥点')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '确认购买' }));
await waitFor(() => expect(purchaseGameMock).toHaveBeenCalledTimes(1));
expect(purchaseGameMock.mock.calls[0]?.[0]).toBe('game-1');
expect(purchaseGameMock.mock.calls[0]?.[1]).toEqual({
expectedPriceMudPoints: 120,
});
// 幂等键必须非空,双击与重试才会被服务端按同一键重放。
expect(String(purchaseGameMock.mock.calls[0]?.[2] ?? '')).not.toBe('');
await waitFor(() =>
expect(walletMocks.onWalletBalanceMayHaveChanged).toHaveBeenCalled(),
);
expect(
await screen.findByText('购买成功,现在可以直接开始游玩。'),
).toBeTruthy();
expect(
await screen.findByRole('button', { name: /立即玩/u }),
).toBeTruthy();
});
it('余额不足时失败关闭并给出充值入口', async () => {
const onOpenRecharge = vi.fn();
getGameMock.mockResolvedValue(createLockedPaidGame());
purchaseGameMock.mockRejectedValueOnce(
new ApiClientError({
message: '泥点余额不足',
status: 400,
code: 'INSUFFICIENT_MUD_POINTS',
}),
);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenRecharge={onOpenRecharge}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: /120 泥点购买/u }),
);
fireEvent.click(
await screen.findByRole('button', { name: '确认购买' }),
);
expect(
await screen.findByText('泥点不足,充值后即可购买这款游戏。'),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '去充值' }));
expect(onOpenRecharge).toHaveBeenCalledTimes(1);
// 失败关闭:既没有游玩入口,也没有伪造成功。
expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull();
expect(
screen.queryByText('购买成功,现在可以直接开始游玩。'),
).toBeNull();
expect(
screen.getByRole('button', { name: /120 泥点购买/u }),
).toBeTruthy();
});
});
describe('GameDetailPage 作者视角', () => {
@@ -478,6 +615,70 @@ describe('GamePlayPage', () => {
expect(await screen.findByText('游戏入口地址无效')).toBeTruthy();
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
});
it('免费游戏不请求播放会话', async () => {
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
await screen.findByTitle('星轨防线 在线游玩');
expect(createGamePlaySessionMock).not.toHaveBeenCalled();
});
it('付费游戏先签发播放会话再挂载 iframe 并计数', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
priceMudPoints: 120,
purchased: true,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
}),
);
createGamePlaySessionMock.mockResolvedValue({
playUrl: '/api/game-distribution/play-sessions/token-1/',
expiresAt: '2026-10-05T00:05:00Z',
});
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
const iframe = await screen.findByTitle('星轨防线 在线游玩');
expect(iframe.getAttribute('src')).toBe(
new URL(
'/api/game-distribution/play-sessions/token-1/',
window.location.origin,
).href,
);
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
expect(createGamePlaySessionMock).toHaveBeenCalledWith('game-1');
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
it('未购买时播放会话被拒,不挂载 iframe 也不上报游玩', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
priceMudPoints: 120,
purchased: false,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
}),
);
createGamePlaySessionMock.mockRejectedValueOnce(
new ApiClientError({
message: '尚未购买这款游戏',
status: 403,
code: 'FORBIDDEN',
}),
);
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
expect(
await screen.findByText(
'这款游戏需要先购买才能游玩,请返回详情页完成购买。',
),
).toBeTruthy();
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
expect(recordGamePlayMock).not.toHaveBeenCalled();
});
});
describe('GameGalleryPage 筛选与返回上下文', () => {
@@ -9,8 +9,10 @@ import {
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiClientError } from '../../services/apiClient';
import {
type GameDistributionGame,
createGamePlaySession,
getGame,
recordGamePlay,
} from '../../services/gameDistributionClient';
@@ -29,7 +31,7 @@ type GamePlayPageProps = { gameId: string | null; onBack: () => void };
/** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */
export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000;
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout' | 'failed';
export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
const frameRef = useRef<HTMLIFrameElement>(null);
@@ -38,6 +40,10 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
const [startupState, setStartupState] = useState<GameStartupState>('idle');
const [hasStarted, setHasStarted] = useState(false);
const [frameRunId, setFrameRunId] = useState(0);
/** 实际交给 iframe 的发行入口:免费游戏来自详情,付费游戏来自播放会话。 */
const [playUrl, setPlayUrl] = useState<string | null>(null);
const [isPreparingPlayback, setIsPreparingPlayback] = useState(false);
const [playError, setPlayError] = useState('');
const [error, setError] = useState('');
const requestSerialRef = useRef(0);
const isMobileViewport = useIsMobileViewport();
@@ -49,6 +55,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
setGame(null);
setHasStarted(false);
setStartupState('idle');
setPlayUrl(null);
setPlayError('');
setError('');
if (!normalizedGameId) {
setError('缺少游戏编号');
@@ -59,14 +67,25 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
void getGame(normalizedGameId)
.then((value) => {
if (requestSerial !== requestSerialRef.current) return;
if (!value?.currentVersion?.entryUrl) {
const currentVersion = value?.currentVersion;
if (!value || !currentVersion) {
throw new Error('这款游戏暂时没有可玩的版本');
}
const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl);
const priceMudPoints = value.priceMudPoints ?? 0;
const rawEntryUrl = currentVersion.entryUrl;
if (!rawEntryUrl) {
// 付费作品的入口由播放会话签发;免费游戏没有入口说明服务端确实没有可玩版本。
if (priceMudPoints <= 0) {
throw new Error('这款游戏暂时没有可玩的版本');
}
setGame({ ...value, currentVersion: { ...currentVersion, entryUrl: null } });
return;
}
const entryUrl = normalizeGameEntryUrl(rawEntryUrl);
if (!entryUrl) throw new Error('游戏入口地址无效');
setGame({
...value,
currentVersion: { ...value.currentVersion, entryUrl },
currentVersion: { ...currentVersion, entryUrl },
});
})
.catch((loadError: unknown) => {
@@ -104,9 +123,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
const mobilePlaybackBlocked = isMobileViewport && !game?.deviceSupport.mobile;
const showRotationHint =
Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport;
const startGame = () => {
if (mobilePlaybackBlocked) return;
// 计数是 fire-and-forget:点击即视为一次游玩,上报失败也不阻断进入游戏。
/** 进入游戏:计数是 fire-and-forget,失败也不阻断;付费作品在会话签发成功后才计数。 */
const startFrame = () => {
const playGameId = game?.id ?? normalizeGameId(gameId);
if (playGameId) {
void recordGamePlay(playGameId).catch(() => {});
@@ -116,8 +134,47 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
setFrameRunId((runId) => runId + 1);
};
/** 重试只重挂载发行页,不改变已开始的会话,也不会在退出后自动重启。 */
const startGame = () => {
if (mobilePlaybackBlocked || !game || isPreparingPlayback) return;
setPlayError('');
const priceMudPoints = game.priceMudPoints ?? 0;
if (priceMudPoints <= 0) {
// 免费游戏维持原来的公开入口链路,不经过播放会话。
setPlayUrl(game.currentVersion?.entryUrl ?? null);
startFrame();
return;
}
setIsPreparingPlayback(true);
void createGamePlaySession(game.id)
.then((session) => {
const sessionUrl = normalizeGameEntryUrl(session.playUrl);
if (!sessionUrl) throw new Error('播放地址无效');
setPlayUrl(sessionUrl);
startFrame();
})
.catch((sessionError: unknown) => {
setHasStarted(true);
setStartupState('failed');
if (sessionError instanceof ApiClientError && sessionError.status === 403) {
setPlayError('这款游戏需要先购买才能游玩,请返回详情页完成购买。');
return;
}
setPlayError(
sessionError instanceof Error && sessionError.message.trim()
? sessionError.message
: '创建播放会话失败,请稍后重试。',
);
})
.finally(() => setIsPreparingPlayback(false));
};
/** 重试:付费作品在会话缺失时重新签发,其余情况只重挂载发行页。 */
const retryGame = () => {
setPlayError('');
if ((game?.priceMudPoints ?? 0) > 0 && !playUrl) {
startGame();
return;
}
setStartupState('loading');
setFrameRunId((runId) => runId + 1);
};
@@ -182,7 +239,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</p>
) : null}
<div className="game-player-shell">
{!hasStarted ? (
{!hasStarted && !isPreparingPlayback ? (
<div className="game-player-launch">
<GameCover game={game} compact />
<div className="game-player-launch__copy">
@@ -211,8 +268,26 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</div>
</div>
) : null}
{hasStarted && startupState === 'loading' ? (
<div className="game-player-loading">游戏正在启动…</div>
{isPreparingPlayback ||
(hasStarted && startupState === 'loading' && !playError) ? (
<div className="game-player-loading">
{isPreparingPlayback ? '正在准备游戏…' : '游戏正在启动…'}
</div>
) : null}
{playError ? (
<PlatformStatusMessage
tone="error"
surface="platform"
className="game-status-message game-player-timeout"
>
<span>{playError}</span>
<button type="button" onClick={retryGame}>
重试
</button>
<button type="button" onClick={onBack}>
返回
</button>
</PlatformStatusMessage>
) : null}
{hasStarted && startupState === 'timeout' ? (
<PlatformStatusMessage
@@ -229,12 +304,12 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</button>
</PlatformStatusMessage>
) : null}
{hasStarted ? (
{hasStarted && playUrl ? (
<iframe
ref={frameRef}
key={`${game.currentVersion.id}:${frameRunId}`}
key={`${game.currentVersion?.id ?? game.id}:${frameRunId}`}
title={`${game.title} 在线游玩`}
src={game.currentVersion.entryUrl}
src={playUrl}
className={`game-player-frame ${startupState === 'ready' ? 'game-player-frame--ready' : ''}`}
sandbox="allow-scripts"
allow="fullscreen"
@@ -18,7 +18,10 @@ import {
resolveGamePublishImagePreview,
uploadGamePublishImageAsset,
} from './gamePublishAssets';
import { resolvePublishMetadataError } from './gamePublishMetadata';
import {
resolveGamePublishPriceMudPoints,
resolvePublishMetadataError,
} from './gamePublishMetadata';
import { GamePublishPage } from './GamePublishPage';
const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn());
@@ -229,6 +232,160 @@ 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: '',
});
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 之间的整数泥点',
});
}
// 元数据校验是同一口径的服务端对齐兜底。
const desktop = { desktop: true, mobile: false, touch: false };
expect(
resolvePublishMetadataError({
title: '游戏',
summary: '简介',
deviceSupport: desktop,
coverAssetId: 'asset-cover',
priceMudPoints: 1000001,
}),
).toBe('买断价必须是 0 到 1000000 之间的整数泥点');
expect(
resolvePublishMetadataError({
title: '游戏',
summary: '简介',
deviceSupport: desktop,
coverAssetId: 'asset-cover',
priceMudPoints: 0,
}),
).toBe('');
});
test('买断制发布把价格随版本请求提交,免费模式提交 0', async () => {
vi.mocked(createGame).mockResolvedValue({
id: 'game-1',
publicationRevision: 0,
} as never);
vi.mocked(createGameVersion).mockResolvedValue({
gameId: 'game-1',
versionId: 'gamever-1',
versionNumber: 1,
status: 'awaiting_upload',
});
vi.mocked(uploadGamePackage).mockResolvedValue({
versionId: 'gamever-1',
status: 'uploaded',
});
vi.mocked(submitGameVersion).mockResolvedValue({
version: { status: 'pending_review' },
});
await renderPage();
const file = await buildZipFile();
fireEvent.change(screen.getByLabelText('游戏名称'), {
target: { value: '付费游戏' },
});
fireEvent.change(screen.getByLabelText('一句话简介'), {
target: { value: '测试简介' },
});
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
target: { files: [file] },
});
await selectCover();
// 买断制下非法价格在本地先拦截,不发任何写请求。
fireEvent.click(screen.getByLabelText('买断制'));
fireEvent.change(screen.getByLabelText(/买断价/u), {
target: { value: '0' },
});
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
expect(
await screen.findByText('买断价必须是 1 到 1000000 之间的整数泥点'),
).toBeTruthy();
expect(createGame).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText(/买断价/u), {
target: { value: '120' },
});
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ priceMudPoints: 120 }),
);
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
gameMetadata: expect.objectContaining({ title: '付费游戏' }),
}),
);
// 价格属于版本冻结资料,不进创建游戏请求的元数据。
expect(vi.mocked(createGame).mock.calls[0]?.[0]).not.toHaveProperty(
'priceMudPoints',
);
});
test('免费模式提交 0 泥点并保持免费链路', async () => {
vi.mocked(createGame).mockResolvedValue({
id: 'game-1',
publicationRevision: 0,
} as never);
vi.mocked(createGameVersion).mockResolvedValue({
gameId: 'game-1',
versionId: 'gamever-1',
versionNumber: 1,
status: 'awaiting_upload',
});
vi.mocked(uploadGamePackage).mockResolvedValue({
versionId: 'gamever-1',
status: 'uploaded',
});
vi.mocked(submitGameVersion).mockResolvedValue({
version: { status: 'pending_review' },
});
await renderPage();
const file = await buildZipFile();
fireEvent.change(screen.getByLabelText('游戏名称'), {
target: { value: '免费游戏' },
});
fireEvent.change(screen.getByLabelText('一句话简介'), {
target: { value: '测试简介' },
});
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
target: { files: [file] },
});
await selectCover();
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({ priceMudPoints: 0 }),
);
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
});
test('未登录时不提交且提示登录', async () => {
await renderPage(null);
expect(screen.getByText('登录后才能发布游戏。')).toBeTruthy();
@@ -484,6 +641,7 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
status: 'published',
publicationRevision: 4,
recoveryAction: 'none',
priceMudPoints: 240,
frozenMetadata: {
coverAssetId: 'asset-cover-old',
coverObjectKey: 'game-distribution/cover/old.png',
@@ -540,6 +698,8 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
packageEntryPath: 'index.html',
// 价格回填自该版本冻结值,并随新版本一起提交。
priceMudPoints: 240,
gameMetadata: expect.objectContaining({
title: '星轨防线',
category: '动作',
@@ -38,7 +38,12 @@ import {
} from './gamePublishDraft';
import { applyVersionMediaDefaults } from './gamePublishMediaDefaults';
import { GamePublishMediaFieldset } from './GamePublishMediaFieldset';
import { resolvePublishMetadataError } from './gamePublishMetadata';
import {
type GamePublishPriceMode,
MAX_GAME_PRICE_MUD_POINTS,
resolveGamePublishPriceMudPoints,
resolvePublishMetadataError,
} from './gamePublishMetadata';
import { prepareGamePackage } from './gameZipPackage';
type GamePublishPageProps = {
@@ -85,6 +90,9 @@ export function GamePublishPage({
});
const [orientation, setOrientation] =
useState<GameDistributionOrientation>('responsive');
/** 付费方式:免费等价于 0 泥点,买断制必须填 1..=1_000_000 的正整数。 */
const [priceMode, setPriceMode] = useState<GamePublishPriceMode>('free');
const [priceInput, setPriceInput] = useState('');
const [packageFile, setPackageFile] = useState<File | null>(null);
// 封面与截图都先上传成平台素材,再把素材 ID 交给服务端冻结;页面只保存素材标识与预览地址。
const [coverAsset, setCoverAsset] = useState<GamePublishImageAsset | null>(
@@ -152,6 +160,19 @@ export function GamePublishPage({
setDescription(detail.game.description);
setCategory(detail.game.category);
setTagsInput(detail.game.tags.join(','));
// 回填既有价格:无价格或 0 按免费展示,正数按买断制填进输入框。
const existingPriceMudPoints =
detail.version.priceMudPoints ?? detail.game.priceMudPoints ?? 0;
if (
typeof existingPriceMudPoints === 'number' &&
existingPriceMudPoints > 0
) {
setPriceMode('paid');
setPriceInput(String(existingPriceMudPoints));
} else {
setPriceMode('free');
setPriceInput('');
}
setUpdateContext({
gameId: game.id,
publicationRevision: detail.version.publicationRevision,
@@ -324,12 +345,18 @@ export function GamePublishPage({
setError(mediaError);
return;
}
const price = resolveGamePublishPriceMudPoints(priceMode, priceInput);
if (price.error) {
setError(price.error);
return;
}
const metadataError = resolvePublishMetadataError({
title,
summary,
deviceSupport,
coverAssetId: coverAsset?.assetObjectId,
screenshotCount: screenshotAssets.length,
priceMudPoints: price.priceMudPoints,
});
if (metadataError) {
setError(metadataError);
@@ -363,6 +390,7 @@ export function GamePublishPage({
const version = await createGameVersion(
updateContext.gameId,
{
priceMudPoints: price.priceMudPoints,
packageSha256: prepared.sha256,
packageBytes: prepared.totalBytes,
packageFileCount: prepared.fileCount,
@@ -414,12 +442,18 @@ export function GamePublishPage({
setError(mediaError);
return;
}
const price = resolveGamePublishPriceMudPoints(priceMode, priceInput);
if (price.error) {
setError(price.error);
return;
}
const metadataError = resolvePublishMetadataError({
title,
summary,
deviceSupport,
coverAssetId: coverAsset?.assetObjectId,
screenshotCount: screenshotAssets.length,
priceMudPoints: price.priceMudPoints,
});
if (metadataError) {
setError(metadataError);
@@ -454,6 +488,7 @@ export function GamePublishPage({
const version = await createGameVersion(
game.id,
{
priceMudPoints: price.priceMudPoints,
packageSha256: prepared.sha256,
packageBytes: prepared.totalBytes,
packageFileCount: prepared.fileCount,
@@ -769,6 +804,45 @@ export function GamePublishPage({
</label>
))}
</fieldset>
<fieldset className="game-publish-fieldset">
<legend>付费方式</legend>
<label className="game-publish-checkbox">
<input
type="radio"
name="game-price-mode"
checked={priceMode === 'free'}
disabled={isResumingDraft}
onChange={() => setPriceMode('free')}
/>
免费
</label>
<label className="game-publish-checkbox">
<input
type="radio"
name="game-price-mode"
checked={priceMode === 'paid'}
disabled={isResumingDraft}
onChange={() => setPriceMode('paid')}
/>
买断制
</label>
</fieldset>
{priceMode === 'paid' ? (
<label className="game-publish-field">
买断价(泥点)
<input
type="number"
inputMode="numeric"
value={priceInput}
disabled={isResumingDraft}
onChange={(event) => setPriceInput(event.target.value)}
/>
<span className="game-publish-hint">
买断一次永久拥有;价格随本次版本审核通过后生效,最多{' '}
{MAX_GAME_PRICE_MUD_POINTS} 泥点
</span>
</label>
) : null}
<GamePublishMediaFieldset
cover={coverAsset}
screenshots={screenshotAssets}
@@ -43,4 +43,28 @@ describe('normalizeGameEntryUrl', () => {
expect(normalizeGameEntryUrl(entryUrl)).toBe(entryUrl);
});
it('resolves a paid play session entry against the current origin', () => {
const sessionPath = '/api/game-distribution/play-sessions/token-1/';
expect(normalizeGameEntryUrl(sessionPath)).toBe(
new URL(sessionPath, window.location.origin).href,
);
});
it('normalizes a same-origin absolute play session URL', () => {
const sessionPath = '/api/game-distribution/play-sessions/token-1/';
expect(
normalizeGameEntryUrl(`${window.location.origin}${sessionPath}`),
).toBe(new URL(sessionPath, window.location.origin).href);
});
it.each([
'/api/game-distribution/play-sessions/',
'/api/game-distribution/games/game-1/purchase',
'/api/other/play-sessions/token-1/',
])('rejects a malformed play session entry: %s', (entryUrl) => {
expect(normalizeGameEntryUrl(entryUrl)).toBeNull();
});
});
@@ -3,10 +3,18 @@ import { useEffect, useState } from 'react';
const MAX_GAME_ID_LENGTH = 128;
const MAX_ENTRY_URL_LENGTH = 4096;
const GAME_ENTRY_PATH_PATTERN = /^\/games\/(game_[0-9a-f]{32})\/?$/;
/**
* 付费游戏的播放会话入口由服务端签发,形状为
* `/api/game-distribution/play-sessions/<token>/`;包内资源经同一前缀相对读取。
*/
const GAME_PLAY_SESSION_PATH_PATTERN =
/^\/api\/game-distribution\/play-sessions\/[A-Za-z0-9._~/-]+$/;
function normalizeGameEntryPath(pathname: string) {
const match = GAME_ENTRY_PATH_PATTERN.exec(pathname);
return match ? `/games/${match[1]}/` : null;
const gameMatch = GAME_ENTRY_PATH_PATTERN.exec(pathname);
if (gameMatch) return `/games/${gameMatch[1]}/`;
if (GAME_PLAY_SESSION_PATH_PATTERN.test(pathname)) return pathname;
return null;
}
function containsControlCharacter(value: string) {
@@ -1,6 +1,40 @@
import type { GameDistributionDeviceSupport } from '../../../packages/shared/src/contracts/gameDistribution';
import { MAX_GAME_SCREENSHOTS } from './gamePublishAssets';
/** 买断价上限,与服务端 `MAX_GAME_PRICE_MUD_POINTS` 保持一致。 */
export const MAX_GAME_PRICE_MUD_POINTS = 1_000_000;
export type GamePublishPriceMode = 'free' | 'paid';
/**
* 解析发布表单的付费方式与买断价输入。
*
* 口径与服务端一致:免费等价于 `0`;买断制必须是 `1..=1_000_000` 的整数泥点。
* 返回的 `error` 为空字符串表示可用,否则 `priceMudPoints` 无意义。
*/
export function resolveGamePublishPriceMudPoints(
mode: GamePublishPriceMode,
rawValue: string,
): { priceMudPoints: number; error: string } {
if (mode === 'free') return { priceMudPoints: 0, error: '' };
const value = rawValue.trim();
if (!/^\d+$/u.test(value)) {
return { priceMudPoints: 0, error: '买断价必须是整数泥点' };
}
const priceMudPoints = Number(value);
if (
!Number.isSafeInteger(priceMudPoints) ||
priceMudPoints < 1 ||
priceMudPoints > MAX_GAME_PRICE_MUD_POINTS
) {
return {
priceMudPoints: 0,
error: `买断价必须是 1 到 ${MAX_GAME_PRICE_MUD_POINTS} 之间的整数泥点`,
};
}
return { priceMudPoints, error: '' };
}
/** 作者侧元数据校验;口径与服务端 `validate_game_metadata` 保持一致。 */
export function resolvePublishMetadataError(input: {
title: string;
@@ -10,6 +44,8 @@ export function resolvePublishMetadataError(input: {
coverAssetId?: string | null;
/** 已选截图数量;上限与服务端 `MAX_GAME_SCREENSHOTS` 一致。 */
screenshotCount?: number;
/** 买断价(整数泥点,`0` 表示免费);缺省不校验,兼容未接入定价的调用方。 */
priceMudPoints?: number | null;
}) {
const title = input.title.trim();
const summary = input.summary.trim();
@@ -31,5 +67,15 @@ export function resolvePublishMetadataError(input: {
if ((input.screenshotCount ?? 0) > MAX_GAME_SCREENSHOTS) {
return '游戏截图最多 6 张';
}
if (input.priceMudPoints !== undefined && input.priceMudPoints !== null) {
const priceMudPoints = input.priceMudPoints;
if (
!Number.isInteger(priceMudPoints) ||
priceMudPoints < 0 ||
priceMudPoints > MAX_GAME_PRICE_MUD_POINTS
) {
return `买断价必须是 0 到 ${MAX_GAME_PRICE_MUD_POINTS} 之间的整数泥点`;
}
}
return '';
}
@@ -870,6 +870,7 @@ export function PlatformEntryFlowShellImpl({
ownerView={gameSearchParams.get('owner') === '1'}
onBack={backFromGameDetail}
onPlay={openGamePlay}
onOpenRecharge={openRecharge}
/>
) : (
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
@@ -20,6 +20,7 @@ const PLATFORM_WALLET_LEDGER_SOURCE_LABELS = {
redeem_code_reward: '兑换码奖励',
puzzle_author_incentive_claim: '拼图作者奖励',
daily_task_reward: '每日任务奖励',
game_purchase: '购买游戏',
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
export type PlatformWalletLedgerEntryPresentation = {
+47 -1
View File
@@ -7,6 +7,9 @@ import type {
GameDistributionDeviceSupport,
GameDistributionGame,
GameDistributionMyReviewResponse,
GameDistributionPlaySessionResponse,
GameDistributionPurchaseRequest,
GameDistributionPurchaseResponse,
GameDistributionRecoveryAction,
GameDistributionReviewsResponse,
GameDistributionSaveReviewRequest,
@@ -35,7 +38,6 @@ export type {
GameDistributionVersionDetail,
GameDistributionVersionSummary,
} from '../../packages/shared/src/contracts/gameDistribution';
export type GameCategory = GameDistributionCategory;
export type GameDeviceSupport = GameDistributionDeviceSupport;
export type GameAuthor = GameDistributionAuthor;
@@ -337,6 +339,50 @@ export function recordGamePlay(
);
}
/**
* 购买买断制作品。扣费与购买记录由服务端在同一事务内完成。
*
* - `Idempotency-Key` 必传:双击、并发或响应丢失重试都只会扣一次;
* - `expectedPriceMudPoints` 必须回传客户端看到的价格,服务端价格已变时返回 409;
* - 余额不足返回 400 与 `INSUFFICIENT_MUD_POINTS`,调用方据此给出充值入口。
*/
export async function purchaseGame(
gameId: string,
payload: GameDistributionPurchaseRequest,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) throw new Error('购买游戏缺少作品编号');
if (!normalizedKey) throw new Error('购买游戏缺少幂等键');
return requestJson<GameDistributionPurchaseResponse>(
`/games/${encodeURIComponent(normalizedGameId)}/purchase`,
{
method: 'POST',
cache: 'no-store',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify(payload),
},
'购买游戏失败',
);
}
/**
* 为买断制作品签发播放会话。
*
* 免费游戏不调用这个接口;付费作品由服务端判定作者/管理员免购买与已购买,
* 未通过鉴权时拒绝并给出可读错误,客户端不得自行放行。
*/
export async function createGamePlaySession(gameId: string) {
const normalizedGameId = gameId.trim();
if (!normalizedGameId) throw new Error('创建播放会话缺少作品编号');
return requestJson<GameDistributionPlaySessionResponse>(
`/games/${encodeURIComponent(normalizedGameId)}/play-session`,
{ method: 'POST', cache: 'no-store' },
'创建播放会话失败',
);
}
export function listGameReviews(gameId: string, page = 1) {
return requestJson<GameDistributionReviewsResponse>(
`/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`,