diff --git a/docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md b/docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md index 3d46b1b95..28c36be08 100644 --- a/docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md +++ b/docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md @@ -39,29 +39,30 @@ URL 里那串随机 UUID 既不可读,客户端也无法在提交前独立构 对外(作者 / 公开)版本一律用 `{version_number}`;后台与内部(`/admin/...`、收据、媒体命名空间) 继续用 `{version_id}`。完整端点表: -| 方法 | 路径 | 请求 | 响应 | 鉴权 / 头 | -|-------------------------------|-----------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------|---------------------------------------| -| POST | `/api/game-distribution/games` | multipart:`metadata` = `NewGameVersionRequest`(JSON),`cover` / `screenshot` 二进制 part | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | -| POST | `/api/game-distribution/games/{game_id}/versions/{version_number}` | 同上,路径值优先并与 body 校验一致 | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | -| GET | `/api/game-distribution/games/{game_id}/versions/{version_number}` | – | 版本详情(`GameDistributionPrivateVersion` + 作品上下文) | Bearer | -| PATCH | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` | -| PUT | `…/versions/{version_number}/package` | `application/octet-stream` | 上传 ack | Bearer + `Idempotency-Key` | -| GET | `…/versions/{version_number}/package/upload-state` | – | 上传状态 | Bearer | -| PUT | `…/versions/{version_number}/package/chunk` | `application/octet-stream` + 偏移头 | 分片 ack | Bearer | -| POST | `…/versions/{version_number}/package/complete` | JSON | ack | Bearer + `Idempotency-Key` | -| POST | `…/versions/{version_number}/package/reset` | JSON | ack | Bearer | +| 方法 | 路径 | 请求 | 响应 | 鉴权 / 头 | +| ----------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------- | +| POST | `/api/game-distribution/games` | multipart:`metadata` = `NewGameVersionRequest`(JSON),`cover` / `screenshot` 二进制 part | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | +| POST | `/api/game-distribution/games/{game_id}/versions/{version_number}` | 同上;body 不含 `gameId` / `versionNumber`,身份全部取自路径 | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | +| GET | `/api/game-distribution/games/{game_id}/versions/{version_number}` | – | 版本详情(`GameDistributionPrivateVersion` + 作品上下文) | Bearer | +| PATCH | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` | +| PUT | `…/versions/{version_number}/package` | `application/octet-stream` | 上传 ack | Bearer + `Idempotency-Key` | +| GET | `…/versions/{version_number}/package/upload-state` | – | 上传状态 | Bearer | +| PUT | `…/versions/{version_number}/package/chunk` | `application/octet-stream` + 偏移头 | 分片 ack | Bearer | +| POST | `…/versions/{version_number}/package/complete` | JSON | ack | Bearer + `Idempotency-Key` | +| POST | `…/versions/{version_number}/package/reset` | JSON | ack | Bearer | | PUT / GET / PUT / POST / POST | `…/versions/{version_number}/source`(`/upload-state` `/chunk` `/complete` `/reset`) | 与发行包族逐条对齐 | 同发行包族 | Bearer(幂等键同族) | -| POST | `…/versions/{version_number}/submit` | JSON:`expectedPublicationRevision` | 版本详情 | Bearer + `Idempotency-Key` | -| POST | `…/versions/{version_number}/cancel` | JSON:`CancelVersionRequest` | 版本详情 | Bearer + `Idempotency-Key` | -| PATCH | `/api/game-distribution/my-games/{game_id}` | multipart:`metadata` = `GameDistributionUpdateGameMetadataRequest`(并入 `forkAuthorization`)+ 媒体 part | `{ game, replayed }` | Bearer + `Idempotency-Key` | -| DELETE | `/api/game-distribution/my-games/{game_id}` | – | 软删 ack | Bearer | -| GET | `/api/game-distribution/games/{game_id}/forks` | – | `GameDistributionDerivedResponse` | 公开(原 `/derived` 改名) | -| GET | `/api/game-distribution/games/{game_id}/network` | – | `GameDistributionLineageResponse`(整棵改编树) | 公开(原 `/lineage` 改名) | -| GET | `/api/game-distribution/games/{game_id}/source`(`/package` `/project`) | – | `GameDistributionForkSourceResponse` / 二进制 | Bearer(原路径改名) | -| GET | `/api/game-distribution/games/{game_id}/network/stats` | – | `GameDistributionContributionResponse`(子树归集) | Bearer(原路径改名) | +| POST | `…/versions/{version_number}/submit` | JSON:`expectedPublicationRevision` | 版本详情 | Bearer + `Idempotency-Key` | +| POST | `…/versions/{version_number}/cancel` | JSON:`CancelVersionRequest` | 版本详情 | Bearer + `Idempotency-Key` | +| PATCH | `/api/game-distribution/my-games/{game_id}` | multipart:`metadata` = `GameDistributionUpdateGameMetadataRequest`(并入 `forkAuthorization`)+ 媒体 part | `{ game, replayed }` | Bearer + `Idempotency-Key` | +| DELETE | `/api/game-distribution/my-games/{game_id}` | – | 软删 ack | Bearer | +| GET | `/api/game-distribution/games/{game_id}/forks` | – | `GameDistributionForksResponse` | 公开(原 `/derived` 改名) | +| GET | `/api/game-distribution/games/{game_id}/network` | – | `GameDistributionNetworkResponse`(整棵改编树) | 公开(原 `/lineage` 改名) | +| GET | `/api/game-distribution/games/{game_id}/source`(`/package` `/project`) | – | `GameDistributionSourceResponse` / 二进制 | Bearer(原路径改名) | +| GET | `/api/game-distribution/games/{game_id}/network/stats` | – | `GameDistributionNetworkStatsResponse`(子树归集) | Bearer(原路径改名) | - `POST /games` 首次发布只强制 `project_key`,`game_id` 由 - `derive_game_distribution_game_id(owner, project_key)` 派生;带 `fork` 时是衍生首版。 + `derive_game_distribution_game_id(owner, project_key)` 派生;**首版号固定为 `1`**(版本身份只在 + 路径 `…/versions/{version_number}` 里出现);带 `fork` 时是衍生首版。 - 作品资料编辑走既有通用 `PATCH /api/game-distribution/my-games/{game_id}`(自带 `expected_publication_revision` CAS);共创授权 `forkAuthorization` 并入该 PATCH,删除单字段 `PUT /api/game-distribution/games/{game_id}/fork-authorization`(`game_distribution.rs:721`)与 @@ -71,15 +72,25 @@ URL 里那串随机 UUID 既不可读,客户端也无法在提交前独立构 资料编辑,则此行删除,改资料仍走 `PATCH /my-games/{game_id}`。 - 后台族继续按 `{version_id}`(`/admin/api/game-distribution/versions/{version_id}/…`),不参与本次换号。 +#### 路径即身份(body 不重复路径参数) + +- 只要路径里有 `{game_id}` / `{version_number}`,请求体就**不得**再出现同名字段:handler 只从 + `Path(…)` 取值,不存在「路径优先还是 body 优先」的校验分支。 +- `NewGameVersionRequest` 因此**去掉** `gameId` 与 `versionNumber`:`POST /games` 只带 `projectKey` + (无路径槽,首版号固定 `1`);`POST /games/{game_id}/versions/{version_number}` 的身份全在路径。 +- 复核:game-distribution 其余带路径参数的写端点(`submit` / `cancel` / `purchase` / `reviews` / + `theme members` / `admin review` / `fork-authorization`)请求体都不含路径身份字段;唯二重复的是 + 本请求与 **待删** 的 legacy `GameDistributionCreateVersionRequest.versionNumber`。 + #### GitHub 术语对齐 -| 现名 | 语义 | GitHub 术语 | 新名 | -| --- | --- | --- | --- | -| `derived` | 直接子代列表 | forks | `forks` | -| `lineage` | 以根为顶的整棵改编树 | fork network(UI「network members」) | `network` | -| `contribution` | 子树各代游玩数归集(`own` / `inherited` / `total`) | Insights / stats(`/stats/…`) | `network/stats` | -| `fork-source` | 拿本作品的成品包 / 工程源包去改编 | `source`(fork 的来源仓库) | `source` | -| `project-bundle` | 某版本作者上传的**可编辑工程源包**(与成品 `package` 并列的上行资产) | source archive(tarball / zipball) | 版本级 `source` | +| 现名 | 语义 | GitHub 术语 | 新名 | +| ---------------- | --------------------------------------------------------------------- | ------------------------------------- | --------------- | +| `derived` | 直接子代列表 | forks | `forks` | +| `lineage` | 以根为顶的整棵改编树 | fork network(UI「network members」) | `network` | +| `contribution` | 子树各代游玩数归集(`own` / `inherited` / `total`) | Insights / stats(`/stats/…`) | `network/stats` | +| `fork-source` | 拿本作品的成品包 / 工程源包去改编 | `source`(fork 的来源仓库) | `source` | +| `project-bundle` | 某版本作者上传的**可编辑工程源包**(与成品 `package` 并列的上行资产) | source archive(tarball / zipball) | 版本级 `source` | - `forks` 只列直接子代,`network` 是整棵树,刻意分开,与 GitHub 的 forks / network members 同构。 - 游戏级 `source`(可 fork 的来源内容)与版本级 `source`(可编辑工程源包)同名但不同层级;若嫌重名, @@ -87,31 +98,31 @@ URL 里那串随机 UUID 既不可读,客户端也无法在提交前独立构 #### fork 字段的可写位置 -- `forkAuthorization`(作品级共创授权档位)是**作品属性**,并入通用 +- `forkAuthorization`(作品级共创授权档位)是 **作品属性**,并入通用 `PATCH /api/game-distribution/my-games/{game_id}`;衍生作品继承父档位后是终态(再改 409)。 - `fork`(`parentGameId` / `parentVersionId`)只在 `POST /games` 创建时写一次,之后不可改——GitHub 也不允许改一个 fork 的 parent。 -- `/forks` 是只读集合,不设 `PATCH /forks`;要改的授权属于**本作品**,不属于子代。 +- `/forks` 是只读集合,不设 `PATCH /forks`;要改的授权属于 **本作品**,不属于子代。 #### projectKey 语义与必填状态 -`projectKey` 是**客户端本地工程标识**(网页 / AGC 的本地 project id),只做**首次发布的身份锚**:服务端 +`projectKey` 是 **客户端本地工程标识**(网页 / AGC 的本地 project id),只做 **首次发布的身份锚**:服务端 用 `derive_game_distribution_game_id(owner, projectKey)` 确定性派生 `gameId`,保证同一工程断网重试 / 多次发布落到同一作品。作品一旦建立,身份权威是路径里的 `gameId`,`projectKey` 不再参与对外寻址。 -AGC 不是每次靠 `projectKey` 反查:它把远端身份**存在本地**——`.agent/manifest.json` 的 +AGC 不是每次靠 `projectKey` 反查:它把远端身份 **存在本地**——`.agent/manifest.json` 的 `publication`(`GameCreationAppPublicationBinding`:`accountId` / `apiBaseUrl` / `gameId` / `revision` / -`latestVersionId` / `latestVersionNumber` / `status`,按「账号 + origin」隔离)。发布时: -**有绑定 → 只传 `gameId`**(走 `POST /games/{game_id}/versions/{version_number}`);**无绑定 → 只传 +`latestVersionId` / `latestVersionNumber` / `status`,按「账号 + origin」隔离)。发布时: **有绑定 → 只传 `gameId`**(走 +`POST /games/{game_id}/versions/{version_number}`); **无绑定 → 只传 `projectKey`**(走 `POST /games`)。无绑定时的恢复:`GET /my-games` 按 `projectKey` 精确匹配;绑定作品 线上 404 → 清掉绑定、回到首次发布。 -| 接口 | `projectKey` | 说明 | -| --- | --- | --- | -| `POST /games` | **必填** | 没有 `gameId` 可参考,靠它派生作品身份;缺失 400 | -| `POST /games/{game_id}/versions/{version_number}` | 可选 | 身份由路径 `game_id` 定;AGC 已绑定时根本不传,只在恢复时兜底 | -| `PATCH /games/{game_id}/versions/{version_number}` | 不需要 | 不换身份 | -| `PATCH /my-games/{game_id}` | 不需要 | 资料编辑不换身份 | +| 接口 | `projectKey` | 说明 | +| -------------------------------------------------- | ------------ | ------------------------------------------------------------- | +| `POST /games` | **必填** | 没有 `gameId` 可参考,靠它派生作品身份;缺失 400 | +| `POST /games/{game_id}/versions/{version_number}` | 可选 | 身份由路径 `game_id` 定;AGC 已绑定时根本不传,只在恢复时兜底 | +| `PATCH /games/{game_id}/versions/{version_number}` | 不需要 | 不换身份 | +| `PATCH /my-games/{game_id}` | 不需要 | 资料编辑不换身份 | ### 3. 请求体与类型只做收敛,不新增第二套 @@ -131,26 +142,26 @@ AGC 不是每次靠 `projectKey` 反查:它把远端身份**存在本地**— #### DTO / 记录类型改名(与路由术语一致) -| 现名 | 新名 | -| --- | --- | -| `GameDistributionDerivedResponse` | `GameDistributionForksResponse` | -| `GameDistributionDerivedGamesRecord` | `GameDistributionForksRecord` | -| `GameDistributionLineageResponse` | `GameDistributionNetworkResponse` | -| `GameDistributionLineage` | `GameDistributionNetwork`(作品在改编网中的位置摘要;JSON 字段名保持 `lineage` 不变) | -| `GameDistributionLineageRecord` | `GameDistributionNetworkRecord` | -| `GameDistributionLineageNode` | `GameDistributionNetworkNode` | -| `GameDistributionLineageNodeRecord` | `GameDistributionNetworkNodeRecord` | -| `GameDistributionLineageTreeRecord` | `GameDistributionNetworkTreeRecord` | -| `GameDistributionContributionResponse` | `GameDistributionNetworkStatsResponse` | -| `GameDistributionContributionTotals` | `GameDistributionNetworkStatsTotals` | -| `GameDistributionContributionGeneration` | `GameDistributionNetworkStatsGeneration` | -| `GameDistributionContributionChild` | `GameDistributionNetworkStatsChild` | -| `GameDistributionForkSource` | `GameDistributionSource` | -| `GameDistributionForkSourceKind` | `GameDistributionSourceKind` | -| `GameDistributionForkSourceResponse` | `GameDistributionSourceResponse` | -| `GameDistributionForkSourceRecord` | `GameDistributionSourceRecord` | -| `GameDistributionForkDeclaration` | `GameDistributionForkMetadata`(抽成独立子对象) | -| `GameDistributionSetForkAuthorizationRequest` | 删除(并入 `PATCH /my-games/{game_id}`) | +| 现名 | 新名 | +| --------------------------------------------- | ------------------------------------------------------------------------------------- | +| `GameDistributionDerivedResponse` | `GameDistributionForksResponse` | +| `GameDistributionDerivedGamesRecord` | `GameDistributionForksRecord` | +| `GameDistributionLineageResponse` | `GameDistributionNetworkResponse` | +| `GameDistributionLineage` | `GameDistributionNetwork`(作品在改编网中的位置摘要;JSON 字段名保持 `lineage` 不变) | +| `GameDistributionLineageRecord` | `GameDistributionNetworkRecord` | +| `GameDistributionLineageNode` | `GameDistributionNetworkNode` | +| `GameDistributionLineageNodeRecord` | `GameDistributionNetworkNodeRecord` | +| `GameDistributionLineageTreeRecord` | `GameDistributionNetworkTreeRecord` | +| `GameDistributionContributionResponse` | `GameDistributionNetworkStatsResponse` | +| `GameDistributionContributionTotals` | `GameDistributionNetworkStatsTotals` | +| `GameDistributionContributionGeneration` | `GameDistributionNetworkStatsGeneration` | +| `GameDistributionContributionChild` | `GameDistributionNetworkStatsChild` | +| `GameDistributionForkSource` | `GameDistributionSource` | +| `GameDistributionForkSourceKind` | `GameDistributionSourceKind` | +| `GameDistributionForkSourceResponse` | `GameDistributionSourceResponse` | +| `GameDistributionForkSourceRecord` | `GameDistributionSourceRecord` | +| `GameDistributionForkDeclaration` | `GameDistributionForkMetadata`(抽成独立子对象) | +| `GameDistributionSetForkAuthorizationRequest` | 删除(并入 `PATCH /my-games/{game_id}`) | - 改名同步落 `packages/shared` 手写契约、`contracts/generated`、`check:game-distribution-dto-parity` 注册表与 `spacetime-client` 记录类型;旧名一律不留别名(clean cut)。 @@ -164,6 +175,9 @@ AGC 不是每次靠 `projectKey` 反查:它把远端身份**存在本地**— 所有 `/api/game-distribution/versions/{version_id}…` 对外路径(`:651-705`)、 `/api/game-distribution/games/{game_id}/derived`(`:796`)与 `PUT /api/game-distribution/games/{game_id}/fork-authorization`(`:721`)。 +- 一并删除只服务旧两步的 DTO:`GameDistributionCreateGameRequest` + (`shared-contracts/src/game_distribution.rs:727`)、`GameDistributionCreateVersionRequest` + (`:790`,其 body 里带 `versionNumber`)、`GameDistributionSetForkAuthorizationRequest`(`:318`)。 - 改名的旧路径一并删:`/lineage`、`/fork-source`(含 `/package` `/project`)、`/contribution`、 `/versions/{version_id}/project-bundle`(由 `/network`、`/source`、`/network/stats`、 `/versions/{version_number}/source` 取代)。 diff --git a/docs/project-memory/plans/【实施计划】游戏分发发布面clean cut与术语对齐-2026-10-07.md b/docs/project-memory/plans/【实施计划】游戏分发发布面clean cut与术语对齐-2026-10-07.md index e6e9a2155..02bff9560 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发发布面clean cut与术语对齐-2026-10-07.md +++ b/docs/project-memory/plans/【实施计划】游戏分发发布面clean cut与术语对齐-2026-10-07.md @@ -25,27 +25,27 @@ 改名表(Rust DTO / 记录 + TS 手写契约 + parity 注册表 + handler 名;旧名不留别名): -| 现名 | 新名 | -| --- | --- | -| `GameDistributionDerivedResponse` | `GameDistributionForksResponse` | -| `GameDistributionDerivedGamesRecord` | `GameDistributionForksRecord` | -| `GameDistributionLineage` | `GameDistributionNetwork`(JSON 字段名保持 `lineage` 不变) | -| `GameDistributionLineageRecord` | `GameDistributionNetworkRecord` | -| `GameDistributionLineageNode` | `GameDistributionNetworkNode` | -| `GameDistributionLineageNodeRecord` | `GameDistributionNetworkNodeRecord` | -| `GameDistributionLineageResponse` | `GameDistributionNetworkResponse` | -| `GameDistributionLineageTreeRecord` | `GameDistributionNetworkTreeRecord` | -| `GameDistributionContributionTotals` | `GameDistributionNetworkStatsTotals` | -| `GameDistributionContributionGeneration` | `GameDistributionNetworkStatsGeneration` | -| `GameDistributionContributionChild` | `GameDistributionNetworkStatsChild` | -| `GameDistributionContributionResponse` | `GameDistributionNetworkStatsResponse` | -| `GameDistributionContribution*Record` | `GameDistributionNetworkStats*Record` | -| `GameDistributionForkSource` | `GameDistributionSource` | -| `GameDistributionForkSourceKind` | `GameDistributionSourceKind` | -| `GameDistributionForkSourceResponse` | `GameDistributionSourceResponse` | -| `GameDistributionForkSourceRecord` | `GameDistributionSourceRecord` | -| `GameDistributionForkDeclaration` | `GameDistributionForkMetadata` | -| `GameDistributionSetForkAuthorizationRequest` | 删除 | +| 现名 | 新名 | +| --------------------------------------------- | ----------------------------------------------------------- | +| `GameDistributionDerivedResponse` | `GameDistributionForksResponse` | +| `GameDistributionDerivedGamesRecord` | `GameDistributionForksRecord` | +| `GameDistributionLineage` | `GameDistributionNetwork`(JSON 字段名保持 `lineage` 不变) | +| `GameDistributionLineageRecord` | `GameDistributionNetworkRecord` | +| `GameDistributionLineageNode` | `GameDistributionNetworkNode` | +| `GameDistributionLineageNodeRecord` | `GameDistributionNetworkNodeRecord` | +| `GameDistributionLineageResponse` | `GameDistributionNetworkResponse` | +| `GameDistributionLineageTreeRecord` | `GameDistributionNetworkTreeRecord` | +| `GameDistributionContributionTotals` | `GameDistributionNetworkStatsTotals` | +| `GameDistributionContributionGeneration` | `GameDistributionNetworkStatsGeneration` | +| `GameDistributionContributionChild` | `GameDistributionNetworkStatsChild` | +| `GameDistributionContributionResponse` | `GameDistributionNetworkStatsResponse` | +| `GameDistributionContribution*Record` | `GameDistributionNetworkStats*Record` | +| `GameDistributionForkSource` | `GameDistributionSource` | +| `GameDistributionForkSourceKind` | `GameDistributionSourceKind` | +| `GameDistributionForkSourceResponse` | `GameDistributionSourceResponse` | +| `GameDistributionForkSourceRecord` | `GameDistributionSourceRecord` | +| `GameDistributionForkDeclaration` | `GameDistributionForkMetadata` | +| `GameDistributionSetForkAuthorizationRequest` | 删除 | - handler / facade 名:`get_game_derived_games`→`get_game_forks`、`get_game_lineage`→`get_game_network`、 `get_game_contribution`→`get_game_network_stats`、`get_fork_source*`→`get_source*`。 @@ -74,8 +74,9 @@ ## 5. M3 路由收敛(新代码拆小文件) -- 新端点:`POST /api/game-distribution/games`(无 `game_id`,强制 `project_key`)复用 `game_distribution_publish.rs` - 的统一发布深模块;`POST /games/{game_id}/versions/{version_number}` 由路径覆盖 body 的 `game_id` / `version_number`。 +- 新端点:`POST /api/game-distribution/games`(无 `game_id`,强制 `project_key`,首版号固定 `1`)复用 + `game_distribution_publish.rs` 的统一发布深模块;`POST /games/{game_id}/versions/{version_number}` + 的 `game_id` / `version_number` **只从路径取**——`NewGameVersionRequest` 删除这两个字段,body 不再重复路径身份。 - 平台化:把 `publish_version` 收成 `publish_version_core(game_id: Option, version_number: Option, multipart)`, 两个路由只做参数提取与鉴权,事务逻辑不变。 - 版本子资源 `package / source / submit / cancel / GET / PATCH` 迁到 `…/versions/{version_number}`; @@ -84,6 +85,7 @@ - 退役:统一 `POST /versions`、旧两步 `POST /games/{game_id}/versions`、所有对外 `/versions/{version_id}…`、 `/derived`、`/lineage`、`/contribution`、`/fork-source`、`PUT .../fork-authorization` 与 `GameDistributionSetForkAuthorizationRequest`;`forkAuthorization` 并入 `PATCH /my-games/{game_id}`。 +- 旧两步 DTO `GameDistributionCreateGameRequest` / `GameDistributionCreateVersionRequest` 一并删除。 - 后台 `/admin/api/game-distribution/versions/{version_id}/…` 保持不动。 ## 6. M4 客户端 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 01c9b28c7..97990d4ab 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -53,11 +53,11 @@ ## 游戏分发发布媒体直传合同(2026-10-06) -| 字段 | 值 | -| --- | --- | -| Version | 0.1 | -| Status | current(实现已落地;真实 OSS 与真实发布 smoke 待验收) | -| Date | 2026-10-06 | +| 字段 | 值 | +| -------- | ------------------------------------------------------------------------------- | +| Version | 0.1 | +| Status | current(实现已落地;真实 OSS 与真实发布 smoke 待验收) | +| Date | 2026-10-06 | | 适用边界 | game-distribution 发布资料媒体(封面/截图)的上传与公开读取;发行包上传链路不变 | - 写:`POST /api/game-distribution/versions`(2026-10-07 起取代 `POST /games` 与 `POST /games/{gameId}/versions`)、`PATCH /api/game-distribution/my-games/{gameId}` 接受 `multipart/form-data`:`metadata` 文本 part(JSON,含 `coverObjectKey` 与 `screenshots: (string|null)[]`);`cover` 二进制 part(可选,出现时覆盖 `coverObjectKey`);`screenshot` 二进制 parts(≤6 张,按 `null` 槽位顺序消费)。`string` 槽位表示沿用线上 objectKey。硬切,不接受素材 ID 形式的媒体引用。 @@ -69,13 +69,13 @@ ## 游戏分发统一发布接口与版本号自然幂等合同(2026-10-07) -| 字段 | 值 | -| --- | --- | -| Version | 0.1 | -| Status | superseded(对外路由拓扑自 2026-10-07 起被「游戏分发发布面 clean cut 与术语对齐合同(2026-10-07)」取代;`versionNumber` 必填与自然幂等键口径仍 current) | -| Date | 2026-10-07 | -| 适用边界 | game-distribution 作者发布写路径(建作品 + 建版本合一)与版本号幂等语义;媒体上传/读取沿用「游戏分发发布媒体直传合同(2026-10-06)」;发行包上传、审核、公开读取不变 | -| 取代 | 本文件「AGC 游戏分发与在线游玩合同」中「平台侧 versionNumber 允许回退/重复提交、服务端不传时自增」「同一 gameId 同一 versionNumber 再次提交创建新 versionId」两条,以及 `POST /games`、`POST /games/{gameId}/versions` 两条路由 | +| 字段 | 值 | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Version | 0.1 | +| Status | superseded(对外路由拓扑自 2026-10-07 起被「游戏分发发布面 clean cut 与术语对齐合同(2026-10-07)」取代;`versionNumber` 必填与自然幂等键口径仍 current) | +| Date | 2026-10-07 | +| 适用边界 | game-distribution 作者发布写路径(建作品 + 建版本合一)与版本号幂等语义;媒体上传/读取沿用「游戏分发发布媒体直传合同(2026-10-06)」;发行包上传、审核、公开读取不变 | +| 取代 | 本文件「AGC 游戏分发与在线游玩合同」中「平台侧 versionNumber 允许回退/重复提交、服务端不传时自增」「同一 gameId 同一 versionNumber 再次提交创建新 versionId」两条,以及 `POST /games`、`POST /games/{gameId}/versions` 两条路由 | > 取代:本节的对外路由拓扑(`POST /versions` 与 `/versions/{versionId}/...`)自 2026-10-07 起由下一节「游戏分发发布面 clean cut 与术语对齐合同(2026-10-07)」取代;`versionNumber` 必填与自然幂等键口径不变。 @@ -93,46 +93,52 @@ ## 游戏分发发布面 clean cut 与术语对齐合同(2026-10-07) -| 字段 | 值 | -| --- | --- | -| Version | 0.1 | -| Status | current(已定稿;实现与证据按实施计划回写) | -| Date | 2026-10-07 | -| 适用边界 | game-distribution 对外发布与读面的路由拓扑、版本身份、项目锚与命名;媒体直传沿用「游戏分发发布媒体直传合同(2026-10-06)」;后台 `/admin/...`、幂等收据与媒体命名空间仍按 `{version_id}` | -| 取代 | 上一节「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」的对外路由拓扑(`POST /versions` 与 `/versions/{versionId}/...`),以及 `/derived`、`/lineage`、`/contribution`、`/fork-source`、`/fork-authorization`、`project-bundle` 命名;`versionNumber` 必填与自然幂等键口径不变 | -| 决策来源 | `docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md` | +| 字段 | 值 | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Version | 0.1 | +| Status | current(已定稿;实现与证据按实施计划回写) | +| Date | 2026-10-07 | +| 适用边界 | game-distribution 对外发布与读面的路由拓扑、版本身份、项目锚与命名;媒体直传沿用「游戏分发发布媒体直传合同(2026-10-06)」;后台 `/admin/...`、幂等收据与媒体命名空间仍按 `{version_id}` | +| 取代 | 上一节「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」的对外路由拓扑(`POST /versions` 与 `/versions/{versionId}/...`),以及 `/derived`、`/lineage`、`/contribution`、`/fork-source`、`/fork-authorization`、`project-bundle` 命名;`versionNumber` 必填与自然幂等键口径不变 | +| 决策来源 | `docs/adr/【ADR】游戏分发版本以versionNumber为对外身份-2026-10-07.md` | ### 对外端点表 对外作者 / 公开版本的路径一律用客户端冻结的正整数 `{version_number}`;后台与内部(`/admin/...`、幂等收据、媒体命名空间)继续用 `{version_id}`。下表除后台族外均带 `/api/game-distribution` 前缀;`…` 表示同一作品前缀 `/api/game-distribution/games/{game_id}`。 -| 方法 | 路径 | 请求 | 响应 | 鉴权 / 头 | -| --- | --- | --- | --- | --- | -| POST | `/api/game-distribution/games` | multipart:`metadata` = `NewGameVersionRequest`(JSON),`cover` / `screenshot` 二进制 part | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | -| POST | `/api/game-distribution/games/{game_id}/versions/{version_number}` | 同上,路径值优先并与 body 校验一致 | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | -| GET | `/api/game-distribution/games/{game_id}/versions/{version_number}` | – | 版本详情(`GameDistributionPrivateVersion` + 作品上下文) | Bearer | -| PATCH | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` | -| PUT | `…/versions/{version_number}/package` | `application/octet-stream` | 上传 ack | Bearer + `Idempotency-Key` | -| GET | `…/versions/{version_number}/package/upload-state` | – | 上传状态 | Bearer | -| PUT | `…/versions/{version_number}/package/chunk` | `application/octet-stream` + 偏移头 | 分片 ack | Bearer | -| POST | `…/versions/{version_number}/package/complete` | JSON | ack | Bearer + `Idempotency-Key` | -| POST | `…/versions/{version_number}/package/reset` | JSON | ack | Bearer | -| PUT / GET / PUT / POST / POST | `…/versions/{version_number}/source`(`/upload-state` `/chunk` `/complete` `/reset`) | 与发行包族逐条对齐 | 同发行包族 | Bearer(幂等键同族) | -| POST | `…/versions/{version_number}/submit` | JSON:`expectedPublicationRevision` | 版本详情 | Bearer + `Idempotency-Key` | -| POST | `…/versions/{version_number}/cancel` | JSON:`CancelVersionRequest` | 版本详情 | Bearer + `Idempotency-Key` | -| PATCH | `/api/game-distribution/my-games/{game_id}` | multipart:`metadata` = `GameDistributionUpdateGameMetadataRequest`(并入 `forkAuthorization`)+ 媒体 part | `{ game, replayed }` | Bearer + `Idempotency-Key` | -| DELETE | `/api/game-distribution/my-games/{game_id}` | – | 软删 ack | Bearer | -| GET | `/api/game-distribution/games/{game_id}/forks` | – | `GameDistributionForksResponse` | 公开(原 `/derived` 改名) | -| GET | `/api/game-distribution/games/{game_id}/network` | – | `GameDistributionNetworkResponse`(整棵改编树) | 公开(原 `/lineage` 改名) | -| GET | `/api/game-distribution/games/{game_id}/source`(`/package` `/project`) | – | `GameDistributionSourceResponse` / 二进制 | Bearer(原路径改名) | -| GET | `/api/game-distribution/games/{game_id}/network/stats` | – | `GameDistributionNetworkStatsResponse`(子树归集) | Bearer(原路径改名) | +| 方法 | 路径 | 请求 | 响应 | 鉴权 / 头 | +| ----------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------- | +| POST | `/api/game-distribution/games` | multipart:`metadata` = `NewGameVersionRequest`(JSON),`cover` / `screenshot` 二进制 part | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | +| POST | `/api/game-distribution/games/{game_id}/versions/{version_number}` | 同上;body 不含 `gameId` / `versionNumber`,身份全部取自路径 | `GameDistributionPublishVersionResponse` | Bearer + `Idempotency-Key` + 发布灰度 | +| GET | `/api/game-distribution/games/{game_id}/versions/{version_number}` | – | 版本详情(`GameDistributionPrivateVersion` + 作品上下文) | Bearer | +| PATCH | `/api/game-distribution/games/{game_id}/versions/{version_number}` | JSON:`GameMetadata` + `expectedPublicationRevision` CAS | 版本详情 | Bearer + `Idempotency-Key` | +| PUT | `…/versions/{version_number}/package` | `application/octet-stream` | 上传 ack | Bearer + `Idempotency-Key` | +| GET | `…/versions/{version_number}/package/upload-state` | – | 上传状态 | Bearer | +| PUT | `…/versions/{version_number}/package/chunk` | `application/octet-stream` + 偏移头 | 分片 ack | Bearer | +| POST | `…/versions/{version_number}/package/complete` | JSON | ack | Bearer + `Idempotency-Key` | +| POST | `…/versions/{version_number}/package/reset` | JSON | ack | Bearer | +| PUT / GET / PUT / POST / POST | `…/versions/{version_number}/source`(`/upload-state` `/chunk` `/complete` `/reset`) | 与发行包族逐条对齐 | 同发行包族 | Bearer(幂等键同族) | +| POST | `…/versions/{version_number}/submit` | JSON:`expectedPublicationRevision` | 版本详情 | Bearer + `Idempotency-Key` | +| POST | `…/versions/{version_number}/cancel` | JSON:`CancelVersionRequest` | 版本详情 | Bearer + `Idempotency-Key` | +| PATCH | `/api/game-distribution/my-games/{game_id}` | multipart:`metadata` = `GameDistributionUpdateGameMetadataRequest`(并入 `forkAuthorization`)+ 媒体 part | `{ game, replayed }` | Bearer + `Idempotency-Key` | +| DELETE | `/api/game-distribution/my-games/{game_id}` | – | 软删 ack | Bearer | +| GET | `/api/game-distribution/games/{game_id}/forks` | – | `GameDistributionForksResponse` | 公开(原 `/derived` 改名) | +| GET | `/api/game-distribution/games/{game_id}/network` | – | `GameDistributionNetworkResponse`(整棵改编树) | 公开(原 `/lineage` 改名) | +| GET | `/api/game-distribution/games/{game_id}/source`(`/package` `/project`) | – | `GameDistributionSourceResponse` / 二进制 | Bearer(原路径改名) | +| GET | `/api/game-distribution/games/{game_id}/network/stats` | – | `GameDistributionNetworkStatsResponse`(子树归集) | Bearer(原路径改名) | -- `POST /games` 首次发布只强制 `projectKey`,`game_id` 由 `derive_game_distribution_game_id(owner, project_key)` 派生;带 `fork` 时是衍生首版。 +- `POST /games` 首次发布只强制 `projectKey`,`game_id` 由 `derive_game_distribution_game_id(owner, project_key)` 派生;**首版号固定为 `1`**(无路径槽),带 `fork` 时是衍生首版。 - 作品资料编辑走既有通用 `PATCH /my-games/{game_id}`(自带 `expectedPublicationRevision` CAS);共创授权 `forkAuthorization` 并入该 PATCH,删除单字段 `PUT /games/{game_id}/fork-authorization` 与 `GameDistributionSetForkAuthorizationRequest`。 - `PATCH …/versions/{version_number}` 的请求体优先复用现有 `GameDistributionUpdateGameMetadataRequest`(CAS + 资料字段),不新增类型;若最终确认只需作品级资料编辑,则删除该行,资料编辑仍走 `PATCH /my-games/{game_id}`。 - 后台族继续按 `{version_id}`(`/admin/api/game-distribution/versions/{version_id}/…`),不参与本次换号。 - 表中 Response 列已按下方「DTO / 记录类型改名」使用新名。 +### 路径即身份(body 不重复路径参数) + +- 只要路径里有 `{game_id}` / `{version_number}`,请求体就**不得**再出现同名字段:handler 只从 `Path(…)` 取值,不存在「路径优先还是 body 优先」的校验分支。 +- `NewGameVersionRequest` 因此**去掉** `gameId` 与 `versionNumber`:`POST /games` 只带 `projectKey`(无路径槽,首版号固定 `1`);`POST /games/{game_id}/versions/{version_number}` 的身份全在路径。 +- 复核:game-distribution 其余带路径参数的写端点(`submit` / `cancel` / `purchase` / `reviews` / `theme members` / `admin review` / `fork-authorization`)请求体都不含路径身份字段;唯二重复的是本请求与 **待删** 的 legacy `GameDistributionCreateVersionRequest.versionNumber`。 + ### 版本身份(versionNumber 对外,version_id 内部主键) - URL 中的版本一律用客户端冻结的正整数 `version_number` 标识;`version_id` 保留为 SpacetimeDB 主键与内部代理键,继续用于幂等收据 `outcome_version_id`、媒体命名空间、客户端草稿与生成绑定。 @@ -147,22 +153,22 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地**:`.agent/manifest.json` 的 `publication`(`GameCreationAppPublicationBinding`:`accountId` / `apiBaseUrl` / `gameId` / `revision` / `latestVersionId` / `latestVersionNumber` / `status`,按「账号 + origin」隔离)。发布时:**有绑定 → 只传 `gameId`**(走 `POST /games/{game_id}/versions/{version_number}`);**无绑定 → 只传 `projectKey`**(走 `POST /games`)。无绑定时的恢复:`GET /my-games` 按 `projectKey` 精确匹配;绑定作品线上 404 → 清掉绑定、回到首次发布。 -| 接口 | `projectKey` | 说明 | -| --- | --- | --- | -| `POST /games` | **必填** | 没有 `gameId` 可参考,靠它派生作品身份;缺失 400 | -| `POST /games/{game_id}/versions/{version_number}` | 可选 | 身份由路径 `game_id` 定;AGC 已绑定时根本不传,只在恢复时兜底 | -| `PATCH /games/{game_id}/versions/{version_number}` | 不需要 | 不换身份 | -| `PATCH /my-games/{game_id}` | 不需要 | 资料编辑不换身份 | +| 接口 | `projectKey` | 说明 | +| -------------------------------------------------- | ------------ | ------------------------------------------------------------- | +| `POST /games` | **必填** | 没有 `gameId` 可参考,靠它派生作品身份;缺失 400 | +| `POST /games/{game_id}/versions/{version_number}` | 可选 | 身份由路径 `game_id` 定;AGC 已绑定时根本不传,只在恢复时兜底 | +| `PATCH /games/{game_id}/versions/{version_number}` | 不需要 | 不换身份 | +| `PATCH /my-games/{game_id}` | 不需要 | 资料编辑不换身份 | ### GitHub 术语对齐 -| 现名 | 语义 | GitHub 术语 | 新名 | -| --- | --- | --- | --- | -| `derived` | 直接子代列表 | forks | `forks` | -| `lineage` | 以根为顶的整棵改编树 | fork network(UI「network members」) | `network` | -| `contribution` | 子树各代游玩数归集(`own` / `inherited` / `total`) | Insights / stats(`/stats/…`) | `network/stats` | -| `fork-source` | 拿本作品的成品包 / 工程源包去改编 | `source`(fork 的来源仓库) | `source` | -| `project-bundle` | 某版本作者上传的**可编辑工程源包**(与成品 `package` 并列的上行资产) | source archive(tarball / zipball) | 版本级 `source` | +| 现名 | 语义 | GitHub 术语 | 新名 | +| ---------------- | --------------------------------------------------------------------- | ------------------------------------- | --------------- | +| `derived` | 直接子代列表 | forks | `forks` | +| `lineage` | 以根为顶的整棵改编树 | fork network(UI「network members」) | `network` | +| `contribution` | 子树各代游玩数归集(`own` / `inherited` / `total`) | Insights / stats(`/stats/…`) | `network/stats` | +| `fork-source` | 拿本作品的成品包 / 工程源包去改编 | `source`(fork 的来源仓库) | `source` | +| `project-bundle` | 某版本作者上传的**可编辑工程源包**(与成品 `package` 并列的上行资产) | source archive(tarball / zipball) | 版本级 `source` | - `forks` 只列直接子代,`network` 是整棵树,刻意分开,与 GitHub 的 forks / network members 同构。 - 游戏级 `source`(可 fork 的来源内容)与版本级 `source`(可编辑工程源包)同名但不同层级;若嫌重名,游戏级改 `upstream`。 @@ -175,26 +181,26 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ### DTO / 记录类型改名(与路由术语一致) -| 现名 | 新名 | -| --- | --- | -| `GameDistributionDerivedResponse` | `GameDistributionForksResponse` | -| `GameDistributionDerivedGamesRecord` | `GameDistributionForksRecord` | -| `GameDistributionLineageResponse` | `GameDistributionNetworkResponse` | -| `GameDistributionLineage` | `GameDistributionNetwork`(作品在改编网中的位置摘要;JSON 字段名保持 `lineage` 不变) | -| `GameDistributionLineageRecord` | `GameDistributionNetworkRecord` | -| `GameDistributionLineageNode` | `GameDistributionNetworkNode` | -| `GameDistributionLineageNodeRecord` | `GameDistributionNetworkNodeRecord` | -| `GameDistributionLineageTreeRecord` | `GameDistributionNetworkTreeRecord` | -| `GameDistributionContributionResponse` | `GameDistributionNetworkStatsResponse` | -| `GameDistributionContributionTotals` | `GameDistributionNetworkStatsTotals` | -| `GameDistributionContributionGeneration` | `GameDistributionNetworkStatsGeneration` | -| `GameDistributionContributionChild` | `GameDistributionNetworkStatsChild` | -| `GameDistributionForkSource` | `GameDistributionSource` | -| `GameDistributionForkSourceKind` | `GameDistributionSourceKind` | -| `GameDistributionForkSourceResponse` | `GameDistributionSourceResponse` | -| `GameDistributionForkSourceRecord` | `GameDistributionSourceRecord` | -| `GameDistributionForkDeclaration` | `GameDistributionForkMetadata`(抽成独立子对象) | -| `GameDistributionSetForkAuthorizationRequest` | 删除(并入 `PATCH /my-games/{game_id}`) | +| 现名 | 新名 | +| --------------------------------------------- | ------------------------------------------------------------------------------------- | +| `GameDistributionDerivedResponse` | `GameDistributionForksResponse` | +| `GameDistributionDerivedGamesRecord` | `GameDistributionForksRecord` | +| `GameDistributionLineageResponse` | `GameDistributionNetworkResponse` | +| `GameDistributionLineage` | `GameDistributionNetwork`(作品在改编网中的位置摘要;JSON 字段名保持 `lineage` 不变) | +| `GameDistributionLineageRecord` | `GameDistributionNetworkRecord` | +| `GameDistributionLineageNode` | `GameDistributionNetworkNode` | +| `GameDistributionLineageNodeRecord` | `GameDistributionNetworkNodeRecord` | +| `GameDistributionLineageTreeRecord` | `GameDistributionNetworkTreeRecord` | +| `GameDistributionContributionResponse` | `GameDistributionNetworkStatsResponse` | +| `GameDistributionContributionTotals` | `GameDistributionNetworkStatsTotals` | +| `GameDistributionContributionGeneration` | `GameDistributionNetworkStatsGeneration` | +| `GameDistributionContributionChild` | `GameDistributionNetworkStatsChild` | +| `GameDistributionForkSource` | `GameDistributionSource` | +| `GameDistributionForkSourceKind` | `GameDistributionSourceKind` | +| `GameDistributionForkSourceResponse` | `GameDistributionSourceResponse` | +| `GameDistributionForkSourceRecord` | `GameDistributionSourceRecord` | +| `GameDistributionForkDeclaration` | `GameDistributionForkMetadata`(抽成独立子对象) | +| `GameDistributionSetForkAuthorizationRequest` | 删除(并入 `PATCH /my-games/{game_id}`) | - 改名同步落 `packages/shared` 手写契约、`contracts/generated`、`check:game-distribution-dto-parity` 注册表与 `spacetime-client` 记录类型;旧名一律不留别名(clean cut)。 - handler 名同步:`get_game_derived_games`→`get_game_forks`、`get_game_lineage`→`get_game_network`、`get_game_contribution`→`get_game_network_stats`、`get_fork_source*`→`get_source*`。 @@ -220,11 +226,11 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ## AGC 游戏分发与在线游玩合同 -| 字段 | 值 | -| --- | --- | -| Version | 0.2 | -| Status | current(实现已落地;真实客户端 GUI 与生产发布仍待验收) | -| Date | 2026-09-18 | +| 字段 | 值 | +| -------- | --------------------------------------------------------------------------------------------- | +| Version | 0.2 | +| Status | current(实现已落地;真实客户端 GUI 与生产发布仍待验收) | +| Date | 2026-09-18 | | 适用边界 | 当前 game-distribution 作品管理、Phaser 4 客户端发布和静态 Web 发行合同;生产发布开关默认关闭 | ### 交付目标与范围 @@ -286,29 +292,29 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 现役内部命名空间使用 `/api/game-distribution`;除标注为完整后台路径的行外,下表路径都带该前缀。标注「已实现」的路由已在 `api-server` 挂载并有定向测试;本表当前没有待落地路由。公开目录只消费后端投影;所有变更经过现有鉴权、限流和埋点中间件。账号内部 API 不扩展 `/api/external/v1`,后续若对外开放再同步 External OpenAPI。 -| 方法与路径 | 身份 | 行为 | -| --- | --- | --- | -| `GET /games?search=&category=&authorId=&limit=&cursor=` | 游客 | **已实现**:关键词、分类与作者筛选,仅公开可玩版本(`published`、未软删除、有当前公开版本)。**真游标分页**:`limit` 缺省 **48**(保持既有首屏语义)、上限 **100**,超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{gameId}"`(与 `/my-collections`、主题列表同一套 `"{micros}:{id}"` 惯例,解析只切第一个冒号);**游标格式非法 → 400 `CATALOG_INVALID_CURSOR`**(不是 200 的空页)。排序 `createdAt` 倒序 + `gameId` 升序兜底(全序,翻页不重不漏),顺序定义为「先按可见性过滤、再排序切页」;响应 `{ games, nextCursor }`,`nextCursor` 是真实游标、**末页为 `null`** | -| `GET /games/{gameId}` | 游客/登录 | **已实现**:当前公开资料、`priceMudPoints` 与查看者 `purchased`(匿名、坏令牌与读取失败按未购买,内容访问失败关闭);付费作品对未购买且非作者/管理员的查看者把 `currentVersion.entryUrl` 置为 `null`,资料与价格仍可见;不可见时 404 | -| `POST /games/{gameId}/plays` | 游客/登录 | **已实现**:上报一次「开始游戏」;可选 Bearer,非公开 404、超限 429,成功返回 `{recorded}`;只进 api-server 内存缓冲,失败不影响游玩 | -| `POST /games/{gameId}/purchase` | 登录用户 | **已实现**:买断制购买,必填 `Idempotency-Key`,体 `{ expectedPriceMudPoints }`;扣费与购买记录同事务,重复购买幂等返回既有记录不再扣费;余额不足 400 `INSUFFICIENT_MUD_POINTS`、作者自购 400 `GAME_PURCHASE_OWNER_EXEMPT`、后台管理员令牌 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`、价格已被新版本改变 409;免费游戏按 400 拒绝 | -| `POST /games/{gameId}/play-session` | 游客/登录 | **已实现**:付费作品需登录,仅已购买、作者本人或后台管理员可签发,响应 `{ playUrl, expiresAt }`;免费作品不校验登录也不签发令牌,直接返回现有公开入口 `/games/{gameId}/`;未登录 401、不可见 404、未购买且非作者/管理员 403 | -| `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;当前公开版本 `price_mud_points > 0` 时统一 404(付费作品只经播放会话开放,不能靠猜测 `gameId` 绕过购买);免费作品的游玩页入口来自详情投影的 `currentVersion.entryUrl` | -| `GET /game-distribution/play-sessions/{token}[/{assetPath}]` | 凭播放令牌 | **已实现**:凭令牌读取当前公开版本包内文件,不读 Cookie(带可解析平台 refresh Cookie 的请求一律 403,边缘/dev 按该前缀清 Cookie),`no-store`;令牌不存在或过期、游戏下架/封禁、无有效公开版本一律 404 | -| `GET /my-games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生,单次最多 48 项 | -| `GET /my-games/{gameId}` | 登录作者 | **已实现**:作者读自己名下单个游戏的详情,条目与 `GET /my-games` 同形(含全部版本私有状态、驳回理由与已公开版本的 `entryUrl`)。作者要能打开「审核中 / 被驳回 / 已下架 / 已撤回」的作品,公开详情只服务已公开投影,所以作者视角必须走这条 owner 作用域路由;游戏不存在或不属于当前主体都返回 404 | -| `PATCH /my-games/{gameId}` | 登录作者 | **已实现**:作者编辑自己名下游戏的展示资料(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向),立即生效并落 `tracking_event` 审计;要求 `Idempotency-Key` 与 `expectedPublicationRevision` CAS,随版本冻结的包摘要与资料快照不受影响,缺封面或沿用 objectKey 不命中该作品当前媒体仍按创建口径拒绝 | -| `DELETE /my-games/{gameId}?expectedPublicationRevision=` | 登录作者 | **已实现**:作者软删除自己的作品。只写 `deleted_at` 并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料保留;作者列表/公开目录/公开详情/发行网关/审核队列与后台默认视图都不再返回,后台可用 `status=deleted` 查看。要求 `Idempotency-Key`(同 key 同请求返回原结果),不受发布灰度开关约束 | -| `POST /games`、`POST /games/{gameId}/versions/{versionNumber}` | 登录作者 | **本次改造**:发布入口(首次发布走 `POST /games`,既有作品发新版走 `POST /games/{gameId}/versions/{versionNumber}`)。`metadata` JSON 带 `gameId?` / `projectKey?` / `versionNumber`(必填)/ 包声明 / `gameMetadata`,媒体走 `cover`/`screenshot` part;无 `gameId` 时按 `(owner, projectKey)` get-or-create 作品并确定性派生 gameId,`gameMetadata` + 媒体 bootstrap 作品行。幂等键 = `(owner, projectKey \| gameId, versionNumber)`:同号同摘要重放、同号异摘要 409;鉴权 / 灰度与头按端点表;返回 `{ game, version, replayed }`。详见「游戏分发发布面 clean cut 与术语对齐合同(2026-10-07)」 | -| `PUT /games/{gameId}/versions/{versionNumber}/package` | owner | **已实现**:接收真实 ZIP、重算摘要与文件清单并写入私有对象;不执行游戏代码 | -| `GET /games/{gameId}/versions/{versionNumber}` | owner/管理员 | **已实现**:回读状态、错误代码、审核结论与服务端 `recoveryAction`;管理员走 `/admin/api/game-distribution/versions/{versionId}`;未知版本与别人的版本都按不可见返回 404 | -| `POST /games/{gameId}/versions/{versionNumber}/submit` | owner | **已实现**:只有已确认完整包可提交,返回 202 并进入 `pending_review` | -| `POST /games/{gameId}/versions/{versionNumber}/cancel` | owner | **已实现**:带 `expectedPublicationRevision` CAS 与 `Idempotency-Key`,只能撤回未参与公开投影的版本;同 key 同请求重放返回 `replayed: true`,摘要不同返回 409 | -| `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 | -| `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 | -| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按 gameId 派生平台同源发行路径 `/games/{gameId}/` 并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 | -| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | **已实现**:安全下架整个游戏并撤销发行访问,要求 `expectedPublicationRevision` CAS 与幂等键;入口在后台「游戏管理」页,带原因输入与二次确认(审核页只做版本级通过/拒绝,不承载游戏级下架);此行是完整后台路径 | -| `GET /admin/api/game-distribution/games` | 管理员 | **已实现**:后台作品管理列表,支持 `limit` / `keyword` / `owner` / `status`(`published`、`unpublished`、`suspended`、`deleted`,为空时排除软删行)/ `cursor` 过滤与游标分页,返回 `{ games, nextCursor }`;`status` 不在白名单时 400;此行是完整后台路径 | +| 方法与路径 | 身份 | 行为 | +| --------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /games?search=&category=&authorId=&limit=&cursor=` | 游客 | **已实现**:关键词、分类与作者筛选,仅公开可玩版本(`published`、未软删除、有当前公开版本)。**真游标分页**:`limit` 缺省 **48**(保持既有首屏语义)、上限 **100**,超界**截断**而非报错;`cursor` 形如 `"{createdAtMicros}:{gameId}"`(与 `/my-collections`、主题列表同一套 `"{micros}:{id}"` 惯例,解析只切第一个冒号);**游标格式非法 → 400 `CATALOG_INVALID_CURSOR`**(不是 200 的空页)。排序 `createdAt` 倒序 + `gameId` 升序兜底(全序,翻页不重不漏),顺序定义为「先按可见性过滤、再排序切页」;响应 `{ games, nextCursor }`,`nextCursor` 是真实游标、**末页为 `null`** | +| `GET /games/{gameId}` | 游客/登录 | **已实现**:当前公开资料、`priceMudPoints` 与查看者 `purchased`(匿名、坏令牌与读取失败按未购买,内容访问失败关闭);付费作品对未购买且非作者/管理员的查看者把 `currentVersion.entryUrl` 置为 `null`,资料与价格仍可见;不可见时 404 | +| `POST /games/{gameId}/plays` | 游客/登录 | **已实现**:上报一次「开始游戏」;可选 Bearer,非公开 404、超限 429,成功返回 `{recorded}`;只进 api-server 内存缓冲,失败不影响游玩 | +| `POST /games/{gameId}/purchase` | 登录用户 | **已实现**:买断制购买,必填 `Idempotency-Key`,体 `{ expectedPriceMudPoints }`;扣费与购买记录同事务,重复购买幂等返回既有记录不再扣费;余额不足 400 `INSUFFICIENT_MUD_POINTS`、作者自购 400 `GAME_PURCHASE_OWNER_EXEMPT`、后台管理员令牌 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED`、价格已被新版本改变 409;免费游戏按 400 拒绝 | +| `POST /games/{gameId}/play-session` | 游客/登录 | **已实现**:付费作品需登录,仅已购买、作者本人或后台管理员可签发,响应 `{ playUrl, expiresAt }`;免费作品不校验登录也不签发令牌,直接返回现有公开入口 `/games/{gameId}/`;未登录 401、不可见 404、未购买且非作者/管理员 403 | +| `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;当前公开版本 `price_mud_points > 0` 时统一 404(付费作品只经播放会话开放,不能靠猜测 `gameId` 绕过购买);免费作品的游玩页入口来自详情投影的 `currentVersion.entryUrl` | +| `GET /game-distribution/play-sessions/{token}[/{assetPath}]` | 凭播放令牌 | **已实现**:凭令牌读取当前公开版本包内文件,不读 Cookie(带可解析平台 refresh Cookie 的请求一律 403,边缘/dev 按该前缀清 Cookie),`no-store`;令牌不存在或过期、游戏下架/封禁、无有效公开版本一律 404 | +| `GET /my-games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生,单次最多 48 项 | +| `GET /my-games/{gameId}` | 登录作者 | **已实现**:作者读自己名下单个游戏的详情,条目与 `GET /my-games` 同形(含全部版本私有状态、驳回理由与已公开版本的 `entryUrl`)。作者要能打开「审核中 / 被驳回 / 已下架 / 已撤回」的作品,公开详情只服务已公开投影,所以作者视角必须走这条 owner 作用域路由;游戏不存在或不属于当前主体都返回 404 | +| `PATCH /my-games/{gameId}` | 登录作者 | **已实现**:作者编辑自己名下游戏的展示资料(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向),立即生效并落 `tracking_event` 审计;要求 `Idempotency-Key` 与 `expectedPublicationRevision` CAS,随版本冻结的包摘要与资料快照不受影响,缺封面或沿用 objectKey 不命中该作品当前媒体仍按创建口径拒绝 | +| `DELETE /my-games/{gameId}?expectedPublicationRevision=` | 登录作者 | **已实现**:作者软删除自己的作品。只写 `deleted_at` 并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料保留;作者列表/公开目录/公开详情/发行网关/审核队列与后台默认视图都不再返回,后台可用 `status=deleted` 查看。要求 `Idempotency-Key`(同 key 同请求返回原结果),不受发布灰度开关约束 | +| `POST /games`、`POST /games/{gameId}/versions/{versionNumber}` | 登录作者 | **本次改造**:发布入口(首次发布走 `POST /games`,既有作品发新版走 `POST /games/{gameId}/versions/{versionNumber}`)。`metadata` JSON 带 `gameId?` / `projectKey?` / `versionNumber`(必填)/ 包声明 / `gameMetadata`,媒体走 `cover`/`screenshot` part;无 `gameId` 时按 `(owner, projectKey)` get-or-create 作品并确定性派生 gameId,`gameMetadata` + 媒体 bootstrap 作品行。幂等键 = `(owner, projectKey \| gameId, versionNumber)`:同号同摘要重放、同号异摘要 409;鉴权 / 灰度与头按端点表;返回 `{ game, version, replayed }`。详见「游戏分发发布面 clean cut 与术语对齐合同(2026-10-07)」 | +| `PUT /games/{gameId}/versions/{versionNumber}/package` | owner | **已实现**:接收真实 ZIP、重算摘要与文件清单并写入私有对象;不执行游戏代码 | +| `GET /games/{gameId}/versions/{versionNumber}` | owner/管理员 | **已实现**:回读状态、错误代码、审核结论与服务端 `recoveryAction`;管理员走 `/admin/api/game-distribution/versions/{versionId}`;未知版本与别人的版本都按不可见返回 404 | +| `POST /games/{gameId}/versions/{versionNumber}/submit` | owner | **已实现**:只有已确认完整包可提交,返回 202 并进入 `pending_review` | +| `POST /games/{gameId}/versions/{versionNumber}/cancel` | owner | **已实现**:带 `expectedPublicationRevision` CAS 与 `Idempotency-Key`,只能撤回未参与公开投影的版本;同 key 同请求重放返回 `replayed: true`,摘要不同返回 409 | +| `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 | +| `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 | +| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按 gameId 派生平台同源发行路径 `/games/{gameId}/` 并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 | +| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | **已实现**:安全下架整个游戏并撤销发行访问,要求 `expectedPublicationRevision` CAS 与幂等键;入口在后台「游戏管理」页,带原因输入与二次确认(审核页只做版本级通过/拒绝,不承载游戏级下架);此行是完整后台路径 | +| `GET /admin/api/game-distribution/games` | 管理员 | **已实现**:后台作品管理列表,支持 `limit` / `keyword` / `owner` / `status`(`published`、`unpublished`、`suspended`、`deleted`,为空时排除软删行)/ `cursor` 过滤与游标分页,返回 `{ games, nextCursor }`;`status` 不在白名单时 400;此行是完整后台路径 | 除显式 `/admin/api/...` 外,表内路径均相对 `/api/game-distribution`。错误采用现有平台 envelope,覆盖 400 格式错误、401 未登录、403 owner/审核权限错误、404 不可见、409 幂等/状态/并发冲突、413 大小上限、422 包或资料校验失败、429 限流和明确的可重试 5xx;服务端响应不包含存储凭据和本地绝对路径。 @@ -342,17 +348,17 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ### 验收标准与证据 -| 条款 | 必须获得的证据 | -| --- | --- | -| 真实包闭环 | 网页 ZIP 与 AGC dist 分别提交同一管道,服务端实际包 SHA-256/文件清单和发行回读一致;仅 metadata 请求被拒绝 | -| 包与数据边界 | 缺入口、摘要不符、穿越、符号链接、压缩炸弹、超限、凭据路径及未授权对象均被拒绝且未公开 | -| 权限与游客 | 匿名浏览/游玩成功,匿名写入 401,其他作者写入/读取私有版本被拒,伪造 owner 无效 | -| 幂等与恢复 | 双击、响应丢失、上传中断、同 key 不同内容、重启恢复、换账号迟到响应分别验证,不生成重复发行版本 | -| 审核与并发 | 待审不公开;拒绝有理由;旧版在更新失败/待审期间在线;审核与下架并发 CAS 拒绝过期写入 | -| 真正可玩 | 桌面及手机真实浏览器覆盖模块加载、素材、音频、触屏、横竖屏、开始/重试/退出和可用全屏;不以 iframe load 代替 | -| 隔离与撤销 | 真实生产构建下同源 iframe 内主站 Cookie/storage/DOM 不可访问、外部网络被 CSP 阻断、离开页面后游戏代码不再运行,旧 URL 在缓存窗口后不能取得新资源 | -| 页面与视觉 | 当前 warm token 下的目录、详情、发布、加载/空/失败/待审态,桌面和移动视口无操作遮挡,键盘可达 | -| 工程门禁 | 定向前后端测试、两端类型检查、真实 SpacetimeDB/API smoke、schema/绑定检查、编码、文档索引及 diff 检查 | +| 条款 | 必须获得的证据 | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 真实包闭环 | 网页 ZIP 与 AGC dist 分别提交同一管道,服务端实际包 SHA-256/文件清单和发行回读一致;仅 metadata 请求被拒绝 | +| 包与数据边界 | 缺入口、摘要不符、穿越、符号链接、压缩炸弹、超限、凭据路径及未授权对象均被拒绝且未公开 | +| 权限与游客 | 匿名浏览/游玩成功,匿名写入 401,其他作者写入/读取私有版本被拒,伪造 owner 无效 | +| 幂等与恢复 | 双击、响应丢失、上传中断、同 key 不同内容、重启恢复、换账号迟到响应分别验证,不生成重复发行版本 | +| 审核与并发 | 待审不公开;拒绝有理由;旧版在更新失败/待审期间在线;审核与下架并发 CAS 拒绝过期写入 | +| 真正可玩 | 桌面及手机真实浏览器覆盖模块加载、素材、音频、触屏、横竖屏、开始/重试/退出和可用全屏;不以 iframe load 代替 | +| 隔离与撤销 | 真实生产构建下同源 iframe 内主站 Cookie/storage/DOM 不可访问、外部网络被 CSP 阻断、离开页面后游戏代码不再运行,旧 URL 在缓存窗口后不能取得新资源 | +| 页面与视觉 | 当前 warm token 下的目录、详情、发布、加载/空/失败/待审态,桌面和移动视口无操作遮挡,键盘可达 | +| 工程门禁 | 定向前后端测试、两端类型检查、真实 SpacetimeDB/API smoke、schema/绑定检查、编码、文档索引及 diff 检查 | 所有运行时证据须注明实际环境和结论;缺少登录、存储、审核或真实游戏时写明未验证,不使用演示 fixture 填充为业务成功。 @@ -368,14 +374,14 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 本节记录已实现的基础评价合同;已实现的后台管理增量规则及其独立验证证据见下方“后台游戏评价管理合同”。 -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | implemented(本地验证通过,待用户验收;未部署) | -| Date | 2026-09-30 | -| 入口 | 网站 `/games/detail?id=` | +| 字段 | 值 | +| -------- | ---------------------------------------------------------------------------------------------------- | +| Version | 1.0 | +| Status | implemented(本地验证通过,待用户验收;未部署) | +| Date | 2026-09-30 | +| 入口 | 网站 `/games/detail?id=` | | 交付目标 | 登录用户对同一游戏保存并修改唯一一条评分与可选评论;所有访客分页浏览评价,并查看真实平均分与评分人数 | -| 里程碑 | [网站游戏评分与评价](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md) | +| 里程碑 | [网站游戏评分与评价](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md) | ### 范围与默认规则 @@ -401,16 +407,16 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 游戏详情保留“开始游戏”和现有资料,在游戏介绍后增加“我的评价”“全部评价”两个区块;平均分与人数显示在详情资料区,并在评价区域共用同一份后端摘要。 -| 状态 | 我的评价区域 | 操作结果 | -| --- | --- | --- | -| 未登录 | 显示“登录后评价”入口,不展示可提交表单 | 使用现有平台登录入口;公共列表和评分摘要仍可浏览 | -| 读取自己的评价中 | 显示加载状态 | 确认不存在已有评价前,不提前展示首次填写表单 | -| 已登录且没有评价 | 展示 1–10 分选择、可选评论输入框、字数统计、取消/提交按钮 | 首次输入的评分和文字仅为当前页面草稿 | -| 已登录且已有评价 | 展示自己的分数、评论、修改时间及“编辑评价”按钮,隐藏输入模块 | 空评论仍展示分数,正文不填造提示内容 | -| 编辑已有评价 | 展示输入模块,完整预填分数与评论 | 个人展示区域暂由编辑表单替代,公共列表仍保留该评价 | -| 提交中 | 保留表单及内容,显示提交状态并禁用评分选择、评论输入及取消/提交 | 冻结本次提交内容,防止重复点击和后续输入被成功响应收起表单时丢失;失败后恢复编辑,不把在途请求解释为已保存 | -| 提交成功 | 用后端权威记录显示自己的评价,隐藏输入模块 | 刷新平均分、人数与公共列表 | -| 读取或提交失败 | 给出错误与重试入口 | 读取失败不伪装成“没有评价”;提交失败保留草稿,不覆盖原评价 | +| 状态 | 我的评价区域 | 操作结果 | +| ---------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| 未登录 | 显示“登录后评价”入口,不展示可提交表单 | 使用现有平台登录入口;公共列表和评分摘要仍可浏览 | +| 读取自己的评价中 | 显示加载状态 | 确认不存在已有评价前,不提前展示首次填写表单 | +| 已登录且没有评价 | 展示 1–10 分选择、可选评论输入框、字数统计、取消/提交按钮 | 首次输入的评分和文字仅为当前页面草稿 | +| 已登录且已有评价 | 展示自己的分数、评论、修改时间及“编辑评价”按钮,隐藏输入模块 | 空评论仍展示分数,正文不填造提示内容 | +| 编辑已有评价 | 展示输入模块,完整预填分数与评论 | 个人展示区域暂由编辑表单替代,公共列表仍保留该评价 | +| 提交中 | 保留表单及内容,显示提交状态并禁用评分选择、评论输入及取消/提交 | 冻结本次提交内容,防止重复点击和后续输入被成功响应收起表单时丢失;失败后恢复编辑,不把在途请求解释为已保存 | +| 提交成功 | 用后端权威记录显示自己的评价,隐藏输入模块 | 刷新平均分、人数与公共列表 | +| 读取或提交失败 | 给出错误与重试入口 | 读取失败不伪装成“没有评价”;提交失败保留草稿,不覆盖原评价 | - 首次填写点击取消:清空评分与文字,保留首次填写表单,不产生后端写入;编辑点击取消:丢弃草稿,恢复上次成功读取/保存的个人评价。 - 首次提交成功后公共列表回到第一页;修改成功后刷新当前页,保留页码。提交响应中的个人记录与统计摘要先更新,分页读取失败单独提示重试,不回滚已成功保存的评价。 @@ -433,15 +439,15 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 游戏评价私有表 `game_distribution_review` 保存以下必要字段: -| 逻辑字段 | 约束 | -| --- | --- | -| `reviewId` | 稳定唯一标识;通过 `gameId + userId` 的无歧义组合标识保证每对游戏/账号只占一个主键 | -| `gameId` | 已存在的稳定游戏身份;建立按游戏查询索引 | -| `userId` | 仅来自平台认证主体,不接受请求指定或转移归属 | -| `score` | 整数 1–10 | -| `comment` | 归一化后的纯文本,最多 4000 Unicode 码点,可为空 | -| `createdAt` | 首次保存时间,修改时不变 | -| `updatedAt` | 最近一次实际内容修改时间 | +| 逻辑字段 | 约束 | +| ----------- | ---------------------------------------------------------------------------------- | +| `reviewId` | 稳定唯一标识;通过 `gameId + userId` 的无歧义组合标识保证每对游戏/账号只占一个主键 | +| `gameId` | 已存在的稳定游戏身份;建立按游戏查询索引 | +| `userId` | 仅来自平台认证主体,不接受请求指定或转移归属 | +| `score` | 整数 1–10 | +| `comment` | 归一化后的纯文本,最多 4000 Unicode 码点,可为空 | +| `createdAt` | 首次保存时间,修改时不变 | +| `updatedAt` | 最近一次实际内容修改时间 | - 游戏必须与当前公开详情采用相同可见性判定,存在已公开可玩版本;未公开、作者下架或管理员封禁时,评价公开读取、个人读取和提交均返回 404。已有评价保留,游戏重新公开后可继续读取和编辑。 - `api-server` 先认证并取得用户 ID;SpacetimeDB 入口继续校验现有受信运行服务身份。数据库上下文中的调用者是 API 服务身份,不能把它误当成网页用户。 @@ -456,11 +462,11 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 以下接口已实现,路径以 `/api/game-distribution` 为前缀,返回沿用平台 `ok/data/error/meta` envelope。用户评价与既有后台游戏发行“审核”是不同对象,`/admin/api/game-distribution/reviews` 继续只表示发行审核队列。 -| 方法与路径 | 身份 | 请求与成功响应 | -| --- | --- | --- | -| `GET /games/{gameId}/reviews?page=1&pageSize=20` | 游客可读 | `data={reviews,page,pageSize,total,totalPages,ratingSummary}`;公共作者资料随每条评价返回 | -| `GET /games/{gameId}/my-review` | 登录用户 | `data={review}`;未评价为 `review:null`,不返回 404 | -| `PUT /games/{gameId}/my-review` | 登录用户 | `{score,comment}`;`comment` 可省略,按空字符串保存;首次保存与修改均返回 `200`、`data={review,ratingSummary}` | +| 方法与路径 | 身份 | 请求与成功响应 | +| ------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------- | +| `GET /games/{gameId}/reviews?page=1&pageSize=20` | 游客可读 | `data={reviews,page,pageSize,total,totalPages,ratingSummary}`;公共作者资料随每条评价返回 | +| `GET /games/{gameId}/my-review` | 登录用户 | `data={review}`;未评价为 `review:null`,不返回 404 | +| `PUT /games/{gameId}/my-review` | 登录用户 | `{score,comment}`;`comment` 可省略,按空字符串保存;首次保存与修改均返回 `200`、`data={review,ratingSummary}` | - `review` 字段:`id`、`gameId`、`author:{id,name,avatarUrl}`、`score`、`comment`、`createdAt`、`updatedAt`;`avatarUrl` 可为空。个人与公共评价使用同一展示形状,时间为统一 UTC 格式。 - `ratingSummary={averageScore:number|null,ratingCount:number}`;公共列表 `total` 等于 `ratingCount`。`totalPages` 由总数与实际 `pageSize` 得出;均分与人数覆盖该游戏全部评价,不只覆盖当前页。 @@ -471,40 +477,40 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ### 验收标准与证据 -| 条款 | 必须获得的证据 | -| --- | --- | -| 首次评分与可选评论 | 1 分、10 分和空评论成功;未选择评分、0/11/非整数被拒,后端没有无效写入 | -| 字数与输入 | 中文、英文、emoji、空格与换行按统一口径计数;4000 成功、4001 拒绝,字数提示实时可见,粘贴/中文输入法不绕过上限 | -| 唯一与并发 | 同一用户同游戏的双击、重试与并发首次保存后仍只有一条;不同用户、不同游戏各自独立 | -| 修改与取消 | 已评价先显示个人卡片;编辑完整预填,取消保留旧值,提交更新同一记录;提交中评分、评论和操作按钮均禁用,失败恢复编辑且不丢草稿;首次取消不落库 | -| 公共分页与重复展示 | 至少 21 条真实评价验证翻页/总数/顺序/空态;自己的评价在个人区和所属公共页各出现一次;空评论评价可见,编辑不换位 | -| 均分与人数 | 无记录为 `null/0`;仅评分计人数;改分只改变均分、不加人数;改评论不改变均分;统计基于所有页 | -| 身份与可见性 | 匿名读成功、写/读个人为 401;伪造用户不能修改他人;下架与封禁后读写 404、恢复公开后记录仍在 | -| 生命周期与故障 | 切换账号/游戏后旧响应不覆盖新状态;保存成功后旧读取不覆盖新记录与摘要,快速翻页/刷新只接受各区域最新读取结果;个人读取失败不误展首次表单,摘要失败不误显示无评分;评价失败不阻断游玩 | -| 页面与工程 | 桌面/移动真实浏览器可操作,评分可键盘选择;相关前后端定向测试、网站类型检查、真实 SpacetimeDB/API smoke、DTO/schema/绑定、编码/文档索引/diff 检查 | +| 条款 | 必须获得的证据 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 首次评分与可选评论 | 1 分、10 分和空评论成功;未选择评分、0/11/非整数被拒,后端没有无效写入 | +| 字数与输入 | 中文、英文、emoji、空格与换行按统一口径计数;4000 成功、4001 拒绝,字数提示实时可见,粘贴/中文输入法不绕过上限 | +| 唯一与并发 | 同一用户同游戏的双击、重试与并发首次保存后仍只有一条;不同用户、不同游戏各自独立 | +| 修改与取消 | 已评价先显示个人卡片;编辑完整预填,取消保留旧值,提交更新同一记录;提交中评分、评论和操作按钮均禁用,失败恢复编辑且不丢草稿;首次取消不落库 | +| 公共分页与重复展示 | 至少 21 条真实评价验证翻页/总数/顺序/空态;自己的评价在个人区和所属公共页各出现一次;空评论评价可见,编辑不换位 | +| 均分与人数 | 无记录为 `null/0`;仅评分计人数;改分只改变均分、不加人数;改评论不改变均分;统计基于所有页 | +| 身份与可见性 | 匿名读成功、写/读个人为 401;伪造用户不能修改他人;下架与封禁后读写 404、恢复公开后记录仍在 | +| 生命周期与故障 | 切换账号/游戏后旧响应不覆盖新状态;保存成功后旧读取不覆盖新记录与摘要,快速翻页/刷新只接受各区域最新读取结果;个人读取失败不误展首次表单,摘要失败不误显示无评分;评价失败不阻断游玩 | +| 页面与工程 | 桌面/移动真实浏览器可操作,评分可键盘选择;相关前后端定向测试、网站类型检查、真实 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;未执行原游戏发行生产验收 | +| 证据 | 当前结果 | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 前端交互与 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;未执行原游戏发行生产验收 | ## 后台游戏评价管理合同 -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | implemented(本地验证通过,待用户验收;未部署) | -| Date | 2026-10-01 | +| 字段 | 值 | +| -------- | ------------------------------------------------------------------------------------------------ | +| Version | 1.0 | +| Status | implemented(本地验证通过,待用户验收;未部署) | +| Date | 2026-10-01 | | 交付目标 | 管理员查找游戏评价并隐藏、恢复或删除;公共列表和评分统计同步遵循管理状态,用户仍可编辑自己的评价 | -| 入口 | 后台“游戏评价”页,以及“游戏管理”的“查看评价”入口 | -| 里程碑 | [后台游戏评价管理](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md) | +| 入口 | 后台“游戏评价”页,以及“游戏管理”的“查看评价”入口 | +| 里程碑 | [后台游戏评价管理](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md) | ### 范围与已确认规则 @@ -558,12 +564,12 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 新增路径以 `/admin/api/game-distribution` 为前缀,沿用平台 envelope 和 `Cache-Control: no-store`。原 `/reviews` 保持发行审核队列含义;评价 ID 在路径中按组件编码。 -| 方法与路径 | 请求 | 成功 `data` | -| --- | --- | --- | -| `GET /user-review-games` | `query` 可空,非空先精确匹配 ID,同时对游戏名称做包含查询;名称匹配区分大小写;`page=1&pageSize=20` | `{games:[{gameId,title,status}],page,pageSize,total,totalPages}`,按创建时间/ID 倒序,分页范围同评价列表 | -| `GET /user-reviews` | 可选 `gameId,userId,keyword,status=all\|visible\|hidden`;`page=1&pageSize=20` | `{reviews,page,pageSize,total,totalPages}` | -| `GET /user-reviews/{reviewId}` | 无 body | `{review,operations}`,操作记录按时间/操作 ID 倒序,只包含当前创建时间对应的记录 | -| `POST /user-reviews/{reviewId}/moderation` | `Idempotency-Key`(沿用已有 1–128 字符限制);`{action:"hide"\|"restore"\|"delete",expectedCreatedAt,reason?}`;恢复省略原因 | `{review,operation,replayed}`;删除 `review=null` | +| 方法与路径 | 请求 | 成功 `data` | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `GET /user-review-games` | `query` 可空,非空先精确匹配 ID,同时对游戏名称做包含查询;名称匹配区分大小写;`page=1&pageSize=20` | `{games:[{gameId,title,status}],page,pageSize,total,totalPages}`,按创建时间/ID 倒序,分页范围同评价列表 | +| `GET /user-reviews` | 可选 `gameId,userId,keyword,status=all\|visible\|hidden`;`page=1&pageSize=20` | `{reviews,page,pageSize,total,totalPages}` | +| `GET /user-reviews/{reviewId}` | 无 body | `{review,operations}`,操作记录按时间/操作 ID 倒序,只包含当前创建时间对应的记录 | +| `POST /user-reviews/{reviewId}/moderation` | `Idempotency-Key`(沿用已有 1–128 字符限制);`{action:"hide"\|"restore"\|"delete",expectedCreatedAt,reason?}`;恢复省略原因 | `{review,operation,replayed}`;删除 `review=null` | - 后台 `review` 为 `{id,gameId,game:{title,status},author:{id,name,avatarUrl},score,comment,isHidden,createdAt,updatedAt}`。时间使用统一 UTC 格式;游戏/作者资料来自关联读取,关联缺失时保留 ID、名称显示占位,不影响定位和处理。 - `operation` 为 `{id,reviewId,gameId,userId,reviewCreatedAt,action,adminUserId,reason,createdAt}`;恢复 `reason=null`。`replayed` 表示同一次成功操作重放,新 key 首次成功为 false。 @@ -577,14 +583,14 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 - 同步 `migration.rs`、后端表目录、生成绑定和 schema 检查。上线前用隔离数据库验证旧 schema 的评价升级后内容、唯一性、时间保留且全部公开;不把新数据库空表测试当成存量迁移证据。 - 回滚应用时保留新增字段和管理记录;旧后端不认识隐藏状态,会重新暴露已隐藏评价,因此产生隐藏记录后禁止直接回退到未过滤隐藏记录的旧读取实现,采用兼容修复。 -| 条款 | 必须获得的证据 | 当前状态 | -| --- | --- | --- | -| 查找与分页 | 超过 20 条记录,四类筛选组合、跨页关键词、游戏名称/ID选择、入口预填、重置、空态、越界页恢复 | 定向交互测试及真实 HTTP 通过;浏览器确认四条件 AND、名称选择、入口预填、21 条跨页和刷新保留页码 | -| 管理操作 | 隐藏/删除必填原因、恢复无原因;取消无写入,失败保留草稿,完整正文只读、分数不可编辑 | 自动化及真实 HTTP 通过;浏览器确认隐藏空原因报错、取消、提交、恢复、删除警示和取消,长评论详情只读 | -| 统计与个人状态 | 隐藏后列表/人数/均分同步排除,全部隐藏为空态;自己显示精确提示,编辑仍隐藏,恢复重新计入 | 真实 HTTP 通过;浏览器确认隐藏后 21→20 人、均分更新,恢复后 20→21 人,个人提示、编辑预填及保存保持隐藏 | -| 删除与重试 | 物理删除、可重新提交唯一评价;旧目标/旧 key 重试不能删新评价,同 key 同请求不重复日志、不同请求 409 | 真实数据库/HTTP 通过,覆盖删除后重建、旧目标 409、旧 key 重放及同状态操作重试;浏览器未执行永久删除 | -| 权限与事务 | 401/403、伪造管理员/隐藏状态无效;私有记录不泄漏原因,失败不部分写入,下架游戏后台可管 | API/领域测试及真实 HTTP 通过;额外伪造管理员请求确认操作人仍来自认证会话 | -| 存量与工程 | 旧数据库无损升级、schema/绑定/DTO、定向 Rust/Vitest/typecheck,真实数据库/API及桌面/移动浏览器 | 21 条旧评价升级无损且默认公开;87 表 schema、生成绑定、31 组 DTO 校验通过;桌面及 375×812 视口确认筛选、表格、操作弹窗和长正文滚动 | +| 条款 | 必须获得的证据 | 当前状态 | +| -------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 查找与分页 | 超过 20 条记录,四类筛选组合、跨页关键词、游戏名称/ID选择、入口预填、重置、空态、越界页恢复 | 定向交互测试及真实 HTTP 通过;浏览器确认四条件 AND、名称选择、入口预填、21 条跨页和刷新保留页码 | +| 管理操作 | 隐藏/删除必填原因、恢复无原因;取消无写入,失败保留草稿,完整正文只读、分数不可编辑 | 自动化及真实 HTTP 通过;浏览器确认隐藏空原因报错、取消、提交、恢复、删除警示和取消,长评论详情只读 | +| 统计与个人状态 | 隐藏后列表/人数/均分同步排除,全部隐藏为空态;自己显示精确提示,编辑仍隐藏,恢复重新计入 | 真实 HTTP 通过;浏览器确认隐藏后 21→20 人、均分更新,恢复后 20→21 人,个人提示、编辑预填及保存保持隐藏 | +| 删除与重试 | 物理删除、可重新提交唯一评价;旧目标/旧 key 重试不能删新评价,同 key 同请求不重复日志、不同请求 409 | 真实数据库/HTTP 通过,覆盖删除后重建、旧目标 409、旧 key 重放及同状态操作重试;浏览器未执行永久删除 | +| 权限与事务 | 401/403、伪造管理员/隐藏状态无效;私有记录不泄漏原因,失败不部分写入,下架游戏后台可管 | API/领域测试及真实 HTTP 通过;额外伪造管理员请求确认操作人仍来自认证会话 | +| 存量与工程 | 旧数据库无损升级、schema/绑定/DTO、定向 Rust/Vitest/typecheck,真实数据库/API及桌面/移动浏览器 | 21 条旧评价升级无损且默认公开;87 表 schema、生成绑定、31 组 DTO 校验通过;桌面及 375×812 视口确认筛选、表格、操作弹窗和长正文滚动 | 2026-10-01 本地验证:领域 21 项、API game_distribution 34 项、独立权限映射 1 项、基础共享 DTO 4 项及后台 DTO 1 项测试通过;最终前端回归为 7 个文件、74 项通过,含后台新评价页 8 项(提交途中切换游戏不覆盖新上下文)、网站评价 11 项,以及相关路由/client/游戏管理和发行审核。Spacetime module/client cargo check、Rust 格式、admin-web/网站类型检查、定向 ESLint、admin-web 构建、DDD/runtime 边界、文档索引、编码和 diff 检查通过。 @@ -596,14 +602,14 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ## 游戏广场评分展示合同 -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | implemented(本地验证通过,待用户验收;未部署) | -| Date | 2026-10-01 | -| 入口 | 网站 `/games` 游戏广场卡片 | -| 交付目标 | 游客与登录用户在游戏卡片查看真实平均分和评分人数,统计与详情评价及后台管理口径一致 | -| 里程碑 | [游戏广场评分展示](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md) | +| 字段 | 值 | +| -------- | ----------------------------------------------------------------------------------- | +| Version | 1.0 | +| Status | implemented(本地验证通过,待用户验收;未部署) | +| Date | 2026-10-01 | +| 入口 | 网站 `/games` 游戏广场卡片 | +| 交付目标 | 游客与登录用户在游戏卡片查看真实平均分和评分人数,统计与详情评价及后台管理口径一致 | +| 里程碑 | [游戏广场评分展示](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md) | ### 范围与展示规则 @@ -623,9 +629,9 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ### API、兼容与数据边界 -| 方法与路径 | 增量响应合同 | -| --- | --- | -| `GET /api/game-distribution/games` | `data.games[]` 每项保证增加 `ratingSummary:{averageScore:number|null,ratingCount:number}`,原字段和 envelope 保留 | +| 方法与路径 | 增量响应合同 | +| ------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET /api/game-distribution/games` | `data.games[]` 每项保证增加 `ratingSummary:{averageScore:number | null,ratingCount:number}`,原字段和 envelope 保留 | | `GET /api/game-distribution/games/{gameId}` | 与列表共用公开投影,保证返回同形状 `ratingSummary`;详情评价接口仍提供提交/刷新时的权威摘要 | - 公开列表和详情使用 `Cache-Control: no-store`,避免浏览器或代理复用过期摘要;既有评价接口的 no-store 行为保留。 @@ -636,12 +642,12 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ### 验收与当前证据 -| 条款 | 验收方式 | 当前证据 | -| --- | --- | --- | -| 展示与字段 | 现有页面测试补正常评分/零评价/缺摘要三种情况,现有公开响应/DTO 测试核对新增字段 | 前端两文件 36 项、API 定向 34 项通过;仅新增评分文案参数化断言并扩展已有响应测试 | -| 统计口径 | 复用有效评价统计及管理测试;用两款游戏和少量评价做一次列表/详情/评价摘要对照,确认隐藏过滤与统计不串联 | 领域 summary 2 项通过;隔离库两款游戏、两条空评论评价,`8/2` 隐藏一条后为 `7/1`,另一游戏始终 `null/0`,三接口摘要一致 | -| 页面与原行为 | 桌面及 375px 移动检查评分行和详情返回后的更新;可见性、列表失败、筛选与滚动恢复沿用现有测试 | 1280px/375px 浏览器卡片无横向溢出;详情期间经 API 改分,返回广场由 `7.0/1` 更新为 `6.0/1`;既有目录/详情测试通过 | -| 工程与兼容 | 受影响代码定向测试/typecheck、DTO/绑定/schema、编码/文档索引/diff 检查及上述一次隔离 API smoke | Rust shared-contracts 4 项、module/client 编译、TypeScript typecheck、受影响文件静态/格式检查及 DTO/绑定/schema/编码/文档索引/diff 检查通过;真实列表/详情 no-store 通过 | +| 条款 | 验收方式 | 当前证据 | +| ------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 展示与字段 | 现有页面测试补正常评分/零评价/缺摘要三种情况,现有公开响应/DTO 测试核对新增字段 | 前端两文件 36 项、API 定向 34 项通过;仅新增评分文案参数化断言并扩展已有响应测试 | +| 统计口径 | 复用有效评价统计及管理测试;用两款游戏和少量评价做一次列表/详情/评价摘要对照,确认隐藏过滤与统计不串联 | 领域 summary 2 项通过;隔离库两款游戏、两条空评论评价,`8/2` 隐藏一条后为 `7/1`,另一游戏始终 `null/0`,三接口摘要一致 | +| 页面与原行为 | 桌面及 375px 移动检查评分行和详情返回后的更新;可见性、列表失败、筛选与滚动恢复沿用现有测试 | 1280px/375px 浏览器卡片无横向溢出;详情期间经 API 改分,返回广场由 `7.0/1` 更新为 `6.0/1`;既有目录/详情测试通过 | +| 工程与兼容 | 受影响代码定向测试/typecheck、DTO/绑定/schema、编码/文档索引/diff 检查及上述一次隔离 API smoke | Rust shared-contracts 4 项、module/client 编译、TypeScript typecheck、受影响文件静态/格式检查及 DTO/绑定/schema/编码/文档索引/diff 检查通过;真实列表/详情 no-store 通过 | 测试按本增量风险控制:只补新展示与摘要契约的必要断言,复用已有评价、管理和目录测试;不新增框架、专用 E2E 脚本、大批量分页数据、故障注入或重复的多层映射测试。既有证据可支撑未改动的规则,但仍须验证本次公开投影和页面接入。 @@ -651,12 +657,12 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ## 后台游戏审核详情与待审版本试玩合同 -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | in-progress | -| Date | 2026-10-02 | -| 适用范围 | admin-web 游戏版本审核、作者/游戏资料回读、待审版本隔离试玩 | +| 字段 | 值 | +| ---------- | ------------------------------------------------------------------------ | +| Version | 1.0 | +| Status | in-progress | +| Date | 2026-10-02 | +| 适用范围 | admin-web 游戏版本审核、作者/游戏资料回读、待审版本隔离试玩 | | 数据事实源 | `game_distribution_game`、`game_distribution_version` 及现有版本冻结资料 | ### 目标 @@ -705,14 +711,14 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ### 验收标准 -| 条款 | 验收方式 | 证据 | -| --- | --- | --- | -| 发布者和资料 | 详情回读 + admin-web 页面检查 | admin-web 详情测试通过,显示作者、资料、版本摘要和冻结 JSON | -| 待审快照 | 修改 game 行资料后仍显示待审版本冻结资料 | 详情响应和页面已接入 `frozenMetadata`;真实数据验证待补 | -| 版本隔离试玩 | 用两个版本验证预览 URL 不能串读,待审包资源全部可加载 | 预览会话路由和版本绑定已实现;真实审核试玩待补 | -| 安全边界 | Cookie、过期 Token、越权 versionId、公开路径访问拒绝 | sandbox、Cookie 拒绝和短期 Token 已实现;HTTP 边界测试待补 | -| 审核动作 | 审核页通过/拒绝、理由、CAS 冲突、幂等重放;游戏级安全下架在「游戏管理」页 | 审核页只保留版本级动作(含取消不写请求);游戏管理页保留「下架(原因必填)+ 二次确认」入口 | -| 工程门禁 | admin-web 测试、api-server 定向测试、类型、编码、diff | admin-web typecheck、详情测试、api-server cargo check、Rust fmt、编码和 diff 通过 | +| 条款 | 验收方式 | 证据 | +| ------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| 发布者和资料 | 详情回读 + admin-web 页面检查 | admin-web 详情测试通过,显示作者、资料、版本摘要和冻结 JSON | +| 待审快照 | 修改 game 行资料后仍显示待审版本冻结资料 | 详情响应和页面已接入 `frozenMetadata`;真实数据验证待补 | +| 版本隔离试玩 | 用两个版本验证预览 URL 不能串读,待审包资源全部可加载 | 预览会话路由和版本绑定已实现;真实审核试玩待补 | +| 安全边界 | Cookie、过期 Token、越权 versionId、公开路径访问拒绝 | sandbox、Cookie 拒绝和短期 Token 已实现;HTTP 边界测试待补 | +| 审核动作 | 审核页通过/拒绝、理由、CAS 冲突、幂等重放;游戏级安全下架在「游戏管理」页 | 审核页只保留版本级动作(含取消不写请求);游戏管理页保留「下架(原因必填)+ 二次确认」入口 | +| 工程门禁 | admin-web 测试、api-server 定向测试、类型、编码、diff | admin-web typecheck、详情测试、api-server cargo check、Rust fmt、编码和 diff 通过 | 2026-10-02 已完成第一版审核详情工作台与待审版本预览接线:后台详情回读复用现有 game/version 投影,预览 Token 只绑定待审 versionId,预览 iframe 保持 `sandbox="allow-scripts"`。尚未完成真实管理员登录、待审包资源全量加载和 Token 越权 HTTP smoke。 @@ -723,13 +729,13 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ## 游戏买断制泥点付费与播放鉴权合同 -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | implemented(本地验证通过,待用户验收;未部署) | -| Date | 2026-10-05 | -| 适用边界 | 主站游戏分发(`/games`)的作者买断制定价、泥点购买、付费游玩鉴权与后台审核价格展示;不含创作者结算 | -| 里程碑 | [游戏买断制泥点付费与播放鉴权](./project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md) | +| 字段 | 值 | +| -------- | ----------------------------------------------------------------------------------------------------------- | +| Version | 1.0 | +| Status | implemented(本地验证通过,待用户验收;未部署) | +| Date | 2026-10-05 | +| 适用边界 | 主站游戏分发(`/games`)的作者买断制定价、泥点购买、付费游玩鉴权与后台审核价格展示;不含创作者结算 | +| 里程碑 | [游戏买断制泥点付费与播放鉴权](./project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md) | ### 目标 @@ -808,18 +814,18 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ### 验收标准与证据 -| 条款 | 验收方式 | 证据 | -| --- | --- | --- | -| 定价 | 网页发布可选「免费/买断制」并拦截非法值,前后端口径一致 | 真实本地栈 E2E:价格 `1000001` → 400 且不落版本,`0` 与 `1000000` → 200;`cargo test -p module-game-distribution` 29 passed(`normalize_game_price_mud_points` 上限)、`cargo test -p api-server game_distribution` 53 passed(`validate_version_declaration` 定价校验);网页发布表单定价定向 Vitest | +| 条款 | 验收方式 | 证据 | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 定价 | 网页发布可选「免费/买断制」并拦截非法值,前后端口径一致 | 真实本地栈 E2E:价格 `1000001` → 400 且不落版本,`0` 与 `1000000` → 200;`cargo test -p module-game-distribution` 29 passed(`normalize_game_price_mud_points` 上限)、`cargo test -p api-server game_distribution` 53 passed(`validate_version_declaration` 定价校验);网页发布表单定价定向 Vitest | | 定价(AGC 入口) | AGC 发布面板与网页一致可选「免费/买断制」并拦截非法值;给已上线作品发新版本时按「最新版本冻结价优先、缺失或为 0 时回落作品行当前价」预填价格与模式,作者不改则价格不变 | AGC 定价前端已覆盖:发布面板定价定向 Vitest `apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx:333/342/802/819`(默认免费提交 `0`;买断制空值与 `1000001` 本地拦截、`120` 随版本提交;历史响应无价格按免费预填;更新模式预填线上买断价 `240` 且作者不改仍按 `240` 提交),请求层定向 Vitest `apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts:180/197`(透传 `priceMudPoints=240`;`1000001` / `1.5` 在 native command 之前失败关闭);预填口径 Rust 单测 `apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1715`。**未覆盖**:AGC 真实栈手点一次发布付费版本。定价 UI 与网页共用 `packages/shared` 共享组件 `PlatformGamePricingField` | -| 后台可见 | 审核详情与待审列表显示该版本价格 | `cargo test -p api-server game_distribution` 53 passed(`private_version_payload` / `version_detail_payload` 输出 `priceMudPoints`,历史无价格版本按 0,不回退游戏行当前价);admin-web 审核价格展示定向 Vitest | -| 审核试玩 | 审核员不限次试玩待审付费版本,不校验购买、不扣泥点 | 真实本地栈 E2E:审核员两次试玩待审付费版本均 200、两次包内字节一致,作者与审核员余额/流水均不变 | -| 购买 | 余额不足失败且不变更;成功扣 N、账单出现 `game_purchase` 流水、购买记录唯一 | 真实本地栈 E2E:余额不足 400 `INSUFFICIENT_MUD_POINTS` 且余额与流水不变;成功购买扣 30(120→90),`game_purchase` 流水与购买行各 1 条;同 key 重放与换 key 重复购买都不再扣费 | -| 幂等 | 双击、并发、响应丢失重试只扣一次 | 真实本地栈 E2E:同 key 重放、换 key 重复购买均不再扣费;两路不同 key 真并发返回 200/200,只有一个 `replayed=false`,余额只扣一次、购买记录 1 条。多账号/多游戏混合并发未覆盖 | -| 播放鉴权 | 未购买直连公开入口 404;购买后可反复游玩且不再扣费 | 真实本地栈 E2E:未购买时详情 `priceMudPoints=30` / `purchased=false` / `entryUrl=null`,直连发行网关与平台同源均 404,创建会话 403,未登录 401;购买后可玩且重复进入不再扣费;伪造令牌 404;直连带平台 Cookie 403、经 dev 代理(该前缀清 Cookie)200;作者下架后旧会话入口与资源均 404。管理员安全下架后的会话失效未单独跑(与作者下架共用同一 404 关闭路径) | -| 免购买游玩 | 作者与管理员可游玩付费作品且不扣费 | 真实本地栈 E2E:作者免购买(详情 `purchased=false` 但有 `entryUrl`,自购 400 `GAME_PURCHASE_OWNER_EXEMPT` 且余额/流水不变,播放会话 200);管理员免购买(管理员令牌创建播放会话 200、入口 200)。管理员免购买经 play-session 管理员分支实现;购买路由的 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED` 分支在当前产品形态不可达(管理员令牌在用户路由先被 `require_bearer_auth` 判 401),作为纵深防御保留并有单元测试 | -| 免费回归 | 免费游戏发布/详情/游玩/计数无行为变化 | 真实本地栈 E2E:免费游戏详情保留入口、发行网关 200、匿名创建会话 200 且不签发令牌;`cargo test -p api-server game_distribution` 免费详情 `priceMudPoints=0` / `purchased=false` / 入口保持 | -| 工程门禁 | schema/绑定、两端类型、定向测试、编码与 diff 检查通过 | `cargo test -p api-server game_distribution` 53 passed、`cargo test -p module-game-distribution` 29 passed、`cargo test -p spacetime-module game_distribution` 7 passed、`cargo test -p pingora-gateway` 42 passed;`check:game-distribution-dto-parity`、`check:pingora-route-parity`、`check:nginx-spa-routes`、`check:pingora-gateway-smoke`、`check:spacetime-schema`、`check:server-rs-ddd`、两端 typecheck 与定向 Vitest、`check:encoding`、`git diff --check` 全绿 | +| 后台可见 | 审核详情与待审列表显示该版本价格 | `cargo test -p api-server game_distribution` 53 passed(`private_version_payload` / `version_detail_payload` 输出 `priceMudPoints`,历史无价格版本按 0,不回退游戏行当前价);admin-web 审核价格展示定向 Vitest | +| 审核试玩 | 审核员不限次试玩待审付费版本,不校验购买、不扣泥点 | 真实本地栈 E2E:审核员两次试玩待审付费版本均 200、两次包内字节一致,作者与审核员余额/流水均不变 | +| 购买 | 余额不足失败且不变更;成功扣 N、账单出现 `game_purchase` 流水、购买记录唯一 | 真实本地栈 E2E:余额不足 400 `INSUFFICIENT_MUD_POINTS` 且余额与流水不变;成功购买扣 30(120→90),`game_purchase` 流水与购买行各 1 条;同 key 重放与换 key 重复购买都不再扣费 | +| 幂等 | 双击、并发、响应丢失重试只扣一次 | 真实本地栈 E2E:同 key 重放、换 key 重复购买均不再扣费;两路不同 key 真并发返回 200/200,只有一个 `replayed=false`,余额只扣一次、购买记录 1 条。多账号/多游戏混合并发未覆盖 | +| 播放鉴权 | 未购买直连公开入口 404;购买后可反复游玩且不再扣费 | 真实本地栈 E2E:未购买时详情 `priceMudPoints=30` / `purchased=false` / `entryUrl=null`,直连发行网关与平台同源均 404,创建会话 403,未登录 401;购买后可玩且重复进入不再扣费;伪造令牌 404;直连带平台 Cookie 403、经 dev 代理(该前缀清 Cookie)200;作者下架后旧会话入口与资源均 404。管理员安全下架后的会话失效未单独跑(与作者下架共用同一 404 关闭路径) | +| 免购买游玩 | 作者与管理员可游玩付费作品且不扣费 | 真实本地栈 E2E:作者免购买(详情 `purchased=false` 但有 `entryUrl`,自购 400 `GAME_PURCHASE_OWNER_EXEMPT` 且余额/流水不变,播放会话 200);管理员免购买(管理员令牌创建播放会话 200、入口 200)。管理员免购买经 play-session 管理员分支实现;购买路由的 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED` 分支在当前产品形态不可达(管理员令牌在用户路由先被 `require_bearer_auth` 判 401),作为纵深防御保留并有单元测试 | +| 免费回归 | 免费游戏发布/详情/游玩/计数无行为变化 | 真实本地栈 E2E:免费游戏详情保留入口、发行网关 200、匿名创建会话 200 且不签发令牌;`cargo test -p api-server game_distribution` 免费详情 `priceMudPoints=0` / `purchased=false` / 入口保持 | +| 工程门禁 | schema/绑定、两端类型、定向测试、编码与 diff 检查通过 | `cargo test -p api-server game_distribution` 53 passed、`cargo test -p module-game-distribution` 29 passed、`cargo test -p spacetime-module game_distribution` 7 passed、`cargo test -p pingora-gateway` 42 passed;`check:game-distribution-dto-parity`、`check:pingora-route-parity`、`check:nginx-spa-routes`、`check:pingora-gateway-smoke`、`check:spacetime-schema`、`check:server-rs-ddd`、两端 typecheck 与定向 Vitest、`check:encoding`、`git diff --check` 全绿 | 上表运行时证据来自人工验收脚本 `scripts/check-game-distribution-purchase-e2e.mjs`(`npm run check:game-distribution-purchase-e2e`):最近一次人工运行 **76 PASS / 0 FAIL / 1 WARN**,唯一 WARN 为「管理员令牌购买分支在现役登录链路下不可达(先被判 401)」。该脚本不在 CI 自动门禁内,CI 绿不代表该链路已验证;本表结论最高到“本机真实栈验证通过,待用户验收,未部署”。 @@ -832,8 +838,8 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 ## 创作者主页与关注粉丝合同 -| 字段 | 值 | -| --- | --- | +| 字段 | 值 | +| ------- | --- | | Version | 1.0 | | Status | implemented(工程验证通过,用户已确认提交交付,未部署) | @@ -866,13 +872,13 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 关系 `A → B` 表示 A 关注 B,同时 B 获得粉丝 A;反方向独立存在。“关注数”统计出边,“粉丝数”统计入边,互相关注不是第三种持久化状态。 -| 场景 | 可用操作 | 正式关系变化 | -| --- | --- | --- | -| A 查看 B 主页或 B 的游戏详情 | 关注/取消关注 | 添加/删除 `A → B` | -| A 查看自己的关注列表中的 B | 取消关注;取消后重新关注 | 删除/恢复 `A → B` | -| A 查看自己的粉丝列表中的 B | 回关/取消关注;移除粉丝 | 前两项只改变 `A → B`;移除只删除 `B → A` | -| A 查看他人的关注列表或粉丝列表 | 仅查看用户资料、点击进入主页;无关系操作按钮 | 不读取或改变 A 与列表用户的关注关系 | -| 游客查看主页或列表 | 浏览;主页点击关注唤起登录,关系列表只读 | 未认证不写关系 | +| 场景 | 可用操作 | 正式关系变化 | +| ------------------------------ | -------------------------------------------- | ---------------------------------------- | +| A 查看 B 主页或 B 的游戏详情 | 关注/取消关注 | 添加/删除 `A → B` | +| A 查看自己的关注列表中的 B | 取消关注;取消后重新关注 | 删除/恢复 `A → B` | +| A 查看自己的粉丝列表中的 B | 回关/取消关注;移除粉丝 | 前两项只改变 `A → B`;移除只删除 `B → A` | +| A 查看他人的关注列表或粉丝列表 | 仅查看用户资料、点击进入主页;无关系操作按钮 | 不读取或改变 A 与列表用户的关注关系 | +| 游客查看主页或列表 | 浏览;主页点击关注唤起登录,关系列表只读 | 未认证不写关系 | 关注/粉丝列表公开可查看(用户已确认),包括无已发布游戏用户的主页。仅允许本人移除自己的粉丝,任何客户端传入的主人 ID 都不能替代服务端认证身份。 @@ -898,16 +904,16 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 新增私有关注关系表:无歧义的有向用户对主键、`follower_user_id`、`followee_user_id`、`created_at`,分别支持关注方和被关注方索引。用户对唯一且不得相同;不保存昵称/头像副本。首版从关系索引计算准确计数,每次摘要或列表请求内部使用一致事务快照,不同 HTTP 请求通过回读收敛;如量级要求额外计数投影,须先补充事务一致性方案。 -| 接口 | 用途与权限 | -| --- | --- | -| `GET /api/creators/{userId}` | 公开资料、关注数、粉丝数;游客可读 | -| `GET /api/creators/{userId}/following` | 公开关注列表,游标分页 | -| `GET /api/creators/{userId}/followers` | 公开粉丝列表,游标分页 | -| `GET /api/creators/{userId}/relationship` | 登录访问者与目标的 `isSelf/isFollowing/isFollowedBy` | -| `PUT /api/creators/{userId}/follow` | 当前登录用户关注目标,幂等 | -| `DELETE /api/creators/{userId}/follow` | 当前登录用户取消关注目标,幂等 | -| `DELETE /api/creators/me/followers/{followerId}` | 当前登录用户移除自己的粉丝,幂等 | -| 扩展 `GET /api/game-distribution/games?authorId=` | 服务端精确作者过滤,复用公开游戏可见性与卡片契约 | +| 接口 | 用途与权限 | +| --------------------------------------------------------- | ---------------------------------------------------- | +| `GET /api/creators/{userId}` | 公开资料、关注数、粉丝数;游客可读 | +| `GET /api/creators/{userId}/following` | 公开关注列表,游标分页 | +| `GET /api/creators/{userId}/followers` | 公开粉丝列表,游标分页 | +| `GET /api/creators/{userId}/relationship` | 登录访问者与目标的 `isSelf/isFollowing/isFollowedBy` | +| `PUT /api/creators/{userId}/follow` | 当前登录用户关注目标,幂等 | +| `DELETE /api/creators/{userId}/follow` | 当前登录用户取消关注目标,幂等 | +| `DELETE /api/creators/me/followers/{followerId}` | 当前登录用户移除自己的粉丝,幂等 | +| 扩展 `GET /api/game-distribution/games?authorId=` | 服务端精确作者过滤,复用公开游戏可见性与卡片契约 | 以上接口已在本地隔离环境实现并验证,未部署。关注/粉丝列表默认 20、上限 50,按关系建立时间与稳定身份倒序,返回 `items/nextCursor/total`;游标绑定主人和列表类型,非法/错域游标返回 400。增删期间重新加载可能改变页内成员,前端按用户 ID 去重,重新关注产生新的建立时间。此分页规则用于关系列表;游戏列表原为「最多读取 48 项、不实现分页」,现已由公开目录自身的真游标分页替换(缺省 48 / 上限 100 / 超界截断 / 末页 `nextCursor` 为 `null`,见上文 HTTP 边界表的 `GET /games` 行),因此作者过滤后不再受 48 项限制。`/my-games`(作者自己的作品管理列表)单次仍最多 48 项、不分页。 @@ -922,18 +928,18 @@ AGC 不靠 `projectKey` 反查远端身份,而是把远端身份**存在本地 每阶段先评审里程碑,再编写该阶段的单独实施计划。后端阶段已完成工程实现及隔离验证并由用户验收通过,页面阶段的工程与真实浏览器验证已通过,用户已确认提交交付;证据保留在工程设计,已完成的临时计划删除,不以单元测试替代真实运行时验证。 -| 验收条款 | 证据要求 | -| --- | --- | -| 桌面第四/第五项与移动三入口;自己/他人深链、刷新、返回 | 路由测试与桌面、375px 移动浏览器操作 | -| A 关注 B、取消、重新关注;A/B 各自正确计数 | 两个真实账号的 API 与数据库回读 | -| 互关后 A 移除 B 粉丝,`A → B` 保留、`B → A` 消失 | 定向事务测试与真实 API;重复移除不重复扣数 | -| 取消关注后当前行可重新关注;刷新后正式列表正确 | UI 定向测试与浏览器操作 | -| 自己/他人的关注/粉丝四类列表均可点击用户进入主页,操作按钮不误跳转 | 游客和登录态浏览器验证,覆盖本人、他人、无公开游戏用户及返回位置恢复 | +| 验收条款 | 证据要求 | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| 桌面第四/第五项与移动三入口;自己/他人深链、刷新、返回 | 路由测试与桌面、375px 移动浏览器操作 | +| A 关注 B、取消、重新关注;A/B 各自正确计数 | 两个真实账号的 API 与数据库回读 | +| 互关后 A 移除 B 粉丝,`A → B` 保留、`B → A` 消失 | 定向事务测试与真实 API;重复移除不重复扣数 | +| 取消关注后当前行可重新关注;刷新后正式列表正确 | UI 定向测试与浏览器操作 | +| 自己/他人的关注/粉丝四类列表均可点击用户进入主页,操作按钮不误跳转 | 游客和登录态浏览器验证,覆盖本人、他人、无公开游戏用户及返回位置恢复 | | 他人的关注、粉丝列表均只读,已有关注缓存也不显示操作;后端和前端均不探测访问者与各行用户的关系 | 两种列表响应 relationship 全为 null、后端关系投影未调用、前端无关系补查或写请求;头像/昵称跳转保留 | -| 游客只读;禁止自关注和第三方移除他人粉丝 | 权限负向测试;第三账号越权调用 | -| 主页只含作者公开游戏,自己的主页也不泄露私有版本 | 混合公开/未公开/下架/删除作品夹具与 HTTP 验证 | -| 超时后回读、重复请求、并发、快速换作者/账号 | 后端幂等及前端迟到响应测试 | -| 分页、空态、错误态、长昵称、触摸与键盘操作 | 超过一页数据及移动/桌面 UI 验证 | +| 游客只读;禁止自关注和第三方移除他人粉丝 | 权限负向测试;第三账号越权调用 | +| 主页只含作者公开游戏,自己的主页也不泄露私有版本 | 混合公开/未公开/下架/删除作品夹具与 HTTP 验证 | +| 超时后回读、重复请求、并发、快速换作者/账号 | 后端幂等及前端迟到响应测试 | +| 分页、空态、错误态、长昵称、触摸与键盘操作 | 超过一页数据及移动/桌面 UI 验证 | ### 已确认决策与开工边界