Files
Genarrative/scripts/check-game-distribution-purchase-e2e.mjs
suzmii dcef9b62a8
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m17s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 7m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 7m48s
Project CI / Repository checks (pull_request) Successful in 5m36s
修复买断制付费评审问题:后台消耗统计、定价上限校验、付费入口自动化断言
- admin.rs:钱包流水来源映射补齐第 17 个变体(索引 16 → game_purchase),后台「消耗泥点」白名单新增 game_purchase,并抽出效果分类与单行累加函数
- profile.rs:消耗口径抽出 profile_wallet_ledger_source_counts_as_consumption,消费投影增量、历史花费重建、投影初始化三处一并纳入 GamePurchase
- module-game-distribution/errors.rs:价格上限文案改为插值 MAX_GAME_PRICE_MUD_POINTS,并新增文案断言
- 新增 scripts/check-game-distribution-price-limit-parity.mjs 并挂进 npm run lint(随 Repository checks 在 CI 生效):断言 TS / 服务端 Rust / AGC Rust 常量与网页侧别名四处同源
- api-server 发行网关抽出可注入 loader 的 serve_public_release_asset,付费 404 严格发生在读取包字节之前;播放会话准入抽出 resolve_paid_play_session_entitlement
- 新增 api-server 单测:付费发行路径 404 且零次读包字节、免费放行且读到包字节、未购买 403 与作者 / 已购买 / 管理员放行
- spacetime-module:把「先判定后扣费」从源码扫描改为 resolve_game_purchase_pre_charge_plan 纯函数断言,删除 include_str 源码切片用例
- E2E 脚本:平台同源断言缺配置时记 SKIP 并在报告末尾汇总 SKIP 清单,E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,汇总行改为输出 PASS/FAIL/WARN/SKIP 计数
- 修正注释里不存在的路由 POST /api/game-distribution/play-sessions(真实为 POST /api/game-distribution/games/{gameId}/play-session)与「管理员令牌 403」口径(403 需带 admin 角色的用户令牌)
2026-10-06 02:24:40 +08:00

