Compare commits

...

3 Commits

Author SHA1 Message Date
lhk229 9b109d75d5 按生产接入边界拆分桌面模块并减少测试编译告警
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m29s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m48s
Project CI / Frontend tests (pull_request) Successful in 2m49s
Project CI / Repository checks (pull_request) Successful in 2m53s
Project CI / Backend tests (pull_request) Successful in 4m38s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m33s
Project CI / Native shell tests (pull_request) Successful in 6m17s
将桌面启动、命令注册和窗口接入集中到非测试模块,保持命令集合与注册顺序不变
拆分命令专属辅助、上传网络执行和素材生成派发,保留共享业务实现及原有测试
按实际消费者限定生产导入和重导出,测试死代码告警由341条降至198条
更新源码接线检查、开发运维文档和共享开发流程
验证普通目标与测试目标编译、60条Rust测试、7条前端测试及配置格式编码文档检查
未运行全量构建或跨平台验证,跳过一个Windows原生弹窗用例
2026-09-24 07:01:07 +00:00
lhk229 5755265f74 清理无用测试辅助代码并收紧条件编译范围
删除七个无调用的测试辅助函数和包装方法
限定 Unix、Unity 与模板假数据辅助代码的编译条件
删除失去测试入口的旧协议措辞断言及多余导入
同步记录测试辅助代码与现役协议的保留边界
2026-09-24 05:49:48 +00:00
lhk229 1424c3ee5a 清理测试编译中的未使用导入与辅助代码
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / Backend tests (pull_request) Successful in 3m48s
Project CI / Frontend tests (pull_request) Successful in 1m49s
Project CI / Native shell tests (pull_request) Successful in 5m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m17s
Project CI / Repository checks (pull_request) Successful in 1m57s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m10s
限定 GUI 和平台测试导入的编译范围,删除多余导入与重导出
删除三个无调用的测试辅助函数,消除冗余赋值
补齐 DirectHome 命令工具禁用断言,明确测试路径校验参数用途
更新开发运维文档中的测试编译告警处理边界
2026-09-24 05:28:04 +00:00
42 changed files with 3207 additions and 3331 deletions
@@ -98,10 +98,13 @@ const appInvokeSources = readSourceFiles(
new URL('../src/', import.meta.url),
new Set(['.ts', '.tsx']),
);
const tauriHandlerSource = fs.readFileSync(
// 生产接线在 desktop,受测的启动诊断实现仍在 main;两者共同构成启动检查范围。
const tauriHandlerSource = [
new URL('../src-tauri/src/main.rs', import.meta.url),
'utf8',
);
new URL('../src-tauri/src/desktop.rs', import.meta.url),
]
.map((source) => fs.readFileSync(source, 'utf8'))
.join('\n');
const tauriRustSource = readSourceTree(
new URL('../src-tauri/src/', import.meta.url),
'.rs',
@@ -45,16 +45,19 @@ mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
mod skill_pack;
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
use codex_app_server::*;
#[cfg(not(test))]
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
direct_game_creator_home_codex_chat, direct_thread_id_for_project, DirectTurnCancelView,
cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, direct_thread_id_for_project,
DirectTurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
};
pub(crate) use codex_provider_proxy::*;
#[cfg(not(test))]
pub(crate) use design_runtime::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_user_item::*;
@@ -2480,42 +2480,7 @@ impl CodexAppServerConnection {
Ok(connection)
}
#[cfg(test)]
async fn spawn(
llm: &GameCreatorLlmConfig,
credential: &CodexAppServerCredential,
) -> Result<Self, platform_llm::LlmError> {
let executable = game_creator_codex_cli_executable_path()
.map_err(platform_llm::LlmError::InvalidConfig)?;
Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await
}
#[cfg(test)]
async fn spawn_with_executable(
llm: &GameCreatorLlmConfig,
executable: &std::ffi::OsStr,
) -> Result<Self, platform_llm::LlmError> {
let credential = resolve_game_creator_codex_app_server_credential(llm)?;
Self::spawn_with_executable_and_credential(llm, &credential, executable).await
}
#[cfg(test)]
async fn spawn_with_executable_and_credential(
llm: &GameCreatorLlmConfig,
credential: &CodexAppServerCredential,
executable: &std::ffi::OsStr,
) -> Result<Self, platform_llm::LlmError> {
Self::spawn_with_executable_and_credential_at_workspace(
llm,
credential,
executable,
None,
CodexAppServerWorkspaceMode::ToolHost,
)
.await
}
#[cfg(test)]
#[cfg(all(test, unix))]
async fn spawn_with_executable_and_credential_at_workspace(
llm: &GameCreatorLlmConfig,
credential: &CodexAppServerCredential,
@@ -7095,6 +7060,8 @@ while IFS= read -r line; do :; done
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("\n");
assert!(home_arguments.contains("--disable\nshell_tool"));
assert!(home_arguments.contains("--disable\nunified_exec"));
}
#[cfg(windows)]
@@ -1688,7 +1688,6 @@ pub(crate) fn read_design_workspace_file(
#[cfg(test)]
mod fake_provider {
use super::*;
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
@@ -8,6 +8,7 @@ use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
mod user_input;
#[cfg(not(test))]
pub(crate) use user_input::chat_with_game_creator_direct_codex;
#[cfg(test)]
pub(crate) use user_input::normalize_direct_client_turn_id;
@@ -49,7 +49,6 @@ pub(in crate::agent) use external_generation_state::{
};
#[cfg(test)]
pub(crate) use external_generation_state::{
platform_art_generation_external_configuration_fingerprint,
platform_art_generation_legacy_external_configuration_fingerprint,
setup_platform_art_generation_runtime_accepted_for_recovery_test,
write_platform_art_generation_runtime_accepted_for_test,
@@ -817,24 +817,17 @@ mod tests {
use super::{
acquire_game_creator_agent_provider_plan_project_write_lock_with_wait,
agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at,
build_game_creator_agent_background_final_reply_request,
build_game_creator_agent_background_tool_plan_request_locked,
game_creator_agent_context_preload_notice,
game_creator_agent_runtime_run_profile_binding_path,
game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at,
new_game_creation_app_seed_tasks, provider_command_exec_contract,
provider_command_start_contract,
game_creator_agent_context_preload_notice, init_local_game_project_at,
new_game_creation_app_seed_tasks,
render_relaxed_autonomous_manifest_ready_task_background_prompt,
required_runtime_prompt_section, start_game_creator_agent_runtime_task_at,
AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft,
AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan,
start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft,
AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig,
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT,
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
};
fn build_game_creator_agent_background_tool_plan_request_for_test(
@@ -871,24 +864,6 @@ mod tests {
)
}
fn native_input_required_fields(
request: &platform_llm::LlmRunRequest,
tool: &str,
) -> Vec<String> {
let function_name = crate::agent_native_tools::native_runtime_function_name(tool)
.expect("native runtime function name");
request
.function_tools
.iter()
.find(|function| function.name == function_name)
.and_then(|function| function.parameters.pointer("/properties/input/required"))
.and_then(serde_json::Value::as_array)
.expect("native input required fields")
.iter()
.map(|field| field.as_str().expect("required field string").to_string())
.collect()
}
#[test]
fn relaxed_request_keeps_general_catalog_after_plan_rejection() {
let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-");
@@ -1437,20 +1412,6 @@ mod tests {
assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract"));
}
fn completion_blocker_protocol_requires_tool_repair_before_response() {
let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL;
assert!(protocol.contains("最新 observation"));
assert!(protocol.contains("runtime.autonomous_completion"));
assert!(protocol.contains("status 为 blocked"));
assert!(protocol.contains("才能调用 respond_to_user"));
assert!(!protocol.contains("legacy response"));
assert!(protocol.contains("observation.detail 的 nextRequiredAction"));
assert!(protocol.contains("读取、修复和验证工具"));
assert!(protocol.contains("完成要求的动作、取得后续可信 observation"));
assert!(protocol.contains("通过完成门禁后"));
}
#[test]
fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure()
{
@@ -302,10 +302,7 @@ pub(crate) use entrypoints::{
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
pub(crate) use finalization::AgentRuntimePendingActionResume;
#[cfg(test)]
pub(crate) use interaction::{
acquire_game_creator_agent_runtime_user_input_answer_locks_for_test,
agent_runtime_tool_requires_repository_context_fingerprint_gate,
};
pub(crate) use interaction::agent_runtime_tool_requires_repository_context_fingerprint_gate;
pub(crate) use interaction::{
answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at,
pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at,
@@ -477,20 +477,6 @@ fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks(
Ok((None, runtime_lock, resolved))
}
#[cfg(test)]
pub(crate) fn acquire_game_creator_agent_runtime_user_input_answer_locks_for_test(
root: &Path,
agent_id: &str,
run_id: &str,
action_id: &str,
) -> Result<(Option<ProjectWriteLock>, AgentRuntimeTaskLock), String> {
let (project_lock, runtime_lock, _) =
resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks(
root, agent_id, run_id, action_id,
)?;
Ok((project_lock, runtime_lock))
}
pub(crate) fn answer_game_creator_agent_runtime_user_input_at(
root: &Path,
agent_id: &str,
@@ -11943,11 +11943,6 @@ impl ExternalGameplayJavascript {
.iter()
.any(|unit| unit.to_ascii_lowercase().contains(&marker))
}
#[cfg(test)]
pub(in crate::agent) fn module_units(&self) -> &[String] {
&self.module_units
}
}
fn normalize_javascript_module_analysis_sources(
@@ -44,9 +44,7 @@ pub(in crate::agent) use ui_workflow::*;
#[cfg(test)]
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
#[cfg(test)]
pub(crate) use delivery::{
build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at,
};
pub(crate) use delivery::build_static_delegate_result_for_child_at;
#[cfg(test)]
pub(crate) use isolated_joins::render_isolated_join_status_batch_with_limit;
#[cfg(test)]
@@ -408,14 +408,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
Ok(true)
}
#[cfg(test)]
pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at(
root: &Path,
parent_task: &AgentRuntimeTaskRecord,
) -> Result<bool, String> {
wake_waiting_static_delegate_parent_run_at(root, parent_task)
}
pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at(
root: &Path,
parent_task: &AgentRuntimeTaskRecord,
@@ -13,8 +13,6 @@ use crate::agent::{
AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT,
};
#[cfg(test)]
use crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID;
pub(crate) const AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan";
pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user";
@@ -25,17 +25,20 @@ use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::agent::{
generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes,
write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions,
read_agent_runtime_json_sidecar_with_max_bytes, write_agent_runtime_json_sidecar_with_max_bytes,
};
use crate::commands::prepare_local_project_asset_generation;
use crate::project::{
enforce_project_permission_policy, prepare_local_project_audio_generation,
read_existing_manifest_for_project, run_local_project_audio_generation_at,
LocalProjectAudioGenerationRequest, LocalProjectResourceEditKind,
read_existing_manifest_for_project, LocalProjectAudioGenerationRequest,
LocalProjectResourceEditKind,
};
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
#[cfg(not(test))]
mod runtime;
#[cfg(not(test))]
pub(crate) use runtime::start_local_project_asset_generation;
pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1";
pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str =
".agent/runtime/asset-generation-tasks/tasks.json";
@@ -45,12 +48,10 @@ pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50;
pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128;
pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued";
pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running";
pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed";
pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed";
const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。";
const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。";
const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。";
/// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str =
@@ -67,9 +68,6 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str =
"应用退出时生成任务仍在进行,目标素材未登记";
const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str =
"应用退出时生成任务仍在进行,未能在清单里确认结果";
/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。
const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材";
/// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -357,57 +355,6 @@ fn register_live_task_id(task_id: &str) -> bool {
.unwrap_or(false)
}
/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。
async fn run_local_project_asset_generation_task(
root: PathBuf,
task_id: String,
prompt: String,
options: PlatformArtAssetGenerationOptions,
) {
if update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string();
task.started_at_millis = Some(now_millis());
})
.is_err()
{
remove_live_task_id(&task_id);
return;
}
let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await;
match outcome {
Ok(generated) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string();
task.asset_id = Some(generated.asset.id.clone());
task.finished_at_millis = Some(now_millis());
task.error = None;
});
}
Err(error) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
task.phase_detail = format!("生成失败:{error}");
task.error = Some(error.clone());
task.finished_at_millis = Some(now_millis());
});
}
}
remove_live_task_id(&task_id);
}
/// 走音频无源生成链路的 kind:音效与背景音乐。
///
/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind
/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。
fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool {
matches!(
kind,
GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic
)
}
/// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。
///
/// 与图片类分支的差异只有三处,且都不改变账本形状:
@@ -455,167 +402,6 @@ fn begin_local_project_audio_generation_task(
Ok((record, request))
}
/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。
///
/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败
/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。
async fn run_local_project_audio_generation_task(
project_path: String,
task_id: String,
request: LocalProjectAudioGenerationRequest,
) {
let root = PathBuf::from(project_path.trim());
if update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string();
task.started_at_millis = Some(now_millis());
})
.is_err()
{
remove_live_task_id(&task_id);
return;
}
let outcome = run_local_project_audio_generation_at(&project_path, &request).await;
match outcome {
Ok(Some(asset_id)) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string();
task.asset_id = Some(asset_id);
task.finished_at_millis = Some(now_millis());
task.error = None;
});
}
Ok(None) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
task.phase_detail =
format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}");
task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string());
task.finished_at_millis = Some(now_millis());
});
}
Err(error) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
task.phase_detail = format!("生成失败:{error}");
task.error = Some(error.clone());
task.finished_at_millis = Some(now_millis());
});
}
}
remove_live_task_id(&task_id);
}
/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。
///
/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界),
/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。
///
/// 音频 kind`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、
/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见
/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。
#[tauri::command]
pub(crate) async fn start_local_project_asset_generation(
project_path: String,
project_id: String,
task_id: String,
kind: String,
prompt: String,
aspect_ratio: Option<String>,
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
reference_asset_ids: Option<Vec<String>>,
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
target_category: Option<String>,
// 前端 IPC 字段 `idempotencyKey`**音频**生成才带——音频请求身份是一对 operation / 幂等键,
// 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对
// 图片 kind 不参与任何校验。
idempotency_key: Option<String>,
) -> Result<AssetGenerationTaskRecord, String> {
let task_id = asset_generation_task_id(&task_id)?;
if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context(
&kind,
"canvas.asset_kind",
)) {
let idempotency_key = idempotency_key.unwrap_or_default();
if idempotency_key.trim().is_empty() {
return Err("音频生成缺少 idempotencyKey".to_string());
}
// 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。
let live_registered = register_live_task_id(&task_id);
let (record, request) = match begin_local_project_audio_generation_task(
&project_path,
&project_id,
&task_id,
&kind,
&prompt,
asset_name.as_deref().unwrap_or_default(),
&idempotency_key,
) {
Ok(pair) => pair,
Err(error) => {
// 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。
if live_registered {
remove_live_task_id(&task_id);
}
return Err(error);
}
};
tauri::async_runtime::spawn(run_local_project_audio_generation_task(
project_path.trim().to_string(),
task_id,
request,
));
return Ok(record);
}
let request = prepare_local_project_asset_generation(
&project_path,
&kind,
&prompt,
aspect_ratio.as_deref(),
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
reference_asset_ids.as_deref().unwrap_or_default(),
target_category.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
let asset_label = request.options.asset_label.clone();
let asset_kind = request.options.asset_kind.clone();
// 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。
let live_registered = register_live_task_id(&task_id);
let record = match begin_local_project_asset_generation_task(
&request.root,
&project_id,
&task_id,
asset_kind,
&asset_label,
request.options.output_path.as_deref(),
) {
Ok(record) => record,
Err(error) => {
if live_registered {
remove_live_task_id(&task_id);
}
return Err(error);
}
};
let root = request.root.clone();
tauri::async_runtime::spawn(run_local_project_asset_generation_task(
root,
task_id,
request.prompt,
request.options,
));
Ok(record)
}
/// 读回项目内账本(重开项目后仍能看到历史任务)。
#[tauri::command]
pub(crate) fn list_local_project_asset_generations(
@@ -0,0 +1,223 @@
use super::*;
use crate::agent::{
generate_platform_art_asset_with_options_at, PlatformArtAssetGenerationOptions,
};
use crate::commands::prepare_local_project_asset_generation;
use crate::project::run_local_project_audio_generation_at;
pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running";
const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。";
/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。
const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材";
/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。
async fn run_local_project_asset_generation_task(
root: PathBuf,
task_id: String,
prompt: String,
options: PlatformArtAssetGenerationOptions,
) {
if update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string();
task.started_at_millis = Some(now_millis());
})
.is_err()
{
remove_live_task_id(&task_id);
return;
}
let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await;
match outcome {
Ok(generated) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string();
task.asset_id = Some(generated.asset.id.clone());
task.finished_at_millis = Some(now_millis());
task.error = None;
});
}
Err(error) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
task.phase_detail = format!("生成失败:{error}");
task.error = Some(error.clone());
task.finished_at_millis = Some(now_millis());
});
}
}
remove_live_task_id(&task_id);
}
/// 走音频无源生成链路的 kind:音效与背景音乐。
///
/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind
/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。
fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool {
matches!(
kind,
GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic
)
}
/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。
///
/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败
/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。
async fn run_local_project_audio_generation_task(
project_path: String,
task_id: String,
request: LocalProjectAudioGenerationRequest,
) {
let root = PathBuf::from(project_path.trim());
if update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string();
task.started_at_millis = Some(now_millis());
})
.is_err()
{
remove_live_task_id(&task_id);
return;
}
let outcome = run_local_project_audio_generation_at(&project_path, &request).await;
match outcome {
Ok(Some(asset_id)) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string();
task.asset_id = Some(asset_id);
task.finished_at_millis = Some(now_millis());
task.error = None;
});
}
Ok(None) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
task.phase_detail =
format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}");
task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string());
task.finished_at_millis = Some(now_millis());
});
}
Err(error) => {
let _ = update_task(&root, &task_id, |task| {
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
task.phase_detail = format!("生成失败:{error}");
task.error = Some(error.clone());
task.finished_at_millis = Some(now_millis());
});
}
}
remove_live_task_id(&task_id);
}
/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。
///
/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界),
/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。
///
/// 音频 kind`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、
/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见
/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。
#[tauri::command]
pub(crate) async fn start_local_project_asset_generation(
project_path: String,
project_id: String,
task_id: String,
kind: String,
prompt: String,
aspect_ratio: Option<String>,
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
reference_asset_ids: Option<Vec<String>>,
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
target_category: Option<String>,
// 前端 IPC 字段 `idempotencyKey`**音频**生成才带——音频请求身份是一对 operation / 幂等键,
// 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对
// 图片 kind 不参与任何校验。
idempotency_key: Option<String>,
) -> Result<AssetGenerationTaskRecord, String> {
let task_id = asset_generation_task_id(&task_id)?;
if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context(
&kind,
"canvas.asset_kind",
)) {
let idempotency_key = idempotency_key.unwrap_or_default();
if idempotency_key.trim().is_empty() {
return Err("音频生成缺少 idempotencyKey".to_string());
}
// 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。
let live_registered = register_live_task_id(&task_id);
let (record, request) = match begin_local_project_audio_generation_task(
&project_path,
&project_id,
&task_id,
&kind,
&prompt,
asset_name.as_deref().unwrap_or_default(),
&idempotency_key,
) {
Ok(pair) => pair,
Err(error) => {
// 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。
if live_registered {
remove_live_task_id(&task_id);
}
return Err(error);
}
};
tauri::async_runtime::spawn(run_local_project_audio_generation_task(
project_path.trim().to_string(),
task_id,
request,
));
return Ok(record);
}
let request = prepare_local_project_asset_generation(
&project_path,
&kind,
&prompt,
aspect_ratio.as_deref(),
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
reference_asset_ids.as_deref().unwrap_or_default(),
target_category.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
let asset_label = request.options.asset_label.clone();
let asset_kind = request.options.asset_kind.clone();
// 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。
let live_registered = register_live_task_id(&task_id);
let record = match begin_local_project_asset_generation_task(
&request.root,
&project_id,
&task_id,
asset_kind,
&asset_label,
request.options.output_path.as_deref(),
) {
Ok(record) => record,
Err(error) => {
if live_registered {
remove_live_task_id(&task_id);
}
return Err(error);
}
};
let root = request.root.clone();
tauri::async_runtime::spawn(run_local_project_asset_generation_task(
root,
task_id,
request.prompt,
request.options,
));
Ok(record)
}
@@ -32,6 +32,7 @@ use super::playtest::{
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, PLAYABLE_GAME_STATE_SCHEMA_VERSION,
};
#[cfg(unix)]
use super::process::create_browser_process_temp_dir;
static PATH_TEST_LOCK: Mutex<()> = Mutex::new(());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1291,6 +1291,8 @@ fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped(
) -> Result<(), String> {
#[cfg(test)]
{
// 测试只校验路径,不触发宿主提权修复,因此不消费修复范围。
let _ = scope;
return validate_game_creator_private_path_ancestors(path, label);
}
#[cfg(not(test))]
@@ -1,7 +1,7 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
#[cfg(not(test))]
mod runtime;
#[cfg(not(test))]
pub(crate) use runtime::init;
/// Release 客户端只取消浏览器的默认上下文菜单。
///
@@ -22,39 +22,6 @@ fn context_menu_init_script(is_release: bool) -> Option<&'static str> {
is_release.then_some(RELEASE_CONTEXT_MENU_INIT_SCRIPT)
}
#[cfg(all(windows, not(debug_assertions)))]
fn disable_windows_default_context_menus<R: Runtime>(webview: tauri::Webview<R>) {
let _ = webview.with_webview(|platform_webview| {
// WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的
// `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。
// SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller
// 这些 COM 接口调用只使用该闭包期间有效的句柄。
let _ = unsafe {
platform_webview
.controller()
.CoreWebView2()
.and_then(|webview| webview.Settings())
.and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false))
};
});
}
/// 注册 release-only 的默认网页上下文菜单策略。
pub(crate) fn init<R: Runtime>() -> TauriPlugin<R> {
let mut builder = Builder::new("release-context-menu");
if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) {
builder = builder.js_init_script_on_all_frames(script);
}
#[cfg(all(windows, not(debug_assertions)))]
{
builder = builder.on_webview_ready(disable_windows_default_context_menus);
}
builder.build()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,39 @@
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
use super::context_menu_init_script;
#[cfg(all(windows, not(debug_assertions)))]
fn disable_windows_default_context_menus<R: Runtime>(webview: tauri::Webview<R>) {
let _ = webview.with_webview(|platform_webview| {
// WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的
// `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。
// SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller
// 这些 COM 接口调用只使用该闭包期间有效的句柄。
let _ = unsafe {
platform_webview
.controller()
.CoreWebView2()
.and_then(|webview| webview.Settings())
.and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false))
};
});
}
/// 注册 release-only 的默认网页上下文菜单策略。
pub(crate) fn init<R: Runtime>() -> TauriPlugin<R> {
let mut builder = Builder::new("release-context-menu");
if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) {
builder = builder.js_init_script_on_all_frames(script);
}
#[cfg(all(windows, not(debug_assertions)))]
{
builder = builder.on_webview_ready(disable_windows_default_context_menus);
}
builder.build()
}
File diff suppressed because it is too large Load Diff
@@ -525,11 +525,21 @@ pub(crate) fn disconnect_unity_editor_connection() {
let _ = disconnect_managed_editor_connection(ManagedEditor::Unity);
}
#[cfg(test)]
#[cfg(all(
test,
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
))]
pub(super) fn unity_pending_delivery() -> &'static Mutex<Option<PendingDelivery>> {
pending_delivery(ManagedEditor::Unity)
}
#[cfg(test)]
#[cfg(all(
test,
windows,
target_arch = "x86_64",
feature = "unity-editor-execute"
))]
pub(crate) fn unity_delivery_requires_ack(id: &str) -> bool {
pending_delivery(ManagedEditor::Unity)
.try_lock()
@@ -7,9 +7,10 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
#[cfg(not(test))]
pub(crate) use web_creation::preflight_web_game_creation;
pub(crate) use web_creation::{
host_web_creation_preflight, preflight_web_game_creation, prepare_new_web_project_at,
record_new_web_scaffold_at,
host_web_creation_preflight, prepare_new_web_project_at, record_new_web_scaffold_at,
};
use serde::Deserialize;
@@ -6,24 +6,20 @@
use std::{
fs::{self, File},
io::{Read, Seek, SeekFrom, Write},
io::Write,
path::{Path, PathBuf},
time::Duration,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::Value;
pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress";
const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client";
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset";
/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。
const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
const CHUNK_MAX_ATTEMPTS: usize = 4;
const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500);
const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging";
#[cfg(not(test))]
mod runtime;
#[cfg(not(test))]
pub(crate) use runtime::{
game_package_upload_staging_dir, progress_event_payload, upload_staged_game_package,
GamePackageUploadOutcome, GamePackageUploadRequest, GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
};
/// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
@@ -35,36 +31,12 @@ pub(crate) struct StagedGamePackage {
pub(crate) package_file_count: u32,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GamePackageUploadOutcome {
pub(crate) version_id: String,
pub(crate) status: String,
pub(crate) uploaded_bytes: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ChunkPlan {
offset: u64,
length: u64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct PackageUploadState {
#[serde(default)]
received_bytes: u64,
#[serde(default)]
chunk_bytes: u64,
#[serde(default)]
declared_package_bytes: u64,
}
/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。
pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf {
app_data_dir.join(UPLOAD_STAGING_DIR_NAME)
}
/// 把归一化后的发行包暂存到内容寻址文件,返回续传所需的元数据。
pub(crate) fn stage_game_package_bytes(
staging_dir: &Path,
@@ -134,15 +106,6 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option<ChunkP
})
}
fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result<Vec<u8>, String> {
file.seek(SeekFrom::Start(plan.offset))
.map_err(|error| format!("读取发行包失败:{error}"))?;
let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)];
file.read_exact(&mut buffer)
.map_err(|error| format!("读取发行包失败:{error}"))?;
Ok(buffer)
}
fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String {
format!(
"{}/api/game-distribution/versions/{version_id}/package{suffix}",
@@ -184,270 +147,6 @@ fn parse_received_bytes(body: &str) -> Option<u64> {
.and_then(Value::as_u64)
}
fn platform_request(
client: &reqwest::Client,
access_token: &str,
method: reqwest::Method,
url: &str,
) -> reqwest::RequestBuilder {
client
.request(method, url)
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {access_token}"),
)
}
async fn read_upload_state(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
access_token: &str,
) -> Result<PackageUploadState, String> {
let response = platform_request(
client,
access_token,
reqwest::Method::GET,
&upload_url(base_url, version_id, "/upload-state"),
)
.send()
.await
.map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?;
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|error| format!("读取上传状态失败:{error}"))?;
if status >= 400 {
let (_, message) = parse_server_error(status, &body);
return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status}")));
}
let parsed: Value = serde_json::from_str(&body)
.map_err(|error| format!("上传状态响应不是合法 JSON{error}"))?;
let payload = parsed.get("data").unwrap_or(&parsed);
serde_json::from_value(payload.clone())
.map_err(|error| format!("上传状态响应缺少字段:{error}"))
}
/// 上传一个分片;返回服务端确认后的已收字节。
async fn upload_chunk(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
access_token: &str,
idempotency_key: &str,
plan: ChunkPlan,
body: Vec<u8>,
) -> Result<u64, ChunkUploadError> {
let response = platform_request(
client,
access_token,
reqwest::Method::PUT,
&upload_url(base_url, version_id, "/chunk"),
)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.header(UPLOAD_OFFSET_HEADER, plan.offset.to_string())
.header("Idempotency-Key", format!("{idempotency_key}:chunk"))
.body(body)
.send()
.await
.map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?;
let status = response.status().as_u16();
let text = response
.text()
.await
.map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?;
if status == 409 {
let (code, message) = parse_server_error(status, &text);
if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") {
let received = parse_received_bytes(&text).ok_or_else(|| {
ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string())
})?;
return Ok(received);
}
return Err(ChunkUploadError::Fatal(
message.unwrap_or_else(|| "发行包分片被拒绝".to_string()),
));
}
if status >= 500 || status == 408 || status == 429 {
let (_, message) = parse_server_error(status, &text);
return Err(ChunkUploadError::Retryable(
message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status}")),
));
}
if status >= 400 {
let (_, message) = parse_server_error(status, &text);
return Err(ChunkUploadError::Fatal(
message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status}")),
));
}
let parsed: Value = serde_json::from_str(&text)
.map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON{error}")))?;
let payload = parsed.get("data").unwrap_or(&parsed);
payload
.get("receivedBytes")
.and_then(Value::as_u64)
.ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string()))
}
enum ChunkUploadError {
Retryable(String),
Fatal(String),
}
async fn complete_upload(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
access_token: &str,
idempotency_key: &str,
) -> Result<GamePackageUploadOutcome, String> {
let response = platform_request(
client,
access_token,
reqwest::Method::POST,
&upload_url(base_url, version_id, "/complete"),
)
.header("Idempotency-Key", format!("{idempotency_key}:complete"))
.send()
.await
.map_err(|error| format!("完成发行包上传失败:{error}"))?;
let status = response.status().as_u16();
let text = response
.text()
.await
.map_err(|error| format!("读取完成响应失败:{error}"))?;
if status >= 400 {
let (_, message) = parse_server_error(status, &text);
return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status}")));
}
let parsed: Value =
serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON{error}"))?;
let payload = parsed.get("data").unwrap_or(&parsed);
Ok(GamePackageUploadOutcome {
version_id: payload
.get("versionId")
.and_then(Value::as_str)
.unwrap_or(version_id)
.to_string(),
status: payload
.get("status")
.and_then(Value::as_str)
.unwrap_or("uploaded")
.to_string(),
uploaded_bytes: 0,
})
}
pub(crate) struct GamePackageUploadRequest<'a> {
pub(crate) staging_path: &'a Path,
pub(crate) version_id: &'a str,
pub(crate) api_base_url: &'a str,
pub(crate) access_token: &'a str,
pub(crate) idempotency_key: &'a str,
}
/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。
pub(crate) async fn upload_staged_game_package(
client: &reqwest::Client,
request: GamePackageUploadRequest<'_>,
mut on_progress: impl FnMut(u64, u64),
) -> Result<GamePackageUploadOutcome, String> {
let total_bytes = fs::metadata(request.staging_path)
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
.len();
if total_bytes == 0 {
return Err("发行包暂存文件为空,请重新导出试玩包".to_string());
}
let state = read_upload_state(
client,
request.api_base_url,
request.version_id,
request.access_token,
)
.await?;
if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes {
return Err(format!(
"本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布",
total_bytes, state.declared_package_bytes
));
}
let chunk_bytes = if state.chunk_bytes == 0 {
MAX_CHUNK_BYTES
} else {
state.chunk_bytes.min(MAX_CHUNK_BYTES)
};
let mut received = state.received_bytes.min(total_bytes);
on_progress(received, total_bytes);
let mut file = File::open(request.staging_path)
.map_err(|error| format!("打开发行包暂存文件失败:{error}"))?;
while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) {
let body = read_chunk(&mut file, plan)?;
let mut attempt = 1_usize;
loop {
match upload_chunk(
client,
request.api_base_url,
request.version_id,
request.access_token,
request.idempotency_key,
plan,
body.clone(),
)
.await
{
Ok(next_received) => {
received = next_received.min(total_bytes);
on_progress(received, total_bytes);
break;
}
Err(ChunkUploadError::Fatal(error)) => return Err(error),
Err(ChunkUploadError::Retryable(error)) => {
if attempt >= CHUNK_MAX_ATTEMPTS {
return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)"));
}
attempt += 1;
tokio::time::sleep(CHUNK_RETRY_DELAY).await;
}
}
}
// 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。
let authoritative = read_upload_state(
client,
request.api_base_url,
request.version_id,
request.access_token,
)
.await?;
received = authoritative.received_bytes.min(total_bytes);
on_progress(received, total_bytes);
}
let mut outcome = complete_upload(
client,
request.api_base_url,
request.version_id,
request.access_token,
request.idempotency_key,
)
.await?;
outcome.uploaded_bytes = total_bytes;
Ok(outcome)
}
/// 进度事件的载荷形状(渲染进程按它显示进度)。
pub(crate) fn progress_event_payload(
version_id: &str,
received_bytes: u64,
total_bytes: u64,
) -> Value {
json!({
"versionId": version_id,
"receivedBytes": received_bytes,
"totalBytes": total_bytes,
})
}
#[cfg(test)]
mod tests {
use super::*;

Some files were not shown because too many files have changed in this diff Show More