From 1e13563a5e9dd71dea270544d151f5375500754d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 18:08:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B8=B8=E6=88=8F=E4=B9=B0?= =?UTF-8?q?=E6=96=AD=E5=88=B6=E6=B3=A5=E7=82=B9=E4=BB=98=E8=B4=B9=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=9C=AC=E5=9C=B0=E6=A0=88=20E2E=20=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/check-game-distribution-purchase-e2e.mjs:覆盖付费游戏发布与审核、未购买用户无可播放入口且直连 404、购买扣泥点与幂等重放、播放会话可玩与重复进入不扣费、免费游戏回归、余额不足失败无副作用 - 脚本端口只读 .app/dev-stack.json 的实际地址,E2E_START_STACK=1 时自行拉起并收束本地 dev 栈 - 追加播放会话网关取证:伪造令牌 404、带平台 refresh Cookie 直连 403、经 dev 代理按前缀清空 Cookie 后仍可播放 - package.json 增加 check:game-distribution-purchase-e2e 别名 --- package.json | 1 + .../check-game-distribution-purchase-e2e.mjs | 967 ++++++++++++++++++ 2 files changed, 968 insertions(+) create mode 100644 scripts/check-game-distribution-purchase-e2e.mjs diff --git a/package.json b/package.json index 1a9caaae3..6561d8567 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "check:game-distribution-package-limits-e2e": "node scripts/check-game-distribution-package-limits-e2e.mjs", "check:game-distribution-validation-restart-e2e": "node scripts/check-game-distribution-validation-restart-e2e.mjs", "check:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs", + "check:game-distribution-purchase-e2e": "node scripts/check-game-distribution-purchase-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-purchase-e2e.mjs b/scripts/check-game-distribution-purchase-e2e.mjs new file mode 100644 index 000000000..3fdd9c50b --- /dev/null +++ b/scripts/check-game-distribution-purchase-e2e.mjs @@ -0,0 +1,967 @@ +// 游戏买断制泥点付费与播放鉴权「真实本地栈」端到端检查。 +// +// 用法: +// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \ +// npm run check:game-distribution-purchase-e2e +// E2E_START_STACK=1 时脚本自行在仓库根执行 `npm run dev --no-interactive`, +// 等 `.app/dev-stack.json` 里的 api-server `/healthz` 就绪后跑用例,进程结束时 +// 用 taskkill / SIGTERM 收掉自己拉起的整棵 dev 栈,不依赖人工清理。 +// 默认不自行起服务:直接从仓库根 `.app/dev-stack.json` 读取实际端口(api-server 与 +// 主站 Vite),端口漂移后仍以文件为准,不臆断 3000 / 8082 这类默认值。 +// E2E_API_BASE / E2E_WEB_BASE 可显式覆盖两个地址;E2E_WEB_BASE 缺省且本地没有 +// 主站 Vite 时,平台同源路径 `/games//` 只做 WARN,不误判为通过。 +// E2E_PRICE_MUD_POINTS 覆盖付费游戏定价(默认 30)。 +// E2E_ADMIN_USER / E2E_ADMIN_PASSWORD 必填,脚本不读取仓库内任何凭据文件。 +// +// 覆盖: +// 1. 作者发布付费游戏(priceMudPoints=N)→ 管理员审核通过。 +// 2. 未购买账号:公开详情有价格但 purchased=false 且无 entryUrl;直连发行网关与 +// 平台同源 `/games//` 都是 404;请求播放会话 403。 +// 3. 购买前记录钱包余额;购买成功扣 N 泥点、账单出现 game_purchase 流水且购买记录唯一。 +// 4. 同 Idempotency-Key 重放与换 key 重复购买都只扣一次。 +// 5. 购买后播放会话返回 playUrl,入口 200 且是同一发行包内容;重复进入不再扣费。 +// 6. 播放会话网关来源约束:伪造令牌 404;带平台 refresh Cookie 直连 api-server 被 403 +// 拒绝,但经 dev 代理(或生产 nginx `^~ /api/game-distribution/play-sessions/`) +// 按前缀清空 Cookie 后仍能播放;同时取证 refresh Cookie 的 Path 属性。 +// 7. 免费游戏回归:公开入口直接可玩,播放会话不签发令牌。 +// 8. (附加)余额不足时购买失败,余额、账单与购买记录都不变。 +import { spawn, spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import JSZip from 'jszip'; + +const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DEV_STACK_PATH = resolve(REPO_ROOT, '.app/dev-stack.json'); +const STACK_LOG_PATH = resolve(REPO_ROOT, 'logs/e2e-game-distribution-purchase.log'); + +const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; +if (!ADMIN_USER || !ADMIN_PASSWORD) { + console.error( + '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:请用已配置管理员账号的环境变量运行,' + + '本地栈可先以 GENARRATIVE_ADMIN_USERNAME / GENARRATIVE_ADMIN_PASSWORD 启动 api-server。', + ); + process.exit(2); +} + +const PRICE_MUD_POINTS = Number.parseInt( + (process.env.E2E_PRICE_MUD_POINTS ?? '').trim() || '30', + 10, +); +if (!Number.isInteger(PRICE_MUD_POINTS) || PRICE_MUD_POINTS <= 0) { + console.error('E2E_PRICE_MUD_POINTS 必须是正整数'); + process.exit(2); +} + +const START_STACK = (process.env.E2E_START_STACK ?? '').trim() === '1'; +const GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE = 'game_purchase'; + +/** 1x1 透明 PNG:仅用于满足发布必填封面,内容不影响本用例断言。 */ +const COVER_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +let failures = 0; +function check(name, ok, detail = '') { + if (!ok) failures += 1; + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`); +} + +function warn(name, detail = '') { + console.log(`WARN ${name}${detail ? ` :: ${detail}` : ''}`); +} + +function readDevStack() { + try { + return JSON.parse(readFileSync(DEV_STACK_PATH, 'utf8')); + } catch { + return null; + } +} + +function normalizeBase(value) { + return String(value ?? '').trim().replace(/\/+$/u, ''); +} + +// api-server / 主站 Vite 的实际地址只认显式覆盖或 .app/dev-stack.json,不用默认端口兜底。 +// 自起 dev 栈时端口由 dev 脚本探测决定,因此这两个地址要在就绪后重新解析一次。 +let devStack = readDevStack(); +let API = normalizeBase( + process.env.E2E_API_BASE ?? devStack?.services?.['api-server']?.url, +); +let WEB = ''; + +/** + * 解析主站 Vite 地址:显式覆盖优先,其次 dev-stack.json 里记录的 web 地址。 + * + * 不能只看 `status`:dev.mjs 记录的 web 状态偶发滞后(进程实际在跑但字段仍是 failed), + * 因此这里以「该地址能不能响应 HTTP」为准,探不通才按未启动处理。 + */ +async function resolveWebBase() { + const candidate = normalizeBase( + process.env.E2E_WEB_BASE ?? devStack?.services?.web?.url, + ); + if (!candidate) return ''; + // 自起 dev 栈时 Vite 在 api-server 之后启动,给它一个就绪窗口。 + const deadline = Date.now() + (START_STACK ? 60_000 : 0); + for (;;) { + try { + await fetch(`${candidate}/`); + return candidate; + } catch { + if (Date.now() >= deadline) return ''; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000)); + } + } +} + +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, + headers: response.headers, + }; +} + +function stamp() { + return `${Date.now()}${Math.floor(Math.random() * 100000)}`; +} + +/** 每次运行都用新手机号注册,避免复用账号把购买记录的「唯一性」断言做假。 */ +async function registerAccount(prefix) { + const phone = `${prefix}${String(Date.now() + Math.floor(Math.random() * 1000)).slice(-8)}`; + const entry = await api('/api/auth/entry', { + method: 'POST', + body: { purePhoneNumber: phone, password: 'GenE2e123!' }, + }); + return { status: entry.status, token: entry.data?.token ?? '', phone }; +} + +async function uploadCover(token, id) { + const fileName = `cover-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'cover', 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-cover', + }, + }); + if (confirm.status !== 200) { + throw new Error( + `confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, + ); + } + return confirm.data.assetObject.assetObjectId; +} + +/** + * 生成最小可玩发行包:入口页里带唯一标记,用来证明播放会话读到的是同一份包内容。 + */ +async function buildPackage(marker) { + const entry = `E2E ${marker}

