4a2b270714
- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。 - 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。 - 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。 - 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。 - 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。 - AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。 - AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。 - 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
247 lines
8.9 KiB
TypeScript
247 lines
8.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* AGC「一键发布」的真实链路验证(默认跳过,按需开启)。
|
|
*
|
|
* 运行方式:
|
|
* GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:10001 \
|
|
* npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
|
|
*
|
|
* 开启后测试会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`(而不是
|
|
* mock 请求层)调用 AGC 的发布函数,覆盖:本地导出包读取、创建游戏、同
|
|
* `localProjectId` 复用游戏身份、真实 ZIP 上传、送审与版本回读。
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
|
|
import JSZip from 'jszip';
|
|
import { expect, test, vi } from 'vitest';
|
|
|
|
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
|
import type { LocalProjectExportPackagePayload } from '../src/app/types';
|
|
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
|
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
|
|
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
|
|
|
/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */
|
|
const LIVE_COVER_PNG_BASE64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
|
|
|
function buildLiveCoverFile() {
|
|
const bytes = Buffer.from(LIVE_COVER_PNG_BASE64, 'base64');
|
|
return new File([new Uint8Array(bytes)], 'agc-live-cover.png', {
|
|
type: 'image/png',
|
|
});
|
|
}
|
|
|
|
const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '')
|
|
.trim()
|
|
.replace(/\/+$/u, '');
|
|
const liveTest = liveBaseUrl ? test : test.skip;
|
|
|
|
const realFetch = globalThis.fetch.bind(globalThis);
|
|
const ENVELOPE_HEADERS = { 'x-genarrative-response-envelope': 'v1' };
|
|
|
|
function apiUrl(path: string) {
|
|
return new URL(path, `${liveBaseUrl}/`).toString();
|
|
}
|
|
|
|
/** 把 AGC 的相对请求改写到本地栈;其余语义(头部、超时包装)保持真实实现。 */
|
|
function installFetchBridge() {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url =
|
|
typeof input === 'string'
|
|
? apiUrl(input)
|
|
: input instanceof URL
|
|
? input.toString()
|
|
: input;
|
|
const body = init?.body;
|
|
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
// jsdom 的 Blob/ArrayBuffer 属于另一个 realm,且旧版 jsdom 没有
|
|
// Blob.arrayBuffer;统一读成字节后复制为 Node 侧 Buffer 再转发。
|
|
const bytes = await readBlobBytes(body);
|
|
return realFetch(url as string, {
|
|
...init,
|
|
body: Buffer.from(bytes),
|
|
});
|
|
}
|
|
return realFetch(url as string, init);
|
|
},
|
|
);
|
|
}
|
|
|
|
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
|
|
const maybeArrayBuffer = (
|
|
blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }
|
|
).arrayBuffer;
|
|
if (typeof maybeArrayBuffer === 'function') {
|
|
return new Uint8Array(await maybeArrayBuffer.call(blob));
|
|
}
|
|
return await new Promise<Uint8Array>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = reader.result;
|
|
resolve(
|
|
result instanceof ArrayBuffer
|
|
? new Uint8Array(result)
|
|
: new Uint8Array(0),
|
|
);
|
|
};
|
|
reader.onerror = () =>
|
|
reject(reader.error ?? new Error('读取发行包字节失败'));
|
|
reader.readAsArrayBuffer(blob);
|
|
});
|
|
}
|
|
|
|
async function unwrap<T>(response: Response): Promise<T> {
|
|
const text = await response.text();
|
|
const parsed: unknown = JSON.parse(text);
|
|
if (
|
|
!parsed ||
|
|
typeof parsed !== 'object' ||
|
|
(parsed as { ok?: boolean }).ok !== true
|
|
) {
|
|
throw new Error(`后端返回失败:${text.slice(0, 400)}`);
|
|
}
|
|
return (parsed as { data: T }).data;
|
|
}
|
|
|
|
async function registerAuthor(): Promise<string> {
|
|
const phone = `136${String(Date.now()).slice(-8)}`;
|
|
const response = await realFetch(apiUrl('/api/auth/entry'), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...ENVELOPE_HEADERS },
|
|
body: JSON.stringify({
|
|
purePhoneNumber: phone,
|
|
password: 'GenTest123!',
|
|
}),
|
|
});
|
|
const data = await unwrap<{ token: string }>(response);
|
|
return data.token;
|
|
}
|
|
|
|
async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
|
|
const zip = new JSZip();
|
|
const indexHtml =
|
|
'<!doctype html><html><head><meta charset="utf-8"><title>AGC Live</title>' +
|
|
'<script src="assets/app.js"></script></head><body><h1>AGC-LIVE</h1></body></html>';
|
|
const appJs =
|
|
'window.__agcLive=1;document.documentElement.dataset.booted="agc";';
|
|
zip.file('index.html', indexHtml);
|
|
zip.file('assets/app.js', appJs);
|
|
const bytes = await zip.generateAsync({ type: 'uint8array' });
|
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
return {
|
|
packageRelativePath: 'dist/game.zip',
|
|
packageBytes: Array.from(bytes),
|
|
packageSha256: sha256,
|
|
packageSizeBytes: bytes.length,
|
|
files: [
|
|
{
|
|
path: 'index.html',
|
|
sizeBytes: Buffer.byteLength(indexHtml),
|
|
sha256: createHash('sha256').update(indexHtml).digest('hex'),
|
|
},
|
|
{
|
|
path: 'assets/app.js',
|
|
sizeBytes: Buffer.byteLength(appJs),
|
|
sha256: createHash('sha256').update(appJs).digest('hex'),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
liveTest(
|
|
'AGC 发布函数在真实后端完成创建、上传、送审并在重复发布时复用游戏身份',
|
|
async () => {
|
|
installFetchBridge();
|
|
const token = await registerAuthor();
|
|
setStoredAccessToken(token);
|
|
|
|
const payload = await buildExportPayload();
|
|
const stamp = String(Date.now());
|
|
const manifest = {
|
|
projectId: `agc-live-${stamp}`,
|
|
name: `AGC 真实发布${stamp.slice(-4)}`,
|
|
goal: '验证 AGC 一键发布链路',
|
|
} as unknown as GameCreationAppManifest;
|
|
const invoke = vi.fn(async () => payload);
|
|
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → confirm。
|
|
const uploadedCover = await uploadPlatformMediaAsset({
|
|
file: buildLiveCoverFile(),
|
|
assetKind: 'game_distribution_cover',
|
|
pathSegments: ['game-distribution', 'cover', stamp],
|
|
entityId: 'game-distribution-cover',
|
|
// jsdom 里没有 Tauri HTTP 插件,复用测试注入的 fetch bridge 直连 dev OSS。
|
|
fetchImpl: (input, init) => realFetch(apiUrl(input), init),
|
|
});
|
|
expect(uploadedCover.assetObjectId).toMatch(/\S/u);
|
|
const metadata = {
|
|
summary: '由 AGC 发布函数真实提交',
|
|
description:
|
|
'集成验证:AGC 发布函数 → 本地 api-server → SpacetimeDB / 私有 OSS',
|
|
category: '益智' as const,
|
|
tags: ['集成验证'],
|
|
deviceSupport: { desktop: true, mobile: false, touch: false },
|
|
inputModes: ['keyboard', 'mouse'] as const,
|
|
orientation: 'landscape' as const,
|
|
coverAssetId: uploadedCover.assetObjectId,
|
|
screenshots: [] as string[],
|
|
};
|
|
|
|
const first = await publishLocalProjectGame({
|
|
invoke,
|
|
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
|
|
packageRelativePath: 'dist/game.zip',
|
|
manifest,
|
|
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
|
|
});
|
|
expect(first.status).toBe('pending_review');
|
|
expect(first.versionNumber).toBe(1);
|
|
expect(first.packageSha256).toBe(payload.packageSha256);
|
|
|
|
const readResult = await unwrap<{
|
|
version: { versionId: string; status: string; recoveryAction: string };
|
|
game: { id: string };
|
|
}>(
|
|
await realFetch(
|
|
apiUrl(`/api/game-distribution/versions/${first.versionId}`),
|
|
{ headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS } },
|
|
),
|
|
);
|
|
expect(readResult.version.status).toBe('pending_review');
|
|
expect(readResult.version.recoveryAction).toBe('wait');
|
|
expect(readResult.game.id).toBe(first.gameId);
|
|
|
|
// 第二次发布不带旧幂等键:同 localProjectId 必须复用同一 gameId 并新增版本。
|
|
const second = await publishLocalProjectGame({
|
|
invoke,
|
|
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
|
|
packageRelativePath: 'dist/game.zip',
|
|
manifest,
|
|
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
|
|
});
|
|
expect(second.gameId).toBe(first.gameId);
|
|
expect(second.versionNumber).toBe(first.versionNumber + 1);
|
|
|
|
const myGames = await unwrap<{
|
|
games: Array<{
|
|
id: string;
|
|
latestVersion: { versionId: string; status: string } | null;
|
|
}>;
|
|
}>(
|
|
await realFetch(apiUrl('/api/game-distribution/my-games'), {
|
|
headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS },
|
|
}),
|
|
);
|
|
const published = myGames.games.find((game) => game.id === first.gameId);
|
|
expect(published?.latestVersion?.versionId).toBe(second.versionId);
|
|
expect(published?.latestVersion?.status).toBe('pending_review');
|
|
},
|
|
120_000,
|
|
);
|
|
|
|
function setStoredAccessToken(token: string) {
|
|
setStoredAuthAccessToken(token);
|
|
}
|