feat(游戏分发): 版本号必填并为发布身份改名 projectKey
- resolve_version_number 删除 max_existing 与自增分支,只校验 versionNumber > 0 - 新增领域纯函数 game_distribution_version_key(自然幂等键)与 derive_game_distribution_game_id(确定性 gameId) - GameDistributionCreateVersionInput、CreateVersionInput、CreateVersionRecordInput 的 versionNumber 改 u64 必填 - SpacetimeDB 删除「同号 pending 标 cancelled」,同号即同一版本身份 - local_project_id 全链路改名 project_key:两张表、shared-contracts、spacetime-client、api-server 与生成绑定 - 重新生成 module_bindings;migration.rs 与后端数据契约记录改名 - AGC 发布命令要求版本号必填;网页首次发布补 versionNumber=1、更新补最新版本 +1;packages/shared DTO versionNumber 必填 - 主规范、实施计划、里程碑与 pitfalls 同步 projectKey 口径 - 注:AGC 前端 gameDistributionPublish.ts 与 decision-log 的 projectKey 改名与并发中的陶泥儿槽位改动同文件,另随后续提交落地
This commit is contained in:
@@ -159,13 +159,13 @@ struct CreatedVersion {
|
||||
|
||||
/// 作者名下作品条目(`GET /my-games` 列表项与 `GET /my-games/{gameId}` 的 `game`)。
|
||||
///
|
||||
/// 只声明恢复绑定需要的字段:游戏身份、`localProjectId`、最近版本与公开修订号。
|
||||
/// 只声明恢复绑定需要的字段:游戏身份、`projectKey`、最近版本与公开修订号。
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OwnerGameEntry {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
local_project_id: Option<String>,
|
||||
project_key: Option<String>,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
@@ -991,7 +991,7 @@ pub(crate) async fn read_game_publish_availability() -> Result<bool, String> {
|
||||
|
||||
/// 发布面板打开时的绑定恢复。
|
||||
///
|
||||
/// 只有「本地绑定(且属于当前账号/origin)+ 线上作品」或「无本地绑定时按作者 + localProjectId
|
||||
/// 只有「本地绑定(且属于当前账号/origin)+ 线上作品」或「无本地绑定时按作者 + projectKey
|
||||
/// 精确匹配」两种情况才进入更新模式;网络/401/5xx 一律返回 `unavailable`,绝不静默按首次发布。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_game_distribution_publication(
|
||||
@@ -1074,7 +1074,7 @@ pub(crate) async fn read_game_distribution_publication(
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let matched = games.into_iter().find(|game| {
|
||||
game.get("localProjectId")
|
||||
game.get("projectKey")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
== Some(local_project_id.as_str())
|
||||
@@ -1384,7 +1384,7 @@ pub(crate) async fn publish_local_project_game(
|
||||
let staged = stage_publish_package(&app_data_dir, &project_path)?;
|
||||
let root = Path::new(project_path.trim());
|
||||
let local_project_id = metadata
|
||||
.local_project_id
|
||||
.project_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -1406,17 +1406,15 @@ pub(crate) async fn publish_local_project_game(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
// 版本号必填:它是版本身份与自然幂等键的一部分,服务端不再自增。
|
||||
let version_number = version_number
|
||||
.ok_or_else(|| "发布需要项目版本号,请重新打开发布面板后再试".to_string())?;
|
||||
// 项目版本按 JSON number 传输:超出安全整数就已经丢精度,必须在发请求前失败关闭。
|
||||
if let Some(number) = version_number {
|
||||
if number == 0 {
|
||||
return Err("项目版本必须是大于 0 的整数".to_string());
|
||||
}
|
||||
if number > MAX_SAFE_VERSION_NUMBER {
|
||||
return Err("项目版本超出安全整数范围".to_string());
|
||||
}
|
||||
if version_number == 0 {
|
||||
return Err("项目版本必须是大于 0 的整数".to_string());
|
||||
}
|
||||
if target_game_id.is_some() && version_number.is_none() {
|
||||
return Err("更新游戏必须填写项目版本".to_string());
|
||||
if version_number > MAX_SAFE_VERSION_NUMBER {
|
||||
return Err("项目版本超出安全整数范围".to_string());
|
||||
}
|
||||
if target_game_id.is_some() && expected_publication_revision.is_none() {
|
||||
return Err("缺少公开修订号,请重新打开发布面板后再试".to_string());
|
||||
@@ -1445,7 +1443,7 @@ pub(crate) async fn publish_local_project_game(
|
||||
local_project_id: &local_project_id,
|
||||
package_sha256: &staged.package_sha256,
|
||||
target_game_id: target_game_id.as_deref(),
|
||||
target_version_number: version_number,
|
||||
target_version_number: Some(version_number),
|
||||
metadata_digest: &digest,
|
||||
},
|
||||
)?
|
||||
@@ -1493,7 +1491,7 @@ pub(crate) async fn publish_local_project_game(
|
||||
}
|
||||
};
|
||||
let version_request = GameDistributionCreateVersionRequest {
|
||||
local_project_id: Some(local_project_id.clone()),
|
||||
project_key: Some(local_project_id.clone()),
|
||||
package_sha256: staged.package_sha256.clone(),
|
||||
package_bytes: staged.package_size_bytes,
|
||||
package_file_count: staged.package_file_count,
|
||||
@@ -1804,7 +1802,7 @@ mod tests {
|
||||
#[test]
|
||||
fn metadata_digest_changes_with_frozen_metadata() {
|
||||
let mut metadata = GameDistributionCreateGameRequest {
|
||||
local_project_id: Some("proj-1".to_string()),
|
||||
project_key: Some("proj-1".to_string()),
|
||||
title: "游戏".to_string(),
|
||||
summary: "简介".to_string(),
|
||||
description: None,
|
||||
@@ -1878,10 +1876,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_game_entry_parses_author_payload_with_local_project_id() {
|
||||
fn owner_game_entry_parses_author_payload_with_project_key() {
|
||||
let entry = parse_owner_game_entry(json!({
|
||||
"id": "game-1",
|
||||
"localProjectId": "proj-1",
|
||||
"projectKey": "proj-1",
|
||||
"title": "星轨防线",
|
||||
"summary": "守住轨道城",
|
||||
"status": "published",
|
||||
@@ -1910,7 +1908,7 @@ mod tests {
|
||||
}))
|
||||
.expect("owner entry");
|
||||
assert_eq!(entry.id, "game-1");
|
||||
assert_eq!(entry.local_project_id.as_deref(), Some("proj-1"));
|
||||
assert_eq!(entry.project_key.as_deref(), Some("proj-1"));
|
||||
assert_eq!(entry.publication_revision, 4);
|
||||
assert_eq!(entry.price_mud_points, 240);
|
||||
assert_eq!(entry.versions.len(), 2);
|
||||
@@ -2069,7 +2067,7 @@ mod tests {
|
||||
screenshots: Vec<Option<&str>>,
|
||||
) -> GameDistributionCreateGameRequest {
|
||||
GameDistributionCreateGameRequest {
|
||||
local_project_id: Some("proj-1".to_string()),
|
||||
project_key: Some("proj-1".to_string()),
|
||||
title: "游戏".to_string(),
|
||||
summary: "简介".to_string(),
|
||||
description: None,
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
|
||||
## 2. 已定决策
|
||||
|
||||
- D1 单一路由 `POST /api/game-distribution/versions`;`metadata` 用 `gameId?`/`localProjectId?` 区分更新与首次发布,无 `gameId` 时 `localProjectId` 必填并作为身份锚。
|
||||
- D1 单一路由 `POST /api/game-distribution/versions`;`metadata` 用 `gameId?`/`projectKey?` 区分更新与首次发布,无 `gameId` 时 `projectKey` 必填并作为身份锚。
|
||||
- D2 `versionNumber` 必填、客户端在一次发布意图内冻结;删服务端自增;删「同号新 versionId」与「同号 pending 取消替换」。
|
||||
- D3 自然幂等键 `(ownerUserId, localProjectId | gameId, versionNumber)`;沿用 `action = create_version`、`idempotency_key = "{anchor}:v{versionNumber}"`;同键同 digest 重放、不同 digest 409;该路由不读 `Idempotency-Key`。
|
||||
- D4 无 `gameId` 时 `gameId` 由 `(ownerUserId, localProjectId)` 确定性派生,保证媒体 objectKey 前缀在事务前稳定、重试/并发不落不同命名空间。
|
||||
- D3 自然幂等键 `(ownerUserId, projectKey | gameId, versionNumber)`;沿用 `action = create_version`、`idempotency_key = "{anchor}:v{versionNumber}"`;同键同 digest 重放、不同 digest 409;该路由不读 `Idempotency-Key`。
|
||||
- D4 无 `gameId` 时 `gameId` 由 `(ownerUserId, projectKey)` 确定性派生,保证媒体 objectKey 前缀在事务前稳定、重试/并发不落不同命名空间。
|
||||
- D5 作品 get-or-create、建版本、写收据在单个 SpacetimeDB procedure 的一次 `try_with_tx` 内提交;首次发布用本次 `gameMetadata` + 已解析媒体 bootstrap 游戏行,更新沿用既有游戏行。
|
||||
- D6 媒体只解析一次,同一批 objectKey 同时用于游戏行 bootstrap 与版本冻结资料。
|
||||
- D7 客户端:AGC 一次调用 + 发布开始冻结版本号;网页新建生成并持久化 `localProjectId`、版本号固定 1,更新读作者中心 max+1 后冻结。
|
||||
- D7 客户端:AGC 一次调用 + 发布开始冻结版本号;网页新建生成并持久化 `projectKey`、版本号固定 1,更新读作者中心 max+1 后冻结。
|
||||
- D8 模块不在线、无历史数据 → 硬切,不保留旧路由/旧自增/旧 JSON 兼容分支。
|
||||
|
||||
## 3. 主规范(行为合同)
|
||||
@@ -36,9 +36,9 @@
|
||||
行为合同以玩法链路「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」为准;本计划只固定实现形状:
|
||||
|
||||
- 请求:`POST /api/game-distribution/versions`,`multipart/form-data`。`metadata` JSON:
|
||||
`{ gameId?, localProjectId?, versionNumber, priceMudPoints?, packageSha256, packageBytes, packageFileCount, packageEntryPath, gameMetadata }`;`gameMetadata` 字段同现有 `GameDistributionCreateGameRequest`(`title/summary/description?/category/tags?/coverObjectKey?/screenshots?/deviceSupport/inputModes/orientation`)。另有可选 `cover`、可重复 `screenshot` part,语义同 2026-10-06 媒体合同。
|
||||
`{ gameId?, projectKey?, versionNumber, priceMudPoints?, packageSha256, packageBytes, packageFileCount, packageEntryPath, gameMetadata }`;`gameMetadata` 字段同现有 `GameDistributionCreateGameRequest`(`title/summary/description?/category/tags?/coverObjectKey?/screenshots?/deviceSupport/inputModes/orientation`)。另有可选 `cover`、可重复 `screenshot` part,语义同 2026-10-06 媒体合同。
|
||||
- 响应:`{ game, version, replayed }`;`game` = 作者侧游戏投影(含 `publicationRevision`),`version` = 私有版本投影。
|
||||
- 幂等:服务端组 `anchor = localProjectId.trim() || gameId`、`idempotency_key = "{anchor}:v{versionNumber}"`;`request_digest` 覆盖规范化元数据(含 `gameMetadata`、沿用 objectKey)+ 新图字节 hash。同键同 digest 返回既有 `{game, version}`;同键不同 digest → `409 IDEMPOTENCY_CONFLICT`。
|
||||
- 幂等:服务端组 `anchor = projectKey.trim() || gameId`、`idempotency_key = "{anchor}:v{versionNumber}"`;`request_digest` 覆盖规范化元数据(含 `gameMetadata`、沿用 objectKey)+ 新图字节 hash。同键同 digest 返回既有 `{game, version}`;同键不同 digest → `409 IDEMPOTENCY_CONFLICT`。
|
||||
- 版本号:`versionNumber >= 1` 必填;不做 max 比较。
|
||||
|
||||
## 4. 里程碑
|
||||
@@ -48,7 +48,7 @@
|
||||
| M1 契约与共享类型 | `module-game-distribution` 领域规则 + `shared-contracts` + `packages/shared` | 无 | 领域单测、契约测试、`tsc` |
|
||||
| M2 SpacetimeDB | 合并 procedure 与事务、收据、删自增/同号替换 | M1 | `cargo test`、真实库定向、schema/绑定门禁 |
|
||||
| M3 api-server | 统一 handler、媒体一次解析、确定性 gameId、删旧路由 | M1、M2 | api-server 定向测试、`/healthz` smoke |
|
||||
| M4 客户端 | AGC 一次调用与冻结版本号;网页 `localProjectId` 与冻结版本号 | M3 | Rust 命令测试、AGC typecheck、web 单测 |
|
||||
| M4 客户端 | AGC 一次调用与冻结版本号;网页 `projectKey` 与冻结版本号 | M3 | Rust 命令测试、AGC typecheck、web 单测 |
|
||||
| M5 文档与记忆 | 实现证据回写、decision-log/pitfalls 收口 | M1–M4 | 文档索引/编码/diff |
|
||||
|
||||
## 5. 实现计划(小而深的模块拆分)
|
||||
@@ -60,7 +60,7 @@
|
||||
**Step 1(重构优先,无行为变化)领域规则收敛**
|
||||
- `server-rs/crates/module-game-distribution/src/domain.rs`:
|
||||
- `resolve_version_number(requested: u64) -> Result<u64, GameDistributionFieldError>`:删 `max_existing` 与 `None` 分支,只保留 `requested == 0` 拒绝。
|
||||
- 新增 `game_distribution_version_key(local_project_id: Option<&str>, game_id: &str, version_number: u64) -> String` 与 `derive_game_distribution_game_id(owner_user_id: &str, local_project_id: &str) -> String`(稳定摘要,`game_` 前缀、`[A-Za-z0-9_-]`)。深模块:小接口、纯函数、单测覆盖空值/Trim/长度/冲突。
|
||||
- 新增 `game_distribution_version_key(project_key: Option<&str>, game_id: &str, version_number: u64) -> String` 与 `derive_game_distribution_game_id(owner_user_id: &str, project_key: &str) -> String`(稳定摘要,`game_` 前缀、`[A-Za-z0-9_-]`)。深模块:小接口、纯函数、单测覆盖空值/Trim/长度/冲突。
|
||||
- 删除 `resolve_version_number` 的 `max_existing` 调用点(`spacetime-module` 的 `resolve_game_distribution_version_number`)。
|
||||
|
||||
**Step 2 shared-contracts**
|
||||
@@ -76,8 +76,8 @@
|
||||
**Step 1 合并 procedure**
|
||||
- `server-rs/crates/spacetime-module/src/game_distribution.rs`(或新 submodule `game_distribution_publish.rs`,只放发布相关 procedure/tx)新增 `publish_game_distribution_version_and_return`(procedure)→ `publish_game_distribution_version_tx`:
|
||||
1. 按 `idempotency_key` 读 `GameDistributionReceipt`;命中则校验 digest 并返回既有 `(game, version)`,`replayed = true`。
|
||||
2. 无 `gameId`:按派生 `game_id` / `local_project_id` 在 owner 索引内 get-or-create 游戏行,`gameMetadata` + `cover_object_key` + `screenshots_json` bootstrap;有 `gameId`:复用既有行,不改。
|
||||
3. 写入版本行(`metadata_json` 冻结资料、`local_project_id`),状态 `awaiting_upload`。
|
||||
2. 无 `gameId`:按派生 `game_id` / `project_key` 在 owner 索引内 get-or-create 游戏行,`gameMetadata` + `cover_object_key` + `screenshots_json` bootstrap;有 `gameId`:复用既有行,不改。
|
||||
3. 写入版本行(`metadata_json` 冻结资料、`project_key`),状态 `awaiting_upload`。
|
||||
4. 写收据,`idempotency_key = "{anchor}:v{versionNumber}"`,`outcome_game_id` + `outcome_version_id` 都落。
|
||||
- 删 `create_game_distribution_game_tx` 里的独立 `create_game` 收据路径与「同号 pending 取消替换」逻辑;`create_game_distribution_version_tx` 的收据重放分支被合并 procedure 取代。
|
||||
- `GameDistributionProcedureResult` 增加「game + version」复合结果变体,`spacetime-client` 同步映射。
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
**网页**
|
||||
- 新增 `src/components/game-distribution/gamePublishSubmission.ts`:把「定 key → 调 API → 写 draft → 传包」抽成深模块(注入 client、DraftStore、package uploader,返回结果/错误),`GamePublishPage.tsx` 只留 UI 与调用。
|
||||
- `gamePublishDraft.ts` 扩展:新建发布在**调用前**生成并持久化 `localProjectId` 与冻结 `versionNumber`(新建 = 1;更新 = 作者中心 max+1,读一次后固定),draft 增加对应字段。
|
||||
- `gamePublishDraft.ts` 扩展:新建发布在**调用前**生成并持久化 `projectKey` 与冻结 `versionNumber`(新建 = 1;更新 = 作者中心 max+1,读一次后固定),draft 增加对应字段。
|
||||
- `GamePublishPage.tsx`:新建/更新统一调 `publishGameVersion`,不再分别 `createGame` + `createGameVersion`;更新模式先读 `updateContext` 的 max。
|
||||
|
||||
### 5.5 提交切分
|
||||
@@ -130,7 +130,7 @@
|
||||
| AGC | `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell game_distribution`、AGC `typecheck` |
|
||||
| 网页 | `npx tsc --noEmit`(web 或对应脚本)、发布/草稿相关 vitest |
|
||||
| 通用 | `npm run check:encoding`、`git diff --check`、`node scripts/check-doc-index.mjs` |
|
||||
| 边界 | owner 归属、无 `gameId` 无 `localProjectId` 400、`versionNumber` 缺失/0 400、同键不同 digest 409、同键同 digest 重放不新建、更新不覆盖游戏行、媒体只上传一次(计数/objectKey 断言) |
|
||||
| 边界 | owner 归属、无 `gameId` 无 `projectKey` 400、`versionNumber` 缺失/0 400、同键不同 digest 409、同键同 digest 重放不新建、更新不覆盖游戏行、媒体只上传一次(计数/objectKey 断言) |
|
||||
|
||||
## 7. 风险与回滚
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
|
||||
## 目标
|
||||
|
||||
把「创建作品」与「创建首版」合并为单一路由 `POST /api/game-distribution/versions`,`versionNumber` 改为必填并由客户端冻结,服务端删除自增;版本身份与幂等键统一为 `(ownerUserId, localProjectId | gameId, versionNumber)`;首次发布只解析一次封面/截图字节;作品 get-or-create、建版本与幂等收据在单个 SpacetimeDB procedure 的一次事务内提交。
|
||||
把「创建作品」与「创建首版」合并为单一路由 `POST /api/game-distribution/versions`,`versionNumber` 改为必填并由客户端冻结,服务端删除自增;版本身份与幂等键统一为 `(ownerUserId, projectKey | gameId, versionNumber)`;首次发布只解析一次封面/截图字节;作品 get-or-create、建版本与幂等收据在单个 SpacetimeDB procedure 的一次事务内提交。
|
||||
|
||||
## 边界
|
||||
|
||||
- 含:统一端点与请求/响应 DTO;`versionNumber` 必填、删服务端自增;自然幂等键;`(owner, localProjectId)` 确定性派生 `gameId`;合并 procedure 与 `create_version` 收据;AGC native 合并为一次调用并冻结版本号;网页新建发布生成并持久化 `localProjectId`、版本号固定 1、更新读 max+1 后冻结;删旧两条路由与同号替换逻辑;实现中把大文件拆成小而深的模块。
|
||||
- 含:统一端点与请求/响应 DTO;`versionNumber` 必填、删服务端自增;自然幂等键;`(owner, projectKey)` 确定性派生 `gameId`;合并 procedure 与 `create_version` 收据;AGC native 合并为一次调用并冻结版本号;网页新建发布生成并持久化 `projectKey`、版本号固定 1、更新读 max+1 后冻结;删旧两条路由与同号替换逻辑;实现中把大文件拆成小而深的模块。
|
||||
- 不含:`PATCH /my-games/{gameId}`、发行包 raw/chunked 上传、审核、公开读、`publicationRevision` CAS、购买/送审/撤回等其它写动作的幂等口径、`/api/external/v1`、历史数据迁移(模块不在线)。
|
||||
|
||||
## 里程碑与验收标准
|
||||
@@ -25,7 +25,7 @@
|
||||
| M1 契约与共享类型 | shared-contracts / packages/shared 统一发布 DTO;`resolve_version_number` 必填;`derive_game_id` 纯函数 | 契约单测、`tsc`、生成绑定 | 无 |
|
||||
| M2 SpacetimeDB | 合并 procedure(get-or-create game + 建 version + 收据)一次事务;删自增与同号替换 | `cargo test`、真实库定向、`check:spacetime-schema`、`check:generated-bindings` | M1 |
|
||||
| M3 api-server | 统一 `POST /versions` handler(媒体解析一次、确定性 gameId、自然键收据、不读 `Idempotency-Key`);删旧两条路由 | api-server 定向测试、`dev:api-server` + `/healthz` smoke | M1、M2 |
|
||||
| M4 客户端 | AGC 合并一次调用 + 冻结版本号;网页接 `localProjectId`、冻结版本号 | Rust 命令测试、AGC typecheck、web 单测/typecheck | M3 |
|
||||
| M4 客户端 | AGC 合并一次调用 + 冻结版本号;网页接 `projectKey`、冻结版本号 | Rust 命令测试、AGC typecheck、web 单测/typecheck | M3 |
|
||||
| M5 文档与共享记忆 | 主规范回写已先行;补实现证据、decision-log/pitfalls 收口 | `check:doc-index`、`check:encoding`、`git diff --check` | M1–M4 |
|
||||
|
||||
## 依赖与门禁
|
||||
|
||||
@@ -5561,7 +5561,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- 恢复列表泄漏陷阱:项目相同不代表账本对当前账号可见。如果列表只按 project / phase 扫描,切 B 后会展示 A 的 operation,迟到回包还可能把已清空的列表重新写回。扫描必须持当前 session 租约并按完整 owner `userId + api origin` 过滤;未绑 / 不完整的远端账本隐藏,纯本地编辑保留。renderer 还要用 auth generation 使恢复 read epoch 失效并清空相关操作状态。
|
||||
- 追加审计回滚陷阱:Direct 恢复中 `asset.register` 是 append-only 审计,返回错误不能证明 append 未持久。若 file / manifest 已落盘且审计成功或结果未知,删文件或回滚 manifest 会创造“审计已存在、资源却消失”的第二种不一致,重试还可能复制审计或重新扣费。应保留 file + manifest + audit 现场并标记 `reconciliation-required`;后续 binding 失败也使用同一语义。
|
||||
- Runner 跨 GUI 陷阱:WebView 的 `authGeneration` 会随 GUI 进程重启从较低值重新开始,但 busy Runner 可能仍持有旧 GUI 的高 generation。只用 generation CAS 会把新账号安装误判为过期;只把 OS owner 锁当作授权,或保留独立 `platform.session.install/clear` 入口,又会让 Runner 在单次 IPC 丢失后继续使用旧账号。Runner 协议 v7 由 owner 锁创建随机 epoch,每次会话变更先推进 durable revision claim,并且只允许与 claim 完全匹配的 `runner.attach_gui_owner` 安装会话。Runner 要持续比对 claim,失配立即清空平台会话并拒绝 Runtime 请求;GUI 同步失败还要隔离或停止旧 Runner,不能只向前端报错。
|
||||
- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 localProjectId 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Runner 回归还要覆盖高 generation 旧 epoch 被低 generation 新 epoch 正确替换、迟到旧 epoch/revision attach 失败关闭、claim 改写或同步失败后旧 Runner 零 Runtime 请求。Developer Key fixture 不能替代平台账号隔离证据。
|
||||
- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 projectKey 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Runner 回归还要覆盖高 generation 旧 epoch 被低 generation 新 epoch 正确替换、迟到旧 epoch/revision attach 失败关闭、claim 改写或同步失败后旧 Runner 零 Runtime 请求。Developer Key fixture 不能替代平台账号隔离证据。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs`、`project/resource_editor.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`。
|
||||
|
||||
## Direct 美术工具不能把“包存在”当成“本次已生成”(2026-08-23)
|
||||
@@ -6583,5 +6583,5 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
|
||||
- **现象**:首次发布新游戏时,封面/截图的同一批字节先在建作品(`POST /games`)时上传一遍,紧接着建首版(`POST /games/{gameId}/versions`)又上传一遍,服务端两条写路径各自 `upload_media_image` 生成新的随机 objectKey,OSS 与带宽都白花一份。
|
||||
- **根因**:游戏行(`cover_object_key` / `screenshots_json`)与版本冻结资料都存媒体,`create_game` 与 `create_version` 是两次独立 multipart 写请求;AGC `publish_local_project_game` 与网页 `GamePublishPage` 都把同一份 `media` 传了两次。
|
||||
- **现行口径**:合并为 `POST /api/game-distribution/versions`,无 `gameId` 时按 `(owner, localProjectId)` get-or-create 作品并确定性派生 `gameId`,一次解析的同一批 objectKey 同时用于作品行 bootstrap 与版本冻结资料。
|
||||
- **现行口径**:合并为 `POST /api/game-distribution/versions`,无 `gameId` 时按 `(owner, projectKey)` get-or-create 作品并确定性派生 `gameId`,一次解析的同一批 objectKey 同时用于作品行 bootstrap 与版本冻结资料。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs`、`src/components/game-distribution/GamePublishPage.tsx`、`server-rs/crates/api-server/src/modules/game_distribution.rs`。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- 适配与错误:未适配弹「让陶泥儿帮我调通」;adapt/fill/repair 三类指令与小红书一致,失败现场原样转发 code agent;「帮我填」只写元数据,不改打包适配。
|
||||
- 线上值:草稿是唯一真相;读命令额外返回只读线上值(live game 行),不自动 seed/回填;面板把线上值**逐字段内联**挂在对应输入框下方(比当前值小一行),首次发布线上为空时不铺空对照;读取自动进行、失败按固定间隔静默自动重试,界面不提供手动「重新读取」入口;文案字段与图片均可「沿用线上值」,图片路径为空表示沿用(发布时带线上 objectKey)。
|
||||
- 退役收口:`exports/` 目录、历史试玩包链、`prepare/upload/list` 命令、生成任务产物声明、prompts、run-trace 白名单与排除列表都不再存在;AI 资料建议与封面生成、`useGameDistributionPublishForm`/`GameDistributionPublishFormView`/`GamePublishBlockedDialog` 一并删除,保留发布进度对话框。
|
||||
- 发布媒体:`POST /api/game-distribution/versions`(2026-10-07 起取代 `POST /games` 与 `POST .../versions`)、`PATCH .../my-games/{id}` 收 `multipart/form-data`(元数据文本 + 封面/截图原始二进制 + 沿用 objectKey);整包仍走 raw/chunked。AGC `publish_local_project_game` 的首次发布与更新合并为一次 `POST /versions` 调用(作品按 `localProjectId` get-or-create,媒体只解析一次),版本号在发布开始时冻结并在该次意图内复用;该路由幂等键 = `(owner, localProjectId | gameId, versionNumber)`,不要求 `Idempotency-Key`。AGC 发布命令所需的本地图路径由发布面板(UI 表单)随命令传入,**不再回读 `.export/taonier.json` 草稿注册表**:文字字段与本地媒体路径都取 `packageAll()` 落定的同一份权威表单,面板看到的值就是提交的值。图片落项目快照桶 `agc/project-snapshots/v1/game-distribution/media/...`,不建 `asset_object`;公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定 = 已发布且 active 的 game 且 objectKey 命中封面/截图,不经素材库 ACL。作者预览自己作品的线上封面/截图走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(需 bearer,判定 = 作品归属 + objectKey 落在该作品媒体命名空间),未发布 / 待审 / 被驳回也能换签;AGC `resolve_preview_url` 已切到该路由,不再走素材库 `/api/assets/read-url`。硬切,无旧 JSON/assetId 兼容。
|
||||
- 发布媒体:`POST /api/game-distribution/versions`(2026-10-07 起取代 `POST /games` 与 `POST .../versions`)、`PATCH .../my-games/{id}` 收 `multipart/form-data`(元数据文本 + 封面/截图原始二进制 + 沿用 objectKey);整包仍走 raw/chunked。AGC `publish_local_project_game` 的首次发布与更新合并为一次 `POST /versions` 调用(作品按 `projectKey` get-or-create,媒体只解析一次),版本号在发布开始时冻结并在该次意图内复用;该路由幂等键 = `(owner, projectKey | gameId, versionNumber)`,不要求 `Idempotency-Key`。AGC 发布命令所需的本地图路径由发布面板(UI 表单)随命令传入,**不再回读 `.export/taonier.json` 草稿注册表**:文字字段与本地媒体路径都取 `packageAll()` 落定的同一份权威表单,面板看到的值就是提交的值。图片落项目快照桶 `agc/project-snapshots/v1/game-distribution/media/...`,不建 `asset_object`;公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定 = 已发布且 active 的 game 且 objectKey 命中封面/截图,不经素材库 ACL。作者预览自己作品的线上封面/截图走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(需 bearer,判定 = 作品归属 + objectKey 落在该作品媒体命名空间),未发布 / 待审 / 被驳回也能换签;AGC `resolve_preview_url` 已切到该路由,不再走素材库 `/api/assets/read-url`。硬切,无旧 JSON/assetId 兼容。
|
||||
- 验收:草稿/打包/失败转发/沿用线上值与发布直传的定向测试;真实 vite 工程首轮适配与一次真实发布 smoke。
|
||||
|
||||
## 2026-10-05 导出产物面板与小红书小工具导出
|
||||
|
||||
@@ -498,7 +498,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
|
||||
- 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。
|
||||
- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(objectKey 字符串数组)。发布写路径(创建游戏 / 创建版本 / 编辑资料)收 `multipart/form-data`:新图由服务端直写项目快照桶 `agc/project-snapshots/v1/game-distribution/media/<gameId>/{cover|screenshot}-<uuid>.<ext>` 并只回 objectKey,不建 `asset_object`;沿用槽位带该作品当前媒体的 objectKey,不命中即 400,附图校验 `image/*`、张数与体积上限。 发布写入受灰度配置键 `game-distribution:publish` 约束:**灰度默认关闭**,未配置或 `enabled=false` 时写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,`enabled=true` 且白名单/比例/标签命中才放行,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published`、存在有效 `active_version_id` 且未软删除的游戏,其 `cover_object_key` / `screenshots_json` 中的 objectKey 才在 `GET /api/game-distribution/media/read-url`(需要同源字节时用 `.../media/read-bytes`)上按游戏分发域内判定获得匿名读授权,不经素材库 ACL;作者预览自己作品(未发布 / 待审 / 被驳回)走 owner 作用域 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(及 `.../media/read-bytes`,需 bearer),判定 = 该 `gameId` 属于当前登录主体且 objectKey 落在该作品媒体命名空间 `agc/project-snapshots/v1/game-distribution/media/<gameId>/`(file 段非空)内,不再要求命中游戏行当前 `cover_object_key` / `screenshots_json`,与公开读共用同一把签名器;软删作品在该读路径上一律 404。
|
||||
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。
|
||||
- 复用规则:末尾可空列 `project_key` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `project_key` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。已软删除的游戏不参与复用:删除后重新发布同一本地项目应得到新的游戏身份。
|
||||
- 软删除:游戏行末尾追加可空 `deleted_at`(2026-10-01)。非空表示作者已删除该作品:`delete_game_distribution_game_and_return` 只写该时间戳并把公开投影下线(可见性回到 `unpublished`、撤销当前公开版本、递增 `publication_revision`),版本行、发行包与其冻结资料一律不改写。软删行不进入作者列表(`list_owner_game_distribution_games_and_return`)、公开目录(`list_public_game_distribution_games_and_return`)、公开详情(`get_public_game_distribution_game_and_return`)、公开媒体读取判定(`get_game_distribution_media_read_access_and_return`)与审核队列;后台默认视图同样排除,只有显式 `status=deleted` 才会读到。作者侧版本回读对软删作品返回空(404),因此上传、确认与送审入口一并关闭。
|
||||
- 资料编辑:`update_game_distribution_game_metadata_and_return` 覆盖游戏行上的展示字段(标题/简介/详介/分类/标签/封面/截图/设备/输入模式/方向)并立即生效,要求 `expected_publication_revision` CAS;版本行与冻结资料不变,下一次审核通过仍会用新版本的冻结资料覆盖游戏行。**资料编辑不得直接改公开价格**:调价必须走新版本审核。
|
||||
- 买断制定价(2026-10-05):游戏行末尾追加 `price_mud_points: u64` 并设置 `#[default(0u64)]`;`0` 表示免费,上限 `1_000_000`(复用 `module-game-distribution::normalize_game_price_mud_points` 校验)。价格是版本冻结资料的一部分:作者在 `GameDistributionCreateVersionRequest.priceMudPoints` 提交,写入版本冻结 `metadata_json.priceMudPoints`,只有 `approve_game_distribution_version_and_return` 通过审核时才随资料整体生效到本行;未通过审核或资料编辑都不会改变当前公开价格。公开投影(`get_public_game_distribution_game_and_return` 等)在游戏快照上带出 `priceMudPoints`。
|
||||
@@ -508,7 +508,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
|
||||
- 发布媒体直传(2026-10-06):游戏行只存 `cover_object_key` 与 objectKey 数组 `screenshots_json`;版本冻结资料 `GameDistributionFrozenMetadata` 用 `coverObjectKey`(`String`)与 `screenshots`(`Vec<String>`,objectKey),`GameDistributionFrozenScreenshot` 不存在。请求 DTO(create game / create version / update metadata)用 `coverObjectKey` 与 `screenshots: (string|null)[]`:`string` 槽位沿用该作品当前媒体的 objectKey,`null` 槽位按序消费可重复的 `screenshot` 二进制 part,可选 `cover` part 出现时覆盖 `metadata.coverObjectKey`。公开读走 `GET /api/game-distribution/media/read-url`(及 `read-bytes`),判定为「已发布且 active 的 game + objectKey 命中 `cover_object_key` / `screenshots_json`」,不经素材库 ACL;owner 作用域读路由 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(`read-bytes` 同理,需 bearer)按「作品归属 + objectKey 落在该作品媒体命名空间」放行(不要求命中游戏行当前媒体,待审 / 被驳回 / 历史版本以及首版发布上传得到的 key 都始终对作者可见),供作者预览未发布 / 被驳回作品的封面与截图(软删作品该路径 404);公开读授权 procedure 只按 objectKey 查询(`get_game_distribution_media_read_access_and_return`),owner 读直接复用 `get_game_distribution_game`(带 `owner_user_id`),不新增 procedure。`request_digest` 覆盖规范化元数据 + 图片字节 hash + 沿用 objectKey,覆盖 create / version / update 三条写路径。硬切、无历史数据;schema 变更仍须同步 `migration.rs`、表目录与生成绑定并运行 `npm run check:spacetime-schema`。
|
||||
|
||||
- 统一发布接口与版本号自然幂等(2026-10-07):`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`;`metadata` 增加必填 `versionNumber`,删掉 `resolve_game_distribution_version_number` 的 `None => max_existing + 1` 自增分支。无 `gameId` 时以 `localProjectId` 为身份锚,`gameId` 由 `(ownerUserId, localProjectId)` 确定性派生;新 procedure 在一次 `try_with_tx` 内 get-or-create 游戏行、写入版本行并落一张 `create_version` 收据(`idempotency_key = "{anchor}:v{versionNumber}"`,同键同摘要重放、不同摘要 409,不要求 `Idempotency-Key`)。删掉「同号 pending 被新提交取消替换」逻辑;`request_digest` 口径不变;媒体只解析一次,同一批 objectKey 同时用于游戏行 bootstrap 与版本冻结资料。消费方:AGC `publish_local_project_game` 合并为一次调用;网页 `GamePublishPage` 新建固定 `versionNumber=1` 并持久化生成的 `localProjectId`,更新读作者中心 max+1 后冻结。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。
|
||||
- 统一发布接口与版本号自然幂等(2026-10-07):`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`;`metadata` 增加必填 `versionNumber`,删掉 `resolve_game_distribution_version_number` 的 `None => max_existing + 1` 自增分支。无 `gameId` 时以 `projectKey` 为身份锚,`gameId` 由 `(ownerUserId, projectKey)` 确定性派生;新 procedure 在一次 `try_with_tx` 内 get-or-create 游戏行、写入版本行并落一张 `create_version` 收据(`idempotency_key = "{anchor}:v{versionNumber}"`,同键同摘要重放、不同摘要 409,不要求 `Idempotency-Key`)。删掉「同号 pending 被新提交取消替换」逻辑;`request_digest` 口径不变;媒体只解析一次,同一批 objectKey 同时用于游戏行 bootstrap 与版本冻结资料。消费方:AGC `publish_local_project_game` 合并为一次调用;网页 `GamePublishPage` 新建固定 `versionNumber=1` 并持久化生成的 `projectKey`,更新读作者中心 max+1 后冻结。详见玩法链路的「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。
|
||||
|
||||
### `game_distribution_review`
|
||||
|
||||
|
||||
@@ -77,12 +77,12 @@
|
||||
| 适用边界 | game-distribution 作者发布写路径(建作品 + 建版本合一)与版本号幂等语义;媒体上传/读取沿用「游戏分发发布媒体直传合同(2026-10-06)」;发行包上传、审核、公开读取不变 |
|
||||
| 取代 | 本文件「AGC 游戏分发与在线游玩合同」中「平台侧 versionNumber 允许回退/重复提交、服务端不传时自增」「同一 gameId 同一 versionNumber 再次提交创建新 versionId」两条,以及 `POST /games`、`POST /games/{gameId}/versions` 两条路由 |
|
||||
|
||||
- **单一路由**:`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`。带 `gameId` 表示给既有作品发新版本;不带时用 `localProjectId` 解析/创建作品身份。`metadata` 文本 part 形状:`{ gameId?, localProjectId?, versionNumber, priceMudPoints?, packageSha256, packageBytes, packageFileCount, packageEntryPath, gameMetadata }`,另有可选 `cover` 与可重复 `screenshot` part(媒体语义同 2026-10-06 合同)。
|
||||
- **身份锚**:`gameId` 存在时以其为锚;否则 `localProjectId` 必填(trim 非空)并作为锚。首次发布的作品行由本次 `gameMetadata` + 解析后的媒体 objectKey bootstrap(作者列表与线上值立即可见),审核通过后再由版本冻结资料覆盖。
|
||||
- **作品 ID 派生**:无 `gameId` 时,`gameId` 由 `(ownerUserId, localProjectId)` 确定性派生(`game_` + 稳定摘要),不再随机生成。这样媒体 objectKey 前缀在事务前就稳定,重试/并发不会落到不同命名空间。
|
||||
- **单一路由**:`POST /api/game-distribution/versions` 取代 `POST /games` 与 `POST /games/{gameId}/versions`。带 `gameId` 表示给既有作品发新版本;不带时用 `projectKey` 解析/创建作品身份。`metadata` 文本 part 形状:`{ gameId?, projectKey?, versionNumber, priceMudPoints?, packageSha256, packageBytes, packageFileCount, packageEntryPath, gameMetadata }`,另有可选 `cover` 与可重复 `screenshot` part(媒体语义同 2026-10-06 合同)。
|
||||
- **身份锚**:`gameId` 存在时以其为锚;否则 `projectKey` 必填(trim 非空)并作为锚。首次发布的作品行由本次 `gameMetadata` + 解析后的媒体 objectKey bootstrap(作者列表与线上值立即可见),审核通过后再由版本冻结资料覆盖。
|
||||
- **作品 ID 派生**:无 `gameId` 时,`gameId` 由 `(ownerUserId, projectKey)` 确定性派生(`game_` + 稳定摘要),不再随机生成。这样媒体 objectKey 前缀在事务前就稳定,重试/并发不会落到不同命名空间。
|
||||
- **版本号必填**:`versionNumber` 是客户端提交的正整数(`>= 1`),不再有「服务端不传取 max+1」的兼容分支;服务端不比较最大值,也不因版本号小于已有最大值而拒绝。
|
||||
- **自然幂等键**:`(ownerUserId, anchor, versionNumber)` 就是版本身份与幂等键,服务端据此记账(沿用 `action = create_version`,`idempotency_key = "{anchor}:v{versionNumber}"`),不要求也不读取 `Idempotency-Key`。同键同 `request_digest` 返回既有 `{ game, version }`(`replayed: true`);同键不同摘要返回 `409 IDEMPOTENCY_CONFLICT`。
|
||||
- **客户端冻结**:一次发布意图(含重试、续传、响应丢失后重发)必须复用同一 `versionNumber`。AGC 取 `localProjectId = manifest.projectId`,版本号取面板发布开始时读到的平台 max+1 并在该次意图内固定;网页新建发布时生成并持久化一个 `localProjectId`、`versionNumber` 固定为 `1`,更新既有作品时先读作者中心 max 再 +1 并在该次意图内固定。主动发起新的一次发布才重新取号。
|
||||
- **客户端冻结**:一次发布意图(含重试、续传、响应丢失后重发)必须复用同一 `versionNumber`。AGC 取 `projectKey = manifest.projectId`,版本号取面板发布开始时读到的平台 max+1 并在该次意图内固定;网页新建发布时生成并持久化一个 `projectKey`、`versionNumber` 固定为 `1`,更新既有作品时先读作者中心 max 再 +1 并在该次意图内固定。主动发起新的一次发布才重新取号。
|
||||
- **原子性**:作品 get-or-create、版本行写入与幂等收据在单个 SpacetimeDB procedure 的一次 `try_with_tx` 内提交;移除「同号 pending 被新提交取消替换」与「同号创建新 versionId」。
|
||||
- **媒体解析一次**:合并后同一次发布只解析(上传)一次封面/截图字节,同一批 objectKey 同时用于作品行 bootstrap 与版本冻结资料;不再出现建作品与建首版各上传一次。
|
||||
- **响应**:`{ game, version, replayed }`;`game` 为作者侧游戏投影(含 `publicationRevision`),`version` 为私有版本投影。创建后发行包仍走既有 `PUT /versions/{versionId}/package` 链路。
|
||||
@@ -132,10 +132,10 @@
|
||||
### 身份、状态、审核与更新
|
||||
|
||||
- `gameId` 是服务端分配的稳定游戏身份;`ownerUserId` 只从当前认证主体派生。AGC 本地 `projectId` 只用于关联提示,不能证明云端游戏所有权。AGC 项目清单保存平台绑定(`gameId`、最近 `versionId`、`publicationRevision`、状态)与 AGC 工程内部版本记录 `versions[]`;AGC 发布面板展示的「项目版本」不是清单里可编辑的标量,而是由 `versions[]` 派生的只读标签(见下条)。网页上传和 AGC 发布使用相同游戏、版本与上传记录,不建立两套发行系统。
|
||||
- 同一作者用相同 `localProjectId` 再次发布时复用既有 `gameId`。一次具体上传/资料/审核快照由不可变 `versionId` 标识;同一个用户版本标签可以有多个提交实例,旧公开实例不原地修改。AGC 如果清单缺少发布记录,首次更新前按作者作品回读和 `localProjectId` 做一次性恢复。
|
||||
- 同一作者用相同 `projectKey` 再次发布时复用既有 `gameId`。一次具体上传/资料/审核快照由不可变 `versionId` 标识;同一个用户版本标签可以有多个提交实例,旧公开实例不原地修改。AGC 如果清单缺少发布记录,首次更新前按作者作品回读和 `projectKey` 做一次性恢复。
|
||||
- AGC 发布面板的「项目版本」是 AGC 工程内部版本序数,唯一来源是 AGC 项目清单的 `versions[]`(AGC 工程内部版本记录,即资源总览「项目版本」栏目里的版本卡):标签等于当前存活的内部版本条数(即最新内部版本卡的「版本 N」),`versions` 为空时取 1。该标签**只读**,用户不能编辑,且**不由平台侧任何字段推导**——不得读取、回填或覆盖平台 `version_number`,也不得读取 `publicationRevision`。每次智能体修订追加一条内部版本,标签随之推进。
|
||||
- 平台侧 `versionNumber` 是客户端提交的正整数版本标签(`>= 1`),**必填且在一次发布意图内冻结**;服务端不再自增、也不比较最大值。AGC 提交发布开始时读到的平台作者视图 max+1 并在该次意图内固定;网页新建发布提交 `1`,更新既有作品提交作者中心 max+1 后固定。该值与 AGC 内部版本标签(上条)互不推导,语义与自然幂等键见「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」。AGC 项目清单里的历史字段 `projectVersion` 属遗留位:仅为兼容旧清单可解析而保留,任何读写路径都不得再把它当作发行版本来源,AGC 也不再提供修改它的入口。
|
||||
- 同一作品(`owner` + `localProjectId` 或 `gameId`)与同一 `versionNumber` 只对应一个版本实例:同键同摘要返回既有 `versionId`(重放),同键不同摘要返回 `409 IDEMPOTENCY_CONFLICT`;不再有「同号创建新 `versionId`」或「同号 pending 被取消替换」。旧的 `published`/`activeVersionId` 继续服务,新提交审核通过后才切换 `activeVersionId`。`publicationRevision` 仍然严格 CAS 递增,但不限制用户版本号。
|
||||
- 同一作品(`owner` + `projectKey` 或 `gameId`)与同一 `versionNumber` 只对应一个版本实例:同键同摘要返回既有 `versionId`(重放),同键不同摘要返回 `409 IDEMPOTENCY_CONFLICT`;不再有「同号创建新 `versionId`」或「同号 pending 被取消替换」。旧的 `published`/`activeVersionId` 继续服务,新提交审核通过后才切换 `activeVersionId`。`publicationRevision` 仍然严格 CAS 递增,但不限制用户版本号。
|
||||
- 游戏单独保存 `publicationRevision`、`activeVersionId` 和可见性 `unpublished | published | suspended`;正式可见性由服务端持久化事实决定。未通过审核时 `activeVersionId` 为空;`suspended` 是管理员安全下架,作者不能自行解除。
|
||||
- 版本状态为 `awaiting_upload → uploaded → validating → pending_review → published`。上传确定失败进入 `upload_failed`,验证失败进入 `validation_failed`,人工拒绝进入 `rejected`;尚未公开提交可以撤回为 `cancelled`,已公开实例可撤销为 `revoked`。同一用户版本标签的新内容必须创建新提交实例。
|
||||
- 首版建议人工审核。审核员检查游戏资料、真实桌面运行、声明移动适配、内容与外部请求被阻断的行为;自动包校验通过只进入 `pending_review`,不自动公开。审核记录保存审核者、目标 `versionId`、结论、理由和时间。后台只授权现有管理员身份,不让普通作者调用审核动作。
|
||||
@@ -146,11 +146,11 @@
|
||||
|
||||
### 幂等、并发与恢复
|
||||
|
||||
- 幂等分两种口径:创建/发布版本用自然键 `(owner, localProjectId | gameId, versionNumber)`,不要求 `Idempotency-Key`;提交、审核、撤销、下架、购买、软删等其他写动作仍携带 `Idempotency-Key`,服务端以认证主体、动作和 key 保存请求摘要与结果,同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`,至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。
|
||||
- 幂等分两种口径:创建/发布版本用自然键 `(owner, projectKey | gameId, versionNumber)`,不要求 `Idempotency-Key`;提交、审核、撤销、下架、购买、软删等其他写动作仍携带 `Idempotency-Key`,服务端以认证主体、动作和 key 保存请求摘要与结果,同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`,至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。
|
||||
- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。首版整包重传,不宣称支持分片断点续传。
|
||||
- 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端在一次发布意图内持久保存操作 ID、目标游戏/版本号与 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时,如果版本号也相同则命中同一版本(不新开);要开新版本必须显式换 `versionNumber`,不能仅按包摘要吞掉或新建发布意图。
|
||||
- 公开版本切换、作者下架和管理员审核必须带 `expectedPublicationRevision`,在持久化事务中比较并推进。并发变化返回 `409 PUBLICATION_CONFLICT`;旧送审版本不能在用户已发布更新或下架之后静默覆盖状态。审核员重新查看现状后才能提交新的明确动作。
|
||||
- 网络中断或响应丢失后,先用同一 `localProjectId`/`gameId` + `versionNumber` 重发(服务端按自然键重放)或先查询原版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。
|
||||
- 网络中断或响应丢失后,先用同一 `projectKey`/`gameId` + `versionNumber` 重发(服务端按自然键重放)或先查询原版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。
|
||||
|
||||
### HTTP 与持久化边界(拟定)
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
| `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 /versions` | 登录作者 | **本次改造**:统一发布入口。`metadata` JSON 带 `gameId?` / `localProjectId?` / `versionNumber`(必填)/ 包声明 / `gameMetadata`,媒体走 `cover`/`screenshot` part;无 `gameId` 时按 `(owner, localProjectId)` get-or-create 作品并确定性派生 gameId,`gameMetadata` + 媒体 bootstrap 作品行。幂等键 = `(owner, localProjectId \| gameId, versionNumber)`,不要求 `Idempotency-Key`;返回 `{ game, version, replayed }`。详见「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」 |
|
||||
| `POST /versions` | 登录作者 | **本次改造**:统一发布入口。`metadata` JSON 带 `gameId?` / `projectKey?` / `versionNumber`(必填)/ 包声明 / `gameMetadata`,媒体走 `cover`/`screenshot` part;无 `gameId` 时按 `(owner, projectKey)` get-or-create 作品并确定性派生 gameId,`gameMetadata` + 媒体 bootstrap 作品行。幂等键 = `(owner, projectKey \| gameId, versionNumber)`,不要求 `Idempotency-Key`;返回 `{ game, version, replayed }`。详见「游戏分发统一发布接口与版本号自然幂等合同(2026-10-07)」 |
|
||||
| `PUT /versions/{versionId}/package` | owner | **已实现**:接收真实 ZIP、重算摘要与文件清单并写入私有对象;不执行游戏代码 |
|
||||
| `GET /versions/{versionId}` | owner/管理员 | **已实现**:回读状态、错误代码、审核结论与服务端 `recoveryAction`;管理员走 `/admin/api/game-distribution/versions/{versionId}`;未知版本与别人的版本都按不可见返回 404 |
|
||||
| `POST /versions/{versionId}/submit` | owner | **已实现**:只有已确认完整包可提交,返回 202 并进入 `pending_review` |
|
||||
|
||||
@@ -151,7 +151,7 @@ export type GameDistributionListResponse = {
|
||||
|
||||
export type GameDistributionCreateGameRequest = {
|
||||
/** 发布方本地项目标识;同一作者重复发布会复用既有 gameId。 */
|
||||
localProjectId?: string | null;
|
||||
projectKey?: string | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
@@ -167,15 +167,14 @@ export type GameDistributionCreateGameRequest = {
|
||||
};
|
||||
|
||||
export type GameDistributionCreateVersionRequest = {
|
||||
localProjectId?: string | null;
|
||||
projectKey?: string | null;
|
||||
/**
|
||||
* 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
|
||||
* 用户可见的正整数版本标签,必填且由客户端在一次发布意图内冻结。
|
||||
*
|
||||
* 传入时只要求 `>= 1`:同一 `gameId` 的同一个版本号可以反复提交,每次提交生成新的
|
||||
* `versionId`,允许重复标签与回退到更小的版本号,不与已有最大值比较。
|
||||
* 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。
|
||||
* 版本身份由 `(owner, projectKey | gameId, versionNumber)` 决定:同一作品同一版本号即同一版本,
|
||||
* 同摘要重放、不同摘要 409。服务端不再自增,也不与已有最大值比较。
|
||||
*/
|
||||
versionNumber?: number | null;
|
||||
versionNumber: number;
|
||||
/**
|
||||
* 作者提交的买断价(整数泥点,`0` 表示免费)。
|
||||
*
|
||||
|
||||
@@ -1691,7 +1691,7 @@ mod tests {
|
||||
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
|
||||
"inputModes": ["keyboard"],
|
||||
"orientation": "responsive",
|
||||
"localProjectId": "publish-switch-test"
|
||||
"projectKey": "publish-switch-test"
|
||||
})
|
||||
.to_string();
|
||||
let app = build_router(state.clone());
|
||||
|
||||
@@ -1743,12 +1743,12 @@ async fn load_owner_game_versions(
|
||||
|
||||
/// 把资料编辑请求映射成创建请求,以复用同一套资料校验与素材归属解析。
|
||||
///
|
||||
/// `localProjectId` 只属于创建语义,编辑资料不参与游戏身份复用,因此固定为空。
|
||||
/// `projectKey` 只属于创建语义,编辑资料不参与游戏身份复用,因此固定为空。
|
||||
fn game_metadata_update_as_create_request(
|
||||
payload: &GameDistributionUpdateGameMetadataRequest,
|
||||
) -> GameDistributionCreateGameRequest {
|
||||
GameDistributionCreateGameRequest {
|
||||
local_project_id: None,
|
||||
project_key: None,
|
||||
title: payload.title.clone(),
|
||||
summary: payload.summary.clone(),
|
||||
description: payload.description.clone(),
|
||||
@@ -2004,9 +2004,9 @@ async fn create_game(
|
||||
// 幂等摘要只覆盖请求内容(元数据 + 图片字节),不含服务端生成的 objectKey,
|
||||
// 这样重放同一个 Idempotency-Key 时摘要稳定、命中既有结果。
|
||||
let request_digest = publish_request_digest(&payload, cover.as_ref(), screenshots.as_slice())?;
|
||||
// 先在接触对象存储前完成所有会让用户输入失败的转换:normalize_local_project_id 的 `?`
|
||||
// 先在接触对象存储前完成所有会让用户输入失败的转换:normalize_project_key 的 `?`
|
||||
// 如果留到 create_game_distribution_game 实参里求值,上传完的封面/截图会因提前返回而成为孤儿。
|
||||
let local_project_id = normalize_local_project_id(payload.local_project_id.as_deref())?;
|
||||
let project_key = normalize_project_key(payload.project_key.as_deref())?;
|
||||
let (cover_object_key, resolved_screenshots, uploaded_media) = resolve_publish_media(
|
||||
&state,
|
||||
game_id.as_str(),
|
||||
@@ -2048,7 +2048,7 @@ async fn create_game(
|
||||
idempotency_key,
|
||||
request_digest,
|
||||
now_micros: now,
|
||||
local_project_id,
|
||||
project_key,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
@@ -2095,7 +2095,7 @@ async fn create_version(
|
||||
)?;
|
||||
// 与 create_game 同理:用户输入相关的失败必须在 resolve_publish_media 上传之前发生,
|
||||
// 否则 `?` 会在 cleanup_uncommitted_publish_media 之前返回,留下孤儿媒体对象。
|
||||
let local_project_id = normalize_local_project_id(payload.local_project_id.as_deref())?;
|
||||
let project_key = normalize_project_key(payload.project_key.as_deref())?;
|
||||
let (cover_object_key, resolved_screenshots, uploaded_media) = resolve_publish_media(
|
||||
&state,
|
||||
game_id.as_str(),
|
||||
@@ -2129,7 +2129,7 @@ async fn create_version(
|
||||
package_bytes: payload.package_bytes,
|
||||
package_file_count: payload.package_file_count,
|
||||
package_entry_path: payload.package_entry_path,
|
||||
local_project_id,
|
||||
project_key,
|
||||
idempotency_key,
|
||||
request_digest,
|
||||
now_micros: now,
|
||||
@@ -3661,12 +3661,12 @@ async fn record_upload_failure(
|
||||
}
|
||||
|
||||
/// 本地项目标识只用于同一作者复用游戏身份;它必须是短标识,不能充当路径或所有权凭证。
|
||||
fn normalize_local_project_id(value: Option<&str>) -> Result<Option<String>, AppError> {
|
||||
fn normalize_project_key(value: Option<&str>) -> Result<Option<String>, AppError> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.chars().count() > 128 {
|
||||
return Err(bad_request("localProjectId 不能超过 128 个字符"));
|
||||
return Err(bad_request("projectKey 不能超过 128 个字符"));
|
||||
}
|
||||
if value.chars().any(|character| character.is_control())
|
||||
|| value.contains('/')
|
||||
@@ -3675,7 +3675,7 @@ fn normalize_local_project_id(value: Option<&str>) -> Result<Option<String>, App
|
||||
|| value == ".."
|
||||
{
|
||||
return Err(bad_request(
|
||||
"localProjectId 只能是短标识,不能包含路径分隔符",
|
||||
"projectKey 只能是短标识,不能包含路径分隔符",
|
||||
));
|
||||
}
|
||||
Ok(Some(value.to_string()))
|
||||
@@ -3945,10 +3945,10 @@ fn owner_game_entry_payload(entry: GameDistributionOwnerGameRecord) -> Value {
|
||||
return payload;
|
||||
};
|
||||
object.insert(
|
||||
"localProjectId".to_string(),
|
||||
"projectKey".to_string(),
|
||||
entry
|
||||
.game
|
||||
.local_project_id
|
||||
.project_key
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
@@ -4856,7 +4856,7 @@ mod tests {
|
||||
|
||||
fn metadata() -> GameDistributionCreateGameRequest {
|
||||
GameDistributionCreateGameRequest {
|
||||
local_project_id: None,
|
||||
project_key: None,
|
||||
title: "测试游戏".to_string(),
|
||||
screenshots: Vec::new(),
|
||||
summary: "用于验证发行合同".to_string(),
|
||||
@@ -5028,7 +5028,7 @@ mod tests {
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
local_project_id: None,
|
||||
project_key: None,
|
||||
cover_object_key: Some("generated/game-cover.png".to_string()),
|
||||
screenshots_json: Some(r#"["generated/shot-1.png"]"#.to_string()),
|
||||
price_mud_points: 0,
|
||||
@@ -5092,7 +5092,7 @@ mod tests {
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
local_project_id: None,
|
||||
project_key: None,
|
||||
cover_object_key: Some("generated/game-cover.png".to_string()),
|
||||
screenshots_json: None,
|
||||
price_mud_points: 0,
|
||||
@@ -5181,7 +5181,7 @@ mod tests {
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
local_project_id: None,
|
||||
project_key: None,
|
||||
cover_object_key: None,
|
||||
screenshots_json: None,
|
||||
price_mud_points: 0,
|
||||
@@ -5572,19 +5572,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_id_is_a_short_identifier_or_empty() {
|
||||
assert_eq!(normalize_local_project_id(None).expect("空值"), None);
|
||||
fn project_key_is_a_short_identifier_or_empty() {
|
||||
assert_eq!(normalize_project_key(None).expect("空值"), None);
|
||||
assert_eq!(
|
||||
normalize_local_project_id(Some(" ")).expect("空白按空处理"),
|
||||
normalize_project_key(Some(" ")).expect("空白按空处理"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_local_project_id(Some(" proj-1 ")).expect("合法标识"),
|
||||
normalize_project_key(Some(" proj-1 ")).expect("合法标识"),
|
||||
Some("proj-1".to_string())
|
||||
);
|
||||
for invalid in ["../escape", "a/b", "a\\b", ".", ".."] {
|
||||
assert_eq!(
|
||||
normalize_local_project_id(Some(invalid))
|
||||
normalize_project_key(Some(invalid))
|
||||
.expect_err("非法本地项目标识应被拒绝")
|
||||
.status_code(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -5592,7 +5592,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
normalize_local_project_id(Some(&"x".repeat(129)))
|
||||
normalize_project_key(Some(&"x".repeat(129)))
|
||||
.expect_err("超长标识应被拒绝")
|
||||
.status_code(),
|
||||
StatusCode::BAD_REQUEST
|
||||
@@ -6249,7 +6249,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_update_request_reuses_create_validation_and_drops_local_project_id() {
|
||||
fn metadata_update_request_reuses_create_validation_and_drops_project_key() {
|
||||
let update = GameDistributionUpdateGameMetadataRequest {
|
||||
expected_publication_revision: 4,
|
||||
title: "新标题".to_string(),
|
||||
@@ -6269,7 +6269,7 @@ mod tests {
|
||||
};
|
||||
let converted = game_metadata_update_as_create_request(&update);
|
||||
// 编辑资料不参与"同一本地项目复用游戏身份",必须与创建语义隔离。
|
||||
assert_eq!(converted.local_project_id, None);
|
||||
assert_eq!(converted.project_key, None);
|
||||
assert_eq!(converted.title, "新标题");
|
||||
assert_eq!(converted.category, "模拟");
|
||||
assert_eq!(converted.tags, vec!["放置".to_string()]);
|
||||
@@ -6450,7 +6450,7 @@ mod tests {
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
local_project_id: None,
|
||||
project_key: None,
|
||||
cover_object_key: None,
|
||||
screenshots_json: None,
|
||||
price_mud_points,
|
||||
@@ -6496,15 +6496,15 @@ mod tests {
|
||||
|
||||
fn priced_version_request(price_mud_points: u64) -> GameDistributionCreateVersionRequest {
|
||||
GameDistributionCreateVersionRequest {
|
||||
local_project_id: None,
|
||||
version_number: None,
|
||||
project_key: None,
|
||||
version_number: 1,
|
||||
price_mud_points,
|
||||
package_sha256: "d".repeat(64),
|
||||
package_bytes: 1024,
|
||||
package_file_count: 1,
|
||||
package_entry_path: "index.html".to_string(),
|
||||
game_metadata: GameDistributionCreateGameRequest {
|
||||
local_project_id: None,
|
||||
project_key: None,
|
||||
title: "付费游戏".to_string(),
|
||||
summary: "摘要".to_string(),
|
||||
description: None,
|
||||
|
||||
@@ -9,8 +9,9 @@ use crate::{
|
||||
},
|
||||
domain::{
|
||||
GameDistributionAction, GamePurchaseDecision, GamePurchaseSnapshot, GameSnapshot,
|
||||
GameVersionSnapshot, GameVersionStatus, GameVisibility, normalize_game_price_mud_points,
|
||||
normalize_id, resolve_game_purchase_decision, resolve_version_number,
|
||||
GameVersionSnapshot, GameVersionStatus, GameVisibility, game_distribution_version_key,
|
||||
normalize_game_price_mud_points, normalize_id, resolve_game_purchase_decision,
|
||||
resolve_version_number,
|
||||
},
|
||||
errors::{GameDistributionError, GameDistributionFieldError},
|
||||
};
|
||||
@@ -145,7 +146,14 @@ impl GameDistributionService {
|
||||
validate_package_bytes(input.package_bytes)?;
|
||||
let price_mud_points = normalize_game_price_mud_points(input.price_mud_points)?;
|
||||
let idem = normalize_idempotency(input.idempotency)?;
|
||||
let scope = scope(&owner_user_id, GameDistributionAction::CreateVersion, &idem);
|
||||
let scope = scope(
|
||||
&owner_user_id,
|
||||
GameDistributionAction::CreateVersion,
|
||||
&IdempotencyRequest {
|
||||
key: game_distribution_version_key(None, &game_id, input.version_number),
|
||||
request_digest: idem.request_digest.clone(),
|
||||
},
|
||||
);
|
||||
let mut state = lock(&self.store)?;
|
||||
if let Some(outcome) = replay_outcome(&state, &scope, &idem.request_digest)? {
|
||||
return version_result(outcome, true);
|
||||
@@ -161,16 +169,7 @@ impl GameDistributionService {
|
||||
if state.versions.contains_key(&version_id) {
|
||||
return Err(GameDistributionError::VersionAlreadyExists);
|
||||
}
|
||||
let version_number = resolve_version_number(
|
||||
state
|
||||
.versions
|
||||
.values()
|
||||
.filter(|version| version.game_id == game_id)
|
||||
.map(|version| version.version_number)
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
input.version_number,
|
||||
)?;
|
||||
let version_number = resolve_version_number(input.version_number)?;
|
||||
let snapshot = GameVersionSnapshot {
|
||||
version_id: version_id.clone(),
|
||||
game_id,
|
||||
@@ -1027,7 +1026,7 @@ mod tests {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-1".to_string(),
|
||||
version_number: None,
|
||||
version_number: 1,
|
||||
price_mud_points: 0,
|
||||
package_sha256: "a".repeat(64),
|
||||
package_bytes: 42,
|
||||
@@ -1308,7 +1307,7 @@ mod tests {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-2".to_string(),
|
||||
version_number: None,
|
||||
version_number: 2,
|
||||
price_mud_points: 0,
|
||||
package_sha256: "b".repeat(64),
|
||||
package_bytes: 7,
|
||||
@@ -1423,61 +1422,63 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_version_number_accepts_repeats_and_reverts_of_the_same_label() {
|
||||
assert_eq!(resolve_version_number(0, Some(1)).expect("首版显式 1"), 1);
|
||||
// 重复标签:线上已有 3 也要能再提交一次 3,并生成新的版本记录。
|
||||
assert_eq!(resolve_version_number(3, Some(3)).expect("重复标签"), 3);
|
||||
// 回退:线上最大 5,也允许提交 2。
|
||||
assert_eq!(resolve_version_number(5, Some(2)).expect("回退标签"), 2);
|
||||
fn version_number_is_required_and_only_rejects_zero() {
|
||||
assert_eq!(resolve_version_number(1).expect("首版显式 1"), 1);
|
||||
assert_eq!(resolve_version_number(3).expect("同号重放合法"), 3);
|
||||
assert_eq!(resolve_version_number(2).expect("回退标签合法"), 2);
|
||||
assert_eq!(
|
||||
resolve_version_number(0, None).expect("旧客户端自动递增"),
|
||||
1
|
||||
resolve_version_number(u64::MAX).expect("极大标签合法"),
|
||||
u64::MAX
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_version_number(7, None).expect("旧客户端按最大值递增"),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_number_must_be_positive_and_auto_increment_fails_closed() {
|
||||
assert_eq!(
|
||||
resolve_version_number(0, Some(0)).expect_err("0 不是合法标签"),
|
||||
resolve_version_number(0).expect_err("0 不是合法标签"),
|
||||
GameDistributionFieldError::InvalidVersionNumber
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_version_number(u64::MAX, None).expect_err("自动递增溢出必须失败关闭"),
|
||||
GameDistributionFieldError::VersionNumberExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_the_same_label_creates_a_new_version_record() {
|
||||
fn repeating_the_same_label_with_different_content_conflicts() {
|
||||
let service = service();
|
||||
let first = create_version(&service);
|
||||
assert_eq!(first.version_number, 1);
|
||||
|
||||
// 同一个版本标签再提交一次:旧的已公开实例不原地修改,而是新增一条版本记录。
|
||||
let repeated = service
|
||||
// 同一 (owner, gameId, versionNumber) 与不同摘要:自然幂等键判定为冲突,不新开版本。
|
||||
let conflict = service
|
||||
.create_version(CreateVersionInput {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-1-again".to_string(),
|
||||
version_number: Some(first.version_number),
|
||||
version_number: first.version_number,
|
||||
price_mud_points: 0,
|
||||
package_sha256: "b".repeat(64),
|
||||
package_bytes: 9,
|
||||
idempotency: idem("create-version-again", "version-again"),
|
||||
created_at_micros: 12,
|
||||
})
|
||||
.expect("同一个版本标签可以重复提交")
|
||||
.snapshot;
|
||||
assert_eq!(repeated.version_number, first.version_number);
|
||||
assert_ne!(repeated.version_id, first.version_id);
|
||||
.expect_err("同号不同摘要必须冲突");
|
||||
assert_eq!(conflict, GameDistributionError::IdempotencyConflict);
|
||||
assert!(service.get_version("version-1-again").is_err());
|
||||
assert_eq!(
|
||||
service.get_version(&first.version_id).expect("旧版本仍在"),
|
||||
first
|
||||
);
|
||||
|
||||
// 同号同摘要:重放既有版本,不新建记录。
|
||||
let replay = service
|
||||
.create_version(CreateVersionInput {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-1-replay".to_string(),
|
||||
version_number: first.version_number,
|
||||
price_mud_points: 0,
|
||||
package_sha256: "a".repeat(64),
|
||||
package_bytes: 42,
|
||||
idempotency: idem("create-version-1", "version-1"),
|
||||
created_at_micros: 13,
|
||||
})
|
||||
.expect("同号同摘要应重放");
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(replay.snapshot.version_id, first.version_id);
|
||||
}
|
||||
|
||||
/// 创建并发布一个指定价格的版本,返回审核通过后的游戏快照。
|
||||
@@ -1491,7 +1492,7 @@ mod tests {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-1".to_string(),
|
||||
version_number: None,
|
||||
version_number: 1,
|
||||
price_mud_points,
|
||||
package_sha256: "a".repeat(64),
|
||||
package_bytes: 42,
|
||||
@@ -1561,7 +1562,7 @@ mod tests {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-1".to_string(),
|
||||
version_number: None,
|
||||
version_number: 1,
|
||||
price_mud_points: MAX_GAME_PRICE_MUD_POINTS + 1,
|
||||
package_sha256: "a".repeat(64),
|
||||
package_bytes: 42,
|
||||
|
||||
@@ -34,10 +34,8 @@ pub struct CreateVersionInput {
|
||||
pub game_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub version_id: String,
|
||||
/// 用户可见的正整数版本标签;`None` 时按该游戏已有最大版本号 +1(旧客户端兼容)。
|
||||
/// 传入时只要求 `>= 1`:同一 gameId 的同一标签可以反复提交,每次都是一条新版本记录。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version_number: Option<u64>,
|
||||
/// 用户可见的正整数版本标签,必填且由客户端在一次发布意图内冻结。
|
||||
pub version_number: u64,
|
||||
/// 本版本冻结的买断制价格(泥点);`0` 表示免费,审核通过时整体生效到游戏行。
|
||||
#[serde(default)]
|
||||
pub price_mud_points: u64,
|
||||
@@ -47,6 +45,30 @@ pub struct CreateVersionInput {
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
/// 统一发布命令:作品 get-or-create + 建版本 + 自然幂等键收据。
|
||||
///
|
||||
/// `game_id` 与 `project_key` 至少一个非空:更新既有作品带 `game_id`,首次发布带
|
||||
/// `project_key`。幂等键由 `(owner, project_key | game_id, version_number)` 派生,
|
||||
/// 调用方只提供 `request_digest`。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PublishVersionInput {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub game_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_key: Option<String>,
|
||||
pub owner_user_id: String,
|
||||
pub version_id: String,
|
||||
pub version_number: u64,
|
||||
#[serde(default)]
|
||||
pub price_mud_points: u64,
|
||||
pub package_sha256: String,
|
||||
pub package_bytes: u64,
|
||||
/// 版本冻结资料的规范化 JSON。
|
||||
pub metadata_json: String,
|
||||
pub request_digest: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PurchaseGameInput {
|
||||
pub game_id: String,
|
||||
|
||||
@@ -39,23 +39,44 @@ pub fn generate_game_version_id(seed_micros: i64, version_number: u64) -> String
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析本次创建的版本号(用户可见的版本标签,不是不可变的 `version_id`)。
|
||||
/// 校验客户端提交的版本号(用户可见的正整数标签,不是不可变的 `version_id`)。
|
||||
///
|
||||
/// - `None`:旧客户端兼容路径,取该游戏已有最大版本号 +1;溢出时失败关闭,不回绕成 0。
|
||||
/// - `Some(requested)`:只要求正整数。同一个 gameId 的同一个版本号可以反复提交——每次提交都是
|
||||
/// 一条新的版本记录(新的 `version_id`),旧公开实例不原地修改——所以**不与已有最大值比较**,
|
||||
/// 允许重复标签,也允许回退到更小的标签。
|
||||
pub fn resolve_version_number(
|
||||
max_existing: u64,
|
||||
requested: Option<u64>,
|
||||
) -> Result<u64, GameDistributionFieldError> {
|
||||
match requested {
|
||||
Some(0) => Err(GameDistributionFieldError::InvalidVersionNumber),
|
||||
Some(requested) => Ok(requested),
|
||||
None => max_existing
|
||||
.checked_add(1)
|
||||
.ok_or(GameDistributionFieldError::VersionNumberExhausted),
|
||||
/// 版本号必填且由客户端在一次发布意图内冻结;服务端不再自增、不与已有最大值比较。版本身份由
|
||||
/// `(ownerUserId, project_key | game_id, version_number)` 决定,见 [`game_distribution_version_key`]。
|
||||
pub fn resolve_version_number(requested: u64) -> Result<u64, GameDistributionFieldError> {
|
||||
if requested == 0 {
|
||||
return Err(GameDistributionFieldError::InvalidVersionNumber);
|
||||
}
|
||||
Ok(requested)
|
||||
}
|
||||
|
||||
/// 版本的自然幂等键:`{anchor}:v{version_number}`。
|
||||
///
|
||||
/// `anchor` 优先取 trim 后非空的 `project_key`(首次发布 / 网页新建),否则取 `game_id`
|
||||
/// (更新既有作品)。服务端据此落 `create_version` 收据,不再依赖不透明 `Idempotency-Key`。
|
||||
pub fn game_distribution_version_key(
|
||||
project_key: Option<&str>,
|
||||
game_id: &str,
|
||||
version_number: u64,
|
||||
) -> String {
|
||||
let anchor = project_key
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(game_id);
|
||||
format!("{anchor}:v{version_number}")
|
||||
}
|
||||
|
||||
/// 首次发布的游戏身份:由 `(ownerUserId, project_key)` 确定性派生。
|
||||
///
|
||||
/// 确定性派生让媒体 objectKey 前缀在事务前就稳定,重试 / 并发不会落到不同命名空间;结果满足
|
||||
/// gameId 的 `[A-Za-z0-9_-]` 约束。
|
||||
pub fn derive_game_distribution_game_id(owner_user_id: &str, project_key: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(owner_user_id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(project_key.as_bytes());
|
||||
let digest = hex::encode(hasher.finalize());
|
||||
format!("{GAME_ID_PREFIX}{}", &digest[..32])
|
||||
}
|
||||
|
||||
/// Computes the lowercase SHA-256 digest used by the idempotency contract.
|
||||
@@ -212,3 +233,59 @@ pub enum GameDistributionAction {
|
||||
pub(crate) fn normalize_id(value: impl AsRef<str>) -> Option<String> {
|
||||
normalize_required_string(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_number_is_required_and_only_rejects_zero() {
|
||||
assert_eq!(resolve_version_number(1).expect("首版 v1"), 1);
|
||||
assert_eq!(
|
||||
resolve_version_number(3).expect("同号重放 / 回退标签都合法"),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_version_number(u64::MAX).expect("极大标签合法"),
|
||||
u64::MAX
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_version_number(0).expect_err("0 不是合法标签"),
|
||||
GameDistributionFieldError::InvalidVersionNumber
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_key_prefers_project_key_and_falls_back_to_game_id() {
|
||||
assert_eq!(
|
||||
game_distribution_version_key(Some("proj-1"), "game-1", 3),
|
||||
"proj-1:v3"
|
||||
);
|
||||
assert_eq!(
|
||||
game_distribution_version_key(Some(" "), "game-1", 3),
|
||||
"game-1:v3"
|
||||
);
|
||||
assert_eq!(
|
||||
game_distribution_version_key(None, "game-1", 3),
|
||||
"game-1:v3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_game_id_is_stable_owner_scoped_and_url_safe() {
|
||||
let first = derive_game_distribution_game_id("owner-1", "proj-1");
|
||||
let again = derive_game_distribution_game_id("owner-1", "proj-1");
|
||||
let other_owner = derive_game_distribution_game_id("owner-2", "proj-1");
|
||||
let other_project = derive_game_distribution_game_id("owner-1", "proj-2");
|
||||
assert_eq!(first, again);
|
||||
assert_ne!(first, other_owner);
|
||||
assert_ne!(first, other_project);
|
||||
assert!(first.starts_with(GAME_ID_PREFIX));
|
||||
assert!(first.len() <= 64);
|
||||
assert!(
|
||||
first[GAME_ID_PREFIX.len()..]
|
||||
.chars()
|
||||
.all(|value| value.is_ascii_alphanumeric() || value == '_' || value == '-')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ pub enum GameDistributionFieldError {
|
||||
InvalidPackageBytes,
|
||||
MissingReviewReason,
|
||||
InvalidVersionNumber,
|
||||
VersionNumberExhausted,
|
||||
InvalidGamePrice,
|
||||
MissingWalletLedgerId,
|
||||
}
|
||||
@@ -32,7 +31,6 @@ impl fmt::Display for GameDistributionFieldError {
|
||||
Self::InvalidPackageBytes => "packageBytes 必须大于 0",
|
||||
Self::MissingReviewReason => "拒绝审核必须提供理由",
|
||||
Self::InvalidVersionNumber => "版本号必须是大于 0 的整数",
|
||||
Self::VersionNumberExhausted => "版本号已达上限,无法自动递增",
|
||||
// 上限只从常量取,避免改价格上限时漏改文案、前后端口径分叉。
|
||||
Self::InvalidGamePrice => {
|
||||
return write!(
|
||||
|
||||
@@ -19,14 +19,15 @@ pub use application::{
|
||||
PublicationOperationResult, PurchaseOperationResult,
|
||||
};
|
||||
pub use commands::{
|
||||
CreateGameInput, CreateVersionInput, IdempotencyRequest, PurchaseGameInput, ReviewDecision,
|
||||
CreateGameInput, CreateVersionInput, IdempotencyRequest, PublishVersionInput, PurchaseGameInput,
|
||||
ReviewDecision,
|
||||
};
|
||||
pub use domain::{
|
||||
GAME_PURCHASE_ID_PREFIX, GameDistributionAction, GamePurchaseDecision, GamePurchaseSnapshot,
|
||||
GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility,
|
||||
MAX_GAME_PRICE_MUD_POINTS, compute_request_digest, game_purchase_id, generate_game_id,
|
||||
generate_game_version_id, normalize_game_price_mud_points, resolve_game_purchase_decision,
|
||||
resolve_version_number,
|
||||
MAX_GAME_PRICE_MUD_POINTS, compute_request_digest, derive_game_distribution_game_id,
|
||||
game_distribution_version_key, game_purchase_id, generate_game_id, generate_game_version_id,
|
||||
normalize_game_price_mud_points, resolve_game_purchase_decision, resolve_version_number,
|
||||
};
|
||||
pub use errors::{GameDistributionError, GameDistributionFieldError};
|
||||
pub use events::GameDistributionEvent;
|
||||
|
||||
@@ -206,7 +206,7 @@ pub struct GameDistributionListResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionCreateGameRequest {
|
||||
#[serde(default)]
|
||||
pub local_project_id: Option<String>,
|
||||
pub project_key: Option<String>,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
#[serde(default)]
|
||||
@@ -229,15 +229,12 @@ pub struct GameDistributionCreateGameRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameDistributionCreateVersionRequest {
|
||||
#[serde(default)]
|
||||
pub local_project_id: Option<String>,
|
||||
/// 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
|
||||
pub project_key: Option<String>,
|
||||
/// 用户可见的正整数版本标签,必填且由客户端在一次发布意图内冻结。
|
||||
///
|
||||
/// 传入时只要求 `>= 1`:同一 gameId 的同一个版本号可以反复提交,每次提交生成新的
|
||||
/// versionId,允许重复标签与回退到更小的版本号,不与已有最大值比较。缺省时保留旧客户端
|
||||
/// 兼容行为:服务端按该游戏已有最大版本号 +1(此时溢出才会返回 409)。
|
||||
/// 缺省时序列化省略该字段,保证旧客户端请求的幂等摘要与升级前一致。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version_number: Option<u64>,
|
||||
/// 版本身份由 `(owner, projectKey | gameId, versionNumber)` 决定:同一作品同一版本号即同一
|
||||
/// 版本,同摘要重放、不同摘要 409。服务端不再自增,也不与已有最大值比较。
|
||||
pub version_number: u64,
|
||||
/// 作者提交的买断制价格(泥点);`0` 表示免费,缺省按免费处理。
|
||||
/// 价格随版本冻结,审核通过时与资料一起生效到游戏行。
|
||||
#[serde(default)]
|
||||
@@ -366,10 +363,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_game_request_round_trips_local_project_id() {
|
||||
fn create_game_request_round_trips_project_key() {
|
||||
let payload: GameDistributionCreateGameRequest =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"localProjectId": "proj-1",
|
||||
"projectKey": "proj-1",
|
||||
"title": "游戏",
|
||||
"summary": "简介",
|
||||
"category": "益智",
|
||||
@@ -378,13 +375,13 @@ mod tests {
|
||||
"orientation": "responsive",
|
||||
}))
|
||||
.expect("请求应可解析");
|
||||
assert_eq!(payload.local_project_id.as_deref(), Some("proj-1"));
|
||||
assert_eq!(payload.project_key.as_deref(), Some("proj-1"));
|
||||
let serialized = serde_json::to_value(&payload).expect("请求应可序列化");
|
||||
assert_eq!(serialized["localProjectId"], "proj-1");
|
||||
assert_eq!(serialized["projectKey"], "proj-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_create_game_request_without_local_project_id_stays_compatible() {
|
||||
fn legacy_create_game_request_without_project_key_stays_compatible() {
|
||||
let payload: GameDistributionCreateGameRequest =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"title": "游戏",
|
||||
@@ -395,14 +392,15 @@ mod tests {
|
||||
"orientation": "responsive",
|
||||
}))
|
||||
.expect("旧请求应继续可解析");
|
||||
assert_eq!(payload.local_project_id, None);
|
||||
assert_eq!(payload.project_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_version_request_accepts_local_project_id() {
|
||||
fn create_version_request_accepts_project_key() {
|
||||
let payload: GameDistributionCreateVersionRequest =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"localProjectId": "proj-1",
|
||||
"projectKey": "proj-1",
|
||||
"versionNumber": 1,
|
||||
"packageSha256": "a".repeat(64),
|
||||
"packageBytes": 10,
|
||||
"packageFileCount": 1,
|
||||
@@ -417,13 +415,13 @@ mod tests {
|
||||
},
|
||||
}))
|
||||
.expect("版本请求应可解析");
|
||||
assert_eq!(payload.local_project_id.as_deref(), Some("proj-1"));
|
||||
assert_eq!(payload.project_key.as_deref(), Some("proj-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_version_request_version_number_is_optional() {
|
||||
let legacy: GameDistributionCreateVersionRequest =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
fn create_version_request_requires_version_number() {
|
||||
let missing = serde_json::from_value::<GameDistributionCreateVersionRequest>(
|
||||
serde_json::json!({
|
||||
"packageSha256": "a".repeat(64),
|
||||
"packageBytes": 10,
|
||||
"packageFileCount": 1,
|
||||
@@ -436,9 +434,9 @@ mod tests {
|
||||
"inputModes": ["keyboard"],
|
||||
"orientation": "responsive",
|
||||
},
|
||||
}))
|
||||
.expect("旧客户端不传版本号应继续可解析");
|
||||
assert_eq!(legacy.version_number, None);
|
||||
}),
|
||||
);
|
||||
assert!(missing.is_err(), "缺版本号必须解析失败");
|
||||
|
||||
let explicit: GameDistributionCreateVersionRequest =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
@@ -457,7 +455,7 @@ mod tests {
|
||||
},
|
||||
}))
|
||||
.expect("显式版本号应可解析");
|
||||
assert_eq!(explicit.version_number, Some(5));
|
||||
assert_eq!(explicit.version_number, 5);
|
||||
let serialized = serde_json::to_value(&explicit).expect("显式版本号应可序列化");
|
||||
assert_eq!(serialized["versionNumber"], 5);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ pub struct GameDistributionGameRecord {
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub cover_object_key: Option<String>,
|
||||
pub local_project_id: Option<String>,
|
||||
pub project_key: Option<String>,
|
||||
pub screenshots_json: Option<String>,
|
||||
/// 买断制价格(泥点);`0` 表示免费。
|
||||
pub price_mud_points: u64,
|
||||
@@ -286,7 +286,7 @@ fn map_game(
|
||||
play_count: value.play_count,
|
||||
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
|
||||
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
|
||||
local_project_id: value.local_project_id,
|
||||
project_key: value.project_key,
|
||||
cover_object_key: value.cover_object_key,
|
||||
screenshots_json: value.screenshots_json,
|
||||
price_mud_points: value.price_mud_points,
|
||||
|
||||
@@ -87,7 +87,7 @@ pub struct GameDistributionCreateGameRecordInput {
|
||||
pub request_digest: String,
|
||||
pub now_micros: i64,
|
||||
/// 同一作者的本地项目标识;重复发布会复用既有 game_id。
|
||||
pub local_project_id: Option<String>,
|
||||
pub project_key: Option<String>,
|
||||
/// 创建时即持久化的封面 OSS objectKey(发布媒体直传)。
|
||||
pub cover_object_key: Option<String>,
|
||||
/// 创建时即持久化的截图 OSS objectKey JSON 数组。
|
||||
@@ -99,16 +99,16 @@ pub struct GameDistributionCreateVersionRecordInput {
|
||||
pub game_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub version_id: String,
|
||||
/// 用户可见的正整数版本标签;`None` 时由服务端按已有最大版本号自动 +1(旧客户端兼容)。
|
||||
/// 传入时只要求 `>= 1`:同一 gameId 的同一标签可以反复提交,每次生成新的 version_id。
|
||||
pub version_number: Option<u64>,
|
||||
/// 用户可见的正整数版本标签,必填且由客户端在一次发布意图内冻结。
|
||||
/// 版本身份由 `(owner, project_key | game_id, version_number)` 决定;服务端不自增。
|
||||
pub version_number: u64,
|
||||
/// api-server 校验并解析素材后生成的资料快照 JSON。
|
||||
pub metadata_json: String,
|
||||
pub package_sha256: String,
|
||||
pub package_bytes: u64,
|
||||
pub package_file_count: u32,
|
||||
pub package_entry_path: String,
|
||||
pub local_project_id: Option<String>,
|
||||
pub project_key: Option<String>,
|
||||
pub idempotency_key: String,
|
||||
pub request_digest: String,
|
||||
pub now_micros: i64,
|
||||
@@ -725,7 +725,7 @@ impl SpacetimeClient {
|
||||
idempotency_key: input.idempotency_key,
|
||||
request_digest: input.request_digest,
|
||||
now_micros: input.now_micros,
|
||||
local_project_id: input.local_project_id,
|
||||
project_key: input.project_key,
|
||||
cover_object_key: input.cover_object_key,
|
||||
screenshots_json: input.screenshots_json,
|
||||
};
|
||||
@@ -764,7 +764,7 @@ impl SpacetimeClient {
|
||||
package_bytes: input.package_bytes,
|
||||
package_file_count: input.package_file_count,
|
||||
package_entry_path: input.package_entry_path,
|
||||
local_project_id: input.local_project_id,
|
||||
project_key: input.project_key,
|
||||
idempotency_key: input.idempotency_key,
|
||||
request_digest: input.request_digest,
|
||||
now_micros: input.now_micros,
|
||||
|
||||
+8
-8
@@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_job_summaries_and_return {
|
||||
input: ExternalGenerationJobAcknowledgeInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl acknowledge_external_generation_job_summaries_and_return for super::RemoteP
|
||||
input: ExternalGenerationJobAcknowledgeInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalGenerationJobSummaryProcedureResult>(
|
||||
|
||||
+8
-8
@@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_jobs_and_return {
|
||||
input: ExternalGenerationJobAcknowledgeInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,10 @@ impl acknowledge_external_generation_jobs_and_return for super::RemoteProcedures
|
||||
input: ExternalGenerationJobAcknowledgeInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
|
||||
|
||||
+8
-8
@@ -31,10 +31,10 @@ pub trait activate_editor_canvas_layout_and_return {
|
||||
input: EditorCanvasLayoutMigrationInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl activate_editor_canvas_layout_and_return for super::RemoteProcedures {
|
||||
input: EditorCanvasLayoutMigrationInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>(
|
||||
|
||||
+8
-8
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_redeem_code {
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures {
|
||||
input: RuntimeProfileRedeemCodeAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
|
||||
|
||||
+8
-8
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_task_config {
|
||||
input: RuntimeProfileTaskConfigAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_disable_profile_task_config for super::RemoteProcedures {
|
||||
input: RuntimeProfileTaskConfigAdminDisableInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
|
||||
|
||||
+8
-8
@@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_and_return {
|
||||
input: RuntimeProfileAdminWalletGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_get_profile_wallet_and_return for super::RemoteProcedures {
|
||||
input: RuntimeProfileAdminWalletGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletProcedureResult>(
|
||||
|
||||
+8
-8
@@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_config {
|
||||
input: RuntimeProfileWalletConfigAdminGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileWalletConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileWalletConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl admin_get_profile_wallet_config for super::RemoteProcedures {
|
||||
input: RuntimeProfileWalletConfigAdminGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileWalletConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileWalletConfigAdminProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileWalletConfigAdminProcedureResult>(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user