feat(storage): 接入游戏内容对象存储与运行时提交门
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

冻结 GameArtifactManifest/GameSourceArchive 契约和 V34 pending/committed 状态记录,增加专用 writer 的全量回读物化校验,并让 Runtime API 在消费和发布前对账 committed、GamePackage checksum 与实际 engineBundle hash。隔离 staging 已验证真实消费拒绝未提交版本;源码桶权限仍保持 fail-closed,正式金标不切换。
This commit is contained in:
lili 2026-07-29 12:02:22 -07:00
parent f95ab63521
commit 17727d08c3
27 changed files with 3641 additions and 46 deletions

60
.gitignore vendored
View File

@ -29,6 +29,10 @@ coverage/
.DS_Store
.gstack/
# ── 本地个人环境配置:仅本机生效,绝不入库(机器画像 / 工具链 / 本地端口属个人配置)──
# 与 AGENTS.md §10 配套AGENTS.md 用 @localagents.md 可选引用,文件缺失不影响共享文档。
localagents.md
# ── 例外 1源码树src/)下名为 build 的目录是合法源码,绝非构建产物 ──
# 第 5 行笼统的 build/(为构建产物设计)会误吞 Java/前端源码中名为 build 的包/目录,例如:
# 后端 com.wanxiang.huijing.game.module.runtime.{dal.dataobject,dal.mysql,service}.build编译任务域4 文件)、
@ -62,38 +66,36 @@ __pycache__/
# __pycache__/ 与 node_modules/ 已由上方全局规则拦截;新 spike 原始 json/png 默认 untracked、勿 git add 批量产物。
# 注意(遵 gitignore-swallows-frontend-source 教训):此处不设 *.json/*.png 笼统忽略,以免误吞 contracts/ 等业务目录下的合法同类文件——分层靠规范 + 收口第 8 步执行,不靠笼统 glob。
# ── AI 生成 / e2e 批跑游戏产物可重生成、本地跑出、不入库2026-06-25──
# game-runtime/games/_wg1-gen/ 下「精选参考游戏」(angrybirds/breakout/... 无前缀,含 _shared/_goldenpath-*)已跟踪、须保留;
# 下列是 agent 生成线与 e2e 批跑的输出(每次跑批产生上百个时间戳目录),纯产物、可重生成,长期污染 git diff故忽略。
# 仍遵「专一不笼统」:只忽略已知的生成/批跑前缀,绝不忽略整个 _wg1-gen会吞掉已跟踪的参考游戏
game-runtime/games/_wg1-gen/saa-saa-e2e-*/
game-runtime/games/_wg1-gen/gd-*/
game-runtime/games/_spike-local-verify/
game-runtime/games/_amodel-spike/
# ── 游戏内容存储边界Git 只放平台代码具体游戏全部进入对象存储2026-07-29──
# 具体游戏的源码、素材、bundle、证据和参照游戏都不再作为平台源码进入 Git。
# 下列豁免只包含通用生成壳、平台模板和共享工具;它们不绑定任何一款具体游戏。
# 已跟踪的历史游戏不会因 ignore 自动消失,必须在对象存储消费切换和下载对账后独立删除。
/game-runtime/games/*
!/game-runtime/games/_generic/
!/game-runtime/games/_generic/**
!/game-runtime/games/_template/
!/game-runtime/games/_template/**
!/game-runtime/games/_template-feiyi/
!/game-runtime/games/_template-feiyi/**
!/game-runtime/games/_template-puzzle/
!/game-runtime/games/_template-puzzle/**
!/game-runtime/games/_template-shop/
!/game-runtime/games/_template-shop/**
!/game-runtime/games/_template-story/
!/game-runtime/games/_template-story/**
!/game-runtime/games/_template-trpg/
!/game-runtime/games/_template-trpg/**
!/game-runtime/games/_wg1-gen/
/game-runtime/games/_wg1-gen/*
!/game-runtime/games/_wg1-gen/_shared/
!/game-runtime/games/_wg1-gen/_shared/**
/game-runtime/games/_wg1-gen/_shared/**/__pycache__/
!/game-runtime/games/_wg1-gen/.agent
/tier2/games/*
# ── cheap-worker 便宜档 Python spike:venv 与生成/对照产物可重生成、不入库2026-06-26──
# 遵「专一不笼统」:只忽略 cheap-worker 自己的 venv 与本 spike 的 cheap-*/node-* 产物前缀,不碰已跟踪的参考游戏。
# cheap-worker 本地虚拟环境与批跑结果不入库。
cheap-worker/.venv/
cheap-worker/results/
game-runtime/games/amgen-cheap-*/
game-runtime/games/amgen-node-*/
game-runtime/games/_wg1-gen/cheap-*/
game-runtime/games/_wg1-gen/node-*/
# ── M1 达标门 bake-off + 代表样本/校验重生成产物(可重生成、不入库,2026-06-27──
# 遵「专一不笼统」:只忽略 bake-off/代表样本/校验 的已知生成前缀(bake-/base[0-9]/vf-/v2-/smoke)。
# 已跟踪参考游戏(angrybirds/breakout/t2048/whack/...)与 _shared/_goldenpath-* 均不以这些前缀开头、不受影响。
game-runtime/games/amgen-bake-*/
game-runtime/games/_wg1-gen/bake-*/
game-runtime/games/amgen-base[0-9]*/
game-runtime/games/_wg1-gen/base[0-9]*/
game-runtime/games/amgen-vf-*/
game-runtime/games/_wg1-gen/vf-*/
game-runtime/games/amgen-v2-*/
game-runtime/games/amgen-smoke*/
# M2 auto-vs-golden 同款双驱动批跑产物(avg- 前缀,可重生成、不入库)
game-runtime/games/amgen-avg-*/
game-runtime/games/_wg1-gen/avg-*/
# ── 本机个人配置:localagents.md 自声明"绝不提交、已在 .gitignore 忽略",但此前无对应忽略行(声明未被机器执行);2026-07-06 补真 ──
/localagents.md

View File

@ -13,13 +13,14 @@
| 1 | API OpenAPI | `api-schemas/*.yaml` | WS1 lead全员 review | 前端 mock / 各模块互调 |
| 2 | DB 迁移 | `db-schemas/V*.sql` | WS1 | 后端各模块 |
| 3 | SDK 接口 + postMessage 协议 | `sdk-interface.d.ts` | WS3 SDK 负责人 | game-studio 宿主 / 游戏侧 |
| 4 | GamePackage 清单 | `game-package.schema.json` | WS3 + WS2 | 生成→编译→预览→发布→运行全链路 |
| 4 | GamePackage 运行清单 + 对象存储索引 | `game-package.schema.json` + `game-artifact-manifest.schema.json` + `game-source-archive.schema.json` | WS3 + WS2 | 生成→编译→源归档→制品存储→预览→发布→运行全链路 |
| 5 | telemetry 事件 | `events.schema.json` | WS5 | 前端埋点 / SDK 上报 / 看板 |
| 6 | ~~Dify workflow I/O~~ ⚠️ 降级远期未部署 | `dify-workflow-io.json`(见 [`DEPRECATED-dify-workflow-io.md`](./DEPRECATED-dify-workflow-io.md) | WS2 | ~~aigc Java 壳 ↔ Dify~~(现行=new-api 网关C2/HJ-GEN-001JSON 本体保留作历史/远期) |
| 7 | 广告位配置 | `ad-slot.schema.json` | WS5 | ad 模块 / SDK Plugin.Ad |
| 8 | Prompt Registry | `prompts/`(独立目录) | 各 prompt owner 工位 | aigc / 生成链路(见 [`prompt-governance`](../.agents/skills/prompt-governance.md) |
> **M0 八类契约已锁**JSON/YAML/Schema 语法校验通过):#1 project.yaml · #2 V1.0.0__…sql · #3 sdk-interface.d.ts · #4 game-package · #5 events · #6 dify-workflow-io · #7 ad-slot · #8 prompts/。
> **#4 对象存储伴随契约2026-07-29**`game-artifact-manifest.schema.json` 只描述素材、运行包、证据对象及源修订身份,不是宿主读取的 GamePackage`game-source-archive.schema.json` 单独描述引擎无关源码归档。生产 `gameId/versionId` 只接受 project 数据库正整数 IDTier2 字符串版本只进入 Tier2 `sourceRevision.revisionId`LittleJS/Canvas 等游戏使用 `game_source_archive` provider不得伪装成 Phaser Tier2。发布状态继续由 `game_runtime_package.status` 管理,不写入不可变对象清单。
> **Wave1 脊柱 4 模块契约已补并锁**aigc/runtime/feed/telemetry2026-06-08经契约先行起草 + 一致性评审 + 收口§2.1+ 主 agent 校验YAML 语法/裸 select* 0/错误码段 101-104 独占/Flyway V2-5 唯一)。
> **Wave2 变现契约已补并锁**ad/trade2026-06-08`ad.yaml`+`trade.yaml`API #1+ `V6.0.0`/`V7.0.0`DB #2),消费既有 #7 ad-slot / #5 events 不重造;经主 agent 校验YAML 语法/错误码 111·106 独占/Flyway V6-7 唯一/金额用「分」/资金幂等键+状态机/ad↔trade Feign seam 一致ad 计费 `uk_trace` 由主 agent 收紧为 `(trace_id,event_type,tenant_id)` 防 reward 被 impression 静默吞掉。**pay=复用 huijing-pay 后置**(收单 P1
> **Wave3 跨主线收口(进行中)**:新增 `studio`(创作主链路最小版编排,错误码 112 / Flyway V8+ `compliance`(锁风门 Gate发布前合规扫描两模块契约。Phase A 已收口 project 侧publish 唯一化(返回 admitted+gates 门禁聚合、ProjectApi seam 定稿getCurrentVersionId GAP-3 / createProject 写类 RPC、GAP-4 把 `events.schema.json` 信封 envelope/user 由 snake_case 统一为 camelCase与 telemetry.yaml/前端 SDK 对齐。compliance gate 在 project.submitPublish 内仅留 Phase C 接入 seamTODO 注释),尚未接 ComplianceGateApi。其余模块community/ip/biz待后续波次补。
@ -52,8 +53,11 @@ contracts/
│ ├── V5.0.0__create_game_telemetry.sql
│ ├── V6.0.0__create_game_ad.sql # ad广告位 + 收入台账Wave2
│ ├── V7.0.0__create_game_trade.sql # trade账户 + 流水 + 提现Wave2
│ └── V8.0.0__create_game_studio.sql # studio创作主链路编排状态Wave3
│ ├── V8.0.0__create_game_studio.sql # studio创作主链路编排状态Wave3
│ └── V34.0.0__create_game_artifact_storage.sql # runtime/storage对象清单 pending/committed 状态)
├── game-package.schema.json # #4 GamePackage 清单(已落地)
├── game-artifact-manifest.schema.json # #4 运行包/素材/证据对象索引(不替代 GamePackage
├── game-source-archive.schema.json # #4 引擎无关源工程归档索引(独立 source 桶)
├── events.schema.json # #5 telemetry 事件 v1已落地
├── sdk-interface.d.ts # #3 已锁SDK API + postMessage 协议)
├── dify-workflow-io.json # #6 ⚠️ 降级远期未部署(见 DEPRECATED-dify-workflow-io.mdJSON 保留作历史,现行=new-api 网关)

View File

@ -0,0 +1,43 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块runtime/storage对象存储版本状态| ownerWS3 + WS2
-- 文件V34.0.0__create_game_artifact_storage.sql
-- 目的保存对象存储清单的桶、key、hash、源修订身份和 pending/committed 状态。
-- 边界:本表只负责对象存储提交状态;运行包自身的取包状态仍由 runtime 表管理。
-- =============================================================================
CREATE TABLE `game_artifact_storage` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '对象存储记录 ID',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
`game_id` BIGINT NOT NULL COMMENT '游戏项目 IDproject.game_project.id',
`version_id` BIGINT NOT NULL COMMENT '游戏版本 IDproject.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_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 对账',
`source_bucket` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '引擎无关源归档所在桶Tier2 外部源修订可为空',
`source_provider` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '源修订 providertier2_source_project/game_source_archive',
`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_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 正文对象数量',
`total_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT '运行包、素材和证据对象总字节数',
`asset_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT '素材 tree hash',
`bundle_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT '运行 bundle SHA-256无 bundle 时为空',
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '对象状态pending=已上传并回读校验committed=数据库 CAS 提交abandoned=失败回收',
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '失败或回收原因,正常状态为空',
`committed_at` DATETIME NULL COMMENT '数据库 CAS 成功时间',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除标志;不可用它替换同一 game/version 记录',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_game_version` (`tenant_id`, `game_id`, `version_id`) COMMENT '同一租户游戏版本不可绑定第二份对象清单',
KEY `idx_status_created` (`status`, `create_time`) COMMENT '扫描 pending/abandoned 记录',
KEY `idx_manifest_hash` (`artifact_manifest_hash`) COMMENT '按清单 hash 查询幂等记录'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '游戏对象存储版本状态记录(与运行包状态分离)';

View File

@ -0,0 +1,107 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/game-artifact-manifest.schema.json",
"title": "GameArtifactManifest",
"description": "具体游戏版本的对象存储索引。它不是 GamePackage/1 运行时 manifest运行时仍读取同一版本前缀下的 manifest.json状态由 game_runtime_package.status 管理。实际游戏内容只存 MinIO/S3。",
"type": "object",
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"sourceRevision": {
"properties": { "provider": { "const": "tier2_source_project" } }
}
}
},
"then": {
"properties": { "packageType": { "const": "phaser" } }
}
}
],
"required": [
"schemaVersion",
"tenantId",
"gameId",
"versionId",
"packageType",
"keyPrefix",
"sourceRevision",
"runtimeManifest",
"objects",
"totals",
"assetHash",
"bundleHash",
"manifestHash"
],
"properties": {
"schemaVersion": { "const": "GameArtifactManifest/1", "type": "string" },
"tenantId": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
"gameId": { "description": "project.game_project.id 的十进制字符串", "type": "string", "pattern": "^[1-9][0-9]*$" },
"versionId": { "description": "project.game_version.id 的十进制字符串", "type": "string", "pattern": "^[1-9][0-9]*$" },
"packageType": { "type": "string", "enum": ["phaser", "littlejs", "canvas"] },
"keyPrefix": { "type": "string", "pattern": "^tenants/[A-Za-z0-9][A-Za-z0-9._-]*/games/[1-9][0-9]*/versions/[1-9][0-9]*$" },
"sourceRevision": {
"description": "源工程由既有 Tier2 SourceProjectStore 或引擎无关 GameSourceArchive 持有;运行制品只绑定其不可变身份,不复制源文件。",
"type": "object",
"additionalProperties": false,
"required": ["provider", "gameId", "revisionId", "manifestRef", "sourceHash", "objects", "bytes"],
"properties": {
"provider": { "type": "string", "enum": ["tier2_source_project", "game_source_archive"] },
"gameId": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
"revisionId": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
"manifestRef": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(tier2-source-project|game-source-archive):[^:]+:[^:]+$" },
"sourceHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"objects": { "type": "integer", "minimum": 1, "maximum": 10000 },
"bytes": { "type": "integer", "minimum": 1 }
}
},
"runtimeManifest": {
"description": "现有 GamePackage/1 manifest.json 的稳定对象身份;不能把 artifact-manifest.json 当作该运行时清单。",
"type": "object",
"additionalProperties": false,
"required": ["store", "key", "sourcePath", "bytes", "sha256", "mime"],
"properties": {
"store": { "const": "artifact", "type": "string" },
"key": { "type": "string", "minLength": 1 },
"sourcePath": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*\\\\).+$" },
"bytes": { "type": "integer", "minimum": 0, "maximum": 104857600 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"mime": { "type": "string", "minLength": 1 }
}
},
"objects": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["category", "store", "path", "sourcePath", "key", "bytes", "sha256", "mime"],
"properties": {
"category": { "type": "string", "enum": ["asset", "runtime", "evidence"] },
"store": { "type": "string", "enum": ["artifact", "evidence"] },
"path": { "type": "string", "minLength": 1, "pattern": "^(assets|runtime|evidence)/[^/].*" },
"sourcePath": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*\\\\).+$" },
"key": { "type": "string", "minLength": 1, "pattern": "^tenants/.+/games/[1-9][0-9]*/versions/[1-9][0-9]*/(assets|runtime|evidence)/[^/].*" },
"bytes": { "type": "integer", "minimum": 0, "maximum": 104857600 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"mime": { "type": "string", "minLength": 1 }
}
}
},
"totals": {
"type": "object",
"additionalProperties": false,
"required": ["objects", "bytes", "assetBytes", "runtimeBytes", "evidenceBytes"],
"properties": {
"objects": { "type": "integer", "minimum": 0 },
"bytes": { "type": "integer", "minimum": 0 },
"assetBytes": { "type": "integer", "minimum": 0 },
"runtimeBytes": { "type": "integer", "minimum": 0 },
"evidenceBytes": { "type": "integer", "minimum": 0 }
}
},
"assetHash": { "description": "SHA-256(GameArtifactTreeHash/1\\0 + canonical JSON entries)", "type": "string", "pattern": "^[a-f0-9]{64}$" },
"bundleHash": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$" },
"manifestHash": { "description": "SHA-256(GameArtifactManifest/1\\0 + 去除 manifestHash 后的 UTF-8、NFC 路径、键排序紧凑 JSON)", "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
}

View File