1657 lines
60 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏买断制泥点付费与播放鉴权「真实本地栈」端到端检查。
//
// 定位:人工验收脚本,**不在 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/<gameId>/` 记 SKIP 并在报告末尾汇总(既不算 PASS
// 也不算 FAIL);设置 E2E_REQUIRE_PLATFORM_ORIGIN=1 时缺配置直接 FAIL,避免把「没验过」
// 当成通过。脚本末尾的汇总行同时给出 PASS / FAIL / WARN / SKIP 四项计数。
// 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/<gameId>/` 都是 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';
/**
* 设置后,平台同源断言缺少主站地址时直接 FAIL(而不是 SKIP)。
*
* 发布前验收或 CI 化演练应打开它:那时「没验过」必须当成失败,不能当成通过。
*/
const REQUIRE_PLATFORM_ORIGIN =
(process.env.E2E_REQUIRE_PLATFORM_ORIGIN ?? '').trim() === '1';
const GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE = 'game_purchase';
/** 1x1 透明 PNG:仅用于满足发布必填封面,内容不影响本用例断言。 */
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
let failures = 0;
let passes = 0;
let warnings = 0;
/** 被跳过的断言:缺配置时既不算 PASS 也不算 FAIL,但必须在报告末尾显式列出。 */
const skips = [];
function check(name, ok, detail = '') {
if (ok) {
passes += 1;
} else {
failures += 1;
}
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
function warn(name, detail = '') {
warnings += 1;
console.log(`WARN ${name}${detail ? ` :: ${detail}` : ''}`);
}
function skip(name, detail = '') {
skips.push({ name, detail });
console.log(`SKIP ${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 = `<!doctype html><html><head><meta charset="utf-8"><title>E2E ${marker}</title><script src="assets/app.js"></script></head><body><h1>${marker}</h1></body></html>`;
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/<gameId>/`。
*
* 缺主站地址时不再静默返回 null:显式记录 SKIP 并在报告末尾汇总;设置
* `E2E_REQUIRE_PLATFORM_ORIGIN=1` 时缺配置直接 FAIL,避免「没验过」被当成通过。
*/
async function fetchPlatformEntry(assertionName, gameId, assetPath = '') {
if (!WEB) {
if (REQUIRE_PLATFORM_ORIGIN) {
check(
assertionName,
false,
'E2E_REQUIRE_PLATFORM_ORIGIN=1 但缺少 E2E_WEB_BASE / dev-stack 主站地址',
);
} else {
skip(
assertionName,
'未发现主站 Vite(缺少 E2E_WEB_BASE),本轮未验证平台同源路径',
);
}
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/<gameId>/ 断言',
'可设置 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(
'付费作品平台同源 /games/<gameId>/ 404',
paid.gameId,
);
if (paidPlatformEntry) {
check(
'付费作品平台同源 /games/<gameId>/ 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) &&
/<html|<!doctype html/iu.test(sessionEntryBody),
`status=${sessionEntry.status} marker=${sessionEntryBody.includes(paid.pkg.marker)}`,
);
check(
'播放会话入口带 nosniff',
sessionEntry.headers.get('x-content-type-options') === 'nosniff',
String(sessionEntry.headers.get('x-content-type-options')),
);
const sessionAsset = await fetch(`${API}${playUrl}${paid.pkg.assetPath}`);
const sessionAssetBody = await sessionAsset.text();
check(
'播放会话包内资源与上传内容逐字节一致',
sessionAsset.status === 200 && sessionAssetBody === paid.pkg.asset,
`status=${sessionAsset.status} bytes=${sessionAssetBody.length}`,
);
const secondSession = await api(
`/api/game-distribution/games/${paid.gameId}/play-session`,
{ method: 'POST', token: buyer.token },
);
const balanceAfterSecondEntry = await walletBalance(buyer.token);
check(
'重复进入不再扣费',
secondSession.status === 200 &&
balanceAfterSecondEntry === afterPurchaseBalance,
`status=${secondSession.status} balance=${balanceAfterSecondEntry} expected=${afterPurchaseBalance}`,
);
// 6. 播放会话网关的来源约束与伪造令牌
// (1) 直连 api-server 且不带 Cookie:上面已断言 200(这是播放会话的正常读取姿态)。
// (2) 伪造令牌:拿不到 token 的未购买账号即使猜 URL 也只能拿到 404。
const forgedToken = `forged${stamp()}`;
const forgedEntry = await fetch(
`${API}/api/game-distribution/play-sessions/${forgedToken}/`,
);
check(
'伪造播放会话令牌请求入口 404',
forgedEntry.status === 404,
`status=${forgedEntry.status}`,
);
const forgedAsset = await fetch(
`${API}/api/game-distribution/play-sessions/${forgedToken}/${paid.pkg.assetPath}`,
);
check(
'伪造播放会话令牌请求包内资源 404',
forgedAsset.status === 404,
`status=${forgedAsset.status}`,
);
// (3) 带平台 refresh Cookie:网关按「播放会话必须在独立来源读取」的约束直接 403。
// 浏览器侧是否真的会带上这个 Cookie 取决于服务端下发的 Path,因此一起取证。
const cookieSource = WEB || API;
const cookieLogin = await loginWithRefreshCookie(cookieSource);
const refreshSetCookie = cookieLogin.setCookies.find((entry) =>
/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(
'免费游戏平台同源 /games/<gameId>/ 直接可玩',
free.gameId,
);
if (freePlatformEntry) {
check(
'免费游戏平台同源 /games/<gameId>/ 直接可玩',
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] 断言汇总:${passes} PASS / ${failures} FAIL / ${warnings} WARN / ${skips.length} SKIP`,
);
if (skips.length > 0) {
console.log('[e2e] SKIP 清单(缺配置未验证,不等于通过):');
for (const entry of skips) {
console.log(
` - ${entry.name}${entry.detail ? ` :: ${entry.detail}` : ''}`,
);
}
}
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);