合并 origin/master:保留 3D 契约与 provider checkpoint,旧玩法表随主线退役

- server-rs/crates/shared-contracts/src/lib.rs:采用主线的模块裁剪(删掉 cfg(any()) 遗留模块、补上 agc_analytics 与 game_distribution),同时保留本分支的 editor_canvas、model3d 模块与 EDITOR_GENERATION_OPERATION_KINDS 导出
- server-rs/crates/spacetime-module/src/migration.rs:主线删除的 410 行旧玩法表 normalize 段保持删除,保留本分支的 provider_kind / provider_task_id 兼容段与对应用例
- docs/【开发运维】本地开发验证与生产运维-2026-05-15.md:校验清单同时保留 Tripo 3D 生成任务与 editor_background_music_generation / model3d_text_to_model / model3d_image_to_model
- .gitignore:补回本分支新增的 3D 模型文件忽略规则(*.glb / *.gltf 等 12 行),压测数据段随主线一并删除
- 共享记忆:本分支的 3D 决策与踩坑条目保留在主线重排后的 decision-log.md 与 pitfalls.md 中
This commit is contained in:
2026-09-23 19:39:30 +08:00
997 changed files with 56264 additions and 80302 deletions
+7
View File
@@ -32,6 +32,13 @@ describe('activeAppTitle', () => {
expect(resolveAppTitleForSelectionStage('image-editor')).toBe(
'美术编辑器 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('games')).toBe('游戏 - 陶泥儿');
expect(resolveAppTitleForSelectionStage('game-mine')).toBe(
'我的游戏 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('game-publish')).toBe(
'发布游戏 - 陶泥儿',
);
});
test('syncs browser and host titles', () => {
+5
View File
@@ -10,6 +10,11 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
project: '项目 - 陶泥儿',
profile: '我的 - 陶泥儿',
'image-editor': '美术编辑器 - 陶泥儿',
games: '游戏 - 陶泥儿',
'game-detail': '游戏详情 - 陶泥儿',
'game-play': '正在游玩 - 陶泥儿',
'game-mine': '我的游戏 - 陶泥儿',
'game-publish': '发布游戏 - 陶泥儿',
};
export function resolveAppTitleForSelectionStage(stage: SelectionStage) {
@@ -5,6 +5,11 @@ const FRONTEND_RUNTIME_CONFIG_API = '/api/runtime/frontend-config';
export type FrontendRuntimeConfig = {
imageEditorAgentSidebarEnabled: boolean;
agcTemplateLibraryEnabled: boolean;
/**
* 游戏发布灰度开关:后端未配置 `game-distribution:publish` 时对已登录作者默认开放,
* 显式收紧后只有白名单/灰度命中的作者为 `true`,匿名恒为 `false`。
*/
gameDistributionPublishEnabled: boolean;
};
export async function loadFrontendRuntimeConfig() {
+250
View File
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
import {
cancelGameVersion,
createGameVersion,
getGame,
getGameVersion,
listGames,
listMyGames,
unpublishGame,
uploadGamePackage,
} from './gameDistributionClient';
describe('gameDistributionClient', () => {
beforeEach(() => {
clearStoredAccessToken({ emit: false });
});
it('按关键词和分类读取后端目录', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
games: [
{
id: 'game-1',
title: '星轨防线',
summary: '测试游戏',
currentVersion: { id: 'v1' },
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
);
const games = await listGames({ keyword: '星轨', category: '动作' });
expect(games).toHaveLength(1);
expect(games[0]?.id).toBe('game-1');
});
it('读取详情时拒绝空 id', async () => {
expect(await getGame('')).toBeNull();
});
it('版本创建携带幂等键并保留后端失败状态', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(
() =>
new Response(JSON.stringify({ message: '包校验失败' }), {
status: 422,
headers: { 'Content-Type': 'application/json' },
}),
),
);
await expect(
createGameVersion(
'game-1',
{
packageSha256: 'x',
packageBytes: 1,
packageFileCount: 1,
packageEntryPath: 'index.html',
gameMetadata: {
title: '测试游戏',
summary: '测试',
category: '益智',
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard'],
orientation: 'responsive',
},
},
'idem-1',
),
).rejects.toMatchObject({ status: 422 });
});
it('发行包上传使用独立版本资源和幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(
() =>
new Response(
JSON.stringify({ versionId: 'version-1', status: 'uploaded' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
vi.stubGlobal('fetch', fetchMock);
setStoredAccessToken('test-access-token', { emit: false });
await uploadGamePackage('version-1', new ArrayBuffer(2), 'idem-upload');
expect(fetchMock).toHaveBeenCalledWith(
'/api/game-distribution/versions/version-1/package',
expect.objectContaining({
method: 'PUT',
body: expect.any(ArrayBuffer),
headers: expect.objectContaining({
'Content-Type': 'application/zip',
'Idempotency-Key': 'idem-upload',
}),
}),
);
});
});
describe('gameDistributionClient 作者接口', () => {
beforeEach(() => {
clearStoredAccessToken({ emit: false });
});
it('读取我的游戏只返回带 id 的条目', async () => {
setStoredAccessToken('author-token', { emit: false });
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
ok: true,
data: { games: [{ id: 'game-1' }, {}, null] },
error: null,
meta: { apiVersion: 'v1' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
),
);
const games = await listMyGames();
expect(games.map((game) => game.id)).toEqual(['game-1']);
const myGamesCall = vi
.mocked(fetch)
.mock.calls.find(([url]) => String(url).includes('/my-games'));
expect(myGamesCall?.[0]).toBe('/api/game-distribution/my-games');
});
it('下架请求携带 CAS 版本与幂等键,空入参在本地失败关闭', async () => {
setStoredAccessToken('author-token', { emit: false });
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
ok: true,
data: { game: { id: 'game-1', status: 'unpublished' } },
error: null,
meta: { apiVersion: 'v1' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
);
vi.stubGlobal('fetch', fetchMock);
await unpublishGame('game-1', 4, 'unpublish-key-1');
const unpublishCalls = () =>
fetchMock.mock.calls.filter(([url]) =>
String(url).includes('/unpublish'),
);
expect(unpublishCalls()).toHaveLength(1);
expect(unpublishCalls()[0]?.[0]).toBe(
'/api/game-distribution/games/game-1/unpublish',
);
expect(unpublishCalls()[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ expectedPublicationRevision: 4 }),
headers: expect.objectContaining({
'Idempotency-Key': 'unpublish-key-1',
}),
}),
);
await expect(unpublishGame(' ', 1, 'k')).rejects.toThrow('缺少游戏编号');
await expect(unpublishGame('game-1', 1, ' ')).rejects.toThrow(
'缺少幂等键',
);
// 本地校验失败不得发出任何下架请求。
expect(unpublishCalls()).toHaveLength(1);
});
it('回读单个版本状态与服务端恢复动作', async () => {
setStoredAccessToken('test-access-token', { emit: false });
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
game: { id: 'game-1' },
version: {
versionId: 'version-1',
status: 'upload_failed',
recoveryAction: 'reupload',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
);
vi.stubGlobal('fetch', fetchMock);
const detail = await getGameVersion('version-1');
expect(detail.version.recoveryAction).toBe('reupload');
const readCall = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/api/game-distribution/versions/version-1'),
);
expect(readCall?.[0]).toBe('/api/game-distribution/versions/version-1');
expect(readCall?.[1]?.method).toBe('GET');
});
it('撤回版本带幂等键与公开修订号,并拒绝空标识', async () => {
setStoredAccessToken('test-access-token', { emit: false });
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ replayed: false }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await cancelGameVersion('version-1', 3, 'idem-1', '看错了');
const cancelCalls = () =>
fetchMock.mock.calls.filter(([url]) =>
String(url).includes('/versions/version-1/cancel'),
);
expect(cancelCalls()).toHaveLength(1);
const cancelCall = cancelCalls()[0];
expect(cancelCall?.[1]?.method).toBe('POST');
expect(
(cancelCall?.[1]?.headers as Record<string, string>)['Idempotency-Key'],
).toBe('idem-1');
expect(JSON.parse(String(cancelCall?.[1]?.body))).toEqual({
expectedPublicationRevision: 3,
reason: '看错了',
});
await expect(cancelGameVersion('', 1, 'idem-2')).rejects.toThrow(
'撤回版本缺少版本编号',
);
await expect(cancelGameVersion('version-1', 1, ' ')).rejects.toThrow(
'撤回版本缺少幂等键',
);
// 本地校验失败不得发出任何撤回请求。
expect(cancelCalls()).toHaveLength(1);
});
});
+284
View File
@@ -0,0 +1,284 @@
import type {
GameDistributionAuthor,
GameDistributionCancelVersionResponse,
GameDistributionCategory,
GameDistributionCreateGameRequest,
GameDistributionCreateVersionRequest,
GameDistributionDeviceSupport,
GameDistributionGame,
GameDistributionVersionDetail,
GameDistributionVersionStatus as GameDistributionVersionStatusValue,
GameDistributionVersionSummary,
} from '../../packages/shared/src/contracts/gameDistribution';
import {
type ApiRequestOptions,
requestJson as requestApiJson,
} from './apiClient';
export type {
GameDistributionAuthor,
GameDistributionDeviceSupport,
GameDistributionGame,
GameDistributionInputMode,
GameDistributionRecoveryAction,
GameDistributionVersionDetail,
GameDistributionVersionSummary,
} from '../../packages/shared/src/contracts/gameDistribution';
export type GameCategory = GameDistributionCategory;
export type GameDeviceSupport = GameDistributionDeviceSupport;
export type GameAuthor = GameDistributionAuthor;
export type GameVersionSummary = GameDistributionVersionSummary;
export type GameListQuery = {
keyword?: string;
category?: GameCategory | '全部';
};
export type GameVersionCreateResult = {
gameId: string;
versionId: string;
versionNumber: number;
status: 'awaiting_upload';
};
const API_BASE = '/api/game-distribution';
function normalizeGameList(value: unknown): GameDistributionGame[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is GameDistributionGame => {
if (!item || typeof item !== 'object') return false;
const game = item as Partial<GameDistributionGame>;
return Boolean(game.id && game.title && game.currentVersion);
});
}
const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = {
skipAuth: true,
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
async function requestJson<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options?: ApiRequestOptions,
) {
return requestApiJson<T>(
`${API_BASE}${path}`,
{
...init,
headers: {
Accept: 'application/json',
...(init.body instanceof FormData ||
(typeof Blob !== 'undefined' && init.body instanceof Blob) ||
init.body instanceof ArrayBuffer
? {}
: init.body
? { 'Content-Type': 'application/json' }
: {}),
...init.headers,
},
},
fallbackMessage,
options,
);
}
export async function listGames(query: GameListQuery = {}) {
const params = new URLSearchParams();
if (query.keyword?.trim()) params.set('keyword', query.keyword.trim());
if (query.category && query.category !== '全部')
params.set('category', query.category);
const payload = await requestJson<unknown>(
`/games${params.size ? `?${params.toString()}` : ''}`,
{ method: 'GET' },
'读取游戏目录失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
return normalizeGameList(
Array.isArray(payload) ? payload : (payload as { games?: unknown[] }).games,
);
}
export type GameDistributionMyGame = GameDistributionGame & {
latestVersion: GameDistributionVersionStatusEntry | null;
versions: GameDistributionVersionStatusEntry[];
};
export type GameDistributionVersionStatusEntry = {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: GameDistributionVersionStatusValue;
publicationRevision: number;
reviewReason?: string | null;
createdAt: string;
updatedAt: string;
};
export async function listMyGames() {
const payload = await requestJson<{ games?: unknown[] }>(
'/my-games',
{ method: 'GET' },
'读取我的游戏失败',
);
const games = Array.isArray(payload?.games) ? payload.games : [];
return games.filter((item): item is GameDistributionMyGame =>
Boolean(item && typeof item === 'object' && (item as { id?: string }).id),
);
}
export async function unpublishGame(
gameId: string,
expectedPublicationRevision: number,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) throw new Error('缺少游戏编号');
if (!normalizedKey) throw new Error('缺少幂等键');
return requestJson<{ game: { id: string; status: string } }>(
`/games/${encodeURIComponent(normalizedGameId)}/unpublish`,
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify({ expectedPublicationRevision }),
},
'下架游戏失败',
);
}
export async function getGame(gameId: string) {
const normalizedId = gameId.trim();
if (!normalizedId) return null;
return requestJson<GameDistributionGame>(
`/games/${encodeURIComponent(normalizedId)}`,
{ method: 'GET' },
'读取游戏详情失败',
PUBLIC_GAME_REQUEST_OPTIONS,
);
}
export async function createGame(
payload: GameDistributionCreateGameRequest,
idempotencyKey: string,
) {
const normalizedKey = idempotencyKey.trim();
if (!normalizedKey) throw new Error('创建游戏缺少幂等键');
return requestJson<GameDistributionGame>(
'/games',
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify(payload),
},
'创建游戏失败',
);
}
export async function createGameVersion(
gameId: string,
payload: GameDistributionCreateVersionRequest,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedIdempotencyKey = idempotencyKey.trim();
if (!normalizedGameId || !normalizedIdempotencyKey) {
throw new Error('游戏版本创建缺少必要标识');
}
return requestJson<GameVersionCreateResult>(
`/games/${encodeURIComponent(normalizedGameId)}/versions`,
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedIdempotencyKey },
body: JSON.stringify(payload),
},
'创建游戏版本失败',
);
}
/** 回读单个版本状态、驳回理由与服务端给出的恢复动作。 */
export async function getGameVersion(versionId: string) {
const normalizedVersionId = versionId.trim();
if (!normalizedVersionId) throw new Error('缺少版本编号');
return requestJson<GameDistributionVersionDetail>(
`/versions/${encodeURIComponent(normalizedVersionId)}`,
{ method: 'GET' },
'读取版本状态失败',
);
}
/** 撤回尚未公开的版本;幂等键由调用方生成,重复提交返回同一结果。 */
export async function cancelGameVersion(
versionId: string,
expectedPublicationRevision: number,
idempotencyKey: string,
reason?: string,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) throw new Error('撤回版本缺少版本编号');
if (!normalizedKey) throw new Error('撤回版本缺少幂等键');
const normalizedReason = reason?.trim();
return requestJson<GameDistributionCancelVersionResponse>(
`/versions/${encodeURIComponent(normalizedVersionId)}/cancel`,
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify({
expectedPublicationRevision,
...(normalizedReason ? { reason: normalizedReason } : {}),
}),
},
'撤回版本失败',
);
}
export async function submitGameVersion(
versionId: string,
expectedPublicationRevision: number,
idempotencyKey: string,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) throw new Error('提交审核缺少版本 ID');
if (!normalizedKey) throw new Error('提交审核缺少幂等键');
return requestJson<{ version?: { status?: string } }>(
`/versions/${encodeURIComponent(normalizedVersionId)}/submit`,
{
method: 'POST',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify({ expectedPublicationRevision }),
},
'提交审核失败',
);
}
export async function uploadGamePackage(
versionId: string,
packageBody: Blob | ArrayBuffer,
idempotencyKey: string,
) {
const normalizedVersionId = versionId.trim();
const normalizedIdempotencyKey = idempotencyKey.trim();
if (!normalizedVersionId || !normalizedIdempotencyKey) {
throw new Error('游戏发行包上传缺少必要标识');
}
return requestJson<{ versionId: string; status: 'uploaded' }>(
`/versions/${encodeURIComponent(normalizedVersionId)}/package`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/zip',
'Idempotency-Key': normalizedIdempotencyKey,
},
body: packageBody,
},
'上传游戏发行包失败',
);
}