From 2c084d4ea4955742f2784f4293ddeb2886738a77 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 1 Oct 2026 12:43:02 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E6=B8=B8=E6=88=8F=E8=AF=84?= =?UTF-8?q?=E4=BB=B7=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=E4=B8=8E=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将后台评价功能回归与旧库迁移验收解耦,独立准备隔离账号凭据 收窄重复输入矩阵和测试专用竞态场景,保留安全与幂等覆盖 复用公开版本判断,减少评价列表的重复过滤和中间集合 同步回归步骤与本轮验证证据 --- ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 3 + .../check-game-distribution-ratings-e2e.mjs | 14 +- ...ame-distribution-review-moderation-e2e.mjs | 122 ++++++++++-------- .../module-game-distribution/src/reviews.rs | 1 - .../spacetime-module/src/game_distribution.rs | 67 +++++----- .../game-distribution/GameReviews.test.tsx | 25 +--- 6 files changed, 110 insertions(+), 122 deletions(-) diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index fabef5d5b..04a5906a8 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -383,6 +383,7 @@ ### 迁移与验收 +- 日常评价回归与旧库迁移验收独立运行。先用 `check-game-distribution-ratings-e2e.mjs` 在显式指定的本机 `reviews-e2e` 隔离库生成新 fixture,再运行 `check-game-distribution-review-moderation-e2e.mjs check`;后台凭据通过该脚本的 `prepare` 模式提前准备,按现有后台 owner 配置启动 API。`check` 以本次读取的评价为基线,不依赖旧快照或迁移证据;`snapshot` / `migration` 仅用于下面的旧库升级验收。非法输入的完整矩阵保留在领域/API 测试,真实 HTTP 回归保留代表性拒绝、无写入及权限、统计、幂等与删除重建验证。 - 将隐藏字段追加在已有 Rust 表结构末尾并设置明确默认值 false,按 SpacetimeDB 2.8.3 兼容追加规则发布;新增管理记录表为空。不手工回填或清空旧评价。 - 同步 `migration.rs`、后端表目录、生成绑定和 schema 检查。上线前用隔离数据库验证旧 schema 的评价升级后内容、唯一性、时间保留且全部公开;不把新数据库空表测试当成存量迁移证据。 - 回滚应用时保留新增字段和管理记录;旧后端不认识隐藏状态,会重新暴露已隐藏评价,因此产生隐藏记录后禁止直接回退到未过滤隐藏记录的旧读取实现,采用兼容修复。 @@ -400,4 +401,6 @@ 真实运行时通过项目 dev 脚本启动隔离数据库与 API(`/healthz` 正常);旧 wasm 带 21 条评价兼容升级,逐项核对 ID、正文、评分、创建/修改时间均保持且隐藏默认 false。`scripts/check-game-distribution-review-moderation-e2e.mjs` 完成 57 项 HTTP 检查,另验证伪造管理员身份不能覆盖认证操作人。浏览器验证仅操作本地合成账号与游戏,没有生产数据写入或 OSS 上传。 +回归脚本精简后,在现有本机隔离服务上以新 fixture 复跑:网站 40 项、后台 51 项 HTTP 检查通过,后台 `prepare` / `check` 不再依赖旧库验收产物。网站交互 11 项、评价领域 5 项、Spacetime module 编译、网站类型检查及定向格式/静态检查通过。此轮未重新发布 module 或重跑旧库迁移、浏览器验收;HTTP 结果用于验证脚本回归流程,新的内部读取简化由编译和源码条件对照验证。 + 未验证:生产部署、真实移动设备/系统输入法、无关全量测试和真实游戏包游玩。永久删除由真实 HTTP 验证,浏览器仅验证提示与取消;网络失败草稿由交互自动化验证,未在浏览器断网复现。用户验收尚未完成,计划和里程碑保留;不据此宣称已上线。 diff --git a/scripts/check-game-distribution-ratings-e2e.mjs b/scripts/check-game-distribution-ratings-e2e.mjs index 8524a9065..f9a4b983c 100644 --- a/scripts/check-game-distribution-ratings-e2e.mjs +++ b/scripts/check-game-distribution-ratings-e2e.mjs @@ -236,14 +236,9 @@ async function main() { ); await invisible(privateGame, author.token, '未公开游戏'); for (const [label, body, status] of [ - ['未选择评分', { comment: '仅文字' }, 400], ['非整数', { score: 1.5 }, 400], - ['字符串评分', { score: '5' }, 400], - ['评论类型', { score: 5, comment: 42 }, 400], ['0 分', { score: 0 }, 422], - ['11 分', { score: 11 }, 422], ['4001 emoji', { score: 5, comment: '😀'.repeat(4001) }, 422], - ['4001 空白先校验', { score: 5, comment: ' '.repeat(4001) }, 422], ]) check( `${label}拒绝 ${status}`, @@ -405,14 +400,7 @@ async function main() { (await list(gameId, '?pageSize=1')).data.reviews.length === 1 && (await list(gameId, '?pageSize=50')).data.reviews.length === 21, ); - for (const query of [ - '?page=0', - '?page=-1', - '?page=1.5', - '?page=abc', - '?pageSize=0', - '?pageSize=51', - ]) { + for (const query of ['?page=0', '?pageSize=51']) { check( `非法分页 ${query} 返回 400`, (await list(gameId, query)).status === 400, diff --git a/scripts/check-game-distribution-review-moderation-e2e.mjs b/scripts/check-game-distribution-review-moderation-e2e.mjs index 942f880ea..4baefe389 100644 --- a/scripts/check-game-distribution-review-moderation-e2e.mjs +++ b/scripts/check-game-distribution-review-moderation-e2e.mjs @@ -1,10 +1,11 @@ -// 后台评价管理真实验收:旧库snapshot → 无损发布新模块 → migration → check。 -// E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-review-moderation-e2e.mjs snapshot|migration|check +// 当前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, readFileSync, writeFileSync } from 'node:fs'; +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; @@ -17,14 +18,17 @@ assert( ['127.0.0.1', 'localhost'].includes(new URL(base).hostname), '只允许本机API', ); -const fixture = JSON.parse( - readFileSync('.app/reviews-e2e-browser.json', 'utf8'), -); +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 = `/api/game-distribution/games/${fixture.gameId}`; +const publicPrefix = + fixture && `/api/game-distribution/games/${fixture.gameId}`; let checks = 0; function check(label, ok) { assert(ok, label); @@ -68,6 +72,27 @@ const oldFields = (review) => (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( @@ -78,6 +103,7 @@ async function snapshot() { reviews.data.reviews.every((review) => !Object.hasOwn(review, 'isHidden')), '此模式只接受未追加isHidden的旧schema API', ); + mkdirSync(dir, { recursive: true }); writeFileSync( snapshotPath, JSON.stringify( @@ -90,31 +116,8 @@ async function snapshot() { 2, ), ); - if (!existsSync(credentialPath)) - writeFileSync( - credentialPath, - JSON.stringify( - { - owner: { - username: 'reviews_e2e_owner', - password: `Rev-${randomUUID()}!`, - }, - allowed: { - username: `reviews_allowed_${Date.now()}`, - password: `Rev-${randomUUID()}!`, - }, - denied: { - username: `reviews_denied_${Date.now()}`, - password: `Rev-${randomUUID()}!`, - }, - }, - null, - 2, - ), - { mode: 0o600 }, - ); console.log( - `旧schema快照已保存:${reviews.data.total}条;凭据仅在ignored文件。`, + `旧schema快照已保存:${reviews.data.total}条;快照仅在ignored文件。`, ); } async function migration() { @@ -152,10 +155,9 @@ async function run() { const health = await fetch(`${base}/healthz`); check('API健康', health.ok); assert( - existsSync(resolve(dir, 'migration-verified.json')), - '先运行migration保存存量验证证据', + existsSync(credentialPath), + '先运行prepare,并向API注入owner凭据后重启', ); - const old = JSON.parse(readFileSync(snapshotPath, 'utf8')); const credentials = JSON.parse(readFileSync(credentialPath, 'utf8')); const ownerLogin = await login(credentials.owner, true); const owner = ownerLogin.token; @@ -163,6 +165,10 @@ async function run() { ['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, @@ -176,6 +182,9 @@ async function run() { }); 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; @@ -226,6 +235,7 @@ async function run() { 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与跨页', @@ -268,13 +278,8 @@ async function run() { ); for (const [label, request, key, expected] of [ ['隐藏空原因', body('hide', ' '), randomUUID(), 422], - ['删除缺原因', body('delete'), randomUUID(), 422], - ['原因4001码点', body('hide', '😀'.repeat(4001)), randomUUID(), 422], ['非法动作', body('unknown', 'x'), randomUUID(), 400], - ['无创建时间', { action: 'hide', reason: 'x' }, randomUUID(), 400], - ['错误时间', body('hide', 'x', 'not-a-date'), randomUUID(), 400], ['缺key', body('hide', 'x'), undefined, 400], - ['过长key', body('hide', 'x'), 'k'.repeat(129), 400], ]) { const response = key === undefined @@ -287,6 +292,13 @@ async function run() { 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( @@ -465,19 +477,14 @@ async function run() { (await query(listPath + '&pageSize=50')).data.reviews .filter((row) => row.id !== before.id) .every((row) => { - const previous = old.reviews.find((value) => value.id === row.id); + 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', - '?pageSize=51', - '?page=1.5', - ]) + 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`, @@ -596,13 +603,16 @@ async function run() { `全部${checks}项通过;browser fixture 已写入ignored .app/review-moderation-e2e/browser.json。`, ); } -await ( - process.argv[2] === 'snapshot' - ? snapshot() - : process.argv[2] === 'migration' - ? migration() - : run() -).catch((error) => { - console.error(`FAIL ${error.message}`); - process.exitCode = 1; -}); +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; + }); diff --git a/server-rs/crates/module-game-distribution/src/reviews.rs b/server-rs/crates/module-game-distribution/src/reviews.rs index 0a10d3141..5c1315616 100644 --- a/server-rs/crates/module-game-distribution/src/reviews.rs +++ b/server-rs/crates/module-game-distribution/src/reviews.rs @@ -173,7 +173,6 @@ mod tests { fn review_identity_sort_and_summary_are_stable() { assert_ne!(review_id("a", "bc"), review_id("ab", "c")); assert_ne!(review_id("a:", "b"), review_id("a", ":b")); - assert_eq!(review_id("中文", "😀"), review_id("中文", "😀")); assert_eq!(review_summary([].into_iter()), (None, 0)); assert_eq!(review_summary([8, 8, 9].into_iter()), (Some(8.3), 3)); assert_eq!(review_summary([10, 1].into_iter()), (Some(5.5), 2)); diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index c4d593605..4a9f2bd61 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -96,13 +96,14 @@ fn ensure_game_distribution_review_visible( ctx: &ReducerContext, game_id: &str, ) -> Result<(), String> { - let visible = get_public_game_distribution_game_tx( - ctx, - GameDistributionPublicGameInput { - game_id: game_id.to_string(), - }, - )? - .is_some(); + let visible = ctx + .db + .game_distribution_game() + .game_id() + .find(game_id.to_string()) + .filter(|game| game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED) + .and_then(|game| public_game_distribution_version(ctx, &game)) + .is_some(); if visible { Ok(()) } else { @@ -165,11 +166,8 @@ pub fn list_game_distribution_user_reviews_and_return( .filter(&game_id) .filter(|review| !review.is_hidden) .collect::>(); - let (average_score, rating_count) = module_game_distribution::visible_review_summary( - reviews - .iter() - .map(|review| (review.score, review.is_hidden)), - ); + let (average_score, rating_count) = + module_game_distribution::review_summary(reviews.iter().map(|review| review.score)); reviews.sort_by(|left, right| { module_game_distribution::review_order( left.created_at.to_micros_since_unix_epoch(), @@ -470,7 +468,7 @@ pub fn list_admin_game_distribution_user_reviews_and_return( .keyword .clone() .and_then(normalize_game_distribution_optional); - let candidates = if let Some(game_id) = &game_id { + let mut reviews = if let Some(game_id) = &game_id { tx.db .game_distribution_review() .by_game_distribution_review_game_id() @@ -485,21 +483,18 @@ pub fn list_admin_game_distribution_user_reviews_and_return( } else { tx.db.game_distribution_review().iter().collect::>() }; - let mut reviews = candidates - .into_iter() - .filter(|review| { - module_game_distribution::review_matches_filters( - &review.game_id, - &review.user_id, - &review.comment, - review.is_hidden, - game_id.as_deref(), - user_id.as_deref(), - keyword.as_deref(), - &input.status, - ) - }) - .collect::>(); + reviews.retain(|review| { + module_game_distribution::review_matches_filters( + &review.game_id, + &review.user_id, + &review.comment, + review.is_hidden, + game_id.as_deref(), + user_id.as_deref(), + keyword.as_deref(), + &input.status, + ) + }); reviews.sort_by(|left, right| { module_game_distribution::review_order( left.created_at.to_micros_since_unix_epoch(), @@ -3333,19 +3328,25 @@ fn game_distribution_version_snapshot_for_game( snapshot } -fn public_game_distribution_snapshot( +fn public_game_distribution_version( ctx: &ReducerContext, game: &GameDistributionGame, -) -> Option { +) -> Option { let version_id = game.active_version_id.as_ref()?; - let version = ctx - .db + ctx.db .game_distribution_version() .version_id() .find(version_id) .filter(|version| { version.game_id == game.game_id && version.status == GAME_DISTRIBUTION_VERSION_PUBLISHED - })?; + }) +} + +fn public_game_distribution_snapshot( + ctx: &ReducerContext, + game: &GameDistributionGame, +) -> Option { + let version = public_game_distribution_version(ctx, game)?; Some(GameDistributionPublicGameSnapshot { game: game_distribution_game_snapshot(game), current_version: Some(game_distribution_version_snapshot_for_game(ctx, &version)), diff --git a/src/components/game-distribution/GameReviews.test.tsx b/src/components/game-distribution/GameReviews.test.tsx index 97c0f600d..c7f54963f 100644 --- a/src/components/game-distribution/GameReviews.test.tsx +++ b/src/components/game-distribution/GameReviews.test.tsx @@ -86,13 +86,7 @@ function deferred() { } function Harness() { const state = useGameReviews('game-1', true); - return ( - <> - - - - - ); + return ; } async function form() { return screen.findByRole('textbox', { name: '评论(可选)' }); @@ -319,17 +313,14 @@ describe('游戏评价', () => { expect(mocks.list.mock.calls[2]).toEqual(['game-1', 1]); }); - it('保存后迟到的个人与列表读取不能覆盖新记录或摘要', async () => { - const oldMine = deferred<{ review: GameDistributionReview | null }>(); + it('保存后迟到的初始列表不能覆盖新摘要或结束刷新状态', async () => { const oldList = deferred(); const refreshed = deferred(); const saving = deferred<{ review: GameDistributionReview; ratingSummary: { averageScore: number; ratingCount: number }; }>(); - mocks.mine - .mockResolvedValueOnce({ review: review() }) - .mockReturnValueOnce(oldMine.promise); + mocks.mine.mockResolvedValueOnce({ review: review() }); mocks.list .mockReturnValueOnce(oldList.promise) .mockReturnValueOnce(refreshed.promise); @@ -338,8 +329,6 @@ describe('游戏评价', () => { fireEvent.click(await screen.findByRole('button', { name: '编辑评价' })); fireEvent.change(await form(), { target: { value: '已保存' } }); fireEvent.click(screen.getByRole('button', { name: '提交' })); - // 保存仍在途时发出个人读取;保存成功后该读取不能覆盖权威记录。 - fireEvent.click(screen.getByRole('button', { name: '重读个人' })); const updated = review({ comment: '已保存' }); await act(async () => saving.resolve({ @@ -348,17 +337,15 @@ describe('游戏评价', () => { }), ); await screen.findByText('已保存'); - mocks.list.mockReturnValueOnce(new Promise(() => {})); - fireEvent.click(screen.getByRole('button', { name: '刷新列表' })); await act(async () => { - oldMine.resolve({ review: null }); oldList.resolve(list()); - refreshed.reject(new Error('过期读取失败')); }); expect(screen.getByText('已保存')).toBeTruthy(); expect(screen.getByText('9.0/10 · 1 人评分')).toBeTruthy(); - expect(screen.queryByText('过期读取失败')).toBeNull(); expect(screen.getByText('正在加载评价…')).toBeTruthy(); + await act(async () => refreshed.resolve(list([updated]))); + expect(screen.queryByText('正在加载评价…')).toBeNull(); + expect(screen.getAllByText('已保存')).toHaveLength(2); }); it('快速翻页只接受最新请求,旧失败和加载结束不覆盖当前页', async () => {