feat(游戏共创): 上架时即可指定共创授权档位(创建作品请求增量)
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 4m32s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 5m3s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 5m24s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled

- TS 契约 `GameDistributionCreateGameRequest` 新增可选 `forkAuthorization?: GameDistributionForkAuthorization`,注释写明缺省禁止共创、非法值整请求 400、上架后只能单向提升
- Rust 契约同结构体新增 `#[serde(default)] pub fork_authorization: GameDistributionForkAuthorization`(非 `Option`):省略与显式传 `forbidden` 得到同一个值,**幂等摘要因此相同**——创建请求的摘要取自整个请求体,若写成 `Option` + `skip_serializing_if`,两种写法会变成「同 key 不同请求」而互相冲突;未知取值由反序列化直接拒绝
- api-server `create_game`:载荷改为 `Result<Json<..>, JsonRejection>`,拒绝统一映射成平台信封 400(与 `set_fork_authorization` 同修法),并按 rejection 文本区分文案(含 `forkAuthorization` → 「共创授权档位不合法,只接受 forbidden / nonCommercial / full」,其余 → 「创建作品请求字段不合法」;框架文本只用于选文案,不回传客户端);创建记录入参新增 `fork_authorization: fork_authorization_value(payload.fork_authorization)`
- spacetime-client:`GameDistributionCreateGameRecordInput` 新增 `fork_authorization: String` 并透传给 procedure;重新生成的 `GameDistributionCreateGameInput` 同步带上该字段
- spacetime-module:`GameDistributionCreateGameInput` 新增 `fork_authorization: String`;创建事务里用 `ForkAuthorization::parse` 解析,未知取值失败关闭(`FORK_AUTHORIZATION_UNKNOWN`,**不静默落成 forbidden**),并把原来的硬编码 `FORK_AUTHORIZATION_FORBIDDEN` 换成解析结果
- 资料编辑复用创建校验的转换函数与既有测试夹具按新字段补默认值;AGC 侧测试字面量同步(AGC 发布面板的 UI 接线留到后续,缺省值等价于不传)
- 测试:`create_game_request_defaults_fork_authorization_without_changing_digest`(缺省 → forbidden;显式 forbidden 与原缺省**摘要相同**;`nonCommercial` / `full` 原样保留;`"allowed"` 反序列化失败)与 `create_game_rejects_unknown_fork_authorization_with_envelope_bad_request`(未知档位 → 400 + 平台信封且无框架纯文本;非档位字段问题走通用文案;合法载荷不会被误拒——测试态发布灰度未配置,得到 503 而非 400,即证明载荷路径已通过且未触达创建)
- 文档:技术方案 §2.4(发布入口上架时即选档位)、§3.4(`POST /games` 请求增量补 `forkAuthorization`)、§3.9(补充说明上架指定档位已支持、事后提升仍保留)
This commit is contained in:
2026-10-05 01:59:41 +08:00
parent dd605a0305
commit bab47484b3
7 changed files with 223 additions and 4 deletions
@@ -1630,6 +1630,9 @@ mod tests {
input_modes: vec![],
orientation:
shared_contracts::game_distribution::GameDistributionOrientation::Responsive,
// 上架时的共创授权档位;AGC 发布面板接线前先按缺省值构造(与不传该字段等价)。
fork_authorization:
shared_contracts::game_distribution::GameDistributionForkAuthorization::Forbidden,
};
let first = metadata_digest(&metadata).expect("digest");
assert_eq!(first.len(), 64);
@@ -127,7 +127,7 @@ stateDiagram-v2
| 位置 | 内容 |
| --- | --- |
| `/games/mine` 每张作品卡 | 新增「共创授权」三态设置(仅允许提升,终态 `full` 时只读);新增「被改编 N」入口,弹层列出直接子代(标题 / 作者 / 代际 / 状态);提升授权后若当前公开版本没有工程源包,行内提示「上传工程源码以支持源码级改造」(上传在桌面端客户端完成,网页端只做引导) |
| `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);从父作品 Fork 而来时显示只读的「改编自《X》」 |
| `/games/publish`、AGC 发布面板 | 新增「授权共创」三态单选(默认「禁止共创」,页面提示:开启后可被他人复刻改编,开启后不可撤销);**上架时随创建请求一起提交**(`forkAuthorization`),不必事后补一次提升;从父作品 Fork 而来时显示只读的「改编自《X》」 |
| AGC 客户端 | ① 详情页「改造这个作品」唤起 AGC(唤起方式**待拍板**,候选见 §7 第 11 条;`genarrative://fork?gameId=<id>` 的 deep link 目前**未注册**,只是候选之一);② AGC 首页/项目入口提供「从平台作品开始创作」(输入 gameId 或从平台跳转) |
**后台**
@@ -312,7 +312,7 @@ pub(crate) project_bundle_sha256: Option<String>,
| --- | --- |
| `PUT /games/{gameId}/fork-authorization`(新) | body `{ expectedForkAuthorization, forkAuthorization }` + `Idempotency-Key`;只允许提升;返回最新 `forkAuthorization` 与 `replayed` |
| `PUT /versions/{versionId}/project-bundle`(新) | `application/octet-stream`,整包或复用现役分片族(`upload-state` / `chunk` / `complete`);服务端校验 zip 门禁后写 OSS 并确认。前置:调用者是该版本作者,且该版本尚**没有**工程包;**发布阶段**上传只要求版本归属,**补齐**场景额外要求该版本是作品当前公开版本且 `fork_authorization != forbidden` |
| `POST /games`(**既有,请求增量**) | 追加可选 `forkedFromGameId` / `forkedFromVersionId` |
| `POST /games`(**既有,请求增量**) | 追加可选 `forkedFromGameId` / `forkedFromVersionId`;再追加可选 `forkAuthorization`(`forbidden` / `nonCommercial` / `full`,**缺省禁止共创**,非法取值整请求 400 + 平台信封),使作者**上架时**即可选择授权档位,不必事后提升 |
#### 登录用户(Bearer,**不叠加**发布灰度)
@@ -417,6 +417,8 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的
| 游戏行同时被两条分支在表尾追加列(`deleted_at` 与 `fork_authorization`) | 合并后两列并存即可;SpacetimeDB 自动迁移按列补齐,旧行各自取默认值 |
| `play_count` 由新增的游玩计数上报变成**活字段** | 本功能不依赖它;主规范 §1.3 里「死字段」的结论在 #565 合并后失效,以合并结果为准 |
补充(2026-10-05):作者**上架时即可指定档位**——创建作品的请求增量里新增可选 `forkAuthorization`(缺省 `forbidden`,非法取值 400 + 平台信封,见 §3.4),不再是「先落 `forbidden`、再靠 PUT 提升」;事后提升(`PUT …/fork-authorization`)仍然保留,用于把已上架作品单向提升。该增量对 #565 侧无影响:创建请求与游戏行默认值都保持向后兼容。
同一批重叠文件(24 个)里多数是**机械冲突**(同一函数/同一 `json!` 块/同一枚举块各加一段),唯一需要重新生成的是 `spacetime-client/src/module_bindings/**`:合并后必须重跑 `npm run spacetime:generate`,不得手工合并生成物。
合并顺序:**先合 #565 到 master,再把本分支 rebase 到新 master**,然后重跑 §5.1 的全部门禁与本地发布 smoke。
@@ -311,6 +311,13 @@ export type GameDistributionCreateGameRequest = {
deviceSupport: GameDistributionDeviceSupport;
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
/**
* 上架时选择的共创授权档位:`forbidden` / `nonCommercial` / `full`。
*
* 缺省按「禁止共创」解释(与库表默认、与旧客户端行为一致);非法取值整请求 400,不会静默
* 落成 `forbidden`。上架之后只能通过 `PUT …/fork-authorization` 单向提升。
*/
forkAuthorization?: GameDistributionForkAuthorization;
/** 改编来源声明;只在全新作品上生效,复用既有身份时会被拒绝。 */
fork?: GameDistributionForkDeclaration | null;
};
@@ -1386,6 +1386,9 @@ fn game_metadata_update_as_create_request(
device_support: payload.device_support.clone(),
input_modes: payload.input_modes.clone(),
orientation: payload.orientation,
// 这两个字段只服务创建语义:资料编辑既不建立血缘也不改授权档位(授权只能靠
// `set_fork_authorization` 单向提升),所以复用创建校验时固定取默认值。
fork_authorization: GameDistributionForkAuthorization::Forbidden,
fork: None,
}
}
@@ -1575,8 +1578,18 @@ async fn create_game(
Extension(ctx): Extension<RequestContext>,
Extension(auth): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Json(payload): Json<GameDistributionCreateGameRequest>,
payload: Result<Json<GameDistributionCreateGameRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
// 未知共创档位(例如 `"allowed"`)在进入业务前就被 serde 拦下:必须映射成平台信封的 400,
// 而不是让 axum 默认的 `JsonRejection` 回 422 纯文本(前端错误处理依赖信封)。框架文本只用来
// 选文案,绝不回传:至少不会把「请求体里哪一段长什么样」透给客户端。
let Json(payload) = payload.map_err(|rejection| {
if rejection.body_text().contains("forkAuthorization") {
bad_request("共创授权档位不合法,只接受 forbidden / nonCommercial / full")
} else {
bad_request("创建作品请求字段不合法")
}
})?;
ensure_publish_enabled(&state, Some(auth.claims().user_id())).await?;
let idempotency_key = idempotency_key(&headers)?;
validate_game_metadata(&payload)?;
@@ -1608,6 +1621,7 @@ async fn create_game(
input_modes_json: serde_json::to_string(&payload.input_modes)
.map_err(|error| internal(error.to_string()))?,
orientation: orientation_wire_value(payload.orientation)?,
fork_authorization: fork_authorization_value(payload.fork_authorization),
idempotency_key,
request_digest,
now_micros: now,
@@ -4455,6 +4469,7 @@ mod tests {
},
input_modes: vec![GameDistributionInputMode::Keyboard],
orientation: GameDistributionOrientation::Landscape,
fork_authorization: GameDistributionForkAuthorization::Forbidden,
fork: None,
}
}
@@ -5394,6 +5409,181 @@ mod tests {
assert_eq!(body.len() as u64, target.package_bytes);
}
/// 上架时的共创授权档位:缺省按「禁止共创」解释,显式传值原样保留,未知取值失败关闭。
///
/// 一并钉住幂等摘要口径:创建请求的摘要是对整个请求体取的,所以 DTO 必须写成非 `Option`
/// + `#[serde(default)]`——这样「省略」与「显式传 forbidden」序列化结果相同、摘要相同,
/// 同一个 Idempotency-Key 才会被识别成重放而不是「同 key 不同请求」。
#[test]
fn create_game_request_defaults_fork_authorization_without_changing_digest() {
let base = json!({
"title": "星轨防线",
"summary": "守住最后一条航线。",
"category": "益智",
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
});
let omitted: GameDistributionCreateGameRequest =
serde_json::from_value(base.clone()).expect("省略档位应可解析");
assert_eq!(
omitted.fork_authorization,
GameDistributionForkAuthorization::Forbidden,
"缺省必须是禁止共创(与表列默认一致)"
);
let mut explicit_value = base.clone();
explicit_value["forkAuthorization"] = json!("forbidden");
let explicit: GameDistributionCreateGameRequest =
serde_json::from_value(explicit_value).expect("显式 forbidden 应可解析");
assert_eq!(
explicit.fork_authorization,
GameDistributionForkAuthorization::Forbidden
);
assert_eq!(
compute_request_digest(&serde_json::to_vec(&omitted).expect("序列化")),
compute_request_digest(&serde_json::to_vec(&explicit).expect("序列化")),
"省略与显式传默认档位必须得到同一条幂等摘要"
);
for (value, expected) in [
(
"nonCommercial",
GameDistributionForkAuthorization::NonCommercial,
),
("full", GameDistributionForkAuthorization::Full),
] {
let mut payload = base.clone();
payload["forkAuthorization"] = json!(value);
let parsed: GameDistributionCreateGameRequest =
serde_json::from_value(payload).expect("合法档位应可解析");
assert_eq!(parsed.fork_authorization, expected, "{value}");
}
let mut unknown = base;
unknown["forkAuthorization"] = json!("allowed");
assert!(
serde_json::from_value::<GameDistributionCreateGameRequest>(unknown).is_err(),
"未知档位必须失败关闭,不能静默落成 forbidden"
);
}
/// 创建作品遇到未知共创档位必须回**平台信封的 400**,且不进入创建路径。
///
/// 「400 而不是 503/502」本身就是「没有创建任何作品」的进程内证据:载荷在业务之前就被拒,
/// 连发布灰度(测试态未配置,合法载荷得到 503)都没走到,因此不可能触达数据库与对象存储。
#[tokio::test]
async fn create_game_rejects_unknown_fork_authorization_with_envelope_bad_request() {
use axum::http::Request;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
};
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
use tower::ServiceExt;
let state = AppState::new(crate::config::AppConfig::default()).unwrap();
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "game-author".into(),
session_id: "create-game-session".into(),
provider: AuthProvider::Password,
roles: vec!["user".into()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: None,
},
state.auth_jwt_config(),
time::OffsetDateTime::now_utc(),
)
.unwrap();
let app = Router::new()
.route("/api/game-distribution/games", post(create_game))
.layer(Extension(AuthenticatedAccessToken::new(claims)))
.layer(middleware::from_fn(
crate::request_context::attach_request_context,
))
.with_state(state);
let valid_body = json!({
"title": "星轨防线",
"summary": "守住最后一条航线。",
"category": "益智",
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
});
let unknown_fork_authorization = json!({
"title": "星轨防线",
"summary": "守住最后一条航线。",
"category": "益智",
"deviceSupport": { "desktop": true, "mobile": false, "touch": false },
"inputModes": ["keyboard"],
"orientation": "responsive",
"forkAuthorization": "allowed",
});
for (payload, expected_message) in [
(unknown_fork_authorization, "共创授权档位不合法"),
// 缺字段而非档位问题的请求走另一条文案分支,避免把「所有解析失败」都说成档位问题。
(json!({ "title": "星轨防线" }), "创建作品请求字段不合法"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/game-distribution/games")
.header("content-type", "application/json")
.header("idempotency-key", "create-game-key-1")
.header(API_RESPONSE_ENVELOPE_HEADER, "v1")
.body(Body::from(payload.to_string()))
.expect("请求"),
)
.await
.expect("路由响应");
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{payload}");
let body = axum::body::to_bytes(response.into_body(), 32_768)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).expect("响应必须是 UTF-8");
assert!(
!text.contains("Failed to deserialize"),
"不得返回框架的纯文本拒绝:{text}"
);
let envelope: Value = serde_json::from_str(&text).expect("必须是平台信封 JSON");
assert_eq!(envelope["ok"], Value::Bool(false), "{text}");
assert_eq!(
envelope["error"]["code"],
Value::String("BAD_REQUEST".into()),
"{text}"
);
assert!(
envelope["error"]["message"]
.as_str()
.is_some_and(|message| message.contains(expected_message)),
"期望 message 含「{expected_message}」:{text}"
);
}
// 合法载荷不会被误拒:这里应当走到发布灰度(测试态未配置 → 503),而不是 400。
let valid = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/game-distribution/games")
.header("content-type", "application/json")
.header("idempotency-key", "create-game-key-2")
.body(Body::from(valid_body.to_string()))
.expect("请求"),
)
.await
.expect("路由响应");
assert_eq!(valid.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn recovery_action_covers_every_version_status() {
for (status, expected) in [
@@ -83,6 +83,8 @@ pub struct GameDistributionCreateGameRecordInput {
pub device_support_touch: bool,
pub input_modes_json: String,
pub orientation: String,
/// 上架时选择的共创授权档位(`forbidden` / `nonCommercial` / `full`)。
pub fork_authorization: String,
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
@@ -763,6 +765,7 @@ impl SpacetimeClient {
device_support_touch: input.device_support_touch,
input_modes_json: input.input_modes_json,
orientation: input.orientation,
fork_authorization: input.fork_authorization,
idempotency_key: input.idempotency_key,
request_digest: input.request_digest,
now_micros: input.now_micros,
@@ -22,6 +22,7 @@ pub struct GameDistributionCreateGameInput {
pub device_support_touch: bool,
pub input_modes_json: String,
pub orientation: String,
pub fork_authorization: String,
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
@@ -961,6 +961,9 @@ pub struct GameDistributionCreateGameInput {
pub device_support_touch: bool,
pub input_modes_json: String,
pub orientation: String,
/// 上架时选择的共创授权档位(`forbidden` / `nonCommercial` / `full`);
/// 由 api-server 归一后传入,未知取值在事务里失败关闭。
pub fork_authorization: String,
pub idempotency_key: String,
pub request_digest: String,
pub now_micros: i64,
@@ -2229,6 +2232,16 @@ fn create_game_distribution_game_tx(
let idempotency_key =
required_game_distribution_text(input.idempotency_key, "idempotency_key")?;
let request_digest = required_game_distribution_text(input.request_digest, "request_digest")?;
// 上架时的共创授权档位:api-server 已按 DTO 枚举挡掉未知取值,这里再判一次是为了让非 HTTP
// 调用方同样失败关闭——**绝不**静默落成 `forbidden`(否则作者会以为自己设成功了)。
let fork_authorization =
module_game_distribution::ForkAuthorization::parse(input.fork_authorization.as_str())
.ok_or_else(|| {
module_game_distribution::GameDistributionError::ForkAuthorizationUnknown {
value: input.fork_authorization.clone(),
}
.to_string()
})?;
let receipt_id = game_distribution_receipt_id(
owner_user_id.as_str(),
GAME_DISTRIBUTION_ACTION_CREATE_GAME,
@@ -2354,7 +2367,7 @@ fn create_game_distribution_game_tx(
cover_object_key: None,
screenshots_json: None,
deleted_at: None,
fork_authorization: module_game_distribution::FORK_AUTHORIZATION_FORBIDDEN.to_string(),
fork_authorization: fork_authorization.as_str().to_string(),
});
// 血缘与游戏行同事务写入:只有全新作品才会走到这里,复用身份已在上面被拒绝。
if let (Some(parent_game_id), Some(parent_version_id), Some((root_game_id, generation))) = (