合并 origin/master:待发消息队列归宿主叠加 master 的 active-turns 广播与保活下沉

- direct_runtime/mod.rs:保留本分支「Thread Manager 是活动回合唯一事实源」(direct_active_turn_id_at),
  丢掉 master 的 DirectTaonierActiveInvocationGuard 第二份进程内表;master 的美术背景路由改动全部保留
- direct_thread_manager.rs:双方都保留——本分支的待发队列 / 容量上限 / 残留回合兜底释放,
  与 master 的 emit_direct_active_turns_changed 广播、unsubscribe、update_active_turn 返回「是否真的变了」
- useDirectProjectChatController.ts:保留本分支入队化逻辑,采纳 master 的 rawMessage 小重构与保活下沉
- direct_turn_dispatch.rs:会话保活改挂放行占用(DirectTurnReservation._session_keepalive),
  随占用释放即停,等价承接 master 放在调用身份守卫里的保活意图
- 保留本分支对 direct-execution-production-fixture.mjs 的删除(--direct-codex-chat CLI 已退役,夹具无入口)
- 连带退役 scripts/run-agc-direct-execution-fixture.mjs 与 npm run check:agc-direct-execution-fixture(只服务该夹具)
- 文档同步:ADR / 09-24 实施计划 / decision-log 记录上述退役,09-16 与 09-18 里程碑、pitfalls 的夹具命令标注为已退役
This commit is contained in:
2026-09-30 13:35:14 +08:00
465 changed files with 34819 additions and 20200 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
// AGC 项目快照「后台列表 + 工程 ZIP 下载」的真实链路检查。
//
// 用法(需要本地 dev 栈与一份已经同步过的项目副本):
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
// E2E_SNAPSHOT_USER_ID=<快照归属 user id> E2E_SNAPSHOT_PROJECT_ID=<project id> \
// E2E_SNAPSHOT_PROJECT_DIR=<本地项目副本绝对路径> \
// npm run check:agc-project-snapshot-admin-http
// E2E_API_BASE 默认 http://127.0.0.1:4198;E2E_SNAPSHOT_CHANNEL 默认 dev。
//
// 覆盖:管理员列表能看到该快照的统计(status=ready / fileCount / totalBytes)→ 下载 ZIP →
// 条目数与列表一致 → 每个条目与本地副本同名文件的 SHA-256 逐一致 → ZIP 里没有版本库 /
// node_modules / .env* 这类被排除的内容。整条链路只读:不写 OSS、不改后台数据。
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import JSZip from 'jszip';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const SNAPSHOT_USER_ID = (process.env.E2E_SNAPSHOT_USER_ID ?? '').trim();
const SNAPSHOT_PROJECT_ID = (process.env.E2E_SNAPSHOT_PROJECT_ID ?? '').trim();
const PROJECT_DIR = (process.env.E2E_SNAPSHOT_PROJECT_DIR ?? '').trim();
const CHANNEL = (process.env.E2E_SNAPSHOT_CHANNEL ?? 'dev').trim();
for (const [name, value] of [
['E2E_ADMIN_USER', ADMIN_USER],
['E2E_ADMIN_PASSWORD', ADMIN_PASSWORD],
['E2E_SNAPSHOT_USER_ID', SNAPSHOT_USER_ID],
['E2E_SNAPSHOT_PROJECT_ID', SNAPSHOT_PROJECT_ID],
['E2E_SNAPSHOT_PROJECT_DIR', PROJECT_DIR],
]) {
if (!value) {
console.error(
`缺少 ${name}:本脚本要对已同步的快照做列表与 ZIP 下载核对。`,
);
process.exit(2);
}
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, raw = false } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
});
if (raw) {
return {
status: response.status,
headers: response.headers,
buffer: Buffer.from(await response.arrayBuffer()),
};
}
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 sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex');
}
async function main() {
const login = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = login.data?.token ?? login.data?.accessToken;
check(
'管理员登录成功',
login.status === 200 && Boolean(admin),
`status=${login.status}`,
);
if (!admin) process.exit(1);
const list = await api(
`/admin/api/project-snapshots?channel=${encodeURIComponent(CHANNEL)}&limit=100`,
{ token: admin },
);
const item = (list.data?.items ?? []).find(
(entry) =>
entry.userId === SNAPSHOT_USER_ID &&
entry.projectId === SNAPSHOT_PROJECT_ID,
);
check(
'后台列表包含该项目的快照',
list.status === 200 && Boolean(item),
`status=${list.status} items=${list.data?.items?.length ?? 'n/a'}`,
);
check(
'快照统计是 ready 且文件数与体积非零',
item?.status === 'ready' && item.fileCount > 0 && item.totalBytes > 0,
`status=${item?.status ?? ''} files=${item?.fileCount ?? 'n/a'} bytes=${item?.totalBytes ?? 'n/a'}`,
);
if (!item) process.exit(1);
const download = await api(
`/admin/api/project-snapshots/${encodeURIComponent(SNAPSHOT_USER_ID)}/${encodeURIComponent(SNAPSHOT_PROJECT_ID)}/download?channel=${encodeURIComponent(CHANNEL)}`,
{ token: admin, raw: true },
);
check(
'后台下载返回 ZIP',
download.status === 200 &&
(download.headers.get('content-type') ?? '').includes('zip'),
`status=${download.status} type=${download.headers.get('content-type') ?? ''} bytes=${download.buffer.length}`,
);
if (download.status !== 200) process.exit(1);
const archive = await JSZip.loadAsync(download.buffer);
const entries = Object.values(archive.files).filter((file) => !file.dir);
check(
'ZIP 条目数与后台列表的文件数一致',
entries.length === item.fileCount,
`zip=${entries.length} list=${item.fileCount}`,
);
const zipBytes = entries.reduce(
(total, entry) => total + (entry._data?.uncompressedSize ?? 0),
0,
);
check(
'ZIP 内文件体积之和与后台列表一致',
zipBytes === item.totalBytes,
`zip=${zipBytes} list=${item.totalBytes}`,
);
let compared = 0;
let missingLocally = 0;
const mismatches = [];
for (const entry of entries) {
const localPath = path.join(PROJECT_DIR, ...entry.name.split('/'));
let localBytes;
try {
const info = await stat(localPath);
if (!info.isFile()) throw new Error('not a file');
localBytes = await readFile(localPath);
} catch {
missingLocally += 1;
continue;
}
const zipBytesForEntry = await entry.async('nodebuffer');
compared += 1;
if (sha256(zipBytesForEntry) !== sha256(localBytes)) {
mismatches.push(entry.name);
}
}
check(
'ZIP 条目在本地副本里都能找到',
missingLocally === 0,
`missing=${missingLocally}`,
);
check(
'ZIP 内容与本地副本逐字节一致(SHA-256)',
compared === entries.length && mismatches.length === 0,
`compared=${compared} mismatches=${mismatches.length}${mismatches.length > 0 ? ` first=${mismatches[0]}` : ''}`,
);
const excluded = entries
.map((entry) => entry.name)
.filter(
(name) =>
/(^|\/)\.git\//u.test(name) ||
/(^|\/)node_modules\//u.test(name) ||
/(^|\/)target\//u.test(name) ||
/(^|\/)\.env(\.|$)/u.test(name),
);
check(
'ZIP 里没有版本库 / node_modules / .env* 这类排除项',
excluded.length === 0,
excluded.length > 0 ? `first=${excluded[0]}` : '',
);
const agentEntries = entries.filter((entry) =>
entry.name.startsWith('.agent/'),
).length;
check(
'ZIP 里确实带上了 .agent 状态目录',
agentEntries > 0,
`.agent files=${agentEntries}`,
);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(`[check:agc-project-snapshot-admin-http] 运行失败:${error}`);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
// External v1 场景生成路由的本地真实栈受理 smoke。
//
// 用法:
// E2E_API_BASE 默认 http://127.0.0.1:4198
// E2E_EXTERNAL_API_KEY 可选;不给时脚本在 dev 环境注册一个账号并通过
// /api/profile/api-keys 现建一个 Key(dev 密码登录会自动注册)。
//
// 覆盖:非法 Key 401 → 缺幂等键 400 → 空 sceneContent 400 → custom 缺 customStyle 400 →
// 合法请求 202 且返回 operationId → 同键重放返回同一 operationId → 同键换请求体 409 →
// 按 operationId 查一次生成状态(Provider 出图按环境可用性如实记录,不作为受理判据)。
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const DEV_PASSWORD = 'GenE2e123!';
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, externalKey } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
if (externalKey) finalHeaders.Authorization = `Bearer ${externalKey}`;
let finalBody;
if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
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,
text,
json,
data: json?.data,
error: json?.error,
};
}
async function resolveExternalKey() {
const provided = (process.env.E2E_EXTERNAL_API_KEY ?? '').trim();
if (provided) return provided;
const phone = `133${String(Date.now()).slice(-8)}`;
const registered = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
const token = registered.data?.token;
check(
'dev 账号注册/登录成功',
registered.status === 200 && Boolean(token),
`status=${registered.status}`,
);
if (!token) process.exit(1);
const created = await api('/api/profile/api-keys', {
method: 'POST',
token,
body: { name: 'E2E 场景路由 smoke' },
});
const apiKey = created.data?.apiKey;
check(
'创建外部 API Key 成功',
created.status === 200 && Boolean(apiKey),
`status=${created.status}`,
);
if (!apiKey) process.exit(1);
return apiKey;
}
const SCENE_PATH = '/api/external/v1/editor/scenes/generations';
async function main() {
const apiKey = await resolveExternalKey();
const unauthorized = await api(SCENE_PATH, {
method: 'POST',
externalKey: 'tnr_sk_invalid_probe',
headers: { 'Idempotency-Key': `scene-smoke-unauthorized-${Date.now()}` },
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'flat' },
});
check(
'非法 API Key 被拒(401)',
unauthorized.status === 401,
`status=${unauthorized.status}`,
);
const stamp = Date.now();
const missingKey = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'flat' },
});
check(
'缺 Idempotency-Key 被拒(400)',
missingKey.status === 400,
`status=${missingKey.status}`,
);
const blankScene = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': `scene-smoke-blank-${stamp}` },
body: { sceneContent: ' ', stylePreset: 'flat' },
});
check(
'空 sceneContent 被拒(400)',
blankScene.status === 400,
`status=${blankScene.status}`,
);
const customWithoutStyle = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': `scene-smoke-custom-${stamp}` },
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'custom' },
});
check(
'custom 风格缺 customStyle 被拒(400)',
customWithoutStyle.status === 400,
`status=${customWithoutStyle.status}`,
);
const idempotencyKey = `scene-smoke-${stamp}`;
const body = {
sceneContent: '夕阳下的海边小镇,远处有灯塔',
stylePreset: 'flat',
aspectRatio: '16:9',
imageSize: '1K',
generationInputs: { source: 'ai-game-creator-client' },
};
const accepted = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body,
});
const operationId = accepted.data?.operationId ?? accepted.data?.operation_id;
check(
'合法场景请求被受理(202 + operationId)',
accepted.status === 202 && Boolean(operationId),
`status=${accepted.status} operationId=${operationId ?? ''} msg=${accepted.error?.message ?? ''}`,
);
if (!operationId) process.exit(1);
const replayed = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body,
});
check(
'同键同请求重放返回同一 operationId',
replayed.status === 202 &&
(replayed.data?.operationId ?? replayed.data?.operation_id) ===
operationId,
`status=${replayed.status} operationId=${replayed.data?.operationId ?? ''}`,
);
const conflicting = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body: { ...body, sceneContent: '同一个幂等键换了画面内容' },
});
check(
'同键换请求体被拒(409)',
conflicting.status === 409,
`status=${conflicting.status} code=${conflicting.error?.code ?? ''}`,
);
let generation = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
generation = await api(`/api/external/v1/generations/${operationId}`, {
externalKey: apiKey,
});
const status = generation.data?.status ?? '';
if (
status === 'completed' ||
status === 'succeeded' ||
status === 'failed' ||
status === 'cancelled'
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
const status = generation?.data?.status ?? 'unknown';
check(
'按 operationId 能查到生成状态',
generation?.status === 200 && status !== 'unknown',
`status=${generation?.status} generationStatus=${status}`,
);
const result = generation?.data?.result ?? {};
const assetObjectId = result.assetObjectId ?? result.asset?.assetObjectId;
const objectKey = result.asset?.objectKey ?? result.objectKey ?? '';
const providerProducedImage =
status === 'completed' && Boolean(assetObjectId);
check(
'Provider 出图成功并返回素材引用(completed + assetObjectId)',
providerProducedImage,
`generationStatus=${status} assetObjectId=${assetObjectId ?? ''}`,
);
if (providerProducedImage && objectKey) {
const readUrl = await api(
`/api/external/v1/assets/read-url?objectKey=${encodeURIComponent(objectKey)}`,
{ externalKey: apiKey },
);
const signedUrl = readUrl.data?.read?.signedUrl ?? readUrl.data?.signedUrl;
const imageResponse = signedUrl ? await fetch(signedUrl) : null;
const contentType = imageResponse?.headers.get('content-type') ?? '';
check(
'出图素材能通过 external read-url 换签名地址下载',
readUrl.status === 200 &&
Boolean(signedUrl) &&
imageResponse?.status === 200 &&
contentType.startsWith('image/'),
`readUrl=${readUrl.status} objectKey=${objectKey} download=${imageResponse?.status ?? 'n/a'} type=${contentType}`,
);
}
console.log(
`\nProvider 出图:${
providerProducedImage
? `已验证(assetObjectId=${assetObjectId}${objectKey ? ` objectKey=${objectKey}` : ''})`
: `未验证(当前 generationStatus=${status},本机图像 Provider 不可用时属环境限制)`
}`,
);
console.log(`生成状态原始响应:${generation?.text?.slice(0, 400) ?? ''}`);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(
`[check-external-v1-scene-generation-smoke] 运行失败:${error}`,
);
process.exit(1);
});
@@ -0,0 +1,460 @@
#!/usr/bin/env node
// 检查 game-distribution 的 Rust DTO 与手写 TS DTO 是否一致。
//
// 为什么需要它:`packages/shared/src/contracts/gameDistribution.ts` 是手写的,没有生成绑定兜底。
// Rust 侧加字段而 TS 侧忘改时,只有跑起来才会发现。这里用一张显式映射表逐字段/逐变体比对:
// - 映射表同时是「哪些 Rust DTO 必须在 TS 里有对应类型」的清单;
// - `TS_ONLY_TYPES` 是「服务端手拼 JSON、没有 Rust 结构体」的说明清单;
// - 两侧字段必须逐一对齐,任一方向多出字段都会失败(没有白名单)。
// - 服务端手拼响应的构建器(`json!` / `object.insert`)单独比对顶层键:
// 类型字段一致只说明契约写得对,这一层才有证据说明构建器真的按契约发键。
// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。
import fs from 'node:fs';
const RUST_FILE = 'server-rs/crates/shared-contracts/src/game_distribution.rs';
const TS_FILE = 'packages/shared/src/contracts/gameDistribution.ts';
const API_MODULE_FILE =
'server-rs/crates/api-server/src/modules/game_distribution.rs';
// [Rust 类型名, TS 类型名]
const PAIRS = [
[
'GameDistributionPublishMetadataSuggestionRequest',
'GameDistributionPublishMetadataSuggestionRequest',
],
[
'GameDistributionPublishMetadataSuggestion',
'GameDistributionPublishMetadataSuggestion',
],
['GameDistributionVersionStatus', 'GameDistributionVersionStatus'],
['GameDistributionVisibility', 'GameDistributionGameVisibility'],
['GameDistributionInputMode', 'GameDistributionInputMode'],
['GameDistributionOrientation', 'GameDistributionOrientation'],
['GameDistributionDeviceSupport', 'GameDistributionDeviceSupport'],
['GameDistributionAuthor', 'GameDistributionAuthor'],
['GameDistributionVersionSummary', 'GameDistributionVersionSummary'],
['GameDistributionGameSummary', 'GameDistributionGame'],
['GameDistributionListResponse', 'GameDistributionListResponse'],
['GameDistributionCreateGameRequest', 'GameDistributionCreateGameRequest'],
[
'GameDistributionCreateVersionRequest',
'GameDistributionCreateVersionRequest',
],
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
];
// 服务端逐字段手拼 JSON 的响应/请求(`public_game_payload` / `game_payload` 等),TS 里这些类型
// 没有 Rust 结构体可对:要收口得先把响应改成结构化类型,不能靠本脚本检查。
const TS_ONLY_TYPES = [
'GameDistributionCategory',
'GameDistributionRecoveryAction',
'GameDistributionFrozenScreenshot',
'GameDistributionVersionFrozenMetadata',
'GameDistributionVersionDetail',
'GameDistributionCancelVersionRequest',
'GameDistributionCancelVersionResponse',
];
// 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。
// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影 = 作者投影 + currentVersion);
// - `mustEmit`:TS 类型为了同时描述两种形状把该键标成可选,但这条路径必须发出。
// 只解析顶层键;需要一并比对的嵌套对象用 `nested` 显式登记,没登记的嵌套对象不在证据范围内。
const RESPONSE_BUILDERS = [
{
fn: 'game_payload',
ts: 'GameDistributionGame',
nested: [
{ key: 'author', ts: 'GameDistributionAuthor' },
{ key: 'deviceSupport', ts: 'GameDistributionDeviceSupport' },
],
},
{
fn: 'public_game_payload',
ts: 'GameDistributionGame',
base: 'game_payload',
mustEmit: ['currentVersion'],
},
{ fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' },
{ fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' },
];
function camelCase(value) {
return value.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase());
}
function snakeCase(value) {
return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
}
// Rust 结构体字段本身就是 snake_case,枚举变体是 PascalCase;
// serde 的 rename_all 决定线上名字。
function renamedMember(value, kind, rename) {
if (kind === 'enum') {
if (rename === 'snake_case') return snakeCase(value);
if (rename === 'camelCase') return camelCase(snakeCase(value));
return value;
}
return rename === 'camelCase' ? camelCase(value) : value;
}
function rustDefinitions(source) {
const result = new Map();
const pattern =
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+)(?<body>[\s\S]*?)\n\}/g;
let match;
while ((match = pattern.exec(source))) {
const { attrs, kind, name, body } = match.groups;
const rename = /rename_all = "(?<style>\w+)"/.exec(attrs)?.groups?.style;
const members =
kind === 'struct'
? [...body.matchAll(/^\s*pub (\w+):/gm)].map((item) =>
renamedMember(item[1], kind, rename),
)
: [...body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)].map((item) =>
renamedMember(item[1], kind, rename),
);
result.set(name, { kind, members });
}
return result;
}
function tsDefinitions(source) {
const result = new Map();
const objectPattern =
/export type (GameDistribution\w+) = \{([\s\S]*?)\n\};/g;
let match;
while ((match = objectPattern.exec(source))) {
const members = [];
const required = [];
for (const line of match[2].split('\n')) {
const member = /^ {2}(\w+)(\??):/.exec(line);
if (!member) continue;
members.push(member[1]);
if (member[2] !== '?') required.push(member[1]);
}
result.set(match[1], { kind: 'struct', members, required });
}
const unionPattern = /export type (GameDistribution\w+) =\s*([^;]+);/g;
while ((match = unionPattern.exec(source))) {
if (result.has(match[1])) continue;
result.set(match[1], {
kind: 'enum',
members: [...match[2].matchAll(/'([^']+)'/g)].map((item) => item[1]),
});
}
return result;
}
function difference(left, right) {
const rightSet = new Set(right);
return left.filter((value) => !rightSet.has(value));
}
// 跳过 Rust 字符串字面量,返回结束引号的下标。
function skipString(source, start) {
let index = start + 1;
while (index < source.length) {
if (source[index] === '\\') {
index += 2;
continue;
}
if (source[index] === '"') return index;
index += 1;
}
return source.length - 1;
}
// 取顶层函数的函数体文本;找不到函数或括号不闭合时返回 null。
function functionBody(source, name) {
const signature = new RegExp(`\\nfn ${name}\\(`).exec(source);
if (!signature) return null;
const open = source.indexOf('{', signature.index + signature[0].length);
if (open < 0) return null;
let depth = 0;
for (let index = open; index < source.length; index += 1) {
const char = source[index];
if (char === '"') {
index = skipString(source, index);
continue;
}
if (char === "'") {
const charLiteral = /^'(?:\\.|[^'\\])'/.exec(source.slice(index));
if (charLiteral) index += charLiteral[0].length - 1;
continue;
}
if (char === '{') depth += 1;
else if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(open + 1, index);
}
}
return null;
}
// 取第一个 `json!({ ... })` 字面量文本;没有则返回 null。
function firstJsonLiteral(body) {
const marker = 'json!({';
const start = body.indexOf(marker);
if (start < 0) return null;
const open = start + marker.length - 1;
let depth = 0;
for (let index = open; index < body.length; index += 1) {
const char = body[index];
if (char === '"') {
index = skipString(body, index);
continue;
}
if (char === '{' || char === '[' || char === '(') depth += 1;
else if (char === '}' || char === ']' || char === ')') {
depth -= 1;
if (depth === 0) return body.slice(open, index + 1);
}
}
return null;
}
// 取括号对的结束下标;不闭合返回 -1。
function matchingBracket(text, open) {
let depth = 0;
for (let index = open; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
index = skipString(text, index);
continue;
}
if (char === '{' || char === '[' || char === '(') depth += 1;
else if (char === '}' || char === ']' || char === ')') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
// 取对象字面量的直接键:只有上一个有效字符是 `{` 或 `,` 且下一字符是 `:`
// 的字符串才算键,因此嵌套对象的键与作为值的字符串不会混进来。
function objectKeys(text) {
const keys = [];
let inner = 0;
let previous = '';
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
const close = skipString(text, index);
if (
inner === 1 &&
(previous === '{' || previous === ',') &&
text[close + 1] === ':'
) {
keys.push(text.slice(index + 1, close));
previous = ':';
index = close;
continue;
}
previous = '"';
index = close;
continue;
}
if (char === '{' || char === '[' || char === '(') inner += 1;
else if (char === '}' || char === ']' || char === ')') inner -= 1;
if (!/\s/.test(char)) previous = char;
}
return keys;
}
// 取对象字面量里某个直接键对应的嵌套对象字面量文本;该键不是对象字面量时返回 null。
function nestedObjectText(text, wanted) {
let inner = 0;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
const close = skipString(text, index);
if (inner === 1 && text.slice(index + 1, close) === wanted) {
let cursor = close + 1;
while (cursor < text.length && /\s/.test(text[cursor])) cursor += 1;
if (text[cursor] === ':') {
let open = cursor + 1;
while (open < text.length && /\s/.test(text[open])) open += 1;
if (text[open] !== '{') return null;
const end = matchingBracket(text, open);
return end < 0 ? null : text.slice(open, end + 1);
}
}
index = close;
continue;
}
if (char === '{' || char === '[' || char === '(') inner += 1;
else if (char === '}' || char === ']' || char === ')') inner -= 1;
}
return null;
}
// 取函数体里顶层追加的键:`object.insert("key".to_string(), ...)`。
function insertedKeys(body) {
return [...body.matchAll(/object\.insert\(\s*"([^"]+)"/g)].map(
(item) => item[1],
);
}
const rust = rustDefinitions(fs.readFileSync(RUST_FILE, 'utf8'));
const ts = tsDefinitions(fs.readFileSync(TS_FILE, 'utf8'));
const failures = [];
const mappedRust = new Set(PAIRS.map(([rustName]) => rustName));
const mappedTs = new Set(PAIRS.map(([, tsName]) => tsName));
for (const [rustName, tsName] of PAIRS) {
const rustDefinition = rust.get(rustName);
const tsDefinition = ts.get(tsName);
if (!rustDefinition) {
failures.push(`映射表里的 Rust 类型不存在:${rustName}`);
continue;
}
if (!tsDefinition) {
failures.push(`映射表里的 TS 类型不存在:${tsName}`);
continue;
}
if (rustDefinition.kind !== tsDefinition.kind) {
failures.push(
`${rustName} 与 ${tsName} 形状不一致(Rust=${rustDefinition.kind} TS=${tsDefinition.kind})`,
);
}
const missingInTs = difference(rustDefinition.members, tsDefinition.members);
if (missingInTs.length > 0) {
failures.push(
`${tsName} 缺少字段/变体:${missingInTs.join(', ')}(${rustName} 已有)`,
);
}
const extraInTs = difference(tsDefinition.members, rustDefinition.members);
if (extraInTs.length > 0) {
failures.push(
`${tsName} 多出未登记字段/变体:${extraInTs.join(', ')}(要么在 Rust 侧补上,要么先确认真实响应不再发出)`,
);
}
}
for (const name of rust.keys()) {
if (!mappedRust.has(name)) {
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(TS 侧必须同步)`);
}
}
const apiSource = fs.readFileSync(API_MODULE_FILE, 'utf8');
const emittedByBuilder = new Map();
for (const builder of RESPONSE_BUILDERS) {
const tsDefinition = ts.get(builder.ts);
if (!tsDefinition) {
failures.push(`响应构建器 ${builder.fn} 的 TS 类型不存在:${builder.ts}`);
continue;
}
const body = functionBody(apiSource, builder.fn);
if (body === null) {
failures.push(`响应构建器 ${builder.fn} 在 ${API_MODULE_FILE} 里找不到`);
continue;
}
const literal = firstJsonLiteral(body);
const literalKeys = literal ? objectKeys(literal) : [];
const ownKeys = [...new Set([...literalKeys, ...insertedKeys(body)])];
let emitted = ownKeys;
if (builder.base) {
if (literalKeys.length > 0) {
failures.push(
`${builder.fn} 声明了 base=${builder.base},却自己也有 json! 字面量;两者只能取其一`,
);
continue;
}
if (ownKeys.length === 0) {
failures.push(
`${builder.fn} 声明了 base=${builder.base},但没有解析到任何顶层 object.insert("…")`,
);
continue;
}
const baseKeys = emittedByBuilder.get(builder.base);
if (!baseKeys) {
failures.push(
`${builder.fn} 的 base=${builder.base} 必须登记在它前面,才能复用已解析的键`,
);
continue;
}
emitted = [...new Set([...baseKeys, ...ownKeys])];
} else if (literalKeys.length === 0) {
failures.push(`${builder.fn} 没有解析到任何 json!({ … }) 顶层键`);
continue;
}
emittedByBuilder.set(builder.fn, emitted);
const unknown = difference(emitted, tsDefinition.members);
if (unknown.length > 0) {
failures.push(
`${builder.fn} 发出 ${builder.ts} 没有的键:${unknown.join(', ')}(TS 类型必须同步)`,
);
}
const missing = difference(tsDefinition.required ?? [], emitted);
if (missing.length > 0) {
failures.push(
`${builder.fn} 没有发出 ${builder.ts} 的必需字段:${missing.join(', ')}`,
);
}
const mustEmit = difference(builder.mustEmit ?? [], emitted);
if (mustEmit.length > 0) {
failures.push(`${builder.fn} 必须发出的键缺失:${mustEmit.join(', ')}`);
}
for (const nested of builder.nested ?? []) {
const nestedDefinition = ts.get(nested.ts);
if (!nestedDefinition) {
failures.push(
`${builder.fn} 的嵌套对象 ${nested.key} 对应的 TS 类型不存在:${nested.ts}`,
);
continue;
}
const nestedText = literal ? nestedObjectText(literal, nested.key) : null;
if (!nestedText) {
failures.push(
`${builder.fn} 的 ${nested.key} 不是对象字面量,无法与 ${nested.ts} 比对`,
);
continue;
}
const nestedKeys = objectKeys(nestedText);
const nestedUnknown = difference(nestedKeys, nestedDefinition.members);
if (nestedUnknown.length > 0) {
failures.push(
`${builder.fn} 的 ${nested.key} 发出 ${nested.ts} 没有的键:${nestedUnknown.join(', ')}`,
);
}
const nestedMissing = difference(
nestedDefinition.required ?? [],
nestedKeys,
);
if (nestedMissing.length > 0) {
failures.push(
`${builder.fn} 的 ${nested.key} 没有发出 ${nested.ts} 的必需字段:${nestedMissing.join(', ')}`,
);
}
}
}
for (const builder of RESPONSE_BUILDERS) {
if (!emittedByBuilder.has(builder.fn)) {
failures.push(
`响应构建器 ${builder.fn} 的键没有解析成功,无法证明与 TS 一致`,
);
}
}
for (const name of ts.keys()) {
if (!mappedTs.has(name) && !TS_ONLY_TYPES.includes(name)) {
failures.push(
`TS 新增类型 ${name} 未分类(映射表或 TS_ONLY_TYPES 二者必居其一)`,
);
}
}
if (failures.length > 0) {
console.error('[check:game-distribution-dto-parity] 不一致:');
for (const failure of failures) console.error(` - ${failure}`);
process.exit(1);
}
console.log(
`[check:game-distribution-dto-parity] OK:${PAIRS.length} 组 Rust/TS 类型一致,` +
`${RESPONSE_BUILDERS.length} 个手拼响应构建器键一致,` +
`${TS_ONLY_TYPES.length} 个手拼响应类型已登记`,
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,469 @@
// 游戏分发「作者归属与越权隔离」真实链路检查(需要本地 dev 栈:SpacetimeDB standalone + api-server)。
//
// 用法:
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-owner-isolation
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:4198)。
//
// 覆盖:两个真实账号注册 → 作者建游戏与版本 → 另一个账号读私有版本、读上传状态、写分片、
// 确认分包、送审、撤回全部按「不可见」处理 → 未认证读私有版本 401 → 越权写没有副作用
// (作者侧已收字节仍为 0)→ 请求体里伪造 owner 不生效(游戏仍归请求者、不出现在被冒名账号
// 的 my-games)→ 作者撤回自己的版本成功。整条链路只在本机 dev 数据库落行;唯一的存储写入
// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(dev bucket,走现役直传 + 确认链路),
// 发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
'game-distribution:publish 写入口;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' +
'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
);
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(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,
meta: json?.meta,
ok: json?.ok,
};
}
function gameMetadata(title) {
return {
title,
summary: '越权隔离用例的临时游戏',
description: '',
category: '休闲',
tags: ['e2e'],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
async function uploadCover(token, id) {
const fileName = `owner-isolation-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'owner-isolation', String(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-owner-isolation',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
async function register(prefix) {
const response = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `${prefix}${String(Date.now()).slice(-8)}`,
password: DEV_PASSWORD,
},
});
return { response, token: response.data?.token };
}
async function main() {
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
const author = await register('137');
const intruder = await register('138');
check(
'作者注册拿到 token',
author.response.status === 200 && Boolean(author.token),
`status=${author.response.status}`,
);
check(
'另一个账号注册拿到 token',
intruder.response.status === 200 && Boolean(intruder.token),
`status=${intruder.response.status}`,
);
if (!author.token || !intruder.token) process.exit(1);
const setGate = (enabled, rolloutPercent) =>
api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 作者归属隔离',
},
});
const gateOpen = await setGate(true, 100);
check(
'发布灰度可开启并放量',
gateOpen.status === 200,
`status=${gateOpen.status}`,
);
const stamp = Date.now();
const title = `越权隔离 ${String(stamp).slice(-6)}`;
const coverAssetId = await uploadCover(author.token, stamp);
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `iso-game-${stamp}` },
body: { ...gameMetadata(title), coverAssetId },
});
const gameId = created.data?.id;
check(
'作者创建游戏成功',
created.status === 200 && Boolean(gameId),
`status=${created.status} msg=${created.error?.message ?? ''}`,
);
if (!gameId) process.exit(1);
const versionResponse = await api(
`/api/game-distribution/games/${gameId}/versions`,
{
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `iso-version-${stamp}` },
body: {
packageSha256: 'a'.repeat(64),
packageBytes: 2048,
packageFileCount: 2,
packageEntryPath: 'index.html',
gameMetadata: { ...gameMetadata(title), coverAssetId },
},
},
);
const versionId = versionResponse.data?.versionId;
const publicationRevision = versionResponse.data?.publicationRevision ?? 0;
check(
'作者创建版本成功(awaiting_upload)',
versionResponse.status === 200 &&
Boolean(versionId) &&
versionResponse.data?.status === 'awaiting_upload',
`status=${versionResponse.status} versionStatus=${versionResponse.data?.status}`,
);
if (!versionId) process.exit(1);
const ownRead = await api(`/api/game-distribution/versions/${versionId}`, {
token: author.token,
});
check(
'作者读到自己的私有版本',
ownRead.status === 200 && ownRead.data?.version?.versionId === versionId,
`status=${ownRead.status}`,
);
const anonymousRead = await api(
`/api/game-distribution/versions/${versionId}`,
);
check(
'未认证读私有版本 401',
anonymousRead.status === 401,
`status=${anonymousRead.status}`,
);
const foreignRead = await api(
`/api/game-distribution/versions/${versionId}`,
{ token: intruder.token },
);
check(
'他人读私有版本按不存在处理(404)',
foreignRead.status === 404,
`status=${foreignRead.status} code=${foreignRead.error?.code ?? ''}`,
);
// 成功 / 失败 envelope 的形状:TS 侧 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts`
// 的运行时守卫读的就是这几个字段(data / error.code / meta.apiVersion / meta.requestId)。
check(
'成功响应 envelope 带 ok/data 与 meta.apiVersion / meta.requestId',
created.status === 200 &&
created.data !== undefined &&
created.ok === true &&
created.error === null &&
typeof created.meta?.apiVersion === 'string' &&
created.meta.apiVersion.length > 0 &&
typeof created.meta?.requestId === 'string' &&
created.meta.requestId.length > 0,
`apiVersion=${created.meta?.apiVersion ?? ''} requestId=${created.meta?.requestId ?? ''}`,
);
check(
'失败响应 envelope 带 ok=false/error.code 与 meta.requestId',
foreignRead.status === 404 &&
foreignRead.ok === false &&
typeof foreignRead.error?.code === 'string' &&
foreignRead.error.code.length > 0 &&
foreignRead.data === null &&
typeof foreignRead.meta?.requestId === 'string' &&
foreignRead.meta.requestId.length > 0,
`code=${foreignRead.error?.code ?? ''} requestId=${foreignRead.meta?.requestId ?? ''}`,
);
const foreignState = await api(
`/api/game-distribution/versions/${versionId}/package/upload-state`,
{ token: intruder.token },
);
check(
'他人读上传状态 404',
foreignState.status === 404,
`status=${foreignState.status}`,
);
const foreignChunk = await api(
`/api/game-distribution/versions/${versionId}/package/chunk`,
{
method: 'PUT',
token: intruder.token,
headers: {
'Content-Type': 'application/octet-stream',
'Idempotency-Key': `iso-chunk-${stamp}`,
'x-genarrative-upload-offset': '0',
},
binary: Buffer.alloc(16, 7),
},
);
check(
'他人写发行包分片 404',
foreignChunk.status === 404,
`status=${foreignChunk.status}`,
);
const foreignComplete = await api(
`/api/game-distribution/versions/${versionId}/package/complete`,
{
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-complete-${stamp}` },
},
);
check(
'他人确认分包 404',
foreignComplete.status === 404,
`status=${foreignComplete.status}`,
);
const foreignSubmit = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-submit-${stamp}` },
body: { expectedPublicationRevision: publicationRevision },
},
);
check(
'他人送审 404',
foreignSubmit.status === 404,
`status=${foreignSubmit.status} code=${foreignSubmit.error?.code ?? ''} msg=${foreignSubmit.error?.message ?? ''}`,
);
const nonexistentSubmit = await api(
`/api/game-distribution/versions/gamever_missing_${stamp}/submit`,
{
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-missing-submit-${stamp}` },
body: { expectedPublicationRevision: 0 },
},
);
check(
'他人送审不存在的版本 404(对照)',
nonexistentSubmit.status === 404,
`status=${nonexistentSubmit.status} code=${nonexistentSubmit.error?.code ?? ''}`,
);
const foreignCancel = await api(
`/api/game-distribution/versions/${versionId}/cancel`,
{
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-cancel-${stamp}` },
body: { expectedPublicationRevision: publicationRevision },
},
);
check(
'他人撤回 404',
foreignCancel.status === 404,
`status=${foreignCancel.status}`,
);
const authorState = await api(
`/api/game-distribution/versions/${versionId}/package/upload-state`,
{ token: author.token },
);
check(
'越权写没有落副作用(作者侧已收字节仍为 0)',
authorState.status === 200 && authorState.data?.receivedBytes === 0,
`status=${authorState.status} receivedBytes=${authorState.data?.receivedBytes}`,
);
const intruderCoverAssetId = await uploadCover(
intruder.token,
`${stamp}-intruder`,
);
const forged = await api('/api/game-distribution/games', {
method: 'POST',
token: intruder.token,
headers: { 'Idempotency-Key': `iso-forged-${stamp}` },
body: {
...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`),
coverAssetId: intruderCoverAssetId,
ownerUserId: 'user_forged',
owner_user_id: 'user_forged',
},
});
const forgedGameId = forged.data?.id;
check(
'请求体伪造 owner 不影响创建成功',
forged.status === 200 && Boolean(forgedGameId),
`status=${forged.status} msg=${forged.error?.message ?? ''}`,
);
const intruderGames = await api('/api/game-distribution/my-games', {
token: intruder.token,
});
const forgedStaysWithRequester =
Array.isArray(intruderGames.data?.games) &&
intruderGames.data.games.some((game) => game.id === forgedGameId);
check(
'伪造 owner 的游戏仍归请求者(在请求者 my-games 里)',
forgedStaysWithRequester,
`games=${intruderGames.data?.games?.length ?? 'n/a'}`,
);
const authorGames = await api('/api/game-distribution/my-games', {
token: author.token,
});
const forgedLeakedToAuthor =
Array.isArray(authorGames.data?.games) &&
authorGames.data.games.some((game) => game.id === forgedGameId);
check(
'伪造 owner 的游戏不出现在被冒名账号的 my-games',
!forgedLeakedToAuthor,
`authorGames=${authorGames.data?.games?.length ?? 'n/a'}`,
);
const ownCancel = await api(
`/api/game-distribution/versions/${versionId}/cancel`,
{
method: 'POST',
token: author.token,
headers: { 'Idempotency-Key': `iso-own-cancel-${stamp}` },
body: { expectedPublicationRevision: publicationRevision },
},
);
check(
'作者撤回自己的版本成功(cancelled)',
ownCancel.status === 200 && ownCancel.data?.version?.status === 'cancelled',
`status=${ownCancel.status} versionStatus=${ownCancel.data?.version?.status}`,
);
const gateClosed = await setGate(false, 0);
check(
'发布灰度恢复关闭',
gateClosed.status === 200,
`status=${gateClosed.status}`,
);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(`[check:game-distribution-owner-isolation] 运行失败:${error}`);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,455 @@
// 游戏分发「分片上传中断 / api-server 进程退出后按权威偏移续传」编排检查。
//
// 需要本地 dev 栈(SpacetimeDB standalone + api-server),两个阶段之间由调用方重启 api-server:
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> E2E_STAGE=prepare \
// npm run check:game-distribution-upload-resume
// <重启 api-server>
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> E2E_STAGE=resume \
// npm run check:game-distribution-upload-resume
//
// E2E_STATE_FILE 可覆盖状态文件路径(默认在系统临时目录),E2E_API_BASE 默认 http://127.0.0.1:4198。
//
// prepare:注册作者 → 建游戏与版本(声明整包摘要/字节数)→ 造一个大于 8 MiB 的真实 ZIP →
// 按分片协议只上传第一片(8 MiB)→ 断言权威已收字节就是第一片长度。
// resume :重新登录同一作者 → 断言灰度配置在进程重启后仍然生效 → 读权威已收字节(来自 OSS,
// 不是进程内状态)→ 从该偏移续传剩余字节 → 确认整包 → 送审。
// 这条链路只在本机 dev 数据库与该 dev bucket 的对象键下落行。
import { createHash, randomBytes } from 'node:crypto';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const STAGE = (process.env.E2E_STAGE ?? 'prepare').trim();
const DEV_PASSWORD = 'GenE2e123!';
const CHUNK_BYTES = 8 * 1024 * 1024;
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
'game-distribution:publish 写入口;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' +
'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
);
process.exit(2);
}
if (STAGE !== 'prepare' && STAGE !== 'resume') {
console.error(`E2E_STAGE 只支持 prepare / resume,收到:${STAGE}`);
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, 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}${pathname}`, {
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,
};
}
async function stateFilePaths() {
const configured = (process.env.E2E_STATE_FILE ?? '').trim();
if (configured) {
return { state: configured, zip: `${configured}.zip` };
}
const dir = await mkdtemp(path.join(tmpdir(), 'genarrative-gd-resume-'));
const state = path.join(dir, 'state.json');
return { state, zip: `${state}.zip` };
}
async function adminToken() {
const login = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const token = login.data?.token ?? login.data?.accessToken;
check(
'管理员登录成功',
login.status === 200 && Boolean(token),
`status=${login.status}`,
);
return token;
}
const setGate = (token, enabled, rolloutPercent) =>
api('/admin/api/feature-gates', {
method: 'PUT',
token,
body: {
gateKey: 'game-distribution:publish',
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 分片续传',
},
});
function gameMetadata(title) {
return {
title,
summary: '分片续传用例的临时游戏',
description: '',
category: '休闲',
tags: ['e2e'],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
async function uploadCover(token, id) {
const fileName = `upload-resume-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'upload-resume', String(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-upload-resume',
},
});
if (confirm.status !== 200) {
throw new Error(
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
);
}
return confirm.data.assetObject.assetObjectId;
}
// 真实 ZIP(> 8 MiB,随机内容不可压缩),根目录含 index.html,条目数与声明的 packageFileCount 一致。
async function buildLargeFixtureZip() {
const archive = new JSZip();
archive.file('index.html', '<!doctype html><title>resume</title>');
archive.file('assets/app.js', "console.log('resume');");
archive.file('assets/blob.bin', randomBytes(9 * 1024 * 1024));
const bytes = await archive.generateAsync({ type: 'uint8array' });
return { bytes: Buffer.from(bytes), fileCount: 3 };
}
async function uploadChunk(token, versionId, bytes, offset, key) {
return api(`/api/game-distribution/versions/${versionId}/package/chunk`, {
method: 'PUT',
token,
headers: {
'Content-Type': 'application/octet-stream',
'Idempotency-Key': key,
'x-genarrative-upload-offset': String(offset),
},
binary: bytes,
});
}
async function runPrepare(paths) {
const admin = await adminToken();
if (!admin) process.exit(1);
const gateOpen = await setGate(admin, true, 100);
check(
'发布灰度可开启并放量',
gateOpen.status === 200,
`status=${gateOpen.status}`,
);
const stamp = Date.now();
const phone = `136${String(stamp).slice(-8)}`;
const register = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
const author = register.data?.token;
check(
'作者注册拿到 token',
register.status === 200 && Boolean(author),
`status=${register.status}`,
);
if (!author) process.exit(1);
const built = await buildLargeFixtureZip();
const zipBytes = built.bytes;
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex');
check(
'fixture 发行包大于单个分片',
zipBytes.length > CHUNK_BYTES,
`packageBytes=${zipBytes.length}`,
);
await writeFile(paths.zip, zipBytes);
const coverAssetId = await uploadCover(author, stamp);
const title = `分片续传 ${String(stamp).slice(-6)}`;
const metadata = { ...gameMetadata(title), coverAssetId };
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `resume-game-${stamp}` },
body: metadata,
});
const gameId = created.data?.id;
check(
'创建游戏成功',
created.status === 200 && Boolean(gameId),
`status=${created.status} msg=${created.error?.message ?? ''}`,
);
if (!gameId) process.exit(1);
const versionResponse = await api(
`/api/game-distribution/games/${gameId}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `resume-version-${stamp}` },
body: {
packageSha256: zipSha256,
packageBytes: zipBytes.length,
packageFileCount: built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
},
);
const versionId = versionResponse.data?.versionId;
check(
'创建版本成功(awaiting_upload)',
versionResponse.status === 200 &&
Boolean(versionId) &&
versionResponse.data?.status === 'awaiting_upload',
`status=${versionResponse.status} versionStatus=${versionResponse.data?.status}`,
);
if (!versionId) process.exit(1);
const firstChunk = zipBytes.subarray(0, CHUNK_BYTES);
const uploaded = await uploadChunk(
author,
versionId,
firstChunk,
0,
`resume-chunk-1-${stamp}`,
);
check(
'第一片上传成功',
uploaded.status === 200 && uploaded.data?.receivedBytes === CHUNK_BYTES,
`status=${uploaded.status} receivedBytes=${uploaded.data?.receivedBytes ?? 'n/a'}`,
);
const state = await api(
`/api/game-distribution/versions/${versionId}/package/upload-state`,
{ token: author },
);
check(
'权威已收字节等于第一片长度',
state.status === 200 && state.data?.receivedBytes === CHUNK_BYTES,
`status=${state.status} receivedBytes=${state.data?.receivedBytes ?? 'n/a'}`,
);
await writeFile(
paths.state,
JSON.stringify(
{
apiBase: API,
stage: 'prepared',
phone,
password: DEV_PASSWORD,
gameId,
versionId,
packageSha256: zipSha256,
packageBytes: zipBytes.length,
packageFileCount: built.fileCount,
firstChunkBytes: CHUNK_BYTES,
zipPath: paths.zip,
},
null,
2,
),
);
console.log(
`\nPREPARED gameId=${gameId} versionId=${versionId} packageBytes=${zipBytes.length} receivedBytes=${CHUNK_BYTES}`,
);
console.log(
'下一步:重启 api-server,再用 E2E_STAGE=resume 运行本脚本(同一 E2E_STATE_FILE)。',
);
process.exit(failures === 0 ? 0 : 1);
}
async function runResume(paths) {
const raw = await readFile(paths.state, 'utf8');
const saved = JSON.parse(raw);
const zipBytes = await readFile(saved.zipPath);
check(
'状态文件与 fixture 齐备',
saved.stage === 'prepared' &&
saved.versionId &&
zipBytes.length === saved.packageBytes,
`versionId=${saved.versionId ?? ''} packageBytes=${zipBytes.length}/${saved.packageBytes}`,
);
const admin = await adminToken();
if (!admin) process.exit(1);
const login = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: saved.phone, password: saved.password },
});
const author = login.data?.token;
check(
'重启后同一作者仍能登录',
login.status === 200 && Boolean(author),
`status=${login.status}`,
);
if (!author) process.exit(1);
const frontendConfig = await api('/api/runtime/frontend-config', {
token: author,
});
check(
'灰度配置在 api-server 重启后仍然生效',
frontendConfig.status === 200 &&
frontendConfig.data?.gameDistributionPublishEnabled === true,
`status=${frontendConfig.status} enabled=${frontendConfig.data?.gameDistributionPublishEnabled}`,
);
const state = await api(
`/api/game-distribution/versions/${saved.versionId}/package/upload-state`,
{ token: author },
);
check(
'重启后权威已收字节仍等于第一片长度(来自对象存储)',
state.status === 200 && state.data?.receivedBytes === saved.firstChunkBytes,
`status=${state.status} receivedBytes=${state.data?.receivedBytes ?? 'n/a'}`,
);
const remaining = zipBytes.subarray(saved.firstChunkBytes);
const resumed = await uploadChunk(
author,
saved.versionId,
remaining,
saved.firstChunkBytes,
`resume-chunk-2-${saved.versionId}`,
);
check(
'按权威偏移续传剩余字节成功',
resumed.status === 200 &&
resumed.data?.receivedBytes === saved.packageBytes,
`status=${resumed.status} receivedBytes=${resumed.data?.receivedBytes ?? 'n/a'} expected=${saved.packageBytes}`,
);
const completed = await api(
`/api/game-distribution/versions/${saved.versionId}/package/complete`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `resume-complete-${saved.versionId}` },
},
);
check(
'整包确认成功(回到可送审状态)',
completed.status === 200 && completed.data?.status === 'uploaded',
`status=${completed.status} versionStatus=${completed.data?.status ?? ''} msg=${completed.error?.message ?? ''}`,
);
const submitted = await api(
`/api/game-distribution/versions/${saved.versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `resume-submit-${saved.versionId}` },
body: {
expectedPublicationRevision: completed.data?.publicationRevision ?? 0,
},
},
);
check(
'送审成功(202 + pending_review)',
submitted.status === 202 &&
submitted.data?.version?.status === 'pending_review',
`status=${submitted.status} versionStatus=${submitted.data?.version?.status ?? ''}`,
);
const gateClosed = await setGate(admin, false, 0);
check(
'发布灰度恢复关闭',
gateClosed.status === 200,
`status=${gateClosed.status}`,
);
await rm(saved.zipPath, { force: true });
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
const paths = await stateFilePaths();
if (STAGE === 'prepare') {
await runPrepare(paths);
} else {
await runResume(paths);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,438 @@
// 游戏分发网页侧「导航 / 键盘可达 / 全屏 / 安全区 / 移动发布面板」检查。
//
// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + playwright。
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-a11y-e2e
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD。');
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, 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}${pathname}`, {
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, text, json, data: json?.data };
}
async function loadPlaywright() {
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
if (!dir) return import('playwright');
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
return requireFromDir('playwright');
}
async function uploadCover(token, id) {
const fileName = `a11y-${id}.png`;
const ticket = await api('/api/assets/direct-upload-tickets', {
method: 'POST',
token,
body: {
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'a11y', String(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}`);
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-a11y',
},
});
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
return confirm.data.assetObject.assetObjectId;
}
async function loginThroughWebUi(page, phone) {
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
// 移动端每次进入都可能重新弹「创作用电脑端」提示,它会盖住账号入口。
await dismissMobileNotice(page);
await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
await page.locator('.platform-account-entry').first().click();
await page.getByRole('tab', { name: '密码登录' }).first().click();
await page.waitForTimeout(600);
await page
.getByLabel('同意法律协议')
.first()
.check({ force: true })
.catch(() => {});
await page
.locator('input[placeholder="13800000000"]:visible')
.first()
.fill(phone);
await page
.locator('input[placeholder="输入密码"]:visible')
.first()
.fill(DEV_PASSWORD);
await page.getByRole('button', { name: '登录', exact: true }).first().click();
await page
.locator('.platform-account-entry')
.first()
.filter({ hasText: phone.slice(-4) })
.waitFor({ state: 'visible', timeout: 30_000 });
}
/**
* 移动端首次进入(以及每次重新进入)会弹「移动端仅支持展示体验、创作用电脑端」的阻断提示,
* 它会盖住底部导航,交互前先关掉。
*/
async function dismissMobileNotice(page) {
const notice = page.locator('.platform-overlay');
// 提示可能在首帧之后才挂上,先等一小会儿再判断,避免"看起来没有"却被它盖住。
await notice
.first()
.waitFor({ state: 'visible', timeout: 10_000 })
.catch(() => {});
if ((await notice.count()) === 0) return;
await notice
.getByRole('button')
.first()
.click({ timeout: 5_000 })
.catch(() => {});
await page.waitForTimeout(600);
}
async function publishFixtureGame(adminToken, stamp, title) {
await api('/admin/api/feature-gates', {
method: 'PUT',
token: adminToken,
body: {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 可达性',
},
});
const register = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `130${String(stamp).slice(-8)}`,
password: DEV_PASSWORD,
},
});
const author = register.data?.token;
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>a11y</title>' +
'<script src="assets/app.js"></script></head><body><h1>A11Y-OK</h1></body></html>',
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="a11y";');
const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
const sha256 = (await import('node:crypto'))
.createHash('sha256')
.update(bytes)
.digest('hex');
const coverAssetId = await uploadCover(author, stamp);
const metadata = {
title,
summary: '可达性 E2E 临时游戏',
description: '',
category: '休闲',
tags: ['e2e'],
coverAssetId,
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `a11y-game-${stamp}` },
body: metadata,
});
const gameId = created.data?.id;
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `a11y-version-${stamp}` },
body: {
packageSha256: sha256,
packageBytes: bytes.length,
packageFileCount: 2,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
});
const versionId = version.data?.versionId;
const uploaded = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `a11y-upload-${stamp}`,
'Content-Type': 'application/zip',
},
binary: bytes,
},
);
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `a11y-submit-${stamp}` },
body: { expectedPublicationRevision: 0 },
},
);
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: adminToken,
headers: { 'Idempotency-Key': `a11y-approve-${stamp}` },
body: { decision: 'approve', expectedPublicationRevision: 0 },
},
);
if (
created.status !== 200 ||
uploaded.status !== 200 ||
submitted.status !== 202 ||
approved.status !== 200
) {
throw new Error(
`发布夹具失败 created=${created.status} uploaded=${uploaded.status} submitted=${submitted.status} approved=${approved.status}`,
);
}
return { gameId, author, metadata };
}
async function main() {
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
const stamp = Date.now();
const title = `可达性 ${String(stamp).slice(-6)}`;
const { gameId } = await publishFixtureGame(admin, stamp, title);
check('测试游戏已发布', Boolean(gameId), `gameId=${gameId ?? ''}`);
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
// 桌面左侧导航:点「游戏」「我的」都能切换页面。
await page.goto(`${WEB}/games`, {
waitUntil: 'commit',
timeout: 120_000,
});
await page
.locator('button.platform-desktop-rail__button', { hasText: '游戏' })
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
await page
.locator('button.platform-desktop-rail__button', { hasText: '我的' })
.first()
.click();
await page.waitForURL(/\/profile/, { timeout: 30_000 });
check('桌面导航切到我的', true, `url=${page.url()}`);
await page
.locator('button.platform-desktop-rail__button', { hasText: '游戏' })
.first()
.click();
await page.waitForURL(/\/games$/, { timeout: 30_000 });
check('桌面导航切回游戏广场', true, `url=${page.url()}`);
// 键盘可达:只用 Tab + Enter 从目录进入详情、进游玩页并开始游戏。
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
const card = page.locator('button.game-card:visible', { hasText: title });
await card.waitFor({ state: 'visible', timeout: 60_000 });
await card.focus();
await page.keyboard.press('Enter');
await page.waitForURL(/\/games\/detail\?id=/, { timeout: 30_000 });
check('键盘在目录里能用 Enter 打开焦点卡片', true, `url=${page.url()}`);
const playButton = page.getByRole('button', { name: '立即玩' });
await playButton.waitFor({ state: 'visible', timeout: 30_000 });
await playButton.focus();
await page.keyboard.press('Enter');
await page.waitForURL(/\/games\/play\?id=/, { timeout: 30_000 });
const startButton = page.getByRole('button', { name: '开始游戏' });
await startButton.waitFor({ state: 'visible', timeout: 30_000 });
await startButton.focus();
await page.keyboard.press('Enter');
await page
.locator('iframe.game-player-frame')
.waitFor({ state: 'visible', timeout: 30_000 });
check('键盘可以从详情一路进到开始游戏', true);
// 全屏:开始前禁用,开始后可用(headless 下不强制断言真的进入全屏)。
const fullscreenButton = page.getByRole('button', { name: '全屏' });
check(
'开始游戏后全屏按钮可用',
!(await fullscreenButton.isDisabled()),
'hasStarted=true',
);
await fullscreenButton.click().catch(() => {});
const enteredFullscreen = await page.evaluate(
() => document.fullscreenElement !== null,
);
console.log(
`INFO 全屏尝试结果:fullscreenElement=${enteredFullscreen}(headless 环境可能不授予)`,
);
// 安全区:移动壳层与底部导航都用 env(safe-area-inset-*),静态核对样式来源。
const indexCss = await readFile('src/index.css', 'utf8');
check(
'移动壳层与底部导航使用安全区内边距',
indexCss.includes('--platform-bottom-nav-height') &&
indexCss.includes('env(safe-area-inset-bottom'),
'src/index.css',
);
// 移动端导航 + 发布面板:底部 dock 能切页,发布表单在窄屏可用且不横向溢出。
const mobile = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
const mobilePage = await mobile.newPage();
await mobilePage.goto(`${WEB}/games`, {
waitUntil: 'commit',
timeout: 120_000,
});
await dismissMobileNotice(mobilePage);
const mobileNav = mobilePage.getByRole('navigation', {
name: '移动平台导航',
});
await mobileNav.waitFor({ state: 'visible', timeout: 60_000 });
await mobileNav.getByRole('button', { name: '我的' }).click();
await mobilePage.waitForURL(/\/profile/, { timeout: 30_000 });
check('移动端底部导航切到我的', true, `url=${mobilePage.url()}`);
await dismissMobileNotice(mobilePage);
await mobileNav.getByRole('button', { name: '游戏' }).click();
await mobilePage.waitForURL(/\/games$/, { timeout: 30_000 });
check('移动端底部导航切回游戏', true, `url=${mobilePage.url()}`);
const mobilePhone = `136${String(stamp).slice(-8)}`;
await dismissMobileNotice(mobilePage);
await loginThroughWebUi(mobilePage, mobilePhone);
await mobilePage.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
await dismissMobileNotice(mobilePage);
await mobilePage
.getByLabel('游戏名称')
.waitFor({ state: 'visible', timeout: 60_000 });
const overflow = await mobilePage.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
check(
'移动端发布面板可用且没有整页横向溢出',
overflow <= 1,
`overflow=${overflow}px`,
);
await mobile.close();
await context.close();
} finally {
await browser.close();
await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled: false,
rolloutPercent: 0,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 可达性(收尾关闭)',
},
});
}
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(`[check-game-distribution-web-a11y-e2e] 运行失败:${error}`);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,349 @@
// 游戏分发网页发布链路检查:真实 dev 网页栈 + Chromium,作者在网页里登录、选封面、选发行包
// ZIP、填资料并提交,随后核对「同一账号在网页里看到待审状态」「审核通过后出现在广场」。
//
// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + playwright。
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-publish-e2e
// E2E_WEB_BASE 默认 http://127.0.0.1:3000,E2E_API_BASE 默认 http://127.0.0.1:8082。
import { createHash } from 'node:crypto';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要通过网页真实发布并审核。',
);
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {} } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
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, text, json, data: json?.data };
}
async function loadPlaywright() {
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
if (!dir) return import('playwright');
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
return requireFromDir('playwright');
}
async function loginThroughWebUi(page, phone) {
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
await page.locator('.platform-account-entry').first().click();
await page.getByRole('tab', { name: '密码登录' }).first().click();
await page.waitForTimeout(600);
await page
.getByLabel('同意法律协议')
.first()
.check({ force: true })
.catch(() => {});
await page
.locator('input[placeholder="13800000000"]:visible')
.first()
.fill(phone);
await page
.locator('input[placeholder="输入密码"]:visible')
.first()
.fill(DEV_PASSWORD);
await page.getByRole('button', { name: '登录', exact: true }).first().click();
await page
.locator('.platform-account-entry')
.first()
.filter({ hasText: phone.slice(-4) })
.waitFor({ state: 'visible', timeout: 30_000 });
}
async function main() {
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
const gate = await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 网页发布链路',
},
});
check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
const stamp = Date.now();
const phone = `131${String(stamp).slice(-8)}`;
const title = `网页发布 ${String(stamp).slice(-6)}`;
const workDir = await mkdtemp(
path.join(tmpdir(), 'genarrative-web-publish-'),
);
const coverPath = path.join(workDir, 'cover.png');
const zipPath = path.join(workDir, 'package.zip');
await writeFile(coverPath, COVER_PNG);
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>web publish</title>' +
'<script src="assets/app.js"></script></head><body><h1>WEB-PUBLISH-OK</h1></body></html>',
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="publish";');
const zipBytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
await writeFile(zipPath, zipBytes);
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex');
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
let gameId = '';
let versionId = '';
try {
const page = await browser.newPage({
viewport: { width: 1280, height: 900 },
});
await loginThroughWebUi(page, phone);
check('网页里用密码登录/注册成功', true, `phone=${phone}`);
await page.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
await page
.getByLabel('游戏名称')
.waitFor({ state: 'visible', timeout: 60_000 });
await page.getByLabel('游戏名称').fill(title);
await page.getByLabel('一句话简介').fill('网页发布链路 E2E');
await page
.getByLabel('详细介绍')
.fill('由 Playwright 在真实网页里提交的发行包。');
await page.getByLabel('分类').selectOption('休闲');
await page.getByLabel('标签').fill('e2e');
await page.getByLabel('游戏封面(必需)').setInputFiles(coverPath);
await page
.locator('img[alt="游戏封面预览"]')
.waitFor({ state: 'visible', timeout: 60_000 });
await page.getByLabel('发行包 ZIP').setInputFiles(zipPath);
await page.waitForTimeout(500);
await page.getByRole('button', { name: '提交审核' }).first().click();
await page
.getByText('已提交审核,审核通过后会自动出现在游戏广场。')
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
check('网页提交发行包 ZIP 后进入待审', true);
const authorLogin = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
const author = authorLogin.data?.token;
const mine = await api('/api/game-distribution/my-games', {
token: author,
});
const mineGame = (mine.data?.games ?? []).find(
(item) => item.title === title,
);
gameId = mineGame?.id ?? '';
versionId = mineGame?.versions?.[0]?.versionId ?? '';
check(
'同一账号在服务端看到该游戏为审核中',
mineGame?.latestVersion?.status === 'pending_review' &&
mineGame?.status === 'unpublished',
`gameId=${gameId} versionStatus=${mineGame?.latestVersion?.status ?? ''} visibility=${mineGame?.status ?? ''}`,
);
const catalogBefore = await api('/api/game-distribution/games?limit=100');
check(
'待审期间广场看不到该游戏',
!(catalogBefore.data?.games ?? []).some((item) => item.id === gameId),
);
await page.goto(`${WEB}/games/mine`, {
waitUntil: 'commit',
timeout: 120_000,
});
await page
.getByText(title)
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
check(
'网页「我的游戏」显示审核中',
await page.getByText('审核中').first().isVisible(),
);
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `web-publish-approve-${stamp}` },
body: { decision: 'approve', expectedPublicationRevision: 0 },
},
);
check(
'管理员审核通过网页提交的版本',
approved.status === 200,
`status=${approved.status}`,
);
const catalogAfter = await api('/api/game-distribution/games?limit=100');
const publishedGame = (catalogAfter.data?.games ?? []).find(
(item) => item.id === gameId,
);
check(
'审核通过后广场出现该游戏且入口由服务端派生',
Boolean(publishedGame) &&
publishedGame.currentVersion?.entryUrl === `/games/${gameId}/`,
`entryUrl=${publishedGame?.currentVersion?.entryUrl ?? ''}`,
);
await page.goto(`${WEB}/games/mine`, {
waitUntil: 'commit',
timeout: 120_000,
});
await page
.getByText(title)
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
check(
'网页「我的游戏」同步显示为已发布',
await page.getByText('已发布').first().isVisible(),
);
const mineAfter = await api('/api/game-distribution/my-games', {
token: author,
});
const sameGame = (mineAfter.data?.games ?? []).find(
(item) => item.id === gameId,
);
check(
'同一 gameId 的账号状态在两端读同一份后端数据',
Boolean(sameGame) &&
sameGame.status === 'published' &&
sameGame.latestVersion?.packageSha256 === zipSha256,
`status=${sameGame?.status ?? ''} sha=${String(sameGame?.latestVersion?.packageSha256).slice(0, 12)} expected=${zipSha256.slice(0, 12)}`,
);
// 更新沿用同一 gameId:网页发布页带 ?game= 回填旧版,提交新版本后仍是同一个游戏。
const updateZip = new JSZip();
updateZip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>web publish v2</title>' +
'<script src="assets/app.js"></script></head><body><h1>WEB-PUBLISH-V2</h1></body></html>',
);
updateZip.file(
'assets/app.js',
'document.documentElement.dataset.e2e="publish-v2";',
);
const updateBytes = Buffer.from(
await updateZip.generateAsync({ type: 'uint8array' }),
);
const updateZipPath = path.join(workDir, 'package-v2.zip');
await writeFile(updateZipPath, updateBytes);
await page.goto(`${WEB}/games/publish?game=${gameId}`, {
waitUntil: 'commit',
timeout: 120_000,
});
await page
.getByText('提交新版本审核')
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
await page.getByLabel('发行包 ZIP').setInputFiles(updateZipPath);
await page.waitForTimeout(500);
await page.getByRole('button', { name: '提交新版本审核' }).first().click();
await page
.getByText('已提交审核,审核通过后会自动出现在游戏广场。')
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
const mineAfterUpdate = await api('/api/game-distribution/my-games', {
token: author,
});
const updatedGame = (mineAfterUpdate.data?.games ?? []).find(
(item) => item.id === gameId,
);
check(
'网页提交新版本沿用同一 gameId 且旧版仍在线',
Boolean(updatedGame) &&
updatedGame.versions?.length === 2 &&
updatedGame.latestVersion?.status === 'pending_review' &&
updatedGame.status === 'published',
`versions=${updatedGame?.versions?.length ?? 'n/a'} latest=${updatedGame?.latestVersion?.status ?? ''} status=${updatedGame?.status ?? ''}`,
);
await page.close();
} finally {
await browser.close();
await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled: false,
rolloutPercent: 0,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 网页发布链路(收尾关闭)',
},
});
}
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(`[check-game-distribution-web-publish-e2e] 运行失败:${error}`);
process.exit(1);
});
@@ -0,0 +1,362 @@
// 游戏分发网页发布「操作恢复」检查:双击提交只落一份、发行包上传中断后草稿仍在、
// 关窗重开后能继续同一版本、换账号不会看到前一个账号的私有草稿。
//
// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + playwright。
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-publish-recovery-e2e
import { mkdtemp, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
const DRAFT_KEY = 'genarrative.game-distribution.publish-draft.v1';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要打开发布灰度并核对发布草稿。',
);
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body } = options;
const headers = { ...ENVELOPE };
if (token) headers.Authorization = `Bearer ${token}`;
let finalBody;
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
method,
headers,
body: finalBody,
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { status: response.status, text, json, data: json?.data };
}
async function loadPlaywright() {
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
if (!dir) return import('playwright');
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
return requireFromDir('playwright');
}
async function loginThroughWebUi(page, phone) {
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
await page.locator('.platform-account-entry').first().click();
await page.getByRole('tab', { name: '密码登录' }).first().click();
await page.waitForTimeout(600);
await page
.getByLabel('同意法律协议')
.first()
.check({ force: true })
.catch(() => {});
await page
.locator('input[placeholder="13800000000"]:visible')
.first()
.fill(phone);
await page
.locator('input[placeholder="输入密码"]:visible')
.first()
.fill(DEV_PASSWORD);
await page.getByRole('button', { name: '登录', exact: true }).first().click();
await page
.locator('.platform-account-entry')
.first()
.filter({ hasText: phone.slice(-4) })
.waitFor({ state: 'visible', timeout: 30_000 });
}
async function fillPublishForm(page, title, coverPath, zipPath) {
await page.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
await page
.getByLabel('游戏名称')
.waitFor({ state: 'visible', timeout: 60_000 });
await page.getByLabel('游戏名称').fill(title);
await page.getByLabel('一句话简介').fill('发布恢复链路 E2E');
await page.getByLabel('分类').selectOption('休闲');
await page.getByLabel('游戏封面(必需)').setInputFiles(coverPath);
await page
.locator('img[alt="游戏封面预览"]')
.waitFor({ state: 'visible', timeout: 60_000 });
await page.getByLabel('发行包 ZIP').setInputFiles(zipPath);
await page.waitForTimeout(400);
}
async function main() {
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发布恢复链路',
},
});
const stamp = Date.now();
const phone = `139${String(stamp).slice(-8)}`;
const title = `发布恢复 ${String(stamp).slice(-6)}`;
const workDir = await mkdtemp(
path.join(tmpdir(), 'genarrative-publish-recovery-'),
);
const coverPath = path.join(workDir, 'cover.png');
const zipPath = path.join(workDir, 'package.zip');
await writeFile(coverPath, COVER_PNG);
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>recovery</title>' +
'<script src="assets/app.js"></script></head><body><h1>RECOVERY-OK</h1></body></html>',
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="recovery";');
await writeFile(
zipPath,
Buffer.from(await zip.generateAsync({ type: 'uint8array' })),
);
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
let draftSnapshot = '';
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
await loginThroughWebUi(page, phone);
check('作者网页登录成功', true, `phone=${phone}`);
let abortUploads = true;
let interrupted = false;
await page.route(
'**/api/game-distribution/versions/*/package*',
async (route) => {
if (abortUploads) {
interrupted = true;
await route.abort();
return;
}
await route.continue();
},
);
await fillPublishForm(page, title, coverPath, zipPath);
const submitButton = page.getByRole('button', { name: '提交审核' }).first();
await submitButton.click();
// 双击:第二次点击发生在提交中,按钮应已禁用,不产生第二份游戏/版本。
await submitButton.click({ force: true, timeout: 3_000 }).catch(() => {});
await page.waitForTimeout(4_000);
check('发行包上传失败时给出错误提示', interrupted, '已拦截一次上传请求');
const authorLogin = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
const author = authorLogin.data?.token;
const mineAfterFailure = await api('/api/game-distribution/my-games', {
token: author,
});
const gamesAfterFailure = (mineAfterFailure.data?.games ?? []).filter(
(item) => item.title === title,
);
check(
'双击提交只落一份游戏与一个版本',
gamesAfterFailure.length === 1 &&
gamesAfterFailure[0].versions?.length === 1,
`games=${gamesAfterFailure.length} versions=${gamesAfterFailure[0]?.versions?.length ?? 'n/a'}`,
);
// 后面续传阶段放行上传。
abortUploads = false;
draftSnapshot = await page.evaluate(
(key) => localStorage.getItem(key) ?? '',
DRAFT_KEY,
);
const draft = draftSnapshot ? JSON.parse(draftSnapshot) : null;
check(
'上传中断后本地保留恢复草稿',
Boolean(draft?.versionId) && draft.gameId === gamesAfterFailure[0]?.id,
`versionId=${draft?.versionId ?? ''}`,
);
// 关窗 + 登录失效:清掉 access token 与 refresh cookie 后重登同一账号,
// 草稿仍在 localStorage 里,应该还能接着原版本上传。
await page.close();
const clearing = await context.newPage();
await clearing.goto(`${WEB}/games`, {
waitUntil: 'commit',
timeout: 120_000,
});
await clearing.evaluate(() => {
try {
localStorage.removeItem('genarrative.auth.access-token.v1');
} catch {
// 存储不可用时下面按登出后的恢复提示判定。
}
});
await context.clearCookies();
await clearing.close();
const reopened = await context.newPage();
await reopened.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
const loginPrompt = await reopened
.getByText('登录后才能发布游戏')
.first()
.waitFor({ state: 'visible', timeout: 30_000 })
.then(() => true)
.catch(() => false);
check('登录失效后发布页要求重新登录', loginPrompt);
await loginThroughWebUi(reopened, phone);
await reopened.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
await reopened
.getByText(/上次发布未完成(版本 v1)/u)
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
check('重新登录后仍能看到继续上传的恢复提示', true);
await reopened.getByRole('button', { name: '继续上传' }).first().click();
await reopened.getByLabel('发行包 ZIP').setInputFiles(zipPath);
await reopened.waitForTimeout(400);
await reopened
.getByRole('button', { name: '继续上传并送审' })
.first()
.click();
await reopened
.getByText('已提交审核,审核通过后会自动出现在游戏广场。')
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
const mineAfterResume = await api('/api/game-distribution/my-games', {
token: author,
});
const resumedGame = (mineAfterResume.data?.games ?? []).find(
(item) => item.title === title,
);
check(
'继续上传沿用原版本并进入待审',
resumedGame?.versions?.length === 1 &&
resumedGame?.versions?.[0]?.versionId === draft?.versionId &&
resumedGame?.latestVersion?.status === 'pending_review',
`versions=${resumedGame?.versions?.length ?? 'n/a'} status=${resumedGame?.latestVersion?.status ?? ''}`,
);
const draftAfterResume = await reopened.evaluate(
(key) => localStorage.getItem(key) ?? '',
DRAFT_KEY,
);
check('恢复成功后清理本地草稿', draftAfterResume === '');
await reopened.close();
await context.close();
// 换账号:把前一个账号的草稿留在同一浏览器里,新账号不应看到恢复提示。
const otherPhone = `137${String(stamp + 7).slice(-8)}`;
const otherContext = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
await otherContext.addInitScript(
({ key, value }) => {
try {
localStorage.setItem(key, value);
} catch {
// 存储不可用时这条用例不成立,下面按「没有恢复提示」判定。
}
},
{ key: DRAFT_KEY, value: draftSnapshot },
);
const otherPage = await otherContext.newPage();
await loginThroughWebUi(otherPage, otherPhone);
await otherPage.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
await otherPage
.getByLabel('游戏名称')
.waitFor({ state: 'visible', timeout: 60_000 });
await otherPage.waitForTimeout(1_500);
const leakedDraft = await otherPage.getByText(/上次发布未完成/u).count();
check(
'换账号后不会显示上一个账号的私有草稿',
leakedDraft === 0,
`matches=${leakedDraft}`,
);
await otherContext.close();
} finally {
await browser.close();
await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled: false,
rolloutPercent: 0,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发布恢复链路(收尾关闭)',
},
});
}
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(
`[check-game-distribution-web-publish-recovery-e2e] 运行失败:${error}`,
);
process.exit(1);
});
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* 校验 ts-rs 生成的共享契约绑定与 Rust 声明一致。
*
* 做法是「重新生成一遍并与工作区现有内容逐字节比较」:只比 git diff 会漏掉「Rust 改了但
* 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接——
* 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const generatedDir = path.join(
repoRoot,
'packages/shared/src/contracts/generated',
);
function snapshot() {
if (!fs.existsSync(generatedDir)) return new Map();
return new Map(
fs
.readdirSync(generatedDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => [
entry.name,
fs.readFileSync(path.join(generatedDir, entry.name)),
]),
);
}
const before = snapshot();
const result = spawnSync(
'cargo',
[
'test',
'--locked',
'-p',
'shared-contracts',
'--features',
'ts-bindings',
'--manifest-path',
'server-rs/Cargo.toml',
'export_bindings',
],
{ cwd: repoRoot, encoding: 'utf8' },
);
if (result.status !== 0) {
console.error('生成绑定校验无法运行:export_bindings 未通过');
if (result.stdout) console.error(result.stdout.trim());
if (result.stderr) console.error(result.stderr.trim());
process.exit(result.status ?? 1);
}
const after = snapshot();
const problems = [];
for (const [name, content] of after) {
const previous = before.get(name);
if (!previous) problems.push(`新增 ${name}`);
else if (!previous.equals(content)) problems.push(`内容变化 ${name}`);
}
for (const name of before.keys()) {
if (!after.has(name)) problems.push(`删除 ${name}`);
}
if (problems.length > 0) {
console.error('生成绑定与 Rust 声明不一致:');
for (const problem of problems) console.error(` - ${problem}`);
console.error(
'请运行 `cargo test -p shared-contracts --features ts-bindings export_bindings`,并把重新生成的结果与 Rust 改动一并提交。',
);
process.exit(1);
}
console.log(
`生成绑定校验通过:${after.size} 个文件与 Rust 声明一致(${path.relative(repoRoot, generatedDir)})。`,
);
+43 -124
View File
@@ -10,9 +10,6 @@ const nativeShellPlanPath =
'docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md';
const hostBridgeProtocolDocPath =
'docs/【前端架构】宿主壳能力统一协议-2026-06-17.md';
const developmentWorkflowDocPath =
'docs/project-memory/shared-memory/development-workflow.md';
const decisionLogDocPath = 'docs/project-memory/shared-memory/decision-log.md';
const rootPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const mobileShellConfigCheckSource = fs.readFileSync(
'apps/mobile-shell/scripts/check-config.mjs',
@@ -2564,21 +2561,47 @@ function assertAiGameCreatorShellUserDevBoundary() {
!aiGameCreatorClientHttpSource.includes(
"'https://dev.genarrative.world'",
) ||
!aiGameCreatorClientHttpSource.includes("transport: 'tauri-http'") ||
!aiGameCreatorClientHttpSource.includes('target.origin !==')
!aiGameCreatorClientHttpSource.includes(
"'https://www.genarrative.world'",
) ||
/\bfetch\s*\(/u.test(aiGameCreatorClientHttpSource) ||
aiGameCreatorClientHttpSource.includes("transport: 'tauri-http'")
) {
throw new Error(
'AI game creator release dev API transport boundary drifted',
'AI game creator clientHttp must only resolve the server selection, not carry a transport',
);
}
// 渲染层是离线前端:平台接口、OSS 直传、Provider 与更新清单的网络 IO 全部在 Rust 侧
// (`src-tauri/src/account_api.rs` / `auth_session.rs` / `platform_asset_upload.rs` /
// `game_distribution_publish.rs` 等 reqwest facade)。这里对整棵渲染源码加网络原语与
// Tauri HTTP guest 的负向门禁,避免以后再长出第二条传输路径。
const rendererNetworkPrimitivePatterns = [
['fetch(', /\bfetch\s*\(/u],
['XMLHttpRequest', /\bXMLHttpRequest\b/u],
['EventSource', /\bEventSource\b/u],
['sendBeacon(', /\bsendBeacon\s*\(/u],
['new WebSocket(', /\bnew\s+WebSocket\s*\(/u],
['@tauri-apps/plugin-http', /@tauri-apps\/plugin-http/u],
];
for (const rendererFilePath of collectFiles(
'apps/ai-game-creator-shell/src',
(entryPath) => /\.(ts|tsx)$/u.test(entryPath),
)) {
const rendererSource = fs.readFileSync(rendererFilePath, 'utf8');
for (const [label, pattern] of rendererNetworkPrimitivePatterns) {
if (pattern.test(rendererSource)) {
throw new Error(
`AI game creator renderer must stay offline, but ${normalizeScannedFilePath(rendererFilePath)} contains ${label}`,
);
}
}
}
if (
!aiGameCreatorCargoManifestSource.includes(
'tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }',
) ||
!aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
aiGameCreatorCargoManifestSource.includes('tauri-plugin-http') ||
aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
) {
throw new Error(
'AI game creator release must register the native HTTP plugin without the OS automatic system-proxy feature',
'AI game creator shell must not register a renderer HTTP plugin; native IO goes through the Rust reqwest facades',
);
}
if (
@@ -2588,27 +2611,18 @@ function assertAiGameCreatorShellUserDevBoundary() {
'AI game creator monorepo build must dedupe React runtime packages',
);
}
const httpPermission = (aiGameCreatorMainCapability.permissions ?? []).find(
(permission) =>
typeof permission === 'object' &&
permission?.identifier === 'http:default',
);
if (
!httpPermission ||
JSON.stringify(httpPermission.allow ?? []) !==
JSON.stringify([
{ url: 'https://dev.genarrative.world/api/*' },
{ url: 'https://www.genarrative.world/api/*' },
{ url: 'https://*/api/*' },
{ url: 'http://localhost:*/*' },
{ url: 'http://127.0.0.1:*/*' },
{ url: 'https://*.aliyuncs.com/*' },
])
(aiGameCreatorMainCapability.permissions ?? []).some(
(permission) =>
permission === 'http:default' ||
(typeof permission === 'object' &&
permission?.identifier === 'http:default'),
)
) {
throw new Error(
// 更新清单与安装包下载由 tauri-plugin-updater 在原生侧完成;
// 封面与截图仍通过 webview 的 http 插件向凭证指定的 OSS 地址直传。
'AI game creator native HTTP scope must match the release, dev, custom HTTPS, loopback API, and OSS media upload boundary',
// 客户端窗口只保留剪贴板、图片、资源关闭、opener、updater 与原生对话框权限;
// 更新清单下载由 tauri-plugin-updater 在原生侧完成,素材直传也不再经渲染层。
'AI game creator client capability must not grant the renderer HTTP permission',
);
}
@@ -3757,107 +3771,12 @@ function extractDocumentMethodTable(source) {
);
}
function assertNativeShellScaffoldScanWording(source, label) {
if (
source.includes('三端生产壳临时替身词扫描') ||
source.includes('三端壳生产源码') ||
source.includes('壳生产源码禁替身') ||
!source.includes('H5 HostBridge 真实调用链的临时替身词扫描')
) {
throw new Error(
`${label} must document production scaffold scanning for the H5 HostBridge call chain`,
);
}
}
function assertNativeShellCapabilityPlan() {
const planSource = fs.readFileSync(nativeShellPlanPath, 'utf8');
const hostBridgeProtocolDocSource = fs.readFileSync(
hostBridgeProtocolDocPath,
'utf8',
);
const developmentWorkflowDocSource = fs.readFileSync(
developmentWorkflowDocPath,
'utf8',
);
const decisionLogDocSource = fs.readFileSync(decisionLogDocPath, 'utf8');
if (
planSource.includes('permissions` 必须只包含 `core:default`') ||
planSource.includes(
'permissions=["core:default","allow-host-bridge-request"]',
)
) {
throw new Error(
'native shell plan must not document core:default as a desktop capability permission',
);
}
if (
!planSource.includes('主窗口 capability 只授予 `allow-host-bridge-request`')
) {
throw new Error(
'native shell plan must document the minimal desktop capability permission',
);
}
for (const staleShareWording of [
'深链、系统分享、即时本地通知',
'重复执行支付、登录、系统分享、文件导入导出',
'发布分享弹窗只有声明 `share.open` 时才显示“系统分享”',
'发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作',
]) {
if (
planSource.includes(staleShareWording) ||
hostBridgeProtocolDocSource.includes(staleShareWording) ||
decisionLogDocSource.includes(staleShareWording)
) {
throw new Error(
`native shell docs must describe share.open as a host-specific controlled share action: ${staleShareWording}`,
);
}
}
for (const requiredShareWording of [
'深链、受控分享动作、即时本地通知',
'重复 `id` 不得重复执行支付、登录、受控分享动作、文件导入导出',
'发布分享弹窗在 Expo 移动壳声明 `share.open` 时提供“系统分享”动作',
'发布分享弹窗在 Tauri 桌面壳中展示“复制分享文案 / 已复制 / 复制失败”',
'并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达',
]) {
if (
!planSource.includes(requiredShareWording) &&
!hostBridgeProtocolDocSource.includes(requiredShareWording) &&
!decisionLogDocSource.includes(requiredShareWording)
) {
throw new Error(
`native shell docs missing host-specific share.open wording: ${requiredShareWording}`,
);
}
}
for (const staleMobilePermissionText of [
'Android `permissions` 不手写显式权限',
'最终 Expo public config 只允许扫码能力由 `expo-camera` plugin 带入 `android.permission.CAMERA`',
'移动拍摄不请求麦克风权限',
]) {
if (
planSource.includes(staleMobilePermissionText) ||
hostBridgeProtocolDocSource.includes(staleMobilePermissionText)
) {
throw new Error(
`native shell docs must not keep stale mobile permission wording: ${staleMobilePermissionText}`,
);
}
}
assertNativeShellScaffoldScanWording(planSource, 'native shell plan');
assertNativeShellScaffoldScanWording(
hostBridgeProtocolDocSource,
'HostBridge protocol document',
);
assertNativeShellScaffoldScanWording(
developmentWorkflowDocSource,
'development workflow document',
);
assertNativeShellScaffoldScanWording(
decisionLogDocSource,
'decision log document',
);
assertShellLayerLayoutDocumented(planSource, 'native shell plan');
assertShellLayerLayoutDocumented(
hostBridgeProtocolDocSource,
+57 -3
View File
@@ -1011,6 +1011,55 @@ async function runSmokeCases(
`API 上游 X-Real-IP 未使用 TCP 对端 IP:${apiPayload.realIp}`,
);
// 平台同源发行入口:/games/game_<32 位小写十六进制 id>/… 重写到发行网关并清空 Cookie,
// 形状不符的路径不许被吞进发行网关(SPA 深链仍是 SPA,`/games/game/...` 仍是真实 404)。
const releaseGameId = 'game_0123456789abcdef0123456789abcdef';
const releaseUpstreamPath = `/api/game-distribution/releases/${releaseGameId}/index.html`;
const releaseBeforeCount = api.state.requests.length;
const releaseResponse = await expectHttp(
baseUrl,
`/games/${releaseGameId}/index.html`,
200,
'"upstream":"api"',
'平台同源发行入口转发到发行网关',
{
headers: {
'X-Request-Id': 'smoke-release-request-id',
Host: 'example.test',
Cookie: 'session=smoke-must-not-reach-release-gateway',
},
},
);
const releasePayload = JSON.parse(releaseResponse.body);
ensure(
releasePayload.url === releaseUpstreamPath,
`发行入口上游路径没有重写:${releasePayload.url}`,
);
const releaseUpstreamRequests = api.state.requests.slice(releaseBeforeCount);
ensure(
releaseUpstreamRequests.length === 1 &&
releaseUpstreamRequests[0].url === releaseUpstreamPath,
`发行入口上游请求不符:${describeRequests(releaseUpstreamRequests)}`,
);
ensure(
releaseUpstreamRequests[0]?.headers.cookie === undefined,
`发行入口没有清空 Cookie:${describeRequests(releaseUpstreamRequests)}`,
);
await expectHttp(
baseUrl,
'/games/detail',
200,
'site-shell',
'发行入口形状不符时 SPA 深链照常回退',
);
await expectHttp(
baseUrl,
'/games/game/index.html',
404,
'',
'发行入口形状不符时仍是真实 404',
);
await expectHttp(
baseUrl,
'/api/upload',
@@ -1720,8 +1769,10 @@ async function expectConcurrencyLimit(baseUrl, api) {
);
} finally {
api.state.releaseHold?.();
hold.socket.end();
// 先读完被放行的响应再关客户端连接:原先「释放 hold 后立刻 FIN」会让客户端半关闭
// 与上游响应抢跑,Windows 上稳定表现为 `HTTP 响应提前关闭`(Linux 上只是偶尔更宽松)。
const holdResponse = await hold.done.catch((error) => ({ error }));
hold.socket.end();
if (holdResponse?.error) {
failures.push(
`API 并发保护: hold 请求失败:${holdResponse.error.message}`,
@@ -2519,9 +2570,12 @@ function resolveGatewayBinary() {
return explicit;
}
// Windows 上 cargo 产出的是 `pingora-gateway.exe`;Linux 侧名字保持不变。
const binaryName =
process.platform === 'win32' ? 'pingora-gateway.exe' : 'pingora-gateway';
const candidates = [
path.join(repoRoot, 'server-rs', 'target', 'debug', 'pingora-gateway'),
path.join(repoRoot, 'target', 'debug', 'pingora-gateway'),
path.join(repoRoot, 'server-rs', 'target', 'debug', binaryName),
path.join(repoRoot, 'target', 'debug', binaryName),
];
const found = candidates.find((candidate) => existsSync(candidate));
if (!found) {
+36
View File
@@ -14,6 +14,7 @@ const PINGORA_GATEWAY_SOURCE = 'server-rs/crates/pingora-gateway/src/main.rs';
const VALID_KINDS = new Set([
'proxy',
'static',
'release_gateway',
'redirect_permanent',
'shadow_probe',
'not_found',
@@ -39,6 +40,9 @@ const REQUIRED_ROUTE_IDS = [
'generated_assets_forbidden',
'web_spa_fallback',
'profile_spa_fallback',
'games_spa_fallback',
'games_release_gateway',
'web_root_spa',
'web_spa_case_trailing_slash',
'web_unknown_path_exact',
'creation_unknown_path_exact',
@@ -108,6 +112,15 @@ function validateExpectation(route) {
fail(`${context} 非 proxy 路由不能配置 protectionClass。`);
}
if (expect.kind !== 'release_gateway' && hasOwn(expect, 'upstreamPath')) {
fail(`${context} 只有 release_gateway 路由才能配置 upstreamPath。`);
}
if (expect.kind === 'release_gateway') {
requireString(expect.upstreamPath, `${context} upstreamPath`);
return;
}
if (expect.kind === 'static') {
if (!VALID_STATIC_ROOTS.has(expect.root)) {
fail(`${context} static root 不支持: ${expect.root}`);
@@ -229,6 +242,28 @@ function validateRustTestUsesMatrix() {
}
}
// 反向覆盖:模板里出现的每条 location 都必须被矩阵某条用例声明过。
// 只做正向检查(矩阵片段存在于模板)会漏掉「Nginx 模板加/改了路由、矩阵与 Pingora 没跟上」——
// 2026-09-29 的发行网关路由就是这样:Nginx 有三份、Pingora 和矩阵都没有,门禁一直是绿的。
function validateNginxLocationsAreCovered() {
for (const environment of ['production', 'development']) {
const source = files[environment];
const locationFragments = matrix.routes
.flatMap((route) => route.nginx?.[environment] ?? [])
.map((fragment) => fragment.trim())
.filter((fragment) => fragment.startsWith('location'));
for (const match of source.matchAll(/^[ \t]*location\b[^\n]*/gmu)) {
const line = match[0].trim().replace(/\s*\{\s*$/u, '');
if (line.startsWith('#')) {
continue;
}
if (!locationFragments.some((fragment) => line.startsWith(fragment))) {
fail(`${environment} 模板的 location 没有被矩阵覆盖: ${line}`);
}
}
}
}
function validateRustMainSpaRoutes() {
const routeBlock = pingoraGatewaySource.match(
/const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
@@ -256,6 +291,7 @@ function validateRustMainSpaRoutes() {
validateMatrixShape();
validateRustTestUsesMatrix();
validateNginxLocationsAreCovered();
validateRustMainSpaRoutes();
if (failures.length > 0) {
+107
View File
@@ -9,6 +9,7 @@ import {
readFileSync,
readlinkSync,
rmSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
@@ -95,6 +96,9 @@ function main() {
assertRealWechatPayUsesLastRefundReconciliationAssignment();
assertDeployCleansStagingReleaseOnFailure();
assertDeployRejectsFinalReleaseRaceAndCleansStaging();
assertDeployPrunesOldReleases();
assertDeployHonorsExplicitKeepReleases();
assertDeployRejectsInvalidKeepReleases();
assertMissingBackupScriptFails();
assertMissingHealthPatrolScriptFails();
assertMissingPingoraCurrentReleaseAuditFails();
@@ -1754,6 +1758,106 @@ function assertDeployRejectsFinalReleaseRaceAndCleansStaging() {
assertMaintenanceCleared(fixture, '目标 release 竞态失败');
}
function assertDeployPrunesOldReleases() {
const fixture = prepareFixture('prune-old-releases');
const nowSeconds = Date.now() / 1000;
const seededReleases = [
['20260610-oldest', nowSeconds - 3000],
['20260611-middle', nowSeconds - 2000],
['20260613-previous', nowSeconds - 1000],
];
for (const [name, mtimeSeconds] of seededReleases) {
const dir = path.join(fixture.releaseRoot, name);
mkdirSync(path.join(dir, 'web'), { recursive: true });
writeFileSync(path.join(dir, 'web', 'index.html'), `${name}\n`, 'utf8');
utimesSync(dir, mtimeSeconds, mtimeSeconds);
}
// 发布根目录下不含 api-server/web 的目录不属于发布产物,清理时必须保留。
const unrelatedDir = path.join(fixture.releaseRoot, 'dev-mcp-host-cache');
mkdirSync(unrelatedDir, { recursive: true });
writeFileSync(path.join(unrelatedDir, 'keep.txt'), 'keep\n', 'utf8');
utimesSync(unrelatedDir, nowSeconds - 4000, nowSeconds - 4000);
const result = runDeploy(fixture);
assertStatus(result, 0, '存在历史 release 时完整 fixture 仍应部署成功。');
if (result.status !== 0) {
return;
}
assertFileExists(
path.join(fixture.releaseRoot, fixture.version),
'本此发布目录必须保留。',
);
assertFileExists(
path.join(fixture.releaseRoot, '20260613-previous'),
'默认 --keep-releases 2 必须保留最近一个历史 release 以便回滚。',
);
for (const name of ['20260611-middle', '20260610-oldest']) {
if (existsSync(path.join(fixture.releaseRoot, name))) {
failures.push(
`默认 --keep-releases 2 必须清理更早的历史 release: ${name}`,
);
}
}
assertFileExists(
path.join(unrelatedDir, 'keep.txt'),
'清理历史 release 不得删除发布根目录下不含 api-server/web 的目录。',
);
assertIncludes(
result.stdout,
'清理历史 release:',
'清理历史 release 时必须输出被清理的目录。',
);
}
function assertDeployHonorsExplicitKeepReleases() {
const fixture = prepareFixture('keep-single-release');
const nowSeconds = Date.now() / 1000;
const previousReleaseDir = path.join(
fixture.releaseRoot,
'20260613-previous',
);
mkdirSync(path.join(previousReleaseDir, 'web'), { recursive: true });
writeFileSync(
path.join(previousReleaseDir, 'web', 'index.html'),
'previous\n',
'utf8',
);
utimesSync(previousReleaseDir, nowSeconds - 1000, nowSeconds - 1000);
const result = runDeploy(fixture, { keepReleases: 1 });
assertStatus(result, 0, '--keep-releases 1 时完整 fixture 仍应部署成功。');
if (result.status !== 0) {
return;
}
assertFileExists(
path.join(fixture.releaseRoot, fixture.version),
'--keep-releases 1 必须保留本次发布目录。',
);
if (existsSync(previousReleaseDir)) {
failures.push('--keep-releases 1 必须清理除 current 以外的历史 release。');
}
}
function assertDeployRejectsInvalidKeepReleases() {
const fixture = prepareFixture('invalid-keep-releases');
const result = runDeploy(fixture, { keepReleases: '0' });
assertStatus(result, 1, '--keep-releases 0 必须被拒绝。');
assertIncludes(
result.stderr,
'--keep-releases 必须是大于等于 1 的整数',
'--keep-releases 非法时必须给出明确错误。',
);
assertNotIncludes(
readOptionalCommandsLog(fixture),
'systemctl restart genarrative-api.service',
'--keep-releases 非法时不得重启 API 服务。',
);
assertNoReleasePromoted(fixture, '--keep-releases 非法时不得提升 release');
}
function assertMissingPingoraDirectCheckFails() {
const fixture = prepareFixture('missing-direct-live');
rmSync(path.join(fixture.sourceDir, 'scripts/check-pingora-direct-live.mjs'));
@@ -2785,6 +2889,9 @@ function runDeploy(fixture, options = {}) {
if (options.keepMaintenance) {
args.push('--keep-maintenance-mode');
}
if (options.keepReleases !== undefined) {
args.push('--keep-releases', String(options.keepReleases));
}
return spawnSync('bash', args, {
cwd: process.cwd(),
encoding: 'utf8',
+91 -124
View File
@@ -107,6 +107,18 @@ const checks = [
includes: 'npm run lint:eslint',
reason: 'Web 生产构建必须执行 ESLint 门禁。',
},
{
file: 'package.json',
includes: 'npm run check:nginx-spa-routes',
reason:
'仓库 lint 链必须包含主站 SPA allowlist 门禁:否则模板与前端路由源漂移只在人工执行时才可见(2026-08-26 起红了一个月就是没有调用方)。',
},
{
file: 'package.json',
includes: 'npm run check:pingora-route-parity',
reason:
'仓库 lint 链必须包含 Nginx/Pingora 路由矩阵 parity 门禁:否则模板新增 location 而矩阵与网关没跟上时,门禁不会在 CI 里跑(发行网关路由就是这么漏的)。',
},
{
file: 'jenkins/Jenkinsfile.production-web-build',
includes: 'npm run typecheck',
@@ -428,6 +440,24 @@ const checks = [
'--bgfilter-worker-env-file "${BGFILTER_WORKER_ENV_FILE:-/etc/genarrative/bgfilter-worker.env}"',
reason: 'API Deploy Job 必须把 BgFilter worker env 路径传给发布脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-api-deploy',
includes: '[staging-cleanup]',
reason:
'API Deploy Job 必须清理历史 build/<version> 暂存,避免目标机被 copyArtifacts 暂存撑满。',
},
{
file: 'jenkins/Jenkinsfile.production-web-deploy',
includes: '[staging-cleanup]',
reason:
'Web Deploy Job 必须清理历史 build/<version> 暂存,避免目标机被 copyArtifacts 暂存撑满。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: '[staging-cleanup]',
reason:
'Stdb Publish Job 必须清理历史 build/<version> 暂存,避免目标机被 copyArtifacts 暂存撑满。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
@@ -475,6 +505,17 @@ const checks = [
reason:
'API deploy 必须在 current 切换前复核真实微信支付退款 reconciliation。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'prune_old_releases',
reason:
'API deploy 必须在发布成功后按 --keep-releases 清理历史 release 目录,避免目标机根盘被历史发布撑满。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: '--keep-releases 必须是大于等于 1 的整数',
reason: 'API deploy 必须拒绝非法的 --keep-releases 参数。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes:
@@ -2014,12 +2055,6 @@ const checks = [
reason:
'Pingora 试点文档必须记录 realpath canary 的 Nginx log_format 加载顺序踩坑。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'Pingora realpath canary include 要晚于 log_format',
reason:
'团队共享踩坑必须记录 realpath canary 独立 server 在 conf.d 中的加载顺序要求。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'genarrative-health-patrol.timer',
@@ -3308,16 +3343,6 @@ const checks = [
includes: 'manifest.files` 视为闭集',
reason: '生产运维文档必须说明证据验真默认闭集归档口径。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'manifest.files` 视为闭集',
reason: '团队共享决策必须记录 Pingora 证据验真闭集归档口径。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'manifest.files` 视为闭集',
reason: '团队共享踩坑必须记录 Pingora 证据目录不能夹带未登记条目。',
},
{
file: 'docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md',
includes: 'pingora-gateway-env-shadow-switch.mjs --apply',
@@ -3330,18 +3355,6 @@ const checks = [
reason:
'生产运维文档必须记录 rollback apply 前用随包脚本恢复 Pingora shadow env。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'pingora-gateway-env-shadow-switch.mjs --apply',
reason:
'团队共享决策必须记录 rollback apply 前用随包脚本恢复 Pingora shadow env。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'pingora-gateway-env-shadow-switch.mjs --apply',
reason:
'团队共享踩坑必须记录 rollback apply 前用随包脚本恢复 Pingora shadow env。',
},
{
file: 'deploy/nginx/README.md',
includes: 'scripts/deploy/pingora-gateway-env-shadow-switch.mjs',
@@ -6731,39 +6744,6 @@ const checks = [
reason:
'Nginx README 必须说明 direct live 静态响应头摘要缺关键证据时会阻断证据包。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'API / WSS 检查不落原始响应头',
reason: '团队共享决策必须记录 direct live 只归档静态白名单响应头。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'directLiveStaticHeaders',
reason:
'团队共享决策必须记录切流 manifest 提升 direct live 静态响应头摘要。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes:
'静态头摘要缺少缓存头、校验头、Range `206 + Content-Range`、ETag 304 / Last-Modified 304 证据时整包记为 `CRITICAL`',
reason: '团队共享决策必须记录静态响应头摘要不完整会阻断证据包。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: '不要把 `direct-live.json` 当成只有状态码的摘要',
reason: '团队共享踩坑必须提醒 direct live JSON 需要可复盘静态响应头证据。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'manifest.summary.directLiveStaticHeaders',
reason: '团队共享踩坑必须提醒切流 manifest 需要可快速复盘静态响应头摘要。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes:
'摘要缺少缓存头、校验头、Range `206 + Content-Range`、ETag 304 或 Last-Modified 304 证据,证据包会直接记为 `CRITICAL`',
reason: '团队共享踩坑必须提醒静态响应头摘要不完整会阻断证据包。',
},
{
file: 'scripts/ops/pingora-cutover-evidence-bundle.mjs',
includes: 'directLiveAccessLog',
@@ -6991,11 +6971,6 @@ const checks = [
excludes: '补齐 Brotli 取舍',
reason: 'Pingora 试点文档不能继续把 Brotli 取舍留成待办。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'Pingora Brotli 不能只看 Content-Encoding',
reason: '团队共享踩坑必须记录 Pingora Brotli 端到端解压风险。',
},
{
file: 'deploy/pingora/pingora-gateway.env.example',
includes: 'GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL=5',
@@ -7096,17 +7071,6 @@ const checks = [
'--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log',
reason: '生产运维文档必须展示 Pingora 直连 access log 落盘校验参数。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: '直连日志证据补充',
reason: '团队共享决策必须记录 Pingora 直连 access log 证据门禁。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes:
'--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log',
reason: '团队共享踩坑必须记录 Pingora 直连 access log 参数。',
},
{
file: 'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md',
includes:
@@ -7151,26 +7115,6 @@ const checks = [
includes: '不要继续用固定 `http://127.0.0.1/healthz` 与 `"ok":true`',
reason: 'Nginx README 必须防止回退 smoke 示例回到旧 healthz 口径。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: '不再默认假定 `/healthz` 会返回 `"ok":true`',
reason: '团队共享决策必须记录回退 smoke 的真实 Nginx 入口要求。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: '启用后复核应看到端口由 Pingora 占用而不是空闲',
reason: '团队共享决策必须记录启用后 --require-direct 不再检查端口空闲。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: '不要继续用固定 `/healthz` 与 `"ok":true`',
reason: '团队共享踩坑必须提醒回退 smoke 不能沿用旧 healthz 片段。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: '启用后 `--require-direct` 复核不再要求端口空闲',
reason: '团队共享踩坑必须提醒端口空闲只属于切流前门禁。',
},
{
file: 'deploy/nginx/README.md',
excludes:
@@ -7193,12 +7137,6 @@ const checks = [
excludes: 'rollback-nginx-smoke-expect-body="ok":true',
reason: 'Pingora 试点 runbook 不应再默认展示 ok:true 作为回退响应体片段。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
excludes:
'--nginx-smoke-url http://127.0.0.1/healthz --nginx-smoke-host <域名>',
reason: '团队共享踩坑不应再把固定本机 healthz 当成可执行回退示例。',
},
{
file: 'deploy/pingora/pingora-gateway.env.example',
includes: 'GENARRATIVE_PINGORA_GATEWAY_TRUSTED_FRONT_PROXY_CONFIRMED',
@@ -7231,27 +7169,6 @@ const checks = [
reason:
'生产运维文档必须明确多实例 Pingora 与进程内保护的启动 / preflight 边界。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: 'GENARRATIVE_PINGORA_GATEWAY_INSTANCE_COUNT>1',
reason: '团队共享决策必须记录 Pingora 多实例接流保护边界。',
},
{
file: 'docs/project-memory/shared-memory/decision-log.md',
includes: '禁止继续执行部署工作区根部脚本',
reason: '团队共享决策必须记录 API Deploy 不再执行 workspace 根部脚本。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: 'API deploy 脚本本身也不能继续用部署工作区根部',
reason:
'团队共享踩坑必须记录 workspace 根部 deploy 脚本会掩盖发布包布局问题。',
},
{
file: 'docs/project-memory/shared-memory/pitfalls.md',
includes: '备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底',
reason: '团队共享踩坑必须记录备份/巡检脚本也不能使用 workspace fallback。',
},
{
file: 'deploy/nginx/README.md',
includes: 'Pingora 影子网关已通过',
@@ -7794,6 +7711,56 @@ for (const [file, content] of [
}
}
}
// 更新签名私钥只允许经 Jenkins 凭据绑定注入构建进程:既不得把凭据本身写进 Jenkinsfile,
// 也不得回显到日志或进归档(对应「AGC 更新发布管线渠道化」第 5 条的可静态复核部分)。
for (const [file, content] of [
['jenkins/Jenkinsfile.ai-game-creator-shell-build', agcPipelineContent],
[
'jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
agcMacosPipelineContent,
],
]) {
for (const [snippet, reason] of [
[
"string(credentialsId: 'AgcUpdaterSigningKey', variable: 'TAURI_SIGNING_PRIVATE_KEY')",
'签名私钥必须经 `AgcUpdaterSigningKey` 凭据绑定注入 `TAURI_SIGNING_PRIVATE_KEY`。',
],
[
"string(credentialsId: 'AgcUpdaterSigningKeyPassword', variable: 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD')",
'私钥口令必须经 `AgcUpdaterSigningKeyPassword` 凭据绑定注入。',
],
]) {
if (!content.includes(snippet)) {
failed = true;
console.error(`[check:production-ops] ${file} ${reason}`);
}
}
for (const leak of [
'echo $TAURI_SIGNING_PRIVATE_KEY',
'echo "$TAURI_SIGNING_PRIVATE_KEY"',
'echo ${TAURI_SIGNING_PRIVATE_KEY}',
'echo "$env:TAURI_SIGNING_PRIVATE_KEY"',
'Write-Host $env:TAURI_SIGNING_PRIVATE_KEY',
'Write-Output $env:TAURI_SIGNING_PRIVATE_KEY',
'%TAURI_SIGNING_PRIVATE_KEY%',
'set -x',
]) {
if (content.includes(leak)) {
failed = true;
console.error(
`[check:production-ops] ${file} 不得把签名凭据打印到构建日志(命中 \`${leak}\`,xtrace 会把绑定的凭据一起打出来)。`,
);
}
}
const archiveBlock = content.split('archiveArtifacts')[1] ?? '';
if (/(\.pem|\.key|\.pfx|\.p12)\b/u.test(archiveBlock.slice(0, 600))) {
failed = true;
console.error(
`[check:production-ops] ${file} 归档 glob 不得包含签名私钥类文件(*.pem / *.key / *.pfx / *.p12)。`,
);
}
}
for (const [snippet, reason] of [
[
"string(name: 'OSS_DOWNLOAD_URL'",
+51 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--require-pingora-gateway] [--bgfilter-worker-service genarrative-bgfilter-worker.service] [--bgfilter-worker-health-url <url>] [--bgfilter-worker-env-file /etc/genarrative/bgfilter-worker.env] [--no-bgfilter-worker] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--controller-env-file /etc/genarrative/external-generation-controller.env] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] [--keep-maintenance-mode]
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--pingora-service genarrative-pingora-gateway.service] [--require-pingora-gateway] [--bgfilter-worker-service genarrative-bgfilter-worker.service] [--bgfilter-worker-health-url <url>] [--bgfilter-worker-env-file /etc/genarrative/bgfilter-worker.env] [--no-bgfilter-worker] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--controller-env-file /etc/genarrative/external-generation-controller.env] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] [--keep-maintenance-mode] [--keep-releases 2]
说明:
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
@@ -16,6 +16,7 @@ usage() {
若发布包包含 pingora-gateway,或传入 --require-pingora-gateway,部署脚本会要求 release manifest、二进制与 checksum 一致,再在 current 链接切换后先复核 systemd/env 仍是本机高端口 shadow 配置,启动或重启 Pingora 影子服务并复核 active。
默认在 readiness 通过后退出维护模式;传入 --keep-maintenance-mode 时保留维护文件,供人工验收后再恢复公网。
current 链接切换前失败时会退出本次打开的维护模式;current 链接切换后失败时保留维护模式,避免暴露半发布版本。
发布成功后按 --keep-releases(默认 2)保留 current 目标与最近的历史 release 目录,清理其它含 api-server 或 web 的旧发布目录,避免目标机根盘被历史发布撑满;该清理失败只告警,不改变本次发布结论。
EOF
}
@@ -1177,6 +1178,7 @@ DEPLOY_COMPLETED=0
PINGORA_INCLUDED=0
REQUIRE_PINGORA_GATEWAY=0
KEEP_MAINTENANCE_MODE=0
KEEP_RELEASES=2
MAINTENANCE_ENABLED_BY_DEPLOY=0
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
CURRENT_LINK_SWITCHED=0
@@ -1222,6 +1224,10 @@ while [[ $# -gt 0 ]]; do
KEEP_MAINTENANCE_MODE=1
shift
;;
--keep-releases)
KEEP_RELEASES="${2:?缺少 --keep-releases 的值}"
shift 2
;;
--worker-service-pattern)
WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}"
shift 2
@@ -1290,6 +1296,10 @@ done
require_argument "${SOURCE_DIR}" "--source-dir"
require_absolute_path "${RELEASE_ROOT}" "--release-root"
require_absolute_path "${CURRENT_LINK}" "--current-link"
if [[ ! "${KEEP_RELEASES}" =~ ^[0-9]+$ ]] || (( KEEP_RELEASES < 1 )); then
echo "[production-api-deploy] --keep-releases 必须是大于等于 1 的整数: ${KEEP_RELEASES}" >&2
exit 1
fi
require_absolute_path "${API_ENV_FILE}" "--api-env-file"
if [[ -n "${WORKER_ENV_FILE}" ]]; then
require_absolute_path "${WORKER_ENV_FILE}" "--worker-env-file"
@@ -1359,6 +1369,44 @@ cleanup_rendered_systemd_unit() {
RENDERED_SYSTEMD_UNIT_FILE=""
}
# 保留 current 目标与最近 keep_total-1 个其它发布目录。
# 只清理同时不是符号链接、名称不以点开头且含 api-server 或 web 的目录,
# 避免误删发布根目录下不属于发布产物的目录。
prune_old_releases() {
local keep_total="$1"
local current_target=""
local keep_others=$(( keep_total - 1 ))
local kept_others=0
local candidate resolved
if [[ -L "${CURRENT_LINK}" ]]; then
current_target="$(readlink -f "${CURRENT_LINK}" 2>/dev/null || true)"
fi
while IFS= read -r candidate; do
[[ -n "${candidate}" ]] || continue
case "${candidate}" in
*[[:space:]]*) continue ;;
esac
[[ -d "${candidate}" && ! -L "${candidate}" ]] || continue
[[ -e "${candidate}/api-server" || -e "${candidate}/web" ]] || continue
resolved="$(readlink -f "${candidate}")"
if [[ -n "${current_target}" && "${resolved}" == "${current_target}" ]]; then
continue
fi
if (( kept_others < keep_others )); then
kept_others=$(( kept_others + 1 ))
continue
fi
echo "[production-api-deploy] 清理历史 release: ${candidate}"
rm -rf --one-file-system "${resolved}"
done < <(
find "${RELEASE_ROOT}" -mindepth 1 -maxdepth 1 -type d -name '[!.]*' -printf '%T@ %p\n' 2>/dev/null |
sort -rn |
cut -d' ' -f2-
)
}
on_exit() {
local exit_code=$?
cleanup_rendered_systemd_unit
@@ -1697,6 +1745,8 @@ for _ in {1..30}; do
bash "${SCRIPT_DIR}/maintenance-off.sh"
fi
DEPLOY_COMPLETED=1
prune_old_releases "${KEEP_RELEASES}" ||
echo "[production-api-deploy] 历史 release 清理失败(仅影响磁盘占用,不影响本次发布结论)" >&2
echo "[production-api-deploy] 完成: ${RELEASE_DIR}/api-server"
exit 0
fi
+3 -1
View File
@@ -277,11 +277,13 @@ test('hook fixtures do not mutate the calling linked worktree or its index', ()
writeFileSync(join(worktree, 'sentinel.txt'), 'unstaged\n');
const gitDir = git(worktree, 'rev-parse', '--absolute-git-dir').trim();
const indexPath = join(gitDir, 'index');
// `git status` 会刷新并重写索引里的 stat 缓存(macOS 与部分 git 版本上字节必然变化),
// 所以先跑完会写索引的探针再取快照,否则断言的是探针自身的副作用而不是被测行为。
const before = {
refs: git(outerRepo, 'show-ref'),
config: readFileSync(join(outerRepo, '.git', 'config'), 'utf8'),
index: readFileSync(indexPath),
status: git(worktree, 'status', '--porcelain'),
index: readFileSync(indexPath),
};
const result = spawnSync(
process.execPath,

Some files were not shown because too many files have changed in this diff Show More