实现网站游戏评分与评价
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:
2026-09-30 23:28:53 +08:00
parent 15c123bc59
commit 97751e4bbd
39 changed files with 3346 additions and 25 deletions
+2 -2
View File
@@ -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;
});
+1
View File
@@ -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",
@@ -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;
}
@@ -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")
}
}
@@ -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 {}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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