Files
Genarrative/scripts/check-game-distribution-review-moderation-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

619 lines
20 KiB
JavaScript

// 当前schema回归:prepare → 注入owner凭据并重启API → ratings-e2e新fixture → check。
// 旧库迁移独立验收:旧schema运行ratings-e2e → snapshot → 无损发布新模块/API → migration。
// E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-review-moderation-e2e.mjs prepare|snapshot|migration|check
// 复用 ratings-e2e 产生的21条评价与 .app/reviews-e2e-browser.json;凭据/快照只保存ignored .app。
// API重启前从 .app/review-moderation-e2e/credentials.json 注入 owner 用户名/密码。
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const state = JSON.parse(readFileSync('.app/dev-stack.json', 'utf8'));
const database = process.env.E2E_REVIEWS_DATABASE;
assert(
database === state.database && database.includes('reviews-e2e'),
'必须显式指定当前 reviews-e2e 隔离数据库',
);
const base = state.services['api-server'].url;
assert(
['127.0.0.1', 'localhost'].includes(new URL(base).hostname),
'只允许本机API',
);
const mode = process.argv[2] ?? 'check';
const fixture =
mode === 'prepare'
? null
: JSON.parse(readFileSync('.app/reviews-e2e-browser.json', 'utf8'));
const dir = resolve('.app/review-moderation-e2e');
const credentialPath = resolve(dir, 'credentials.json');
const snapshotPath = resolve(dir, 'old-reviews.json');
const adminPrefix = '/admin/api/game-distribution';
const publicPrefix =
fixture && `/api/game-distribution/games/${fixture.gameId}`;
let checks = 0;
function check(label, ok) {
assert(ok, label);
checks += 1;
console.log(`PASS ${label}`);
}
async function api(path, { token, method = 'GET', body, key } = {}) {
const headers = { 'x-genarrative-response-envelope': 'v1' };
if (token) headers.Authorization = `Bearer ${token}`;
if (key !== undefined) headers['Idempotency-Key'] = key;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetch(`${base}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
const result = await response.json();
return {
status: response.status,
data: result.data,
headers: response.headers,
};
}
async function login(account, admin = false) {
const response = await api(admin ? '/admin/api/login' : '/api/auth/entry', {
method: 'POST',
body: admin
? account
: { purePhoneNumber: account.phone, password: account.password },
});
assert(
response.status === 200 && response.data.token,
`登录fixture失败 ${response.status}`,
);
return response.data;
}
const oldFields = (review) =>
Object.fromEntries(
['id', 'gameId', 'score', 'comment', 'createdAt', 'updatedAt'].map(
(key) => [key, review[key]],
),
);
function prepare() {
mkdirSync(dir, { recursive: true });
if (!existsSync(credentialPath))
writeFileSync(
credentialPath,
JSON.stringify(
{
owner: {
username: 'reviews_e2e_owner',
password: `Rev-${randomUUID()}!`,
},
},
null,
2,
),
{ mode: 0o600 },
);
console.log(
'owner凭据已准备;从ignored credentials.json注入API环境变量后重启API。',
);
}
async function snapshot() {
const reviews = await api(`${publicPrefix}/reviews?pageSize=50`);
assert(
reviews.status === 200 && reviews.data.total >= 21,
'先在旧schema运行ratings-e2e建立21条评价',
);
assert(
reviews.data.reviews.every((review) => !Object.hasOwn(review, 'isHidden')),
'此模式只接受未追加isHidden的旧schema API',
);
mkdirSync(dir, { recursive: true });
writeFileSync(
snapshotPath,
JSON.stringify(
{
database,
gameId: fixture.gameId,
reviews: reviews.data.reviews.map(oldFields),
},
null,
2,
),
);
console.log(
`旧schema快照已保存:${reviews.data.total}条;快照仅在ignored文件。`,
);
}
async function migration() {
const old = JSON.parse(readFileSync(snapshotPath, 'utf8'));
const current = await api(`${publicPrefix}/reviews?pageSize=50`);
check(
'旧schema存量无损升级,默认公开false',
current.status === 200 &&
current.data.total === old.reviews.length &&
old.reviews.every((review) => {
const row = current.data.reviews.find(
(value) => value.id === review.id,
);
return (
row?.isHidden === false &&
JSON.stringify(oldFields(row)) === JSON.stringify(review)
);
}),
);
writeFileSync(
resolve(dir, 'migration-verified.json'),
JSON.stringify(
{
database,
gameId: fixture.gameId,
count: old.reviews.length,
verifiedAt: new Date().toISOString(),
},
null,
2,
),
);
}
async function run() {
const health = await fetch(`${base}/healthz`);
check('API健康', health.ok);
assert(
existsSync(credentialPath),
'先运行prepare,并向API注入owner凭据后重启',
);
const credentials = JSON.parse(readFileSync(credentialPath, 'utf8'));
const ownerLogin = await login(credentials.owner, true);
const owner = ownerLogin.token;
for (const [name, tabs] of [
['allowed', ['game-reviews']],
['denied', ['game-management']],
]) {
credentials[name] = {
username: `reviews_${name}_${randomUUID().replaceAll('-', '')}`,
password: `Rev-${randomUUID()}!`,
};
const created = await api('/admin/api/accounts', {
method: 'POST',
token: owner,
body: {
...credentials[name],
displayName: `评价验收${name}`,
tabPermissions: tabs,
actionPermissions: [],
enabled: true,
},
});
assert(created.status === 200, `后台fixture账号创建失败 ${created.status}`);
}
writeFileSync(credentialPath, JSON.stringify(credentials, null, 2), {
mode: 0o600,
});
const allowedLogin = await login(credentials.allowed, true);
const allowed = allowedLogin.token;
const denied = (await login(credentials.denied, true)).token;
const userLogin = await login(fixture.accounts[0]);
const user = userLogin.token;
const userId = userLogin.user.id;
const query = (suffix = '', token = allowed) =>
api(`${adminPrefix}/user-reviews${suffix}`, { token });
const myReview = () => api(`${publicPrefix}/my-review`, { token: user });
const save = (body) =>
api(`${publicPrefix}/my-review`, { token: user, method: 'PUT', body });
const publicReviews = () => api(`${publicPrefix}/reviews?pageSize=50`);
const before = (await myReview()).data.review;
const reviewPath = `${adminPrefix}/user-reviews/${encodeURIComponent(before.id)}`;
const detail = () => api(reviewPath, { token: allowed });
const moderation = (body, key = randomUUID(), token = allowed) =>
api(`${reviewPath}/moderation`, { token, method: 'POST', key, body });
const body = (action, reason, expectedCreatedAt = before.createdAt) => ({
action,
expectedCreatedAt,
...(reason !== undefined ? { reason } : {}),
});
const listPath = '?gameId=' + encodeURIComponent(fixture.gameId);
for (const path of [
'/user-reviews',
'/user-review-games',
`/user-reviews/${encodeURIComponent(before.id)}`,
]) {
check(`匿名${path}401`, (await api(adminPrefix + path)).status === 401);
check(
`无权限${path}403`,
(await api(adminPrefix + path, { token: denied })).status === 403,
);
}
check(
'无权限写403',
(await moderation(body('hide', '权限检查'), randomUUID(), denied))
.status === 403,
);
check(
'普通账号不能进入后台',
[401, 403].includes((await query('', user)).status),
);
const all = await query(listPath + '&pageSize=50');
check(
'21+全量记录与no-store',
all.status === 200 &&
all.data.total >= 21 &&
all.headers.get('cache-control') === 'no-store',
);
const baseline = all.data.reviews.map(oldFields);
const page2 = await query(listPath + '&page=2');
check(
'后台默认分页20与跨页',
(await query(listPath)).data.reviews.length === 20 &&
page2.data.reviews.length >= 1,
);
const target = page2.data.reviews[0];
check('组合查询目标原来在第二页', target.id === before.id);
const keyword = `CrossPageNeedle[.*]${randomUUID()}`;
const edited = await save({ score: before.score, comment: keyword });
assert(edited.status === 200);
const combined = await query(
`${listPath}&userId=${encodeURIComponent(userId)}&keyword=${encodeURIComponent(keyword)}&status=visible&pageSize=1`,
);
check(
'四条件AND在全量数据筛选,关键词按字面量',
combined.data.total === 1 && combined.data.reviews[0].id === before.id,
);
check(
'区分大小写与空评论不命中',
(
await query(
listPath + '&keyword=' + encodeURIComponent(keyword.toLowerCase()),
)
).data.total === 0,
);
const gameSearch = await api(
`${adminPrefix}/user-review-games?query=${encodeURIComponent(fixture.gameId)}`,
{ token: allowed },
);
check(
'游戏ID精确选择',
gameSearch.status === 200 &&
gameSearch.data.games.some((game) => game.gameId === fixture.gameId),
);
check(
'详情no-store且正文完整',
(await detail()).headers.get('cache-control') === 'no-store' &&
(await detail()).data.review.comment === keyword,
);
for (const [label, request, key, expected] of [
['隐藏空原因', body('hide', ' '), randomUUID(), 422],
['非法动作', body('unknown', 'x'), randomUUID(), 400],
['缺key', body('hide', 'x'), undefined, 400],
]) {
const response =
key === undefined
? await api(`${reviewPath}/moderation`, {
token: allowed,
method: 'POST',
body: request,
})
: await moderation(request, key);
check(`${label}拒绝`, response.status === expected);
}
const preHide = (await myReview()).data.review;
check(
'无效管理请求没有变更或操作记录',
JSON.stringify(oldFields(preHide)) ===
JSON.stringify(oldFields(edited.data.review)) &&
!preHide.isHidden &&
(await detail()).data.operations.length === 0,
);
const total = (await publicReviews()).data.total;
const hideKey = randomUUID();
const hidden = await moderation(
{ ...body('hide', ' 秘密管理原因 '), adminUserId: 'forged-admin' },
hideKey,
);
check(
'隐藏成功原因trim管理员来自会话',
hidden.status === 200 &&
hidden.data.review.isHidden &&
hidden.data.operation.reason === '秘密管理原因' &&
hidden.data.operation.adminUserId === allowedLogin.admin.subject,
);
check(
'隐藏不改用户内容时间',
hidden.data.review.createdAt === preHide.createdAt &&
hidden.data.review.updatedAt === preHide.updatedAt,
);
const hiddenMine = (await myReview()).data.review;
const hiddenPublic = await publicReviews();
check(
'个人显示隐藏,公共排除统计',
hiddenMine.isHidden &&
hiddenPublic.data.total === total - 1 &&
hiddenPublic.data.ratingSummary.ratingCount === total - 1 &&
!hiddenPublic.data.reviews.some((row) => row.id === before.id),
);
const hiddenAverage =
Math.round(
(hiddenPublic.data.reviews.reduce((sum, row) => sum + row.score, 0) /
hiddenPublic.data.total) *
10,
) / 10;
check(
'隐藏后均分基于公开全集',
hiddenPublic.data.ratingSummary.averageScore === hiddenAverage,
);
check(
'普通DTO不泄漏管理原因或管理员',
!JSON.stringify(hiddenMine).includes('秘密管理原因') &&
!Object.hasOwn(hiddenMine, 'operations') &&
!Object.hasOwn(hiddenMine, 'adminUserId'),
);
const newContent = await save({
score: 10,
comment: '隐藏后编辑',
isHidden: false,
adminUserId: 'forged',
});
check(
'用户编辑保留隐藏且摘要不计入',
newContent.status === 200 &&
newContent.data.review.isHidden &&
newContent.data.ratingSummary.ratingCount === total - 1,
);
check(
'隐藏状态组合筛选',
(
await query(
listPath + '&status=hidden&userId=' + encodeURIComponent(userId),
)
).data.total === 1,
);
check(
'同key重试不重复日志',
(await moderation(body('hide', '秘密管理原因'), hideKey)).data.replayed &&
(await detail()).data.operations.length === 1,
);
check(
'同key不同请求409',
(await moderation(body('hide', '另一原因'), hideKey)).status === 409,
);
const noOpKey = randomUUID();
const noOpHide = await moderation(
body('hide', '重复隐藏也记录请求'),
noOpKey,
);
check(
'已隐藏的新key请求仍记录操作',
noOpHide.status === 200 &&
!noOpHide.data.replayed &&
(await detail()).data.operations.length === 2,
);
const restored = await moderation(body('restore'));
check(
'恢复无原因,统计重新加入',
restored.status === 200 &&
!restored.data.review.isHidden &&
restored.data.operation.reason === null &&
(await publicReviews()).data.total === total,
);
const replayHide = await moderation(body('hide', '秘密管理原因'), hideKey);
check(
'旧hide key重放不重新隐藏并返回现状态',
replayHide.data.replayed &&
replayHide.data.review.isHidden === false &&
(await myReview()).data.review.isHidden === false,
);
const replayNoOp = await moderation(
body('hide', '重复隐藏也记录请求'),
noOpKey,
);
check(
'旧无变化操作key不重新执行',
replayNoOp.data.replayed && replayNoOp.data.review.isHidden === false,
);
const longReason = await moderation(body('hide', '😀'.repeat(4000)));
check(
'原因4000码点成功',
longReason.status === 200 &&
[...longReason.data.operation.reason].length === 4000,
);
await moderation(body('restore'));
const deletionKey = randomUUID();
const deleted = await moderation(body('delete', '物理删除验收'), deletionKey);
check(
'删除成功且个人null',
deleted.status === 200 &&
deleted.data.review === null &&
(await myReview()).data.review === null &&
(await detail()).status === 404,
);
check(
'删除后无目标新key404',
(await moderation(body('hide', '已删除目标'))).status === 404,
);
const recreated = await save({ score: 7, comment: '删除后重新评价' });
check(
'删除后重建同唯一ID新创建时间',
recreated.status === 200 &&
recreated.data.review.id === before.id &&
recreated.data.review.createdAt !== before.createdAt &&
!recreated.data.review.isHidden,
);
check(
'旧createdAt新操作409',
(await moderation(body('delete', '旧页面'))).status === 409,
);
const replayDelete = await moderation(
body('delete', '物理删除验收'),
deletionKey,
);
check(
'旧delete key重试不删新评价,返回旧实例null',
replayDelete.status === 200 &&
replayDelete.data.replayed &&
replayDelete.data.review === null &&
(await myReview()).data.review.createdAt ===
recreated.data.review.createdAt,
);
check(
'当前实例详情不混旧日志',
(await detail()).data.operations.length === 0,
);
const logPath =
'/admin/api/database/tables/game_distribution_review_moderation_log/rows?limit=100&search=' +
encodeURIComponent(before.id);
const retainedLogs = await api(logPath, { token: owner });
check(
'删除操作记录独立留存且不复制评论',
retainedLogs.status === 200 &&
retainedLogs.data.rows.some(
(row) => row.cells.operation_id === deleted.data.operation.id,
) &&
!retainedLogs.data.columns.includes('comment'),
);
check(
'评价权限不越权读取私有通用表',
(await api(logPath, { token: allowed })).status === 403,
);
const recreatedBody = (action, reason) =>
body(action, reason, recreated.data.review.createdAt);
await moderation(recreatedBody('hide', '浏览器隐藏提示验收'));
check(
'存量其他评价ID内容时间未改变',
(await query(listPath + '&pageSize=50')).data.reviews
.filter((row) => row.id !== before.id)
.every((row) => {
const previous = baseline.find((value) => value.id === row.id);
return (
previous &&
JSON.stringify(oldFields(row)) === JSON.stringify(previous)
);
}),
);
for (const suffix of ['?status=unknown', '?page=0'])
check(`错误筛选${suffix}400`, (await query(suffix)).status === 400);
const privateGames = await api(
`${adminPrefix}/user-review-games?pageSize=50`,
{ token: allowed },
);
check(
'后台搜索覆盖未公开游戏',
privateGames.data.games.some((game) => game.status === 'unpublished'),
);
const secondPrefix = `/api/game-distribution/games/${fixture.secondGame}`;
const secondMine = (await api(secondPrefix + '/my-review', { token: user }))
.data.review;
assert(secondMine);
const secondModerationPath = `${adminPrefix}/user-reviews/${encodeURIComponent(secondMine.id)}/moderation`;
const secondHide = await api(secondModerationPath, {
token: allowed,
method: 'POST',
key: randomUUID(),
body: {
action: 'hide',
expectedCreatedAt: secondMine.createdAt,
reason: '仅有评价全部隐藏',
},
});
const allHidden = await api(secondPrefix + '/reviews');
check(
'全部隐藏为null/0且0页空列表',
secondHide.status === 200 &&
allHidden.data.reviews.length === 0 &&
allHidden.data.totalPages === 0 &&
allHidden.data.ratingSummary.averageScore === null &&
allHidden.data.ratingSummary.ratingCount === 0,
);
const secondGameDetail = await api(secondPrefix);
const revision = secondGameDetail.data.publicationRevision;
const suspend = await api(
`${adminPrefix}/games/${fixture.secondGame}/suspend`,
{
token: owner,
method: 'POST',
key: randomUUID(),
body: {
expectedPublicationRevision: revision,
reason: '后台不可见游戏管理验收',
},
},
);
assert(suspend.status === 200);
check(
'暂停后网站个人与公开404',
(await api(secondPrefix + '/my-review', { token: user })).status === 404 &&
(await api(secondPrefix + '/reviews')).status === 404,
);
const secondAdmin = await query(
'?gameId=' + encodeURIComponent(fixture.secondGame),
);
check(
'暂停游戏后台仍可读取隐藏评价',
secondAdmin.status === 200 &&
secondAdmin.data.total === 1 &&
secondAdmin.data.reviews[0].game.status === 'suspended',
);
const secondRestore = await api(secondModerationPath, {
token: allowed,
method: 'POST',
key: randomUUID(),
body: { action: 'restore', expectedCreatedAt: secondMine.createdAt },
});
check(
'暂停游戏后台仍可恢复评价',
secondRestore.status === 200 && !secondRestore.data.review.isHidden,
);
await api(secondModerationPath, {
token: allowed,
method: 'POST',
key: randomUUID(),
body: {
action: 'hide',
expectedCreatedAt: secondMine.createdAt,
reason: '验证游戏恢复不解除评价隐藏',
},
});
const gameRestored = await api(
`${adminPrefix}/games/${fixture.secondGame}/restore`,
{
token: owner,
method: 'POST',
key: randomUUID(),
body: { expectedPublicationRevision: revision + 1 },
},
);
check(
'恢复游戏不解除评价隐藏',
gameRestored.status === 200 &&
(await api(secondPrefix + '/my-review', { token: user })).data.review
.isHidden &&
(await api(secondPrefix + '/reviews')).data.total === 0,
);
writeFileSync(
resolve(dir, 'browser.json'),
JSON.stringify(
{
database,
gameId: fixture.gameId,
apiBase: base,
targetReviewId: before.id,
accounts: fixture.accounts,
admin: credentials,
},
null,
2,
),
{ mode: 0o600 },
);
console.log(
`全部${checks}项通过;browser fixture 已写入ignored .app/review-moderation-e2e/browser.json。`,
);
}
const modes = { prepare, snapshot, migration, check: run };
await Promise.resolve()
.then(() => {
assert(
Object.hasOwn(modes, mode),
'模式须为prepare、snapshot、migration或check',
);
return modes[mode]();
})
.catch((error) => {
console.error(`FAIL ${error.message}`);
process.exitCode = 1;
});