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
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:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user