fix(网页发布): 恢复路径改用 versionId 派生幂等键

- handleResumeSubmit 原先用每会话随机的 resolvePublishKey(),与提交模块的 `${versionId}:upload`/`:submit` 不一致,刷新页面后重试无法被服务端去重
- 统一成同一组 versionId 派生键,并删除不再使用的 createPublishKey / publishKeyRef / publishKeySeed
- 补 GamePublishPage 断言恢复上传与送审确实发送 ${versionId}:upload / :submit
This commit is contained in:
2026-10-07 19:55:32 +08:00
parent 3dfcd8d66f
commit 80e910dfe5
2 changed files with 18 additions and 24 deletions
@@ -523,7 +523,13 @@ test('按统一发布、上传、送审顺序提交并展示审核中', async ()
'shot-2.png',
]);
expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[0]).toBe('gamever-1');
expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[2]).toBe(
'gamever-1:upload',
);
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(0);
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[2]).toBe(
'gamever-1:submit',
);
expect(
await screen.findByText('已提交审核,审核通过后会自动出现在游戏广场。'),
@@ -586,6 +592,10 @@ test('同一账号回到发布页可以沿用原版本继续送审', async () =>
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('gamever-1');
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(2);
// 恢复送审与提交模块共用同一套 versionId 派生键,刷新页面后重试也能被服务端去重。
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[2]).toBe(
'gamever-1:submit',
);
expect(
await screen.findByText('已提交审核,审核通过后会自动出现在游戏广场。'),
).toBeTruthy();
@@ -68,16 +68,6 @@ type GameUpdateContext = {
title: string;
};
let publishKeySeed = 0;
function createPublishKey() {
publishKeySeed += 1;
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${publishKeySeed}`;
return `web-publish-${random}`;
}
/**
* 网页发布编排:UI 只组输入与展示结果,定 key、调接口、写草稿、传包、送审都在提交模块内。
* 依赖在模块加载时注入一次,单测替换 `gameDistributionClient` 即可覆盖整条链路。
@@ -150,19 +140,14 @@ export function GamePublishPage({
const [error, setError] = useState('');
const [publishedGameId, setPublishedGameId] = useState('');
/**
* 同步的在途提交标记与恢复上传/送审用的稳定幂等键。
* 同步的在途提交标记。
*
* `isSubmitting` 是 React 状态,双击发生在同一次渲染窗口里时还没生效;再叠上
* `prepareGamePackage` 的压缩耗时,两次点击会各建一份游戏。这里用 ref 同步拦截。
* 新建/更新发布的自然幂等由提交模块冻结的 `projectKey`/`versionNumber` 负责;
* 恢复已有版本继续上传时仍复用同一组 `Idempotency-Key`。
* 幂等键全部由提交模块按不可变 `versionId` 派生(`{versionId}:upload` / `:submit`),
* 恢复路径沿用同一组,不再用每会话随机键,刷新页面后重试也能被服务端去重。
*/
const submitInFlightRef = useRef(false);
const publishKeyRef = useRef<string | null>(null);
const resolvePublishKey = () => {
if (!publishKeyRef.current) publishKeyRef.current = createPublishKey();
return publishKeyRef.current;
};
const [draft, setDraft] = useState<GamePublishDraft | null>(null);
const [draftDetail, setDraftDetail] =
useState<GameDistributionVersionDetail | null>(null);
@@ -390,28 +375,27 @@ export function GamePublishPage({
setIsSubmitting(true);
setError('');
try {
const resumeKey = resolvePublishKey();
const resumeVersionId = draftDetail.version.versionId;
if (draftDetail.version.recoveryAction !== 'submit') {
const prepared = await prepareGamePackage(packageFile as File);
await uploadGamePackage(
draftDetail.version.versionId,
resumeVersionId,
new Blob([prepared.bytes as unknown as BlobPart], {
type: 'application/zip',
}),
`${resumeKey}:upload`,
`${resumeVersionId}:upload`,
);
}
await submitGameVersion(
draftDetail.version.versionId,
resumeVersionId,
draftDetail.game.publicationRevision,
`${resumeKey}:submit`,
`${resumeVersionId}:submit`,
);
clearPublishDraft();
setDraft(null);
setDraftDetail(null);
setIsResumingDraft(false);
setPublishedGameId(draftDetail.game.id);
publishKeyRef.current = null;
} catch (resumeError: unknown) {
setError(
resumeError instanceof Error ? resumeError.message : '继续发布失败',