From 1981a6be01e2e7a314df8af661871423fbbb4bb4 Mon Sep 17 00:00:00 2001
From: lili
Date: Thu, 30 Jul 2026 05:21:44 -0700
Subject: [PATCH] =?UTF-8?q?feat(storage):=20=E9=97=AD=E5=90=88=20Agent=20?=
=?UTF-8?q?=E5=88=B0=20Runtime=20=E7=9A=84=20OSS=20=E6=B8=B8=E6=88=8F?=
=?UTF-8?q?=E9=93=BE=E8=B7=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
---
.agents/skills/staging-ops.md | 15 +
.gitea/workflows/contract-gates.yml | 70 ++-
.githooks/pre-commit | 25 +
contracts/api-schemas/runtime.yaml | 41 +-
...0__clarify_game_artifact_manifest_hash.sql | 12 +
contracts/gate-fixtures/run.mjs | 47 +-
contracts/gate-fixtures/run.test.mjs | 23 +
..._reference_asset_trusted_consumption_v2.py | 13 +
docs/architecture/后端/数据模型.md | 13 +-
docs/architecture/架构/契约总览.md | 11 +-
docs/内网凭据与端点.md | 21 +-
.../content/GameContentControlController.java | 151 +++++
.../content/GameContentControlProperties.java | 23 +
.../content/GameContentControlService.java | 72 +++
.../content/GameContentFinalizeTxService.java | 59 ++
.../content/GameContentSignatureVerifier.java | 65 ++
.../GameContentControlControllerTest.java | 160 +++++
.../GameContentControlServiceTest.java | 86 +++
.../GameContentFinalizeTxServiceTest.java | 103 +++
.../feed/service/feed/FeedServiceImpl.java | 2 +
.../module/project/api/ProjectVersionApi.java | 25 +
.../ProjectContentVersionActivateReqDTO.java | 41 ++
.../dto/ProjectContentVersionBindReqDTO.java | 41 ++
.../ProjectContentVersionIdentityReqDTO.java | 30 +
.../ProjectContentVersionPrepareReqDTO.java | 31 +
.../project/enums/ErrorCodeConstants.java | 2 +
.../project/api/ProjectVersionApiImpl.java | 31 +
.../service/version/GameVersionService.java | 16 +
.../version/GameVersionServiceImpl.java | 237 +++++++
.../version/GameVersionServiceImplTest.java | 250 ++++++++
.../module/runtime/api/RuntimePackageApi.java | 12 +
.../dto/RuntimeOssPackageFinalizeReqDTO.java | 45 ++
.../dto/RuntimeOssPackageFinalizeRespDTO.java | 21 +
.../runtime/enums/ErrorCodeConstants.java | 6 +
.../game-module-runtime-server/pom.xml | 6 +
.../runtime/api/RuntimePackageApiImpl.java | 35 ++
.../app/runtime/AppRuntimeController.java | 37 +-
.../app/runtime/vo/RuntimePackageRespVO.java | 5 +-
.../app/runtime/vo/SandboxPolicyVO.java | 6 +-
.../convert/runtime/RuntimeConvert.java | 54 +-
.../dal/dataobject/pkg/RuntimePackageDO.java | 4 +-
.../dataobject/storage/ArtifactStorageDO.java | 4 +-
.../mysql/storage/ArtifactStorageMapper.java | 6 +
.../service/pkg/ArtifactStorageGate.java | 108 +---
.../pkg/RuntimeArtifactContentService.java | 219 +++++++
.../pkg/RuntimeArtifactStorageProperties.java | 63 ++
.../service/pkg/RuntimeAssetContent.java | 5 +
.../service/pkg/RuntimePackageService.java | 18 +-
.../pkg/RuntimePackageServiceImpl.java | 340 +++++++++-
.../store/ArtifactObjectReadException.java | 12 +
.../pkg/store/ArtifactObjectReader.java | 15 +
.../service/pkg/store/DbPackageStore.java | 2 +-
.../pkg/store/S3ArtifactObjectReader.java | 89 +++
.../api/RuntimePackageApiImplDogfoodTest.java | 41 ++
.../app/runtime/AppRuntimeControllerTest.java | 95 +++
.../convert/runtime/RuntimeConvertTest.java | 44 +-
.../service/pkg/ArtifactStorageGateTest.java | 25 +-
.../RuntimeArtifactContentServiceTest.java | 100 +++
.../RuntimeArtifactStoragePropertiesTest.java | 37 ++
.../RuntimeOssPackageFinalizeServiceTest.java | 284 +++++++++
.../pkg/RuntimePackageServiceImplTest.java | 39 ++
.../pkg/store/S3ArtifactObjectReaderTest.java | 25 +
.../main/resources/application-staging.yaml | 22 +
...0__clarify_game_artifact_manifest_hash.sql | 12 +
game-runtime/games/_generic/entry-generic.js | 1 +
.../_shared/entry-bundle.amodel.template.js | 3 +-
.../_wg1-gen/_shared/entry-bundle.template.js | 2 +
game-runtime/src/host/boot-game-host.js | 21 +-
game-runtime/src/host/boot-game-host.test.mjs | 41 ++
game-studio/package.json | 3 +-
game-studio/scripts/inject-security.test.mjs | 335 ++++++++++
game-studio/src/api/types.ts | 8 +-
game-studio/src/host/GamePlayer.vue | 79 ++-
game-studio/src/host/bridge.ts | 105 +++-
game-studio/src/host/contract.ts | 10 +-
game-studio/src/host/inject.ts | 123 +++-
game-studio/src/mock/runtime.ts | 10 +-
game-studio/src/views/play/Play.vue | 2 +-
tier2/config/infra.yaml | 18 +
.../scripts/game_content_repository.py | 168 +++++
.../tests/test_game_artifact_storage_ddl.py | 23 +
.../tests/test_game_artifact_storage_store.py | 47 ++
.../tests/test_game_artifact_store.py | 174 +++++-
.../tests/test_game_content_repository.py | 585 ++++++++++++++++++
.../tests/test_game_source_archive.py | 18 +
.../worker/game_artifact_storage_store.py | 53 +-
.../gen-worker/worker/game_artifact_store.py | 83 ++-
.../worker/game_content_repository.py | 398 ++++++++++++
.../worker/game_source_archive_store.py | 9 +-
89 files changed, 5541 insertions(+), 330 deletions(-)
create mode 100644 contracts/db-schemas/V35.0.0__clarify_game_artifact_manifest_hash.sql
create mode 100644 contracts/gate-fixtures/run.test.mjs
create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlController.java
create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlProperties.java
create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlService.java
create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxService.java
create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentSignatureVerifier.java
create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlControllerTest.java
create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlServiceTest.java
create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxServiceTest.java
create mode 100644 game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionActivateReqDTO.java
create mode 100644 game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionBindReqDTO.java
create mode 100644 game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionIdentityReqDTO.java
create mode 100644 game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionPrepareReqDTO.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeReqDTO.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeRespDTO.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentService.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStorageProperties.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeAssetContent.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReadException.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReader.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReader.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeControllerTest.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentServiceTest.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStoragePropertiesTest.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeOssPackageFinalizeServiceTest.java
create mode 100644 game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReaderTest.java
create mode 100644 game-cloud/huijing-server/src/main/resources/db/migration/V35.0.0__clarify_game_artifact_manifest_hash.sql
create mode 100644 game-studio/scripts/inject-security.test.mjs
create mode 100644 tier2/gen-worker/scripts/game_content_repository.py
create mode 100644 tier2/gen-worker/tests/test_game_content_repository.py
create mode 100644 tier2/gen-worker/worker/game_content_repository.py
diff --git a/.agents/skills/staging-ops.md b/.agents/skills/staging-ops.md
index cc09f41f..d6ed18aa 100644
--- a/.agents/skills/staging-ops.md
+++ b/.agents/skills/staging-ops.md
@@ -69,6 +69,21 @@ description: "在 mini-desktop/mini-infra 上做 staging 或内测 dev 的部署
- **feed 流端点语义(2026-07-05 真机坐实,防 R5 式假警报)**:主混合流 `GET /app-api/feed/stream` 只收 `cursor`+`size`、**无 zoneId 参**,恒服务 zone0;带 zoneId 的专区流是另一个端点 `GET /app-api/feed/zone/stream?zoneId=N`。给 `/feed/stream` 传 zoneId 会被静默忽略(曾据此误判「新游戏不在流」)。`size` 有 `@Max`(=20,**超限校验失败返空 `data` 而非报错**——排「流空」先核 size)。发布编排(创始人「汇入主混合流」)对每次发布**双写** zone0 主流基线 + 若创作者选非零专区再补写该专区(`PublishOrchestrationServiceImpl.writePublishBaseline`),故一款游戏可同时现于主流与其专区;新游戏 sort_score=0,排在已回灌的高分游戏之后、未刷分组之首。
- **API 全绿 ≠ UI 通**:编排器旁路会掩盖 UI 缺陷,用户可见波次收口前必须做一次真 UI 走查(见 [`ui-walkthrough-cdp.md`](./ui-walkthrough-cdp.md))。
+### 游戏内容 OSS 单款隔离验收
+
+这条配方只验证“Agent 从 OSS 续改并回存、Runtime 从 OSS 加载、浏览器可玩”,不切 live,也不代表存量游戏完成迁移。
+
+1. 在 `:48090` 起隔离后端前先加载 `/root/game-staging/infra/.env` 的真实 staging MySQL/Redis 凭据;显式打开 `aigc.game-content-control.enabled=true`、注入凭据档中的 HMAC secret,并设置 `game.artifact-storage.enforce-committed=true`、`read-mode=OSS`。漏加载 env 会以 MySQL 拒绝访问表现,不能误判为业务代码故障。
+2. Agent 用 `tier2/gen-worker/scripts/game_content_repository.py` 调 `prepare` 取得后端分配的版本身份,再从 `game-sources` checkout。LittleJS/Canvas 源码依赖 Git 中的平台引擎与插件,检出目标必须是被 `.gitignore` 排除的 `game-runtime/games/<工作目录>`;测试和构建都在这里运行,具体游戏源码与 `dist` 仍不得加入 Git。
+3. 修改完成后只能走 `commit`:源码归档 → 运行包/素材 → V34 pending → 后端 `finalize` 在同一本地事务中写 Runtime 元数据、CAS V34 committed、绑定版本摘要 → Agent 再幂等确认 committed。`finalize` 不修改 `game_project.current_version_id`;任一步失败都不能切走当前可用版本,重试必须幂等;不得直写 project/runtime 表。
+4. 用 `/gstack` 按新 `versionId` 真开预览页并进入游戏。收口证据至少包括:菜单与玩法截图、canvas 非空像素、游戏状态随时间推进、manifest 与声明素材均走 `/app-api/runtime/package/{versionId}/...`、网络中没有 MinIO 直连。截图和结构化 verdict 上传 `game-evidence`,不进 Git。
+5. 只有浏览器验收通过后才调用 `activate --expected-current-version-id <验收前 current>`。后端在 Project 锁内比较 current:同一版本重试幂等;并发旧快照或向旧版本回切都拒绝。激活失败时 current 必须保持原值。
+6. `gameId`、旧 `versionId` 和调用租户必须一致。历史 tenant=0 探针不能在 tenant=1 下复用;控制面拒绝是正确安全行为,不得靠改库绕过。
+7. 前端构建必须显式指定隔离 API:`VITE_API_BASE=http://100.64.0.7:48090 npm run build -- --mode staging`。iframe 只授予 `allow-scripts`;游戏持久化当前会回落内存适配器,禁止为消除 warning 恢复 `allow-same-origin`。
+8. Runtime reader 只能 `GetObject game-artifacts/tenants/*`。验收时同时验证 List、Put、Delete 和 `game-evidence` GET 被拒绝;不得让 Runtime 复用 writer 或 root 身份。
+
+验收完成后删除临时 OAuth token、对应 Redis 精确 key、本地 checkout 和临时脚本。保留 OSS 中的不可变失败版本用于审计,项目当前版本只指向最后一个通过验收并显式激活的版本。
+
## 6. docker compose 部署到 mini-infra(观测栈第一波实战蒸馏,2026-07-05)
观测栈五件(Collector/Prometheus/Tempo/Loki/Grafana)起在 mini-infra 时,从 compose 写好到五件全绿踩了四个坑,没一个是 compose 语法错——`docker compose config` 全过、`pull` 也成功,问题全在环境和镜像。起容器前按这四条自查,能省一整轮排障。
diff --git a/.gitea/workflows/contract-gates.yml b/.gitea/workflows/contract-gates.yml
index 7dec5b44..2a8713ed 100644
--- a/.gitea/workflows/contract-gates.yml
+++ b/.gitea/workflows/contract-gates.yml
@@ -2,8 +2,8 @@
# pre-commit 是自愿门(--no-verify / 新 clone 未配 hooksPath 可绕),本工作流是不可绕的服务端门。
# 与 docs-gate.yml 平级、各管一摊:docs-gate 管文档治理七检,本工作流管四类契约门。
# 生效前提:Gitea 实例已配 act_runner;未配时本文件静默不跑,不影响开发。
-# CI 侧全量真跑(不做 pre-commit 的路径条件触发):判例库四段尽量装齐 esbuild + pytest 后全跑,
-# 装不齐时 run.mjs 自带零依赖降级、诚实 SKIP 缺依赖段,不假绿。
+# CI 侧全量真跑(不做 pre-commit 的路径条件触发):判例库四段必须装齐 esbuild + pytest,
+# 依赖安装失败或任一整段 SKIP 都判红;本地裸环境仍可由 run.mjs 诚实 SKIP。
name: contract-gates
on:
push:
@@ -19,18 +19,70 @@ jobs:
# 其余四门只读工作区、不依赖 git 历史,fetch-depth 变化对它们无影响。
fetch-depth: 0
- # 判例库 python-gates 段需 pytest;check-undef 段需 esbuild(随 node_modules)。
- # 尽力预装让四段全量真跑;装不上则相应段 run.mjs 会诚实 SKIP。
- - name: 预装门依赖(尽力)
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: maven
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: |
+ game-runtime/package-lock.json
+ game-studio/package-lock.json
+
+ # 服务端门必须真跑全部判例;安装失败立即终止,不能降级成绿色 SKIP。
+ - name: 安装完整门依赖
run: |
- pip3 install --quiet pytest || true
- npm ci --prefix game-runtime/tools/amodel-gen || true
+ pip3 install --quiet pytest jsonschema
+ npm ci --prefix game-runtime
- name: 门金标判例库全量回归
- run: node contracts/gate-fixtures/run.mjs
+ env:
+ GATE_FIXTURES_REQUIRE_ALL: '1'
+ run: |
+ node --test contracts/gate-fixtures/run.test.mjs
+ node contracts/gate-fixtures/run.mjs
- name: play-loop 契约正负样本套件
- run: python3 contracts/play-loop/validate.py --suite
+ run: |
+ python3 contracts/play-loop/validate.py --suite
+ python3 contracts/play-loop/test_reference_asset_trusted_consumption_v2.py
+
+ - name: 游戏内容对象存储契约正负样本套件
+ env:
+ PYTHONPATH: tier2/gen-worker
+ run: >-
+ python3 -m pytest -q
+ tier2/gen-worker/tests/test_game_artifact_store.py
+ tier2/gen-worker/tests/test_game_source_archive.py
+ tier2/gen-worker/tests/test_game_artifact_storage_store.py
+ tier2/gen-worker/tests/test_game_artifact_storage_ddl.py
+ tier2/gen-worker/tests/test_game_content_repository.py
+
+ - name: 游戏内容控制面与 Runtime 事务边界
+ run: |
+ mvn -f game-cloud/pom.xml \
+ -pl huijing-dependencies,game-module-project/game-module-project-server,game-module-runtime/game-module-runtime-server,game-module-aigc/game-module-aigc-server \
+ -am -DskipTests install
+ mvn -f game-cloud/game-module-project/game-module-project-server/pom.xml \
+ -Dtest=GameVersionServiceImplTest \
+ -Dsurefire.failIfNoSpecifiedTests=true test
+ mvn -f game-cloud/game-module-runtime/game-module-runtime-server/pom.xml \
+ -Dtest=RuntimePackageApiImplDogfoodTest,AppRuntimeControllerTest,RuntimeConvertTest,ArtifactStorageGateTest,RuntimeArtifactContentServiceTest,RuntimeArtifactStoragePropertiesTest,RuntimeOssPackageFinalizeServiceTest,RuntimePackageServiceImplTest,S3ArtifactObjectReaderTest \
+ -Dsurefire.failIfNoSpecifiedTests=true test
+ mvn -f game-cloud/game-module-aigc/game-module-aigc-server/pom.xml \
+ -Dtest=GameContentControlControllerTest,GameContentControlServiceTest,GameContentFinalizeTxServiceTest \
+ -Dsurefire.failIfNoSpecifiedTests=true test
+
+ - name: 宿主同源取包与 iframe 安全边界
+ run: |
+ npm test --prefix game-runtime
+ npm ci --prefix game-studio
+ npm run test:host-security --prefix game-studio
+ npm run build --prefix game-studio -- --mode staging
- name: 品类 rubric fixture↔skill 双写对账
run: python3 .agents/tools/rubric-sync-gate.py
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index fa092fa2..cd9ae61e 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -24,6 +24,31 @@ if printf '%s\n' "$STAGED" | grep -qE '^contracts/play-loop/'; then
python3 "$ROOT/contracts/play-loop/validate.py" --suite || { echo '✘ play-loop 契约正负样本套件未过'; fail=1; }
fi
+# ── 门 3b:游戏内容对象存储契约正负样本 —— 触发 = 三份清单/状态迁移/实现或判例变更 ──
+if printf '%s\n' "$STAGED" | grep -qE '(contracts/(game-(package|artifact-manifest|source-archive)\.schema\.json|db-schemas/V3[45].*artifact)|game-cloud/huijing-server/src/main/resources/db/migration/V3[45].*artifact|tier2/gen-worker/(worker/game_(artifact|source_archive|artifact_storage)_store\.py|worker/game_content_repository\.py|scripts/game_content_repository\.py|tests/test_game_(artifact|source_archive|artifact_storage|content_repository)))'; then
+ # mise 的默认 Python 可能未装 pytest;仓内测试 venv 可用时复用,否则明确判红,绝不静默跳过。
+ PYTEST_PYTHON=python3
+ if ! "$PYTEST_PYTHON" -c 'import pytest' >/dev/null 2>&1; then
+ if [ -x "$ROOT/cheap-worker/.venv/bin/python" ] \
+ && "$ROOT/cheap-worker/.venv/bin/python" -c 'import pytest' >/dev/null 2>&1; then
+ PYTEST_PYTHON="$ROOT/cheap-worker/.venv/bin/python"
+ else
+ echo '✘ 游戏内容对象存储门缺少 pytest(默认 Python 与 cheap-worker/.venv 均不可用)'
+ PYTEST_PYTHON=''
+ fail=1
+ fi
+ fi
+ if [ -n "$PYTEST_PYTHON" ]; then
+ PYTHONPATH="$ROOT/tier2/gen-worker" "$PYTEST_PYTHON" -m pytest -q \
+ "$ROOT/tier2/gen-worker/tests/test_game_artifact_store.py" \
+ "$ROOT/tier2/gen-worker/tests/test_game_source_archive.py" \
+ "$ROOT/tier2/gen-worker/tests/test_game_artifact_storage_store.py" \
+ "$ROOT/tier2/gen-worker/tests/test_game_artifact_storage_ddl.py" \
+ "$ROOT/tier2/gen-worker/tests/test_game_content_repository.py" \
+ || { echo '✘ 游戏内容对象存储契约正负样本未过'; fail=1; }
+ fi
+fi
+
# ── 门 4:品类 rubric fixture ↔ skill 双写对账 —— 触发 = 触及品类 rubric fixture 或品类 skill ──
if printf '%s\n' "$STAGED" | grep -qE '(cheap-worker/fixtures/genre-rubrics/|\.agents/skills/(trpg|heritage|puzzle|narrative|sim-business)-game-design\.md)'; then
python3 "$ROOT/.agents/tools/rubric-sync-gate.py" || { echo '✘ rubric 双写对账未过'; fail=1; }
diff --git a/contracts/api-schemas/runtime.yaml b/contracts/api-schemas/runtime.yaml
index 719efd60..5dd76e89 100644
--- a/contracts/api-schemas/runtime.yaml
+++ b/contracts/api-schemas/runtime.yaml
@@ -53,7 +53,8 @@ paths:
【§3.4 C4 关键约束】返回 manifest 的原始 JSON 文本,**不包 CommonResult、不 parse/re-serialize、不加换行**——
宿主对该响应文本原始字节算 sha256,须与 RuntimePackageRespVO.checksum 严格一致(PackageFactory 写入时按相同字节计算并存 game_runtime_package.checksum),
任何包裹/重序列化都会破坏字节一致性导致校验失败。
- MVP 无 OSS:manifest 整包存 game_runtime_package.package_json,由 PackageStore 的 DB impl 读出原样返回(M3 接 OSS 后换 OSS impl,调用方/契约不变,退场契约)。
+ 存储介质由服务端 read-mode=db|shadow|oss 切换;浏览器始终访问本同源端点,不向对象域发送平台 Token。
+ oss 模式只返回 V34 committed locator 指向且通过原文/bundle 摘要校验的对象,任何失败不回退 DB。
取包门禁与 GET /app-api/runtime/package/{versionId} 同源:scene=preview 放行 status∈{0,1} 且校验版本 owner;scene=play 仅放行 status=1;无就绪运行包返回 1-102-001-001。
parameters:
- { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: 版本 ID(project.game_version.id) }
@@ -67,6 +68,26 @@ paths:
type: string
description: 'GamePackage.manifest 的原始 JSON 字符串(原样字节,宿主据此算 sha256 校验后注入运行容器,对齐 #4 manifest 段)'
+ /app-api/runtime/package/{versionId}/assets/{sha256}:
+ get:
+ tags: [app-runtime]
+ summary: 按内容摘要读取版本素材
+ description: >
+ scene/status/preview owner 门与取包端点同源。服务端只从 V34 committed artifact manifest 中按 sha256
+ 映射精确对象 key,逐字节复算 bytes/hash 后返回;客户端不能提交 bucket、key 或 URL。
+ parameters:
+ - { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: 版本 ID }
+ - { name: sha256, in: path, required: true, schema: { type: string, pattern: '^[a-f0-9]{64}$' }, description: 'GamePackage.assets[].hash' }
+ - { name: scene, in: query, required: false, schema: { type: string, enum: [preview, play], default: play }, description: 取包场景 }
+ responses:
+ '200':
+ description: >
+ 素材原始字节;Content-Type/Length/ETag 均来自已验清单和真实对象。
+ preview 返回 Cache-Control: private,no-store;play 的 version+sha256 内容寻址素材返回 public,max-age=31536000,immutable。
+ content:
+ application/octet-stream:
+ schema: { type: string, format: binary }
+
/app-api/runtime/session/start:
post:
tags: [app-runtime]
@@ -236,15 +257,16 @@ components:
type: object
description: >-
版本运行包清单。字段语义对齐 GamePackage(#4).manifest,宿主据此渲染 iframe 并桥接 SDK(#3)。
- 【GAP-2 取包约束】① 宿主先 fetch manifestUrl 取 manifest,按 checksum 做 sha256 完整性校验通过后再注入运行容器(T-RT-15),校验失败拒绝加载并落 error;
- ② OSS/CDN 须放行宿主 origin 的 CORS(AllowedOrigin=宿主 origin、AllowedMethod=GET/HEAD、ExposeHeader=ETag),否则宿主跨域 fetch 被拦;
+ 【取包约束】① 宿主只从受信 API origin 的同源 manifestUrl 读取 manifest,按 checksum 做 sha256 完整性校验通过后再注入运行容器(T-RT-15);
+ ② DB/shadow/oss 的对象读取由 Runtime 服务端完成,浏览器不直连对象域、不向对象域发送平台 Token;
③ scene=preview|play 鉴权与取包门禁须一致:preview 仅版本 owner 可取未发布包、play 仅放行已发布包(与 getPackageManifest 门禁同源,不在前端兜底)。
properties:
gameId: { type: integer, format: int64, description: 游戏 ID }
versionId: { type: integer, format: int64, description: 版本 ID }
templateId: { type: string, description: 玩法模板 ID(宿主据此选 Runtime 容器) }
- packageUrl: { type: string, description: 'GamePackage(manifest)OSS/CDN URL,按 /games/{gameId}/versions/{versionId}/ 版本化(#4)' }
- manifestUrl: { type: string, description: '§3.4 C4:指向 GET /app-api/runtime/package/{versionId}/manifest 端点(MVP 包存 DB,端点原样服务 manifest JSON)。宿主先 fetch 此端点取 manifest,对响应原始文本算 sha256 与 checksum 严格比对通过后再注入运行容器(相对 URL 须 resolve 到 API base,见 §2.5);M3 接 OSS 后改指向 OSS/CDN 版本化 URL(退场契约)' }
+ packageUrl: { type: string, description: '历史兼容字段;浏览器不得据此直连对象域,现行取包统一使用 manifestUrl' }
+ manifestUrl: { type: string, description: '固定指向 GET /app-api/runtime/package/{versionId}/manifest 同源端点;Runtime 服务端按 db/shadow/oss 模式读取原文,宿主校验响应原始 sha256 后注入运行容器' }
+ assetUrlTemplate: { type: string, description: 'OSS 主读模式下返回的对象素材同源代理模板;宿主把 {sha256} 替换为 GamePackage.assets[].hash 后带平台鉴权读取。DB/shadow 不返回' }
entry: { type: string, description: 入口文件相对路径(#4 manifest.entry) }
runtimeVersion: { type: string, description: '目标 WanxiangGameSDK / Canvas Runtime 版本(semver,#4 manifest.runtimeVersion)' }
preloadPolicy: { type: string, enum: [eager, lazy], description: '预加载策略(#4 manifest.preloadPolicy)' }
@@ -255,12 +277,11 @@ components:
SandboxPolicyVO:
type: object
description: >-
- iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14)。
- 【GAP-2 CORS 约束】allowOrigins 同时是 OSS/CDN 取包(manifestUrl/packageUrl)须放行的跨域来源:
- OSS 桶 CORS 规则 AllowedOrigin 应与本白名单一致(含宿主 origin),AllowedMethod=GET/HEAD、ExposeHeader=ETag,否则宿主跨域 fetch manifest 被拦。
+ iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 postMessage origin 白名单(T-RT-14)。
+ manifest 与对象素材均由受信 Runtime API 同源代理,本字段不承担对象存储 CORS 配置。
properties:
- sandboxAttr: { type: string, description: "iframe sandbox 属性值(如 'allow-scripts allow-same-origin')" }
- allowOrigins: { type: array, items: { type: string }, description: 'postMessage 允许的 origin 白名单(宿主侧 #3 双校验);亦为 OSS/CDN 取包 CORS 须放行的宿主 origin' }
+ sandboxAttr: { type: string, description: "iframe sandbox 属性值(现行固定为 'allow-scripts')" }
+ allowOrigins: { type: array, items: { type: string }, description: 'postMessage 允许的 origin 白名单(宿主侧来源窗口/origin/schema 三校验)' }
# ---- 试玩会话开/收(脊柱:play_start/end/时长 → 回灌 telemetry #5)----
SessionStartReqVO:
diff --git a/contracts/db-schemas/V35.0.0__clarify_game_artifact_manifest_hash.sql b/contracts/db-schemas/V35.0.0__clarify_game_artifact_manifest_hash.sql
new file mode 100644
index 00000000..6394d588
--- /dev/null
+++ b/contracts/db-schemas/V35.0.0__clarify_game_artifact_manifest_hash.sql
@@ -0,0 +1,12 @@
+-- =============================================================================
+-- 契约 #2 DB 迁移 | 模块:runtime/storage(对象清单摘要语义)| owner:WS3 + WS2
+-- 文件:V35.0.0__clarify_game_artifact_manifest_hash.sql
+-- 目的:澄清 V34 两个契约清单摘要字段保存的是契约 manifestHash,而非清单原始字节摘要。
+-- 边界:V34 已执行且保持字节不可变;本迁移只修改列注释,不改变数据和索引。
+-- =============================================================================
+
+ALTER TABLE `game_artifact_storage`
+ MODIFY COLUMN `artifact_manifest_hash` CHAR(64) NOT NULL DEFAULT ''
+ COMMENT 'GameArtifactManifest/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)',
+ MODIFY COLUMN `source_manifest_hash` CHAR(64) NOT NULL DEFAULT ''
+ COMMENT 'GameSourceArchive/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)';
diff --git a/contracts/gate-fixtures/run.mjs b/contracts/gate-fixtures/run.mjs
index 2fd7d12d..ad92f014 100644
--- a/contracts/gate-fixtures/run.mjs
+++ b/contracts/gate-fixtures/run.mjs
@@ -20,13 +20,15 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
-import { dirname, resolve } from 'node:path';
+import { delimiter, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// gate-fixtures → contracts → 仓根
const REPO_ROOT = resolve(__dirname, '../..');
const NODE = process.execPath;
+// 服务端 CI 要求四段全部真跑;本地裸环境未装可选依赖时仍保留诚实 SKIP。
+const REQUIRE_ALL = process.env.GATE_FIXTURES_REQUIRE_ALL === '1';
/** pytest 是否可用(缺则 python-gates 整段 skip,保持零新依赖)。 */
function pytestAvailable() {
@@ -70,14 +72,39 @@ function runNodeStage(stage) {
return { ...stage, status, ...c, note, raw: out };
}
-/** 跑 python-gates(pytest);缺 pytest 则整段 SKIP。 */
-function runPythonStage(stage) {
- if (!pytestAvailable()) {
+/**
+ * 跑 python-gates(pytest);strict 模式下固定判例缺少任意一个都立即判红。
+ * 可注入根目录与 pytest 探针,供入口自身用真实临时文件验证缺失判例分支。
+ */
+export function runPythonStage(stage, options = {}) {
+ const repoRoot = options.repoRoot ?? REPO_ROOT;
+ const requireAll = options.requireAll ?? REQUIRE_ALL;
+ const hasPytest = options.hasPytest ?? pytestAvailable;
+ if (!hasPytest()) {
return { ...stage, status: 'SKIP', pass: 0, fail: 0, skip: 0, note: '未装 pytest → 整段跳过(零新依赖口径);装后本段覆盖 tier2/cheap 的门' };
}
- const files = stage.files.filter((f) => existsSync(resolve(REPO_ROOT, f)));
+ const missing = stage.files.filter((f) => !existsSync(resolve(repoRoot, f)));
+ if (requireAll && missing.length > 0) {
+ return {
+ ...stage,
+ status: 'MISSING',
+ pass: 0,
+ fail: 0,
+ skip: 0,
+ note: `pytest 判例文件缺失:${missing.join(',')}`,
+ };
+ }
+ const files = stage.files.filter((f) => existsSync(resolve(repoRoot, f)));
if (files.length === 0) return { ...stage, status: 'MISSING', pass: 0, fail: 0, skip: 0, note: 'pytest 判例文件全缺失' };
- const r = spawnSync('python3', ['-m', 'pytest', '-q', ...files], { cwd: REPO_ROOT, encoding: 'utf8' });
+ // tier2 测试按服务部署布局导入顶层 worker 包;统一门必须复刻该模块根,不能依赖调用者碰巧设置 PYTHONPATH。
+ const pythonPath = [resolve(repoRoot, 'tier2/gen-worker'), process.env.PYTHONPATH]
+ .filter(Boolean)
+ .join(delimiter);
+ const r = spawnSync('python3', ['-m', 'pytest', '-q', ...files], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ env: { ...process.env, PYTHONPATH: pythonPath },
+ });
const out = `${r.stdout || ''}${r.stderr || ''}`;
const c = parsePytest(out);
let status;
@@ -140,6 +167,7 @@ function main() {
if (r.status === 'SKIP' && (r.pass + r.fail === 0)) {
console.log(` ${r.note || '整段跳过'}`);
skipStages += 1;
+ if (REQUIRE_ALL) hardFail += 1;
} else if (r.status === 'MISSING' || r.status === 'ERROR') {
console.log(` ${r.note || '判例执行异常'}`);
hardFail += 1;
@@ -152,7 +180,7 @@ function main() {
console.log(`\n${'='.repeat(72)}`);
const green = hardFail === 0 && totFail === 0;
- console.log(`总计:${totPass} 过 / ${totFail} 挂 / ${totSkip} 跳;${skipStages} 段整段跳过 → ${green ? '绿(全量通过)' : '红(有判据回归)'}`);
+ console.log(`总计:${totPass} 过 / ${totFail} 挂 / ${totSkip} 跳;${skipStages} 段整段跳过 → ${green ? '绿(全量通过)' : '红(有判据回归或必跑段跳过)'}`);
if (!green) {
console.log('失败明细见上;门判据改动前必须先让本回归全绿。');
for (const r of results) if (r.status === 'FAIL' || r.status === 'ERROR' || r.status === 'MISSING') {
@@ -162,4 +190,7 @@ function main() {
process.exit(green ? 0 : 1);
}
-main();
+// 被 node:test 导入时只暴露纯入口;直接执行仍保持原 CLI 行为与退出码。
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main();
+}
diff --git a/contracts/gate-fixtures/run.test.mjs b/contracts/gate-fixtures/run.test.mjs
new file mode 100644
index 00000000..0b000aed
--- /dev/null
+++ b/contracts/gate-fixtures/run.test.mjs
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import test from 'node:test';
+
+import { runPythonStage } from './run.mjs';
+
+test('strict 模式下固定 Python 判例缺少任意一个都判为 MISSING', () => {
+ const root = mkdtempSync(join(tmpdir(), 'gate-fixtures-missing-'));
+ try {
+ writeFileSync(join(root, 'present.py'), 'def test_present():\n assert True\n');
+ const result = runPythonStage(
+ { name: 'python-gates', files: ['present.py', 'missing.py'] },
+ { repoRoot: root, requireAll: true, hasPytest: () => true },
+ );
+
+ assert.equal(result.status, 'MISSING');
+ assert.match(result.note, /missing\.py/);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py b/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py
index 335deb7d..8cd7a5e0 100644
--- a/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py
+++ b/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py
@@ -294,6 +294,19 @@ def test_task2_manifest_excludes_gitignored_candidates_in_worktree() -> None:
if initialized.returncode != 0:
return
+ # 这组 /2 判例复验的是迁移前已经入库的历史金标。仓根现已默认忽略所有具体游戏,
+ # 因此临时 worktree 必须显式复刻“批准范围已被 Git 跟踪”,新增候选仍保持未跟踪并受 ignore 约束。
+ tracked = subprocess.run(
+ ["git", "add", "-f", "--", *source_paths],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ check("临时 worktree 复刻历史金标已跟踪状态", tracked.returncode == 0, tracked.stderr[-1000:])
+ if tracked.returncode != 0:
+ return
+
ignored_candidates = (
"game-runtime/games/shanhai-xingji/src/debug.js",
"game-runtime/games/shanhai-xingji/src/cache.js",
diff --git a/docs/architecture/后端/数据模型.md b/docs/architecture/后端/数据模型.md
index a900221f..87281d99 100644
--- a/docs/architecture/后端/数据模型.md
+++ b/docs/architecture/后端/数据模型.md
@@ -10,7 +10,7 @@ date: 2026-06-22
> **给谁看**:要动表结构或写跨模块查询的后端工程师、做数据侧 code review 的人、想搞清"一款游戏从一句话到能赚钱,数据在库里怎么流转"的人。
> **怎么读**:第 1 章一张跨域主干图建立全局心智;第 2 章按业务链路下钻看每条链的实体关系;第 3 章是按表速查的索引,从这里跳进具体迁移;第 4、5 章是两处最容易误解的真相 —— 软关联和数据语义。
-地面实情以 Flyway 迁移为准。执行权威目录是 `game-cloud/huijing-server/src/main/resources/db/migration/`,共 25 个 `V1` 到 `V25` 的 `.sql`。`contracts/db-schemas/` 是跨模块迁移的授权源副本,但只含 18 个(`V1`-`V13` + `V21`-`V25`):单模块的 additive `ALTER`(`V14`-`V20`)不进 contracts,只在执行目录。只读 contracts 会漏掉 `game_aigc_task` 的 `level/trace/source/modify` 系列列、`game_source_project` 的并发幂等唯一键、以及 `game_feed_rank` 的 `exposure_limit`。每个 `V*.sql` 的头注写满了权属决策、幂等语义、seam 边界和状态机流转,是表级细节的权威源;本页只锚点引用,不抄全文。
+地面实情以 Flyway 执行迁移为准。执行权威目录是 `game-cloud/huijing-server/src/main/resources/db/migration/`,共 35 个 `V1` 到 `V35` 的 `.sql`。`contracts/db-schemas/` 是跨模块镜像,现有 24 个文件,与执行目录仍未全量一致:它缺 `V14`-`V20`、`V26`-`V30`,并多一份未进入执行目录的 `V31.0.1` 回退稿。只读 contracts 会漏掉 `game_aigc_task` 的 `level/trace/source/modify/idempotency` 系列列、`game_source_project` 的并发幂等唯一键和配置治理表。每个执行迁移的头注写明权属、幂等和状态流转,是表级细节的权威源;本页只锚点引用。
迁移覆盖的是 11 个 `game-module-*` 业务模块加 passport 身份层,约 40 张表。蓝图里另列的 `pay`(支付)和 `ip`(素材授权)两个模块当前没有独立的 `game_pay_*` / `game_ip_*` 迁移落地:赋余额走 trade 的 `game_trade_grant`,素材登记走 studio 的 `game_material`,真实支付与版权授权是 M4 之后的事。
@@ -26,6 +26,7 @@ erDiagram
game_project ||--o{ game_version : "1:N 版本"
game_project ||--|| game_version : "current_version_id 指针"
game_version ||--|| game_runtime_package : "一版本一包"
+ game_version ||--o| game_artifact_storage : "OSS 对象身份与提交态"
game_aigc_task ||--o| game_version : "生成产物回填"
game_source_project }o--|| game_version : "源↔产物解耦"
game_project ||--o{ game_feed_rank : "status=4 入流"
@@ -82,7 +83,11 @@ erDiagram
### 2.2 编译发布与合规闸门(runtime + compliance)
-aigc 产物交给 runtime 编译。`game_runtime_build` 是编译任务,`status` 走 0 排队 / 1 编译中 / 2 成功 / 3 失败,成功时把 `package_url` / `bundle_size` / `checksum` 回写。编译产出一份对外清单 `game_runtime_package`,一个版本一行(`uk_version`),试玩宿主据它加载游戏:`entry` / `runtime_version` / `preload_policy` / `sandbox_attr` / `allow_origins` 描述 iframe 沙箱;`status` 走 0 预览就绪 / 1 已发布 / 2 已失效,是取包门禁的权威字段,和 `game_version.status` 不是一回事。MVP 没有 OSS,`V10` 给它加的 `package_json LONGTEXT` 让整包内嵌进 DB。
+aigc 产物交给 runtime 编译。`game_runtime_build` 是编译任务,`status` 走 0 排队 / 1 编译中 / 2 成功 / 3 失败,成功时把 `package_url` / `bundle_size` / `checksum` 回写。编译产出一份对外清单 `game_runtime_package`,一个版本一行(`uk_version`),试玩宿主据它加载游戏:`entry` / `runtime_version` / `preload_policy` / `sandbox_attr` / `allow_origins` 描述 iframe 沙箱;`status` 走 0 预览就绪 / 1 已发布 / 2 已失效,是取包门禁的权威字段,和 `game_version.status` 不是一回事。
+
+`V10` 增加的 `package_json LONGTEXT` 只服务旧 DB 读取模式。OSS 版本不保存游戏包正文,`package_json` 为 NULL;`game_runtime_package` 只留宿主所需元数据与原始 manifest hash。`V34` 的 `game_artifact_storage` 保存 artifact/source manifest 的 bucket、canonical key、hash、对象总量以及 pending/committed 状态,Runtime 只消费 committed 行;`V35` 以追加迁移澄清 `artifact_manifest_hash` 的语义,没有修改已应用 V34。
+
+OSS 内容版本有两道独立门。`finalize` 只把 Runtime 元数据、V34 committed 和 `game_version` 摘要放进同一个本地事务,不写 `game_project.current_version_id`;浏览器按精确版本验收后,`activate` 才锁定 Project,比较 `expectedCurrentVersionId` 并更新当前指针。同一版本重试幂等,过期快照和向旧版本回切都拒绝。2026-07-30 的隔离 staging 实证将 `gameId=80035` 从 `versionId=93159` 显式激活到 `versionId=93160`,失败的旧版回切后当前指针仍为 `93160`。默认生产读取模式与存量游戏尚未迁移。
试玩会话 `game_runtime_session` 既是试玩入口,也是遥测回灌的源头。`client_play_token` 做幂等(`uk_play_token`),`scene` 区分 preview / play;它的 `sessionId` 透传进遥测做 `session_id` 对账键,但试玩本身不计 `play_count`。`V11` 把 `player_user_id` 放宽成可空、加了 `anon_id`,匿名玩家也能试玩。
@@ -94,6 +99,7 @@ erDiagram
game_runtime_build }o--|| game_version : "version_id"
game_runtime_build ||--|| game_runtime_package : "成功产清单"
game_runtime_package ||--|| game_version : "uk_version 一版一行"
+ game_artifact_storage ||--|| game_version : "uk_version 对象提交态"
game_runtime_session }o--|| game_version : "试玩 version_id"
game_runtime_package ..> game_version : "回写 package_url/checksum"
game_compliance_gate_result }o--|| game_project : "publish 落门"
@@ -182,7 +188,8 @@ erDiagram
| game_project_zone | project | 游戏↔专区 M:N | uk_game_zone(game_id,zone_id) | V1 L106 |
| game_aigc_task | aigc | 无状态生成原子 / 异步队列 | id(=taskId);prompt_hash 缓存键 | V2 L23;V15/16/17/19 ALTER |
| game_runtime_build | runtime | 编译任务 | game_id / version_id | V3 L23 |
-| game_runtime_package | runtime | 对外清单,试玩宿主据此加载 | uk_version(一版一行) | V3 L56;V10 ALTER |
+| game_runtime_package | runtime | 宿主元数据;旧 DB 模式可内嵌正文,OSS 模式正文为 NULL | uk_version(一版一行) | V3 L56;V10 ALTER |
+| game_artifact_storage | runtime | OSS artifact/source 定位、摘要与 pending/committed 状态 | uk_version(一版一行) | V34;V35 语义澄清 |
| game_runtime_session | runtime | 试玩会话,遥测回灌源 | uk_play_token | V3 L90;V11 ALTER |
| game_feed_rank | feed | 排序缓存 / 精选覆盖层 | uk_game_zone(game_id,zone_id) | V4 L19;V25 ALTER |
| game_feed_interact_log | feed | 互动信号幂等流水 | uk_user_game_action | V4 L47 |
diff --git a/docs/architecture/架构/契约总览.md b/docs/architecture/架构/契约总览.md
index e6f744f1..d29acc40 100644
--- a/docs/architecture/架构/契约总览.md
+++ b/docs/architecture/架构/契约总览.md
@@ -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` + `contracts/game-artifact-manifest.schema.json` + `contracts/game-source-archive.schema.json` | WS3 + WS2 | 生成 → 编译 → 源归档/对象存储 → 预览 → 发布 → 运行全链路 | GamePackage 已建;制品索引和引擎无关源归档契约已建、生产接线分波迁移 |
+| **跨仓 #4 GamePackage** | `contracts/game-package.schema.json` + `contracts/game-artifact-manifest.schema.json` + `contracts/game-source-archive.schema.json` | WS3 + WS2 | 生成 → 编译 → 源归档/对象存储 → 预览 → 发布 → 运行全链路 | 单款隔离链路已跑通;批量迁移与生产切换未完成 |
| **跨仓 #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 | 已建 |
@@ -58,6 +58,10 @@ flowchart TB
#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 源工程继续绑定现有 SourceProjectStore;LittleJS/Canvas 等游戏通过 `game_source_archive` provider 绑定引擎无关源归档。对象存储提交记录由 V34 `game_artifact_storage` 保存,不能把 MinIO marker 当作 committed。
+2026-07-30 的隔离 staging 实证已经把单款链路闭合。Agent 先向 game-cloud 申请版本身份,把 `game-sources` 源码物化到 Git 忽略的游戏工作目录,并使用 Git 中的平台引擎与插件重新测试、构建;`commit/finalize` 上传源码、运行包和素材,在一个本地事务中写 Runtime 元数据、V34 committed 与版本摘要,但不改项目当前版本。浏览器按新 `versionId` 真玩通过后,独立 `activate` 才在 Project 锁内按 `expectedCurrentVersionId` 切换 `current_version_id`;重复激活幂等,并发旧快照和旧版本回切都拒绝。
+
+最终激活的是 `gameId=80035/versionId=93160`:`artifactManifestHash=81664710da3c3ed84ca7fbda5c989a2b469df66080c573a1cee00c1b6ae2226c`、`bundleHash=1592c8d10cff2823a1164a9cb3f077b2a0fa8b147b2dd80357ed210853879653`、`runtimeManifestHash=dd0678e160eaf12999e6c9cefb796e5cf2815a8a4c37c982afb6c1e1097dac1e`、`sourceManifestHash=9186f0c3f4e47c6a3682dddfbe03e25367bdd688885199f418423aa048ad5d67`。数据库只保存版本、hash、对象定位和 committed 状态,`game_runtime_package.package_json` 保持空;Runtime 按同源 `/app-api/runtime/package/{versionId}/manifest|assets/**` 从 OSS 代理内容,浏览器不接触 MinIO 凭据或直链。这证明了单款路径,不代表存量游戏已经迁移,也不代表生产 Runtime 已切换 OSS。
+
`agent-loop/` 和 `templates/` 这两个目录在 `contracts/README.md` 的目录结构图里没有 —— 那张图只画了跨仓 8 类。新人 `ls` 到它们时无从对应,这是 README 自身的覆盖缺口,在此补登:`agent-loop/` = 生成线的源项目 keystone 加 QA 闭环工件契约族,`templates/` = 13 个品类/形态 schema。
## "第几类"口径收口
@@ -95,16 +99,17 @@ flowchart LR
## 防漂移门:声明的 vs 代码里的
-`contracts/` 的纪律靠几道"防漂移门"撑着,文档把它们写成 CI 会自动拦截。核到代码里,这些门目前都不存在 —— 它们是文档承诺,不是运行中的机器门。
+`contracts/` 的防漂移门只覆盖已经落成的具体契约,不得把局部机器门外推成全仓完备。游戏内容对象存储契约已有 pre-commit 条件门和 Gitea Actions 全量门;DB 全量镜像、Flyway validate 等旧承诺仍未兑现。
| 防漂移承诺 | 文档出处 | 代码实情 |
|---|---|---|
+| GamePackage / artifact / source / V34-V35 与生产消费实现 | `.githooks/pre-commit`、`.gitea/workflows/contract-gates.yml` | **已落地**:pre-commit 对相关变更触发 5 组 Python 对象契约测试;CI 对每次 push / PR 另跑 Project/Runtime/AIGC 事务测试、宿主安全测试与前端构建 |
| `contracts/db-schemas` ↔ 执行副本 Flyway diff 一致 | [README.md:41](../../../contracts/README.md) | **已漂移**:18 vs 25,加 Huijing/Yudao 审计列注释差 |
| CI 跑 `flyway validate` 阻断不合规 | [README.md:62](../../../contracts/README.md) | 全仓唯一 CI = `game-cloud/.github/workflows/maven.yml`,是 yudao fork 继承件:`on push to master`(本仓分支 dev/2.0.0、默认 dev/1.0.0,**永不触发**)、`-Dmaven.test.skip=true`(跳测)、**无 flyway validate、无 contracts↔migration diff 步** |
| prompt 变更过"四道闸(CI)" | [registry.yaml:3](../../../contracts/prompts/registry.yaml) | 只是 YAML 注释,未见对应 workflow |
| 改契约先改 contracts 再写码 | README.md:4 / [contract-first-development.md](../../../.agents/skills/contract-first-development.md) | 纯口头纪律,无脚本校验 |
-`deploy/smoke-test.sh` 里 grep `flyway`/`contracts`/`diff`/`cmp` 零命中,确认这套门在部署 smoke 阶段也不存在。同步当成纯人工纪律在跑,DB 镜像已经因此漂掉。
+`deploy/smoke-test.sh` 里仍没有 `flyway`/`contracts`/`diff`/`cmp` 门;对象存储契约由 pre-commit 与 Gitea Actions 守护,DB 全量镜像同步仍靠人工,已有漂移。
这是一个待决策位,不在本档自裁:要么补一道真门 —— 在 wave-close 或 pre-commit 加一步 `contracts/db-schemas` ↔ migration 的 `cmp`,漂移即红线拦截;要么干脆裁定执行副本为唯一源、把 `contracts/db-schemas` 降为只读快照或删掉,消除"声称授权源却滞后"的矛盾。无论哪条,先得让 README §41 的口径和代码实情对上。
diff --git a/docs/内网凭据与端点.md b/docs/内网凭据与端点.md
index 18a36ba7..01f85325 100644
--- a/docs/内网凭据与端点.md
+++ b/docs/内网凭据与端点.md
@@ -56,7 +56,7 @@ canonical: true
| 服务 | 端点(Tailscale) | 凭据 | 用途 |
|---|---|---|---|
| **Redis** | `100.64.0.8:6379` | `requirepass` = `9ea28f5d28d68b09bfd7ccfc31216a52` | tier2 Agent Service MessageBus / session 状态(create_app) |
-| **MySQL** | `100.64.0.8:3306` | root / `ZRH3jwYLOrntBcTAw29MW9BP` | tier2 落库源工程版本表(manifest);game-cloud 业务库 |
+| **MySQL** | `100.64.0.8:3306` | root / `ZRH3jwYLOrntBcTAw29MW9BP` | tier2 落库源工程版本表(manifest);不是 game-cloud 业务库 |
| **MinIO**(S3 兼容 OSS) | `100.64.0.8:9000`(控制台 9001) | `ragflow` / `6c4b77b2f055c8a66e00e3c38ef3d818c166951d478cc7cc` | tier2 落库源文件全文(key=`tier2-src///<相对路径>`) |
| **PostgreSQL** | `100.64.0.8:5433`(外)→5432(内) | root / `f6710e2d0294eb1c10e26a805a64bc54` | new-api 库(users/tokens/quota,WU2 预置池)/ ragflow;超级用户 = **root** 非 postgres |
@@ -76,6 +76,17 @@ canonical: true
| 验收证据桶 | `game-evidence` |
| 允许资源 | `game-artifacts/tenants/*`、`game-evidence/tenants/*` |
+Runtime 不复用 writer。`game-artifact-runtime-reader` 只允许读取已提交制品桶的 `tenants/*` 对象,不能列举、上传、删除,也不能读取 `game-evidence`;Spring staging 配置默认仍为 `read-mode=DB`,隔离验收实例才显式覆盖为 `OSS`。
+
+| 项 | 值 |
+|---|---|
+| access key | `game-artifact-runtime-reader` |
+| secret key | `b863aa88d40c4a07c4f7ffc44d1b51e016b0014ec570b8010fd6d9315d0ef24c` |
+| 策略 | `game-artifact-runtime-reader-policy-v1` |
+| 允许资源 | `game-artifacts/tenants/*` 的 `GetObject` |
+
+Agent 不直写 project/runtime 表。隔离 staging 受信任控制面为 `http://100.64.0.7:48090/admin-api/aigc/game-content/{prepare,finalize,activate}`,HMAC secret 为 `acfe0d5af3da62470f0cf2af4820f1e3fedbe76c81dd905ca847cf2480bf97b9`。主配置默认 `enabled=false` 且空密钥拒绝启动该能力;只有隔离验收实例显式打开。`prepare` 在 project 边界分配并锁定版本身份;Agent 上传时对每个对象做 GET 回读和 hash 对账;`finalize` 校验对象定位、原始 GamePackage、bundle 字节数与 hash,在同一个本地事务内写 Runtime 元数据、V34 CAS committed 和版本摘要,不修改项目当前指针;浏览器验收通过后,`activate` 才按 `expectedCurrentVersionId` 锁定并切换 `current_version_id`。Runtime 消费每个素材时仍会逐字节复算,不把上传回执当成读取信任。
+
### 引擎无关源归档专用身份(2026-07-29)
| 项 | 值 |
@@ -88,11 +99,15 @@ canonical: true
2026-07-29 权限实测:artifact writer 的两个允许前缀 Put/Get、前缀内 List 和完整 GET/物化流程通过;根列桶只返回该身份可见的制品/证据桶,列/写 `tier2-src`、删除已写对象均被拒绝。当天创建 `game-sources` 与独立 source writer 后,source writer 在 `tenants/*` 下 Put/Get/List 成功,删除、越前缀写入和访问 `game-artifacts` 均被拒绝;root 只用于清理临时权限探针。运行器分别读取 `tier2/config/infra.yaml` 的 `game_artifact_minio` 与 `game_source_minio` 分区,任何一个分区缺失都必须拒绝联网,禁止回落到 `minio` root 或另一身份。
+2026-07-30 新增 Runtime reader 权限实测:已提交制品对象 GET 成功;List、Put、Delete 和 `game-evidence` GET 均返回拒绝。Agent 的 staging committed 权威固定为 mini-desktop 本机 `127.0.0.1:13306/ruoyi-vue-pro`,运行 CLI 时显式选择 `game_content_staging_mysql`;不得改连 mini-infra 的空 `game_cloud`。
+
两类身份都只代表对象上传/回读权限,不单独代表数据库 committed。source CLI 首次上传回执为 `verified_pending`;本轮 staging 随后完成 V34 数据库条件创建/完整不可变字段 CAS,形成 `committed=true` 回执。
-同日生产身份盘点纠偏:mini-infra 的 `game_cloud` 空库没有接入现行 game-cloud,不是游戏身份权威。内网 staging 的真实消费库是 mini-desktop `game-staging-mysql/ruoyi-vue-pro`;2026-07-29 在隔离服务进程中应用 V34 后,库中共有 34 条成功 Flyway 历史、110 个项目、53 个版本和 54 个运行包,并通过正式 App API 创建了对象存储验收项目 `gameId=80034`、版本 `versionId=93156`。V34 存储记录已按 artifact/source manifest、bucket、provider、版本和 hash 完整 CAS 为 `status=committed/committed=true`;该 staging 身份只用于本轮隔离验收,不能当作生产发布或正式金标身份,也禁止在空 `game_cloud` 建无人消费的临时记录。
+同日生产身份盘点纠偏:mini-infra 的 `game_cloud` 空库没有接入现行 game-cloud,不是游戏身份权威。内网 staging 的真实消费库是 mini-desktop `game-staging-mysql/ruoyi-vue-pro`;2026-07-29 的 `gameId=80034/versionId=93156` 是迁移探针,因旧数据租户身份不一致,被新控制面正确拒绝继续复用。2026-07-30 新建的隔离验收项目 `gameId=80035` 有三个 committed OSS 版本 `93158`、`93159`、`93160`,当前指针已经浏览器验收和显式激活切到 `versionId=93160`。该 staging 身份只用于本轮隔离验收,不能当作生产发布或正式金标身份,也禁止在空 `game_cloud` 建无人消费的临时记录。
-运行时对象提交门已接入 game-cloud 的取包、原始 manifest 和发布入口,配置键为 `game.artifact-storage.enforce-committed`,默认 `false` 仅用于生产切换前的兼容窗口。此前隔离 Runtime 打开 `true` 时,真实 `GET /app-api/runtime/package/93156?scene=preview` 对 pending 记录返回 `1102001001`,日志记录 `[artifactStorageGate] 对象记录未 committed,拒绝运行时消费`;同一版本的 artifact manifest 已由专用 writer 全量 GET 回读并物化 263 个对象,`runtimeManifestHash=1191b84776703ce009d361c920da0c3dbb73f328f1383e25580915b10937e4ee`,staging generic 适配 bundle 的 `bundleHash=ef2fff2764227b773e4f4dc092941ddbef7f7062fae2dae80160992de637066d`,下载后逐字节一致。提交后的同一进程因 staging Spring 循环依赖未能启动,未宣称 HTTP 放行已复测;正式金标继续使用已签认 bundle `17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd`,未切换 OSS `PackageStore`。
+运行时对象提交门已接入 game-cloud 的取包、原始 manifest 和发布入口,配置键为 `game.artifact-storage.enforce-committed`,默认 `false` 仅用于生产切换前的兼容窗口。隔离 Runtime 在 `:48090` 同时打开 `enforce-committed=true` 与 `read-mode=OSS`,`versionId=93160` 的 `manifest` 与 6 个声明素材均经同源 Runtime 路径返回 200,浏览器没有请求 `100.64.0.8:9000`。这一版 `artifactManifestHash=81664710da3c3ed84ca7fbda5c989a2b469df66080c573a1cee00c1b6ae2226c`、`bundleHash=1592c8d10cff2823a1164a9cb3f077b2a0fa8b147b2dd80357ed210853879653`、`runtimeManifestHash=dd0678e160eaf12999e6c9cefb796e5cf2815a8a4c37c982afb6c1e1097dac1e`、`sourceManifestHash=9186f0c3f4e47c6a3682dddfbe03e25367bdd688885199f418423aa048ad5d67`、`sourceHash=18451759d2272a1f2e17ec6969309f7e3b6d57c4b22aa8ed54d88cc423d27775`。`finalize` 后当前版本仍为 `93159`;浏览器验收后以 `expectedCurrentVersionId=93159` 激活 `93160`,同请求重试幂等,尝试回切 `93158` 被拒绝且指针留在 `93160`。
+
+R4 浏览器证据位于 `game-evidence/tenants/1/games/80035/versions/93160/evidence/browser/`。`post-review-menu.png` 和 `post-review-gameplay.png` 的 SHA-256 分别为 `b4a01427cba73105c5287f5af1586281478a1e7f75efbd5fe91721faa85c0146` 与 `ff442ebcd2e3697493be4e50a188c9ef7102664b1702aa1ef614701064197502`;`browser-chain-verdict.json` 与 `activation-verdict.json` 分别为 `903318f2a551c4edb858131a59a49cced2af9b1acd30bd9954a534e9fceb9701` 与 `67dcd54b2505504ce4589dc441ca100b612a37054e369623565e98ff5a7fe0f6`。真玩在 8.3 秒时仍为 `play`,击杀 6、生命 136、位置已从原点移到 `(527.2,131.8)`;画布抽样 20678 个不透明点、9052 个亮点、975 种量化颜色。激活前后两轮均捕获 9 个 Runtime 请求、0 个 MinIO 直连、0 个非 2xx 请求。这只完成单款隔离链路;存量游戏批量迁移、`:48080` 生产切换和正式金标重新绑定均未执行。
---
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlController.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlController.java
new file mode 100644
index 00000000..54fab160
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlController.java
@@ -0,0 +1,151 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.content;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.wanxiang.huijing.framework.common.pojo.CommonResult;
+import com.wanxiang.huijing.framework.tenant.core.aop.TenantIgnore;
+import com.wanxiang.huijing.game.module.aigc.service.content.GameContentControlService;
+import com.wanxiang.huijing.game.module.aigc.service.content.GameContentSignatureVerifier;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.annotation.security.PermitAll;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.util.StringUtils;
+
+import static com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
+import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
+
+/** Agent 游戏内容仓库的受信任 prepare/finalize/activate 控制面。 */
+@Slf4j
+@Tag(name = "管理后台 - 游戏内容控制面")
+@RestController
+@RequestMapping("/aigc/game-content")
+public class GameContentControlController {
+
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ @Resource
+ private GameContentSignatureVerifier signatureVerifier;
+
+ @Resource
+ private GameContentControlService gameContentControlService;
+
+ /** project 权威创建或幂等复用内容版本。 */
+ @PostMapping("/prepare")
+ @PermitAll
+ @TenantIgnore
+ @Operation(summary = "预留游戏内容版本")
+ public CommonResult prepare(
+ @RequestBody String rawBody,
+ @RequestHeader(value = GameContentSignatureVerifier.SIGNATURE_HEADER, required = false) String signature,
+ HttpServletResponse response) {
+ if (!signatureVerifier.verify(rawBody, signature)) {
+ return unauthorized(response, "prepare");
+ }
+ ProjectContentVersionPrepareReqDTO req;
+ try {
+ req = JSON.readValue(rawBody, ProjectContentVersionPrepareReqDTO.class);
+ } catch (Exception ex) {
+ return badRequest(response, "prepare body 解析失败", ex);
+ }
+ if (req.getTenantId() == null || req.getTenantId() <= 0
+ || req.getGameId() == null || req.getGameId() <= 0
+ || !validRevision(req.getRevisionId())) {
+ return badRequest(response, "prepare 身份字段非法", null);
+ }
+ return success(gameContentControlService.prepareContentVersion(req));
+ }
+
+ /** 在一个本地事务内完成 Project 锁定、Runtime OSS 落元数据和 Project 绑定。 */
+ @PostMapping("/finalize")
+ @PermitAll
+ @TenantIgnore
+ @Operation(summary = "收口 OSS 游戏内容版本")
+ public CommonResult finalizeContent(
+ @RequestBody String rawBody,
+ @RequestHeader(value = GameContentSignatureVerifier.SIGNATURE_HEADER, required = false) String signature,
+ HttpServletResponse response) {
+ if (!signatureVerifier.verify(rawBody, signature)) {
+ return unauthorized(response, "finalize");
+ }
+ RuntimeOssPackageFinalizeReqDTO req;
+ try {
+ req = JSON.readValue(rawBody, RuntimeOssPackageFinalizeReqDTO.class);
+ } catch (Exception ex) {
+ return badRequest(response, "finalize body 解析失败", ex);
+ }
+ if (req.getTenantId() == null || req.getTenantId() <= 0
+ || req.getGameId() == null || req.getGameId() <= 0
+ || req.getVersionId() == null || req.getVersionId() <= 0
+ || !validRevision(req.getRevisionId())
+ || !validSha256(req.getArtifactManifestHash())
+ || !StringUtils.hasText(req.getManifestJson())) {
+ return badRequest(response, "finalize 身份或摘要字段非法", null);
+ }
+ return success(gameContentControlService.finalizeContent(req));
+ }
+
+ /** 浏览器按目标版本验收通过后,带 expected-current 前置条件显式激活。 */
+ @PostMapping("/activate")
+ @PermitAll
+ @TenantIgnore
+ @Operation(summary = "激活已验收游戏内容版本")
+ public CommonResult activateContentVersion(
+ @RequestBody String rawBody,
+ @RequestHeader(value = GameContentSignatureVerifier.SIGNATURE_HEADER, required = false) String signature,
+ HttpServletResponse response) {
+ if (!signatureVerifier.verify(rawBody, signature)) {
+ return unauthorized(response, "activate");
+ }
+ ProjectContentVersionActivateReqDTO req;
+ try {
+ req = JSON.readValue(rawBody, ProjectContentVersionActivateReqDTO.class);
+ } catch (Exception ex) {
+ return badRequest(response, "activate body 解析失败", ex);
+ }
+ if (req.getTenantId() == null || req.getTenantId() <= 0
+ || req.getGameId() == null || req.getGameId() <= 0
+ || req.getVersionId() == null || req.getVersionId() <= 0
+ || (req.getExpectedCurrentVersionId() != null && req.getExpectedCurrentVersionId() <= 0)
+ || !validRevision(req.getRevisionId())) {
+ return badRequest(response, "activate 身份或前置版本字段非法", null);
+ }
+ return success(gameContentControlService.activateContentVersion(req));
+ }
+
+ /** 验签失败始终以 HTTP 401 收口,且此时尚未解析或调用任何写服务。 */
+ private static CommonResult unauthorized(HttpServletResponse response, String operation) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ log.warn("[gameContentControl] HMAC 拒绝 operation={}", operation);
+ return CommonResult.error(UNAUTHORIZED.getCode(), "游戏内容控制面验签失败");
+ }
+
+ /** 原始 body 或必填字段非法时返回 HTTP 400,不进入写链。 */
+ private static CommonResult badRequest(HttpServletResponse response, String reason, Exception ex) {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ if (ex == null) {
+ log.warn("[gameContentControl] 请求非法 reason={}", reason);
+ } else {
+ log.warn("[gameContentControl] 请求解析失败 reason={}", reason, ex);
+ }
+ return CommonResult.error(400, reason);
+ }
+
+ private static boolean validRevision(String value) {
+ return StringUtils.hasText(value) && value.length() <= 128
+ && value.matches("^[A-Za-z0-9][A-Za-z0-9._-]*$");
+ }
+
+ private static boolean validSha256(String value) {
+ return StringUtils.hasText(value) && value.matches("^[a-f0-9]{64}$");
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlProperties.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlProperties.java
new file mode 100644
index 00000000..bf081286
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlProperties.java
@@ -0,0 +1,23 @@
+package com.wanxiang.huijing.game.module.aigc.service.content;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 游戏内容控制面配置。
+ *
+ * 该 Bean 始终装配且默认关闭,不受 {@code aigc.executor.enabled} 影响;
+ * 只有 enabled=true 且独立 secret 非空时,HMAC 入口才会执行任何写操作。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "aigc.game-content-control")
+public class GameContentControlProperties {
+
+ /** 控制面总开关,默认关闭。 */
+ private boolean enabled = false;
+
+ /** 独立于生成 worker callback-secret 的 HMAC 共享密钥。 */
+ private String secret = "";
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlService.java
new file mode 100644
index 00000000..ebc17425
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlService.java
@@ -0,0 +1,72 @@
+package com.wanxiang.huijing.game.module.aigc.service.content;
+
+import com.wanxiang.huijing.framework.common.enums.UserTypeEnum;
+import com.wanxiang.huijing.framework.security.core.LoginUser;
+import com.wanxiang.huijing.framework.tenant.core.util.TenantUtils;
+import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import jakarta.annotation.Resource;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+/** HMAC 控制器后的系统身份与显式租户执行边界。 */
+@Service
+public class GameContentControlService {
+
+ @Resource
+ private ProjectVersionApi projectVersionApi;
+
+ @Resource
+ private GameContentFinalizeTxService finalizeTxService;
+
+ /** 以目标租户和系统身份调用 project 权威 prepare。 */
+ public Long prepareContentVersion(ProjectContentVersionPrepareReqDTO req) {
+ return executeAsSystem(req.getTenantId(),
+ () -> projectVersionApi.prepareContentVersion(req).getCheckedData());
+ }
+
+ /** 以目标租户和系统身份进入单一 Finalize 事务。 */
+ public Long finalizeContent(RuntimeOssPackageFinalizeReqDTO req) {
+ return executeAsSystem(req.getTenantId(), () -> finalizeTxService.finalizeContent(req));
+ }
+
+ /** 以目标租户和系统身份执行验收后的 expected-current CAS 激活。 */
+ public Long activateContentVersion(ProjectContentVersionActivateReqDTO req) {
+ return executeAsSystem(req.getTenantId(), () -> {
+ projectVersionApi.activateContentVersion(req).getCheckedData();
+ return req.getVersionId();
+ });
+ }
+
+ /**
+ * 设置 tenant + system LoginUser,并在所有成功/异常路径恢复线程上下文。
+ *
+ * 使用 TenantUtils 的 Runnable 重载,保留 ServiceException 原类型,不被 Callable 重载包装。
+ */
+ private static T executeAsSystem(Long tenantId, Supplier action) {
+ Authentication previous = SecurityContextHolder.getContext().getAuthentication();
+ LoginUser systemUser = new LoginUser().setId(0L)
+ .setUserType(UserTypeEnum.ADMIN.getValue()).setTenantId(tenantId);
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(systemUser, null, Collections.emptyList()));
+ AtomicReference result = new AtomicReference<>();
+ try {
+ TenantUtils.execute(tenantId, () -> result.set(action.get()));
+ return result.get();
+ } finally {
+ if (previous == null) {
+ SecurityContextHolder.clearContext();
+ } else {
+ SecurityContextHolder.getContext().setAuthentication(previous);
+ }
+ }
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxService.java
new file mode 100644
index 00000000..5bc6f304
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxService.java
@@ -0,0 +1,59 @@
+package com.wanxiang.huijing.game.module.aigc.service.content;
+
+import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO;
+import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/** 游戏内容 Finalize 的单体本地事务编排。 */
+@Slf4j
+@Service
+public class GameContentFinalizeTxService {
+
+ @Resource
+ private ProjectVersionApi projectVersionApi;
+
+ @Resource
+ private RuntimePackageApi runtimePackageApi;
+
+ /**
+ * 按统一锁序完成 Project 身份锁定、Runtime OSS 元数据落库和 Project 摘要绑定。
+ *
+ * 三个 API 在 MVP 单体内均解析为本地 {@code @Primary} Bean,加入本事务;任一步失败整体回滚。
+ * 第一调用会在 project 模块按 project -> version 加行锁,后续不得调整顺序。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public Long finalizeContent(RuntimeOssPackageFinalizeReqDTO req) {
+ ProjectContentVersionIdentityReqDTO identityReq = new ProjectContentVersionIdentityReqDTO();
+ identityReq.setTenantId(req.getTenantId());
+ identityReq.setGameId(req.getGameId());
+ identityReq.setVersionId(req.getVersionId());
+ identityReq.setRevisionId(req.getRevisionId());
+ projectVersionApi.validateContentVersionIdentity(identityReq).getCheckedData();
+
+ RuntimeOssPackageFinalizeRespDTO runtimeResult =
+ runtimePackageApi.finalizeOssPackage(req).getCheckedData();
+ if (runtimeResult == null || runtimeResult.getPackageId() == null
+ || runtimeResult.getChecksum() == null || runtimeResult.getBundleSize() == null) {
+ throw new IllegalStateException("Runtime OSS Finalize 未返回完整权威元数据");
+ }
+
+ ProjectContentVersionBindReqDTO bindReq = new ProjectContentVersionBindReqDTO();
+ bindReq.setTenantId(req.getTenantId());
+ bindReq.setGameId(req.getGameId());
+ bindReq.setVersionId(req.getVersionId());
+ bindReq.setRevisionId(req.getRevisionId());
+ bindReq.setChecksum(runtimeResult.getChecksum());
+ bindReq.setBundleSize(runtimeResult.getBundleSize());
+ projectVersionApi.bindContentRuntimeArtifact(bindReq).getCheckedData();
+ log.info("[gameContentFinalize] 内容版本已提交并绑定摘要,等待浏览器验收激活 tenantId={}, gameId={}, versionId={}, packageId={}",
+ req.getTenantId(), req.getGameId(), req.getVersionId(), runtimeResult.getPackageId());
+ return runtimeResult.getPackageId();
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentSignatureVerifier.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentSignatureVerifier.java
new file mode 100644
index 00000000..c7b4c930
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentSignatureVerifier.java
@@ -0,0 +1,65 @@
+package com.wanxiang.huijing.game.module.aigc.service.content;
+
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+
+/** 游戏内容 prepare/finalize 原始请求体的 fail-closed HMAC-SHA256 验签器。 */
+@Slf4j
+@Component
+public class GameContentSignatureVerifier {
+
+ /** Agent 调用内容控制面必须携带的签名头。 */
+ public static final String SIGNATURE_HEADER = "X-Game-Content-Signature";
+ private static final String HMAC_ALGORITHM = "HmacSHA256";
+
+ @Resource
+ private GameContentControlProperties properties;
+
+ /** 测试和轻量装配使用的显式构造器。 */
+ public GameContentSignatureVerifier(GameContentControlProperties properties) {
+ this.properties = properties;
+ }
+
+ /** Spring 字段注入使用的默认构造器。 */
+ public GameContentSignatureVerifier() {
+ }
+
+ /**
+ * 对 HTTP 实际收到的原始 UTF-8 body 验签。
+ *
+ * 禁用、空密钥、缺签、格式错误、算法异常或摘要不一致一律返回 false。
+ */
+ public boolean verify(String rawBody, String signature) {
+ String secret = properties == null ? null : properties.getSecret();
+ if (properties == null || !properties.isEnabled() || !StringUtils.hasText(secret)) {
+ log.warn("[gameContentSignature] 控制面未启用或独立密钥为空,拒绝请求");
+ return false;
+ }
+ if (rawBody == null || !StringUtils.hasText(signature) || !signature.matches("^[a-f0-9]{64}$")) {
+ log.warn("[gameContentSignature] 原始 body 或签名头缺失/格式非法,拒绝请求");
+ return false;
+ }
+ try {
+ Mac mac = Mac.getInstance(HMAC_ALGORITHM);
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
+ String expected = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8)));
+ boolean matched = MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
+ signature.getBytes(StandardCharsets.US_ASCII));
+ if (!matched) {
+ log.warn("[gameContentSignature] HMAC 不匹配,拒绝请求");
+ }
+ return matched;
+ } catch (Exception ex) {
+ log.error("[gameContentSignature] HMAC-SHA256 计算异常,拒绝请求", ex);
+ return false;
+ }
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlControllerTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlControllerTest.java
new file mode 100644
index 00000000..ae7f0c2d
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlControllerTest.java
@@ -0,0 +1,160 @@
+package com.wanxiang.huijing.game.module.aigc.controller.admin.content;
+
+import com.wanxiang.huijing.game.module.aigc.service.content.GameContentControlProperties;
+import com.wanxiang.huijing.game.module.aigc.service.content.GameContentControlService;
+import com.wanxiang.huijing.game.module.aigc.service.content.GameContentSignatureVerifier;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.HexFormat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/** 游戏内容 HMAC 控制面真实路由与零写拒绝测试。 */
+class GameContentControlControllerTest {
+
+ private static final String SECRET = "content-control-secret";
+
+ @Test
+ void prepare_disabledControlPlaneReturnsUnauthorizedWithoutWrite() throws Exception {
+ GameContentControlService service = mock(GameContentControlService.class);
+ MockMvc mvc = mvc(false, SECRET, service);
+ String body = prepareBody();
+
+ mvc.perform(post("/aigc/game-content/prepare")
+ .contentType(APPLICATION_JSON).content(body)
+ .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET)))
+ .andExpect(status().isUnauthorized());
+
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void prepare_emptySecretFailsClosedWithoutWrite() throws Exception {
+ GameContentControlService service = mock(GameContentControlService.class);
+ MockMvc mvc = mvc(true, "", service);
+
+ mvc.perform(post("/aigc/game-content/prepare")
+ .contentType(APPLICATION_JSON).content(prepareBody()))
+ .andExpect(status().isUnauthorized());
+
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void prepare_wrongSignatureReturnsUnauthorizedWithoutWrite() throws Exception {
+ GameContentControlService service = mock(GameContentControlService.class);
+ MockMvc mvc = mvc(true, SECRET, service);
+
+ mvc.perform(post("/aigc/game-content/prepare")
+ .contentType(APPLICATION_JSON).content(prepareBody())
+ .header(GameContentSignatureVerifier.SIGNATURE_HEADER, "0".repeat(64)))
+ .andExpect(status().isUnauthorized());
+
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void prepare_validSignatureCallsTrustedService() throws Exception {
+ GameContentControlService service = mock(GameContentControlService.class);
+ when(service.prepareContentVersion(any())).thenReturn(2048L);
+ MockMvc mvc = mvc(true, SECRET, service);
+ String body = prepareBody();
+
+ mvc.perform(post("/aigc/game-content/prepare")
+ .contentType(APPLICATION_JSON).content(body)
+ .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").value(2048));
+
+ verify(service).prepareContentVersion(any());
+ verify(service, never()).finalizeContent(any());
+ }
+
+ @Test
+ void finalize_validSignatureUsesFinalizeRoute() throws Exception {
+ GameContentControlService service = mock(GameContentControlService.class);
+ when(service.finalizeContent(any())).thenReturn(31L);
+ MockMvc mvc = mvc(true, SECRET, service);
+ String body = "{\"tenantId\":7,\"gameId\":1024,\"versionId\":2048,"
+ + "\"revisionId\":\"revision-a\",\"artifactManifestHash\":\"" + "a".repeat(64)
+ + "\",\"manifestJson\":\"{}\"}";
+
+ mvc.perform(post("/aigc/game-content/finalize")
+ .contentType(APPLICATION_JSON).content(body)
+ .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").value(31));
+
+ verify(service).finalizeContent(any());
+ }
+
+ @Test
+ void activate_validSignatureUsesActivateRoute() throws Exception {
+ GameContentControlService service = mock(GameContentControlService.class);
+ when(service.activateContentVersion(any())).thenReturn(2048L);
+ MockMvc mvc = mvc(true, SECRET, service);
+ String body = "{\"tenantId\":7,\"gameId\":1024,\"versionId\":2048,"
+ + "\"revisionId\":\"revision-a\",\"expectedCurrentVersionId\":1023}";
+
+ mvc.perform(post("/aigc/game-content/activate")
+ .contentType(APPLICATION_JSON).content(body)
+ .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").value(2048));
+
+ verify(service).activateContentVersion(any());
+ verify(service, never()).finalizeContent(any());
+ }
+
+ @Test
+ void activate_nonPositiveExpectedCurrentReturnsBadRequestWithoutWrite() throws Exception {
+ GameContentControlService service = mock(GameContentControlService.class);
+ MockMvc mvc = mvc(true, SECRET, service);
+ String body = "{\"tenantId\":7,\"gameId\":1024,\"versionId\":2048,"
+ + "\"revisionId\":\"revision-a\",\"expectedCurrentVersionId\":0}";
+
+ mvc.perform(post("/aigc/game-content/activate")
+ .contentType(APPLICATION_JSON).content(body)
+ .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET)))
+ .andExpect(status().isBadRequest());
+
+ verifyNoInteractions(service);
+ }
+
+ /** 以真实 verifier 和真实 Spring handler mapping 建独立控制器测试。 */
+ private static MockMvc mvc(boolean enabled, String secret, GameContentControlService service) {
+ GameContentControlProperties properties = new GameContentControlProperties();
+ properties.setEnabled(enabled);
+ properties.setSecret(secret);
+ GameContentControlController controller = new GameContentControlController();
+ ReflectionTestUtils.setField(controller, "signatureVerifier", new GameContentSignatureVerifier(properties));
+ ReflectionTestUtils.setField(controller, "gameContentControlService", service);
+ return MockMvcBuilders.standaloneSetup(controller).build();
+ }
+
+ private static String prepareBody() {
+ return "{\"tenantId\":7,\"gameId\":1024,\"revisionId\":\"revision-a\"}";
+ }
+
+ /** 独立按原始 UTF-8 body 计算 HMAC,避免复用生产验签逻辑形成镜像断言。 */
+ private static String sign(String body, String secret) throws Exception {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ return HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlServiceTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlServiceTest.java
new file mode 100644
index 00000000..a313c02e
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlServiceTest.java
@@ -0,0 +1,86 @@
+package com.wanxiang.huijing.game.module.aigc.service.content;
+
+import com.wanxiang.huijing.framework.common.enums.UserTypeEnum;
+import com.wanxiang.huijing.framework.security.core.LoginUser;
+import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
+import com.wanxiang.huijing.framework.tenant.core.context.TenantContextHolder;
+import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
+import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.when;
+
+/** HMAC 通过后的系统身份、租户隔离和线程上下文清理测试。 */
+class GameContentControlServiceTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private GameContentControlService controlService;
+
+ @Mock
+ private ProjectVersionApi projectVersionApi;
+ @Mock
+ private GameContentFinalizeTxService finalizeTxService;
+
+ @AfterEach
+ void clearThreadContext() {
+ TenantContextHolder.clear();
+ org.springframework.security.core.context.SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ void prepareContentVersion_runsUnderRequestedTenantAndSystemIdentityThenCleansContext() {
+ ProjectContentVersionPrepareReqDTO req = new ProjectContentVersionPrepareReqDTO();
+ req.setTenantId(7L);
+ req.setGameId(1024L);
+ req.setRevisionId("revision-a");
+ when(projectVersionApi.prepareContentVersion(req)).thenAnswer(invocation -> {
+ LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
+ assertEquals(0L, loginUser.getId());
+ assertEquals(UserTypeEnum.ADMIN.getValue(), loginUser.getUserType());
+ assertEquals(7L, loginUser.getTenantId());
+ assertEquals(7L, TenantContextHolder.getTenantId());
+ assertFalse(TenantContextHolder.isIgnore());
+ return success(2048L);
+ });
+
+ assertEquals(2048L, controlService.prepareContentVersion(req));
+
+ assertNull(SecurityFrameworkUtils.getLoginUser());
+ assertNull(TenantContextHolder.getTenantId());
+ assertFalse(TenantContextHolder.isIgnore());
+ }
+
+ @Test
+ void activateContentVersion_runsUnderRequestedTenantAndSystemIdentityThenCleansContext() {
+ ProjectContentVersionActivateReqDTO req = new ProjectContentVersionActivateReqDTO();
+ req.setTenantId(7L);
+ req.setGameId(1024L);
+ req.setVersionId(2048L);
+ req.setRevisionId("revision-a");
+ req.setExpectedCurrentVersionId(1023L);
+ when(projectVersionApi.activateContentVersion(req)).thenAnswer(invocation -> {
+ LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
+ assertEquals(0L, loginUser.getId());
+ assertEquals(UserTypeEnum.ADMIN.getValue(), loginUser.getUserType());
+ assertEquals(7L, loginUser.getTenantId());
+ assertEquals(7L, TenantContextHolder.getTenantId());
+ assertFalse(TenantContextHolder.isIgnore());
+ return success(Boolean.TRUE);
+ });
+
+ assertEquals(2048L, controlService.activateContentVersion(req));
+
+ assertNull(SecurityFrameworkUtils.getLoginUser());
+ assertNull(TenantContextHolder.getTenantId());
+ assertFalse(TenantContextHolder.isIgnore());
+ }
+}
diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxServiceTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxServiceTest.java
new file mode 100644
index 00000000..d4394b47
--- /dev/null
+++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxServiceTest.java
@@ -0,0 +1,103 @@
+package com.wanxiang.huijing.game.module.aigc.service.content;
+
+import com.wanxiang.huijing.framework.common.exception.ServiceException;
+import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
+import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO;
+import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import static com.wanxiang.huijing.framework.common.pojo.CommonResult.error;
+import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** 游戏内容 Finalize 单事务编排顺序测试。 */
+class GameContentFinalizeTxServiceTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private GameContentFinalizeTxService finalizeTxService;
+
+ @Mock
+ private ProjectVersionApi projectVersionApi;
+ @Mock
+ private RuntimePackageApi runtimePackageApi;
+
+ @Test
+ void finalizeContent_locksProjectIdentityThenStoresRuntimeThenBindsProject() {
+ RuntimeOssPackageFinalizeReqDTO req = request();
+ when(projectVersionApi.validateContentVersionIdentity(any())).thenReturn(success(Boolean.TRUE));
+ when(runtimePackageApi.finalizeOssPackage(req)).thenReturn(success(
+ new RuntimeOssPackageFinalizeRespDTO(31L, "b".repeat(64), 4096L)));
+ when(projectVersionApi.bindContentRuntimeArtifact(any())).thenReturn(success(Boolean.TRUE));
+
+ Long packageId = finalizeTxService.finalizeContent(req);
+
+ assertEquals(31L, packageId);
+ InOrder order = inOrder(projectVersionApi, runtimePackageApi);
+ order.verify(projectVersionApi).validateContentVersionIdentity(any());
+ order.verify(runtimePackageApi).finalizeOssPackage(req);
+ order.verify(projectVersionApi).bindContentRuntimeArtifact(any());
+
+ ArgumentCaptor identityCaptor =
+ ArgumentCaptor.forClass(ProjectContentVersionIdentityReqDTO.class);
+ verify(projectVersionApi).validateContentVersionIdentity(identityCaptor.capture());
+ assertEquals(7L, identityCaptor.getValue().getTenantId());
+ assertEquals(1024L, identityCaptor.getValue().getGameId());
+ assertEquals(2048L, identityCaptor.getValue().getVersionId());
+ assertEquals("revision-a", identityCaptor.getValue().getRevisionId());
+
+ ArgumentCaptor bindCaptor =
+ ArgumentCaptor.forClass(ProjectContentVersionBindReqDTO.class);
+ verify(projectVersionApi).bindContentRuntimeArtifact(bindCaptor.capture());
+ assertEquals("b".repeat(64), bindCaptor.getValue().getChecksum());
+ assertEquals(4096L, bindCaptor.getValue().getBundleSize());
+ }
+
+ @Test
+ void finalizeContent_identityFailurePerformsNoRuntimeWrite() {
+ RuntimeOssPackageFinalizeReqDTO req = request();
+ when(projectVersionApi.validateContentVersionIdentity(any()))
+ .thenReturn(error(1_100_000_009, "invalid"));
+
+ assertThrows(ServiceException.class, () -> finalizeTxService.finalizeContent(req));
+
+ verify(runtimePackageApi, never()).finalizeOssPackage(any());
+ verify(projectVersionApi, never()).bindContentRuntimeArtifact(any());
+ }
+
+ @Test
+ void finalizeContent_runtimeFailureDoesNotBindProject() {
+ RuntimeOssPackageFinalizeReqDTO req = request();
+ when(projectVersionApi.validateContentVersionIdentity(any())).thenReturn(success(Boolean.TRUE));
+ when(runtimePackageApi.finalizeOssPackage(req)).thenReturn(error(1_102_001_009, "invalid"));
+
+ assertThrows(ServiceException.class, () -> finalizeTxService.finalizeContent(req));
+
+ verify(projectVersionApi, never()).bindContentRuntimeArtifact(any());
+ }
+
+ /** 构造已由 HMAC 控制器解析的 Finalize 入参。 */
+ private static RuntimeOssPackageFinalizeReqDTO request() {
+ RuntimeOssPackageFinalizeReqDTO req = new RuntimeOssPackageFinalizeReqDTO();
+ req.setTenantId(7L);
+ req.setGameId(1024L);
+ req.setVersionId(2048L);
+ req.setRevisionId("revision-a");
+ req.setArtifactManifestHash("a".repeat(64));
+ req.setManifestJson("{\"gameId\":\"1024\"}");
+ return req;
+ }
+}
diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java
index cf590afc..b7215dc1 100644
--- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java
+++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java
@@ -29,6 +29,7 @@ import com.wanxiang.huijing.framework.common.pojo.PageResult;
import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -92,6 +93,7 @@ public class FeedServiceImpl implements FeedService {
* MVP 单体:由 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析。
*/
@Resource
+ @Lazy
private ProjectApi projectApi;
/**
diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApi.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApi.java
index c86814ef..b3ec423f 100644
--- a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApi.java
+++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApi.java
@@ -2,6 +2,10 @@ package com.wanxiang.huijing.game.module.project.api;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO;
import com.wanxiang.huijing.game.module.project.enums.ApiConstants;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import io.swagger.v3.oas.annotations.Operation;
@@ -55,4 +59,25 @@ public interface ProjectVersionApi {
@Operation(summary = "绑定版本运行产物摘要")
CommonResult bindRuntimeArtifact(@RequestBody @Valid ProjectVersionBindArtifactReqDTO req);
+ /** 由 project 权威创建或复用指定源修订的内容版本。 */
+ @PostMapping(PREFIX + "/version/prepare-content")
+ @Operation(summary = "预留游戏内容版本")
+ CommonResult prepareContentVersion(@RequestBody @Valid ProjectContentVersionPrepareReqDTO req);
+
+ /** Finalize 写 Runtime 前校验 tenant/game/version/revision 四元身份。 */
+ @PostMapping(PREFIX + "/version/validate-content-identity")
+ @Operation(summary = "校验游戏内容版本身份")
+ CommonResult validateContentVersionIdentity(
+ @RequestBody @Valid ProjectContentVersionIdentityReqDTO req);
+
+ /** Runtime 已写入同一预览元数据后,只绑定版本摘要,不激活项目指针。 */
+ @PostMapping(PREFIX + "/version/bind-content-artifact")
+ @Operation(summary = "绑定游戏内容运行产物")
+ CommonResult bindContentRuntimeArtifact(@RequestBody @Valid ProjectContentVersionBindReqDTO req);
+
+ /** 浏览器验收通过后,按 expected-current CAS 激活内容版本。 */
+ @PostMapping(PREFIX + "/version/activate-content")
+ @Operation(summary = "激活已验收游戏内容版本")
+ CommonResult activateContentVersion(@RequestBody @Valid ProjectContentVersionActivateReqDTO req);
+
}
diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionActivateReqDTO.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionActivateReqDTO.java
new file mode 100644
index 00000000..b3851698
--- /dev/null
+++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionActivateReqDTO.java
@@ -0,0 +1,41 @@
+package com.wanxiang.huijing.game.module.project.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/** 浏览器验收通过后,以期望当前版本 CAS 激活内容版本的入参。 */
+@Data
+public class ProjectContentVersionActivateReqDTO {
+
+ /** 目标租户 ID。 */
+ @NotNull(message = "tenantId 不能为空")
+ @Positive(message = "tenantId 必须大于 0")
+ private Long tenantId;
+
+ /** 目标游戏项目 ID。 */
+ @NotNull(message = "gameId 不能为空")
+ @Positive(message = "gameId 必须大于 0")
+ private Long gameId;
+
+ /** 已完成 finalize 和浏览器验收的版本 ID。 */
+ @NotNull(message = "versionId 不能为空")
+ @Positive(message = "versionId 必须大于 0")
+ private Long versionId;
+
+ /** prepare 时使用的不可变源修订 ID。 */
+ @NotBlank(message = "revisionId 不能为空")
+ @Size(max = 128, message = "revisionId 长度不能超过 128")
+ @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法")
+ private String revisionId;
+
+ /**
+ * 浏览器验收开始时观察到的项目当前版本。
+ * 新游戏首次激活时为 null;其余情况必须与持锁后项目指针完全一致。
+ */
+ @Positive(message = "expectedCurrentVersionId 必须大于 0")
+ private Long expectedCurrentVersionId;
+}
diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionBindReqDTO.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionBindReqDTO.java
new file mode 100644
index 00000000..79ce1bf7
--- /dev/null
+++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionBindReqDTO.java
@@ -0,0 +1,41 @@
+package com.wanxiang.huijing.game.module.project.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/** Runtime 已完成 OSS 元数据落库后,将同一制品摘要绑定到内容版本的入参。 */
+@Data
+public class ProjectContentVersionBindReqDTO {
+
+ /** 目标租户 ID。 */
+ @NotNull(message = "tenantId 不能为空")
+ private Long tenantId;
+
+ /** 目标游戏项目 ID。 */
+ @NotNull(message = "gameId 不能为空")
+ private Long gameId;
+
+ /** 由 prepare 返回的权威版本 ID。 */
+ @NotNull(message = "versionId 不能为空")
+ private Long versionId;
+
+ /** prepare 时使用的不可变源修订 ID。 */
+ @NotBlank(message = "revisionId 不能为空")
+ @Size(max = 128, message = "revisionId 长度不能超过 128")
+ @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法")
+ private String revisionId;
+
+ /** GamePackage 原始 UTF-8 字节 SHA-256。 */
+ @NotBlank(message = "checksum 不能为空")
+ @Pattern(regexp = "^[a-f0-9]{64}$", message = "checksum 必须为 64 位小写十六进制 sha256")
+ private String checksum;
+
+ /** GamePackage manifest 声明的运行包字节数。 */
+ @NotNull(message = "bundleSize 不能为空")
+ @Positive(message = "bundleSize 必须大于 0")
+ private Long bundleSize;
+}
diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionIdentityReqDTO.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionIdentityReqDTO.java
new file mode 100644
index 00000000..8648776e
--- /dev/null
+++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionIdentityReqDTO.java
@@ -0,0 +1,30 @@
+package com.wanxiang.huijing.game.module.project.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/** 游戏内容 Finalize 前的版本身份校验入参。 */
+@Data
+public class ProjectContentVersionIdentityReqDTO {
+
+ /** 目标租户 ID。 */
+ @NotNull(message = "tenantId 不能为空")
+ private Long tenantId;
+
+ /** 目标游戏项目 ID。 */
+ @NotNull(message = "gameId 不能为空")
+ private Long gameId;
+
+ /** 由 prepare 返回的权威版本 ID。 */
+ @NotNull(message = "versionId 不能为空")
+ private Long versionId;
+
+ /** prepare 时使用的不可变源修订 ID。 */
+ @NotBlank(message = "revisionId 不能为空")
+ @Size(max = 128, message = "revisionId 长度不能超过 128")
+ @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法")
+ private String revisionId;
+}
diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionPrepareReqDTO.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionPrepareReqDTO.java
new file mode 100644
index 00000000..51d0eb20
--- /dev/null
+++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionPrepareReqDTO.java
@@ -0,0 +1,31 @@
+package com.wanxiang.huijing.game.module.project.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 游戏内容仓库预留权威版本入参。
+ *
+ * 租户、项目和不可变源修订共同定义幂等身份;版本 ID 只能由 project 模块创建,
+ * 内容 worker 不得自行指定或直接写 {@code game_version}。
+ */
+@Data
+public class ProjectContentVersionPrepareReqDTO {
+
+ /** 目标租户 ID。 */
+ @NotNull(message = "tenantId 不能为空")
+ private Long tenantId;
+
+ /** 已存在且归属于目标租户的游戏项目 ID。 */
+ @NotNull(message = "gameId 不能为空")
+ private Long gameId;
+
+ /** 引擎无关源归档的不可变修订 ID。 */
+ @NotBlank(message = "revisionId 不能为空")
+ @Size(max = 128, message = "revisionId 长度不能超过 128")
+ @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法")
+ private String revisionId;
+}
diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java
index 3f9f1172..2718acb0 100644
--- a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java
+++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java
@@ -31,5 +31,7 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_REVIEW_ARTIFACT_HASH_INVALID = new ErrorCode(1_100_000_007, "审核版本产物摘要无效");
/** callback 绑定的运行产物摘要非法,或目标版本不存在。 */
ErrorCode PROJECT_VERSION_ARTIFACT_BIND_FAILED = new ErrorCode(1_100_000_008, "版本运行产物摘要绑定失败");
+ /** 内容控制面的租户、项目、版本或源修订身份不一致。 */
+ ErrorCode PROJECT_CONTENT_VERSION_INVALID = new ErrorCode(1_100_000_009, "游戏内容版本身份无效");
}
diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java
index 47323984..38d6088c 100644
--- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java
+++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java
@@ -2,6 +2,10 @@ package com.wanxiang.huijing.game.module.project.api;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO;
import com.wanxiang.huijing.game.module.project.service.version.GameVersionService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import com.wanxiang.huijing.framework.common.util.servlet.ServletUtils;
@@ -51,6 +55,33 @@ public class ProjectVersionApiImpl implements ProjectVersionApi {
return success(Boolean.TRUE);
}
+ @Override
+ public CommonResult prepareContentVersion(ProjectContentVersionPrepareReqDTO req) {
+ rejectExternalRpcWrite("project.rpc.prepare-content");
+ return success(gameVersionService.prepareContentVersion(req));
+ }
+
+ @Override
+ public CommonResult validateContentVersionIdentity(ProjectContentVersionIdentityReqDTO req) {
+ rejectExternalRpcWrite("project.rpc.validate-content-identity");
+ gameVersionService.validateContentVersionIdentity(req);
+ return success(Boolean.TRUE);
+ }
+
+ @Override
+ public CommonResult bindContentRuntimeArtifact(ProjectContentVersionBindReqDTO req) {
+ rejectExternalRpcWrite("project.rpc.bind-content-artifact");
+ gameVersionService.bindContentRuntimeArtifact(req);
+ return success(Boolean.TRUE);
+ }
+
+ @Override
+ public CommonResult activateContentVersion(ProjectContentVersionActivateReqDTO req) {
+ rejectExternalRpcWrite("project.rpc.activate-content");
+ gameVersionService.activateContentVersion(req);
+ return success(Boolean.TRUE);
+ }
+
/** 当前单体只信任本地 Java 调用;拆微服务前须先建立服务身份,不能直接放开此 HTTP 写入口。 */
private void rejectExternalRpcWrite(String operation) {
HttpServletRequest request = ServletUtils.getRequest();
diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionService.java
index d0bda2cd..b3eb2a39 100644
--- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionService.java
+++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionService.java
@@ -3,6 +3,10 @@ package com.wanxiang.huijing.game.module.project.service.version;
import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO;
/**
* 游戏版本 Service 接口(黄金闭环 §3.3 C3 新增)
@@ -32,6 +36,18 @@ public interface GameVersionService {
*/
void bindRuntimeArtifact(ProjectVersionBindArtifactReqDTO req);
+ /** 预留或幂等复用一个由源修订唯一绑定的内容版本。 */
+ Long prepareContentVersion(ProjectContentVersionPrepareReqDTO req);
+
+ /** Finalize 写 Runtime 前校验内容版本四元身份。 */
+ void validateContentVersionIdentity(ProjectContentVersionIdentityReqDTO req);
+
+ /** Runtime 预览包就绪后绑定摘要,但不切换项目当前版本。 */
+ void bindContentRuntimeArtifact(ProjectContentVersionBindReqDTO req);
+
+ /** 浏览器验收通过后,按 expected-current CAS 切换项目当前版本。 */
+ void activateContentVersion(ProjectContentVersionActivateReqDTO req);
+
/**
* 取版本 DO(发布编排取当前生效版本以做版本态流转)
*
diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java
index 7ef5aff7..c9c04682 100644
--- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java
+++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java
@@ -6,6 +6,10 @@ import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper;
import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO;
import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi;
import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum;
import jakarta.annotation.Resource;
@@ -16,7 +20,11 @@ import org.springframework.util.StringUtils;
import java.util.Objects;
import java.util.regex.Pattern;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_CONTENT_VERSION_INVALID;
import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -39,6 +47,10 @@ public class GameVersionServiceImpl implements GameVersionService {
private static final int DEFAULT_VERSION_NO = 1;
/** 审核门认可的权威产物摘要格式,与 runtime 落包契约保持一致。 */
private static final Pattern SHA256_PATTERN = Pattern.compile("^[a-f0-9]{64}$");
+ /** 源修订 ID 与对象存储 writer 使用同一安全字符集,禁止路径注入。 */
+ private static final Pattern REVISION_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$");
+ /** 内容修订幂等键的域标签,避免与普通 AIGC taskId 共用命名空间。 */
+ private static final String CONTENT_REVISION_KEY_DOMAIN = "game-content-version/v1\0";
@Resource
private GameVersionMapper gameVersionMapper;
@@ -55,6 +67,231 @@ public class GameVersionServiceImpl implements GameVersionService {
@Resource
private RuntimePackageApi runtimePackageApi;
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long prepareContentVersion(ProjectContentVersionPrepareReqDTO req) {
+ if (!validContentIdentityInput(req == null ? null : req.getTenantId(),
+ req == null ? null : req.getGameId(), req == null ? null : req.getRevisionId())) {
+ rejectContentIdentity("prepare 入参非法", req == null ? null : req.getTenantId(),
+ req == null ? null : req.getGameId(), null);
+ }
+
+ // 项目行锁同时承担存在性、租户归属和同项目修订幂等串行化,避免并发重复建版本。
+ ProjectDO project = projectMapper.selectByIdForUpdate(req.getGameId());
+ if (!matchesProject(project, req.getTenantId(), req.getGameId())) {
+ rejectContentIdentity("prepare 项目不存在或跨租户", req.getTenantId(), req.getGameId(), null);
+ }
+
+ String revisionKey = contentRevisionKey(req.getTenantId(), req.getGameId(), req.getRevisionId());
+ GameVersionDO existing = gameVersionMapper.selectByGenTaskId(revisionKey);
+ if (existing != null) {
+ if (!matchesVersion(existing, req.getTenantId(), req.getGameId(), revisionKey)) {
+ rejectContentIdentity("prepare 幂等键命中错误版本", req.getTenantId(), req.getGameId(), existing.getId());
+ }
+ log.info("[prepareContentVersion] 内容修订幂等命中 tenantId={}, gameId={}, versionId={}, revisionId={}",
+ req.getTenantId(), req.getGameId(), existing.getId(), req.getRevisionId());
+ return existing.getId();
+ }
+
+ GameVersionDO version = new GameVersionDO();
+ // 显式写租户列,使关闭租户拦截器的隔离 staging 也不会回落表默认 tenant_id=0。
+ version.setTenantId(req.getTenantId());
+ version.setGameId(req.getGameId());
+ version.setVersionNo(DEFAULT_VERSION_NO);
+ version.setGenTaskId(revisionKey);
+ version.setPackageUrl("");
+ version.setBundleSize(0L);
+ version.setChecksum("");
+ version.setStatus(STATUS_PREVIEW_READY);
+ if (gameVersionMapper.insert(version) != 1 || version.getId() == null) {
+ rejectContentIdentity("prepare 版本创建失败", req.getTenantId(), req.getGameId(), null);
+ }
+ // 此处不改 currentVersionId;finalize 只会绑定摘要,浏览器验收后由 activate 单独切换。
+ log.info("[prepareContentVersion] 已预留内容版本 tenantId={}, gameId={}, versionId={}, revisionId={}",
+ req.getTenantId(), req.getGameId(), version.getId(), req.getRevisionId());
+ return version.getId();
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void validateContentVersionIdentity(ProjectContentVersionIdentityReqDTO req) {
+ if (req == null || req.getVersionId() == null
+ || !validContentIdentityInput(req.getTenantId(), req.getGameId(), req.getRevisionId())) {
+ rejectContentIdentity("identity 入参非法", req == null ? null : req.getTenantId(),
+ req == null ? null : req.getGameId(), req == null ? null : req.getVersionId());
+ }
+ // Finalize 外层事务首先按 project -> version 统一锁序持锁,后续 Runtime/V34 写入不得反向抢锁。
+ ProjectDO project = projectMapper.selectByIdForUpdate(req.getGameId());
+ GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId());
+ String revisionKey = contentRevisionKey(req.getTenantId(), req.getGameId(), req.getRevisionId());
+ if (!matchesProject(project, req.getTenantId(), req.getGameId())
+ || !matchesVersion(version, req.getTenantId(), req.getGameId(), revisionKey)) {
+ rejectContentIdentity("identity 四元身份不一致", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+ log.info("[validateContentVersionIdentity] 内容版本身份通过 tenantId={}, gameId={}, versionId={}, revisionId={}",
+ req.getTenantId(), req.getGameId(), req.getVersionId(), req.getRevisionId());
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void bindContentRuntimeArtifact(ProjectContentVersionBindReqDTO req) {
+ if (req == null || req.getVersionId() == null || req.getBundleSize() == null || req.getBundleSize() <= 0
+ || !StringUtils.hasText(req.getChecksum()) || !SHA256_PATTERN.matcher(req.getChecksum()).matches()
+ || !validContentIdentityInput(req.getTenantId(), req.getGameId(), req.getRevisionId())) {
+ rejectContentIdentity("bind 入参非法", req == null ? null : req.getTenantId(),
+ req == null ? null : req.getGameId(), req == null ? null : req.getVersionId());
+ }
+
+ // 与审核发布保持 project -> version 锁序;持锁后重新验证全部身份,不能信任 Finalize 前的旧读。
+ ProjectDO project = projectMapper.selectByIdForUpdate(req.getGameId());
+ GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId());
+ String revisionKey = contentRevisionKey(req.getTenantId(), req.getGameId(), req.getRevisionId());
+ if (!matchesProject(project, req.getTenantId(), req.getGameId())
+ || !matchesVersion(version, req.getTenantId(), req.getGameId(), revisionKey)) {
+ rejectContentIdentity("bind 持锁后身份不一致", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+
+ boolean sameArtifact = Objects.equals(version.getChecksum(), req.getChecksum())
+ && Objects.equals(version.getBundleSize(), req.getBundleSize());
+ if (!Objects.equals(version.getStatus(), STATUS_PREVIEW_READY)) {
+ if (Objects.equals(version.getStatus(), STATUS_PUBLISHED) && sameArtifact
+ && Objects.equals(project.getCurrentVersionId(), version.getId())) {
+ log.info("[bindContentRuntimeArtifact] 已发布内容版本同摘要幂等返回 versionId={}", version.getId());
+ return;
+ }
+ rejectContentIdentity("bind 版本状态或摘要不可改写", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+ if (StringUtils.hasText(version.getChecksum()) && !sameArtifact) {
+ rejectContentIdentity("bind 预览版本已有不同摘要", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+
+ Integer runtimeStatus;
+ try {
+ runtimeStatus = runtimePackageApi.getStatus(req.getVersionId()).getCheckedData();
+ } catch (Exception ex) {
+ log.warn("[bindContentRuntimeArtifact] Runtime 状态读取失败 versionId={}", req.getVersionId(), ex);
+ throw exception(PROJECT_CONTENT_VERSION_INVALID);
+ }
+ if (!PackageStatusEnum.PREVIEW_READY.getStatus().equals(runtimeStatus)
+ && !PackageStatusEnum.PUBLISHED.getStatus().equals(runtimeStatus)) {
+ rejectContentIdentity("bind Runtime 包未就绪", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+
+ if (!sameArtifact) {
+ GameVersionDO update = new GameVersionDO();
+ update.setId(req.getVersionId());
+ update.setChecksum(req.getChecksum());
+ update.setBundleSize(req.getBundleSize());
+ if (gameVersionMapper.updateById(update) != 1) {
+ rejectContentIdentity("bind 版本摘要更新失败", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+ }
+ log.info("[bindContentRuntimeArtifact] 内容版本摘要绑定完成,等待验收激活 tenantId={}, gameId={}, versionId={}, checksum={}",
+ req.getTenantId(), req.getGameId(), req.getVersionId(), req.getChecksum());
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void activateContentVersion(ProjectContentVersionActivateReqDTO req) {
+ if (req == null || req.getVersionId() == null
+ || (req.getExpectedCurrentVersionId() != null && req.getExpectedCurrentVersionId() <= 0)
+ || !validContentIdentityInput(req.getTenantId(), req.getGameId(), req.getRevisionId())) {
+ rejectContentIdentity("activate 入参非法", req == null ? null : req.getTenantId(),
+ req == null ? null : req.getGameId(), req == null ? null : req.getVersionId());
+ }
+
+ // 与 finalize 保持 project -> version 锁序,锁内同时校验归属、摘要和 CAS 前置。
+ ProjectDO project = projectMapper.selectByIdForUpdate(req.getGameId());
+ GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId());
+ String revisionKey = contentRevisionKey(req.getTenantId(), req.getGameId(), req.getRevisionId());
+ if (!matchesProject(project, req.getTenantId(), req.getGameId())
+ || !matchesVersion(version, req.getTenantId(), req.getGameId(), revisionKey)
+ || !StringUtils.hasText(version.getChecksum())
+ || !SHA256_PATTERN.matcher(version.getChecksum()).matches()
+ || version.getBundleSize() == null || version.getBundleSize() <= 0) {
+ rejectContentIdentity("activate 版本未完成 finalize", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+
+ Long currentVersionId = project.getCurrentVersionId();
+ if (Objects.equals(currentVersionId, req.getVersionId())) {
+ // 激活成功后发布编排会把版本推进为 PUBLISHED;迟到重试仍须保持幂等。
+ if (!Objects.equals(version.getStatus(), STATUS_PREVIEW_READY)
+ && !Objects.equals(version.getStatus(), STATUS_PUBLISHED)) {
+ rejectContentIdentity("activate 当前版本状态非法", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+ log.info("[activateContentVersion] 目标版本已激活,幂等返回 gameId={}, versionId={}",
+ req.getGameId(), req.getVersionId());
+ return;
+ }
+ if (!Objects.equals(version.getStatus(), STATUS_PREVIEW_READY)) {
+ rejectContentIdentity("activate 非当前版本必须处于预览态", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+ if (!Objects.equals(currentVersionId, req.getExpectedCurrentVersionId())) {
+ rejectContentIdentity("activate expected-current 已漂移", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+ // game_version.id 由同库自增分配;只允许首次激活或向更大的新代际前进。
+ if (currentVersionId != null && currentVersionId > req.getVersionId()) {
+ rejectContentIdentity("activate 禁止回退到旧版本", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+
+ Integer runtimeStatus;
+ try {
+ runtimeStatus = runtimePackageApi.getStatus(req.getVersionId()).getCheckedData();
+ } catch (Exception ex) {
+ log.warn("[activateContentVersion] Runtime 状态读取失败 versionId={}", req.getVersionId(), ex);
+ throw exception(PROJECT_CONTENT_VERSION_INVALID);
+ }
+ if (!PackageStatusEnum.PREVIEW_READY.getStatus().equals(runtimeStatus)) {
+ rejectContentIdentity("activate Runtime 预览包未就绪", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+
+ ProjectDO update = new ProjectDO();
+ update.setId(req.getGameId());
+ update.setCurrentVersionId(req.getVersionId());
+ if (projectMapper.updateById(update) != 1) {
+ rejectContentIdentity("activate 项目当前版本更新失败", req.getTenantId(), req.getGameId(), req.getVersionId());
+ }
+ log.info("[activateContentVersion] 验收版本已激活 tenantId={}, gameId={}, versionId={}, previousVersionId={}",
+ req.getTenantId(), req.getGameId(), req.getVersionId(), currentVersionId);
+ }
+
+ /** 为内容源修订构造固定 64 位幂等键,适配 game_version.gen_task_id 现有列宽。 */
+ static String contentRevisionKey(long tenantId, long gameId, String revisionId) {
+ String material = CONTENT_REVISION_KEY_DOMAIN + tenantId + "\0" + gameId + "\0" + revisionId;
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(material.getBytes(StandardCharsets.UTF_8));
+ return java.util.HexFormat.of().formatHex(digest);
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("JDK 缺少 SHA-256", ex);
+ }
+ }
+
+ /** 校验内容控制面基础身份字段,防止直接 Java 调用绕过 DTO 校验。 */
+ private static boolean validContentIdentityInput(Long tenantId, Long gameId, String revisionId) {
+ return tenantId != null && tenantId > 0 && gameId != null && gameId > 0
+ && StringUtils.hasText(revisionId) && REVISION_PATTERN.matcher(revisionId).matches();
+ }
+
+ /** 项目必须是未删除且精确属于请求租户的同一行。 */
+ private static boolean matchesProject(ProjectDO project, Long tenantId, Long gameId) {
+ return project != null && !Boolean.TRUE.equals(project.getDeleted())
+ && Objects.equals(project.getId(), gameId) && Objects.equals(project.getTenantId(), tenantId);
+ }
+
+ /** 版本必须精确属于租户、项目和源修订幂等键,且未逻辑删除。 */
+ private static boolean matchesVersion(GameVersionDO version, Long tenantId, Long gameId, String revisionKey) {
+ return version != null && !Boolean.TRUE.equals(version.getDeleted())
+ && Objects.equals(version.getTenantId(), tenantId) && Objects.equals(version.getGameId(), gameId)
+ && Objects.equals(version.getGenTaskId(), revisionKey);
+ }
+
+ /** 统一记录不含密钥或正文的拒绝日志并抛稳定业务码。 */
+ private static void rejectContentIdentity(String reason, Long tenantId, Long gameId, Long versionId) {
+ log.warn("[gameContentVersion] 拒绝内容版本操作 reason={}, tenantId={}, gameId={}, versionId={}",
+ reason, tenantId, gameId, versionId);
+ throw exception(PROJECT_CONTENT_VERSION_INVALID);
+ }
+
@Override
public Long createForPackage(ProjectVersionCreateForPackageReqDTO req) {
// 幂等:同 genTaskId 已建版本则直接复用,不重复建(PackageFactory 重复落包安全)
diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java
index 48b3c31a..2a1610de 100644
--- a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java
+++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java
@@ -6,7 +6,11 @@ import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO
import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO;
import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper;
import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO;
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO;
+import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO;
import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi;
import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum;
import org.junit.jupiter.api.Test;
@@ -16,9 +20,12 @@ import org.mockito.InOrder;
import org.mockito.Mock;
import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED;
+import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_CONTENT_VERSION_INVALID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verify;
@@ -45,6 +52,178 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest {
@Mock
private RuntimePackageApi runtimePackageApi;
+ @Test
+ void prepareContentVersion_createsStablePreviewReservationWithoutExposingCurrentVersion() {
+ ProjectDO project = project(1024L, 7L, null);
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project);
+ when(gameVersionMapper.selectByGenTaskId(anyString())).thenReturn(null);
+ doAnswer(invocation -> {
+ GameVersionDO version = invocation.getArgument(0);
+ version.setId(2048L);
+ return 1;
+ }).when(gameVersionMapper).insert(any(GameVersionDO.class));
+
+ Long versionId = gameVersionService.prepareContentVersion(prepareReq(7L, 1024L, "revision-a"));
+
+ assertEquals(2048L, versionId);
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(GameVersionDO.class);
+ verify(gameVersionMapper).insert(versionCaptor.capture());
+ assertEquals(1024L, versionCaptor.getValue().getGameId());
+ assertEquals(2, versionCaptor.getValue().getStatus());
+ assertEquals(64, versionCaptor.getValue().getGenTaskId().length());
+ verify(projectMapper, never()).updateById(any(ProjectDO.class));
+ }
+
+ @Test
+ void prepareContentVersion_reusesSameRevisionUnderProjectLock() {
+ ProjectDO project = project(1024L, 7L, null);
+ GameVersionDO existing = contentVersion(2048L, 1024L, 7L, "revision-a");
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project);
+ when(gameVersionMapper.selectByGenTaskId(anyString())).thenReturn(existing);
+
+ Long versionId = gameVersionService.prepareContentVersion(prepareReq(7L, 1024L, "revision-a"));
+
+ assertEquals(2048L, versionId);
+ verify(gameVersionMapper, never()).insert(any(GameVersionDO.class));
+ }
+
+ @Test
+ void prepareContentVersion_rejectsCrossTenantProjectWithoutWriting() {
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 8L, null));
+
+ ServiceException error = assertThrows(ServiceException.class,
+ () -> gameVersionService.prepareContentVersion(prepareReq(7L, 1024L, "revision-a")));
+
+ assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), error.getCode());
+ verify(gameVersionMapper, never()).insert(any(GameVersionDO.class));
+ }
+
+ @Test
+ void validateContentVersionIdentity_rejectsMissingOrCrossGameVersion() {
+ ProjectContentVersionIdentityReqDTO req = identityReq(7L, 1024L, 2048L, "revision-a");
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, null));
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(null);
+ ServiceException missing = assertThrows(ServiceException.class,
+ () -> gameVersionService.validateContentVersionIdentity(req));
+ assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), missing.getCode());
+
+ GameVersionDO crossGame = contentVersion(2048L, 2049L, 7L, "revision-a");
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(crossGame);
+ ServiceException mismatch = assertThrows(ServiceException.class,
+ () -> gameVersionService.validateContentVersionIdentity(req));
+ assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), mismatch.getCode());
+ }
+
+ @Test
+ void bindContentRuntimeArtifact_recordsSummaryWithoutActivatingProject() {
+ ProjectContentVersionBindReqDTO req = bindContentReq();
+ ProjectDO project = project(1024L, 7L, null);
+ GameVersionDO version = contentVersion(2048L, 1024L, 7L, "revision-a");
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project);
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version);
+ when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.PREVIEW_READY.getStatus()));
+ when(gameVersionMapper.updateById(any(GameVersionDO.class))).thenReturn(1);
+
+ gameVersionService.bindContentRuntimeArtifact(req);
+
+ InOrder order = org.mockito.Mockito.inOrder(projectMapper, gameVersionMapper, runtimePackageApi);
+ order.verify(projectMapper).selectByIdForUpdate(1024L);
+ order.verify(gameVersionMapper).selectByIdForUpdate(2048L);
+ order.verify(runtimePackageApi).getStatus(2048L);
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(GameVersionDO.class);
+ verify(gameVersionMapper).updateById(versionCaptor.capture());
+ assertEquals("a".repeat(64), versionCaptor.getValue().getChecksum());
+ verify(projectMapper, never()).updateById(any(ProjectDO.class));
+ }
+
+ @Test
+ void bindContentRuntimeArtifact_oldReplayOnlyConfirmsOwnSummary() {
+ ProjectContentVersionBindReqDTO req = bindContentReq();
+ ProjectDO project = project(1024L, 7L, 2050L);
+ GameVersionDO oldVersion = contentVersion(2048L, 1024L, 7L, "revision-a");
+ oldVersion.setChecksum("a".repeat(64));
+ oldVersion.setBundleSize(4096L);
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project);
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(oldVersion);
+ when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.PREVIEW_READY.getStatus()));
+
+ gameVersionService.bindContentRuntimeArtifact(req);
+
+ verify(projectMapper, never()).updateById(any(ProjectDO.class));
+ verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class));
+ }
+
+ @Test
+ void activateContentVersion_switchesPointerOnlyAfterExpectedCurrentMatches() {
+ ProjectContentVersionActivateReqDTO req = activateContentReq(2000L, 2048L);
+ ProjectDO project = project(1024L, 7L, 2000L);
+ GameVersionDO version = finalizedContentVersion(2048L);
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project);
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version);
+ when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.PREVIEW_READY.getStatus()));
+ when(projectMapper.updateById(any(ProjectDO.class))).thenReturn(1);
+
+ gameVersionService.activateContentVersion(req);
+
+ ArgumentCaptor projectCaptor = ArgumentCaptor.forClass(ProjectDO.class);
+ verify(projectMapper).updateById(projectCaptor.capture());
+ assertEquals(2048L, projectCaptor.getValue().getCurrentVersionId());
+ }
+
+ @Test
+ void activateContentVersion_rejectsStaleExpectedCurrentWithoutWriting() {
+ ProjectContentVersionActivateReqDTO req = activateContentReq(1999L, 2048L);
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, 2000L));
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(finalizedContentVersion(2048L));
+
+ ServiceException error = assertThrows(ServiceException.class,
+ () -> gameVersionService.activateContentVersion(req));
+
+ assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), error.getCode());
+ verify(projectMapper, never()).updateById(any(ProjectDO.class));
+ verifyNoInteractions(runtimePackageApi);
+ }
+
+ @Test
+ void activateContentVersion_rejectsRollbackEvenWhenExpectedCurrentMatches() {
+ ProjectContentVersionActivateReqDTO req = activateContentReq(2050L, 2048L);
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, 2050L));
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(finalizedContentVersion(2048L));
+
+ ServiceException error = assertThrows(ServiceException.class,
+ () -> gameVersionService.activateContentVersion(req));
+
+ assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), error.getCode());
+ verify(projectMapper, never()).updateById(any(ProjectDO.class));
+ verifyNoInteractions(runtimePackageApi);
+ }
+
+ @Test
+ void activateContentVersion_sameCurrentIsIdempotent() {
+ ProjectContentVersionActivateReqDTO req = activateContentReq(2048L, 2048L);
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, 2048L));
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(finalizedContentVersion(2048L));
+
+ gameVersionService.activateContentVersion(req);
+
+ verify(projectMapper, never()).updateById(any(ProjectDO.class));
+ verifyNoInteractions(runtimePackageApi);
+ }
+
+ @Test
+ void activateContentVersion_publishedSameCurrentRetryIsIdempotent() {
+ ProjectContentVersionActivateReqDTO req = activateContentReq(2048L, 2048L);
+ GameVersionDO publishedVersion = finalizedContentVersion(2048L);
+ publishedVersion.setStatus(3);
+ when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, 2048L));
+ when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(publishedVersion);
+
+ gameVersionService.activateContentVersion(req);
+
+ verify(projectMapper, never()).updateById(any(ProjectDO.class));
+ verifyNoInteractions(runtimePackageApi);
+ }
+
@Test
void bindRuntimeArtifact_updatesAuthoritativeChecksumForApproveGate() {
ProjectVersionBindArtifactReqDTO req = artifactReq("a".repeat(64));
@@ -202,6 +381,77 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest {
return req;
}
+ /** 构造内容仓库预留版本入参。 */
+ private static ProjectContentVersionPrepareReqDTO prepareReq(long tenantId, long gameId, String revisionId) {
+ ProjectContentVersionPrepareReqDTO req = new ProjectContentVersionPrepareReqDTO();
+ req.setTenantId(tenantId);
+ req.setGameId(gameId);
+ req.setRevisionId(revisionId);
+ return req;
+ }
+
+ /** 构造内容版本身份校验入参。 */
+ private static ProjectContentVersionIdentityReqDTO identityReq(
+ long tenantId, long gameId, long versionId, String revisionId) {
+ ProjectContentVersionIdentityReqDTO req = new ProjectContentVersionIdentityReqDTO();
+ req.setTenantId(tenantId);
+ req.setGameId(gameId);
+ req.setVersionId(versionId);
+ req.setRevisionId(revisionId);
+ return req;
+ }
+
+ /** 构造内容运行包绑定入参。 */
+ private static ProjectContentVersionBindReqDTO bindContentReq() {
+ ProjectContentVersionBindReqDTO req = new ProjectContentVersionBindReqDTO();
+ req.setTenantId(7L);
+ req.setGameId(1024L);
+ req.setVersionId(2048L);
+ req.setRevisionId("revision-a");
+ req.setChecksum("a".repeat(64));
+ req.setBundleSize(4096L);
+ return req;
+ }
+
+ /** 构造验收后激活请求,expected 是验收开始时的项目指针。 */
+ private static ProjectContentVersionActivateReqDTO activateContentReq(Long expectedCurrentVersionId,
+ long versionId) {
+ ProjectContentVersionActivateReqDTO req = new ProjectContentVersionActivateReqDTO();
+ req.setTenantId(7L);
+ req.setGameId(1024L);
+ req.setVersionId(versionId);
+ req.setRevisionId("revision-a");
+ req.setExpectedCurrentVersionId(expectedCurrentVersionId);
+ return req;
+ }
+
+ /** 构造含显式租户归属的项目。 */
+ private static ProjectDO project(long id, long tenantId, Long currentVersionId) {
+ ProjectDO project = new ProjectDO();
+ project.setId(id);
+ project.setTenantId(tenantId);
+ project.setCurrentVersionId(currentVersionId);
+ return project;
+ }
+
+ /** 构造与内容修订幂等键一致的版本;幂等键算法由生产服务统一提供。 */
+ private static GameVersionDO contentVersion(long id, long gameId, long tenantId, String revisionId) {
+ GameVersionDO version = version(2, "", 0L);
+ version.setId(id);
+ version.setGameId(gameId);
+ version.setTenantId(tenantId);
+ version.setGenTaskId(GameVersionServiceImpl.contentRevisionKey(tenantId, gameId, revisionId));
+ return version;
+ }
+
+ /** 构造 finalize 已写入权威摘要的内容版本。 */
+ private static GameVersionDO finalizedContentVersion(long id) {
+ GameVersionDO version = contentVersion(id, 1024L, 7L, "revision-a");
+ version.setChecksum("a".repeat(64));
+ version.setBundleSize(4096L);
+ return version;
+ }
+
/** 准备版本、项目、runtime 三方都指向同一待绑定版本的正常场景。 */
private void prepareBindableVersion() {
GameVersionDO version = version(2, "", 0L);
diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java
index 76fb871d..f39ec307 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java
@@ -1,6 +1,8 @@
package com.wanxiang.huijing.game.module.runtime.api;
import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO;
import com.wanxiang.huijing.game.module.runtime.enums.ApiConstants;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import io.swagger.v3.oas.annotations.Operation;
@@ -85,4 +87,14 @@ public interface RuntimePackageApi {
@Operation(summary = "落包:建运行包行(status=0)+写 manifest(agent 闭环/Dify 共用)")
CommonResult storeForVersion(@RequestBody @Valid RuntimePackageStoreReqDTO req);
+ /**
+ * 受信任内容控制面在同 JVM 事务内落 OSS 运行包预览元数据。
+ *
+ * Feign 契约保留映射以保证代理可装配,但 Runtime 实现会无条件拒绝直接命中该 RPC 的 HTTP 请求;
+ * 外部唯一可执行入口是 AIGC HMAC 控制面内的同 JVM 调用。
+ */
+ @PostMapping(PREFIX + "/finalize-oss-package")
+ CommonResult finalizeOssPackage(
+ @RequestBody @Valid RuntimeOssPackageFinalizeReqDTO req);
+
}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeReqDTO.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeReqDTO.java
new file mode 100644
index 00000000..8d9bfd02
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeReqDTO.java
@@ -0,0 +1,45 @@
+package com.wanxiang.huijing.game.module.runtime.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * OSS GamePackage Finalize 入参。
+ *
+ * {@code manifestJson} 只在同 JVM 事务调用中用于现场复算摘要和提取预览元数据,
+ * Runtime 数据库严禁持久化该正文。
+ */
+@Data
+public class RuntimeOssPackageFinalizeReqDTO {
+
+ /** 目标租户 ID。 */
+ @NotNull(message = "tenantId 不能为空")
+ private Long tenantId;
+
+ /** 目标游戏项目 ID。 */
+ @NotNull(message = "gameId 不能为空")
+ private Long gameId;
+
+ /** project prepare 返回的权威版本 ID。 */
+ @NotNull(message = "versionId 不能为空")
+ private Long versionId;
+
+ /** V34 记录绑定的不可变源修订 ID。 */
+ @NotBlank(message = "revisionId 不能为空")
+ @Size(max = 128, message = "revisionId 长度不能超过 128")
+ @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法")
+ private String revisionId;
+
+ /** GameArtifactManifest/1 的域分隔 canonical 摘要。 */
+ @NotBlank(message = "artifactManifestHash 不能为空")
+ @Pattern(regexp = "^[a-f0-9]{64}$", message = "artifactManifestHash 必须为 64 位小写十六进制 sha256")
+ private String artifactManifestHash;
+
+ /** 已上传至 canonical runtime key 的 GamePackage 原始 UTF-8 JSON 文本。 */
+ @NotBlank(message = "manifestJson 不能为空")
+ @Size(max = 16777216, message = "manifestJson 超过 16MiB")
+ private String manifestJson;
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeRespDTO.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeRespDTO.java
new file mode 100644
index 00000000..0f19c9b9
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeRespDTO.java
@@ -0,0 +1,21 @@
+package com.wanxiang.huijing.game.module.runtime.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/** Runtime 已现场验证并落库的 OSS 预览元数据。 */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class RuntimeOssPackageFinalizeRespDTO {
+
+ /** game_runtime_package 主键。 */
+ private Long packageId;
+
+ /** GamePackage 原始 UTF-8 字节 SHA-256。 */
+ private String checksum;
+
+ /** GamePackage manifest 声明的运行包字节数。 */
+ private Long bundleSize;
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java
index a688a5ba..33cf1e1c 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java
@@ -31,6 +31,12 @@ public interface ErrorCodeConstants {
ErrorCode RUNTIME_PACKAGE_INVALIDATE_REJECTED = new ErrorCode(1_102_001_006, "运行包不存在或状态不可失效");
/** 狗粮态禁止通过 runtime RPC 的外部 HTTP 入口写运行包。 */
ErrorCode RUNTIME_DOGFOOD_RPC_WRITE_DISABLED = new ErrorCode(1_102_001_007, "内部狗粮环境禁止直接写运行包");
+ /** OSS 内容 Finalize 只允许在 Runtime 已切到 OSS 读取模式时执行。 */
+ ErrorCode RUNTIME_OSS_FINALIZE_DISABLED = new ErrorCode(1_102_001_008, "Runtime 尚未启用 OSS 内容模式");
+ /** OSS 内容 Finalize 的身份、locator、摘要或 GamePackage 元数据不一致。 */
+ ErrorCode RUNTIME_OSS_FINALIZE_INVALID = new ErrorCode(1_102_001_009, "OSS 运行包身份或摘要无效");
+ /** Runtime OSS Finalize RPC 禁止外部 HTTP 直调,只允许 AIGC HMAC 控制面本地调用。 */
+ ErrorCode RUNTIME_OSS_FINALIZE_RPC_DISABLED = new ErrorCode(1_102_001_010, "OSS Finalize 仅允许受信任控制面调用");
// ========== 编译 1-102-002-*** (对齐契约 #1 BuildRespVO.failCode)==========
/** GameConfig 静态校验失败(门禁 T-RT-16,编译期对接点判定) */
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml b/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml
index d234c00c..5c7ac9b2 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml
@@ -72,6 +72,12 @@
huijing-spring-boot-starter-rpc
+
+
+ software.amazon.awssdk
+ s3
+
+
com.wanxiang
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java
index 220ad2d2..2262be00 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java
@@ -1,16 +1,24 @@
package com.wanxiang.huijing.game.module.runtime.api;
import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO;
import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageService;
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
+import com.wanxiang.huijing.framework.common.util.servlet.ServletUtils;
import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
import jakarta.annotation.Resource;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.HandlerMapping;
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_DOGFOOD_RPC_WRITE_DISABLED;
+import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_RPC_DISABLED;
+import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 运行包发布 API 实现(黄金闭环 §3.2 C2,提供 RESTful 接口给跨模块 Feign 调用:发布编排翻包 + 可见态校验 + 落包)
@@ -24,6 +32,7 @@ import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.
@RestController // 提供 RESTful API 接口,给 Feign 调用
@Validated
@Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析,发布编排事务内本地调用)
+@Slf4j
public class RuntimePackageApiImpl implements RuntimePackageApi {
@Resource
@@ -60,6 +69,32 @@ public class RuntimePackageApiImpl implements RuntimePackageApi {
return success(runtimePackageService.storeForVersion(req));
}
+ @Override
+ public CommonResult finalizeOssPackage(RuntimeOssPackageFinalizeReqDTO req) {
+ rejectExternalOssFinalize();
+ return success(runtimePackageService.finalizeOssPackage(req));
+ }
+
+ /** OSS Finalize 无论环境都禁止外部直调,只信任 AIGC 请求内的同 JVM 方法调用。 */
+ private void rejectExternalOssFinalize() {
+ HttpServletRequest request = ServletUtils.getRequest();
+ if (request == null) {
+ return;
+ }
+ Object patternAttribute = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
+ if (patternAttribute == null) {
+ return;
+ }
+ String pattern = patternAttribute.toString();
+ if (!pattern.equals(RuntimePackageApi.PREFIX)
+ && !pattern.startsWith(RuntimePackageApi.PREFIX + "/")) {
+ return;
+ }
+ log.warn("[runtimeRpcGuard] 已拒绝外部 OSS Finalize HTTP 写入口 code={}",
+ RUNTIME_OSS_FINALIZE_RPC_DISABLED.getCode());
+ throw exception(RUNTIME_OSS_FINALIZE_RPC_DISABLED);
+ }
+
/** 仅封锁 runtime RPC 的外部 HTTP 写入;project/aigc 请求内的本地 Java 调用和无请求上下文调用保持可用。 */
private void rejectExternalRpcWrite(String operation) {
dogfoodAccessGuard.rejectHttpPathInDogfood(operation, RuntimePackageApi.PREFIX,
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java
index 4324d7b0..7a13a940 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java
@@ -20,6 +20,8 @@ import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -59,7 +61,15 @@ public class AppRuntimeController {
// 取包门禁(scene+status 权威判定 + 预览归属)在 Service 承载
RuntimePackageDO pkg = runtimePackageService.getPackageManifest(versionId, scene, userId);
// manifestUrl 继承本次请求的 scene:preview 场景拼 ?scene=preview,避免宿主裸 GET 落 play 缺省门禁(冒烟⑥缝隙修复)
- return success(RuntimeConvert.toPackageRespVO(pkg, scene));
+ RuntimePackageRespVO response = RuntimeConvert.toPackageRespVO(pkg, scene);
+ if (runtimePackageService.isObjectAssetProxyEnabled()) {
+ String template = "/app-api/runtime/package/" + versionId + "/assets/{sha256}";
+ if (RuntimeSceneEnum.isPreview(scene)) {
+ template += "?scene=preview";
+ }
+ response.setAssetUrlTemplate(template);
+ }
+ return success(response);
}
@GetMapping(value = "/package/{versionId}/manifest", produces = MediaType.APPLICATION_JSON_VALUE)
@@ -76,6 +86,31 @@ public class AppRuntimeController {
return runtimePackageService.getPackageManifestRaw(versionId, scene, userId);
}
+ @GetMapping(value = "/package/{versionId}/assets/{sha256}")
+ @PermitAll
+ @Operation(summary = "按内容摘要读取版本素材",
+ description = "复用取包场景/归属门,从 committed artifact manifest 映射精确对象并逐字节复算")
+ public ResponseEntity getPackageAsset(
+ @PathVariable("versionId") Long versionId,
+ @PathVariable("sha256") String sha256,
+ @RequestParam(value = "scene", required = false, defaultValue = "play") String scene) {
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+ com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimeAssetContent asset =
+ runtimePackageService.getPackageAsset(versionId, scene, userId, sha256);
+ CacheControl cacheControl = RuntimeSceneEnum.isPreview(scene)
+ // 未发布预览素材受 owner 门保护,任何共享缓存和浏览器持久缓存都不得留副本。
+ ? CacheControl.noStore().cachePrivate()
+ // 已发布素材由 version + sha256 内容寻址,可安全长期公开缓存。
+ : CacheControl.maxAge(java.time.Duration.ofDays(365)).cachePublic().immutable();
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType(asset.mime()))
+ .contentLength(asset.bytes().length)
+ .eTag('"' + asset.sha256() + '"')
+ .cacheControl(cacheControl)
+ .header("X-Content-Type-Options", "nosniff")
+ .body(asset.bytes());
+ }
+
@PostMapping("/session/start")
@PermitAll // 2026-06-10 鉴权件放行,匿名合法:匿名试玩开会话(§6.1 #7);userId 可空时落 player_user_id=NULL + reqVO.anonId 归属(对齐 V11 ALTER)
@Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用;clientPlayToken 幂等;匿名携带 anonId 归属")
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java
index ed7d64db..a19cb383 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java
@@ -26,10 +26,13 @@ public class RuntimePackageRespVO {
@Schema(description = "GamePackage(manifest)OSS/CDN URL(按 /games/{gameId}/versions/{versionId}/ 版本化)")
private String packageUrl;
- @Schema(description = "manifest.json 取包清单 URL(GAP-2 分支A:由 packageUrl 同前缀派生 .../manifest.json,宿主先 fetch 校验 sha256 再注入)",
+ @Schema(description = "同源 Runtime manifest URL;服务端按读取模式取原文,宿主校验 sha256 后注入",
example = "https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json")
private String manifestUrl;
+ @Schema(description = "对象素材同源代理模板;宿主把 {sha256} 替换为 assets[].hash 后带平台鉴权读取")
+ private String assetUrlTemplate;
+
@Schema(description = "入口文件相对路径(#4 manifest.entry)", example = "index.html")
private String entry;
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/SandboxPolicyVO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/SandboxPolicyVO.java
index 68c72bc6..bf43c030 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/SandboxPolicyVO.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/SandboxPolicyVO.java
@@ -8,7 +8,7 @@ import java.util.List;
/**
* iframe 沙箱隔离策略 VO(对齐契约 SandboxPolicyVO,T-RT-04)
*
- * 宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14,#3 双校验)。
+ * 宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14,来源窗口/origin/schema 三校验)。
*
* @author 绘境AI
*/
@@ -16,10 +16,10 @@ import java.util.List;
@Data
public class SandboxPolicyVO {
- @Schema(description = "iframe sandbox 属性值", example = "allow-scripts allow-same-origin")
+ @Schema(description = "iframe sandbox 属性值", example = "allow-scripts")
private String sandboxAttr;
- @Schema(description = "postMessage 允许的 origin 白名单(宿主侧 #3 双校验)")
+ @Schema(description = "postMessage 允许的 origin 白名单(宿主侧来源窗口/origin/schema 三校验)")
private List allowOrigins;
}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvert.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvert.java
index 575153a0..3670d245 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvert.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvert.java
@@ -55,11 +55,8 @@ public class RuntimeConvert {
}
// 平铺字段(gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷)
RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class);
- // manifestUrl 解析(§3.4 C4 退场契约):
- // - MVP 无 OSS(packageUrl 空,整包存 DB):指向 manifest 端点 /app-api/runtime/package/{versionId}/manifest,
- // 宿主 fetch 该端点取原始 JSON、对响应文本算 sha256 与 checksum 比对后注入(相对 URL 由前端 resolve 到 API base,§2.5);
- // preview 场景必须拼 ?scene=preview,使 manifest 端点门禁与取包端点同场景判定(见上方法注释的缝隙说明);
- // - M3 接 OSS(packageUrl 非空):回落 GAP-2 分支A 由 packageUrl 同前缀派生 .../manifest.json(OSS/CDN 版本化 URL,静态文件无门禁不拼参)。
+ // manifestUrl 始终指向同源 Runtime API。后端可在 DB/shadow/OSS 三模式切流,浏览器不感知对象域,
+ // 也不会把平台 Authorization/tenant-id 发往 MinIO 或 CDN。
vo.setManifestUrl(resolveManifestUrl(pkg, scene));
// 沙箱策略单独组装:sandboxAttr 直拷,allowOrigins 逗号串拆 List
SandboxPolicyVO sandbox = new SandboxPolicyVO();
@@ -70,49 +67,18 @@ public class RuntimeConvert {
}
/**
- * 解析 manifestUrl(§3.4 C4 退场契约:MVP 走 DB 端点 / M3 走 OSS 派生)
+ * 解析同源 manifestUrl;存储介质切换由服务端 read-mode 承担。
*
- * @param pkg 运行包 DO(取 packageUrl 判 MVP/OSS,versionId 拼端点路径)
- * @param scene 取包请求场景:仅 preview 在 DB 端点分支拼 ?scene=preview(play/null 保持历史裸路径,行为零变化)
- * @return manifestUrl:packageUrl 空 → manifest 端点相对路径(preview 带 scene 参数);非空 → 同前缀派生 .json
+ * @param pkg 运行包 DO(只使用 versionId 拼同源端点路径)
+ * @param scene 取包请求场景:preview 拼 ?scene=preview,play/null 保持裸路径
+ * @return 固定的同源 Runtime manifest 端点
*/
public static String resolveManifestUrl(RuntimePackageDO pkg, String scene) {
- if (!StringUtils.hasText(pkg.getPackageUrl())) {
- // MVP 无 OSS:整包存 DB,manifestUrl 指向原样服务端点(相对路径,前端 resolve 到 API base)
- String url = "/app-api/runtime/package/" + pkg.getVersionId() + "/manifest";
- // preview 场景拼参:manifest 端点 scene 缺省为 play(仅放行 status=1),
- // 预览 status=0 包必须显式声明 preview 才能过同场景门禁(缝隙修复方案 A)
- if ("preview".equals(scene)) {
- url += "?scene=preview";
- }
- return url;
+ String url = "/app-api/runtime/package/" + pkg.getVersionId() + "/manifest";
+ if ("preview".equals(scene)) {
+ url += "?scene=preview";
}
- // M3 OSS:保留 GAP-2 分支A 派生(静态文件无门禁,scene 不参与)
- return deriveManifestUrl(pkg.getPackageUrl());
- }
-
- /**
- * 由 packageUrl 同前缀派生 manifest.json 取包清单 URL(GAP-2 分支A,纯派生不增列)
- *
- * 规则:取 packageUrl 末段 '/' 之前的同级目录前缀,拼接 manifest.json。
- * - 以 '/' 结尾(目录形态,如 .../versions/2048/):直接追加 manifest.json;
- * - 含文件名(如 .../2048/package.zip):替换末段文件名为 manifest.json,保持同前缀同级;
- * - 空/空白:返回 null(无包则无清单,宿主走兜底,不构造非法 URL)。
- *
- * @param packageUrl 运行包 OSS/CDN URL(按 /games/{gameId}/versions/{versionId}/ 版本化)
- * @return manifest.json 清单 URL;packageUrl 空时返回 null
- */
- public static String deriveManifestUrl(String packageUrl) {
- if (!StringUtils.hasText(packageUrl)) {
- return null;
- }
- int lastSlash = packageUrl.lastIndexOf('/');
- // 无 '/'(异常退化形态):直接当作前缀目录,拼接 manifest.json,避免越权改写其它路径
- if (lastSlash < 0) {
- return packageUrl + "/manifest.json";
- }
- // 保留含末位 '/' 的同级前缀,替换末段文件名(或空段)为 manifest.json
- return packageUrl.substring(0, lastSlash + 1) + "manifest.json";
+ return url;
}
// ============================== 编译任务 ==============================
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java
index 530918f4..42906a5d 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java
@@ -69,7 +69,7 @@ public class RuntimePackageDO extends TenantBaseDO {
*/
private String sandboxAttr;
/**
- * postMessage 允许 origin 白名单(逗号分隔,宿主侧 #3 双校验 T-RT-14;VO 层转 List)
+ * postMessage 允许 origin 白名单(逗号分隔,宿主侧来源窗口/origin/schema 三校验;VO 层转 List)
*/
private String allowOrigins;
/**
@@ -82,7 +82,7 @@ public class RuntimePackageDO extends TenantBaseDO {
/**
* 整包 manifest 原始 JSON(黄金闭环 §3.4 C4,V10 新增 package_json LONGTEXT)
*
- * MVP 无 OSS:整包存 DB,由 {@code PackageStore} 的 DB impl 读写本列(M3 接 OSS 后下线,退场契约)。
+ * 旧 DB 模式由 {@code PackageStore} 读写;OSS 版本保持 NULL,具体游戏正文只存在对象存储。
* manifest 端点 GET /app-api/runtime/package/{versionId}/manifest 经 PackageStore 读出原样返回,
* 不包 CommonResult、不 parse/re-serialize(任何重序列化都破坏字节一致性导致宿主 sha256 校验失败)。
*/
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/storage/ArtifactStorageDO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/storage/ArtifactStorageDO.java
index c16c13bc..406c2624 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/storage/ArtifactStorageDO.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/storage/ArtifactStorageDO.java
@@ -28,7 +28,7 @@ public class ArtifactStorageDO extends TenantBaseDO {
private String artifactBucket;
/** artifact-manifest.json 对象 key。 */
private String artifactManifestKey;
- /** artifact-manifest.json 原始字节 SHA-256。 */
+ /** GameArtifactManifest/1 的 manifestHash(域标签加 canonical JSON 摘要)。 */
private String artifactManifestHash;
/** artifact-manifest.json 字节数。 */
private Long artifactManifestBytes;
@@ -46,7 +46,7 @@ public class ArtifactStorageDO extends TenantBaseDO {
private String sourceRevisionId;
/** source-manifest.json 对象 key。 */
private String sourceManifestKey;
- /** source-manifest.json 原始字节 SHA-256。 */
+ /** GameSourceArchive/1 的 manifestHash(域标签加 canonical JSON 摘要)。 */
private String sourceManifestHash;
/** 源文件树 SHA-256。 */
private String sourceHash;
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/mysql/storage/ArtifactStorageMapper.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/mysql/storage/ArtifactStorageMapper.java
index 2b51f2ca..507efc17 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/mysql/storage/ArtifactStorageMapper.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/mysql/storage/ArtifactStorageMapper.java
@@ -30,4 +30,10 @@ public interface ArtifactStorageMapper extends BaseMapperX {
.eq(ArtifactStorageDO::getVersionId, versionId)
.eq(ArtifactStorageDO::getStatus, "committed"));
}
+
+ /** Finalize 事务锁定指定三元身份的 pending/committed 状态记录。 */
+ default ArtifactStorageDO selectByIdentityForUpdate(Long tenantId, Long gameId, Long versionId) {
+ return selectOneForUpdate(ArtifactStorageDO::getTenantId, tenantId,
+ ArtifactStorageDO::getGameId, gameId, ArtifactStorageDO::getVersionId, versionId);
+ }
}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGate.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGate.java
index ef2b9d9d..b556827d 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGate.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGate.java
@@ -3,16 +3,11 @@ 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;
@@ -24,111 +19,56 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept
*
* 开关默认关闭,允许 V34 尚未部署的环境继续使用旧 DB manifest 路径;打开后,运行时必须
* 找到 committed 记录,且记录中的 GamePackage manifest hash 必须与 runtime package checksum
- * 一致;若 manifest 含 engineBundle,还必须把实际 bundle 字节 hash 与对象记录对账。
+ * 一致,并且桶和 key 必须与三元业务身份的 canonical locator 一致。
*/
@Slf4j
@Component
public class ArtifactStorageGate {
- /** 只读解析原始 GamePackage,不对外重新序列化,避免破坏 checksum 字节语义。 */
- private static final ObjectMapper PACKAGE_MAPPER = new ObjectMapper();
-
@Resource
private ArtifactStorageMapper artifactStorageMapper;
- /**
- * 生产切换开关。只有 V34 已落库且上传器完成 pending→committed 后才允许打开。
- */
- @Value("${game.artifact-storage.enforce-committed:false}")
- private boolean enforceCommitted;
+ @Resource
+ private RuntimeArtifactStorageProperties properties;
/**
* 校验运行包是否已经绑定到已提交对象记录。
*
* @param runtimePackage 当前运行包记录
*/
- public void requireCommitted(RuntimePackageDO runtimePackage) {
- if (!enforceCommitted) {
- return;
+ public ArtifactStorageDO requireCommitted(RuntimePackageDO runtimePackage) {
+ if (!properties.requiresCommitted() && !properties.isShadowRead()) {
+ return null;
}
ArtifactStorageDO storage = artifactStorageMapper.selectCommittedByIdentity(
runtimePackage.getTenantId(), runtimePackage.getGameId(), runtimePackage.getVersionId());
if (storage == null) {
+ if (!properties.requiresCommitted()) {
+ return 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()));
+ String expectedPrefix = "tenants/" + runtimePackage.getTenantId() + "/games/"
+ + runtimePackage.getGameId() + "/versions/" + runtimePackage.getVersionId();
+ boolean locatorMatches = Objects.equals(storage.getTenantId(), runtimePackage.getTenantId())
+ && Objects.equals(storage.getGameId(), runtimePackage.getGameId())
+ && Objects.equals(storage.getVersionId(), runtimePackage.getVersionId())
+ && Objects.equals(storage.getArtifactBucket(), properties.getArtifactBucket())
+ && Objects.equals(storage.getArtifactManifestKey(), expectedPrefix + "/artifact-manifest.json")
+ && Objects.equals(storage.getRuntimeManifestKey(), expectedPrefix + "/manifest.json")
+ && StringUtils.hasText(storage.getArtifactManifestHash())
+ && storage.getArtifactManifestHash().matches("^[a-f0-9]{64}$")
+ && storage.getArtifactManifestBytes() != null && storage.getArtifactManifestBytes() > 0;
if (!Objects.equals(storage.getRuntimeManifestHash(), runtimePackage.getChecksum())
- || !bundleMatches) {
+ || !locatorMatches) {
log.error("[artifactStorageGate] 对象记录与运行包摘要不一致,拒绝消费 versionId={}, "
- + "runtimeManifestHash={}, runtimeChecksum={}, bundleHash={}, actualBundleHash={}",
+ + "runtimeManifestHash={}, runtimeChecksum={}, artifactBucket={}, runtimeManifestKey={}",
runtimePackage.getVersionId(), storage.getRuntimeManifestHash(), runtimePackage.getChecksum(),
- storage.getBundleHash(), bundleCheck.sha256());
+ storage.getArtifactBucket(), storage.getRuntimeManifestKey());
throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
}
- }
-
- /**
- * 从当前实际消费的 DB manifest 提取 engineBundle 并计算 UTF-8 SHA-256。
- *
- * 对象上传器已经对远端 bundle 做逐字节回读;消费侧再对 DB manifest 中即将交给宿主的
- * engineBundle 做一次对账,避免只检查数据库里的 bundle_hash 非空而放过陈旧内容。
- */
- 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);
- }
+ return storage;
}
}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentService.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentService.java
new file mode 100644
index 00000000..ea5b89ce
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentService.java
@@ -0,0 +1,219 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+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.service.pkg.store.ArtifactObjectReadException;
+import com.wanxiang.huijing.game.module.runtime.service.pkg.store.ArtifactObjectReader;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+import java.util.Iterator;
+import java.util.Objects;
+import java.util.TreeSet;
+import java.util.regex.Pattern;
+
+import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
+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;
+
+/**
+ * 从对象存储读取并校验 GamePackage、artifact manifest 和素材原始字节。
+ *
+ * 对象 locator 只来自 {@link ArtifactStorageDO};客户端只能提交素材 hash,不能提交 bucket、key 或 URL。
+ */
+@Slf4j
+@Component
+public class RuntimeArtifactContentService {
+
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private static final Pattern SHA256 = Pattern.compile("^[a-f0-9]{64}$");
+ private static final byte[] ARTIFACT_HASH_DOMAIN = "GameArtifactManifest/1\0"
+ .getBytes(StandardCharsets.UTF_8);
+
+ @Resource
+ private ArtifactObjectReader objectReader;
+
+ @Resource
+ private RuntimeArtifactStorageProperties properties;
+
+ /** 读取将要返回宿主的 GamePackage 原文,并校验原文、身份和内联 bundle。 */
+ public String readManifest(RuntimePackageDO runtimePackage, ArtifactStorageDO storage) {
+ byte[] bytes;
+ try {
+ bytes = objectReader.read(storage.getArtifactBucket(), storage.getRuntimeManifestKey(),
+ properties.getMaxManifestBytes());
+ } catch (ArtifactObjectReadException ex) {
+ log.error("[runtimeArtifact] OSS GamePackage 读取失败 versionId={}", runtimePackage.getVersionId(), ex);
+ throw exception(RUNTIME_PACKAGE_NOT_READY);
+ }
+ if (!Objects.equals(sha256Hex(bytes), storage.getRuntimeManifestHash())
+ || !Objects.equals(storage.getRuntimeManifestHash(), runtimePackage.getChecksum())) {
+ log.error("[runtimeArtifact] OSS GamePackage 原文摘要漂移 versionId={}", runtimePackage.getVersionId());
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ String raw = strictUtf8(bytes, "GamePackage");
+ try {
+ JsonNode root = JSON.readTree(raw);
+ if (root == null || !root.isObject()
+ || !Objects.equals(root.path("gameId").asText(), String.valueOf(runtimePackage.getGameId()))
+ || !Objects.equals(root.path("versionId").asText(), String.valueOf(runtimePackage.getVersionId()))) {
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ JsonNode bundle = root.get("engineBundle");
+ if (bundle == null || bundle.isNull()) {
+ if (StringUtils.hasText(storage.getBundleHash())) {
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ } else if (!bundle.isTextual() || !StringUtils.hasText(bundle.asText())
+ || !Objects.equals(sha256Hex(bundle.asText().getBytes(StandardCharsets.UTF_8)), storage.getBundleHash())) {
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ return raw;
+ } catch (com.wanxiang.huijing.framework.common.exception.ServiceException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ log.error("[runtimeArtifact] OSS GamePackage 解析或 bundle 校验失败 versionId={}",
+ runtimePackage.getVersionId(), ex);
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ }
+
+ /** 按素材内容 hash 从已验 artifact manifest 映射精确对象并复算原始字节。 */
+ public RuntimeAssetContent readAsset(RuntimePackageDO runtimePackage, ArtifactStorageDO storage, String assetHash) {
+ if (!SHA256.matcher(String.valueOf(assetHash)).matches()) {
+ throw exception(RUNTIME_PACKAGE_NOT_READY);
+ }
+ JsonNode manifest = readArtifactManifest(runtimePackage, storage);
+ JsonNode selected = null;
+ for (JsonNode item : manifest.path("objects")) {
+ if ("asset".equals(item.path("category").asText())
+ && "artifact".equals(item.path("store").asText())
+ && assetHash.equals(item.path("sha256").asText())) {
+ selected = item;
+ break;
+ }
+ }
+ if (selected == null) {
+ throw exception(RUNTIME_PACKAGE_NOT_READY);
+ }
+ String path = selected.path("path").asText();
+ String key = selected.path("key").asText();
+ String prefix = manifest.path("keyPrefix").asText();
+ long expectedBytes = selected.path("bytes").asLong(-1);
+ String mime = selected.path("mime").asText();
+ if (!path.startsWith("assets/") || !Objects.equals(key, prefix + "/" + path)
+ || expectedBytes < 0 || expectedBytes > properties.getMaxAssetBytes()
+ || !StringUtils.hasText(mime)) {
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ byte[] bytes;
+ try {
+ bytes = objectReader.read(storage.getArtifactBucket(), key, expectedBytes);
+ } catch (ArtifactObjectReadException ex) {
+ log.error("[runtimeArtifact] OSS 素材读取失败 versionId={}, hash={}",
+ runtimePackage.getVersionId(), assetHash, ex);
+ throw exception(RUNTIME_PACKAGE_NOT_READY);
+ }
+ if (bytes.length != expectedBytes || !Objects.equals(sha256Hex(bytes), assetHash)) {
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ return new RuntimeAssetContent(bytes, mime, assetHash);
+ }
+
+ /** 读取并验证 artifact manifest 的契约摘要、身份和 canonical key。 */
+ private JsonNode readArtifactManifest(RuntimePackageDO runtimePackage, ArtifactStorageDO storage) {
+ byte[] bytes;
+ try {
+ bytes = objectReader.read(storage.getArtifactBucket(), storage.getArtifactManifestKey(),
+ Math.min(properties.getMaxManifestBytes(), storage.getArtifactManifestBytes()));
+ } catch (ArtifactObjectReadException ex) {
+ log.error("[runtimeArtifact] artifact manifest 读取失败 versionId={}", runtimePackage.getVersionId(), ex);
+ throw exception(RUNTIME_PACKAGE_NOT_READY);
+ }
+ if (bytes.length != storage.getArtifactManifestBytes()) {
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ try {
+ JsonNode root = JSON.readTree(strictUtf8(bytes, "artifact manifest"));
+ String expectedPrefix = "tenants/" + runtimePackage.getTenantId() + "/games/"
+ + runtimePackage.getGameId() + "/versions/" + runtimePackage.getVersionId();
+ if (root == null || !root.isObject()
+ || !"GameArtifactManifest/1".equals(root.path("schemaVersion").asText())
+ || !String.valueOf(runtimePackage.getTenantId()).equals(root.path("tenantId").asText())
+ || !String.valueOf(runtimePackage.getGameId()).equals(root.path("gameId").asText())
+ || !String.valueOf(runtimePackage.getVersionId()).equals(root.path("versionId").asText())
+ || !expectedPrefix.equals(root.path("keyPrefix").asText())
+ || !root.path("objects").isArray()) {
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ ObjectNode unsigned = ((ObjectNode) root).deepCopy();
+ unsigned.remove("manifestHash");
+ ByteArrayOutputStream payload = new ByteArrayOutputStream();
+ payload.write(ARTIFACT_HASH_DOMAIN);
+ payload.write(JSON.writeValueAsBytes(sortNode(unsigned)));
+ String actual = sha256Hex(payload.toByteArray());
+ if (!Objects.equals(actual, storage.getArtifactManifestHash())
+ || !Objects.equals(root.path("manifestHash").asText(), storage.getArtifactManifestHash())) {
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ return root;
+ } catch (com.wanxiang.huijing.framework.common.exception.ServiceException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ log.error("[runtimeArtifact] artifact manifest 校验失败 versionId={}", runtimePackage.getVersionId(), ex);
+ throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH);
+ }
+ }
+
+ /** 递归按字段名排序对象节点;数组保持契约顺序,对齐 Python canonical JSON。 */
+ private static JsonNode sortNode(JsonNode node) {
+ if (node.isObject()) {
+ ObjectNode sorted = JSON.createObjectNode();
+ TreeSet names = new TreeSet<>();
+ Iterator iterator = node.fieldNames();
+ iterator.forEachRemaining(names::add);
+ names.forEach(name -> sorted.set(name, sortNode(node.get(name))));
+ return sorted;
+ }
+ if (node.isArray()) {
+ ArrayNode sorted = JSON.createArrayNode();
+ node.forEach(item -> sorted.add(sortNode(item)));
+ return sorted;
+ }
+ return node;
+ }
+
+ /** 严格 UTF-8 解码,非法序列不能被替换字符悄悄吞掉。 */
+ private static String strictUtf8(byte[] bytes, String label) {
+ try {
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes)).toString();
+ } catch (CharacterCodingException ex) {
+ throw new IllegalArgumentException(label + " 不是合法 UTF-8", ex);
+ }
+ }
+
+ /** 统一计算小写 SHA-256。 */
+ private static String sha256Hex(byte[] bytes) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
+ } catch (Exception ex) {
+ throw new IllegalStateException("JDK 缺少 SHA-256", ex);
+ }
+ }
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStorageProperties.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStorageProperties.java
new file mode 100644
index 00000000..ce5a4eab
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStorageProperties.java
@@ -0,0 +1,63 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+
+/**
+ * Runtime 游戏内容对象存储配置。
+ *
+ * 读取模式独立于 committed 强制开关:db 保持旧路径,shadow 对账但返回 DB,oss 只返回对象原文。
+ * oss 模式天然强制 committed,不能通过关闭兼容开关绕过。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "game.artifact-storage")
+public class RuntimeArtifactStorageProperties {
+
+ /** 运行清单读取模式。 */
+ public enum ReadMode { DB, SHADOW, OSS }
+
+ /** 默认保持历史 DB 路径。 */
+ private ReadMode readMode = ReadMode.DB;
+ /** 兼容期独立提交门;oss 模式无论该值如何都强制 committed。 */
+ private boolean enforceCommitted = false;
+ /** S3 兼容 API 根地址。 */
+ private String endpoint = "";
+ /** S3 区域;MinIO 使用固定占位区域即可。 */
+ private String region = "us-east-1";
+ /** Runtime 专用只读访问键。 */
+ private String accessKey = "";
+ /** Runtime 专用只读密钥。 */
+ private String secretKey = "";
+ /** MinIO 需要 path-style,云 S3 可按环境关闭。 */
+ private boolean pathStyle = true;
+ /** 允许运行时读取的唯一制品桶。 */
+ private String artifactBucket = "game-artifacts";
+ /** GamePackage 和 artifact manifest 单对象读取上限。 */
+ private long maxManifestBytes = 16L * 1024 * 1024;
+ /** 单素材读取上限。 */
+ private long maxAssetBytes = 100L * 1024 * 1024;
+ /** 一次 S3 API 调用的总超时,覆盖重试在内的完整调用。 */
+ private Duration apiCallTimeout = Duration.ofSeconds(15);
+ /** 单次 S3 API 尝试超时,避免一次网络尝试占满总调用预算。 */
+ private Duration apiCallAttemptTimeout = Duration.ofSeconds(5);
+
+ public boolean isOssRead() {
+ return readMode == ReadMode.OSS;
+ }
+
+ public boolean isShadowRead() {
+ return readMode == ReadMode.SHADOW;
+ }
+
+ public boolean requiresCommitted() {
+ return enforceCommitted || isOssRead();
+ }
+
+ public boolean isObjectAssetProxyEnabled() {
+ return readMode == ReadMode.OSS;
+ }
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeAssetContent.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeAssetContent.java
new file mode 100644
index 00000000..24c99573
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeAssetContent.java
@@ -0,0 +1,5 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg;
+
+/** 经 committed artifact manifest 定位并逐字节校验的素材响应。 */
+public record RuntimeAssetContent(byte[] bytes, String mime, String sha256) {
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java
index cd332c25..dd4b8935 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java
@@ -2,12 +2,14 @@ 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.dto.RuntimePackageStoreReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO;
/**
* 版本运行包 Service 接口
*
* 承载取包门禁(决策1:scene+status 权威判定 + 预览归属校验)、发布态回写(status 0→1 + 回写 game_version)。
- * OSS/CDN 包存取与刷新为外部对接点,骨架不实现。
+ * DB/shadow/OSS 读取均由实现层收口,调用方不接触对象存储 locator。
*
* @author 绘境AI
*/
@@ -30,12 +32,17 @@ public interface RuntimePackageService {
/**
* 取已通过同一门禁的原始 manifest 字节。
*
- * MVP 的 DB PackageStore 直接返回刚完成门禁校验的运行包行中的 packageJson,避免
- * controller 先校验 A 行、再二次查询 B 行造成 hash/提交状态竞态;未来 OSS store 仍由
- * 实现按不可变版本 key 返回原始字节。
+ * DB 模式返回同一运行包行的 packageJson;shadow 模式读取 OSS 对账后仍返回 DB;
+ * oss 模式只返回 committed locator 指向且通过摘要校验的对象原文,失败不回退 DB。
*/
String getPackageManifestRaw(Long versionId, String scene, Long userId);
+ /** 按内容 hash 读取经 artifact manifest 映射和复算的素材原始字节。 */
+ RuntimeAssetContent getPackageAsset(Long versionId, String scene, Long userId, String assetHash);
+
+ /** 当前是否启用对象素材代理;仅 OSS 主读模式为 true,shadow 不改变浏览器素材路径。 */
+ boolean isObjectAssetProxyEnabled();
+
/**
* 发布态回写对接点(编译就绪运行包 → 置为已发布,补全发布态写入链路)
*
@@ -78,4 +85,7 @@ public interface RuntimePackageService {
*/
Long storeForVersion(RuntimePackageStoreReqDTO req);
+ /** 校验 V34 与 GamePackage 摘要链后,仅落预览元数据,数据库不保存具体游戏正文。 */
+ RuntimeOssPackageFinalizeRespDTO finalizeOssPackage(RuntimeOssPackageFinalizeReqDTO req);
+
}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java
index 1976b219..0d27617e 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java
@@ -1,7 +1,14 @@
package com.wanxiang.huijing.game.module.runtime.service.pkg;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
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.pkg.RuntimePackageMapper;
+import com.wanxiang.huijing.game.module.runtime.dal.mysql.storage.ArtifactStorageMapper;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO;
import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO;
import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum;
import com.wanxiang.huijing.game.module.runtime.enums.RuntimeSceneEnum;
@@ -12,24 +19,34 @@ import com.wanxiang.huijing.framework.security.core.LoginUser;
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Objects;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.time.LocalDateTime;
import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_PUBLISHED;
import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_READY;
import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_PREVIEW_NOT_OWNER;
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_INVALIDATE_REJECTED;
+import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_DISABLED;
+import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_INVALID;
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 版本运行包 Service 实现
*
* 业务规则(取包门禁 / 预览归属 / 发布态状态机)在此承载,可单测、可追溯。
- * OSS/CDN 包存取与刷新、project.game_version 回写为外部对接点,骨架不实现(留 TODO)。
+ * DB/shadow/OSS 三种读取模式在服务端收口,浏览器只访问同源 Runtime API。
*
* @author 绘境AI
*/
@@ -37,6 +54,15 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept
@Service
public class RuntimePackageServiceImpl implements RuntimePackageService {
+ /** Finalize 只解析固定 GamePackage 字段,不做重新序列化。 */
+ private static final ObjectMapper JSON = new ObjectMapper();
+ /** 所有对象身份摘要统一使用小写 SHA-256。 */
+ private static final Pattern SHA256_PATTERN = Pattern.compile("^[a-f0-9]{64}$");
+ /** 源修订与对象 key 的安全字符集。 */
+ private static final Pattern REVISION_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$");
+ /** GamePackage 只允许两种既有预加载策略。 */
+ private static final Set PRELOAD_POLICIES = Set.of("eager", "lazy");
+
/** 落包入参缺省值:入口文件相对路径(对齐 GamePackage manifest.entry MVP 形态) */
private static final String DEFAULT_ENTRY = "index.html";
/** 落包入参缺省值:Canvas Runtime 版本(对齐 GamePackage manifest.runtimeVersion MVP 形态) */
@@ -47,30 +73,44 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
@Resource
private RuntimePackageMapper runtimePackageMapper;
- /**
- * 整包 manifest 存储抽象(§3.4 C4):MVP=DbPackageStore(写 game_runtime_package.package_json),M3 换 OSS impl 调用方不变。
- */
+ /** Finalize 直接锁定 V34 状态行,不能经 committed-only 消费门丢失 pending 状态。 */
+ @Resource
+ private ArtifactStorageMapper artifactStorageMapper;
+
+ /** 旧 DB 主读与 shadow 对账使用的 manifest 存储抽象;OSS 主读不写 package_json。 */
@Resource
private PackageStore packageStore;
/** project 权威归属只读 seam;预览门禁不信任前端传入的 owner 信息。 */
@Resource
+ @Lazy
private ProjectApi projectApi;
/** 对象存储提交状态门;开关关闭时保持现有 DB manifest 兼容路径。 */
@Resource
private ArtifactStorageGate artifactStorageGate;
+ /** 对象存储原文与素材的可信读取/摘要校验。 */
+ @Resource
+ private RuntimeArtifactContentService runtimeArtifactContentService;
+
+ /** db/shadow/oss 切流配置。 */
+ @Resource
+ private RuntimeArtifactStorageProperties runtimeArtifactStorageProperties;
+
@Override
public RuntimePackageDO getPackageManifest(Long versionId, String scene, Long userId) {
+ return authorizePackage(versionId, scene, userId).runtimePackage();
+ }
+
+ /** 先完成场景授权,再暴露对象状态;避免越权调用者通过错误差异探测对象是否存在。 */
+ private AuthorizedPackage authorizePackage(Long versionId, String scene, Long userId) {
// 取就绪运行包(uk_version 唯一)
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
if (pkg == null) {
// 无对应就绪运行包(编译未完成或版本不存在),对齐契约 #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 或服务端已认证管理员。
@@ -84,25 +124,69 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
throw exception(RUNTIME_PACKAGE_NOT_PUBLISHED);
}
}
+ // 授权完成后再读取 committed 记录;oss 模式缺记录必须失败,shadow/db 按配置兼容。
+ ArtifactStorageDO storage = artifactStorageGate.requireCommitted(pkg);
log.info("[getPackageManifest] 取包成功 versionId={}, scene={}, pkgStatus={}", versionId, scene, pkg.getStatus());
- return pkg;
+ return new AuthorizedPackage(pkg, storage);
}
@Override
public String getPackageManifestRaw(Long versionId, String scene, Long userId) {
- RuntimePackageDO pkg = getPackageManifest(versionId, scene, userId);
- // MVP DB store 的 packageJson 就是刚完成门禁校验的同一行,避免二次 mapper 查询竞态。
+ AuthorizedPackage authorized = authorizePackage(versionId, scene, userId);
+ RuntimePackageDO pkg = authorized.runtimePackage();
+ ArtifactStorageDO storage = authorized.storage();
+ if (runtimeArtifactStorageProperties.isOssRead()) {
+ if (storage == null) {
+ throw exception(RUNTIME_PACKAGE_NOT_READY);
+ }
+ // OSS 模式只返回已验远端原文;任何读取/摘要失败由内容服务 fail-closed,禁止回 DB。
+ return runtimeArtifactContentService.readManifest(pkg, storage);
+ }
+ String dbManifest = readDbManifest(pkg);
+ if (runtimeArtifactStorageProperties.isShadowRead() && storage != null) {
+ try {
+ String ossManifest = runtimeArtifactContentService.readManifest(pkg, storage);
+ if (!Objects.equals(dbManifest, ossManifest)) {
+ log.warn("[getPackageManifestRaw] shadow 对账发现 DB/OSS 原文字节不同 versionId={}", versionId);
+ }
+ } catch (Exception ex) {
+ // shadow 只观测,不改变旧 DB 返回;日志保留版本身份和堆栈供切流前排障。
+ log.error("[getPackageManifestRaw] shadow OSS 对账失败,继续返回 DB versionId={}", versionId, ex);
+ }
+ }
+ return dbManifest;
+ }
+
+ /** 读取已授权同一 DB 行的原始清单;仅历史空列才走 PackageStore 兼容入口。 */
+ private String readDbManifest(RuntimePackageDO pkg) {
if (StringUtils.hasText(pkg.getPackageJson())) {
return pkg.getPackageJson();
}
- // OSS PackageStore 退场接线保留:其实现必须按不可变 version key 返回原始字节。
- String manifest = packageStore.getManifest(versionId);
+ String manifest = packageStore.getManifest(pkg.getVersionId());
if (!StringUtils.hasText(manifest)) {
throw exception(RUNTIME_PACKAGE_NOT_READY);
}
return manifest;
}
+ @Override
+ public RuntimeAssetContent getPackageAsset(Long versionId, String scene, Long userId, String assetHash) {
+ if (!runtimeArtifactStorageProperties.isObjectAssetProxyEnabled()) {
+ throw exception(RUNTIME_PACKAGE_NOT_READY);
+ }
+ AuthorizedPackage authorized = authorizePackage(versionId, scene, userId);
+ if (authorized.storage() == null) {
+ throw exception(RUNTIME_PACKAGE_NOT_READY);
+ }
+ return runtimeArtifactContentService.readAsset(
+ authorized.runtimePackage(), authorized.storage(), assetHash);
+ }
+
+ @Override
+ public boolean isObjectAssetProxyEnabled() {
+ return runtimeArtifactStorageProperties.isObjectAssetProxyEnabled();
+ }
+
@Override
public void publishPackage(Long versionId, String expectedArtifactHash) {
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
@@ -147,6 +231,10 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
versionId, pkg.getId());
}
+ /** 同一次运行包行读取与 committed locator 查询的授权快照。 */
+ private record AuthorizedPackage(RuntimePackageDO runtimePackage, ArtifactStorageDO storage) {
+ }
+
@Override
public void invalidate(Long versionId) {
RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId);
@@ -193,7 +281,7 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
insert.setChecksum(req.getChecksum());
insert.setStatus(PackageStatusEnum.PREVIEW_READY.getStatus());
// package_url/sandbox_attr/allow_origins 不设:沿 V3 DDL 表默认值
- // (packageUrl 留空串 → RuntimeConvert 回落 DB manifest 路径,MVP 无 OSS 形态自动成立)
+ // packageUrl 保持历史兼容空串;浏览器始终使用 RuntimeConvert 生成的同源 manifest 端点。
runtimePackageMapper.insert(insert);
// 行已存在后写整包 manifest(putManifest 未命中行显式抛错回滚,Z1 带病链在此杜绝)
packageStore.putManifest(req.getVersionId(), req.getManifestJson());
@@ -256,6 +344,234 @@ public class RuntimePackageServiceImpl implements RuntimePackageService {
return existing.getId();
}
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public RuntimeOssPackageFinalizeRespDTO finalizeOssPackage(RuntimeOssPackageFinalizeReqDTO req) {
+ if (!runtimeArtifactStorageProperties.isOssRead()) {
+ log.warn("[finalizeOssPackage] Runtime 非 OSS 模式,拒绝内容 Finalize");
+ throw exception(RUNTIME_OSS_FINALIZE_DISABLED);
+ }
+ if (!validFinalizeRequest(req)) {
+ rejectOssFinalize("Finalize 入参非法", req);
+ }
+
+ byte[] rawManifest = req.getManifestJson().getBytes(StandardCharsets.UTF_8);
+ if (rawManifest.length > runtimeArtifactStorageProperties.getMaxManifestBytes()) {
+ rejectOssFinalize("GamePackage 超过读取上限", req);
+ }
+ String runtimeManifestHash = sha256Hex(rawManifest);
+ GamePackageMetadata metadata = parseGamePackage(req, rawManifest);
+
+ ArtifactStorageDO storage = artifactStorageMapper.selectByIdentityForUpdate(
+ req.getTenantId(), req.getGameId(), req.getVersionId());
+ if (!matchesStorageIdentity(storage, req, runtimeManifestHash, metadata.bundleHash())) {
+ rejectOssFinalize("V34 身份、locator 或摘要链不一致", req);
+ }
+
+ RuntimePackageDO existing = runtimePackageMapper.selectByVersionId(req.getVersionId());
+ if (existing != null) {
+ if (!sameOssPackage(existing, req, metadata, runtimeManifestHash)) {
+ rejectOssFinalize("既有运行包与本次 Finalize 摘要不同", req);
+ }
+ commitArtifactStorage(storage, req);
+ log.info("[finalizeOssPackage] 同摘要幂等返回 tenantId={}, gameId={}, versionId={}, packageId={}",
+ req.getTenantId(), req.getGameId(), req.getVersionId(), existing.getId());
+ return new RuntimeOssPackageFinalizeRespDTO(
+ existing.getId(), runtimeManifestHash, metadata.bundleSize());
+ }
+
+ RuntimePackageDO insert = new RuntimePackageDO();
+ // 显式写租户列,保证 tenant 插件关闭的隔离 staging 仍落入请求的真实租户。
+ insert.setTenantId(req.getTenantId());
+ insert.setGameId(req.getGameId());
+ insert.setVersionId(req.getVersionId());
+ insert.setTemplateId(metadata.templateId());
+ insert.setEntry(metadata.entry());
+ insert.setRuntimeVersion(metadata.runtimeVersion());
+ insert.setPreloadPolicy(metadata.preloadPolicy());
+ insert.setBundleSize(metadata.bundleSize());
+ insert.setChecksum(runtimeManifestHash);
+ insert.setStatus(PackageStatusEnum.PREVIEW_READY.getStatus());
+ // packageJson 故意不赋值:具体游戏内容只存在对象存储,数据库仅保存平台预览元数据。
+ if (runtimePackageMapper.insert(insert) != 1 || insert.getId() == null) {
+ rejectOssFinalize("运行包预览元数据写入失败", req);
+ }
+ commitArtifactStorage(storage, req);
+ log.info("[finalizeOssPackage] OSS 运行包元数据已落库 tenantId={}, gameId={}, versionId={}, "
+ + "packageId={}, runtimeManifestHash={}, bundleHash={}",
+ req.getTenantId(), req.getGameId(), req.getVersionId(), insert.getId(),
+ runtimeManifestHash, metadata.bundleHash());
+ return new RuntimeOssPackageFinalizeRespDTO(insert.getId(), runtimeManifestHash, metadata.bundleSize());
+ }
+
+ /** 在当前 Finalize 事务内把已锁定 V34 行 CAS 为 committed;Project 只能在本方法返回后绑定。 */
+ private void commitArtifactStorage(ArtifactStorageDO storage, RuntimeOssPackageFinalizeReqDTO req) {
+ if (Objects.equals(storage.getStatus(), "committed")) {
+ return;
+ }
+ ArtifactStorageDO update = new ArtifactStorageDO();
+ update.setStatus("committed");
+ update.setCommittedAt(LocalDateTime.now());
+ int affected = artifactStorageMapper.update(update, new LambdaUpdateWrapper()
+ .eq(ArtifactStorageDO::getId, storage.getId())
+ .eq(ArtifactStorageDO::getTenantId, req.getTenantId())
+ .eq(ArtifactStorageDO::getGameId, req.getGameId())
+ .eq(ArtifactStorageDO::getVersionId, req.getVersionId())
+ .eq(ArtifactStorageDO::getStatus, "pending")
+ .eq(ArtifactStorageDO::getArtifactManifestHash, req.getArtifactManifestHash())
+ .eq(ArtifactStorageDO::getRuntimeManifestHash, storage.getRuntimeManifestHash())
+ .eq(ArtifactStorageDO::getSourceRevisionId, req.getRevisionId())
+ .eq(ArtifactStorageDO::getBundleHash, storage.getBundleHash()));
+ if (affected != 1) {
+ rejectOssFinalize("V34 committed CAS 未命中", req);
+ }
+ storage.setStatus("committed");
+ storage.setCommittedAt(update.getCommittedAt());
+ }
+
+ /** 校验调用方直接 Java 调用也无法绕过 DTO 边界。 */
+ private static boolean validFinalizeRequest(RuntimeOssPackageFinalizeReqDTO req) {
+ return req != null && req.getTenantId() != null && req.getTenantId() > 0
+ && req.getGameId() != null && req.getGameId() > 0
+ && req.getVersionId() != null && req.getVersionId() > 0
+ && StringUtils.hasText(req.getRevisionId())
+ && REVISION_PATTERN.matcher(req.getRevisionId()).matches()
+ && StringUtils.hasText(req.getArtifactManifestHash())
+ && SHA256_PATTERN.matcher(req.getArtifactManifestHash()).matches()
+ && StringUtils.hasText(req.getManifestJson());
+ }
+
+ /** 解析并校验 Runtime 落库所需的 GamePackage 权威字段。 */
+ private static GamePackageMetadata parseGamePackage(RuntimeOssPackageFinalizeReqDTO req, byte[] rawManifest) {
+ try {
+ JsonNode root = JSON.readTree(rawManifest);
+ JsonNode manifest = root == null ? null : root.get("manifest");
+ JsonNode engineBundleNode = root == null ? null : root.get("engineBundle");
+ boolean topLevelValid = root != null && root.isObject()
+ && "1.0".equals(text(root, "schemaVersion"))
+ && String.valueOf(req.getGameId()).equals(text(root, "gameId"))
+ && String.valueOf(req.getVersionId()).equals(text(root, "versionId"))
+ && StringUtils.hasText(text(root, "templateId"))
+ && root.path("gameConfig").isObject() && root.path("assets").isArray()
+ && root.path("meta").isObject() && manifest != null && manifest.isObject()
+ && engineBundleNode != null && engineBundleNode.isTextual()
+ && StringUtils.hasText(engineBundleNode.textValue())
+ && engineBundleNode.textValue().contains("__GameBundle");
+ if (!topLevelValid) {
+ rejectOssFinalize("GamePackage 顶层身份或必填结构非法", req);
+ }
+
+ String runtimeVersion = text(manifest, "runtimeVersion");
+ String entry = text(manifest, "entry");
+ String preloadPolicy = text(manifest, "preloadPolicy");
+ JsonNode bundleSizeNode = manifest.get("bundleSize");
+ String innerChecksum = text(manifest, "checksum");
+ if (!StringUtils.hasText(runtimeVersion) || !StringUtils.hasText(entry)
+ || !PRELOAD_POLICIES.contains(preloadPolicy)
+ || bundleSizeNode == null || !bundleSizeNode.canConvertToLong()
+ || bundleSizeNode.longValue() <= 0
+ || !StringUtils.hasText(innerChecksum) || !SHA256_PATTERN.matcher(innerChecksum).matches()) {
+ rejectOssFinalize("GamePackage manifest 字段非法", req);
+ }
+ String engineBundle = engineBundleNode.textValue();
+ long actualBundleSize = engineBundle.getBytes(StandardCharsets.UTF_8).length;
+ if (bundleSizeNode.longValue() != actualBundleSize) {
+ rejectOssFinalize("GamePackage bundleSize 与 engineBundle UTF-8 字节数不一致", req);
+ }
+ return new GamePackageMetadata(text(root, "templateId"), runtimeVersion, entry, preloadPolicy,
+ actualBundleSize, sha256Hex(engineBundle.getBytes(StandardCharsets.UTF_8)));
+ } catch (Exception ex) {
+ if (ex instanceof com.wanxiang.huijing.framework.common.exception.ServiceException serviceException) {
+ throw serviceException;
+ }
+ log.warn("[finalizeOssPackage] GamePackage JSON 解析失败 tenantId={}, gameId={}, versionId={}",
+ req.getTenantId(), req.getGameId(), req.getVersionId(), ex);
+ throw exception(RUNTIME_OSS_FINALIZE_INVALID);
+ }
+ }
+
+ /** 只接受 JSON 文本字段,数字或布尔值不能经字符串化冒充契约值。 */
+ private static String text(JsonNode node, String field) {
+ JsonNode value = node == null ? null : node.get(field);
+ return value != null && value.isTextual() ? value.textValue() : null;
+ }
+
+ /** 校验 V34 pending/committed 记录及全部 canonical locator 和摘要链。 */
+ private boolean matchesStorageIdentity(ArtifactStorageDO storage, RuntimeOssPackageFinalizeReqDTO req,
+ String runtimeManifestHash, String bundleHash) {
+ if (storage == null || Boolean.TRUE.equals(storage.getDeleted())) {
+ return false;
+ }
+ String artifactPrefix = "tenants/" + req.getTenantId() + "/games/" + req.getGameId()
+ + "/versions/" + req.getVersionId();
+ String sourcePrefix = "tenants/" + req.getTenantId() + "/games/" + req.getGameId()
+ + "/source-revisions/" + req.getRevisionId();
+ return storage.getId() != null && storage.getId() > 0
+ && Objects.equals(storage.getTenantId(), req.getTenantId())
+ && Objects.equals(storage.getGameId(), req.getGameId())
+ && Objects.equals(storage.getVersionId(), req.getVersionId())
+ && (Objects.equals(storage.getStatus(), "pending") || Objects.equals(storage.getStatus(), "committed"))
+ && Objects.equals(storage.getArtifactBucket(), runtimeArtifactStorageProperties.getArtifactBucket())
+ && Objects.equals(storage.getArtifactManifestKey(), artifactPrefix + "/artifact-manifest.json")
+ && Objects.equals(storage.getArtifactManifestHash(), req.getArtifactManifestHash())
+ && storage.getArtifactManifestBytes() != null && storage.getArtifactManifestBytes() > 0
+ && Objects.equals(storage.getRuntimeManifestKey(), artifactPrefix + "/manifest.json")
+ && Objects.equals(storage.getRuntimeManifestHash(), runtimeManifestHash)
+ && Objects.equals(storage.getSourceProvider(), "game_source_archive")
+ && StringUtils.hasText(storage.getSourceBucket())
+ && !Objects.equals(storage.getSourceBucket(), storage.getArtifactBucket())
+ && Objects.equals(storage.getSourceGameId(), String.valueOf(req.getGameId()))
+ && Objects.equals(storage.getSourceRevisionId(), req.getRevisionId())
+ && Objects.equals(storage.getSourceManifestKey(), sourcePrefix + "/source-manifest.json")
+ && validSha256(storage.getSourceManifestHash()) && validSha256(storage.getSourceHash())
+ && Objects.equals(storage.getBundleHash(), bundleHash);
+ }
+
+ /** 同 versionId 重放必须逐字段等同,且既有行从未保存 GamePackage 正文。 */
+ private static boolean sameOssPackage(RuntimePackageDO existing, RuntimeOssPackageFinalizeReqDTO req,
+ GamePackageMetadata metadata, String runtimeManifestHash) {
+ return !Boolean.TRUE.equals(existing.getDeleted())
+ && Objects.equals(existing.getTenantId(), req.getTenantId())
+ && Objects.equals(existing.getGameId(), req.getGameId())
+ && Objects.equals(existing.getVersionId(), req.getVersionId())
+ && Objects.equals(existing.getTemplateId(), metadata.templateId())
+ && Objects.equals(existing.getEntry(), metadata.entry())
+ && Objects.equals(existing.getRuntimeVersion(), metadata.runtimeVersion())
+ && Objects.equals(existing.getPreloadPolicy(), metadata.preloadPolicy())
+ && Objects.equals(existing.getBundleSize(), metadata.bundleSize())
+ && Objects.equals(existing.getChecksum(), runtimeManifestHash)
+ && (PackageStatusEnum.isPreviewReady(existing.getStatus())
+ || PackageStatusEnum.isPublished(existing.getStatus()))
+ && existing.getPackageJson() == null;
+ }
+
+ /** 判断可用 SHA-256 字段。 */
+ private static boolean validSha256(String value) {
+ return StringUtils.hasText(value) && SHA256_PATTERN.matcher(value).matches();
+ }
+
+ /** 现场计算原始 UTF-8 字节摘要。 */
+ private static String sha256Hex(byte[] value) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException("JDK 缺少 SHA-256", ex);
+ }
+ }
+
+ /** 统一记录不含正文的拒绝日志并抛稳定业务码。 */
+ private static void rejectOssFinalize(String reason, RuntimeOssPackageFinalizeReqDTO req) {
+ log.warn("[finalizeOssPackage] 拒绝 OSS 内容 Finalize reason={}, tenantId={}, gameId={}, versionId={}",
+ reason, req == null ? null : req.getTenantId(), req == null ? null : req.getGameId(),
+ req == null ? null : req.getVersionId());
+ throw exception(RUNTIME_OSS_FINALIZE_INVALID);
+ }
+
+ /** 已校验的 GamePackage 预览元数据和运行 bundle 摘要。 */
+ private record GamePackageMetadata(String templateId, String runtimeVersion, String entry,
+ String preloadPolicy, Long bundleSize, String bundleHash) {
+ }
+
/**
* 落包入参缺省值兜底(DTO 可空字段统一在服务端落缺省,保证表列 NOT NULL 语义)
*
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReadException.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReadException.java
new file mode 100644
index 00000000..11f17114
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReadException.java
@@ -0,0 +1,12 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg.store;
+
+/** 对象缺失、配置、网络或读取上限失败;不向 API 泄漏 SDK 异常类型。 */
+public class ArtifactObjectReadException extends RuntimeException {
+ public ArtifactObjectReadException(String message) {
+ super(message);
+ }
+
+ public ArtifactObjectReadException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReader.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReader.java
new file mode 100644
index 00000000..889ac75e
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReader.java
@@ -0,0 +1,15 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg.store;
+
+/** Runtime 对象存储只读边界;bucket/key 必须由 committed 数据库记录派生。 */
+public interface ArtifactObjectReader {
+
+ /**
+ * 有界读取对象原始字节。
+ *
+ * @param bucket 受信 committed 行中的桶
+ * @param key 受信 committed 行或已验 artifact manifest 中的 key
+ * @param maxBytes 最大允许字节数
+ * @return 对象原始字节
+ */
+ byte[] read(String bucket, String key, long maxBytes);
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/DbPackageStore.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/DbPackageStore.java
index 5be05327..31567eed 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/DbPackageStore.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/DbPackageStore.java
@@ -10,7 +10,7 @@ import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
- * 整包 manifest 存储 DB 实现(黄金闭环 §3.4 C4,MVP 无 OSS:读写 game_runtime_package.package_json)
+ * 旧 DB 读取模式的整包 manifest 实现,读写 game_runtime_package.package_json。
*
* 退场契约:M3 接 OSS 后新增 OSS impl 并切换 @Primary,本类下线,{@link PackageStore} 调用方不变。
*
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReader.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReader.java
new file mode 100644
index 00000000..b8286754
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReader.java
@@ -0,0 +1,89 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg.store;
+
+import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimeArtifactStorageProperties;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3Configuration;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+
+import java.net.URI;
+
+/** S3/MinIO 有界 GET 实现;不提供 List、Put 或 Delete。 */
+@Component
+public class S3ArtifactObjectReader implements ArtifactObjectReader {
+
+ @Resource
+ private RuntimeArtifactStorageProperties properties;
+
+ private volatile S3Client client;
+
+ @Override
+ public byte[] read(String bucket, String key, long maxBytes) {
+ if (!StringUtils.hasText(bucket) || !StringUtils.hasText(key)
+ || maxBytes < 0 || maxBytes > Integer.MAX_VALUE - 1L) {
+ throw new ArtifactObjectReadException("对象读取参数非法");
+ }
+ try (ResponseInputStream input = client().getObject(
+ GetObjectRequest.builder().bucket(bucket).key(key).build())) {
+ Long declared = input.response().contentLength();
+ if (declared != null && declared > maxBytes) {
+ throw new ArtifactObjectReadException("对象超过读取上限");
+ }
+ byte[] bytes = input.readNBytes((int) maxBytes + 1);
+ if (bytes.length > maxBytes) {
+ throw new ArtifactObjectReadException("对象超过读取上限");
+ }
+ if (declared != null && declared != bytes.length) {
+ throw new ArtifactObjectReadException("对象声明长度与实际字节不一致");
+ }
+ return bytes;
+ } catch (ArtifactObjectReadException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ throw new ArtifactObjectReadException("对象读取失败", ex);
+ }
+ }
+
+ /** 惰性建只读客户端,DB 模式不会因未配置 OSS 凭据而影响启动。 */
+ private S3Client client() {
+ S3Client current = client;
+ if (current != null) {
+ return current;
+ }
+ synchronized (this) {
+ if (client == null) {
+ if (!StringUtils.hasText(properties.getEndpoint())
+ || !StringUtils.hasText(properties.getAccessKey())
+ || !StringUtils.hasText(properties.getSecretKey())) {
+ throw new ArtifactObjectReadException("Runtime OSS 只读端点或凭据未配置");
+ }
+ client = S3Client.builder()
+ .endpointOverride(URI.create(properties.getEndpoint()))
+ .region(Region.of(properties.getRegion()))
+ .credentialsProvider(StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
+ .serviceConfiguration(S3Configuration.builder()
+ .pathStyleAccessEnabled(properties.isPathStyle()).build())
+ .overrideConfiguration(buildOverrideConfiguration(properties))
+ .build();
+ }
+ return client;
+ }
+ }
+
+ /** 把总调用和单次尝试超时显式交给 SDK;禁止对象 GET 因网络半开无限等待。 */
+ static ClientOverrideConfiguration buildOverrideConfiguration(RuntimeArtifactStorageProperties properties) {
+ return ClientOverrideConfiguration.builder()
+ .apiCallTimeout(properties.getApiCallTimeout())
+ .apiCallAttemptTimeout(properties.getApiCallAttemptTimeout())
+ .build();
+ }
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImplDogfoodTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImplDogfoodTest.java
index c76d70a9..7a7c08ef 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImplDogfoodTest.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImplDogfoodTest.java
@@ -4,6 +4,8 @@ import com.wanxiang.huijing.framework.common.exception.ServiceException;
import com.wanxiang.huijing.framework.env.config.DogfoodProperties;
import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard;
import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO;
import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -11,11 +13,14 @@ import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
+import org.springframework.web.servlet.HandlerMapping;
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.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -101,6 +106,29 @@ class RuntimePackageApiImplDogfoodTest {
verify(runtimePackageService).publishPackage(2048L, "a".repeat(64));
}
+ @Test
+ void ossFinalizeAlwaysRejectsDirectRuntimeHttpEvenOutsideDogfood() {
+ RuntimePackageApiImpl api = createApi(false);
+ bindRequest("/rpc-api/runtime/finalize-oss-package");
+
+ assertThrows(ServiceException.class, () -> api.finalizeOssPackage(finalizeReq()));
+
+ verify(runtimePackageService, never()).finalizeOssPackage(any(RuntimeOssPackageFinalizeReqDTO.class));
+ }
+
+ @Test
+ void ossFinalizeAllowsTrustedAigcLocalCall() {
+ RuntimePackageApiImpl api = createApi(false);
+ RuntimeOssPackageFinalizeReqDTO req = finalizeReq();
+ bindRequest("/admin-api/aigc/game-content/finalize");
+ when(runtimePackageService.finalizeOssPackage(req)).thenReturn(
+ new RuntimeOssPackageFinalizeRespDTO(31L, "b".repeat(64), 4096L));
+
+ assertEquals(31L, api.finalizeOssPackage(req).getData().getPackageId());
+
+ verify(runtimePackageService).finalizeOssPackage(req);
+ }
+
private RuntimePackageApiImpl createApi(boolean dogfoodEnabled) {
RuntimePackageApiImpl api = new RuntimePackageApiImpl();
ReflectionTestUtils.setField(api, "runtimePackageService", runtimePackageService);
@@ -112,6 +140,7 @@ class RuntimePackageApiImplDogfoodTest {
private static void bindRequest(String requestUri) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+ request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, requestUri);
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
}
@@ -126,4 +155,16 @@ class RuntimePackageApiImplDogfoodTest {
return req;
}
+ /** 构造仅用于验证 HTTP 旁路被拒绝的 OSS Finalize 入参。 */
+ private static RuntimeOssPackageFinalizeReqDTO finalizeReq() {
+ RuntimeOssPackageFinalizeReqDTO req = new RuntimeOssPackageFinalizeReqDTO();
+ req.setTenantId(7L);
+ req.setGameId(1024L);
+ req.setVersionId(2048L);
+ req.setRevisionId("revision-a");
+ req.setArtifactManifestHash("a".repeat(64));
+ req.setManifestJson("{}");
+ return req;
+ }
+
}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeControllerTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeControllerTest.java
new file mode 100644
index 00000000..fca8e07c
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeControllerTest.java
@@ -0,0 +1,95 @@
+package com.wanxiang.huijing.game.module.runtime.controller.app.runtime;
+
+import com.wanxiang.huijing.framework.common.pojo.CommonResult;
+import com.wanxiang.huijing.game.module.runtime.controller.app.runtime.vo.RuntimePackageRespVO;
+import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO;
+import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum;
+import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimeAssetContent;
+import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** App Runtime 响应中的对象代理与缓存可信边界测试。 */
+class AppRuntimeControllerTest {
+
+ @Test
+ void shadowModeDoesNotAdvertiseAssetProxy() {
+ RuntimePackageService service = mock(RuntimePackageService.class);
+ when(service.getPackageManifest(2048L, "play", null)).thenReturn(pkg());
+ when(service.isObjectAssetProxyEnabled()).thenReturn(false);
+
+ CommonResult result = controller(service).getPackage(2048L, "play");
+
+ assertNotNull(result.getData());
+ assertNull(result.getData().getAssetUrlTemplate());
+ }
+
+ @Test
+ void ossModeAdvertisesSceneBoundAssetProxy() {
+ RuntimePackageService service = mock(RuntimePackageService.class);
+ when(service.getPackageManifest(2048L, "preview", null)).thenReturn(pkg());
+ when(service.isObjectAssetProxyEnabled()).thenReturn(true);
+
+ CommonResult result = controller(service).getPackage(2048L, "preview");
+
+ assertNotNull(result.getData());
+ assertTrue(result.getData().getAssetUrlTemplate().endsWith("/{sha256}?scene=preview"));
+ }
+
+ @Test
+ void previewAssetIsPrivateAndNeverCached() {
+ RuntimePackageService service = assetService();
+
+ ResponseEntity response = controller(service).getPackageAsset(2048L, "a".repeat(64), "preview");
+
+ String cacheControl = response.getHeaders().getCacheControl();
+ assertTrue(cacheControl.contains("private"));
+ assertTrue(cacheControl.contains("no-store"));
+ assertFalse(cacheControl.contains("public"));
+ assertFalse(cacheControl.contains("immutable"));
+ }
+
+ @Test
+ void playAssetUsesImmutablePublicCache() {
+ RuntimePackageService service = assetService();
+
+ ResponseEntity response = controller(service).getPackageAsset(2048L, "a".repeat(64), "play");
+
+ String cacheControl = response.getHeaders().getCacheControl();
+ assertTrue(cacheControl.contains("public"));
+ assertTrue(cacheControl.contains("immutable"));
+ assertTrue(cacheControl.contains("max-age=31536000"));
+ }
+
+ private static AppRuntimeController controller(RuntimePackageService service) {
+ AppRuntimeController controller = new AppRuntimeController();
+ ReflectionTestUtils.setField(controller, "runtimePackageService", service);
+ return controller;
+ }
+
+ private static RuntimePackageService assetService() {
+ RuntimePackageService service = mock(RuntimePackageService.class);
+ when(service.getPackageAsset(2048L, "preview", null, "a".repeat(64)))
+ .thenReturn(new RuntimeAssetContent(new byte[]{1}, "image/png", "a".repeat(64)));
+ when(service.getPackageAsset(2048L, "play", null, "a".repeat(64)))
+ .thenReturn(new RuntimeAssetContent(new byte[]{1}, "image/png", "a".repeat(64)));
+ return service;
+ }
+
+ private static RuntimePackageDO pkg() {
+ RuntimePackageDO pkg = new RuntimePackageDO();
+ pkg.setGameId(1024L);
+ pkg.setVersionId(2048L);
+ pkg.setStatus(PackageStatusEnum.PUBLISHED.getStatus());
+ pkg.setChecksum("b".repeat(64));
+ return pkg;
+ }
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvertTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvertTest.java
index 80cbfd3e..2e10e6c1 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvertTest.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvertTest.java
@@ -9,42 +9,12 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* {@link RuntimeConvert} 单元测试(纯静态方法,不依赖 Spring/DB)
*
- * 覆盖 GAP-2 分支A:manifestUrl 由 packageUrl 同前缀派生 .../manifest.json(含目录形态/文件名形态/空值/退化形态各路)。
+ * 覆盖同源 manifest 端点、scene 门禁继承和 sandbox 策略转换。
*
* @author 绘境AI
*/
class RuntimeConvertTest {
- // ============================== deriveManifestUrl 同前缀派生 ==============================
-
- @Test
- void testDeriveManifestUrl_dirForm() {
- // packageUrl 以 '/' 结尾(版本化目录形态)→ 直接追加 manifest.json
- assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json",
- RuntimeConvert.deriveManifestUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/"));
- }
-
- @Test
- void testDeriveManifestUrl_fileForm() {
- // packageUrl 含末段文件名 → 替换为同级 manifest.json,保持同前缀
- assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json",
- RuntimeConvert.deriveManifestUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip"));
- }
-
- @Test
- void testDeriveManifestUrl_noSlashDegraded() {
- // 无 '/' 退化形态 → 当作前缀目录拼接,不越权改写其它路径
- assertEquals("pkg/manifest.json", RuntimeConvert.deriveManifestUrl("pkg"));
- }
-
- @Test
- void testDeriveManifestUrl_blankReturnsNull() {
- // 空/空白 packageUrl → 返回 null(无包则无清单,不构造非法 URL)
- assertNull(RuntimeConvert.deriveManifestUrl(null));
- assertNull(RuntimeConvert.deriveManifestUrl(""));
- assertNull(RuntimeConvert.deriveManifestUrl(" "));
- }
-
// ============================== toPackageRespVO 整体回填 ==============================
@Test
@@ -59,10 +29,10 @@ class RuntimeConvertTest {
RuntimePackageRespVO vo = RuntimeConvert.toPackageRespVO(pkg);
assertNotNull(vo);
- // 平铺字段直拷 + manifestUrl 同前缀派生
+ // 平铺字段直拷;manifest 始终经同源 Runtime API,避免平台 Token 发往对象域。
assertEquals(1024L, vo.getGameId());
assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip", vo.getPackageUrl());
- assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", vo.getManifestUrl());
+ assertEquals("/app-api/runtime/package/2048/manifest", vo.getManifestUrl());
// 沙箱策略 allowOrigins 字符串拆 List
assertNotNull(vo.getSandbox());
assertEquals("allow-scripts", vo.getSandbox().getSandboxAttr());
@@ -74,11 +44,11 @@ class RuntimeConvertTest {
assertNull(RuntimeConvert.toPackageRespVO(null));
}
- // ============================== §3.4 C4:MVP 无 OSS manifestUrl 指向 DB 端点 ==============================
+ // ============================== DB 兼容模式与 OSS 模式共用同源端点 ==============================
@Test
void testToPackageRespVO_mvpEmptyPackageUrl_manifestUrlPointsToEndpoint() {
- // MVP 无 OSS(packageUrl 空,整包存 DB):manifestUrl 指向 /app-api/runtime/package/{versionId}/manifest 端点
+ // 旧 DB 模式下 packageUrl 为空,manifestUrl 仍指向统一 Runtime 端点。
RuntimePackageDO pkg = new RuntimePackageDO();
pkg.setGameId(1024L);
pkg.setVersionId(2048L);
@@ -124,9 +94,9 @@ class RuntimeConvertTest {
RuntimeConvert.toPackageRespVO(pkg, "play").getManifestUrl());
assertEquals("/app-api/runtime/package/2048/manifest",
RuntimeConvert.toPackageRespVO(pkg, null).getManifestUrl());
- // OSS 分支(packageUrl 非空)scene 不参与:静态文件无门禁
+ // 即使历史 packageUrl 非空,preview 仍经同源端点并继承 scene 门禁。
pkg.setPackageUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip");
- assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json",
+ assertEquals("/app-api/runtime/package/2048/manifest?scene=preview",
RuntimeConvert.toPackageRespVO(pkg, "preview").getManifestUrl());
}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGateTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGateTest.java
index d625bde6..dc338f38 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGateTest.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGateTest.java
@@ -14,6 +14,7 @@ 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.never;
import static org.mockito.Mockito.when;
/** committed 对象记录与运行包 checksum/bundle hash 的消费门测试。 */
@@ -26,9 +27,10 @@ class ArtifactStorageGateTest {
ArtifactStorageMapper mapper = mock(ArtifactStorageMapper.class);
ArtifactStorageGate gate = new ArtifactStorageGate();
ReflectionTestUtils.setField(gate, "artifactStorageMapper", mapper);
- ReflectionTestUtils.setField(gate, "enforceCommitted", false);
+ ReflectionTestUtils.setField(gate, "properties", properties(false));
- assertDoesNotThrow(() -> gate.requireCommitted(pkg("a".repeat(64))));
+ assertEquals(null, gate.requireCommitted(pkg("a".repeat(64))));
+ verify(mapper, never()).selectCommittedByIdentity(1L, 1024L, 2048L);
}
@Test
@@ -54,7 +56,8 @@ class ArtifactStorageGateTest {
assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode());
stale.setRuntimeManifestHash("a".repeat(64));
- stale.setBundleHash("");
+ stale.setRuntimeManifestHash("a".repeat(64));
+ stale.setArtifactBucket("wrong-bucket");
ex = assertThrows(ServiceException.class, () -> gate.requireCommitted(pkg("a".repeat(64))));
assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode());
}
@@ -85,10 +88,17 @@ class ArtifactStorageGateTest {
private static ArtifactStorageGate configuredGate(ArtifactStorageMapper mapper) {
ArtifactStorageGate gate = new ArtifactStorageGate();
ReflectionTestUtils.setField(gate, "artifactStorageMapper", mapper);
- ReflectionTestUtils.setField(gate, "enforceCommitted", true);
+ ReflectionTestUtils.setField(gate, "properties", properties(true));
return gate;
}
+ private static RuntimeArtifactStorageProperties properties(boolean enforceCommitted) {
+ RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties();
+ properties.setEnforceCommitted(enforceCommitted);
+ properties.setArtifactBucket("game-artifacts");
+ return properties;
+ }
+
private static RuntimePackageDO pkg(String checksum) {
RuntimePackageDO pkg = new RuntimePackageDO();
pkg.setTenantId(1L);
@@ -102,7 +112,14 @@ class ArtifactStorageGateTest {
private static ArtifactStorageDO committed(String runtimeManifestHash, String bundleHash) {
ArtifactStorageDO storage = new ArtifactStorageDO();
storage.setVersionId(2048L);
+ storage.setTenantId(1L);
+ storage.setGameId(1024L);
storage.setStatus("committed");
+ storage.setArtifactBucket("game-artifacts");
+ storage.setArtifactManifestKey("tenants/1/games/1024/versions/2048/artifact-manifest.json");
+ storage.setArtifactManifestHash("d".repeat(64));
+ storage.setArtifactManifestBytes(512L);
+ storage.setRuntimeManifestKey("tenants/1/games/1024/versions/2048/manifest.json");
storage.setRuntimeManifestHash(runtimeManifestHash);
storage.setBundleHash(bundleHash);
return storage;
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentServiceTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentServiceTest.java
new file mode 100644
index 00000000..1ea23b83
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentServiceTest.java
@@ -0,0 +1,100 @@
+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.service.pkg.store.ArtifactObjectReader;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.nio.charset.StandardCharsets;
+
+import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_ARTIFACT_MISMATCH;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** OSS GamePackage 与素材的原始字节/hash/身份消费测试。 */
+class RuntimeArtifactContentServiceTest {
+
+ private static final String BUNDLE_HASH = "1e6ed65d77d6364eeaed5a745ba5c4985ae2b700dd85d7cf7f027bdf294a33fc";
+ private static final String RUNTIME_HASH = "df5535659c8682a0edbde5b7be6eb26fc8a7d15c9de054c79227ac55e94f3e76";
+ private static final String ASSET_HASH = "d59386e0ae435e292fbe0ebcdb954b75ed5fb3922091277cb19f798fc5d50718";
+ private static final String ARTIFACT_HASH = "d20b7124e197b1e08f936c2fdd5adb6bded18b0c9e12dd901a761899097a07af";
+ private static final String RUNTIME_JSON =
+ "{\"engineBundle\":\"bundle\",\"gameId\":\"1024\",\"schemaVersion\":\"1.0\",\"versionId\":\"2048\"}";
+ private static final String ARTIFACT_JSON =
+ "{\"gameId\":\"1024\",\"keyPrefix\":\"tenants/1/games/1024/versions/2048\","
+ + "\"manifestHash\":\"" + ARTIFACT_HASH + "\",\"objects\":[{\"bytes\":5,"
+ + "\"category\":\"asset\",\"key\":\"tenants/1/games/1024/versions/2048/assets/hero.webp\","
+ + "\"mime\":\"image/webp\",\"path\":\"assets/hero.webp\",\"sha256\":\"" + ASSET_HASH + "\","
+ + "\"store\":\"artifact\"}],\"schemaVersion\":\"GameArtifactManifest/1\","
+ + "\"tenantId\":\"1\",\"versionId\":\"2048\"}";
+
+ @Test
+ void readsRemoteManifestAndVerifiesRuntimeAndBundleHashes() {
+ RuntimeArtifactContentService service = service((bucket, key, maxBytes) -> {
+ assertEquals("game-artifacts", bucket);
+ assertEquals("tenants/1/games/1024/versions/2048/manifest.json", key);
+ return RUNTIME_JSON.getBytes(StandardCharsets.UTF_8);
+ });
+
+ assertEquals(RUNTIME_JSON, service.readManifest(pkg(), storage()));
+ }
+
+ @Test
+ void rejectsRemoteManifestHashDrift() {
+ RuntimeArtifactContentService service = service((bucket, key, maxBytes) ->
+ RUNTIME_JSON.replace("bundle", "changed").getBytes(StandardCharsets.UTF_8));
+
+ ServiceException ex = assertThrows(ServiceException.class, () -> service.readManifest(pkg(), storage()));
+ assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode());
+ }
+
+ @Test
+ void resolvesAssetOnlyThroughVerifiedArtifactManifestHash() {
+ RuntimeArtifactContentService service = service((bucket, key, maxBytes) -> {
+ if (key.endsWith("artifact-manifest.json")) {
+ return ARTIFACT_JSON.getBytes(StandardCharsets.UTF_8);
+ }
+ assertEquals("tenants/1/games/1024/versions/2048/assets/hero.webp", key);
+ return "asset".getBytes(StandardCharsets.UTF_8);
+ });
+
+ RuntimeAssetContent asset = service.readAsset(pkg(), storage(), ASSET_HASH);
+ assertEquals("image/webp", asset.mime());
+ assertEquals(ASSET_HASH, asset.sha256());
+ assertArrayEquals("asset".getBytes(StandardCharsets.UTF_8), asset.bytes());
+ }
+
+ private static RuntimeArtifactContentService service(ArtifactObjectReader reader) {
+ RuntimeArtifactContentService service = new RuntimeArtifactContentService();
+ ReflectionTestUtils.setField(service, "objectReader", reader);
+ ReflectionTestUtils.setField(service, "properties", new RuntimeArtifactStorageProperties());
+ return service;
+ }
+
+ private static RuntimePackageDO pkg() {
+ RuntimePackageDO pkg = new RuntimePackageDO();
+ pkg.setTenantId(1L);
+ pkg.setGameId(1024L);
+ pkg.setVersionId(2048L);
+ pkg.setChecksum(RUNTIME_HASH);
+ return pkg;
+ }
+
+ private static ArtifactStorageDO storage() {
+ ArtifactStorageDO storage = new ArtifactStorageDO();
+ storage.setTenantId(1L);
+ storage.setGameId(1024L);
+ storage.setVersionId(2048L);
+ storage.setArtifactBucket("game-artifacts");
+ storage.setArtifactManifestKey("tenants/1/games/1024/versions/2048/artifact-manifest.json");
+ storage.setArtifactManifestHash(ARTIFACT_HASH);
+ storage.setArtifactManifestBytes((long) ARTIFACT_JSON.getBytes(StandardCharsets.UTF_8).length);
+ storage.setRuntimeManifestKey("tenants/1/games/1024/versions/2048/manifest.json");
+ storage.setRuntimeManifestHash(RUNTIME_HASH);
+ storage.setBundleHash(BUNDLE_HASH);
+ return storage;
+ }
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStoragePropertiesTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStoragePropertiesTest.java
new file mode 100644
index 00000000..ea27b269
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStoragePropertiesTest.java
@@ -0,0 +1,37 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Runtime 对象读取模式与网络超时边界测试。 */
+class RuntimeArtifactStoragePropertiesTest {
+
+ @Test
+ void shadowModeDoesNotExposeObjectAssetProxy() {
+ RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties();
+ properties.setReadMode(RuntimeArtifactStorageProperties.ReadMode.SHADOW);
+
+ assertFalse(properties.isObjectAssetProxyEnabled());
+ }
+
+ @Test
+ void ossModeExposesObjectAssetProxy() {
+ RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties();
+ properties.setReadMode(RuntimeArtifactStorageProperties.ReadMode.OSS);
+
+ assertTrue(properties.isObjectAssetProxyEnabled());
+ }
+
+ @Test
+ void s3CallsHaveFiniteDefaultTimeouts() {
+ RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties();
+
+ assertEquals(Duration.ofSeconds(15), properties.getApiCallTimeout());
+ assertEquals(Duration.ofSeconds(5), properties.getApiCallAttemptTimeout());
+ }
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeOssPackageFinalizeServiceTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeOssPackageFinalizeServiceTest.java
new file mode 100644
index 00000000..1744aca5
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeOssPackageFinalizeServiceTest.java
@@ -0,0 +1,284 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.wanxiang.huijing.framework.common.exception.ServiceException;
+import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
+import com.wanxiang.huijing.game.module.project.api.ProjectApi;
+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.pkg.RuntimePackageMapper;
+import com.wanxiang.huijing.game.module.runtime.dal.mysql.storage.ArtifactStorageMapper;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO;
+import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO;
+import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum;
+import com.wanxiang.huijing.game.module.runtime.service.pkg.store.PackageStore;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_DISABLED;
+import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_INVALID;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/** OSS GamePackage Finalize 的身份、摘要与无正文落库边界测试。 */
+class RuntimeOssPackageFinalizeServiceTest extends BaseMockitoUnitTest {
+
+ private static final String ENGINE_BUNDLE = "window.__GameBundle={};";
+
+ @InjectMocks
+ private RuntimePackageServiceImpl runtimePackageService;
+
+ @Mock
+ private RuntimePackageMapper runtimePackageMapper;
+ @Mock
+ private ArtifactStorageMapper artifactStorageMapper;
+ @Mock
+ private RuntimeArtifactStorageProperties properties;
+ @Mock
+ private PackageStore packageStore;
+ @Mock
+ private ProjectApi projectApi;
+ @Mock
+ private ArtifactStorageGate artifactStorageGate;
+ @Mock
+ private RuntimeArtifactContentService runtimeArtifactContentService;
+
+ @Test
+ void finalizeOssPackage_rejectsNonOssModeWithoutDatabaseWrite() {
+ when(properties.isOssRead()).thenReturn(false);
+
+ ServiceException error = assertThrows(ServiceException.class,
+ () -> runtimePackageService.finalizeOssPackage(request(gamePackage())));
+
+ assertEquals(RUNTIME_OSS_FINALIZE_DISABLED.getCode(), error.getCode());
+ verifyNoInteractions(artifactStorageMapper);
+ verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class));
+ }
+
+ @Test
+ void finalizeOssPackage_rejectsMissingOrWrongArtifactIdentity() {
+ RuntimeOssPackageFinalizeReqDTO req = request(gamePackage());
+ enableOssMode();
+ when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(null);
+
+ ServiceException missing = assertThrows(ServiceException.class,
+ () -> runtimePackageService.finalizeOssPackage(req));
+ assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), missing.getCode());
+
+ ArtifactStorageDO storage = storage(req.getManifestJson());
+ storage.setArtifactManifestHash("b".repeat(64));
+ when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(storage);
+ ServiceException mismatch = assertThrows(ServiceException.class,
+ () -> runtimePackageService.finalizeOssPackage(req));
+ assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), mismatch.getCode());
+ verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class));
+ }
+
+ @Test
+ void finalizeOssPackage_rejectsRuntimeManifestOrBundleHashDrift() {
+ String raw = gamePackage();
+ RuntimeOssPackageFinalizeReqDTO req = request(raw);
+ enableOssMode();
+ ArtifactStorageDO manifestDrift = storage(raw);
+ manifestDrift.setRuntimeManifestHash("d".repeat(64));
+ when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(manifestDrift);
+ ServiceException manifestError = assertThrows(ServiceException.class,
+ () -> runtimePackageService.finalizeOssPackage(req));
+ assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), manifestError.getCode());
+
+ ArtifactStorageDO bundleDrift = storage(raw);
+ bundleDrift.setBundleHash("e".repeat(64));
+ when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(bundleDrift);
+ ServiceException bundleError = assertThrows(ServiceException.class,
+ () -> runtimePackageService.finalizeOssPackage(req));
+ assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), bundleError.getCode());
+ verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class));
+ }
+
+ @Test
+ void finalizeOssPackage_rejectsDeclaredBundleSizeDifferentFromUtf8Bytes() {
+ String raw = gamePackageWithBundleSize(1L);
+ RuntimeOssPackageFinalizeReqDTO req = request(raw);
+ when(properties.isOssRead()).thenReturn(true);
+ when(properties.getMaxManifestBytes()).thenReturn(16L * 1024 * 1024);
+
+ ServiceException error = assertThrows(ServiceException.class,
+ () -> runtimePackageService.finalizeOssPackage(req));
+
+ assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), error.getCode());
+ verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class));
+ }
+
+ @Test
+ void finalizeOssPackage_insertsPreviewMetadataWithoutManifestBody() {
+ String raw = gamePackage();
+ RuntimeOssPackageFinalizeReqDTO req = request(raw);
+ ArtifactStorageDO pending = storage(raw);
+ enableOssMode();
+ when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(pending);
+ when(artifactStorageMapper.update(any(ArtifactStorageDO.class),
+ org.mockito.ArgumentMatchers.>any())).thenReturn(1);
+ when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(null);
+ when(runtimePackageMapper.insert(any(RuntimePackageDO.class))).thenAnswer(invocation -> {
+ RuntimePackageDO value = invocation.getArgument(0);
+ value.setId(31L);
+ return 1;
+ });
+
+ RuntimeOssPackageFinalizeRespDTO result = runtimePackageService.finalizeOssPackage(req);
+
+ assertEquals(31L, result.getPackageId());
+ assertEquals(sha256(raw), result.getChecksum());
+ ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimePackageDO.class);
+ verify(runtimePackageMapper).insert(captor.capture());
+ RuntimePackageDO saved = captor.getValue();
+ assertEquals(PackageStatusEnum.PREVIEW_READY.getStatus(), saved.getStatus());
+ assertEquals(sha256(raw), saved.getChecksum());
+ assertEquals((long) ENGINE_BUNDLE.getBytes(StandardCharsets.UTF_8).length, saved.getBundleSize());
+ assertNull(saved.getPackageJson());
+ ArgumentCaptor storageCaptor = ArgumentCaptor.forClass(ArtifactStorageDO.class);
+ verify(artifactStorageMapper).update(storageCaptor.capture(),
+ org.mockito.ArgumentMatchers.>any());
+ assertEquals("committed", storageCaptor.getValue().getStatus());
+ assertNotNull(storageCaptor.getValue().getCommittedAt());
+ verifyNoInteractions(packageStore);
+ }
+
+ @Test
+ void finalizeOssPackage_sameDigestReplayIsIdempotentAndKeepsBodyEmpty() {
+ String raw = gamePackage();
+ RuntimeOssPackageFinalizeReqDTO req = request(raw);
+ ArtifactStorageDO storage = storage(raw);
+ RuntimePackageDO existing = new RuntimePackageDO();
+ existing.setId(31L);
+ existing.setTenantId(7L);
+ existing.setGameId(1024L);
+ existing.setVersionId(2048L);
+ existing.setTemplateId("phaser");
+ existing.setEntry("index.html");
+ existing.setRuntimeVersion("1.0.0");
+ existing.setPreloadPolicy("eager");
+ existing.setBundleSize((long) ENGINE_BUNDLE.getBytes(StandardCharsets.UTF_8).length);
+ existing.setChecksum(sha256(raw));
+ existing.setStatus(PackageStatusEnum.PREVIEW_READY.getStatus());
+ existing.setPackageJson(null);
+ enableOssMode();
+ when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(storage);
+ when(artifactStorageMapper.update(any(ArtifactStorageDO.class),
+ org.mockito.ArgumentMatchers.>any())).thenReturn(1);
+ when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(existing);
+
+ assertEquals(31L, runtimePackageService.finalizeOssPackage(req).getPackageId());
+
+ verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class));
+ verify(runtimePackageMapper, never()).updateById(any(RuntimePackageDO.class));
+ verifyNoInteractions(packageStore);
+ }
+
+ @Test
+ void finalizeOssPackage_rejectsWhenStorageCommitCasMisses() {
+ String raw = gamePackage();
+ RuntimeOssPackageFinalizeReqDTO req = request(raw);
+ enableOssMode();
+ when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(storage(raw));
+ when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(null);
+ when(runtimePackageMapper.insert(any(RuntimePackageDO.class))).thenAnswer(invocation -> {
+ RuntimePackageDO value = invocation.getArgument(0);
+ value.setId(31L);
+ return 1;
+ });
+ when(artifactStorageMapper.update(any(ArtifactStorageDO.class),
+ org.mockito.ArgumentMatchers.>any())).thenReturn(0);
+
+ ServiceException error = assertThrows(ServiceException.class,
+ () -> runtimePackageService.finalizeOssPackage(req));
+
+ assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), error.getCode());
+ }
+
+ /** 构造完整且可手工核对的最小 GamePackage 原文。 */
+ private static String gamePackage() {
+ long bundleBytes = ENGINE_BUNDLE.getBytes(StandardCharsets.UTF_8).length;
+ return gamePackageWithBundleSize(bundleBytes);
+ }
+
+ /** 构造可单独改变 bundleSize 的 GamePackage,供字节完整性负样本使用。 */
+ private static String gamePackageWithBundleSize(long bundleBytes) {
+ return "{\"schemaVersion\":\"1.0\",\"gameId\":\"1024\",\"versionId\":\"2048\","
+ + "\"templateId\":\"phaser\",\"gameConfig\":{},\"assets\":[],\"manifest\":{"
+ + "\"runtimeVersion\":\"1.0.0\",\"entry\":\"index.html\",\"preloadPolicy\":\"eager\","
+ + "\"bundleSize\":" + bundleBytes + ",\"checksum\":\"" + "c".repeat(64) + "\"},"
+ + "\"meta\":{\"title\":\"测试\",\"summary\":\"\",\"cover\":\"https://example.com/c.webp\","
+ + "\"ageRating\":\"all\"},\"engineBundle\":\"" + ENGINE_BUNDLE + "\"}";
+ }
+
+ /** 构造 Runtime Finalize 入参。 */
+ private static RuntimeOssPackageFinalizeReqDTO request(String raw) {
+ RuntimeOssPackageFinalizeReqDTO req = new RuntimeOssPackageFinalizeReqDTO();
+ req.setTenantId(7L);
+ req.setGameId(1024L);
+ req.setVersionId(2048L);
+ req.setRevisionId("revision-a");
+ req.setArtifactManifestHash("a".repeat(64));
+ req.setManifestJson(raw);
+ return req;
+ }
+
+ /** 构造与请求和 GamePackage 两条摘要链均一致的 V34 记录。 */
+ private static ArtifactStorageDO storage(String raw) {
+ ArtifactStorageDO storage = new ArtifactStorageDO();
+ storage.setId(5L);
+ storage.setTenantId(7L);
+ storage.setGameId(1024L);
+ storage.setVersionId(2048L);
+ storage.setArtifactBucket("game-artifacts");
+ storage.setArtifactManifestKey("tenants/7/games/1024/versions/2048/artifact-manifest.json");
+ storage.setArtifactManifestHash("a".repeat(64));
+ storage.setArtifactManifestBytes(1024L);
+ storage.setRuntimeManifestKey("tenants/7/games/1024/versions/2048/manifest.json");
+ storage.setRuntimeManifestHash(sha256(raw));
+ storage.setSourceBucket("game-sources");
+ storage.setSourceProvider("game_source_archive");
+ storage.setSourceGameId("1024");
+ storage.setSourceRevisionId("revision-a");
+ storage.setSourceManifestKey(
+ "tenants/7/games/1024/source-revisions/revision-a/source-manifest.json");
+ storage.setSourceManifestHash("f".repeat(64));
+ storage.setSourceHash("1".repeat(64));
+ storage.setBundleHash(sha256(ENGINE_BUNDLE));
+ storage.setStatus("pending");
+ return storage;
+ }
+
+ /** 为需要进入摘要校验的用例开启 OSS 模式和固定读取上限。 */
+ private void enableOssMode() {
+ when(properties.isOssRead()).thenReturn(true);
+ when(properties.getArtifactBucket()).thenReturn("game-artifacts");
+ when(properties.getMaxManifestBytes()).thenReturn(16L * 1024 * 1024);
+ }
+
+ /** 使用 JDK 标准实现独立计算测试期望 SHA-256。 */
+ private static String sha256(String value) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(value.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(digest);
+ } catch (NoSuchAlgorithmException ex) {
+ throw new AssertionError(ex);
+ }
+ }
+}
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java
index 86262fa4..b4a9ee26 100644
--- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java
@@ -1,6 +1,7 @@
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.pkg.RuntimePackageMapper;
import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO;
import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum;
@@ -52,6 +53,12 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest {
@Mock
private ArtifactStorageGate artifactStorageGate;
+ @Mock
+ private RuntimeArtifactContentService runtimeArtifactContentService;
+
+ @Mock
+ private RuntimeArtifactStorageProperties runtimeArtifactStorageProperties;
+
@AfterEach
void clearSecurityContext() {
org.springframework.security.core.context.SecurityContextHolder.clearContext();
@@ -98,6 +105,37 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest {
verify(runtimePackageMapper, times(1)).selectByVersionId(2048L);
}
+ @Test
+ void testGetPackageRawOssModeReturnsVerifiedRemoteManifestWithoutDbFallback() {
+ RuntimePackageDO p = pkg(PackageStatusEnum.PUBLISHED.getStatus());
+ p.setPackageJson("{\"source\":\"db\"}");
+ ArtifactStorageDO storage = new ArtifactStorageDO();
+ when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p);
+ when(artifactStorageGate.requireCommitted(p)).thenReturn(storage);
+ when(runtimeArtifactStorageProperties.isOssRead()).thenReturn(true);
+ when(runtimeArtifactContentService.readManifest(p, storage)).thenReturn("{\"source\":\"oss\"}");
+
+ assertEquals("{\"source\":\"oss\"}", runtimePackageService.getPackageManifestRaw(
+ 2048L, RuntimeSceneEnum.PLAY.getScene(), 99L));
+ verify(packageStore, never()).getManifest(anyLong());
+ verify(runtimePackageMapper, times(1)).selectByVersionId(2048L);
+ }
+
+ @Test
+ void testGetPackageRawShadowModeReadsOssButReturnsDbManifest() {
+ RuntimePackageDO p = pkg(PackageStatusEnum.PUBLISHED.getStatus());
+ p.setPackageJson("{\"source\":\"db\"}");
+ ArtifactStorageDO storage = new ArtifactStorageDO();
+ when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p);
+ when(artifactStorageGate.requireCommitted(p)).thenReturn(storage);
+ when(runtimeArtifactStorageProperties.isShadowRead()).thenReturn(true);
+ when(runtimeArtifactContentService.readManifest(p, storage)).thenReturn("{\"source\":\"oss\"}");
+
+ assertEquals(p.getPackageJson(), runtimePackageService.getPackageManifestRaw(
+ 2048L, RuntimeSceneEnum.PLAY.getScene(), 99L));
+ verify(runtimeArtifactContentService).readManifest(p, storage);
+ }
+
@Test
void testGetPackage_playRejectsExpiredAfterUnlist() {
when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.EXPIRED.getStatus()));
@@ -125,6 +163,7 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest {
() -> runtimePackageService.getPackageManifest(2048L, RuntimeSceneEnum.PREVIEW.getScene(), 100L));
assertEquals(RUNTIME_PACKAGE_PREVIEW_NOT_OWNER.getCode(), ex.getCode());
+ verify(artifactStorageGate, never()).requireCommitted(any());
}
@Test
diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReaderTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReaderTest.java
new file mode 100644
index 00000000..9d6cf74c
--- /dev/null
+++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReaderTest.java
@@ -0,0 +1,25 @@
+package com.wanxiang.huijing.game.module.runtime.service.pkg.store;
+
+import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimeArtifactStorageProperties;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
+
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/** S3 客户端必须把配置的总调用和单次尝试超时交给 SDK。 */
+class S3ArtifactObjectReaderTest {
+
+ @Test
+ void appliesApiCallTimeoutsToSdkClient() {
+ RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties();
+ properties.setApiCallTimeout(Duration.ofSeconds(12));
+ properties.setApiCallAttemptTimeout(Duration.ofSeconds(4));
+
+ ClientOverrideConfiguration configuration = S3ArtifactObjectReader.buildOverrideConfiguration(properties);
+
+ assertEquals(Duration.ofSeconds(12), configuration.apiCallTimeout().orElseThrow());
+ assertEquals(Duration.ofSeconds(4), configuration.apiCallAttemptTimeout().orElseThrow());
+ }
+}
diff --git a/game-cloud/huijing-server/src/main/resources/application-staging.yaml b/game-cloud/huijing-server/src/main/resources/application-staging.yaml
index 0e931961..f3ef8683 100644
--- a/game-cloud/huijing-server/src/main/resources/application-staging.yaml
+++ b/game-cloud/huijing-server/src/main/resources/application-staging.yaml
@@ -3,6 +3,24 @@
server:
port: 48080
+--- #################### 游戏内容对象存储 ####################
+# staging 默认继续读 DB,隔离验收实例通过命令行只覆盖 read-mode=OSS;这样不会让尚未迁移的旧版本失效。
+# Runtime 使用独立只读服务身份,只能 GET game-artifacts/tenants/*,不能列举、上传、删除或读取证据桶。
+game:
+ artifact-storage:
+ read-mode: DB
+ enforce-committed: false
+ endpoint: http://100.64.0.8:9000
+ region: us-east-1
+ access-key: game-artifact-runtime-reader
+ secret-key: b863aa88d40c4a07c4f7ffc44d1b51e016b0014ec570b8010fd6d9315d0ef24c
+ path-style: true
+ artifact-bucket: game-artifacts
+ max-manifest-bytes: 16777216
+ max-asset-bytes: 104857600
+ api-call-timeout: 15s
+ api-call-attempt-timeout: 5s
+
--- #################### 单体启动:允许 Bean 覆盖 ####################
# 多个 huijing 模块声明同名 @FeignClient,单体内会产生同名 FeignClientSpecification,开启 Bean 覆盖(huijing 单体标准接法)。
spring:
@@ -182,6 +200,10 @@ pf4j:
--- #################### aigc 生成执行器(M-b 生成半下沉 HJ-AGENT-LOOP-EXEC-002)####################
aigc:
+ # Agent 游戏内容仓库的独立受信任控制面。默认关闭;隔离 OSS 链路实例显式打开。
+ game-content-control:
+ enabled: false
+ secret: acfe0d5af3da62470f0cf2af4820f1e3fedbe76c81dd905ca847cf2480bf97b9
executor:
enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关
api-key: ${NEWAPI_KEY:} # 密钥只走环境变量占位符,严禁写真值入 repo
diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V35.0.0__clarify_game_artifact_manifest_hash.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V35.0.0__clarify_game_artifact_manifest_hash.sql
new file mode 100644
index 00000000..6394d588
--- /dev/null
+++ b/game-cloud/huijing-server/src/main/resources/db/migration/V35.0.0__clarify_game_artifact_manifest_hash.sql
@@ -0,0 +1,12 @@
+-- =============================================================================
+-- 契约 #2 DB 迁移 | 模块:runtime/storage(对象清单摘要语义)| owner:WS3 + WS2
+-- 文件:V35.0.0__clarify_game_artifact_manifest_hash.sql
+-- 目的:澄清 V34 两个契约清单摘要字段保存的是契约 manifestHash,而非清单原始字节摘要。
+-- 边界:V34 已执行且保持字节不可变;本迁移只修改列注释,不改变数据和索引。
+-- =============================================================================
+
+ALTER TABLE `game_artifact_storage`
+ MODIFY COLUMN `artifact_manifest_hash` CHAR(64) NOT NULL DEFAULT ''
+ COMMENT 'GameArtifactManifest/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)',
+ MODIFY COLUMN `source_manifest_hash` CHAR(64) NOT NULL DEFAULT ''
+ COMMENT 'GameSourceArchive/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)';
diff --git a/game-runtime/games/_generic/entry-generic.js b/game-runtime/games/_generic/entry-generic.js
index f1caade3..d5b56ad9 100644
--- a/game-runtime/games/_generic/entry-generic.js
+++ b/game-runtime/games/_generic/entry-generic.js
@@ -84,6 +84,7 @@ export async function bootGameHost(opts) {
plugins: cfg.plugins,
registerOrder: cfg.registerOrder,
buildFactoryOpts: cfg.buildFactoryOpts,
+ assets: o.assets || [],
// studio 渠道产物:不注 recHook(引擎 call-ID 取证是 ref harness 专属;渠道产物无副作用)。
// 无 onReady:studio 不消费 ref 取证全局;就绪信号经 SDK lifecycle game_loaded/game_end(studio 轨接)。
});
diff --git a/game-runtime/games/_wg1-gen/_shared/entry-bundle.amodel.template.js b/game-runtime/games/_wg1-gen/_shared/entry-bundle.amodel.template.js
index 1b7bc070..56d5dcc6 100644
--- a/game-runtime/games/_wg1-gen/_shared/entry-bundle.amodel.template.js
+++ b/game-runtime/games/_wg1-gen/_shared/entry-bundle.amodel.template.js
@@ -34,7 +34,7 @@ export default gameFactory;
/**
* 【SAA boot 入口】host 页(index.template.html)调 window.__GameBundle.bootGameHost。
- * @param {{canvas:HTMLCanvasElement, statusEl?:HTMLElement, seed?:number, recHook?:Function, onReady?:Function, engine?:any, mode?:string, allowedGameEventTypes?:Iterable, interactionProfileId?:string}} opts
+ * @param {{canvas:HTMLCanvasElement, statusEl?:HTMLElement, seed?:number, recHook?:Function, onReady?:Function, engine?:any, mode?:string, allowedGameEventTypes?:Iterable, interactionProfileId?:string, assets?:Array