网页端接入游戏买断制泥点付费
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
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user