Files
Genarrative/scripts/check-game-distribution-media-e2e.mjs
T
kdletters f502829fde
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 5m19s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m59s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m19s
Project CI / Native shell tests (push) Successful in 6m48s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Repository checks (push) Successful in 2m58s
Project CI / AI game creator shell web tests (push) Successful in 2m34s
发布灰度默认关闭:修掉客户端“看得到点不动”与后台看不到 key (#482)
## 解决什么

你反馈的两个现象都是真问题,这个 PR 一次修掉:

1. **没开灰度也能看到发布按钮**:发布开关此前是「无 gate 行 = 默认开放」,所以没配灰度时后端把 `gameDistributionPublishEnabled` 判成 true,客户端就渲染了发布入口。
2. **按钮点了没反应**:发布动作的提示原先只写 `workspaceStatus`,而普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,于是「先打开一个项目再发布」「构建失败…」这类信息全都看不见。
3. **后台看不到 key**:预设项此前在未合并的另一个 PR 里,而且后台只列「已创建」的 gate 行。

## 改成什么

- `is_game_distribution_publish_enabled_for_user` 要求 gate 行存在且 `enabled=true`:**灰度默认关闭**,未登录/无行/`enabled=false` 一律不开放;只有白名单 / 灰度比例 / 用户标签命中才开放。作者写入与新版本激活都按这条判据。
- 新增与作者无关的总开关 `is_game_distribution_publish_open`:管理员审核激活新版本只看「灰度是否开启」,不会因为审核员不在作者白名单里而被挡住(这是合入后立刻发现的回归,已修 + 有用例)。
- AGC 新增 `announcePublishMessage`:发布相关提示同时写工作台状态与 DirectProject 对话,点「发布到游戏广场」不会再像没反应。
- 后台「灰度发布配置」新增「可配置开关」列表:`game-distribution:publish` 等预设即使还没创建行也会显示,点「配置」按默认关闭填表,运营开启后填白名单/比例即可放量。

## 验证

- `npm run check:game-distribution-media-e2e`(真实 Phaser 构建产物)现在覆盖:灰度关闭 → 作者拿不到入口 + 写入口 503 → 开启灰度 → 真实封面/截图直传 → 创建游戏/版本/上传/送审 → 管理员审核通过 → 网关 index.html 与 bundle 200 → nosniff;全流程通过。
- 后端:`cargo test -p api-server game_distribution` 19 passed(含新增 `game_distribution_publish_open_ignores_author_allowlist_for_activation`)、`frontend_runtime_config` 6 passed。
- 前端:admin 灰度页 10 用例(含「未创建的预设开关可见并可一键配置」)、AGC 聊天头 3 用例、网页发布页 15 用例;三端 typecheck、`check:encoding`、`check:doc-index`、`check:rustfmt`、全量 `npm test`(400 文件 / 4441 用例)与 `check:repository-ci -- origin/master HEAD` 全绿。

## 上线注意

灰度默认关闭是**刻意**的:合入后到运营在后台把 `game-distribution:publish` 配置并开启之前,作者看不到发布入口、写入口 503;已公开游戏、目录、详情、发行网关、安全下架不受影响。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/482
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-22 21:38:03 +08:00

618 lines
20 KiB
JavaScript
Raw 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.
// 游戏分发「封面 + 截图」真实链路检查(需要本地 dev 栈 + 真实 OSS 配置)。
//
// 用法:
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-media-e2e
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。
// E2E_PACKAGE_ZIP 指向一个已经构建好的发行包(根目录含 index.html),例如真实
// Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。
// E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。
//
// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 →
// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影
// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。
import { readFile } from 'node:fs/promises';
import JSZip from 'jszip';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
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 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}` : ''}`,
);
}
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,
};
}
function stamp() {
return `${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
async function uploadImage(token, kind, id) {
const bytes = COVER_PNG;
const fileName = `${kind}-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', kind, id],
fileName,
contentType: 'image/png',
access: 'private',
maxSizeBytes: bytes.length,
metadata: { asset_kind: `game_distribution_${kind}` },
},
});
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([bytes], { 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: bytes.length,
assetKind: `game_distribution_${kind}`,
accessPolicy: 'private',
entityId: `game-distribution-${kind}`,
},
});
if (confirm.status !== 200) {
throw new Error(
`confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return {
assetObjectId: confirm.data.assetObject.assetObjectId,
objectKey: confirm.data.assetObject.objectKey,
};
}
function gameMetadata(overrides = {}) {
return {
title: `分发媒体验证 ${stamp().slice(-6)}`,
summary: '真实链路验证封面与截图冻结',
description: 'E2E:真实素材直传 + 冻结 + 审核生效',
category: '益智',
tags: ['E2E'],
deviceSupport: { desktop: true, mobile: true, touch: true },
inputModes: ['keyboard', 'mouse', 'touch'],
orientation: 'responsive',
...overrides,
};
}
const externalPackageZip = (process.env.E2E_PACKAGE_ZIP ?? '').trim();
const gameTitleOverride = (process.env.E2E_GAME_TITLE ?? '').trim();
/** 返回待发布的发行包字节与条目数:优先使用调用方真实构建产物,否则用内置 fixture。 */
async function buildZip() {
if (externalPackageZip) {
const bytes = await readFile(externalPackageZip);
const archive = new JSZip();
const parsed = await archive.loadAsync(bytes);
const entryNames = Object.keys(parsed.files).filter(
(name) => !parsed.files[name].dir,
);
if (!entryNames.includes('index.html')) {
throw new Error(
`E2E_PACKAGE_ZIP 根目录缺少 index.html${externalPackageZip}`,
);
}
// 真实构建产物(Phaser/Vite 等)资源名带哈希:从包内派生一个资源路径做网关断言。
const assetPath =
entryNames.find((name) => /^assets\/.+\.js$/u.test(name)) ??
entryNames.find((name) => name.endsWith('.js'));
if (!assetPath) {
throw new Error(
`E2E_PACKAGE_ZIP 内没有可断言的 JS 资源:${externalPackageZip}`,
);
}
return {
bytes: Buffer.from(bytes),
fileCount: entryNames.length,
assetPath,
entryMarker: null,
};
}
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>E2E 媒体验证</title><script src="assets/app.js"></script></head><body><h1>E2E-MEDIA-OK</h1></body></html>',
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";');
const bytes = await zip.generateAsync({ type: 'uint8array' });
return {
bytes: Buffer.from(bytes),
fileCount: 2,
assetPath: 'assets/app.js',
entryMarker: 'E2E-MEDIA-OK',
};
}
async function main() {
// 1. 作者注册
const phone = `137${String(Date.now()).slice(-8)}`;
const entry = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: 'GenE2e123!' },
});
check(
'作者注册拿到 token',
entry.status === 200 && Boolean(entry.data?.token),
`status=${entry.status}`,
);
const author = entry.data.token;
const otherEntry = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `138${String(Date.now() + 7).slice(-8)}`,
password: 'GenE2e123!',
},
});
const another = otherEntry.data.token;
// 1.1 管理员登录:发布灰度默认关闭,脚本先验证关闭态再为本轮验证开启。
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
check(
'管理员登录成功',
adminLogin.status === 200 &&
Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken),
`status=${adminLogin.status}`,
);
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
const setPublishGate = (enabled, rolloutPercent) =>
api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发布灰度',
},
});
const gateClosed = await setPublishGate(false, 0);
check(
'发布灰度可配置为关闭',
gateClosed.status === 200,
`status=${gateClosed.status}`,
);
const closedAvailability = await api('/api/runtime/frontend-config', {
token: author,
});
check(
'灰度关闭时作者拿不到发布入口',
closedAvailability.data?.gameDistributionPublishEnabled === false,
`value=${closedAvailability.data?.gameDistributionPublishEnabled}`,
);
const closedPublish = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-gate-closed-${Date.now()}` },
body: gameMetadata({ title: `灰度关闭验证 ${Date.now()}` }),
});
check(
'灰度关闭时写入口 503',
closedPublish.status === 503,
`status=${closedPublish.status} code=${closedPublish.error?.code ?? ''}`,
);
const gateOpen = await setPublishGate(true, 100);
check(
'发布灰度可开启并放量',
gateOpen.status === 200,
`status=${gateOpen.status}`,
);
const openAvailability = await api('/api/runtime/frontend-config', {
token: author,
});
check(
'灰度开启后作者拿到发布入口',
openAvailability.data?.gameDistributionPublishEnabled === true,
`value=${openAvailability.data?.gameDistributionPublishEnabled}`,
);
// 2. 真实素材直传
const id = stamp();
const cover = await uploadImage(author, 'cover', id);
const shot1 = await uploadImage(author, 'screenshot', `${id}-1`);
const shot2 = await uploadImage(author, 'screenshot', `${id}-2`);
check(
'封面素材直传并 confirm',
Boolean(cover.assetObjectId) &&
cover.objectKey.includes('game-distribution/cover'),
cover.objectKey,
);
check(
'截图素材直传并 confirm',
Boolean(shot1.assetObjectId) && Boolean(shot2.assetObjectId),
);
// 3. 服务端校验:缺封面 / 超 6 张 / 素材不存在
const noCover = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-nocover-${id}` },
body: gameMetadata({ title: '缺封面验证' }),
});
check(
'缺少封面被拒(400',
noCover.status === 400,
`status=${noCover.status} msg=${noCover.error?.message ?? ''}`,
);
const tooMany = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-many-${id}` },
body: gameMetadata({
coverAssetId: cover.assetObjectId,
screenshots: Array.from({ length: 7 }, () => shot1.assetObjectId),
}),
});
check(
'截图超过 6 张被拒(400',
tooMany.status === 400,
`status=${tooMany.status} msg=${tooMany.error?.message ?? ''}`,
);
const ghost = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghost-${id}` },
body: gameMetadata({ coverAssetId: 'asset_not_exists', screenshots: [] }),
});
check(
'不存在的封面素材被拒',
ghost.status === 400,
`status=${ghost.status} msg=${ghost.error?.message ?? ''}`,
);
// 4. 创建游戏 + 版本(冻结资料)
const metadata = gameMetadata({
title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [shot1.assetObjectId, shot2.assetObjectId],
});
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-game-${id}` },
body: metadata,
});
check(
'创建游戏成功',
created.status === 200 && Boolean(created.data?.id),
`status=${created.status} ${created.text.slice(0, 200)}`,
);
const gameId = created.data.id;
// 版本级校验用例需要真实的游戏行:用合法素材建一个只用于负面校验的游戏。
const ghostGame = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghostgame-${id}` },
body: gameMetadata({
title: `版本级素材校验 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [],
}),
});
const gameIdForGhost = ghostGame.data?.id ?? gameId;
const ghostVersion = await api(
`/api/game-distribution/games/${gameIdForGhost}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-ghost-version-${id}` },
body: {
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
packageFileCount: 1,
packageEntryPath: 'index.html',
gameMetadata: gameMetadata({
coverAssetId: 'asset_not_exists',
screenshots: [],
}),
},
},
);
check(
'版本冻结时同样拒绝不存在的素材',
ghostVersion.status >= 400,
`status=${ghostVersion.status}`,
);
const built = await buildZip();
const zipBytes = built.bytes;
const crypto = await import('node:crypto');
const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex');
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-version-${id}` },
body: {
packageSha256: sha256,
packageBytes: zipBytes.length,
packageFileCount: built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
check(
'创建版本成功',
version.status === 200 && Boolean(version.data?.versionId),
`status=${version.status} ${version.text.slice(0, 200)}`,
);
const versionId = version.data.versionId;
const uploadPackage = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `e2e-upload-${id}`,
'Content-Type': 'application/zip',
},
binary: zipBytes,
},
);
check(
'上传发行包成功',
uploadPackage.status === 200 && uploadPackage.data?.status === 'uploaded',
`status=${uploadPackage.status}`,
);
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `e2e-submit-${id}` },
body: {
expectedPublicationRevision: created.data.publicationRevision ?? 0,
},
},
);
check(
'送审成功(202 + pending_review',
submitted.status === 202 &&
submitted.data?.version?.status === 'pending_review',
`status=${submitted.status} ${submitted.text.slice(0, 200)}`,
);
// 版本冻结前仍要复核素材归属:换成别人的封面素材必须被拒。
const foreign = await api('/api/game-distribution/games', {
method: 'POST',
token: another,
headers: { 'Idempotency-Key': `e2e-foreign-game-${id}` },
body: gameMetadata({
title: `他人素材验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [],
}),
});
check(
'借用他人封面素材创建游戏被拒',
foreign.status === 403 || foreign.status === 400,
`status=${foreign.status} msg=${foreign.error?.message ?? ''}`,
);
// 5. 作者回读版本:冻结资料带回素材 ID
const readback = await api(`/api/game-distribution/versions/${versionId}`, {
token: author,
});
const frozen = readback.data?.version?.frozenMetadata;
check(
'作者回读拿到 frozenMetadata',
Boolean(frozen),
`status=${readback.status}`,
);
check(
'冻结资料保留封面素材 ID',
frozen?.coverAssetId === cover.assetObjectId,
String(frozen?.coverAssetId),
);
check(
'冻结资料保留截图素材 ID 顺序',
Array.isArray(frozen?.screenshots) &&
frozen.screenshots[0]?.assetId === shot1.assetObjectId &&
frozen.screenshots[1]?.assetId === shot2.assetObjectId,
);
check(
'冻结资料对象键由服务端派生',
frozen?.coverObjectKey === cover.objectKey,
String(frozen?.coverObjectKey),
);
// 6. 待审期间:公开目录不可见,匿名读封面被拒
const catalogBefore = await api('/api/game-distribution/games');
check(
'待审期间公开目录不含该游戏',
!(catalogBefore.data?.games ?? []).some((game) => game.id === gameId),
);
const readBefore = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`,
);
check(
'未公开游戏的封面没有匿名读授权',
readBefore.status >= 400,
`status=${readBefore.status}`,
);
// 7. 管理员审核通过(本地非生产允许回环 http 入口;管理员 token 在步骤 1.1 已取得)
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `e2e-approve-${id}` },
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
// 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。
entryUrl: `${API}/api/game-distribution/releases/${gameId}/`,
},
},
);
check(
'管理员审核通过',
approved.status === 200,
`status=${approved.status} ${approved.text.slice(0, 250)}`,
);
// 8. 公开目录:封面/截图对象键生效
const catalogAfter = await api('/api/game-distribution/games');
const publishedGame = (catalogAfter.data?.games ?? []).find(
(game) => game.id === gameId,
);
check('公开目录返回该游戏', Boolean(publishedGame));
check(
'公开投影带封面对象键',
publishedGame?.coverObjectKey === cover.objectKey,
String(publishedGame?.coverObjectKey),
);
check(
'公开投影带截图对象键',
Array.isArray(publishedGame?.screenshots) &&
publishedGame.screenshots[0] === shot1.objectKey,
JSON.stringify(publishedGame?.screenshots ?? []),
);
check(
'公开投影不泄露素材 ID',
!JSON.stringify(publishedGame ?? {}).includes(cover.assetObjectId),
);
// 9. 匿名读授权:封面与截图都能换签名地址
const coverRead = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(cover.objectKey)}`,
);
check(
'匿名可读已公开游戏封面',
coverRead.status === 200 &&
Boolean(coverRead.data?.read?.signedUrl ?? coverRead.data?.signedUrl),
`status=${coverRead.status}`,
);
const shotRead = await api(
`/api/assets/read-url?objectKey=${encodeURIComponent(shot1.objectKey)}`,
);
check(
'匿名可读已公开游戏截图',
shotRead.status === 200 &&
Boolean(shotRead.data?.read?.signedUrl ?? shotRead.data?.signedUrl),
`status=${shotRead.status}`,
);
// 10. 发行网关可直接玩
const release = await fetch(
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
const releaseBody = await release.text();
const entryOk =
release.status === 200 &&
/<html|<!doctype html/iu.test(releaseBody) &&
(built.entryMarker === null || releaseBody.includes(built.entryMarker));
check(
'发行网关返回游戏入口',
entryOk,
`status=${release.status} bytes=${releaseBody.length}`,
);
check(
'发行入口带 nosniff',
release.headers.get('x-content-type-options') === 'nosniff',
);
const releaseAsset = await fetch(
`${API}/api/game-distribution/releases/${gameId}/${built.assetPath}`,
);
const assetBody = await releaseAsset.arrayBuffer();
check(
'发行网关返回包内资源',
releaseAsset.status === 200 && assetBody.byteLength > 0,
`status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`,
);
console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exitCode = failures === 0 ? 0 : 1;
}
main().catch((error) => {
console.error('E2E 脚本异常:', error);
process.exitCode = 1;
});