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
+253 -1
View File
@@ -1,9 +1,14 @@
/* @vitest-environment jsdom */
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
import {
ApiClientError,
clearStoredAccessToken,
setStoredAccessToken,
} from './apiClient';
import {
cancelGameVersion,
createGamePlaySession,
createGameVersion,
getDerivedGames,
getGame,
@@ -13,6 +18,7 @@ import {
listGameReviews,
listGames,
listMyGames,
purchaseGame,
recordGamePlay,
saveMyGameReview,
unpublishGame,
@@ -150,6 +156,60 @@ describe('gameDistributionClient', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('详情可选鉴权:已登录带 bearer,未登录匿名且不补票', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
id: 'game-1',
title: '星轨防线',
priceMudPoints: 120,
purchased: false,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
);
vi.stubGlobal('fetch', fetchMock);
// 未登录:匿名读取,且不为了补票多发一次 refresh 请求。
await getGame('game-1');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/api/game-distribution/games/game-1',
);
expect(
fetchMock.mock.calls[0]?.[1]?.headers?.Authorization,
).toBeUndefined();
// 已登录:带上 bearer,服务端才能算真实购买态与付费入口。
setStoredAccessToken('viewer-token', { emit: false });
await getGame('game-1');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[1]?.headers?.Authorization).toBe(
'Bearer viewer-token',
);
});
it('目录列表保持匿名公开读取,不带 bearer', async () => {
setStoredAccessToken('viewer-token', { emit: false });
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ games: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await listGames();
expect(
fetchMock.mock.calls[0]?.[1]?.headers?.Authorization,
).toBeUndefined();
});
it('游玩上报携带稳定的匿名 clientId,且不携带未登录 bearer', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
@@ -445,3 +505,195 @@ describe('gameDistributionClient 作者接口', () => {
expect(cancelCalls()).toHaveLength(1);
});
});
describe('gameDistributionClient 购买与播放会话', () => {
beforeEach(() => {
// Node 新版提供的原生 localStorage 不等于浏览器存储;测试使用独立内存存储。
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear(),
});
clearStoredAccessToken({ emit: false });
});
it('购买请求带路径、幂等键与 expectedPriceMudPoints,响应按契约解析', async () => {
setStoredAccessToken('buyer-token', { emit: false });
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
purchase: {
purchaseId: 'gdpurchase_1',
gameId: 'game-1',
priceMudPoints: 120,
createdAt: '2026-10-05T00:00:00Z',
},
walletBalance: 380,
replayed: false,
},
error: null,
meta: { apiVersion: 'v1' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const result = await purchaseGame(
'game-1',
{ expectedPriceMudPoints: 120 },
'purchase-key-1',
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/game-distribution/games/game-1/purchase',
expect.objectContaining({
method: 'POST',
cache: 'no-store',
headers: expect.objectContaining({
'Idempotency-Key': 'purchase-key-1',
}),
}),
);
// 序列化名必须是服务端契约的 expectedPriceMudPoints,不能退化成客户端本地字段名。
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
expectedPriceMudPoints: 120,
});
expect(result.walletBalance).toBe(380);
expect(result.replayed).toBe(false);
expect(result.purchase.priceMudPoints).toBe(120);
});
it('播放会话请求路径固定,并把 playUrl / expiresAt 原样解析出来', async () => {
setStoredAccessToken('buyer-token', { emit: false });
const playUrl =
'/api/game-distribution/play-sessions/3f2504e0-4f89-41d3-9a0c-0305e82c3301/';
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
playUrl,
expiresAt: '2026-10-05T00:10:00Z',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const session = await createGamePlaySession('game-1');
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/api/game-distribution/games/game-1/play-session',
);
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
cache: 'no-store',
});
expect(session.playUrl).toBe(playUrl);
expect(session.expiresAt).toBe('2026-10-05T00:10:00Z');
});
it('购买与播放会话的本地标识缺失时失败关闭且不发出请求', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(
purchaseGame(' ', { expectedPriceMudPoints: 1 }, 'key'),
).rejects.toThrow('购买游戏缺少作品编号');
await expect(
purchaseGame('game-1', { expectedPriceMudPoints: 1 }, ' '),
).rejects.toThrow('购买游戏缺少幂等键');
await expect(createGamePlaySession(' ')).rejects.toThrow(
'创建播放会话缺少作品编号',
);
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
[401, 'UNAUTHORIZED', '播放付费作品需要先登录'],
[403, 'GAME_PURCHASE_ADMIN_NOT_ALLOWED', '管理员无需购买作品'],
[409, 'CONFLICT', '价格已变化,请按最新价格重新确认'],
])(
'购买失败把 HTTP %i / %s 映射成 ApiClientError',
async (status, code, message) => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Response(
JSON.stringify({
ok: false,
error: { code, message },
meta: { apiVersion: 'v1' },
}),
{ status, headers: { 'Content-Type': 'application/json' } },
),
),
);
const error = await purchaseGame(
'game-1',
{ expectedPriceMudPoints: 120 },
'purchase-key-1',
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ApiClientError);
expect((error as ApiClientError).status).toBe(status);
expect((error as ApiClientError).code).toBe(code);
},
);
it('余额不足映射成 400 与 INSUFFICIENT_MUD_POINTS,供调用方引导充值', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Response(
JSON.stringify({
ok: false,
error: {
code: 'INSUFFICIENT_MUD_POINTS',
message: '泥点余额不足',
},
meta: { apiVersion: 'v1' },
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
),
);
await expect(
purchaseGame('game-1', { expectedPriceMudPoints: 120 }, 'purchase-key-1'),
).rejects.toMatchObject({
name: 'ApiClientError',
status: 400,
code: 'INSUFFICIENT_MUD_POINTS',
});
});
it('播放会话被拒绝时同样抛 ApiClientError,而不是普通 Error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Response(
JSON.stringify({
ok: false,
error: { code: 'FORBIDDEN', message: '尚未购买这款游戏' },
meta: { apiVersion: 'v1' },
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } },
),
),
);
await expect(createGamePlaySession('game-1')).rejects.toMatchObject({
name: 'ApiClientError',
status: 403,
code: 'FORBIDDEN',
});
});
});
+74 -5
View File
@@ -10,6 +10,9 @@ import type {
GameDistributionGame,
GameDistributionLineageResponse,
GameDistributionMyReviewResponse,
GameDistributionPlaySessionResponse,
GameDistributionPurchaseRequest,
GameDistributionPurchaseResponse,
GameDistributionRecoveryAction,
GameDistributionReviewsResponse,
GameDistributionSaveReviewRequest,
@@ -44,7 +47,6 @@ export type {
GameDistributionVersionDetail,
GameDistributionVersionSummary,
} from '../../packages/shared/src/contracts/gameDistribution';
export type GameCategory = GameDistributionCategory;
export type GameDeviceSupport = GameDistributionDeviceSupport;
export type GameAuthor = GameDistributionAuthor;
@@ -81,6 +83,18 @@ const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = {
clearAuthOnUnauthorized: false,
};
/**
* 公开读接口的「可选鉴权」。
*
* 已登录时公共封装配上 Bearer,服务端据此算真实购买态与是否下发付费入口;未登录时按匿名请求,
* 不主动补票、也不因 401 影响全局登录态——游客浏览详情不会被要求登录。
*/
const OPTIONAL_AUTH_REQUEST_OPTIONS: ApiRequestOptions = {
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
/**
* 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401
* 也不刷新会话、不改动全局登录态——直接复用后台请求已有的同一份鉴权降级策略。
@@ -354,14 +368,20 @@ export async function updateGameForkAuthorization(
);
}
/**
* 读取游戏公开详情。
*
* 走「可选鉴权」:公开资料对所有人可见,但已登录时必须带上身份,否则服务端算不出 `purchased`,
* 已购买用户也会拿到空的付费入口。
*/
export async function getGame(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionGameWithCollection>(
`/games/${encodeURIComponent(normalizedId)}`,
{ method: 'GET' },
{ method: 'GET', cache: 'no-store' },
'读取游戏详情失败',
PUBLIC_GAME_REQUEST_OPTIONS,
OPTIONAL_AUTH_REQUEST_OPTIONS,
);
}
@@ -414,7 +434,8 @@ function normalizeThemeList(value: unknown): GameDistributionThemeSummary[] {
return value.flatMap((item) => {
if (!item || typeof item !== 'object') return [];
const theme = item as Partial<GameDistributionThemeSummary>;
const themeId = typeof theme.themeId === 'string' ? theme.themeId.trim() : '';
const themeId =
typeof theme.themeId === 'string' ? theme.themeId.trim() : '';
const name = typeof theme.name === 'string' ? theme.name.trim() : '';
if (!themeId || !name) return [];
return [
@@ -538,7 +559,11 @@ export async function listMyCollections() {
const payload = await requestJson<{
games?: unknown;
nextCursor?: string | null;
}>('/my-collections', { method: 'GET', cache: 'no-store' }, '读取我的收藏失败');
}>(
'/my-collections',
{ method: 'GET', cache: 'no-store' },
'读取我的收藏失败',
);
return {
games: normalizeGameList(payload.games),
nextCursor: payload.nextCursor ?? null,
@@ -598,6 +623,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`,