From 97751e4bbde9635a10ba0590ea3a8c325e62715d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 30 Sep 2026 23:28:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=BD=91=E7=AB=99=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E8=AF=84=E5=88=86=E4=B8=8E=E8=AF=84=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增游戏唯一评分评价私有表、事务保存与公共分页统计 接入个人评价读取保存接口并同步前后端契约及生成绑定 在网站详情页实现评分评论、编辑取消、字数统计和请求隔离 补齐定向测试、真实数据库接口验收脚本及文档证据 --- docs/README.md | 4 +- ...�实施计划】网站游戏评分与评价-2026-09-30.md | 43 ++ ...【里程碑】网站游戏评分与评价-2026-09-30.md | 8 +- .../shared-memory/decision-log.md | 3 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 + ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 20 +- .../shared/src/contracts/gameDistribution.ts | 38 ++ .../check-game-distribution-dto-parity.mjs | 6 + .../check-game-distribution-ratings-e2e.mjs | 488 ++++++++++++++++++ server-rs/Cargo.lock | 1 + .../src/modules/game_distribution.rs | 389 +++++++++++++- .../module-game-distribution/src/lib.rs | 5 + .../module-game-distribution/src/reviews.rs | 109 ++++ .../shared-contracts/src/game_distribution.rs | 79 +++ .../spacetime-client/src/active/mapper.rs | 11 +- .../src/active/mapper/game_distribution.rs | 60 +++ .../spacetime-client/src/game_distribution.rs | 111 ++++ .../spacetime-client/src/module_bindings.rs | 44 ++ .../game_distribution_my_review_input_type.rs | 16 + .../game_distribution_review_table.rs | 231 +++++++++ .../game_distribution_review_type.rs | 69 +++ ...ame_distribution_user_review_input_type.rs | 18 + ...istribution_user_review_list_input_type.rs | 17 + ...me_distribution_user_review_result_type.rs | 25 + ..._distribution_user_review_snapshot_type.rs | 23 + ...ribution_my_review_and_return_procedure.rs | 59 +++ ...ution_user_reviews_and_return_procedure.rs | 62 +++ ...ribution_my_review_and_return_procedure.rs | 59 +++ server-rs/crates/spacetime-module/Cargo.toml | 1 + .../spacetime-module/src/game_distribution.rs | 272 ++++++++++ .../crates/spacetime-module/src/migration.rs | 1 + .../game-distribution/GameDetailPage.tsx | 40 +- .../GameDistributionPages.test.tsx | 9 + .../game-distribution/GameReviews.test.tsx | 410 +++++++++++++++ .../game-distribution/GameReviews.tsx | 257 +++++++++ .../game-distribution/gameReviews.css | 123 +++++ .../game-distribution/useGameReviews.ts | 180 +++++++ src/services/gameDistributionClient.test.ts | 38 ++ src/services/gameDistributionClient.ts | 35 ++ 39 files changed, 3346 insertions(+), 25 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】网站游戏评分与评价-2026-09-30.md create mode 100644 scripts/check-game-distribution-ratings-e2e.mjs create mode 100644 server-rs/crates/module-game-distribution/src/reviews.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_my_review_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_review_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_user_review_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_user_review_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_user_review_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_user_review_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_game_distribution_my_review_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_game_distribution_user_reviews_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/save_game_distribution_my_review_and_return_procedure.rs create mode 100644 src/components/game-distribution/GameReviews.test.tsx create mode 100644 src/components/game-distribution/GameReviews.tsx create mode 100644 src/components/game-distribution/gameReviews.css create mode 100644 src/components/game-distribution/useGameReviews.ts diff --git a/docs/README.md b/docs/README.md index c3607c2f8..ad78d57c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,8 +19,8 @@ ## 当前产品与平台 - [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。 -- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):平台壳、图片画布、游戏分发与在线游玩合同;网站游戏评分与评价方案为 proposed,尚未实现。 -- [网站游戏评分与评价里程碑](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md):唯一评价、编辑预填、4000 字符、公共分页与平均分/人数的待评审验收边界。 +- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):平台壳、图片画布、游戏分发与在线游玩合同;网站游戏评分与评价已实现并通过本地验证,待用户验收,未部署。 +- [网站游戏评分与评价里程碑](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md):唯一评价、编辑预填、4000 字符、公共分页与平均分/人数的验收边界与本地证据。 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 diff --git a/docs/project-memory/plans/【实施计划】网站游戏评分与评价-2026-09-30.md b/docs/project-memory/plans/【实施计划】网站游戏评分与评价-2026-09-30.md new file mode 100644 index 000000000..0675ed52d --- /dev/null +++ b/docs/project-memory/plans/【实施计划】网站游戏评分与评价-2026-09-30.md @@ -0,0 +1,43 @@ +# 【实施计划】网站游戏评分与评价 + +| 字段 | 值 | +| --- | --- | +| Milestone | [网站游戏评分与评价](./【里程碑】网站游戏评分与评价-2026-09-30.md) | +| Status | implemented(本地验证通过,待用户验收) | +| Owner | Codex | + +## 修改边界 + +- 按用户确认的最新主规范实施网站详情评价、评分统计、公共分页与个人编辑。 +- 后端限定现有 game-distribution 领域、私有评价表、spacetime-client、共享 DTO 与 API 路由;新增表不破坏现有表。 +- 前端限定网站详情、评价组件与 API client,复用现有登录、按钮和状态组件。 +- 不扩展 AGC、广场、外部 API,不新增删除、审核、回复、点赞、排行榜或统计缓存。 + +## 实现顺序 + +1. 补齐评分、码点长度、归一化、排序统计领域规则与 Rust/TS DTO。 +2. 新增 game_distribution_review 私有表,以游戏/用户组合为唯一主键,按游戏建立索引;同步 migration、表目录、生成绑定。在事务内检查公开可玩状态并保存,读取分页与统计使用一致快照。 +3. facade 和 HTTP 接入公共分页、个人读取、唯一 PUT,身份由认证主体取得,响应 no-store。 +4. 网站实现个人表单/记录/编辑、公共分页与平均分人数,输入冻结及读取请求序号隔离;补定向交互测试。 +5. 定向 Rust/Vitest、typecheck、DTO/schema/编码/索引检查及真实数据库/API/桌面移动浏览器 smoke;记录逐条证据和限制。 + +## 验证命令 + +- `cargo test --manifest-path server-rs/Cargo.toml -p module-game-distribution` +- `cargo test --manifest-path server-rs/Cargo.toml -p api-server game_distribution` +- `cargo test --manifest-path server-rs/Cargo.toml -p spacetime-module game_distribution` +- `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:game-distribution-dto-parity` +- `npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts` +- `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` +- `npm run dev:api-server` 检查 `/healthz`,真实本地 SpacetimeDB/API 验证两账号、唯一并发与至少 21 条评价;浏览器验证桌面、移动、编辑取消与键盘评分。 + +## 风险与回滚点 + +- 当前是新增私有表,无旧数据回填。回滚页面/API 时保留评价数据,不删除数据库记录。 +- 中文/emoji 统一按换行归一化后的 Unicode 码点计数,不依赖 textarea 原生 UTF-16 maxLength。 +- 保存成功与账号/游戏切换使旧读取失效,旧成功、失败、finally 均不得覆盖当前状态。 +- 若环境不支持真实 smoke,明确记录已通过的自动化证据和未验证项,不标记已上线或已验收。 + +## 验证结果 + +本计划步骤已实施;各条行为和验证证据已回写[主规范](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同)。真实数据库/API 49 项及桌面/移动视口浏览器 smoke 通过,未部署。用户验收后与里程碑一起清理。 diff --git a/docs/project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md b/docs/project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md index ec4c0d00b..2856c16d0 100644 --- a/docs/project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md +++ b/docs/project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | proposed(待评审,未实现、未验收) | +| Status | implemented(本地验证通过,待用户验收;未部署) | | Date | 2026-09-30 | | Parent Spec | [平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同)中的“网站游戏评分与评价合同”Version 1.0 | @@ -36,6 +36,8 @@ ## 验收标准 +下列条款已完成本地验证,证据与限制见主规范;保留勾选供用户验收确认。 + - [ ] 游客可读公开游戏评价与摘要;未登录不能保存或读取个人评价。 - [ ] 1/10 分和空评论成功,未选/越界/非整数分数与超长评论均被拒。 - [ ] 中文、英文、emoji、空格及换行计数一致,4000/4001 边界、粘贴和中文输入法符合主规范。 @@ -57,6 +59,6 @@ - 边界:未登录、伪造身份、不可见游戏、4000/4001 字符、0/11/非整数分数、响应丢失与迟到响应。 - 证据按主规范条款逐项记录环境、操作、结果与未验证原因,原游戏发行证据不自动代替本次验收。 -## 未验证 +## 实施与验证状态 -当前仅有方案文档,所有实现与运行时验收尚未开始。评审后再创建单里程碑实现计划;功能完成前保持本文件活动状态,完成后将稳定结论回写主规范并清理临时计划。 +实现与本地验收已完成,详细证据已回写主规范:前端 77 项、领域 18 项、API 31 项、DTO 4 项、真实数据库/API 49 项(21 条评价)与桌面/移动视口浏览器操作通过。网站类型、定向严格类型/ESLint、DTO/schema/绑定、DDD/runtime access、编码/索引/diff 检查通过。未部署,未验证物理手机/真实系统输入法或发行资源游玩;用户验收后清理本里程碑和实施计划。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0e39922dd..90295562b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,7 +4,8 @@ - 已确认需求:网站游戏详情支持每账号每游戏唯一一条 1–10 分评分和可空的评论(最多 4000 字符),可修改自己的评价;个人区默认展示已有评价并提供编辑预填,公共列表分页且不排除自己。 - 已确认扩展:详情显示真实平均分与评分人数,空评论计人数,修改不增加人数;当前先由后端评价记录计算,不增加统计缓存。 -- 状态:本次仅保存 proposed 方案,尚未实现或验收;整数评分、默认每页 20 条、创建时间排序、取消行为与游戏维度保留按主规范评审默认值推进。原发行 Version 0.2 的排除项不再被解释为永久禁止新增用户评价。 +- 状态:用户已确认按最新方案实现;整数评分、默认每页 20 条、创建时间排序、取消行为与游戏维度保留作为现行合同。网站与后端实现已落地,本地验收证据见主规范;用户验收与生产发布单独确认。原发行 Version 0.2 的排除项不再被解释为永久禁止新增用户评价。 +- 实现边界:新增私有 `game_distribution_review` 表和三条评价 API,游戏/账号组合主键保证唯一;可见性与写入同事务,分页与均分来自一致快照,作者资料读时关联账号。网站用游戏/账号上下文隔离草稿,以读取序号隔离旧成功、错误和加载结束,保存期间冻结输入。 - 权威入口:[平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同);执行验收使用[独立里程碑](../plans/【里程碑】网站游戏评分与评价-2026-09-30.md),不得把既有发行或本次文档检查当作评价功能上线证据。 ## 2026-09-30 release 渠道移除产品名与包名后缀 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4f61f7f79..415bc5e99 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -461,6 +461,13 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。 - 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。 +### `game_distribution_review` + +- Rust 结构体:`GameDistributionReview`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供评价投影。 +- 主键 `review_id` 使用游戏 ID 字节长度前缀与 `game_id`、`user_id` 无歧义组合,确保每账号每游戏只有一条;`by_game_distribution_review_game_id` 用于游戏评价读取。保存 `score`(1–10 整数)、`comment`(换行统一 LF 后最多 4000 Unicode 码点)、首次创建与最近实际修改时间,不绑定发行版本。 +- 事务内复用公开详情可见性,创建或更新唯一评价;重复相同内容不改时间。公共分页按创建时间和主键倒序,作者资料从 `user_account` 读取;均分和人数与分页在同一事务快照内计算,不落额外统计或缓存。 +- 网站 HTTP:`GET /api/game-distribution/games/{gameId}/reviews` 允许游客页码读取;`GET/PUT /api/game-distribution/games/{gameId}/my-review` 使用认证账号,均返回 `Cache-Control: no-store`。未公开、下架、暂停或无有效公开版本时统一 404;不受发布灰度影响。完整行为见玩法链路的“网站游戏评分与评价合同”。 + ### `game_distribution_version` - Rust 结构体:`GameDistributionVersion` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index ec3a5691a..562c8338c 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -182,7 +182,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | proposed(方案待评审,未实现、未验收) | +| Status | implemented(本地验证通过,待用户验收;未部署) | | Date | 2026-09-30 | | 入口 | 网站 `/games/detail?id=` | | 交付目标 | 登录用户对同一游戏保存并修改唯一一条评分与可选评论;所有访客分页浏览评价,并查看真实平均分与评分人数 | @@ -242,7 +242,7 @@ ### 数据、身份与持久化边界 -新增游戏评价私有表,保存以下必要字段;实际 Rust 表定义与绑定在实现计划中确定: +游戏评价私有表 `game_distribution_review` 保存以下必要字段: | 逻辑字段 | 约束 | | --- | --- | @@ -263,9 +263,9 @@ - 旧客户端和现有游戏目录、详情 DTO 不需要增加必填字段;评分摘要由新增评价接口提供。本次不扩展 `/api/external/v1`,不改外部 OpenAPI。 - 规则与校验进入 `module-game-distribution`,表与事务进入 `spacetime-module`,读取经 `spacetime-client` facade,HTTP/鉴权进入现有 `api-server` 分发路由,DTO 同步 `shared-contracts` / `packages/shared`;网站只持有展示和编辑草稿。 -### HTTP 与 DTO 草案 +### HTTP 与 DTO -以下接口均为**待实现**,路径以 `/api/game-distribution` 为前缀,返回沿用平台 `ok/data/error/meta` envelope。用户评价与既有后台游戏发行“审核”是不同对象,`/admin/api/game-distribution/reviews` 继续只表示发行审核队列。 +以下接口已实现,路径以 `/api/game-distribution` 为前缀,返回沿用平台 `ok/data/error/meta` envelope。用户评价与既有后台游戏发行“审核”是不同对象,`/admin/api/game-distribution/reviews` 继续只表示发行审核队列。 | 方法与路径 | 身份 | 请求与成功响应 | | --- | --- | --- | @@ -294,4 +294,14 @@ | 生命周期与故障 | 切换账号/游戏后旧响应不覆盖新状态;保存成功后旧读取不覆盖新记录与摘要,快速翻页/刷新只接受各区域最新读取结果;个人读取失败不误展首次表单,摘要失败不误显示无评分;评价失败不阻断游玩 | | 页面与工程 | 桌面/移动真实浏览器可操作,评分可键盘选择;相关前后端定向测试、网站类型检查、真实 SpacetimeDB/API smoke、DTO/schema/绑定、编码/文档索引/diff 检查 | -本节仅完成方案文档,所有实现、测试、真实数据库与浏览器验收证据均待补。完整行为作为一个独立里程碑交付;评审通过后再创建仅覆盖该里程碑的实现计划,不把文档编写视为功能验收。 +实现已覆盖本节合同,按独立里程碑进行本地验收;具体证据如下。生产发布与用户验收仍单独确认,不能把本地验证当成上线证据。 + +| 证据 | 当前结果 | +| --- | --- | +| 前端交互与 API client | 游戏详情/发布/作者页等相关 Vitest 共 77 项通过,其中新增评价交互 10 项;覆盖编辑取消、空评论、码点上限、提交冻结、失败保留、保存后旧读取、快速分页与账号/游戏切换 | +| 领域与 HTTP/DTO | `module-game-distribution` 18 项、`api-server game_distribution` 31 项、共享 DTO 4 项通过;SpacetimeDB module 编译与 wasm 构建通过。module 的 `game_distribution` 测试筛选为 0 项,事务行为由真实数据库验收补充 | +| 工程契约 | 网站 typecheck 与游戏组件测试定向严格类型检查、定向 ESLint、DTO parity(20 组)、2.8.3 绑定生成/schema guard(86 表)、DDD/runtime access、共享生成契约、编码/文档索引/diff 检查通过 | +| 真实数据库/API | 使用本机隔离 `genarrative-reviews-e2e` 数据库,`E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-ratings-e2e.mjs` 49 项通过;含 21 个账号/评价、六个并发首次保存、唯一性/同内容重试、1/10/空评论、码点与空白 4000/4001、换行归一化、身份伪造、分页/全量均分、no-store、下架/封禁/恢复公开与新版本保留。使用 `npm run dev:api-server` 拉起 API,健康检查通过;fixture 经现有受信 procedure 建立公开游戏,不上传 OSS,不替代发行资源/游玩验收 | +| 浏览器 | 本地真实 Chromium:1280×900 桌面与 375×812 移动视口通过游客浏览、登录、自己的评价在个人区/公共第二页各展示一次、编辑预填、取消恢复、方向键评分、提交收起、页码保留、重载回读、移动端空评论/1 分提交。4001 个 emoji 输入显示 `4001/4000` 并禁用提交,评分控件换行且输入/操作按钮不被底部导航遮挡;评论中的 HTML/Markdown 按纯文本显示。桌面与移动截图保存在本地忽略目录供复核 | +| 验证限制 | 未使用物理手机或真实系统中文输入法;组合输入事件、超限和迟到响应/故障路径由定向交互测试覆盖。未重跑与评价无关的完整测试套件 | +| 发布边界 | 未部署 dev 或 production;未执行原游戏发行生产验收 | diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 388a3ac3a..9de388abe 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -41,6 +41,44 @@ export type GameDistributionAuthor = { avatarUrl?: string | null; }; +export type GameDistributionReview = { + id: string; + gameId: string; + author: GameDistributionAuthor; + score: number; + comment: string; + createdAt: string; + updatedAt: string; +}; + +export type GameDistributionRatingSummary = { + averageScore: number | null; + ratingCount: number; +}; + +export type GameDistributionReviewsResponse = { + reviews: GameDistributionReview[]; + page: number; + pageSize: number; + total: number; + totalPages: number; + ratingSummary: GameDistributionRatingSummary; +}; + +export type GameDistributionMyReviewResponse = { + review: GameDistributionReview | null; +}; + +export type GameDistributionSaveReviewRequest = { + score: number; + comment?: string; +}; + +export type GameDistributionSaveReviewResponse = { + review: GameDistributionReview; + ratingSummary: GameDistributionRatingSummary; +}; + export type GameDistributionVersionStatus = | 'awaiting_upload' | 'uploaded' diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 672cd1b2b..f159980ad 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -33,6 +33,12 @@ const PAIRS = [ ['GameDistributionOrientation', 'GameDistributionOrientation'], ['GameDistributionDeviceSupport', 'GameDistributionDeviceSupport'], ['GameDistributionAuthor', 'GameDistributionAuthor'], + ['GameDistributionReview', 'GameDistributionReview'], + ['GameDistributionRatingSummary', 'GameDistributionRatingSummary'], + ['GameDistributionReviewsResponse', 'GameDistributionReviewsResponse'], + ['GameDistributionMyReviewResponse', 'GameDistributionMyReviewResponse'], + ['GameDistributionSaveReviewRequest', 'GameDistributionSaveReviewRequest'], + ['GameDistributionSaveReviewResponse', 'GameDistributionSaveReviewResponse'], ['GameDistributionVersionSummary', 'GameDistributionVersionSummary'], ['GameDistributionGameSummary', 'GameDistributionGame'], ['GameDistributionListResponse', 'GameDistributionListResponse'], diff --git a/scripts/check-game-distribution-ratings-e2e.mjs b/scripts/check-game-distribution-ratings-e2e.mjs new file mode 100644 index 000000000..8524a9065 --- /dev/null +++ b/scripts/check-game-distribution-ratings-e2e.mjs @@ -0,0 +1,488 @@ +// 网站游戏评价真实 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 [ + ['未选择评分', { 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}`, + (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: '\n**纯文本**', + }); + check( + '只改评论不改变均分并保持纯文本', + commentEdit.data.ratingSummary.averageScore === expectedAverage && + commentEdit.data.review.comment.startsWith('