游戏作品管理:补作者读自己作品详情的 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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user