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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user