From c277fe17e5f90e33563aa9c29cef0a89a3dd7e35 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 30 Sep 2026 16:09:29 +0800 Subject: [PATCH 01/34] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=9A=E8=A1=A5=E4=BD=9C=E8=80=85=E8=AF=BB?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E4=BD=9C=E5=93=81=E8=AF=A6=E6=83=85=E7=9A=84?= =?UTF-8?q?=20owner=20=E4=BD=9C=E7=94=A8=E5=9F=9F=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-server 新增 GET /api/game-distribution/my-games/{gameId},作者可读自己名下审核中/被驳回/已下架/已撤回的作品 - 抽取 owner_game_entry_payload 统一作者条目形状,公开投影继续不携带版本私有状态 - 版本私有状态补 entryUrl 与 packageFileCount,供客户端作品详情展示版本历史与公开链接 - 主规范路由表登记新路由,并修正 /my-games 的错名 - 新增里程碑实施计划【实施计划】游戏作品管理与客户端发布收口-2026-09-30 --- ...划】游戏作品管理与客户端发布收口-2026-09-30.md | 70 +++++++ ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 3 +- .../src/modules/game_distribution.rs | 196 ++++++++++++++++-- 3 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】游戏作品管理与客户端发布收口-2026-09-30.md 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] From a2b70d92b802d792a8e34e8c031a160651005d1b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 16:30:40 +0800 Subject: [PATCH 02/34] =?UTF-8?q?=E4=BD=9C=E5=93=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8EPhaser4=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 Phaser4 客户端发布前校验、阶段进度与原生幂等账本 增加 Web 作者私有作品管理、资料编辑、版本历史与软删除 增加后台作品筛选、游标分页与删除治理 同步 SpacetimeDB schema、bindings、DTO、文档与定向测试 --- apps/admin-web/src/api/adminApiClient.test.ts | 28 +- apps/admin-web/src/api/adminApiClient.ts | 26 +- apps/admin-web/src/api/adminApiTypes.ts | 13 +- .../pages/AdminGameManagementPage.test.tsx | 110 +++++ .../src/pages/AdminGameManagementPage.tsx | 287 ++++++++++-- .../src-tauri/src/commands.rs | 20 + .../src/game_distribution_publish.rs | 127 ++++- .../src-tauri/src/game_package_upload.rs | 41 +- .../src-tauri/src/game_publish_attempt.rs | 390 +++++++++++++++ .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project/export.rs | 199 ++++++++ .../src-tauri/src/tests/project.rs | 142 ++++++ apps/ai-game-creator-shell/src/App.tsx | 1 + .../GameDistributionPublishPanel.tsx | 65 ++- .../GamePublishPhaseSteps.tsx | 70 +++ .../GamePublishProgressDialog.tsx | 13 + .../src/services/gameDistributionPublish.ts | 19 +- .../src/services/gamePackageUploadProgress.ts | 116 +++++ apps/ai-game-creator-shell/src/styles.css | 96 ++++ .../tests/gameDistributionPublish.test.ts | 25 + .../tests/gameDistributionPublishLive.test.ts | 43 +- .../gameDistributionPublishPanel.test.tsx | 120 ++++- .../tests/gamePackageUploadProgress.test.ts | 136 ++++++ ...划】游戏作品管理与客户端发布收口-2026-09-30.md | 49 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 17 +- ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 7 +- .../shared/src/contracts/gameDistribution.ts | 25 +- scripts/admin-web-fake-api.mjs | 60 ++- .../check-game-distribution-dto-parity.mjs | 4 + .../src/modules/game_distribution.rs | 443 +++++++++++++++++- .../shared-contracts/src/game_distribution.rs | 50 ++ .../crates/spacetime-client/src/active.rs | 11 +- .../src/active/mapper/game_distribution.rs | 12 +- .../spacetime-client/src/game_distribution.rs | 143 +++++- .../spacetime-client/src/module_bindings.rs | 8 + ...stribution_version_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ...stribution_package_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 59 +++ ...istribution_upload_and_return_procedure.rs | 16 +- ...distribution_admin_game_list_input_type.rs | 4 + ...istribution_admin_game_list_result_type.rs | 1 + ...e_distribution_admin_game_snapshot_type.rs | 1 + ...ame_distribution_delete_game_input_type.rs | 20 + .../game_distribution_game_type.rs | 3 + ...distribution_update_metadata_input_type.rs | 33 ++ ..._distribution_game_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ...distribution_games_and_return_procedure.rs | 16 +- ...distribution_games_and_return_procedure.rs | 16 +- ...stribution_reviews_and_return_procedure.rs | 16 +- ...distribution_games_and_return_procedure.rs | 16 +- ...distribution_games_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ...version_for_review_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ...tion_game_metadata_and_return_procedure.rs | 62 +++ .../spacetime-module/src/game_distribution.rs | 410 +++++++++++++++- .../crates/spacetime-module/src/migration.rs | 2 + .../game-distribution/GameDetailPage.tsx | 176 ++++++- .../GameDistributionPages.test.tsx | 117 ++++- .../GamePublishMediaFieldset.tsx | 234 +++++++++ .../game-distribution/GamePublishPage.tsx | 328 +------------ .../game-distribution/GameVersionHistory.tsx | 79 ++++ .../GameWorkMetadataEditor.tsx | 337 +++++++++++++ .../game-distribution/MyGamesPage.test.tsx | 344 +++++++++++++- .../game-distribution/MyGamesPage.tsx | 417 ++++++++++++++++- .../game-distribution/gameDistribution.css | 133 ++++++ .../game-distribution/gameMetadataOptions.ts | 55 +++ .../gamePublishMediaDefaults.ts | 80 ++++ .../gameVersionHistoryModel.ts | 60 +++ .../PlatformEntryActiveFlowShell.test.tsx | 160 +++++++ .../PlatformEntryActiveFlowShell.tsx | 17 +- src/services/gameDistributionClient.ts | 143 +++++- 80 files changed, 5836 insertions(+), 676 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs create mode 100644 apps/ai-game-creator-shell/src/components/game-distribution/GamePublishPhaseSteps.tsx create mode 100644 apps/ai-game-creator-shell/src/services/gamePackageUploadProgress.ts create mode 100644 apps/ai-game-creator-shell/tests/gamePackageUploadProgress.test.ts create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/delete_game_distribution_game_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_delete_game_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_metadata_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/update_game_distribution_game_metadata_and_return_procedure.rs create mode 100644 src/components/game-distribution/GamePublishMediaFieldset.tsx create mode 100644 src/components/game-distribution/GameVersionHistory.tsx create mode 100644 src/components/game-distribution/GameWorkMetadataEditor.tsx create mode 100644 src/components/game-distribution/gameMetadataOptions.ts create mode 100644 src/components/game-distribution/gamePublishMediaDefaults.ts create mode 100644 src/components/game-distribution/gameVersionHistoryModel.ts diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 7c5464f06..299f2c2b4 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -568,7 +568,7 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键' ); expect(fetchMock.mock.calls[0]?.[0]).toBe( - '/admin/api/game-distribution/games?limit=50', + '/admin/api/game-distribution/games?limit=80', ); expect(fetchMock.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ @@ -606,6 +606,32 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键' expect(fetchMock).toHaveBeenCalledTimes(2); }); +test('游戏管理列表把筛选与游标编码进查询串', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ ok: true, data: { games: [], nextCursor: null } }), + { status: 200 }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await listAdminGameDistributionGames('admin-token', { + limit: 20, + keyword: ' 拼图 ', + owner: 'user_9', + status: 'suspended', + cursor: '1710000000000:game_1', + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/games?limit=20&keyword=%E6%8B%BC%E5%9B%BE&owner=user_9&status=suspended&cursor=1710000000000%3Agame_1', + ); +}); + test('安全下架请求携带公开修订号、原因与幂等键', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 8aae410af..9dd924e8f 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -33,6 +33,7 @@ import type { AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, AdminGameDistributionGameListResponse, + AdminGameDistributionGameStatus, AdminGameDistributionRestoreRequest, AdminGameDistributionRestoreResponse, AdminGameDistributionReviewListResponse, @@ -1243,17 +1244,36 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { ); } +/** + * 后台作品管理列表。 + * + * 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数, + * 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。 + */ export function listAdminGameDistributionGames( token: string, - options: { limit?: number } = {}, + options: { + limit?: number; + keyword?: string; + owner?: string; + status?: AdminGameDistributionGameStatus | ''; + cursor?: string | null; + } = {}, signal?: AbortSignal, ) { const requestedLimit = options.limit ?? 50; const normalizedLimit = Number.isFinite(requestedLimit) - ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 200) : 50; + const params = new URLSearchParams({ limit: String(normalizedLimit) }); + const keyword = options.keyword?.trim(); + if (keyword) params.set('keyword', keyword); + const owner = options.owner?.trim(); + if (owner) params.set('owner', owner); + if (options.status) params.set('status', options.status); + if (options.cursor) params.set('cursor', options.cursor); return request( - `/admin/api/game-distribution/games?limit=${normalizedLimit}`, + `/admin/api/game-distribution/games?${params.toString()}`, { token, signal }, ); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 429ce7c5a..e6efdcc19 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1158,18 +1158,29 @@ export interface AdminGameDistributionGameEntry { name: string; avatarUrl: string | null; }; - status: string; + status: AdminGameDistributionGameStatus; versionCount: number; playCount: number; activeVersionId: string | null; publicationRevision: number; createdAt: string; updatedAt: string; + /** 作者软删除时间;为空表示未删除。 */ + deletedAt: string | null; versions: AdminGameDistributionGameVersionEntry[]; } +/** 后台作品列表的状态:游戏可见性 + 作者软删除。 */ +export type AdminGameDistributionGameStatus = + | 'published' + | 'unpublished' + | 'suspended' + | 'deleted'; + export interface AdminGameDistributionGameListResponse { games: AdminGameDistributionGameEntry[]; + /** 还有下一页时为下一页游标;旧响应没有该字段,按空值处理。 */ + nextCursor?: string | null; } export interface AdminGameDistributionRestoreRequest { diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx index 18407c5bb..8836d3244 100644 --- a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx @@ -68,6 +68,7 @@ const publishedGame: AdminGameDistributionGameEntry = { publicationRevision: 4, createdAt: '2026-09-18T08:00:00Z', updatedAt: '2026-09-20T10:00:00Z', + deletedAt: null, versions: [version], }; @@ -86,6 +87,26 @@ const suspendedGame: AdminGameDistributionGameEntry = { publicationRevision: 7, createdAt: '2026-09-19T08:00:00Z', updatedAt: '2026-09-21T08:00:00Z', + deletedAt: null, + versions: [], +}; + +const deletedGame: AdminGameDistributionGameEntry = { + gameId: 'game_3', + title: '已删除作品', + author: { + id: 'user_3', + name: '作者丙', + avatarUrl: null, + }, + status: 'deleted', + versionCount: 1, + playCount: 3, + activeVersionId: null, + publicationRevision: 2, + createdAt: '2026-09-17T08:00:00Z', + updatedAt: '2026-09-22T08:00:00Z', + deletedAt: '2026-09-22T08:00:00Z', versions: [], }; @@ -263,3 +284,92 @@ test('401 交给 onUnauthorized 处理', async () => { ); expect(screen.queryByRole('alert')).toBeNull(); }); + +test('筛选条件按服务端口径透传,重置回到无条件第一页', async () => { + render( + , + ); + + await screen.findByText('测试游戏'); + fireEvent.change(screen.getByPlaceholderText('标题、gameId 或作者 ID'), { + target: { value: ' 拼图 ' }, + }); + fireEvent.change(screen.getByPlaceholderText('作者 user ID'), { + target: { value: 'user_9' }, + }); + fireEvent.change(screen.getByRole('combobox', { name: '状态' }), { + target: { value: 'suspended' }, + }); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + + await waitFor(() => + expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2), + ); + const [, applied] = vi.mocked(listAdminGameDistributionGames).mock.calls[1]!; + expect(applied).toEqual({ + limit: 50, + cursor: null, + keyword: '拼图', + owner: 'user_9', + status: 'suspended', + }); + + fireEvent.click(screen.getByRole('button', { name: '重置' })); + await waitFor(() => + expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(3), + ); + const [, reset] = vi.mocked(listAdminGameDistributionGames).mock.calls[2]!; + expect(reset).toEqual({ + limit: 50, + cursor: null, + keyword: '', + owner: '', + status: '', + }); + expect( + (screen.getByPlaceholderText('标题、gameId 或作者 ID') as HTMLInputElement) + .value, + ).toBe(''); +}); + +test('游标分页把上一页返回的 nextCursor 传给下一页', async () => { + vi.mocked(listAdminGameDistributionGames) + .mockResolvedValueOnce({ games: [publishedGame], nextCursor: 'cursor-1' }) + .mockResolvedValueOnce({ games: [suspendedGame], nextCursor: null }); + + render( + , + ); + + await screen.findByText('测试游戏'); + const nextButton = screen.getByRole('button', { name: '下一页' }); + expect((nextButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(nextButton); + + await screen.findByText('下架游戏'); + expect(screen.queryByText('测试游戏')).toBeNull(); + const [, secondPage] = vi.mocked(listAdminGameDistributionGames).mock + .calls[1]!; + expect(secondPage?.cursor).toBe('cursor-1'); + expect( + (screen.getByRole('button', { name: '下一页' }) as HTMLButtonElement) + .disabled, + ).toBe(true); +}); + +test('已删除作品显示删除时间且不再提供下架与恢复', async () => { + vi.mocked(listAdminGameDistributionGames).mockResolvedValue({ + games: [deletedGame], + }); + + render( + , + ); + + const row = (await screen.findByText('已删除作品')).closest('tr')!; + expect(within(row).getByText('已删除')).toBeTruthy(); + expect(within(row).getByText(/删除于/)).toBeTruthy(); + expect(within(row).queryByRole('button', { name: '下架' })).toBeNull(); + expect(within(row).queryByRole('button', { name: '恢复' })).toBeNull(); + expect(within(row).getByRole('button', { name: '版本历史' })).toBeTruthy(); +}); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.tsx index 074a92927..043f6c3d8 100644 --- a/apps/admin-web/src/pages/AdminGameManagementPage.tsx +++ b/apps/admin-web/src/pages/AdminGameManagementPage.tsx @@ -4,15 +4,17 @@ import { AdminAlert, AdminButton, AdminDialog, + AdminField, AdminFieldGroup, AdminListPanel, AdminPage, AdminPageHeading, + AdminPagination, AdminStatusPill, formatAdminDateTime, } from '@genarrative/shared/components'; -import { RefreshCcw } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { RefreshCcw, Search, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { listAdminGameDistributionGames, @@ -21,6 +23,7 @@ import { } from '../api/adminApiClient'; import type { AdminGameDistributionGameEntry, + AdminGameDistributionGameStatus, AdminGameDistributionGameVersionEntry, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; @@ -38,8 +41,32 @@ const GAME_STATUS_META: Record< published: { label: '已公开', tone: 'ok' }, suspended: { label: '已下架', tone: 'error' }, unpublished: { label: '未公开', tone: 'pending' }, + // 作者软删除:保留在后台可见(默认视图之外),但不再公开可玩。 + deleted: { label: '已删除', tone: 'error' }, }; +const STATUS_FILTER_OPTIONS: { + value: AdminGameDistributionGameStatus | ''; + label: string; +}[] = [ + { value: '', label: '全部状态' }, + { value: 'published', label: '已公开' }, + { value: 'unpublished', label: '未公开' }, + { value: 'suspended', label: '已下架' }, + { value: 'deleted', label: '已删除' }, +]; + +const DEFAULT_PAGE_SIZE = 50; +const PAGE_SIZE_OPTIONS = [20, 50, 100]; + +interface GameFilters { + keyword: string; + owner: string; + status: AdminGameDistributionGameStatus | ''; +} + +const EMPTY_FILTERS: GameFilters = { keyword: '', owner: '', status: '' }; + function gameStatusMeta(status: string) { return ( GAME_STATUS_META[status] ?? { @@ -89,7 +116,13 @@ export function AdminGameManagementPage({ onUnauthorized, }: AdminGameManagementPageProps) { const [games, setGames] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); + const [filters, setFilters] = useState(EMPTY_FILTERS); const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); const [busyGameId, setBusyGameId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); @@ -99,26 +132,109 @@ export function AdminGameManagementPage({ const [versionGame, setVersionGame] = useState(null); const writeConfirm = useAdminWriteConfirm(); + const listController = useRef(null); + // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, + // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 + const pageCursors = useRef<(string | null)[]>([null]); - const loadGames = useCallback(async () => { - setIsLoading(true); - setErrorMessage(''); - try { - const response = await listAdminGameDistributionGames(token, { - limit: 50, - }); - setGames(response.games); - } catch (error) { - handlePageError(error, onUnauthorized, setErrorMessage); - } finally { - setIsLoading(false); - } - }, [token, onUnauthorized]); + const loadGames = useCallback( + async (cursor: string | null, page: number) => { + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminGameDistributionGames( + token, + { + limit: pageSize, + cursor, + keyword: filters.keyword, + owner: filters.owner, + status: filters.status, + }, + controller.signal, + ); + if (controller.signal.aborted) return; + setGames(response.games); + setNextCursor(response.nextCursor ?? null); + setPageIndex(page); + setHasLoaded(true); + } catch (error: unknown) { + // 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。 + if (!controller.signal.aborted) + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + if (listController.current === controller) { + listController.current = null; + setIsLoading(false); + } + } + }, + [ + token, + onUnauthorized, + pageSize, + filters.keyword, + filters.owner, + filters.status, + ], + ); + // 条件或每页条数变化时回到第一页;这里是唯一的"重置游标"入口。 useEffect(() => { - void loadGames(); + pageCursors.current = [null]; + setPageIndex(1); + void loadGames(null, 1); }, [loadGames]); + useEffect( + () => () => { + listController.current?.abort(); + }, + [], + ); + + function applyFilters() { + setFilters({ + keyword: draftFilters.keyword.trim(), + owner: draftFilters.owner.trim(), + status: draftFilters.status, + }); + } + + function resetFilters() { + setDraftFilters(EMPTY_FILTERS); + setFilters(EMPTY_FILTERS); + } + + function refreshCurrentPage() { + void loadGames(pageCursors.current[pageIndex - 1] ?? null, pageIndex); + } + + function goToNextPage() { + if (!nextCursor) return; + const nextPage = pageIndex + 1; + pageCursors.current[nextPage - 1] = nextCursor; + void loadGames(nextCursor, nextPage); + } + + function goToPreviousPage() { + if (pageIndex <= 1) return; + const previousPage = pageIndex - 1; + void loadGames(pageCursors.current[previousPage - 1] ?? null, previousPage); + } + + function changePageSize(size: number) { + pageCursors.current = [null]; + setPageSize(size); + } + + const filtersActive = Boolean( + filters.keyword || filters.owner || filters.status, + ); + async function suspendGame(entry: AdminGameDistributionGameEntry) { const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); if (!reason) { @@ -151,7 +267,7 @@ export function AdminGameManagementPage({ ...current, [entry.gameId]: '', })); - await loadGames(); + refreshCurrentPage(); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -177,7 +293,7 @@ export function AdminGameManagementPage({ { expectedPublicationRevision: entry.publicationRevision }, ); setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`); - await loadGames(); + refreshCurrentPage(); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -193,7 +309,7 @@ export function AdminGameManagementPage({ void loadGames()} + onClick={refreshCurrentPage} disabled={isLoading} >