diff --git a/package.json b/package.json
index e8edf38ac..51ceb5ff3 100644
--- a/package.json
+++ b/package.json
@@ -92,6 +92,7 @@
"check:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs",
"check:game-distribution-fork-authorization-e2e": "node scripts/check-game-distribution-fork-authorization-e2e.mjs",
"check:game-distribution-lineage-e2e": "node scripts/check-game-distribution-lineage-e2e.mjs",
+ "check:game-distribution-project-bundle-e2e": "node scripts/check-game-distribution-project-bundle-e2e.mjs",
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
"check:preview-deployer": "node scripts/check-preview-deployer.mjs",
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
diff --git a/scripts/check-game-distribution-project-bundle-e2e.mjs b/scripts/check-game-distribution-project-bundle-e2e.mjs
new file mode 100644
index 000000000..a1090ca91
--- /dev/null
+++ b/scripts/check-game-distribution-project-bundle-e2e.mjs
@@ -0,0 +1,958 @@
+// 游戏分发「工程源包(M2b)」上行 + 下行优先链路真实行为验收。
+//
+// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;本脚本**不需要浏览器**。
+//
+// 用法:
+// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
+// node scripts/check-game-distribution-project-bundle-e2e.mjs
+// E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口)
+//
+// 契约来源(全部读实现确认,未按描述猜):
+// [1] 上行路由族(Bearer + 发布灰度):api-server/src/modules/game_distribution.rs:363-385
+// [2] 阶段门(已确认 → 409 ALREADY_EXISTS;仅 awaiting_upload/upload_failed 可写 → 409 UPLOAD_NOT_ALLOWED):
+// api-server/...:2246-2274(ensure_project_bundle_uploadable)
+// [3] 整包 PUT(要求 application/octet-stream):api-server/...:2285-2390;校验失败 → 422 PROJECT_BUNDLE_VALIDATION_FAILED
+// (api-server/...:2277-2283 map_project_bundle_error)
+// [4] 分片:api-server/...:2473-2597(x-genarrative-upload-offset 头 api-server/...:96、8 MiB 上限 :88、
+// 偏移/超限错误码 :2492/:2501/:2525)、upload-state :2445-2470(chunkBytes/receivedBytes)
+// [5] complete:api-server/...:2599-2700(未开始 → 409 UPLOAD_NOT_STARTED;校验失败删半包并 422)
+// [6] 校验器:module-game-distribution/src/project_bundle.rs:66-152,拒绝清单含 node_modules / .env:
+// :156-224(reject_forbidden_path / is_sensitive_file_name)
+// [7] 版本私有 payload 暴露 projectBundleBytes / projectBundleSha256 且不含对象键:api-server/...:3421-3436
+// [8] 下行优先:api-server/...:3110-3160(有工程包 → source=Project,回落 package)
+// + /fork-source/project 无工程包时 409 FORK_SOURCE_NOT_AVAILABLE:api-server/...:3258-3270
+// + downloadPath 按资产拼接:api-server/...:3174-3188
+// [9] 对象键前缀 agc/project-snapshots/v1/game-distribution/(响应里绝不能出现):api-server/...:106,2757-2763
+//
+// 复用/照抄的 helper(本脚本与其同源,注释里标了出处):
+// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs:115-133(源自 ratings-e2e.mjs:15-27)
+// - check/brief/api/register/gameMetadata/uploadCover/createGame/ownerGame:lineage 脚本 :139-318
+// (uploadCover 又源自 owner-isolation.mjs:97-150)
+// - publishToPublic(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本 :320-407
+// (其顺序与请求体源自 media-e2e.mjs:431-495,523-527,561-573)
+// - downloadBinary(按字节校验下载):lineage 脚本 :418-433
+// - 对象键/私有字段泄漏判定模式:lineage 脚本 :445-452 的同类写法
+//
+// 已知与工单描述不一致处(读实现后按实现断言,并在报告里单独标注):
+// - 工单说「另一个作者的 token → 403」,实现是 **404**:api-server/...:4224-4240
+// (load_owner_version_or_404:非 owner 按「不存在」处理,与发行包上行族同口径)。
+
+import { createHash, randomBytes } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+
+import JSZip from 'jszip';
+
+const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
+const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
+const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
+const DEV_PASSWORD = 'GenE2e123!';
+const GATE_KEY = 'game-distribution:publish';
+
+// 工程源包对象键前缀(api-server/...:106):响应里出现它等于泄漏了对象键。
+const OBJECT_KEY_PREFIX = 'agc/project-snapshots/v1/game-distribution/';
+const PRIVATE_OBJECT_PATTERN =
+ /agc\/project-snapshots|\.project\.zip|project_bundle_object_key/iu;
+const CHUNK_HEADER = 'x-genarrative-upload-offset';
+
+if (!ADMIN_USER || !ADMIN_PASSWORD) {
+ console.error(
+ '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
+ 'game-distribution:publish 写入口并走完整发布链路;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' +
+ 'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
+ );
+ process.exit(2);
+}
+
+const devStack = JSON.parse(
+ readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
+);
+const API = (
+ process.env.E2E_API_BASE ??
+ devStack.services?.['api-server']?.url ??
+ ''
+).replace(/\/+$/u, '');
+if (!API) {
+ console.error(
+ '无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' +
+ '或用 E2E_API_BASE 显式指定。',
+ );
+ process.exit(2);
+}
+
+let checks = 0;
+let failures = 0;
+const skipped = 0;
+function check(name, ok, detail = '') {
+ checks += 1;
+ if (!ok) failures += 1;
+ console.log(
+ `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
+ );
+}
+function note(message) {
+ console.log(`NOTE ${message}`);
+}
+
+const COVER_PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+ 'base64',
+);
+
+async function api(pathname, options = {}) {
+ const { method = 'GET', token, body, headers = {}, binary } = options;
+ const finalHeaders = { ...ENVELOPE, ...headers };
+ if (token) finalHeaders.Authorization = `Bearer ${token}`;
+ let finalBody;
+ if (binary) {
+ finalBody = binary;
+ } else if (body !== undefined) {
+ finalHeaders['Content-Type'] = 'application/json';
+ finalBody = JSON.stringify(body);
+ }
+ const response = await fetch(`${API}${pathname}`, {
+ method,
+ headers: finalHeaders,
+ body: finalBody,
+ signal: AbortSignal.timeout(120_000),
+ });
+ const text = await response.text();
+ let json = null;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = null;
+ }
+ return {
+ status: response.status,
+ text,
+ json,
+ data: json?.data,
+ error: json?.error,
+ };
+}
+
+function brief(body) {
+ const code = body?.error?.code ?? body?.json?.error?.code ?? '';
+ return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 220)}`;
+}
+
+async function register(prefix) {
+ const phone = `${prefix}${String(Date.now()).slice(-8)}`;
+ const response = await api('/api/auth/entry', {
+ method: 'POST',
+ body: { purePhoneNumber: phone, password: DEV_PASSWORD },
+ });
+ return { phone, response, token: response.data?.token };
+}
+
+function gameMetadata({ title, coverAssetId }) {
+ return {
+ title,
+ summary: '工程源包验收临时作品',
+ description: '',
+ category: '休闲',
+ tags: ['e2e'],
+ coverAssetId,
+ deviceSupport: { desktop: true, mobile: false, touch: false },
+ inputModes: ['keyboard', 'mouse'],
+ orientation: 'landscape',
+ };
+}
+
+async function uploadCover(token, id) {
+ const fileName = `project-bundle-${id}.png`;
+ const ticket = await api('/api/assets/direct-upload-tickets', {
+ method: 'POST',
+ token,
+ body: {
+ legacyPrefix: 'generated-character-drafts',
+ pathSegments: ['game-distribution', 'project-bundle', String(id)],
+ fileName,
+ contentType: 'image/png',
+ access: 'private',
+ maxSizeBytes: COVER_PNG.length,
+ metadata: { asset_kind: 'game_distribution_cover' },
+ },
+ });
+ if (ticket.status !== 200) {
+ throw new Error(
+ `创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
+ );
+ }
+ const upload = ticket.data.upload;
+ const form = new FormData();
+ for (const [key, value] of Object.entries(upload.formFields ?? {})) {
+ if (value !== null && value !== undefined) form.append(key, String(value));
+ }
+ form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
+ const put = await fetch(upload.host, { method: 'POST', body: form });
+ if (!put.ok) {
+ throw new Error(`直传对象存储失败 ${put.status}`);
+ }
+ const confirm = await api('/api/assets/objects/confirm', {
+ method: 'POST',
+ token,
+ body: {
+ bucket: upload.bucket,
+ objectKey: upload.objectKey,
+ contentType: 'image/png',
+ contentLength: COVER_PNG.length,
+ assetKind: 'game_distribution_cover',
+ accessPolicy: 'private',
+ entityId: 'game-distribution-project-bundle',
+ },
+ });
+ if (confirm.status !== 200) {
+ throw new Error(
+ `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
+ );
+ }
+ return confirm.data.assetObject.assetObjectId;
+}
+
+async function createGame({ token, metadata, idemKey }) {
+ return api('/api/game-distribution/games', {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': idemKey },
+ body: metadata,
+ });
+}
+
+/// 发行包(可玩成品)ZIP:与工程源包是两份不同资产,本脚本两者都要传。
+async function buildReleaseZip(marker) {
+ const zip = new JSZip();
+ zip.file(
+ 'index.html',
+ `
${marker}${marker}
`,
+ );
+ const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
+ return {
+ bytes,
+ fileCount: 1,
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ };
+}
+
+/// 合法工程源包:包内条目覆盖 index.html / package.json / vite.config.js / src/main.js。
+/// `extraFiles` 用于按用例注入禁项(如 .env);`randomBytesCount` 用于造 >8 MiB 的不可压缩负载。
+async function buildProjectBundle({
+ marker,
+ extraFiles = {},
+ randomBytesCount = 0,
+}) {
+ const zip = new JSZip();
+ zip.file(
+ 'index.html',
+ `${marker}` +
+ '',
+ );
+ zip.file(
+ 'package.json',
+ JSON.stringify({ name: marker, version: '0.0.0' }, null, 2),
+ );
+ zip.file('vite.config.js', 'export default { build: { outDir: "dist" } };\n');
+ zip.file('src/main.js', `console.log(${JSON.stringify(marker)});\n`);
+ if (randomBytesCount > 0) {
+ // STORE:随机字节不可压缩,保证包体真的越过 8 MiB 分片边界。
+ zip.file('assets/blob.bin', randomBytes(randomBytesCount), {
+ compression: 'STORE',
+ });
+ }
+ for (const [filePath, content] of Object.entries(extraFiles)) {
+ zip.file(filePath, content);
+ }
+ const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
+ return {
+ bytes,
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ entries: Object.keys(zip.files).filter((name) => !zip.files[name].dir),
+ };
+}
+
+// ---------- 上行 ----------
+
+async function putProjectBundle(versionId, bytes, { token, key }) {
+ return api(`/api/game-distribution/versions/${versionId}/project-bundle`, {
+ method: 'PUT',
+ token,
+ headers: {
+ 'Idempotency-Key': key,
+ 'Content-Type': 'application/octet-stream',
+ },
+ binary: bytes,
+ });
+}
+
+async function putProjectBundleChunk(versionId, chunk, offset, { token, key }) {
+ return api(
+ `/api/game-distribution/versions/${versionId}/project-bundle/chunk`,
+ {
+ method: 'PUT',
+ token,
+ headers: {
+ 'Idempotency-Key': key,
+ 'Content-Type': 'application/octet-stream',
+ [CHUNK_HEADER]: String(offset),
+ },
+ binary: chunk,
+ },
+ );
+}
+
+async function projectBundleUploadState(versionId, token) {
+ return api(
+ `/api/game-distribution/versions/${versionId}/project-bundle/upload-state`,
+ {
+ token,
+ },
+ );
+}
+
+async function ownerVersion(token, versionId) {
+ const response = await api(`/api/game-distribution/versions/${versionId}`, {
+ token,
+ });
+ return { response, version: response.data?.version ?? null };
+}
+
+async function downloadBinary(pathname, token) {
+ const headers = { ...ENVELOPE };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ const response = await fetch(`${API}${pathname}`, {
+ headers,
+ signal: AbortSignal.timeout(120_000),
+ });
+ const bytes = Buffer.from(await response.arrayBuffer());
+ return {
+ status: response.status,
+ contentType: response.headers.get('content-type') ?? '',
+ contentLength: response.headers.get('content-length') ?? '',
+ bytes,
+ };
+}
+
+// ---------- 发布链路(照抄 lineage 脚本 :320-407 的 helper,抽成共享模块会动到其它脚本,故按工单要求照抄并注明) ----------
+
+async function createVersion({ token, gameId, metadata, zip, stamp, tag }) {
+ return api(`/api/game-distribution/games/${gameId}/versions`, {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `pb-version-${tag}-${stamp}` },
+ body: {
+ packageSha256: zip.sha256,
+ packageBytes: zip.bytes.length,
+ packageFileCount: zip.fileCount,
+ packageEntryPath: 'index.html',
+ gameMetadata: metadata,
+ },
+ });
+}
+
+async function publishToPublic({
+ token,
+ admin,
+ gameId,
+ gameRevision,
+ metadata,
+ stamp,
+ tag,
+ label,
+}) {
+ const zip = await buildReleaseZip(`release-${tag}-${stamp}`);
+ const created = await createVersion({
+ token,
+ gameId,
+ metadata,
+ zip,
+ stamp,
+ tag,
+ });
+ const versionId = created.data?.versionId;
+ check(
+ `${label} 版本创建成功`,
+ created.status === 200 && Boolean(versionId),
+ `status=${created.status} ${created.text.slice(0, 160)}`,
+ );
+ if (!versionId) return { versionId: null };
+
+ const upload = await api(
+ `/api/game-distribution/versions/${versionId}/package`,
+ {
+ method: 'PUT',
+ token,
+ headers: {
+ 'Idempotency-Key': `pb-upload-${tag}-${stamp}`,
+ 'Content-Type': 'application/zip',
+ },
+ binary: zip.bytes,
+ },
+ );
+ check(
+ `${label} 发行包上传成功`,
+ upload.status === 200,
+ `status=${upload.status}`,
+ );
+
+ const submitted = await api(
+ `/api/game-distribution/versions/${versionId}/submit`,
+ {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `pb-submit-${tag}-${stamp}` },
+ body: { expectedPublicationRevision: gameRevision },
+ },
+ );
+ check(
+ `${label} 送审成功(202)`,
+ submitted.status === 202,
+ `status=${submitted.status} ${submitted.text.slice(0, 140)}`,
+ );
+
+ const readback = await api(`/api/game-distribution/versions/${versionId}`, {
+ token,
+ });
+ const approved = await api(
+ `/admin/api/game-distribution/versions/${versionId}/review`,
+ {
+ method: 'POST',
+ token: admin,
+ headers: { 'Idempotency-Key': `pb-approve-${tag}-${stamp}` },
+ body: {
+ decision: 'approve',
+ expectedPublicationRevision:
+ readback.data?.version?.publicationRevision ?? gameRevision,
+ },
+ },
+ );
+ check(
+ `${label} 管理员审核通过并公开`,
+ approved.status === 200,
+ `status=${approved.status} ${approved.text.slice(0, 140)}`,
+ );
+ return { versionId };
+}
+
+// ---------- 主流程 ----------
+
+async function main() {
+ console.log(
+ `[project-bundle-e2e] api-server=${API} database=${devStack.database}`,
+ );
+
+ const adminLogin = await api('/admin/api/login', {
+ method: 'POST',
+ body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
+ });
+ const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
+ check(
+ '管理员登录成功',
+ adminLogin.status === 200 && Boolean(admin),
+ `status=${adminLogin.status}`,
+ );
+ if (!admin) process.exit(1);
+
+ const gate = await api('/admin/api/feature-gates', {
+ method: 'PUT',
+ token: admin,
+ body: {
+ gateKey: GATE_KEY,
+ enabled: true,
+ rolloutPercent: 100,
+ allowUserIds: [],
+ allowUserTags: [],
+ denyUserIds: [],
+ description: 'E2E 工程源包链路',
+ },
+ });
+ check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
+
+ const stamp = Date.now();
+ const suffix = String(stamp).slice(-6);
+ const author = await register('132');
+ const other = await register('133');
+ check(
+ '作者注册拿到 token',
+ author.response.status === 200 && Boolean(author.token),
+ `status=${author.response.status} phone=${author.phone}`,
+ );
+ check(
+ '另一个作者注册拿到 token',
+ other.response.status === 200 && Boolean(other.token),
+ `status=${other.response.status} phone=${other.phone}`,
+ );
+ if (!author.token || !other.token) process.exit(1);
+
+ // ---------- fixture:作品 A ----------
+ const gameTitle = `工程源包 ${suffix}`;
+ const coverAssetId = await uploadCover(author.token, stamp);
+ const metadata = gameMetadata({ title: gameTitle, coverAssetId });
+ const created = await createGame({
+ token: author.token,
+ metadata,
+ idemKey: `pb-game-${stamp}`,
+ });
+ const gameId = created.data?.id;
+ const gameRevision = created.data?.publicationRevision ?? 0;
+ check(
+ '作品创建成功',
+ created.status === 200 && Boolean(gameId),
+ `status=${created.status} id=${gameId ?? ''}`,
+ );
+ if (!gameId) process.exit(1);
+
+ // ---------- A1:草稿版本上传合法工程源包 ----------
+ const validBundle = await buildProjectBundle({ marker: `proj-${suffix}` });
+ const v1ReleaseZip = await buildReleaseZip(`v1-${suffix}`);
+ const v1 = await createVersion({
+ token: author.token,
+ gameId,
+ metadata,
+ zip: v1ReleaseZip,
+ stamp,
+ tag: 'v1',
+ });
+ const v1Id = v1.data?.versionId;
+ check(
+ 'v1 版本创建成功且处于可写档位(awaiting_upload)',
+ v1.status === 200 && Boolean(v1Id) && v1.data?.status === 'awaiting_upload',
+ `status=${v1.status} versionId=${v1Id ?? ''} versionStatus=${v1.data?.status ?? ''}`,
+ );
+ if (!v1Id) process.exit(1);
+
+ const put1 = await putProjectBundle(v1Id, validBundle.bytes, {
+ token: author.token,
+ key: `pb-put-v1-${stamp}`,
+ });
+ check(
+ 'A1 合法工程源包整包上传成功',
+ put1.status === 200 && put1.data?.versionId === v1Id,
+ brief(put1),
+ );
+ const v1After = await ownerVersion(author.token, v1Id);
+ check(
+ 'A1 版本私有 payload 的 projectBundleBytes / projectBundleSha256 与上传一致',
+ v1After.version?.projectBundleBytes === validBundle.bytes.length &&
+ v1After.version?.projectBundleSha256 === validBundle.sha256,
+ `bytes=${v1After.version?.projectBundleBytes} 期望=${validBundle.bytes.length} ` +
+ `sha256=${v1After.version?.projectBundleSha256 ?? ''} 期望=${validBundle.sha256}`,
+ );
+ check(
+ 'A1 版本私有 payload 不下发对象键',
+ !PRIVATE_OBJECT_PATTERN.test(v1After.response.text) &&
+ !v1After.response.text.includes(OBJECT_KEY_PREFIX),
+ `objectKeyPrefix=${OBJECT_KEY_PREFIX}`,
+ );
+
+ // ---------- A3:含禁项 → 422,且不落库 ----------
+ const forbiddenBundle = await buildProjectBundle({
+ marker: `bad-${suffix}`,
+ extraFiles: { '.env': 'SECRET=1\n' },
+ });
+ const v2 = await createVersion({
+ token: author.token,
+ gameId,
+ metadata,
+ zip: await buildReleaseZip(`v2-${suffix}`),
+ stamp,
+ tag: 'v2',
+ });
+ const v2Id = v2.data?.versionId;
+ check(
+ 'v2 版本创建成功(用于禁项用例)',
+ v2.status === 200 && Boolean(v2Id),
+ `status=${v2.status} versionId=${v2Id ?? ''}`,
+ );
+ if (!v2Id) process.exit(1);
+ const putBad = await putProjectBundle(v2Id, forbiddenBundle.bytes, {
+ token: author.token,
+ key: `pb-put-v2-${stamp}`,
+ });
+ check(
+ 'A3 含 .env 的工程源包被拒(422 PROJECT_BUNDLE_VALIDATION_FAILED)',
+ putBad.status === 422 &&
+ (putBad.error?.code ?? '') === 'PROJECT_BUNDLE_VALIDATION_FAILED',
+ brief(putBad),
+ );
+ const v2After = await ownerVersion(author.token, v2Id);
+ check(
+ 'A3 被拒后该版本仍未落库工程源包(bytes=0 / sha256 空)',
+ (v2After.version?.projectBundleBytes ?? -1) === 0 &&
+ !v2After.version?.projectBundleSha256,
+ `bytes=${v2After.version?.projectBundleBytes} sha256=${JSON.stringify(v2After.version?.projectBundleSha256 ?? null)}`,
+ );
+
+ // ---------- A2:>8 MiB 不可压缩负载走两片 ----------
+ const bigRandomBytes = 9 * 1024 * 1024; // 9 MiB 随机字节:压缩后仍 >8 MiB
+ const bigBundle = await buildProjectBundle({
+ marker: `big-${suffix}`,
+ randomBytesCount: bigRandomBytes,
+ });
+ const v3 = await createVersion({
+ token: author.token,
+ gameId,
+ metadata,
+ zip: await buildReleaseZip(`v3-${suffix}`),
+ stamp,
+ tag: 'v3',
+ });
+ const v3Id = v3.data?.versionId;
+ check(
+ 'v3 版本创建成功(用于多分片用例)',
+ v3.status === 200 && Boolean(v3Id),
+ `status=${v3.status} versionId=${v3Id ?? ''}`,
+ );
+ if (!v3Id) process.exit(1);
+
+ const state0 = await projectBundleUploadState(v3Id, author.token);
+ const chunkBytes = state0.data?.chunkBytes ?? 0;
+ check(
+ 'A2 upload-state 下发权威分片大小与服务端已收字节',
+ state0.status === 200 && chunkBytes > 0 && state0.data?.receivedBytes === 0,
+ `chunkBytes=${chunkBytes} receivedBytes=${state0.data?.receivedBytes}`,
+ );
+ check(
+ 'A2 构造的不可压缩负载确实超过一个分片(> chunkBytes)',
+ bigBundle.bytes.length > chunkBytes && chunkBytes > 0,
+ `bundleBytes=${bigBundle.bytes.length} chunkBytes=${chunkBytes} entries=${bigBundle.entries.length}`,
+ );
+
+ const firstChunk = bigBundle.bytes.subarray(0, chunkBytes);
+ const secondChunk = bigBundle.bytes.subarray(chunkBytes);
+ const chunk1 = await putProjectBundleChunk(v3Id, firstChunk, 0, {
+ token: author.token,
+ key: `pb-chunk1-${stamp}`,
+ });
+ check('A2 第一片(offset=0)写入成功', chunk1.status === 200, brief(chunk1));
+ const state1 = await projectBundleUploadState(v3Id, author.token);
+ check(
+ 'A2 upload-state 权威偏移等于第一片字节数',
+ state1.data?.receivedBytes === firstChunk.length,
+ `receivedBytes=${state1.data?.receivedBytes} 期望=${firstChunk.length}`,
+ );
+
+ const chunk2 = await putProjectBundleChunk(
+ v3Id,
+ secondChunk,
+ firstChunk.length,
+ {
+ token: author.token,
+ key: `pb-chunk2-${stamp}`,
+ },
+ );
+ check('A2 第二片(续传偏移)写入成功', chunk2.status === 200, brief(chunk2));
+ const state2 = await projectBundleUploadState(v3Id, author.token);
+ check(
+ 'A2 upload-state 权威偏移等于整包字节数',
+ state2.data?.receivedBytes === bigBundle.bytes.length,
+ `receivedBytes=${state2.data?.receivedBytes} 期望=${bigBundle.bytes.length}`,
+ );
+
+ const complete3 = await api(
+ `/api/game-distribution/versions/${v3Id}/project-bundle/complete`,
+ {
+ method: 'POST',
+ token: author.token,
+ headers: { 'Idempotency-Key': `pb-complete-v3-${stamp}` },
+ },
+ );
+ check(
+ 'A2 分片收齐后 complete 成功',
+ complete3.status === 200 && complete3.data?.versionId === v3Id,
+ brief(complete3),
+ );
+ const v3After = await ownerVersion(author.token, v3Id);
+ check(
+ 'A2 分片上传确认后的 bytes / sha256 与本地构造一致',
+ v3After.version?.projectBundleBytes === bigBundle.bytes.length &&
+ v3After.version?.projectBundleSha256 === bigBundle.sha256,
+ `bytes=${v3After.version?.projectBundleBytes} 期望=${bigBundle.bytes.length} ` +
+ `sha256=${v3After.version?.projectBundleSha256 ?? ''} 期望=${bigBundle.sha256}`,
+ );
+
+ // ---------- A4:重复确认 ----------
+ const putAgain = await putProjectBundle(v1Id, validBundle.bytes, {
+ token: author.token,
+ key: `pb-put-v1-again-${stamp}`,
+ });
+ check(
+ 'A4 同一版本二次上传被拒(409 PROJECT_BUNDLE_ALREADY_EXISTS)',
+ putAgain.status === 409 &&
+ (putAgain.error?.code ?? '') === 'PROJECT_BUNDLE_ALREADY_EXISTS',
+ brief(putAgain),
+ );
+
+ // ---------- A6:鉴权 ----------
+ const anonPut = await api(
+ `/api/game-distribution/versions/${v1Id}/project-bundle`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Idempotency-Key': `pb-anon-${stamp}`,
+ 'Content-Type': 'application/octet-stream',
+ },
+ binary: validBundle.bytes,
+ },
+ );
+ check('A6 未带 Bearer → 401', anonPut.status === 401, brief(anonPut));
+ const foreignPut = await putProjectBundle(v1Id, validBundle.bytes, {
+ token: other.token,
+ key: `pb-foreign-${stamp}`,
+ });
+ check(
+ 'A6 另一个作者的 token → 404(实现:非 owner 按不存在处理)',
+ foreignPut.status === 404 &&
+ !String(foreignPut.text).includes(OBJECT_KEY_PREFIX),
+ brief(foreignPut),
+ );
+ note(
+ '工单描述该用例为 403;实现是 404(api-server/...:4224-4240 load_owner_version_or_404,' +
+ '与发行包上行族同口径:不区分「别人的版本」与「不存在」)。本脚本按实现断言 404,并在此标注差异。',
+ );
+
+ // ---------- 发布 v1(带工程源包)→ A5 阶段门 ----------
+ const releaseZipV1 = v1ReleaseZip;
+ const uploadPackageV1 = await api(
+ `/api/game-distribution/versions/${v1Id}/package`,
+ {
+ method: 'PUT',
+ token: author.token,
+ headers: {
+ 'Idempotency-Key': `pb-release-v1-${stamp}`,
+ 'Content-Type': 'application/zip',
+ },
+ binary: releaseZipV1.bytes,
+ },
+ );
+ check(
+ 'v1 发行包(成品)上传成功',
+ uploadPackageV1.status === 200,
+ brief(uploadPackageV1),
+ );
+ const submitV1 = await api(`/api/game-distribution/versions/${v1Id}/submit`, {
+ method: 'POST',
+ token: author.token,
+ headers: { 'Idempotency-Key': `pb-submit-v1-${stamp}` },
+ body: { expectedPublicationRevision: gameRevision },
+ });
+ check('v1 送审成功(202)', submitV1.status === 202, brief(submitV1));
+ const v1Readback = await ownerVersion(author.token, v1Id);
+ const approveV1 = await api(
+ `/admin/api/game-distribution/versions/${v1Id}/review`,
+ {
+ method: 'POST',
+ token: admin,
+ headers: { 'Idempotency-Key': `pb-approve-v1-${stamp}` },
+ body: {
+ decision: 'approve',
+ expectedPublicationRevision:
+ v1Readback.version?.publicationRevision ?? gameRevision,
+ },
+ },
+ );
+ check('v1 管理员审核通过并公开', approveV1.status === 200, brief(approveV1));
+
+ const putAfterPublish = await putProjectBundle(v1Id, validBundle.bytes, {
+ token: author.token,
+ key: `pb-put-after-publish-${stamp}`,
+ });
+ check(
+ 'A5 已公开且已有工程包的版本再传 → 409 ALREADY_EXISTS(阶段门先判「已存在」)',
+ putAfterPublish.status === 409 &&
+ (putAfterPublish.error?.code ?? '') === 'PROJECT_BUNDLE_ALREADY_EXISTS',
+ brief(putAfterPublish),
+ );
+ note(
+ '实现里「已确认过工程包」先于「版本档位」判定(api-server/...:2246-2274 的注释与顺序):' +
+ '已有工程包的已公开版本因此返回 ALREADY_EXISTS;「已公开但无工程包」的补传用例在 A5b/B9 段。',
+ );
+
+ // 取件通道要求作品共创授权非禁止(api-server/...:3110-3145):发布后再提升授权。
+ const promoteFork = await api(
+ `/api/game-distribution/games/${gameId}/fork-authorization`,
+ {
+ method: 'PUT',
+ token: author.token,
+ headers: { 'Idempotency-Key': `pb-promote-${stamp}` },
+ body: {
+ expectedForkAuthorization: 'forbidden',
+ forkAuthorization: 'nonCommercial',
+ },
+ },
+ );
+ check(
+ '作品 A 共创授权提升为 nonCommercial(取件通道前提)',
+ promoteFork.status === 200 &&
+ (promoteFork.data?.game?.forkAuthorization ?? null) === 'nonCommercial',
+ brief(promoteFork),
+ );
+
+ // ---------- B7:下行优先 ----------
+ const forkSource = await api(
+ `/api/game-distribution/games/${gameId}/fork-source`,
+ {
+ token: other.token,
+ },
+ );
+ const source = forkSource.data?.forkSource ?? null;
+ check(
+ 'B7 带工程包的公开作品 fork-source 200 且 source=project',
+ forkSource.status === 200 && source?.source === 'project',
+ brief(forkSource),
+ );
+ check(
+ 'B7 sha256 / bytes 与上传的工程源包一致',
+ source?.sha256 === validBundle.sha256 &&
+ source?.bytes === validBundle.bytes.length,
+ `sha256=${source?.sha256 ?? ''} 期望=${validBundle.sha256} bytes=${source?.bytes} 期望=${validBundle.bytes.length}`,
+ );
+ check(
+ 'B7 downloadPath 指向 project 资产',
+ typeof source?.downloadPath === 'string' &&
+ source.downloadPath.endsWith('/fork-source/project') &&
+ !source.downloadPath.includes('://'),
+ `downloadPath=${source?.downloadPath ?? ''}`,
+ );
+ check(
+ 'B7 取件元数据不含对象键',
+ !PRIVATE_OBJECT_PATTERN.test(forkSource.text) &&
+ !forkSource.text.includes(OBJECT_KEY_PREFIX),
+ `objectKeyPrefix=${OBJECT_KEY_PREFIX}`,
+ );
+
+ // ---------- B8:按 downloadPath 下载并逐字节校验 ----------
+ const projectDownload = await downloadBinary(
+ source?.downloadPath ?? '',
+ other.token,
+ );
+ check(
+ 'B8 工程源包下载 200 + application/zip',
+ projectDownload.status === 200 &&
+ projectDownload.contentType.includes('application/zip'),
+ `status=${projectDownload.status} content-type=${projectDownload.contentType}`,
+ );
+ check(
+ 'B8 content-length 与实际字节数一致',
+ Number(projectDownload.contentLength) === projectDownload.bytes.length,
+ `content-length=${projectDownload.contentLength} actual=${projectDownload.bytes.length}`,
+ );
+ const projectDownloadSha = createHash('sha256')
+ .update(projectDownload.bytes)
+ .digest('hex');
+ check(
+ 'B8 下载字节 sha256 与元数据一致',
+ projectDownloadSha === source?.sha256,
+ `download=${projectDownloadSha} meta=${source?.sha256 ?? ''}`,
+ );
+ let unzippedEntries = [];
+ try {
+ const archive = await JSZip.loadAsync(projectDownload.bytes);
+ unzippedEntries = Object.keys(archive.files).filter(
+ (name) => !archive.files[name].dir,
+ );
+ } catch (error) {
+ unzippedEntries = [];
+ note(`B8 解压失败:${error?.message ?? error}`);
+ }
+ check(
+ 'B8 下载内容可解压且包含上传的工程条目',
+ ['index.html', 'package.json', 'vite.config.js', 'src/main.js'].every(
+ (entry) => unzippedEntries.includes(entry),
+ ),
+ `entries=${unzippedEntries.join(',')}`,
+ );
+
+ // ---------- B9:对照(无工程包的公开作品回落成品包) ----------
+ const controlTitle = `工程源包对照 ${suffix}`;
+ const controlCover = await uploadCover(author.token, `${stamp}-control`);
+ const controlMeta = gameMetadata({
+ title: controlTitle,
+ coverAssetId: controlCover,
+ });
+ const controlCreated = await createGame({
+ token: author.token,
+ metadata: controlMeta,
+ idemKey: `pb-control-game-${stamp}`,
+ });
+ const controlGameId = controlCreated.data?.id;
+ check(
+ 'B9 对照作品创建成功',
+ controlCreated.status === 200 && Boolean(controlGameId),
+ `status=${controlCreated.status} id=${controlGameId ?? ''}`,
+ );
+ if (!controlGameId) process.exit(1);
+ const controlPublish = await publishToPublic({
+ token: author.token,
+ admin,
+ gameId: controlGameId,
+ gameRevision: controlCreated.data?.publicationRevision ?? 0,
+ metadata: controlMeta,
+ stamp,
+ tag: 'control',
+ label: '对照',
+ });
+ // A5b:已公开且**没有**工程包的版本不允许补传(此时阶段门才落到档位判定)。
+ const controlVersionId = controlPublish.versionId;
+ if (controlVersionId) {
+ const controlPut = await putProjectBundle(
+ controlVersionId,
+ validBundle.bytes,
+ { token: author.token, key: `pb-control-put-${stamp}` },
+ );
+ check(
+ 'A5b 已公开但无工程包的版本不允许补传 → 409 PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED',
+ controlPut.status === 409 &&
+ (controlPut.error?.code ?? '') === 'PROJECT_BUNDLE_UPLOAD_NOT_ALLOWED',
+ brief(controlPut),
+ );
+ }
+ const controlPromote = await api(
+ `/api/game-distribution/games/${controlGameId}/fork-authorization`,
+ {
+ method: 'PUT',
+ token: author.token,
+ headers: { 'Idempotency-Key': `pb-control-promote-${stamp}` },
+ body: {
+ expectedForkAuthorization: 'forbidden',
+ forkAuthorization: 'nonCommercial',
+ },
+ },
+ );
+ check(
+ 'B9 对照作品共创授权提升为 nonCommercial',
+ controlPromote.status === 200,
+ brief(controlPromote),
+ );
+ const controlSource = await api(
+ `/api/game-distribution/games/${controlGameId}/fork-source`,
+ { token: other.token },
+ );
+ check(
+ 'B9 无工程包的作品 source 回落为 package',
+ controlSource.status === 200 &&
+ controlSource.data?.forkSource?.source === 'package',
+ brief(controlSource),
+ );
+ const controlProjectDownload = await downloadBinary(
+ `/api/game-distribution/games/${controlGameId}/fork-source/project`,
+ other.token,
+ );
+ check(
+ 'B9 无工程包时 /fork-source/project → 409(不静默回落成品包)',
+ controlProjectDownload.status === 409,
+ `status=${controlProjectDownload.status}`,
+ );
+
+ console.log(
+ `[project-bundle-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`,
+ );
+ if (failures > 0) {
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ console.error(`[project-bundle-e2e] 未捕获异常:${error?.stack ?? error}`);
+ process.exitCode = 1;
+});