From c2c5e1ced549ccb8f7e6a1961ea7ed4ccada506a Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:04:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E5=88=B0=20200=20MiB=20=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20AGC=20=E5=88=86=E7=89=87=E7=BB=AD=E4=BC=A0=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20-=20=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A=E9=99=90?= =?UTF-8?q?=20100=E2=86=92200=20MiB=E3=80=81=E5=B1=95=E5=BC=80=E6=80=BB?= =?UTF-8?q?=E9=87=8F=20250=E2=86=92500=20MiB=EF=BC=8C=E6=95=B4=E5=8C=85?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E8=AF=B7=E6=B1=82=E4=BD=93=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E4=BB=8E=E5=8C=85=E4=B8=8A=E9=99=90=E6=B4=BE?= =?UTF-8?q?=E7=94=9F=EF=BC=9B=E5=8F=91=E8=A1=8C=E9=9D=99=E6=80=81=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E8=BF=9B=E7=A8=8B=E5=86=85=E7=BC=93=E5=AD=98=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E6=8F=90=E5=88=B0=20256=20MiB=20-=20=E5=8F=8D?= =?UTF-8?q?=E4=BB=A3=E6=94=BE=E8=A1=8C=E9=87=8F=E5=90=8C=E6=AD=A5=E6=94=BE?= =?UTF-8?q?=E5=AE=BD=E5=88=B0=20210=20MiB=EF=BC=9ANginx=20=E4=B8=89?= =?UTF-8?q?=E4=BB=BD=E6=A8=A1=E6=9D=BF=E7=9A=84=20client=5Fmax=5Fbody=5Fsi?= =?UTF-8?q?ze=E3=80=81Pingora=20=E7=BD=91=E5=85=B3=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=80=BC=E4=B8=8E=20env=20=E6=A0=B7=E4=BE=8B=E3=80=81=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=AF=B9=E7=85=A7=E7=9F=A9=E9=98=B5=20-=20platform-os?= =?UTF-8?q?s=20=E6=96=B0=E5=A2=9E=E5=86=85=E9=83=A8=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E5=86=99=20append=5Finternal=5Fobject(=5Fwit?= =?UTF-8?q?h=5Fretry)=EF=BC=8C=E4=BB=A5=20OSS=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E7=9A=84=20next-append-position=20=E4=BD=9C=E4=B8=BA=E6=9D=83?= =?UTF-8?q?=E5=A8=81=E5=B7=B2=E6=94=B6=E5=AD=97=E8=8A=82=20-=20api-server?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=20upload-state=20/=20chunk=20/=20complete?= =?UTF-8?q?=20/=20reset=20=E5=9B=9B=E6=9D=A1=E5=88=86=E7=89=87=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=EF=BC=8C=E6=8A=BD=E5=87=BA=E5=85=B1=E4=BA=AB=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=20confirm=5Fvalidated=5Fpackage=EF=BC=9B=E5=81=8F?= =?UTF-8?q?=E7=A7=BB=E4=B8=8D=E7=AC=A6=E8=BF=94=E5=9B=9E=20409=20=E4=B8=8E?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E5=81=8F=E7=A7=BB=EF=BC=8C=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=88=A0=E9=99=A4=E5=8D=8A=E5=8C=85=E5=B9=B6?= =?UTF-8?q?=E8=90=BD=20upload=5Ffailed=20-=20AGC=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E4=B8=8A=E4=BC=A0=E5=99=A8=20game=5Fpackage?= =?UTF-8?q?=5Fupload.rs=EF=BC=88=E5=86=85=E5=AE=B9=E5=AF=BB=E5=9D=80?= =?UTF-8?q?=E6=9A=82=E5=AD=98=E3=80=81=E5=88=86=E7=89=87=E7=BB=AD=E4=BC=A0?= =?UTF-8?q?=E3=80=81=E5=8F=97=E6=8E=A7=E9=87=8D=E8=AF=95=E3=80=81=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E4=BA=8B=E4=BB=B6=EF=BC=89=E4=B8=8E=20prepare=20/=20u?= =?UTF-8?q?pload=20=E4=B8=A4=E6=9D=A1=E5=91=BD=E4=BB=A4=EF=BC=8C=E9=80=80?= =?UTF-8?q?=E5=BD=B9=E6=95=B4=E5=8C=85=E5=9B=9E=E4=BC=A0=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=20-=20=E6=B8=B2=E6=9F=93=E8=BF=9B=E7=A8=8B=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20prepare=20=E2=86=92=20=E5=88=9B=E5=BB=BA=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=20=E2=86=92=20=E5=88=9B=E5=BB=BA=E7=89=88=E6=9C=AC=20=E2=86=92?= =?UTF-8?q?=20=E5=8E=9F=E7=94=9F=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=20?= =?UTF-8?q?=E2=86=92=20=E9=80=81=E5=AE=A1=EF=BC=8CLocalProjectExportPackag?= =?UTF-8?q?ePayload=20=E6=95=B4=E5=8C=85=E7=B1=BB=E5=9E=8B=E9=80=80?= =?UTF-8?q?=E5=BD=B9=20-=20=E5=90=8C=E6=97=B6=E4=BF=AE=E6=AD=A3=20live=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=97=A0=E6=B3=95=E6=8C=87=E5=90=91=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=A0=88=E7=9A=84=E4=B8=A4=E5=A4=84=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=AE=BE=E6=96=BD=E9=97=AE=E9=A2=98=EF=BC=9A=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=9F=BA=E5=9D=80=E6=8C=89=E4=BC=A0=E5=85=A5=20URL=20=E9=80=89?= =?UTF-8?q?=E6=8B=A9=EF=BC=8C=E6=A1=A5=E6=8E=A5=E5=B1=82=E6=8A=8A=20jsdom?= =?UTF-8?q?=20realm=20=E7=9A=84=20Headers=20/=20Blob=20/=20FormData=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E6=88=90=20Node=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=80=BC=20-=20=E6=B5=8B=E8=AF=95=EF=BC=9Aplatform-oss=2074?= =?UTF-8?q?=E3=80=81api-server=20game=5Fdistribution=2023=E3=80=81AGC=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=204=E3=80=81=E5=8F=91=E5=B8=83=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=89=8D=E7=AB=AF=2020=EF=BC=9Blive=20=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E8=A1=A5=E7=9C=9F=E5=AE=9E=E6=A0=88=E3=80=8C=E4=B8=AD?= =?UTF-8?q?=E6=96=AD=20=E2=86=92=20=E7=BB=AD=E4=BC=A0=20=E2=86=92=20?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E3=80=8D=E6=96=AD=E8=A8=80=EF=BC=88=E5=88=86?= =?UTF-8?q?=E7=89=87=E5=81=8F=E7=A7=BB=E5=BA=8F=E5=88=97=20[0,=208388608]?= =?UTF-8?q?=EF=BC=89=20-=20=E6=96=87=E6=A1=A3=EF=BC=9A=E7=8E=A9=E6=B3=95?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E4=B8=BB=E8=A7=84=E8=8C=83=E7=9A=84=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=90=88=E5=90=8C=E3=80=81=E8=BF=90=E7=BB=B4=E4=B8=8E?= =?UTF-8?q?=20Pingora=20=E6=96=87=E6=A1=A3=E3=80=81=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E3=80=81=E5=8F=91=E8=A1=8C=E9=87=8C=E7=A8=8B?= =?UTF-8?q?=E7=A2=91=E5=8F=A3=E5=BE=84=EF=BC=8C=E4=BB=A5=E5=8F=8A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9A=84=E7=BB=AD=E4=BC=A0=E9=87=8C=E7=A8=8B=E7=A2=91?= =?UTF-8?q?=E4=B8=8E=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/commands.rs | 73 ++- .../src-tauri/src/game_package_upload.rs | 533 +++++++++++++++++ .../src-tauri/src/main.rs | 4 +- apps/ai-game-creator-shell/src/app/types.ts | 17 +- .../src/services/gameDistributionPublish.ts | 60 +- .../tests/gameDistributionPublish.test.ts | 78 +-- .../tests/gameDistributionPublishLive.test.ts | 296 +++++++++- deploy/container/nginx.conf | 5 +- deploy/nginx/README.md | 4 +- deploy/nginx/genarrative-dev-http.conf | 5 +- deploy/nginx/genarrative.conf | 5 +- deploy/pingora/nginx-route-parity.matrix.json | 4 +- deploy/pingora/pingora-gateway.env.example | 2 +- ...žæ–½è®¡åˆ’】AGCå‘行包分片续传上传-2026-09-23.md | 49 ++ ...里程碑】AGCå‘行包分片续传上传-2026-09-23.md | 58 ++ ...‹ç¢‘】游æˆåˆ†å‘目录详情与在线游玩-2026-09-18.md | 2 +- .../shared-memory/decision-log.md | 15 + ...å¼€å‘è¿ç»´ã€‘Pingora独立网关试点-2026-06-11.md | 2 +- ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 2 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 7 +- .../src/modules/game_distribution.rs | 536 ++++++++++++++++-- .../module-game-distribution/src/package.rs | 49 +- server-rs/crates/pingora-gateway/src/main.rs | 4 +- server-rs/crates/platform-oss/src/lib.rs | 124 +++- .../game-distribution/gameZipPackage.test.ts | 10 +- .../game-distribution/gameZipPackage.ts | 4 +- 26 files changed, 1779 insertions(+), 169 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs create mode 100644 docs/project-memory/plans/ã€å®žæ–½è®¡åˆ’】AGCå‘行包分片续传上传-2026-09-23.md create mode 100644 docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】AGCå‘行包分片续传上传-2026-09-23.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a18bdb369..4dbb39ad7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5935,14 +5935,81 @@ pub(crate) async fn export_local_project_package( export_local_project_package_for_publish_at(root).await } +/// 把归一化åŽçš„å‘行包è½åˆ°å†…容寻å€çš„æš‚存文件,返回分片续传所需的元数æ®ã€‚ +/// +/// å‘å¸ƒé“¾è·¯ä»Žæ­¤åªæŠŠã€Œæš‚å­˜è·¯å¾„ + æ‘˜è¦ + 体积ã€äº¤ç»™æ¸²æŸ“进程:整包字节ä¸å†ç»è¿‡ +/// WebView IPC,续传时也å¤ç”¨åŒä¸€ä¸ªæš‚存文件(åŒååŒå†…容)。 #[tauri::command] -pub(crate) fn read_local_project_export_package( +pub(crate) fn prepare_local_project_game_package( + app: tauri::AppHandle, project_path: String, package_relative_path: String, -) -> Result { +) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.export_package")?; - read_local_project_export_package_at(root, package_relative_path.trim()) + let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; + let staging_dir = game_package_upload_staging_dir(&app)?; + let mut staged = crate::game_package_upload::stage_game_package_bytes( + &staging_dir, + &payload.package_sha256, + &payload.package_bytes, + )?; + staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); + Ok(staged) +} + +/// 分片续传上传暂存的å‘行包;进度通过 `game-package-upload-progress` 事件回传。 +#[tauri::command] +pub(crate) async fn upload_local_project_game_package( + app: tauri::AppHandle, + staging_path: String, + version_id: String, + api_base_url: String, + access_token: String, + idempotency_key: String, +) -> Result { + let staging_dir = game_package_upload_staging_dir(&app)?; + let resolved_path = + crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; + let client = reqwest::Client::builder() + .build() + .map_err(|error| format!("创建上传客户端失败:{error}"))?; + let version_id = version_id.trim().to_string(); + if version_id.is_empty() { + return Err("缺少å‘行版本标识".to_string()); + } + let emit_handle = app.clone(); + let progress_version_id = version_id.clone(); + crate::game_package_upload::upload_staged_game_package( + &client, + crate::game_package_upload::GamePackageUploadRequest { + staging_path: &resolved_path, + version_id: &version_id, + api_base_url: api_base_url.trim(), + access_token: access_token.trim(), + idempotency_key: idempotency_key.trim(), + }, + move |received_bytes, total_bytes| { + let _ = emit_handle.emit( + crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, + crate::game_package_upload::progress_event_payload( + &progress_version_id, + received_bytes, + total_bytes, + ), + ); + }, + ) + .await +} + +fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|app_data_root| { + crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) + }) + .map_err(|error| format!("æ— æ³•è¯»å– AGC 应用数æ®ç›®å½•:{error}")) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs new file mode 100644 index 000000000..c52745b45 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -0,0 +1,533 @@ +//! AGC å‘行包的分片续传上传。 +//! +//! 一键å‘布ä¸å†æŠŠæ•´åŒ…字节交给 WebViewï¼šè¿™é‡Œåœ¨åŽŸç”Ÿè¿›ç¨‹ä¸­è¯»å–æœ¬åœ°ï¼ˆæˆ–暂存)的试玩包, +//! 按æœåŠ¡ç«¯ä¸‹å‘的分片大å°é¡ºåºå‘é€ï¼Œå¹¶ä»¥æœåŠ¡ç«¯è¿”å›žçš„**æƒå¨åç§»**续传;进度通过事件 +//! 回传渲染进程。传输失败ã€åº”用é‡å¯åŽé‡æ–°å‘布都åªè¡¥ä¼ ç¼ºå¤±å­—节,ä¸é‡æ”¾æ•´åŒ…。 + +use std::{ + fs::{self, File}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::{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"; + +/// 已暂存(归一化åŽï¼‰çš„å‘行包:内容寻å€ï¼Œé‡å¯åŽåŒä¸€åŒ…å¤ç”¨åŒä¸€ä¸ªæ–‡ä»¶ï¼Œç»­ä¼ æ‰æœ‰æ„义。 +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StagedGamePackage { + pub(crate) staging_path: String, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + 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, + package_sha256: &str, + package_bytes: &[u8], +) -> Result { + if package_bytes.is_empty() { + return Err("å‘è¡ŒåŒ…å†…å®¹ä¸ºç©ºï¼Œè¯·é‡æ–°å¯¼å‡ºè¯•玩包".to_string()); + } + if package_sha256.len() != 64 || !package_sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("å‘è¡ŒåŒ…æ‘˜è¦æ— æ•ˆï¼Œè¯·é‡æ–°å¯¼å‡ºè¯•玩包".to_string()); + } + fs::create_dir_all(staging_dir).map_err(|error| format!("创建å‘行包暂存目录失败:{error}"))?; + let staging_path = staging_dir.join(format!("{package_sha256}.zip")); + if !staging_path.is_file() { + let temporary_path = staging_dir.join(format!("{package_sha256}.zip.tmp")); + let mut file = File::create(&temporary_path) + .map_err(|error| format!("写入å‘行包暂存文件失败:{error}"))?; + file.write_all(package_bytes) + .map_err(|error| format!("写入å‘行包暂存文件失败:{error}"))?; + file.sync_all() + .map_err(|error| format!("刷写å‘行包暂存文件失败:{error}"))?; + drop(file); + fs::rename(&temporary_path, &staging_path) + .map_err(|error| format!("æäº¤å‘行包暂存文件失败:{error}"))?; + } + Ok(StagedGamePackage { + staging_path: staging_path.to_string_lossy().to_string(), + package_sha256: package_sha256.to_string(), + package_size_bytes: package_bytes.len() as u64, + package_file_count: 0, + }) +} + +/// 暂存文件必须è½åœ¨æš‚存目录内;渲染进程ä¸èƒ½å€Ÿè¿™æ¡å‘½ä»¤è¯»ä»»æ„路径。 +pub(crate) fn ensure_staging_path_in_dir( + staging_dir: &Path, + staging_path: &str, +) -> Result { + let candidate = PathBuf::from(staging_path.trim()); + let parent = candidate + .parent() + .ok_or_else(|| "å‘行包暂存路径无效".to_string())?; + let canonical_parent = parent + .canonicalize() + .map_err(|error| format!("å‘行包暂存目录ä¸å¯ç”¨ï¼š{error}"))?; + let canonical_dir = staging_dir + .canonicalize() + .map_err(|error| format!("å‘行包暂存目录ä¸å¯ç”¨ï¼š{error}"))?; + if canonical_parent != canonical_dir { + return Err("å‘行包暂存路径越界".to_string()); + } + if !candidate.is_file() { + return Err("å‘行包暂存文件ä¸å­˜åœ¨ï¼Œè¯·é‡æ–°å¯¼å‡ºè¯•玩包".to_string()); + } + Ok(candidate) +} + +/// 下一次è¦å‘é€çš„分片;`None` 表示整包已收é½ã€‚ +fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option { + if received >= total || chunk_bytes == 0 { + return None; + } + Some(ChunkPlan { + offset: received, + length: chunk_bytes.min(total - received), + }) +} + +fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result, 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}", + base_url.trim_end_matches('/') + ) +} + +/// æœåŠ¡ç«¯é”™è¯¯ä¿¡å°ï¼šå…¼å®¹å¸¦ envelope çš„ `{ error: { code, message } }` 与旧形状。 +fn parse_server_error(status: u16, body: &str) -> (Option, Option) { + let parsed = serde_json::from_str::(body).ok(); + let error = parsed + .as_ref() + .and_then(|value| value.get("error")) + .cloned(); + let code = error + .as_ref() + .and_then(|value| value.get("code")) + .or_else(|| parsed.as_ref().and_then(|value| value.get("code"))) + .and_then(Value::as_str) + .map(str::to_string); + let message = error + .as_ref() + .and_then(|value| value.get("message")) + .or_else(|| parsed.as_ref().and_then(|value| value.get("message"))) + .and_then(Value::as_str) + .map(str::to_string); + let _ = status; + (code, message) +} + +/// 从 409 å“åº”é‡Œå–æƒå¨å·²æ”¶å­—节;å–ä¸åˆ°å°±è¿”回 `None`,由调用方按失败处ç†ã€‚ +fn parse_received_bytes(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok()?; + let error = parsed.get("error").unwrap_or(&parsed); + error + .get("details") + .and_then(|details| details.get("receivedBytes")) + .or_else(|| error.get("receivedBytes")) + .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 { + 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, +) -> Result { + 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 { + 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 { + 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::*; + + #[test] + fn chunk_plan_covers_whole_package_without_gaps_or_overlap() { + let total = 20 * 1024 * 1024 + 3; + let chunk = 8 * 1024 * 1024; + let mut received = 0_u64; + let mut plans = Vec::new(); + while let Some(plan) = next_chunk_plan(received, total, chunk) { + plans.push(plan); + received += plan.length; + } + assert_eq!(received, total); + assert_eq!(plans.len(), 3); + assert_eq!(plans[0].offset, 0); + assert_eq!(plans[1].offset, chunk); + assert_eq!(plans[2].length, 4 * 1024 * 1024 + 3); + assert!(next_chunk_plan(total, total, chunk).is_none()); + assert!(next_chunk_plan(0, total, 0).is_none()); + } + + #[test] + fn offset_mismatch_response_yields_authoritative_position() { + let body = r#"{"error":{"code":"PACKAGE_UPLOAD_OFFSET_MISMATCH","message":"åç§»ä¸ä¸€è‡´","details":{"provider":"game-distribution","receivedBytes":16777216}}}"#; + assert_eq!(parse_received_bytes(body), Some(16_777_216)); + let (code, message) = parse_server_error(409, body); + assert_eq!(code.as_deref(), Some("PACKAGE_UPLOAD_OFFSET_MISMATCH")); + assert_eq!(message.as_deref(), Some("åç§»ä¸ä¸€è‡´")); + + let legacy = + r#"{"error":{"code":"PACKAGE_UPLOAD_INCOMPLETE","message":"未收é½"},"meta":{}}"#; + assert_eq!(parse_received_bytes(legacy), None); + assert_eq!( + parse_server_error(409, legacy).0.as_deref(), + Some("PACKAGE_UPLOAD_INCOMPLETE") + ); + } + + #[test] + fn upload_url_joins_base_without_double_slash() { + assert_eq!( + upload_url("https://dev.genarrative.world/", "ver-1", "/chunk"), + "https://dev.genarrative.world/api/game-distribution/versions/ver-1/package/chunk" + ); + assert_eq!( + upload_url("http://127.0.0.1:10001", "ver 1", "/complete"), + "http://127.0.0.1:10001/api/game-distribution/versions/ver 1/package/complete" + ); + } + + #[test] + fn staging_file_is_content_addressed_and_reused() { + let dir = std::env::temp_dir().join(format!( + "agc-game-package-staging-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + let sha = "a".repeat(64); + let first = stage_game_package_bytes(&dir, &sha, b"package-bytes").expect("首次暂存应æˆåŠŸ"); + let second = + stage_game_package_bytes(&dir, &sha, b"package-bytes").expect("é‡å¤æš‚存应å¤ç”¨"); + assert_eq!(first.staging_path, second.staging_path); + assert_eq!(first.package_size_bytes, 13); + assert!(Path::new(&first.staging_path).is_file()); + assert_eq!( + fs::read(&first.staging_path).expect("è¯»å–æš‚存文件"), + b"package-bytes" + ); + + let bad = stage_game_package_bytes(&dir, "not-a-sha", b"x"); + assert!(bad.is_err()); + let empty = stage_game_package_bytes(&dir, &sha, b""); + assert!(empty.is_err()); + + let outside = ensure_staging_path_in_dir(&dir, "/etc/passwd"); + assert!(outside.is_err()); + let inside = ensure_staging_path_in_dir(&dir, &first.staging_path); + assert!(inside.is_ok()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 256c38c3d..3b356fcd6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -132,6 +132,7 @@ mod editor_adapter; mod editor_adapters; mod environment_check; pub mod error_report; +mod game_package_upload; mod git_inspect; mod goal; mod http_client; @@ -2757,7 +2758,8 @@ fn main() { build_local_project_index, create_local_project_checkpoint, export_local_project_package, - read_local_project_export_package, + prepare_local_project_game_package, + upload_local_project_game_package, list_local_project_export_packages, diff_local_project_checkpoint, restore_local_project_checkpoint, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d1a93d69f..73f45325f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -796,12 +796,21 @@ export interface LocalProjectExportPackageFileDigest { sha256: string; } -export interface LocalProjectExportPackagePayload { - packageRelativePath: string; - packageBytes: number[]; +/** + * 已暂存的归一化å‘行包:å‘布链路åªä¼ é€’这个摘è¦ä¸Žè·¯å¾„,整包字节留在原生进程里, + * ä¸å†ç»è¿‡ WebView IPC。 + */ +export interface StagedGamePackage { + stagingPath: string; packageSha256: string; packageSizeBytes: number; - files: LocalProjectExportPackageFileDigest[]; + packageFileCount: number; +} + +export interface GamePackageUploadOutcome { + versionId: string; + status: string; + uploadedBytes: number; } export interface LocalProjectExportPackageSummary { diff --git a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts index 5e1a96402..993ccd754 100644 --- a/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts +++ b/apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts @@ -7,10 +7,12 @@ import type { GameDistributionOrientation, } from '../../../../packages/shared/src/contracts/gameDistribution'; import type { - LocalProjectExportPackagePayload, + GamePackageUploadOutcome, + StagedGamePackage, TauriInvoke, } from '../app/types'; -import { requestClientApi } from './clientApi'; +import { getStoredAuthAccessToken, requestClientApi } from './clientApi'; +import { getClientServerBaseUrl } from './clientHttp'; export type GameDistributionPublishMetadata = { title: string; @@ -328,20 +330,20 @@ export async function publishLocalProjectGame(args: { if (!projectPath || !packageRelativePath) { throw new Error('å‘布需è¦ç»‘定本地项目和试玩包'); } - const payload = await args.invoke( - 'read_local_project_export_package', + // 整包字节åªç•™åœ¨åŽŸç”Ÿè¿›ç¨‹ï¼šè¿™é‡Œæ‹¿åˆ°çš„æ˜¯å½’ä¸€åŒ–åŽçš„æ‘˜è¦ä¸Žå†…容坻倿š‚存路径, + // 上传由原生侧按æœåŠ¡ç«¯åˆ†ç‰‡å¤§å°å®Œæˆï¼Œä¸­æ–­åŽåŒä¸€æš‚存文件å¯ç›´æŽ¥ç»­ä¼ ã€‚ + const staged = await args.invoke( + 'prepare_local_project_game_package', { projectPath, packageRelativePath }, ); if ( - !payload.packageBytes.length || - payload.packageSizeBytes !== payload.packageBytes.length || - payload.files.length === 0 + !staged.stagingPath.trim() || + staged.packageSha256.length !== 64 || + staged.packageSizeBytes <= 0 || + staged.packageFileCount <= 0 ) { throw new Error('本地å‘è¡ŒåŒ…æ‘˜è¦æ— æ•ˆï¼Œè¯·é‡æ–°å¯¼å‡ºè¯•玩包'); } - if (payload.packageRelativePath !== packageRelativePath) { - throw new Error('本地å‘行包路径已å˜åŒ–ï¼Œè¯·é‡æ–°å¯¼å‡ºè¯•玩包'); - } const metadata = normalizeMetadata(args.manifest, args.metadata); const localProjectId = args.manifest.projectId.trim(); @@ -369,9 +371,9 @@ export async function publishLocalProjectGame(args: { const versionRequest: GameDistributionCreateVersionRequest = { localProjectId, - packageSha256: payload.packageSha256, - packageBytes: payload.packageSizeBytes, - packageFileCount: payload.files.length, + packageSha256: staged.packageSha256, + packageBytes: staged.packageSizeBytes, + packageFileCount: staged.packageFileCount, packageEntryPath: 'index.html', gameMetadata, }; @@ -391,23 +393,19 @@ export async function publishLocalProjectGame(args: { throw new Error('创建å‘行版本未返回版本 ID'); } - const packageBody = new Blob([new Uint8Array(payload.packageBytes)], { - type: 'application/zip', - }); - const uploaded = await requestClientApi<{ - versionId: string; - status: string; - }>( - `/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/package`, + const accessToken = getStoredAuthAccessToken(); + if (!accessToken) { + throw new Error('陶泥儿登录凭æ®ç¼ºå¤±ï¼Œè¯·é‡æ–°ç™»å½•'); + } + const uploaded = await args.invoke( + 'upload_local_project_game_package', { - method: 'PUT', - headers: { - 'Content-Type': 'application/zip', - 'Idempotency-Key': `${rootKey}:upload`, - }, - body: packageBody, + stagingPath: staged.stagingPath, + versionId: version.versionId, + apiBaseUrl: getClientServerBaseUrl(), + accessToken, + idempotencyKey: `${rootKey}:upload`, }, - '上传游æˆå‘行包失败', ); const submitted = await requestClientApi<{ game?: { publicationRevision?: number }; @@ -431,8 +429,8 @@ export async function publishLocalProjectGame(args: { versionId: version.versionId, versionNumber: version.versionNumber, status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review', - packageSha256: payload.packageSha256, - packageSizeBytes: payload.packageSizeBytes, - fileCount: payload.files.length, + packageSha256: staged.packageSha256, + packageSizeBytes: staged.packageSizeBytes, + fileCount: staged.packageFileCount, }; } diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts index 9110172ea..e4aa8e332 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts @@ -14,6 +14,12 @@ vi.mock('../src/services/errorReporting', () => ({ captureClientError: vi.fn(), })); +// 原生侧上传需è¦ç™»å½•凭æ®ï¼›è¿™é‡Œåªé’‰ä½ã€Œå–到了 tokenã€è¿™ä¸€ä»¶äº‹ã€‚ +vi.mock('../src/services/clientApi', async (importOriginal) => ({ + ...(await importOriginal()), + getStoredAuthAccessToken: () => 'test-access-token', +})); + import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { generateGameDistributionCover, @@ -29,6 +35,14 @@ const MANIFEST = { goal: '守ä½è½¨é“城', } as unknown as GameCreationAppManifest; +/** 归一化å‘行包的暂存摘è¦ï¼›å‘布链路åªåº”传递它,ä¸å†ä¼ æ•´åŒ…字节。 */ +const STAGED_PACKAGE = { + stagingPath: 'C:/app-data/game-package-staging/aaaa.zip', + packageSha256: 'a'.repeat(64), + packageSizeBytes: 1024, + packageFileCount: 1, +}; + function jsonResponse(payload: unknown) { return new Response( JSON.stringify({ @@ -66,23 +80,25 @@ test('å‘布时æºå¸¦æœ¬åœ°é¡¹ç›®æ ‡è¯†ï¼Œè®©é‡å¤å‘布å¤ç”¨åŒä¸€ä¸ªå¹³å° status: 'awaiting_upload', }), ) - .mockResolvedValueOnce( - jsonResponse({ versionId: 'gamever_1', status: 'uploaded' }), - ) .mockResolvedValueOnce( jsonResponse({ version: { status: 'pending_review' } }), ); + const invokeCalls: Array<{ command: string; args: unknown }> = []; const result = await publishLocalProjectGame({ - invoke: (async (command: string) => { - expect(command).toBe('read_local_project_export_package'); - return { - packageRelativePath: 'exports/playtest-package-1.zip', - packageBytes: [1, 2, 3], - packageSha256: 'a'.repeat(64), - packageSizeBytes: 3, - files: [{ path: 'index.html', sizeBytes: 3, sha256: 'a'.repeat(64) }], - }; + invoke: (async (command: string, args?: Record) => { + invokeCalls.push({ command, args }); + if (command === 'prepare_local_project_game_package') { + return STAGED_PACKAGE; + } + if (command === 'upload_local_project_game_package') { + return { + versionId: 'gamever_1', + status: 'uploaded', + uploadedBytes: STAGED_PACKAGE.packageSizeBytes, + }; + } + throw new Error(`未预期的命令:${command}`); }) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', @@ -113,18 +129,26 @@ test('å‘布时æºå¸¦æœ¬åœ°é¡¹ç›®æ ‡è¯†ï¼Œè®©é‡å¤å‘布å¤ç”¨åŒä¸€ä¸ªå¹³å° expect(result.gameId).toBe('game_1'); expect(result.versionId).toBe('gamever_1'); + + // 关键回归:整包字节ä¸å†ç»è¿‡ IPC,上传交给原生侧按版本 ID + 暂存路径完æˆã€‚ + const uploadCall = invokeCalls.find( + (call) => call.command === 'upload_local_project_game_package', + ); + expect(uploadCall?.args).toMatchObject({ + stagingPath: STAGED_PACKAGE.stagingPath, + versionId: 'gamever_1', + apiBaseUrl: 'https://dev.genarrative.world', + accessToken: 'test-access-token', + }); + expect(Object.keys(uploadCall?.args ?? {})).not.toContain('packageBytes'); + // 三次 HTTP:创建游æˆã€åˆ›å»ºç‰ˆæœ¬ã€é€å®¡ï¼›ä¸Šä¼ ä¸å†å ç”¨ä¸€æ¡ HTTP 调用。 + expect(fetchClientHttp).toHaveBeenCalledTimes(3); }); test('缺少本地项目标识时在å‘起请求å‰å¤±è´¥å…³é—­', async () => { await expect( publishLocalProjectGame({ - invoke: (async () => ({ - packageRelativePath: 'exports/playtest-package-1.zip', - packageBytes: [1], - packageSha256: 'a'.repeat(64), - packageSizeBytes: 1, - files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }], - })) as never, + invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest, @@ -137,13 +161,7 @@ test('缺少本地项目标识时在å‘起请求å‰å¤±è´¥å…³é—­', async () => { test('缺少å°é¢æ—¶åœ¨åˆ›å»ºæ¸¸æˆå‰å¤±è´¥å…³é—­', async () => { await expect( publishLocalProjectGame({ - invoke: (async () => ({ - packageRelativePath: 'exports/playtest-package-1.zip', - packageBytes: [1], - packageSha256: 'a'.repeat(64), - packageSizeBytes: 1, - files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }], - })) as never, + invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, @@ -156,13 +174,7 @@ test('缺少å°é¢æ—¶åœ¨åˆ›å»ºæ¸¸æˆå‰å¤±è´¥å…³é—­', async () => { test('截图超过 6 张时在创建游æˆå‰å¤±è´¥å…³é—­', async () => { await expect( publishLocalProjectGame({ - invoke: (async () => ({ - packageRelativePath: 'exports/playtest-package-1.zip', - packageBytes: [1], - packageSha256: 'a'.repeat(64), - packageSizeBytes: 1, - files: [{ path: 'index.html', sizeBytes: 1, sha256: 'a'.repeat(64) }], - })) as never, + invoke: (async () => STAGED_PACKAGE) as never, projectPath: '/tmp/project', packageRelativePath: 'exports/playtest-package-1.zip', manifest: MANIFEST, diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index 552b6c26c..b79228dd1 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -7,18 +7,23 @@ * npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts * * å¼€å¯åŽæµ‹è¯•会注册一个临时作者,并通过真实的 `clientApi` / `clientHttp`ï¼ˆè€Œä¸æ˜¯ - * mock 请求层)调用 AGC çš„å‘布函数,覆盖:本地导出包读å–ã€åˆ›å»ºæ¸¸æˆã€åŒ - * `localProjectId` å¤ç”¨æ¸¸æˆèº«ä»½ã€çœŸå®ž ZIP 上传ã€é€å®¡ä¸Žç‰ˆæœ¬å›žè¯»ã€‚ + * mock 请求层)调用 AGC çš„å‘布函数,覆盖:本地å‘行包暂存摘è¦ã€åˆ›å»ºæ¸¸æˆã€åŒ + * `localProjectId` å¤ç”¨æ¸¸æˆèº«ä»½ã€çœŸå®žåˆ†ç‰‡ä¸Šä¼ ã€é€å®¡ä¸Žç‰ˆæœ¬å›žè¯»ã€‚ + * + * jsdom 里没有 Tauri è¿è¡Œæ—¶ï¼Œ`upload_local_project_game_package` 由本测试按æœåŠ¡ç«¯ + * 分片å议(upload-state → chunk → complete)代跑,等åŒäºŽåŽŸç”Ÿä¸Šä¼ å™¨çš„è¡Œä¸ºï¼› + * åŽŸç”Ÿå®žçŽ°è‡ªèº«çš„åˆ†ç‰‡è§„åˆ’ã€æƒå¨å移续传与错误分类在 Rust 啿µ‹é‡Œè¦†ç›–。 */ -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import JSZip from 'jszip'; import { expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { LocalProjectExportPackagePayload } from '../src/app/types'; +import type { StagedGamePackage } from '../src/app/types'; import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { setStoredAuthAccessToken } from '../src/services/clientAuth'; +import { setClientServerSelection } from '../src/services/clientHttp'; import { publishLocalProjectGame } from '../src/services/gameDistributionPublish'; /** 1x1 逿˜Ž PNGï¼šçœŸå®žä¸Šä¼ ä¸€å¼ åˆæ³•图片作为å°é¢ï¼Œé¿å…ä¾èµ–æœ¬åœ°ç´ ææ–‡ä»¶ã€‚ */ @@ -37,6 +42,12 @@ const liveBaseUrl = (process.env.GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL ?? '') .replace(/\/+$/u, ''); const liveTest = liveBaseUrl ? test : test.skip; +// AGC æœåŠ¡é»˜è®¤æŒ‰æ¸ é“选 dev / release 域åï¼›è·‘çœŸå®žé“¾è·¯æ—¶æŠŠã€Œå¹³å°æœåС噍ã€åˆ‡åˆ°ä¼ å…¥çš„æœ¬åœ°æ ˆï¼Œ +// å¦åˆ™è¯·æ±‚会打到线上域åè€Œä¸æ˜¯è¿™å°æœºå™¨ä¸Šçš„ api-server。 +if (liveBaseUrl) { + setClientServerSelection({ preset: 'custom', customBaseUrl: liveBaseUrl }); +} + const realFetch = globalThis.fetch.bind(globalThis); const ENVELOPE_HEADERS = { 'x-genarrative-response-envelope': 'v1' }; @@ -55,21 +66,73 @@ function installFetchBridge() { : input instanceof URL ? input.toString() : input; + // jsdom realm çš„ Headers / AbortSignal / Blob éƒ½ä¸æ˜¯ undici è®¤å¾—çš„ç±»åž‹ï¼ˆåŒ 2026-09-20 + // é‚£æ¡ã€Œè·¨ realm BodyInit 被 undici æ‹’ç»ã€çš„å‘):统一é™çº§æˆ Node 侧能接å—的原生值。 + const headers = init?.headers + ? Object.fromEntries(Array.from(new Headers(init.headers).entries())) + : undefined; + const signal = undefined; const body = init?.body; + if (typeof FormData !== 'undefined' && body instanceof FormData) { + // jsdom çš„ FormData åŒæ ·ä¸è¢« undici 接å—:这里手工åºåˆ—åŒ–æˆ multipart 字节。 + const multipart = await serializeFormData(body); + return realFetch(url as string, { + ...init, + headers: { ...headers, 'Content-Type': multipart.contentType }, + signal, + body: multipart.body, + }); + } if (typeof Blob !== 'undefined' && body instanceof Blob) { // jsdom çš„ Blob/ArrayBuffer 属于å¦ä¸€ä¸ª realm,且旧版 jsdom 没有 // Blob.arrayBuffer;统一读æˆå­—节åŽå¤åˆ¶ä¸º Node ä¾§ Buffer å†è½¬å‘。 const bytes = await readBlobBytes(body); return realFetch(url as string, { ...init, + headers, + signal, body: Buffer.from(bytes), }); } - return realFetch(url as string, init); + return realFetch(url as string, { ...init, headers, signal }); }, ); } +async function serializeFormData(form: FormData): Promise<{ + body: Buffer; + contentType: string; +}> { + const boundary = `----agcLive${Date.now().toString(16)}`; + const chunks: Buffer[] = []; + for (const [name, value] of form.entries()) { + if (typeof value === 'string') { + chunks.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, + ), + ); + continue; + } + const bytes = await readBlobBytes(value); + const fileName = + (value as File).name || `agc-live-${Date.now().toString(16)}.bin`; + const contentType = value.type || 'application/octet-stream'; + chunks.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`, + ), + ); + chunks.push(Buffer.from(bytes)); + chunks.push(Buffer.from('\r\n')); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + return { + body: Buffer.concat(chunks), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + async function readBlobBytes(blob: Blob): Promise { const maybeArrayBuffer = ( blob as Blob & { arrayBuffer?: () => Promise } @@ -120,7 +183,10 @@ async function registerAuthor(): Promise { return data.token; } -async function buildExportPayload(): Promise { +async function buildStagedPackage(): Promise<{ + staged: StagedGamePackage; + bytes: Uint8Array; +}> { const zip = new JSZip(); const indexHtml = 'AGC Live' + @@ -129,28 +195,169 @@ async function buildExportPayload(): Promise { 'window.__agcLive=1;document.documentElement.dataset.booted="agc";'; zip.file('index.html', indexHtml); zip.file('assets/app.js', appJs); + // 让å‘行包超过å•个分片(8 MiBï¼‰ï¼šåˆ†ç‰‡ç»­ä¼ åªæœ‰è·¨ç‰‡æ‰æœ‰æ„ä¹‰ï¼Œéšæœºå­—节ä¿è¯ä¸å¯åŽ‹ç¼©ã€‚ + zip.file('assets/bulk.bin', randomBytes(9 * 1024 * 1024)); const bytes = await zip.generateAsync({ type: 'uint8array' }); const sha256 = createHash('sha256').update(bytes).digest('hex'); return { - packageRelativePath: 'dist/game.zip', - packageBytes: Array.from(bytes), - packageSha256: sha256, - packageSizeBytes: bytes.length, - files: [ - { - path: 'index.html', - sizeBytes: Buffer.byteLength(indexHtml), - sha256: createHash('sha256').update(indexHtml).digest('hex'), - }, - { - path: 'assets/app.js', - sizeBytes: Buffer.byteLength(appJs), - sha256: createHash('sha256').update(appJs).digest('hex'), - }, - ], + staged: { + stagingPath: '/tmp/agc-live-staging/game.zip', + packageSha256: sha256, + packageSizeBytes: bytes.length, + packageFileCount: 3, + }, + bytes, }; } +type PackageUploadState = { + receivedBytes: number; + chunkBytes: number; + declaredPackageBytes: number; +}; + +function packageAuthHeaders(token: string) { + return { + Authorization: `Bearer ${token}`, + ...ENVELOPE_HEADERS, + }; +} + +/** è¯»å–æœåŠ¡ç«¯æƒå¨å·²æ”¶å­—èŠ‚ï¼ˆåŽŸç”Ÿä¸Šä¼ å™¨åŒæ ·ä»¥å®ƒä¸ºå‡†ï¼‰ã€‚ */ +async function readPackageUploadState( + versionId: string, + token: string, +): Promise { + return await unwrap( + await realFetch( + apiUrl( + `/api/game-distribution/versions/${versionId}/package/upload-state`, + ), + { headers: packageAuthHeaders(token) }, + ), + ); +} + +/** 上传一个分片;å移由调用方按æƒå¨å移给出。 */ +async function uploadPackageChunk(input: { + versionId: string; + token: string; + idempotencyKey: string; + offset: number; + body: Uint8Array; +}): Promise { + const response = await realFetch( + apiUrl(`/api/game-distribution/versions/${input.versionId}/package/chunk`), + { + method: 'PUT', + headers: { + ...packageAuthHeaders(input.token), + 'Content-Type': 'application/octet-stream', + 'x-genarrative-upload-offset': String(input.offset), + 'Idempotency-Key': `${input.idempotencyKey}:chunk`, + }, + body: Buffer.from(input.body), + }, + ); + if (!response.ok) { + throw new Error( + `分片上传失败:${response.status} ${await response.text()}`, + ); + } + const payload = (await response.json()) as { + data?: { receivedBytes?: number }; + receivedBytes?: number; + }; + return payload.data?.receivedBytes ?? payload.receivedBytes ?? input.offset; +} + +async function completePackageUpload(input: { + versionId: string; + token: string; + idempotencyKey: string; +}) { + return await unwrap<{ versionId: string; status: string }>( + await realFetch( + apiUrl( + `/api/game-distribution/versions/${input.versionId}/package/complete`, + ), + { + method: 'POST', + headers: { + ...packageAuthHeaders(input.token), + 'Idempotency-Key': `${input.idempotencyKey}:complete`, + }, + }, + ), + ); +} + +/** 从æƒå¨å移继续å‘é€å‰©ä½™åˆ†ç‰‡ï¼Œè¿”回本次实际å‘é€è¿‡çš„åç§»åºåˆ—。 */ +async function uploadRemainingChunks(input: { + versionId: string; + bytes: Uint8Array; + token: string; + idempotencyKey: string; +}): Promise { + const state = await readPackageUploadState(input.versionId, input.token); + const sentOffsets: number[] = []; + let received = state.receivedBytes; + while (received < input.bytes.length) { + const length = Math.min(state.chunkBytes, input.bytes.length - received); + await uploadPackageChunk({ + versionId: input.versionId, + token: input.token, + idempotencyKey: input.idempotencyKey, + offset: received, + body: input.bytes.subarray(received, received + length), + }); + sentOffsets.push(received); + received = (await readPackageUploadState(input.versionId, input.token)) + .receivedBytes; + } + return sentOffsets; +} + +/** + * 按æœåŠ¡ç«¯åˆ†ç‰‡å议上传整包:与原生上传器åŒä¸€å¥—è¯·æ±‚å½¢çŠ¶ï¼Œç”¨äºŽéªŒè¯æœåŠ¡ç«¯åˆåŒã€‚ + * 第一次调用会**åªä¼ ç¬¬ä¸€ç‰‡å°±åœä¸‹**,模拟传输中断;åŽç»­è°ƒç”¨æŒ‰æƒå¨å移续传, + * å› æ­¤è¿™é‡Œèƒ½ç›´æŽ¥è¯æ˜Žã€Œä¸­æ–­åŽä¸é‡ä¼ å·²æ”¶å­—节ã€ã€‚ + */ +async function uploadStagedPackageViaProtocol(input: { + versionId: string; + bytes: Uint8Array; + token: string; + idempotencyKey: string; + sentOffsets: number[]; +}) { + const state = await readPackageUploadState(input.versionId, input.token); + if (state.receivedBytes === 0) { + const firstLength = Math.min(state.chunkBytes, input.bytes.length); + await uploadPackageChunk({ + versionId: input.versionId, + token: input.token, + idempotencyKey: input.idempotencyKey, + offset: 0, + body: input.bytes.subarray(0, firstLength), + }); + input.sentOffsets.push(0); + } + input.sentOffsets.push( + ...(await uploadRemainingChunks({ + versionId: input.versionId, + bytes: input.bytes, + token: input.token, + idempotencyKey: input.idempotencyKey, + })), + ); + const completed = await completePackageUpload({ + versionId: input.versionId, + token: input.token, + idempotencyKey: input.idempotencyKey, + }); + return { versionId: completed.versionId, status: completed.status }; +} + liveTest( 'AGC å‘布函数在真实åŽç«¯å®Œæˆåˆ›å»ºã€ä¸Šä¼ ã€é€å®¡å¹¶åœ¨é‡å¤å‘布时å¤ç”¨æ¸¸æˆèº«ä»½', async () => { @@ -158,22 +365,46 @@ liveTest( const token = await registerAuthor(); setStoredAccessToken(token); - const payload = await buildExportPayload(); + const { staged, bytes } = await buildStagedPackage(); const stamp = String(Date.now()); const manifest = { projectId: `agc-live-${stamp}`, name: `AGC 真实å‘布${stamp.slice(-4)}`, goal: 'éªŒè¯ AGC 一键å‘布链路', } as unknown as GameCreationAppManifest; - const invoke = vi.fn(async () => payload); + // 记录本次å‘布实际å‘é€è¿‡çš„分片å移,用æ¥è¯æ˜Žã€Œä¸­æ–­åŽä¸é‡ä¼ å·²æ”¶å­—节ã€ã€‚ + const sentOffsets: number[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'prepare_local_project_game_package') { + return staged; + } + if (command === 'upload_local_project_game_package') { + const uploaded = await uploadStagedPackageViaProtocol({ + versionId: String(args?.versionId ?? ''), + bytes, + token, + idempotencyKey: String(args?.idempotencyKey ?? ''), + sentOffsets, + }); + return { + versionId: uploaded.versionId, + status: uploaded.status, + uploadedBytes: bytes.length, + }; + } + throw new Error(`未预期的命令:${command}`); + }, + ); // æœåŠ¡ç«¯è¦æ±‚å‘布必须带å°é¢ï¼šçœŸå®žèµ°ä¸€éå‡­è¯ â†’ ç›´ä¼  → confirm。 const uploadedCover = await uploadPlatformMediaAsset({ file: buildLiveCoverFile(), assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', stamp], entityId: 'game-distribution-cover', - // jsdom 里没有 Tauri HTTP æ’件,å¤ç”¨æµ‹è¯•注入的 fetch bridge 直连 dev OSS。 - fetchImpl: (input, init) => realFetch(apiUrl(input), init), + // jsdom 里没有 Tauri HTTP æ’件:直传也走åŒä¸€ä¸ªæ¡¥ï¼Œè·¨ realm çš„ FormData 会被 + // å…ˆåºåˆ—åŒ–æˆ Node ä¾§ multipart 字节,å¦åˆ™ OSS 会以 405 æ‹’ç»ã€‚ + fetchImpl: (input, init) => globalThis.fetch(input, init), }); expect(uploadedCover.assetObjectId).toMatch(/\S/u); const metadata = { @@ -198,7 +429,18 @@ liveTest( }); expect(first.status).toBe('pending_review'); expect(first.versionNumber).toBe(1); - expect(first.packageSha256).toBe(payload.packageSha256); + expect(first.packageSha256).toBe(staged.packageSha256); + // åˆ†ç‰‡ç»­ä¼ è¯æ®ï¼šç¬¬ä¸€ç‰‡ï¼ˆåç§» 0)åªå‘é€ä¸€æ¬¡ï¼›ä¸­æ–­åŽçš„续传从æƒå¨å移开始, + // 已收字节ä¸é‡æ”¾ã€ä¹Ÿä¸è·³æ®µã€‚ + expect(sentOffsets[0]).toBe(0); + expect(sentOffsets.filter((offset) => offset === 0)).toHaveLength(1); + expect(sentOffsets[1]).toBeGreaterThan(0); + expect(sentOffsets).toEqual( + Array.from( + { length: Math.ceil(staged.packageSizeBytes / 8 / 1024 / 1024) }, + (_, index) => index * 8 * 1024 * 1024, + ), + ); const readResult = await unwrap<{ version: { versionId: string; status: string; recoveryAction: string }; diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 9e34fbaca..6d3284ce0 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -90,8 +90,9 @@ http { location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接å£ä¼šæºå¸¦å‚考图 Data URL,Nginx åªæ”¾è¡Œåˆ° api-server;真实大å°é™åˆ¶ä»ç”±è·¯ç”± DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接å£ä¼šæºå¸¦å‚考图 Data URL,游æˆå‘行包 PUT 更大,Nginx åªè´Ÿè´£æ”¾è¡Œåˆ° api-serverï¼› + # 真实大å°é™åˆ¶ä»ç”±è·¯ç”± DefaultBodyLimit(å‘行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index c3c4bff34..6d2b2dffe 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -4,8 +4,8 @@ ## è¯·æ±‚ä½“å¤§å° -- 生产ã€å¼€å‘æœå’Œå®¹å™¨æ¨¡æ¿éƒ½åœ¨é€šç”¨ `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 64m`。 -- 该值åªç”¨äºŽè®©æºå¸¦å‚考图 Data URL çš„åˆ›ä½œæŽ¥å£æŠµè¾¾ `api-server`ï¼›ä¸è¦æŠŠå®ƒå½“作业务上传上é™ã€‚Rust 路由ä»é€šè¿‡ `DefaultBodyLimit` 和解ç åŽå­—节校验é™åˆ¶å…·ä½“接å£ï¼Œä¾‹å¦‚拼图å‚è€ƒå›¾è·¯ç”±åªæ”¾å®½åˆ° 12 MiB 请求体,图片字节继续按业务规则拒ç»ã€‚ +- 生产ã€å¼€å‘æœå’Œå®¹å™¨æ¨¡æ¿éƒ½åœ¨é€šç”¨ `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 210m`。 +- 该值åªç”¨äºŽè®©æºå¸¦å‚考图 Data URL 的创作接å£å’Œæ¸¸æˆå‘行包 PUTï¼ˆè·¯ç”±ä¸Šé™ 200 MiB + 1 KiB)抵达 `api-server`ï¼›ä¸è¦æŠŠå®ƒå½“作业务上传上é™ã€‚Rust 路由ä»é€šè¿‡ `DefaultBodyLimit` 和解ç åŽå­—节校验é™åˆ¶å…·ä½“接å£ï¼Œä¾‹å¦‚拼图å‚è€ƒå›¾è·¯ç”±åªæ”¾å®½åˆ° 12 MiB 请求体,图片字节继续按业务规则拒ç»ã€‚Pingora 网关侧的 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` å¿…é¡»åŒæ ·ä¸ä½ŽäºŽè¯¥å€¼ï¼Œå¦åˆ™è¯·æ±‚会在网关层被 413。 - 若线上看到 `413 Request Entity Too Large`,并且 access log 里 `request_time=0.000 upstream_status=-`,通常是 Nginx æ²¡æœ‰åŠ è½½è¯¥æ¨¡æ¿æˆ–未 reload;先执行 `nginx -T | grep client_max_body_size` å’Œ `nginx -t` 冿£€æŸ¥ `api-server`。 ## gzip diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index f210766ab..640ef088c 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -119,8 +119,9 @@ server { # 临时兼容主站ä»åœ¨ä½¿ç”¨çš„ /api/* HTTP facadeï¼›å‰ç«¯å®Œæˆ SpacetimeDB SDK è¿ç§»åŽåˆ é™¤ã€‚ location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接å£ä¼šæºå¸¦å‚考图 Data URL,Nginx åªæ”¾è¡Œåˆ° api-server;真实大å°é™åˆ¶ä»ç”±è·¯ç”± DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接å£ä¼šæºå¸¦å‚考图 Data URL,游æˆå‘行包 PUT 更大,Nginx åªè´Ÿè´£æ”¾è¡Œåˆ° api-serverï¼› + # 真实大å°é™åˆ¶ä»ç”±è·¯ç”± DefaultBodyLimit(å‘行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 87bf769e3..b7d1c433a 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -139,8 +139,9 @@ server { # 临时兼容主站ä»åœ¨ä½¿ç”¨çš„ /api/* HTTP facadeï¼›å‰ç«¯å®Œæˆ SpacetimeDB SDK è¿ç§»åŽåˆ é™¤ã€‚ location ~ ^/api(?:/|$) { default_type application/json; - # 中文注释:创作接å£ä¼šæºå¸¦å‚考图 Data URL,Nginx åªæ”¾è¡Œåˆ° api-server;真实大å°é™åˆ¶ä»ç”±è·¯ç”± DefaultBodyLimit 和业务字节校验负责。 - client_max_body_size 64m; + # 中文注释:创作接å£ä¼šæºå¸¦å‚考图 Data URL,游æˆå‘行包 PUT 更大,Nginx åªè´Ÿè´£æ”¾è¡Œåˆ° api-serverï¼› + # 真实大å°é™åˆ¶ä»ç”±è·¯ç”± DefaultBodyLimit(å‘行包 200 MiB + 1 KiB)和业务字节校验负责。 + client_max_body_size 210m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 59eaaa850..f1c90ee98 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -124,14 +124,14 @@ "nginx": { "production": [ "location ~ ^/api(?:/|$)", - "client_max_body_size 64m;", + "client_max_body_size 210m;", "limit_conn genarrative_api_conn 64;", "limit_req zone=genarrative_api_rps burst=64 nodelay;", "add_header X-Accel-Buffering no always;" ], "development": [ "location ~ ^/api(?:/|$)", - "client_max_body_size 64m;", + "client_max_body_size 210m;", "limit_conn genarrative_api_conn 64;", "limit_req zone=genarrative_api_rps burst=64 nodelay;", "add_header X-Accel-Buffering no always;" diff --git a/deploy/pingora/pingora-gateway.env.example b/deploy/pingora/pingora-gateway.env.example index 467d559d2..d85eb293a 100644 --- a/deploy/pingora/pingora-gateway.env.example +++ b/deploy/pingora/pingora-gateway.env.example @@ -28,7 +28,7 @@ GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE=/var/lib/genarrative/maintenance/en GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE=/var/lib/genarrative/maintenance/page.html GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http -GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=67108864 +GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960 # gzip 默认开å¯ï¼›ç­‰çº§å’Œæœ€å°å“åº”é•¿åº¦å¯¹é½ Nginx gzip_comp_level 5 / gzip_min_length 1024。 # Pingora æ­£å¼åŒ–å£å¾„固定为 gzip-onlyï¼›br / zstd ä¸è¿›å…¥å½“å‰ç½‘关,Brotli ç»§ç»­ç”± Nginx / å‰ç½®ä»£ç†æ‰¿æ‹…。 GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS=gzip diff --git a/docs/project-memory/plans/ã€å®žæ–½è®¡åˆ’】AGCå‘行包分片续传上传-2026-09-23.md b/docs/project-memory/plans/ã€å®žæ–½è®¡åˆ’】AGCå‘行包分片续传上传-2026-09-23.md new file mode 100644 index 000000000..21aa5c760 --- /dev/null +++ b/docs/project-memory/plans/ã€å®žæ–½è®¡åˆ’】AGCå‘行包分片续传上传-2026-09-23.md @@ -0,0 +1,49 @@ +# AGC å‘行包分片续传上传实施计划 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | runtime-smoke-passedï¼ˆå­˜å‚¨åŽŸè¯­ã€æœåŠ¡ç«¯å…¥å£ã€åŽŸç”Ÿä¸Šä¼ å™¨ã€æ¸²æŸ“进程接线与真实栈分片续传 smoke å‡å·²è½åœ°ï¼‰ | +| Date | 2026-09-23 | +| Parent Milestone | `docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】AGCå‘行包分片续传上传-2026-09-23.md` | + +## ä¿®æ”¹è¾¹ç•Œä¸Žé¡ºåº + +1. **存储原语(已完æˆï¼‰**:`server-rs/crates/platform-oss/src/lib.rs` 新增 `append_internal_object` / `append_internal_object_with_retry` 与 `OssAppendInternalObjectRequest` / `OssAppendInternalObjectResponse`ï¼›å¤ç”¨çް役 V4 ç­¾å助手 `signed_request_builder`(查询串已å‚与签å)与 `run_internal_put_with_retry` çš„å¯é‡è¯•分类。`position = 0` 追加到末尾,`position > 0` 必须等于对象当å‰é•¿åº¦ï¼›è¿”回 `next_position` 作为æƒå¨å·²æ”¶å­—节。 +2. **æœåŠ¡ç«¯å…¥å£ï¼ˆå·²å®Œæˆï¼‰**:`server-rs/crates/api-server/src/modules/game_distribution.rs` + - 新增 `GET .../package/upload-state`ã€`PUT .../package/chunk`ã€`POST .../package/complete`ã€`POST .../package/reset` 四个路由,沿用作者鉴æƒã€`game-distribution:publish` ç°åº¦å¼€å…³ä¸Ž `Idempotency-Key` 约定; + - åˆ†ç‰‡å¤§å° `PACKAGE_UPLOAD_CHUNK_BYTES = 8 MiB`,分片请求体放行é‡ä¸ºåˆ†ç‰‡å¤§å° + 1 KiBï¼› + - 从整包 `PUT` æŠ½å‡ºå…±äº«æ”¶å£ `confirm_validated_package`(声明比对 → 确认 → 结构化事件),两ç§å…¥å£å…±ç”¨ï¼› + - 新增 `game_distribution_oss_client` / `game_distribution_package_object_key` / `staged_package_bytes` / `require_octet_stream_content_type` / `package_upload_offset` 辅助函数;åç§»ä¸ä¸€è‡´è¿”回 `409 PACKAGE_UPLOAD_OFFSET_MISMATCH` 与æƒå¨å移;未收é½è¿”回 `409 PACKAGE_UPLOAD_INCOMPLETE`;校验失败删除åŠåŒ…å¹¶è½ `upload_failed`。 +3. **AGC 原生上传器(已完æˆï¼‰**:新增 `apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs`ï¼šå†…å®¹å¯»å€æš‚存(`/game-package-staging/.zip`,é‡å¯åŽåŒåŒ…å¤ç”¨åŒä¸€æ–‡ä»¶ï¼‰ã€`upload-state → chunk → complete` 循环ã€409 æƒå¨å移续传(å“åº”ä¸¢å¤±åŽæŒ‰æœåŠ¡ç«¯å·²æ”¶å­—èŠ‚å¯¹é½ï¼Œä¸é‡æ”¾ä¸è·³æ®µï¼‰ã€ä»…对传输/è¶…æ—¶/408/429/5xx 退é¿é‡è¯•(默认 4 次å°è¯•)ã€`game-package-upload-progress` 进度事件;暂存路径必须è½åœ¨æš‚存目录内。命令 `prepare_local_project_game_package` / `upload_local_project_game_package` 已注册,整包回传命令 `read_local_project_export_package` 退役(`read_local_project_export_package_at` ä»ä¾›æš‚存使用)。 +4. **渲染进程接线(已完æˆï¼‰**:`apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts` 改为 `prepare`(拿摘è¦ä¸Žæš‚存路径)→ åˆ›å»ºæ¸¸æˆ â†’ 创建版本 → 原生分片上传 → é€å®¡ï¼›`LocalProjectExportPackagePayload` 整包类型退役,改为 `StagedGamePackage` / `GamePackageUploadOutcome`ï¼›ä¸å†æœ‰ä»»ä½•整包字节进 IPC。 +5. **真实栈 smoke(已完æˆï¼‰**:本地 api-server + 真实 OSS bucket 上跑通「中断 → ç»­ä¼  → 确认ã€ã€‚åšæ³•ä¸Žè¯æ®ï¼š + - 先用 `npm run dev:spacetime` æŠŠå½“å‰æ¨¡å—å‘布到本地库(`genarrative-game-creator-dev`,自动è¿ç§»å®Œæˆï¼‰ï¼Œå†ç”¨ `npm run dev:api-server` èµ· `127.0.0.1:8082`ï¼› + - 本地库的 `feature_gate_config` 原本为空(å‘布开关默认关闭),用 `spacetime call … upsert_feature_gate_config` 写入 `game-distribution:publish enabled=true rollout=100`ï¼› + - `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL=http://127.0.0.1:8082 npx vitest run apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts` → **1 passed / 3.9s**(å‘行包 9.0 MiB,跨 8 MiB 分片边界); + - 用例断言实际å‘é€è¿‡çš„分片åç§»åºåˆ—等于 `[0, 8388608]`:第一片åªå‘一次,中断åŽçš„续传从æƒå¨å移开始,ä¸é‡æ”¾ä¹Ÿä¸è·³æ®µï¼› + - api-server ä¾§åŒä¸€è½®æ—¥å¿—:`package_chunk_stored offset=0 chunk_bytes=8388608 received_bytes=8388608 elapsed_ms=201`ã€`package_chunk_stored offset=8388608 chunk_bytes=1049210 received_bytes=9437818 elapsed_ms=82`ã€`package_confirmed package_bytes=9437818 file_count=3 oss_put_skipped=true elapsed_ms=884`。 + - ä¸ºäº†èƒ½æŒ‡å‘æœ¬åœ°æ ˆï¼Œç”¨ä¾‹è¿˜è¡¥äº†ä¸¤å¤„基础设施修正:把客户端平å°åŸºå€åˆ‡åˆ°ä¼ å…¥çš„ base URL(`setClientServerSelection({preset:'custom'})`ï¼‰ï¼Œä»¥åŠæ¡¥æŽ¥å±‚把 jsdom realm çš„ `Headers` / `Blob` / `FormData` é™çº§æˆ Node 侧原生值(`FormData` 手工åºåˆ—化为 multipart 字节,å¦åˆ™ OSS 直传回 405)。 + +## 䏿”¹çš„部分 + +- 网页端å‘布路径与整包 `PUT` 语义ä¸å˜ï¼›`MAX_PACKAGE_BYTES`ã€å±•å¼€é‡ã€å•文件与文件数上é™ä¸å˜ã€‚ +- 未新增 SpacetimeDB è¡¨æˆ–å­—æ®µï¼šå·²æ”¶å­—èŠ‚çš„äº‹å®žæ¥æºæ˜¯ OSS å¯¹è±¡é•¿åº¦ï¼Œç‰ˆæœ¬çŠ¶æ€æœºæ²¿ç”¨æ—¢æœ‰ `awaiting_upload → uploaded → …`。 +- 未引入åŠåŒ…定时清ç†ä»»åŠ¡ã€‚ + +## 验è¯å‘½ä»¤ + +- `cargo test -p platform-oss`(74 passed) +- `cargo test -p api-server game_distribution`(23 passedï¼Œå«æ–°å¢ž `package_chunk_size_stays_inside_declared_limits`ã€`package_upload_offset_requires_non_negative_integer`ã€`package_chunk_content_type_must_be_octet_stream`) +- `cargo fmt --all -- --check`ã€`npm run check:encoding`ã€`npm run check:doc-index`ã€`git diff --check` +- `cargo test game_package_upload`(AGC 原生侧 4 passedï¼šåˆ†ç‰‡è§„åˆ’æ— ç¼æ— é‡å ã€409 æƒå¨å移解æžã€URL 拼接ã€å†…容坻倿š‚存与路径校验) +- `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`ã€`npm run --workspace apps/ai-game-creator-shell typecheck`ï¼ˆå« `check-config.mjs` 的命令登记门ç¦ï¼‰ +- `npx vitest run`(å‘布函数 6 passedã€å‘å¸ƒé¢æ¿ 9 passedã€å‘布å馈 5 passed;真实链路用例在无 `GENARRATIVE_AGC_PUBLISH_E2E_BASE_URL` 时按设计跳过) +- å¾…åšï¼šçœŸå®žæ ˆ smoke(本地 api-server + 真实 OSS bucket 上跑「中断 → ç»­ä¼  → 完æˆã€ï¼Œå« `x-oss-next-append-position` 语义确认) + +## 风险与回滚点 + +- **对象å¯è¿½åŠ æ€§**:`platform-oss` 之剿²¡æœ‰è¿½åŠ å†™ï¼Œé¦–æ¬¡çœŸå®žè°ƒç”¨éœ€è¦åœ¨çœŸå®ž bucket 上确认 `x-oss-next-append-position` 语义;失败时回滚点是 `platform-oss` 新增函数与四æ¡è·¯ç”±ï¼ˆæ•´åŒ… `PUT` ä¸å—å½±å“,å¯ç‹¬ç«‹å›žé€€ï¼‰ã€‚ +- **åŠåŒ…对象**:分片写入直接è½åœ¨ç‰ˆæœ¬é”®ä¸Šï¼Œæœªå®Œæˆæ—¶æ˜¯åŠåŒ…。它ä¸è¿›å…¬å¼€ç›®å½•ã€ä¸æœåŠ¡å‘行网关;失败或作者é‡ç½®æ—¶åˆ é™¤ã€‚若删除失败会记录 `package_staging_delete_failed` 告警,需è¦äººå·¥ç¡®è®¤å¯¹è±¡é”®çжæ€ã€‚ +- **é‡ç½®è¯­ä¹‰**ï¼šåªæœ‰ `awaiting_upload` / `upload_failed` å…许é‡ç½®ï¼Œé¿å…ç ´å已确认事实。 +- **内存**:完æˆåŠ¨ä½œæŒ‰ 200 MiB 上é™å›žè¯»æ•´åŒ…冿 ¡éªŒï¼Œå³°å€¼ä¸Žæ•´åŒ… `PUT` åŒé‡çº§ï¼›åˆ†ç‰‡è·¯å¾„ä¸å†è®©æ•´åŒ…驻留客户端。 diff --git a/docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】AGCå‘行包分片续传上传-2026-09-23.md b/docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】AGCå‘行包分片续传上传-2026-09-23.md new file mode 100644 index 000000000..a881e7e88 --- /dev/null +++ b/docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】AGCå‘行包分片续传上传-2026-09-23.md @@ -0,0 +1,58 @@ +# AGC å‘行包分片续传上传 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | runtime-smoke-passed(真实栈「中断 → ç»­ä¼  → 确认ã€å·²é€šè¿‡ï¼›AGC 真机一键å‘布与 200 MiB æ¡£å®¹é‡æ•°æ®æœªéªŒè¯ï¼‰ | +| Date | 2026-09-23 | +| Parent Spec | `docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`(真实å‘行包与资料åˆåŒç¬¬ 10 æ¡ã€å¹‚等并å‘与æ¢å¤ï¼‰ | + +## èƒŒæ™¯ä¸Žè§¦å‘ + +AGC 一键å‘布今天把整包字节从 WebView ä¾§é€å‡ºï¼š`read_local_project_export_package` 先把 `packageBytes` 整包过一é IPC 回到渲染进程,渲染进程å†ç”¨ `@tauri-apps/plugin-http` 呿•´åŒ… `PUT`,而该æ’件会把 body åºåˆ—åŒ–æˆ `Array.from(new Uint8Array(buffer))` å†èµ°ä¸€æ¬¡ IPC。两次整包 IPC 决定了 AGC 实际å¯å‘布的包远å°äºŽæœåŠ¡ç«¯ 200 MiB 上é™ï¼Œå¤±è´¥æ—¶è¡¨çŽ°ä¸ºå®¢æˆ·ç«¯ä¾§ä¼ è¾“é”™è¯¯ï¼ˆä¾‹å¦‚ã€Œæ— æ³•è¿žæŽ¥ç™»å½•æœåŠ¡ã€ï¼‰ï¼ŒæœåŠ¡ç«¯è®¿é—®æ—¥å¿—é‡Œæ²¡æœ‰è¿™æ¬¡è¯·æ±‚ï¼›æ–­æµåŽä¹Ÿåªèƒ½æ•´åŒ…白传。本里程碑把上传下沉到原生侧并支æŒåˆ†ç‰‡ç»­ä¼ ã€‚ + +## 目标 + +1. AGC 一键å‘å¸ƒç”±åŽŸç”Ÿè¿›ç¨‹ç›´æŽ¥è¯»å–æœ¬åœ°è¯•çŽ©åŒ…ã€æŒ‰æœåŠ¡ç«¯ä¸‹å‘的固定分片大å°ä¸Šä¼ ï¼Œæ•´åŒ…字节ä¸å†ç»è¿‡ WebView IPC。 +2. 传输中断ã€ç½‘络失败ã€å®¢æˆ·ç«¯è¿›ç¨‹é€€å‡ºæˆ–应用é‡å¯åŽï¼ŒåŒä¸€ `versionId` åªè¡¥ä¼ ç¼ºå¤±å­—节,ä¸ç™½ä¼ æ•´åŒ…。 +3. 分片入å£ä¸ŽçŽ°å½¹æ•´åŒ… `PUT` 共用åŒä¸€ç‰ˆæœ¬çŠ¶æ€æœºã€æ‘˜è¦å£å¾„ã€å¹‚等键与包校验;网页端å‘布路径ä¸å˜ã€‚ + +## ä¸åœ¨æœ¬é‡Œç¨‹ç¢‘内 + +- 䏿”¹ç½‘页端å‘布路径(继续整包 `PUT`),ä¸ä¸ºæµè§ˆå™¨å®žçŽ°ç»­ä¼ ã€‚ +- ä¸åšå¹¶è¡Œåˆ†ç‰‡ä¸Šä¼ ã€ä¸åšå®¢æˆ·ç«¯ç›´ä¼  OSS(分片ä»ç» `api-server` 转å‘,与今天整包路径åŒä¸€å‡ºå£ï¼‰ã€‚ +- ä¸åšã€ŒåŽå°è‡ªåŠ¨ç»­ä¼ ã€ï¼šç»­ä¼ åªåœ¨ä¸‹ä¸€æ¬¡å‘布动作或应用é‡å¯åŽçš„é‡è¯•里å‘生,ä¸å¼•入常驻é‡ä¼ ä»»åŠ¡ã€‚ +- ä¸åšæœªå®Œæˆåˆ†ç‰‡ä¼šè¯çš„定时清ç†ä»»åŠ¡ï¼›åŠåŒ…对象的回收å•独开里程碑。 +- 䏿”¹å‘行包上é™ã€å±•å¼€é‡ã€å•文件与文件数上é™ã€‚ + +## åˆåŒè¦ç‚¹ + +- **å…¥å£ä¸Žçжæ€**:分片续传对既有 `versionId` ç”Ÿæ•ˆï¼Œç‰ˆæœ¬çŠ¶æ€æ²¿ç”¨ `awaiting_upload → uploaded → …`;分片入å£ä¸Žæ•´åŒ…å…¥å£äº’斥,åŒä¸€ç‰ˆæœ¬åŒæ—¶åªèƒ½æœ‰ä¸€ä¸ªå†™å…¥è€…,第二个写入返回 `409 UPLOAD_IN_PROGRESS`。 +- **æƒå¨åç§»**:æœåŠ¡ç«¯è®°å½•çš„å·²æ”¶å­—èŠ‚æ˜¯å”¯ä¸€æƒå¨ã€‚客户端分片å移与之ä¸ç¬¦æ—¶è¿”回 `409` 与æƒå¨å移,客户端按æƒå¨å移续传;é‡å¤åˆ†ç‰‡ä¸å¾—造æˆé‡å¤å†™å…¥ã€‚ +- **完æˆåŠ¨ä½œ**:全部字节到é½åŽæ‰æ‰§è¡Œæ ¡éªŒä¸Žç¡®è®¤ï¼›æ ¡éªŒå¤±è´¥åˆ é™¤åŠåŒ…对象并把版本è½åˆ° `upload_failed`(`recoveryAction=reupload`ï¼‰ã€‚é‡æ–°ä¸Šä¼ åŒä¸€ç‰ˆæœ¬å‰å¿…须显å¼é‡ç½®åˆ†ç‰‡ä¼šè¯ï¼Œé‡ç½®åŽå移归零,ä¸å…许在åŠåŒ…之上续写ä¸åŒå­—节。 +- **å¯è§æ€§**:åŠåŒ…对象ä¸è¿›å…¥å…¬å¼€ç›®å½•ã€ä¸æœåŠ¡å‘行网关ã€ä¸æ”¹å˜å½“å‰å…¬å¼€ç‰ˆæœ¬ï¼›ä¸Žæ—¢æœ‰ã€Œæœªé€šè¿‡å®¡æ ¸ä¸æ”¹å˜ `activeVersionId`ã€å£å¾„一致。 +- **原生侧边界**:原生上传åªè¯»æœ¬åœ°è¯•玩包并é€ç‰‡å‘é€ï¼Œè¿›åº¦ä»¥äº‹ä»¶å›žä¼ æ¸²æŸ“进程;渲染进程ä¸å†æŒæœ‰æ•´åŒ…字节。 + +## ä¾èµ– + +- `platform-oss`:需è¦ä¸€ç»„å¯ç»­å†™çš„对象写入原语(追加语义或等价的分片会è¯ï¼‰ï¼Œä»¥åŠè¯»å–å·²æ”¶å­—èŠ‚çš„æŽ¢æµ‹èƒ½åŠ›ï¼›çŽ°å½¹åªæœ‰æ•´å¯¹è±¡ `PUT`。 +- `api-server`:`modules/game_distribution.rs` 新增分片入å£ä¸Žå®ŒæˆåŠ¨ä½œï¼Œå¤ç”¨æ—¢æœ‰ `validate_release_zip`ã€OSS 上传é‡è¯•分类ã€`package_confirmed` / `package_rejected` å¯è§‚测事件。 +- AGC:`src-tauri` 新增原生上传命令与进度事件,`src/services/gameDistributionPublish.ts` 改为调用原生命令;`read_local_project_export_package` ä¸å†ä¸ºå‘布回传整包字节。 +- å代/网关:分片请求体远å°äºŽçް役 210 MiB 放行é‡ï¼Œæ²¿ç”¨çŽ°æœ‰é…ç½®ï¼Œä¸æ”¹é™é¢ã€‚ + +## 验收标准 + +1. **ä¸å†æ•´åŒ…过 IPC**:å‘布 200 MiB 档包时,渲染进程侧ä¸å‡ºçŽ°æ•´åŒ…å­—èŠ‚ï¼ˆå¯¹ç…§ `read_local_project_export_package` 的返回体与 IPC 报文大å°ï¼‰ï¼Œä¸Šä¼ ç”±åŽŸç”Ÿè¿›ç¨‹å®Œæˆã€‚ +2. **续传生效**:上传中途断开传输åŽé‡å‘åŒä¸€ç‰ˆæœ¬ï¼Œåªè¡¥ä¼ ç¼ºå¤±åˆ†ç‰‡ï¼›åˆ†ç‰‡è¯·æ±‚æ•°ã€å·²ä¼ å­—节与最终包摘è¦ä¸‰é¡¹å‡å¯å¤æ ¸ã€‚ +3. **è·¨é‡å¯ç»­ä¼ **:上传中断时退出应用并é‡å¯ï¼Œé‡æ–°å‘布时æœåŠ¡ç«¯è¿”å›žæƒå¨å·²æ”¶å­—节,客户端从该å移继续,最终确认æˆåŠŸã€‚ +4. **å移与é‡å¤**:分片åç§»ä¸ç¬¦è¿”回 `409` 与æƒå¨å移;é‡å¤æäº¤åŒä¸€åˆ†ç‰‡ä¸äº§ç”Ÿé‡å¤å†™å…¥ï¼›åŒç‰ˆæœ¬ç¬¬äºŒä¸ªå†™å…¥è€…返回 `409 UPLOAD_IN_PROGRESS`。 +5. **失败关闭**:完æˆåŠ¨ä½œé‡Œæ ¡éªŒå¤±è´¥ï¼ˆéžæ³• ZIPã€è¶…é™ã€åŽ‹ç¼©æ¯”è¶Šç•Œç­‰ï¼‰åˆ é™¤åŠåŒ…对象ã€ç‰ˆæœ¬è½ `upload_failed`,åŠåŒ…ä¸å‡ºçŽ°åœ¨å…¬å¼€ç›®å½•ï¼Œä¹Ÿä¸å½±å“当å‰å…¬å¼€ç‰ˆæœ¬ã€‚ +6. **兼容与回归**:整包 `PUT` è·¯å¾„ä¸Žæ—¢æœ‰æµ‹è¯•ä¿æŒç»¿ï¼›`npm run check:doc-index`ã€`npm run check:encoding`ã€`git diff --check` 通过;`check:spacetime-schema` æŒ‰æ˜¯å¦æ–°å¢žæŒä¹…字段决定是å¦çº³å…¥ã€‚ +7. **è¿è¡Œæ—¶è¯æ®ï¼ˆå·²èŽ·å¾—ï¼‰**:本地 api-server(`127.0.0.1:8082`,库 `genarrative-game-creator-dev`)+ 真实 OSS bucket 上跑通 `gameDistributionPublishLive.test.ts`:9.0 MiB å‘行包跨 8 MiB 分片边界,第一片åªå‘é€ä¸€æ¬¡ï¼Œä¸­æ–­åŽç»­ä¼ ä»Žæƒå¨åç§» `8388608` ç»§ç»­ã€ç¬¬äºŒç‰‡ `received_bytes=9437818`ï¼Œæœ€åŽ `package_confirmed`(`oss_put_skipped=true`);整轮 3.9s。**未获得**:AGC 真机(Tauri è¿è¡Œæ—¶ï¼‰ä¸€é”®å‘布的端到端è¿è¡Œï¼Œä»¥åŠ 200 MiB 档的耗时 / å†…å­˜å®¹é‡æ•°æ®ã€‚ + +## 待评审的决策点 + +1. **续写原语**:OSS 追加写(顺åºã€å•对象ã€ç»­ä¼ åªéœ€å›žè¯»å½“å‰é•¿åº¦ï¼‰å¯¹æ¯” OSS Multipart(å¯å¹¶è¡Œã€æ›´é€šç”¨ä½†éœ€è¦å¤šç»„æ–°æ“作)。建议追加写,顺åºç»­ä¼ å·²æ»¡è¶³æœ¬é‡Œç¨‹ç¢‘目标。 +2. **分片大å°**:建议 8 MiB(200 MiB ä¸Šé™ â†’ 最多 25 片,å•片请求体远低于现役放行é‡ï¼‰ã€‚ +3. **é‡ç½®è¯­ä¹‰**ï¼šå»ºè®®åªæœ‰æ˜¾å¼é‡ç½®ï¼ˆä½œè€…ç‚¹ã€Œé‡æ–°ä¸Šä¼ ã€æˆ– `reupload` æ¢å¤åŠ¨ä½œï¼‰æ‰åˆ é™¤åŠåŒ…并归零;其余情况一律按æƒå¨å移续传。 +4. **åŠåŒ…回收**ï¼šæœ¬é‡Œç¨‹ç¢‘åªæ ‡è®°æœªå®Œæˆä¼šè¯ï¼Œä¸åšå®šæ—¶æ¸…ç†ï¼›å›žæ”¶å¦ç«‹é‡Œç¨‹ç¢‘(涉åŠã€Œä¸å¾—删除ä»è¢«å…¬å¼€ç‰ˆæœ¬å¼•用的对象ã€å£å¾„)。 diff --git a/docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】游æˆåˆ†å‘目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】游æˆåˆ†å‘目录详情与在线游玩-2026-09-18.md index 226855815..e55f03b43 100644 --- a/docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】游æˆåˆ†å‘目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/ã€é‡Œç¨‹ç¢‘】游æˆåˆ†å‘目录详情与在线游玩-2026-09-18.md @@ -120,7 +120,7 @@ ### 行为与验收 - [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → æ–°ç‰ˆåˆ‡æ¢ â†’ 下架撤销â€ã€‚ -- [ ] 100 MiB 包与获批文件数/展开é‡è¾¹ç•Œæœ‰å¯å¤æ ¸è€—æ—¶ã€å†…å­˜å’Œå¤±è´¥è¯æ®ï¼›æ ¡éªŒä¸ä¼šæ‰§è¡Œä¸Šä¼ ä»£ç ï¼ŒæœåŠ¡èµ„æºæœ‰ç•Œã€‚ +- [ ] 200 MiB 包(现行上é™ï¼Œè§ 2026-09-23 决策记录)与获批文件数/展开é‡è¾¹ç•Œæœ‰å¯å¤æ ¸è€—æ—¶ã€å†…å­˜å’Œå¤±è´¥è¯æ®ï¼›æ ¡éªŒä¸ä¼šæ‰§è¡Œä¸Šä¼ ä»£ç ï¼ŒæœåŠ¡èµ„æºæœ‰ç•Œã€‚å·²æœ‰è¯æ®è¦†ç›– 100 MiB æ¡£ï¼Œä¸Šé™æå‡åŽçš„æ¡£ä½å¾…å¤è·‘。 - [ ] 校验执行器é‡å¯å¯æ¢å¤ï¼Œå®¡æ ¸ç§¯åŽ‹ä¸Žå¤±è´¥å¯è§‚测,清ç†ä¸åˆ é™¤ä»è¢«å…¬å¼€ç‰ˆæœ¬å¼•用的文件。 - [ ] CDN purge 失败时ä»åœ¨èŽ·æ‰¹ç¼“å­˜ TTL å†…æ‹’ç»æ–°èµ„æºï¼›æ˜Žç¡®å·²ä¸‹è½½è„šæœ¬æ— æ³•è¿œç¨‹æŠ¹é™¤çš„è¾¹ç•Œã€‚ - [ ] å‘布/回滚步骤ä¿ç•™å½“å‰å…¬å¼€ç‰ˆæœ¬ï¼Œèƒ½å…³é—­æ–°æäº¤å’Œæ–°ç‰ˆæœ¬æ¿€æ´»ï¼›éƒ¨ç½²è·¯ç”±ã€ç¼“å­˜ã€å“åº”å¤´ã€æ—¥å¿—脱æ•å’Œå‘Šè­¦å®Œæˆæ£€æŸ¥ã€‚ diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 265ec8690..a11c2b6ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,20 @@ # 决策记录 +## 2026-09-23 è‡ªç»˜æ ‡é¢˜æ æ˜¯çª—å£è¾¹æ¡†ï¼šå¼¹å±‚从它下方开始,焦点陷阱放行它 + +- 背景:AGC 打开任æ„一个 `ThemedModal` 弹窗(å‘å¸ƒé¢æ¿ã€å‘布进度ã€èµ„æºé¢„览ã€è´¦æœ¬ã€é”™è¯¯æŠ¥å‘Šç­‰ï¼‰åŽï¼Œå³ä¸Šè§’「最å°åŒ– / 最大化 / 关闭ã€ç‚¹å‡»æ²¡æœ‰ä»»ä½•ååº”ï¼Œæ ‡é¢˜æ æ‹–拽也ä¸èƒ½ç§»åŠ¨çª—å£ï¼›å…³æŽ‰å¼¹çª—立刻æ¢å¤ã€‚原因是标题æ åœ¨æ¨¡æ€ä¹‹å¤–,而 `focus-trap-react` 在 document æ•èŽ·é˜¶æ®µç›‘å¬ `mousedown`/`touchstart`/`click`,模æ€å¤–的点击被 `preventDefault()` 且 `click` 直接 `stopImmediatePropagation()` —— React 的监å¬åœ¨æ›´å†…层,事件到ä¸äº†å®ƒï¼Œæ‰€ä»¥è¡¨çŽ°æ˜¯ã€Œç‚¹äº†æ²¡å应ã€è€Œä¸æ˜¯æŠ¥é”™ã€‚妿œ‰ `.app-update-overlay` 用 `inset: 0` 真的把标题æ ç›–ä½äº†ã€‚ +- 决策:把自绘标题æ å®šä¸º**窗å£è¾¹æ¡†**,ä¸å±žäºŽå¼¹å±‚内容:① portal 到 body 的全å±å¼¹å±‚一律 `top: var(--window-chrome-height)`ï¼Œç¦æ­¢ç”¨ `inset: 0` ç›–ä½æ ‡é¢˜æ ï¼›â‘¡ `ThemedModal` 的焦点陷阱用 `allowOutsideClick` åªæ”¾è¡Œè½åœ¨ `[data-window-chrome-bar]` 内的目标,工作区内容的点击继续被拦ä½ï¼›â‘¢ `WindowChrome` 的标题æ åŠ  `data-window-chrome-bar` 标记,作为这æ¡çº¦å®šçš„唯一契约点。 +- å½±å“范围:`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx`ã€`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`ã€`apps/ai-game-creator-shell/src/styles.css`(`:root` 注释ã€`.app-update-overlay`ã€`.game-publish-progress-overlay`)。 +- éªŒè¯æ–¹å¼ï¼š`tests/themedModal.test.tsx`(标题æ ç‚¹å‡»æ”¾è¡Œã€å·¥ä½œåŒºç‚¹å‡»ä»è¢«æ‹¦ï¼‰ã€`tests/WindowChrome.test.tsx`ï¼ˆå¼¹çª—æ‰“å¼€æ—¶ä¸‰ä¸ªçª—å£æŒ‰é’®ä»è°ƒç”¨åŽŸç”Ÿçª—å£ API)ã€`tests/windowChromeOverlayContract.test.ts`(7 个全å±å¼¹å±‚都从标题æ ä¸‹æ–¹å¼€å§‹ï¼‰ã€`tests/gamePublishFeedback.test.tsx` 与 appSurface(208 passed);两处新增用例都åšè¿‡ã€ŒåŽ»æŽ‰ä¿®å¤å³å¤±è´¥ã€çš„åå‘确认。`npm run --workspace apps/ai-game-creator-shell typecheck`ã€eslintã€`npm run check:encoding`ã€`git diff --check` 通过。 + +## 2026-09-23 游æˆå‘è¡ŒåŒ…ä¸Šé™æå‡åˆ° 200 MiB(å代放行é‡ä¸Žå‘è¡Œç¼“å­˜åŒæ­¥ï¼‰ + +- 背景:游æˆå¹¿åœºå‘行包上é™åŽŸä¸º 100 MiB(`module-game-distribution` çš„ `MAX_PACKAGE_BYTES` 与网页端 `GAME_PACKAGE_MAX_BYTES`),而 Nginx 三份模æ¿ä¸Ž Pingora 网关的通用 `/api` æ”¾è¡Œé‡æ˜¯ 64 MiB。上é™åªæ”¹ä¸€å±‚没有æ„义:包体超过 100 MiB 时先在å代层被 413,`api-server` çš„ ZIP 校验根本ä¸ä¼šæ‰§è¡Œã€‚ +- 决策:å‘è¡ŒåŒ…ä¸Šé™ 100 MiB → 200 MiBï¼›å±•å¼€æ€»é‡ 250 MiB → 500 MiBï¼ˆä¿æŒ 2.5 å€ä½™é‡ï¼‰ï¼›å•文件 64 MiBã€æœ€å¤š 10,000 个文件ã€å±•å¼€/压缩比 100 三æ¡å†…容规则ä¸å˜ï¼›å‘行包路由请求体上é™ç»§ç»­ä»ŽåŒ…ä¸Šé™æ´¾ç”Ÿï¼ˆ200 MiB + 1 KiB)。å代放行é‡ç»Ÿä¸€æ”¾å®½åˆ° 210 MiB:`deploy/nginx/genarrative.conf`ã€`deploy/nginx/genarrative-dev-http.conf`ã€`deploy/container/nginx.conf` 使用 `client_max_body_size 210m`,Pingora `DEFAULT_MAX_API_BODY_BYTES` 改为 `220200960` å¹¶åŒæ­¥ `deploy/pingora/pingora-gateway.env.example`。å‘è¡Œé™æ€èµ„æºè¿›ç¨‹å†…缓存字节预算 200 MiB → 256 MiB,让 200 MiB æ¡£å‘行包ä»èƒ½è¿›ç¼“å­˜ã€ä¸”ä¸ç‹¬å æ•´ä»½é¢„算。 +- 边界:包内å•个文件ä»ä¸å¾—超过 64 MiB;线上 Pingora 环境文件若ä»å†™ `67108864`,必须在é‡å¯ç½‘å…³å‰åŒæ­¥æ”¹å€¼ï¼Œå¦åˆ™å‘行包 PUT 会在网关层被 413。AGC 一键å‘å¸ƒç» `@tauri-apps/plugin-http` 传整包字节,实际å¯å‘布体积还å—该传输方å¼é™åˆ¶ï¼Œ200 MiB 档的客户端容é‡éœ€è¦å•独验è¯ã€‚ +- å½±å“范围:`server-rs/crates/module-game-distribution/src/package.rs`ã€`server-rs/crates/api-server/src/modules/game_distribution.rs`ã€`server-rs/crates/pingora-gateway/src/main.rs`ã€`src/components/game-distribution/gameZipPackage.ts`ã€`deploy/{nginx,container,pingora}`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`ã€`docs/technical/ã€å¼€å‘è¿ç»´ã€‘Pingora独立网关试点-2026-06-11.md`。 +- éªŒè¯æ–¹å¼ï¼š`cargo test -p module-game-distribution`(13 passed,其中 `accepts_package_above_the_previous_hundred_mib_limit` 用两个 50 MiB 存储型æ¡ç›®æž„造 100 MiB 出头的包;把上é™ä¸´æ—¶æ”¹å›ž 100 MiB æ—¶è¯¥ç”¨ä¾‹ç¡®å®žå¤±è´¥ï¼Œè¯æ˜Žå®ƒèƒ½å®ˆä½æ–°ä¸Šé™ï¼‰ã€`cargo test -p api-server game_distribution`(20 passedï¼Œå«æ–°å¢žçš„请求体上é™è¦†ç›–åŒ…ä¸Šé™æ–­è¨€ï¼‰ã€`cargo test -p pingora-gateway`(38 passedï¼Œå« `matches_nginx_route_parity_matrix`)ã€`npx vitest run src/components/game-distribution`(46 passed)ã€`cargo fmt --all -- --check`ã€`npm run check:encoding`ã€`npm run check:doc-index`ã€`git diff --check` 通过。`npm run check:pingora-route-parity` ä»åœ¨ dev-http / 容器模æ¿ç¼ºå°‘ `/games` ç­‰ SPA 路由处失败,改动å‰åŒæ ·å¤±è´¥ï¼Œä¸Žæœ¬æ¬¡å£å¾„无关。200 MiB 档真实栈容é‡è¯æ®ï¼ˆä¸Šä¼ è€—æ—¶ã€api-server 峰值内存ã€è¶…é™ 413 å£å¾„)尚未å¤è·‘,å‘布å‰éœ€æŒ‰é˜¶æ®µ D 脚本é‡è·‘一轮。 + ## 2026-09-23 引用åä¸å…许空白:素æ / Skill / 附件共用 `normalizeMentionName` - 背景:自动评审å‘现 `buildContentFromTextTokens` 在å‰ç¼€é‡å æ—¶ä¼šå¤šæ’ä¸€æžšèŠ¯ç‰‡â€”â€”ç´ ææ˜¾ç¤ºå `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短å先按 index 平局抢ä½ï¼Œé•¿åæˆäº†è¡¥åˆ°æœ«å°¾çš„å­¤å„¿ï¼‰ã€‚æ ¹å› ä¸æ˜¯åŒ¹é…算法,而是**引用å自己带空白**:token 的边界规则是「å‰åŽä¸ºç©ºç™½æˆ–行首行尾ã€ï¼Œ`@heroâ ` 在 `@hero v2` å†…éƒ¨ä¹Ÿç®—ä¸€æ¬¡åˆæ³•命中。 diff --git a/docs/technical/ã€å¼€å‘è¿ç»´ã€‘Pingora独立网关试点-2026-06-11.md b/docs/technical/ã€å¼€å‘è¿ç»´ã€‘Pingora独立网关试点-2026-06-11.md index 74b666676..d5d5e3f34 100644 --- a/docs/technical/ã€å¼€å‘è¿ç»´ã€‘Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/ã€å¼€å‘è¿ç»´ã€‘Pingora独立网关试点-2026-06-11.md @@ -488,7 +488,7 @@ dev æ ¹ç›˜ç©ºé—´åœ¨å®‰è£…åŽæ›¾æŽ¥è¿‘满盘;2026-06-17 进入 canary å‰å·²æ¸… | `GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT` | `/var/www/html` | ACME challenge 陿€ç›®å½•。 | | `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE` | `/var/lib/genarrative/maintenance/enabled` | 存在å³è¿›å…¥ç»´æŠ¤æ¨¡å¼ã€‚ | | `GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO` | `http` | 写入 `X-Forwarded-Proto` 的值。 | -| `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` | `67108864` | `/api` 通用路由的 `Content-Length` 上é™ã€‚ | +| `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` | `220200960` | `/api` 通用路由的 `Content-Length` 上é™ï¼ˆ210 MiB,覆盖游æˆå‘行包 PUT çš„ 200 MiB + 1 KiB 路由上é™ï¼‰ã€‚ | | `GENARRATIVE_PINGORA_GATEWAY_COMPRESSION_ALGORITHMS` | `gzip` | 当å‰å”¯ä¸€å…许的压缩算法白åå•ï¼›Pingora æ­£å¼åŒ–å£å¾„固定为 gzip-only,Brotli ç»§ç»­ç”± Nginx / å‰ç½®ä»£ç†æ‰¿æ‹…。 | | `GENARRATIVE_PINGORA_GATEWAY_GZIP_ENABLED` | `true` | 是å¦å¯ç”¨ gzip å“应压缩。 | | `GENARRATIVE_PINGORA_GATEWAY_GZIP_LEVEL` | `5` | gzip 压缩等级,必须在 `0..=9`。 | diff --git a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md index 8fcaf11f0..ee9e75881 100644 --- a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md +++ b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md @@ -893,7 +893,7 @@ worker è¢«ç¡¬æ€æˆ–断电åŽï¼Œlease è¿‡æœŸä»»åŠ¡åªæœ‰å°šæœªè€—å°½ `max_attempt - Server provision ä¸å†é€šè¿‡ Windows helper 下载,也ä¸å†é€šè¿‡ Linux build 节点中转 SpacetimeDB / otelcol 工具包;Linux build 节点åªè´Ÿè´£ä»Žå†…网 Git æºå‡†å¤‡ provision 脚本和é…置并上传给目标 agent。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:SpacetimeDB å¿…é¡»åŒæ—¶åŒ¹é…è¿è¡Œç‰ˆæœ¬ `2.8.3` å’Œ commit `8e410d28...` æ‰èƒ½å¤ç”¨ï¼›åªæœ‰ç¼ºå¤±æˆ–版本 / commit ä¸åŒ¹é…æ—¶æ‰ä½¿ç”¨ `PROVISION_DOWNLOADS_DIR` 里的本地包或从é…置的下载æºå‡†å¤‡å®˜æ–¹ `v2.8.3` 资产。`SPACETIME_EXPECTED_COMMIT` 与下载根必须æˆå¯¹è°ƒæ•´ï¼Œå®‰è£…结果也执行åŒä¸€ commit é—¨ç¦ã€‚otelcol-contrib 当å‰é”定 `0.151.0`;如果目标æœåŠ¡å™¨ä¸‹è½½éœ€è¦ä»£ç†ï¼Œåœ¨ `PROVISION_DOWNLOAD_PROXY` é…置目标机å¯è®¿é—®çš„ HTTP 代ç†ã€‚ - 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`ã€`Genarrative-Web-Build`ã€`Genarrative-Api-Build`ã€`Genarrative-*Deploy`ã€`Genarrative-Database-Import/Export`ã€`Genarrative-Full-Build-And-Deploy` å’Œ `Genarrative-Notify-Email` çš„ç”Ÿäº§æµæ°´çº¿çŽ°éƒ½ä»¥ Linux agent ä¸ºä¸»ï¼Œä»æŒ‰å„自 Jenkinsfile çš„ checkout å£å¾„执行。Server provision ä¸ä½¿ç”¨å…¬ç½‘备用 Git æºï¼Œç›®æ ‡éƒ¨ç½² agent 也ä¸å†éœ€è¦è®¿é—®æºç  Git remote。 - `otelcol-contrib.service` 作为å¯é€‰ç³»ç»ŸæœåŠ¡åŠ å…¥ provisionï¼Œé»˜è®¤ç›‘å¬ `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是å¦å‘é€ OTLP ä»ç”± `GENARRATIVE_OTEL_ENABLED` 控制,æœåŠ¡ unit è§ `deploy/systemd/otelcol-contrib.service`。该æœåŠ¡å¿…é¡»å­˜åœ¨ç³»ç»Ÿç”¨æˆ· / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,å†è¡¥é½ `/etc/otelcol` é…置目录并é‡å¯æœåŠ¡ã€‚ -- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代ç†åˆ° `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,åŽå° API 使用 `genarrative_admin_rps`。通用 `/api` location ä¿ç•™ `client_max_body_size 64m` 作为编辑器图片ã€è§†é¢‘和文档请求的å代兜底,真实大å°ä»ç”±è·¯ç”±ä¸Žä¸šåŠ¡æ ¡éªŒè´Ÿè´£ã€‚è‹¥çº¿ä¸Šå‡ºçŽ° `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`ã€`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模æ¿ä¸Žå®žé™…媒体大å°ã€‚`limit_conn_status 429` å’Œ `limit_req_status 429` 必须在 HTTP 与 HTTPS server ä¸­åŒæ—¶ç”Ÿæ•ˆã€‚ +- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代ç†åˆ° `127.0.0.1:8082`,upstream keepalive 为 64;通用 API 使用 `genarrative_api_rps`,åŽå° API 使用 `genarrative_admin_rps`。通用 `/api` location ä¿ç•™ `client_max_body_size 210m` 作为编辑器图片ã€è§†é¢‘ã€æ–‡æ¡£è¯·æ±‚与游æˆå‘行包 PUTï¼ˆè·¯ç”±ä¸Šé™ 200 MiB + 1 KiB)的å代兜底,真实大å°ä»ç”±è·¯ç”±ä¸Žä¸šåŠ¡æ ¡éªŒè´Ÿè´£ï¼›ä½¿ç”¨ Pingora 网关时 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES` å¿…é¡»åŒæ­¥ä¸ä½ŽäºŽè¯¥å€¼ï¼Œå¦åˆ™ç½‘关侧会先返回 413。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`ã€`upstream_status=-`,说明请求在 Nginx 层被拦截,先核对 release 模æ¿ä¸Žå®žé™…媒体大å°ã€‚`limit_conn_status 429` å’Œ `limit_req_status 429` 必须在 HTTP 与 HTTPS server ä¸­åŒæ—¶ç”Ÿæ•ˆã€‚ 容器化隔离部署方案å•独放在 `deploy/container/`ï¼Œç”¨äºŽæœ¬æœºæˆ–é¢„å‘æ¨¡æ‹ŸçŽ°æœ‰çš„ Linux release + Nginx + OTLP Collector éž BgFilter æ‹“æ‰‘ï¼Œä¸æ›¿æ¢å½“å‰ç”Ÿäº§ `systemd + Nginx + Jenkins` å‘å¸ƒè·¯å¾„ã€‚å½“å‰ compose 没有 `bgfilter-worker`ï¼Œä¸æž„æˆå®Œæ•´ BgFilter 预呿‹“扑,也ä¸è¦†ç›–ä»»ä½•ä¼šè§¦å‘ BgFilter 的现役任务;它åªç”¨äºŽéž BgFilter 路径,或通过下述 unsupported job smoke 验è¯å¤–部生æˆé˜Ÿåˆ—çš„ claim / fail 回写和 API-only 更新。当å‰å®¹å™¨æ¨¡æ‹Ÿå‚æ•°ä¿ç•™ `genarrative-release` çš„ CPUã€`nofile=4096` 与 `worker_connections=768` 采样å£å¾„,并在 compose 里è½å®žåˆ° `spacetimedb cpus=1.0 mem_limit=2g`ã€`api-server cpus=2.0 mem_limit=1g`ã€`external-generation-worker cpus=2.0 mem_limit=1g`ã€`nginx cpus=0.5 mem_limit=128m`ã€`otelcol cpus=0.25 mem_limit=128m`。完整模å—首次实例化会超过旧 `896m` cgroup 上é™ï¼Œå› æ­¤ SpacetimeDB 必须使用 `2g`ï¼›è¿™ä¸æ”¹å˜ç”Ÿäº§æœåŠ¡èµ„æºåˆåŒã€‚容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,åªå¢žåŠ  Tokio worker 调度并å‘,ä¸çªç ´ `api-server cpus=2.0` çš„ CPU é…é¢ï¼›å®¹å™¨é»˜è®¤ `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,å¯ç”¨ `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验è¯ä¸ç»è¿‡ BgFilter çš„å¤–éƒ¨ç”Ÿæˆ worker åŠ¨æ€æ‰©ç¼©å®¹ï¼Œ`inline` 模å¼ä¸å‚与该验è¯ï¼š diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index accee82c1..e30e1e175 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -84,13 +84,14 @@ 1. AGC å‘布å–å½“å‰ npm 工程已æˆåŠŸæž„å»ºçš„ `dist/` å†…å®¹ï¼Œé‡æ–°æ£€æŸ¥å…¥å£å’Œå®žé™…字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对å‘è¡Œæ ¹ä¿æŒä¸å˜ã€‚ä¸å¾—ä¸Šä¼ æ•´ä¸ªé¡¹ç›®ã€æºç å¿«ç…§æˆ–ä»…å‘逿œ¬åœ°è·¯å¾„。网页 ZIP åŒæ ·è¦æ±‚æ ¹ `index.html`,ä¸çŒœæµ‹å¹¶è‡ªåŠ¨å‰¥ç¦»å¤šå±‚ç›®å½•ã€‚ 2. 所有è¿è¡Œä¾èµ–都必须在å‘è¡ŒåŒ…å†…ã€‚èµ„æº URL 使用与å‘行版本目录兼容的相对地å€ï¼›å‰å¯¼ `/assets`ã€æœ¬åœ°æ–‡ä»¶ URLã€å¤–部脚本/æ ·å¼/媒体/字体地å€å‡ä¸å±žäºŽå¯æŽ¥å—å‘行åˆåŒã€‚å®¢æˆ·ç«¯ç»™å‡ºå¯æ“作错误,æœåС噍ä»ç‹¬ç«‹æ ¡éªŒï¼›é™æ€æ ¡éªŒä¸èƒ½ä»£æ›¿è¿è¡Œæ—¶ CSP 阻断。 -3. 建议首版é™é¢ï¼šåŽ‹ç¼©åŒ… 100 MiBã€å±•å¼€æ€»é‡ 250 MiBã€å•文件 64 MiBã€æœ€å¤š 10,000 个文件ã€å±•å¼€/压缩比ä¸è¶…过 100。æœåŠ¡ç«¯æ‹’ç»åР坆 ZIPã€é‡å¤æˆ–大å°å†™å†²çªè·¯å¾„ã€ç»å¯¹è·¯å¾„ã€`..`ã€ç¬¦å·é“¾æŽ¥/é‡è§£æžç‚¹ã€è®¾å¤‡æ–‡ä»¶å’ŒåµŒå¥—åŽ‹ç¼©åŒ…ï¼›æ‹’ç» `.agent`ã€ç‰ˆæœ¬æŽ§åˆ¶ç›®å½•ã€`node_modules`ã€å‡­æ®æ–‡ä»¶ä¸Žæºç æ˜ å°„文件。超é™è¿”å›žæ˜Žç¡®é”™è¯¯ï¼Œä¸æˆªæ–­åŽç»§ç»­å‘布。 +3. 现行é™é¢ï¼šåŽ‹ç¼©åŒ… 200 MiBã€å±•å¼€æ€»é‡ 500 MiBã€å•文件 64 MiBã€æœ€å¤š 10,000 个文件ã€å±•å¼€/压缩比ä¸è¶…过 100。压缩包上é™åŒæ—¶å†³å®š `api-server` çš„å‘行包路由请求体上é™ï¼ˆ200 MiB + 1 KiB)与å代放行é‡ï¼šNginx 通用 `/api` location 为 `client_max_body_size 210m`,Pingora 网关为 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960`ï¼›ä¸‰è€…å¿…é¡»åŒæ—¶æ»¡è¶³ï¼Œå¦åˆ™åˆæ³•包会在å代或路由层被 413。æœåŠ¡ç«¯æ‹’ç»åР坆 ZIPã€é‡å¤æˆ–大å°å†™å†²çªè·¯å¾„ã€ç»å¯¹è·¯å¾„ã€`..`ã€ç¬¦å·é“¾æŽ¥/é‡è§£æžç‚¹ã€è®¾å¤‡æ–‡ä»¶å’ŒåµŒå¥—åŽ‹ç¼©åŒ…ï¼›æ‹’ç» `.agent`ã€ç‰ˆæœ¬æŽ§åˆ¶ç›®å½•ã€`node_modules`ã€å‡­æ®æ–‡ä»¶ä¸Žæºç æ˜ å°„文件。超é™è¿”å›žæ˜Žç¡®é”™è¯¯ï¼Œä¸æˆªæ–­åŽç»§ç»­å‘布。超过约 100 MiB 的包在 `api-server` ä¼šå¸¦æ¥æ•°ç™¾ MB 的瞬时内存å ç”¨ï¼Œå‘布窗å£ä¸Žå®žä¾‹è§„格需按容é‡éªŒè¯åŸºçº¿é¢„留。 4. æäº¤å£°æ˜Ž ZIP çš„ SHA-256 与字节数,æœåŠ¡ç«¯å¯¹æ”¶åˆ°çš„çœŸå®ž ZIP 釿–°è®¡ç®—,å†å¯¹å±•开文件建立相对路径ã€å­—节数和 SHA-256 清å•。摘è¦ä¸ä¸€è‡´ã€ç¼ºæ–‡ä»¶æˆ–入壿Ÿåæ—¶åœæ­¢ï¼›åªæœ‰ metadata 而没有已确认完整对象的æäº¤å¿…须失败。 5. 游æˆèµ„æ–™éšå‘行版本冻结:标题 2–40 å­—ã€çŸ­ç®€ä»‹ä¸è¶…过 120 å­—ã€è¯¦ç»†ä»‹ç»ä¸è¶…过 2,000 å­—ã€ä¸€ä¸ªåˆ†ç±»ã€æœ€å¤š 5 个标签(æ¯ä¸ªä¸è¶…过 20 字)ã€å¿…需å°é¢ã€æœ€å¤š 6 å¼ æˆªå›¾ã€æ“作方å¼ä¸è¶…过 240 字。分类首版为休闲ã€ç›Šæ™ºã€åŠ¨ä½œã€å†’é™©ã€æ¨¡æ‹Ÿã€ç­–ç•¥ã€å…¶ä»–ï¼›å°é¢/截图å¤ç”¨å¹³å°å›¾ç‰‡ä¸Šä¼ ä¸Žå½’å±žæ ¡éªŒï¼Œä¸æŽ¥å—ä»»æ„外链作为审核图片。作者ä¸éœ€è¦è‡ªå·±æž„建或打 ZIP:AGC å‘布时对 `game/` å­å·¥ç¨‹æŒ‰éœ€æ‰§è¡Œ `npm install`(å¤ç”¨ `project.bootstrap`)与 `npm run build`(å¤ç”¨ `project.verify` çš„å—æŽ§ npm è¿è¡Œå™¨ï¼Œè„šæœ¬ç™½åå•å« `build`ã€ç¦æ­¢é¡¹ç›®çº§ `.npmrc` æ”¹å†™è¯­ä¹‰ï¼‰ï¼Œå†æŠŠ `game/dist` å½’ä¸€åŒ–æˆæ ¹ `index.html` çš„å‘行包上传;已有å¯çީ入å£ï¼ˆ`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此å£å¾„端到端验è¯ï¼ˆæž„建产物ã€å‘行网关与网页沙箱播放)。å‘å¸ƒå…¥å£æŒ‰ç°åº¦ä¸‹å‘:åŽç«¯ç°åº¦é…置键固定为 `game-distribution:publish`(åŽå°ã€Œç°åº¦å‘布é…ç½®ã€å¯æ”¹ï¼Œæ”¯æŒ `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。ç°åº¦é»˜è®¤å…³é—­ï¼šæœªé…ç½®è¯¥é”®ã€æˆ– `enabled=false` 时,未登录与已登录作者都拿到ä¸å¼€æ”¾ï¼ˆå‘布入å£ä¸æ¸²æŸ“ã€å†™å…¥å£ 503);è¿è¥åœ¨åŽå°åˆ›å»ºè¯¥é”®å¹¶ `enabled=true` åŽï¼Œåªæœ‰ç™½åå• / ç°åº¦æ¯”例 / 用户标签命中的作者拿到开放状æ€ã€‚å‘布入å£çš„开放状æ€éš `/api/runtime/frontend-config` çš„ `gameDistributionPublishEnabled` 下å‘,网页广场/我的游æˆå…¥å£ä¸Ž AGC èŠå¤©å¤´ã€Œå‘布到游æˆå¹¿åœºã€æŒ‰é’®æ®æ­¤æ˜¾ç¤ºæˆ–éšè—;写入å£ä»ç‹¬ç«‹æ ¡éªŒï¼Œæ”¶ç´§æœŸé—´æäº¤è¿”回 503 与å¯è¯»æ–‡æ¡ˆï¼Œè¯»æŽ¥å£ã€ç›®å½•ã€è¯¦æƒ…ã€å‘行网关与安全下架ä¸å—å½±å“ã€‚ä½œè€…ç»­å‘æ—¶æŒ‰ç‰ˆæœ¬å†»ç»“快照回填å°é¢ä¸Žæˆªå›¾å¹¶å¤ç”¨åŒä¸€æ‰¹ç´ æï¼›å…¬å¼€æŠ•å½±åªæš´éœ²å¯¹è±¡é”®ï¼Œç´ æ ID åªåœ¨ä½œè€…与管ç†å‘˜å›žè¯»æ—¶è¿”回,快照里缺素æ ID çš„æ—§ç‰ˆæœ¬å¿…é¡»è¦æ±‚ä½œè€…é‡æ–°é€‰æ‹©å°é¢ã€‚AGC å‘å¸ƒé¢æ¿ä¸å±•示 ZIP è·¯å¾„ã€æ–‡ä»¶æ•°æˆ–体积等技术摘è¦ï¼›ä¸€å¥è¯ç®€ä»‹ä¸Žåˆ†ç±»å¯æ ¹æ®æœ‰ç•Œã€è„±æ•的创作上下文å…费生æˆï¼ˆä¸æ‰£ç”¨æˆ·æ³¥ç‚¹ï¼Œä»å¯ç¼–辑),分类必须收敛到上述白åå•;游æˆå°é¢æ”¯æŒåŸºäºŽé¡¹ç›®ä¸Šä¸‹æ–‡ç”Ÿæˆï¼Œç”Ÿæˆèµ°çŽ°å½¹å›¾ç‰‡ç”Ÿæˆä¸Žæ³¥ç‚¹æ‰£è´¹é“¾è·¯ï¼Œäº§ç‰©å¿…须登记为当å‰è´¦å·å¹³å°ç´ æåŽæ‰èƒ½ä½œä¸º `coverAssetId` æäº¤ã€‚ 6. `supportedDevices` è‡³å°‘åŒ…å« `desktop` 或 `mobile`ï¼›`inputModes` æ¥è‡ª `keyboard`ã€`mouse`ã€`touch`ï¼›å£°æ˜Žç§»åŠ¨ç«¯å¿…é¡»åŒ…å« `touch`。`orientation` 为 `landscape`ã€`portrait` 或 `responsive`ã€‚è¿™äº›æ˜¯å¾…äººå·¥å¤æ ¸çš„ä½œè€…å£°æ˜Žï¼Œç›®å½•åªæ˜¾ç¤ºå·²ç»éšç‰ˆæœ¬å®¡æ ¸é€šè¿‡çš„值。 7. 原始 ZIPã€æœªå®¡æ ¸å±•开目录ã€å®¡æ ¸èµ„æ–™å‡ä¸ºç§æœ‰å¯¹è±¡ï¼›å…¬å¼€ç‰ˆæœ¬ä¸æš´éœ²æºç é•œåƒé”®ã€æœ¬åœ°è·¯å¾„ã€è®¿é—®å‡­æ®æˆ–ç§æœ‰è´¦å·å…ƒæ•°æ®ã€‚è¿è¡Œæ–‡ä»¶åªèƒ½ç”±å‘行网关按游æˆã€ç‰ˆæœ¬å’Œæ–‡ä»¶ç™½åå•读å–,ä¸èƒ½ç»•过网关访问公开 OSS bucket。 8. 现役å‘行网关由 `api-server` æä¾›ï¼š`GET /api/game-distribution/releases/{gameId}`(å«å°¾æ–œæ ï¼‰ç­‰ä»·äºŽè¯¥æ¸¸æˆçš„ `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` åªæœåС当å‰å·²å…¬å¼€ç‰ˆæœ¬åŒ…å†…çš„æ–‡ä»¶ï¼Œç§æœ‰ ZIP 与未公开版本ä¸å› çŸ¥é“ ID 而å¯è¯»ã€‚å“应按扩展å白åå•设定内容类型,未知扩展å返回 404;全部å“应带 `X-Content-Type-Options: nosniff`ã€`Cross-Origin-Resource-Policy: cross-origin` 与ä¸å¸¦ credentials çš„ `Access-Control-Allow-Origin: *`(å‘行文档è¿è¡Œåœ¨ `allow-scripts` çš„ opaque origin 沙箱里,`same-origin` 会让游æˆè‡ªå·±çš„脚本被æµè§ˆå™¨æ‹¦ä¸‹ï¼‰ï¼ŒHTML è¿½åŠ æœ€å°æƒé™ CSPã€‚å¸¦å¹³å° `Cookie` 的请求一律 `403`,é¿å…å‘è¡Œæ–‡ä»¶è¢«ä¸»ç«™åŒæºè¯»å–ï¼›å‘è¡Œç½‘å…³å¿…é¡»éƒ¨ç½²åœ¨ç‹¬ç«‹æ¥æºã€‚å‘è¡ŒåŒ…æŒ‰å¯¹è±¡é”®åœ¨è¿›ç¨‹å†…åšæœ‰ç•Œç¼“存,å•个超预算包ä¸è¿›å…¥ç¼“存。 9. 审核通过时必须æäº¤ç»å¯¹ HTTPS `entryUrl`ï¼Œä¸”ä¸æŽ¥å—凭æ®ã€query å’Œ fragmentï¼›æœåŠ¡ç«¯ä¸æ ¹æ®è¯·æ±‚ Host 或本地路径拼默认å‘行地å€ï¼Œé¿å…æŠŠå†…ç½‘åœ°å€æˆ–ä¸»ç«™æ¥æºå†™è¿›å…¬å¼€æŠ•影。 éžç”Ÿäº§çŽ¯å¢ƒé¢å¤–å…许 http 回环地å€ï¼ˆ`127.0.0.1` / `localhost` / `[::1]`),å£å¾„与å‰ç«¯ `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验è¯å†…åµŒæ¸¸çŽ©ï¼›ç”Ÿäº§çŽ¯å¢ƒåªæŽ¥å— HTTPS。 +10. 上传有两æ¡ç­‰ä»·å…¥å£ï¼Œå…±ç”¨åŒä¸€ç‰ˆæœ¬çŠ¶æ€æœºã€æ‘˜è¦å£å¾„ã€å¹‚等键与校验规则:① 整包入å£â€”—网页端与旧客户端对 `versionId` 直接 `PUT` 整包字节,原å­ã€ä¸å¯ç»­ä¼ ï¼Œä»å—å‘行包上é™ä¸Žè¯·æ±‚体é™åˆ¶çº¦æŸï¼›â‘¡ 分片续传入å£â€”—AGC 原生一键å‘布对åŒä¸€ `versionId` 顺åºä¸Šä¼ å›ºå®šå¤§å°çš„分片,å†ä»¥å•独的完æˆåŠ¨ä½œæ”¶å£ã€‚分片大å°ç”±æœåŠ¡ç«¯ä¸‹å‘且固定(现为 8 MiB,éšå‘è¡ŒåŒ…ä¸Šé™ 200 MiB å–æ•´åˆ° 25 片以内),客户端ä¸å¾—自行改å˜ï¼›åˆ†ç‰‡ç»­ä¼ å…¥å£åªè¡¥ä¼ ç¼ºå¤±å­—节,任何分片é‡å¤æˆ–ä¹±åºéƒ½ä¸å¾—造æˆé‡å¤å†™å…¥ã€‚AGC ä¾§å¿…é¡»ç”±åŽŸç”Ÿè¿›ç¨‹ç›´æŽ¥è¯»å–æœ¬åœ°è¯•玩包并按分片å‘é€ï¼Œæ•´åŒ…字节ä¸å¾—ç»è¿‡ WebView IPC 往返,也ä¸å¾—整包驻留宿主内存。 ### 身份ã€çжæ€ã€å®¡æ ¸ä¸Žæ›´æ–° @@ -106,7 +107,9 @@ ### 幂等ã€å¹¶å‘与æ¢å¤ - æ‰€æœ‰åˆ›å»ºã€æäº¤ã€å®¡æ ¸ã€æ’¤é”€å’Œä¸‹æž¶åŠ¨ä½œæºå¸¦ `Idempotency-Key`。æœåŠ¡ç«¯ä»¥è®¤è¯ä¸»ä½“ã€åŠ¨ä½œå’Œ key ä¿å­˜è¯·æ±‚摘è¦ä¸Žç»“æžœï¼›åŒ key åŒè¯·æ±‚è¿”å›žåŽŸç»“æžœï¼ŒåŒ key ä¸åŒè¯·æ±‚返回 `409 IDEMPOTENCY_CONFLICT`。至少ä¿ç•™ 30 天;客户端超出æ¢å¤çª—å£å…ˆå›žè¯»è®°å½•,ä¸èƒ½æŠŠæœªçŸ¥ç»“果自动当作失败é‡å‘。 -- åŒä¸€ä¸ªç‰ˆæœ¬åªèƒ½ç¡®è®¤ä¸€ä»½ ZIP:中断é‡ä¼ ä»ä½¿ç”¨åŒ `versionId` 和摘è¦ï¼Œå·²ç¡®è®¤ç›¸åŒå­—节直接返回æˆåŠŸï¼Œä¸åŒæ‘˜è¦è¿”回 409。上传中åŒç‰ˆæœ¬ç¬¬äºŒä¸ªå†™å…¥è¿”回 `409 UPLOAD_IN_PROGRESS`;未确认åŠåŒ…ä¸ä¼šè¿›å…¥æ ¡éªŒã€‚首版整包é‡ä¼ ï¼Œä¸å®£ç§°æ”¯æŒåˆ†ç‰‡æ–­ç‚¹ç»­ä¼ ã€‚ +- åŒä¸€ä¸ªç‰ˆæœ¬åªèƒ½ç¡®è®¤ä¸€ä»½ ZIP:中断é‡ä¼ ä»ä½¿ç”¨åŒ `versionId` 和摘è¦ï¼Œå·²ç¡®è®¤ç›¸åŒå­—节直接返回æˆåŠŸï¼Œä¸åŒæ‘˜è¦è¿”回 409。上传中åŒç‰ˆæœ¬ç¬¬äºŒä¸ªå†™å…¥è¿”回 `409 UPLOAD_IN_PROGRESS`;未确认åŠåŒ…ä¸ä¼šè¿›å…¥æ ¡éªŒã€‚ +- 分片续传以「æœåŠ¡ç«¯å·²æ”¶å­—èŠ‚ã€ä¸ºå”¯ä¸€æƒå¨å移:客户端带上自己认为的å移上传分片,与æœåŠ¡ç«¯è®°å½•ä¸ä¸€è‡´æ—¶æœåŠ¡ç«¯è¿”å›ž `409` 与æƒå¨å移,客户端按æƒå¨å移继续,ä¸é‡æ”¾ä¹Ÿä¸è·³æ®µã€‚传输失败ã€ç½‘络中断ã€å®¢æˆ·ç«¯è¿›ç¨‹é€€å‡ºæˆ–应用é‡å¯åŽï¼ŒåŒä¸€ `versionId` 釿–°å‘布åªè¡¥ä¼ ç¼ºå¤±åˆ†ç‰‡ï¼›å·²æ”¶å­—节数由æœåŠ¡ç«¯æŒä¹…化事实决定,ä¸ä¾èµ–客户端本地记录。 +- 分片会è¯åœ¨å…¨éƒ¨å­—节到é½å¹¶æ‰§è¡Œå®ŒæˆåŠ¨ä½œä¹‹å‰ï¼Œä¸è¿›å…¥åŒ…校验ã€ä¸ç¡®è®¤ç‰ˆæœ¬ã€ä¸æ”¹å˜ä»»ä½•公开å¯è§æ€§ï¼ŒåŠåŒ…å¯¹è±¡ä¹Ÿä¸æœåŠ¡ç»™å‘行网关。完æˆåŠ¨ä½œé‡Œæ ¡éªŒå¤±è´¥æ—¶åˆ é™¤è¯¥åŠåŒ…对象并把版本è½åˆ° `upload_failed`(`recoveryAction=reupload`);作者è¦é‡æ–°ä¸Šä¼ åŒä¸€ç‰ˆæœ¬æ—¶å¿…须先显å¼é‡ç½®åˆ†ç‰‡ä¼šè¯ï¼Œé‡ç½®åŽå·²æ”¶å­—节归零,ä¸å…许在åŠåŒ…之上续写ä¸åŒå­—节。 - é‡å¤æäº¤åŒä¸€æ¬¡ AGC æ“作ä¸å¾—åˆ›å»ºç¬¬äºŒä¸ªæ¸¸æˆæˆ–版本;原生端æŒä¹…ä¿å­˜æ“作 IDã€ç›®æ ‡æ¸¸æˆ/版本和 key,网页ä¿å­˜æ¢å¤æ ‡è¯†å¹¶ä»¥æœåŠ¡ç«¯å›žè¯»ä¸ºå‡†ã€‚ç›¸åŒ ZIP 用于ä¸åŒèµ„料修订时å…许新版本,ä¸èƒ½ä»…按包摘è¦åžæŽ‰æ–°çš„å‘布æ„图。 - 公开版本切æ¢ã€ä½œè€…下架和管ç†å‘˜å®¡æ ¸å¿…须带 `expectedPublicationRevision`,在æŒä¹…化事务中比较并推进。并å‘å˜åŒ–返回 `409 PUBLICATION_CONFLICT`;旧é€å®¡ç‰ˆæœ¬ä¸èƒ½åœ¨ç”¨æˆ·å·²å‘布更新或下架之åŽé™é»˜è¦†ç›–状æ€ã€‚å®¡æ ¸å‘˜é‡æ–°æŸ¥çœ‹çŽ°çŠ¶åŽæ‰èƒ½æäº¤æ–°çš„æ˜Žç¡®åŠ¨ä½œã€‚ - 网络中断或å“应丢失åŽå…ˆæŸ¥è¯¢åŽŸæ“作/版本;æœåŠ¡ç«¯æ¢å¤ `validating` 的在途任务并按版本身份幂等续作,ä¸å¦å»ºç‰ˆæœ¬ã€‚登录失效ä¿ç•™ç§æœ‰è‰ç¨¿å’Œæ¢å¤æ ‡è¯†ï¼Œé‡æ–°ç™»å½•åŒè´¦å·åŽç»§ç»­ï¼›æ¢è´¦å·ä¸èƒ½è¯»å–æˆ–æŽ¥ç®¡åŽŸè´¦å·æ“作。 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 108f29425..ac56a40c2 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -19,7 +19,10 @@ use module_game_distribution::{ validate_release_zip, }; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest}; -use platform_oss::{OssGetObjectRequest, OssInternalPutObjectRequest, OssObjectAccess}; +use platform_oss::{ + OssAppendInternalObjectRequest, OssDeleteObjectRequest, OssGetObjectRequest, + OssInternalPutObjectRequest, OssObjectAccess, +}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::game_distribution::{ @@ -49,6 +52,13 @@ use crate::{ }; pub(crate) const MAX_PACKAGE_REQUEST_BODY_BYTES: usize = MAX_PACKAGE_BYTES as usize + 1024; +/// 分片续传的固定分片大å°ï¼š200 MiB 上é™ä¸‹æœ€å¤š 25 片,å•片远低于å代放行é‡ã€‚ +/// 客户端åªèƒ½ä½¿ç”¨æœåŠ¡ç«¯ä¸‹å‘的值,ä¸å¾—自行改å˜åˆ†ç‰‡è¾¹ç•Œï¼Œå¦åˆ™æƒå¨å移会立刻对ä¸ä¸Šã€‚ +pub(crate) const PACKAGE_UPLOAD_CHUNK_BYTES: usize = 8 * 1024 * 1024; +/// 分片路由的请求体放行é‡ï¼šåˆ†ç‰‡å¤§å° + 1 KiB 头部余é‡ã€‚ +pub(crate) const MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES: usize = PACKAGE_UPLOAD_CHUNK_BYTES + 1024; +/// 分片å移由客户端显å¼å£°æ˜Žï¼ŒæœåŠ¡ç«¯ä»¥å¯¹è±¡å½“å‰é•¿åº¦ä¸ºå”¯ä¸€æƒå¨ã€‚ +const PACKAGE_UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; const MAX_LIST_LIMIT: u32 = 48; const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128; const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024; @@ -60,7 +70,9 @@ const GAME_DISTRIBUTION_PUBLISHED_STATUS: &str = "published"; const GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS: usize = 3; const GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS: [u64; 2] = [250, 500]; const RELEASE_PACKAGE_CACHE_MAX_ENTRIES: usize = 4; -const RELEASE_PACKAGE_CACHE_MAX_BYTES: usize = 200 * 1024 * 1024; +/// 缓存字节预算必须比å•个å‘行包上é™å¤§å‡ºä¸€æ¡£ï¼Œå¦åˆ™ 200 MiB 档的包åªèƒ½åˆšå¥½è‡ªå æ•´ä»½é¢„算, +/// 任何并å‘çš„å°åŒ…都会被立刻挤掉。 +const RELEASE_PACKAGE_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024; /// å‘è¡Œé™æ€èµ„æºçš„进程内缓存。 /// @@ -179,6 +191,23 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/versions/{version_id}/package", put(upload_package).layer(DefaultBodyLimit::max(MAX_PACKAGE_REQUEST_BODY_BYTES)), ) + .route( + "/api/game-distribution/versions/{version_id}/package/upload-state", + get(package_upload_state), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/chunk", + put(upload_package_chunk) + .layer(DefaultBodyLimit::max(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES)), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/complete", + post(complete_package_upload), + ) + .route( + "/api/game-distribution/versions/{version_id}/package/reset", + post(reset_package_upload), + ) .route( "/api/game-distribution/versions/{version_id}/submit", post(submit_version), @@ -630,46 +659,10 @@ async fn upload_package( return Err(mapped); } }; - if manifest.package_sha256 != expected.package_sha256 - || manifest.package_bytes != expected.package_bytes - || u32::try_from(manifest.files.len()).unwrap_or(u32::MAX) != expected.package_file_count - || expected.package_entry_path != "index.html" - { - warn!( - request_id = ctx.request_id(), - operation = "package_rejected", - game_id = %expected.game_id, - version_id = %version_id, - code = "PACKAGE_MISMATCH", - declared_bytes = expected.package_bytes, - actual_bytes = manifest.package_bytes, - declared_file_count = expected.package_file_count, - actual_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), - elapsed_ms = ctx.elapsed(), - "å‘行包与版本声明ä¸ä¸€è‡´" - ); - let error = AppError::from_status(StatusCode::CONFLICT) - .with_code("PACKAGE_MISMATCH") - .with_details(json!({ - "provider": "game-distribution", - "message": "å‘行包摘è¦ã€ä½“ç§¯ã€æ–‡ä»¶æ•°æˆ–å…¥å£ä¸Žç‰ˆæœ¬å£°æ˜Žä¸ä¸€è‡´", - })); - record_upload_failure( - &state, - &owner_user_id, - &version_id, - &idempotency_key, - "PACKAGE_MISMATCH", - "å‘行包摘è¦ã€ä½“ç§¯ã€æ–‡ä»¶æ•°æˆ–å…¥å£ä¸Žç‰ˆæœ¬å£°æ˜Žä¸ä¸€è‡´".to_string(), - ) - .await; - return Err(error); - } let package_object_key = format!( "{GAME_DISTRIBUTION_OBJECT_PREFIX}{}/{version_id}.zip", expected.game_id ); - let package_manifest_json = package_manifest_json(&manifest)?; let oss = state.project_snapshot_oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("游æˆå‘行包 OSS 未é…ç½®") })?; @@ -697,7 +690,8 @@ async fn upload_package( None => false, }; if !skipped { - // 啿¬¡ 100 MiB PUT 在本机实测 12 秒上下,å¶å‘传输失败会让作者白传一次; + // 啿¬¡ 100 MiB æ¡£ PUT 在本机实测 12 ç§’ä¸Šä¸‹ï¼ˆä¸Šé™æå‡åˆ° 200 MiB åŽå•次耗时与失败 + // 暴露é¢åŒæ­¥æ”¾å¤§ï¼‰ï¼Œå¶å‘传输失败会让作者白传一次; // 这里按 platform-oss 既有的å¯é‡è¯•分类åšå—控é‡è¯•(åªé‡è¯•传输/è¶…æ—¶/408/429/5xx)。 oss.put_internal_object_with_retry( state.editor_oss_http_client(), @@ -714,28 +708,93 @@ async fn upload_package( .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; } + confirm_validated_package( + &state, + &ctx, + &owner_user_id, + &version_id, + &expected, + &manifest, + package_object_key, + &idempotency_key, + skipped, + ) + .await +} + +/// 校验通过åŽçš„å…±åŒæ”¶å£ï¼šå£°æ˜Žæ¯”对 → 确认 → 结构化事件。 +/// +/// 整包 `PUT` 与分片续传的完æˆåŠ¨ä½œå…±ç”¨è¿™æ¡è·¯å¾„,两ç§å…¥å£çš„æ ¡éªŒã€å¹‚等与事件å£å¾„必须一致; +/// 任何入å£éƒ½ä¸å¾—绕过它直接写版本状æ€ã€‚ +#[allow(clippy::too_many_arguments)] +async fn confirm_validated_package( + state: &AppState, + ctx: &RequestContext, + owner_user_id: &str, + version_id: &str, + expected: &GameDistributionVersionRecord, + manifest: &ReleasePackageManifest, + package_object_key: String, + idempotency_key: &str, + oss_put_skipped: bool, +) -> Result, AppError> { + if manifest.package_sha256 != expected.package_sha256 + || manifest.package_bytes != expected.package_bytes + || u32::try_from(manifest.files.len()).unwrap_or(u32::MAX) != expected.package_file_count + || expected.package_entry_path != "index.html" + { + warn!( + request_id = ctx.request_id(), + operation = "package_rejected", + game_id = %expected.game_id, + version_id = %version_id, + code = "PACKAGE_MISMATCH", + declared_bytes = expected.package_bytes, + actual_bytes = manifest.package_bytes, + declared_file_count = expected.package_file_count, + actual_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), + elapsed_ms = ctx.elapsed(), + "å‘行包与版本声明ä¸ä¸€è‡´" + ); + let error = AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_MISMATCH") + .with_details(json!({ + "provider": "game-distribution", + "message": "å‘行包摘è¦ã€ä½“ç§¯ã€æ–‡ä»¶æ•°æˆ–å…¥å£ä¸Žç‰ˆæœ¬å£°æ˜Žä¸ä¸€è‡´", + })); + record_upload_failure( + state, + owner_user_id, + version_id, + idempotency_key, + "PACKAGE_MISMATCH", + "å‘行包摘è¦ã€ä½“ç§¯ã€æ–‡ä»¶æ•°æˆ–å…¥å£ä¸Žç‰ˆæœ¬å£°æ˜Žä¸ä¸€è‡´".to_string(), + ) + .await; + return Err(error); + } + let package_manifest_json = package_manifest_json(manifest)?; let request_digest = compute_request_digest( - &serde_json::to_vec(&(version_id.as_str(), manifest.package_sha256.as_str())) + &serde_json::to_vec(&(version_id, manifest.package_sha256.as_str())) .map_err(|error| internal(error.to_string()))?, ); let log_game_id = expected.game_id.clone(); let log_package_bytes = manifest.package_bytes; let log_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX); let log_sha_prefix = manifest.package_sha256.chars().take(12).collect::(); - let log_oss_put_skipped = skipped; let confirmed = state .spacetime_client() .confirm_game_distribution_package( spacetime_client::GameDistributionConfirmPackageRecordInput { - version_id, - owner_user_id, - package_sha256: manifest.package_sha256, + version_id: version_id.to_string(), + owner_user_id: owner_user_id.to_string(), + package_sha256: manifest.package_sha256.clone(), package_bytes: manifest.package_bytes, package_file_count: u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), package_entry_path: "index.html".to_string(), package_object_key, package_manifest_json, - idempotency_key, + idempotency_key: idempotency_key.to_string(), request_digest, updated_at_micros: now_micros(), }, @@ -750,16 +809,341 @@ async fn upload_package( package_bytes = log_package_bytes, file_count = log_file_count, sha256_prefix = %log_sha_prefix, - oss_put_skipped = log_oss_put_skipped, + oss_put_skipped, elapsed_ms = ctx.elapsed(), "å‘行包已确认" ); Ok(json_success_body( - Some(&ctx), + Some(ctx), json!({ "versionId": confirmed.0.version_id, "status": confirmed.0.status }), )) } +/// åˆ†ç‰‡ç»­ä¼ çš„çŠ¶æ€æŸ¥è¯¢ï¼šå®¢æˆ·ç«¯æ‹¿åˆ°çš„ã€Œå·²æ”¶å­—èŠ‚ã€æ¥è‡ª OSS 对象事实,ä¸ä¾èµ–本地记录, +/// 因此进程é‡å¯ã€æ¢æœºå™¨æˆ–æ¢ç½‘络åŽéƒ½èƒ½ä»Žæƒå¨å移继续。 +async fn package_upload_state( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + Ok(json_success_body( + Some(&ctx), + json!({ + "versionId": version_id, + "status": version.status, + "chunkBytes": PACKAGE_UPLOAD_CHUNK_BYTES, + "declaredPackageBytes": version.package_bytes, + "receivedBytes": received_bytes, + }), + )) +} + +/// 分片写入。 +/// +/// 客户端声明的å移必须等于æœåŠ¡ç«¯å·²æ”¶å­—èŠ‚ï¼›ä¸ä¸€è‡´æ—¶è¿”回 409 与æƒå¨å移, +/// 由客户端按æƒå¨å移续传 —— è¿™æ ·é‡æ”¾ä¸Žä¹±åºéƒ½ä¸ä¼šé€ æˆé‡å¤å†™å…¥ã€‚ +async fn upload_package_chunk( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, + body: Bytes, +) -> Result, AppError> { + require_octet_stream_content_type(&headers)?; + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + // åˆ†ç‰‡çº§é‡æ”¾ç”±å移语义ä¿è¯ï¼Œè¿™é‡Œä»è¦æ±‚å¹‚ç­‰é”®ï¼Œä¿æŒä¸Žå…¶å®ƒå†™å…¥å£ä¸€è‡´çš„调用约定。 + let _idempotency_key = idempotency_key(&headers)?; + let offset = package_upload_offset(&headers)?; + if body.is_empty() { + return Err(bad_request("å‘行包分片内容ä¸èƒ½ä¸ºç©º")); + } + if body.len() > PACKAGE_UPLOAD_CHUNK_BYTES { + return Err(AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE) + .with_code("PACKAGE_CHUNK_TOO_LARGE") + .with_message("å‘行包分片超过æœåŠ¡ç«¯ä¸‹å‘的大å°")); + } + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + let chunk_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX); + let end = offset + .checked_add(chunk_bytes) + .ok_or_else(|| bad_request("å‘行包分片å移溢出"))?; + if end > version.package_bytes { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_EXCEEDS_DECLARED") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": offset, + "message": "分片写入会超过版本声明的å‘行包大å°", + }))); + } + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + if offset != received_bytes { + warn!( + request_id = ctx.request_id(), + operation = "package_chunk_offset_mismatch", + game_id = %version.game_id, + version_id = %version_id, + declared_offset = offset, + received_bytes, + "å‘行包分片å移与æœåŠ¡ç«¯å·²æ”¶å­—èŠ‚ä¸ä¸€è‡´" + ); + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_OFFSET_MISMATCH") + .with_message("分片å移与æœåŠ¡ç«¯å·²æ”¶å­—èŠ‚ä¸ä¸€è‡´ï¼Œè¯·æŒ‰æƒå¨å移续传") + .with_details(json!({ + "provider": "game-distribution", + "receivedBytes": received_bytes, + }))); + } + let appended = oss + .append_internal_object_with_retry( + state.editor_oss_http_client(), + OssAppendInternalObjectRequest { + object_key, + content_type: Some("application/zip".to_string()), + position: offset, + body: body.to_vec(), + }, + GAME_DISTRIBUTION_OSS_PUT_MAX_ATTEMPTS, + &GAME_DISTRIBUTION_OSS_PUT_RETRY_DELAYS_MS, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + info!( + request_id = ctx.request_id(), + operation = "package_chunk_stored", + game_id = %version.game_id, + version_id = %version_id, + offset, + chunk_bytes = appended.appended_bytes, + received_bytes = appended.next_position, + elapsed_ms = ctx.elapsed(), + "å‘行包分片已写入" + ); + Ok(json_success_body( + Some(&ctx), + json!({ + "versionId": version_id, + "chunkBytes": PACKAGE_UPLOAD_CHUNK_BYTES, + "receivedBytes": appended.next_position, + }), + )) +} + +/// 分片续传的完æˆåŠ¨ä½œï¼šå…¨éƒ¨å­—èŠ‚åˆ°é½åŽæ‰å›žè¯»æ•´åŒ…ã€æ ¡éªŒå¹¶ç¡®è®¤ã€‚ +/// +/// 校验失败时删除åŠåŒ…对象并把版本è½åˆ° `upload_failed`,é¿å…åŠåŒ…留在对象键上拖ä½åŽç»­é‡ä¼ ã€‚ +async fn complete_package_upload( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let idempotency_key = idempotency_key(&headers)?; + let version = + load_owner_version_or_404(&state, owner_user_id.clone(), version_id.clone()).await?; + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + let received_bytes = staged_package_bytes(&state, oss, &object_key).await?; + if received_bytes == 0 { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_NOT_STARTED") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": 0, + "message": "该版本还没有任何已收分片", + }))); + } + if received_bytes != version.package_bytes { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_INCOMPLETE") + .with_message("å‘行包分片尚未收é½") + .with_details(json!({ + "provider": "game-distribution", + "declaredPackageBytes": version.package_bytes, + "receivedBytes": received_bytes, + }))); + } + let body = oss + .get_object( + state.editor_oss_http_client(), + OssGetObjectRequest { + object_key: object_key.clone(), + max_bytes: MAX_PACKAGE_BYTES as usize, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let manifest = match validate_release_zip(&body) { + Ok(manifest) => manifest, + Err(error) => { + let reason = format!("{error:?}"); + warn!( + request_id = ctx.request_id(), + operation = "package_rejected", + game_id = %version.game_id, + version_id = %version_id, + code = "PACKAGE_VALIDATION_FAILED", + reason = %reason, + uploaded_bytes = body.len(), + elapsed_ms = ctx.elapsed(), + "å‘行包校验失败" + ); + let mapped = map_package_error(error); + if let Err(delete_error) = oss + .delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + { + warn!( + request_id = ctx.request_id(), + operation = "package_staging_delete_failed", + version_id = %version_id, + error = %delete_error, + "校验失败的åŠåŒ…对象删除失败,需è¦äººå·¥ç¡®è®¤å¯¹è±¡é”®çжæ€" + ); + } + record_upload_failure( + &state, + &owner_user_id, + &version_id, + &idempotency_key, + "PACKAGE_VALIDATION_FAILED", + reason, + ) + .await; + return Err(mapped); + } + }; + confirm_validated_package( + &state, + &ctx, + &owner_user_id, + &version_id, + &version, + &manifest, + object_key, + &idempotency_key, + true, + ) + .await +} + +/// 显å¼é‡ç½®åˆ†ç‰‡ä¼šè¯ï¼šåˆ é™¤åŠåŒ…对象并把已收字节归零。 +/// +/// åªæœ‰å°šæœªç¡®è®¤è¿‡å‘行包的版本能é‡ç½®ï¼›å·²ç¡®è®¤çš„版本必须新建版本,ä¸èƒ½åœ¨åŠåŒ…之上续写ä¸åŒå­—节。 +async fn reset_package_upload( + State(state): State, + Extension(ctx): Extension, + Extension(auth): Extension, + headers: HeaderMap, + Path(version_id): Path, +) -> Result, AppError> { + let owner_user_id = auth.claims().user_id().to_string(); + ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let _idempotency_key = idempotency_key(&headers)?; + let version = load_owner_version_or_404(&state, owner_user_id, version_id.clone()).await?; + if !matches!(version.status.as_str(), "awaiting_upload" | "upload_failed") { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_code("PACKAGE_UPLOAD_RESET_NOT_ALLOWED") + .with_message("该版本已ç»ç¡®è®¤è¿‡å‘è¡ŒåŒ…ï¼Œé‡æ–°ä¸Šä¼ è¯·æ–°å»ºç‰ˆæœ¬")); + } + let oss = game_distribution_oss_client(&state)?; + let object_key = game_distribution_package_object_key(&version.game_id, &version_id); + oss.delete_object( + state.editor_oss_http_client(), + OssDeleteObjectRequest { + object_key: object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + info!( + request_id = ctx.request_id(), + operation = "package_upload_reset", + game_id = %version.game_id, + version_id = %version_id, + elapsed_ms = ctx.elapsed(), + "å‘行包分片会è¯å·²é‡ç½®" + ); + Ok(json_success_body( + Some(&ctx), + json!({ "versionId": version_id, "receivedBytes": 0 }), + )) +} + +fn game_distribution_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { + state.project_snapshot_oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("游æˆå‘行包 OSS 未é…ç½®") + }) +} + +fn game_distribution_package_object_key(game_id: &str, version_id: &str) -> String { + format!("{GAME_DISTRIBUTION_OBJECT_PREFIX}{game_id}/{version_id}.zip") +} + +/// 已收字节的æƒå¨æ¥æºï¼šå¯¹è±¡å­˜åœ¨æ—¶çš„长度;确定ä¸å­˜åœ¨æ—¶æ˜¯ 0,其它失败按上游错误上报。 +async fn staged_package_bytes( + state: &AppState, + oss: &platform_oss::OssClient, + object_key: &str, +) -> Result { + let head = oss + .head_internal_object(state.editor_oss_http_client(), object_key) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + Ok(head.map(|object| object.content_length).unwrap_or(0)) +} + +fn require_octet_stream_content_type(headers: &HeaderMap) -> Result<(), AppError> { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + }); + if content_type.as_deref() != Some("application/octet-stream") { + return Err(bad_request("å‘行包分片必须使用 application/octet-stream")); + } + Ok(()) +} + +fn package_upload_offset(headers: &HeaderMap) -> Result { + let raw = headers + .get(PACKAGE_UPLOAD_OFFSET_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| bad_request("缺少å‘行包分片åç§»"))?; + raw.parse::() + .map_err(|_| bad_request("å‘行包分片å移必须是éžè´Ÿæ•´æ•°")) +} + async fn submit_version( State(state): State, Extension(ctx): Extension, @@ -1969,6 +2353,64 @@ mod tests { } } + #[test] + fn package_request_body_limit_covers_max_package_bytes() { + // å£å¾„约æŸï¼šå‘行包路由的请求体放行é‡å¿…须覆盖包体上é™ï¼Œå¦åˆ™åˆæ³•包会在 + // `DefaultBodyLimit` 处被 413,而 ZIP 校验根本没机会执行。 + assert!(MAX_PACKAGE_REQUEST_BODY_BYTES > MAX_PACKAGE_BYTES as usize); + } + + #[test] + fn package_chunk_size_stays_inside_declared_limits() { + // 分片必须能整除å¼åœ°è¦†ç›– 200 MiB æ¡£å‘行包(最多 25 片),且分片放行é‡è¦ç•™å‡ºå¤´éƒ¨ä½™é‡ã€‚ + assert_eq!(PACKAGE_UPLOAD_CHUNK_BYTES, 8 * 1024 * 1024); + assert!(PACKAGE_UPLOAD_CHUNK_BYTES < MAX_PACKAGE_BYTES as usize); + assert!(MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES > PACKAGE_UPLOAD_CHUNK_BYTES); + assert!( + (MAX_PACKAGE_BYTES as usize).div_ceil(PACKAGE_UPLOAD_CHUNK_BYTES) <= 25, + "200 MiB æ¡£å‘行包的分片数必须ä¸è¶…过 25 片" + ); + } + + #[test] + fn package_upload_offset_requires_non_negative_integer() { + let mut headers = HeaderMap::new(); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static(" ")); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert(PACKAGE_UPLOAD_OFFSET_HEADER, HeaderValue::from_static("-1")); + assert!(package_upload_offset(&headers).is_err()); + + headers.insert( + PACKAGE_UPLOAD_OFFSET_HEADER, + HeaderValue::from_static("8388608"), + ); + assert_eq!( + package_upload_offset(&headers).expect("åˆæ³•åç§»"), + PACKAGE_UPLOAD_CHUNK_BYTES as u64 + ); + } + + #[test] + fn package_chunk_content_type_must_be_octet_stream() { + let mut headers = HeaderMap::new(); + assert!(require_octet_stream_content_type(&headers).is_err()); + + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/zip"), + ); + assert!(require_octet_stream_content_type(&headers).is_err()); + + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + assert!(require_octet_stream_content_type(&headers).is_ok()); + } + #[test] fn metadata_rejects_mobile_games_without_touch_support() { let mut payload = metadata(); diff --git a/server-rs/crates/module-game-distribution/src/package.rs b/server-rs/crates/module-game-distribution/src/package.rs index 5e8faf9f5..feb85a54e 100644 --- a/server-rs/crates/module-game-distribution/src/package.rs +++ b/server-rs/crates/module-game-distribution/src/package.rs @@ -6,8 +6,13 @@ use std::{ use sha2::{Digest, Sha256}; -pub const MAX_PACKAGE_BYTES: u64 = 100 * 1024 * 1024; -pub const MAX_EXPANDED_BYTES: u64 = 250 * 1024 * 1024; +/// å‘行包体积上é™ã€‚å代放行é‡ä¸Žè·¯ç”±è¯·æ±‚体上é™éƒ½ä»Žå®ƒæ´¾ç”Ÿï¼šNginx +/// `client_max_body_size`ã€Pingora `MAX_API_BODY_BYTES` å¿…é¡»åŒæ­¥æ”¾å®½ï¼Œå¦åˆ™åˆæ³•包会在 +/// 到达 `api-server` 之å‰è¢«æ‹’。 +pub const MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024; +/// 展开总é‡ä¸Šé™ä¿æŒåŽ‹ç¼©åŒ…ä¸Šé™çš„ 2.5 å€ä½™é‡ï¼šåŒ…体本身基本ä¸å¯å†åŽ‹æ—¶å±•å¼€é‡çº¦ç­‰äºŽåŒ…体, +/// 纯文本 / JSON 资æºå æ¯”高的包ä»è¦æœ‰è¶³å¤Ÿç©ºé—´ã€‚ +pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; pub const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; pub const MAX_FILE_COUNT: usize = 10_000; pub const MAX_COMPRESSION_RATIO: u64 = 100; @@ -187,6 +192,23 @@ mod tests { output.into_inner() } + /// 存储型æ¡ç›®çš„åŽ‹ç¼©åŒ…ï¼›ç”¨äºŽæž„é€ ä½“ç§¯å¯æŽ§ä¸”ä¸å‚与 deflate 的大包。 + fn stored_archive(files: &[(&str, &[u8])]) -> Vec { + let mut output = Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(&mut output); + for (path, content) in files { + writer + .start_file( + *path, + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored), + ) + .expect("zip entry"); + writer.write_all(content).expect("zip content"); + } + writer.finish().expect("finish zip"); + output.into_inner() + } + #[test] fn accepts_root_entry_and_returns_file_manifest() { let bytes = archive(&[("index.html", b""), ("assets/a.txt", b"a")]); @@ -222,4 +244,27 @@ mod tests { Err(ReleasePackageError::InvalidPath) ); } + + #[test] + fn keeps_expansion_headroom_over_package_limit() { + // å£å¾„约æŸï¼šå‘行包上é™è°ƒæ•´æ—¶ï¼Œå±•开总é‡è‡³å°‘è¦ç•™å‡ºä¸¤å€ä½™é‡ï¼Œ + // å¦åˆ™é«˜æ–‡æœ¬å æ¯”çš„åˆæ³•åŒ…ä¼šåœ¨å±•å¼€é‡æ£€æŸ¥å¤„被误拒。 + assert!(MAX_EXPANDED_BYTES >= MAX_PACKAGE_BYTES.saturating_mul(2)); + } + + #[test] + fn accepts_package_above_the_previous_hundred_mib_limit() { + // 上é™ä»Ž 100 MiB æåˆ° 200 MiB 的回归防护:两个 50 MiB 存储型æ¡ç›®ç»„æˆ 100 MiB + // 出头的包,旧上é™ä¼šåœ¨è¿™é‡Œåˆ¤ PackageTooLarge,新上é™å¿…须放行并给出完整清å•。 + let chunk = vec![0_u8; 50 * 1024 * 1024]; + let bytes = stored_archive(&[ + ("index.html", b""), + ("assets/a.bin", chunk.as_slice()), + ("assets/b.bin", chunk.as_slice()), + ]); + assert!(bytes.len() as u64 > 100 * 1024 * 1024); + let manifest = validate_release_zip(&bytes).expect("package above 100 MiB"); + assert_eq!(manifest.package_bytes, bytes.len() as u64); + assert_eq!(manifest.files.len(), 3); + } } diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 88af466f2..9c1a6e877 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -37,7 +37,9 @@ const DEFAULT_WEB_ROOT: &str = "/srv/genarrative/web"; const DEFAULT_ACME_ROOT: &str = "/var/www/html"; const DEFAULT_MAINTENANCE_FILE: &str = "/var/lib/genarrative/maintenance/enabled"; const DEFAULT_MAINTENANCE_PAGE_FILE: &str = "/var/lib/genarrative/maintenance/page.html"; -const DEFAULT_MAX_API_BODY_BYTES: u64 = 64 * 1024 * 1024; +// 通用 /api 路由的放行上é™ï¼›å¿…须覆盖游æˆå‘行包 PUT(路由 `DefaultBodyLimit` 200 MiB + 1 KiB), +// 具体接å£çš„真实上é™ä»ç”± api-server é€è·¯ç”±æ ¡éªŒã€‚ +const DEFAULT_MAX_API_BODY_BYTES: u64 = 210 * 1024 * 1024; const DEFAULT_GZIP_LEVEL: u32 = 5; const DEFAULT_GZIP_MIN_LENGTH_BYTES: u64 = 1024; const DEFAULT_UPSTREAM_CONNECT_TIMEOUT_MS: u64 = 3_000; diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 32b3d02f6..4cce2d31a 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -132,6 +132,29 @@ pub struct OssInternalPutObjectRequest { pub body: Vec, } +/// 内部对象的追加写请求(OSS AppendObject)。 +/// +/// `position = 0` è¡¨ç¤ºè¿½åŠ åˆ°å½“å‰æœ«å°¾ï¼›`position > 0` 必须等于对象当å‰é•¿åº¦ï¼Œ +/// å¦åˆ™ä¸Šæ¸¸ç›´æŽ¥å¤±è´¥ —— 分片续传ä¾èµ–è¿™æ¡è¯­ä¹‰ä¿è¯é‡æ”¾ä¸ä¼šé‡å¤å†™å…¥ã€‚ +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssAppendInternalObjectRequest { + pub object_key: String, + pub content_type: Option, + pub position: u64, + pub body: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssAppendInternalObjectResponse { + pub provider: &'static str, + pub bucket: String, + pub endpoint: String, + pub object_key: String, + pub appended_bytes: u64, + /// 下一次å¯å†™ä½ç½®ï¼Œç”± OSS 返回,是「已收字节ã€çš„æƒå¨å€¼ã€‚ + pub next_position: u64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct OssPutObjectRequest { pub prefix: LegacyAssetPrefix, @@ -1018,6 +1041,105 @@ impl OssClient { .await } + /// 内部对象追加写,返回 OSS 给出的下一次å¯å†™ä½ç½®ï¼ˆå·²æ”¶å­—节的æƒå¨å€¼ï¼‰ã€‚ + /// + /// 与整对象 PUT 的区别是「å¯ç»­å†™ã€ï¼š`position = 0` è¿½åŠ åˆ°å½“å‰æœ«å°¾ï¼Œ + /// `position > 0` 必须与对象当å‰é•¿åº¦ä¸€è‡´ï¼ˆä¸ä¸€è‡´æ—¶ OSS 判失败,ä¸ä¼šé‡å¤å†™å…¥ï¼‰ã€‚ + /// 因此续传åªéœ€è¦å›žè¯»å¯¹è±¡é•¿åº¦ï¼Œå†ä»Žæœªå†™ä½ç½®ç»§ç»­ã€‚ + pub async fn append_internal_object( + &self, + client: &reqwest::Client, + request: OssAppendInternalObjectRequest, + ) -> Result { + let object_key = normalize_internal_object_key(&request.object_key)?; + if request.body.is_empty() { + return Err(OssError::InvalidRequest( + "æœåŠ¡ç«¯å†…éƒ¨å¯¹è±¡è¿½åŠ å†…å®¹ä¸èƒ½ä¸ºç©º".to_string(), + )); + } + let content_type = normalize_optional_value(request.content_type); + let mut target_url = + build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err( + |error| { + request_error( + OssRequestOperation::Put, + &format!("构造 OSS 对象 URL 失败:{error}"), + ) + }, + )?; + target_url + .query_pairs_mut() + .append_pair("append", "") + .append_pair("position", &request.position.to_string()); + let appended_bytes = u64::try_from(request.body.len()) + .map_err(|_| OssError::InvalidRequest("追加内容大å°è¶…å‡ºå¯æ”¯æŒèŒƒå›´".to_string()))?; + let headers = BTreeMap::new(); + let builder = signed_request_builder( + client, + &self.config, + Method::POST, + Some(&object_key), + target_url, + content_type.as_deref(), + &headers, + )? + .header(reqwest::header::CONTENT_LENGTH, appended_bytes) + .body(request.body); + let response = builder + .send() + .await + .map_err(|error| request_error_from_reqwest(OssRequestOperation::Put, error))?; + if !response.status().is_success() { + return Err(request_status_error( + OssRequestOperation::Put, + response.status().as_u16(), + format!("OSS AppendObject 失败,状æ€ç ï¼š{}", response.status()), + )); + } + let next_position = response + .headers() + .get("x-oss-next-append-position") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| { + OssError::InvalidRequest("OSS AppendObject 未返回 next-append-position".to_string()) + })?; + Ok(OssAppendInternalObjectResponse { + provider: OSS_PROVIDER, + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + object_key, + appended_bytes, + next_position, + }) + } + + /// å†…éƒ¨å¯¹è±¡è¿½åŠ å†™çš„å—æŽ§é‡è¯•,判定与退é¿å£å¾„å’Œ `put_internal_object_with_retry` 一致。 + pub async fn append_internal_object_with_retry( + &self, + client: &reqwest::Client, + request: OssAppendInternalObjectRequest, + max_attempts: usize, + retry_delays_ms: &[u64], + ) -> Result { + if max_attempts == 0 { + return Err(OssError::InvalidConfig( + "内部对象追加é‡è¯•次数至少为 1".to_string(), + )); + } + if retry_delays_ms.len() < max_attempts.saturating_sub(1) { + return Err(OssError::InvalidConfig( + "内部对象追加é‡è¯•缺少退é¿é…ç½®".to_string(), + )); + } + let log_key = request.object_key.clone(); + run_internal_put_with_retry(max_attempts, retry_delays_ms, &log_key, move || { + let request = request.clone(); + async move { self.append_internal_object(client, request).await } + }) + .await + } + async fn put_internal_object_bytes( &self, client: &reqwest::Client, @@ -1709,7 +1831,7 @@ where max_attempts, retry_delay_ms = delay_ms, error = %error, - "OSS 内部对象 PUT 失败,按退é¿é‡è¯•" + "OSS 内部对象写入失败,按退é¿é‡è¯•" ); sleep(std::time::Duration::from_millis(delay_ms)).await; attempt += 1; diff --git a/src/components/game-distribution/gameZipPackage.test.ts b/src/components/game-distribution/gameZipPackage.test.ts index d4f4181a0..ca2a4a826 100644 --- a/src/components/game-distribution/gameZipPackage.test.ts +++ b/src/components/game-distribution/gameZipPackage.test.ts @@ -3,7 +3,7 @@ import JSZip from 'jszip'; import { describe, expect, it } from 'vitest'; -import { prepareGamePackage } from './gameZipPackage'; +import { GAME_PACKAGE_MAX_BYTES, prepareGamePackage } from './gameZipPackage'; async function buildZip(files: Record) { const zip = new JSZip(); @@ -52,4 +52,12 @@ describe('prepareGamePackage', () => { ), ).rejects.toThrow('å‘è¡ŒåŒ…ä¸æ˜¯æœ‰æ•ˆçš„ ZIP'); }); + + it('è¶…è¿‡ä¸Šé™æ—¶åœ¨è¯»å–字节之å‰å°±å¤±è´¥å…³é—­', async () => { + // åªä¼ªé€ ä½“积,ä¸çœŸçš„åˆ†é… 200 MiBï¼›é¢„æ£€å¿…é¡»åœ¨è¯»å­—èŠ‚ä¹‹å‰æ‹¦ä¸‹ã€‚ + const oversized = { size: GAME_PACKAGE_MAX_BYTES + 1 } as unknown as File; + await expect(prepareGamePackage(oversized)).rejects.toThrow( + 'å‘行包ä¸èƒ½è¶…过 200 MiB', + ); + }); }); diff --git a/src/components/game-distribution/gameZipPackage.ts b/src/components/game-distribution/gameZipPackage.ts index eb8497f18..fcc25c8da 100644 --- a/src/components/game-distribution/gameZipPackage.ts +++ b/src/components/game-distribution/gameZipPackage.ts @@ -1,6 +1,6 @@ import JSZip from 'jszip'; -export const GAME_PACKAGE_MAX_BYTES = 100 * 1024 * 1024; +export const GAME_PACKAGE_MAX_BYTES = 200 * 1024 * 1024; export const GAME_PACKAGE_MAX_FILE_COUNT = 10_000; export type PreparedGamePackage = { @@ -59,7 +59,7 @@ export async function prepareGamePackage( throw new Error('请选择éžç©ºçš„å‘行包 ZIP'); } if (file.size > GAME_PACKAGE_MAX_BYTES) { - throw new Error('å‘行包ä¸èƒ½è¶…过 100 MiB'); + throw new Error('å‘行包ä¸èƒ½è¶…过 200 MiB'); } const bytes = await readGamePackageBytes(file); let archive: JSZip;