From 15c123bc596079aa252643d0a288aa87bee96d5b Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 30 Sep 2026 14:43:58 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E7=BD=91=E7=AB=99=E6=B8=B8=E6=88=8F=E8=AF=84=E5=88=86?= =?UTF-8?q?=E4=B8=8E=E8=AF=84=E4=BB=B7=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增网站游戏评分与评价主规范及独立里程碑 明确唯一评价、编辑取消、公共分页与平均分人数合同 补齐提交期间冻结输入和迟到读取响应隔离的验收要求 同步文档索引、发行范围说明与共享决策记录 --- docs/README.md | 3 +- ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 2 +- ...【里程碑】网站游戏评分与评价-2026-09-30.md | 62 +++++++++ .../shared-memory/decision-log.md | 7 + ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 125 +++++++++++++++++- 5 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 docs/project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md diff --git a/docs/README.md b/docs/README.md index cc3979acf..c3607c2f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,8 @@ ## 当前产品与平台 - [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。 -- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):只描述现役平台壳与图片画布编辑器链路。 +- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):平台壳、图片画布、游戏分发与在线游玩合同;网站游戏评分与评价方案为 proposed,尚未实现。 +- [网站游戏评分与评价里程碑](./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-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 7d22dd8c7..2f5fc6306 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -36,7 +36,7 @@ - 正式状态来自后端,素材仍复用平台上传与归属能力;前端和 AGC 不另建公开游戏状态、owner 事实或审核结果。 - 真实发行包具有不可变版本和 SHA-256,AGC dist 归一化为发行根 `index.html`;网页 ZIP 与 AGC 共用一条服务管道。 - 不恢复退役玩法 API、公开作品表或专属 runtime;不把私有项目源码镜像公开。 -- 首版不包含原生/Wasm 游戏、任意外网依赖、服务端进程、多人联机、云存档、评论/评分/关注、排行榜、推荐算法和收益结算。 +- 本发行里程碑不包含原生/Wasm 游戏、任意外网依赖、服务端进程、多人联机、云存档、评论/评分/关注、排行榜、推荐算法和收益结算。网站评分与评价作为[独立里程碑](./【里程碑】网站游戏评分与评价-2026-09-30.md)推进,不借用本文件的发行验收结论。 - 本文件只协调本业务;不顺带改造图片编辑器、Agent Runtime 执行模型或无关项目数据。 ## 阶段 A:真实包、身份与可恢复发行 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..ec4c0d00b --- /dev/null +++ b/docs/project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md @@ -0,0 +1,62 @@ +# 【里程碑】网站游戏评分与评价 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed(待评审,未实现、未验收) | +| Date | 2026-09-30 | +| Parent Spec | [平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同)中的“网站游戏评分与评价合同”Version 1.0 | + +## 目标 + +在网站公开游戏详情页交付“一用户一游戏一条评分与可选评论 → 查看个人评价 → 编辑与取消/提交 → 公共分页浏览 → 平均分与评分人数更新”的完整行为闭环。 + +## 范围 + +- 复用现有平台登录态,游客可浏览公开评价与统计,登录后可保存和修改自己的唯一评价。 +- 评分为 1–10 整数且必选,评论可空、最多 4000 Unicode 码点;实时显示字数与上限。 +- 已评价默认显示个人记录和编辑按钮,编辑时预填;取消不保存修改,成功提交收起输入模块。 +- 公共列表默认每页 20 条,按首次提交时间倒序;包含当前用户和空评论评价,不因编辑改变位置。 +- 详情显示平均分与评分人数,无人评分为“暂无评分”;人数不因修改增加,均分覆盖全部评价。 +- 评价随游戏身份保留,新版本发布不清空;下架与封禁关闭评价读写,重新公开恢复读取。 +- 覆盖并发唯一性、账号切换、失败与重试、移动端和键盘可操作性。 + +## 不在范围内 + +- AGC 客户端评价、独立评价页面、游戏广场评分卡片、评论回复/点赞/附件/Markdown。 +- 评价删除、评价审核、评分排行/推荐、必须游玩或消费才能评价的门槛。 +- 通用评论框架、统计缓存、评分任务或外部 API;不补做游戏发行原里程碑的生产上线验收。 + +## 依赖与前置条件 + +- 主规范与本里程碑评审通过,整数评分、分页与取消行为等默认规则已确认;均分/人数已由用户确认纳入范围。 +- 当前游戏详情、公开可见性、平台认证、账号公开资料读取与受信服务身份链路在验收环境可用。 +- 能建立独立测试账号及公开游戏,运行真实 SpacetimeDB/API 和网站浏览器验收;不得把本地通过当成生产上线。 +- 新增评价 schema、共享 DTO 与既有绑定变更在实现前写入本里程碑的单独实施计划,不对现有表进行破坏性修改。 + +## 验收标准 + +- [ ] 游客可读公开游戏评价与摘要;未登录不能保存或读取个人评价。 +- [ ] 1/10 分和空评论成功,未选/越界/非整数分数与超长评论均被拒。 +- [ ] 中文、英文、emoji、空格及换行计数一致,4000/4001 边界、粘贴和中文输入法符合主规范。 +- [ ] 已评价先显示自己的记录,编辑预填;取消恢复旧记录,提交中禁用评分选择、评论输入及取消/提交,成功隐藏表单,失败恢复编辑并保留草稿。 +- [ ] 首次取消不落库;同一账号/游戏的重复与并发保存只有一条;不能指定他人身份写入。 +- [ ] 至少 21 条真实记录验证分页、顺序和总数,自己的评价允许在个人区与公共区各出现一次。 +- [ ] 空评论也展示分数并计人数;编辑不增加人数、不改变创建时间或排序位置。 +- [ ] 无评价显示“暂无评分”;改分正确更新全部评价均分,改评论不改变均分,分页不影响统计。 +- [ ] 切换账号或游戏不残留个人草稿、不接受旧响应覆盖;读取失败不伪装为未评价。 +- [ ] 保存成功后此前的个人评价与公共列表读取失效,不覆盖新记录、摘要或列表;快速翻页/重复刷新只接受各区域最新请求的结果,旧请求不改写错误及加载状态。 +- [ ] 游戏下架/封禁后读写不可见,恢复公开后仍能读取已有评价,新版本不清空评价。 +- [ ] 评价读取或保存故障不阻断既有游戏资料与游玩;桌面/移动/键盘操作均可用。 +- [ ] 定向测试、网站类型、DTO、schema/生成绑定、真实 API/SpacetimeDB/browser smoke 及编码/文档索引/diff 检查有可复核结果。 + +## 证据要求 + +- 自动化:评分/字数/唯一性与修改规则、统计、分页、页面交互及账号切换定向测试。 +- 运行时:真实 SpacetimeDB/API 上的两账号写入、并发首次保存与分页统计;真实浏览器编辑/取消/提交、移动端字数提示与键盘评分。 +- 边界:未登录、伪造身份、不可见游戏、4000/4001 字符、0/11/非整数分数、响应丢失与迟到响应。 +- 证据按主规范条款逐项记录环境、操作、结果与未验证原因,原游戏发行证据不自动代替本次验收。 + +## 未验证 + +当前仅有方案文档,所有实现与运行时验收尚未开始。评审后再创建单里程碑实现计划;功能完成前保持本文件活动状态,完成后将稳定结论回写主规范并清理临时计划。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c01d2a426..0e39922dd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,12 @@ # 决策记录 +## 2026-09-30 网站游戏评价范围与状态边界 + +- 已确认需求:网站游戏详情支持每账号每游戏唯一一条 1–10 分评分和可空的评论(最多 4000 字符),可修改自己的评价;个人区默认展示已有评价并提供编辑预填,公共列表分页且不排除自己。 +- 已确认扩展:详情显示真实平均分与评分人数,空评论计人数,修改不增加人数;当前先由后端评价记录计算,不增加统计缓存。 +- 状态:本次仅保存 proposed 方案,尚未实现或验收;整数评分、默认每页 20 条、创建时间排序、取消行为与游戏维度保留按主规范评审默认值推进。原发行 Version 0.2 的排除项不再被解释为永久禁止新增用户评价。 +- 权威入口:[平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同);执行验收使用[独立里程碑](../plans/【里程碑】网站游戏评分与评价-2026-09-30.md),不得把既有发行或本次文档检查当作评价功能上线证据。 + ## 2026-09-30 release 渠道移除产品名与包名后缀 - 决策:`release` 渠道的正式产品名统一为 `陶泥儿`,Windows NSIS、macOS DMG / updater 归档等由 Tauri `productName` 派生的包名不再包含 `Release` 文本;`identifier=world.genarrative.ai-game-creator.release` 与 `release-win` 更新分区保持不变。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 8e98e71bf..ec3a5691a 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -1,8 +1,8 @@ # 平台入口与玩法链路 -> 更新时间:`2026-09-23` +> 更新时间:`2026-09-30` > -> 本文只描述现役主站平台壳、图片画布编辑器和对应后端契约。未出现在现役路由树、shared-contracts 或 SpacetimeDB module / bindings 中的入口与数据对象不属于本文范围。 +> 本文描述现役主站平台壳、图片画布编辑器和游戏分发合同;拟新增能力在各节单独标明状态。“网站游戏评分与评价合同”是待实现方案,不代表现役能力。当前实现以路由树、shared-contracts 和 SpacetimeDB module / bindings 为准。 ## 现役平台入口 @@ -64,7 +64,7 @@ 交付“AGC 一键提交游戏 / 网页上传游戏 ZIP → 后端收取真实发行包 → 校验与人工审核 → 主站发现、详情、游客在线游玩 → 更新与下架”的完整闭环。验收必须使用真实上传、真实存储、真实审核状态和隔离发行域名;静态演示卡片、metadata-only 请求或前端本地发布状态不能作为完成证据。 -首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。暂不做评分、评论、关注、榜单、推荐算法、交易和创作者收入。 +首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。本发行合同 Version 0.2 不包含用户评分与评论,其扩展范围见下文“网站游戏评分与评价合同”;关注、榜单、推荐算法、交易和创作者收入仍不在范围内。 ### 入口与产品体验 @@ -176,3 +176,122 @@ 3. 是否接受首版离线静态包、无 Wasm/外网/持久存档的范围,以及建议包额度、7 天失败包保留和 60 秒缓存撤销窗口;公开/撤销版本及审核记录保留周期待定。 4. 是否接受平台同源路径发行(`/games//` + `sandbox="allow-scripts"` 的不透明来源隔离)替代独立发行域名:选择该方案后独立域名、通配 DNS/TLS 与 CDN 不再是上线门禁,存储区域与运营责任仍需确定。 5. 本节已提供页面行为、发行状态、真实上传、幂等/CAS 和 API 草案,足以评审完整业务;尚不足以直接实现持久化和部署,必须在对应里程碑评审前冻结表/索引/服务身份、额度/清理、最终响应 DTO 与发行基础设施配置。未经评审不建立 `ready` 实施计划,不把 proposed 标记为 accepted。 + +## 网站游戏评分与评价合同 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed(方案待评审,未实现、未验收) | +| Date | 2026-09-30 | +| 入口 | 网站 `/games/detail?id=` | +| 交付目标 | 登录用户对同一游戏保存并修改唯一一条评分与可选评论;所有访客分页浏览评价,并查看真实平均分与评分人数 | +| 里程碑 | [网站游戏评分与评价](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md) | + +### 范围与默认规则 + +- 本次只扩展网站游戏详情页、现有游戏分发后端与共享契约;复用平台账号,评分与评论合并为一条“评价”。 +- 用户已明确要求:评分范围 1–10、评论可空且最多 4000 字符、可修改自己的评价、每用户每游戏最多一条、个人评价展示与编辑预填、公共列表分页且包含自己的评价。 +- 用户已确认加入详情页平均分和评分人数:无人评分显示“暂无评分”,空评论计入评分人数,修改不增加人数,当前规模先由后端读取评价记录计算。 +- 本方案采用以下评审默认值:评分只接受整数;每页默认 20 条;按首次提交时间倒序;取消首次填写清空草稿,取消编辑恢复原评价;评价跟随游戏身份,不随发行版本更新清空。 +- 登录用户均可评价,包括游戏作者;不增加必须先游玩、消费或具有发布权限的条件。评价权限不复用 `game-distribution:publish` 发布灰度。 +- 本次不增加 AGC 客户端评价界面、独立评价页面、游戏目录评分展示、评论回复/点赞/图片/Markdown、评价删除、评价审核或推荐排行;不新建通用评论框架、评分缓存或独立统计任务。 + +### 评分、评论与统计口径 + +1. 评分必选,取值为整数 1–10;首次填写时不预选分数,不能仅提交文字而不评分。 +2. 评论为空字符串或未提供时均允许提交。纯空白评论保存为空字符串;普通评论保留首尾空格与换行。 +3. 输入与提交前统一把 `CRLF` / `CR` 换行为 `LF`,再按 Unicode 码点计数;空格、换行同样计入上限。前端与后端计数一致,不按 UTF-8 字节或 UTF-16 单元计数;组合 emoji 可能含多个码点。 +4. 长度校验先于纯空白归一化,4000 字符允许,4001 字符拒绝;不静默截断后保存。输入框旁始终显示 `当前字数/4000`,超限显示可读错误并阻止提交;中文输入法与粘贴同样按该口径处理。 +5. 每个评价对应一个已认证账号和一个稳定 `gameId`,不绑定 `versionId`。分数与评论一起原子保存;修改已有记录,`createdAt` 不变,有内容变化时更新 `updatedAt`。 +6. 平均分等于所有有效评价分数之和除以评价人数,包含作者、当前用户和只评分的记录;每账号最多计一次。后端返回保留一位小数的平均分,页面显示如“8.2/10 · 26 人评分”。 +7. 无评价时 `ratingCount=0`、`averageScore=null`,显示“暂无评分”;不能显示“0 分”或虚构默认分。修改评分重新计算平均分,人数保持不变;修改文字不改变均分。 +8. 平均分与人数来自后端对该游戏评价的权威读取,不能由当前分页或前端累计计算。本次不持久化总分、人数或均分,不增加缓存及重算任务。 + +### 页面布局与交互 + +游戏详情保留“立即玩”和现有资料,在游戏介绍后增加“我的评价”“全部评价”两个区块;平均分与人数显示在详情资料区,并在评价区域共用同一份后端摘要。 + +| 状态 | 我的评价区域 | 操作结果 | +| --- | --- | --- | +| 未登录 | 显示“登录后评价”入口,不展示可提交表单 | 使用现有平台登录入口;公共列表和评分摘要仍可浏览 | +| 读取自己的评价中 | 显示加载状态 | 确认不存在已有评价前,不提前展示首次填写表单 | +| 已登录且没有评价 | 展示 1–10 分选择、可选评论输入框、字数统计、取消/提交按钮 | 首次输入的评分和文字仅为当前页面草稿 | +| 已登录且已有评价 | 展示自己的分数、评论、修改时间及“编辑评价”按钮,隐藏输入模块 | 空评论仍展示分数,正文不填造提示内容 | +| 编辑已有评价 | 展示输入模块,完整预填分数与评论 | 个人展示区域暂由编辑表单替代,公共列表仍保留该评价 | +| 提交中 | 保留表单及内容,显示提交状态并禁用评分选择、评论输入及取消/提交 | 冻结本次提交内容,防止重复点击和后续输入被成功响应收起表单时丢失;失败后恢复编辑,不把在途请求解释为已保存 | +| 提交成功 | 用后端权威记录显示自己的评价,隐藏输入模块 | 刷新平均分、人数与公共列表 | +| 读取或提交失败 | 给出错误与重试入口 | 读取失败不伪装成“没有评价”;提交失败保留草稿,不覆盖原评价 | + +- 首次填写点击取消:清空评分与文字,保留首次填写表单,不产生后端写入;编辑点击取消:丢弃草稿,恢复上次成功读取/保存的个人评价。 +- 首次提交成功后公共列表回到第一页;修改成功后刷新当前页,保留页码。提交响应中的个人记录与统计摘要先更新,分页读取失败单独提示重试,不回滚已成功保存的评价。 +- 保存成功后,使此前发出的个人评价与公共列表读取失效;迟到响应不得覆盖已保存的个人记录、评分摘要或刷新后的列表。同一游戏内快速翻页或重复刷新时,各读取区域只接受其最新请求的结果,旧请求的成功、错误和加载结束均不得覆盖当前状态。 +- 刷新页面重新读取后端保存结果,不自动保存或恢复未提交草稿;退出登录、切换账号或切换游戏时清空个人评价、草稿与旧错误,旧请求的迟到响应不得写入新上下文。 +- 评论纯文本展示并保留换行,不执行 HTML、不解析 Markdown。移动端评分控件可换行,评论与按钮不被底部导航遮挡;控件支持键盘选择并提供可访问名称。 +- 评分摘要、公共列表和个人评价分别承接加载/失败;评价区域故障不阻断已有游戏资料和游玩动作。摘要加载失败显示重试状态,不能误显示“暂无评分”。 +- 优先复用共享动作按钮、状态提示、空态、输入与分页表现;存在跨页面重复时再抽取共享表现组件,领域规则仍留在后端。 + +### 公共评价列表与分页 + +- 游客和登录用户都能读取公开游戏的评价;列表展示作者公开昵称/头像、分数、评论、首次提交时间,编辑后标明修改时间。 +- 只评分的评价仍进入列表与总数,不制造“无评论”正文;**不排除当前用户**。自己的评价可以在个人区域与公共列表各出现一次,公共列表内部只能出现一条。 +- 使用服务端页码分页,`page` 从 1 开始,`pageSize` 默认 20、允许 1–50;页面提供上一页、下一页、当前页及总页数。前端不能一次取全后自行分页。 +- 默认按 `createdAt` 倒序,同时间按稳定评价标识倒序;编辑不改变排序位置,不新增排序/筛选选项。 +- 无记录时第一页返回空列表、`total=0`、`totalPages=0`,页面显示真实空态并禁用翻页;超出末页返回空列表,不把不存在的页作为服务端错误。 +- 分页与统计在单次读取的一致快照中返回;不同翻页请求之间允许反映新提交,不承诺跨请求冻结整份评价集合。 + +### 数据、身份与持久化边界 + +新增游戏评价私有表,保存以下必要字段;实际 Rust 表定义与绑定在实现计划中确定: + +| 逻辑字段 | 约束 | +| --- | --- | +| `reviewId` | 稳定唯一标识;通过 `gameId + userId` 的无歧义组合标识保证每对游戏/账号只占一个主键 | +| `gameId` | 已存在的稳定游戏身份;建立按游戏查询索引 | +| `userId` | 仅来自平台认证主体,不接受请求指定或转移归属 | +| `score` | 整数 1–10 | +| `comment` | 归一化后的纯文本,最多 4000 Unicode 码点,可为空 | +| `createdAt` | 首次保存时间,修改时不变 | +| `updatedAt` | 最近一次实际内容修改时间 | + +- 游戏必须与当前公开详情采用相同可见性判定,存在已公开可玩版本;未公开、作者下架或管理员封禁时,评价公开读取、个人读取和提交均返回 404。已有评价保留,游戏重新公开后可继续读取和编辑。 +- `api-server` 先认证并取得用户 ID;SpacetimeDB 入口继续校验现有受信运行服务身份。数据库上下文中的调用者是 API 服务身份,不能把它误当成网页用户。 +- 在同一数据库事务里检查游戏可见性并按唯一主键创建或更新;不得用 HTTP 层“先查是否存在,再生成随机 ID 新建”保证唯一性。并发首次提交、双击与重试最终都只有一条记录。 +- 对同一条评价的并发修改采用后提交成功的事务覆盖,避免为该低风险编辑引入新版本锁或操作账本;重复提交相同内容返回已有记录,不变更人数和创建时间,也不制造修改时间。 +- 公共作者资料由现有账号公开投影取得,不能由评论请求伪造昵称或头像;私有表不直接对浏览器订阅公开。日志不记录评论正文、Token 或私有账号字段。 +- 新增表,不删除、改名、重排或修改现有游戏/版本字段;同步迁移登记、表目录、生成绑定与 schema 检查。现有评价数据不存在,无需旧数据回填;已有游戏无评价时自然返回空列表与“暂无评分”。 +- 旧客户端和现有游戏目录、详情 DTO 不需要增加必填字段;评分摘要由新增评价接口提供。本次不扩展 `/api/external/v1`,不改外部 OpenAPI。 +- 规则与校验进入 `module-game-distribution`,表与事务进入 `spacetime-module`,读取经 `spacetime-client` facade,HTTP/鉴权进入现有 `api-server` 分发路由,DTO 同步 `shared-contracts` / `packages/shared`;网站只持有展示和编辑草稿。 + +### HTTP 与 DTO 草案 + +以下接口均为**待实现**,路径以 `/api/game-distribution` 为前缀,返回沿用平台 `ok/data/error/meta` envelope。用户评价与既有后台游戏发行“审核”是不同对象,`/admin/api/game-distribution/reviews` 继续只表示发行审核队列。 + +| 方法与路径 | 身份 | 请求与成功响应 | +| --- | --- | --- | +| `GET /games/{gameId}/reviews?page=1&pageSize=20` | 游客可读 | `data={reviews,page,pageSize,total,totalPages,ratingSummary}`;公共作者资料随每条评价返回 | +| `GET /games/{gameId}/my-review` | 登录用户 | `data={review}`;未评价为 `review:null`,不返回 404 | +| `PUT /games/{gameId}/my-review` | 登录用户 | `{score,comment}`;`comment` 可省略,按空字符串保存;首次保存与修改均返回 `200`、`data={review,ratingSummary}` | + +- `review` 字段:`id`、`gameId`、`author:{id,name,avatarUrl}`、`score`、`comment`、`createdAt`、`updatedAt`;`avatarUrl` 可为空。个人与公共评价使用同一展示形状,时间为统一 UTC 格式。 +- `ratingSummary={averageScore:number|null,ratingCount:number}`;公共列表 `total` 等于 `ratingCount`。`totalPages` 由总数与实际 `pageSize` 得出;均分与人数覆盖该游戏全部评价,不只覆盖当前页。 +- PUT 是“保存我的唯一评价”,不能指定目标用户或改他人记录;首次创建和更新不拆成不同接口,不接收任意 `reviewId` 定位写入。 +- `400` 表示 JSON/字段类型、非整数分数或分页参数错误;`401` 表示未登录/认证失效;`404` 表示游戏不可公开访问;`422` 表示整数评分越界或评论超长;复用现有限流的 `429` 及可读 `5xx` 失败。 +- 网络中断或响应丢失时保存结果可能未知,前端不能宣布“已提交”或删除原评价;保留草稿并可通过个人读取核对结果后重试。PUT 重试不会追加第二条记录。 +- 公共与个人响应均不缓存过期的评价/评分摘要,使用 `Cache-Control: no-store`;不混入发布灰度或扩大现有发行沙箱权限。 + +### 验收标准与证据 + +| 条款 | 必须获得的证据 | +| --- | --- | +| 首次评分与可选评论 | 1 分、10 分和空评论成功;未选择评分、0/11/非整数被拒,后端没有无效写入 | +| 字数与输入 | 中文、英文、emoji、空格与换行按统一口径计数;4000 成功、4001 拒绝,字数提示实时可见,粘贴/中文输入法不绕过上限 | +| 唯一与并发 | 同一用户同游戏的双击、重试与并发首次保存后仍只有一条;不同用户、不同游戏各自独立 | +| 修改与取消 | 已评价先显示个人卡片;编辑完整预填,取消保留旧值,提交更新同一记录;提交中评分、评论和操作按钮均禁用,失败恢复编辑且不丢草稿;首次取消不落库 | +| 公共分页与重复展示 | 至少 21 条真实评价验证翻页/总数/顺序/空态;自己的评价在个人区和所属公共页各出现一次;空评论评价可见,编辑不换位 | +| 均分与人数 | 无记录为 `null/0`;仅评分计人数;改分只改变均分、不加人数;改评论不改变均分;统计基于所有页 | +| 身份与可见性 | 匿名读成功、写/读个人为 401;伪造用户不能修改他人;下架与封禁后读写 404、恢复公开后记录仍在 | +| 生命周期与故障 | 切换账号/游戏后旧响应不覆盖新状态;保存成功后旧读取不覆盖新记录与摘要,快速翻页/刷新只接受各区域最新读取结果;个人读取失败不误展首次表单,摘要失败不误显示无评分;评价失败不阻断游玩 | +| 页面与工程 | 桌面/移动真实浏览器可操作,评分可键盘选择;相关前后端定向测试、网站类型检查、真实 SpacetimeDB/API smoke、DTO/schema/绑定、编码/文档索引/diff 检查 | + +本节仅完成方案文档,所有实现、测试、真实数据库与浏览器验收证据均待补。完整行为作为一个独立里程碑交付;评审通过后再创建仅覆盖该里程碑的实现计划,不把文档编写视为功能验收。 -- 2.52.0 From 97751e4bbde9635a10ba0590ea3a8c325e62715d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 30 Sep 2026 23:28:53 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=BD=91=E7=AB=99?= =?UTF-8?q?=E6=B8=B8=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('