diff --git a/docs/project-memory/plans/【实施计划】游戏作品管理与客户端发布收口-2026-09-30.md b/docs/project-memory/plans/【实施计划】游戏作品管理与客户端发布收口-2026-09-30.md new file mode 100644 index 000000000..eef8998e6 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】游戏作品管理与客户端发布收口-2026-09-30.md @@ -0,0 +1,70 @@ +# 【实施计划】游戏作品管理与客户端发布收口-2026-09-30 + +关联 Issue:`#470`「做一下创作者作品管理,从工具到平台的一键导出和发布」。 +分支:`feat/game-works-management`。 + +## 目标 + +在 10.7「陶泥儿游戏平台」上线前,把「创作者作品管理(用户侧 / 后台)+ 客户端打包上传(只保证 Phaser 4)」这条链路补齐到可用:作品能进来、能看得见状态、能管住。 + +## 当前事实 + +- 发布管道(AGC 构建打包 → 8 MiB 分片上传 → 送审 → 后台审核 → 公开可玩 → 发行网关)已实现,并有真实栈证据(见[实施计划【游戏分发阶段A领域合同】](【实施计划】游戏分发阶段A领域合同-2026-09-19.md))。 +- 未通电:`game-distribution:publish` 灰度默认关闭;生产 0 个作品;dev 仅 4 条记录。 +- 创作者侧缺作品管理:AGC 内没有作品列表 / 详情;`/games/mine` 不能删除作品、不能改展示资料、看不到版本历史。 +- 作者点自己的「审核中 / 被驳回 / 已下架」作品只有 404:公开详情只服务已公开投影。 +- 后台作品管理前端固定 50 条、无搜索无分页。 +- 客户端发布没有真实进度(分片进度事件 Rust 已 emit、前端零监听);幂等键只存在于面板内存,关面板即换 key。 + +## 已落地(本分支) + +| 项 | 落点 | 证据 | +| --- | --- | --- | +| 作者读自己名下单个游戏详情 `GET /api/game-distribution/my-games/{gameId}` | `api-server/src/modules/game_distribution.rs`(`get_owner_game`、`load_owner_game_versions`、`owner_game_entry_payload`) | `cargo test -p api-server game_distribution::tests` → 26 passed;`owner_game_entry_payload_carries_private_versions_that_public_payload_omits` 断言作者条目带版本私有状态、公开投影不带 | +| 版本私有状态补 `entryUrl` / `packageFileCount` | 同上 `private_version_payload` | 同上测试断言 | +| 主规范路由表登记新路由并修正 `/my/games` 错名 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 路由表 | — | + +设计要点:不新增 SpacetimeDB 表与 procedure。详情路由的 game 走 `get_game_distribution_game`(owner 作用域,精确命中),版本沿用作者自有列表 procedure 的聚合结果。 + +## 计划 + +### 波 1:闭环可用 + +1. AGC 真机把「构建 → 打包 → 分片上传 → 送审」跑通一次(唯一还没在客户端 GUI 取证的环节)。 +2. 发布进度接线:前端监听既有 `game-package-upload-progress`,渲染真实阶段与百分比。 +3. 幂等键落盘:按 `gameId` + 本地项目持久化,重试复用同一 key。 +4. AGC「我的作品」页:状态筛选、内联驳回理由、行内操作。 +5. AGC 作品详情抽屉:展示资料、当前公开版本、版本历史、公开链接。 +6. 运营开闸 + 生产发布第一个作品,走完审核 → 公开 → 游客可玩。 + +### 波 2:管理完整 + +7. 作品资料编辑:展示资料在 `game` 级可编辑、立即生效并留审计;包仍随版本冻结。 +8. 作品软删除:`game_distribution_game` 末尾追加 `deleted_at`,公开投影与列表过滤软删行,OSS 对象进入回收窗口。 +9. 网页侧对齐:`/games/mine` 补删除、资料编辑、版本历史、作者看自己未公开作品。 +10. 后台作品列表:分页 + 搜索 + 按作者筛选(当前前端固定 50 条)。 + +### 波 3:运营治理 + +11. 审核历史展示(复用版本已有的 `reviewed_by_user_id` / `submitted_at` / `reviewed_at` / `published_at`,不新增表)。 +12. 批量审核操作。 +13. 精选 / 推荐位(与游玩线的推荐排序定边界)。 +14. 举报处理(先确认能否复用现役反馈管道)。 + +## 不变式 + +- 新增字段一律追加到 Rust 表结构体末尾并带明确默认值;不改名、不重排、不改类型。改 schema 后同步 `migration.rs`、表目录与生成绑定,并跑 `npm run check:spacetime-schema`。 +- 公开投影不得携带作者私有字段(包摘要、驳回理由、文档本地路径)。作者视角走 owner 作用域路由。 +- 复用现役管道与组件,不新造上传、审核、发行通道。 + +## 已知限制 + +- 作者自有游戏列表与新增的作者详情共享同一个单次条数上限(48):单作者作品数超过上限时,详情页拿不到版本记录。放量前需要补一条按 `gameId` 精确列版本的 procedure。 +- 客户端不暴露 `localProjectId`:本地项目与线上作品的对应关系由客户端本地账本维护,服务端只在发布时用它复用 `gameId`。 + +## 尚未完成 + +- AGC 真机 GUI 端到端发布取证。 +- 生产环境发布第一个作品并开闸。 +- 非 Phaser(Godot / Cocos / Unity / 单 HTML)发布的主动拦截:当前只给泛化错误文案。 +- 发布包清理策略的上线验收。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 8e98e71bf..9ba6d41b5 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -120,7 +120,8 @@ | `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 | | `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 | | `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` | -| `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 | +| `GET /my-games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生,单次最多 48 项 | +| `GET /my-games/{gameId}` | 登录作者 | **已实现**:作者读自己名下单个游戏的详情,条目与 `GET /my-games` 同形(含全部版本私有状态、驳回理由与已公开版本的 `entryUrl`)。作者要能打开「审核中 / 被驳回 / 已下架 / 已撤回」的作品,公开详情只服务已公开投影,所以作者视角必须走这条 owner 作用域路由;游戏不存在或不属于当前主体都返回 404 | | `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` | | `POST /games/{gameId}/versions` | owner | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 | | `PUT /versions/{versionId}/package` | owner | **已实现**:接收真实 ZIP、重算摘要与文件清单并写入私有对象;不执行游戏代码 | diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index da217c4cf..a88aebd16 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -34,11 +34,11 @@ use spacetime_client::{ GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord, GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput, GameDistributionGameRecord, - GameDistributionGetGameRecordInput, GameDistributionPublicGameListRecordInput, - GameDistributionPublicGameRecord, GameDistributionRejectRecordInput, - GameDistributionRestoreRecordInput, GameDistributionSubmitReviewRecordInput, - GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, - GameDistributionVersionRecord, SpacetimeClientError, + GameDistributionGetGameRecordInput, GameDistributionOwnerGameRecord, + GameDistributionPublicGameListRecordInput, GameDistributionPublicGameRecord, + GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput, + GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput, + GameDistributionUnpublishRecordInput, GameDistributionVersionRecord, SpacetimeClientError, }; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -234,6 +234,10 @@ pub fn router(state: AppState) -> Router { post(cancel_version), ) .route("/api/game-distribution/my-games", get(list_my_games)) + .route( + "/api/game-distribution/my-games/{game_id}", + get(get_owner_game), + ) .route( "/api/game-distribution/games/{game_id}/unpublish", post(unpublish_game), @@ -514,26 +518,66 @@ async fn list_my_games( .map_err(map_spacetime_error)?; let payload = games .into_iter() - .map(|entry| { - let mut value = game_payload(&entry.game); - let versions = entry - .versions - .iter() - .map(private_version_payload) - .collect::>(); - if let Value::Object(ref mut object) = value { - object.insert( - "latestVersion".to_string(), - versions.first().cloned().unwrap_or(Value::Null), - ); - object.insert("versions".to_string(), Value::Array(versions)); - } - value - }) + .map(owner_game_entry_payload) .collect::>(); Ok(json_success_body(Some(&ctx), json!({ "games": payload }))) } +/// 作者读取自己名下单个游戏的详情,与 `list_my_games` 的条目同形。 +/// +/// 作者管理页要能打开「审核中 / 被驳回 / 已下架 / 已撤回」的作品,公开详情只服务已公开 +/// 投影,所以作者视角必须走这条 owner 作用域路由,否则作者点自己的作品只会拿到 404。 +/// 游戏不存在或不属于当前主体都返回 404,避免用错误码区分"别人的游戏"和"不存在的游戏"。 +async fn get_owner_game( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(game_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + let game = state + .spacetime_client() + .get_game_distribution_game(GameDistributionGetGameRecordInput { + game_id: game_id.clone(), + owner_user_id: Some(owner_user_id.clone()), + }) + .await + .map_err(map_spacetime_error)? + .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let versions = load_owner_game_versions(&state, owner_user_id, &game_id).await?; + Ok(json_success_body( + Some(&ctx), + json!({ "game": owner_game_entry_payload(GameDistributionOwnerGameRecord { game, versions }) }), + )) +} + +/// 读取当前主体名下某个游戏的版本列表。 +/// +/// 目前复用作者自有列表 procedure(版本随游戏聚合返回),因此与 `/my-games` 共享同一个 +/// 条数上限:单作者作品数超过上限时该游戏没有版本记录。放量前需要补一条按 `game_id` +/// 精确列版本的 procedure。 +async fn load_owner_game_versions( + state: &AppState, + owner_user_id: String, + game_id: &str, +) -> Result, AppError> { + let games = state + .spacetime_client() + .list_owner_game_distribution_games( + spacetime_client::GameDistributionOwnerGameListRecordInput { + owner_user_id, + limit: MAX_LIST_LIMIT, + }, + ) + .await + .map_err(map_spacetime_error)?; + Ok(games + .into_iter() + .find(|entry| entry.game.game_id == game_id) + .map(|entry| entry.versions) + .unwrap_or_default()) +} + async fn create_game( State(state): State, Extension(ctx): Extension, @@ -1959,6 +2003,28 @@ fn game_payload(game: &GameDistributionGameRecord) -> Value { }) } +/// 作者自有游戏条目:公开投影 + 全部版本的私有状态。 +/// +/// 公开目录与公开详情继续只用 `game_payload`,私有字段(包摘要、驳回理由、入口地址) +/// 不会随公开投影下发。 +fn owner_game_entry_payload(entry: GameDistributionOwnerGameRecord) -> Value { + let versions = entry + .versions + .iter() + .map(private_version_payload) + .collect::>(); + let mut payload = game_payload(&entry.game); + let Some(object) = payload.as_object_mut() else { + return payload; + }; + object.insert( + "latestVersion".to_string(), + versions.first().cloned().unwrap_or(Value::Null), + ); + object.insert("versions".to_string(), Value::Array(versions)); + payload +} + fn version_summary_payload(version: &GameDistributionVersionRecord) -> Value { json!({ "id": version.version_id, @@ -1977,9 +2043,11 @@ fn private_version_payload(version: &GameDistributionVersionRecord) -> Value { "versionNumber": version.version_number, "packageSha256": version.package_sha256, "packageBytes": version.package_bytes, + "packageFileCount": version.package_file_count, "status": version.status, "publicationRevision": version.publication_revision, "reviewReason": version.review_reason, + "entryUrl": version.entry_url, "createdAt": version.created_at, "updatedAt": version.updated_at, }) @@ -2722,6 +2790,76 @@ mod tests { assert!(legacy_payload["version"]["frozenMetadata"].is_null()); } + /// 作者管理页依赖这条边界:公开投影不带版本私有状态,作者条目必须带。 + #[test] + fn owner_game_entry_payload_carries_private_versions_that_public_payload_omits() { + let game = GameDistributionGameRecord { + game_id: "game_1".to_string(), + owner_user_id: "user_1".to_string(), + title: "待审游戏".to_string(), + summary: "摘要".to_string(), + description: "描述".to_string(), + category: "益智".to_string(), + tags_json: "[]".to_string(), + cover_asset_id: Some("asset_cover".to_string()), + author_name: None, + author_avatar_url: None, + device_support_desktop: true, + device_support_mobile: false, + device_support_touch: false, + input_modes_json: "[]".to_string(), + orientation: "responsive".to_string(), + publication_revision: 1, + active_version_id: None, + visibility: "unpublished".to_string(), + play_count: 0, + created_at: "2026-09-20T00:00:00Z".to_string(), + updated_at: "2026-09-20T00:00:00Z".to_string(), + cover_object_key: None, + screenshots_json: None, + }; + let version = GameDistributionVersionRecord { + version_id: "version_1".to_string(), + game_id: "game_1".to_string(), + owner_user_id: "user_1".to_string(), + version_number: 1, + package_sha256: "b".repeat(64), + package_bytes: 4096, + package_file_count: 7, + package_entry_path: "index.html".to_string(), + status: "rejected".to_string(), + review_reason: Some("封面与游戏内容无关".to_string()), + entry_url: None, + publication_revision: 1, + created_at: "2026-09-20T00:00:00Z".to_string(), + updated_at: "2026-09-20T00:00:00Z".to_string(), + metadata_json: None, + }; + + let public = game_payload(&game); + assert!(public.get("versions").is_none()); + assert!(public.get("latestVersion").is_none()); + + let entry = owner_game_entry_payload(GameDistributionOwnerGameRecord { + game, + versions: vec![version], + }); + assert_eq!(entry["status"], Value::String("unpublished".to_string())); + assert_eq!( + entry["versions"][0]["status"], + Value::String("rejected".to_string()) + ); + assert_eq!( + entry["versions"][0]["reviewReason"], + Value::String("封面与游戏内容无关".to_string()) + ); + assert_eq!(entry["versions"][0]["packageFileCount"], 7); + assert_eq!( + entry["latestVersion"]["versionId"], + Value::String("version_1".to_string()) + ); + } + #[test] fn package_validation_errors_are_unprocessable() { let error = map_package_error(ReleasePackageError::MissingEntry); @@ -2845,6 +2983,7 @@ mod tests { // 发布写入必须要求登录态,未带 Bearer 时在进入业务前就被拒绝。 let unauthenticated_create = app + .clone() .oneshot( Request::builder() .method("POST") @@ -2856,6 +2995,21 @@ mod tests { .await .expect("路由响应"); assert_eq!(unauthenticated_create.status(), StatusCode::UNAUTHORIZED); + + // 作者作品详情是 owner 作用域路由,未带 Bearer 时同样在进入业务前被拒绝。 + let unauthenticated_owner_game = app + .oneshot( + Request::builder() + .uri("/api/game-distribution/my-games/game_1") + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!( + unauthenticated_owner_game.status(), + StatusCode::UNAUTHORIZED + ); } #[test]