diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index b3ee96410..697a518b3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -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); diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index 677c11663..3abbdabfe 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -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=` 的 deep link 目前**未注册**,只是候选之一);② AGC 首页/项目入口提供「从平台作品开始创作」(输入 gameId 或从平台跳转) | **后台** @@ -312,7 +312,7 @@ pub(crate) project_bundle_sha256: Option, | --- | --- | | `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。 diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 267e9a1ae..e30e722c0 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -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; }; diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index e5b6a2fb3..58e03ce1d 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -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, Extension(auth): Extension, headers: HeaderMap, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result, 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::(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 [ diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index 335e96f12..907317de5 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs index 332e27688..94e3c1e68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_create_game_input_type.rs @@ -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, diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index 6ae385821..aac2a8182 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -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))) = (