diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 52eb54443..a15a7fb2e 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -99,7 +99,7 @@ - [ ] AGC 发布面板隐藏发行包技术摘要;打开时基于有界、脱敏的项目上下文免费生成一句话简介与白名单分类,失败保留本地兜底且不阻断发布;作者始终可以直接编辑生成结果。 - [ ] AGC 发布封面支持基于项目上下文生成,复用现役图片生成与泥点扣费链路;生成结果登记为当前账号平台素材后自动作为 `coverAssetId`,不二次上传。 - [x] 网页可选 ZIP、提交封面和必需资料,进入相同上传/校验/审核流程;任一客户端可以查看同账号游戏状态,更新沿用相同 `gameId`。 -- [ ] 上传中断、双击、登录失效、窗口关闭后恢复原操作;换账号不能恢复前账号私有状态;待审不能显示为已发布。 +- [x] 上传中断、双击、登录失效、窗口关闭后恢复原操作;换账号不能恢复前账号私有状态;待审不能显示为已发布。 - [x] 目录支持真实数据、关键词/分类/设备筛选、空/错/加载态;详情提供明确主动作,搜索与返回恢复上下文。 - [x] 游客从目录/分享链接进入详情再启动真实已发布游戏;开始操作后才加载 iframe,加载失败可重试,退出不会自动重启游戏。 - [ ] 桌面与移动导航、详情、上传/发布面板、旋转提示、全屏、安全区和焦点可达符合主规范;不适配移动端的游戏有明确门槛。 @@ -208,5 +208,6 @@ - 已勾选(第 4 条:网页可选 ZIP、提交封面与必需资料,进入同一上传/校验/审核流程;同账号状态可见;更新沿用同一 `gameId`)——本轮补齐 - 新增 `scripts/check-game-distribution-web-publish-e2e.mjs`(`npm run check:game-distribution-web-publish-e2e`,真实 dev 网页栈 + Chromium):网页里用密码登录/注册 → `/games/publish` 填资料、选封面、选发行包 ZIP → 提交后页面给出「已提交审核」;服务端回读同一账号 `my-games` 是 `latestVersion.status=pending_review` + 游戏 `unpublished`,广场里看不到该游戏;网页「我的游戏」显示「审核中」。管理员审核通过后广场出现该游戏且 `entryUrl=/games//`,网页「我的游戏」同步显示「已发布」;再用 `/games/publish?game=` 提交新版本,`versions.length=2`、`latestVersion=pending_review`、游戏仍是 `published`(旧版在线)——更新沿用同一 `gameId`。**12 项 PASS**。 - **本轮顺带修掉一处真实缺陷**:`loadFrontendRuntimeConfig()` 之前用 `skipAuth: true` 调 `/api/runtime/frontend-config`,而发布灰度是**按作者**判定的(匿名恒为 false)。结果是:只要运营显式配置过 `game-distribution:publish`,登录作者在网页端也永远看不到发布入口(页面一直显示「发布功能正在灰度中」)。改为带登录态请求后,登录后壳层重读配置即可拿到发布入口,无需刷新页面;`frontendRuntimeConfigService.test.ts` 同步更新。 -- 仍未勾选(第 5 条其余部分) - - 「待审不能显示为已发布」已随本轮取证(更新待审时游戏仍显示已发布、新版单独处于审核中);**仍缺**:上传中断、双击提交、登录失效、窗口关闭后恢复原操作,以及换账号不能恢复前账号私有状态的用例。 \ No newline at end of file + - 已勾选(第 5 条:上传中断、双击、登录失效、窗口关闭后恢复原操作;换账号不能恢复前账号私有状态;待审不能显示为已发布)——本轮补齐 + - 新增 `scripts/check-game-distribution-web-publish-recovery-e2e.mjs`(`npm run check:game-distribution-web-publish-recovery-e2e`,真实网页栈 + Chromium,**10 项 PASS**):拦截一次发行包上传让页面失败 → 本地留下恢复草稿且服务端只有一份游戏/一个版本;清掉 access token 与 refresh cookie 后发布页要求重新登录,重登同一账号后恢复提示仍在;关窗后用新页面打开 `/games/publish` 能看到「上次发布未完成(版本 v1)/ 继续上传」,续传沿用**原 versionId** 进入待审并清掉草稿;把前一账号的草稿留在同一浏览器里换成另一账号打开时不再显示该草稿。待审不冒充已发布由发布链路脚本的更新场景覆盖(游戏仍 `published`,新版本单独 `pending_review`)。 + - **本轮顺带修掉一处真实缺陷**:连点两次「提交审核」会创建两份游戏(`isSubmitting` 是 React 状态,双击落在同一次渲染窗口里还没生效,再叠上 `prepareGamePackage` 的压缩耗时)。修复:加同步的 `submitInFlightRef` 在途守卫,并让同一次发布(含失败后重试)复用同一组 Idempotency-Key(`publishKeyRef`),服务端按幂等重放;E2E 里「双击只落一份游戏与一个版本(games=1 versions=1)」就是这条的回归证据,`GamePublishPage.test.tsx` 15 passed。 - 「待审不能显示为已发布」已随本轮取证(更新待审时游戏仍显示已发布、新版单独处于审核中);**仍缺**:上传中断、双击提交、登录失效、窗口关闭后恢复原操作,以及换账号不能恢复前账号私有状态的用例。 \ No newline at end of file diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9d1f312ef..313cdf806 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6073,4 +6073,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:运营把 `game-distribution:publish` 显式配置成 `enabled=true, rolloutPercent=100` 后,登录作者在网页 `/games/publish` 仍看到「发布功能正在灰度中 / 当前账号还没有发布入口」,但用同一账号的 access token 直接请求 `/api/runtime/frontend-config` 得到 `gameDistributionPublishEnabled=true`。 - **根因**:`src/services/frontendRuntimeConfigService.ts` 的 `loadFrontendRuntimeConfig()` 传了 `skipAuth: true`,请求不带登录态;而发布灰度是**按作者**判定的(匿名恒为 false),所以壳层拿到的永远是匿名结果。 -- **处理**:去掉 `skipAuth`(保留 `skipRefresh` 等选项),登录后壳层按 `gamePublishGateUserId` 重读配置即可拿到发布入口;`frontendRuntimeConfigService.test.ts` 与 `check:game-distribution-web-publish-e2e` 都覆盖回归。教训:任何「按用户/作者灰度」的配置接口都不能走 `skipAuth`,匿名结果与登录结果语义不同。 \ No newline at end of file +- **处理**:去掉 `skipAuth`(保留 `skipRefresh` 等选项),登录后壳层按 `gamePublishGateUserId` 重读配置即可拿到发布入口;`frontendRuntimeConfigService.test.ts` 与 `check:game-distribution-web-publish-e2e` 都覆盖回归。教训:任何「按用户/作者灰度」的配置接口都不能走 `skipAuth`,匿名结果与登录结果语义不同。 +## 2026-09-28 发布按钮连点两次会建两份游戏:React isSubmitting 挡不住同一次渲染窗口里的双击 + +- **现象**:网页发布页飞快双击「提交审核」,服务端出现两份游戏(各带一个版本)。`isSubmitting` 是 React 状态,双击发生在同一次渲染窗口里时还没生效,再叠上 `prepareGamePackage` 的 ZIP 压缩耗时,两次点击各走一遍创建工作。 +- **处理**:`GamePublishPage` 加同步 `submitInFlightRef` 在途守卫(进入提交前同步置位、`finally` 复位),并让同一次发布(含失败后重试)复用同一组 Idempotency-Key(`publishKeyRef`,成功后清空),服务端按幂等重放;`check:game-distribution-web-publish-recovery-e2e` 断言「双击只落一份游戏与一个版本」,`GamePublishPage.test.tsx` 15 passed。 +- **教训**:任何「点一次会建资源」的入口都需要同步守卫或稳定幂等键,只靠 React 状态禁用按钮不够。 \ No newline at end of file diff --git a/package.json b/package.json index d3e3dcd11..2e2e7102c 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "check:external-v1-scene-generation-smoke": "node scripts/check-external-v1-scene-generation-smoke.mjs", "check:game-distribution-web-e2e": "node scripts/check-game-distribution-web-e2e.mjs", "check:game-distribution-web-publish-e2e": "node scripts/check-game-distribution-web-publish-e2e.mjs", + "check:game-distribution-web-publish-recovery-e2e": "node scripts/check-game-distribution-web-publish-recovery-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-web-publish-recovery-e2e.mjs b/scripts/check-game-distribution-web-publish-recovery-e2e.mjs new file mode 100644 index 000000000..f694e571f --- /dev/null +++ b/scripts/check-game-distribution-web-publish-recovery-e2e.mjs @@ -0,0 +1,362 @@ +// 游戏分发网页发布「操作恢复」检查:双击提交只落一份、发行包上传中断后草稿仍在、 +// 关窗重开后能继续同一版本、换账号不会看到前一个账号的私有草稿。 +// +// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + playwright。 +// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright +// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw +// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-publish-recovery-e2e +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import JSZip from 'jszip'; + +const COVER_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); +const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000'; +const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082'; +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 DRAFT_KEY = 'genarrative.game-distribution.publish-draft.v1'; + +if (!ADMIN_USER || !ADMIN_PASSWORD) { + console.error( + '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要打开发布灰度并核对发布草稿。', + ); + 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(pathname, options = {}) { + const { method = 'GET', token, body } = options; + const headers = { ...ENVELOPE }; + if (token) headers.Authorization = `Bearer ${token}`; + let finalBody; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + finalBody = JSON.stringify(body); + } + const response = await fetch(`${API}${pathname}`, { + method, + headers, + body: finalBody, + }); + 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 }; +} + +async function loadPlaywright() { + const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim(); + if (!dir) return import('playwright'); + const requireFromDir = createRequire(path.join(dir, 'noop.js')); + return requireFromDir('playwright'); +} + +async function loginThroughWebUi(page, phone) { + await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 }); + await page.waitForSelector('.platform-account-entry', { timeout: 120_000 }); + await page.locator('.platform-account-entry').first().click(); + await page.getByRole('tab', { name: '密码登录' }).first().click(); + await page.waitForTimeout(600); + await page + .getByLabel('同意法律协议') + .first() + .check({ force: true }) + .catch(() => {}); + await page + .locator('input[placeholder="13800000000"]:visible') + .first() + .fill(phone); + await page + .locator('input[placeholder="输入密码"]:visible') + .first() + .fill(DEV_PASSWORD); + await page.getByRole('button', { name: '登录', exact: true }).first().click(); + await page + .locator('.platform-account-entry') + .first() + .filter({ hasText: phone.slice(-4) }) + .waitFor({ state: 'visible', timeout: 30_000 }); +} + +async function fillPublishForm(page, title, coverPath, zipPath) { + await page.goto(`${WEB}/games/publish`, { + waitUntil: 'commit', + timeout: 120_000, + }); + await page + .getByLabel('游戏名称') + .waitFor({ state: 'visible', timeout: 60_000 }); + await page.getByLabel('游戏名称').fill(title); + await page.getByLabel('一句话简介').fill('发布恢复链路 E2E'); + await page.getByLabel('分类').selectOption('休闲'); + await page.getByLabel('游戏封面(必需)').setInputFiles(coverPath); + await page + .locator('img[alt="游戏封面预览"]') + .waitFor({ state: 'visible', timeout: 60_000 }); + await page.getByLabel('发行包 ZIP').setInputFiles(zipPath); + await page.waitForTimeout(400); +} + +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); + await api('/admin/api/feature-gates', { + method: 'PUT', + token: admin, + body: { + gateKey: 'game-distribution:publish', + enabled: true, + rolloutPercent: 100, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 发布恢复链路', + }, + }); + + const stamp = Date.now(); + const phone = `139${String(stamp).slice(-8)}`; + const title = `发布恢复 ${String(stamp).slice(-6)}`; + const workDir = await mkdtemp( + path.join(tmpdir(), 'genarrative-publish-recovery-'), + ); + const coverPath = path.join(workDir, 'cover.png'); + const zipPath = path.join(workDir, 'package.zip'); + await writeFile(coverPath, COVER_PNG); + const zip = new JSZip(); + zip.file( + 'index.html', + 'recovery' + + '

