发行包上限提升到 200 MiB 并支持 AGC 分片续传上传
- 发行包上限 100→200 MiB、展开总量 250→500 MiB,整包路由请求体上限继续从包上限派生;发行静态资源进程内缓存预算提到 256 MiB - 反代放行量同步放宽到 210 MiB:Nginx 三份模板的 client_max_body_size、Pingora 网关默认值与 env 样例、路由对照矩阵 - platform-oss 新增内部对象追加写 append_internal_object(_with_retry),以 OSS 返回的 next-append-position 作为权威已收字节 - api-server 新增 upload-state / chunk / complete / reset 四条分片路由,抽出共享收口 confirm_validated_package;偏移不符返回 409 与权威偏移,校验失败删除半包并落 upload_failed - AGC 新增原生上传器 game_package_upload.rs(内容寻址暂存、分片续传、受控重试、进度事件)与 prepare / upload 两条命令,退役整包回传命令 - 渲染进程改为 prepare → 创建游戏 → 创建版本 → 原生分片上传 → 送审,LocalProjectExportPackagePayload 整包类型退役 - 同时修正 live 用例无法指向本地栈的两处基础设施问题:平台基址按传入 URL 选择,桥接层把 jsdom realm 的 Headers / Blob / FormData 降级成 Node 原生值 - 测试:platform-oss 74、api-server game_distribution 23、AGC 原生 4、发布相关前端 20;live 用例补真实栈「中断 → 续传 → 确认」断言(分片偏移序列 [0, 8388608]) - 文档:玩法创作主规范的上传合同、运维与 Pingora 文档、决策记录、发行里程碑口径,以及新增的续传里程碑与实施计划
This commit is contained in:
@@ -5935,14 +5935,81 @@ pub(crate) async fn export_local_project_package(
|
||||
export_local_project_package_for_publish_at(root).await
|
||||
}
|
||||
|
||||
/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。
|
||||
///
|
||||
/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过
|
||||
/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_export_package(
|
||||
pub(crate) fn prepare_local_project_game_package(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
package_relative_path: String,
|
||||
) -> Result<LocalProjectExportPackagePayload, String> {
|
||||
) -> Result<crate::game_package_upload::StagedGamePackage, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.export_package")?;
|
||||
read_local_project_export_package_at(root, package_relative_path.trim())
|
||||
let payload = read_local_project_export_package_at(root, package_relative_path.trim())?;
|
||||
let staging_dir = game_package_upload_staging_dir(&app)?;
|
||||
let mut staged = crate::game_package_upload::stage_game_package_bytes(
|
||||
&staging_dir,
|
||||
&payload.package_sha256,
|
||||
&payload.package_bytes,
|
||||
)?;
|
||||
staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX);
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn upload_local_project_game_package(
|
||||
app: tauri::AppHandle,
|
||||
staging_path: String,
|
||||
version_id: String,
|
||||
api_base_url: String,
|
||||
access_token: String,
|
||||
idempotency_key: String,
|
||||
) -> Result<crate::game_package_upload::GamePackageUploadOutcome, String> {
|
||||
let staging_dir = game_package_upload_staging_dir(&app)?;
|
||||
let resolved_path =
|
||||
crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(|error| format!("创建上传客户端失败:{error}"))?;
|
||||
let version_id = version_id.trim().to_string();
|
||||
if version_id.is_empty() {
|
||||
return Err("缺少发行版本标识".to_string());
|
||||
}
|
||||
let emit_handle = app.clone();
|
||||
let progress_version_id = version_id.clone();
|
||||
crate::game_package_upload::upload_staged_game_package(
|
||||
&client,
|
||||
crate::game_package_upload::GamePackageUploadRequest {
|
||||
staging_path: &resolved_path,
|
||||
version_id: &version_id,
|
||||
api_base_url: api_base_url.trim(),
|
||||
access_token: access_token.trim(),
|
||||
idempotency_key: idempotency_key.trim(),
|
||||
},
|
||||
move |received_bytes, total_bytes| {
|
||||
let _ = emit_handle.emit(
|
||||
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
crate::game_package_upload::progress_event_payload(
|
||||
&progress_version_id,
|
||||
received_bytes,
|
||||
total_bytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map(|app_data_root| {
|
||||
crate::game_package_upload::game_package_upload_staging_dir(&app_data_root)
|
||||
})
|
||||
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -132,6 +132,7 @@ mod editor_adapter;
|
||||
mod editor_adapters;
|
||||
mod environment_check;
|
||||
pub mod error_report;
|
||||
mod game_package_upload;
|
||||
mod git_inspect;
|
||||
mod goal;
|
||||
mod http_client;
|
||||
@@ -2757,7 +2758,8 @@ fn main() {
|
||||
build_local_project_index,
|
||||
create_local_project_checkpoint,
|
||||
export_local_project_package,
|
||||
read_local_project_export_package,
|
||||
prepare_local_project_game_package,
|
||||
upload_local_project_game_package,
|
||||
list_local_project_export_packages,
|
||||
diff_local_project_checkpoint,
|
||||
restore_local_project_checkpoint,
|
||||
|
||||
@@ -796,12 +796,21 @@ export interface LocalProjectExportPackageFileDigest {
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface LocalProjectExportPackagePayload {
|
||||
packageRelativePath: string;
|
||||
packageBytes: number[];
|
||||
/**
|
||||
* 已暂存的归一化发行包:发布链路只传递这个摘要与路径,整包字节留在原生进程里,
|
||||
* 不再经过 WebView IPC。
|
||||
*/
|
||||
export interface StagedGamePackage {
|
||||
stagingPath: string;
|
||||
packageSha256: string;
|
||||
packageSizeBytes: number;
|
||||
files: LocalProjectExportPackageFileDigest[];
|
||||
packageFileCount: number;
|
||||
}
|
||||
|
||||
export interface GamePackageUploadOutcome {
|
||||
versionId: string;
|
||||
status: string;
|
||||
uploadedBytes: number;
|
||||
}
|
||||
|
||||
export interface LocalProjectExportPackageSummary {
|
||||
|
||||
@@ -7,10 +7,12 @@ import type {
|
||||
GameDistributionOrientation,
|
||||
} from '../../../../packages/shared/src/contracts/gameDistribution';
|
||||
import type {
|
||||
LocalProjectExportPackagePayload,
|
||||
GamePackageUploadOutcome,
|
||||
StagedGamePackage,
|
||||
TauriInvoke,
|
||||
} from '../app/types';
|
||||
import { requestClientApi } from './clientApi';
|
||||
import { getStoredAuthAccessToken, requestClientApi } from './clientApi';
|
||||
import { getClientServerBaseUrl } from './clientHttp';
|
||||
|
||||
export type GameDistributionPublishMetadata = {
|
||||
title: string;
|
||||
@@ -328,20 +330,20 @@ export async function publishLocalProjectGame(args: {
|
||||
if (!projectPath || !packageRelativePath) {
|
||||
throw new Error('发布需要绑定本地项目和试玩包');
|
||||
}
|
||||
const payload = await args.invoke<LocalProjectExportPackagePayload>(
|
||||
'read_local_project_export_package',
|
||||
// 整包字节只留在原生进程:这里拿到的是归一化后的摘要与内容寻址暂存路径,
|
||||
// 上传由原生侧按服务端分片大小完成,中断后同一暂存文件可直接续传。
|
||||
const staged = await args.invoke<StagedGamePackage>(
|
||||
'prepare_local_project_game_package',
|
||||
{ projectPath, packageRelativePath },
|
||||
);
|
||||
if (
|
||||
!payload.packageBytes.length ||
|
||||
payload.packageSizeBytes !== payload.packageBytes.length ||
|
||||
payload.files.length === 0
|
||||
!staged.stagingPath.trim() ||
|
||||
staged.packageSha256.length !== 64 ||
|
||||
staged.packageSizeBytes <= 0 ||
|
||||
staged.packageFileCount <= 0
|
||||
) {
|
||||
throw new Error('本地发行包摘要无效,请重新导出试玩包');
|
||||
}
|
||||
if (payload.packageRelativePath !== packageRelativePath) {
|
||||
throw new Error('本地发行包路径已变化,请重新导出试玩包');
|
||||
}
|
||||
|
||||
const metadata = normalizeMetadata(args.manifest, args.metadata);
|
||||
const localProjectId = args.manifest.projectId.trim();
|
||||
@@ -369,9 +371,9 @@ export async function publishLocalProjectGame(args: {
|
||||
|
||||
const versionRequest: GameDistributionCreateVersionRequest = {
|
||||
localProjectId,
|
||||
packageSha256: payload.packageSha256,
|
||||
packageBytes: payload.packageSizeBytes,
|
||||
packageFileCount: payload.files.length,
|
||||
packageSha256: staged.packageSha256,
|
||||
packageBytes: staged.packageSizeBytes,
|
||||
packageFileCount: staged.packageFileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata,
|
||||
};
|
||||
@@ -391,23 +393,19 @@ export async function publishLocalProjectGame(args: {
|
||||
throw new Error('创建发行版本未返回版本 ID');
|
||||
}
|
||||
|
||||
const packageBody = new Blob([new Uint8Array(payload.packageBytes)], {
|
||||
type: 'application/zip',
|
||||
});
|
||||
const uploaded = await requestClientApi<{
|
||||
versionId: string;
|
||||
status: string;
|
||||
}>(
|
||||
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/package`,
|
||||
const accessToken = getStoredAuthAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new Error('陶泥儿登录凭据缺失,请重新登录');
|
||||
}
|
||||
const uploaded = await args.invoke<GamePackageUploadOutcome>(
|
||||
'upload_local_project_game_package',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Idempotency-Key': `${rootKey}:upload`,
|
||||
},
|
||||
body: packageBody,
|
||||
stagingPath: staged.stagingPath,
|
||||
versionId: version.versionId,
|
||||
apiBaseUrl: getClientServerBaseUrl(),
|
||||
accessToken,
|
||||
idempotencyKey: `${rootKey}:upload`,
|
||||
},
|
||||
'上传游戏发行包失败',
|
||||
);
|
||||
const submitted = await requestClientApi<{
|
||||
game?: { publicationRevision?: number };
|
||||
@@ -431,8 +429,8 @@ export async function publishLocalProjectGame(args: {
|
||||
versionId: version.versionId,
|
||||
versionNumber: version.versionNumber,
|
||||
status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review',
|
||||
packageSha256: payload.packageSha256,
|
||||
packageSizeBytes: payload.packageSizeBytes,
|
||||
fileCount: payload.files.length,
|
||||
packageSha256: staged.packageSha256,
|
||||
packageSizeBytes: staged.packageSizeBytes,
|
||||
fileCount: staged.packageFileCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ vi.mock('../src/services/errorReporting', () => ({
|
||||
captureClientError: vi.fn(),
|
||||
}));
|
||||
|
||||
// 原生侧上传需要登录凭据;这里只钉住「取到了 token」这一件事。
|
||||
vi.mock('../src/services/clientApi', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../src/services/clientApi')>()),
|
||||
getStoredAuthAccessToken: () => 'test-access-token',
|
||||
}));
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
generateGameDistributionCover,
|
||||
@@ -29,6 +35,14 @@ const MANIFEST = {
|
||||
goal: '守住轨道城',
|
||||
} as unknown as GameCreationAppManifest;
|
||||
|
||||
/** 归一化发行包的暂存摘要;发布链路只应传递它,不再传整包字节。 */
|
||||
const STAGED_PACKAGE = {
|
||||
stagingPath: 'C:/app-data/game-package-staging/aaaa.zip',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1024,
|
||||
packageFileCount: 1,
|
||||
};
|
||||
|
||||
function jsonResponse(payload: unknown) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -66,23 +80,25 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
|
||||
status: 'awaiting_upload',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ versionId: 'gamever_1', status: 'uploaded' }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ version: { status: 'pending_review' } }),
|
||||
);
|
||||
|
||||
const invokeCalls: Array<{ command: string; args: unknown }> = [];
|
||||
const result = await publishLocalProjectGame({
|
||||
invoke: (async (command: string) => {
|
||||
expect(command).toBe('read_local_project_export_package');
|
||||
return {
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1, 2, 3],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 3,
|
||||
files: [{ path: 'index.html', sizeBytes: 3, sha256: 'a'.repeat(64) }],
|
||||
};
|
||||
invoke: (async (command: string, args?: Record<string, unknown>) => {
|
||||
invokeCalls.push({ command, args });
|
||||
if (command === 'prepare_local_project_game_package') {
|
||||
return STAGED_PACKAGE;
|
||||
}
|
||||
if (command === 'upload_local_project_game_package') {
|
||||
return {
|
||||
versionId: 'gamever_1',
|
||||
status: 'uploaded',
|
||||
uploadedBytes: STAGED_PACKAGE.packageSizeBytes,
|
||||
};
|
||||
}
|
||||
throw new Error(`未预期的命令:${command}`);
|
||||
}) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
@@ -113,18 +129,26 @@ test('发布时携带本地项目标识,让重复发布复用同一个平台
|
||||
|
||||
expect(result.gameId).toBe('game_1');
|
||||
expect(result.versionId).toBe('gamever_1');
|
||||
|
||||
// 关键回归:整包字节不再经过 IPC,上传交给原生侧按版本 ID + 暂存路径完成。
|
||||
const uploadCall = invokeCalls.find(
|
||||
(call) => call.command === 'upload_local_project_game_package',
|
||||
);
|
||||
expect(uploadCall?.args).toMatchObject({
|
||||
stagingPath: STAGED_PACKAGE.stagingPath,
|
||||
versionId: 'gamever_1',
|
||||
apiBaseUrl: 'https://dev.genarrative.world',
|
||||
accessToken: 'test-access-token',
|
||||
});
|
||||
expect(Object.keys(uploadCall?.args ?? {})).not.toContain('packageBytes');
|
||||
// 三次 HTTP:创建游戏、创建版本、送审;上传不再占用一条 HTTP 调用。
|
||||
expect(fetchClientHttp).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test('缺少本地项目标识时在发起请求前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
invoke: (async () => STAGED_PACKAGE) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
|
||||
@@ -137,13 +161,7 @@ test('缺少本地项目标识时在发起请求前失败关闭', async () => {
|
||||
test('缺少封面时在创建游戏前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
invoke: (async () => STAGED_PACKAGE) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
@@ -156,13 +174,7 @@ test('缺少封面时在创建游戏前失败关闭', async () => {
|
||||
test('截图超过 6 张时在创建游戏前失败关闭', async () => {
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke: (async () => ({
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
packageBytes: [1],
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 1,
|
||||
files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }],
|
||||
})) as never,
|
||||
invoke: (async () => STAGED_PACKAGE) as never,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
|
||||
@@ -7,18 +7,23 @@
|
||||
* npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts
|
||||
*
|
||||
* 开启后测试会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`(而不是
|
||||
* mock 请求层)调用 AGC 的发布函数,覆盖:本地导出包读取、创建游戏、同
|
||||
* `localProjectId` 复用游戏身份、真实 ZIP 上传、送审与版本回读。
|
||||
* mock 请求层)调用 AGC 的发布函数,覆盖:本地发行包暂存摘要、创建游戏、同
|
||||
* `localProjectId` 复用游戏身份、真实分片上传、送审与版本回读。
|
||||
*
|
||||
* jsdom 里没有 Tauri 运行时,`upload_local_project_game_package` 由本测试按服务端
|
||||
* 分片协议(upload-state → chunk → complete)代跑,等同于原生上传器的行为;
|
||||
* 原生实现自身的分片规划、权威偏移续传与错误分类在 Rust 单测里覆盖。
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomBytes } 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 type { StagedGamePackage } from '../src/app/types';
|
||||
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
import { setStoredAuthAccessToken } from '../src/services/clientAuth';
|
||||
import { setClientServerSelection } from '../src/services/clientHttp';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
|
||||
/** 1x1 透明 PNG:真实上传一张合法图片作为封面,避免依赖本地素材文件。 */
|
||||
@@ -37,6 +42,12 @@ const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '')
|
||||
.replace(/\/+$/u, '');
|
||||
const liveTest = liveBaseUrl ? test : test.skip;
|
||||
|
||||
// AGC 服务默认按渠道选 dev / release 域名;跑真实链路时把「平台服务器」切到传入的本地栈,
|
||||
// 否则请求会打到线上域名而不是这台机器上的 api-server。
|
||||
if (liveBaseUrl) {
|
||||
setClientServerSelection({ preset: 'custom', customBaseUrl: liveBaseUrl });
|
||||
}
|
||||
|
||||
const realFetch = globalThis.fetch.bind(globalThis);
|
||||
const ENVELOPE_HEADERS = { 'x-genarrative-response-envelope': 'v1' };
|
||||
|
||||
@@ -55,21 +66,73 @@ function installFetchBridge() {
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input;
|
||||
// jsdom realm 的 Headers / AbortSignal / Blob 都不是 undici 认得的类型(同 2026-09-20
|
||||
// 那条「跨 realm BodyInit 被 undici 拒绝」的坑):统一降级成 Node 侧能接受的原生值。
|
||||
const headers = init?.headers
|
||||
? Object.fromEntries(Array.from(new Headers(init.headers).entries()))
|
||||
: undefined;
|
||||
const signal = undefined;
|
||||
const body = init?.body;
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
||||
// jsdom 的 FormData 同样不被 undici 接受:这里手工序列化成 multipart 字节。
|
||||
const multipart = await serializeFormData(body);
|
||||
return realFetch(url as string, {
|
||||
...init,
|
||||
headers: { ...headers, 'Content-Type': multipart.contentType },
|
||||
signal,
|
||||
body: multipart.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,
|
||||
headers,
|
||||
signal,
|
||||
body: Buffer.from(bytes),
|
||||
});
|
||||
}
|
||||
return realFetch(url as string, init);
|
||||
return realFetch(url as string, { ...init, headers, signal });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function serializeFormData(form: FormData): Promise<{
|
||||
body: Buffer;
|
||||
contentType: string;
|
||||
}> {
|
||||
const boundary = `----agcLive${Date.now().toString(16)}`;
|
||||
const chunks: Buffer[] = [];
|
||||
for (const [name, value] of form.entries()) {
|
||||
if (typeof value === 'string') {
|
||||
chunks.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const bytes = await readBlobBytes(value);
|
||||
const fileName =
|
||||
(value as File).name || `agc-live-${Date.now().toString(16)}.bin`;
|
||||
const contentType = value.type || 'application/octet-stream';
|
||||
chunks.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
||||
),
|
||||
);
|
||||
chunks.push(Buffer.from(bytes));
|
||||
chunks.push(Buffer.from('\r\n'));
|
||||
}
|
||||
chunks.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
return {
|
||||
body: Buffer.concat(chunks),
|
||||
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
|
||||
const maybeArrayBuffer = (
|
||||
blob as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }
|
||||
@@ -120,7 +183,10 @@ async function registerAuthor(): Promise<string> {
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
|
||||
async function buildStagedPackage(): Promise<{
|
||||
staged: StagedGamePackage;
|
||||
bytes: Uint8Array;
|
||||
}> {
|
||||
const zip = new JSZip();
|
||||
const indexHtml =
|
||||
'<!doctype html><html><head><meta charset="utf-8"><title>AGC Live</title>' +
|
||||
@@ -129,28 +195,169 @@ async function buildExportPayload(): Promise<LocalProjectExportPackagePayload> {
|
||||
'window.__agcLive=1;document.documentElement.dataset.booted="agc";';
|
||||
zip.file('index.html', indexHtml);
|
||||
zip.file('assets/app.js', appJs);
|
||||
// 让发行包超过单个分片(8 MiB):分片续传只有跨片才有意义,随机字节保证不可压缩。
|
||||
zip.file('assets/bulk.bin', randomBytes(9 * 1024 * 1024));
|
||||
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'),
|
||||
},
|
||||
],
|
||||
staged: {
|
||||
stagingPath: '/tmp/agc-live-staging/game.zip',
|
||||
packageSha256: sha256,
|
||||
packageSizeBytes: bytes.length,
|
||||
packageFileCount: 3,
|
||||
},
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
type PackageUploadState = {
|
||||
receivedBytes: number;
|
||||
chunkBytes: number;
|
||||
declaredPackageBytes: number;
|
||||
};
|
||||
|
||||
function packageAuthHeaders(token: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...ENVELOPE_HEADERS,
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取服务端权威已收字节(原生上传器同样以它为准)。 */
|
||||
async function readPackageUploadState(
|
||||
versionId: string,
|
||||
token: string,
|
||||
): Promise<PackageUploadState> {
|
||||
return await unwrap<PackageUploadState>(
|
||||
await realFetch(
|
||||
apiUrl(
|
||||
`/api/game-distribution/versions/${versionId}/package/upload-state`,
|
||||
),
|
||||
{ headers: packageAuthHeaders(token) },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 上传一个分片;偏移由调用方按权威偏移给出。 */
|
||||
async function uploadPackageChunk(input: {
|
||||
versionId: string;
|
||||
token: string;
|
||||
idempotencyKey: string;
|
||||
offset: number;
|
||||
body: Uint8Array;
|
||||
}): Promise<number> {
|
||||
const response = await realFetch(
|
||||
apiUrl(`/api/game-distribution/versions/${input.versionId}/package/chunk`),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...packageAuthHeaders(input.token),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'x-genarrative-upload-offset': String(input.offset),
|
||||
'Idempotency-Key': `${input.idempotencyKey}:chunk`,
|
||||
},
|
||||
body: Buffer.from(input.body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`分片上传失败:${response.status} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
data?: { receivedBytes?: number };
|
||||
receivedBytes?: number;
|
||||
};
|
||||
return payload.data?.receivedBytes ?? payload.receivedBytes ?? input.offset;
|
||||
}
|
||||
|
||||
async function completePackageUpload(input: {
|
||||
versionId: string;
|
||||
token: string;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
return await unwrap<{ versionId: string; status: string }>(
|
||||
await realFetch(
|
||||
apiUrl(
|
||||
`/api/game-distribution/versions/${input.versionId}/package/complete`,
|
||||
),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...packageAuthHeaders(input.token),
|
||||
'Idempotency-Key': `${input.idempotencyKey}:complete`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 从权威偏移继续发送剩余分片,返回本次实际发送过的偏移序列。 */
|
||||
async function uploadRemainingChunks(input: {
|
||||
versionId: string;
|
||||
bytes: Uint8Array;
|
||||
token: string;
|
||||
idempotencyKey: string;
|
||||
}): Promise<number[]> {
|
||||
const state = await readPackageUploadState(input.versionId, input.token);
|
||||
const sentOffsets: number[] = [];
|
||||
let received = state.receivedBytes;
|
||||
while (received < input.bytes.length) {
|
||||
const length = Math.min(state.chunkBytes, input.bytes.length - received);
|
||||
await uploadPackageChunk({
|
||||
versionId: input.versionId,
|
||||
token: input.token,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
offset: received,
|
||||
body: input.bytes.subarray(received, received + length),
|
||||
});
|
||||
sentOffsets.push(received);
|
||||
received = (await readPackageUploadState(input.versionId, input.token))
|
||||
.receivedBytes;
|
||||
}
|
||||
return sentOffsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按服务端分片协议上传整包:与原生上传器同一套请求形状,用于验证服务端合同。
|
||||
* 第一次调用会**只传第一片就停下**,模拟传输中断;后续调用按权威偏移续传,
|
||||
* 因此这里能直接证明「中断后不重传已收字节」。
|
||||
*/
|
||||
async function uploadStagedPackageViaProtocol(input: {
|
||||
versionId: string;
|
||||
bytes: Uint8Array;
|
||||
token: string;
|
||||
idempotencyKey: string;
|
||||
sentOffsets: number[];
|
||||
}) {
|
||||
const state = await readPackageUploadState(input.versionId, input.token);
|
||||
if (state.receivedBytes === 0) {
|
||||
const firstLength = Math.min(state.chunkBytes, input.bytes.length);
|
||||
await uploadPackageChunk({
|
||||
versionId: input.versionId,
|
||||
token: input.token,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
offset: 0,
|
||||
body: input.bytes.subarray(0, firstLength),
|
||||
});
|
||||
input.sentOffsets.push(0);
|
||||
}
|
||||
input.sentOffsets.push(
|
||||
...(await uploadRemainingChunks({
|
||||
versionId: input.versionId,
|
||||
bytes: input.bytes,
|
||||
token: input.token,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
})),
|
||||
);
|
||||
const completed = await completePackageUpload({
|
||||
versionId: input.versionId,
|
||||
token: input.token,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
});
|
||||
return { versionId: completed.versionId, status: completed.status };
|
||||
}
|
||||
|
||||
liveTest(
|
||||
'AGC 发布函数在真实后端完成创建、上传、送审并在重复发布时复用游戏身份',
|
||||
async () => {
|
||||
@@ -158,22 +365,46 @@ liveTest(
|
||||
const token = await registerAuthor();
|
||||
setStoredAccessToken(token);
|
||||
|
||||
const payload = await buildExportPayload();
|
||||
const { staged, bytes } = await buildStagedPackage();
|
||||
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);
|
||||
// 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。
|
||||
const sentOffsets: number[] = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'prepare_local_project_game_package') {
|
||||
return staged;
|
||||
}
|
||||
if (command === 'upload_local_project_game_package') {
|
||||
const uploaded = await uploadStagedPackageViaProtocol({
|
||||
versionId: String(args?.versionId ?? ''),
|
||||
bytes,
|
||||
token,
|
||||
idempotencyKey: String(args?.idempotencyKey ?? ''),
|
||||
sentOffsets,
|
||||
});
|
||||
return {
|
||||
versionId: uploaded.versionId,
|
||||
status: uploaded.status,
|
||||
uploadedBytes: bytes.length,
|
||||
};
|
||||
}
|
||||
throw new Error(`未预期的命令:${command}`);
|
||||
},
|
||||
);
|
||||
// 服务端要求发布必须带封面:真实走一遍凭证 → 直传 → 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),
|
||||
// jsdom 里没有 Tauri HTTP 插件:直传也走同一个桥,跨 realm 的 FormData 会被
|
||||
// 先序列化成 Node 侧 multipart 字节,否则 OSS 会以 405 拒绝。
|
||||
fetchImpl: (input, init) => globalThis.fetch(input, init),
|
||||
});
|
||||
expect(uploadedCover.assetObjectId).toMatch(/\S/u);
|
||||
const metadata = {
|
||||
@@ -198,7 +429,18 @@ liveTest(
|
||||
});
|
||||
expect(first.status).toBe('pending_review');
|
||||
expect(first.versionNumber).toBe(1);
|
||||
expect(first.packageSha256).toBe(payload.packageSha256);
|
||||
expect(first.packageSha256).toBe(staged.packageSha256);
|
||||
// 分片续传证据:第一片(偏移 0)只发送一次;中断后的续传从权威偏移开始,
|
||||
// 已收字节不重放、也不跳段。
|
||||
expect(sentOffsets[0]).toBe(0);
|
||||
expect(sentOffsets.filter((offset) => offset === 0)).toHaveLength(1);
|
||||
expect(sentOffsets[1]).toBeGreaterThan(0);
|
||||
expect(sentOffsets).toEqual(
|
||||
Array.from(
|
||||
{ length: Math.ceil(staged.packageSizeBytes / 8 / 1024 / 1024) },
|
||||
(_, index) => index * 8 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
|
||||
const readResult = await unwrap<{
|
||||
version: { versionId: string; status: string; recoveryAction: string };
|
||||
|
||||
Reference in New Issue
Block a user