修复发行包遗漏项目素材

构建项目发布时补入 dist 未包含的项目根 assets 文件,并优先保留 dist 同路径产物。

增加 Phaser 发行包素材回归测试并同步发行契约文档。
This commit is contained in:
2026-10-02 18:33:36 +08:00
parent db344426f2
commit c8e4ca2512
3 changed files with 60 additions and 1 deletions
@@ -738,6 +738,20 @@ pub(crate) fn collect_project_export_package_files(
.ok_or("构建产物路径非法")?
);
}
// AGC 上传的素材登记在项目根 `assets/`,部分 Phaser 工程直接以
// `assets/uploads/...` 作为运行 URL,没有通过 Vite public 目录复制到 dist。
// 构建产物优先;只有 dist 没有同路径时才补入根目录素材,避免重复 ZIP 条目。
let assets_root = resolve_local_project_path(root, "assets")?;
if assets_root.exists() {
let mut root_assets = Vec::new();
collect_project_export_package_dir_files(root, "assets", &mut root_assets)?;
for entry in root_assets {
let dist_path = format!("game/{}", entry.0);
if !files.iter().any(|(path, _, _)| path == &dist_path) {
files.push(entry);
}
}
}
} else {
collect_project_export_package_dir_files(root, "game", &mut files)?;
}
@@ -4356,6 +4356,51 @@ async fn publish_export_reports_actionable_error_without_entry_or_build_script()
fs::remove_dir_all(root).ok();
}
#[test]
fn built_project_export_includes_uploaded_assets_and_prefers_dist_assets() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-built-assets", "发行素材项目")
.expect("project init");
fs::create_dir_all(root.join("game/dist/assets/uploads")).expect("create dist assets");
fs::create_dir_all(root.join("assets/uploads")).expect("create uploaded assets");
fs::write(
root.join("game/dist/index.html"),
"<!doctype html><html><body><canvas></canvas><script>window.started=true;</script></body></html>",
)
.expect("write built entry");
fs::write(root.join("assets/uploads/food.png"), b"root-food").expect("write uploaded food");
fs::write(root.join("assets/uploads/shared.png"), b"root-shared")
.expect("write root shared asset");
fs::write(
root.join("game/dist/assets/uploads/shared.png"),
b"dist-shared",
)
.expect("write dist shared asset");
fs::create_dir_all(root.join("exports")).expect("create exports");
fs::write(root.join("exports/README.md"), "publish notes").expect("write readme");
let exported = export_local_project_package_at(&root).expect("export built project");
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
.expect("normalize publish package");
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(payload.package_bytes))
.expect("read normalized package");
let mut food = Vec::new();
archive
.by_name("assets/uploads/food.png")
.expect("uploaded asset must be packaged")
.read_to_end(&mut food)
.expect("read uploaded asset");
assert_eq!(food, b"root-food");
let mut shared = Vec::new();
archive
.by_name("assets/uploads/shared.png")
.expect("shared asset must be unique")
.read_to_end(&mut shared)
.expect("read shared asset");
assert_eq!(shared, b"dist-shared");
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
let root = unique_project_path();
@@ -86,7 +86,7 @@
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
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` 提交。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install` 和 `npm run build`,将 `dist` 归一化为根 `index.html` ZIP;为兼容 AGC 上传素材的运行 URL,会补入项目根 `assets/**` 中 dist 未包含的文件,同路径以 dist 构建产物为准,不修改项目源码。
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,并在游戏脚本前注入隔离的运行期 `localStorage` / `sessionStorage` 兼容层,避免游戏直接读取 opaque origin 原生 storage 时抛 `SecurityError`。兼容层只在当前运行实例内存中有效,不读取平台 Cookie、主站 DOM 或账号数据。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。