${marker}

`; + const asset = `document.documentElement.dataset.e2e="${marker}";`; + const zip = new JSZip(); + zip.file('index.html', entry); + zip.file('assets/app.js', asset); + const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); + const crypto = await import('node:crypto'); + const sha256 = crypto.createHash('sha256').update(bytes).digest('hex'); + return { + bytes, + sha256, + entry, + asset, + assetPath: 'assets/app.js', + fileCount: 2, + marker, + }; +} + +function gameMetadata(title, coverAssetId) { + return { + title, + summary: '买断制泥点付费真实链路验证', + description: 'E2E:定价 → 审核 → 购买 → 播放会话', + category: '益智', + tags: ['E2E'], + deviceSupport: { desktop: true, mobile: true, touch: true }, + inputModes: ['keyboard', 'mouse', 'touch'], + orientation: 'responsive', + coverAssetId, + screenshots: [], + }; +} + +/** 走完「建游戏 → 建版本(冻结价格)→ 传包 → 送审 → 管理员通过」的真实发布链路。 */ +async function publishGame({ authorToken, adminToken, coverAssetId, price, marker, title }) { + const id = stamp(); + const metadata = gameMetadata(title, coverAssetId); + const created = await api('/api/game-distribution/games', { + method: 'POST', + token: authorToken, + headers: { 'Idempotency-Key': `e2e-purchase-game-${id}` }, + body: metadata, + }); + if (created.status !== 200 || !created.data?.id) { + throw new Error(`创建游戏失败 ${created.status} ${created.text.slice(0, 300)}`); + } + const gameId = created.data.id; + + const pkg = await buildPackage(marker); + const version = await api(`/api/game-distribution/games/${gameId}/versions`, { + method: 'POST', + token: authorToken, + headers: { 'Idempotency-Key': `e2e-purchase-version-${id}` }, + body: { + priceMudPoints: price, + packageSha256: pkg.sha256, + packageBytes: pkg.bytes.length, + packageFileCount: pkg.fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }); + if (version.status !== 200 || !version.data?.versionId) { + throw new Error(`创建版本失败 ${version.status} ${version.text.slice(0, 300)}`); + } + const versionId = version.data.versionId; + + const upload = await api(`/api/game-distribution/versions/${versionId}/package`, { + method: 'PUT', + token: authorToken, + headers: { + 'Idempotency-Key': `e2e-purchase-upload-${id}`, + 'Content-Type': 'application/zip', + }, + binary: pkg.bytes, + }); + if (upload.status !== 200 || upload.data?.status !== 'uploaded') { + throw new Error(`上传发行包失败 ${upload.status} ${upload.text.slice(0, 300)}`); + } + + const submitted = await api(`/api/game-distribution/versions/${versionId}/submit`, { + method: 'POST', + token: authorToken, + headers: { 'Idempotency-Key': `e2e-purchase-submit-${id}` }, + body: { expectedPublicationRevision: created.data.publicationRevision ?? 0 }, + }); + if (submitted.status !== 202) { + throw new Error(`送审失败 ${submitted.status} ${submitted.text.slice(0, 300)}`); + } + + const readback = await api(`/api/game-distribution/versions/${versionId}`, { + token: authorToken, + }); + const approved = await api( + `/admin/api/game-distribution/versions/${versionId}/review`, + { + method: 'POST', + token: adminToken, + headers: { 'Idempotency-Key': `e2e-purchase-approve-${id}` }, + body: { + decision: 'approve', + expectedPublicationRevision: readback.data?.version?.publicationRevision ?? 0, + }, + }, + ); + if (approved.status !== 200) { + throw new Error(`审核通过失败 ${approved.status} ${approved.text.slice(0, 300)}`); + } + return { gameId, versionId, pkg }; +} + +async function walletBalance(token) { + const dashboard = await api('/api/profile/dashboard', { token }); + return dashboard.data?.walletBalance; +} + +async function walletLedgerEntries(token) { + const ledger = await api('/api/profile/wallet-ledger', { token }); + return ledger.data?.entries ?? []; +} + +/** 平台同源发行入口:只有主站 Vite 在跑时才能真实走 `/games//`。 */ +async function fetchPlatformEntry(gameId, assetPath = '') { + if (!WEB) return null; + const response = await fetch(`${WEB}/games/${gameId}/${assetPath}`); + const body = await response.text(); + return { status: response.status, body }; +} + +/** + * 取一份真实的平台 refresh Cookie(含服务端下发的 Path 属性)。 + * + * 登录请求通过 `base` 发出:`base` 传主站 Vite 时走的是和浏览器完全相同的 dev 代理链路。 + */ +async function loginWithRefreshCookie(base) { + const phone = `136${String(Date.now() + Math.floor(Math.random() * 1000)).slice(-8)}`; + const response = await fetch(`${base}/api/auth/entry`, { + method: 'POST', + headers: { ...ENVELOPE, 'Content-Type': 'application/json' }, + body: JSON.stringify({ purePhoneNumber: phone, password: 'GenE2e123!' }), + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + const setCookies = + typeof response.headers.getSetCookie === 'function' + ? response.headers.getSetCookie() + : []; + return { + status: response.status, + token: json?.data?.token ?? '', + setCookies, + cookieHeader: setCookies.map((entry) => entry.split(';')[0]).join('; '), + }; +} + +/** 平台 refresh Cookie 的 Path 属性决定浏览器会不会把它带到播放会话前缀上。 */ +function refreshCookiePath(setCookie) { + const matched = /;\s*path=([^;]+)/iu.exec(setCookie); + return matched ? matched[1].trim() : ''; +} + +async function waitForStackReady(deadlineMs = 900_000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < deadlineMs) { + devStack = readDevStack(); + const apiService = devStack?.services?.['api-server']; + const candidate = normalizeBase( + process.env.E2E_API_BASE ?? apiService?.url, + ); + if (candidate) { + try { + const response = await fetch(`${candidate}/healthz`); + if (response.ok) { + return candidate; + } + } catch { + // 端口还没起来:继续等。 + } + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 2000)); + } + throw new Error('等待 API /healthz 就绪超时'); +} + +let stackChild = null; +let stackDumpLogs = null; + +function startStack() { + const isWindows = process.platform === 'win32'; + stackChild = spawn('npm', ['run', 'dev', '--', '--no-interactive'], { + cwd: REPO_ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows, + detached: !isWindows, + env: process.env, + }); + const logChunks = []; + const capture = (chunk) => { + logChunks.push(chunk); + if (logChunks.length > 400) logChunks.shift(); + }; + stackChild.stdout?.on('data', capture); + stackChild.stderr?.on('data', capture); + const dump = () => { + try { + writeFileSync(STACK_LOG_PATH, Buffer.concat(logChunks)); + } catch { + // 日志落盘失败不影响用例结论。 + } + }; + stackChild.on('exit', dump); + stackDumpLogs = dump; + return dump; +} + +/** 只收自己拉起的 dev 栈;attach 模式不动别人已经在跑的服务。 */ +function stopStack(dumpLogs) { + if (!stackChild?.pid) return; + try { + dumpLogs?.(); + } catch { + // 忽略日志落盘失败。 + } + const isWindows = process.platform === 'win32'; + // dev.mjs 记录的 pid 才是 SpacetimeDB / api-server / bgfilter-worker 的真实进程; + // 只杀 npm 外壳会在 Windows 上留下孤儿进程,所以两者都要按实例声明回收。 + const recorded = readDevStack(); + const pids = new Set([String(stackChild.pid)]); + const instanceId = recorded?.instanceId; + if (instanceId) { + for (const service of Object.values(recorded?.services ?? {})) { + if (service?.instanceId === instanceId && Number.isInteger(service?.pid)) { + pids.add(String(service.pid)); + } + } + } + if (isWindows) { + for (const pid of pids) { + spawnSync('taskkill', ['/PID', pid, '/T', '/F'], { stdio: 'ignore' }); + } + return; + } + try { + process.kill(-stackChild.pid, 'SIGTERM'); + } catch { + stackChild.kill('SIGTERM'); + } + for (const pid of pids) { + if (pid === String(stackChild.pid)) continue; + try { + process.kill(Number(pid), 'SIGTERM'); + } catch { + // 进程已经退出。 + } + } +} + +async function main() { + if (START_STACK) { + console.log('[e2e] E2E_START_STACK=1:自行拉起本地 dev 栈'); + const dumpLogs = startStack(); + try { + const resolved = await waitForStackReady(); + devStack = readDevStack(); + API = normalizeBase(process.env.E2E_API_BASE ?? resolved); + console.log(`[e2e] dev 栈就绪: api-server=${API}`); + } catch (error) { + dumpLogs(); + throw error; + } + } + + WEB = await resolveWebBase(); + + if (!API) { + console.error( + '缺少 api-server 地址:请设置 E2E_API_BASE,或用 E2E_START_STACK=1 由脚本自起 dev 栈。', + ); + return 2; + } + + console.log(`[e2e] api-server: ${API}`); + if (WEB) { + console.log(`[e2e] 主站 Vite: ${WEB}`); + } else { + warn( + '未发现主站 Vite,跳过平台同源 /games// 断言', + '可设置 E2E_WEB_BASE 或 E2E_START_STACK=1 覆盖', + ); + } + + // 1. 账号与管理员 + const author = await registerAccount('137'); + check('作者账号注册拿到 token', author.status === 200 && Boolean(author.token), `status=${author.status}`); + const buyer = await registerAccount('138'); + check('买家账号注册拿到 token', buyer.status === 200 && Boolean(buyer.token), `status=${buyer.status}`); + const stranger = await registerAccount('139'); + check('未购买账号注册拿到 token', stranger.status === 200 && Boolean(stranger.token), `status=${stranger.status}`); + + const adminLogin = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + const adminToken = adminLogin.data?.token ?? adminLogin.data?.accessToken; + check('管理员登录成功', adminLogin.status === 200 && Boolean(adminToken), `status=${adminLogin.status}`); + if (!author.token || !buyer.token || !stranger.token || !adminToken) { + console.error('[e2e] 缺少必要身份,无法继续'); + return 1; + } + + // 发布灰度是写入闸门:显式开到 100%,避免被运营收紧状态误伤。 + const gateOpen = await api('/admin/api/feature-gates', { + method: 'PUT', + token: adminToken, + body: { + gateKey: 'game-distribution:publish', + enabled: true, + rolloutPercent: 100, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 买断制付费发布灰度', + }, + }); + check('发布灰度可开启并放量', gateOpen.status === 200, `status=${gateOpen.status}`); + + const coverAssetId = await uploadCover(author.token, stamp()); + check('作者封面素材直传并 confirm', Boolean(coverAssetId), String(coverAssetId).slice(0, 24)); + + // 2. 付费游戏:发布 → 审核通过 + const paidMarker = `E2E-PAID-OK-${stamp()}`; + const paid = await publishGame({ + authorToken: author.token, + adminToken, + coverAssetId, + price: PRICE_MUD_POINTS, + marker: paidMarker, + title: `买断制支付验证 ${stamp().slice(-6)}`, + }); + check('付费游戏发布并审核通过', Boolean(paid.gameId && paid.versionId), `gameId=${paid.gameId}`); + + // 3. 未购买用户:看得到资料与价格,但拿不到任何可播放入口 + const anonymousDetail = await api(`/api/game-distribution/games/${paid.gameId}`); + check( + '匿名公开详情展示价格', + anonymousDetail.status === 200 && anonymousDetail.data?.priceMudPoints === PRICE_MUD_POINTS, + `status=${anonymousDetail.status} price=${anonymousDetail.data?.priceMudPoints}`, + ); + check( + '匿名公开详情 purchased=false 且无发行入口', + anonymousDetail.data?.purchased === false && + anonymousDetail.data?.currentVersion?.entryUrl === null, + `purchased=${anonymousDetail.data?.purchased} entryUrl=${String(anonymousDetail.data?.currentVersion?.entryUrl)}`, + ); + + const strangerDetail = await api(`/api/game-distribution/games/${paid.gameId}`, { + token: stranger.token, + }); + check( + '未购买账号详情 purchased=false 且无发行入口', + strangerDetail.status === 200 && + strangerDetail.data?.purchased === false && + strangerDetail.data?.currentVersion?.entryUrl === null, + `status=${strangerDetail.status} purchased=${strangerDetail.data?.purchased} entryUrl=${String(strangerDetail.data?.currentVersion?.entryUrl)}`, + ); + + const paidGateway = await fetch(`${API}/api/game-distribution/releases/${paid.gameId}`); + const paidGatewayBody = await paidGateway.text(); + check( + '付费作品直连发行网关入口 404', + paidGateway.status === 404, + `status=${paidGateway.status} bytes=${paidGatewayBody.length}`, + ); + const paidGatewayAsset = await fetch( + `${API}/api/game-distribution/releases/${paid.gameId}/${paid.pkg.assetPath}`, + ); + check( + '付费作品直连发行网关包内资源 404', + paidGatewayAsset.status === 404, + `status=${paidGatewayAsset.status}`, + ); + + const paidPlatformEntry = await fetchPlatformEntry(paid.gameId); + if (paidPlatformEntry) { + check( + '付费作品平台同源 /games// 404', + paidPlatformEntry.status === 404, + `status=${paidPlatformEntry.status}`, + ); + } + + const strangerSession = await api( + `/api/game-distribution/games/${paid.gameId}/play-session`, + { method: 'POST', token: stranger.token }, + ); + check( + '未购买账号请求播放会话 403', + strangerSession.status === 403, + `status=${strangerSession.status} code=${strangerSession.error?.code ?? ''}`, + ); + const anonymousSession = await api( + `/api/game-distribution/games/${paid.gameId}/play-session`, + { method: 'POST' }, + ); + check( + '未登录请求付费播放会话 401', + anonymousSession.status === 401, + `status=${anonymousSession.status}`, + ); + + // 4. 购买:余额、账单与幂等 + const buyerBefore = await walletBalance(buyer.token); + check( + '买家钱包有足够余额完成购买', + Number.isFinite(buyerBefore) && buyerBefore >= PRICE_MUD_POINTS, + `balance=${buyerBefore} price=${PRICE_MUD_POINTS}`, + ); + + const purchaseKey = `e2e-purchase-${stamp()}`; + const purchase = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, { + method: 'POST', + token: buyer.token, + headers: { 'Idempotency-Key': purchaseKey }, + body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, + }); + check( + '购买成功并扣费', + purchase.status === 200 && + purchase.data?.purchase?.priceMudPoints === PRICE_MUD_POINTS && + purchase.data?.replayed === false && + purchase.data?.walletBalance === buyerBefore - PRICE_MUD_POINTS, + `status=${purchase.status} replayed=${purchase.data?.replayed} balance=${purchase.data?.walletBalance} before=${buyerBefore}`, + ); + const afterPurchaseBalance = purchase.data?.walletBalance; + const purchaseId = purchase.data?.purchase?.purchaseId; + + const ledgerAfterPurchase = await walletLedgerEntries(buyer.token); + const purchaseLedger = ledgerAfterPurchase.filter( + (entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE, + ); + check( + '账单出现唯一的 game_purchase 扣费流水', + purchaseLedger.length === 1 && + purchaseLedger[0]?.amountDelta === -PRICE_MUD_POINTS && + purchaseLedger[0]?.balanceAfter === afterPurchaseBalance, + `count=${purchaseLedger.length} amountDelta=${purchaseLedger[0]?.amountDelta} balanceAfter=${purchaseLedger[0]?.balanceAfter}`, + ); + + // 同 key 重放:必须回放既有购买,不再扣费 + const replay = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, { + method: 'POST', + token: buyer.token, + headers: { 'Idempotency-Key': purchaseKey }, + body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, + }); + check( + '同 Idempotency-Key 重放只扣一次', + replay.status === 200 && + replay.data?.replayed === true && + replay.data?.walletBalance === afterPurchaseBalance && + replay.data?.purchase?.purchaseId === purchaseId, + `status=${replay.status} replayed=${replay.data?.replayed} balance=${replay.data?.walletBalance}`, + ); + + // 换 key 重复购买:已有所有权,同样不再扣费 + const duplicate = await api(`/api/game-distribution/games/${paid.gameId}/purchase`, { + method: 'POST', + token: buyer.token, + headers: { 'Idempotency-Key': `e2e-purchase-${stamp()}` }, + body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, + }); + check( + '换 key 重复购买只扣一次', + duplicate.status === 200 && + duplicate.data?.replayed === true && + duplicate.data?.walletBalance === afterPurchaseBalance, + `status=${duplicate.status} replayed=${duplicate.data?.replayed} balance=${duplicate.data?.walletBalance}`, + ); + + const ledgerAfterDuplicate = await walletLedgerEntries(buyer.token); + check( + '重复购买未新增 game_purchase 流水', + ledgerAfterDuplicate.filter( + (entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE, + ).length === 1, + `count=${ledgerAfterDuplicate.filter((entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE).length}`, + ); + + const buyerDetail = await api(`/api/game-distribution/games/${paid.gameId}`, { + token: buyer.token, + }); + check( + '购买后详情 purchased=true 且下发发行入口', + buyerDetail.data?.purchased === true && + buyerDetail.data?.currentVersion?.entryUrl === `/games/${paid.gameId}/`, + `purchased=${buyerDetail.data?.purchased} entryUrl=${String(buyerDetail.data?.currentVersion?.entryUrl)}`, + ); + + // 5. 播放会话:签发令牌、可玩、重复进入不再扣费 + const playSession = await api( + `/api/game-distribution/games/${paid.gameId}/play-session`, + { method: 'POST', token: buyer.token }, + ); + const playUrl = playSession.data?.playUrl ?? ''; + check( + '购买后签发播放会话 playUrl', + playSession.status === 200 && + playUrl.startsWith('/api/game-distribution/play-sessions/'), + `status=${playSession.status} playUrl=${playUrl.slice(0, 48)}…`, + ); + + const sessionEntry = await fetch(`${API}${playUrl}`); + const sessionEntryBody = await sessionEntry.text(); + check( + '播放会话入口 200 且是同一发行包内容', + sessionEntry.status === 200 && + sessionEntryBody.includes(paid.pkg.marker) && + / + /refresh|session/iu.test(entry), + ); + const cookiePath = refreshSetCookie ? refreshCookiePath(refreshSetCookie) : ''; + check( + '取到平台 refresh Cookie 且 Path 限定非 /api/game-distribution', + cookieLogin.status === 200 && + Boolean(cookieLogin.cookieHeader) && + Boolean(cookiePath) && + !cookiePath.startsWith('/api/game-distribution'), + `path=${cookiePath} cookies=${cookieLogin.setCookies.length} via=${cookieSource}`, + ); + + const cookieInspect = await fetch(`${API}/_internal/auth/refresh-cookie`, { + headers: { ...ENVELOPE, Cookie: cookieLogin.cookieHeader }, + }); + const cookieInspectBody = await cookieInspect.json().catch(() => null); + const cookiePresent = + cookieInspectBody?.data?.present ?? cookieInspectBody?.present; + check( + '网关能解析这份 Cookie(确认是有效平台会话)', + cookieInspect.status === 200 && cookiePresent === true, + `status=${cookieInspect.status} present=${cookiePresent}`, + ); + + const cookieEntryDirect = await fetch(`${API}${playUrl}`, { + headers: { Cookie: cookieLogin.cookieHeader }, + }); + const cookieEntryDirectBody = await cookieEntryDirect.text(); + check( + '带平台 Cookie 直连播放会话入口被 403 拒绝(网关来源约束)', + cookieEntryDirect.status === 403, + `status=${cookieEntryDirect.status} bytes=${cookieEntryDirectBody.length}`, + ); + if (WEB) { + // dev 代理对播放会话前缀有专门规则清空 Cookie(vite.config.ts 里排在通用 + // `/api/game-distribution` 之前;生产 nginx 用 `location ^~ .../play-sessions/` 做同一件事), + // 所以带 Cookie 经代理访问仍然可玩。这就是 iframe 在真实浏览器里可用的边缘证据。 + const cookieEntryViaProxy = await fetch(`${WEB}${playUrl}`, { + headers: { Cookie: cookieLogin.cookieHeader }, + }); + const cookieEntryViaProxyBody = await cookieEntryViaProxy.text(); + check( + '带平台 Cookie 经 dev 代理仍能播放(代理按前缀清空 Cookie)', + cookieEntryViaProxy.status === 200 && + cookieEntryViaProxyBody.includes(paid.pkg.marker), + `status=${cookieEntryViaProxy.status} marker=${cookieEntryViaProxyBody.includes(paid.pkg.marker)} bytes=${cookieEntryViaProxyBody.length}`, + ); + const forgedViaProxy = await fetch( + `${WEB}/api/game-distribution/play-sessions/${forgedToken}/`, + { headers: { Cookie: cookieLogin.cookieHeader } }, + ); + check( + '伪造令牌经 dev 代理请求播放会话入口 404', + forgedViaProxy.status === 404, + `status=${forgedViaProxy.status}`, + ); + } else { + warn('跳过 dev 代理带 Cookie 取证', '未发现主站 Vite'); + } + + // 7. 免费游戏回归:公开入口直接可玩,播放会话不签发令牌 + const freeMarker = `E2E-FREE-OK-${stamp()}`; + const free = await publishGame({ + authorToken: author.token, + adminToken, + coverAssetId, + price: 0, + marker: freeMarker, + title: `免费游戏回归 ${stamp().slice(-6)}`, + }); + check('免费游戏发布并审核通过', Boolean(free.gameId && free.versionId), `gameId=${free.gameId}`); + + const freeDetail = await api(`/api/game-distribution/games/${free.gameId}`); + check( + '免费游戏公开详情保留发行入口', + freeDetail.status === 200 && + freeDetail.data?.priceMudPoints === 0 && + freeDetail.data?.currentVersion?.entryUrl === `/games/${free.gameId}/`, + `status=${freeDetail.status} price=${freeDetail.data?.priceMudPoints} entryUrl=${String(freeDetail.data?.currentVersion?.entryUrl)}`, + ); + + const freeGateway = await fetch(`${API}/api/game-distribution/releases/${free.gameId}/`); + const freeGatewayBody = await freeGateway.text(); + check( + '免费游戏发行网关直接可玩', + freeGateway.status === 200 && freeGatewayBody.includes(freeMarker), + `status=${freeGateway.status} marker=${freeGatewayBody.includes(freeMarker)}`, + ); + + const freePlatformEntry = await fetchPlatformEntry(free.gameId); + if (freePlatformEntry) { + check( + '免费游戏平台同源 /games// 直接可玩', + freePlatformEntry.status === 200 && freePlatformEntry.body.includes(freeMarker), + `status=${freePlatformEntry.status} marker=${freePlatformEntry.body.includes(freeMarker)}`, + ); + } + + const freeSession = await api( + `/api/game-distribution/games/${free.gameId}/play-session`, + { method: 'POST' }, + ); + check( + '免费游戏播放会话不签发令牌、直接回公开入口', + freeSession.status === 200 && + freeSession.data?.playUrl === `/games/${free.gameId}/`, + `status=${freeSession.status} playUrl=${String(freeSession.data?.playUrl)}`, + ); + + // 8. 附加:余额不足时购买失败且不产生任何副作用 + if (Number.isFinite(buyerBefore) && buyerBefore < 1_000_000) { + const priceyMarker = `E2E-PRICEY-OK-${stamp()}`; + const pricey = await publishGame({ + authorToken: author.token, + adminToken, + coverAssetId, + price: 1_000_000, + marker: priceyMarker, + title: `余额不足验证 ${stamp().slice(-6)}`, + }); + const balanceBeforeFailure = await walletBalance(buyer.token); + const ledgerBeforeFailure = (await walletLedgerEntries(buyer.token)).length; + const insufficient = await api( + `/api/game-distribution/games/${pricey.gameId}/purchase`, + { + method: 'POST', + token: buyer.token, + headers: { 'Idempotency-Key': `e2e-purchase-insufficient-${stamp()}` }, + body: { expectedPriceMudPoints: 1_000_000 }, + }, + ); + const balanceAfterFailure = await walletBalance(buyer.token); + const ledgerAfterFailure = (await walletLedgerEntries(buyer.token)).length; + check( + '余额不足购买失败(400 INSUFFICIENT_MUD_POINTS)', + insufficient.status === 400 && + (insufficient.error?.code === 'INSUFFICIENT_MUD_POINTS' || + String(insufficient.error?.message ?? '').includes('泥点')), + `status=${insufficient.status} code=${insufficient.error?.code ?? ''} msg=${insufficient.error?.message ?? ''}`, + ); + check( + '余额不足时余额、账单与购买记录都不变', + balanceAfterFailure === balanceBeforeFailure && + ledgerAfterFailure === ledgerBeforeFailure, + `balance=${balanceAfterFailure}/${balanceBeforeFailure} ledger=${ledgerAfterFailure}/${ledgerBeforeFailure}`, + ); + } else { + warn('跳过余额不足用例', `买家余额 ${buyerBefore} 已达上限价,无法构造不足场景`); + } + + console.log( + `\n[e2e] 断言汇总:${failures === 0 ? '全部通过' : `${failures} 条失败`}`, + ); + return failures === 0 ? 0 : 1; +} + +let exitCode = 1; +try { + exitCode = await main(); +} catch (error) { + console.error(`[e2e] 执行失败: ${error?.message ?? error}`); + exitCode = 1; +} finally { + stopStack(stackDumpLogs); +} +process.exit(exitCode);