@ -0,0 +1,103 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wanxiang.ai/contracts/game-source-archive.schema.json",
"title": "GameSourceArchive",
"description": "引擎无关的游戏源工程对象清单。源代码和源配置进入独立 game-sources 桶;运行包、发布素材和验收证据仍由 GameArtifactManifest/1 管理。",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"tenantId",
"gameId",
"revisionId",
"engine",
"keyPrefix",
"objects",
"totals",
"sourceHash",
"manifestHash"
],
"properties": {
"schemaVersion": { "const": "GameSourceArchive/1", "type": "string" },
"tenantId": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"gameId": {
"description": "project.game_project.id 的十进制字符串。",
"type": "string",
"pattern": "^[1-9][0-9]*$"
},
"revisionId": {
"description": "源工程不可变修订标识,不承载路径分隔符。",
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"engine": {
"description": "生产源工程使用的引擎或宿主描述,只作元数据,不把非 Phaser 游戏伪装成 Tier2。",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]*$"
},
"keyPrefix": {
"type": "string",
"pattern": "^tenants/[A-Za-z0-9][A-Za-z0-9._-]*/games/[1-9][0-9]*/source-revisions/[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"objects": {
"type": "array",
"minItems": 1,
"maxItems": 10000,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "sourcePath", "key", "bytes", "sha256", "mime"],
"properties": {
"path": {
"type": "string",
"minLength": 1,
"pattern": "^source/[^/].*"
},
"sourcePath": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?!.*\\\\).+$"
},
"key": {
"type": "string",
"minLength": 1,
"pattern": "^tenants/.+/games/[1-9][0-9]*/source-revisions/[A-Za-z0-9][A-Za-z0-9._-]*/source/[^/].*"
},
"bytes": {
"type": "integer",
"minimum": 0,
"maximum": 104857600
},
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"mime": { "type": "string", "minLength": 1 }
}
}
},
"totals": {
"type": "object",
"additionalProperties": false,
"required": ["objects", "bytes"],
"properties": {
"objects": { "type": "integer", "minimum": 0, "maximum": 10000 },
"bytes": { "type": "integer", "minimum": 1 }
}
},
"sourceHash": {
"description": "SHA-256(GameSourceArchiveTreeHash/1\\0 + 按 NFC 路径排序的 path/bytes/sha256 规范 JSON)。",
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"manifestHash": {
"description": "SHA-256(GameSourceArchive/1\\0 + 去除 manifestHash 后的规范 JSON)。",
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}

View File

@ -43,7 +43,7 @@ flowchart TB
| **跨仓 #1 API** | `contracts/api-schemas/*.yaml`(12 个:aigc/ad/biz/community/compliance/feed/passport/project/runtime/studio/telemetry/trade) | WS1 lead | 后端定义 → 前端 mock / 各模块互调 | 已建 |
| **跨仓 #2 DB** | `contracts/db-schemas/V*.sql` | WS1 | 后端各模块 | 已建(镜像已漂移,见下节) |
| **跨仓 #3 SDK** | `contracts/sdk-interface.d.ts` | WS3 | game-studio 宿主 ↔ 游戏侧 postMessage | 已建 |
| **跨仓 #4 GamePackage** | `contracts/game-package.schema.json` | WS3 + WS2 | 生成 → 编译 → 预览 → 发布 → 运行全链路 | 已建 |
| **跨仓 #4 GamePackage** | `contracts/game-package.schema.json` + `contracts/game-artifact-manifest.schema.json` + `contracts/game-source-archive.schema.json` | WS3 + WS2 | 生成 → 编译 → 源归档/对象存储 → 预览 → 发布 → 运行全链路 | GamePackage 已建;制品索引和引擎无关源归档契约已建、生产接线分波迁移 |
| **跨仓 #5 events** | `contracts/events.schema.json` | WS5 | 前端埋点 / SDK 上报 → 看板 | 已建 |
| **跨仓 #6 Dify I/O** | `contracts/dify-workflow-io.json` | WS2 | ~~aigc 壳 ↔ Dify~~ | **废**(降远期 / 从未部署;现行=new-api,见 `DEPRECATED-dify-workflow-io.md`) |
| **跨仓 #7 ad-slot** | `contracts/ad-slot.schema.json` | WS5 | ad 模块 / SDK Plugin.Ad | 已建 |
@ -56,6 +56,8 @@ flowchart TB
`templates/generic.schema.json` 有个反直觉的约束:P3 走引擎 bundle 路后,后端对 GameConfig 玩法字段的校验已退场,但这 13 个 schema 文件仍须就位 —— 缺一个,`PromptResourceLoader.isReady()` 就因 `getAllTemplateSchemaTexts` 链加载不到资源而卡在 false([generic.schema.json:5](../../../contracts/templates/generic.schema.json))。退场不等于可删。
#4 内部有三份用途互斥的 JSON。`game-package.schema.json` 是宿主真正解析的运行清单,版本前缀下文件名仍为 `manifest.json``game-artifact-manifest.schema.json` 是平台上传、下载和逐文件校验使用的运行包/素材/证据对象索引,文件名固定为 `artifact-manifest.json``game-source-archive.schema.json` 是引擎无关源码归档索引,文件名固定为 `source-manifest.json`,存放在独立 `game-sources` 桶。对象索引不能作为 `manifestUrl` 返回给宿主,不能携带 candidate/published/retired 状态,也不保存签名 URL。生产版本身份来自 project 数据库Tier2 源工程继续绑定现有 SourceProjectStoreLittleJS/Canvas 等游戏通过 `game_source_archive` provider 绑定引擎无关源归档。对象存储提交记录由 V34 `game_artifact_storage` 保存,不能把 MinIO marker 当作 committed。
`agent-loop/``templates/` 这两个目录在 `contracts/README.md` 的目录结构图里没有 —— 那张图只画了跨仓 8 类。新人 `ls` 到它们时无从对应,这是 README 自身的覆盖缺口,在此补登:`agent-loop/` = 生成线的源项目 keystone 加 QA 闭环工件契约族,`templates/` = 13 个品类/形态 schema。
## "第几类"口径收口

View File

@ -0,0 +1,168 @@
---
status: 修订待复核
sot-impact: 修订 GamePackage、运行时分发、参照资产消费和游戏内容存储边界本档只记录迁移方案不复制架构 SoT
canonical: false
上级: docs/architecture/架构/契约总览.md
---
# 游戏包与资产对象存储迁移
> **执行约束**:按波次实施,每一波独立验证。任何具体游戏从 Git 删除前,必须先完成对象存储上传、下载逐文件对账、真实消费者切换和回滚观察。
**目标**Git 只保存平台代码、契约和最小合成测试夹具;每款具体游戏的源工程、素材、运行包和验收证据进入对象存储,并能被授权下载、预览、发布和可信消费。
**架构**:对象存储保存不可变内容,数据库保存租户归属、发布状态、对象 key 和可信 hash。运行时清单继续使用现有 `GamePackage/1``GameArtifactManifest/1` 只索引运行包、素材和证据;引擎无关 `GameSourceArchive/1` 单独索引源码。三者不共用 schema、不共用 URL。对象 marker 只代表回读校验完成V34 `game_artifact_storage` 的数据库 CAS 才代表 committed。
**现有技术**MinIO/S3、MySQL、Spring Runtime API、Python 生成 worker、Vue 游戏宿主、ReferenceAsset 消费门。
## 边界
| 类别 | Git | 对象存储 | 生产消费方式 |
|---|---|---|---|
| 平台代码 | 保留 | 可选发布镜像 | 正常构建、部署 |
| 合成测试夹具 | 保留最小量 | 可选 | 只验证平台算法,不呈现具体游戏内容 |
| 游戏源工程 | 不保留 | 引擎无关源码进入私有 `game-sources`Tier2 历史源工程仍在 `tier2-src` | 生成、修复服务按 source manifest 或 Tier2 manifest 物化 |
| 游戏素材 | 不保留 | `game-artifacts` | 运行时授权后代理或签发精确短期 URL |
| 游戏运行包 | 不保留 | `game-artifacts` | 宿主读取现有 `GamePackage/1` 和版本 bundle |
| 验收证据 | 不保留 | 私有 `game-evidence` | 审核服务按 record/key/hash 读取 |
| 临时批跑 | 不保留 | 默认不保存 | 需要审计时按保留期进入证据桶 |
| 设计和签认结论 | 保留摘要 | 原始媒体可外置 | Git 保存决策、签认身份和对象 hash不保存游戏内容 |
平台共享引擎、SDK、生成器、构建器、上传下载器、契约和门脚本仍属于项目代码。绑定某一款游戏的玩法代码、配置、素材、bundle、截图、轨迹和测试不属于平台代码。
## 三份清单
运行版本前缀下保留运行清单和制品清单;源码修订使用独立清单和桶:
```text
tenants/<tenantId>/games/<gameId>/versions/<versionId>/
├── manifest.json # 现有 GamePackage/1游戏宿主读取
├── artifact-manifest.json # GameArtifactManifest/1运行包/素材/证据索引
├── assets/<相对路径>
└── runtime/<bundle 或渠道包>
game-sources 桶GameSourceArchive/1权限和桶预建完成前禁止联网写:
└── tenants/<tenantId>/games/<gameId>/source-revisions/<revisionId>/
├── source-manifest.json
└── source/<源工程相对路径>
tier2-src 桶(沿用现有 Tier2 SourceProjectStore仅 Tier2
└── <sourceGameId>/<sourceRevisionId>/<源工程相对路径>
game-evidence 桶:
└── tenants/<tenantId>/games/<gameId>/versions/<versionId>/evidence/<验收证据路径>
```
`manifest.json` 的原始字节 hash 继续写入 `game_runtime_package.checksum`,宿主按现有 `GamePackage/1` 解析。`artifact-manifest.json` 记录素材、运行包和证据对象的 `category/store/path/sourcePath/key/bytes/sha256/mime`、分类 tree hash、bundle hash 和自身 hash。引擎无关源工程由 `GameSourceArchive/1` 记录源码路径、字节 hash、sourceHash 和 `source-manifest.json` 自身 hash`game_source_archive` provider 绑定这份清单Tier2 继续使用 `tier2_source_project` 和现有 `tier2-src`。Tier2 provider 只允许 Phaser packageLittleJS/Canvas 必须走引擎无关 provider。清单不包含发布状态、时间或执行者也不能作为 `manifestUrl` 返回给宿主。创建时间、操作者和 trace 属于数据库审计记录,不能让相同内容重复构建出不同 manifest hash。
对象 key 永久稳定,签名 URL 只在授权请求时生成,不写入清单。路径先做 NFC 规范化并按 POSIX 相对路径排序tree hash 和 manifest hash 使用不同域标签,防止不同类型摘要被混用。
## 数据权威
| 数据 | 权威位置 | 说明 |
|---|---|---|
| 游戏版本身份 | `project.game_version.id` | 运行包对象前缀的 `versionId`契约只接受正整数十进制字符串Tier2 字符串版本只能写入 `sourceRevision.revisionId` |
| 发布生命周期 | `game_runtime_package.status` | 保持 preview/published/retired 的 CAS 门,不写入不可变对象清单 |
| GamePackage 原始 hash | `game_runtime_package.checksum` | 对 `manifest.json` 原始字节计算 |
| 对象索引身份 | 新的版本存储记录 | 保存 bucket、artifact manifest key/hash、状态 pending/committed、字节数和创建者 |
| 引擎无关源工程 | `GameSourceArchive/1` + `game-sources` | 保存 source manifest key/hash、source hash、引擎元数据V34 对象记录保存其与运行版本的绑定 |
| Tier0/1 结构化源项目 | 迁移后由对象存储持有正文 | `game_source_project` 只保存版本、对象 key、source hash 和元数据 |
| Tier2 源工程 | 现有 MySQL manifest + `tier2-src` | 保留已上线的 manifest-first 路径;生产制品通过 `tier2-source-project:<gameId>:<revisionId>` 和 sourceHash 绑定,不复制源文件 |
| 金标消费身份 | 新版 ReferenceAsset Registry/Release | 保存可信对象 key/hash消费前物化到临时可信根并逐文件校验 |
对象存储版本记录将在波次 2 使用状态 `pending → committed`。所有对象上传并 GET 全量复算后,还必须由数据库条件创建/CAS 把 pending 变为 committed发布 CAS 只接受 committed 版本。同一 `tenantId/gameId/versionId` 已存在不同 manifest hash 时拒绝覆盖,相同 hash 视为幂等命中。失败的 pending 记录由定时回收任务按保留期清理published/retired 版本都不能被覆盖。
波次 1 使用的 MinIO Python SDK 没有公开的条件 Put 接口。离线上传器因此只允许调用方先持有 `tenantId/gameId/versionId` 级外部单写锁,并返回 `verified_pending`,明确 `committed=false``artifact-manifest.json``source-manifest.json` 已存在时,不同 hash 会在任何 Put 前拒绝,相同 hash 会逐对象回读校验marker 尚不存在时,残留 partial 对象可在单写锁内被当前内容覆盖。源码桶必须是显式独立桶,不能与 artifact/evidence 桶复用。这个行为不是并发不可覆盖保证:两个无锁写者仍可能在“读取 marker”和“写 marker”之间相互覆盖波次 1 的 marker 也不能作为发布提交凭据。
对象存储提交状态与运行包发布状态是两台独立状态机,只通过“发布只接受 committed 对象”这道门连接。
```mermaid
stateDiagram-v2
state "对象存储版本" as storage {
[*] --> pending: 创建存储记录
pending --> committed: 对象全量 GET 对账 + 数据库 CAS
pending --> abandoned: 超时或上传失败
committed --> abandoned: 未发布且明确废弃
}
state "运行包发布" as release {
[*] --> preview
preview --> published: 存储记录必须 committed
published --> retired: 下架
published --> published: 禁止覆盖
retired --> retired: 只读或法务冻结
}
```
## 读写接口
平台只接受数据库中已登记的对象 key不接受调用方传入任意 URL。
| 接口 | 输入 | 输出 | 失败方式 |
|---|---|---|---|
| 构建对象索引 | 本地临时游戏目录、数据库 tenant/game/version、完整 GamePackage、已保存的 Tier2 sourceRevision | `GameArtifactManifest/1` | GamePackage schema/身份不一致、源修订 hash 漂移、路径越界、符号链接、凭据文件或重复路径即拒绝 |
| 构建源归档 | 本地临时游戏目录、数据库 tenant/game、source revision、引擎描述 | `GameSourceArchive/1` | 空源树、二进制/路径 hash 漂移、运行 manifest 混入、路径越界、符号链接或重复路径即拒绝 |
| 波次 1 上传验证 | manifest、可信本地根、服务端身份、外部单写锁 | `verified_pending``committed=false` 回执 | 未声明外部单写、同版本 marker 不同 hash、上传/回读不一致即拒绝 |
| 生产上传提交(波次 2 | pending 数据库记录、manifest、可信本地根、服务端身份 | 数据库 CAS 后的 committed 回执 | 并发条件创建失败、同版本不同 hash、配额超限、上传/回读不一致即拒绝 |
| 物化版本 | 数据库登记的 tenant/game/version、manifest key/hash、用途、目标临时根 | 已逐文件校验并原子交付的本地快照 | 期望身份/hash 缺失、生产消费未 committed、越权或逐文件 hash 不一致即拒绝且清理临时目录;波次 1 只允许诊断回验,不接生产消费 |
| 运行时取包 | 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 编译完成后同时写现有 GamePackage 和对象存储版本记录。影子读阶段仍由 DB 返回 GamePackage但后台从 OSS 下载并对账;切换后 Runtime API 在完成 tenant/owner/status 门禁后代理返回 GamePackage 或签发对象 URL。宿主的生产模式遇到网络、schema 或 hash 错误必须显示加载失败demo 兜底只留在显式 mock/development 模式。
当前真实消费链已核对为:`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 游戏内容来消红。
### 引擎无关源工程
对 LittleJS、Canvas 和其它非 Tier2 工程,先用 `GameSourceArchive/1` 固定 NFC 路径、二进制安全 sourceHash 和 source manifest hash再由 V34 对象记录绑定 game/version。`game-sources` 未完成专用权限和预建桶前只允许离线构建与 fake/合成测试;完成上传、逐文件回读和 CAS 后才允许生产物化。
### Tier0/1 结构化源工程
先确定 Tier0/1 SourceProjectStore 的 canonical hash 和不可变修订身份,再修订 artifact schema 增加对应 provider。随后给 `game_source_project` 增加对象 key、source hash、manifest hash 和迁移状态,写入时双写 DB JSON 与对象存储;完成历史 backfill 和全量对账后,读取改为对象存储,最后停止写正文 JSON。对外 fetch DTO 返回稳定对象身份,不返回永久公开 URL。
### Tier2 源工程
保留现有 `BackendStore` 的 MySQL manifest-first 与 `tier2-src` MinIO 实现。发布运行包时artifact manifest 的 `versionId` 只写 `project.game_version.id`Tier2 返回的 `{id, versionId, sourceHash}` 原样写入 `sourceRevision`;构建器必须复算本地 SourceProjectStore 口径的 `path\0content\0` hash 并与 sourceHash 相等。两者的映射随 artifact manifest hash 一起进入对象存储版本记录,不新建第二套 Tier2 源权威。
### 金标和参照资产
冻结现有 ReferenceAsset `/1``/2`,新增对象存储消费版本,不就地放宽相对路径。新 Registry 保存对象身份与 artifact manifest hashRelease 继续绑定 Registry 和 policy 原始字节 hash。`frozen_preflight` 先把对象下载到临时可信根,再沿现有 fd/逐文件 hash 逻辑校验。只有新消费路径和回归测试全绿后,才把《山海行纪》及其它参照内容从 Git 删除。
## 实施波次
- [x] **波次 1契约与离线工具。** 已冻结 `GamePackage/1``GameArtifactManifest/1``GameSourceArchive/1` 三份清单,收紧 Tier2/Phaser provider 边界、引擎无关源 hash、外部单写前提下的 marker 幂等、`verified_pending` 回执、下载逐文件验证和安全负测;未产出 committed未改生产消费未删除 Git 内容。
- [ ] **波次 2对象存储状态记录。** 增加 pending/committed 记录、数据库条件创建/CAS、并发不可覆盖门、孤儿回收、审计日志和配额完成至少一款候选的上传、committed 和下载回验。
- [ ] **波次 3运行时影子读。** GamePackage 双写 OSS后端影子下载对账修正绝对 URL Token 泄露和生产 demo 回退。
- [ ] **波次 4源工程迁移。** 分别迁移 Tier0/1 和 Tier2完成 backfill、影子读、全量对账和回滚观察。
- [ ] **波次 5金标消费迁移。** 新 Registry/Release/物化 preflight 通过后,将 active 金标绑定对象身份。
- [ ] **波次 6Git 清理。** 上传所有具体游戏、切换全部消费者、确认观察窗无回退后,独立提交删除 tracked 游戏内容并扩大 ignore平台合成 fixture 留在 Git。
## 验收
### 当前执行证据2026-07-29
离线工具、V34 状态记录、运行时提交门和正式《山海行纪》验收均已在本地完成验证Python worker `181 passed`Java runtime 定向测试 `28 passed`Shanhai Node 测试 `54/54`,三场景 headless smoke 全绿quick/formal simulation gate 分别满足 c1c4`test_reference_asset_and_acceptance_v3.py``43/43`docs-gate 与 `git diff --check` 全绿。运行时门的默认开关仍为 `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 一致。
源码归档清单已离线构建57 个对象,`sourceHash=26dcb489bd147c393dfae2b567090f787c2be1219525321e40d9d619af8eddbc``manifestHash=761dd48aef911bbfff9a5a03d9710ccec6cce1b8360382b397a233291f7d5358`),但联网上传仍按设计 fail-closed`game-sources` 尚未预建并授权给专用写者CLI 返回 `GAME-SOURCE-ARCHIVE-ERROR`。因此尚不能把这轮 staging 证据记为 committed、不能打开生产运行时开关也不能据此改写正式金标 Registry生产切换和波次 2 完成仍以源码桶权限、pending→committed CAS 及观察窗为前置条件。
- 干净 clone 不含任何具体游戏源、素材、bundle、dist 或 evidence只保留平台代码和合成 fixture。
- 每款迁移游戏都有 committed 回执,能从对象存储下载并逐文件复算 hash。
- 浏览器真实加载对象存储提供的 GamePackage、bundle 和素材;控制台无阻断错误,平台 Token 不发往对象域。
- 运行包加素材总量不超过 10MB、首屏不超过 2MB单文件不超过 100MB、单版本不超过 10000 个对象、相对路径不超过 1024 字节,租户总配额由服务端记录强制。
- preview、play、retired、跨租户、过期签名、重定向 SSRF、并发覆写、错误 MIME/CORS/CSP、配额和孤儿回收负测均被拦截。
- ReferenceAsset 的 Registry、Release、消费清单和物化快照形成完整 hash 链,干净 clone 下仍能完成 `frozen_preflight`
- 同一版本重复上传相同 hash 为幂等命中,不同 hash 拒绝published 后不可覆盖retired 后不可再签发新 URL。
- `.agents/tools/docs-gate.sh`、相关契约/后端/前端测试、对象存储 smoke、浏览器真玩和 `git diff --check` 全绿。
## 删除门
现有 `game-runtime/games/*``tier2/games/*``_wg1-gen` 中仍有 tracked 具体游戏。增加 `.gitignore` 不能移除已跟踪文件,也不能替代消费迁移。只有对应游戏同时满足“已上传、已下载对账、消费者已切换、观察窗结束、回滚包可取”五项条件,才允许从 Git 删除;任何一项缺失都继续保留,避免发布链和金标门被切断。

View File

@ -62,6 +62,26 @@ canonical: true
> Redis 需 AUTH(NOAUTH 已实证);MinIO root 即 ragflow(与 ragflow 共用实例,tier2 另开 bucket 如 `tier2-src` 隔离)。这些是内网 Tailscale 凭据、按铁律入仓。
### 游戏内容对象存储专用身份2026-07-29
具体游戏的素材、运行包和验收证据不复用 MinIO root。专用账号当前只允许对两个预建桶的 `tenants/` 前缀执行对象 Put/Get、前缀内 List 与分片上传恢复,并允许 Python SDK 读取这两个桶的区域;不能访问未授权桶,也没有 DeleteObject、建桶、删桶或策略管理权限。引擎无关源归档的 `game-sources` 桶尚未预建、未加入该账号策略;在契约和权限独立复核前,源归档 CLI 的联网写入必须失败。当前 MinIO 的根列桶接口会返回调用者有权访问的两个桶名,不会返回其它桶;这项行为不能表述为根列桶请求被拒绝。
| 项 | 值 |
|---|---|
| S3 端点 | `100.64.0.8:9000`(内网 HTTP公网/生产必须切 TLS |
| access key | `game-artifact-writer` |
| secret key | `4aa0a3f0036aac13cded3942c056df2c270b99cb7962624b` |
| 策略 | `game-artifact-writer-policy-v3` |
| 运行包与素材桶 | `game-artifacts` |
| 验收证据桶 | `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 凭据。
同日生产身份盘点纠偏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` 建无人消费的临时记录。
运行时对象提交门已接入 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`
---
## Nacos 配置中心 + 服务发现mini-infra `100.64.0.8` · 2026-07-01 自托管落档 · 切片一 阶段〇 B1

View File

@ -0,0 +1,69 @@
package com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableName;
import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 游戏对象存储版本状态 DO
*
* <p>该表只记录对象上传校验与数据库提交状态不替代 {@code game_runtime_package.status}
* 运行时消费门只读取 status=committed 的记录并用 runtime manifest hash 对账运行包 checksum</p>
*/
@TableName("game_artifact_storage")
@KeySequence("game_artifact_storage_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class ArtifactStorageDO extends TenantBaseDO {
/** 对象存储状态记录 ID。 */
private Long id;
/** 游戏项目 ID。 */
private Long gameId;
/** 游戏版本 ID。 */
private Long versionId;
/** 运行包、素材和证据对象所在桶。 */
private String artifactBucket;
/** artifact-manifest.json 对象 key。 */
private String artifactManifestKey;
/** artifact-manifest.json 原始字节 SHA-256。 */
private String artifactManifestHash;
/** artifact-manifest.json 字节数。 */
private Long artifactManifestBytes;
/** GamePackage manifest.json 对象 key。 */
private String runtimeManifestKey;
/** GamePackage manifest.json 原始字节 SHA-256与 runtime checksum 对账。 */
private String runtimeManifestHash;
/** 引擎无关源归档所在桶Tier2 外部源修订可为空。 */
private String sourceBucket;
/** 源修订 provider。 */
private String sourceProvider;
/** 源修订所属稳定标识。 */
private String sourceGameId;
/** 源修订不可变标识。 */
private String sourceRevisionId;
/** source-manifest.json 对象 key。 */
private String sourceManifestKey;
/** source-manifest.json 原始字节 SHA-256。 */
private String sourceManifestHash;
/** 源文件树 SHA-256。 */
private String sourceHash;
/** 运行包形态phaser/littlejs/canvas。 */
private String packageType;
/** artifact manifest 正文对象数量。 */
private Integer objectCount;
/** 运行包、素材和证据对象总字节数。 */
private Long totalBytes;
/** 素材 tree hash。 */
private String assetHash;
/** 运行 bundle SHA-256。 */
private String bundleHash;
/** pending/committed/abandoned。 */
private String status;
/** 失败或回收原因。 */
private String failureReason;
/** 数据库 CAS 成功时间。 */
private java.time.LocalDateTime committedAt;
}

View File

@ -0,0 +1,33 @@
package com.wanxiang.huijing.game.module.runtime.dal.mysql.storage;
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO;
import org.apache.ibatis.annotations.Mapper;
/**
* 游戏对象存储版本状态 Mapper
*
* <p>消费侧只允许按版本读取 committed 记录pending/abandoned 不得进入运行时</p>
*/
@Mapper
public interface ArtifactStorageMapper extends BaseMapperX<ArtifactStorageDO> {
/**
* 按租户游戏和版本读取唯一已提交对象记录
*
* <p>三元身份必须完整进入查询条件不能只信 versionId这是对象记录跨租户隔离的可信边界</p>
*
* @param tenantId 租户 ID
* @param gameId 游戏项目 ID
* @param versionId 游戏版本 ID
* @return committed 记录不存在时返回 null
*/
default ArtifactStorageDO selectCommittedByIdentity(Long tenantId, Long gameId, Long versionId) {
return selectOne(new LambdaQueryWrapperX<ArtifactStorageDO>()
.eq(ArtifactStorageDO::getTenantId, tenantId)
.eq(ArtifactStorageDO::getGameId, gameId)
.eq(ArtifactStorageDO::getVersionId, versionId)
.eq(ArtifactStorageDO::getStatus, "committed"));
}
}

View File

@ -0,0 +1,134 @@
package com.wanxiang.huijing.game.module.runtime.service.pkg;
import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO;
import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO;
import com.wanxiang.huijing.game.module.runtime.dal.mysql.storage.ArtifactStorageMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Objects;
import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_ARTIFACT_MISMATCH;
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;
/**
* 对象存储提交状态消费门
*
* <p>开关默认关闭允许 V34 尚未部署的环境继续使用旧 DB manifest 路径打开后运行时必须
* 找到 committed 记录且记录中的 GamePackage manifest hash 必须与 runtime package checksum
* 一致 manifest engineBundle还必须把实际 bundle 字节 hash 与对象记录对账</p>
*/
@Slf4j
@Component
public class ArtifactStorageGate {
/** 只读解析原始 GamePackage不对外重新序列化避免破坏 checksum 字节语义。 */
private static final ObjectMapper PACKAGE_MAPPER = new ObjectMapper();
@Resource
private ArtifactStorageMapper artifactStorageMapper;
/**
* 生产切换开关只有 V34 已落库且上传器完成 pendingcommitted 后才允许打开
*/
@Value("${game.artifact-storage.enforce-committed:false}")
private boolean enforceCommitted;
/**
* 校验运行包是否已经绑定到已提交对象记录
*
* @param runtimePackage 当前运行包记录
*/
public void requireCommitted(RuntimePackageDO runtimePackage) {
if (!enforceCommitted) {
return;
}
ArtifactStorageDO storage = artifactStorageMapper.selectCommittedByIdentity(
runtimePackage.getTenantId(), runtimePackage.getGameId(), runtimePackage.getVersionId());
if (storage == null) {
log.warn("[artifactStorageGate] 对象记录未 committed拒绝运行时消费 versionId={}",
runtimePackage.getVersionId());
throw exception(RUNTIME_PACKAGE_NOT_READY);
}
BundleCheck bundleCheck = inspectBundle(runtimePackage);
boolean bundleMatches = bundleCheck.readable()
&& (bundleCheck.present()
? StringUtils.hasText(storage.getBundleHash())
&& Objects.equals(storage.getBundleHash(), bundleCheck.sha256())
: !StringUtils.hasText(storage.getBundleHash()));
if (!Objects.equals(storage.getRuntimeManifestHash(), runtimePackage.getChecksum())
|| !bundleMatches) {
log.error("[artifactStorageGate] 对象记录与运行包摘要不一致,拒绝消费 versionId={}, "
+ "runtimeManifestHash={}, runtimeChecksum={}, bundleHash={}, actualBundleHash={}",
runtimePackage.getVersionId(), storage.getRuntimeManifestHash(), runtimePackage.getChecksum(),
storage.getBundleHash(), bundleCheck.sha256());
throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
}
}
/**
* 从当前实际消费的 DB manifest 提取 engineBundle 并计算 UTF-8 SHA-256
*
* <p>对象上传器已经对远端 bundle 做逐字节回读消费侧再对 DB manifest 中即将交给宿主的
* engineBundle 做一次对账避免只检查数据库里的 bundle_hash 非空而放过陈旧内容</p>
*/
private BundleCheck inspectBundle(RuntimePackageDO runtimePackage) {
if (!StringUtils.hasText(runtimePackage.getPackageJson())) {
log.error("[artifactStorageGate] 运行包缺少 DB manifest无法校验实际 bundle versionId={}",
runtimePackage.getVersionId());
return BundleCheck.unreadable();
}
try {
JsonNode root = PACKAGE_MAPPER.readTree(runtimePackage.getPackageJson());
JsonNode bundle = root == null ? null : root.get("engineBundle");
if (bundle == null || bundle.isNull()) {
// GamePackage.engineBundle 是可选字段缺失时必须同时没有 bundle_hash
return BundleCheck.noBundle();
}
if (!bundle.isTextual() || !StringUtils.hasText(bundle.asText())) {
log.error("[artifactStorageGate] DB manifest 的 engineBundle 类型或内容非法 versionId={}",
runtimePackage.getVersionId());
return BundleCheck.unreadable();
}
return BundleCheck.present(sha256Hex(bundle.asText()));
} catch (Exception ex) {
log.error("[artifactStorageGate] DB manifest 无法解析,拒绝 bundle 校验 versionId={}",
runtimePackage.getVersionId(), ex);
return BundleCheck.unreadable();
}
}
/** 当前 manifest 是否可读、是否包含 bundle以及 bundle 的真实摘要。 */
private record BundleCheck(boolean readable, boolean present, String sha256) {
private static BundleCheck unreadable() {
return new BundleCheck(false, false, null);
}
private static BundleCheck noBundle() {
return new BundleCheck(true, false, null);
}
private static BundleCheck present(String sha256) {
return new BundleCheck(true, true, sha256);
}
}
/** 计算 bundle 文本 UTF-8 字节摘要,与对象归档及生成链路使用同一 sha256 口径。 */
private static String sha256Hex(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (Exception ex) {
throw new IllegalStateException("JDK 缺少 SHA-256 算法", ex);
}
}
}

View File

@ -57,6 +57,10 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
@Resource
private ProjectApi projectApi;
/** 对象存储提交状态门;开关关闭时保持现有 DB manifest 兼容路径。 */
@Resource
private ArtifactStorageGate artifactStorageGate;
@Override
public RuntimePackageDO getPackageManifest(Long versionId, String scene, Long userId) {
// 取就绪运行包uk_version 唯一
@ -65,6 +69,8 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
// 无对应就绪运行包编译未完成或版本不存在对齐契约 #1返回 1-102-001-001
throw exception(RUNTIME_PACKAGE_NOT_READY);
}
// 生产切换后只允许消费已经完成对象上传回读校验和数据库 CAS 的版本
artifactStorageGate.requireCommitted(pkg);
// 取包门禁决策1 game_runtime_package.status 为权威判定字段不与 project.game_version.status 混用
if (RuntimeSceneEnum.isPreview(scene)) {
// 预览放行 status{0 预览就绪,1 已发布}且校验调用者为项目 owner 或服务端已认证管理员
@ -89,6 +95,8 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
if (pkg == null) {
throw exception(RUNTIME_PACKAGE_NOT_READY);
}
// 发布前同样校验 committed 记录避免未完成对象上传的版本进入已发布状态
artifactStorageGate.requireCommitted(pkg);
if (!Objects.equals(pkg.getChecksum(), expectedArtifactHash)) {
log.warn("[publishPackage] 产物摘要不一致,拒绝发布 versionId={}", versionId);
throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);

View File

@ -0,0 +1,110 @@
package com.wanxiang.huijing.game.module.runtime.service.pkg;
import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO;
import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO;
import com.wanxiang.huijing.game.module.runtime.dal.mysql.storage.ArtifactStorageMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_ARTIFACT_MISMATCH;
import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_READY;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** committed 对象记录与运行包 checksum/bundle hash 的消费门测试。 */
class ArtifactStorageGateTest {
private static final String BUNDLE_HASH = "1e6ed65d77d6364eeaed5a745ba5c4985ae2b700dd85d7cf7f027bdf294a33fc";
@Test
void disabledGateKeepsLegacyReadPath() {
ArtifactStorageMapper mapper = mock(ArtifactStorageMapper.class);
ArtifactStorageGate gate = new ArtifactStorageGate();
ReflectionTestUtils.setField(gate, "artifactStorageMapper", mapper);
ReflectionTestUtils.setField(gate, "enforceCommitted", false);
assertDoesNotThrow(() -> gate.requireCommitted(pkg("a".repeat(64))));
}
@Test
void enabledGateRejectsMissingCommittedRecord() {
ArtifactStorageMapper mapper = mock(ArtifactStorageMapper.class);
when(mapper.selectCommittedByIdentity(1L, 1024L, 2048L)).thenReturn(null);
ArtifactStorageGate gate = configuredGate(mapper);
ServiceException ex = assertThrows(ServiceException.class,
() -> gate.requireCommitted(pkg("a".repeat(64))));
assertEquals(RUNTIME_PACKAGE_NOT_READY.getCode(), ex.getCode());
}
@Test
void enabledGateRejectsStaleRuntimeManifestOrMissingBundleHash() {
ArtifactStorageMapper mapper = mock(ArtifactStorageMapper.class);
ArtifactStorageDO stale = committed("b".repeat(64), "c".repeat(64));
when(mapper.selectCommittedByIdentity(1L, 1024L, 2048L)).thenReturn(stale);
ArtifactStorageGate gate = configuredGate(mapper);
ServiceException ex = assertThrows(ServiceException.class,
() -> gate.requireCommitted(pkg("a".repeat(64))));
assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode());
stale.setRuntimeManifestHash("a".repeat(64));
stale.setBundleHash("");
ex = assertThrows(ServiceException.class, () -> gate.requireCommitted(pkg("a".repeat(64))));
assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode());
}
@Test
void enabledGateAllowsCommittedHashes() {
ArtifactStorageMapper mapper = mock(ArtifactStorageMapper.class);
when(mapper.selectCommittedByIdentity(1L, 1024L, 2048L))
.thenReturn(committed("a".repeat(64), BUNDLE_HASH));
ArtifactStorageGate gate = configuredGate(mapper);
assertDoesNotThrow(() -> gate.requireCommitted(pkg("a".repeat(64))));
}
@Test
void enabledGateAllowsManifestWithoutOptionalBundle() {
ArtifactStorageMapper mapper = mock(ArtifactStorageMapper.class);
when(mapper.selectCommittedByIdentity(1L, 1024L, 2048L))
.thenReturn(committed("a".repeat(64), ""));
ArtifactStorageGate gate = configuredGate(mapper);
RuntimePackageDO runtimePackage = pkg("a".repeat(64));
runtimePackage.setPackageJson("{\"schemaVersion\":\"1.0\"}");
assertDoesNotThrow(() -> gate.requireCommitted(runtimePackage));
verify(mapper).selectCommittedByIdentity(1L, 1024L, 2048L);
}
private static ArtifactStorageGate configuredGate(ArtifactStorageMapper mapper) {
ArtifactStorageGate gate = new ArtifactStorageGate();
ReflectionTestUtils.setField(gate, "artifactStorageMapper", mapper);
ReflectionTestUtils.setField(gate, "enforceCommitted", true);
return gate;
}
private static RuntimePackageDO pkg(String checksum) {
RuntimePackageDO pkg = new RuntimePackageDO();
pkg.setTenantId(1L);
pkg.setGameId(1024L);
pkg.setVersionId(2048L);
pkg.setChecksum(checksum);
pkg.setPackageJson("{\"engineBundle\":\"bundle\"}");
return pkg;
}
private static ArtifactStorageDO committed(String runtimeManifestHash, String bundleHash) {
ArtifactStorageDO storage = new ArtifactStorageDO();
storage.setVersionId(2048L);
storage.setStatus("committed");
storage.setRuntimeManifestHash(runtimeManifestHash);
storage.setBundleHash(bundleHash);
return storage;
}
}

View File

@ -49,6 +49,9 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest {
@Mock
private ProjectApi projectApi;
@Mock
private ArtifactStorageGate artifactStorageGate;
@AfterEach
void clearSecurityContext() {
org.springframework.security.core.context.SecurityContextHolder.clearContext();

View File

@ -0,0 +1,43 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块runtime/storage对象存储版本状态| ownerWS3 + WS2
-- 文件V34.0.0__create_game_artifact_storage.sql
-- 目的保存对象存储清单的桶、key、hash、源修订身份和 pending/committed 状态。
-- 边界:本表只负责对象存储提交状态;运行包自身的取包状态仍由 runtime 表管理。
-- =============================================================================
CREATE TABLE `game_artifact_storage` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '对象存储记录 ID',
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID',
`game_id` BIGINT NOT NULL COMMENT '游戏项目 IDproject.game_project.id',
`version_id` BIGINT NOT NULL COMMENT '游戏版本 IDproject.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_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 对账',
`source_bucket` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '引擎无关源归档所在桶Tier2 外部源修订可为空',
`source_provider` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '源修订 providertier2_source_project/game_source_archive',
`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_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 正文对象数量',
`total_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT '运行包、素材和证据对象总字节数',
`asset_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT '素材 tree hash',
`bundle_hash` CHAR(64) NOT NULL DEFAULT '' COMMENT '运行 bundle SHA-256无 bundle 时为空',
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '对象状态pending=已上传并回读校验committed=数据库 CAS 提交abandoned=失败回收',
`failure_reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '失败或回收原因,正常状态为空',
`committed_at` DATETIME NULL COMMENT '数据库 CAS 成功时间',
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除标志;不可用它替换同一 game/version 记录',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_game_version` (`tenant_id`, `game_id`, `version_id`) COMMENT '同一租户游戏版本不可绑定第二份对象清单',
KEY `idx_status_created` (`status`, `create_time`) COMMENT '扫描 pending/abandoned 记录',
KEY `idx_manifest_hash` (`artifact_manifest_hash`) COMMENT '按清单 hash 查询幂等记录'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '游戏对象存储版本状态记录(与运行包状态分离)';

View File

@ -1,17 +1,5 @@
# ── 游戏生成线运行时 / 生成产物:可重生成、本地跑出、不入库(2026-07-23)──
# 以下三处均为 a-model 生成线与 Actor 校准的批量产物,tracked=0(已用 git ls-files 逐一复核),
# 纯产物、可重生成,长期污染 git status,故整目录忽略。
# 遵「专一不笼统」红线:
# - amgen-* = a-model 生成的游戏目录(bundle.iife.js/entry-bundle.js/trace.jsonl/turns.jsonl/evidence/src/...),
# 其中 src/ 是该「生成游戏」自身的产出源码,非本仓手写工程源码;全部 93 个 amgen-* 均 tracked=0,故整前缀忽略。
# - 绝不忽略 _wg1-gen(B 组 tracked=197,含 _shared 真实新源码与精选参考游戏)与各 _template*/_tier2-gen 等目录。
# - _amgen-archive 以 _ 开头,不被 amgen-* 匹配,单独列出。
# ── 游戏运行目录只作临时物化根;具体游戏由仓根 .gitignore 统一排除2026-07-29──
# 本文件只保留运行时局部缓存规则;通用模板/共享工具的豁免也由仓根维护,避免两套口径漂移。
# Actor 校准 attempt 产物(attempt-*/ + calibration-summary.json + runtime-log.jsonl,4787 个,tracked=0)
evidence/match3-actor-calibration/
# a-model 生成的游戏目录(93 个,tracked=0)——覆盖根 .gitignore 已忽略的具体前缀之外的全部 amgen-*
games/amgen-*/
# a-model 生成归档(tracked=0)
games/_amgen-archive/

View File

@ -57,3 +57,14 @@ minio:
bucket: "tier2-src" # tier2 源文件全文桶(U2 落库前先 mb 建桶)
kb_bucket: "cheap-kb" # 便宜档知识件外置知识包桶(W-CFG-KB;与 tier2-src 隔离、另立)
secure: false # 内网 http(非 https)
# ── 游戏内容制品:专用最小权限写入身份(不复用上面的 MinIO root──
# 只允许 Put/Get 和 List 两个桶的 tenants/ 前缀,并允许读取桶区域;无 ListAll/Delete/建桶/策略管理权。
# 上传器只读本分区,缺失即拒绝联网。凭据权威副本见 docs/内网凭据与端点.md。
game_artifact_minio:
endpoint: "100.64.0.8:9000"
access_key: "game-artifact-writer"
secret_key: "4aa0a3f0036aac13cded3942c056df2c270b99cb7962624b"
artifact_bucket: "game-artifacts"
evidence_bucket: "game-evidence"
secure: false # 当前 Tailscale 内网;公网/生产必须切 TLS

View File

@ -12,6 +12,7 @@ agentscope[service,storage]==2.0.3
openai>=2.41.1 # 便宜档deepseek-v4 等)走 OpenAI 兼容端点new-api
json-repair # 脏 JSON 兜底(承袭裸路)
pyyaml # 模型路由 models.yaml + infra.yaml 基建配置
jsonschema>=4.23,<5 # GameArtifactManifest 与 GamePackage Draft 2020-12 契约校验
# U2 第二装载落库BackendStore仅 TIER2_STORE=backend 启用worker/store.py 内惰性 import
# 6c6g 不装这俩也能 py_compile / import store.py真跑/部署在 mini-desktop 时装)。

View File

@ -0,0 +1,144 @@
"""游戏包对象存储同步 CLI。
本地合成夹具示例只构建 manifest绝不上传
PYTHONPATH=tier2/gen-worker python3 tier2/gen-worker/scripts/game_artifact_sync.py \
--root /tmp/synthetic-game-artifact-fixture \
--tenant-id fixture --game-id 9000001 --version-id 9000002 --package-type phaser \
--runtime-manifest game-package.json --source-provider tier2_source_project \
--source-game-id synthetic-fixture --source-revision-id fixture-r1 \
--source-manifest-ref tier2-source-project:synthetic-fixture:fixture-r1 --source-hash <sha256>
默认只构建本地 manifest波次 1 上传还必须显式传 --external-single-writer表示调用方已在本工具外
持有该 tenant/game/version 的单写锁上传结果只到 verified_pending不代表数据库 committed
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from worker.game_artifact_store import (
ArtifactError,
build_manifest,
download_manifest,
fetch_manifest,
upload_manifest,
)
def _config(bucket: str) -> dict:
"""读取专用 artifact-writer 配置;禁止回落复用现有 MinIO root 凭据。"""
try:
from service import infra_config
config = {
"endpoint": os.getenv("GAME_ARTIFACT_ENDPOINT", infra_config.get("game_artifact_minio", "endpoint", "")),
"access_key": os.getenv("GAME_ARTIFACT_ACCESS_KEY", infra_config.get("game_artifact_minio", "access_key", "")),
"secret_key": os.getenv("GAME_ARTIFACT_SECRET_KEY", infra_config.get("game_artifact_minio", "secret_key", "")),
"secure": os.getenv("GAME_ARTIFACT_SECURE", str(infra_config.get("game_artifact_minio", "secure", True))).lower() in {"1", "true", "yes"},
"artifact_bucket": os.getenv("GAME_ARTIFACT_BUCKET", infra_config.get("game_artifact_minio", "artifact_bucket", bucket)),
"evidence_bucket": os.getenv("GAME_EVIDENCE_BUCKET", infra_config.get("game_artifact_minio", "evidence_bucket", "game-evidence")),
}
if not config["endpoint"] or not config["access_key"] or not config["secret_key"]:
raise ArtifactError("专用 game_artifact_minio 配置未就位,拒绝复用现有 MinIO root 凭据")
return config
except Exception as exc: # noqa: BLE001 - CLI 在缺 tier2 配置时给出稳定错误
raise ArtifactError(f"读取 MinIO 配置失败:{exc}") from exc
def main() -> int:
parser = argparse.ArgumentParser(description="构建、上传或下载不可变游戏内容版本")
parser.add_argument("--root", type=Path, help="具体游戏目录(只作为本次上传输入)")
parser.add_argument("--tenant-id")
parser.add_argument("--game-id")
parser.add_argument("--version-id")
parser.add_argument("--package-type", choices=["phaser", "littlejs", "canvas"])
parser.add_argument("--runtime-manifest", help="游戏目录内现有 GamePackage/1 JSON 相对路径")
parser.add_argument("--source-provider", choices=["tier2_source_project", "game_source_archive"])
parser.add_argument("--source-game-id")
parser.add_argument("--source-revision-id")
parser.add_argument("--source-manifest-ref")
parser.add_argument("--source-hash")
parser.add_argument("--bucket", default="game-artifacts")
parser.add_argument("--manifest-out", type=Path)
parser.add_argument("--upload", action="store_true", help="显式访问 MinIO 并上传")
parser.add_argument(
"--external-single-writer", action="store_true",
help="确认调用方已持有外部单写锁;波次 1 不提供对象存储条件写",
)
parser.add_argument("--download-key", help="受信 artifact-manifest 对象 key不接受任意 URL")
parser.add_argument("--expected-manifest-hash", help="数据库登记的 artifact manifest SHA-256")
parser.add_argument("--target", type=Path, help="下载物化目录")
args = parser.parse_args()
try:
if args.download_key:
if args.upload or args.external_single_writer or not all((args.target, args.expected_manifest_hash,
args.tenant_id, args.game_id, args.version_id)):
raise ArtifactError("下载模式必须提供 --target、期望 hash 和 tenant/game/version且不能带上传参数")
manifest = fetch_manifest(
_config(args.bucket), key=args.download_key,
expected_manifest_hash=args.expected_manifest_hash,
expected_tenant_id=args.tenant_id,
expected_game_id=args.game_id,
expected_version_id=args.version_id,
)
result = download_manifest(
manifest, _config(args.bucket), args.target,
expected_manifest_hash=args.expected_manifest_hash,
)
print(json.dumps({
"gameId": manifest["gameId"],
"versionId": manifest["versionId"],
"manifestHash": manifest["manifestHash"],
"target": str(result),
}, ensure_ascii=False))
return 0
required = (
args.root, args.tenant_id, args.game_id, args.version_id, args.package_type,
args.runtime_manifest, args.source_provider, args.source_game_id,
args.source_revision_id, args.source_manifest_ref, args.source_hash,
)
if not all(required):
raise ArtifactError("构建/上传模式缺少生产版本、GamePackage 或 sourceRevision 必填参数")
manifest = build_manifest(args.root, args.tenant_id, args.game_id, args.version_id,
package_type=args.package_type,
runtime_manifest_path=args.runtime_manifest,
source_revision={
"provider": args.source_provider,
"gameId": args.source_game_id,
"revisionId": args.source_revision_id,
"manifestRef": args.source_manifest_ref,
"sourceHash": args.source_hash,
})
payload = json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
if args.manifest_out:
args.manifest_out.parent.mkdir(parents=True, exist_ok=True)
args.manifest_out.write_text(payload, encoding="utf-8")
print(json.dumps({
"tenantId": manifest["tenantId"],
"gameId": manifest["gameId"],
"versionId": manifest["versionId"],
"objects": manifest["totals"]["objects"],
"bytes": manifest["totals"]["bytes"],
"sourceHash": manifest["sourceRevision"]["sourceHash"],
"assetHash": manifest["assetHash"],
"bundleHash": manifest["bundleHash"],
"manifestHash": manifest["manifestHash"],
"upload": args.upload,
}, ensure_ascii=False))
if args.upload:
if not args.external_single_writer:
raise ArtifactError("波次 1 上传必须显式确认 --external-single-writer")
result = upload_manifest(
args.root, manifest, _config(args.bucket), external_single_writer=True,
)
print(json.dumps(result, ensure_ascii=False))
return 0
except ArtifactError as exc:
print(f"GAME-ARTIFACT-ERROR:{exc}")
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,133 @@
"""引擎无关游戏源归档同步 CLI。
默认只构建本地 GameSourceArchive/1 清单波次 1 上传必须显式传
``--external-single-writer``并且只返回 ``verified_pending``不能把对象
marker 当成数据库 committed 状态
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from worker.game_source_archive_store import (
ArtifactError,
build_source_archive,
download_source_archive,
fetch_source_archive,
upload_source_archive,
)
def _config() -> dict:
"""读取专用 game_artifact_minio 配置,禁止回落到 MinIO root。"""
try:
from service import infra_config
section = "game_artifact_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()
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", "")),
"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 凭据")
if not config["source_bucket"]:
raise ArtifactError("game-sources 尚未预建或加入专用身份策略,拒绝联网上传")
if config["source_bucket"] in {config["artifact_bucket"], config["evidence_bucket"]}:
raise ArtifactError("source_bucket 不能与 artifact/evidence 桶复用")
return config
except Exception as exc: # noqa: BLE001 - CLI 给出稳定错误
raise ArtifactError(f"读取 MinIO 配置失败:{exc}") from exc
def main() -> int:
parser = argparse.ArgumentParser(description="构建、上传或下载引擎无关游戏源归档")
parser.add_argument("--root", type=Path, help="具体游戏源目录(只作为本次上传输入)")
parser.add_argument("--tenant-id")
parser.add_argument("--game-id")
parser.add_argument("--revision-id")
parser.add_argument("--engine")
parser.add_argument("--runtime-manifest", default="game-package.json",
help="与 GameArtifactManifest 同一输入根中的 GamePackage/1 相对路径")
parser.add_argument("--exclude-path", action="append", default=[], help="不进入源归档的目录内相对路径")
parser.add_argument("--manifest-out", type=Path)
parser.add_argument("--upload", action="store_true", help="显式访问 MinIO 并上传")
parser.add_argument(
"--external-single-writer", action="store_true",
help="确认调用方已持有 tenant/game/revision 外部单写锁",
)
parser.add_argument("--download-key", help="受信 source-manifest 对象 key不接受任意 URL")
parser.add_argument("--expected-manifest-hash", help="数据库登记的 source manifest SHA-256")
parser.add_argument("--target", type=Path, help="下载物化目录")
args = parser.parse_args()
try:
if args.download_key:
if args.upload or args.external_single_writer or not all((
args.target, args.expected_manifest_hash, args.tenant_id,
args.game_id, args.revision_id,
)):
raise ArtifactError("下载模式必须提供 target、期望 hash 和 tenant/game/revision且不能带上传参数")
config = _config()
manifest = fetch_source_archive(
config, key=args.download_key,
expected_manifest_hash=args.expected_manifest_hash,
expected_tenant_id=args.tenant_id,
expected_game_id=args.game_id,
expected_revision_id=args.revision_id,
)
target = download_source_archive(
manifest, config, args.target, expected_manifest_hash=args.expected_manifest_hash,
)
print(json.dumps({
"gameId": manifest["gameId"],
"revisionId": manifest["revisionId"],
"sourceHash": manifest["sourceHash"],
"manifestHash": manifest["manifestHash"],
"target": str(target),
}, ensure_ascii=False))
return 0
if not all((args.root, args.tenant_id, args.game_id, args.revision_id, args.engine)):
raise ArtifactError("构建/上传模式缺少 root、tenant/game/revision 或 engine")
manifest = build_source_archive(
args.root, args.tenant_id, args.game_id, args.revision_id,
engine=args.engine, exclude_paths=args.exclude_path,
runtime_manifest_path=args.runtime_manifest,
)
payload = json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
if args.manifest_out:
args.manifest_out.parent.mkdir(parents=True, exist_ok=True)
args.manifest_out.write_text(payload, encoding="utf-8")
print(json.dumps({
"tenantId": manifest["tenantId"],
"gameId": manifest["gameId"],
"revisionId": manifest["revisionId"],
"objects": manifest["totals"]["objects"],
"bytes": manifest["totals"]["bytes"],
"sourceHash": manifest["sourceHash"],
"manifestHash": manifest["manifestHash"],
"upload": args.upload,
}, ensure_ascii=False))
if args.upload:
if not args.external_single_writer:
raise ArtifactError("波次 1 上传必须显式确认 --external-single-writer")
result = upload_source_archive(
args.root, manifest, _config(), external_single_writer=True,
)
print(json.dumps(result, ensure_ascii=False))
return 0
except ArtifactError as exc:
print(f"GAME-SOURCE-ARCHIVE-ERROR:{exc}")
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,47 @@
"""对象存储状态记录的 DB 契约与 Flyway 执行副本对账门。"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
CONTRACT = ROOT / "contracts/db-schemas/V34.0.0__create_game_artifact_storage.sql"
EXECUTION = ROOT / "game-cloud/huijing-server/src/main/resources/db/migration/V34.0.0__create_game_artifact_storage.sql"
def _norm_sql(text: str) -> str:
"""去注释、分号和空白,比较 SQL token 而不是排版。"""
lines = [line for line in text.splitlines() if not line.strip().startswith("--")]
return " ".join(" ".join(lines).replace(";", " ").split()).strip()
def test_storage_record_contract_has_flyway_execution_copy():
assert CONTRACT.exists(), f"缺 DB 契约:{CONTRACT}"
assert EXECUTION.exists(), f"缺 Flyway 执行副本:{EXECUTION}"
assert _norm_sql(CONTRACT.read_text(encoding="utf-8")) == _norm_sql(
EXECUTION.read_text(encoding="utf-8"),
)
def test_storage_record_is_immutable_per_tenant_game_version():
sql = CONTRACT.read_text(encoding="utf-8")
assert "CREATE TABLE `game_artifact_storage`" in sql
assert "`artifact_bucket`" in sql
assert "`artifact_manifest_key`" in sql
assert "`artifact_manifest_hash`" in sql
assert "`runtime_manifest_key`" in sql
assert "`runtime_manifest_hash`" in sql
assert "`source_provider`" in sql
assert "`source_manifest_key`" in sql
assert "`source_manifest_hash`" in sql
assert "`source_hash`" in sql
assert "`status`" in sql
assert "pending" in sql and "committed" in sql and "abandoned" in sql
assert "UNIQUE KEY `uk_tenant_game_version` (`tenant_id`, `game_id`, `version_id`)" in sql
assert "KEY `idx_status_created` (`status`, `create_time`)" in sql
def test_storage_record_cas_columns_are_not_runtime_release_state():
"""对象提交状态必须独立于 game_runtime_package.status。"""
sql = CONTRACT.read_text(encoding="utf-8")
assert "game_runtime_package" not in sql
assert "release" not in sql.lower()

View File

@ -0,0 +1,213 @@
"""game_artifact_storage writer 的条件创建/CAS 行为测试。"""
from datetime import datetime, timezone
from pathlib import Path
import pytest
from test_game_artifact_store import _make_game
from worker import game_artifact_store as artifact_store
from worker import game_source_archive_store as source_store
from worker import game_artifact_storage_store as store
def _record(*, artifact_hash: str = "a" * 64, source_hash: str = "b" * 64) -> dict:
"""构造已通过上游 manifest 校验的存储状态记录。"""
return {
"tenant_id": 1,
"game_id": 1024,
"version_id": 2048,
"artifact_bucket": "game-artifacts",
"artifact_manifest_key": "tenants/1/games/1024/versions/2048/artifact-manifest.json",
"artifact_manifest_hash": artifact_hash,
"artifact_manifest_bytes": 512,
"runtime_manifest_key": "tenants/1/games/1024/versions/2048/manifest.json",
"runtime_manifest_hash": "6" * 64,
"source_bucket": "game-sources",
"source_provider": "game_source_archive",
"source_game_id": "1024",
"source_revision_id": "shanhai-r1",
"source_manifest_key": "tenants/1/games/1024/source-revisions/shanhai-r1/source-manifest.json",
"source_manifest_hash": "c" * 64,
"source_hash": source_hash,
"package_type": "littlejs",
"object_count": 3,
"total_bytes": 4096,
"asset_hash": "d" * 64,
"bundle_hash": "e" * 64,
"status": "pending",
"failure_reason": "",
"creator": "test",
"updater": "test",
}
class _Duplicate(Exception):
errno = 1062
class _Cursor:
def __init__(self, rows: list[dict]):
self.rows = rows
self.last = None
self.rowcount = 0
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, sql: str, params=()):
upper = " ".join(sql.split()).upper()
if upper.startswith("INSERT INTO"):
(
tenant_id, game_id, version_id, artifact_bucket, artifact_key, artifact_hash,
artifact_bytes, runtime_key, runtime_hash, source_bucket, source_provider, source_game_id, source_revision_id,
source_key, source_manifest_hash, source_hash, package_type, object_count,
total_bytes, asset_hash, bundle_hash, status, failure_reason, creator, updater,
) = params
if any(
row["tenant_id"] == tenant_id and row["game_id"] == game_id and row["version_id"] == version_id
for row in self.rows
):
raise _Duplicate("duplicate version")
row = _record(artifact_hash=artifact_hash, source_hash=source_hash)
row.update({
"tenant_id": tenant_id, "game_id": game_id, "version_id": version_id,
"artifact_bucket": artifact_bucket, "artifact_manifest_key": artifact_key,
"artifact_manifest_bytes": artifact_bytes, "source_bucket": source_bucket,
"runtime_manifest_key": runtime_key, "runtime_manifest_hash": runtime_hash,
"source_provider": source_provider, "source_game_id": source_game_id,
"source_revision_id": source_revision_id, "source_manifest_key": source_key,
"source_manifest_hash": source_manifest_hash, "package_type": package_type,
"object_count": object_count, "total_bytes": total_bytes, "asset_hash": asset_hash,
"bundle_hash": bundle_hash, "status": status, "failure_reason": failure_reason,
"creator": creator, "updater": updater, "id": len(self.rows) + 1,
})
self.rows.append(row)
self.rowcount = 1
return self.rowcount
if upper.startswith("SELECT"):
tenant_id, game_id, version_id = params[:3]
self.last = next((
dict(row) for row in self.rows
if row["tenant_id"] == tenant_id and row["game_id"] == game_id and row["version_id"] == version_id
), None)
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
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["status"] = "committed"
row["committed_at"] = committed_at
row["updater"] = updater
self.rowcount = 1
return self.rowcount
raise AssertionError(f"未覆盖 SQL:{sql}")
def fetchone(self):
return self.last
class _Connection:
def __init__(self):
self.rows: list[dict] = []
self.commits = 0
self.rollbacks = 0
def cursor(self):
return _Cursor(self.rows)
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
def close(self):
return None
def test_create_pending_is_idempotent_and_immutable():
connection = _Connection()
writer = store.MySqlArtifactStorageStore(lambda: connection)
record = _record()
first = writer.create_pending(record)
second = writer.create_pending(record)
assert first["status"] == "pending"
assert first["idempotent"] is False
assert second["idempotent"] is True
assert second["id"] == first["id"]
assert len(connection.rows) == 1
changed = _record(artifact_hash="f" * 64)
with pytest.raises(store.StorageRecordError, match="不可覆盖"):
writer.create_pending(changed)
assert len(connection.rows) == 1
def test_commit_is_hash_conditioned_and_repeat_is_idempotent():
connection = _Connection()
writer = store.MySqlArtifactStorageStore(lambda: connection)
record = _record()
writer.create_pending(record)
committed_at = datetime(2026, 7, 29, tzinfo=timezone.utc)
first = writer.commit_pending(record, committed_at=committed_at)
second = writer.commit_pending(record, committed_at=committed_at)
assert first["status"] == "committed"
assert first["idempotent"] is False
assert second["status"] == "committed"
assert second["idempotent"] is True
assert connection.rows[0]["committed_at"] == committed_at
wrong = _record(source_hash="9" * 64)
with pytest.raises(store.StorageRecordError, match="CAS"):
writer.commit_pending(wrong, committed_at=committed_at)
def test_build_storage_record_binds_real_source_and_artifact_manifests(tmp_path: Path):
_make_game(tmp_path)
source_manifest = source_store.build_source_archive(
tmp_path, "1", "1024", "shanhai-r1", engine="custom-canvas-littlejs",
)
artifact_manifest = artifact_store.build_manifest(
tmp_path, "1", "1024", "2048", package_type="littlejs",
runtime_manifest_path="game-package.json",
source_revision={
"provider": "game_source_archive",
"gameId": "1024",
"revisionId": "shanhai-r1",
"manifestRef": "game-source-archive:1024:shanhai-r1",
"sourceHash": source_manifest["sourceHash"],
},
)
record = store.build_storage_record(
artifact_manifest, artifact_bucket="game-artifacts",
source_bucket="game-sources", source_archive=source_manifest,
)
assert record["artifact_manifest_hash"] == artifact_manifest["manifestHash"]
assert record["source_manifest_hash"] == source_manifest["manifestHash"]
assert record["status"] == "pending"
def test_build_storage_record_requires_generic_source_manifest():
record = _record()
record["source_bucket"] = ""
record["source_manifest_key"] = ""
record["source_manifest_hash"] = ""
with pytest.raises(store.StorageRecordError, match="source_bucket"):
store._validate_record(record)

View File

@ -0,0 +1,477 @@
"""游戏内容对象存储契约的离线测试。"""
import hashlib
import json
import subprocess
import sys
from io import BytesIO
from pathlib import Path
import pytest
from jsonschema import Draft202012Validator
from worker import game_artifact_store as store
from worker import game_source_archive_store as source_store
TENANT_ID = "1"
GAME_ID = "1024"
VERSION_ID = "2048"
def _make_game(root: Path, *, package_game_id: str = GAME_ID, package_version_id: str = VERSION_ID) -> None:
"""创建含完整 GamePackage、源工程、素材、runtime 和证据的最小游戏包。"""
(root / "src").mkdir(parents=True)
(root / "assets").mkdir(parents=True)
(root / "dist").mkdir(parents=True)
(root / "evidence").mkdir(parents=True)
(root / "src" / "main.js").write_text("export const GAME = 1;\n", encoding="utf-8")
(root / "assets" / "hero.webp").write_bytes(b"asset")
(root / "dist" / "bundle.iife.js").write_bytes(b"bundle")
(root / "dist" / "scan.json").write_text("{}", encoding="utf-8")
(root / "evidence" / "qa.md").write_text("verified", encoding="utf-8")
game_package = {
"schemaVersion": "1.0",
"gameId": package_game_id,
"versionId": package_version_id,
"templateId": "runner",
"gameConfig": {},
"assets": [],
"manifest": {
"runtimeVersion": "1.0.0",
"entry": "index.html",
"preloadPolicy": "eager",
"bundleSize": len(b"bundle"),
"checksum": "0" * 64,
},
"meta": {
"title": "契约测试游戏",
"summary": "只用于平台契约测试",
"cover": "https://assets.invalid/cover.webp",
"ageRating": "all",
},
}
(root / "game-package.json").write_text(
json.dumps(game_package, ensure_ascii=False, separators=(",", ":")), encoding="utf-8",
)
(root / "src" / "__pycache__").mkdir()
(root / "src" / "__pycache__" / "ignored.pyc").write_bytes(b"cache")
def _source_revision(root: Path, *, source_hash: str | None = None) -> dict:
"""构造指向现有 Tier2 SourceProjectStore 的不可变源修订身份。"""
if source_hash is None:
digest = hashlib.sha256()
digest.update(b"src/main.js\0")
digest.update("export const GAME = 1;\n\0".encode())
source_hash = digest.hexdigest()
return {
"provider": "tier2_source_project",
"gameId": "night-market",
"revisionId": "m4-r1",
"manifestRef": "tier2-source-project:night-market:m4-r1",
"sourceHash": source_hash,
}
def _build(root: Path) -> dict:
return store.build_manifest(
root, TENANT_ID, GAME_ID, VERSION_ID,
package_type="phaser",
runtime_manifest_path="game-package.json",
source_revision=_source_revision(root),
)
def test_build_and_verify_manifest(tmp_path: Path):
_make_game(tmp_path)
manifest = _build(tmp_path)
store.verify_manifest(manifest)
assert manifest["totals"]["objects"] == 4
assert manifest["totals"]["assetBytes"] == len(b"asset")
assert manifest["bundleHash"] == store._hash_bytes(b"bundle")
assert manifest["runtimeManifest"]["key"].endswith("/manifest.json")
assert manifest["sourceRevision"]["revisionId"] == "m4-r1"
assert not any(item["category"] == "source" for item in manifest["objects"])
assert not any("ignored.pyc" in item["path"] for item in manifest["objects"])
schema_path = Path(__file__).resolve().parents[3] / "contracts/game-artifact-manifest.schema.json"
schema = json.loads(schema_path.read_text(encoding="utf-8"))
errors = sorted(Draft202012Validator(schema).iter_errors(manifest), key=lambda item: list(item.path))
assert not errors, [error.message for error in errors]
assert _build(tmp_path)["manifestHash"] == manifest["manifestHash"]
def test_manifest_hash_and_schema_detect_mutation(tmp_path: Path):
_make_game(tmp_path)
manifest = _build(tmp_path)
manifest["objects"][0]["bytes"] += 1
with pytest.raises(store.ArtifactError, match="manifestHash"):
store.verify_manifest(manifest)
manifest = _build(tmp_path)
manifest["status"] = "published"
unsigned = dict(manifest)
unsigned.pop("manifestHash")
manifest["manifestHash"] = store._hash_bytes(store._MANIFEST_HASH_DOMAIN + store._canonical_json(unsigned))
with pytest.raises(store.ArtifactError, match="game-artifact-manifest.schema.json"):
store.verify_manifest(manifest)
def test_rejects_symlink_and_source_hash_drift(tmp_path: Path):
_make_game(tmp_path)
outside = tmp_path.parent / "outside-game-secret.txt"
outside.write_text("secret", encoding="utf-8")
(tmp_path / "src" / "linked.txt").symlink_to(outside)
with pytest.raises(store.ArtifactError, match="符号链接"):
_build(tmp_path)
(tmp_path / "src" / "linked.txt").unlink()
with pytest.raises(store.ArtifactError, match="源工程 hash"):
store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID, package_type="phaser",
runtime_manifest_path="game-package.json",
source_revision=_source_revision(tmp_path, source_hash="0" * 64),
)
def test_game_package_contract_and_identity_are_enforced(tmp_path: Path):
_make_game(tmp_path, package_version_id="9999")
with pytest.raises(store.ArtifactError, match="对象版本不匹配"):
_build(tmp_path)
with pytest.raises(store.ArtifactError, match="数据库正整数"):
store.build_manifest(
tmp_path, TENANT_ID, "night-market", "m4-r1", package_type="phaser",
runtime_manifest_path="game-package.json", source_revision=_source_revision(tmp_path),
)
class _Response:
"""模拟 MinIO HTTPResponse覆盖连接释放协议。"""
def __init__(self, data: bytes):
self._data = data
self._offset = 0
self.read_sizes: list[int] = []
def read(self, size: int = -1) -> bytes:
self.read_sizes.append(size)
if size < 0:
size = len(self._data) - self._offset
chunk = self._data[self._offset:self._offset + size]
self._offset += len(chunk)
return chunk
def close(self) -> None:
return None
def release_conn(self) -> None:
return None
class _NoSuchKey(Exception):
"""模拟 MinIO S3Error 的稳定 code。"""
code = "NoSuchKey"
class _NoSuchBucket(Exception):
"""模拟桶缺失,必须与对象缺失采用不同失败语义。"""
code = "NoSuchBucket"
class _FakeMinio:
"""内存对象存储,用于验证上传、幂等、可信下载和原子物化。"""
def __init__(self):
self.objects: dict[tuple[str, str], bytes] = {}
self.bucket_exists_calls = 0
self.make_bucket_calls = 0
self.put_calls: list[tuple[str, str]] = []
self.last_response: _Response | None = None
def bucket_exists(self, _bucket: str) -> bool:
self.bucket_exists_calls += 1
raise AssertionError("上传器禁止探测/列出桶")
def make_bucket(self, _bucket: str) -> None:
self.make_bucket_calls += 1
def put_object(self, bucket: str, key: str, stream: BytesIO, *, length: int, content_type: str):
data = stream.read()
assert len(data) == length
assert content_type
self.put_calls.append((bucket, key))
self.objects[(bucket, key)] = data
def get_object(self, bucket: str, key: str) -> _Response:
identity = (bucket, key)
if identity not in self.objects:
raise _NoSuchKey(key)
self.last_response = _Response(self.objects[identity])
return self.last_response
def _trusted_fetch(store_config: dict, key: str, manifest: dict) -> dict:
return store.fetch_manifest(
store_config, key=key,
expected_manifest_hash=manifest["manifestHash"],
expected_tenant_id=TENANT_ID,
expected_game_id=GAME_ID,
expected_version_id=VERSION_ID,
)
def test_upload_download_and_idempotency(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
game = tmp_path / "game"
_make_game(game)
manifest = _build(game)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"}
first = store.upload_manifest(game, manifest, config, external_single_writer=True)
assert first["idempotent"] is False
assert first["status"] == "verified_pending"
assert first["committed"] is False
second = store.upload_manifest(game, manifest, config, external_single_writer=True)
assert second["idempotent"] is True
assert second["status"] == "verified_pending"
assert second["committed"] is False
prefix = f"tenants/{TENANT_ID}/games/{GAME_ID}/versions/{VERSION_ID}"
assert ("game-artifacts", f"{prefix}/assets/hero.webp") in client.objects
assert ("game-evidence", f"{prefix}/evidence/qa.md") in client.objects
assert not any("source/src/main.js" in key for _, key in client.objects)
remote = _trusted_fetch(config, first["artifactManifestKey"], manifest)
target = tmp_path / "materialized"
store.download_manifest(remote, config, target, expected_manifest_hash=manifest["manifestHash"])
assert not (target / "source").exists()
assert (target / "assets/hero.webp").read_bytes() == b"asset"
assert json.loads((target / "manifest.json").read_text(encoding="utf-8"))["schemaVersion"] == "1.0"
changed = dict(manifest)
changed["sourceRevision"] = dict(manifest["sourceRevision"])
changed["sourceRevision"]["revisionId"] = "m4-r2"
changed["sourceRevision"]["manifestRef"] = "tier2-source-project:night-market:m4-r2"
unsigned = dict(changed)
unsigned.pop("manifestHash")
changed["manifestHash"] = store._hash_bytes(store._MANIFEST_HASH_DOMAIN + store._canonical_json(unsigned))
puts_before = list(client.put_calls)
marker_before = client.objects[("game-artifacts", first["artifactManifestKey"])]
with pytest.raises(store.ArtifactError, match="拒绝覆盖"):
store.upload_manifest(game, changed, config, external_single_writer=True)
assert client.put_calls == puts_before
assert client.objects[("game-artifacts", first["artifactManifestKey"])] == marker_before
def test_partial_objects_require_external_single_writer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""波次 1 可覆盖无 marker 的残留 partial但必须由调用方持有外部单写锁。"""
_make_game(tmp_path)
manifest = _build(tmp_path)
client = _FakeMinio()
asset = next(item for item in manifest["objects"] if item["category"] == "asset")
client.objects[("game-artifacts", asset["key"])] = b"stale-partial"
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"}
with pytest.raises(store.ArtifactError, match="外部单写"):
store.upload_manifest(tmp_path, manifest, config)
assert client.objects[("game-artifacts", asset["key"])] == b"stale-partial"
receipt = store.upload_manifest(tmp_path, manifest, config, external_single_writer=True)
assert receipt["status"] == "verified_pending"
assert receipt["committed"] is False
assert client.objects[("game-artifacts", asset["key"])] == b"asset"
def test_artifact_upload_preflights_runtime_manifest_before_put(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_game(tmp_path)
manifest = _build(tmp_path)
(tmp_path / "game-package.json").write_text("drift", encoding="utf-8")
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
with pytest.raises(store.ArtifactError, match="GamePackage manifest 本地 hash 漂移"):
store.upload_manifest(tmp_path, manifest, {
"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence",
}, external_single_writer=True)
assert client.put_calls == []
def test_trusted_anchor_and_atomic_materialization(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
game = tmp_path / "game"
_make_game(game)
manifest = _build(game)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"}
receipt = store.upload_manifest(game, manifest, config, external_single_writer=True)
with pytest.raises(store.ArtifactError, match="数据库期望 hash"):
store.fetch_manifest(
config, key=receipt["artifactManifestKey"], expected_manifest_hash="0" * 64,
expected_tenant_id=TENANT_ID, expected_game_id=GAME_ID, expected_version_id=VERSION_ID,
)
remote = _trusted_fetch(config, receipt["artifactManifestKey"], manifest)
asset = next(item for item in manifest["objects"] if item["category"] == "asset")
client.objects[("game-artifacts", asset["key"])] = b"wrong"
target = tmp_path / "materialized"
with pytest.raises(store.ArtifactError, match="下载对象 hash"):
store.download_manifest(remote, config, target, expected_manifest_hash=manifest["manifestHash"])
assert not target.exists()
assert not list(tmp_path.glob(".materialized.partial-*"))
def test_fetch_rejects_untrusted_key(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(store, "_minio_client", lambda _config: _FakeMinio())
with pytest.raises(store.ArtifactError, match="key 非法"):
store.fetch_manifest(
{}, key="https://attacker.invalid/artifact-manifest.json",
expected_manifest_hash="0" * 64, expected_tenant_id=TENANT_ID,
expected_game_id=GAME_ID, expected_version_id=VERSION_ID,
)
def test_upload_never_probes_or_creates_bucket(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_game(tmp_path)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
store.upload_manifest(tmp_path, _build(tmp_path), {}, external_single_writer=True)
assert client.bucket_exists_calls == 0
assert client.make_bucket_calls == 0
def test_missing_runtime_manifest_is_rejected(tmp_path: Path):
_make_game(tmp_path)
with pytest.raises(store.ArtifactError, match="GamePackage manifest 不存在"):
store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID, package_type="phaser",
runtime_manifest_path="missing.json", source_revision=_source_revision(tmp_path),
)
def test_cli_build_reports_source_revision_hash(tmp_path: Path):
"""CLI 构建结果必须从 sourceRevision 输出源 hash不能读取不存在的顶层字段。"""
_make_game(tmp_path)
source_revision = _source_revision(tmp_path)
script = Path(__file__).resolve().parents[1] / "scripts/game_artifact_sync.py"
result = subprocess.run(
[
sys.executable, str(script),
"--root", str(tmp_path),
"--tenant-id", TENANT_ID,
"--game-id", GAME_ID,
"--version-id", VERSION_ID,
"--package-type", "phaser",
"--runtime-manifest", "game-package.json",
"--source-provider", source_revision["provider"],
"--source-game-id", source_revision["gameId"],
"--source-revision-id", source_revision["revisionId"],
"--source-manifest-ref", source_revision["manifestRef"],
"--source-hash", source_revision["sourceHash"],
],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
receipt = json.loads(result.stdout)
assert receipt["sourceHash"] == source_revision["sourceHash"]
def test_only_tier2_source_provider_is_supported(tmp_path: Path):
"""Tier0/1 尚无统一 SourceProjectStore hash 口径,禁止提前声明 provider。"""
_make_game(tmp_path)
revision = _source_revision(tmp_path)
revision.update({
"provider": "studio_source_project",
"manifestRef": "studio-source-project:night-market:m4-r1",
})
with pytest.raises(store.ArtifactError, match="sourceRevision.provider"):
store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID,
package_type="phaser", runtime_manifest_path="game-package.json",
source_revision=revision,
)
def test_engine_agnostic_source_provider_binds_binary_safe_hash(tmp_path: Path):
"""LittleJS/Canvas 等游戏可绑定独立源归档,但不能复用 Tier2 文本 hash。"""
_make_game(tmp_path)
source_hash = source_store.source_hash_from_files([
("src/main.js", tmp_path / "src/main.js"),
])
manifest = store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID,
package_type="littlejs",
runtime_manifest_path="game-package.json",
source_revision={
"provider": "game_source_archive",
"gameId": GAME_ID,
"revisionId": "shanhai-r1",
"manifestRef": f"game-source-archive:{GAME_ID}:shanhai-r1",
"sourceHash": source_hash,
},
)
store.verify_manifest(manifest)
assert manifest["sourceRevision"]["provider"] == "game_source_archive"
assert manifest["sourceRevision"]["sourceHash"] == source_hash
def test_tier2_source_provider_cannot_bind_littlejs_package(tmp_path: Path):
_make_game(tmp_path)
with pytest.raises(store.ArtifactError, match="packageType=phaser"):
store.build_manifest(
tmp_path, TENANT_ID, GAME_ID, VERSION_ID,
package_type="littlejs", runtime_manifest_path="game-package.json",
source_revision=_source_revision(tmp_path),
)
def test_schema_rejects_tier2_source_with_littlejs_package(tmp_path: Path):
_make_game(tmp_path)
manifest = _build(tmp_path)
manifest["packageType"] = "littlejs"
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("phaser" in error.message for error in errors)
def test_remote_read_stops_at_expected_bytes_plus_one():
"""远端响应超限时只多读一个字节,不把任意大对象完整载入内存。"""
client = _FakeMinio()
key = "tenants/1/games/1024/versions/2048/runtime/bundle.js"
client.objects[("game-artifacts", key)] = b"0123456789"
with pytest.raises(store.ArtifactError, match="超过期望字节数"):
store._read_remote(client, "game-artifacts", key, expected_bytes=4)
assert client.last_response is not None
assert client.last_response._offset == 5
assert client.last_response.read_sizes == [5]
def test_fetch_manifest_rejects_oversized_remote_payload(monkeypatch: pytest.MonkeyPatch):
"""artifact manifest 也必须有固定读取上限,不能因伪造 marker 耗尽内存。"""
client = _FakeMinio()
key = "tenants/1/games/1024/versions/2048/artifact-manifest.json"
client.objects[("game-artifacts", key)] = b"x" * (8 * 1024 * 1024 + 1)
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
with pytest.raises(store.ArtifactError, match="超过期望字节数"):
store.fetch_manifest(
{}, key=key, expected_manifest_hash="0" * 64,
expected_tenant_id=TENANT_ID,
expected_game_id=GAME_ID,
expected_version_id=VERSION_ID,
)
def test_missing_bucket_is_not_treated_as_missing_marker():
"""桶缺失表示部署错误,不能降级成尚未上传。"""
class MissingBucketClient:
def get_object(self, _bucket: str, _key: str):
raise _NoSuchBucket("game-artifacts")
with pytest.raises(store.ArtifactError, match="对象存储桶不存在"):
store._existing_manifest(
MissingBucketClient(), "game-artifacts",
"tenants/1/games/1024/versions/2048/artifact-manifest.json",
)

View File

@ -0,0 +1,301 @@
"""引擎无关游戏源归档的契约与对象存储离线测试。"""
import json
import os
import subprocess
import sys
import unicodedata
from io import BytesIO
from pathlib import Path
import pytest
from jsonschema import Draft202012Validator
from worker import game_artifact_store as artifact_store
from worker import game_source_archive_store as store
TENANT_ID = "1"
GAME_ID = "1024"
REVISION_ID = "shanhai-r1"
def _make_source(root: Path) -> None:
"""创建包含源码、二进制源码资产、制品和证据的最小归档输入。"""
(root / "src").mkdir(parents=True)
(root / "assets").mkdir(parents=True)
(root / "dist").mkdir(parents=True)
(root / "evidence").mkdir(parents=True)
(root / "src" / "main.js").write_text("export const GAME = 1;\n", encoding="utf-8")
(root / "src" / "sprite.bin").write_bytes(bytes([0, 1, 2, 255]))
(root / "assets" / "hero.webp").write_bytes(b"runtime-asset")
(root / "dist" / "bundle.iife.js").write_bytes(b"runtime-bundle")
(root / "evidence" / "qa.md").write_text("evidence", encoding="utf-8")
(root / "src" / "__pycache__").mkdir()
(root / "src" / "__pycache__" / "ignored.pyc").write_bytes(b"cache")
def _build(root: Path) -> dict:
return store.build_source_archive(
root, TENANT_ID, GAME_ID, REVISION_ID, engine="custom-canvas-littlejs",
)
def test_build_and_verify_engine_agnostic_source_archive(tmp_path: Path):
_make_source(tmp_path)
manifest = _build(tmp_path)
store.verify_source_archive(manifest)
assert manifest["schemaVersion"] == "GameSourceArchive/1"
assert manifest["keyPrefix"] == f"tenants/{TENANT_ID}/games/{GAME_ID}/source-revisions/{REVISION_ID}"
assert {item["sourcePath"] for item in manifest["objects"]} == {
"src/main.js", "src/sprite.bin",
}
assert all(item["path"].startswith("source/") for item in manifest["objects"])
assert manifest["totals"]["objects"] == 2
assert manifest["totals"]["bytes"] == len("export const GAME = 1;\n".encode()) + 4
assert manifest["sourceHash"] == store.source_hash_from_files(
[(item["sourcePath"], tmp_path / item["sourcePath"]) for item in manifest["objects"]]
)
schema_path = Path(__file__).resolve().parents[3] / "contracts/game-source-archive.schema.json"
schema = json.loads(schema_path.read_text(encoding="utf-8"))
errors = sorted(Draft202012Validator(schema).iter_errors(manifest), key=lambda item: list(item.path))
assert not errors, [error.message for error in errors]
assert _build(tmp_path)["manifestHash"] == manifest["manifestHash"]
def test_source_hash_is_binary_safe_and_mutation_is_detected(tmp_path: Path):
_make_source(tmp_path)
manifest = _build(tmp_path)
(tmp_path / "src" / "sprite.bin").write_bytes(bytes([0, 1, 3, 255]))
with pytest.raises(store.ArtifactError, match="源归档 hash"):
store.build_source_archive(
tmp_path, TENANT_ID, GAME_ID, REVISION_ID,
engine="custom-canvas-littlejs", expected_source_hash=manifest["sourceHash"],
)
manifest = _build(tmp_path)
manifest["objects"][0]["bytes"] += 1
with pytest.raises(store.ArtifactError, match="manifestHash"):
store.verify_source_archive(manifest)
def test_source_hash_sorts_after_nfc_normalization(tmp_path: Path):
nfd_name = unicodedata.normalize("NFD", "á.js")
first = tmp_path / nfd_name
second = tmp_path / "z.js"
first.write_bytes(b"a")
second.write_bytes(b"z")
files = [(nfd_name, first), ("z.js", second)]
assert store.source_hash_from_files(files) == artifact_store._source_archive_hash(files)[2]
def test_default_runtime_manifest_is_not_source(tmp_path: Path):
_make_source(tmp_path)
(tmp_path / "game-package.json").write_text("runtime manifest", encoding="utf-8")
manifest = _build(tmp_path)
assert "game-package.json" not in {item["sourcePath"] for item in manifest["objects"]}
def test_empty_source_tree_is_rejected_consistently(tmp_path: Path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "empty.js").write_bytes(b"")
with pytest.raises(store.ArtifactError, match="bytes|全空"):
_build(tmp_path)
def test_cli_build_reports_source_archive_identity(tmp_path: Path):
_make_source(tmp_path)
script = Path(__file__).resolve().parents[1] / "scripts/game_source_archive_sync.py"
result = subprocess.run(
[
sys.executable, str(script),
"--root", str(tmp_path),
"--tenant-id", TENANT_ID,
"--game-id", GAME_ID,
"--revision-id", REVISION_ID,
"--engine", "custom-canvas-littlejs",
],
check=False,
capture_output=True,
text=True,
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1])},
)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["gameId"] == GAME_ID
assert output["objects"] == 2
assert output["sourceHash"] == _build(tmp_path)["sourceHash"]
def test_source_archive_rejects_unsafe_paths_and_symlinks(tmp_path: Path):
_make_source(tmp_path)
outside = tmp_path.parent / "outside-source-secret.bin"
outside.write_bytes(b"secret")
(tmp_path / "src" / "linked.bin").symlink_to(outside)
with pytest.raises(store.ArtifactError, match="符号链接"):
_build(tmp_path)
class _Response:
"""模拟 MinIO 响应,覆盖上传器使用的连接释放协议。"""
def __init__(self, data: bytes):
self._data = data
self._offset = 0
def read(self, size: int = -1) -> bytes:
if size < 0:
size = len(self._data) - self._offset
chunk = self._data[self._offset:self._offset + size]
self._offset += len(chunk)
return chunk
def close(self) -> None:
return None
def release_conn(self) -> None:
return None
class _NoSuchKey(Exception):
code = "NoSuchKey"
class _FakeMinio:
"""内存对象存储,验证源桶隔离、幂等和原子下载。"""
def __init__(self):
self.objects: dict[tuple[str, str], bytes] = {}
self.put_calls: list[tuple[str, str]] = []
def put_object(self, bucket: str, key: str, stream: BytesIO, *, length: int, content_type: str):
data = stream.read()
assert len(data) == length
assert content_type
self.put_calls.append((bucket, key))
self.objects[(bucket, key)] = data
def get_object(self, bucket: str, key: str) -> _Response:
if (bucket, key) not in self.objects:
raise _NoSuchKey(key)
return _Response(self.objects[(bucket, key)])
def test_upload_is_idempotent_and_download_is_atomic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"source_bucket": "game-sources"}
with pytest.raises(store.ArtifactError, match="外部单写"):
store.upload_source_archive(tmp_path, manifest, config)
first = store.upload_source_archive(tmp_path, manifest, config, external_single_writer=True)
second = store.upload_source_archive(tmp_path, manifest, config, external_single_writer=True)
assert first["idempotent"] is False
assert second["idempotent"] is True
assert first["status"] == second["status"] == "verified_pending"
assert first["committed"] is second["committed"] is False
assert all(bucket == "game-sources" for bucket, _ in client.put_calls)
assert ("game-sources", first["sourceManifestKey"]) in client.objects
remote = store.fetch_source_archive(
config, key=first["sourceManifestKey"],
expected_manifest_hash=manifest["manifestHash"],
expected_tenant_id=TENANT_ID, expected_game_id=GAME_ID,
expected_revision_id=REVISION_ID,
)
target = tmp_path / "materialized"
store.download_source_archive(remote, config, target, expected_manifest_hash=manifest["manifestHash"])
assert (target / "src" / "main.js").read_text(encoding="utf-8") == "export const GAME = 1;\n"
assert (target / "src" / "sprite.bin").read_bytes() == bytes([0, 1, 2, 255])
assert not (target / "assets" / "hero.webp").exists()
def test_source_upload_preflights_all_files_before_put(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
(tmp_path / "src" / "sprite.bin").write_bytes(b"drift")
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
with pytest.raises(store.ArtifactError, match="hash 漂移"):
store.upload_source_archive(
tmp_path, manifest, {"source_bucket": "game-sources"}, external_single_writer=True,
)
assert client.put_calls == []
def test_source_upload_requires_explicit_source_bucket(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
monkeypatch.setattr(store, "_minio_client", lambda _config: _FakeMinio())
with pytest.raises(store.ArtifactError, match="source_bucket"):
store.upload_source_archive(tmp_path, manifest, {}, external_single_writer=True)
def test_source_bucket_cannot_alias_artifact_bucket(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_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", "artifact_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)
class BrokenClient:
def get_object(self, _bucket, _key):
raise _NoSuchKey("missing marker")
def put_object(self, *_args, **_kwargs):
raise RuntimeError("network down")
monkeypatch.setattr(store, "_minio_client", lambda _config: BrokenClient())
with pytest.raises(store.ArtifactError, match="对象上传失败"):
store.upload_source_archive(
tmp_path, manifest, {"source_bucket": "game-sources"}, external_single_writer=True,
)
def test_source_archive_does_not_overwrite_different_marker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"source_bucket": "game-sources"}
store.upload_source_archive(tmp_path, manifest, config, external_single_writer=True)
changed = dict(manifest)
changed["engine"] = "littlejs"
unsigned = dict(changed)
unsigned.pop("manifestHash")
changed["manifestHash"] = artifact_store._hash_bytes(
store._MANIFEST_HASH_DOMAIN + store._canonical_json(unsigned),
)
puts_before = list(client.put_calls)
with pytest.raises(store.ArtifactError, match="拒绝覆盖"):
store.upload_source_archive(tmp_path, changed, config, external_single_writer=True)
assert client.put_calls == puts_before
def test_download_failure_leaves_no_partial_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
_make_source(tmp_path)
manifest = _build(tmp_path)
client = _FakeMinio()
monkeypatch.setattr(store, "_minio_client", lambda _config: client)
config = {"source_bucket": "game-sources"}
store.upload_source_archive(tmp_path, manifest, config, external_single_writer=True)
first = next(item for item in manifest["objects"] if item["sourcePath"] == "src/main.js")
client.objects[("game-sources", first["key"])] = b"wrong"
target = tmp_path / "materialized"
with pytest.raises(store.ArtifactError, match="下载对象 hash"):
store.download_source_archive(manifest, config, target, expected_manifest_hash=manifest["manifestHash"])
assert not target.exists()
assert not list(tmp_path.glob(".materialized.partial-*"))

View File

@ -0,0 +1,330 @@
"""game_artifact_storage 的数据库状态写入与条件提交。
对象存储上传器只得到 ``verified_pending``本模块把已验证的 manifest 身份
写入 game-cloud 业务库并以 ``tenant/game/version + hash`` 条件把 pending
推进到 committed它不执行对象上传也不接受调用方重新拼接 bucket/key
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Callable, Mapping
from .game_artifact_store import (
ArtifactError,
_ARTIFACT_MANIFEST_KEY_RE,
_HASH_RE,
_canonical_json,
_validate_db_id,
verify_manifest,
)
from .game_source_archive_store import verify_source_archive
_TABLE = "game_artifact_storage"
_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_SOURCE_MANIFEST_KEY_RE = re.compile(
r"^tenants/[A-Za-z0-9][A-Za-z0-9._-]*/games/[1-9][0-9]*/"
r"source-revisions/[A-Za-z0-9][A-Za-z0-9._-]*/source-manifest\.json$"
)
_IMMUTABLE_FIELDS = (
"artifact_bucket", "artifact_manifest_key", "artifact_manifest_hash", "artifact_manifest_bytes",
"runtime_manifest_key", "runtime_manifest_hash",
"source_bucket", "source_provider", "source_game_id", "source_revision_id",
"source_manifest_key", "source_manifest_hash", "source_hash", "package_type",
"object_count", "total_bytes", "asset_hash", "bundle_hash",
)
_SELECT_COLUMNS = ", ".join((
"id", "tenant_id", "game_id", "version_id", "artifact_bucket", "artifact_manifest_key",
"artifact_manifest_hash", "artifact_manifest_bytes", "source_bucket", "source_provider",
"runtime_manifest_key", "runtime_manifest_hash",
"source_game_id", "source_revision_id", "source_manifest_key", "source_manifest_hash",
"source_hash", "package_type", "object_count", "total_bytes", "asset_hash", "bundle_hash",
"status", "failure_reason", "committed_at", "creator", "create_time", "updater", "update_time",
))
class StorageRecordError(ArtifactError):
"""对象存储状态记录不满足数据库边界或 CAS 条件。"""
def _bucket(value: str, label: str) -> str:
"""桶名必须由受信配置提供,禁止通过路径片段注入对象 key。"""
if not isinstance(value, str) or not value or "/" in value or "\\" in value:
raise StorageRecordError(f"{label} 非法或未配置")
return value
def _validate_record(record: Mapping) -> None:
"""校验 writer 输入的不可变字段,避免绕过 manifest builder 写入伪身份。"""
for field in ("tenant_id", "game_id", "version_id"):
value = record.get(field)
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise StorageRecordError(f"{field} 必须是正整数")
_bucket(record.get("artifact_bucket", ""), "artifact_bucket")
artifact_key = str(record.get("artifact_manifest_key", ""))
if not _ARTIFACT_MANIFEST_KEY_RE.fullmatch(artifact_key):
raise StorageRecordError("artifact_manifest_key 非法")
expected_artifact_key = (
f"tenants/{record['tenant_id']}/games/{record['game_id']}"
f"/versions/{record['version_id']}/artifact-manifest.json"
)
if artifact_key != expected_artifact_key:
raise StorageRecordError("artifact_manifest_key 与 tenant/game/version 不一致")
if not _HASH_RE.fullmatch(str(record.get("artifact_manifest_hash", ""))):
raise StorageRecordError("artifact_manifest_hash 非法")
if not isinstance(record.get("artifact_manifest_bytes"), int) or record["artifact_manifest_bytes"] <= 0:
raise StorageRecordError("artifact_manifest_bytes 非法")
runtime_key = str(record.get("runtime_manifest_key", ""))
if runtime_key != f"tenants/{record['tenant_id']}/games/{record['game_id']}/versions/{record['version_id']}/manifest.json":
raise StorageRecordError("runtime_manifest_key 与 tenant/game/version 不一致")
if not _HASH_RE.fullmatch(str(record.get("runtime_manifest_hash", ""))):
raise StorageRecordError("runtime_manifest_hash 非法")
provider = record.get("source_provider")
if provider not in {"tier2_source_project", "game_source_archive"}:
raise StorageRecordError("source_provider 不受支持")
if provider == "game_source_archive":
_bucket(record.get("source_bucket", ""), "source_bucket")
if record.get("source_bucket") in {record.get("artifact_bucket"), ""}:
raise StorageRecordError("source_bucket 不能与 artifact_bucket 复用")
source_game_id = str(record.get("source_game_id", ""))
if not source_game_id.isdigit() or source_game_id.startswith("0") or int(source_game_id) <= 0:
raise StorageRecordError("game_source_archive 的 source_game_id 必须是正整数")
revision_id = str(record.get("source_revision_id", ""))
if not _ID_RE.fullmatch(revision_id):
raise StorageRecordError("source_revision_id 非法")
source_key = str(record.get("source_manifest_key", ""))
if not _SOURCE_MANIFEST_KEY_RE.fullmatch(source_key):
raise StorageRecordError("source_manifest_key 非法")
expected_source_key = (
f"tenants/{record['tenant_id']}/games/{source_game_id}"
f"/source-revisions/{revision_id}/source-manifest.json"
)
if source_key != expected_source_key:
raise StorageRecordError("source_manifest_key 与源修订身份不一致")
if not _HASH_RE.fullmatch(str(record.get("source_manifest_hash", ""))):
raise StorageRecordError("source_manifest_hash 非法")
else:
if not _ID_RE.fullmatch(str(record.get("source_game_id", ""))):
raise StorageRecordError("source_game_id 非法")
if not _ID_RE.fullmatch(str(record.get("source_revision_id", ""))):
raise StorageRecordError("source_revision_id 非法")
if any(record.get(field, "") for field in ("source_bucket", "source_manifest_key", "source_manifest_hash")):
raise StorageRecordError("Tier2 外部源修订不能伪造 game-sources 清单")
if not _HASH_RE.fullmatch(str(record.get("source_hash", ""))):
raise StorageRecordError("source_hash 非法")
if record.get("status") != "pending":
raise StorageRecordError("新对象存储记录必须从 pending 开始")
if record.get("package_type") not in {"phaser", "littlejs", "canvas"}:
raise StorageRecordError("package_type 不受支持")
if provider == "tier2_source_project" and record.get("package_type") != "phaser":
raise StorageRecordError("tier2_source_project 只允许 package_type=phaser")
for field in ("object_count", "total_bytes"):
if not isinstance(record.get(field), int) or record[field] < 0:
raise StorageRecordError(f"{field} 非法")
for field in ("asset_hash", "bundle_hash"):
if record.get(field) and not _HASH_RE.fullmatch(str(record[field])):
raise StorageRecordError(f"{field} 非法")
def build_storage_record(artifact_manifest: Mapping, *, artifact_bucket: str,
source_bucket: str = "", source_archive: Mapping | None = None,
creator: str = "", updater: str = "") -> dict:
"""从已验证的 artifact/source manifest 派生数据库记录,不接受手填 key/hash。"""
verify_manifest(artifact_manifest)
tenant_id = str(artifact_manifest["tenantId"])
_validate_db_id(tenant_id, "tenantId")
game_id = str(artifact_manifest["gameId"])
version_id = str(artifact_manifest["versionId"])
artifact_bucket = _bucket(artifact_bucket, "artifact_bucket")
revision = artifact_manifest["sourceRevision"]
provider = revision["provider"]
source_record = {
"source_bucket": "",
"source_provider": provider,
"source_game_id": str(revision["gameId"]),
"source_revision_id": str(revision["revisionId"]),
"source_manifest_key": "",
"source_manifest_hash": "",
}
if provider == "game_source_archive":
if source_archive is None:
raise StorageRecordError("game_source_archive 必须同时提供已验证 source archive manifest")
verify_source_archive(source_archive)
if (
source_archive["tenantId"] != tenant_id
or source_archive["gameId"] != revision["gameId"]
or source_archive["revisionId"] != revision["revisionId"]
or source_archive["sourceHash"] != revision["sourceHash"]
):
raise StorageRecordError("artifact/source manifest 身份或 sourceHash 不一致")
source_bucket = _bucket(source_bucket, "source_bucket")
if source_bucket == artifact_bucket:
raise StorageRecordError("source_bucket 不能与 artifact_bucket 复用")
source_record.update({
"source_bucket": source_bucket,
"source_manifest_key": f"{source_archive['keyPrefix']}/source-manifest.json",
"source_manifest_hash": source_archive["manifestHash"],
})
elif source_archive is not None or source_bucket:
raise StorageRecordError("Tier2 sourceRevision 不应绑定 game-sources 清单")
record = {
"tenant_id": int(tenant_id),
"game_id": int(game_id),
"version_id": int(version_id),
"artifact_bucket": artifact_bucket,
"artifact_manifest_key": f"{artifact_manifest['keyPrefix']}/artifact-manifest.json",
"artifact_manifest_hash": artifact_manifest["manifestHash"],
"artifact_manifest_bytes": len(_canonical_json(artifact_manifest)),
"runtime_manifest_key": artifact_manifest["runtimeManifest"]["key"],
"runtime_manifest_hash": artifact_manifest["runtimeManifest"]["sha256"],
**source_record,
"source_hash": revision["sourceHash"],
"package_type": artifact_manifest["packageType"],
"object_count": artifact_manifest["totals"]["objects"],
"total_bytes": artifact_manifest["totals"]["bytes"],
"asset_hash": artifact_manifest["assetHash"],
"bundle_hash": artifact_manifest["bundleHash"] or "",
"status": "pending",
"failure_reason": "",
"creator": str(creator),
"updater": str(updater),
}
_validate_record(record)
return record
def _is_duplicate_error(exc: Exception) -> bool:
"""识别 MySQL duplicate-key 错误,不吞其它数据库故障。"""
if getattr(exc, "errno", None) == 1062 or getattr(exc, "args", (None,))[0:1] == (1062,):
return True
return "duplicate" in str(exc).lower() or "1062" in str(exc)
class MySqlArtifactStorageStore:
"""使用调用方连接工厂写入 game_artifact_storage。"""
def __init__(self, connection_factory: Callable[[], object]):
self._connection_factory = connection_factory
def _select_identity(self, connection, record: Mapping) -> dict | None:
sql = (
f"SELECT {_SELECT_COLUMNS} FROM {_TABLE} "
"WHERE tenant_id=%s AND game_id=%s AND version_id=%s LIMIT 1"
)
with connection.cursor() as cursor:
cursor.execute(sql, (record["tenant_id"], record["game_id"], record["version_id"]))
row = cursor.fetchone()
return dict(row) if row else None
@staticmethod
def _same_immutable_fields(existing: Mapping, record: Mapping) -> bool:
return all(existing.get(field) == record.get(field) for field in _IMMUTABLE_FIELDS)
@staticmethod
def _receipt(row: Mapping, *, idempotent: bool) -> dict:
return {
"id": row.get("id"),
"tenantId": str(row["tenant_id"]),
"gameId": str(row["game_id"]),
"versionId": str(row["version_id"]),
"artifactManifestHash": row["artifact_manifest_hash"],
"sourceManifestHash": row.get("source_manifest_hash", ""),
"status": row["status"],
"idempotent": idempotent,
"committed": row["status"] == "committed",
}
def create_pending(self, record: Mapping) -> dict:
"""条件创建 pending唯一键冲突时仅允许完全相同记录幂等命中。"""
_validate_record(record)
connection = self._connection_factory()
sql = (
f"INSERT INTO {_TABLE} (tenant_id, game_id, version_id, artifact_bucket, artifact_manifest_key, "
"artifact_manifest_hash, artifact_manifest_bytes, runtime_manifest_key, runtime_manifest_hash, source_bucket, source_provider, source_game_id, "
"source_revision_id, source_manifest_key, source_manifest_hash, source_hash, package_type, object_count, "
"total_bytes, asset_hash, bundle_hash, status, failure_reason, creator, updater) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
)
params = tuple(record[field] for field in (
"tenant_id", "game_id", "version_id", "artifact_bucket", "artifact_manifest_key",
"artifact_manifest_hash", "artifact_manifest_bytes", "runtime_manifest_key", "runtime_manifest_hash",
"source_bucket", "source_provider",
"source_game_id", "source_revision_id", "source_manifest_key", "source_manifest_hash",
"source_hash", "package_type", "object_count", "total_bytes", "asset_hash", "bundle_hash",
"status", "failure_reason", "creator", "updater",
))
try:
with connection.cursor() as cursor:
cursor.execute(sql, params)
connection.commit()
existing = self._select_identity(connection, record)
if existing is None:
raise StorageRecordError("pending 创建成功但读取不到记录")
return self._receipt(existing, idempotent=False)
except Exception as exc: # noqa: BLE001 - 只把 duplicate 转为幂等路径
connection.rollback()
if not _is_duplicate_error(exc):
raise StorageRecordError(f"创建 pending 记录失败:{type(exc).__name__}:{exc}") from exc
existing = self._select_identity(connection, record)
if existing is None:
raise StorageRecordError("唯一键冲突后找不到对象存储记录") from exc
if not self._same_immutable_fields(existing, record):
raise StorageRecordError("同一租户游戏版本已有不同对象清单,不可覆盖") from exc
return self._receipt(existing, idempotent=True)
finally:
connection.close()
def commit_pending(self, record: Mapping, *, committed_at: datetime) -> dict:
"""以所有不可变 hash 为条件把 pending CAS 到 committed。"""
_validate_record(record)
if not isinstance(committed_at, datetime):
raise StorageRecordError("committed_at 必须由调用方显式提供")
connection = self._connection_factory()
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"
)
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", ""),
)
try:
with connection.cursor() as cursor:
cursor.execute(sql, params)
affected = int(getattr(cursor, "rowcount", 0))
if affected:
connection.commit()
row = self._select_identity(connection, record)
if row is None:
raise StorageRecordError("CAS committed 成功但读取不到记录")
return self._receipt(row, idempotent=False)
existing = self._select_identity(connection, record)
connection.rollback()
if existing and existing.get("status") == "committed" and self._same_immutable_fields(existing, record):
return self._receipt(existing, idempotent=True)
raise StorageRecordError("对象存储 CAS 未命中记录不存在、hash 不一致或已被提交")
except StorageRecordError:
raise
except Exception as exc: # noqa: BLE001 - DB 错误必须显式失败
connection.rollback()
raise StorageRecordError(f"提交对象存储记录失败:{type(exc).__name__}:{exc}") from exc
finally:
connection.close()
def get_committed(self, *, tenant_id: int, game_id: int, version_id: int) -> dict | None:
"""只读取 committed 记录pending/abandoned 对生产消费不可见。"""
for name, value in (("tenant_id", tenant_id), ("game_id", game_id), ("version_id", version_id)):
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise StorageRecordError(f"{name} 必须是正整数")
connection = self._connection_factory()
try:
row = self._select_identity(connection, {
"tenant_id": tenant_id, "game_id": game_id, "version_id": version_id,
})
if not row or row.get("status") != "committed":
return None
return self._receipt(row, idempotent=True)
finally:
connection.close()

View File

@ -0,0 +1,748 @@
"""游戏包对象存储工具。
本模块只处理平台侧的分类哈希manifest MinIO/S3 读写具体游戏目录只是一次性上传输入
不是生产消费来源实现使用标准库完成本地快照只有真正执行对象存储读写时才惰性导入 minio
让契约测试和离线 manifest 构建不依赖网络或第三方 SDK
"""
from __future__ import annotations
import hashlib
import json
import mimetypes
import os
import posixpath
import re
import shutil
import tempfile
import unicodedata
from io import BytesIO
from pathlib import Path
from typing import Iterable, Mapping
SCHEMA_VERSION = "GameArtifactManifest/1"
_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_DB_ID_RE = re.compile(r"^[1-9][0-9]*$")
_HASH_RE = re.compile(r"^[a-f0-9]{64}$")
_ARTIFACT_MANIFEST_KEY_RE = re.compile(
r"^tenants/[A-Za-z0-9][A-Za-z0-9._-]*/games/[1-9][0-9]*/"
r"versions/[1-9][0-9]*/artifact-manifest\.json$"
)
_CATEGORY_ORDER = ("asset", "runtime", "evidence")
_CATEGORY_DIR = {"asset": "assets", "runtime": "runtime", "evidence": "evidence"}
_CATEGORY_STORE = {"asset": "artifact", "runtime": "artifact", "evidence": "evidence"}
_TRANSIENT_PARTS = frozenset({
".git", ".idea", "node_modules", "__pycache__", ".pytest_cache", "target",
"coverage", ".DS_Store", "_store", "_cheap-sessions", "_tier2-gen",
})
_SECRET_NAMES = frozenset({
".env", ".env.local", ".env.production", "credentials.json", "service-account.json",
})
_MANIFEST_HASH_DOMAIN = b"GameArtifactManifest/1\0"
_TREE_HASH_DOMAIN = b"GameArtifactTreeHash/1\0"
_SOURCE_TREE_HASH_DOMAIN = b"GameSourceArchiveTreeHash/1\0"
_MAX_OBJECTS = 10_000
_MAX_PATH_BYTES = 1_024
_MAX_OBJECT_BYTES = 100 * 1024 * 1024
_MAX_RUNTIME_ASSET_BYTES = 10 * 1024 * 1024
_MAX_MANIFEST_BYTES = 8 * 1024 * 1024
_REMOTE_READ_CHUNK_BYTES = 1024 * 1024
class ArtifactError(ValueError):
"""对象包不满足安全或一致性约束时抛出的稳定异常。"""
def _validate_id(value: str, label: str) -> str:
"""校验 gameId/versionId避免对象 key 被注入路径片段。"""
if not isinstance(value, str) or not _ID_RE.fullmatch(value):
raise ArtifactError(f"{label} 非法:{value!r}")
return value
def _validate_db_id(value: str, label: str) -> str:
"""生产 game/version 身份必须来自数据库正整数主键,禁止 Tier2 slug 冒充。"""
if not isinstance(value, str) or not _DB_ID_RE.fullmatch(value):
raise ArtifactError(f"{label} 必须是数据库正整数 ID:{value!r}")
return value
def _safe_rel(path: str) -> str:
"""把本地相对路径规范化为 POSIX并拒绝绝对路径、父级跳转和空路径。"""
normalized = unicodedata.normalize("NFC", posixpath.normpath(path.replace(os.sep, "/")))
if not normalized or normalized in (".", "..") or normalized.startswith("../") or normalized.startswith("/"):
raise ArtifactError(f"对象相对路径非法:{path!r}")
if any(part in ("", ".", "..") for part in normalized.split("/")):
raise ArtifactError(f"对象相对路径含危险片段:{path!r}")
if len(normalized.encode("utf-8")) > _MAX_PATH_BYTES:
raise ArtifactError(f"对象相对路径超过 {_MAX_PATH_BYTES} 字节:{path!r}")
return normalized
def _canonical_json(value: Mapping) -> bytes:
"""使用无空格、排序键的 JSON 字节,保证跨语言 manifest hash 可复算。"""
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
def _hash_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _file_digest(path: Path) -> tuple[int, str]:
"""流式计算本地文件大小与 SHA-256避免把大文件整体读入内存。"""
digest = hashlib.sha256()
size = 0
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
size += len(chunk)
if size > _MAX_OBJECT_BYTES:
raise ArtifactError(f"单文件超过 {_MAX_OBJECT_BYTES} 字节:{path.name}")
digest.update(chunk)
return size, digest.hexdigest()
def _source_project_hash(files: Iterable[tuple[str, Path]]) -> tuple[int, int, str]:
"""按 SourceProjectStore 的 path\0content\0 口径复算源工程 contentHash。"""
rows = sorted(files, key=lambda item: item[0])
digest = hashlib.sha256()
total_bytes = 0
for rel, path in rows:
data = path.read_bytes()
if len(data) > _MAX_OBJECT_BYTES:
raise ArtifactError(f"源文件超过 {_MAX_OBJECT_BYTES} 字节:{rel}")
try:
content = data.decode("utf-8")
except UnicodeDecodeError as exc:
raise ArtifactError(f"源工程文件必须是 UTF-8 文本:{rel}") from exc
digest.update((rel + "\0").encode("utf-8"))
digest.update((content + "\0").encode("utf-8"))
total_bytes += len(data)
if not rows:
raise ArtifactError("游戏包缺少源工程文件")
return len(rows), total_bytes, digest.hexdigest()
def _source_archive_rows(files: Iterable[tuple[str, Path]]) -> list[dict]:
"""按二进制内容构造引擎无关源归档的稳定文件行。"""
rows: list[dict] = []
seen: set[str] = set()
normalized_files = [(_safe_rel(rel), path) for rel, path in files]
for normalized, path in sorted(normalized_files, key=lambda item: item[0]):
if normalized in seen:
raise ArtifactError(f"源归档存在重复路径:{normalized}")
seen.add(normalized)
size, sha256 = _file_digest(path)
rows.append({"path": normalized, "bytes": size, "sha256": sha256})
if not rows:
raise ArtifactError("游戏包缺少源工程文件")
if len(rows) > _MAX_OBJECTS:
raise ArtifactError(f"源归档对象数量超过 {_MAX_OBJECTS}")
return rows
def _source_archive_hash(files: Iterable[tuple[str, Path]]) -> tuple[int, int, str]:
"""计算 GameSourceArchive/1 的二进制安全源工程 hash。"""
rows = _source_archive_rows(files)
total_bytes = sum(item["bytes"] for item in rows)
digest = _hash_bytes(_SOURCE_TREE_HASH_DOMAIN + _canonical_json(rows))
return len(rows), total_bytes, digest
def _contract_schema(name: str) -> dict:
"""从仓内契约 SoT 读取 JSON Schema。"""
path = Path(__file__).resolve().parents[3] / "contracts" / name
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ArtifactError(f"读取契约失败:{name}:{exc}") from exc
def _validate_contract(instance: Mapping, schema_name: str) -> None:
"""执行 Draft 2020-12 契约校验,包含 additionalProperties 边界。"""
try:
from jsonschema import Draft202012Validator, FormatChecker
except Exception as exc: # pragma: no cover - 部署缺依赖时响亮失败
raise ArtifactError(f"缺少 jsonschema 依赖:{exc}") from exc
errors = sorted(
Draft202012Validator(
_contract_schema(schema_name), format_checker=FormatChecker(),
).iter_errors(instance),
key=lambda item: tuple(str(part) for part in item.absolute_path),
)
if errors:
first = errors[0]
location = "/".join(str(part) for part in first.absolute_path) or "#"
raise ArtifactError(f"{schema_name} 校验失败:{location}:{first.message}")
def _validate_game_package(data: bytes, game_id: str, version_id: str) -> dict:
"""验证运行时 manifest 是完整 GamePackage/1且身份与对象版本一致。"""
try:
game_package = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ArtifactError("GamePackage manifest 不是合法 UTF-8 JSON") from exc
if not isinstance(game_package, Mapping):
raise ArtifactError("GamePackage manifest 顶层必须是对象")
_validate_contract(game_package, "game-package.schema.json")
if str(game_package.get("gameId")) != game_id or str(game_package.get("versionId")) != version_id:
raise ArtifactError("GamePackage gameId/versionId 与对象版本不匹配")
return dict(game_package)
def _hash_objects(objects: Iterable[Mapping]) -> str:
"""对对象清单的稳定摘要,不读取对象内容之外的机器路径和时间。"""
rows = [
{"category": item["category"], "path": item["path"], "bytes": item["bytes"], "sha256": item["sha256"]}
for item in objects
]
rows.sort(key=lambda item: (item["category"], item["path"]))
return _hash_bytes(_TREE_HASH_DOMAIN + _canonical_json(rows))
def _bundle_object(objects: Iterable[Mapping]) -> Mapping | None:
"""按固定优先级挑选 runtime bundle避免排序变化导致身份漂移。"""
runtime_js = [item for item in objects if item.get("category") == "runtime" and str(item.get("path", "")).endswith(".js")]
if not runtime_js:
return None
runtime_js.sort(key=lambda item: (0 if str(item["path"]).endswith("bundle.iife.js") else 1,
0 if str(item["path"]).endswith("entry-bundle.js") else 1,
str(item["path"])))
return runtime_js[0]
def _classify(rel: str) -> tuple[str, str]:
"""按路径把游戏输入映射为 source/asset/runtime/evidence 和包内路径。"""
path = _safe_rel(rel)
parts = path.split("/")
name = parts[-1]
if parts[0] == "evidence":
return "evidence", path
if parts[0] == "assets":
return "asset", path
if parts[0] == "dist":
# dist 中只有可执行 bundle/meta 进入 runtime其余报告留作 evidence。
if name.endswith(".js") or name.endswith(".js.meta.json") or name.endswith(".html"):
return "runtime", f"runtime/{path[5:]}"
return "evidence", f"evidence/{path}"
if name in {"bundle.iife.js", "entry-bundle.js"} or name.endswith(".bundle.js"):
return "runtime", f"runtime/{path}"
if parts[0] in {"screenshots", "reports"} or name.endswith((".png", ".jpg", ".jpeg", ".webp", ".mp3", ".wav")):
# 游戏根下孤立的二进制不是源码;按证据保存,资产应放在 assets/ 下。
return "evidence", f"evidence/{path}"
return "source", f"source/{path}"
def _is_transient(path: Path, rel: str) -> bool:
"""过滤缓存、凭据和临时批跑;过滤规则宁可少收,不把敏感文件上传。"""
if any(part in _TRANSIENT_PARTS for part in path.parts):
return True
if path.name in _SECRET_NAMES or path.name.startswith(".env.") or path.name.endswith((".pem", ".key", ".p12")):
return True
if "/raw/" in f"/{rel}/" or rel.startswith("runs/raw/"):
return True
return False
def _iter_files(root: Path) -> Iterable[tuple[str, Path]]:
"""以排序顺序枚举真实文件,拒绝符号链接和越界路径。"""
root = root.resolve()
if not root.is_dir():
raise ArtifactError(f"游戏根目录不存在或不是目录:{root}")
for item in sorted(root.rglob("*"), key=lambda p: p.as_posix()):
rel = item.relative_to(root).as_posix()
if _is_transient(item, rel):
continue
if item.is_symlink():
raise ArtifactError(f"游戏包禁止符号链接:{rel}")
if item.is_file():
yield _safe_rel(rel), item
def build_manifest(root: Path, tenant_id: str, game_id: str, version_id: str, *, package_type: str,
runtime_manifest_path: str, source_revision: Mapping) -> dict:
"""扫描一款游戏目录,生成可上传的对象清单和分类 hash。"""
_validate_id(tenant_id, "tenantId")
_validate_db_id(game_id, "gameId")
_validate_db_id(version_id, "versionId")
if package_type not in {"phaser", "littlejs", "canvas"}:
raise ArtifactError(f"packageType 非法:{package_type}")
key_prefix = f"tenants/{tenant_id}/games/{game_id}/versions/{version_id}"
objects: list[dict] = []
root = Path(root).resolve()
runtime_manifest_rel = _safe_rel(runtime_manifest_path)
runtime_manifest = None
source_files: list[tuple[str, Path]] = []
for rel, file_path in _iter_files(root):
if rel == runtime_manifest_rel:
if file_path.stat().st_size > _MAX_OBJECT_BYTES:
raise ArtifactError(f"GamePackage manifest 超过 {_MAX_OBJECT_BYTES} 字节")
data = file_path.read_bytes()
_validate_game_package(data, game_id, version_id)
runtime_manifest = {
"store": "artifact",
"key": f"{key_prefix}/manifest.json",
"sourcePath": rel,
"bytes": len(data),
"sha256": _hash_bytes(data),
"mime": "application/json",
}
continue
category, package_path = _classify(rel)
if category == "source":
source_files.append((rel, file_path))
continue
size, sha256 = _file_digest(file_path)
mime = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
key = f"{key_prefix}/{package_path}"
objects.append({
"category": category,
"store": _CATEGORY_STORE[category],
"path": package_path,
"sourcePath": rel,
"key": key,
"bytes": size,
"sha256": sha256,
"mime": mime,
})
if runtime_manifest is None:
raise ArtifactError(f"指定的 GamePackage manifest 不存在:{runtime_manifest_rel}")
provider = str(source_revision.get("provider", ""))
source_game_id = _validate_id(str(source_revision.get("gameId", "")), "sourceRevision.gameId")
revision_id = _validate_id(str(source_revision.get("revisionId", "")), "sourceRevision.revisionId")
manifest_ref = str(source_revision.get("manifestRef", ""))
if provider not in {"tier2_source_project", "game_source_archive"}:
raise ArtifactError(f"sourceRevision.provider 非法:{provider}")
if provider == "tier2_source_project" and package_type != "phaser":
raise ArtifactError("tier2_source_project 只允许 packageType=phaser")
if provider == "game_source_archive":
_validate_db_id(source_game_id, "sourceRevision.gameId")
source_objects, source_bytes, source_hash = _source_archive_hash(source_files)
expected_manifest_ref = f"game-source-archive:{source_game_id}:{revision_id}"
else:
source_objects, source_bytes, source_hash = _source_project_hash(source_files)
expected_manifest_ref = f"tier2-source-project:{source_game_id}:{revision_id}"
if manifest_ref != expected_manifest_ref:
raise ArtifactError("sourceRevision.manifestRef 与 provider/gameId/revisionId 不匹配")
if str(source_revision.get("sourceHash", "")) != source_hash:
raise ArtifactError("本地源工程 hash 与 sourceRevision.sourceHash 不匹配")
source_revision_record = {
"provider": provider,
"gameId": source_game_id,
"revisionId": revision_id,
"manifestRef": manifest_ref,
"sourceHash": source_hash,
"objects": source_objects,
"bytes": source_bytes,
}
objects.sort(key=lambda item: (item["category"], item["path"]))
if len(objects) > _MAX_OBJECTS:
raise ArtifactError(f"对象数量超过 {_MAX_OBJECTS}")
by_category = {
category: [item for item in objects if item["category"] == category]
for category in _CATEGORY_ORDER
}
bundle = _bundle_object(by_category["runtime"])
runtime_asset_bytes = sum(item["bytes"] for item in by_category["runtime"] + by_category["asset"])
if runtime_asset_bytes > _MAX_RUNTIME_ASSET_BYTES:
raise ArtifactError(f"运行包与素材合计超过 {_MAX_RUNTIME_ASSET_BYTES} 字节")
manifest = {
"schemaVersion": SCHEMA_VERSION,
"tenantId": tenant_id,
"gameId": game_id,
"versionId": version_id,
"packageType": package_type,
"keyPrefix": key_prefix,
"sourceRevision": source_revision_record,
"runtimeManifest": runtime_manifest,
"objects": objects,
"totals": {
"objects": len(objects),
"bytes": sum(item["bytes"] for item in objects),
"assetBytes": sum(item["bytes"] for item in by_category["asset"]),
"runtimeBytes": sum(item["bytes"] for item in by_category["runtime"]),
"evidenceBytes": sum(item["bytes"] for item in by_category["evidence"]),
},
"assetHash": _hash_objects(by_category["asset"]),
"bundleHash": bundle["sha256"] if bundle else None,
}
manifest["manifestHash"] = _hash_bytes(_MANIFEST_HASH_DOMAIN + _canonical_json(manifest))
if len(_canonical_json(manifest)) > _MAX_MANIFEST_BYTES:
raise ArtifactError(f"artifact manifest 超过 {_MAX_MANIFEST_BYTES} 字节")
return manifest
def verify_manifest(manifest: Mapping) -> None:
"""验证 manifest 自身 hash、对象路径和 totals失败即拒绝消费。"""
_validate_contract(manifest, "game-artifact-manifest.schema.json")
if len(_canonical_json(manifest)) > _MAX_MANIFEST_BYTES:
raise ArtifactError(f"artifact manifest 超过 {_MAX_MANIFEST_BYTES} 字节")
if manifest.get("schemaVersion") != SCHEMA_VERSION:
raise ArtifactError("manifest schemaVersion 不受支持")
_validate_id(str(manifest.get("tenantId", "")), "tenantId")
_validate_db_id(str(manifest.get("gameId", "")), "gameId")
_validate_db_id(str(manifest.get("versionId", "")), "versionId")
declared = manifest.get("manifestHash")
if not isinstance(declared, str) or not _HASH_RE.fullmatch(declared):
raise ArtifactError("manifestHash 非法")
unsigned = dict(manifest)
unsigned.pop("manifestHash", None)
if _hash_bytes(_MANIFEST_HASH_DOMAIN + _canonical_json(unsigned)) != declared:
raise ArtifactError("manifestHash 不匹配")
if manifest.get("packageType") not in {"phaser", "littlejs", "canvas"}:
raise ArtifactError("manifest packageType 不受支持")
key_prefix = str(manifest.get("keyPrefix", ""))
if key_prefix != f"tenants/{manifest['tenantId']}/games/{manifest['gameId']}/versions/{manifest['versionId']}":
raise ArtifactError("manifest keyPrefix 与 tenant/game/version 不匹配")
source_revision = manifest["sourceRevision"]
provider = source_revision["provider"]
if provider not in {"tier2_source_project", "game_source_archive"}:
raise ArtifactError("sourceRevision.provider 不受支持")
if provider == "tier2_source_project" and manifest.get("packageType") != "phaser":
raise ArtifactError("tier2_source_project 只允许 packageType=phaser")
if provider == "game_source_archive":
_validate_db_id(str(source_revision["gameId"]), "sourceRevision.gameId")
expected_source_ref = f"game-source-archive:{source_revision['gameId']}:{source_revision['revisionId']}"
else:
_validate_id(str(source_revision["gameId"]), "sourceRevision.gameId")
expected_source_ref = f"tier2-source-project:{source_revision['gameId']}:{source_revision['revisionId']}"
if source_revision["manifestRef"] != expected_source_ref:
raise ArtifactError("sourceRevision.manifestRef 与身份字段不匹配")
objects = manifest.get("objects") or []
totals = manifest.get("totals") or {}
if len(objects) != totals.get("objects"):
raise ArtifactError("manifest totals.objects 不匹配")
if len(objects) > _MAX_OBJECTS:
raise ArtifactError(f"对象数量超过 {_MAX_OBJECTS}")
seen: set[str] = set()
seen_sources: set[str] = set()
sums = {category: 0 for category in _CATEGORY_ORDER}
grouped: dict[str, list[Mapping]] = {category: [] for category in _CATEGORY_ORDER}
for item in objects:
raw_path = str(item.get("path", ""))
raw_source_path = str(item.get("sourcePath", ""))
path = _safe_rel(raw_path)
source_path = _safe_rel(raw_source_path)
if raw_path != path or raw_source_path != source_path:
raise ArtifactError(f"manifest 路径必须使用 NFC 规范形式:{raw_source_path}")
key = str(item.get("key", ""))
if path in seen:
raise ArtifactError(f"manifest 存在重复路径:{path}")
if source_path in seen_sources:
raise ArtifactError(f"manifest 存在重复源路径:{source_path}")
seen.add(path)
seen_sources.add(source_path)
category = str(item.get("category", ""))
if category not in _CATEGORY_DIR:
raise ArtifactError(f"对象 category 非法:{path}")
expected_path_prefix = f"{_CATEGORY_DIR[category]}/"
if not path.startswith(expected_path_prefix):
raise ArtifactError(f"对象 path 与 category 不匹配:{path}")
if item.get("store") != _CATEGORY_STORE[category]:
raise ArtifactError(f"对象 store 与 category 不匹配:{path}")
expected_key = f"{key_prefix}/{path}"
if key != expected_key:
raise ArtifactError(f"对象 key 与 path 不匹配:{key}")
if not isinstance(item.get("bytes"), int) or item["bytes"] < 0:
raise ArtifactError(f"对象 bytes 非法:{path}")
if item["bytes"] > _MAX_OBJECT_BYTES:
raise ArtifactError(f"对象 bytes 超限:{path}")
if not _HASH_RE.fullmatch(str(item.get("sha256", ""))):
raise ArtifactError(f"对象 hash 非法:{path}")
if not isinstance(item.get("mime"), str) or not item["mime"]:
raise ArtifactError(f"对象 mime 非法:{path}")
sums[category] += item["bytes"]
grouped[category].append(item)
runtime_manifest = manifest.get("runtimeManifest")
if not isinstance(runtime_manifest, Mapping):
raise ArtifactError("runtimeManifest 必须是对象")
raw_runtime_source_path = str(runtime_manifest.get("sourcePath", ""))
source_path = _safe_rel(raw_runtime_source_path)
if raw_runtime_source_path != source_path:
raise ArtifactError("runtimeManifest sourcePath 必须使用 NFC 规范形式")
if str(runtime_manifest.get("key")) != f"{key_prefix}/manifest.json":
raise ArtifactError("runtimeManifest key 不匹配")
if runtime_manifest.get("store") != "artifact":
raise ArtifactError("runtimeManifest store 必须为 artifact")
if source_path in seen_sources:
raise ArtifactError("runtimeManifest 与对象 sourcePath 重复")
if not isinstance(runtime_manifest.get("bytes"), int) or runtime_manifest["bytes"] < 0:
raise ArtifactError("runtimeManifest bytes 非法")
if runtime_manifest["bytes"] > _MAX_OBJECT_BYTES:
raise ArtifactError("runtimeManifest bytes 超限")
if not _HASH_RE.fullmatch(str(runtime_manifest.get("sha256", ""))):
raise ArtifactError("runtimeManifest hash 非法")
if runtime_manifest.get("mime") != "application/json":
raise ArtifactError("runtimeManifest mime 必须为 application/json")
expected_totals = {
"bytes": sum(sums.values()),
"assetBytes": sums["asset"],
"runtimeBytes": sums["runtime"],
"evidenceBytes": sums["evidence"],
}
for name, expected in expected_totals.items():
if totals.get(name) != expected:
raise ArtifactError(f"manifest totals.{name} 不匹配")
if sums["runtime"] + sums["asset"] > _MAX_RUNTIME_ASSET_BYTES:
raise ArtifactError("运行包与素材总字节超限")
if manifest.get("assetHash") != _hash_objects(grouped["asset"]):
raise ArtifactError("manifest assetHash 不匹配")
bundle = _bundle_object(grouped["runtime"])
if manifest.get("bundleHash") != (bundle["sha256"] if bundle else None):
raise ArtifactError("manifest bundleHash 不匹配")
def _minio_client(config: Mapping):
"""惰性创建 MinIO 客户端;不在 import 期连接网络。"""
try:
from minio import Minio
except Exception as exc: # pragma: no cover - 环境缺 SDK 时由调用方看到稳定错误
raise ArtifactError(f"缺少 minio Python SDK:{exc}") from exc
endpoint = str(config.get("endpoint", "")).replace("http://", "").replace("https://", "")
if not endpoint or not config.get("access_key") or not config.get("secret_key"):
raise ArtifactError("缺少专用 game artifact 对象存储端点或凭据")
return Minio(endpoint, access_key=config.get("access_key"), secret_key=config.get("secret_key"),
secure=bool(config.get("secure", False)))
def _read_remote(client, bucket: str, key: str, *, expected_bytes: int) -> bytes:
"""按调用方字节预算流式读取;发现第 expected+1 字节就立即拒绝。"""
if not isinstance(expected_bytes, int) or expected_bytes < 0:
raise ArtifactError(f"远端对象期望字节数非法:{key}")
try:
response = client.get_object(bucket, key)
except Exception as exc: # noqa: BLE001 - 兼容 MinIO S3Error 和测试 fake
if getattr(exc, "code", None) == "NoSuchBucket":
raise ArtifactError(f"对象存储桶不存在:{bucket}") from exc
raise
try:
chunks: list[bytes] = []
total = 0
try:
while True:
remaining = expected_bytes - total
chunk = response.read(min(_REMOTE_READ_CHUNK_BYTES, remaining + 1))
if not chunk:
break
total += len(chunk)
if total > expected_bytes:
raise ArtifactError(f"远端对象超过期望字节数:{key}:{expected_bytes}")
chunks.append(chunk)
return b"".join(chunks)
except ArtifactError:
raise
except Exception as exc: # noqa: BLE001 - SDK/网络读取统一失败语义
raise ArtifactError(f"读取远端对象失败:{bucket}/{key}:{type(exc).__name__}:{exc}") from exc
finally:
response.close()
response.release_conn()
def _bucket(config: Mapping, store: str) -> str:
"""把稳定 store 身份映射到环境桶名manifest 不保存凭据或临时 URL。"""
if store == "artifact":
return str(config.get("artifact_bucket", config.get("bucket", "game-artifacts")))
if store == "evidence":
return str(config.get("evidence_bucket", "game-evidence"))
if store == "source":
source_bucket = str(config.get("source_bucket", ""))
if not source_bucket:
raise ArtifactError("缺少显式 source_bucketgame-sources 尚未完成权限配置")
if source_bucket in {
str(config.get("artifact_bucket", "")),
str(config.get("evidence_bucket", "")),
}:
raise ArtifactError("source_bucket 不能与 artifact/evidence 桶复用")
return source_bucket
raise ArtifactError(f"未知对象 store:{store}")
def _existing_manifest(client, bucket: str, key: str) -> dict | None:
"""读取已存在的版本索引;仅把 S3 的对象不存在视为未上传。"""
try:
payload = _read_remote(client, bucket, key, expected_bytes=_MAX_MANIFEST_BYTES)
except ArtifactError:
raise
except Exception as exc: # noqa: BLE001 - 兼容 MinIO S3Error 和测试 fake
if getattr(exc, "code", None) in {"NoSuchKey", "NoSuchObject"}:
return None
raise ArtifactError(f"探测远端 artifact-manifest 失败:{type(exc).__name__}:{exc}") from exc
try:
existing = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ArtifactError("远端 artifact-manifest 已存在但不是合法 JSON") from exc
verify_manifest(existing)
return existing
def _verify_remote_object(client, bucket: str, key: str, expected_hash: str, expected_bytes: int) -> None:
"""用 GET 字节校验远端对象;不把 HEAD/ETag 当作内容完整性证据。"""
try:
data = _read_remote(client, bucket, key, expected_bytes=expected_bytes)
except ArtifactError:
raise
except Exception as exc: # noqa: BLE001 - 缺对象/SDK 错误统一成稳定失败
raise ArtifactError(f"读取远端对象失败:{bucket}/{key}:{type(exc).__name__}:{exc}") from exc
if len(data) != expected_bytes or _hash_bytes(data) != expected_hash:
raise ArtifactError(f"上传后对象校验失败:{key}")
def _put_remote(client, bucket: str, key: str, stream, *, length: int, content_type: str) -> None:
"""统一包装 S3 Put 异常CLI 只暴露稳定 ArtifactError。"""
try:
client.put_object(bucket, key, stream, length=length, content_type=content_type)
except ArtifactError:
raise
except Exception as exc: # noqa: BLE001 - 兼容 MinIO S3Error/网络异常
raise ArtifactError(f"对象上传失败:{bucket}/{key}:{type(exc).__name__}:{exc}") from exc
def upload_manifest(root: Path, manifest: Mapping, config: Mapping, *,
external_single_writer: bool = False) -> dict:
"""在外部单写锁内上传并校验对象;波次 1 只返回 verified_pending。"""
verify_manifest(manifest)
if external_single_writer is not True:
raise ArtifactError("波次 1 上传必须由调用方持有外部单写锁")
client = _minio_client(config)
buckets = {store: _bucket(config, store) for store in ("artifact", "evidence")}
root = Path(root).resolve()
manifest_key = f"{manifest['keyPrefix']}/artifact-manifest.json"
existing = _existing_manifest(client, buckets["artifact"], manifest_key)
if existing is not None:
if existing["manifestHash"] == manifest["manifestHash"]:
for item in manifest["objects"]:
_verify_remote_object(client, buckets[item["store"]], item["key"],
item["sha256"], item["bytes"])
runtime_manifest = manifest["runtimeManifest"]
_verify_remote_object(client, buckets["artifact"], runtime_manifest["key"],
runtime_manifest["sha256"], runtime_manifest["bytes"])
return {
"artifactBucket": buckets["artifact"],
"artifactManifestKey": manifest_key,
"manifestHash": manifest["manifestHash"],
"idempotent": True,
"status": "verified_pending",
"committed": False,
}
raise ArtifactError("同一 gameId/versionId 已存在不同 artifact-manifest拒绝覆盖")
# 先把所有正文和 GamePackage 做完本地预检,再开始任何远端 Put避免后一个文件漂移
# 时留下没有数据库 pending 记录的半上传对象。
local_objects: list[tuple[Mapping, Path, int]] = []
for item in manifest["objects"]:
package_path = str(item["path"])
source = root / _safe_rel(str(item["sourcePath"]))
if source.is_symlink() or not source.is_file():
raise ArtifactError(f"manifest 对应本地文件不存在:{package_path}")
size, sha256 = _file_digest(source)
if size != item["bytes"]:
raise ArtifactError(f"本地对象字节数漂移:{package_path}")
if sha256 != item["sha256"]:
raise ArtifactError(f"本地对象 hash 漂移:{package_path}")
local_objects.append((item, source, size))
runtime_manifest = manifest["runtimeManifest"]
source = root / _safe_rel(str(runtime_manifest["sourcePath"]))
if source.is_symlink() or not source.is_file():
raise ArtifactError("GamePackage manifest 本地文件不存在")
payload = source.read_bytes()
if len(payload) != runtime_manifest["bytes"] or _hash_bytes(payload) != runtime_manifest["sha256"]:
raise ArtifactError("GamePackage manifest 本地 hash 漂移")
_validate_game_package(payload, str(manifest["gameId"]), str(manifest["versionId"]))
# 所有本地输入都已验证,以下才产生远端副作用。
for item, source, size in local_objects:
with source.open("rb") as stream:
_put_remote(client, buckets[item["store"]], item["key"], stream, length=size,
content_type=item["mime"])
_put_remote(client, buckets["artifact"], runtime_manifest["key"], BytesIO(payload), length=len(payload),
content_type="application/json")
# 先对正文对象做 GET 全量复算,再写 markermarker 仍不是数据库 CAS 提交记录。
for item in manifest["objects"]:
_verify_remote_object(client, buckets[item["store"]], item["key"], item["sha256"], item["bytes"])
_verify_remote_object(client, buckets["artifact"], runtime_manifest["key"],
runtime_manifest["sha256"], runtime_manifest["bytes"])
payload = _canonical_json(manifest)
_put_remote(client, buckets["artifact"], manifest_key, BytesIO(payload), length=len(payload),
content_type="application/json")
remote_bytes = _read_remote(
client, buckets["artifact"], manifest_key, expected_bytes=len(payload),
)
if remote_bytes != payload:
raise ArtifactError("上传后 manifest 下载 hash 不匹配")
return {
"artifactBucket": buckets["artifact"],
"artifactManifestKey": manifest_key,
"manifestHash": manifest["manifestHash"],
"idempotent": False,
"status": "verified_pending",
"committed": False,
}
def fetch_manifest(config: Mapping, *, key: str, expected_manifest_hash: str,
expected_tenant_id: str, expected_game_id: str, expected_version_id: str) -> dict:
"""从对象存储读取 artifact-manifest不接受任意 URL调用方需传入受信 key。"""
client = _minio_client(config)
bucket = _bucket(config, "artifact")
if not _ARTIFACT_MANIFEST_KEY_RE.fullmatch(key):
raise ArtifactError("artifact-manifest key 非法")
if not _HASH_RE.fullmatch(expected_manifest_hash):
raise ArtifactError("expectedManifestHash 非法")
_validate_id(expected_tenant_id, "expectedTenantId")
_validate_db_id(expected_game_id, "expectedGameId")
_validate_db_id(expected_version_id, "expectedVersionId")
payload = _read_remote(client, bucket, key, expected_bytes=_MAX_MANIFEST_BYTES)
try:
manifest = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ArtifactError("远端 artifact-manifest 不是合法 JSON") from exc
verify_manifest(manifest)
if manifest["manifestHash"] != expected_manifest_hash:
raise ArtifactError("artifact-manifest 与数据库期望 hash 不匹配")
if (manifest["tenantId"], manifest["gameId"], manifest["versionId"]) != (
expected_tenant_id, expected_game_id, expected_version_id,
):
raise ArtifactError("artifact-manifest 与数据库期望身份不匹配")
if key != f"{manifest['keyPrefix']}/artifact-manifest.json":
raise ArtifactError("artifact-manifest key 与内容身份不匹配")
return manifest
def download_manifest(manifest: Mapping, config: Mapping, target: Path, *, expected_manifest_hash: str) -> Path:
"""按可信 hash 物化到临时目录,全量校验成功后原子交付。"""
verify_manifest(manifest)
if manifest["manifestHash"] != expected_manifest_hash:
raise ArtifactError("物化 manifest 与数据库期望 hash 不匹配")
client = _minio_client(config)
target = Path(target).resolve()
if target.exists():
raise ArtifactError(f"物化目标已存在:{target}")
target.parent.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.partial-", dir=target.parent)).resolve()
try:
for item in manifest["objects"]:
rel = _safe_rel(str(item["path"]))
destination = (staging / rel).resolve()
if staging not in destination.parents:
raise ArtifactError(f"下载路径越界:{rel}")
destination.parent.mkdir(parents=True, exist_ok=True)
data = _read_remote(
client, _bucket(config, str(item["store"])), item["key"],
expected_bytes=item["bytes"],
)
if len(data) != item["bytes"] or _hash_bytes(data) != item["sha256"]:
raise ArtifactError(f"下载对象 hash 不匹配:{rel}")
destination.write_bytes(data)
runtime_manifest = manifest["runtimeManifest"]
destination = staging / "manifest.json"
data = _read_remote(
client, _bucket(config, "artifact"), runtime_manifest["key"],
expected_bytes=runtime_manifest["bytes"],
)
if len(data) != runtime_manifest["bytes"] or _hash_bytes(data) != runtime_manifest["sha256"]:
raise ArtifactError("下载 GamePackage manifest hash 不匹配")
_validate_game_package(data, str(manifest["gameId"]), str(manifest["versionId"]))
destination.write_bytes(data)
(staging / "artifact-manifest.json").write_bytes(_canonical_json(manifest))
staging.replace(target)
except Exception:
shutil.rmtree(staging, ignore_errors=True)
raise
return target

View File

@ -0,0 +1,353 @@
"""引擎无关游戏源归档工具。
源归档只保存源码和源配置运行包发布素材证据仍由
``game_artifact_store`` 管理两类清单使用不同 schema桶和 hash 避免
LittleJS/Canvas 游戏被误标成 Tier2 Phaser 源工程
"""
from __future__ import annotations
import json
import mimetypes
import re
import shutil
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Iterable, Mapping
from .game_artifact_store import (
ArtifactError,
_HASH_RE,
_MAX_MANIFEST_BYTES,
_MAX_OBJECTS,
_MAX_OBJECT_BYTES,
_SOURCE_TREE_HASH_DOMAIN,
_bucket,
_canonical_json,
_classify,
_file_digest,
_hash_bytes,
_iter_files,
_minio_client,
_put_remote,
_read_remote,
_safe_rel,
_validate_contract,
_validate_db_id,
_validate_id,
)
SCHEMA_VERSION = "GameSourceArchive/1"
_MANIFEST_HASH_DOMAIN = b"GameSourceArchive/1\0"
_SOURCE_MANIFEST_KEY_RE = re.compile(
r"^tenants/[A-Za-z0-9][A-Za-z0-9._-]*/games/[1-9][0-9]*/"
r"source-revisions/[A-Za-z0-9][A-Za-z0-9._-]*/source-manifest\.json$"
)
_ENGINE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
def _source_hash_from_rows(rows: Iterable[Mapping]) -> str:
"""按 manifest 中的路径、字节数和内容 hash 复算源树摘要。"""
canonical_rows = sorted(
[
{"path": _safe_rel(str(row["path"])), "bytes": row["bytes"], "sha256": row["sha256"]}
for row in rows
],
key=lambda row: row["path"],
)
return _hash_bytes(_SOURCE_TREE_HASH_DOMAIN + _canonical_json(canonical_rows))
def source_hash_from_files(files: Iterable[tuple[str, Path]]) -> str:
"""对一组源码文件按字节计算可跨引擎复算的源 hash。"""
rows: list[dict] = []
seen: set[str] = set()
for rel, path in sorted(files, key=lambda item: item[0]):
normalized = _safe_rel(rel)
if normalized in seen:
raise ArtifactError(f"源归档存在重复路径:{normalized}")
seen.add(normalized)
size, sha256 = _file_digest(path)
rows.append({"path": normalized, "bytes": size, "sha256": sha256})
if not rows:
raise ArtifactError("游戏包缺少源工程文件")
if len(rows) > _MAX_OBJECTS:
raise ArtifactError(f"源归档对象数量超过 {_MAX_OBJECTS}")
return _source_hash_from_rows(rows)
def _source_files(root: Path, exclude_paths: Iterable[str], runtime_manifest_path: str | None) -> list[tuple[str, Path]]:
"""从游戏目录挑出与运行制品清单相同口径的 source 类文件。"""
excluded = {_safe_rel(path) for path in exclude_paths}
if runtime_manifest_path:
excluded.add(_safe_rel(runtime_manifest_path))
# 两份对象清单是上传器生成的 marker不属于源工程正文。
excluded.update({"artifact-manifest.json", "source-manifest.json"})
files: list[tuple[str, Path]] = []
for rel, path in _iter_files(root):
category, _ = _classify(rel)
if category == "source" and rel not in excluded:
files.append((rel, path))
return files
def build_source_archive(root: Path, tenant_id: str, game_id: str, revision_id: str, *, engine: str,
exclude_paths: Iterable[str] = (), runtime_manifest_path: str | None = "game-package.json",
expected_source_hash: str | None = None) -> dict:
"""扫描源码并生成可上传的 GameSourceArchive/1 清单。"""
_validate_id(tenant_id, "tenantId")
_validate_db_id(game_id, "gameId")
_validate_id(revision_id, "revisionId")
if not isinstance(engine, str) or not _ENGINE_RE.fullmatch(engine) or len(engine) > 64:
raise ArtifactError(f"engine 非法:{engine!r}")
root = Path(root).resolve()
files = _source_files(root, exclude_paths, runtime_manifest_path)
if not files:
raise ArtifactError("游戏包缺少源工程文件")
key_prefix = f"tenants/{tenant_id}/games/{game_id}/source-revisions/{revision_id}"
objects: list[dict] = []
for rel, path in files:
size, sha256 = _file_digest(path)
package_path = f"source/{_safe_rel(rel)}"
objects.append({
"path": package_path,
"sourcePath": _safe_rel(rel),
"key": f"{key_prefix}/{package_path}",
"bytes": size,
"sha256": sha256,
"mime": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
})
objects.sort(key=lambda item: item["path"])
source_hash = _source_hash_from_rows(
{"path": item["sourcePath"], "bytes": item["bytes"], "sha256": item["sha256"]}
for item in objects
)
if expected_source_hash is not None and source_hash != expected_source_hash:
raise ArtifactError("源归档 hash 与期望 sourceHash 不匹配")
manifest = {
"schemaVersion": SCHEMA_VERSION,
"tenantId": tenant_id,
"gameId": game_id,
"revisionId": revision_id,
"engine": engine,
"keyPrefix": key_prefix,
"objects": objects,
"totals": {"objects": len(objects), "bytes": sum(item["bytes"] for item in objects)},
"sourceHash": source_hash,
}
manifest["manifestHash"] = _hash_bytes(_MANIFEST_HASH_DOMAIN + _canonical_json(manifest))
if len(_canonical_json(manifest)) > _MAX_MANIFEST_BYTES:
raise ArtifactError(f"source manifest 超过 {_MAX_MANIFEST_BYTES} 字节")
verify_source_archive(manifest)
return manifest
def verify_source_archive(manifest: Mapping) -> None:
"""校验源归档清单的身份、路径、汇总和 hash 链。"""
_validate_contract(manifest, "game-source-archive.schema.json")
if len(_canonical_json(manifest)) > _MAX_MANIFEST_BYTES:
raise ArtifactError(f"source manifest 超过 {_MAX_MANIFEST_BYTES} 字节")
if manifest.get("schemaVersion") != SCHEMA_VERSION:
raise ArtifactError("source manifest schemaVersion 不受支持")
_validate_id(str(manifest.get("tenantId", "")), "tenantId")
_validate_db_id(str(manifest.get("gameId", "")), "gameId")
_validate_id(str(manifest.get("revisionId", "")), "revisionId")
if not isinstance(manifest.get("engine"), str) or not _ENGINE_RE.fullmatch(manifest["engine"]):
raise ArtifactError("source manifest engine 非法")
key_prefix = str(manifest.get("keyPrefix", ""))
expected_prefix = (
f"tenants/{manifest['tenantId']}/games/{manifest['gameId']}"
f"/source-revisions/{manifest['revisionId']}"
)
if key_prefix != expected_prefix:
raise ArtifactError("source manifest keyPrefix 与身份不匹配")
declared = manifest.get("manifestHash")
if not isinstance(declared, str) or not _HASH_RE.fullmatch(declared):
raise ArtifactError("source manifestHash 非法")
unsigned = dict(manifest)
unsigned.pop("manifestHash", None)
if _hash_bytes(_MANIFEST_HASH_DOMAIN + _canonical_json(unsigned)) != declared:
raise ArtifactError("source manifestHash 不匹配")
objects = manifest.get("objects") or []
if not objects or len(objects) > _MAX_OBJECTS:
raise ArtifactError("source manifest objects 数量非法")
seen_paths: set[str] = set()
seen_sources: set[str] = set()
rows: list[dict] = []
total_bytes = 0
for item in objects:
raw_path = str(item.get("path", ""))
raw_source_path = str(item.get("sourcePath", ""))
path = _safe_rel(raw_path)
source_path = _safe_rel(raw_source_path)
if raw_path != path or raw_source_path != source_path:
raise ArtifactError(f"source manifest 路径必须使用 NFC 规范形式:{raw_source_path}")
if path != f"source/{source_path}":
raise ArtifactError(f"source path 与 sourcePath 不匹配:{path}")
if path in seen_paths or source_path in seen_sources:
raise ArtifactError(f"source manifest 存在重复路径:{path}")
seen_paths.add(path)
seen_sources.add(source_path)
expected_key = f"{key_prefix}/{path}"
if item.get("key") != expected_key:
raise ArtifactError(f"source object key 与 path 不匹配:{path}")
if not isinstance(item.get("bytes"), int) or item["bytes"] < 0 or item["bytes"] > _MAX_OBJECT_BYTES:
raise ArtifactError(f"source object bytes 非法:{path}")
if not _HASH_RE.fullmatch(str(item.get("sha256", ""))):
raise ArtifactError(f"source object hash 非法:{path}")
if not isinstance(item.get("mime"), str) or not item["mime"]:
raise ArtifactError(f"source object mime 非法:{path}")
total_bytes += item["bytes"]
rows.append({"path": source_path, "bytes": item["bytes"], "sha256": item["sha256"]})
totals = manifest.get("totals") or {}
if totals.get("objects") != len(objects) or totals.get("bytes") != total_bytes:
raise ArtifactError("source manifest totals 不匹配")
if total_bytes < 1:
raise ArtifactError("source manifest 不能是全空源工程")
if manifest.get("sourceHash") != _source_hash_from_rows(rows):
raise ArtifactError("source manifest sourceHash 不匹配")
def _existing_source_manifest(client, bucket: str, key: str) -> dict | None:
"""读取已存在的源 marker只有对象不存在才视为未上传。"""
try:
payload = _read_remote(client, bucket, key, expected_bytes=_MAX_MANIFEST_BYTES)
except Exception as exc: # noqa: BLE001 - 兼容 MinIO S3Error 和测试 fake
if getattr(exc, "code", None) in {"NoSuchKey", "NoSuchObject"}:
return None
raise ArtifactError(f"探测远端 source-manifest 失败:{type(exc).__name__}:{exc}") from exc
try:
existing = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ArtifactError("远端 source-manifest 已存在但不是合法 JSON") from exc
verify_source_archive(existing)
return existing
def _verify_remote_source_object(client, bucket: str, item: Mapping) -> None:
"""用 GET 全量复算源对象,不能用 ETag 替代内容校验。"""
try:
data = _read_remote(client, bucket, str(item["key"]), expected_bytes=item["bytes"])
except ArtifactError:
raise
except Exception as exc: # noqa: BLE001 - 缺对象/SDK 错误统一成稳定失败
raise ArtifactError(f"读取远端源对象失败:{bucket}/{item['key']}:{type(exc).__name__}:{exc}") from exc
if len(data) != item["bytes"] or _hash_bytes(data) != item["sha256"]:
raise ArtifactError(f"上传后源对象校验失败:{item['path']}")
def upload_source_archive(root: Path, manifest: Mapping, config: Mapping, *,
external_single_writer: bool = False) -> dict:
"""在外部单写锁内上传源对象并返回 verified_pending。"""
verify_source_archive(manifest)
if external_single_writer is not True:
raise ArtifactError("波次 1 源归档上传必须由调用方持有外部单写锁")
client = _minio_client(config)
bucket = _bucket(config, "source")
root = Path(root).resolve()
manifest_key = f"{manifest['keyPrefix']}/source-manifest.json"
existing = _existing_source_manifest(client, bucket, manifest_key)
if existing is not None:
if existing["manifestHash"] != manifest["manifestHash"]:
raise ArtifactError("同一源修订已存在不同 source-manifest拒绝覆盖")
for item in manifest["objects"]:
_verify_remote_source_object(client, bucket, item)
return {
"sourceBucket": bucket,
"sourceManifestKey": manifest_key,
"sourceHash": manifest["sourceHash"],
"manifestHash": manifest["manifestHash"],
"idempotent": True,
"status": "verified_pending",
"committed": False,
}
# 先验证整个本地源目录,再开始远端写入,失败不会留下未登记的 partial。
local_objects: list[tuple[Mapping, Path, int]] = []
for item in manifest["objects"]:
source = root / _safe_rel(str(item["sourcePath"]))
if source.is_symlink() or not source.is_file():
raise ArtifactError(f"source manifest 对应本地文件不存在:{item['sourcePath']}")
size, sha256 = _file_digest(source)
if size != item["bytes"] or sha256 != item["sha256"]:
raise ArtifactError(f"本地源对象 hash 漂移:{item['sourcePath']}")
local_objects.append((item, source, size))
for item, source, size in local_objects:
with source.open("rb") as stream:
_put_remote(client, bucket, item["key"], stream, length=size, content_type=item["mime"])
for item in manifest["objects"]:
_verify_remote_source_object(client, bucket, item)
payload = _canonical_json(manifest)
_put_remote(client, bucket, manifest_key, BytesIO(payload), length=len(payload), content_type="application/json")
if _read_remote(client, bucket, manifest_key, expected_bytes=len(payload)) != payload:
raise ArtifactError("上传后 source-manifest 下载 hash 不匹配")
return {
"sourceBucket": bucket,
"sourceManifestKey": manifest_key,
"sourceHash": manifest["sourceHash"],
"manifestHash": manifest["manifestHash"],
"idempotent": False,
"status": "verified_pending",
"committed": False,
}
def fetch_source_archive(config: Mapping, *, key: str, expected_manifest_hash: str,
expected_tenant_id: str, expected_game_id: str,
expected_revision_id: str) -> dict:
"""按受信 key/hash/身份读取源清单,拒绝任意 URL 或跨版本清单。"""
if not _SOURCE_MANIFEST_KEY_RE.fullmatch(key):
raise ArtifactError("source-manifest key 非法")
if not _HASH_RE.fullmatch(expected_manifest_hash):
raise ArtifactError("expected source manifest hash 非法")
_validate_id(expected_tenant_id, "expectedTenantId")
_validate_db_id(expected_game_id, "expectedGameId")
_validate_id(expected_revision_id, "expectedRevisionId")
client = _minio_client(config)
payload = _read_remote(client, _bucket(config, "source"), key, expected_bytes=_MAX_MANIFEST_BYTES)
try:
manifest = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ArtifactError("远端 source-manifest 不是合法 JSON") from exc
verify_source_archive(manifest)
if manifest["manifestHash"] != expected_manifest_hash:
raise ArtifactError("source-manifest 与数据库期望 hash 不匹配")
if (manifest["tenantId"], manifest["gameId"], manifest["revisionId"]) != (
expected_tenant_id, expected_game_id, expected_revision_id,
):
raise ArtifactError("source-manifest 与数据库期望身份不匹配")
if key != f"{manifest['keyPrefix']}/source-manifest.json":
raise ArtifactError("source-manifest key 与内容身份不匹配")
return manifest
def download_source_archive(manifest: Mapping, config: Mapping, target: Path, *,
expected_manifest_hash: str) -> Path:
"""逐文件校验并原子物化源码;失败时清理 staging 目录。"""
verify_source_archive(manifest)
if manifest["manifestHash"] != expected_manifest_hash:
raise ArtifactError("物化 source manifest 与数据库期望 hash 不匹配")
client = _minio_client(config)
target = Path(target).resolve()
if target.exists():
raise ArtifactError(f"物化目标已存在:{target}")
target.parent.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.partial-", dir=target.parent)).resolve()
try:
for item in manifest["objects"]:
rel = _safe_rel(str(item["sourcePath"]))
destination = (staging / rel).resolve()
if staging not in destination.parents:
raise ArtifactError(f"下载源路径越界:{rel}")
destination.parent.mkdir(parents=True, exist_ok=True)
data = _read_remote(
client, _bucket(config, "source"), item["key"], expected_bytes=item["bytes"],
)
if len(data) != item["bytes"] or _hash_bytes(data) != item["sha256"]:
raise ArtifactError(f"下载对象 hash 不匹配:{rel}")
destination.write_bytes(data)
staging.replace(target)
except Exception:
shutil.rmtree(staging, ignore_errors=True)
raise
return target