From f1c864309b7a28ff6966a244cabd1e6be565759e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 8 Oct 2026 12:19:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=BE=E5=AE=BD=E6=9B=B4=E6=96=B0=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=8F=91=E5=B8=83=E8=8D=89=E7=A8=BF=E7=9A=84=20gameId?= =?UTF-8?q?/versionId=20=E9=85=8D=E5=AF=B9=E6=A0=A1=E9=AA=8C=20`isDraft`?= =?UTF-8?q?=20=E4=B8=8D=E5=86=8D=E8=A6=81=E6=B1=82=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=20id=20=E5=90=8C=E6=97=B6=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E6=88=96=E7=BC=BA=E5=A4=B1=EF=BC=9A=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=9C=A8=E8=AF=B7=E6=B1=82=E5=89=8D=E5=8F=AA?= =?UTF-8?q?=E4=BC=9A=E5=86=BB=E7=BB=93=20`gameId`=EF=BC=8C`versionId`=20?= =?UTF-8?q?=E8=A6=81=E7=AD=89=E5=93=8D=E5=BA=94=EF=BC=8C=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E8=AF=A5=E8=8D=89=E7=A8=BF=E4=BC=9A=E5=9C=A8=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=E5=90=8E=E6=8A=8A=E5=86=BB=E7=BB=93=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=E6=8D=A2=E6=88=90=E3=80=8C=E7=BA=BF=E4=B8=8A=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E7=89=88=E6=9C=AC=20+1=E3=80=8D=E8=80=8C=E9=87=8D=E5=8F=91?= =?UTF-8?q?=E6=88=90=E7=AC=AC=E4=BA=8C=E4=B8=AA=E7=89=88=E6=9C=AC=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=8D=89=E7=A8=BF=E8=AF=BB=E5=8F=96=E4=B8=8E?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=A8=A1=E5=BC=8F=E6=B2=BF=E7=94=A8=E5=86=BB?= =?UTF-8?q?=E7=BB=93=E7=89=88=E6=9C=AC=E5=8F=B7=E7=9A=84=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gamePublishDraft.test.ts | 16 ++++---- .../game-distribution/gamePublishDraft.ts | 12 +++--- .../gamePublishSubmission.test.ts | 37 +++++++++++++++++++ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/components/game-distribution/gamePublishDraft.test.ts b/src/components/game-distribution/gamePublishDraft.test.ts index 3f6c32639..14b22a5b7 100644 --- a/src/components/game-distribution/gamePublishDraft.test.ts +++ b/src/components/game-distribution/gamePublishDraft.test.ts @@ -44,15 +44,15 @@ describe('readPublishDraft 的 gameId/versionId 配对', () => { expect(readPublishDraft(storageWith(draft))).toEqual(draft); }); - it('只回填 gameId 的半写草稿判为不可恢复', () => { - expect( - readPublishDraft(storageWith({ ...BASE_DRAFT, gameId: 'game_1' })), - ).toBeNull(); + it('只有 gameId(更新模式发请求前的正常中间态)仍可恢复', () => { + // 更新模式在请求前就写 gameId(路径身份),versionId 等响应;这不是半写损坏,必须保留草稿, + // 否则响应丢失后重载会丢掉冻结的 versionNumber、按线上最近版本 +1 推出第二个版本。 + const draft: GamePublishDraft = { ...BASE_DRAFT, gameId: 'game_1' }; + expect(readPublishDraft(storageWith(draft))).toEqual(draft); }); - it('只回填 versionId 的半写草稿判为不可恢复', () => { - expect( - readPublishDraft(storageWith({ ...BASE_DRAFT, versionId: 'gamever_1' })), - ).toBeNull(); + it('只有 versionId 的半写草稿仍可恢复(编号类型合法即可)', () => { + const draft: GamePublishDraft = { ...BASE_DRAFT, versionId: 'gamever_1' }; + expect(readPublishDraft(storageWith(draft))).toEqual(draft); }); }); diff --git a/src/components/game-distribution/gamePublishDraft.ts b/src/components/game-distribution/gamePublishDraft.ts index d1ca42043..6e5d5ff57 100644 --- a/src/components/game-distribution/gamePublishDraft.ts +++ b/src/components/game-distribution/gamePublishDraft.ts @@ -8,8 +8,10 @@ import type { GameDistributionRecoveryAction } from '../../../packages/shared/sr * * `projectKey` 与 `versionNumber` 必须在调用发布接口**之前**写入:它们构成服务端自然幂等键 * `(owner, projectKey | gameId, versionNumber)` 的一部分,响应丢失后重试才能命中同一游戏与版本。 - * `gameId` / `versionId` 是服务端分配后才回填的字段。`ownerUserId` 用于换账号时拒绝恢复, - * 避免把上一个账号的私有状态展示给新账号。 + * `gameId` / `versionId` 是服务端分配后才回填的字段。**更新模式**在请求前就写 `gameId`(路径身份), + * `versionId` 要等响应,因此「有 `gameId` 没 `versionId`」是正常的中间态,不能当成损坏草稿丢弃—— + * 丢了它,响应丢失后重载会按「线上最近版本 +1」推出第二个版本,而不是用冻结的 `versionNumber` 重放。 + * `ownerUserId` 用于换账号时拒绝恢复,避免把上一个账号的私有状态展示给新账号。 */ export type GamePublishDraft = { ownerUserId: string; @@ -40,8 +42,6 @@ function resolveStorage(storage?: StorageLike): StorageLike | null { function isDraft(value: unknown): value is GamePublishDraft { if (!value || typeof value !== 'object') return false; const draft = value as Partial; - const hasGameId = draft.gameId !== undefined; - const hasVersionId = draft.versionId !== undefined; return Boolean( draft.ownerUserId && draft.projectKey && @@ -49,9 +49,7 @@ function isDraft(value: unknown): value is GamePublishDraft { draft.versionNumber >= 1 && typeof draft.title === 'string' && (draft.gameId === undefined || typeof draft.gameId === 'string') && - (draft.versionId === undefined || typeof draft.versionId === 'string') && - // 两个 id 由同一次发布响应一起回填,半写状态只可能来自损坏的旧草稿,直接判为不可恢复。 - hasGameId === hasVersionId, + (draft.versionId === undefined || typeof draft.versionId === 'string'), ); } diff --git a/src/components/game-distribution/gamePublishSubmission.test.ts b/src/components/game-distribution/gamePublishSubmission.test.ts index c937f47d4..a944d4164 100644 --- a/src/components/game-distribution/gamePublishSubmission.test.ts +++ b/src/components/game-distribution/gamePublishSubmission.test.ts @@ -261,6 +261,43 @@ describe('gamePublishSubmission', () => { expect(createProjectKey).not.toHaveBeenCalled(); }); + it('更新模式草稿只有 gameId(响应丢失的中间态)时仍沿用冻结版本号', async () => { + const store = createDraftStore({ + ownerUserId: 'user-1', + projectKey: 'web-project-fixed', + versionNumber: 3, + gameId: 'game-1', + // 刻意不写 versionId:更新模式在请求前只会冻结 gameId,`versionId` 要等响应。 + title: '旧版本', + updatedAt: '2026-10-06T00:00:00.000Z', + }); + const client = createClient({ + publishGameVersion: vi.fn(async () => + publishResponse({ versionNumber: 3 }), + ), + }); + const submit = createGamePublishSubmission({ + ...client, + ...store, + createProjectKey: () => 'should-not-be-used', + now: FIXED_NOW, + }); + + // 调用方重读到 max+1 = 4;必须用草稿冻结的 3 重放,而不是新建 v4。 + await submit({ + ...BASE_INPUT, + target: { mode: 'update', gameId: 'game-1' }, + versionNumber: 4, + }); + + const [target] = client.publishGameVersion.mock.calls[0] ?? []; + expect(target).toEqual({ + mode: 'update', + gameId: 'game-1', + versionNumber: 3, + }); + }); + it('换账号时忽略上一账号的草稿并生成新的 projectKey', async () => { const store = createDraftStore({ ownerUserId: 'user-2',