游戏作品管理:补作者读自己作品详情的 owner 作用域路由

- api-server 新增 GET /api/game-distribution/my-games/{gameId},作者可读自己名下审核中/被驳回/已下架/已撤回的作品
- 抽取 owner_game_entry_payload 统一作者条目形状,公开投影继续不携带版本私有状态
- 版本私有状态补 entryUrl 与 packageFileCount,供客户端作品详情展示版本历史与公开链接
- 主规范路由表登记新路由,并修正 /my-games 的错名
- 新增里程碑实施计划【实施计划】游戏作品管理与客户端发布收口-2026-09-30
This commit is contained in:
2026-09-30 16:09:29 +08:00
parent c25fd47d16
commit c277fe17e5
3 changed files with 247 additions and 22 deletions
@@ -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)发布的主动拦截:当前只给泛化错误文案。
- 发布包清理策略的上线验收。
@@ -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、重算摘要与文件清单并写入私有对象;不执行游戏代码 |
@@ -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<AppState> {
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::<Vec<_>>();
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::<Vec<_>>();
Ok(json_success_body(Some(&ctx), json!({ "games": payload })))
}
/// 作者读取自己名下单个游戏的详情,与 `list_my_games` 的条目同形。
///
/// 作者管理页要能打开「审核中 / 被驳回 / 已下架 / 已撤回」的作品,公开详情只服务已公开
/// 投影,所以作者视角必须走这条 owner 作用域路由,否则作者点自己的作品只会拿到 404。
/// 游戏不存在或不属于当前主体都返回 404,避免用错误码区分"别人的游戏"和"不存在的游戏"。
async fn get_owner_game(
State(state): State<AppState>,
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
Path(game_id): Path<String>,
) -> Result<Json<Value>, 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<Vec<GameDistributionVersionRecord>, 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<AppState>,
Extension(ctx): Extension<RequestContext>,
@@ -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::<Vec<_>>();
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]