diff --git a/package.json b/package.json
index 51ceb5ff3..446aa1fc0 100644
--- a/package.json
+++ b/package.json
@@ -93,6 +93,7 @@
"check:game-distribution-fork-authorization-e2e": "node scripts/check-game-distribution-fork-authorization-e2e.mjs",
"check:game-distribution-lineage-e2e": "node scripts/check-game-distribution-lineage-e2e.mjs",
"check:game-distribution-project-bundle-e2e": "node scripts/check-game-distribution-project-bundle-e2e.mjs",
+ "check:game-distribution-collection-e2e": "node scripts/check-game-distribution-collection-e2e.mjs",
"check:production-ops": "node scripts/check-production-ops-guardrails.mjs",
"check:preview-deployer": "node scripts/check-preview-deployer.mjs",
"check:maintenance-page": "node scripts/check-maintenance-page.mjs",
diff --git a/scripts/check-game-distribution-collection-e2e.mjs b/scripts/check-game-distribution-collection-e2e.mjs
new file mode 100644
index 000000000..054454372
--- /dev/null
+++ b/scripts/check-game-distribution-collection-e2e.mjs
@@ -0,0 +1,766 @@
+// 游戏分发「收藏(收录)」链路真实行为验收。
+//
+// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;不需要浏览器。
+//
+// 用法:
+// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
+// node scripts/check-game-distribution-collection-e2e.mjs
+// E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口)
+//
+// 契约来源(逐条读实现确认,未按描述猜):
+// [1] 三条路由(Bearer + no-store):api-server/src/modules/game_distribution.rs:340-352
+// [2] PUT 收藏:同文件 :1393-1426(幂等键必填、响应 `{collected, replayed}`)
+// — 请求摘要绑定 `(user_id, game_id)`::1380-1385
+// [3] DELETE 取消收藏:同文件 :1428-1461(不要求幂等键;不存在也算成功;响应只有 `collected`)
+// [4] GET 我的收藏:同文件 :1465-1481 + `my_collections_payload` :1487-1497(`{games, nextCursor:null}`)
+// [5] 公开详情按可选登录态加 `collected`:同文件 :1305-1373(匿名 / 无效 token → **不加键**)
+// [6] 事务语义:spacetime-module/src/game_distribution.rs:4669-4783
+// — 不存在 → 「作品不存在」(→ 404);存在但不可读 → `GAME_DISTRIBUTION_COLLECTION_STATE_CONFLICT`(→ 409)
+// — 收据键 `(user_id, action, idempotency_key)`,摘要不一致 → 「幂等键对应的请求摘要不一致」(→ 409)
+// — 收藏行主键 `{user_id}:{game_id}`,重复收藏不产生第二行
+// — 取消收藏按确定性主键删除,未收藏也成功
+// [7] 语义常量:shared-contracts/src/game_distribution.rs:33-34
+// (文案刻意不含「不存在」/「已被删除」,避免命中 404 分支泄露未公开作品的存在性)
+//
+// 复用/照抄的 helper(注释里标了出处;抽公共模块会改动其它已提交脚本,故照抄):
+// - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs:115-133
+// - check/note/api/brief/register/gameMetadata/uploadCover/createGame/ownerGame:lineage 脚本 :139-318
+// - publishToPublic(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本 :320-407
+// (顺序与请求体源自 media-e2e.mjs:431-495,523-527,561-573)
+//
+// 与工单描述不一致、按实现断言并在报告里标注的地方:
+// - 工单说「同键不同请求(换作品 / 换用户)→ 409」。实测 **换作品** 是 409(同一收据键、摘要不同),
+// 但 **换用户** 不会命中别人的收据(收据键含 user_id),因此是正常 200 新收藏。api-server 的
+// handler 注释写「换用户也是 409」,与同一提交里 spacetime-module 的注释(「不同用户 / 不同作品
+// 即使共用同一个 Idempotency-Key 也不会互相命中」)相反;本脚本按后者(= 实测)断言。
+
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+
+import JSZip from 'jszip';
+
+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 GATE_KEY = 'game-distribution:publish';
+
+if (!ADMIN_USER || !ADMIN_PASSWORD) {
+ console.error(
+ '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
+ 'game-distribution:publish 写入口并把作品推到公开(收藏要求作品公开可读)。',
+ );
+ process.exit(2);
+}
+
+const devStack = JSON.parse(
+ readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
+);
+const API = (
+ process.env.E2E_API_BASE ??
+ devStack.services?.['api-server']?.url ??
+ ''
+).replace(/\/+$/u, '');
+if (!API) {
+ console.error(
+ '无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' +
+ '或用 E2E_API_BASE 显式指定。',
+ );
+ process.exit(2);
+}
+
+let checks = 0;
+let failures = 0;
+const skipped = 0;
+function check(name, ok, detail = '') {
+ checks += 1;
+ if (!ok) failures += 1;
+ console.log(
+ `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
+ );
+}
+function note(message) {
+ console.log(`NOTE ${message}`);
+}
+
+const COVER_PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+ 'base64',
+);
+
+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,
+ signal: AbortSignal.timeout(120_000),
+ });
+ 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,
+ cacheControl: response.headers.get('cache-control') ?? '',
+ };
+}
+
+function brief(body) {
+ const code = body?.error?.code ?? body?.json?.error?.code ?? '';
+ return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 220)}`;
+}
+
+async function register(prefix) {
+ const phone = `${prefix}${String(Date.now()).slice(-8)}`;
+ const response = await api('/api/auth/entry', {
+ method: 'POST',
+ body: { purePhoneNumber: phone, password: DEV_PASSWORD },
+ });
+ return {
+ phone,
+ response,
+ token: response.data?.token,
+ user: response.data?.user,
+ };
+}
+
+function gameMetadata({ title, coverAssetId }) {
+ return {
+ title,
+ summary: '收藏验收临时作品',
+ description: '',
+ category: '休闲',
+ tags: ['e2e'],
+ coverAssetId,
+ deviceSupport: { desktop: true, mobile: false, touch: false },
+ inputModes: ['keyboard', 'mouse'],
+ orientation: 'landscape',
+ };
+}
+
+async function uploadCover(token, id) {
+ const fileName = `collection-${id}.png`;
+ const ticket = await api('/api/assets/direct-upload-tickets', {
+ method: 'POST',
+ token,
+ body: {
+ legacyPrefix: 'generated-character-drafts',
+ pathSegments: ['game-distribution', 'collection', 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-collection',
+ },
+ });
+ if (confirm.status !== 200) {
+ throw new Error(
+ `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
+ );
+ }
+ return confirm.data.assetObject.assetObjectId;
+}
+
+async function createGame({ token, metadata, idemKey }) {
+ return api('/api/game-distribution/games', {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': idemKey },
+ body: metadata,
+ });
+}
+
+async function ownerGame(token, gameId) {
+ const response = await api(`/api/game-distribution/my-games/${gameId}`, {
+ token,
+ });
+ return { response, game: response.data?.game ?? null };
+}
+
+async function buildReleaseZip(marker) {
+ const zip = new JSZip();
+ zip.file(
+ 'index.html',
+ `
${marker}${marker}
`,
+ );
+ const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
+ return {
+ bytes,
+ fileCount: 1,
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ };
+}
+
+/// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。
+async function publishToPublic({
+ token,
+ admin,
+ gameId,
+ gameRevision,
+ metadata,
+ stamp,
+ tag,
+ label,
+}) {
+ const zip = await buildReleaseZip(`collection-${tag}-${stamp}`);
+ const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `col-version-${tag}-${stamp}` },
+ body: {
+ packageSha256: zip.sha256,
+ packageBytes: zip.bytes.length,
+ packageFileCount: zip.fileCount,
+ packageEntryPath: 'index.html',
+ gameMetadata: metadata,
+ },
+ });
+ const versionId = created.data?.versionId;
+ check(
+ `${label} 版本创建成功`,
+ created.status === 200 && Boolean(versionId),
+ `status=${created.status} ${created.text.slice(0, 140)}`,
+ );
+ if (!versionId) return { versionId: null };
+
+ const upload = await api(
+ `/api/game-distribution/versions/${versionId}/package`,
+ {
+ method: 'PUT',
+ token,
+ headers: {
+ 'Idempotency-Key': `col-upload-${tag}-${stamp}`,
+ 'Content-Type': 'application/zip',
+ },
+ binary: zip.bytes,
+ },
+ );
+ check(
+ `${label} 发行包上传成功`,
+ upload.status === 200,
+ `status=${upload.status}`,
+ );
+
+ const submitted = await api(
+ `/api/game-distribution/versions/${versionId}/submit`,
+ {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `col-submit-${tag}-${stamp}` },
+ body: { expectedPublicationRevision: gameRevision },
+ },
+ );
+ check(
+ `${label} 送审成功(202)`,
+ submitted.status === 202,
+ `status=${submitted.status} ${submitted.text.slice(0, 140)}`,
+ );
+
+ const readback = await api(`/api/game-distribution/versions/${versionId}`, {
+ token,
+ });
+ const approved = await api(
+ `/admin/api/game-distribution/versions/${versionId}/review`,
+ {
+ method: 'POST',
+ token: admin,
+ headers: { 'Idempotency-Key': `col-approve-${tag}-${stamp}` },
+ body: {
+ decision: 'approve',
+ expectedPublicationRevision:
+ readback.data?.version?.publicationRevision ?? gameRevision,
+ },
+ },
+ );
+ check(
+ `${label} 管理员审核通过并公开`,
+ approved.status === 200,
+ `status=${approved.status} ${approved.text.slice(0, 140)}`,
+ );
+ return { versionId };
+}
+
+// ---------- 收藏专用 helper ----------
+
+function collectGame({ token, gameId, key }) {
+ return api(`/api/game-distribution/games/${gameId}/collection`, {
+ method: 'PUT',
+ token,
+ ...(key === undefined ? {} : { headers: { 'Idempotency-Key': key } }),
+ });
+}
+
+function uncollectGame({ token, gameId }) {
+ return api(`/api/game-distribution/games/${gameId}/collection`, {
+ method: 'DELETE',
+ token,
+ });
+}
+
+function myCollections(token) {
+ return api('/api/game-distribution/my-collections', { token });
+}
+
+function publicDetail(gameId, token) {
+ return api(`/api/game-distribution/games/${gameId}`, { token });
+}
+
+function publicCatalog() {
+ return api('/api/game-distribution/games');
+}
+
+function occurrences(games, gameId) {
+ return (games ?? []).filter((game) => game.id === gameId).length;
+}
+
+// ---------- 主流程 ----------
+
+async function main() {
+ console.log(
+ `[collection-e2e] api-server=${API} database=${devStack.database}`,
+ );
+
+ 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: GATE_KEY,
+ enabled: true,
+ rolloutPercent: 100,
+ allowUserIds: [],
+ allowUserTags: [],
+ denyUserIds: [],
+ description: 'E2E 收藏链路',
+ },
+ });
+ check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
+
+ const stamp = Date.now();
+ const suffix = String(stamp).slice(-6);
+ const author = await register('132');
+ const viewer = await register('133');
+ check(
+ '作者账号注册拿到 token',
+ author.response.status === 200 && Boolean(author.token),
+ `status=${author.response.status}`,
+ );
+ check(
+ '第二个账号(未收藏视图)注册拿到 token',
+ viewer.response.status === 200 && Boolean(viewer.token),
+ `status=${viewer.response.status}`,
+ );
+ if (!author.token || !viewer.token) process.exit(1);
+
+ // ---------- fixture:已公开作品 X + 草稿作品 D ----------
+ const metadata = gameMetadata({
+ title: `收藏作品 ${suffix}`,
+ coverAssetId: await uploadCover(author.token, stamp),
+ });
+ const created = await createGame({
+ token: author.token,
+ metadata,
+ idemKey: `col-game-${stamp}`,
+ });
+ const gameId = created.data?.id;
+ check(
+ '作品 X 创建成功',
+ created.status === 200 && Boolean(gameId),
+ `status=${created.status} id=${gameId ?? ''}`,
+ );
+ if (!gameId) process.exit(1);
+ await publishToPublic({
+ token: author.token,
+ admin,
+ gameId,
+ gameRevision: created.data?.publicationRevision ?? 0,
+ metadata,
+ stamp,
+ tag: 'x',
+ label: '作品 X',
+ });
+ const xPublic = await publicDetail(gameId);
+ check(
+ '作品 X 已公开可读',
+ xPublic.status === 200 && xPublic.data?.status === 'published',
+ brief(xPublic),
+ );
+
+ const draftMetadata = gameMetadata({
+ title: `收藏草稿 ${suffix}`,
+ coverAssetId: await uploadCover(author.token, `${stamp}-draft`),
+ });
+ const draftCreated = await createGame({
+ token: author.token,
+ metadata: draftMetadata,
+ idemKey: `col-draft-${stamp}`,
+ });
+ const draftId = draftCreated.data?.id;
+ check(
+ '草稿作品 D 创建成功(用于 409 用例)',
+ draftCreated.status === 200 && Boolean(draftId),
+ `status=${draftCreated.status} id=${draftId ?? ''}`,
+ );
+ if (!draftId) process.exit(1);
+
+ // ---------- 1/2:两次不同幂等键 + 同键重放,且始终只有一行 ----------
+ const listBefore = await myCollections(author.token);
+ check(
+ '收藏前我的收藏列表为空',
+ listBefore.status === 200 &&
+ Array.isArray(listBefore.data?.games) &&
+ listBefore.data.games.length === 0,
+ `status=${listBefore.status} size=${listBefore.data?.games?.length ?? 'n/a'}`,
+ );
+
+ const keyA = `col-key-a-${stamp}`;
+ const keyB = `col-key-b-${stamp}`;
+ const firstCollect = await collectGame({
+ token: author.token,
+ gameId,
+ key: keyA,
+ });
+ check(
+ '1) 首次收藏成功且 replayed=false',
+ firstCollect.status === 200 &&
+ firstCollect.data?.collected === true &&
+ firstCollect.data?.replayed === false,
+ brief(firstCollect),
+ );
+ const secondCollect = await collectGame({
+ token: author.token,
+ gameId,
+ key: keyB,
+ });
+ check(
+ '1) 换幂等键再次收藏仍成功且 replayed=false',
+ secondCollect.status === 200 &&
+ secondCollect.data?.collected === true &&
+ secondCollect.data?.replayed === false,
+ brief(secondCollect),
+ );
+ const listAfterTwoKeys = await myCollections(author.token);
+ check(
+ '1) 两次不同键后该作品在列表中只出现一次(长度=1)',
+ listAfterTwoKeys.data?.games?.length === 1 &&
+ occurrences(listAfterTwoKeys.data?.games, gameId) === 1,
+ `size=${listAfterTwoKeys.data?.games?.length} occurrences=${occurrences(listAfterTwoKeys.data?.games, gameId)}`,
+ );
+
+ const replay = await collectGame({ token: author.token, gameId, key: keyB });
+ check(
+ '2) 同键重放 replayed=true 且仍只一行',
+ replay.status === 200 &&
+ replay.data?.collected === true &&
+ replay.data?.replayed === true &&
+ occurrences((await myCollections(author.token)).data?.games, gameId) ===
+ 1,
+ brief(replay),
+ );
+
+ // ---------- 3:同键不同请求 ----------
+ const sameKeyOtherGame = await collectGame({
+ token: author.token,
+ gameId: draftId,
+ key: keyA,
+ });
+ check(
+ '3) 同键换作品 → 409(幂等摘要不一致)',
+ sameKeyOtherGame.status === 409 &&
+ String(
+ sameKeyOtherGame.error?.details?.message ??
+ sameKeyOtherGame.error?.message ??
+ '',
+ ).includes('幂等'),
+ brief(sameKeyOtherGame),
+ );
+ note(
+ '3) 同键换**用户**不会命中别人的收据:收据键是 (user_id, action, idempotency_key),' +
+ '不同用户共用同一个 Idempotency-Key 是各自独立的新收藏(见下面的跨用户探针),' +
+ '与 api-server handler 注释里「换用户也是 409」不一致,但与该提交里 spacetime-module 的注释一致。',
+ );
+
+ // ---------- 4:DELETE 幂等 ----------
+ const deleted = await uncollectGame({ token: author.token, gameId });
+ check(
+ '4) 已收藏时 DELETE → 200 {collected:false} 且不发 replayed',
+ deleted.status === 200 &&
+ deleted.data?.collected === false &&
+ deleted.data?.replayed === undefined,
+ brief(deleted),
+ );
+ const deletedAgain = await uncollectGame({ token: author.token, gameId });
+ check(
+ '4) 未收藏时再 DELETE 仍 200 {collected:false}',
+ deletedAgain.status === 200 && deletedAgain.data?.collected === false,
+ brief(deletedAgain),
+ );
+ const deletedNever = await uncollectGame({
+ token: author.token,
+ gameId: draftId,
+ });
+ check(
+ '4) 从未收藏过的作品 DELETE 也 200(不要求作品公开)',
+ deletedNever.status === 200 && deletedNever.data?.collected === false,
+ brief(deletedNever),
+ );
+
+ // ---------- 5:未登录 401 + no-store ----------
+ for (const [label, call] of [
+ ['PUT', () => collectGame({ gameId, key: `col-anon-${stamp}` })],
+ ['DELETE', () => uncollectGame({ gameId })],
+ ['GET', () => myCollections('')],
+ ]) {
+ const response = await call();
+ check(`5) 未登录 ${label} → 401`, response.status === 401, brief(response));
+ check(
+ `5) 未登录 ${label} 响应带 Cache-Control: no-store(证明挂载而非 404/405)`,
+ response.cacheControl.includes('no-store'),
+ `cache-control=${response.cacheControl || '∅'}`,
+ );
+ }
+ const noKey = await collectGame({ token: author.token, gameId });
+ check(
+ '5) 已登录但缺 Idempotency-Key 的 PUT → 400',
+ noKey.status === 400,
+ brief(noKey),
+ );
+
+ // ---------- 6:未公开 / 不存在 ----------
+ const draftCollect = await collectGame({
+ token: author.token,
+ gameId: draftId,
+ key: `col-draft-key-${stamp}`,
+ });
+ const draftMessage = String(
+ draftCollect.error?.details?.message ?? draftCollect.error?.message ?? '',
+ );
+ check(
+ '6) 未公开作品 PUT → 409(状态冲突)',
+ draftCollect.status === 409,
+ brief(draftCollect),
+ );
+ check(
+ '6) 409 文案含「状态」且不含「不存在」(不泄露未公开作品的存在性)',
+ draftMessage.includes('状态') && !draftMessage.includes('不存在'),
+ `message=${draftMessage || '∅'}`,
+ );
+ const missingCollect = await collectGame({
+ token: author.token,
+ gameId: `game_${'0'.repeat(32)}`,
+ key: `col-missing-key-${stamp}`,
+ });
+ check(
+ '6) 不存在的 gameId PUT → 404',
+ missingCollect.status === 404,
+ brief(missingCollect),
+ );
+
+ // ---------- 9:跨用户隔离(在跨用户探针之前做,避免被后面的收藏干扰) ----------
+ await collectGame({ token: author.token, gameId, key: `col-iso-${stamp}` });
+ const viewerList = await myCollections(viewer.token);
+ check(
+ '9) 用户 A 收藏后,用户 B 的 my-collections 不含该作品',
+ viewerList.status === 200 &&
+ occurrences(viewerList.data?.games, gameId) === 0,
+ `viewer size=${viewerList.data?.games?.length ?? 'n/a'}`,
+ );
+ const viewerDetail = await publicDetail(gameId, viewer.token);
+ check(
+ '9) 用户 B 的公开详情里 collected=false(未收藏但已认证)',
+ viewerDetail.status === 200 && viewerDetail.data?.collected === false,
+ `collected=${JSON.stringify(viewerDetail.data?.collected ?? null)}`,
+ );
+
+ // 跨用户同键探针(工单预期 409,实现按用户隔离收据 → 期望 200 新收藏)
+ const crossUser = await collectGame({
+ token: viewer.token,
+ gameId,
+ key: keyA,
+ });
+ check(
+ '9+) 另一用户复用 A 用过的同一 Idempotency-Key → 200(收据按用户隔离,非 409)',
+ crossUser.status === 200 &&
+ crossUser.data?.collected === true &&
+ crossUser.data?.replayed === false,
+ brief(crossUser),
+ );
+ check(
+ '9+) 跨用户收藏后 B 的列表含该作品,且 A 的列表不受影响',
+ occurrences((await myCollections(viewer.token)).data?.games, gameId) ===
+ 1 &&
+ occurrences((await myCollections(author.token)).data?.games, gameId) ===
+ 1,
+ `viewer=${occurrences((await myCollections(viewer.token)).data?.games, gameId)} author=${occurrences((await myCollections(author.token)).data?.games, gameId)}`,
+ );
+ await uncollectGame({ token: viewer.token, gameId });
+
+ // ---------- 7:下架 → 重新公开 ----------
+ const beforeUnpublish = await ownerGame(author.token, gameId);
+ const unpublishRevision = beforeUnpublish.game?.publicationRevision ?? 0;
+ const unpublished = await api(
+ `/api/game-distribution/games/${gameId}/unpublish`,
+ {
+ method: 'POST',
+ token: author.token,
+ headers: { 'Idempotency-Key': `col-unpublish-${stamp}` },
+ body: { expectedPublicationRevision: unpublishRevision },
+ },
+ );
+ check('7) 作者下架成功', unpublished.status === 200, brief(unpublished));
+ const hiddenList = await myCollections(author.token);
+ check(
+ '7) 下架后 my-collections 不含该作品(读侧过滤)',
+ hiddenList.status === 200 &&
+ occurrences(hiddenList.data?.games, gameId) === 0,
+ `size=${hiddenList.data?.games?.length} occurrences=${occurrences(hiddenList.data?.games, gameId)}`,
+ );
+ const republish = await publishToPublic({
+ token: author.token,
+ admin,
+ gameId,
+ gameRevision:
+ (await ownerGame(author.token, gameId)).game?.publicationRevision ?? 0,
+ metadata,
+ stamp,
+ tag: 'x-v2',
+ label: '作品 X 重新公开',
+ });
+ check(
+ '7) 重新公开(新版本审核通过)后 my-collections 又含该作品且只一行',
+ republish.versionId !== null &&
+ occurrences((await myCollections(author.token)).data?.games, gameId) ===
+ 1,
+ `occurrences=${occurrences((await myCollections(author.token)).data?.games, gameId)}`,
+ );
+
+ // ---------- 8:公开详情 collected 的匿名 vs 已认证 ----------
+ const anonymousDetail = await publicDetail(gameId);
+ const authorDetail = await publicDetail(gameId, author.token);
+ const anonymousHasKey = Object.hasOwn(
+ anonymousDetail.data ?? {},
+ 'collected',
+ );
+ const authorKeys = Object.keys(authorDetail.data ?? {});
+ const anonymousKeys = Object.keys(anonymousDetail.data ?? {});
+ check(
+ '8) 匿名公开详情没有 collected 键',
+ anonymousDetail.status === 200 && !anonymousHasKey,
+ `keys=${anonymousKeys.join(',')}`,
+ );
+ check(
+ '8) 已登录公开详情恰好多 collected 这一个键且值为 true',
+ authorDetail.status === 200 &&
+ authorDetail.data?.collected === true &&
+ authorKeys.length === anonymousKeys.length + 1 &&
+ authorKeys.every(
+ (key) => key === 'collected' || anonymousKeys.includes(key),
+ ),
+ `authorKeys=${authorKeys.length} anonymousKeys=${anonymousKeys.length} collected=${JSON.stringify(authorDetail.data?.collected ?? null)}`,
+ );
+ const authorWithoutCollected = { ...(authorDetail.data ?? {}) };
+ delete authorWithoutCollected.collected;
+ check(
+ '8) 去掉 collected 后,已登录详情与匿名详情逐字段一致',
+ JSON.stringify(authorWithoutCollected) ===
+ JSON.stringify(anonymousDetail.data ?? {}),
+ `diffKeys=${anonymousKeys.filter((key) => JSON.stringify(authorDetail.data?.[key]) !== JSON.stringify(anonymousDetail.data?.[key])).join(',') || '无'}`,
+ );
+ const catalog = await publicCatalog();
+ const catalogEntry = (catalog.data?.games ?? []).find(
+ (game) => game.id === gameId,
+ );
+ const catalogKeys = Object.keys(catalogEntry ?? {});
+ const mismatchedCatalogKeys = catalogKeys.filter(
+ (key) =>
+ JSON.stringify(catalogEntry?.[key]) !==
+ JSON.stringify(anonymousDetail.data?.[key]),
+ );
+ check(
+ '8) 公开目录里的同作品条目与匿名详情键集合与取值都一致',
+ Boolean(catalogEntry) &&
+ catalogKeys.length === anonymousKeys.length &&
+ mismatchedCatalogKeys.length === 0,
+ `catalogKeys=${catalogKeys.length} detailKeys=${anonymousKeys.length} 不一致=${mismatchedCatalogKeys.join(',') || '无'}`,
+ );
+ check(
+ '8) 我的收藏列表条数与「已收藏」状态一致(nextCursor=null)',
+ (await myCollections(author.token)).data?.nextCursor === null &&
+ occurrences((await myCollections(author.token)).data?.games, gameId) ===
+ 1,
+ `nextCursor=${JSON.stringify((await myCollections(author.token)).data?.nextCursor ?? null)}`,
+ );
+ note(
+ `未覆盖:没有收藏计数类接口可读,因此「只一行」用 my-collections 的长度与出现次数证明(工单允许的方式);` +
+ `当前库内该用户收藏条数=${(await myCollections(author.token)).data?.games?.length ?? 'n/a'}。`,
+ );
+
+ console.log(
+ `[collection-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`,
+ );
+ if (failures > 0) {
+ process.exitCode = 1;
+ }
+}
+
+main().catch((error) => {
+ console.error(`[collection-e2e] 未捕获异常:${error?.stack ?? error}`);
+ process.exitCode = 1;
+});