feat(游戏共创): Fork 真实阶段事件 + 公开作品详情读取

为「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 用例与深链解析用例全绿。
This commit is contained in:
2026-10-06 17:19:22 +08:00
parent 3e3e009b11
commit dab99e82a8
4 changed files with 161 additions and 34 deletions
@@ -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,
@@ -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<Value, String> {
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<Value, String> {
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,
@@ -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<InitLocalProjectResult, String> {
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/<gameId>/`。
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!(
@@ -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/<gameId>/`。
///
/// 阶段(渲染层状态条只显示这些真实发生过的阶段,不发假百分比):
/// 取回作品内容 → 校验摘要与字节 → 解压到项目 → 生成工程与来源记录。
///
/// 失败关闭点(按顺序):
/// 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 {