diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md
index ee5282303..f4db3314a 100644
--- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md
+++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md
@@ -51,7 +51,7 @@
- [x] 登录用户创建服务端分配的游戏,owner 不能由请求伪造;其他账号不能读取私有版本、上传、提交或撤销。
- [x] 服务端接收真实 ZIP 字节,重算摘要/字节数并建立展开清单;只有 metadata 的请求不能获得已上传或已发布状态。
- [x] 缺入口、越界/重复/大小写冲突路径、符号链接、压缩炸弹、敏感内容和额度超限均失败关闭,原私有对象和公开状态保持一致。
-- [ ] 一份版本只接受一份已确认内容;同 key 同请求重放无重复游戏/版本,不同请求冲突;同版本并发上传不混写。
+- [x] 一份版本只接受一份已确认内容;同 key 同请求重放无重复游戏/版本,不同请求冲突;同版本并发上传不混写。
- [ ] 校验可异步恢复,响应丢失、服务进程退出和客户端重试均回到原版本;确定失败和未知结果在响应中可区分。
- [ ] 正常及失败状态、私有查询和错误 envelope 在 Rust 与 TypeScript DTO 中一致;新增 schema、迁移、表目录与绑定一致。
@@ -163,7 +163,9 @@
- **本轮新增** `scripts/check-game-distribution-owner-isolation.mjs`(`npm run check:game-distribution-owner-isolation`):对本地真实栈(SpacetimeDB `xushi-p4wfr` @ 127.0.0.1:3101 + api-server @ 127.0.0.1:4198,发布灰度按既有 E2E 口径临时开启后恢复关闭)注册两个真实账号,作者建游戏与版本后逐条验证越权:读私有版本 404、读上传状态 404、写分片 404、确认分包 404、送审 404、撤回 404、未认证读取 401;越权写之后作者侧 `receivedBytes` 仍为 0(越权请求没有落任何副作用);请求体里塞 `ownerUserId` / `owner_user_id` 的伪造游戏仍只出现在请求者自己的 `my-games`、不出现在被冒名账号的 `my-games`;作者撤回自己的版本得到 `cancelled`。**21 项全部 PASS**。
- **本轮顺带修掉一处真实缺陷**:`submit_version` 之前直接调 `get_owner_game_distribution_version`,owner 不匹配被映射成 403,而读版本/上传状态/分片/确认/撤回都按 404 处理——同一条「不属于当前主体」的语义出现两种错误码,送审入口会泄露版本是否存在。改成复用 `load_owner_version_or_404` 后,他人送审作者版本与送审不存在的版本都返回 404;脚本里这两条断言分别覆盖,修复前前者是 403 `FORBIDDEN` 让用例变红。
- 边界:脚本会为「建游戏必须提供封面」写一个 67 字节 PNG 到 dev bucket(走现役直传 + 确认链路);发行包分片与确认全部停在越权拒绝之前,不产生任何对象。
+- 已勾选(第 4 条:一份版本只接受一份已确认内容、同 key 重放不产生第二份、换请求体冲突、同版本并发上传不混写)——本轮补齐
+ - 领域层原有 `idempotency_replays_same_snapshot_and_rejects_digest_conflict`、`validation_failure_can_retry_same_confirmed_package`、`a_version_accepts_only_one_confirmed_package`;**本轮新增** `scripts/check-game-distribution-upload-concurrency.mjs`(`npm run check:game-distribution-upload-concurrency`)在本地真实栈补接口层:同 key 同请求重放建游戏/建版本返回同一条记录、同 key 换请求体建游戏 409 `CONFLICT`、同版本同偏移并发写两片相同字节只成功一片、服务端权威已收字节等于单份包长(没有重复追加)、随后确认校验通过并落到 `uploaded`(重复追加或混写都会让校验失败)、换 key 第二次送审 409 且版本数仍为 1。**17 项全部 PASS**。
+ - **本轮顺带修掉并发落败请求的错误码**:分片追加失败时之前直接把 OSS 的 position 冲突映射成 `502 UPSTREAM_ERROR`;现在追加失败后回读权威已收字节,只要长度已前进就返回 409 `PACKAGE_UPLOAD_OFFSET_MISMATCH` 并带上 `receivedBytes`,客户端可直接按权威偏移续传。修复前脚本观察到 `502/UPSTREAM_ERROR`,修复后同一条断言看到 `409/PACKAGE_UPLOAD_OFFSET_MISMATCH receivedBytes=751`。
- 仍未勾选(缺口写具体,避免"看起来做了")
- - 第 4 条(一份版本只接受一份已确认内容;同 key 同请求幂等、不同请求冲突;同版本并发上传不混写):前两半已覆盖——`idempotency_replays_same_snapshot_and_rejects_digest_conflict`、`validation_failure_can_retry_same_confirmed_package`、api-server 的 `idempotency_key_requires_a_bounded_non_empty_header`;**本轮新增** `a_version_accepts_only_one_confirmed_package`(不同摘要或字节数的确认被拒为 `PackageMismatch`,重复确认同一份内容返回同一包身份;变异去掉该守卫后只有它变红)。**仍缺**「同版本并发上传不混写」:串行化在 `spacetime-module` / api-server 的 CAS 那一层,领域服务本身是同步的,需要在那一层写用例。
- - 第 5 条(校验可异步恢复;响应丢失、服务进程退出与客户端重试回到原版本;确定失败与未知结果可区分):只有 api-server 的 `recovery_action_covers_every_version_status` 覆盖"状态 → 恢复动作"的映射;后两句在 game-distribution 链路没有用例。
+ - 第 5 条(校验可异步恢复;响应丢失、服务进程退出和客户端重试回到原版本;确定失败和未知结果可区分):`recovery_action_covers_every_version_status` 覆盖状态到恢复动作的映射;**本轮补齐「客户端重试」**——`check:game-distribution-upload-concurrency` 里同 key 重放送审得到 `202 replayed=true` 且仍是同一个版本、换 key 重复送审 409 且不产生第二份提交。**仍缺**「响应丢失与 api-server 进程退出后回到原版本」:需要在 8 MiB 分片上传中途重启 api-server 再续传的编排用例,本轮未做。
- 第 6 条(状态/私有查询/错误 envelope 的 Rust 与 TS DTO 一致;新增 schema、迁移、表目录与绑定一致):后半句有门禁(`npm run lint` 内的 SpacetimeDB schema guard 覆盖 85 张表、生成绑定校验通过)。**本轮新增** `check:game-distribution-dto-parity`(已接进 `npm run lint`):按显式映射表逐字段/逐变体比对 14 组 Rust `shared-contracts` DTO 与手写 `packages/shared/src/contracts/gameDistribution.ts`,两个方向都做过变异验证——TS 侧把 `name` 改成 `displayName`、Rust 侧给 `GameDistributionAuthor` 加 `extra_field`,各自都让门禁失败并指出缺哪个字段;脚本同时登记了 7 个「服务端逐字段手拼 JSON、没有 Rust 结构体」的 TS 类型。**本轮补齐** `coverObjectKey` / `screenshots` / `publicationRevision` 四个字段进 Rust 结构体(依据是 `game_payload` 与 `private_version_payload` 实际发出的键),并删掉脚本里用来豁免它们的 `TS_ONLY_FIELDS` 白名单:现在任一方向多出字段都会让门禁失败,作者侧响应省略 `currentVersion` 这一条差异改用 TS 可选字段描述。**本轮再补构建器一层**:门禁新增 `RESPONSE_BUILDERS`,解析 `server-rs/crates/api-server/src/modules/game_distribution.rs` 里 `game_payload` / `public_game_payload` / `private_version_payload` / `version_summary_payload` 的 `json!` 顶层键与顶层 `object.insert(…)`,逐键比对 TS 类型:发出的键必须都在类型里、类型的必需字段必须都发出、`public_game_payload` 还必须发出被 TS 标成可选的 `currentVersion`,`game_payload` 的 `author` / `deviceSupport` 两个嵌套字面量同样逐键比对。变异验证五种改法各自让门禁失败并指出具体键:删掉 `game_payload.publicationRevision`、删掉 `deviceSupport.touch`、把 `author.avatarUrl` 改名、把插入的 `currentVersion` 改名、给 `version_summary_payload` 加一个 TS 没有的键;恢复后通过。**剩余缺口**:门禁比对的是键而不是值的类型,也覆盖不到未登记的嵌套对象与 envelope——成功/失败 envelope 的字段由 TS 侧运行时守卫(`packages/shared/src/http.ts`、`src/services/apiClient.ts`)消费,要彻底类型化得先把这两个响应改成结构化构建。
diff --git a/package.json b/package.json
index ad3a7cbe7..52699419b 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"check:game-distribution-dto-parity": "node scripts/check-game-distribution-dto-parity.mjs",
"check:game-distribution-media-e2e": "node scripts/check-game-distribution-media-e2e.mjs",
"check:game-distribution-owner-isolation": "node scripts/check-game-distribution-owner-isolation.mjs",
+ "check:game-distribution-upload-concurrency": "node scripts/check-game-distribution-upload-concurrency.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-upload-concurrency.mjs b/scripts/check-game-distribution-upload-concurrency.mjs
new file mode 100644
index 000000000..d2d234a5e
--- /dev/null
+++ b/scripts/check-game-distribution-upload-concurrency.mjs
@@ -0,0 +1,441 @@
+// 游戏分发「同版本并发上传不混写 + 幂等重放」真实链路检查(需要本地 dev 栈:SpacetimeDB + api-server)。
+//
+// 用法:
+// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
+// npm run check:game-distribution-upload-concurrency
+// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:4198)。
+//
+// 覆盖:
+// 1. 同一个 Idempotency-Key 重放建游戏 / 建版本 → 返回同一条记录,不产生第二份;
+// 同一个 Key 换请求体 → 冲突拒绝。
+// 2. 同一个版本、同一个偏移量并发写两片相同字节 → 只有一片被接受,另一片是可重试错误;
+// 已收字节等于单份包长,随后的确认校验必须通过(混写或重复追加都会让校验失败)。
+// 3. 送审成功后用同一个 Key 重放 → 202 `replayed=true` 且仍是同一版本;
+// 不同 Key 重复送审不会产生第二份提交(版本数仍为 1、状态不变)。
+// 整条链路只在本地 dev 数据库与该 dev bucket 的对象前缀下落行;一个几百字节的发行包对象
+// 会在确认后留在 bucket 里(与现役上传链路的产物一致),另有 67 字节封面 PNG。
+import { createHash } from 'node:crypto';
+
+import JSZip from 'jszip';
+
+const COVER_PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+ 'base64',
+);
+const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
+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!';
+
+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);
+}
+
+let failures = 0;
+function check(name, ok, detail = '') {
+ if (!ok) failures += 1;
+ console.log(
+ `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
+ );
+}
+
+async function api(path, 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}${path}`, {
+ method,
+ headers: finalHeaders,
+ body: finalBody,
+ });
+ const text = await response.text();
+ let json = null;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = null;
+ }
+ return {
+ status: response.status,
+ json,
+ text,
+ data: json?.data,
+ error: json?.error,
+ };
+}
+
+// 两份内容不同但长度一致的条目,保证 fixture ZIP 的可复现长度(分片并发用例需要固定声明大小)。
+async function buildFixtureZip() {
+ const archive = new JSZip();
+ archive.file('index.html', '
concurrency');
+ archive.file('assets/app.js', `console.log('${'x'.repeat(384)}');`);
+ const bytes = await archive.generateAsync({ type: 'uint8array' });
+ return Buffer.from(bytes);
+}
+
+function gameMetadata(title) {
+ return {
+ title,
+ summary: '并发上传与幂等重放用例的临时游戏',
+ description: '',
+ category: '休闲',
+ tags: ['e2e'],
+ deviceSupport: { desktop: true, mobile: false, touch: false },
+ inputModes: ['keyboard', 'mouse'],
+ orientation: 'landscape',
+ };
+}
+
+async function uploadCover(token, id) {
+ const fileName = `upload-safety-${id}.png`;
+ const ticket = await api('/api/assets/direct-upload-tickets', {
+ method: 'POST',
+ token,
+ body: {
+ legacyPrefix: 'generated-character-drafts',
+ pathSegments: ['game-distribution', 'upload-safety', 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-upload-safety',
+ },
+ });
+ if (confirm.status !== 200) {
+ throw new Error(
+ `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
+ );
+ }
+ return confirm.data.assetObject.assetObjectId;
+}
+
+async function main() {
+ 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 stamp = Date.now();
+ const register = await api('/api/auth/entry', {
+ method: 'POST',
+ body: {
+ purePhoneNumber: `139${String(stamp).slice(-8)}`,
+ password: DEV_PASSWORD,
+ },
+ });
+ const author = register.data?.token;
+ check(
+ '作者注册拿到 token',
+ register.status === 200 && Boolean(author),
+ `status=${register.status}`,
+ );
+ if (!author) process.exit(1);
+
+ const setGate = (enabled, rolloutPercent) =>
+ api('/admin/api/feature-gates', {
+ method: 'PUT',
+ token: admin,
+ body: {
+ gateKey: 'game-distribution:publish',
+ enabled,
+ rolloutPercent,
+ allowUserIds: [],
+ allowUserTags: [],
+ denyUserIds: [],
+ description: 'E2E 并发上传与幂等重放',
+ },
+ });
+ const gateOpen = await setGate(true, 100);
+ check(
+ '发布灰度可开启并放量',
+ gateOpen.status === 200,
+ `status=${gateOpen.status}`,
+ );
+
+ const zipBytes = await buildFixtureZip();
+ const zipSha256 = createHash('sha256').update(zipBytes).digest('hex');
+ const coverAssetId = await uploadCover(author, stamp);
+ const title = `并发上传 ${String(stamp).slice(-6)}`;
+ const metadata = { ...gameMetadata(title), coverAssetId };
+
+ // 1. 幂等重放:同 key 同请求返回同一条记录,同 key 换请求体冲突。
+ const gameKey = `safety-game-${stamp}`;
+ const created = await api('/api/game-distribution/games', {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': gameKey },
+ body: metadata,
+ });
+ const gameId = created.data?.id;
+ check(
+ '创建游戏成功',
+ created.status === 200 && Boolean(gameId),
+ `status=${created.status} msg=${created.error?.message ?? ''}`,
+ );
+ if (!gameId) process.exit(1);
+
+ const replayedGame = await api('/api/game-distribution/games', {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': gameKey },
+ body: metadata,
+ });
+ check(
+ '同 key 同请求重放建游戏返回同一 game',
+ replayedGame.status === 200 && replayedGame.data?.id === gameId,
+ `status=${replayedGame.status} gameId=${replayedGame.data?.id ?? ''}`,
+ );
+
+ const conflictingGame = await api('/api/game-distribution/games', {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': gameKey },
+ body: { ...metadata, title: `${title} 改` },
+ });
+ check(
+ '同 key 换请求体建游戏被拒(不产生第二份)',
+ conflictingGame.status === 409,
+ `status=${conflictingGame.status} code=${conflictingGame.error?.code ?? ''}`,
+ );
+
+ const versionKey = `safety-version-${stamp}`;
+ const versionBody = {
+ packageSha256: zipSha256,
+ packageBytes: zipBytes.length,
+ packageFileCount: 2,
+ packageEntryPath: 'index.html',
+ gameMetadata: metadata,
+ };
+ const versionResponse = await api(
+ `/api/game-distribution/games/${gameId}/versions`,
+ {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': versionKey },
+ body: versionBody,
+ },
+ );
+ const versionId = versionResponse.data?.versionId;
+ const publicationRevision = versionResponse.data?.publicationRevision ?? 0;
+ check(
+ '创建版本成功(awaiting_upload)',
+ versionResponse.status === 200 &&
+ Boolean(versionId) &&
+ versionResponse.data?.status === 'awaiting_upload',
+ `status=${versionResponse.status} versionStatus=${versionResponse.data?.status}`,
+ );
+ if (!versionId) process.exit(1);
+
+ const replayedVersion = await api(
+ `/api/game-distribution/games/${gameId}/versions`,
+ {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': versionKey },
+ body: versionBody,
+ },
+ );
+ check(
+ '同 key 同请求重放建版本返回同一 version',
+ replayedVersion.status === 200 &&
+ replayedVersion.data?.versionId === versionId,
+ `status=${replayedVersion.status} versionId=${replayedVersion.data?.versionId ?? ''}`,
+ );
+
+ // 2. 同版本同偏移并发写两片:只允许一片落地。
+ const chunk = (idempotencyKey) =>
+ api(`/api/game-distribution/versions/${versionId}/package/chunk`, {
+ method: 'PUT',
+ token: author,
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ 'Idempotency-Key': idempotencyKey,
+ 'x-genarrative-upload-offset': '0',
+ },
+ binary: zipBytes,
+ });
+ const raced = await Promise.all([
+ chunk(`safety-chunk-a-${stamp}`),
+ chunk(`safety-chunk-b-${stamp}`),
+ ]);
+ const winners = raced.filter((item) => item.status === 200);
+ const losers = raced.filter((item) => item.status !== 200);
+ check(
+ '并发同偏移分片只有一片写入成功',
+ winners.length === 1,
+ raced
+ .map(
+ (item) =>
+ `${item.status}${item.error?.code ? `/${item.error.code}` : ''}`,
+ )
+ .join(' '),
+ );
+ check(
+ '落败请求返回偏移冲突与权威已收字节(可直接续传)',
+ losers.length === 1 &&
+ losers[0].status === 409 &&
+ losers[0].error?.details?.receivedBytes === zipBytes.length,
+ losers
+ .map(
+ (item) =>
+ `${item.status} ${item.error?.code ?? ''} receivedBytes=${item.error?.details?.receivedBytes ?? 'n/a'}`,
+ )
+ .join(''),
+ );
+ check(
+ '成功分片回报的已收字节等于单份包长',
+ winners[0]?.data?.receivedBytes === zipBytes.length,
+ `receivedBytes=${winners[0]?.data?.receivedBytes ?? 'n/a'} zip=${zipBytes.length}`,
+ );
+
+ const stateAfterRace = await api(
+ `/api/game-distribution/versions/${versionId}/package/upload-state`,
+ { token: author },
+ );
+ check(
+ '服务端权威已收字节等于单份包长(没有重复追加)',
+ stateAfterRace.status === 200 &&
+ stateAfterRace.data?.receivedBytes === zipBytes.length,
+ `receivedBytes=${stateAfterRace.data?.receivedBytes ?? 'n/a'}`,
+ );
+
+ const completed = await api(
+ `/api/game-distribution/versions/${versionId}/package/complete`,
+ {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': `safety-complete-${stamp}` },
+ },
+ );
+ check(
+ '并发落地的对象就是完整一份(确认校验通过并落到 uploaded)',
+ completed.status === 200 && completed.data?.status === 'uploaded',
+ `status=${completed.status} versionStatus=${completed.data?.status ?? ''} msg=${completed.error?.message ?? ''}`,
+ );
+
+ // 3. 送审与重放。
+ const submitKey = `safety-submit-${stamp}`;
+ const submitBody = { expectedPublicationRevision: publicationRevision };
+ const submitted = await api(
+ `/api/game-distribution/versions/${versionId}/submit`,
+ {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': submitKey },
+ body: submitBody,
+ },
+ );
+ check(
+ '送审成功(202 + pending_review)',
+ submitted.status === 202 &&
+ submitted.data?.version?.status === 'pending_review',
+ `status=${submitted.status} versionStatus=${submitted.data?.version?.status ?? ''}`,
+ );
+
+ const replayedSubmit = await api(
+ `/api/game-distribution/versions/${versionId}/submit`,
+ {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': submitKey },
+ body: submitBody,
+ },
+ );
+ check(
+ '同 key 重放送审回到同一个版本且 replayed=true',
+ replayedSubmit.status === 202 &&
+ replayedSubmit.data?.replayed === true &&
+ replayedSubmit.data?.version?.versionId === versionId &&
+ replayedSubmit.data?.version?.status === 'pending_review',
+ `status=${replayedSubmit.status} replayed=${replayedSubmit.data?.replayed}`,
+ );
+
+ const resubmitted = await api(
+ `/api/game-distribution/versions/${versionId}/submit`,
+ {
+ method: 'POST',
+ token: author,
+ headers: { 'Idempotency-Key': `safety-submit-again-${stamp}` },
+ body: submitBody,
+ },
+ );
+ const myGamesAfterResubmit = await api('/api/game-distribution/my-games', {
+ token: author,
+ });
+ const gameAfterResubmit = Array.isArray(myGamesAfterResubmit.data?.games)
+ ? myGamesAfterResubmit.data.games.find((item) => item.id === gameId)
+ : null;
+ check(
+ '换 key 重复送审按状态冲突拒绝且不产生第二份提交(版本数仍为 1)',
+ resubmitted.status === 409 &&
+ gameAfterResubmit?.versions?.length === 1 &&
+ gameAfterResubmit.versions[0]?.versionId === versionId &&
+ gameAfterResubmit.versions[0]?.status === 'pending_review',
+ `submitStatus=${resubmitted.status} replayed=${resubmitted.data?.replayed} versions=${gameAfterResubmit?.versions?.length ?? 'n/a'}`,
+ );
+
+ const gateClosed = await setGate(false, 0);
+ check(
+ '发布灰度恢复关闭',
+ gateClosed.status === 200,
+ `status=${gateClosed.status}`,
+ );
+
+ console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
+ process.exit(failures === 0 ? 0 : 1);
+}
+
+main().catch((error) => {
+ console.error(
+ `[check:game-distribution-upload-concurrency] 运行失败:${error}`,
+ );
+ process.exit(1);
+});
diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs
index 558f88c55..89f07cd59 100644
--- a/server-rs/crates/api-server/src/modules/game_distribution.rs
+++ b/server-rs/crates/api-server/src/modules/game_distribution.rs
@@ -925,11 +925,11 @@ async fn upload_package_chunk(
"receivedBytes": received_bytes,
})));
}
- let appended = oss
+ let append_result = oss
.append_internal_object_with_retry(
state.editor_oss_http_client(),
OssAppendInternalObjectRequest {
- object_key,
+ object_key: object_key.clone(),
content_type: Some("application/zip".to_string()),
position: offset,
body: body.to_vec(),
@@ -937,8 +937,36 @@ async fn upload_package_chunk(
GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS,
&GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS,
)
- .await
- .map_err(|error| map_oss_error(error, "aliyun-oss"))?;
+ .await;
+ let appended = match append_result {
+ Ok(appended) => appended,
+ Err(error) => {
+ // 追加失败也可能是「同偏移的并发写入先赢了一片」:先读权威已收字节,
+ // 只要长度已经前进就按偏移冲突返回,让客户端按权威偏移续传,
+ // 而不是把一个可恢复的并发结果报成上游故障。
+ if let Ok(authoritative) = staged_package_bytes(&state, oss, &object_key).await
+ && authoritative > offset
+ {
+ warn!(
+ request_id = ctx.request_id(),
+ operation = "package_chunk_offset_lost_race",
+ game_id = %version.game_id,
+ version_id = %version_id,
+ declared_offset = offset,
+ received_bytes = authoritative,
+ "并发写入已推进已收字节,按偏移冲突返回权威位置"
+ );
+ return Err(AppError::from_status(StatusCode::CONFLICT)
+ .with_code("PACKAGE_UPLOAD_OFFSET_MISMATCH")
+ .with_message("分片偏移与服务端已收字节不一致,请按权威偏移续传")
+ .with_details(json!({
+ "provider": "game-distribution",
+ "receivedBytes": authoritative,
+ })));
+ }
+ return Err(map_oss_error(error, "aliyun-oss"));
+ }
+ };
info!(
request_id = ctx.request_id(),
operation = "package_chunk_stored",