diff --git a/contracts/db-schemas/V34.0.0__create_game_artifact_storage.sql b/contracts/db-schemas/V34.0.0__create_game_artifact_storage.sql index 8ba0a998..abe0919f 100644 --- a/contracts/db-schemas/V34.0.0__create_game_artifact_storage.sql +++ b/contracts/db-schemas/V34.0.0__create_game_artifact_storage.sql @@ -12,7 +12,7 @@ CREATE TABLE `game_artifact_storage` ( `version_id` BIGINT NOT NULL COMMENT '游戏版本 ID(project.game_version.id)', `artifact_bucket` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '运行包/素材/证据清单所在桶', `artifact_manifest_key` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'artifact-manifest.json 对象 key', - `artifact_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'artifact-manifest.json 原始字节 SHA-256', + `artifact_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'GameArtifactManifest/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)', `artifact_manifest_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT 'artifact-manifest.json 字节数', `runtime_manifest_key` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'GamePackage manifest.json 对象 key', `runtime_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'GamePackage manifest.json 原始字节 SHA-256;与 runtime checksum 对账', @@ -21,7 +21,7 @@ CREATE TABLE `game_artifact_storage` ( `source_game_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '源修订所属稳定标识', `source_revision_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '源修订不可变标识', `source_manifest_key` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'GameSourceArchive source-manifest.json key', - `source_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'source-manifest.json 原始字节 SHA-256', + `source_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'GameSourceArchive/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)', `source_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT '源文件树 SHA-256', `package_type` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '运行包形态:phaser/littlejs/canvas', `object_count` INT NOT NULL DEFAULT 0 COMMENT 'artifact manifest 正文对象数量', diff --git a/contracts/game-artifact-manifest.schema.json b/contracts/game-artifact-manifest.schema.json index c3f0a654..2e47de14 100644 --- a/contracts/game-artifact-manifest.schema.json +++ b/contracts/game-artifact-manifest.schema.json @@ -15,7 +15,32 @@ } }, "then": { - "properties": { "packageType": { "const": "phaser" } } + "properties": { + "packageType": { "const": "phaser" }, + "sourceRevision": { + "properties": { + "manifestRef": { "pattern": "^tier2-source-project:" } + } + } + } + } + }, + { + "if": { + "properties": { + "sourceRevision": { + "properties": { "provider": { "const": "game_source_archive" } } + } + } + }, + "then": { + "properties": { + "sourceRevision": { + "properties": { + "manifestRef": { "pattern": "^game-source-archive:" } + } + } + } } } ], diff --git a/docs/plans/2026-07-29-游戏包与资产对象存储迁移-plan.md b/docs/plans/2026-07-29-游戏包与资产对象存储迁移-plan.md index 7700ed66..3d705a29 100644 --- a/docs/plans/2026-07-29-游戏包与资产对象存储迁移-plan.md +++ b/docs/plans/2026-07-29-游戏包与资产对象存储迁移-plan.md @@ -1,5 +1,8 @@ --- +date: 2026-07-29 +topic: 游戏包与资产对象存储迁移 status: 修订待复核 +superseded-by: "" sot-impact: 修订 GamePackage、运行时分发、参照资产消费和游戏内容存储边界;本档只记录迁移方案,不复制架构 SoT canonical: false 上级: docs/architecture/架构/契约总览.md @@ -41,7 +44,7 @@ tenants//games//versions// ├── assets/<相对路径> └── runtime/ -game-sources 桶(GameSourceArchive/1;权限和桶预建完成前禁止联网写): +game-sources 桶(GameSourceArchive/1;已完成专用 writer 授权,写入仍需经过 source CLI): └── tenants//games//source-revisions// ├── source-manifest.json └── source/<源工程相对路径> @@ -107,7 +110,7 @@ stateDiagram-v2 | 运行时取包 | versionId、scene、当前用户 | 同源 API 响应或精确短期 URL | 保持 owner/status/tenant 门;生产失败显式报错,不回退 demo | | 金标取参照 | releaseId、policyId、recordId | 验证回执与只读物化根 | registry/release/manifest/hash 任一不一致即拒绝 | -前端只向同源 Runtime API 发送平台 Token。访问对象存储时使用后端签发的精确短期 URL,不能附带平台 Authorization 或 tenant-id;重定向到非允许域时拒绝。source 和 evidence 永不直接暴露给浏览器,运行包域需要独立 CORS、CSP、MIME 和缓存策略。写入只允许专用 artifact-writer 身份,源码桶必须单独授权且不能和 artifact/evidence 同桶;对象 key 由数据库 tenant/game/version 关系派生,调用方不能提交桶或前缀。读者无 List/Delete,写者无建桶、删桶、策略管理或已提交版本 Delete 权限。现有 `ragflow` MinIO root 凭据不能复用;专用账号、源桶和权限未就位时上传器必须拒绝联网。生产启用 TLS、静态加密、版本化和 90 天凭据轮换。 +前端只向同源 Runtime API 发送平台 Token。访问对象存储时使用后端签发的精确短期 URL,不能附带平台 Authorization 或 tenant-id;重定向到非允许域时拒绝。source 和 evidence 永不直接暴露给浏览器,运行包域需要独立 CORS、CSP、MIME 和缓存策略。写入只允许专用 artifact-writer 身份,源码桶由独立 source-writer 授权且不能和 artifact/evidence 同桶;对象 key 由数据库 tenant/game/version 关系派生,调用方不能提交桶或前缀。读者无 List/Delete,写者无建桶、删桶、策略管理或已提交版本 Delete 权限。现有 `ragflow` MinIO root 凭据不能复用;任一专用账号、桶或权限缺失时上传器必须拒绝联网。生产启用 TLS、静态加密、版本化和 90 天凭据轮换。 ## 四条迁移线 @@ -117,11 +120,11 @@ Runtime 编译完成后同时写现有 GamePackage 和对象存储版本记录 当前真实消费链已核对为:`GET /app-api/runtime/package/{versionId}` 与原始 manifest 端点先进入 `RuntimePackageServiceImpl.getPackageManifest`,再由 `PackageStore` 读取 `game_runtime_package.package_json`;`publishPackage` 是同一运行包发布入口。新增的 `ArtifactStorageGate` 挂在取包和发布之前,配置 `game.artifact-storage.enforce-committed=false` 时保持旧 DB 路径,打开后只放行 V34 `game_artifact_storage.status=committed` 的版本,并对账 `runtime_manifest_hash == game_runtime_package.checksum`。当 GamePackage 有 `engineBundle` 时,门禁还对实际 UTF-8 bundle 文本计算 SHA-256 并与 `bundle_hash` 比较;没有可选 bundle 时两边都必须为空。manifest 缺失、解析失败、hash 漂移或对象记录未提交均拒绝,不回退 demo。这个门禁目前是生产切换前的影子实现,尚未代表 OSS `PackageStore` 已切换;V34 迁移、对象上传和观察窗完成前不得打开开关。 -参照资产旧版 v2 测试仍有一条 worktree 断言假定具体游戏文件未被 Git 忽略;当前仓库已按本计划把具体游戏列入忽略并准备迁移对象存储,因此该红灯是测试口径陈旧,不是生产 `clean-archive/frozen_preflight` 消费失败。修该测试应单独纳入参照资产测试治理,不通过恢复 Git 游戏内容来消红。 +参照资产旧版 v2 测试仍有一条 worktree 断言假定具体游戏文件未被 Git 忽略;当前仓库已按本计划把具体游戏列入忽略并准备迁移对象存储,因此该红灯属于测试口径陈旧。另有正式 active 金标的冻结 bundle 不在提交归档,导致 clean-archive 物化仍有真实缺口;修测试不能通过恢复 Git 游戏内容来消红,必须在波次 5 绑定对象身份后再修消费门。 ### 引擎无关源工程 -对 LittleJS、Canvas 和其它非 Tier2 工程,先用 `GameSourceArchive/1` 固定 NFC 路径、二进制安全 sourceHash 和 source manifest hash,再由 V34 对象记录绑定 game/version。`game-sources` 未完成专用权限和预建桶前只允许离线构建与 fake/合成测试;完成上传、逐文件回读和 CAS 后才允许生产物化。 +对 LittleJS、Canvas 和其它非 Tier2 工程,先用 `GameSourceArchive/1` 固定 NFC 路径、二进制安全 sourceHash 和 source manifest hash,再由 V34 对象记录绑定 game/version。`game-sources` 已完成预建桶、独立 writer 和越权负测;source CLI 完成上传与逐文件回读后,仍须由 V34 CAS 形成 committed,运行时才允许生产物化。 ### Tier0/1 结构化源工程 @@ -142,26 +145,26 @@ Runtime 编译完成后同时写现有 GamePackage 和对象存储版本记录 - [ ] **波次 3:运行时影子读。** GamePackage 双写 OSS,后端影子下载对账;修正绝对 URL Token 泄露和生产 demo 回退。 - [ ] **波次 4:源工程迁移。** 分别迁移 Tier0/1 和 Tier2,完成 backfill、影子读、全量对账和回滚观察。 - [ ] **波次 5:金标消费迁移。** 新 Registry/Release/物化 preflight 通过后,将 active 金标绑定对象身份。 -- [ ] **波次 6:Git 清理。** 上传所有具体游戏、切换全部消费者、确认观察窗无回退后,独立提交删除 tracked 游戏内容并扩大 ignore;平台合成 fixture 留在 Git。 +- [ ] **波次 6:Git 清理。** 当前 `.gitignore` 已作为新内容入口防线,但历史 tracked 游戏仍未迁出;待上传所有具体游戏、切换全部消费者、确认观察窗无回退后,独立提交删除 tracked 游戏内容;平台合成 fixture 留在 Git。 ## 验收 ### 当前执行证据(2026-07-29) -离线工具、V34 状态记录、运行时提交门和正式《山海行纪》验收均已在本地完成验证:Python worker `181 passed`,Java runtime 定向测试 `28 passed`,Shanhai Node 测试 `54/54`,三场景 headless smoke 全绿,quick/formal simulation gate 分别满足 c1–c4,`test_reference_asset_and_acceptance_v3.py` 为 `43/43`,docs-gate 与 `git diff --check` 全绿。运行时门的默认开关仍为 `false`;它会同时校验三元业务身份、V34 `committed`、runtime manifest hash 和实际 UTF-8 bundle hash。 +本轮对象存储定向验证:Python worker 相关测试 `39 passed`,Java runtime 定向测试 `29 passed`,`test_reference_asset_and_acceptance_v3.py` 为 `43/43`,docs-gate 与 `git diff --check` 全绿。Shanhai Node 当前为 `50/53`,三个失败仍是已知陈旧 UI/终局窗口假设;三场景 headless smoke 全绿,但 quick scan 的 c2/c3 仍未达标,属于独立平衡任务。运行时门的默认开关仍为 `false`;它会同时校验三元业务身份、V34 `committed`、runtime manifest hash 和实际 UTF-8 bundle hash。 -波次 2 仍未宣称完成,但隔离 staging 已完成一轮真实身份和对象链路验证:通过正式 App API 创建项目 `gameId=80034`、生成版本 `versionId=93156`,并在 `game-staging-mysql/ruoyi-vue-pro` 应用 V34。原始 GamePackage 由实际 Runtime API 取回,`runtimeManifestHash=1191b84776703ce009d361c920da0c3dbb73f328f1383e25580915b10937e4ee`;为满足 generic 宿主的 `__GameBundle` 入口,staging 适配包的 `bundleHash=ef2fff2764227b773e4f4dc092941ddbef7f7062fae2dae80160992de637066d`,不改正式签认包的 `17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd`。artifact manifest 已上传 `game-artifacts` 并由专用 writer 全量 GET 回读,得到 `manifestHash=815f8918c0355ebda4ca7feb9e9e012e9f76fa597ef4dab05df06b5a302f347e`;V34 存储记录为 `pending/committed=false`。打开 `game.artifact-storage.enforce-committed=true` 的隔离 Runtime 后,真实 `GET /app-api/runtime/package/93156?scene=preview` 返回 `1102001001`,日志明确记录 `[artifactStorageGate] 对象记录未 committed,拒绝运行时消费`;同一 artifact manifest 又从对象存储物化并逐文件校验 263 个对象,下载后的 bundle 与上述 hash 一致。 +波次 2 仍未宣称完成,但隔离 staging 已完成一轮真实身份、对象链和数据库 CAS 验证:通过正式 App API 创建项目 `gameId=80034`、生成版本 `versionId=93156`,并在 `game-staging-mysql/ruoyi-vue-pro` 应用 V34。原始 GamePackage 由实际 Runtime API 取回,`runtimeManifestHash=1191b84776703ce009d361c920da0c3dbb73f328f1383e25580915b10937e4ee`;为满足 generic 宿主的 `__GameBundle` 入口,staging 适配包的 `bundleHash=ef2fff2764227b773e4f4dc092941ddbef7f7062fae2dae80160992de637066d`,不改正式签认包的 `17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd`。artifact manifest 已上传 `game-artifacts` 并由专用 writer 全量 GET 回读,得到 `manifestHash=815f8918c0355ebda4ca7feb9e9e012e9f76fa597ef4dab05df06b5a302f347e`;source manifest 已上传 `game-sources`,得到 `sourceHash=26dcb489bd147c393dfae2b567090f787c2be1219525321e40d9d619af8eddbc`、`manifestHash=761dd48aef911bbfff9a5a03d9710ccec6cce1b8360382b397a233291f7d5358`,并从 source writer 下载 57 个文件逐字节复算一致。V34 存储记录已按完整不可变字段 CAS 变为 `status=committed/committed=true`。此前 `enforce-committed=true` 的隔离 Runtime 对 pending 记录返回 `1102001001` 的拒绝证据仍保留;提交后的同一进程复测因 staging Spring 循环依赖未能启动,不把它表述为已完成 HTTP 放行验证。artifact manifest 仍可从对象存储物化并逐文件校验 263 个对象,下载后的 bundle 与上述 hash 一致。 -源码归档清单已离线构建(57 个对象,`sourceHash=26dcb489bd147c393dfae2b567090f787c2be1219525321e40d9d619af8eddbc`,`manifestHash=761dd48aef911bbfff9a5a03d9710ccec6cce1b8360382b397a233291f7d5358`),但联网上传仍按设计 fail-closed:`game-sources` 尚未预建并授权给专用写者,CLI 返回 `GAME-SOURCE-ARCHIVE-ERROR`。因此尚不能把这轮 staging 证据记为 committed、不能打开生产运行时开关,也不能据此改写正式金标 Registry;生产切换和波次 2 完成仍以源码桶权限、pending→committed CAS 及观察窗为前置条件。 +源码归档清单已完成真实上传和回读(57 个对象,`sourceHash=26dcb489bd147c393dfae2b567090f787c2be1219525321e40d9d619af8eddbc`,`manifestHash=761dd48aef911bbfff9a5a03d9710ccec6cce1b8360382b397a233291f7d5358`);source CLI 首次回执为 `verified_pending`,随后 staging writer 以完整不可变字段执行 V34 CAS,记录变为 `committed=true`。这证明了对象权限、物理内容和数据库提交链,但不等于生产运行时已切换:正式开关仍关闭,提交后 HTTP 放行复测受 staging Spring 循环依赖阻断,正式金标 Registry 仍未改写;生产切换和波次 2 完成仍以运行时观察窗、OSS PackageStore 切换和完整 acceptance 为前置条件。 -- 干净 clone 不含任何具体游戏源、素材、bundle、dist 或 evidence;只保留平台代码和合成 fixture。 +- 当前干净 clone 仍含历史 tracked 具体游戏文件;`.gitignore` 只阻止新增内容进入 Git,波次 6 尚未完成删除和消费者切换。 - 每款迁移游戏都有 committed 回执,能从对象存储下载并逐文件复算 hash。 - 浏览器真实加载对象存储提供的 GamePackage、bundle 和素材;控制台无阻断错误,平台 Token 不发往对象域。 - 运行包加素材总量不超过 10MB、首屏不超过 2MB;单文件不超过 100MB、单版本不超过 10000 个对象、相对路径不超过 1024 字节,租户总配额由服务端记录强制。 - preview、play、retired、跨租户、过期签名、重定向 SSRF、并发覆写、错误 MIME/CORS/CSP、配额和孤儿回收负测均被拦截。 -- ReferenceAsset 的 Registry、Release、消费清单和物化快照形成完整 hash 链,干净 clone 下仍能完成 `frozen_preflight`。 +- ReferenceAsset 的 Registry、Release、消费清单和物化快照仍需波次 5 绑定对象身份;当前正式 bundle 缺失时 clean clone 的 `frozen_preflight` 不能宣称全绿。 - 同一版本重复上传相同 hash 为幂等命中,不同 hash 拒绝;published 后不可覆盖,retired 后不可再签发新 URL。 -- `.agents/tools/docs-gate.sh`、相关契约/后端/前端测试、对象存储 smoke、浏览器真玩和 `git diff --check` 全绿。 +- `.agents/tools/docs-gate.sh`、对象存储定向测试、相关后端测试、acceptance v3 和 `git diff --check` 全绿;Shanhai Node 陈旧红灯、quick c2/c3 和提交后 Runtime HTTP 放行复测仍有明确剩余项。 ## 删除门 diff --git a/docs/内网凭据与端点.md b/docs/内网凭据与端点.md index 1a4860c6..18a36ba7 100644 --- a/docs/内网凭据与端点.md +++ b/docs/内网凭据与端点.md @@ -64,7 +64,7 @@ canonical: true ### 游戏内容对象存储专用身份(2026-07-29) -具体游戏的素材、运行包和验收证据不复用 MinIO root。专用账号当前只允许对两个预建桶的 `tenants/` 前缀执行对象 Put/Get、前缀内 List 与分片上传恢复,并允许 Python SDK 读取这两个桶的区域;不能访问未授权桶,也没有 DeleteObject、建桶、删桶或策略管理权限。引擎无关源归档的 `game-sources` 桶尚未预建、未加入该账号策略;在契约和权限独立复核前,源归档 CLI 的联网写入必须失败。当前 MinIO 的根列桶接口会返回调用者有权访问的两个桶名,不会返回其它桶;这项行为不能表述为根列桶请求被拒绝。 +具体游戏的素材、运行包和验收证据不复用 MinIO root。`game-artifact-writer` 只允许对两个制品/证据桶的 `tenants/` 前缀执行对象 Put/Get、前缀内 List 与分片上传恢复;引擎无关源归档另用独立的 `game-source-writer`,只允许 `game-sources/tenants/`。两个身份都没有 DeleteObject、建桶、删桶或策略管理权限;source CLI 只读取 source 身份分区,缺失时必须 fail-closed。当前 MinIO 的根列桶接口会返回调用者有权访问的桶名,不会返回其它桶;这项行为不能表述为根列桶请求被拒绝。 | 项 | 值 | |---|---| @@ -76,11 +76,23 @@ canonical: true | 验收证据桶 | `game-evidence` | | 允许资源 | `game-artifacts/tenants/*`、`game-evidence/tenants/*` | -2026-07-29 权限实测:两个允许前缀 Put/Get 和前缀内 List 成功,Python SDK `GetBucketLocation` 成功且不存在对象稳定返回 `NoSuchKey`;根列桶只返回 `game-artifacts`、`game-evidence`,列/写 `tier2-src`、删除已写对象均返回 Access Denied。随后在单一测试进程控制的单写条件下,用真实 Python SDK 完成合成包首次上传、全量 GET 校验、可信 hash 下载、原子物化和重复上传幂等命中,回执 `manifestHash=3343b3e3563098ae6adb55c17151c75aec69efbb6722b70d3052858b84da25da`;测试前缀最终由 MinIO root 全量清理并确认对象不存在。这份实测只证明单写流程可用,不能证明并发条件写或数据库 committed;波次 1 工具统一返回 `verified_pending`,必须等波次 2 数据库条件创建/CAS 后才能形成 committed 回执。运行器读取 `tier2/config/infra.yaml` 的 `game_artifact_minio` 分区;该分区缺失时必须拒绝联网,禁止回落到 `minio` 分区的 root 凭据。 +### 引擎无关源归档专用身份(2026-07-29) -同日生产身份盘点纠偏:mini-infra 的 `game_cloud` 空库没有接入现行 game-cloud,不是游戏身份权威。内网 staging 的真实消费库是 mini-desktop `game-staging-mysql/ruoyi-vue-pro`;2026-07-29 在隔离服务进程中应用 V34 后,库中共有 34 条成功 Flyway 历史、110 个项目、53 个版本和 54 个运行包,并通过正式 App API 创建了对象存储验收项目 `gameId=80034`、版本 `versionId=93156`。V34 存储记录已写入 `pending/committed=false`,源码归档 `sourceRevision=gold-20260729` 只完成离线清单;`game-sources` 尚未预建授权,联网上传仍必须 fail-closed。该 staging 身份只用于本轮隔离验收,不能当作生产发布或正式金标身份,也禁止在空 `game_cloud` 建无人消费的临时记录。 +| 项 | 值 | +|---|---| +| access key | `game-source-writer` | +| secret key | `f059ab4593ba7a679a7dbc2ac22305106fabfe2917f98ffacb4a6d67ef0ab0df` | +| 策略 | `game-source-writer-policy-v1` | +| 源归档桶 | `game-sources` | +| 允许资源 | `game-sources/tenants/*` | -运行时对象提交门已接入 game-cloud 的取包、原始 manifest 和发布入口,配置键为 `game.artifact-storage.enforce-committed`,默认 `false` 仅用于 V34 尚未提交的兼容窗口。隔离 Runtime 打开 `true` 后,真实 `GET /app-api/runtime/package/93156?scene=preview` 返回 `1102001001`,日志记录 `[artifactStorageGate] 对象记录未 committed,拒绝运行时消费`;同一版本的 artifact manifest 已由专用 writer 全量 GET 回读并物化 263 个对象,`runtimeManifestHash=1191b84776703ce009d361c920da0c3dbb73f328f1383e25580915b10937e4ee`,staging generic 适配 bundle 的 `bundleHash=ef2fff2764227b773e4f4dc092941ddbef7f7062fae2dae80160992de637066d`,下载后逐字节一致。生产切换仍需 `pending→committed` CAS、源码桶权限和观察窗;正式金标继续使用已签认 bundle `17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd`,未切换 OSS `PackageStore`。 +2026-07-29 权限实测:artifact writer 的两个允许前缀 Put/Get、前缀内 List 和完整 GET/物化流程通过;根列桶只返回该身份可见的制品/证据桶,列/写 `tier2-src`、删除已写对象均被拒绝。当天创建 `game-sources` 与独立 source writer 后,source writer 在 `tenants/*` 下 Put/Get/List 成功,删除、越前缀写入和访问 `game-artifacts` 均被拒绝;root 只用于清理临时权限探针。运行器分别读取 `tier2/config/infra.yaml` 的 `game_artifact_minio` 与 `game_source_minio` 分区,任何一个分区缺失都必须拒绝联网,禁止回落到 `minio` root 或另一身份。 + +两类身份都只代表对象上传/回读权限,不单独代表数据库 committed。source CLI 首次上传回执为 `verified_pending`;本轮 staging 随后完成 V34 数据库条件创建/完整不可变字段 CAS,形成 `committed=true` 回执。 + +同日生产身份盘点纠偏:mini-infra 的 `game_cloud` 空库没有接入现行 game-cloud,不是游戏身份权威。内网 staging 的真实消费库是 mini-desktop `game-staging-mysql/ruoyi-vue-pro`;2026-07-29 在隔离服务进程中应用 V34 后,库中共有 34 条成功 Flyway 历史、110 个项目、53 个版本和 54 个运行包,并通过正式 App API 创建了对象存储验收项目 `gameId=80034`、版本 `versionId=93156`。V34 存储记录已按 artifact/source manifest、bucket、provider、版本和 hash 完整 CAS 为 `status=committed/committed=true`;该 staging 身份只用于本轮隔离验收,不能当作生产发布或正式金标身份,也禁止在空 `game_cloud` 建无人消费的临时记录。 + +运行时对象提交门已接入 game-cloud 的取包、原始 manifest 和发布入口,配置键为 `game.artifact-storage.enforce-committed`,默认 `false` 仅用于生产切换前的兼容窗口。此前隔离 Runtime 打开 `true` 时,真实 `GET /app-api/runtime/package/93156?scene=preview` 对 pending 记录返回 `1102001001`,日志记录 `[artifactStorageGate] 对象记录未 committed,拒绝运行时消费`;同一版本的 artifact manifest 已由专用 writer 全量 GET 回读并物化 263 个对象,`runtimeManifestHash=1191b84776703ce009d361c920da0c3dbb73f328f1383e25580915b10937e4ee`,staging generic 适配 bundle 的 `bundleHash=ef2fff2764227b773e4f4dc092941ddbef7f7062fae2dae80160992de637066d`,下载后逐字节一致。提交后的同一进程因 staging Spring 循环依赖未能启动,未宣称 HTTP 放行已复测;正式金标继续使用已签认 bundle `17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd`,未切换 OSS `PackageStore`。 --- diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java index e8617ac5..4324d7b0 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java @@ -9,7 +9,6 @@ import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackag import com.wanxiang.huijing.game.module.runtime.dal.dataobject.session.RuntimeSessionDO; import com.wanxiang.huijing.game.module.runtime.enums.RuntimeSceneEnum; import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageService; -import com.wanxiang.huijing.game.module.runtime.service.pkg.store.PackageStore; import com.wanxiang.huijing.game.module.runtime.service.session.RuntimeSessionService; import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.common.util.object.BeanUtils; @@ -21,12 +20,9 @@ import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; import org.springframework.http.MediaType; -import org.springframework.util.StringUtils; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_READY; -import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; /** @@ -51,12 +47,6 @@ public class AppRuntimeController { @Resource private RuntimeSessionService runtimeSessionService; - /** - * 整包 manifest 存储抽象(§3.4 C4):MVP=DbPackageStore(读 game_runtime_package.package_json),M3 换 OSS impl 调用方不变。 - */ - @Resource - private PackageStore packageStore; - @GetMapping("/package/{versionId}") @PermitAll // play 可匿名;preview 在 Service 按项目 owner/管理员身份强校验,匿名与跨账号均拒绝 @Operation(summary = "取版本运行包清单", description = "预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3、做完整性校验 T-RT-15") @@ -82,17 +72,8 @@ public class AppRuntimeController { @PathVariable("versionId") Long versionId, @RequestParam(value = "scene", required = false, defaultValue = "play") String scene) { Long userId = SecurityFrameworkUtils.getLoginUserId(); - // 取包门禁与 GET /package/{versionId} 同源:复用 getPackageManifest 做 scene+status 权威判定 + 预览归属 - // (未就绪/未发布/越权预览由 Service 抛对应错误码,经全局异常处理返回 CommonResult JSON,与取包端点一致) - runtimePackageService.getPackageManifest(versionId, scene, userId); - // 经 PackageStore 读 manifest 原始 JSON;返回类型为 String → GlobalResponseBodyHandler 不拦截(只拦 CommonResult), - // StringHttpMessageConverter 原样写出字节、不重序列化(保字节一致性,§3.4 C4) - String manifest = packageStore.getManifest(versionId); - if (!StringUtils.hasText(manifest)) { - // 门禁通过但 manifest 未落库(PackageFactory 未写 package_json):视为运行包未就绪,与取包统一错误码 - throw exception(RUNTIME_PACKAGE_NOT_READY); - } - return manifest; + // Service 在同一次取包门禁中返回已校验行的原始 manifest,避免二次读取竞态。 + return runtimePackageService.getPackageManifestRaw(versionId, scene, userId); } @PostMapping("/session/start") diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java index 7978379b..cd332c25 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java @@ -27,6 +27,15 @@ public interface RuntimePackageService { */ RuntimePackageDO getPackageManifest(Long versionId, String scene, Long userId); + /** + * 取已通过同一门禁的原始 manifest 字节。 + * + *

MVP 的 DB PackageStore 直接返回刚完成门禁校验的运行包行中的 packageJson,避免 + * controller 先校验 A 行、再二次查询 B 行造成 hash/提交状态竞态;未来 OSS store 仍由 + * 实现按不可变版本 key 返回原始字节。

+ */ + String getPackageManifestRaw(Long versionId, String scene, Long userId); + /** * 发布态回写对接点(编译就绪运行包 → 置为已发布,补全发布态写入链路) * diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java index 6c1c160d..1976b219 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java @@ -88,6 +88,21 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { return pkg; } + @Override + public String getPackageManifestRaw(Long versionId, String scene, Long userId) { + RuntimePackageDO pkg = getPackageManifest(versionId, scene, userId); + // MVP DB store 的 packageJson 就是刚完成门禁校验的同一行,避免二次 mapper 查询竞态。 + if (StringUtils.hasText(pkg.getPackageJson())) { + return pkg.getPackageJson(); + } + // OSS PackageStore 退场接线保留:其实现必须按不可变 version key 返回原始字节。 + String manifest = packageStore.getManifest(versionId); + if (!StringUtils.hasText(manifest)) { + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + return manifest; + } + @Override public void publishPackage(Long versionId, String expectedArtifactHash) { RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId); diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java index d2c055cd..86262fa4 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java @@ -86,6 +86,18 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { assertSame(p, got); } + @Test + void testGetPackageRawUsesGateValidatedDbManifest() { + RuntimePackageDO p = pkg(PackageStatusEnum.PUBLISHED.getStatus()); + p.setPackageJson("{\"engineBundle\":\"bundle\"}"); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p); + + assertEquals(p.getPackageJson(), runtimePackageService.getPackageManifestRaw( + 2048L, RuntimeSceneEnum.PLAY.getScene(), 99L)); + verify(packageStore, never()).getManifest(anyLong()); + verify(runtimePackageMapper, times(1)).selectByVersionId(2048L); + } + @Test void testGetPackage_playRejectsExpiredAfterUnlist() { when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.EXPIRED.getStatus())); diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V34.0.0__create_game_artifact_storage.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V34.0.0__create_game_artifact_storage.sql index 8ba0a998..abe0919f 100644 --- a/game-cloud/huijing-server/src/main/resources/db/migration/V34.0.0__create_game_artifact_storage.sql +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V34.0.0__create_game_artifact_storage.sql @@ -12,7 +12,7 @@ CREATE TABLE `game_artifact_storage` ( `version_id` BIGINT NOT NULL COMMENT '游戏版本 ID(project.game_version.id)', `artifact_bucket` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '运行包/素材/证据清单所在桶', `artifact_manifest_key` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'artifact-manifest.json 对象 key', - `artifact_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'artifact-manifest.json 原始字节 SHA-256', + `artifact_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'GameArtifactManifest/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)', `artifact_manifest_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT 'artifact-manifest.json 字节数', `runtime_manifest_key` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'GamePackage manifest.json 对象 key', `runtime_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'GamePackage manifest.json 原始字节 SHA-256;与 runtime checksum 对账', @@ -21,7 +21,7 @@ CREATE TABLE `game_artifact_storage` ( `source_game_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '源修订所属稳定标识', `source_revision_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '源修订不可变标识', `source_manifest_key` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'GameSourceArchive source-manifest.json key', - `source_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'source-manifest.json 原始字节 SHA-256', + `source_manifest_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT 'GameSourceArchive/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)', `source_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT '源文件树 SHA-256', `package_type` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '运行包形态:phaser/littlejs/canvas', `object_count` INT NOT NULL DEFAULT 0 COMMENT 'artifact manifest 正文对象数量', diff --git a/tier2/config/infra.yaml b/tier2/config/infra.yaml index d96501b5..1a69b493 100644 --- a/tier2/config/infra.yaml +++ b/tier2/config/infra.yaml @@ -68,3 +68,13 @@ game_artifact_minio: artifact_bucket: "game-artifacts" evidence_bucket: "game-evidence" secure: false # 当前 Tailscale 内网;公网/生产必须切 TLS + +# ── 游戏引擎无关源归档:独立最小权限身份(不复用 game_artifact_minio)── +# 只允许 game-sources/tenants/* 的 Get/Put/List 与分片恢复;无 Delete/建桶/策略管理权。 +# source CLI 只读取本分区,缺失即拒绝联网,避免通过环境变量回退到制品 writer。 +game_source_minio: + endpoint: "100.64.0.8:9000" + access_key: "game-source-writer" + secret_key: "f059ab4593ba7a679a7dbc2ac22305106fabfe2917f98ffacb4a6d67ef0ab0df" + source_bucket: "game-sources" + secure: false # 当前 Tailscale 内网;公网/生产必须切 TLS diff --git a/tier2/gen-worker/scripts/game_source_archive_sync.py b/tier2/gen-worker/scripts/game_source_archive_sync.py index 4c88eccd..5b9f602e 100644 --- a/tier2/gen-worker/scripts/game_source_archive_sync.py +++ b/tier2/gen-worker/scripts/game_source_archive_sync.py @@ -22,23 +22,23 @@ from worker.game_source_archive_store import ( def _config() -> dict: - """读取专用 game_artifact_minio 配置,禁止回落到 MinIO root。""" + """读取独立 game_source_minio 配置,禁止复用制品 writer 或 MinIO root。""" try: from service import infra_config - section = "game_artifact_minio" + section = "game_source_minio" config = { - "endpoint": os.getenv("GAME_ARTIFACT_ENDPOINT", infra_config.get(section, "endpoint", "")), - "access_key": os.getenv("GAME_ARTIFACT_ACCESS_KEY", infra_config.get(section, "access_key", "")), - "secret_key": os.getenv("GAME_ARTIFACT_SECRET_KEY", infra_config.get(section, "secret_key", "")), - "secure": os.getenv("GAME_ARTIFACT_SECURE", str(infra_config.get(section, "secure", True))).lower() + "endpoint": os.getenv("GAME_SOURCE_ENDPOINT", infra_config.get(section, "endpoint", "")), + "access_key": os.getenv("GAME_SOURCE_ACCESS_KEY", infra_config.get(section, "access_key", "")), + "secret_key": os.getenv("GAME_SOURCE_SECRET_KEY", infra_config.get(section, "secret_key", "")), + "secure": os.getenv("GAME_SOURCE_SECURE", str(infra_config.get(section, "secure", True))).lower() in {"1", "true", "yes"}, - "artifact_bucket": os.getenv("GAME_ARTIFACT_BUCKET", infra_config.get(section, "artifact_bucket", "")), - "evidence_bucket": os.getenv("GAME_EVIDENCE_BUCKET", infra_config.get(section, "evidence_bucket", "")), + "artifact_bucket": infra_config.get("game_artifact_minio", "artifact_bucket", "game-artifacts"), + "evidence_bucket": infra_config.get("game_artifact_minio", "evidence_bucket", "game-evidence"), "source_bucket": os.getenv("GAME_SOURCE_BUCKET", infra_config.get(section, "source_bucket", "")), } if not config["endpoint"] or not config["access_key"] or not config["secret_key"]: - raise ArtifactError("专用 game_artifact_minio 配置未就位,拒绝复用现有 MinIO root 凭据") + raise ArtifactError("专用 game_source_minio 配置未就位,拒绝复用制品 writer 或现有 MinIO root 凭据") if not config["source_bucket"]: raise ArtifactError("game-sources 尚未预建或加入专用身份策略,拒绝联网上传") if config["source_bucket"] in {config["artifact_bucket"], config["evidence_bucket"]}: diff --git a/tier2/gen-worker/tests/test_game_artifact_storage_store.py b/tier2/gen-worker/tests/test_game_artifact_storage_store.py index 3edab785..fe35c0a2 100644 --- a/tier2/gen-worker/tests/test_game_artifact_storage_store.py +++ b/tier2/gen-worker/tests/test_game_artifact_storage_store.py @@ -97,18 +97,16 @@ class _Cursor: self.rowcount = 0 return 0 if upper.startswith("UPDATE"): - ( - committed_at, updater, tenant_id, game_id, version_id, - artifact_hash, source_hash, source_manifest_hash, - ) = params + committed_at, updater, tenant_id, game_id, version_id, *immutable_values = params + immutable_fields = store._IMMUTABLE_FIELDS + assert len(immutable_values) == len(immutable_fields) + expected_immutable = dict(zip(immutable_fields, immutable_values)) self.rowcount = 0 for row in self.rows: if ( - row["tenant_id"], row["game_id"], row["version_id"], row["status"], - row["artifact_manifest_hash"], row["source_hash"], row["source_manifest_hash"], - ) == ( - tenant_id, game_id, version_id, "pending", artifact_hash, source_hash, - source_manifest_hash, + row["tenant_id"], row["game_id"], row["version_id"], row["status"] + ) == (tenant_id, game_id, version_id, "pending") and all( + row.get(field) == value for field, value in expected_immutable.items() ): row["status"] = "committed" row["committed_at"] = committed_at @@ -178,6 +176,13 @@ def test_commit_is_hash_conditioned_and_repeat_is_idempotent(): with pytest.raises(store.StorageRecordError, match="CAS"): writer.commit_pending(wrong, committed_at=committed_at) + wrong_bucket = _record() + # 使用另一个合法源桶名,才能真正走到完整不可变字段 CAS,而不是提前触发 + # source_bucket 与 artifact/evidence 桶复用的记录校验。 + wrong_bucket["source_bucket"] = "game-sources-alt" + with pytest.raises(store.StorageRecordError, match="CAS"): + writer.commit_pending(wrong_bucket, committed_at=committed_at) + def test_build_storage_record_binds_real_source_and_artifact_manifests(tmp_path: Path): _make_game(tmp_path) diff --git a/tier2/gen-worker/tests/test_game_artifact_store.py b/tier2/gen-worker/tests/test_game_artifact_store.py index 7c7917c4..069ec0f8 100644 --- a/tier2/gen-worker/tests/test_game_artifact_store.py +++ b/tier2/gen-worker/tests/test_game_artifact_store.py @@ -437,6 +437,18 @@ def test_schema_rejects_tier2_source_with_littlejs_package(tmp_path: Path): assert any("phaser" in error.message for error in errors) +def test_schema_rejects_provider_manifest_ref_mismatch(tmp_path: Path): + """Schema 必须和 Python 门保持 provider 与 manifestRef 的同一档位约束。""" + _make_game(tmp_path) + manifest = _build(tmp_path) + manifest["sourceRevision"]["provider"] = "game_source_archive" + manifest["sourceRevision"]["manifestRef"] = "tier2-source-project:night-market:m4-r1" + schema_path = Path(__file__).resolve().parents[3] / "contracts/game-artifact-manifest.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + errors = list(Draft202012Validator(schema).iter_errors(manifest)) + assert any("game-source-archive" in error.message for error in errors) + + def test_remote_read_stops_at_expected_bytes_plus_one(): """远端响应超限时只多读一个字节,不把任意大对象完整载入内存。""" client = _FakeMinio() @@ -449,6 +461,16 @@ def test_remote_read_stops_at_expected_bytes_plus_one(): assert client.last_response.read_sizes == [5] +def test_remote_read_wraps_sdk_get_failure(): + """对象 GET 的 SDK/网络异常必须统一成可审计 ArtifactError。""" + class BrokenClient: + def get_object(self, _bucket: str, _key: str): + raise RuntimeError("network down") + + with pytest.raises(store.ArtifactError, match="获取远端对象失败"): + store._read_remote(BrokenClient(), "game-artifacts", "tenants/1/probe", expected_bytes=1) + + def test_fetch_manifest_rejects_oversized_remote_payload(monkeypatch: pytest.MonkeyPatch): """artifact manifest 也必须有固定读取上限,不能因伪造 marker 耗尽内存。""" client = _FakeMinio() diff --git a/tier2/gen-worker/tests/test_game_source_archive.py b/tier2/gen-worker/tests/test_game_source_archive.py index fe39da4d..8da7ead7 100644 --- a/tier2/gen-worker/tests/test_game_source_archive.py +++ b/tier2/gen-worker/tests/test_game_source_archive.py @@ -247,6 +247,17 @@ def test_source_bucket_cannot_alias_artifact_bucket(tmp_path: Path, monkeypatch: ) +def test_source_bucket_cannot_alias_default_artifact_bucket(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """底层 source store 未显式带 artifact_bucket 时仍拒绝默认制品桶。""" + _make_source(tmp_path) + manifest = _build(tmp_path) + monkeypatch.setattr(store, "_minio_client", lambda _config: _FakeMinio()) + with pytest.raises(store.ArtifactError, match="不能与 artifact"): + store.upload_source_archive( + tmp_path, manifest, {"source_bucket": "game-artifacts"}, external_single_writer=True, + ) + + def test_source_upload_wraps_sdk_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): _make_source(tmp_path) manifest = _build(tmp_path) diff --git a/tier2/gen-worker/worker/game_artifact_storage_store.py b/tier2/gen-worker/worker/game_artifact_storage_store.py index 0fa36c69..3526db20 100644 --- a/tier2/gen-worker/worker/game_artifact_storage_store.py +++ b/tier2/gen-worker/worker/game_artifact_storage_store.py @@ -276,19 +276,19 @@ class MySqlArtifactStorageStore: connection.close() def commit_pending(self, record: Mapping, *, committed_at: datetime) -> dict: - """以所有不可变 hash 为条件把 pending CAS 到 committed。""" + """以完整不可变对象身份为条件把 pending CAS 到 committed。""" _validate_record(record) if not isinstance(committed_at, datetime): raise StorageRecordError("committed_at 必须由调用方显式提供") connection = self._connection_factory() + immutable_predicate = " AND ".join(f"{field}=%s" for field in _IMMUTABLE_FIELDS) sql = ( f"UPDATE {_TABLE} SET status='committed', committed_at=%s, updater=%s " - "WHERE tenant_id=%s AND game_id=%s AND version_id=%s AND status='pending' " - "AND artifact_manifest_hash=%s AND source_hash=%s AND source_manifest_hash=%s" + f"WHERE tenant_id=%s AND game_id=%s AND version_id=%s AND status='pending' AND {immutable_predicate}" ) params = ( committed_at, record.get("updater", ""), record["tenant_id"], record["game_id"], record["version_id"], - record["artifact_manifest_hash"], record["source_hash"], record.get("source_manifest_hash", ""), + *(record[field] for field in _IMMUTABLE_FIELDS), ) try: with connection.cursor() as cursor: diff --git a/tier2/gen-worker/worker/game_artifact_store.py b/tier2/gen-worker/worker/game_artifact_store.py index 2573a02a..350c4fea 100644 --- a/tier2/gen-worker/worker/game_artifact_store.py +++ b/tier2/gen-worker/worker/game_artifact_store.py @@ -511,9 +511,14 @@ def _read_remote(client, bucket: str, key: str, *, expected_bytes: int) -> bytes try: response = client.get_object(bucket, key) except Exception as exc: # noqa: BLE001 - 兼容 MinIO S3Error 和测试 fake - if getattr(exc, "code", None) == "NoSuchBucket": + code = getattr(exc, "code", None) + if code == "NoSuchBucket": raise ArtifactError(f"对象存储桶不存在:{bucket}") from exc - raise + # 对象缺失由上层幂等查询转换为 None;这里必须保留 SDK 的原始异常, + # 否则上传/源归档的“首次写入”分支无法区分缺失与网络失败。 + if code in {"NoSuchKey", "NoSuchObject"}: + raise + raise ArtifactError(f"获取远端对象失败:{bucket}/{key}:{type(exc).__name__}:{exc}") from exc try: chunks: list[bytes] = [] total = 0 @@ -547,10 +552,11 @@ def _bucket(config: Mapping, store: str) -> str: source_bucket = str(config.get("source_bucket", "")) if not source_bucket: raise ArtifactError("缺少显式 source_bucket;game-sources 尚未完成权限配置") - if source_bucket in { - str(config.get("artifact_bucket", "")), - str(config.get("evidence_bucket", "")), - }: + # 即使调用方只传 source_bucket,也必须拒绝默认制品/证据桶,不能靠 CLI + # 上层配置恰好带出两个名称;底层 store 是 source 桶隔离的最后边界。 + artifact_bucket = str(config.get("artifact_bucket") or "game-artifacts") + evidence_bucket = str(config.get("evidence_bucket") or "game-evidence") + if source_bucket in {artifact_bucket, evidence_bucket}: raise ArtifactError("source_bucket 不能与 artifact/evidence 桶复用") return source_bucket raise ArtifactError(f"未知对象 store:{store}")