RECOVERY-OK

', + ); + zip.file('assets/app.js', 'document.documentElement.dataset.e2e="recovery";'); + await writeFile( + zipPath, + Buffer.from(await zip.generateAsync({ type: 'uint8array' })), + ); + + const { chromium } = await loadPlaywright(); + const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim(); + const browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}), + }); + let draftSnapshot = ''; + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + }); + const page = await context.newPage(); + await loginThroughWebUi(page, phone); + check('作者网页登录成功', true, `phone=${phone}`); + + let abortUploads = true; + let interrupted = false; + await page.route( + '**/api/game-distribution/versions/*/package*', + async (route) => { + if (abortUploads) { + interrupted = true; + await route.abort(); + return; + } + await route.continue(); + }, + ); + await fillPublishForm(page, title, coverPath, zipPath); + const submitButton = page.getByRole('button', { name: '提交审核' }).first(); + await submitButton.click(); + // 双击:第二次点击发生在提交中,按钮应已禁用,不产生第二份游戏/版本。 + await submitButton.click({ force: true, timeout: 3_000 }).catch(() => {}); + await page.waitForTimeout(4_000); + check('发行包上传失败时给出错误提示', interrupted, '已拦截一次上传请求'); + + const authorLogin = await api('/api/auth/entry', { + method: 'POST', + body: { purePhoneNumber: phone, password: DEV_PASSWORD }, + }); + const author = authorLogin.data?.token; + const mineAfterFailure = await api('/api/game-distribution/my-games', { + token: author, + }); + const gamesAfterFailure = (mineAfterFailure.data?.games ?? []).filter( + (item) => item.title === title, + ); + check( + '双击提交只落一份游戏与一个版本', + gamesAfterFailure.length === 1 && + gamesAfterFailure[0].versions?.length === 1, + `games=${gamesAfterFailure.length} versions=${gamesAfterFailure[0]?.versions?.length ?? 'n/a'}`, + ); + // 后面续传阶段放行上传。 + abortUploads = false; + draftSnapshot = await page.evaluate( + (key) => localStorage.getItem(key) ?? '', + DRAFT_KEY, + ); + const draft = draftSnapshot ? JSON.parse(draftSnapshot) : null; + check( + '上传中断后本地保留恢复草稿', + Boolean(draft?.versionId) && draft.gameId === gamesAfterFailure[0]?.id, + `versionId=${draft?.versionId ?? ''}`, + ); + + // 关窗 + 登录失效:清掉 access token 与 refresh cookie 后重登同一账号, + // 草稿仍在 localStorage 里,应该还能接着原版本上传。 + await page.close(); + const clearing = await context.newPage(); + await clearing.goto(`${WEB}/games`, { + waitUntil: 'commit', + timeout: 120_000, + }); + await clearing.evaluate(() => { + try { + localStorage.removeItem('genarrative.auth.access-token.v1'); + } catch { + // 存储不可用时下面按登出后的恢复提示判定。 + } + }); + await context.clearCookies(); + await clearing.close(); + + const reopened = await context.newPage(); + await reopened.goto(`${WEB}/games/publish`, { + waitUntil: 'commit', + timeout: 120_000, + }); + const loginPrompt = await reopened + .getByText('登录后才能发布游戏') + .first() + .waitFor({ state: 'visible', timeout: 30_000 }) + .then(() => true) + .catch(() => false); + check('登录失效后发布页要求重新登录', loginPrompt); + await loginThroughWebUi(reopened, phone); + await reopened.goto(`${WEB}/games/publish`, { + waitUntil: 'commit', + timeout: 120_000, + }); + await reopened + .getByText(/上次发布未完成(版本 v1)/u) + .first() + .waitFor({ state: 'visible', timeout: 60_000 }); + check('重新登录后仍能看到继续上传的恢复提示', true); + await reopened.getByRole('button', { name: '继续上传' }).first().click(); + await reopened.getByLabel('发行包 ZIP').setInputFiles(zipPath); + await reopened.waitForTimeout(400); + await reopened + .getByRole('button', { name: '继续上传并送审' }) + .first() + .click(); + await reopened + .getByText('已提交审核,审核通过后会自动出现在游戏广场。') + .first() + .waitFor({ state: 'visible', timeout: 60_000 }); + const mineAfterResume = await api('/api/game-distribution/my-games', { + token: author, + }); + const resumedGame = (mineAfterResume.data?.games ?? []).find( + (item) => item.title === title, + ); + check( + '继续上传沿用原版本并进入待审', + resumedGame?.versions?.length === 1 && + resumedGame?.versions?.[0]?.versionId === draft?.versionId && + resumedGame?.latestVersion?.status === 'pending_review', + `versions=${resumedGame?.versions?.length ?? 'n/a'} status=${resumedGame?.latestVersion?.status ?? ''}`, + ); + const draftAfterResume = await reopened.evaluate( + (key) => localStorage.getItem(key) ?? '', + DRAFT_KEY, + ); + check('恢复成功后清理本地草稿', draftAfterResume === ''); + await reopened.close(); + await context.close(); + + // 换账号:把前一个账号的草稿留在同一浏览器里,新账号不应看到恢复提示。 + const otherPhone = `137${String(stamp + 7).slice(-8)}`; + const otherContext = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + }); + await otherContext.addInitScript( + ({ key, value }) => { + try { + localStorage.setItem(key, value); + } catch { + // 存储不可用时这条用例不成立,下面按「没有恢复提示」判定。 + } + }, + { key: DRAFT_KEY, value: draftSnapshot }, + ); + const otherPage = await otherContext.newPage(); + await loginThroughWebUi(otherPage, otherPhone); + await otherPage.goto(`${WEB}/games/publish`, { + waitUntil: 'commit', + timeout: 120_000, + }); + await otherPage + .getByLabel('游戏名称') + .waitFor({ state: 'visible', timeout: 60_000 }); + await otherPage.waitForTimeout(1_500); + const leakedDraft = await otherPage.getByText(/上次发布未完成/u).count(); + check( + '换账号后不会显示上一个账号的私有草稿', + leakedDraft === 0, + `matches=${leakedDraft}`, + ); + await otherContext.close(); + } finally { + await browser.close(); + await api('/admin/api/feature-gates', { + method: 'PUT', + token: admin, + body: { + gateKey: 'game-distribution:publish', + enabled: false, + rolloutPercent: 0, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 发布恢复链路(收尾关闭)', + }, + }); + } + + console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((error) => { + console.error( + `[check-game-distribution-web-publish-recovery-e2e] 运行失败:${error}`, + ); + process.exit(1); +}); diff --git a/src/components/game-distribution/GamePublishPage.tsx b/src/components/game-distribution/GamePublishPage.tsx index 3c92099be..a6fecdab8 100644 --- a/src/components/game-distribution/GamePublishPage.tsx +++ b/src/components/game-distribution/GamePublishPage.tsx @@ -196,6 +196,19 @@ export function GamePublishPage({ const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(''); const [publishedGameId, setPublishedGameId] = useState(''); + /** + * 同步的在途提交标记与稳定的幂等键。 + * + * `isSubmitting` 是 React 状态,双击发生在同一次渲染窗口里时还没生效;再叠上 + * `prepareGamePackage` 的压缩耗时,两次点击会各建一份游戏。这里用 ref 同步拦截, + * 并让同一次发布(含失败后重试)复用同一组 Idempotency-Key,服务端按幂等重放。 + */ + const submitInFlightRef = useRef(false); + const publishKeyRef = useRef(null); + const resolvePublishKey = () => { + if (!publishKeyRef.current) publishKeyRef.current = createPublishKey(); + return publishKeyRef.current; + }; const [draft, setDraft] = useState(null); const [draftDetail, setDraftDetail] = useState(null); @@ -420,10 +433,12 @@ export function GamePublishPage({ setError('请选择上次那个发行包 ZIP 继续上传'); return; } + if (submitInFlightRef.current) return; + submitInFlightRef.current = true; setIsSubmitting(true); setError(''); try { - const resumeKey = createPublishKey(); + const resumeKey = resolvePublishKey(); if (draftDetail.version.recoveryAction !== 'submit') { const prepared = await prepareGamePackage(packageFile as File); await uploadGamePackage( @@ -444,11 +459,13 @@ export function GamePublishPage({ setDraftDetail(null); setIsResumingDraft(false); setPublishedGameId(draftDetail.game.id); + publishKeyRef.current = null; } catch (resumeError: unknown) { setError( resumeError instanceof Error ? resumeError.message : '继续发布失败', ); } finally { + submitInFlightRef.current = false; setIsSubmitting(false); } } @@ -482,11 +499,13 @@ export function GamePublishPage({ setError('请选择发行包 ZIP'); return; } + if (submitInFlightRef.current) return; + submitInFlightRef.current = true; setIsSubmitting(true); setError(''); try { const prepared = await prepareGamePackage(packageFile); - const updateKey = createPublishKey(); + const updateKey = resolvePublishKey(); const inputModes: GameDistributionInputMode[] = ['keyboard', 'mouse']; if (deviceSupport.touch) inputModes.push('touch'); const tags = tagsInput @@ -535,11 +554,13 @@ export function GamePublishPage({ ? { ...current, versionNumber: version.versionNumber } : current, ); + publishKeyRef.current = null; } catch (updateError: unknown) { setError( updateError instanceof Error ? updateError.message : '发布新版本失败', ); } finally { + submitInFlightRef.current = false; setIsSubmitting(false); } } @@ -573,6 +594,8 @@ export function GamePublishPage({ setError('请选择发行包 ZIP'); return; } + if (submitInFlightRef.current) return; + submitInFlightRef.current = true; setIsSubmitting(true); setError(''); try { @@ -596,7 +619,7 @@ export function GamePublishPage({ inputModes, orientation, }; - const publishKey = createPublishKey(); + const publishKey = resolvePublishKey(); const game = await createGame(gameMetadata, `${publishKey}:game`); const version = await createGameVersion( game.id, @@ -631,11 +654,13 @@ export function GamePublishPage({ ); clearPublishDraft(); setPublishedGameId(game.id); + publishKeyRef.current = null; } catch (submitError: unknown) { setError( submitError instanceof Error ? submitError.message : '发布游戏失败', ); } finally { + submitInFlightRef.current = false; setIsSubmitting(false); } }