实现网站游戏评分与评价
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m6s
Project CI / Backend tests (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m51s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m15s
Project CI / Native shell tests (pull_request) Successful in 5m37s
Project CI / Repository checks (pull_request) Successful in 2m48s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m6s
Project CI / Backend tests (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m51s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m15s
Project CI / Native shell tests (pull_request) Successful in 5m37s
Project CI / Repository checks (pull_request) Successful in 2m48s
新增游戏唯一评分评价私有表、事务保存与公共分页统计 接入个人评价读取保存接口并同步前后端契约及生成绑定 在网站详情页实现评分评论、编辑取消、字数统计和请求隔离 补齐定向测试、真实数据库接口验收脚本及文档证据
This commit is contained in:
+2
-2
@@ -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 契约唯一机器可读来源。
|
||||
|
||||
@@ -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 通过,未部署。用户验收后与里程碑一起清理。
|
||||
@@ -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 检查通过。未部署,未验证物理手机/真实系统输入法或发行资源游玩;用户验收后清理本里程碑和实施计划。
|
||||
|
||||
@@ -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 渠道移除产品名与包名后缀
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | proposed(方案待评审,未实现、未验收) |
|
||||
| Status | implemented(本地验证通过,待用户验收;未部署) |
|
||||
| Date | 2026-09-30 |
|
||||
| 入口 | 网站 `/games/detail?id=<gameId>` |
|
||||
| 交付目标 | 登录用户对同一游戏保存并修改唯一一条评分与可选评论;所有访客分页浏览评价,并查看真实平均分与评分人数 |
|
||||
@@ -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;未执行原游戏发行生产验收 |
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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: '<script>alert(1)</script>\n**纯文本**',
|
||||
});
|
||||
check(
|
||||
'只改评论不改变均分并保持纯文本',
|
||||
commentEdit.data.ratingSummary.averageScore === expectedAverage &&
|
||||
commentEdit.data.review.comment.startsWith('<script>'),
|
||||
);
|
||||
const afterRows = [
|
||||
...(await list(gameId)).data.reviews,
|
||||
...(await list(gameId, '?page=2')).data.reviews,
|
||||
];
|
||||
check(
|
||||
'编辑不改变排序位置',
|
||||
afterRows.every((row, index) => row.id === rows[index].id),
|
||||
);
|
||||
check(
|
||||
'超出末页是空列表',
|
||||
(await list(gameId, '?page=3')).data.reviews.length === 0,
|
||||
);
|
||||
check(
|
||||
'pageSize=1/50 边界',
|
||||
(await list(gameId, '?pageSize=1')).data.reviews.length === 1 &&
|
||||
(await list(gameId, '?pageSize=50')).data.reviews.length === 21,
|
||||
);
|
||||
for (const query of [
|
||||
'?page=0',
|
||||
'?page=-1',
|
||||
'?page=1.5',
|
||||
'?page=abc',
|
||||
'?pageSize=0',
|
||||
'?pageSize=51',
|
||||
]) {
|
||||
check(
|
||||
`非法分页 ${query} 返回 400`,
|
||||
(await list(gameId, query)).status === 400,
|
||||
);
|
||||
}
|
||||
const personal = await mine(gameId, author.token);
|
||||
check(
|
||||
'个人响应 no-store 与公共同一形状',
|
||||
personal.headers.get('cache-control') === 'no-store' &&
|
||||
personal.data.review.id === original.id &&
|
||||
personal.data.review.author.id === author.user.id,
|
||||
);
|
||||
await publish(gameId, author, 1);
|
||||
check(
|
||||
'发布新版本保留评价',
|
||||
(await list(gameId)).data.total === 21 &&
|
||||
(await mine(gameId, author.token)).data.review.id === original.id,
|
||||
);
|
||||
await procedure('unpublish_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
owner_user_id: author.user.id,
|
||||
expected_publication_revision: 2,
|
||||
...receipt(),
|
||||
});
|
||||
await invisible(gameId, author.token, '作者下架');
|
||||
await publish(gameId, author, 3);
|
||||
check('重新公开恢复已有评价', (await list(gameId)).data.total === 21);
|
||||
await procedure('suspend_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
admin_user_id: 'reviews-e2e-fixture',
|
||||
expected_publication_revision: 4,
|
||||
reason: [0, '评价验收'],
|
||||
...receipt(),
|
||||
});
|
||||
await invisible(gameId, author.token, '管理员封禁');
|
||||
await procedure('restore_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
admin_user_id: 'reviews-e2e-fixture',
|
||||
expected_publication_revision: 5,
|
||||
...receipt(),
|
||||
});
|
||||
check(
|
||||
'解除封禁保留评价并可编辑',
|
||||
(await list(gameId)).data.total === 21 &&
|
||||
(await save(gameId, author.token, { score: 9, comment: '恢复后编辑' }))
|
||||
.status === 200,
|
||||
);
|
||||
writeFileSync(
|
||||
resolve('.app/reviews-e2e-browser.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
database,
|
||||
apiBase,
|
||||
gameId,
|
||||
secondGame,
|
||||
accounts: [author, other].map(({ phone }) => ({
|
||||
phone,
|
||||
password: 'ReviewsE2e123!',
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
console.log(
|
||||
`全部 ${checks} 项通过;数据库=${database};主游戏=${gameId};第二游戏=${secondGame}。fixture 保留在隔离库,未上传 OSS。`,
|
||||
);
|
||||
}
|
||||
await main().catch((error) => {
|
||||
console.error(`FAIL ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Generated
+1
@@ -5472,6 +5472,7 @@ dependencies = [
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-editor-agent",
|
||||
"module-game-distribution",
|
||||
"module-runtime",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -7,16 +7,19 @@ use std::{
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, Bytes},
|
||||
extract::{DefaultBodyLimit, Extension, Path, Query, State},
|
||||
extract::{
|
||||
DefaultBodyLimit, Extension, Path, Query, Request, State,
|
||||
rejection::{JsonRejection, QueryRejection},
|
||||
},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
middleware,
|
||||
middleware::{self, Next},
|
||||
response::Response,
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use module_game_distribution::{
|
||||
MAX_PACKAGE_BYTES, ReleaseAssetError, ReleasePackageError, ReleasePackageManifest,
|
||||
compute_request_digest, extract_release_asset, release_asset_content_type,
|
||||
validate_release_zip,
|
||||
compute_request_digest, extract_release_asset, normalize_review_comment,
|
||||
release_asset_content_type, validate_release_zip,
|
||||
};
|
||||
use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest};
|
||||
use platform_oss::{
|
||||
@@ -26,18 +29,22 @@ use platform_oss::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::game_distribution::{
|
||||
GAME_DISTRIBUTION_CATEGORIES, GameDistributionCreateGameRequest,
|
||||
GAME_DISTRIBUTION_CATEGORIES, GameDistributionAuthor, GameDistributionCreateGameRequest,
|
||||
GameDistributionCreateVersionRequest, GameDistributionInputMode,
|
||||
GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest,
|
||||
GameDistributionMyReviewResponse, GameDistributionPublishMetadataSuggestion,
|
||||
GameDistributionPublishMetadataSuggestionRequest, GameDistributionRatingSummary,
|
||||
GameDistributionReview, GameDistributionReviewsResponse, GameDistributionSaveReviewRequest,
|
||||
GameDistributionSaveReviewResponse,
|
||||
};
|
||||
use spacetime_client::{
|
||||
GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord,
|
||||
GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput,
|
||||
GameDistributionCancelVersionRecordInput, GameDistributionGameRecord,
|
||||
GameDistributionGetGameRecordInput, GameDistributionPublicGameListRecordInput,
|
||||
GameDistributionPublicGameRecord, GameDistributionRejectRecordInput,
|
||||
GameDistributionRestoreRecordInput, GameDistributionSubmitReviewRecordInput,
|
||||
GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput,
|
||||
GameDistributionPublicGameRecord, GameDistributionRatingSummaryRecord,
|
||||
GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput,
|
||||
GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput,
|
||||
GameDistributionUnpublishRecordInput, GameDistributionUserReviewRecord,
|
||||
GameDistributionVersionRecord, SpacetimeClientError,
|
||||
};
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -142,6 +149,25 @@ struct GameListQuery {
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UserReviewListQuery {
|
||||
page: Option<u32>,
|
||||
page_size: Option<u32>,
|
||||
}
|
||||
|
||||
impl UserReviewListQuery {
|
||||
fn pagination(self) -> Result<(u32, u32), AppError> {
|
||||
let page = self.page.unwrap_or(1);
|
||||
let page_size = self.page_size.unwrap_or(20);
|
||||
if page == 0 || !(1..=50).contains(&page_size) {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("页码须从 1 开始,每页条数须为 1–50"));
|
||||
}
|
||||
Ok((page, page_size))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdminReviewListQuery {
|
||||
limit: Option<u32>,
|
||||
@@ -190,6 +216,20 @@ struct AdminRestoreGameRequest {
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
let user_reviews = Router::new()
|
||||
.route(
|
||||
"/api/game-distribution/games/{game_id}/my-review",
|
||||
get(get_my_review).put(save_my_review),
|
||||
)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
))
|
||||
.route(
|
||||
"/api/game-distribution/games/{game_id}/reviews",
|
||||
get(list_user_reviews),
|
||||
)
|
||||
.route_layer(middleware::from_fn(add_user_review_response_headers));
|
||||
let protected = Router::new()
|
||||
.route(
|
||||
"/api/game-distribution/publish-metadata/suggestions",
|
||||
@@ -288,9 +328,138 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
get(serve_release_entry),
|
||||
)
|
||||
.merge(protected)
|
||||
.merge(user_reviews)
|
||||
.merge(admin)
|
||||
}
|
||||
|
||||
async fn add_user_review_response_headers(request: Request, next: Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
async fn list_user_reviews(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
Path(game_id): Path<String>,
|
||||
query: Result<Query<UserReviewListQuery>, QueryRejection>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let Query(query) = query.map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("分页参数须为整数")
|
||||
})?;
|
||||
let (page, page_size) = query.pagination()?;
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.list_game_distribution_user_reviews(game_id, page, page_size)
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&ctx),
|
||||
GameDistributionReviewsResponse {
|
||||
reviews: result
|
||||
.reviews
|
||||
.into_iter()
|
||||
.map(user_review_payload)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
page: result.page,
|
||||
page_size: result.page_size,
|
||||
total: result.total,
|
||||
total_pages: result.total_pages,
|
||||
rating_summary: rating_summary_payload(result.rating_summary),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_my_review(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Path(game_id): Path<String>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let review = state
|
||||
.spacetime_client()
|
||||
.get_game_distribution_my_review(game_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&ctx),
|
||||
GameDistributionMyReviewResponse {
|
||||
review: review.map(user_review_payload).transpose()?,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn save_my_review(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Path(game_id): Path<String>,
|
||||
payload: Result<Json<GameDistributionSaveReviewRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let Json(payload) = payload.map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("评价请求须包含整数评分与可选文字评论")
|
||||
})?;
|
||||
let comment = normalize_review_comment(payload.score, &payload.comment).map_err(|error| {
|
||||
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(error.to_string())
|
||||
})?;
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.save_game_distribution_my_review(
|
||||
game_id,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload.score,
|
||||
comment,
|
||||
)
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&ctx),
|
||||
GameDistributionSaveReviewResponse {
|
||||
review: user_review_payload(result.review)?,
|
||||
rating_summary: rating_summary_payload(result.rating_summary),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn user_review_payload(
|
||||
review: GameDistributionUserReviewRecord,
|
||||
) -> Result<GameDistributionReview, AppError> {
|
||||
Ok(GameDistributionReview {
|
||||
id: review.review_id,
|
||||
game_id: review.game_id,
|
||||
author: GameDistributionAuthor {
|
||||
id: review.author_id,
|
||||
name: review.author_name,
|
||||
avatar_url: review.author_avatar_url,
|
||||
},
|
||||
score: review.score,
|
||||
comment: review.comment,
|
||||
created_at: user_review_timestamp(review.created_at_micros)?,
|
||||
updated_at: user_review_timestamp(review.updated_at_micros)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn user_review_timestamp(micros: i64) -> Result<String, AppError> {
|
||||
time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000)
|
||||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))
|
||||
.and_then(|timestamp| {
|
||||
shared_kernel::format_rfc3339(timestamp)
|
||||
.map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))
|
||||
})
|
||||
}
|
||||
|
||||
fn rating_summary_payload(
|
||||
summary: GameDistributionRatingSummaryRecord,
|
||||
) -> GameDistributionRatingSummary {
|
||||
GameDistributionRatingSummary {
|
||||
average_score: summary.average_score,
|
||||
rating_count: summary.rating_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// 发行网关根路径:等价于请求该游戏的 `index.html`。
|
||||
async fn serve_release_entry(
|
||||
state: State<AppState>,
|
||||
@@ -2491,6 +2660,208 @@ mod tests {
|
||||
GameDistributionDeviceSupport, GameDistributionOrientation,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_review_routes_validate_pagination_and_require_personal_auth() {
|
||||
use axum::http::Request;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let app =
|
||||
crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap());
|
||||
for (method, uri, status) in [
|
||||
(
|
||||
"GET",
|
||||
"/api/game-distribution/games/game_1/reviews",
|
||||
StatusCode::BAD_GATEWAY,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/game-distribution/games/game_1/reviews?page=0",
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/game-distribution/games/game_1/reviews?page=-1",
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/game-distribution/games/game_1/reviews?page=1.5",
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/game-distribution/games/game_1/reviews?pageSize=0",
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/game-distribution/games/game_1/reviews?pageSize=51",
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/game-distribution/games/game_1/reviews?pageSize=x",
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/api/game-distribution/games/game_1/my-review",
|
||||
StatusCode::UNAUTHORIZED,
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/api/game-distribution/games/game_1/my-review",
|
||||
StatusCode::UNAUTHORIZED,
|
||||
),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"score":8}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), status, "{method} {uri}");
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
}
|
||||
assert_eq!(
|
||||
UserReviewListQuery {
|
||||
page: None,
|
||||
page_size: None
|
||||
}
|
||||
.pagination()
|
||||
.unwrap(),
|
||||
(1, 20)
|
||||
);
|
||||
assert_eq!(
|
||||
UserReviewListQuery {
|
||||
page: Some(u32::MAX),
|
||||
page_size: Some(50)
|
||||
}
|
||||
.pagination()
|
||||
.unwrap(),
|
||||
(u32::MAX, 50)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_review_save_distinguishes_bad_payload_from_invalid_content() {
|
||||
use axum::http::Request;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
let state = AppState::new(crate::config::AppConfig::default()).unwrap();
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: "reviewer".into(),
|
||||
session_id: "review-session".into(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".into()],
|
||||
token_version: 1,
|
||||
phone_verified: false,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: None,
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
time::OffsetDateTime::now_utc(),
|
||||
)
|
||||
.unwrap();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/game-distribution/games/{game_id}/my-review",
|
||||
put(save_my_review),
|
||||
)
|
||||
.layer(Extension(AuthenticatedAccessToken::new(claims)))
|
||||
.layer(Extension(RequestContext::new(
|
||||
"review-test".into(),
|
||||
"PUT /review".into(),
|
||||
std::time::Duration::ZERO,
|
||||
true,
|
||||
)))
|
||||
.with_state(state);
|
||||
// 使用真实 handler 验证 JSON extraction 与领域校验,合法输入会触达未配置的数据库。
|
||||
for (payload, status) in [
|
||||
("{".to_string(), StatusCode::BAD_REQUEST),
|
||||
(r#"{}"#.to_string(), StatusCode::BAD_REQUEST),
|
||||
(r#"{"score":8.5}"#.to_string(), StatusCode::BAD_REQUEST),
|
||||
(r#"{"score":"8"}"#.to_string(), StatusCode::BAD_REQUEST),
|
||||
(
|
||||
r#"{"score":8,"comment":null}"#.to_string(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
r#"{"score":0}"#.to_string(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
r#"{"score":11}"#.to_string(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"score":8,"comment":"游".repeat(4001)}).to_string(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(r#"{"score":1}"#.to_string(), StatusCode::BAD_GATEWAY),
|
||||
(
|
||||
json!({"score":10,"comment":"😀".repeat(4000)}).to_string(),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/game-distribution/games/game_1/my-review")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(payload))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_review_payload_has_public_author_and_utc_timestamps() {
|
||||
let review = user_review_payload(GameDistributionUserReviewRecord {
|
||||
review_id: "review-1".into(),
|
||||
game_id: "game-1".into(),
|
||||
author_id: "user-1".into(),
|
||||
author_name: "玩家".into(),
|
||||
author_avatar_url: None,
|
||||
score: 9,
|
||||
comment: " 好玩\n".into(),
|
||||
created_at_micros: 0,
|
||||
updated_at_micros: 1_000_000,
|
||||
})
|
||||
.unwrap();
|
||||
let value = serde_json::to_value(review).unwrap();
|
||||
assert_eq!(
|
||||
value["author"],
|
||||
json!({"id":"user-1","name":"玩家","avatarUrl":null})
|
||||
);
|
||||
assert_eq!(value["gameId"], "game-1");
|
||||
assert_eq!(value["createdAt"], "1970-01-01T00:00:00Z");
|
||||
assert_eq!(value["updatedAt"], "1970-01-01T00:00:01Z");
|
||||
assert_eq!(value["comment"], " 好玩\n");
|
||||
assert_eq!(
|
||||
map_spacetime_error(SpacetimeClientError::Procedure(
|
||||
"游戏不存在或不可公开访问".into()
|
||||
))
|
||||
.status_code(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
fn metadata() -> GameDistributionCreateGameRequest {
|
||||
GameDistributionCreateGameRequest {
|
||||
local_project_id: None,
|
||||
|
||||
@@ -5,6 +5,11 @@ mod errors;
|
||||
mod events;
|
||||
mod package;
|
||||
mod release;
|
||||
mod reviews;
|
||||
|
||||
pub use reviews::{
|
||||
UserReviewValidationError, normalize_review_comment, review_id, review_order, review_summary,
|
||||
};
|
||||
|
||||
pub use application::{
|
||||
GameDistributionService, GameOperationResult, InMemoryGameDistributionStore,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
use std::{cmp::Ordering, fmt};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UserReviewValidationError {
|
||||
InvalidScore,
|
||||
CommentTooLong,
|
||||
}
|
||||
|
||||
impl fmt::Display for UserReviewValidationError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::InvalidScore => "评分必须为 1–10 的整数",
|
||||
Self::CommentTooLong => "评论最多 4000 字符",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_review_comment(
|
||||
score: i64,
|
||||
comment: &str,
|
||||
) -> Result<String, UserReviewValidationError> {
|
||||
if !(1..=10).contains(&score) {
|
||||
return Err(UserReviewValidationError::InvalidScore);
|
||||
}
|
||||
let comment = comment.replace("\r\n", "\n").replace('\r', "\n");
|
||||
if comment.chars().count() > 4000 {
|
||||
return Err(UserReviewValidationError::CommentTooLong);
|
||||
}
|
||||
Ok(if comment.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
comment
|
||||
})
|
||||
}
|
||||
|
||||
/// 长度前缀使任意游戏和账号组合都只有一个无歧义的主键。
|
||||
pub fn review_id(game_id: &str, user_id: &str) -> String {
|
||||
format!("{}:{game_id}{user_id}", game_id.len())
|
||||
}
|
||||
|
||||
pub fn review_summary(scores: impl Iterator<Item = i64>) -> (Option<f64>, u64) {
|
||||
let (sum, count) = scores.fold((0u64, 0u64), |(sum, count), score| {
|
||||
(sum + score as u64, count + 1)
|
||||
});
|
||||
let average = (count > 0).then(|| ((sum as f64 / count as f64) * 10.0).round() / 10.0);
|
||||
(average, count)
|
||||
}
|
||||
|
||||
pub fn review_order(
|
||||
left_created: i64,
|
||||
left_id: &str,
|
||||
right_created: i64,
|
||||
right_id: &str,
|
||||
) -> Ordering {
|
||||
right_created
|
||||
.cmp(&left_created)
|
||||
.then_with(|| right_id.cmp(left_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn review_score_and_comment_follow_unicode_contract() {
|
||||
for score in [1, 10] {
|
||||
assert_eq!(normalize_review_comment(score, ""), Ok(String::new()));
|
||||
}
|
||||
for score in [0, 11, -1] {
|
||||
assert_eq!(
|
||||
normalize_review_comment(score, ""),
|
||||
Err(UserReviewValidationError::InvalidScore)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
normalize_review_comment(7, " 中文😀\r\n英文\r "),
|
||||
Ok(" 中文😀\n英文\n ".into())
|
||||
);
|
||||
assert!(normalize_review_comment(7, &"😀".repeat(4000)).is_ok());
|
||||
assert_eq!(
|
||||
normalize_review_comment(7, &"😀".repeat(4001)),
|
||||
Err(UserReviewValidationError::CommentTooLong)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_review_comment(7, &" ".repeat(4000)),
|
||||
Ok(String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_review_comment(7, &" ".repeat(4001)),
|
||||
Err(UserReviewValidationError::CommentTooLong)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_review_comment(7, &"\r\n".repeat(4000)),
|
||||
Ok(String::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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));
|
||||
assert_eq!(review_order(1, "z", 2, "a"), Ordering::Greater);
|
||||
assert_eq!(review_order(2, "b", 2, "a"), Ordering::Less);
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,57 @@ pub struct GameDistributionAuthor {
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionReview {
|
||||
pub id: String,
|
||||
pub game_id: String,
|
||||
pub author: GameDistributionAuthor,
|
||||
pub score: i64,
|
||||
pub comment: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionRatingSummary {
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionReviewsResponse {
|
||||
pub reviews: Vec<GameDistributionReview>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub total: u64,
|
||||
pub total_pages: u64,
|
||||
pub rating_summary: GameDistributionRatingSummary,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionMyReviewResponse {
|
||||
pub review: Option<GameDistributionReview>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionSaveReviewRequest {
|
||||
pub score: i64,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionSaveReviewResponse {
|
||||
pub review: GameDistributionReview,
|
||||
pub rating_summary: GameDistributionRatingSummary,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionVersionSummary {
|
||||
@@ -196,6 +247,34 @@ pub struct GameDistributionPrivateVersion {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn save_review_request_requires_integer_score_and_defaults_comment() {
|
||||
let request: GameDistributionSaveReviewRequest =
|
||||
serde_json::from_value(serde_json::json!({"score": 10})).unwrap();
|
||||
assert_eq!(request.comment, "");
|
||||
for payload in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"score": 8.5}),
|
||||
serde_json::json!({"score": "8"}),
|
||||
serde_json::json!({"score": 8, "comment": null}),
|
||||
serde_json::json!({"score": 8, "comment": 123}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<GameDistributionSaveReviewRequest>(payload).is_err());
|
||||
}
|
||||
let response =
|
||||
serde_json::to_value(GameDistributionMyReviewResponse { review: None }).unwrap();
|
||||
assert_eq!(response, serde_json::json!({"review": null}));
|
||||
let summary = serde_json::to_value(GameDistributionRatingSummary {
|
||||
average_score: None,
|
||||
rating_count: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
summary,
|
||||
serde_json::json!({"averageScore": null, "ratingCount": 0})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_game_request_round_trips_local_project_id() {
|
||||
let payload: GameDistributionCreateGameRequest =
|
||||
|
||||
@@ -94,6 +94,8 @@ pub use self::external_generation::{
|
||||
pub use self::game_distribution::{
|
||||
GameDistributionAdminGameRecord, GameDistributionAdminVersionRecord,
|
||||
GameDistributionGameRecord, GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord,
|
||||
GameDistributionRatingSummaryRecord, GameDistributionUserReviewListRecord,
|
||||
GameDistributionUserReviewRecord, GameDistributionUserReviewSaveRecord,
|
||||
GameDistributionVersionRecord,
|
||||
};
|
||||
pub use self::llm_router_account::{LlmRouterAccountRecord, LlmRouterAccountUpsertRecordInput};
|
||||
@@ -149,10 +151,11 @@ pub(crate) use self::external_generation::{
|
||||
map_external_generation_queue_stats_result,
|
||||
};
|
||||
pub(crate) use self::game_distribution::{
|
||||
map_game_distribution_admin_game_list_result, map_game_distribution_game_result,
|
||||
map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result,
|
||||
map_game_distribution_public_game_result, map_game_distribution_review_list_result,
|
||||
map_game_distribution_version_result,
|
||||
ensure_user_review_result, map_game_distribution_admin_game_list_result,
|
||||
map_game_distribution_game_result, map_game_distribution_owner_game_list_result,
|
||||
map_game_distribution_public_game_list_result, map_game_distribution_public_game_result,
|
||||
map_game_distribution_review_list_result, map_game_distribution_version_result,
|
||||
map_user_review,
|
||||
};
|
||||
pub(crate) use self::runtime::{
|
||||
map_feature_gate_config_procedure_result, map_runtime_setting_procedure_result,
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GameDistributionUserReviewRecord {
|
||||
pub review_id: String,
|
||||
pub game_id: String,
|
||||
pub author_id: String,
|
||||
pub author_name: String,
|
||||
pub author_avatar_url: Option<String>,
|
||||
pub score: i64,
|
||||
pub comment: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GameDistributionRatingSummaryRecord {
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GameDistributionUserReviewListRecord {
|
||||
pub reviews: Vec<GameDistributionUserReviewRecord>,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub total: u64,
|
||||
pub total_pages: u64,
|
||||
pub rating_summary: GameDistributionRatingSummaryRecord,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GameDistributionUserReviewSaveRecord {
|
||||
pub review: GameDistributionUserReviewRecord,
|
||||
pub rating_summary: GameDistributionRatingSummaryRecord,
|
||||
}
|
||||
|
||||
pub(crate) fn map_user_review(
|
||||
snapshot: crate::module_bindings::GameDistributionUserReviewSnapshot,
|
||||
) -> GameDistributionUserReviewRecord {
|
||||
GameDistributionUserReviewRecord {
|
||||
review_id: snapshot.review_id,
|
||||
game_id: snapshot.game_id,
|
||||
author_id: snapshot.author_id,
|
||||
author_name: snapshot.author_name,
|
||||
author_avatar_url: snapshot.author_avatar_url,
|
||||
score: snapshot.score,
|
||||
comment: snapshot.comment,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_user_review_result(
|
||||
result: crate::module_bindings::GameDistributionUserReviewResult,
|
||||
) -> Result<crate::module_bindings::GameDistributionUserReviewResult, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GameDistributionGameRecord {
|
||||
pub game_id: String,
|
||||
|
||||
@@ -165,6 +165,117 @@ pub struct GameDistributionSuspendRecordInput {
|
||||
}
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn list_game_distribution_user_reviews(
|
||||
&self,
|
||||
game_id: String,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<GameDistributionUserReviewListRecord, SpacetimeClientError> {
|
||||
let input = crate::module_bindings::GameDistributionUserReviewListInput {
|
||||
game_id,
|
||||
page,
|
||||
page_size,
|
||||
};
|
||||
self.call_after_connect(
|
||||
"list_game_distribution_user_reviews",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.list_game_distribution_user_reviews_and_return_then(
|
||||
input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(ensure_user_review_result)
|
||||
.map(|result| GameDistributionUserReviewListRecord {
|
||||
reviews: result
|
||||
.reviews
|
||||
.into_iter()
|
||||
.map(map_user_review)
|
||||
.collect(),
|
||||
page: result.page,
|
||||
page_size: result.page_size,
|
||||
total: result.rating_count,
|
||||
total_pages: result.total_pages,
|
||||
rating_summary: GameDistributionRatingSummaryRecord {
|
||||
average_score: result.average_score,
|
||||
rating_count: result.rating_count,
|
||||
},
|
||||
});
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_game_distribution_my_review(
|
||||
&self,
|
||||
game_id: String,
|
||||
user_id: String,
|
||||
) -> Result<Option<GameDistributionUserReviewRecord>, SpacetimeClientError> {
|
||||
let input = crate::module_bindings::GameDistributionMyReviewInput { game_id, user_id };
|
||||
self.call_after_connect(
|
||||
"get_game_distribution_my_review",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.get_game_distribution_my_review_and_return_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(ensure_user_review_result)
|
||||
.map(|result| result.review.map(map_user_review));
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_game_distribution_my_review(
|
||||
&self,
|
||||
game_id: String,
|
||||
user_id: String,
|
||||
score: i64,
|
||||
comment: String,
|
||||
) -> Result<GameDistributionUserReviewSaveRecord, SpacetimeClientError> {
|
||||
let input = crate::module_bindings::GameDistributionUserReviewInput {
|
||||
game_id,
|
||||
user_id,
|
||||
score,
|
||||
comment,
|
||||
};
|
||||
self.call_after_connect(
|
||||
"save_game_distribution_my_review",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.save_game_distribution_my_review_and_return_then(input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(ensure_user_review_result)
|
||||
.and_then(|result| {
|
||||
let review = result.review.ok_or_else(|| {
|
||||
SpacetimeClientError::procedure_failed(Some(
|
||||
"保存评价缺少结果".into(),
|
||||
))
|
||||
})?;
|
||||
Ok(GameDistributionUserReviewSaveRecord {
|
||||
review: map_user_review(review),
|
||||
rating_summary: GameDistributionRatingSummaryRecord {
|
||||
average_score: result.average_score,
|
||||
rating_count: result.rating_count,
|
||||
},
|
||||
})
|
||||
});
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_game_distribution_games(
|
||||
&self,
|
||||
input: GameDistributionPublicGameListRecordInput,
|
||||
|
||||
@@ -416,6 +416,7 @@ pub mod game_distribution_game_type;
|
||||
pub mod game_distribution_get_game_input_type;
|
||||
pub mod game_distribution_idempotency_receipt_table;
|
||||
pub mod game_distribution_idempotency_receipt_type;
|
||||
pub mod game_distribution_my_review_input_type;
|
||||
pub mod game_distribution_owner_game_list_input_type;
|
||||
pub mod game_distribution_owner_game_list_procedure_result_type;
|
||||
pub mod game_distribution_owner_game_snapshot_type;
|
||||
@@ -427,9 +428,15 @@ pub mod game_distribution_public_game_snapshot_type;
|
||||
pub mod game_distribution_reject_input_type;
|
||||
pub mod game_distribution_restore_input_type;
|
||||
pub mod game_distribution_review_list_input_type;
|
||||
pub mod game_distribution_review_table;
|
||||
pub mod game_distribution_review_type;
|
||||
pub mod game_distribution_submit_review_input_type;
|
||||
pub mod game_distribution_suspend_input_type;
|
||||
pub mod game_distribution_unpublish_input_type;
|
||||
pub mod game_distribution_user_review_input_type;
|
||||
pub mod game_distribution_user_review_list_input_type;
|
||||
pub mod game_distribution_user_review_result_type;
|
||||
pub mod game_distribution_user_review_snapshot_type;
|
||||
pub mod game_distribution_version_lookup_input_type;
|
||||
pub mod game_distribution_version_procedure_result_type;
|
||||
pub mod game_distribution_version_snapshot_type;
|
||||
@@ -453,6 +460,7 @@ pub mod get_external_generation_job_summary_and_return_procedure;
|
||||
pub mod get_external_generation_queue_stats_and_return_procedure;
|
||||
pub mod get_feature_gate_config_procedure;
|
||||
pub mod get_game_distribution_game_and_return_procedure;
|
||||
pub mod get_game_distribution_my_review_and_return_procedure;
|
||||
pub mod get_game_distribution_version_and_return_procedure;
|
||||
pub mod get_llm_router_account_and_return_procedure;
|
||||
pub mod get_owner_game_distribution_version_and_return_procedure;
|
||||
@@ -484,6 +492,7 @@ pub mod list_external_generation_job_summaries_and_return_procedure;
|
||||
pub mod list_external_generation_jobs_and_return_procedure;
|
||||
pub mod list_game_distribution_games_and_return_procedure;
|
||||
pub mod list_game_distribution_reviews_and_return_procedure;
|
||||
pub mod list_game_distribution_user_reviews_and_return_procedure;
|
||||
pub mod list_owner_game_distribution_games_and_return_procedure;
|
||||
pub mod list_profile_recharge_refund_holds_for_reconciliation_procedure;
|
||||
pub mod list_profile_recharge_refunds_for_reconciliation_procedure;
|
||||
@@ -748,6 +757,7 @@ pub mod save_editor_project_layout_ack_procedure;
|
||||
pub mod save_editor_project_layout_and_return_procedure;
|
||||
pub mod save_editor_project_layout_v_2_ack_procedure;
|
||||
pub mod save_editor_project_layout_v_2_and_return_procedure;
|
||||
pub mod save_game_distribution_my_review_and_return_procedure;
|
||||
pub mod seed_analytics_date_dimensions_reducer;
|
||||
pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure;
|
||||
pub mod settle_llm_router_quota_and_return_procedure;
|
||||
@@ -1193,6 +1203,7 @@ pub use game_distribution_game_type::GameDistributionGame;
|
||||
pub use game_distribution_get_game_input_type::GameDistributionGetGameInput;
|
||||
pub use game_distribution_idempotency_receipt_table::*;
|
||||
pub use game_distribution_idempotency_receipt_type::GameDistributionIdempotencyReceipt;
|
||||
pub use game_distribution_my_review_input_type::GameDistributionMyReviewInput;
|
||||
pub use game_distribution_owner_game_list_input_type::GameDistributionOwnerGameListInput;
|
||||
pub use game_distribution_owner_game_list_procedure_result_type::GameDistributionOwnerGameListProcedureResult;
|
||||
pub use game_distribution_owner_game_snapshot_type::GameDistributionOwnerGameSnapshot;
|
||||
@@ -1204,9 +1215,15 @@ pub use game_distribution_public_game_snapshot_type::GameDistributionPublicGameS
|
||||
pub use game_distribution_reject_input_type::GameDistributionRejectInput;
|
||||
pub use game_distribution_restore_input_type::GameDistributionRestoreInput;
|
||||
pub use game_distribution_review_list_input_type::GameDistributionReviewListInput;
|
||||
pub use game_distribution_review_table::*;
|
||||
pub use game_distribution_review_type::GameDistributionReview;
|
||||
pub use game_distribution_submit_review_input_type::GameDistributionSubmitReviewInput;
|
||||
pub use game_distribution_suspend_input_type::GameDistributionSuspendInput;
|
||||
pub use game_distribution_unpublish_input_type::GameDistributionUnpublishInput;
|
||||
pub use game_distribution_user_review_input_type::GameDistributionUserReviewInput;
|
||||
pub use game_distribution_user_review_list_input_type::GameDistributionUserReviewListInput;
|
||||
pub use game_distribution_user_review_result_type::GameDistributionUserReviewResult;
|
||||
pub use game_distribution_user_review_snapshot_type::GameDistributionUserReviewSnapshot;
|
||||
pub use game_distribution_version_lookup_input_type::GameDistributionVersionLookupInput;
|
||||
pub use game_distribution_version_procedure_result_type::GameDistributionVersionProcedureResult;
|
||||
pub use game_distribution_version_snapshot_type::GameDistributionVersionSnapshot;
|
||||
@@ -1230,6 +1247,7 @@ pub use get_external_generation_job_summary_and_return_procedure::get_external_g
|
||||
pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return;
|
||||
pub use get_feature_gate_config_procedure::get_feature_gate_config;
|
||||
pub use get_game_distribution_game_and_return_procedure::get_game_distribution_game_and_return;
|
||||
pub use get_game_distribution_my_review_and_return_procedure::get_game_distribution_my_review_and_return;
|
||||
pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return;
|
||||
pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return;
|
||||
pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return;
|
||||
@@ -1261,6 +1279,7 @@ pub use list_external_generation_job_summaries_and_return_procedure::list_extern
|
||||
pub use list_external_generation_jobs_and_return_procedure::list_external_generation_jobs_and_return;
|
||||
pub use list_game_distribution_games_and_return_procedure::list_game_distribution_games_and_return;
|
||||
pub use list_game_distribution_reviews_and_return_procedure::list_game_distribution_reviews_and_return;
|
||||
pub use list_game_distribution_user_reviews_and_return_procedure::list_game_distribution_user_reviews_and_return;
|
||||
pub use list_owner_game_distribution_games_and_return_procedure::list_owner_game_distribution_games_and_return;
|
||||
pub use list_profile_recharge_refund_holds_for_reconciliation_procedure::list_profile_recharge_refund_holds_for_reconciliation;
|
||||
pub use list_profile_recharge_refunds_for_reconciliation_procedure::list_profile_recharge_refunds_for_reconciliation;
|
||||
@@ -1525,6 +1544,7 @@ pub use save_editor_project_layout_ack_procedure::save_editor_project_layout_ack
|
||||
pub use save_editor_project_layout_and_return_procedure::save_editor_project_layout_and_return;
|
||||
pub use save_editor_project_layout_v_2_ack_procedure::save_editor_project_layout_v_2_ack;
|
||||
pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project_layout_v_2_and_return;
|
||||
pub use save_game_distribution_my_review_and_return_procedure::save_game_distribution_my_review_and_return;
|
||||
pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions;
|
||||
pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return;
|
||||
pub use settle_llm_router_quota_and_return_procedure::settle_llm_router_quota_and_return;
|
||||
@@ -1714,6 +1734,7 @@ pub struct DbUpdate {
|
||||
feature_gate_config: __sdk::TableUpdate<FeatureGateConfig>,
|
||||
game_distribution_game: __sdk::TableUpdate<GameDistributionGame>,
|
||||
game_distribution_idempotency_receipt: __sdk::TableUpdate<GameDistributionIdempotencyReceipt>,
|
||||
game_distribution_review: __sdk::TableUpdate<GameDistributionReview>,
|
||||
game_distribution_version: __sdk::TableUpdate<GameDistributionVersion>,
|
||||
llm_router_account: __sdk::TableUpdate<LlmRouterAccount>,
|
||||
llm_router_billing_checkpoint: __sdk::TableUpdate<LlmRouterBillingCheckpoint>,
|
||||
@@ -1929,6 +1950,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
||||
)?,
|
||||
)
|
||||
}
|
||||
"game_distribution_review" => db_update.game_distribution_review.append(
|
||||
game_distribution_review_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"game_distribution_version" => db_update.game_distribution_version.append(
|
||||
game_distribution_version_table::parse_table_update(table_update)?,
|
||||
),
|
||||
@@ -2322,6 +2346,12 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
&self.game_distribution_idempotency_receipt,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.receipt_id);
|
||||
diff.game_distribution_review = cache
|
||||
.apply_diff_to_table::<GameDistributionReview>(
|
||||
"game_distribution_review",
|
||||
&self.game_distribution_review,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.review_id);
|
||||
diff.game_distribution_version = cache
|
||||
.apply_diff_to_table::<GameDistributionVersion>(
|
||||
"game_distribution_version",
|
||||
@@ -2683,6 +2713,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"game_distribution_idempotency_receipt" => db_update
|
||||
.game_distribution_idempotency_receipt
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"game_distribution_review" => db_update
|
||||
.game_distribution_review
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"game_distribution_version" => db_update
|
||||
.game_distribution_version
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
@@ -2951,6 +2984,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"game_distribution_idempotency_receipt" => db_update
|
||||
.game_distribution_idempotency_receipt
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"game_distribution_review" => db_update
|
||||
.game_distribution_review
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"game_distribution_version" => db_update
|
||||
.game_distribution_version
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
@@ -3134,6 +3170,7 @@ pub struct AppliedDiff<'r> {
|
||||
game_distribution_game: __sdk::TableAppliedDiff<'r, GameDistributionGame>,
|
||||
game_distribution_idempotency_receipt:
|
||||
__sdk::TableAppliedDiff<'r, GameDistributionIdempotencyReceipt>,
|
||||
game_distribution_review: __sdk::TableAppliedDiff<'r, GameDistributionReview>,
|
||||
game_distribution_version: __sdk::TableAppliedDiff<'r, GameDistributionVersion>,
|
||||
llm_router_account: __sdk::TableAppliedDiff<'r, LlmRouterAccount>,
|
||||
llm_router_billing_checkpoint: __sdk::TableAppliedDiff<'r, LlmRouterBillingCheckpoint>,
|
||||
@@ -3409,6 +3446,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
&self.game_distribution_idempotency_receipt,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<GameDistributionReview>(
|
||||
"game_distribution_review",
|
||||
&self.game_distribution_review,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<GameDistributionVersion>(
|
||||
"game_distribution_version",
|
||||
&self.game_distribution_version,
|
||||
@@ -4314,6 +4356,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
feature_gate_config_table::register_table(client_cache);
|
||||
game_distribution_game_table::register_table(client_cache);
|
||||
game_distribution_idempotency_receipt_table::register_table(client_cache);
|
||||
game_distribution_review_table::register_table(client_cache);
|
||||
game_distribution_version_table::register_table(client_cache);
|
||||
llm_router_account_table::register_table(client_cache);
|
||||
llm_router_billing_checkpoint_table::register_table(client_cache);
|
||||
@@ -4401,6 +4444,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
"feature_gate_config",
|
||||
"game_distribution_game",
|
||||
"game_distribution_idempotency_receipt",
|
||||
"game_distribution_review",
|
||||
"game_distribution_version",
|
||||
"llm_router_account",
|
||||
"llm_router_billing_checkpoint",
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct GameDistributionMyReviewInput {
|
||||
pub game_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionMyReviewInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use super::game_distribution_review_type::GameDistributionReview;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `game_distribution_review`.
|
||||
///
|
||||
/// Obtain a handle from the [`GameDistributionReviewTableAccess::game_distribution_review`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.game_distribution_review()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.game_distribution_review().on_insert(...)`.
|
||||
pub struct GameDistributionReviewTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<GameDistributionReview>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
/// Lifetime-aware accessor marker for the table `game_distribution_review`.
|
||||
pub struct GameDistributionReviewTableAccessor;
|
||||
|
||||
impl __sdk::TableAccessor<super::RemoteTables> for GameDistributionReviewTableAccessor {
|
||||
type Row = GameDistributionReview;
|
||||
type Handle<'db> = GameDistributionReviewTableHandle<'db>;
|
||||
|
||||
fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> {
|
||||
db.game_distribution_review()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `game_distribution_review`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait GameDistributionReviewTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`GameDistributionReviewTableHandle`], which mediates access to the table `game_distribution_review`.
|
||||
fn game_distribution_review(&self) -> GameDistributionReviewTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl GameDistributionReviewTableAccess for super::RemoteTables {
|
||||
fn game_distribution_review(&self) -> GameDistributionReviewTableHandle<'_> {
|
||||
GameDistributionReviewTableHandle {
|
||||
imp: self
|
||||
.imp
|
||||
.get_table::<GameDistributionReview>("game_distribution_review"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GameDistributionReviewInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct GameDistributionReviewDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::TableLike for GameDistributionReviewTableHandle<'ctx> {
|
||||
type Row = GameDistributionReview;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = GameDistributionReview> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> __sdk::Table for GameDistributionReviewTableHandle<'ctx> {
|
||||
type Row = GameDistributionReview;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = GameDistributionReview> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = GameDistributionReviewInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> GameDistributionReviewInsertCallbackId {
|
||||
GameDistributionReviewInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: GameDistributionReviewInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = GameDistributionReviewDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> GameDistributionReviewDeleteCallbackId {
|
||||
GameDistributionReviewDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: GameDistributionReviewDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> __sdk::WithInsert for GameDistributionReviewTableHandle<'ctx> {
|
||||
type InsertCallbackId = GameDistributionReviewInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> GameDistributionReviewInsertCallbackId {
|
||||
GameDistributionReviewInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: GameDistributionReviewInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> __sdk::WithDelete for GameDistributionReviewTableHandle<'ctx> {
|
||||
type DeleteCallbackId = GameDistributionReviewDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> GameDistributionReviewDeleteCallbackId {
|
||||
GameDistributionReviewDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: GameDistributionReviewDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GameDistributionReviewUpdateCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::TableWithPrimaryKey for GameDistributionReviewTableHandle<'ctx> {
|
||||
type UpdateCallbackId = GameDistributionReviewUpdateCallbackId;
|
||||
|
||||
fn on_update(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
|
||||
) -> GameDistributionReviewUpdateCallbackId {
|
||||
GameDistributionReviewUpdateCallbackId(self.imp.on_update(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_update(&self, callback: GameDistributionReviewUpdateCallbackId) {
|
||||
self.imp.remove_on_update(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> __sdk::WithUpdate for GameDistributionReviewTableHandle<'ctx> {
|
||||
type UpdateCallbackId = GameDistributionReviewUpdateCallbackId;
|
||||
|
||||
fn on_update(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
|
||||
) -> GameDistributionReviewUpdateCallbackId {
|
||||
GameDistributionReviewUpdateCallbackId(self.imp.on_update(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_update(&self, callback: GameDistributionReviewUpdateCallbackId) {
|
||||
self.imp.remove_on_update(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Access to the `review_id` unique index on the table `game_distribution_review`,
|
||||
/// which allows point queries on the field of the same name
|
||||
/// via the [`GameDistributionReviewReviewIdUnique::find`] method.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.game_distribution_review().review_id().find(...)`.
|
||||
pub struct GameDistributionReviewReviewIdUnique<'ctx> {
|
||||
imp: __sdk::UniqueConstraintHandle<GameDistributionReview, String>,
|
||||
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
impl<'ctx> GameDistributionReviewTableHandle<'ctx> {
|
||||
/// Get a handle on the `review_id` unique index on the table `game_distribution_review`.
|
||||
pub fn review_id(&self) -> GameDistributionReviewReviewIdUnique<'ctx> {
|
||||
GameDistributionReviewReviewIdUnique {
|
||||
imp: self.imp.get_unique_constraint::<String>("review_id"),
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ctx> GameDistributionReviewReviewIdUnique<'ctx> {
|
||||
/// Find the subscribed row whose `review_id` column value is equal to `col_val`,
|
||||
/// if such a row is present in the client cache.
|
||||
pub fn find(&self, col_val: &String) -> Option<GameDistributionReview> {
|
||||
self.imp.find(col_val)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table =
|
||||
client_cache.get_or_make_table::<GameDistributionReview>("game_distribution_review");
|
||||
_table.add_unique_constraint::<String>("review_id", |row| &row.review_id);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<GameDistributionReview>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<GameDistributionReview>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `GameDistributionReview`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait game_distribution_reviewQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `GameDistributionReview`.
|
||||
fn game_distribution_review(&self) -> __sdk::__query_builder::Table<GameDistributionReview>;
|
||||
}
|
||||
|
||||
impl game_distribution_reviewQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn game_distribution_review(&self) -> __sdk::__query_builder::Table<GameDistributionReview> {
|
||||
__sdk::__query_builder::Table::new("game_distribution_review")
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct GameDistributionReview {
|
||||
pub review_id: String,
|
||||
pub game_id: String,
|
||||
pub user_id: String,
|
||||
pub score: i64,
|
||||
pub comment: String,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionReview {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `GameDistributionReview`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct GameDistributionReviewCols {
|
||||
pub review_id: __sdk::__query_builder::Col<GameDistributionReview, String>,
|
||||
pub game_id: __sdk::__query_builder::Col<GameDistributionReview, String>,
|
||||
pub user_id: __sdk::__query_builder::Col<GameDistributionReview, String>,
|
||||
pub score: __sdk::__query_builder::Col<GameDistributionReview, i64>,
|
||||
pub comment: __sdk::__query_builder::Col<GameDistributionReview, String>,
|
||||
pub created_at: __sdk::__query_builder::Col<GameDistributionReview, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<GameDistributionReview, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for GameDistributionReview {
|
||||
type Cols = GameDistributionReviewCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
GameDistributionReviewCols {
|
||||
review_id: __sdk::__query_builder::Col::new(table_name, "review_id"),
|
||||
game_id: __sdk::__query_builder::Col::new(table_name, "game_id"),
|
||||
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
|
||||
score: __sdk::__query_builder::Col::new(table_name, "score"),
|
||||
comment: __sdk::__query_builder::Col::new(table_name, "comment"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `GameDistributionReview`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct GameDistributionReviewIxCols {
|
||||
pub game_id: __sdk::__query_builder::IxCol<GameDistributionReview, String>,
|
||||
pub review_id: __sdk::__query_builder::IxCol<GameDistributionReview, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for GameDistributionReview {
|
||||
type IxCols = GameDistributionReviewIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
GameDistributionReviewIxCols {
|
||||
game_id: __sdk::__query_builder::IxCol::new(table_name, "game_id"),
|
||||
review_id: __sdk::__query_builder::IxCol::new(table_name, "review_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for GameDistributionReview {}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct GameDistributionUserReviewInput {
|
||||
pub game_id: String,
|
||||
pub user_id: String,
|
||||
pub score: i64,
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionUserReviewInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct GameDistributionUserReviewListInput {
|
||||
pub game_id: String,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionUserReviewListInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::game_distribution_user_review_snapshot_type::GameDistributionUserReviewSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct GameDistributionUserReviewResult {
|
||||
pub ok: bool,
|
||||
pub reviews: Vec<GameDistributionUserReviewSnapshot>,
|
||||
pub review: Option<GameDistributionUserReviewSnapshot>,
|
||||
pub average_score: Option<f64>,
|
||||
pub rating_count: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
pub total_pages: u64,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionUserReviewResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct GameDistributionUserReviewSnapshot {
|
||||
pub review_id: String,
|
||||
pub game_id: String,
|
||||
pub author_id: String,
|
||||
pub author_name: String,
|
||||
pub author_avatar_url: Option<String>,
|
||||
pub score: i64,
|
||||
pub comment: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionUserReviewSnapshot {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user