// 游戏买断制泥点付费与播放鉴权「真实本地栈」端到端检查。 // // 定位:人工验收脚本,**不在 CI 自动门禁内**(需要真实 SpacetimeDB + api-server + 可用的 // OSS 凭据 + 管理员账号,冒烟时长与环境依赖都不适合放进流水线)。发布前由人工在本机 // 或联调机上执行,失败即非零退出。 // // 用法: // 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 必填,脚本不读取仓库内任何凭据文件。 // 「购买记录条数」用 `spacetime sql` 直读 `game_distribution_purchase` 做交叉验证; // CLI 不可用时该项降级为 WARN,其余断言仍按 HTTP/账单证据判定。 // // 覆盖: // 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. (附加)余额不足时购买失败,余额、账单与购买记录都不变。 // 9. 作者本人免购买:详情有入口但 purchased=false、自购被 400 `GAME_PURCHASE_OWNER_EXEMPT` // 拒绝且无扣费副作用、可直接创建播放会话。 // 10. 管理员免购买:管理员令牌可直接创建播放会话并可玩;购买被拒(当前真实链路是用户 // 鉴权前置的 401,`403 GAME_PURCHASE_ADMIN_NOT_ALLOWED` 需要带 admin 角色的用户令牌, // 详见运行时 WARN 与交付说明)。 // 11. 真并发购买:同一未购买账号对同一付费游戏同时发起两个不同 Idempotency-Key 的购买, // 断言无 5xx、只扣一次、game_purchase 流水与购买记录都只有 1 条、详情 purchased=true。 // 12. 审核员试玩待审付费版本:admin preview session 入口 200 且同包、可反复创建不限次、 // 钱包与购买记录不变。 // 13. 定价越界:priceMudPoints=1000001 被 400 拒绝且不落版本;0 与 1000000 边界可通过。 // 14. 下架后失效:作者下架后旧播放会话入口与包内资源 404、公开详情不再返回入口。 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 createVersionAndSubmit({ authorToken, gameId, price, marker, title, coverAssetId, expectedPublicationRevision, }) { const id = stamp(); const metadata = gameMetadata(title, coverAssetId); 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 }, }); if (submitted.status !== 202) { throw new Error(`送审失败 ${submitted.status} ${submitted.text.slice(0, 300)}`); } return { versionId, pkg }; } /** 走完「建游戏 → 建版本(冻结价格)→ 传包 → 送审 → 管理员通过」的真实发布链路。 */ 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 { versionId, pkg } = await createVersionAndSubmit({ authorToken, gameId, price, marker, title, coverAssetId, expectedPublicationRevision: created.data.publicationRevision ?? 0, }); 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 ?? []; } /** access token 的 JWT payload 里 `sub` 就是 user_id,用于直读购买表。 */ function jwtSubject(token) { try { const payload = token.split('.')[1]; const claims = JSON.parse( Buffer.from(payload, 'base64url').toString('utf8'), ); return typeof claims?.sub === 'string' ? claims.sub : ''; } catch { return ''; } } /** * 只读查询本地 SpacetimeDB(`spacetime sql`),用于交叉验证 HTTP 之外的落库事实。 * * CLI 路径与本地发布 identity 的 config 都来自 `.app/dev-stack.json`;CLI 缺失、未登录或 * 查询失败时返回 null,由调用方降级为 API 可见证据并打印 WARN,不把环境问题当业务失败。 */ function spacetimeCount(sql) { const dataDir = devStack?.spacetimeDataDir; const serverUrl = devStack?.services?.spacetime?.url; const database = devStack?.database; if (!dataDir || !serverUrl || !database) return null; const result = spawnSync( 'spacetime', [ '--config-path', resolve(dataDir, 'dev-cli/cli.toml'), 'sql', database, sql, '--server', serverUrl, ], { encoding: 'utf8', shell: process.platform === 'win32', timeout: 60_000 }, ); if (result.error || result.status !== 0) return null; const numbers = String(result.stdout ?? '') .split(/\r?\n/u) .map((line) => line.trim()) .filter((line) => /^\d+$/u.test(line)); return numbers.length ? Number(numbers[numbers.length - 1]) : null; } /** 购买记录条数:直读 game_distribution_purchase;环境不可用时返回 null。 */ function purchaseRowCount(userId, gameId) { if (!userId) return null; return spacetimeCount( `SELECT COUNT(*) AS c FROM game_distribution_purchase WHERE user_id = '${userId}' AND game_id = '${gameId}'`, ); } /** 购买流水条数:只数 game_purchase 来源,避免把充值等其他流水算进来。 */ function gamePurchaseLedgerCount(entries) { return entries.filter( (entry) => entry.sourceType === GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE, ).length; } /** 平台同源发行入口:只有主站 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} 已达上限价,无法构造不足场景`); } // 9. 作者本人免购买(作者对自己的付费作品有免购买权,且绝不扣费) const authorDetail = await api(`/api/game-distribution/games/${paid.gameId}`, { token: author.token, }); check( '作者读取自己付费作品详情:有发行入口但 purchased=false', authorDetail.status === 200 && authorDetail.data?.purchased === false && authorDetail.data?.currentVersion?.entryUrl === `/games/${paid.gameId}/`, `status=${authorDetail.status} purchased=${authorDetail.data?.purchased} entryUrl=${String(authorDetail.data?.currentVersion?.entryUrl)}`, ); const authorBalanceBefore = await walletBalance(author.token); const authorLedgerBefore = gamePurchaseLedgerCount( await walletLedgerEntries(author.token), ); const authorPurchase = await api( `/api/game-distribution/games/${paid.gameId}/purchase`, { method: 'POST', token: author.token, headers: { 'Idempotency-Key': `e2e-owner-exempt-${stamp()}` }, body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, }, ); check( '作者本人购买自己的付费作品被拒(400 GAME_PURCHASE_OWNER_EXEMPT)', authorPurchase.status === 400 && authorPurchase.error?.code === 'GAME_PURCHASE_OWNER_EXEMPT', `status=${authorPurchase.status} code=${authorPurchase.error?.code ?? ''} msg=${authorPurchase.error?.message ?? ''}`, ); const authorBalanceAfter = await walletBalance(author.token); const authorLedgerAfter = gamePurchaseLedgerCount( await walletLedgerEntries(author.token), ); const authorRows = purchaseRowCount(jwtSubject(author.token), paid.gameId); check( '作者被拒后余额与 game_purchase 流水不变', authorBalanceAfter === authorBalanceBefore && authorLedgerAfter === authorLedgerBefore, `balance=${authorBalanceAfter}/${authorBalanceBefore} ledger=${authorLedgerAfter}/${authorLedgerBefore}`, ); if (authorRows === null) { warn('跳过作者购买记录条数直查', 'spacetime sql 不可用'); } else { check('作者自购不落购买记录', authorRows === 0, `rows=${authorRows}`); } const authorSession = await api( `/api/game-distribution/games/${paid.gameId}/play-session`, { method: 'POST', token: author.token }, ); check( '作者本人可直接创建播放会话(免购买)', authorSession.status === 200 && String(authorSession.data?.playUrl ?? '').startsWith( '/api/game-distribution/play-sessions/', ), `status=${authorSession.status} playUrl=${String(authorSession.data?.playUrl)}`, ); if (authorSession.data?.playUrl) { const authorEntry = await fetch(`${API}${authorSession.data.playUrl}`); const authorEntryBody = await authorEntry.text(); check( '作者播放会话入口 200 且是同一发行包内容', authorEntry.status === 200 && authorEntryBody.includes(paid.pkg.marker), `status=${authorEntry.status} marker=${authorEntryBody.includes(paid.pkg.marker)}`, ); } // 10. 管理员免购买(管理员令牌可直接试玩;购买路径必须先被用户鉴权拦下) const adminSubject = jwtSubject(adminToken); const adminBalanceRowsBefore = purchaseRowCount(adminSubject, paid.gameId); const adminSession = await api( `/api/game-distribution/games/${paid.gameId}/play-session`, { method: 'POST', token: adminToken }, ); check( '管理员令牌可直接创建播放会话(免购买)', adminSession.status === 200 && String(adminSession.data?.playUrl ?? '').startsWith( '/api/game-distribution/play-sessions/', ), `status=${adminSession.status} playUrl=${String(adminSession.data?.playUrl)}`, ); if (adminSession.data?.playUrl) { const adminEntry = await fetch(`${API}${adminSession.data.playUrl}`); const adminEntryBody = await adminEntry.text(); check( '管理员播放会话入口 200 且是同一发行包内容', adminEntry.status === 200 && adminEntryBody.includes(paid.pkg.marker), `status=${adminEntry.status} marker=${adminEntryBody.includes(paid.pkg.marker)}`, ); } const adminPurchase = await api( `/api/game-distribution/games/${paid.gameId}/purchase`, { method: 'POST', token: adminToken, headers: { 'Idempotency-Key': `e2e-admin-purchase-${stamp()}` }, body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, }, ); check( '管理员令牌发起购买被拒绝且不产生扣费', adminPurchase.status === 401 || adminPurchase.status === 403, `status=${adminPurchase.status} code=${adminPurchase.error?.code ?? ''} msg=${adminPurchase.error?.message ?? ''}`, ); if (adminPurchase.status === 403) { check( '管理员购买被拒码为 GAME_PURCHASE_ADMIN_NOT_ALLOWED', adminPurchase.error?.code === 'GAME_PURCHASE_ADMIN_NOT_ALLOWED', `code=${adminPurchase.error?.code ?? ''}`, ); } else { warn( '管理员购买走的是用户鉴权前置 401,未进入 403 GAME_PURCHASE_ADMIN_NOT_ALLOWED 分支', '该 403 分支要求带 admin 角色的用户 access token,而现役登录链路只签发 roles=["user"];' + '后台管理员令牌在 /api/* 用户路由上先被 require_bearer_auth 判为无效登录态', ); } const adminRowsAfter = purchaseRowCount(adminSubject, paid.gameId); if (adminRowsAfter === null || adminBalanceRowsBefore === null) { warn('跳过管理员购买记录条数直查', 'spacetime sql 不可用'); } else { check( '管理员购买被拒后不落购买记录', adminRowsAfter === adminBalanceRowsBefore, `rows=${adminRowsAfter}/${adminBalanceRowsBefore}`, ); } // 11. 真并发购买:同一未购买账号同时对同一付费游戏发两个不同幂等键 const strangerBalanceBefore = await walletBalance(stranger.token); const [concurrent1, concurrent2] = await Promise.all([ api(`/api/game-distribution/games/${paid.gameId}/purchase`, { method: 'POST', token: stranger.token, headers: { 'Idempotency-Key': `e2e-concurrent-a-${stamp()}` }, body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, }), api(`/api/game-distribution/games/${paid.gameId}/purchase`, { method: 'POST', token: stranger.token, headers: { 'Idempotency-Key': `e2e-concurrent-b-${stamp()}` }, body: { expectedPriceMudPoints: PRICE_MUD_POINTS }, }), ]); const concurrentStatuses = [concurrent1.status, concurrent2.status]; check( '真并发购买:两个响应都不是 5xx', concurrentStatuses.every((status) => status > 0 && status < 500), `statuses=${concurrentStatuses.join('/')} codes=${[concurrent1.error?.code, concurrent2.error?.code].join('/')}`, ); check( '真并发购买:状态码为 200 且最多一个 409(允许服务端竞态语义)', concurrentStatuses.every((status) => status === 200 || status === 409) && concurrentStatuses.includes(200), `statuses=${concurrentStatuses.join('/')}`, ); const chargedClaims = [concurrent1, concurrent2].filter( (response) => response.status === 200 && response.data?.replayed === false, ).length; const replayFlags = [concurrent1, concurrent2].map( (response) => `${response.status}:${String(response.data?.replayed)}`, ); check( '真并发购买:最多一个响应声称发生新扣费(两个都声称即双扣风险)', chargedClaims <= 1, `chargedClaims=${chargedClaims} replayFlags=${replayFlags.join(',')}`, ); const strangerBalanceAfter = await walletBalance(stranger.token); check( '真并发购买:钱包只减少一次', strangerBalanceAfter === strangerBalanceBefore - PRICE_MUD_POINTS, `balance=${strangerBalanceAfter}/${strangerBalanceBefore} price=${PRICE_MUD_POINTS}`, ); const strangerLedger = gamePurchaseLedgerCount( await walletLedgerEntries(stranger.token), ); check( '真并发购买:只落 1 条 game_purchase 流水', strangerLedger === 1, `count=${strangerLedger}`, ); const strangerRows = purchaseRowCount(jwtSubject(stranger.token), paid.gameId); if (strangerRows === null) { warn('跳过并发购买记录条数直查', 'spacetime sql 不可用'); } else { check('真并发购买:只落 1 条购买记录', strangerRows === 1, `rows=${strangerRows}`); } const strangerDetailAfterPurchase = await api( `/api/game-distribution/games/${paid.gameId}`, { token: stranger.token }, ); check( '真并发购买后详情 purchased=true', strangerDetailAfterPurchase.data?.purchased === true, `purchased=${strangerDetailAfterPurchase.data?.purchased}`, ); // 12. 审核员试玩待审付费版本(不限次、不改钱包与购买记录) const pendingMarker = `E2E-PENDING-OK-${stamp()}`; const paidPublicBeforePending = await api( `/api/game-distribution/games/${paid.gameId}`, ); const pending = await createVersionAndSubmit({ authorToken: author.token, gameId: paid.gameId, price: PRICE_MUD_POINTS, marker: pendingMarker, title: `待审付费版本 ${stamp().slice(-6)}`, coverAssetId, expectedPublicationRevision: paidPublicBeforePending.data?.publicationRevision ?? 0, }); const pendingReadback = await api( `/api/game-distribution/versions/${pending.versionId}`, { token: author.token }, ); check( '付费作品可提交待审新版本(pending_review)', pendingReadback.status === 200 && pendingReadback.data?.version?.status === 'pending_review', `status=${pendingReadback.status} versionStatus=${pendingReadback.data?.version?.status ?? ''}`, ); const authorLedgerBeforePreview = gamePurchaseLedgerCount( await walletLedgerEntries(author.token), ); const previewChecks = []; for (let round = 1; round <= 2; round += 1) { const preview = await api( `/admin/api/game-distribution/versions/${pending.versionId}/preview-session`, { method: 'POST', token: adminToken }, ); const previewUrl = preview.data?.previewUrl ?? ''; const previewEntry = previewUrl ? await fetch(`${API}${previewUrl}`) : { status: 0, text: async () => '' }; const previewBody = await previewEntry.text(); const previewAsset = previewUrl ? await fetch(`${API}${previewUrl}${pending.pkg.assetPath}`) : { status: 0, text: async () => '' }; const previewAssetBody = await previewAsset.text(); previewChecks.push( preview.status === 200 && previewUrl.startsWith('/api/game-distribution/admin-previews/') && previewEntry.status === 200 && previewBody.includes(pendingMarker) && previewAsset.status === 200 && previewAssetBody === pending.pkg.asset, ); check( `审核员第 ${round} 次试玩待审付费版本:入口与包内资源都可用且同包`, previewChecks[round - 1], `previewStatus=${preview.status} entryStatus=${previewEntry.status} marker=${previewBody.includes(pendingMarker)} assetBytes=${previewAssetBody.length}`, ); } check( '同一待审版本可反复创建试玩会话(不限次)', previewChecks.length === 2 && previewChecks.every(Boolean), `rounds=${previewChecks.join(',')}`, ); const authorBalanceAfterPreview = await walletBalance(author.token); const authorLedgerAfterPreview = gamePurchaseLedgerCount( await walletLedgerEntries(author.token), ); const adminRowsAfterPreview = purchaseRowCount(adminSubject, paid.gameId); check( '试玩待审付费版本不改动钱包与 game_purchase 流水', authorBalanceAfterPreview === authorBalanceBefore && authorLedgerAfterPreview === authorLedgerBeforePreview, `authorBalance=${authorBalanceAfterPreview}/${authorBalanceBefore} ledger=${authorLedgerAfterPreview}/${authorLedgerBeforePreview}`, ); if (adminRowsAfterPreview === null || adminRowsAfter === null) { warn('跳过试玩购买记录条数直查', 'spacetime sql 不可用'); } else { check( '审核员试玩不落购买记录', adminRowsAfterPreview === adminRowsAfter, `rows=${adminRowsAfterPreview}/${adminRowsAfter}`, ); } // 13. 定价越界:1000001 被拒且不落版本;0 与 1000000 边界可通过 // 定价边界用例用独立作者账号:作者自有列表按条数上限聚合返回版本,作品多的账号 // 会让目标游戏的版本查不到(既有口径),独立账号才能用公开接口可靠数版本。 const boundsAuthor = await registerAccount('132'); const boundsCoverAssetId = await uploadCover(boundsAuthor.token, stamp()); check( '定价边界用例的准备作者与封面就绪', boundsAuthor.status === 200 && Boolean(boundsCoverAssetId), `status=${boundsAuthor.status} cover=${Boolean(boundsCoverAssetId)}`, ); const boundsGameCreated = await api('/api/game-distribution/games', { method: 'POST', token: boundsAuthor.token, headers: { 'Idempotency-Key': `e2e-bounds-game-${stamp()}` }, body: gameMetadata(`定价边界验证 ${stamp().slice(-6)}`, boundsCoverAssetId), }); const boundsGameId = boundsGameCreated.data?.id; check( '定价边界用例的准备游戏创建成功', boundsGameCreated.status === 200 && Boolean(boundsGameId), `status=${boundsGameCreated.status} gameId=${String(boundsGameId)}`, ); // 单游戏作者视图把自有条目挂在 `data.game` 下(`versions` 在条目里,不在顶层)。 const countBoundsVersions = async () => { const ownerEntry = await api( `/api/game-distribution/my-games/${boundsGameId}`, { token: boundsAuthor.token }, ); const entry = ownerEntry.data?.game ?? ownerEntry.data; return (entry?.versions ?? []).length; }; const declareVersion = (price, suffix) => api(`/api/game-distribution/games/${boundsGameId}/versions`, { method: 'POST', token: boundsAuthor.token, headers: { 'Idempotency-Key': `e2e-bounds-version-${suffix}-${stamp()}` }, body: { priceMudPoints: price, packageSha256: 'a'.repeat(64), packageBytes: 1024, packageFileCount: 1, packageEntryPath: 'index.html', gameMetadata: gameMetadata(`定价边界验证 ${suffix}`, boundsCoverAssetId), }, }); const tooHigh = await declareVersion(1_000_001, 'toohigh'); check( 'priceMudPoints=1000001 被 400 拒绝', tooHigh.status === 400, `status=${tooHigh.status} code=${tooHigh.error?.code ?? ''} msg=${tooHigh.error?.message ?? ''}`, ); const versionsAfterTooHigh = await countBoundsVersions(); const rowsAfterTooHigh = spacetimeCount( `SELECT COUNT(*) AS c FROM game_distribution_version WHERE game_id = '${boundsGameId}'`, ); check( '越界价格不落版本', versionsAfterTooHigh === 0 && (rowsAfterTooHigh === null || rowsAfterTooHigh === 0), `ownerVersions=${versionsAfterTooHigh} tableRows=${String(rowsAfterTooHigh)}`, ); if (rowsAfterTooHigh === null) { warn('版本表直查不可用', '越界不落版本只由作者自有列表断言'); } const maxPrice = await declareVersion(1_000_000, 'max'); check( 'priceMudPoints=1000000 边界可通过', maxPrice.status === 200 && Boolean(maxPrice.data?.versionId), `status=${maxPrice.status} msg=${maxPrice.error?.message ?? ''}`, ); const freePrice = await declareVersion(0, 'free'); check( 'priceMudPoints=0(免费)边界可通过', freePrice.status === 200 && Boolean(freePrice.data?.versionId), `status=${freePrice.status} msg=${freePrice.error?.message ?? ''}`, ); const boundsFinalVersions = await countBoundsVersions(); const boundsFinalRows = spacetimeCount( `SELECT COUNT(*) AS c FROM game_distribution_version WHERE game_id = '${boundsGameId}'`, ); check( '越界被拒后边界价各落一个版本(共 2 个)', boundsFinalVersions === 2 && (boundsFinalRows === null || boundsFinalRows === 2), `ownerVersions=${boundsFinalVersions} tableRows=${String(boundsFinalRows)}`, ); // 14. 下架后失效:旧播放会话与公开入口都必须关闭(放在最后,会改动付费作品的公开态) const paidBeforeUnpublish = await api( `/api/game-distribution/games/${paid.gameId}`, { token: buyer.token }, ); const unpublished = await api( `/api/game-distribution/games/${paid.gameId}/unpublish`, { method: 'POST', token: author.token, headers: { 'Idempotency-Key': `e2e-unpublish-${stamp()}` }, body: { expectedPublicationRevision: paidBeforeUnpublish.data?.publicationRevision ?? 0, }, }, ); check( '作者下架付费作品成功', unpublished.status === 200, `status=${unpublished.status} code=${unpublished.error?.code ?? ''} msg=${unpublished.error?.message ?? ''}`, ); const staleEntry = await fetch(`${API}${playUrl}`); const staleEntryBody = await staleEntry.text(); check( '下架后旧播放会话入口 404', staleEntry.status === 404, `status=${staleEntry.status} bytes=${staleEntryBody.length}`, ); const staleAsset = await fetch(`${API}${playUrl}${paid.pkg.assetPath}`); check( '下架后旧播放会话包内资源 404', staleAsset.status === 404, `status=${staleAsset.status}`, ); const detailAfterUnpublish = await api( `/api/game-distribution/games/${paid.gameId}`, ); check( '下架后公开详情不再返回入口', detailAfterUnpublish.status === 404 || detailAfterUnpublish.data?.currentVersion?.entryUrl == null, `status=${detailAfterUnpublish.status} entryUrl=${String(detailAfterUnpublish.data?.currentVersion?.entryUrl)}`, ); const sessionAfterUnpublish = await api( `/api/game-distribution/games/${paid.gameId}/play-session`, { method: 'POST', token: buyer.token }, ); check( '下架后不能为付费作品创建新的播放会话', sessionAfterUnpublish.status === 404, `status=${sessionAfterUnpublish.status} code=${sessionAfterUnpublish.error?.code ?? ''}`, ); 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);