fix(storage): harden source authorization and committed CAS
Provision the isolated game-source writer path, bind source uploads to GameSourceArchive manifests, and require every immutable storage field for pending-to-committed CAS. Reuse the gate-validated runtime manifest snapshot, normalize remote storage errors, and record the staging object/CAS evidence.
This commit is contained in:
parent
17727d08c3
commit
a513ae38fd
@ -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 正文对象数量',
|
||||
|
||||
@ -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:" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@ -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/<tenantId>/games/<gameId>/versions/<versionId>/
|
||||
├── assets/<相对路径>
|
||||
└── runtime/<bundle 或渠道包>
|
||||
|
||||
game-sources 桶(GameSourceArchive/1;权限和桶预建完成前禁止联网写):
|
||||
game-sources 桶(GameSourceArchive/1;已完成专用 writer 授权,写入仍需经过 source CLI):
|
||||
└── tenants/<tenantId>/games/<gameId>/source-revisions/<revisionId>/
|
||||
├── 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 放行复测仍有明确剩余项。
|
||||
|
||||
## 删除门
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -27,6 +27,15 @@ public interface RuntimePackageService {
|
||||
*/
|
||||
RuntimePackageDO getPackageManifest(Long versionId, String scene, Long userId);
|
||||
|
||||
/**
|
||||
* 取已通过同一门禁的原始 manifest 字节。
|
||||
*
|
||||
* <p>MVP 的 DB PackageStore 直接返回刚完成门禁校验的运行包行中的 packageJson,避免
|
||||
* controller 先校验 A 行、再二次查询 B 行造成 hash/提交状态竞态;未来 OSS store 仍由
|
||||
* 实现按不可变版本 key 返回原始字节。</p>
|
||||
*/
|
||||
String getPackageManifestRaw(Long versionId, String scene, Long userId);
|
||||
|
||||
/**
|
||||
* 发布态回写对接点(编译就绪运行包 → 置为已发布,补全发布态写入链路)
|
||||
*
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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 正文对象数量',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]}:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user