Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s

# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs
#	apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx
#	apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	package.json
#	packages/shared/src/contracts/gameDistribution.ts
#	scripts/check-game-distribution-dto-parity.mjs
#	server-rs/crates/api-server/src/modules/game_distribution.rs
#	server-rs/crates/module-game-distribution/src/errors.rs
#	server-rs/crates/module-game-distribution/src/lib.rs
#	server-rs/crates/shared-contracts/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/active.rs
#	server-rs/crates/spacetime-client/src/active/mapper.rs
#	server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs
#	server-rs/crates/spacetime-client/src/game_distribution.rs
#	server-rs/crates/spacetime-client/src/module_bindings.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs
#	server-rs/crates/spacetime-module/src/game_distribution.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	src/components/game-distribution/GameDetailPage.tsx
#	src/components/game-distribution/GameDistributionPages.test.tsx
#	src/components/game-distribution/GamePlayPage.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
#	src/services/gameDistributionClient.test.ts
#	src/services/gameDistributionClient.ts
This commit is contained in:
2026-10-06 16:10:52 +08:00
314 changed files with 13094 additions and 2427 deletions
@@ -0,0 +1,2 @@
export type { PlatformGamePricingFieldProps } from '@genarrative/shared/components';
export { PlatformGamePricingField } from '@genarrative/shared/components';
+7 -2
View File
@@ -138,10 +138,15 @@ it('自己的粉丝支持回关和取消回关,移除须确认且只移除该
total: 1,
});
render(<CreatorConnectionsPage id="a" tab="followers" {...callbacks} />);
await screen.findByRole('button', { name: '回关' });
// 关系 store 的 seed 晚于行首帧,此时按钮仍是 disabled;React 不会对 disabled
// 元素派发 onClick,必须先等到按钮可用再点,否则这次点击会被直接吞掉。
const back = (await screen.findByRole('button', {
name: '回关',
})) as HTMLButtonElement;
await waitFor(() => expect(back.disabled).toBe(false));
api.write.mockResolvedValue({ relationship: following });
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
fireEvent.click(screen.getByRole('button', { name: '回关' }));
fireEvent.click(back);
await screen.findByRole('button', { name: '取消关注' });
expect(api.write).toHaveBeenLastCalledWith('b', 'follow');
fireEvent.click(screen.getByRole('button', { name: '移除粉丝' }));
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,11 @@ import {
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution';
import type {
GameDistributionPlaySessionResponse,
GameDistributionVersionStatus,
} from '../../../packages/shared/src/contracts/gameDistribution';
import { ApiClientError } from '../../services/apiClient';
import type {
GameDistributionGame,
GameDistributionGameWithCollection,
@@ -43,6 +47,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());
const getPublicAuthUserByIdMock = vi.hoisted(() => vi.fn());
const getCreatorRelationshipMock = vi.hoisted(() => vi.fn());
const changeCreatorRelationshipMock = vi.hoisted(() => vi.fn());
@@ -69,6 +75,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),
}));
/**
@@ -153,6 +174,8 @@ function createGame(
},
playCount: 12,
createdAt: '2026-09-18T00:00:00.000Z',
priceMudPoints: 0,
purchased: true,
...overrides,
};
}
@@ -216,6 +239,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}` : '',
);
@@ -286,6 +312,7 @@ describe('GameDetailPage', () => {
expect(link.querySelector('strong')?.textContent).toBe('');
// 补查由 effect 发起、提交后一拍才发出:这里等它发生,避免断言与 React 提交竞态
// (此前偶发 0 次调用,会让整份文件的门禁随机变红)。
// 占位昵称的补查发生在渲染后的 effect 里:并发负载下首帧可能还没触发,必须等。
await waitFor(() =>
expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2'),
);
@@ -440,6 +467,207 @@ describe('GameDetailPage', () => {
// 「由 测试作者 制作」是作品自身署名,不在断言范围内。
expect(document.querySelector('.game-fork-lineage__author')).toBeNull();
});
it('付费已购买时显示立即玩并可进入游玩页', async () => {
const onPlay = vi.fn();
getGameMock.mockResolvedValueOnce(
createGame({ priceMudPoints: 120, purchased: true }),
);
render(<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull();
expect(screen.getByText('120 泥点')).toBeTruthy();
fireEvent.click(playButton);
expect(onPlay).toHaveBeenCalledWith('game-1');
});
it('付费未购买但服务端已下发公开入口(作者/管理员)时显示立即玩而不是购买', async () => {
const onPlay = vi.fn();
// 作者本人与管理员不会被记为已购买(purchased=false),但后端仍授予可玩入口。
getGameMock.mockResolvedValueOnce(
createGame({ priceMudPoints: 120, purchased: false }),
);
render(<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull();
expect(screen.getByText('120 泥点')).toBeTruthy();
fireEvent.click(playButton);
expect(onPlay).toHaveBeenCalledWith('game-1');
});
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();
const confirmButton = within(dialog).getByRole('button', {
name: '确认购买',
});
// 双击必须只扣一次:第二次点击落在同一次购买的请求窗口内,不得再发一次购买请求。
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
await waitFor(() => expect(purchaseGameMock).toHaveBeenCalledTimes(1));
expect(purchaseGameMock.mock.calls[0]?.[0]).toBe('game-1');
expect(purchaseGameMock.mock.calls[0]?.[1]).toEqual({
expectedPriceMudPoints: 120,
});
// 同一次购买只用一个幂等键:键为空或每次点击换键都意味着服务端会当新购买扣费。
const purchaseKeys = purchaseGameMock.mock.calls.map((call) =>
String(call[2] ?? ''),
);
expect(purchaseKeys).toEqual([
expect.stringMatching(/^web-game-purchase-[0-9a-f-]{36}$/u),
]);
await waitFor(() =>
expect(walletMocks.onWalletBalanceMayHaveChanged).toHaveBeenCalled(),
);
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();
});
it('服务端返回 409 价格已变化时提示已刷新并保留购买入口', async () => {
getGameMock.mockResolvedValue(createLockedPaidGame());
purchaseGameMock.mockRejectedValueOnce(
new ApiClientError({
message: '价格已变化,请按最新价格重新确认',
status: 409,
code: 'CONFLICT',
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(
await screen.findByRole('button', { name: /120 泥点购买/u }),
);
fireEvent.click(await screen.findByRole('button', { name: '确认购买' }));
expect(
await screen.findByText(
'价格已更新,已为你刷新最新价格,请重新确认购买。',
),
).toBeTruthy();
// CAS 失败必须回读详情拿最新价,但绝不按旧价扣费,也不伪造购买成功。
await waitFor(() => expect(getGameMock).toHaveBeenCalledTimes(2));
expect(screen.queryByText('购买成功,现在可以直接开始游玩。')).toBeNull();
expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy();
});
it('旧响应缺价格且没有可玩入口时说明「立即玩」为何不可用', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
// 旧响应没有买断价:按免费兜底会被判成可玩,但服务端并没有下发入口。
priceMudPoints: undefined,
purchased: false,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
await waitFor(() =>
expect((playButton as HTMLButtonElement).disabled).toBe(true),
);
// 禁用按钮必须带可操作说明,否则用户只看到一个点不动的按钮。
const reason = screen.getByText('暂时拿不到可玩入口,请刷新页面后重试。');
expect(reason.id).toBe('game-entry-missing-warning');
expect(playButton.getAttribute('aria-describedby')).toBe(reason.id);
});
});
describe('GameDetailPage 作者视角', () => {
@@ -694,6 +922,138 @@ 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();
});
/** 付费未购买:服务端不下发公开入口,只能靠播放会话判定能否游玩。 */
function createLockedPlayGame(): GameDistributionGame {
return createGame({
priceMudPoints: 120,
purchased: false,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
});
}
it('付费游戏先签发播放会话再挂载 iframe 并计数', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
priceMudPoints: 120,
purchased: true,
// 已购买时公开入口也在,但付费作品的发行网关只认会话,必须走播放会话。
currentVersion: { ...createGame().currentVersion! },
}),
);
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(createLockedPlayGame());
createGamePlaySessionMock.mockRejectedValueOnce(
new ApiClientError({
message: '播放付费作品需要先登录',
status: 401,
code: 'UNAUTHORIZED',
}),
);
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();
});
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();
});
it('付费作品在会话签发期间显示共享加载面,签发完成后才挂载 iframe', async () => {
const pendingSession: {
resolve: (session: GameDistributionPlaySessionResponse) => void;
} = { resolve: () => {} };
getGameMock.mockResolvedValueOnce(createLockedPlayGame());
createGamePlaySessionMock.mockImplementationOnce(
() =>
new Promise<GameDistributionPlaySessionResponse>((resolve) => {
pendingSession.resolve = resolve;
}),
);
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
// 会话签发期间不能只留空白:这是付费作品唯一的“正在准备”反馈。
const loadingSurface = await screen.findByRole('status');
expect(
loadingSurface.classList.contains('platform-game-loading--player'),
).toBe(true);
expect(loadingSurface.textContent).toContain('正在准备游戏…');
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
expect(recordGamePlayMock).not.toHaveBeenCalled();
await act(async () => {
pendingSession.resolve({
playUrl: '/api/game-distribution/play-sessions/token-9/',
expiresAt: '2026-10-05T00:05:00Z',
});
});
const iframe = await screen.findByTitle('星轨防线 在线游玩');
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
});
describe('GameGalleryPage 筛选与返回上下文', () => {
@@ -2263,7 +2623,9 @@ describe('GameLineagePage 可视化与交互', () => {
).toBeTruthy();
// 卡片上只保留「打开作品 / Fork」两个动作按钮(第三个次要动作已按复验意见移除),
// 封面+标题本身也是一个打开详情的按钮。
expect(within(leafCard).getByRole('button', { name: '打开作品' })).toBeTruthy();
expect(
within(leafCard).getByRole('button', { name: '打开作品' }),
).toBeTruthy();
expect(
within(leafCard).getByRole('button', { name: 'Fork 叶节点' }),
).toBeTruthy();
+100 -13
View File
@@ -13,7 +13,9 @@ import {
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiClientError } from '../../services/apiClient';
import {
createGamePlaySession,
type GameDistributionGame,
getGame,
recordGamePlay,
@@ -31,7 +33,7 @@ import { GamePlayFrame } from './GamePlayFrame';
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout' | 'failed';
export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
const frameRef = useRef<HTMLIFrameElement>(null);
@@ -40,6 +42,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();
@@ -51,6 +57,8 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
setGame(null);
setHasStarted(false);
setStartupState('idle');
setPlayUrl(null);
setPlayError('');
setError('');
if (!normalizedGameId) {
setError('缺少游戏编号');
@@ -61,14 +69,28 @@ 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) => {
@@ -106,9 +128,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(() => {});
@@ -118,8 +139,57 @@ 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;
}
if (
sessionError instanceof ApiClientError &&
sessionError.status === 401
) {
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);
};
@@ -184,7 +254,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">
@@ -213,14 +283,31 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</div>
</div>
) : null}
{hasStarted && startupState === 'loading' ? (
{isPreparingPlayback ||
(hasStarted && startupState === 'loading' && !playError) ? (
<PlatformGameLoadingSurface
key={`game-loading-${frameRunId}`}
tone="player"
title={game.title}
cover={<GameCover game={game} compact />}
message={isPreparingPlayback ? '正在准备游戏…' : undefined}
/>
) : 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
tone="error"
@@ -236,12 +323,12 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</button>
</PlatformStatusMessage>
) : null}
{hasStarted ? (
{hasStarted && playUrl ? (
<GamePlayFrame
key={`${game.currentVersion.id}:${frameRunId}`}
frameRef={frameRef}
key={`${game.currentVersion?.id ?? game.id}:${frameRunId}`}
title={`${game.title} 在线游玩`}
entryUrl={game.currentVersion.entryUrl}
entryUrl={playUrl}
ready={startupState === 'ready'}
onLoad={() => setStartupState('ready')}
/>
@@ -229,6 +229,169 @@ test('元数据校验与服务端口径一致', () => {
).toBe('');
});
test('草稿恢复按版本冻结价预填并锁住定价,提示写明将沿用的价格', async () => {
writeDraft('user-1');
vi.mocked(getGameVersion).mockResolvedValue({
game: {
id: 'game-1',
title: '上次的游戏',
summary: '上次的简介',
description: '上次的详细介绍',
publicationRevision: 2,
// 游戏行现价与版本冻结价刻意取不同值:恢复必须取版本冻结价。
priceMudPoints: 999,
},
version: {
versionId: 'gamever-1',
versionNumber: 1,
status: 'uploaded',
recoveryAction: 'upload',
priceMudPoints: 240,
frozenMetadata: { coverAssetId: 'asset-cover-old' },
},
} as never);
await renderPage();
fireEvent.click(await screen.findByRole('button', { name: '继续上传' }));
// 恢复态定价字段被禁用,所以提示必须直接写出将要沿用的冻结价,作者不必回表单里找。
expect(
await screen.findByText(/本次将沿用版本冻结价 240 泥点/u),
).toBeTruthy();
const paidRadio = screen.getByLabelText('买断制') as HTMLInputElement;
expect(paidRadio.checked).toBe(true);
expect(paidRadio.disabled).toBe(true);
const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement;
expect(priceInput.value).toBe('240');
expect(priceInput.disabled).toBe(true);
});
test('越界价格在网页元数据兜底里被拦下', () => {
// 纯函数解析口径已由共享组件用例覆盖;这里只保留网页独有的元数据兜底。
const desktop = { desktop: true, mobile: false, touch: false };
expect(
resolvePublishMetadataError({
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 +647,7 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
status: 'published',
publicationRevision: 4,
recoveryAction: 'none',
priceMudPoints: 240,
frozenMetadata: {
coverAssetId: 'asset-cover-old',
coverObjectKey: 'game-distribution/cover/old.png',
@@ -540,6 +704,8 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
packageEntryPath: 'index.html',
// 价格回填自该版本冻结值,并随新版本一起提交。
priceMudPoints: 240,
gameMetadata: expect.objectContaining({
title: '星轨防线',
category: '动作',
@@ -20,6 +20,7 @@ import {
} from '../../services/gameDistributionClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformGamePricingField } from '../common/PlatformGamePricingField';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
GAME_METADATA_CATEGORIES,
@@ -38,7 +39,11 @@ import {
} from './gamePublishDraft';
import { applyVersionMediaDefaults } from './gamePublishMediaDefaults';
import { GamePublishMediaFieldset } from './GamePublishMediaFieldset';
import { resolvePublishMetadataError } from './gamePublishMetadata';
import {
type GamePublishPriceMode,
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,
@@ -229,6 +250,17 @@ export function GamePublishPage({
setTitle(detail.game.title);
setSummary(detail.game.summary);
setDescription(detail.game.description);
// 草稿恢复的字段是禁用的,但定价必须显示这一版真正会沿用的价格:
// 否则已付费作品会以「免费」示人,作者会以为价格被改掉了。
const frozenPriceMudPoints =
detail.version.priceMudPoints ?? detail.game.priceMudPoints ?? 0;
if (frozenPriceMudPoints > 0) {
setPriceMode('paid');
setPriceInput(String(frozenPriceMudPoints));
} else {
setPriceMode('free');
setPriceInput('');
}
void applyVersionMediaDefaults(detail, {
setCover: setCoverAsset,
setScreenshots: setScreenshotAssets,
@@ -248,6 +280,12 @@ export function GamePublishPage({
isResumingDraft &&
draftDetail !== null &&
draftDetail.version.recoveryAction !== 'submit';
/** 恢复态定价字段被锁住,这里把将要沿用的冻结价直接写进提示,作者不必回表单里找。 */
const resumeFrozenPriceMudPoints = draftDetail
? (draftDetail.version.priceMudPoints ??
draftDetail.game.priceMudPoints ??
0)
: 0;
/** 展示中的线上封面没有素材 ID 时无法冻结进新版本,必须先本地拦一次并说明原因。 */
function resolveMediaSubmitError() {
@@ -324,12 +362,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 +407,7 @@ export function GamePublishPage({
const version = await createGameVersion(
updateContext.gameId,
{
priceMudPoints: price.priceMudPoints,
packageSha256: prepared.sha256,
packageBytes: prepared.totalBytes,
packageFileCount: prepared.fileCount,
@@ -414,12 +459,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 +505,7 @@ export function GamePublishPage({
const version = await createGameVersion(
game.id,
{
priceMudPoints: price.priceMudPoints,
packageSha256: prepared.sha256,
packageBytes: prepared.totalBytes,
packageFileCount: prepared.fileCount,
@@ -662,6 +714,9 @@ export function GamePublishPage({
<PlatformStatusMessage tone="info" surface="platform">
<span>
继续发布沿用上次冻结的资料与版本号,资料要改就撤回后在「我的游戏」重新发布。
{resumeFrozenPriceMudPoints > 0
? `本次将沿用版本冻结价 ${resumeFrozenPriceMudPoints} 泥点。`
: '本次将沿用版本冻结价,作品为免费。'}
</span>
</PlatformStatusMessage>
) : null}
@@ -769,6 +824,13 @@ export function GamePublishPage({
</label>
))}
</fieldset>
<PlatformGamePricingField
mode={priceMode}
priceInput={priceInput}
disabled={isResumingDraft}
onModeChange={setPriceMode}
onPriceInputChange={setPriceInput}
/>
<GamePublishMediaFieldset
cover={coverAsset}
screenshots={screenshotAssets}
@@ -6,6 +6,8 @@ import { normalizeGameEntryUrl } from './gameDistributionGuards';
const GAME_ID = `game_${'a'.repeat(32)}`;
const GAME_ENTRY_PATH = `/games/${GAME_ID}/`;
/** 与 `gameDistributionGuards.ts` 的 `MAX_ENTRY_URL_LENGTH` 对齐;守卫内部常量不导出。 */
const MAX_ENTRY_URL_LENGTH = 4096;
describe('normalizeGameEntryUrl', () => {
it('resolves a release gateway relative path against the current origin', () => {
@@ -43,4 +45,86 @@ 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('accepts a uuid v4 play session token with and without the trailing slash', () => {
const token = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
const sessionPath = `/api/game-distribution/play-sessions/${token}/`;
expect(normalizeGameEntryUrl(sessionPath)).toBe(
new URL(sessionPath, window.location.origin).href,
);
// 不带尾斜杠时按原样接受:是否带尾斜杠由服务端签发的 playUrl 决定,守卫不代它补。
const withoutSlash = `/api/game-distribution/play-sessions/${token}`;
expect(normalizeGameEntryUrl(withoutSlash)).toBe(
new URL(withoutSlash, window.location.origin).href,
);
});
it('refuses a play session token that escapes the prefix with dot segments', () => {
// `[A-Za-z0-9._~/-]` 会放过 `..`,`new URL` 随后把它归一成同源其它路径。
expect(
normalizeGameEntryUrl('/api/game-distribution/play-sessions/../../x/'),
).toBeNull();
expect(
normalizeGameEntryUrl(
`${window.location.origin}/api/game-distribution/play-sessions/../../x/`,
),
).toBeNull();
expect(
normalizeGameEntryUrl(
'/api/game-distribution/play-sessions/..%2F..%2Fx/',
),
).toBeNull();
});
it.each(['token.1', 'token_1', 'token~1', 'token%2F1', 'token 1'])(
'refuses a play session token outside the uuid alphabet: %s',
(token) => {
expect(
normalizeGameEntryUrl(`/api/game-distribution/play-sessions/${token}/`),
).toBeNull();
},
);
it('refuses an entry URL longer than the guard limit', () => {
expect(
normalizeGameEntryUrl(
`/api/game-distribution/play-sessions/${'a'.repeat(MAX_ENTRY_URL_LENGTH)}/`,
),
).toBeNull();
expect(
normalizeGameEntryUrl(`https://play.example.test/${'a'.repeat(4200)}`),
).toBeNull();
});
it.each([
'/api/game-distribution/play-sessions/tok\u0001en/',
'/api/game-distribution/play-sessions/token-1/\u007f',
])('refuses an entry URL carrying control characters: %j', (entryUrl) => {
expect(normalizeGameEntryUrl(entryUrl)).toBeNull();
});
it.each([
'/api/game-distribution/play-sessions/',
'/api/game-distribution/games/game-1/purchase',
'/api/other/play-sessions/token-1/',
])('rejects a malformed play session entry: %s', (entryUrl) => {
expect(normalizeGameEntryUrl(entryUrl)).toBeNull();
});
});
@@ -3,10 +3,21 @@ 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>/`;包内资源经同一前缀相对读取。
*
* token 段只允许 `[A-Za-z0-9-]`(令牌是 UUID v4):放开 `.` 与 `/` 会让
* `…/play-sessions/../../x` 这类输入被 `new URL` 归一成同源其它路径。
*/
const GAME_PLAY_SESSION_PATH_PATTERN =
/^\/api\/game-distribution\/play-sessions\/[A-Za-z0-9-]+\/?$/;
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,25 @@
import {
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
type PlatformGamePricingMode,
resolvePlatformGamePriceMudPoints,
} from '../../../packages/shared/src/components/platformGamePricingModel';
import type { GameDistributionDeviceSupport } from '../../../packages/shared/src/contracts/gameDistribution';
import { MAX_GAME_SCREENSHOTS } from './gamePublishAssets';
/** 买断价上限,复用共享定价组件常量,网页与 AGC 及服务端保持同一口径。 */
export const MAX_GAME_PRICE_MUD_POINTS = PLATFORM_GAME_MAX_PRICE_MUD_POINTS;
export type GamePublishPriceMode = PlatformGamePricingMode;
/**
* 解析发布表单的付费方式与买断价输入。
*
* 口径与服务端一致:免费等价于 `0`;买断制必须是 `1..=1_000_000` 的整数泥点。
* 实现落在共享 `platformGamePricingModel`,网页与 AGC 发布面板共用同一份校验。
*/
export const resolveGamePublishPriceMudPoints =
resolvePlatformGamePriceMudPoints;
/** 作者侧元数据校验;口径与服务端 `validate_game_metadata` 保持一致。 */
export function resolvePublishMetadataError(input: {
title: string;
@@ -10,6 +29,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 +52,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 '';
}
@@ -1083,6 +1083,7 @@ export function PlatformEntryFlowShellImpl({
ownerView={gameSearchParams.get('owner') === '1'}
onBack={backFromGameDetail}
onPlay={openGamePlay}
onOpenRecharge={openRecharge}
onOpenLineage={openGameLineage}
onOpenCompare={openGameCompare}
onOpenTheme={openGameTheme}
@@ -21,6 +21,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 = {