Files
Genarrative/scripts/check-game-distribution-ratings-e2e.mjs
T
lhk229 b60aea4e71
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
实现网站游戏评分与评价 (#561)
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。

- 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。
- 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。
- 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。

验证:
- 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。
- 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。
- 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。

发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/561
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-01 19:19:58 +08:00

477 lines
16 KiB
JavaScript

// 网站游戏评价真实 HTTP/SpacetimeDB 验收,仅允许显式指定本机隔离数据库。
// 先运行 npm run dev backend -- --database genarrative-reviews-e2e
// --spacetime-data-dir server-rs/.spacetimedb/reviews-e2e/data --no-interactive
// 再运行 E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-ratings-e2e.mjs
// 地址与本地 runtime writer 凭据从当前 .app/dev-stack.json/data-dir 读取;不会输出凭据。
// fixture 只调用已有数据库 procedure,不上传 OSS,也不验证发行包或真实游玩。
// 创建 21 个临时账号、两个公开游戏及一个未公开游戏;记录留在隔离库供浏览器验收。
// 清理由停止本次栈后删除明确指定的隔离 data-dir 完成,不删除或修改其它数据库。
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const state = JSON.parse(readFileSync(resolve('.app/dev-stack.json'), 'utf8'));
const database = process.env.E2E_REVIEWS_DATABASE;
assert(
database && database === state.database,
'请显式指定与当前 dev 栈一致的 E2E_REVIEWS_DATABASE',
);
assert(database.includes('reviews-e2e'), '本验收仅允许 reviews-e2e 隔离数据库');
const apiBase = process.env.E2E_API_BASE ?? state.services['api-server'].url;
const spacetimeBase = state.services.spacetime.url;
for (const value of [apiBase, spacetimeBase]) {
const url = new URL(value);
assert(
url.protocol === 'http:' &&
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname),
'仅允许本机 HTTP 栈',
);
}
const identity = JSON.parse(
readFileSync(
resolve(state.spacetimeDataDir, 'dev-api-identities/local-node.json'),
'utf8',
),
);
assert(identity.token, '本地 runtime writer 凭据缺失');
let checks = 0;
function check(label, condition) {
assert(condition, label);
checks += 1;
console.log(`PASS ${label}`);
}
async function api(path, { token, body, method = 'GET' } = {}) {
const headers = { 'x-genarrative-response-envelope': 'v1' };
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetch(`${apiBase}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
const json = await response.json();
return {
status: response.status,
data: json.data,
headers: response.headers,
};
}
let operation = 0;
function receipt() {
const key = `reviews-e2e-${randomUUID()}-${operation++}`;
return {
idempotency_key: key,
request_digest: key,
now_micros: Date.now() * 1000,
};
}
async function procedure(name, input) {
const response = await fetch(
`${spacetimeBase}/v1/database/${encodeURIComponent(database)}/call/${name}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${identity.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([input]),
signal: AbortSignal.timeout(30000),
},
);
if (!response.ok) {
const diagnostic = await response.text();
throw new Error(
`${name}: HTTP ${response.status} ${diagnostic.slice(0, 400)}`,
);
}
const result = await response.json();
assert(
(result.success ?? result[0]) === true,
`${name}: fixture procedure 失败`,
);
return result;
}
const pathFor = (gameId) => `/api/game-distribution/games/${gameId}`;
const list = (gameId, query = '') => api(`${pathFor(gameId)}/reviews${query}`);
const mine = (gameId, token) => api(`${pathFor(gameId)}/my-review`, { token });
const save = (gameId, token, body) =>
api(`${pathFor(gameId)}/my-review`, { method: 'PUT', token, body });
async function account(index, stamp) {
const phone = `139${String(stamp + index).slice(-8)}`;
const response = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: 'ReviewsE2e123!' },
});
assert(
response.status === 200 && response.data?.token && response.data?.user?.id,
`账号 ${index} 登录失败 ${response.status}`,
);
return { token: response.data.token, user: response.data.user, phone };
}
async function createGame(owner, title) {
const gameId = `game_${randomUUID().replaceAll('-', '')}`;
await procedure('create_game_distribution_game_and_return', {
game_id: gameId,
owner_user_id: owner.user.id,
title,
summary: '评价验收本地 fixture',
description: null,
category: '休闲',
tags_json: '["e2e"]',
cover_asset_id: null,
author_name: [0, owner.user.displayName],
author_avatar_url: null,
device_support_desktop: true,
device_support_mobile: true,
device_support_touch: true,
input_modes_json: '["keyboard","touch"]',
orientation: 'landscape',
local_project_id: null,
...receipt(),
});
return gameId;
}
async function publish(gameId, owner, revision) {
const versionId = `version_${randomUUID().replaceAll('-', '')}`;
const metadata = {
title: '网站评价验收',
summary: '本地评价 fixture,发行资源不上传',
category: '休闲',
coverAssetId: 'reviews-e2e-cover',
coverObjectKey: 'reviews-e2e/cover.png',
tags: ['e2e'],
deviceSupport: { desktop: true, mobile: true, touch: true },
inputModes: ['keyboard', 'touch'],
orientation: 'landscape',
};
const packageFields = {
package_sha_256: 'a'.repeat(64),
package_bytes: 1,
package_file_count: 1,
package_entry_path: 'index.html',
};
await procedure('create_game_distribution_version_and_return', {
game_id: gameId,
owner_user_id: owner.user.id,
version_id: versionId,
metadata_json: JSON.stringify(metadata),
...packageFields,
local_project_id: null,
...receipt(),
});
const confirmReceipt = receipt();
const { now_micros, ...confirmKeys } = confirmReceipt;
await procedure('confirm_game_distribution_package_and_return', {
version_id: versionId,
owner_user_id: owner.user.id,
...packageFields,
package_object_key: 'reviews-e2e/package.zip',
package_manifest_json: '{}',
...confirmKeys,
updated_at_micros: now_micros,
});
await procedure('submit_game_distribution_version_for_review_and_return', {
version_id: versionId,
owner_user_id: owner.user.id,
expected_publication_revision: revision,
...receipt(),
});
await procedure('approve_game_distribution_version_and_return', {
version_id: versionId,
admin_user_id: 'reviews-e2e-fixture',
expected_publication_revision: revision,
entry_url: `http://127.0.0.1/games/${gameId}/index.html`,
...receipt(),
});
}
async function invisible(gameId, token, label) {
const responses = await Promise.all([
list(gameId),
mine(gameId, token),
save(gameId, token, { score: 5 }),
]);
check(
`${label}:公共/个人/保存均 404`,
responses.every((row) => row.status === 404),
);
}
async function main() {
const health = await fetch(`${apiBase}/healthz`);
check('真实 API 健康', health.ok);
const users = [];
const stamp = Date.now();
for (let index = 0; index < 21; index += 1)
users.push(await account(index, stamp));
const [author, other] = users;
const gameId = await createGame(author, '评价验收主游戏');
const secondGame = await createGame(author, '评价验收第二游戏');
const privateGame = await createGame(author, '评价验收未公开游戏');
await publish(gameId, author, 0);
await publish(secondGame, author, 0);
const empty = await list(gameId);
check(
'匿名空列表与 null/0 统计',
empty.status === 200 &&
empty.data.reviews.length === 0 &&
empty.data.totalPages === 0 &&
empty.data.ratingSummary.averageScore === null &&
empty.data.ratingSummary.ratingCount === 0,
);
check('公开响应 no-store', empty.headers.get('cache-control') === 'no-store');
check(
'已登录未评价返回 null',
(await mine(gameId, author.token)).data.review === null,
);
check('匿名个人读取 401', (await mine(gameId)).status === 401);
check(
'匿名写入 401',
(await save(gameId, undefined, { score: 5 })).status === 401,
);
check(
'失效认证 401',
(await save(gameId, 'invalid-token', { score: 5 })).status === 401,
);
await invisible(privateGame, author.token, '未公开游戏');
for (const [label, body, status] of [
['非整数', { score: 1.5 }, 400],
['0 分', { score: 0 }, 422],
['4001 emoji', { score: 5, comment: '😀'.repeat(4001) }, 422],
])
check(
`${label}拒绝 ${status}`,
(await save(gameId, author.token, body)).status === status,
);
check('无效输入没有写入', (await list(gameId)).data.total === 0);
const concurrent = await Promise.all(
Array.from({ length: 6 }, () => save(gameId, author.token, { score: 1 })),
);
check(
'并发首次保存均成功且同一 ID',
concurrent.every(
(row) =>
row.status === 200 &&
row.data.review.id === concurrent[0].data.review.id,
),
);
const original = concurrent[0].data.review;
check('1 分与省略评论成功', original.score === 1 && original.comment === '');
check('并发只有一人', (await list(gameId)).data.total === 1);
const repeated = await save(gameId, author.token, { score: 1, comment: '' });
check(
'相同内容重试不制造修改时间',
repeated.data.review.updatedAt === original.updatedAt &&
repeated.data.review.createdAt === original.createdAt,
);
const ten = await save(gameId, other.token, { score: 10, comment: '' });
check(
'10 分与空评论成功,全量均分 5.5',
ten.status === 200 &&
ten.data.ratingSummary.averageScore === 5.5 &&
ten.data.ratingSummary.ratingCount === 2,
);
const long = await save(gameId, author.token, {
score: 1,
comment: '😀'.repeat(4000),
});
check(
'4000 Unicode 码点保存完整',
long.status === 200 && [...long.data.review.comment].length === 4000,
);
const text = ' 中文 English 😀 \r\n第二行\r末尾 ';
const normal = await save(gameId, author.token, { score: 1, comment: text });
check(
'保留空格并归一化 CRLF/CR',
normal.data.review.comment === text.replace(/\r\n?/gu, '\n'),
);
const blank = await save(gameId, author.token, {
score: 1,
comment: '\r\n \t',
});
check('纯空白保存为空', blank.data.review.comment === '');
const forged = await save(gameId, author.token, {
score: 3,
comment: '作者更新',
userId: other.user.id,
reviewId: ten.data.review.id,
author: { id: other.user.id, name: '伪造昵称' },
});
check(
'伪造身份不修改他人',
[200, 400].includes(forged.status) &&
(await mine(gameId, other.token)).data.review.score === 10,
);
const second = await save(secondGame, author.token, { score: 8 });
check(
'同账号不同游戏独立',
second.status === 200 &&
second.data.ratingSummary.ratingCount === 1 &&
second.data.review.id !== original.id,
);
for (let index = 2; index < users.length; index += 1) {
const saved = await save(gameId, users[index].token, {
score: (index % 10) + 1,
comment: index % 2 ? '' : `评价 ${index}`,
});
assert(saved.status === 200, `评价 ${index} 保存失败 ${saved.status}`);
}
const firstPage = await list(gameId);
const lastPage = await list(gameId, '?page=2');
const rows = [...firstPage.data.reviews, ...lastPage.data.reviews];
check(
'21 条真实评价,两页 20/1',
firstPage.data.total === 21 &&
firstPage.data.pageSize === 20 &&
firstPage.data.reviews.length === 20 &&
lastPage.data.reviews.length === 1 &&
firstPage.data.totalPages === 2,
);
check(
'公共列表无重复并包含自己的评价',
new Set(rows.map((row) => row.id)).size === 21 &&
rows.filter((row) => row.id === original.id).length === 1,
);
const sorted = [...rows].sort(
(a, b) =>
Date.parse(b.createdAt) - Date.parse(a.createdAt) ||
b.id.localeCompare(a.id),
);
check(
'创建时间倒序与稳定 ID 次序',
rows.every((row, index) => row.id === sorted[index].id),
);
const average =
Math.round(
(rows.reduce((sum, row) => sum + row.score, 0) / rows.length) * 10,
) / 10;
check(
'均分覆盖全部页',
firstPage.data.ratingSummary.averageScore === average &&
lastPage.data.ratingSummary.averageScore === average &&
firstPage.data.ratingSummary.ratingCount === 21,
);
const before = (await mine(gameId, author.token)).data.review;
const edited = await save(gameId, author.token, {
score: 9,
comment: '改分',
});
check(
'修改稳定 ID/创建时间/人数',
edited.data.review.id === before.id &&
edited.data.review.createdAt === before.createdAt &&
edited.data.ratingSummary.ratingCount === 21 &&
edited.data.review.updatedAt !== before.updatedAt,
);
const expectedAverage =
Math.round(
((rows.reduce((sum, row) => sum + row.score, 0) - before.score + 9) /
21) *
10,
) / 10;
check(
'改分重新计算均分',
edited.data.ratingSummary.averageScore === expectedAverage,
);
const commentEdit = await save(gameId, author.token, {
score: 9,
comment: '<script>alert(1)</script>\n**纯文本**',
});
check(
'只改评论不改变均分并保持纯文本',
commentEdit.data.ratingSummary.averageScore === expectedAverage &&
commentEdit.data.review.comment.startsWith('<script>'),
);
const afterRows = [
...(await list(gameId)).data.reviews,
...(await list(gameId, '?page=2')).data.reviews,
];
check(
'编辑不改变排序位置',
afterRows.every((row, index) => row.id === rows[index].id),
);
check(
'超出末页是空列表',
(await list(gameId, '?page=3')).data.reviews.length === 0,
);
check(
'pageSize=1/50 边界',
(await list(gameId, '?pageSize=1')).data.reviews.length === 1 &&
(await list(gameId, '?pageSize=50')).data.reviews.length === 21,
);
for (const query of ['?page=0', '?pageSize=51']) {
check(
`非法分页 ${query} 返回 400`,
(await list(gameId, query)).status === 400,
);
}
const personal = await mine(gameId, author.token);
check(
'个人响应 no-store 与公共同一形状',
personal.headers.get('cache-control') === 'no-store' &&
personal.data.review.id === original.id &&
personal.data.review.author.id === author.user.id,
);
await publish(gameId, author, 1);
check(
'发布新版本保留评价',
(await list(gameId)).data.total === 21 &&
(await mine(gameId, author.token)).data.review.id === original.id,
);
await procedure('unpublish_game_distribution_game_and_return', {
game_id: gameId,
owner_user_id: author.user.id,
expected_publication_revision: 2,
...receipt(),
});
await invisible(gameId, author.token, '作者下架');
await publish(gameId, author, 3);
check('重新公开恢复已有评价', (await list(gameId)).data.total === 21);
await procedure('suspend_game_distribution_game_and_return', {
game_id: gameId,
admin_user_id: 'reviews-e2e-fixture',
expected_publication_revision: 4,
reason: [0, '评价验收'],
...receipt(),
});
await invisible(gameId, author.token, '管理员封禁');
await procedure('restore_game_distribution_game_and_return', {
game_id: gameId,
admin_user_id: 'reviews-e2e-fixture',
expected_publication_revision: 5,
...receipt(),
});
check(
'解除封禁保留评价并可编辑',
(await list(gameId)).data.total === 21 &&
(await save(gameId, author.token, { score: 9, comment: '恢复后编辑' }))
.status === 200,
);
writeFileSync(
resolve('.app/reviews-e2e-browser.json'),
JSON.stringify(
{
database,
apiBase,
gameId,
secondGame,
accounts: [author, other].map(({ phone }) => ({
phone,
password: 'ReviewsE2e123!',
})),
},
null,
2,
),
{ mode: 0o600 },
);
console.log(
`全部 ${checks} 项通过;数据库=${database};主游戏=${gameId};第二游戏=${secondGame}。fixture 保留在隔离库,未上传 OSS。`,
);
}
await main().catch((error) => {
console.error(`FAIL ${error.message}`);
process.exitCode = 1;
});