发行包上限提升到 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 };
|
||||
|
||||
@@ -90,8 +90,9 @@ http {
|
||||
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
|
||||
client_max_body_size 64m;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server;
|
||||
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
|
||||
client_max_body_size 210m;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
## 请求体大小
|
||||
|
||||
- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 64m`。
|
||||
- 该值只用于让携带参考图 Data URL 的创作接口抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。
|
||||
- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 210m`。
|
||||
- 该值只用于让携带参考图 Data URL 的创作接口和游戏发行包 PUT(路由上限 200 MiB + 1 KiB)抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。Pingora 网关侧的 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` 必须同样不低于该值,否则请求会在网关层被 413。
|
||||
- 若线上看到 `413 Request Entity Too Large`,并且 access log 里 `request_time=0.000 upstream_status=-`,通常是 Nginx 没有加载该模板或未 reload;先执行 `nginx -T | grep client_max_body_size` 和 `nginx -t` 再检查 `api-server`。
|
||||
|
||||
## gzip
|
||||
|
||||
@@ -119,8 +119,9 @@ server {
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
|
||||
client_max_body_size 64m;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server;
|
||||
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
|
||||
client_max_body_size 210m;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
|
||||
@@ -139,8 +139,9 @@ server {
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。
|
||||
client_max_body_size 64m;
|
||||
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server;
|
||||
# 真实大小限制仍由路由 DefaultBodyLimit(发行包 200 MiB + 1 KiB)和业务字节校验负责。
|
||||
client_max_body_size 210m;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
|
||||
@@ -124,14 +124,14 @@
|
||||
"nginx": {
|
||||
"production": [
|
||||
"location ~ ^/api(?:/|$)",
|
||||
"client_max_body_size 64m;",
|
||||
"client_max_body_size 210m;",
|
||||
"limit_conn genarrative_api_conn 64;",
|
||||
"limit_req zone=genarrative_api_rps burst=64 nodelay;",
|
||||
"add_header X-Accel-Buffering no always;"
|
||||
],
|
||||
"development": [
|
||||
"location ~ ^/api(?:/|$)",
|
||||
"client_max_body_size 64m;",
|
||||
"client_max_body_size 210m;",
|
||||
"limit_conn genarrative_api_conn 64;",
|
||||
"limit_req zone=genarrative_api_rps burst=64 nodelay;",
|
||||
"add_header X-Accel-Buffering no always;"
|
||||
|
||||
@@ -28,7 +28,7 @@ GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE=/var/lib/genarrative/maintenance/en
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE=/var/lib/genarrative/maintenance/page.html
|
||||
|
||||
GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=67108864
|
||||
GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960
|
||||
# gzip 默认开启;等级和最小响应长度对齐 Nginx gzip_comp_level 5 / gzip_min_length 1024。
|
||||
# Pingora 正式化口径固定为 gzip-only;br / zstd 不进入当前网关,Brotli 继续由 Nginx / 前置代理承担。
|
||||
GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# AGC 发行包分片续传上传实施计划
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | runtime-smoke-passed(存储原语、服务端入口、原生上传器、渲染进程接线与真实栈分片续传 smoke 均已落地) |
|
||||
| Date | 2026-09-23 |
|
||||
| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC发行包分片续传上传-2026-09-23.md` |
|
||||
|
||||
## 修改边界与顺序
|
||||
|
||||
1. **存储原语(已完成)**:`server-rs/crates/platform-oss/src/lib.rs` 新增 `append_internal_object` / `append_internal_object_with_retry` 与 `OssAppendInternalObjectRequest` / `OssAppendInternalObjectResponse`;复用现役 V4 签名助手 `signed_request_builder`(查询串已参与签名)与 `run_internal_put_with_retry` 的可重试分类。`position = 0` 追加到末尾,`position > 0` 必须等于对象当前长度;返回 `next_position` 作为权威已收字节。
|
||||
2. **服务端入口(已完成)**:`server-rs/crates/api-server/src/modules/game_distribution.rs`
|
||||
- 新增 `GET .../package/upload-state`、`PUT .../package/chunk`、`POST .../package/complete`、`POST .../package/reset` 四个路由,沿用作者鉴权、`game-distribution:publish` 灰度开关与 `Idempotency-Key` 约定;
|
||||
- 分片大小 `PACKAGE_UPLOAD_CHUNK_BYTES = 8 MiB`,分片请求体放行量为分片大小 + 1 KiB;
|
||||
- 从整包 `PUT` 抽出共享收口 `confirm_validated_package`(声明比对 → 确认 → 结构化事件),两种入口共用;
|
||||
- 新增 `game_distribution_oss_client` / `game_distribution_package_object_key` / `staged_package_bytes` / `require_octet_stream_content_type` / `package_upload_offset` 辅助函数;偏移不一致返回 `409 PACKAGE_UPLOAD_OFFSET_MISMATCH` 与权威偏移;未收齐返回 `409 PACKAGE_UPLOAD_INCOMPLETE`;校验失败删除半包并落 `upload_failed`。
|
||||
3. **AGC 原生上传器(已完成)**:新增 `apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs`:内容寻址暂存(`<appData>/game-package-staging/<sha256>.zip`,重启后同包复用同一文件)、`upload-state → chunk → complete` 循环、409 权威偏移续传(响应丢失后按服务端已收字节对齐,不重放不跳段)、仅对传输/超时/408/429/5xx 退避重试(默认 4 次尝试)、`game-package-upload-progress` 进度事件;暂存路径必须落在暂存目录内。命令 `prepare_local_project_game_package` / `upload_local_project_game_package` 已注册,整包回传命令 `read_local_project_export_package` 退役(`read_local_project_export_package_at` 仍供暂存使用)。
|
||||
4. **渲染进程接线(已完成)**:`apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts` 改为 `prepare`(拿摘要与暂存路径)→ 创建游戏 → 创建版本 → 原生分片上传 → 送审;`LocalProjectExportPackagePayload` 整包类型退役,改为 `StagedGamePackage` / `GamePackageUploadOutcome`;不再有任何整包字节进 IPC。
|
||||
5. **真实栈 smoke(已完成)**:本地 api-server + 真实 OSS bucket 上跑通「中断 → 续传 → 确认」。做法与证据:
|
||||
- 先用 `npm run dev:spacetime` 把当前模块发布到本地库(`genarrative-game-creator-dev`,自动迁移完成),再用 `npm run dev:api-server` 起 `127.0.0.1:8082`;
|
||||
- 本地库的 `feature_gate_config` 原本为空(发布开关默认关闭),用 `spacetime call … upsert_feature_gate_config` 写入 `game-distribution:publish enabled=true rollout=100`;
|
||||
- `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:8082 npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts` → **1 passed / 3.9s**(发行包 9.0 MiB,跨 8 MiB 分片边界);
|
||||
- 用例断言实际发送过的分片偏移序列等于 `[0, 8388608]`:第一片只发一次,中断后的续传从权威偏移开始,不重放也不跳段;
|
||||
- api-server 侧同一轮日志:`package_chunk_stored offset=0 chunk_bytes=8388608 received_bytes=8388608 elapsed_ms=201`、`package_chunk_stored offset=8388608 chunk_bytes=1049210 received_bytes=9437818 elapsed_ms=82`、`package_confirmed package_bytes=9437818 file_count=3 oss_put_skipped=true elapsed_ms=884`。
|
||||
- 为了能指向本地栈,用例还补了两处基础设施修正:把客户端平台基址切到传入的 base URL(`setClientServerSelection({preset:'custom'})`),以及桥接层把 jsdom realm 的 `Headers` / `Blob` / `FormData` 降级成 Node 侧原生值(`FormData` 手工序列化为 multipart 字节,否则 OSS 直传回 405)。
|
||||
|
||||
## 不改的部分
|
||||
|
||||
- 网页端发布路径与整包 `PUT` 语义不变;`MAX_PACKAGE_BYTES`、展开量、单文件与文件数上限不变。
|
||||
- 未新增 SpacetimeDB 表或字段:已收字节的事实来源是 OSS 对象长度,版本状态机沿用既有 `awaiting_upload → uploaded → …`。
|
||||
- 未引入半包定时清理任务。
|
||||
|
||||
## 验证命令
|
||||
|
||||
- `cargo test -p platform-oss`(74 passed)
|
||||
- `cargo test -p api-server game_distribution`(23 passed,含新增 `package_chunk_size_stays_inside_declared_limits`、`package_upload_offset_requires_non_negative_integer`、`package_chunk_content_type_must_be_octet_stream`)
|
||||
- `cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
|
||||
- `cargo test game_package_upload`(AGC 原生侧 4 passed:分片规划无缝无重叠、409 权威偏移解析、URL 拼接、内容寻址暂存与路径校验)
|
||||
- `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run --workspace apps/ai-game-creator-shell typecheck`(含 `check-config.mjs` 的命令登记门禁)
|
||||
- `npx vitest run`(发布函数 6 passed、发布面板 9 passed、发布反馈 5 passed;真实链路用例在无 `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL` 时按设计跳过)
|
||||
- 待做:真实栈 smoke(本地 api-server + 真实 OSS bucket 上跑「中断 → 续传 → 完成」,含 `x-oss-next-append-position` 语义确认)
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- **对象可追加性**:`platform-oss` 之前没有追加写,首次真实调用需要在真实 bucket 上确认 `x-oss-next-append-position` 语义;失败时回滚点是 `platform-oss` 新增函数与四条路由(整包 `PUT` 不受影响,可独立回退)。
|
||||
- **半包对象**:分片写入直接落在版本键上,未完成时是半包。它不进公开目录、不服务发行网关;失败或作者重置时删除。若删除失败会记录 `package_staging_delete_failed` 告警,需要人工确认对象键状态。
|
||||
- **重置语义**:只有 `awaiting_upload` / `upload_failed` 允许重置,避免破坏已确认事实。
|
||||
- **内存**:完成动作按 200 MiB 上限回读整包再校验,峰值与整包 `PUT` 同量级;分片路径不再让整包驻留客户端。
|
||||
@@ -0,0 +1,58 @@
|
||||
# AGC 发行包分片续传上传
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | runtime-smoke-passed(真实栈「中断 → 续传 → 确认」已通过;AGC 真机一键发布与 200 MiB 档容量数据未验证) |
|
||||
| Date | 2026-09-23 |
|
||||
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(真实发行包与资料合同第 10 条、幂等并发与恢复) |
|
||||
|
||||
## 背景与触发
|
||||
|
||||
AGC 一键发布今天把整包字节从 WebView 侧送出:`read_local_project_export_package` 先把 `packageBytes` 整包过一遍 IPC 回到渲染进程,渲染进程再用 `@tauri-apps/plugin-http` 发整包 `PUT`,而该插件会把 body 序列化成 `Array.from(new Uint8Array(buffer))` 再走一次 IPC。两次整包 IPC 决定了 AGC 实际可发布的包远小于服务端 200 MiB 上限,失败时表现为客户端侧传输错误(例如「无法连接登录服务」),服务端访问日志里没有这次请求;断流后也只能整包白传。本里程碑把上传下沉到原生侧并支持分片续传。
|
||||
|
||||
## 目标
|
||||
|
||||
1. AGC 一键发布由原生进程直接读取本地试玩包、按服务端下发的固定分片大小上传,整包字节不再经过 WebView IPC。
|
||||
2. 传输中断、网络失败、客户端进程退出或应用重启后,同一 `versionId` 只补传缺失字节,不白传整包。
|
||||
3. 分片入口与现役整包 `PUT` 共用同一版本状态机、摘要口径、幂等键与包校验;网页端发布路径不变。
|
||||
|
||||
## 不在本里程碑内
|
||||
|
||||
- 不改网页端发布路径(继续整包 `PUT`),不为浏览器实现续传。
|
||||
- 不做并行分片上传、不做客户端直传 OSS(分片仍经 `api-server` 转发,与今天整包路径同一出口)。
|
||||
- 不做「后台自动续传」:续传只在下一次发布动作或应用重启后的重试里发生,不引入常驻重传任务。
|
||||
- 不做未完成分片会话的定时清理任务;半包对象的回收单独开里程碑。
|
||||
- 不改发行包上限、展开量、单文件与文件数上限。
|
||||
|
||||
## 合同要点
|
||||
|
||||
- **入口与状态**:分片续传对既有 `versionId` 生效,版本状态沿用 `awaiting_upload → uploaded → …`;分片入口与整包入口互斥,同一版本同时只能有一个写入者,第二个写入返回 `409 UPLOAD_IN_PROGRESS`。
|
||||
- **权威偏移**:服务端记录的已收字节是唯一权威。客户端分片偏移与之不符时返回 `409` 与权威偏移,客户端按权威偏移续传;重复分片不得造成重复写入。
|
||||
- **完成动作**:全部字节到齐后才执行校验与确认;校验失败删除半包对象并把版本落到 `upload_failed`(`recoveryAction=reupload`)。重新上传同一版本前必须显式重置分片会话,重置后偏移归零,不允许在半包之上续写不同字节。
|
||||
- **可见性**:半包对象不进入公开目录、不服务发行网关、不改变当前公开版本;与既有「未通过审核不改变 `activeVersionId`」口径一致。
|
||||
- **原生侧边界**:原生上传只读本地试玩包并逐片发送,进度以事件回传渲染进程;渲染进程不再持有整包字节。
|
||||
|
||||
## 依赖
|
||||
|
||||
- `platform-oss`:需要一组可续写的对象写入原语(追加语义或等价的分片会话),以及读取已收字节的探测能力;现役只有整对象 `PUT`。
|
||||
- `api-server`:`modules/game_distribution.rs` 新增分片入口与完成动作,复用既有 `validate_release_zip`、OSS 上传重试分类、`package_confirmed` / `package_rejected` 可观测事件。
|
||||
- AGC:`src-tauri` 新增原生上传命令与进度事件,`src/services/gameDistributionPublish.ts` 改为调用原生命令;`read_local_project_export_package` 不再为发布回传整包字节。
|
||||
- 反代/网关:分片请求体远小于现役 210 MiB 放行量,沿用现有配置,不改限额。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. **不再整包过 IPC**:发布 200 MiB 档包时,渲染进程侧不出现整包字节(对照 `read_local_project_export_package` 的返回体与 IPC 报文大小),上传由原生进程完成。
|
||||
2. **续传生效**:上传中途断开传输后重发同一版本,只补传缺失分片;分片请求数、已传字节与最终包摘要三项均可复核。
|
||||
3. **跨重启续传**:上传中断时退出应用并重启,重新发布时服务端返回权威已收字节,客户端从该偏移继续,最终确认成功。
|
||||
4. **偏移与重复**:分片偏移不符返回 `409` 与权威偏移;重复提交同一分片不产生重复写入;同版本第二个写入者返回 `409 UPLOAD_IN_PROGRESS`。
|
||||
5. **失败关闭**:完成动作里校验失败(非法 ZIP、超限、压缩比越界等)删除半包对象、版本落 `upload_failed`,半包不出现在公开目录,也不影响当前公开版本。
|
||||
6. **兼容与回归**:整包 `PUT` 路径与既有测试保持绿;`npm run check:doc-index`、`npm run check:encoding`、`git diff --check` 通过;`check:spacetime-schema` 按是否新增持久字段决定是否纳入。
|
||||
7. **运行时证据(已获得)**:本地 api-server(`127.0.0.1:8082`,库 `genarrative-game-creator-dev`)+ 真实 OSS bucket 上跑通 `gameDistributionPublishLive.test.ts`:9.0 MiB 发行包跨 8 MiB 分片边界,第一片只发送一次,中断后续传从权威偏移 `8388608` 继续、第二片 `received_bytes=9437818`,最后 `package_confirmed`(`oss_put_skipped=true`);整轮 3.9s。**未获得**:AGC 真机(Tauri 运行时)一键发布的端到端运行,以及 200 MiB 档的耗时 / 内存容量数据。
|
||||
|
||||
## 待评审的决策点
|
||||
|
||||
1. **续写原语**:OSS 追加写(顺序、单对象、续传只需回读当前长度)对比 OSS Multipart(可并行、更通用但需要多组新操作)。建议追加写,顺序续传已满足本里程碑目标。
|
||||
2. **分片大小**:建议 8 MiB(200 MiB 上限 → 最多 25 片,单片请求体远低于现役放行量)。
|
||||
3. **重置语义**:建议只有显式重置(作者点「重新上传」或 `reupload` 恢复动作)才删除半包并归零;其余情况一律按权威偏移续传。
|
||||
4. **半包回收**:本里程碑只标记未完成会话,不做定时清理;回收另立里程碑(涉及「不得删除仍被公开版本引用的对象」口径)。
|
||||
@@ -120,7 +120,7 @@
|
||||
### 行为与验收
|
||||
|
||||
- [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。
|
||||
- [ ] 100 MiB 包与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。
|
||||
- [ ] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界。已有证据覆盖 100 MiB 档,上限提升后的档位待复跑。
|
||||
- [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。
|
||||
- [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。
|
||||
- [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它
|
||||
|
||||
- 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()` 且 `click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay` 用 `inset: 0` 真的把标题栏盖住了。
|
||||
- 决策:把自绘标题栏定为**窗口边框**,不属于弹层内容:① portal 到 body 的全屏弹层一律 `top: var(--window-chrome-height)`,禁止用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容的点击继续被拦住;③ `WindowChrome` 的标题栏加 `data-window-chrome-bar` 标记,作为这条约定的唯一契约点。
|
||||
- 影响范围:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/styles.css`(`:root` 注释、`.app-update-overlay`、`.game-publish-progress-overlay`)。
|
||||
- 验证方式:`tests/themedModal.test.tsx`(标题栏点击放行、工作区点击仍被拦)、`tests/WindowChrome.test.tsx`(弹窗打开时三个窗口按钮仍调用原生窗口 API)、`tests/windowChromeOverlayContract.test.ts`(7 个全屏弹层都从标题栏下方开始)、`tests/gamePublishFeedback.test.tsx` 与 appSurface(208 passed);两处新增用例都做过「去掉修复即失败」的反向确认。`npm run --workspace apps/ai-game-creator-shell typecheck`、eslint、`npm run check:encoding`、`git diff --check` 通过。
|
||||
|
||||
## 2026-09-23 游戏发行包上限提升到 200 MiB(反代放行量与发行缓存同步)
|
||||
|
||||
- 背景:游戏广场发行包上限原为 100 MiB(`module-game-distribution` 的 `MAX_PACKAGE_BYTES` 与网页端 `GAME_PACKAGE_MAX_BYTES`),而 Nginx 三份模板与 Pingora 网关的通用 `/api` 放行量是 64 MiB。上限只改一层没有意义:包体超过 100 MiB 时先在反代层被 413,`api-server` 的 ZIP 校验根本不会执行。
|
||||
- 决策:发行包上限 100 MiB → 200 MiB;展开总量 250 MiB → 500 MiB(保持 2.5 倍余量);单文件 64 MiB、最多 10,000 个文件、展开/压缩比 100 三条内容规则不变;发行包路由请求体上限继续从包上限派生(200 MiB + 1 KiB)。反代放行量统一放宽到 210 MiB:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf` 使用 `client_max_body_size 210m`,Pingora `DEFAULT_MAX_API_BODY_BYTES` 改为 `220200960` 并同步 `deploy/pingora/pingora-gateway.env.example`。发行静态资源进程内缓存字节预算 200 MiB → 256 MiB,让 200 MiB 档发行包仍能进缓存、且不独占整份预算。
|
||||
- 边界:包内单个文件仍不得超过 64 MiB;线上 Pingora 环境文件若仍写 `67108864`,必须在重启网关前同步改值,否则发行包 PUT 会在网关层被 413。AGC 一键发布经 `@tauri-apps/plugin-http` 传整包字节,实际可发布体积还受该传输方式限制,200 MiB 档的客户端容量需要单独验证。
|
||||
- 影响范围:`server-rs/crates/module-game-distribution/src/package.rs`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`server-rs/crates/pingora-gateway/src/main.rs`、`src/components/game-distribution/gameZipPackage.ts`、`deploy/{nginx,container,pingora}`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`。
|
||||
- 验证方式:`cargo test -p module-game-distribution`(13 passed,其中 `accepts_package_above_the_previous_hundred_mib_limit` 用两个 50 MiB 存储型条目构造 100 MiB 出头的包;把上限临时改回 100 MiB 时该用例确实失败,证明它能守住新上限)、`cargo test -p api-server game_distribution`(20 passed,含新增的请求体上限覆盖包上限断言)、`cargo test -p pingora-gateway`(38 passed,含 `matches_nginx_route_parity_matrix`)、`npx vitest run src/components/game-distribution`(46 passed)、`cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。`npm run check:pingora-route-parity` 仍在 dev-http / 容器模板缺少 `/games` 等 SPA 路由处失败,改动前同样失败,与本次口径无关。200 MiB 档真实栈容量证据(上传耗时、api-server 峰值内存、超限 413 口径)尚未复跑,发布前需按阶段 D 脚本重跑一轮。
|
||||
|
||||
## 2026-09-23 引用名不允许空白:素材 / Skill / 附件共用 `normalizeMentionName`
|
||||
|
||||
- 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。
|
||||
|
||||
@@ -488,7 +488,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
|
||||
| `GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT` | `/var/www/html` | ACME challenge 静态目录。 |
|
||||
| `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE` | `/var/lib/genarrative/maintenance/enabled` | 存在即进入维护模式。 |
|
||||
| `GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO` | `http` | 写入 `X-Forwarded-Proto` 的值。 |
|
||||
| `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` | `67108864` | `/api` 通用路由的 `Content-Length` 上限。 |
|
||||
| `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` | `220200960` | `/api` 通用路由的 `Content-Length` 上限(210 MiB,覆盖游戏发行包 PUT 的 200 MiB + 1 KiB 路由上限)。 |
|
||||
| `GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS` | `gzip` | 当前唯一允许的压缩算法白名单;Pingora 正式化口径固定为 gzip-only,Brotli 继续由 Nginx / 前置代理承担。 |
|
||||
| `GENARRATIVE_PINGORA_GATEWAY_GZIP_ENABLED` | `true` | 是否启用 gzip 响应压缩。 |
|
||||
| `GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL` | `5` | gzip 压缩等级,必须在 `0..=9`。 |
|
||||
|
||||
@@ -893,7 +893,7 @@ worker 被硬杀或断电后,lease 过期任务只有尚未耗尽 `max_attempt
|
||||
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点只负责从内网 Git 源准备 provision 脚本和配置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:SpacetimeDB 必须同时匹配运行版本 `2.8.3` 和 commit `8e410d28...` 才能复用;只有缺失或版本 / commit 不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备官方 `v2.8.3` 资产。`SPACETIME_EXPECTED_COMMIT` 与下载根必须成对调整,安装结果也执行同一 commit 门禁。otelcol-contrib 当前锁定 `0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
|
||||
- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源,目标部署 agent 也不再需要访问源码 Git remote。
|
||||
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。
|
||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 64m` 作为编辑器图片、视频和文档请求的反代兜底,真实大小仍由路由与业务校验负责。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。
|
||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`。通用 `/api` location 保留 `client_max_body_size 210m` 作为编辑器图片、视频、文档请求与游戏发行包 PUT(路由上限 200 MiB + 1 KiB)的反代兜底,真实大小仍由路由与业务校验负责;使用 Pingora 网关时 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` 必须同步不低于该值,否则网关侧会先返回 413。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模板与实际媒体大小。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效。
|
||||
|
||||
容器化隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟现有的 Linux release + Nginx + OTLP Collector 非 BgFilter 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前 compose 没有 `bgfilter-worker`,不构成完整 BgFilter 预发拓扑,也不覆盖任何会触发 BgFilter 的现役任务;它只用于非 BgFilter 路径,或通过下述 unsupported job smoke 验证外部生成队列的 claim / fail 回写和 API-only 更新。当前容器模拟参数保留 `genarrative-release` 的 CPU、`nofile=4096` 与 `worker_connections=768` 采样口径,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=2g`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`。完整模块首次实例化会超过旧 `896m` cgroup 上限,因此 SpacetimeDB 必须使用 `2g`;这不改变生产服务资源合同。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证不经过 BgFilter 的外部生成 worker 动态扩缩容,`inline` 模式不参与该验证:
|
||||
|
||||
|
||||
@@ -84,13 +84,14 @@
|
||||
|
||||
1. AGC 发布取当前 npm 工程已成功构建的 `dist/` 内容,重新检查入口和实际字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对发行根保持不变。不得上传整个项目、源码快照或仅发送本地路径。网页 ZIP 同样要求根 `index.html`,不猜测并自动剥离多层目录。
|
||||
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
|
||||
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
|
||||
3. 现行限额:压缩包 200 MiB、展开总量 500 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。压缩包上限同时决定 `api-server` 的发行包路由请求体上限(200 MiB + 1 KiB)与反代放行量:Nginx 通用 `/api` location 为 `client_max_body_size 210m`,Pingora 网关为 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960`;三者必须同时满足,否则合法包会在反代或路由层被 413。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。超过约 100 MiB 的包在 `api-server` 会带来数百 MB 的瞬时内存占用,发布窗口与实例规格需按容量验证基线预留。
|
||||
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
|
||||
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
|
||||
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
9. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、query 和 fragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`),口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。
|
||||
10. 上传有两条等价入口,共用同一版本状态机、摘要口径、幂等键与校验规则:① 整包入口——网页端与旧客户端对 `versionId` 直接 `PUT` 整包字节,原子、不可续传,仍受发行包上限与请求体限制约束;② 分片续传入口——AGC 原生一键发布对同一 `versionId` 顺序上传固定大小的分片,再以单独的完成动作收口。分片大小由服务端下发且固定(现为 8 MiB,随发行包上限 200 MiB 取整到 25 片以内),客户端不得自行改变;分片续传入口只补传缺失字节,任何分片重复或乱序都不得造成重复写入。AGC 侧必须由原生进程直接读取本地试玩包并按分片发送,整包字节不得经过 WebView IPC 往返,也不得整包驻留宿主内存。
|
||||
|
||||
### 身份、状态、审核与更新
|
||||
|
||||
@@ -106,7 +107,9 @@
|
||||
### 幂等、并发与恢复
|
||||
|
||||
- 所有创建、提交、审核、撤销和下架动作携带 `Idempotency-Key`。服务端以认证主体、动作和 key 保存请求摘要与结果;同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`。至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。
|
||||
- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。首版整包重传,不宣称支持分片断点续传。
|
||||
- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。
|
||||
- 分片续传以「服务端已收字节」为唯一权威偏移:客户端带上自己认为的偏移上传分片,与服务端记录不一致时服务端返回 `409` 与权威偏移,客户端按权威偏移继续,不重放也不跳段。传输失败、网络中断、客户端进程退出或应用重启后,同一 `versionId` 重新发布只补传缺失分片;已收字节数由服务端持久化事实决定,不依赖客户端本地记录。
|
||||
- 分片会话在全部字节到齐并执行完成动作之前,不进入包校验、不确认版本、不改变任何公开可见性,半包对象也不服务给发行网关。完成动作里校验失败时删除该半包对象并把版本落到 `upload_failed`(`recoveryAction=reupload`);作者要重新上传同一版本时必须先显式重置分片会话,重置后已收字节归零,不允许在半包之上续写不同字节。
|
||||
- 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端持久保存操作 ID、目标游戏/版本和 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时允许新版本,不能仅按包摘要吞掉新的发布意图。
|
||||
- 公开版本切换、作者下架和管理员审核必须带 `expectedPublicationRevision`,在持久化事务中比较并推进。并发变化返回 `409 PUBLICATION_CONFLICT`;旧送审版本不能在用户已发布更新或下架之后静默覆盖状态。审核员重新查看现状后才能提交新的明确动作。
|
||||
- 网络中断或响应丢失后先查询原操作/版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,13 @@ use std::{
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const MAX_PACKAGE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
pub const MAX_EXPANDED_BYTES: u64 = 250 * 1024 * 1024;
|
||||
/// 发行包体积上限。反代放行量与路由请求体上限都从它派生:Nginx
|
||||
/// `client_max_body_size`、Pingora `MAX_API_BODY_BYTES` 必须同步放宽,否则合法包会在
|
||||
/// 到达 `api-server` 之前被拒。
|
||||
pub const MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024;
|
||||
/// 展开总量上限保持压缩包上限的 2.5 倍余量:包体本身基本不可再压时展开量约等于包体,
|
||||
/// 纯文本 / JSON 资源占比高的包仍要有足够空间。
|
||||
pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024;
|
||||
pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
pub const MAX_FILE_COUNT: usize = 10_000;
|
||||
pub const MAX_COMPRESSION_RATIO: u64 = 100;
|
||||
@@ -187,6 +192,23 @@ mod tests {
|
||||
output.into_inner()
|
||||
}
|
||||
|
||||
/// 存储型条目的压缩包;用于构造体积可控且不参与 deflate 的大包。
|
||||
fn stored_archive(files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut output = Cursor::new(Vec::new());
|
||||
let mut writer = ZipWriter::new(&mut output);
|
||||
for (path, content) in files {
|
||||
writer
|
||||
.start_file(
|
||||
*path,
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored),
|
||||
)
|
||||
.expect("zip entry");
|
||||
writer.write_all(content).expect("zip content");
|
||||
}
|
||||
writer.finish().expect("finish zip");
|
||||
output.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_root_entry_and_returns_file_manifest() {
|
||||
let bytes = archive(&[("index.html", b"<html></html>"), ("assets/a.txt", b"a")]);
|
||||
@@ -222,4 +244,27 @@ mod tests {
|
||||
Err(ReleasePackageError::InvalidPath)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_expansion_headroom_over_package_limit() {
|
||||
// 口径约束:发行包上限调整时,展开总量至少要留出两倍余量,
|
||||
// 否则高文本占比的合法包会在展开量检查处被误拒。
|
||||
assert!(MAX_EXPANDED_BYTES >= MAX_PACKAGE_BYTES.saturating_mul(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_package_above_the_previous_hundred_mib_limit() {
|
||||
// 上限从 100 MiB 提到 200 MiB 的回归防护:两个 50 MiB 存储型条目组成 100 MiB
|
||||
// 出头的包,旧上限会在这里判 PackageTooLarge,新上限必须放行并给出完整清单。
|
||||
let chunk = vec![0_u8; 50 * 1024 * 1024];
|
||||
let bytes = stored_archive(&[
|
||||
("index.html", b"<html></html>"),
|
||||
("assets/a.bin", chunk.as_slice()),
|
||||
("assets/b.bin", chunk.as_slice()),
|
||||
]);
|
||||
assert!(bytes.len() as u64 > 100 * 1024 * 1024);
|
||||
let manifest = validate_release_zip(&bytes).expect("package above 100 MiB");
|
||||
assert_eq!(manifest.package_bytes, bytes.len() as u64);
|
||||
assert_eq!(manifest.files.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ const DEFAULT_WEB_ROOT: &str = "/srv/genarrative/web";
|
||||
const DEFAULT_ACME_ROOT: &str = "/var/www/html";
|
||||
const DEFAULT_MAINTENANCE_FILE: &str = "/var/lib/genarrative/maintenance/enabled";
|
||||
const DEFAULT_MAINTENANCE_PAGE_FILE: &str = "/var/lib/genarrative/maintenance/page.html";
|
||||
const DEFAULT_MAX_API_BODY_BYTES: u64 = 64 * 1024 * 1024;
|
||||
// 通用 /api 路由的放行上限;必须覆盖游戏发行包 PUT(路由 `DefaultBodyLimit` 200 MiB + 1 KiB),
|
||||
// 具体接口的真实上限仍由 api-server 逐路由校验。
|
||||
const DEFAULT_MAX_API_BODY_BYTES: u64 = 210 * 1024 * 1024;
|
||||
const DEFAULT_GZIP_LEVEL: u32 = 5;
|
||||
const DEFAULT_GZIP_MIN_LENGTH_BYTES: u64 = 1024;
|
||||
const DEFAULT_UPSTREAM_CONNECT_TIMEOUT_MS: u64 = 3_000;
|
||||
|
||||
@@ -132,6 +132,29 @@ pub struct OssInternalPutObjectRequest {
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 内部对象的追加写请求(OSS AppendObject)。
|
||||
///
|
||||
/// `position = 0` 表示追加到当前末尾;`position > 0` 必须等于对象当前长度,
|
||||
/// 否则上游直接失败 —— 分片续传依赖这条语义保证重放不会重复写入。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OssAppendInternalObjectRequest {
|
||||
pub object_key: String,
|
||||
pub content_type: Option<String>,
|
||||
pub position: u64,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OssAppendInternalObjectResponse {
|
||||
pub provider: &'static str,
|
||||
pub bucket: String,
|
||||
pub endpoint: String,
|
||||
pub object_key: String,
|
||||
pub appended_bytes: u64,
|
||||
/// 下一次可写位置,由 OSS 返回,是「已收字节」的权威值。
|
||||
pub next_position: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OssPutObjectRequest {
|
||||
pub prefix: LegacyAssetPrefix,
|
||||
@@ -1018,6 +1041,105 @@ impl OssClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 内部对象追加写,返回 OSS 给出的下一次可写位置(已收字节的权威值)。
|
||||
///
|
||||
/// 与整对象 PUT 的区别是「可续写」:`position = 0` 追加到当前末尾,
|
||||
/// `position > 0` 必须与对象当前长度一致(不一致时 OSS 判失败,不会重复写入)。
|
||||
/// 因此续传只需要回读对象长度,再从未写位置继续。
|
||||
pub async fn append_internal_object(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
request: OssAppendInternalObjectRequest,
|
||||
) -> Result<OssAppendInternalObjectResponse, OssError> {
|
||||
let object_key = normalize_internal_object_key(&request.object_key)?;
|
||||
if request.body.is_empty() {
|
||||
return Err(OssError::InvalidRequest(
|
||||
"服务端内部对象追加内容不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let content_type = normalize_optional_value(request.content_type);
|
||||
let mut target_url =
|
||||
build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err(
|
||||
|error| {
|
||||
request_error(
|
||||
OssRequestOperation::Put,
|
||||
&format!("构造 OSS 对象 URL 失败:{error}"),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
target_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("append", "")
|
||||
.append_pair("position", &request.position.to_string());
|
||||
let appended_bytes = u64::try_from(request.body.len())
|
||||
.map_err(|_| OssError::InvalidRequest("追加内容大小超出可支持范围".to_string()))?;
|
||||
let headers = BTreeMap::new();
|
||||
let builder = signed_request_builder(
|
||||
client,
|
||||
&self.config,
|
||||
Method::POST,
|
||||
Some(&object_key),
|
||||
target_url,
|
||||
content_type.as_deref(),
|
||||
&headers,
|
||||
)?
|
||||
.header(reqwest::header::CONTENT_LENGTH, appended_bytes)
|
||||
.body(request.body);
|
||||
let response = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| request_error_from_reqwest(OssRequestOperation::Put, error))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(request_status_error(
|
||||
OssRequestOperation::Put,
|
||||
response.status().as_u16(),
|
||||
format!("OSS AppendObject 失败,状态码:{}", response.status()),
|
||||
));
|
||||
}
|
||||
let next_position = response
|
||||
.headers()
|
||||
.get("x-oss-next-append-position")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.ok_or_else(|| {
|
||||
OssError::InvalidRequest("OSS AppendObject 未返回 next-append-position".to_string())
|
||||
})?;
|
||||
Ok(OssAppendInternalObjectResponse {
|
||||
provider: OSS_PROVIDER,
|
||||
bucket: self.config.bucket.clone(),
|
||||
endpoint: self.config.endpoint.clone(),
|
||||
object_key,
|
||||
appended_bytes,
|
||||
next_position,
|
||||
})
|
||||
}
|
||||
|
||||
/// 内部对象追加写的受控重试,判定与退避口径和 `put_internal_object_with_retry` 一致。
|
||||
pub async fn append_internal_object_with_retry(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
request: OssAppendInternalObjectRequest,
|
||||
max_attempts: usize,
|
||||
retry_delays_ms: &[u64],
|
||||
) -> Result<OssAppendInternalObjectResponse, OssError> {
|
||||
if max_attempts == 0 {
|
||||
return Err(OssError::InvalidConfig(
|
||||
"内部对象追加重试次数至少为 1".to_string(),
|
||||
));
|
||||
}
|
||||
if retry_delays_ms.len() < max_attempts.saturating_sub(1) {
|
||||
return Err(OssError::InvalidConfig(
|
||||
"内部对象追加重试缺少退避配置".to_string(),
|
||||
));
|
||||
}
|
||||
let log_key = request.object_key.clone();
|
||||
run_internal_put_with_retry(max_attempts, retry_delays_ms, &log_key, move || {
|
||||
let request = request.clone();
|
||||
async move { self.append_internal_object(client, request).await }
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_internal_object_bytes(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
@@ -1709,7 +1831,7 @@ where
|
||||
max_attempts,
|
||||
retry_delay_ms = delay_ms,
|
||||
error = %error,
|
||||
"OSS 内部对象 PUT 失败,按退避重试"
|
||||
"OSS 内部对象写入失败,按退避重试"
|
||||
);
|
||||
sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
attempt += 1;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import JSZip from 'jszip';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { prepareGamePackage } from './gameZipPackage';
|
||||
import { GAME_PACKAGE_MAX_BYTES, prepareGamePackage } from './gameZipPackage';
|
||||
|
||||
async function buildZip(files: Record<string, string>) {
|
||||
const zip = new JSZip();
|
||||
@@ -52,4 +52,12 @@ describe('prepareGamePackage', () => {
|
||||
),
|
||||
).rejects.toThrow('发行包不是有效的 ZIP');
|
||||
});
|
||||
|
||||
it('超过上限时在读取字节之前就失败关闭', async () => {
|
||||
// 只伪造体积,不真的分配 200 MiB;预检必须在读字节之前拦下。
|
||||
const oversized = { size: GAME_PACKAGE_MAX_BYTES + 1 } as unknown as File;
|
||||
await expect(prepareGamePackage(oversized)).rejects.toThrow(
|
||||
'发行包不能超过 200 MiB',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user