发行包上限提升到 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 };
+3 -2
View File
@@ -90,8 +90,9 @@ http {
location ~ ^/api(?:/|$) {
default_type application/json;
# 中文注释:创作接口会携带参考图 Data URLNginx 只放行到 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;
+2 -2
View File
@@ -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
+3 -2
View File
@@ -119,8 +119,9 @@ server {
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
location ~ ^/api(?:/|$) {
default_type application/json;
# 中文注释:创作接口会携带参考图 Data URLNginx 只放行到 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;
+3 -2
View File
@@ -139,8 +139,9 @@ server {
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
location ~ ^/api(?:/|$) {
default_type application/json;
# 中文注释:创作接口会携带参考图 Data URLNginx 只放行到 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;"
+1 -1
View File
@@ -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-onlybr / 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 MiB200 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` 与 appSurface208 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-onlyBrotli 继续由 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);
}
}
+3 -1
View File
@@ -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;
+123 -1
View File
@@ -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