From e6c3729adbcee55209079001ada73f6b7c62166e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:36:28 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E5=8F=91=E5=B8=83=E5=89=8D=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=B9=B3=E5=8F=B0=E5=8C=85=E4=BD=93=E4=B8=8A=E9=99=90?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=8A=8A=E4=B8=8A=E9=99=90=E5=8D=95=E4=B8=80?= =?UTF-8?q?=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 新增 GAME_DISTRIBUTION_MAX_PACKAGE_BYTES(200 MiB),服务端 module-game-distribution 保留同值常量并在注释里指明;api-server 新增 publish_package_limit_matches_the_shared_contract 守卫测试把两处锁一致 - AGC project/export 新增 ensure_within_platform_package_limit:读包阶段预检,超限返回「发行包 X MiB 超过平台上限 200 MiB;请精简资源后重新导出再发布」,不再让 200–512 MiB 的包白读盘/暂存后吃服务端 413 - 用例:project::export::npm_export_tests::publish_precheck_rejects_packages_over_the_platform_limit(边界=上限放行、超限文案、本地导出上限确实更宽) - 里程碑 C1 与 pitfalls 记录「本地导出上限 ≠ 平台发布上限」这一口径 --- .../src-tauri/src/project/export.rs | 33 +++++++++++++++++++ ...‹碑】游戏分发目录详情与在线游玩-2026-09-18.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 6 ++++ .../src/modules/game_distribution.rs | 10 ++++++ .../module-game-distribution/src/package.rs | 4 +++ .../shared-contracts/src/game_distribution.rs | 6 ++++ 6 files changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 3ed1dfe83..3f27a23ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -301,6 +301,22 @@ pub(crate) async fn export_local_project_package_for_publish_at( export_local_project_package_at(root) } +/// 发布前的客户端预检:包体不得超过平台上限。 +/// +/// 服务端仍会独立校验;这里先判一次是为了给作者可操作错误(含实际体积与上限), +/// 而不是让他把包传上去再吃一个 413。上限单一来源在 `shared-contracts`。 +fn ensure_within_platform_package_limit(package_bytes: u64) -> Result<(), String> { + let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES; + if package_bytes > limit { + return Err(format!( + "发行包 {:.1} MiB 超过平台上限 {} MiB;请精简资源后重新导出再发布。", + package_bytes as f64 / (1024.0 * 1024.0), + limit / (1024 * 1024) + )); + } + Ok(()) +} + pub(crate) fn read_local_project_export_package_at( root: &Path, package_relative_path: &str, @@ -322,6 +338,7 @@ pub(crate) fn read_local_project_export_package_at( if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES { return Err("发行包大小超出本地发布上限".to_string()); } + ensure_within_platform_package_limit(metadata.len())?; let package_bytes = fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?; if package_bytes.len() as u64 != metadata.len() { @@ -643,6 +660,22 @@ pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result mod npm_export_tests { use super::*; + #[test] + fn publish_precheck_rejects_packages_over_the_platform_limit() { + let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES; + // 边界:正好等于上限放行,差一个字节也放行(服务端同样按 `>` 判定)。 + ensure_within_platform_package_limit(limit).unwrap(); + ensure_within_platform_package_limit(limit - 1).unwrap(); + // 超限:错误文案必须给出实际体积与上限,作者才能据此精简。 + let error = ensure_within_platform_package_limit(limit + 1).expect_err("over limit"); + assert!(error.contains("超过平台上限"), "{error}"); + assert!(error.contains("200 MiB"), "{error}"); + assert!(error.contains("MiB"), "{error}"); + // 本地导出上限更宽松(512 MiB):超平台的包必须在读包阶段就被拦下, + // 不能等到上传后由服务端 413。 + assert!(limit < MAX_PROJECT_EXPORT_PACKAGE_BYTES); + } + #[test] fn npm_package_contains_only_dist_and_publish_readme() { let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 4ed1582c1..00da8e48d 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -95,7 +95,7 @@ ### 行为与验收 -- [ ] AGC 从已构建 dist 生成根入口为 `index.html` 的真实包,一次提交动作完成检查、资料确认、上传和送审;状态及失败原因与服务端回读一致。 +- [ ] AGC 从已构建 dist 生成根入口为 `index.html` 的真实包,一次提交动作完成检查、资料确认、上传和送审;状态及失败原因与服务端回读一致。(2026-09-29 补客户端侧检查口径:此前客户端只有本地导出上限 **512 MiB**,比平台上限宽一倍多——300 MiB 的包会一路读盘/暂存到建版本时才吃服务端 413。现在上限单一来源落在 `shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES`(200 MiB),AGC 读包阶段先做 `ensure_within_platform_package_limit` 预检并给出「发行包 X MiB 超过平台上限 200 MiB;请精简资源后重新导出再发布」;服务端 `module-game-distribution` 仍保留自己的常量,由 api-server 的 `publish_package_limit_matches_the_shared_contract` 把两者锁成一致(改一处不改另一处直接变红)。AGS 侧用例 `project::export::npm_export_tests::publish_precheck_rejects_packages_over_the_platform_limit` 覆盖边界(=上限放行)与超限文案。仍未验收:AGC GUI 里真实一键发布与状态/失败原因回读一致。) - [ ] AGC 发布面板隐藏发行包技术摘要;打开时基于有界、脱敏的项目上下文免费生成一句话简介与白名单分类,失败保留本地兜底且不阻断发布;作者始终可以直接编辑生成结果。(2026-09-29 补客户端侧口径:「脱敏」此前只在服务端有界——`buildPublishProjectContext()` 会把素材的 `localPath` 原样写进提示词,登记来源不受本地约束时绝对路径(含用户目录)就会外发。现在素材只带**文件名**(`contextSafeAssetLabel` 取最后一段并限长 80),上下文整体仍截到 6,000 字符;`gameDistributionPublishPanel.test.tsx` 新增「发布资料建议只带脱敏且有界的项目上下文」并用 40 个绝对路径素材 + 超长版本说明验证。变异验证:把该处改回 `asset.localPath` 原样拼接后,用例立即以 `expected ... to not include 'C:\Users'` 变红。GUI 打开面板、真实 Provider 生成与失败兜底仍待客户端验收。) - [ ] AGC 发布封面支持基于项目上下文生成,复用现役图片生成与泥点扣费链路;生成结果登记为当前账号平台素材后自动作为 `coverAssetId`,不二次上传。 - [x] 网页可选 ZIP、提交封面和必需资料,进入相同上传/校验/审核流程;任一客户端可以查看同账号游戏状态,更新沿用相同 `gameId`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3caf758bd..d266f74a9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6139,3 +6139,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **实测**:冷启动时 `Invoke-WebRequest /games` 花了 **20,171 ms**(`/` 约 2,146 ms),随后连续两次 4,208 ms / 10,119 ms,预热完成后 **14 ms**。原因是 Vite dev 按需编译该路由的模块图,首个请求最贵。 - **处理**:脚本在浏览器导航前先 `fetch` 预热本轮要用的 `/games`、`/games/detail?id=…`、`/games/play?id=…`(120s 上限,失败只告警),并把首个 `goto` 超时提到 120s;冷启动重跑 → **24/24 PASS**。另外三个 web 脚本原本就用 `waitUntil: 'commit', timeout: 120_000`,属同一类防护,别再去掉。 - **判据**:遇到"首个 goto 超时 + API 断言全过"的形态,先用 `curl`/`Invoke-WebRequest` 量一次同 URL 的耗时;20s 级冷启动说明是 Vite 编译,不是页面回归。 + +## 2026-09-29 客户端"本地导出上限"不等于"平台发布上限" + +- **现象/风险**:AGC 里 `MAX_PROJECT_EXPORT_PACKAGE_BYTES = 512 MiB` 管的是"本地导出/暂存的体积安全线",而平台发布上限是 **200 MiB**(`module-game-distribution` 的 `MAX_PACKAGE_BYTES`,2026-09-23 决策从 100 MiB 放宽而来)。两者不是一个概念,拿前者当发布前检查会让 200–512 MiB 的包一路读盘 + 暂存,最后在建版本时吃服务端 413,作者白等一轮。 +- **现状(2026-09-29 已修)**:上限单一来源落到 `shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES`;AGC 在读包阶段就做 `ensure_within_platform_package_limit()` 预检并给出「发行包 X MiB 超过平台上限 200 MiB;请精简资源后重新导出再发布」。服务端领域 crate 不依赖 `shared-contracts`,仍保留自己的常量,由 api-server 的 `publish_package_limit_matches_the_shared_contract` 锁成一致。 +- **判据**:任何"客户端先检查、服务端再校验"的额度都要问一句"这两处是同一个数吗、谁保证不漂移";本地安全线(防呆)与平台业务额度(可对外承诺)要分开命名,别混用。 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 402b0ab3e..da217c4cf 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -2511,6 +2511,16 @@ mod tests { } } + #[test] + fn publish_package_limit_matches_the_shared_contract() { + // 客户端(AGC 发布前检查)读的是 `shared-contracts` 里的同一份上限;这里把服务端 + // 领域常量与它锁在一起,避免两边各改一处后静默漂移。 + assert_eq!( + MAX_PACKAGE_BYTES, + shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES, + ); + } + #[test] fn package_request_body_limit_covers_max_package_bytes() { // 口径约束:发行包路由的请求体放行量必须覆盖包体上限,否则合法包会在 diff --git a/server-rs/crates/module-game-distribution/src/package.rs b/server-rs/crates/module-game-distribution/src/package.rs index 3fdf0621f..139ddc4b8 100644 --- a/server-rs/crates/module-game-distribution/src/package.rs +++ b/server-rs/crates/module-game-distribution/src/package.rs @@ -9,6 +9,10 @@ use sha2::{Digest, Sha256}; /// 发行包体积上限。反代放行量与路由请求体上限都从它派生:Nginx /// `client_max_body_size`、Pingora `MAX_API_BODY_BYTES` 必须同步放宽,否则合法包会在 /// 到达 `api-server` 之前被拒。 +/// +/// 客户端发布前检查读的是 `shared_contracts::game_distribution:: +/// GAME_DISTRIBUTION_MAX_PACKAGE_BYTES`;本 crate 不依赖 `shared-contracts`,两处数字 +/// 由 api-server 的守卫测试锁成一致(改一处不改另一处会直接变红)。 pub const MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024; /// 展开总量上限保持压缩包上限的 2.5 倍余量:包体本身基本不可再压时展开量约等于包体, /// 纯文本 / JSON 资源占比高的包仍要有足够空间。 diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index edfa2b6f1..0009f9b1a 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -8,6 +8,12 @@ use serde::{Deserialize, Serialize}; pub const GAME_DISTRIBUTION_CATEGORIES: [&str; 7] = ["休闲", "益智", "动作", "冒险", "模拟", "策略", "其他"]; +/// 发行包体积上限(字节)。 +/// +/// 服务端校验与客户端「发布前检查」共用这一份:两边各写一个数字迟早会漂移, +/// 客户端要么放行服务端必拒的包(白等一次上传),要么拦掉服务端能收的包。 +pub const GAME_DISTRIBUTION_MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024; + /// 发布页免费生成简介与分类的输入。 /// /// 只传经过裁剪的项目摘要,不传本地绝对路径、聊天记录、凭据或完整 manifest。