发行包上限提升到 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:
kdletters
2026-09-23 19:04:45 +08:00
parent c38d07044a
commit c2c5e1ced5
26 changed files with 1779 additions and 169 deletions
@@ -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,
+13 -4
View File
@@ -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 };