From dab99e82a8567f1ec9a100e59c8fdaa3fca064c0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 17:19:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20F?= =?UTF-8?q?ork=20=E7=9C=9F=E5=AE=9E=E9=98=B6=E6=AE=B5=E4=BA=8B=E4=BB=B6=20?= =?UTF-8?q?+=20=E5=85=AC=E5=BC=80=E4=BD=9C=E5=93=81=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为「Fork 工程确认页」提供两件原生能力。 1. 真实阶段事件(不造百分比):ForkSyncStage 四类真实阶段 download/verify/extract/project (带稳定事件键与可读名),并在注释里写明与需求文档「源码/素材/场景/配置」四分类的关系—— 文档假设按资产种类分别同步,而客户端这条链路是整包取回(一次下载、一次校验、一次解压、 一次建项),没有按种类分开的可测量阶段,因此如实降级成四个真实阶段; create_project_from_platform_fork_at 加 on_stage 回调(4 处测试调用点补 no-op 闭包), 在解压前/生成工程前按真实发生顺序报阶段;命令在取件前/校验前发 download/verify,新增事件 game-fork-progress({ stage, label })。 2. read_public_game_detail(gameId):复用平台既有公开详情接口(匿名、不带 Bearer),给确认页的 父作品信息卡拿标题/作者/授权类型/封面对象键;顺手把匿名 GET 抽成 get_public_platform_json, 目录命令改走同一段(不再各写一遍请求与错误映射)。 3. 适配共享契约新增字段:创建版本请求补 change_summary: None(AGC 暂无收集入口,服务端把 None 当「未填写」并省略该键,与旧客户端行为一致)。 测试:阶段键与 serde 序列化一致 + 阶段名可读(新增);既有 Fork 用例与深链解析用例全绿。 --- .../src-tauri/src/desktop.rs | 1 + .../src/game_distribution_publish.rs | 101 ++++++++++++------ .../src-tauri/src/game_fork.rs | 73 +++++++++++++ .../src-tauri/src/game_fork/desktop.rs | 20 ++++ 4 files changed, 161 insertions(+), 34 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 532b74884..c11a2e712 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -625,6 +625,7 @@ pub(super) fn run() { generate_game_distribution_cover, list_platform_game_catalog, read_public_game_cover_preview, + read_public_game_detail, read_game_distribution_publication, publish_local_project_game, read_game_creator_app_config, 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 343aee81a..e034a41d7 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 @@ -1129,6 +1129,68 @@ async fn bound_publication_result( }) } +/// 匿名读平台公开 JSON:目录与详情共用同一段(**不带 Bearer**、带客户端标识与 envelope 头)。 +/// +/// 带上 Bearer 会把「匿名可读」变成依赖登录态,因此这里刻意不加;非 2xx 折成可读错误。 +async fn get_public_platform_json( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + url: &str, + failure_label: &str, +) -> Result { + let response = client + .request(Method::GET, url) + .header( + HeaderName::from_static(AGC_CLIENT_MARKER_HEADER), + HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE), + ) + .header( + HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER), + HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION), + ) + .timeout(HTTP_TIMEOUT) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + format!("{failure_label}:请求超时,请稍后重试") + } else { + format!("{failure_label}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") + } + })?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("{failure_label}:读取响应失败:{error}"))?; + if !status.is_success() { + let (code, message) = parse_error_payload(&text); + return Err(format!( + "{failure_label}:{}", + server_error_detail(code, message, || format!("HTTP {}", status.as_u16())) + )); + } + response_data(&text).map_err(|error| format!("{failure_label}:{error}")) +} + +/// 单个公开作品详情(匿名单读):父作品确认页要的标题 / 作者 / 授权类型 / 封面对象键都在这里。 +/// +/// 复用平台**既有**的公开详情接口(不新增平台接口),同样不带 Bearer。响应体是公开投影,由 +/// 服务端逐字段手拼 JSON,因此这里原样透传 `Value`,类型在渲染层按共享契约的 +/// `GameDistributionGame` 收口(Rust 侧不重复定义一份)。 +#[tauri::command] +pub(crate) async fn read_public_game_detail(game_id: String) -> Result { + let game_id = game_id.trim(); + if !is_safe_fork_game_id(game_id) { + return Err("作品标识无效".to_string()); + } + let snapshot = require_platform_session()?; + let client = build_client()?; + let url = endpoint(&snapshot, &["api", "game-distribution", "games", game_id])?; + let value = get_public_platform_json(&client, &snapshot, &url, "读取作品详情失败").await?; + Ok(value) +} + /// 平台公开作品目录(`GET /api/game-distribution/games?limit=&cursor=`)。 /// /// 这条接口**匿名可读**,因此不叠加发布灰度、按公开投影返回;这里仍取平台会话只为拿 base URL @@ -1161,40 +1223,8 @@ pub(crate) async fn list_platform_game_catalog( query.append_pair("cursor", cursor); } } - let response = client - .request(Method::GET, url.as_str()) - .header( - HeaderName::from_static(AGC_CLIENT_MARKER_HEADER), - HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE), - ) - .header( - HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER), - HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION), - ) - .timeout(HTTP_TIMEOUT) - .send() - .await - .map_err(|error| { - if error.is_timeout() { - "读取共创目录失败:请求超时,请稍后重试".to_string() - } else { - "读取共创目录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试" - .to_string() - } - })?; - let status = response.status(); - let text = response - .text() - .await - .map_err(|error| format!("读取共创目录失败:读取响应失败:{error}"))?; - if !status.is_success() { - let (code, message) = parse_error_payload(&text); - return Err(format!( - "读取共创目录失败:{}", - server_error_detail(code, message, || format!("HTTP {}", status.as_u16())) - )); - } - let value = response_data(&text).map_err(|error| format!("读取共创目录失败:{error}"))?; + let value = + get_public_platform_json(&client, &snapshot, url.as_str(), "读取共创目录失败").await?; serde_json::from_value(value).map_err(|error| format!("共创目录响应无效:{error}")) } @@ -1885,6 +1915,9 @@ pub(crate) async fn publish_local_project_game( // 买断价随版本冻结;面板已本地校验,这里透传服务端口径的整数泥点。 price_mud_points, game_metadata: metadata, + // 二创「本次核心改动说明」:AGC 目前没有收集它的入口(发布设置页由另一条链路接线), + // 服务端把 `None` 当「未填写」且序列化时省略该键,因此与旧客户端行为一致。 + change_summary: None, }; let version_value = request_json( &client, diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs index 5857dbbca..8e17d5009 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs @@ -85,6 +85,8 @@ pub(crate) fn create_project_from_platform_fork_at( package_bytes: &[u8], requested_name: Option<&str>, planning: bool, + // 阶段回调:真实发生到哪一步就报哪一步(渲染层状态条据此渲染,不造百分比)。 + on_stage: &dyn Fn(ForkSyncStage), ) -> Result { let requested_name = requested_name .map(normalize_game_creation_project_name) @@ -127,6 +129,7 @@ pub(crate) fn create_project_from_platform_fork_at( match source { // 工程源包:包本身就是可编辑工程,先解到项目根再初始化。 ProjectForkSourceKind::Project => { + on_stage(ForkSyncStage::Extract); // 复用模板归档的同一套门禁:条目数上限、单文件上限、拒符号链接、 // 条目路径只允许项目内相对路径(ZIP 根 == 项目根)。 crate::template_library::extract_template_archive( @@ -134,6 +137,7 @@ pub(crate) fn create_project_from_platform_fork_at( &project_root, ) .map_err(|error| format!("Fork 工程源包解压失败:{error}"))?; + on_stage(ForkSyncStage::Project); let project = init_local_game_project_at( &project_root, &format!("gameagent-{workspace_id}"), @@ -152,11 +156,13 @@ pub(crate) fn create_project_from_platform_fork_at( // 成品包:脚手架必须在参考产物之前生成,否则 `create_npm_scaffold` 判据被 // 成品包里的 `index.html` / `package.json` 打断,项目从此无法发布。 ProjectForkSourceKind::Package => { + on_stage(ForkSyncStage::Project); let project = init_local_game_project_at( &project_root, &format!("gameagent-{workspace_id}"), &project_name, )?; + on_stage(ForkSyncStage::Extract); let reference_root = project_root.join(&reference_relative); ensure_game_creator_private_directory_tree( &reference_root, @@ -196,6 +202,46 @@ pub(crate) fn create_project_from_platform_fork_at( Err("自动工作区命名冲突,请重试".to_string()) } +/// Fork 工程同步的**真实阶段**:状态条只呈现这里真实发出的阶段,不造百分比、不预估进度。 +/// +/// 与需求文档「源码 / 素材 / 场景 / 配置」四分类的对应关系:文档那四类假设的是「按资产种类分别 +/// 同步」,而客户端这条链路是**整包取回**(一次下载、一次校验、一次解压、一次建项),没有按种类 +/// 分开的可测量阶段,所以这里如实降级成四个真实阶段,并在渲染层说明当前阶段在做什么。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ForkSyncStage { + /// 取回作品内容:读取件元数据 + 下载整包。 + Download, + /// 校验整包字节数与 SHA-256(失败即放弃落盘)。 + Verify, + /// 解压到项目:工程源包解到项目根,成品包解到 `reference//`。 + Extract, + /// 生成工程与来源记录:脚手架 / `.agent` 身份 / `.agent/fork-source.json`。 + Project, +} + +impl ForkSyncStage { + /// 事件里用的稳定键(渲染层按它匹配阶段,不匹配文案)。 + pub(crate) fn key(self) -> &'static str { + match self { + Self::Download => "download", + Self::Verify => "verify", + Self::Extract => "extract", + Self::Project => "project", + } + } + + /// 状态条上给用户看的阶段名。 + pub(crate) fn label(self) -> &'static str { + match self { + Self::Download => "取回作品内容", + Self::Verify => "校验摘要与字节", + Self::Extract => "解压到项目", + Self::Project => "生成工程与来源记录", + } + } +} + /// 深链协议名:与 `tauri.conf.json` 的 `plugins.deep-link.desktop.schemes` 必须一致。 pub(crate) const FORK_DEEP_LINK_SCHEME: &str = "genarrative"; @@ -274,6 +320,29 @@ mod tests { writer.finish().expect("finish zip").into_inner() } + #[test] + fn fork_sync_stages_keep_stable_keys_and_readable_labels() { + // 事件键必须与 serde 的 kebab-case 序列化一致:渲染层按 key 匹配阶段。 + for (stage, key) in [ + (ForkSyncStage::Download, "download"), + (ForkSyncStage::Verify, "verify"), + (ForkSyncStage::Extract, "extract"), + (ForkSyncStage::Project, "project"), + ] { + assert_eq!(stage.key(), key); + assert_eq!( + serde_json::to_string(&stage).expect("serialize stage"), + format!("\"{key}\"") + ); + assert!(!stage.label().is_empty(), "阶段必须有可读名"); + } + // 真实顺序:取回 → 校验 → 解压 → 建项(状态条按这个顺序排)。 + assert_eq!(ForkSyncStage::Download.label(), "取回作品内容"); + assert_eq!(ForkSyncStage::Verify.label(), "校验摘要与字节"); + assert_eq!(ForkSyncStage::Extract.label(), "解压到项目"); + assert_eq!(ForkSyncStage::Project.label(), "生成工程与来源记录"); + } + #[test] fn deep_link_accepts_both_fork_forms_and_ignores_extra_parameters() { assert_eq!( @@ -424,6 +493,7 @@ mod tests { &bytes, Some("源码改编测试"), false, + &|_| {}, ) .expect("create fork project from project bundle"); let root = Path::new(&project.project_path); @@ -469,6 +539,7 @@ mod tests { &bytes, Some("改编测试"), false, + &|_| {}, ) .expect("create fork project"); let root = Path::new(&project.project_path); @@ -507,6 +578,7 @@ mod tests { b"not-a-zip", None, false, + &|_| {}, ) .expect_err("invalid archive must fail"); assert!(error.contains("zip"), "{error}"); @@ -535,6 +607,7 @@ mod tests { &bytes, None, false, + &|_| {}, ) .is_err()); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs index dba7ac241..206381116 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs @@ -72,12 +72,28 @@ pub(crate) fn initialize_fork_deep_link(app: &tauri::AppHandle) { } } +/// Fork 工程同步进度事件:每进入一个**真实阶段**发一次,渲染层据此渲染状态条。 +pub(crate) const FORK_PROGRESS_EVENT: &str = "game-fork-progress"; + +/// 把阶段透给渲染层;发事件失败只记日志,绝不影响 Fork 本身。 +fn emit_fork_stage(app: &tauri::AppHandle, stage: ForkSyncStage) { + if let Err(error) = app.emit( + FORK_PROGRESS_EVENT, + json!({ "stage": stage.key(), "label": stage.label() }), + ) { + app_log!("game.fork.progress.emit_failed: {error}"); + } +} + /// 从平台作品 Fork:按取件形态建成项目。 /// /// - `source: "project"`(工程源包):解压即得作者的工程,再走标准初始化补 `.agent` 身份。 /// - `source: "package"`(成品包):平台只有成品时生成脚手架,成品放进 /// `reference//`。 /// +/// 阶段(渲染层状态条只显示这些真实发生过的阶段,不发假百分比): +/// 取回作品内容 → 校验摘要与字节 → 解压到项目 → 生成工程与来源记录。 +/// /// 失败关闭点(按顺序): /// 1. 没有平台登录态 → 直接给出可操作的登录提示,不发起任何请求; /// 2. 取件元数据/下载失败(404 / 409 / 403 / 401)→ 原样返回可区分的错误,不建目录; @@ -100,8 +116,11 @@ pub(crate) async fn create_local_project_from_platform_game( let session = require_platform_session()?; let identity = session.identity(); let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; + emit_fork_stage(&app, ForkSyncStage::Download); let (source, package_bytes) = fetch_platform_game_fork_source(&game_id).await?; + emit_fork_stage(&app, ForkSyncStage::Verify); verify_fork_source_bytes(&package_bytes, &source.sha256, source.bytes)?; + let stage_handle = app.clone(); let result = with_validated_platform_session_identity(&identity, || { create_project_from_platform_fork_at( &projects_root, @@ -115,6 +134,7 @@ pub(crate) async fn create_local_project_from_platform_game( &package_bytes, name.as_deref(), planning.unwrap_or(false), + &|stage| emit_fork_stage(&stage_handle, stage), ) }); if let Ok(project) = &result {