文档:新增游戏游玩次数计数 ADR

- 新增 docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md:点开始游戏触发、api-server 纯内存 5s flush、30min 去重、IP+game 限流、批量 procedure 自增 play_count、不 bump updated_at、失败少计优于双计
- docs/README.md:登记该 ADR 到当前产品与平台
This commit is contained in:
2026-10-03 17:55:36 +08:00
parent 633355952b
commit 7f5ddf8f47
2 changed files with 129 additions and 0 deletions
+1
View File
@@ -24,6 +24,7 @@
- [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。
- [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。
- [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。
- [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。
- [外部 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,128 @@
# 【ADR】游戏游玩次数计数
状态:已接受(2026-10-03)
## 背景
网站游戏分发已经展示「X 次游玩」:目录卡片、详情、作者「我的游戏」与后台「游戏管理」都读
`game_distribution_game.play_count`,契约里也有 `playCount`。但这条链路只读不写——游戏创建时置 `0`,
之后没有任何自增、reducer 或 procedure;因此所有游戏恒显示 `0` 次游玩。
主规范要求「不虚构评分、玩家数、排名和收藏状态」,里程碑与证据矩阵以「新游戏显示 0、目录数据来自真实
接口」作为无假统计的证据,却从未定义「一次游玩如何累加」。相关现状:
- **触发信号不存在**:游玩页 `/games/play` 先给封面与「开始游戏」,点击后才挂
`sandbox="allow-scripts"` 的 iframe;`startGame()` 纯本地状态,不发任何请求。AGC 客户端 12 类埋点白名单
不含「开始游玩」,主站路由埋点只有查看统计。
- **发行网关不能承担计数**:`serve_release_entry` / `serve_release_asset` 只按 `gameId` 服务当前公开版本
文件;一次加载会打多个资源请求,且按设计禁带平台 Cookie、拿不到会话身份。
- **平台没有匿名身份**:没有匿名访客 cookie,web 端没有 device/client id;`x-client-instance-id` 只在登录
链路采集。
- **没有现成的服务端缓冲管线可复用**:AGC 埋点管线是客户端本地 15 分钟一批、服务端同步原子入库且明确
不做聚合;真正的「内存缓冲 + 周期 flush + 关停 flush」范式是主站路由埋点 `tracking_outbox`。
- **部署与限流现状**:api-server 单实例;无 CSRF/Origin 中间件;应用层只有并发背压,按 IP 令牌桶在独立
的 `pingora-gateway`;现有缓存惯例是 std `OnceLock`/`LazyLock` + `Mutex`(无 `dashmap`/`moka`)。
## 决策
### 1. 触发点 = 游玩页点「开始游戏」
以用户点击「开始游戏」后的前端上报作为一次游玩。理由:这是产品定义的启动动作,游客可用;`iframe load`
只代表文档加载(规范已明确不能当业务状态),发行网关无法区分会话且会因资源请求重复。点击后即使 iframe
超时/未真正载入也计一次(用户意图)。
### 2. 落点 = 复用现有 `play_count`
`game_distribution_game.play_count` 已经是 `u64`、已进公开/后台 DTO、已进四处 UI。只新增写入路径,不改
字段语义、不新建计数表,避免双源。计数跟随游戏身份,不随发行版本。
### 3. 范围 = 只做累计总次数
不做日粒度 / 近 7 天热度、独立玩家数、榜单、推荐。`public_work_play_daily_stat` 属已退役自定义世界口径,
不复活。
### 4. api-server 纯内存缓冲 + 周期 flush
- **纯内存,不落盘**:崩溃/被杀允许丢最后一个 flush 窗口;正常 SIGTERM/滚动重启必须在
`finalize_shutdown` 内 force flush。
- **两张表**:增量表 `pending: HashMap<gameId, u64>`(5 秒级、flush 即清)与 30 分钟去重窗口表 `seen:
HashMap<identity + gameId, timestamp>`(30 分钟级、按 TTL 清理)。两者键不同、生命周期差 360 倍,不能合并:
合并会把 30 分钟窗口状态塞进 5 秒清空的表,或让 flush 需要按 gameId 重新聚合。
- **限流表**:`rate: HashMap<ip + gameId, (windowStart, count)>` 固定窗口。
- **并发**:请求路径只在短锁内做 HashMap 命中 + 自增;**flush 的网络调用移出锁外**。
### 5. 写入形状 = 批量 procedure
一次 flush 发一个 procedure,入参 `Vec<{gameId, delta}>`(按 500 分块),事务内逐条
`play_count = play_count.saturating_add(delta)`;procedure 内**原子校验**游戏当前为 `published` 且
`active_version_id` 存在,非公开跳过。**不 bump `updated_at`**:它只表示公开资料变更,且作者自有列表按它
排序,bump 会让每次游玩重排作者列表。
### 6. 失败语义 = 少计优于双计
只重试确定未发出的 `Build`;`Timeout` / `ConnectDropped` 无法判断是否已提交,直接丢弃该批并 `warn!` 记录
丢量。理由是:超时后重试会在"其实已提交"时造成系统性双计,而丢弃只是偶发少计——对一个非交易展示指标,
后者更可接受(perf 优先于 correctness)。
### 7. 接口
`POST /api/game-distribution/games/{game_id}/plays`,公开端点(可选 bearer):
- 不挂 `require_bearer_auth`;用 `optional_access_token_from_headers` 拿可选 `userId`。
- **不加 `Idempotency-Key`**(与其它游戏分发写路由惯例不同):高频计数用不上幂等收据,30 分钟去重窗口就是
护栏。
- 非公开 / 下架 / 封禁返回 `404` 且不计数;被限流返回 `429`;成功统一 `200 {recorded: bool}`。
- 前端 fire-and-forget,**任何失败静默、绝不阻断游玩**;不做发行网关兜底计数。
### 8. 身份与去重键
登录用 `userId`;匿名用前端 `localStorage` 持久随机 `clientId`(随请求体带上);两者都缺失时回退
`IP + UA`。30 分钟窗口按 `identity + gameId`。不新造匿名 cookie、不依赖登录后才有的设备指纹。
### 9. 落位 = 纯持久化 / 读模型
procedure 在 `spacetime-module`,facade + mapper 在 `spacetime-client`,缓冲 / worker / 端点在
`api-server`;`module-game-distribution` 不动(自增不是领域规则,与既有「点赞计数」直接落持久化流程同构)。
新增 procedure 不改表,schema guard 不触发,但必须 `npm run spacetime:generate` 重生成绑定并同步文档。
### 10. 展示一致性 = 接受滞后
读路径(目录 / 详情)继续只读 DB,不叠加内存 pending;接受「flush 间隔 + 写库」的 ≤10 秒滞后。
## 影响与代价
- 计数非实时,最多一个 flush 窗口的滞后;崩溃 / kill 丢最后一个窗口;模糊传输错误少计。
- 匿名 `clientId` 可被清除 / 伪造,指标定位为展示用次数,仅靠限流兜底。
- 单实例前提:去重 / 限流窗口不跨实例;将来多实例时各实例自行 flush(加法幂等),窗口不共享。
- 不 bump `updated_at`,作者自有列表排序、公开修订 CAS 均不受影响。
- 新增 procedure 只改 ABI(绑定),不改表 schema / `migration.rs`。
## 备选方案与取舍
1. **发行网关服务端计数**:一次加载多资源请求会重复、禁 Cookie 拿不到会话、无法去重。已否决。
2. **复用 AGC 客户端埋点管线**:服务端同步入库且明确不做聚合,客户端 15 分钟批次,白名单不含游玩。已否决。
3. **落盘 / outbox 保可靠**:无持久性需求(崩溃丢窗口已接受),引入磁盘与独立目录要求。已否决。
4. **单张 map / 不做去重**:会重复计数;去重状态与增量生命周期不同。已否决。
5. **每个游戏一次 procedure**:N 次 WebSocket 往返。已否决。
6. **模糊失败重试**:超时已提交时系统性双计。已否决。
7. **匿名 HttpOnly cookie / 纯 IP 去重**:前者要新造 cookie 与跨端 / 沙箱处理,后者在 NAT 下把多人并成一人。
已否决。
## 明确不做
- 日粒度 / 近 7 天 / 独立玩家数 / 榜单 / 推荐。
- AGC 客户端界面埋点、外部 API / External OpenAPI 扩展。
- 服务端网关兜底计数、CSRF token、应用层全局按 IP 限流(沿用既有限流与网关能力)。
- 幂等收据表、双计补偿、跨实例窗口共享。
## 落地与验收
- 实施边界:`spacetime-module` 新增批量自增 procedure 与 `SpacetimeType`;`spacetime-client` facade +
mapper;`api-server` 新增计数模块(增量 / 去重 / 限流 / flush worker / 关停 flush)、AppState 接线与公开
端点;前端 `gameDistributionClient` 增 `recordGamePlay` 并在 `startGame()` 触发。
- 权威文档同步:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(路由表与游戏分发合同节)、
`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`(`game_distribution_game` 的 procedure 与
写入口径)。
- 验收判据:新游戏 `0` → 游客点一次 ≤10s 内显示 `1`;同身份 30 分钟内重复点击不增、不同身份各 `+1`;
超限流 `429` 且不写;下架 `404` 不计数但历史值保留;SIGTERM 关停前最后一窗已落库;计数接口任何失败都
不影响游玩页;相关 Rust / 前端定向测试与 schema / DDD / 绑定 / DTO parity / 编码 / doc-index 门禁全绿。