feat(storage): 闭合 Agent 到 Runtime 的 OSS 游戏链路
增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
This commit is contained in:
parent
a513ae38fd
commit
1981a6be01
@ -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` 也成功,问题全在环境和镜像。起容器前按这四条自查,能省一整轮排障。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)';
|
||||
@ -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();
|
||||
}
|
||||
|
||||
23
contracts/gate-fixtures/run.test.mjs
Normal file
23
contracts/gate-fixtures/run.test.mjs
Normal file
@ -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 });
|
||||
}
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 的口径和代码实情对上。
|
||||
|
||||
|
||||
@ -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/<game_id>/<version_id>/<相对路径>`) |
|
||||
| **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` 生产切换和正式金标重新绑定均未执行。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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<Long> 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<Long> 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<Long> 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 <T> CommonResult<T> 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 <T> CommonResult<T> 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}$");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 游戏内容控制面配置。
|
||||
*
|
||||
* <p>该 Bean 始终装配且默认关闭,不受 {@code aigc.executor.enabled} 影响;
|
||||
* 只有 enabled=true 且独立 secret 非空时,HMAC 入口才会执行任何写操作。</p>
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "aigc.game-content-control")
|
||||
public class GameContentControlProperties {
|
||||
|
||||
/** 控制面总开关,默认关闭。 */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** 独立于生成 worker callback-secret 的 HMAC 共享密钥。 */
|
||||
private String secret = "";
|
||||
}
|
||||
@ -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,并在所有成功/异常路径恢复线程上下文。
|
||||
*
|
||||
* <p>使用 TenantUtils 的 Runnable 重载,保留 ServiceException 原类型,不被 Callable 重载包装。</p>
|
||||
*/
|
||||
private static <T> T executeAsSystem(Long tenantId, Supplier<T> 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<T> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 摘要绑定。
|
||||
*
|
||||
* <p>三个 API 在 MVP 单体内均解析为本地 {@code @Primary} Bean,加入本事务;任一步失败整体回滚。
|
||||
* 第一调用会在 project 模块按 project -> version 加行锁,后续不得调整顺序。</p>
|
||||
*/
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@ -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 验签。
|
||||
*
|
||||
* <p>禁用、空密钥、缺签、格式错误、算法异常或摘要不一致一律返回 false。</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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<ProjectContentVersionIdentityReqDTO> 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<ProjectContentVersionBindReqDTO> 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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<Boolean> bindRuntimeArtifact(@RequestBody @Valid ProjectVersionBindArtifactReqDTO req);
|
||||
|
||||
/** 由 project 权威创建或复用指定源修订的内容版本。 */
|
||||
@PostMapping(PREFIX + "/version/prepare-content")
|
||||
@Operation(summary = "预留游戏内容版本")
|
||||
CommonResult<Long> prepareContentVersion(@RequestBody @Valid ProjectContentVersionPrepareReqDTO req);
|
||||
|
||||
/** Finalize 写 Runtime 前校验 tenant/game/version/revision 四元身份。 */
|
||||
@PostMapping(PREFIX + "/version/validate-content-identity")
|
||||
@Operation(summary = "校验游戏内容版本身份")
|
||||
CommonResult<Boolean> validateContentVersionIdentity(
|
||||
@RequestBody @Valid ProjectContentVersionIdentityReqDTO req);
|
||||
|
||||
/** Runtime 已写入同一预览元数据后,只绑定版本摘要,不激活项目指针。 */
|
||||
@PostMapping(PREFIX + "/version/bind-content-artifact")
|
||||
@Operation(summary = "绑定游戏内容运行产物")
|
||||
CommonResult<Boolean> bindContentRuntimeArtifact(@RequestBody @Valid ProjectContentVersionBindReqDTO req);
|
||||
|
||||
/** 浏览器验收通过后,按 expected-current CAS 激活内容版本。 */
|
||||
@PostMapping(PREFIX + "/version/activate-content")
|
||||
@Operation(summary = "激活已验收游戏内容版本")
|
||||
CommonResult<Boolean> activateContentVersion(@RequestBody @Valid ProjectContentVersionActivateReqDTO req);
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 游戏内容仓库预留权威版本入参。
|
||||
*
|
||||
* <p>租户、项目和不可变源修订共同定义幂等身份;版本 ID 只能由 project 模块创建,
|
||||
* 内容 worker 不得自行指定或直接写 {@code game_version}。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@ -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, "游戏内容版本身份无效");
|
||||
|
||||
}
|
||||
|
||||
@ -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<Long> prepareContentVersion(ProjectContentVersionPrepareReqDTO req) {
|
||||
rejectExternalRpcWrite("project.rpc.prepare-content");
|
||||
return success(gameVersionService.prepareContentVersion(req));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Boolean> validateContentVersionIdentity(ProjectContentVersionIdentityReqDTO req) {
|
||||
rejectExternalRpcWrite("project.rpc.validate-content-identity");
|
||||
gameVersionService.validateContentVersionIdentity(req);
|
||||
return success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Boolean> bindContentRuntimeArtifact(ProjectContentVersionBindReqDTO req) {
|
||||
rejectExternalRpcWrite("project.rpc.bind-content-artifact");
|
||||
gameVersionService.bindContentRuntimeArtifact(req);
|
||||
return success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Boolean> 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();
|
||||
|
||||
@ -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(发布编排取当前生效版本以做版本态流转)
|
||||
*
|
||||
|
||||
@ -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 重复落包安全)
|
||||
|
||||
@ -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<GameVersionDO> 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<GameVersionDO> 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<ProjectDO> 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);
|
||||
|
||||
@ -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<Long> storeForVersion(@RequestBody @Valid RuntimePackageStoreReqDTO req);
|
||||
|
||||
/**
|
||||
* 受信任内容控制面在同 JVM 事务内落 OSS 运行包预览元数据。
|
||||
*
|
||||
* <p>Feign 契约保留映射以保证代理可装配,但 Runtime 实现会无条件拒绝直接命中该 RPC 的 HTTP 请求;
|
||||
* 外部唯一可执行入口是 AIGC HMAC 控制面内的同 JVM 调用。</p>
|
||||
*/
|
||||
@PostMapping(PREFIX + "/finalize-oss-package")
|
||||
CommonResult<RuntimeOssPackageFinalizeRespDTO> finalizeOssPackage(
|
||||
@RequestBody @Valid RuntimeOssPackageFinalizeReqDTO req);
|
||||
|
||||
}
|
||||
|
||||
@ -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 入参。
|
||||
*
|
||||
* <p>{@code manifestJson} 只在同 JVM 事务调用中用于现场复算摘要和提取预览元数据,
|
||||
* Runtime 数据库严禁持久化该正文。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,编译期对接点判定) */
|
||||
|
||||
@ -72,6 +72,12 @@
|
||||
<artifactId>huijing-spring-boot-starter-rpc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Runtime 只读对象代理:使用 V34 committed locator 读取 GamePackage/素材,不依赖 infra-server 实现。 -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试 -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
|
||||
@ -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<RuntimeOssPackageFinalizeRespDTO> 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,
|
||||
|
||||
@ -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<byte[]> 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 归属")
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<String> allowOrigins;
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// ============================== 编译任务 ==============================
|
||||
|
||||
@ -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 校验失败)。
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -30,4 +30,10 @@ public interface ArtifactStorageMapper extends BaseMapperX<ArtifactStorageDO> {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*
|
||||
* <p>开关默认关闭,允许 V34 尚未部署的环境继续使用旧 DB manifest 路径;打开后,运行时必须
|
||||
* 找到 committed 记录,且记录中的 GamePackage manifest hash 必须与 runtime package checksum
|
||||
* 一致;若 manifest 含 engineBundle,还必须把实际 bundle 字节 hash 与对象记录对账。</p>
|
||||
* 一致,并且桶和 key 必须与三元业务身份的 canonical locator 一致。</p>
|
||||
*/
|
||||
@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。
|
||||
*
|
||||
* <p>对象上传器已经对远端 bundle 做逐字节回读;消费侧再对 DB manifest 中即将交给宿主的
|
||||
* engineBundle 做一次对账,避免只检查数据库里的 bundle_hash 非空而放过陈旧内容。</p>
|
||||
*/
|
||||
private BundleCheck inspectBundle(RuntimePackageDO runtimePackage) {
|
||||
if (!StringUtils.hasText(runtimePackage.getPackageJson())) {
|
||||
log.error("[artifactStorageGate] 运行包缺少 DB manifest,无法校验实际 bundle versionId={}",
|
||||
runtimePackage.getVersionId());
|
||||
return BundleCheck.unreadable();
|
||||
}
|
||||
try {
|
||||
JsonNode root = PACKAGE_MAPPER.readTree(runtimePackage.getPackageJson());
|
||||
JsonNode bundle = root == null ? null : root.get("engineBundle");
|
||||
if (bundle == null || bundle.isNull()) {
|
||||
// GamePackage.engineBundle 是可选字段;缺失时必须同时没有 bundle_hash。
|
||||
return BundleCheck.noBundle();
|
||||
}
|
||||
if (!bundle.isTextual() || !StringUtils.hasText(bundle.asText())) {
|
||||
log.error("[artifactStorageGate] DB manifest 的 engineBundle 类型或内容非法 versionId={}",
|
||||
runtimePackage.getVersionId());
|
||||
return BundleCheck.unreadable();
|
||||
}
|
||||
return BundleCheck.present(sha256Hex(bundle.asText()));
|
||||
} catch (Exception ex) {
|
||||
log.error("[artifactStorageGate] DB manifest 无法解析,拒绝 bundle 校验 versionId={}",
|
||||
runtimePackage.getVersionId(), ex);
|
||||
return BundleCheck.unreadable();
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前 manifest 是否可读、是否包含 bundle,以及 bundle 的真实摘要。 */
|
||||
private record BundleCheck(boolean readable, boolean present, String sha256) {
|
||||
private static BundleCheck unreadable() {
|
||||
return new BundleCheck(false, false, null);
|
||||
}
|
||||
|
||||
private static BundleCheck noBundle() {
|
||||
return new BundleCheck(true, false, null);
|
||||
}
|
||||
|
||||
private static BundleCheck present(String sha256) {
|
||||
return new BundleCheck(true, true, sha256);
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算 bundle 文本 UTF-8 字节摘要,与对象归档及生成链路使用同一 sha256 口径。 */
|
||||
private static String sha256Hex(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return java.util.HexFormat.of().formatHex(digest);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("JDK 缺少 SHA-256 算法", ex);
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 和素材原始字节。
|
||||
*
|
||||
* <p>对象 locator 只来自 {@link ArtifactStorageDO};客户端只能提交素材 hash,不能提交 bucket、key 或 URL。</p>
|
||||
*/
|
||||
@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<String> names = new TreeSet<>();
|
||||
Iterator<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 游戏内容对象存储配置。
|
||||
*
|
||||
* <p>读取模式独立于 committed 强制开关:db 保持旧路径,shadow 对账但返回 DB,oss 只返回对象原文。
|
||||
* oss 模式天然强制 committed,不能通过关闭兼容开关绕过。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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 字节。
|
||||
*
|
||||
* <p>MVP 的 DB PackageStore 直接返回刚完成门禁校验的运行包行中的 packageJson,避免
|
||||
* controller 先校验 A 行、再二次查询 B 行造成 hash/提交状态竞态;未来 OSS store 仍由
|
||||
* 实现按不可变版本 key 返回原始字节。</p>
|
||||
* <p>DB 模式返回同一运行包行的 packageJson;shadow 模式读取 OSS 对账后仍返回 DB;
|
||||
* oss 模式只返回 committed locator 指向且通过摘要校验的对象原文,失败不回退 DB。</p>
|
||||
*/
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
@ -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<String> 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<ArtifactStorageDO>()
|
||||
.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 语义)
|
||||
*
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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} 调用方不变。
|
||||
*
|
||||
|
||||
@ -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<GetObjectResponse> 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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<RuntimePackageRespVO> 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<RuntimePackageRespVO> result = controller(service).getPackage(2048L, "preview");
|
||||
|
||||
assertNotNull(result.getData());
|
||||
assertTrue(result.getData().getAssetUrlTemplate().endsWith("/{sha256}?scene=preview"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewAssetIsPrivateAndNeverCached() {
|
||||
RuntimePackageService service = assetService();
|
||||
|
||||
ResponseEntity<byte[]> 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<byte[]> 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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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.<Wrapper<ArtifactStorageDO>>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<RuntimePackageDO> 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<ArtifactStorageDO> storageCaptor = ArgumentCaptor.forClass(ArtifactStorageDO.class);
|
||||
verify(artifactStorageMapper).update(storageCaptor.capture(),
|
||||
org.mockito.ArgumentMatchers.<Wrapper<ArtifactStorageDO>>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.<Wrapper<ArtifactStorageDO>>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.<Wrapper<ArtifactStorageDO>>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)';
|
||||
@ -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 轨接)。
|
||||
});
|
||||
|
||||
@ -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<string>, interactionProfileId?:string}} opts
|
||||
* @param {{canvas:HTMLCanvasElement, statusEl?:HTMLElement, seed?:number, recHook?:Function, onReady?:Function, engine?:any, mode?:string, allowedGameEventTypes?:Iterable<string>, interactionProfileId?:string, assets?:Array<object>}} opts
|
||||
* @returns {Promise<object>} bootGameHost 句柄(frameCount/stepFrames/tap/do/state 等)
|
||||
*/
|
||||
export async function bootGameHost(opts) {
|
||||
@ -70,5 +70,6 @@ export async function bootGameHost(opts) {
|
||||
buildFactoryOpts: cfg.buildFactoryOpts, // 注入 runtime:{plugins,bundle}
|
||||
recHook: o.recHook || null, // 九门取证:引擎 call-ID 探针
|
||||
onReady: o.onReady || null, // 九门取证:收 internals(uncaught)
|
||||
assets: o.assets || [], // studio 已逐字节校验后注入的内存素材描述
|
||||
});
|
||||
}
|
||||
|
||||
@ -48,6 +48,7 @@ export default gameFactory;
|
||||
* @param {Function} [opts.recHook] 引擎 call-ID 取证钩子 id=>void(取证路径注入 window.__engineCalls.push;渠道产物不注)。
|
||||
* @param {Function} [opts.onReady] (host, internals)=>void 就绪回调(取证收集 internals.uncaught 等)。
|
||||
* @param {Iterable<string>} [opts.allowedGameEventTypes] 当前可信 profile 的业务事件 type 白名单。
|
||||
* @param {Array<object>} [opts.assets] Studio 已完成字节校验的素材描述。
|
||||
* @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。
|
||||
* @returns {Promise<object>} bootGameHost 的 host 句柄(frameCount/stepFrames/tap/do/state 等)。
|
||||
*/
|
||||
@ -81,5 +82,6 @@ export async function bootGameHost(opts) {
|
||||
? o.allowedGameEventTypes : (cfg.allowedGameEventTypes || []),
|
||||
recHook: o.recHook || null, // 取证:引擎 call-ID 探针(默认 no-op)
|
||||
onReady: o.onReady || null, // 取证:收集 internals(uncaught)
|
||||
assets: o.assets || [], // studio 已逐字节校验后注入的内存素材描述
|
||||
});
|
||||
}
|
||||
|
||||
@ -456,8 +456,23 @@ function resolveActionProvenanceBoundary() {
|
||||
* @param {string} [baseUrl] 资产根(缺省 './assets/',相对 index.html;play.cdp/studio iframe 同源可达)。
|
||||
* @returns {Promise<Record<string,{image:HTMLImageElement|null,file:string,role:string,missing?:boolean}>>}
|
||||
*/
|
||||
async function loadHostAssets(baseUrl) {
|
||||
async function loadHostAssets(baseUrl, descriptors) {
|
||||
if (typeof fetch !== 'function' || typeof Image !== 'function') return {}; // node/无浏览器 → 空集,smoke-boot 安全
|
||||
if (Array.isArray(descriptors)) {
|
||||
const out = {};
|
||||
await Promise.all(descriptors.map((asset) => new Promise((resolve) => {
|
||||
if (!asset || typeof asset.url !== 'string') { resolve(); return; }
|
||||
const ref = String(asset.id || '').trim();
|
||||
if (!ref) { resolve(); return; }
|
||||
out[ref] = { image: null, file: ref, role: '', url: asset.url, type: asset.type, mime: asset.mime };
|
||||
if (asset.type !== 'image') { resolve(); return; }
|
||||
const img = new Image();
|
||||
img.onload = () => { out[ref] = { ...out[ref], image: img }; resolve(); };
|
||||
img.onerror = () => { out[ref] = { ...out[ref], missing: true }; resolve(); };
|
||||
img.src = asset.url;
|
||||
})));
|
||||
return out;
|
||||
}
|
||||
const root = baseUrl == null ? './assets/' : baseUrl;
|
||||
let manifest = null;
|
||||
try {
|
||||
@ -571,6 +586,8 @@ const MATCH3_AUDIO_ONSET_DEADLINE_SECONDS = 0.05;
|
||||
* @param {object} [opts.visualTimelineCapability] L1 受保护视觉时间线实例;宿主独占其 renderEffects,
|
||||
* 并经 readMatch3EffectProbe 暴露冻结回执。本局未绑定时读口返回 null。
|
||||
* @param {object} [opts.audioDirectorCapability] L1 受保护音频导演;宿主独占资产、启音、静音与阶段消费。
|
||||
* @param {Array<{id:string,type:string,url:string,mime:string}>} [opts.assets] Studio 已逐字节校验并物化的素材描述;
|
||||
* 宿主只按逻辑 ID 注入本局,游戏不能自行提交对象桶或对象键。
|
||||
* @param {Iterable<string>} [opts.allowedGameEventTypes] 可信编排器为本局注入的业务事件白名单;
|
||||
* 只下传默认游戏 context,插件派生 context 即使拿到 event() 也无业务写权。
|
||||
* @param {(host: object, internals: object) => void} [opts.onReady] 就绪钩子(受控面/游戏全就绪后回调)。
|
||||
@ -972,7 +989,7 @@ export async function bootGameHost(opts) {
|
||||
// 资产注入(U1 wiring 已落地):boot.assets={ref:{image}} 由顶部 loadHostAssets 异步载 ./assets/manifest.json 列出的图后传入下方 boot。
|
||||
// boot.assets 注入(上方 HOST TODO 已落地·见顶部 loadHostAssets):浏览器侧载 ./assets/manifest.json 列出的图 → boot.assets。
|
||||
// node smoke-boot / 无美术游戏 → 空集,boot 与之前逐字等价(无回归)。游戏经 boot.assets[ref].image 取图、g.drawImage 绘制。
|
||||
const hostAssets = await loadHostAssets();
|
||||
const hostAssets = await loadHostAssets(undefined, opts.assets);
|
||||
await game.init({
|
||||
ctx: bundle.context,
|
||||
mainContext: renderCtx,
|
||||
|
||||
@ -476,6 +476,47 @@ test('④ 正常路径:非抛错游戏逐帧各调一次 update/render,dt
|
||||
env.restore();
|
||||
});
|
||||
|
||||
test('OSS 素材描述按逻辑 ID 注入游戏,图像解码与非图像元数据互不混淆', async () => {
|
||||
const env = installEnv();
|
||||
const previousImage = globalThis.Image;
|
||||
const loadedUrls = [];
|
||||
let bootAssets = null;
|
||||
class FakeImage {
|
||||
set src(value) {
|
||||
loadedUrls.push(value);
|
||||
queueMicrotask(() => this.onload());
|
||||
}
|
||||
}
|
||||
globalThis.Image = FakeImage;
|
||||
try {
|
||||
await bootGameHost({
|
||||
canvas: env.canvas,
|
||||
seed: 20260730,
|
||||
mode: 'evidence',
|
||||
engineMode: 'stub',
|
||||
viewport: { w: 390, h: 844 },
|
||||
assets: [
|
||||
{ id: 'atlas/xingji-actors', type: 'image', url: 'data:image/webp;base64,AA==', mime: 'image/webp' },
|
||||
{ id: 'atlas/xingji-actors-frames', type: 'json', url: 'data:application/json;base64,e30=', mime: 'application/json' },
|
||||
],
|
||||
factory: () => ({
|
||||
async init(boot) { bootAssets = boot.assets; },
|
||||
update() {}, render() {}, destroy() {},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(loadedUrls, ['data:image/webp;base64,AA=='], '只有 image 类型应交给 Image 解码');
|
||||
assert.ok(bootAssets['atlas/xingji-actors'].image instanceof FakeImage);
|
||||
assert.equal(bootAssets['atlas/xingji-actors'].missing, undefined);
|
||||
assert.equal(bootAssets['atlas/xingji-actors-frames'].image, null);
|
||||
assert.equal(bootAssets['atlas/xingji-actors-frames'].type, 'json');
|
||||
} finally {
|
||||
if (previousImage === undefined) delete globalThis.Image;
|
||||
else globalThis.Image = previousImage;
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Match-3 特效由 host 在游戏状态恢复后尾绘恰一次,L3 无 renderEffects 且 probe 可信冻结', async () => {
|
||||
const env = installEnv();
|
||||
const order = [];
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:host-security": "node --test scripts/inject-security.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.18.0",
|
||||
|
||||
335
game-studio/scripts/inject-security.test.mjs
Normal file
335
game-studio/scripts/inject-security.test.mjs
Normal file
@ -0,0 +1,335 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { after, before, test } from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
let server;
|
||||
let inject;
|
||||
let bridgeModule;
|
||||
let originalFetch;
|
||||
const stagingEnv = readFileSync(new URL('../.env.staging', import.meta.url), 'utf8');
|
||||
const trustedApiBase = stagingEnv.match(/^VITE_API_BASE=(.+)$/m)?.[1];
|
||||
|
||||
test('iframe 只允许脚本,不能同时授予同源权限', () => {
|
||||
const playerSource = readFileSync(new URL('../src/host/GamePlayer.vue', import.meta.url), 'utf8');
|
||||
assert.match(playerSource, /sandbox="allow-scripts"/);
|
||||
assert.doesNotMatch(playerSource, /sandbox="[^"]*allow-same-origin/);
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
originalFetch = globalThis.fetch;
|
||||
server = await createServer({
|
||||
root: new URL('..', import.meta.url).pathname,
|
||||
mode: 'staging',
|
||||
appType: 'custom',
|
||||
logLevel: 'silent',
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
inject = await server.ssrLoadModule('/src/host/inject.ts');
|
||||
bridgeModule = await server.ssrLoadModule('/src/host/bridge.ts');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* 用真实 HostBridge 驱动一条目标 iframe 消息,并收集分发与拒绝结果。
|
||||
* 测试替身只承担浏览器事件入口,信封校验始终执行生产代码。
|
||||
*/
|
||||
function dispatchBridgeMessage(envelope) {
|
||||
const originalWindow = globalThis.window;
|
||||
const listeners = new Set();
|
||||
const fakeWindow = {
|
||||
location: { origin: 'http://host.test' },
|
||||
addEventListener(type, handler) {
|
||||
if (type === 'message') listeners.add(handler);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
if (type === 'message') listeners.delete(handler);
|
||||
},
|
||||
};
|
||||
const targetWindow = { postMessage() {} };
|
||||
const accepted = [];
|
||||
const rejected = [];
|
||||
globalThis.window = fakeWindow;
|
||||
const bridge = new bridgeModule.HostBridge({
|
||||
targetWindow,
|
||||
allowedOrigins: ['null'],
|
||||
onMessage: (message) => accepted.push(message),
|
||||
onReject: (reason) => rejected.push(reason),
|
||||
});
|
||||
try {
|
||||
const handler = Array.from(listeners)[0];
|
||||
handler({ origin: 'null', source: targetWindow, data: envelope });
|
||||
return { accepted, rejected };
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
}
|
||||
|
||||
test('opaque iframe 消息只接受目标 contentWindow,拒绝其它 null-origin frame', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const listeners = new Set();
|
||||
const fakeWindow = {
|
||||
location: { origin: 'http://host.test' },
|
||||
addEventListener(type, handler) {
|
||||
if (type === 'message') listeners.add(handler);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
if (type === 'message') listeners.delete(handler);
|
||||
},
|
||||
};
|
||||
const targetWindow = { postMessage() {} };
|
||||
const attackerWindow = { postMessage() {} };
|
||||
const accepted = [];
|
||||
const rejected = [];
|
||||
globalThis.window = fakeWindow;
|
||||
const bridge = new bridgeModule.HostBridge({
|
||||
targetWindow,
|
||||
allowedOrigins: ['null'],
|
||||
onMessage: (message) => accepted.push(message),
|
||||
onReject: (reason) => rejected.push(reason),
|
||||
});
|
||||
const envelope = {
|
||||
channel: 'wanxiang-game-sdk', type: 'lifecycle', direction: 'game_to_host',
|
||||
traceId: 'trace-1', payload: { event: 'game_loaded' },
|
||||
};
|
||||
try {
|
||||
const handler = Array.from(listeners)[0];
|
||||
handler({ origin: 'null', source: attackerWindow, data: envelope });
|
||||
assert.deepEqual(accepted, []);
|
||||
assert.deepEqual(rejected, ['source_not_target']);
|
||||
|
||||
handler({ origin: 'null', source: targetWindow, data: envelope });
|
||||
assert.equal(accepted.length, 1);
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test('宿主入站拒绝伪造的 host_to_game 方向', () => {
|
||||
const result = dispatchBridgeMessage({
|
||||
channel: 'wanxiang-game-sdk', type: 'lifecycle', direction: 'host_to_game',
|
||||
traceId: 'trace-1', payload: { event: 'game_loaded' },
|
||||
});
|
||||
|
||||
assert.deepEqual(result.accepted, []);
|
||||
assert.deepEqual(result.rejected, ['bad_direction']);
|
||||
});
|
||||
|
||||
test('宿主入站按消息类型拒绝畸形 payload', () => {
|
||||
const invalidEnvelopes = [
|
||||
{ type: 'lifecycle', payload: null },
|
||||
{ type: 'lifecycle', payload: [] },
|
||||
{ type: 'lifecycle', payload: { event: 'unknown_event' } },
|
||||
{ type: 'telemetry', payload: { props: {} } },
|
||||
{ type: 'error', payload: { message: 7 } },
|
||||
{ type: 'ad', requestId: 'ad-1', payload: { slotId: 'slot-1', adType: 'video' } },
|
||||
{ type: 'ad', payload: { slotId: 'slot-1', adType: 'rewarded' } },
|
||||
{ type: 'pay', requestId: 'pay-1', payload: { orderId: 'order-1', amount: Number.NaN } },
|
||||
{ type: 'pay', payload: { orderId: 'order-1', amount: 1 } },
|
||||
{ type: 'storage', payload: { key: 7, value: '{}' } },
|
||||
{ type: 'storage', payload: { key: 'save:g:v', value: {} } },
|
||||
{ type: 'init', payload: { gameId: 'g', versionId: 'v', traceId: 'trace-1' } },
|
||||
];
|
||||
|
||||
for (const candidate of invalidEnvelopes) {
|
||||
const result = dispatchBridgeMessage({
|
||||
channel: 'wanxiang-game-sdk', direction: 'game_to_host', traceId: 'trace-1',
|
||||
...candidate,
|
||||
});
|
||||
assert.deepEqual(result.accepted, [], `畸形 ${candidate.type} 不应进入 GamePlayer`);
|
||||
assert.deepEqual(result.rejected, ['bad_payload'], `畸形 ${candidate.type} 应由 schema 闸拒绝`);
|
||||
}
|
||||
});
|
||||
|
||||
test('宿主入站保留合法 lifecycle、telemetry、error、ad、pay 与 storage 消息', () => {
|
||||
const validEnvelopes = [
|
||||
{ type: 'lifecycle', payload: { event: 'game_end', data: { score: 10 } } },
|
||||
{ type: 'telemetry', payload: { event: 'score_changed', props: { score: 10 } } },
|
||||
{ type: 'error', payload: { message: 'boom', stack: 'line:1' } },
|
||||
{ type: 'ad', requestId: 'ad-1', payload: { slotId: 'slot-1', adType: 'rewarded' } },
|
||||
{ type: 'pay', requestId: 'pay-1', payload: { orderId: 'order-1', amount: 1 } },
|
||||
{ type: 'storage', payload: { key: 'save:g:v', value: '{}' } },
|
||||
{ type: 'storage', requestId: 'storage-1', payload: { key: 'save:g:v' } },
|
||||
];
|
||||
|
||||
for (const candidate of validEnvelopes) {
|
||||
const result = dispatchBridgeMessage({
|
||||
channel: 'wanxiang-game-sdk', direction: 'game_to_host', traceId: 'trace-1',
|
||||
...candidate,
|
||||
});
|
||||
assert.equal(result.accepted.length, 1, `合法 ${candidate.type} 应进入 GamePlayer`);
|
||||
assert.deepEqual(result.rejected, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('iframe CSP 只允许内联素材 URL,拒绝任意 HTTP 图片出站', () => {
|
||||
const srcdoc = inject.buildIframeSrcdoc({
|
||||
schemaVersion: '1.0', gameId: 'g', versionId: 'v', templateId: 't',
|
||||
gameConfig: {}, assets: [],
|
||||
manifest: { runtimeVersion: '1.0.0', entry: 'index.js', preloadPolicy: 'eager', bundleSize: 0, checksum: '0'.repeat(64) },
|
||||
meta: { title: 'test', cover: '', ageRating: 'all' },
|
||||
}, 'trace-1', 'g', 'v');
|
||||
const csp = srcdoc.match(/Content-Security-Policy"\s+content="([^"]+)"/)?.[1] ?? '';
|
||||
|
||||
assert.match(csp, /img-src data: blob:/);
|
||||
assert.doesNotMatch(csp, /img-src[^;]*https?:/);
|
||||
});
|
||||
|
||||
test('manifest 拒绝任意绝对 URL,且不会向该 origin 发送平台头', async () => {
|
||||
let calls = 0;
|
||||
globalThis.fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(JSON.stringify({ manifest: {}, assets: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
inject.fetchAndVerifyManifest('https://attacker.invalid/app-api/runtime/package/2048/manifest'),
|
||||
/不受信任的 Runtime API 地址/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('manifest 拒绝受信 origin 之外的路径', async () => {
|
||||
let calls = 0;
|
||||
globalThis.fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response('{}', { status: 200 });
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
inject.fetchAndVerifyManifest('/app-api/system/user/profile'),
|
||||
/不受信任的 Runtime API 地址/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('素材代理拒绝任意绝对 URL 模板,且不会发送平台头', async () => {
|
||||
let calls = 0;
|
||||
globalThis.fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(new Uint8Array(), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png', 'content-length': '0' },
|
||||
});
|
||||
};
|
||||
const pkg = {
|
||||
assets: [{
|
||||
id: 'hero', type: 'image', url: 'https://old.invalid/hero.png',
|
||||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
bytes: 0, mime: 'image/png',
|
||||
}],
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
inject.materializeVerifiedAssets(
|
||||
pkg,
|
||||
'https://attacker.invalid/app-api/runtime/package/2048/assets/{sha256}',
|
||||
),
|
||||
/不受信任的 Runtime API 地址/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('素材代理禁止跟随重定向并拒绝已重定向响应', async () => {
|
||||
let requestOptions;
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
requestOptions = options;
|
||||
return {
|
||||
ok: true,
|
||||
redirected: true,
|
||||
headers: new Headers({ 'content-type': 'image/png', 'content-length': '0' }),
|
||||
};
|
||||
};
|
||||
const pkg = {
|
||||
assets: [{
|
||||
id: 'hero', type: 'image', url: 'https://old.invalid/hero.png',
|
||||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
bytes: 0, mime: 'image/png',
|
||||
}],
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
inject.materializeVerifiedAssets(
|
||||
pkg,
|
||||
'/app-api/runtime/package/2048/assets/{sha256}',
|
||||
),
|
||||
/素材请求失败:hero/,
|
||||
);
|
||||
assert.equal(requestOptions.redirect, 'error');
|
||||
});
|
||||
|
||||
test('未下发素材代理模板时保留 GamePackage 原素材路径', async () => {
|
||||
const pkg = {
|
||||
assets: [{
|
||||
id: 'hero', type: 'image', url: 'https://cdn.example/hero.png',
|
||||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
bytes: 0, mime: 'image/png',
|
||||
}],
|
||||
};
|
||||
|
||||
const resolved = await inject.materializeVerifiedAssets(pkg, undefined);
|
||||
|
||||
assert.equal(resolved, pkg);
|
||||
assert.equal(resolved.assets[0].url, 'https://cdn.example/hero.png');
|
||||
});
|
||||
|
||||
test('相对 Runtime manifest 路径仍可携带平台头读取', async () => {
|
||||
let request;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
request = { url, options };
|
||||
return new Response(JSON.stringify({ manifest: {}, assets: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
await inject.fetchAndVerifyManifest('/app-api/runtime/package/2048/manifest');
|
||||
|
||||
assert.equal(request.url, `${trustedApiBase}/app-api/runtime/package/2048/manifest`);
|
||||
assert.equal(request.options.headers['tenant-id'], '1');
|
||||
assert.equal(request.options.redirect, 'error');
|
||||
});
|
||||
|
||||
test('manifest 即使收到已重定向成功响应也会在读取正文前拒绝', async () => {
|
||||
let bodyRead = false;
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
redirected: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
bodyRead = true;
|
||||
return JSON.stringify({ manifest: {}, assets: [] });
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
inject.fetchAndVerifyManifest('/app-api/runtime/package/2048/manifest'),
|
||||
/manifest 请求禁止重定向/,
|
||||
);
|
||||
assert.equal(bodyRead, false);
|
||||
});
|
||||
|
||||
test('受信 API origin 的绝对 Runtime manifest 地址仍可读取', async () => {
|
||||
let requestUrl;
|
||||
globalThis.fetch = async (url) => {
|
||||
requestUrl = url;
|
||||
return new Response(JSON.stringify({ manifest: {}, assets: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
const manifestUrl = `${trustedApiBase}/app-api/runtime/package/2048/manifest?scene=preview`;
|
||||
|
||||
await inject.fetchAndVerifyManifest(manifestUrl);
|
||||
|
||||
assert.equal(requestUrl, manifestUrl);
|
||||
});
|
||||
@ -190,9 +190,9 @@ export interface AigcTask {
|
||||
|
||||
/** iframe 沙箱隔离策略(runtime.yaml SandboxPolicyVO) */
|
||||
export interface SandboxPolicy {
|
||||
/** iframe sandbox 属性值(如 'allow-scripts allow-same-origin') */
|
||||
/** iframe sandbox 属性值(当前固定为 'allow-scripts') */
|
||||
sandboxAttr: string
|
||||
/** postMessage 允许的 origin 白名单(宿主侧双校验) */
|
||||
/** postMessage 允许的 origin 白名单(宿主侧来源窗口/origin/schema 三校验) */
|
||||
allowOrigins: string[]
|
||||
}
|
||||
|
||||
@ -206,9 +206,11 @@ export interface RuntimePackage {
|
||||
packageUrl: string
|
||||
/**
|
||||
* manifest.json 直取 URL(GAP-2,additive):宿主据此 fetch 并按 checksum(sha256) 校验后注入 iframe。
|
||||
* 后端由 packageUrl 同前缀派生(.../manifest.json)或单列回填;缺省时宿主退回 demo 兜底(见 GamePlayer.resolvePackage)。
|
||||
* 后端固定回填同源 Runtime manifest 端点;staging 缺失时宿主 fail-closed,本地 mock 才允许 demo 兜底。
|
||||
*/
|
||||
manifestUrl?: string
|
||||
/** 对象素材同源代理模板,{sha256} 替换为 assets[].hash。 */
|
||||
assetUrlTemplate?: string
|
||||
/** 入口文件相对路径(manifest.entry) */
|
||||
entry: string
|
||||
/** 目标 SDK / Runtime 版本(semver) */
|
||||
|
||||
@ -3,18 +3,18 @@
|
||||
* A2 宿主引擎 | GamePlayer.vue —— iframe 沙箱试玩宿主容器(脊柱命门)
|
||||
* ----------------------------------------------------------------------------
|
||||
* 职责(spec §3.4 / AC5 / AC6):
|
||||
* 1. iframe 沙箱:设 sandbox="allow-scripts allow-same-origin"(security §1.1)。
|
||||
* 1. iframe 沙箱:只授予 sandbox="allow-scripts"(security §1.1)。
|
||||
* 2. 取包(GAP-2):传入 manifest 则直接用;否则按 versionId 调 runtime.getPackage(契约 #1)取清单,
|
||||
* 若清单含 manifestUrl 则 fetch 并按 checksum(sha256) 校验后注入真包;无 manifestUrl 才退回 demo 兜底。
|
||||
* 若清单含 manifestUrl 则 fetch 并按 checksum(sha256) 校验后注入真包;仅本地 mock 允许 demo 兜底。
|
||||
* 3. 注入:SDK + Runtime + GamePackage → iframe srcdoc 内联(inject.ts)。
|
||||
* 4. 挂 bridge:postMessage 收发 + origin/schema 双校验 + ad/pay requestId 配对。
|
||||
* 4. 挂 bridge:postMessage 收发 + 来源窗口/origin/schema 三校验 + ad/pay requestId 配对。
|
||||
* 5. 三容器(当前/上/下)预加载占位(竖屏刷流体验占位,骨架阶段为视觉占位)。
|
||||
* 6. ad/pay 桩弹层:点击模拟激励视频/支付,回调 rewarded:true / paid:true。
|
||||
* 7. 生命周期经 emits 抛出(loaded/start/end/error),遥测经 emits 转发。
|
||||
*
|
||||
* 链路自洽(AC5):
|
||||
* 宿主挂 iframe(srcdoc 注入 SDK+Runtime) → iframe 内 boot() 调 sdk.init → Runtime 发
|
||||
* game_loaded/start/end →(postToHost)→ 宿主 bridge 收到并双校验 → 经 emits('loaded'/'start'/'end') 抛出
|
||||
* game_loaded/start/end →(postToHost)→ 宿主 bridge 收到并完成三校验 → 经 emits('loaded'/'start'/'end') 抛出
|
||||
* → 遥测 lifecycle/telemetry → emits('telemetry') 供上层转 telemetry.eventsBatch。
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
@ -32,7 +32,7 @@ import {
|
||||
} from './contract';
|
||||
import { HostBridge, buildOriginAllowlist, type RejectReason } from './bridge';
|
||||
import { gateStorageKey, gateStorageWrite } from './storageGate';
|
||||
import { buildIframeSrcdoc, buildDemoPackage, fetchAndVerifyManifest } from './inject';
|
||||
import { buildIframeSrcdoc, buildDemoPackage, fetchAndVerifyManifest, materializeVerifiedAssets } from './inject';
|
||||
import { getRuntimePackage } from './runtimeApi';
|
||||
|
||||
/** 狗粮阶段不展示任何假广告/假支付 UI;请求仍须显式失败回包,避免游戏悬挂等待。 */
|
||||
@ -128,8 +128,8 @@ const containers = ['prev', 'current', 'next'] as const;
|
||||
* 1) 优先 props.manifest(创作预览直传,不走网络)。
|
||||
* 2) 否则按 versionId 调 runtime.getPackage 取「运行包清单」(契约 #1 RuntimePackageRespVO):
|
||||
* - 清单含 manifestUrl → fetch manifest.json 并按 checksum(sha256) 完整性校验后注入真包(T-RT-15);
|
||||
* 校验/拉取失败 → emit('error') 并退回 demo 兜底(不白屏)。
|
||||
* - 清单无 manifestUrl(如 mock 阶段后端尚未回填)→ 退回 demo 兜底,并标注这是「无 manifestUrl 兜底」。
|
||||
* staging 校验/拉取失败时 fail-closed;没有 API base 的本地 mock 才退回 demo。
|
||||
* - 清单无 manifestUrl:staging 报错,本地 mock 退回 demo。
|
||||
* 3) versionId 非法 → demo 兜底。
|
||||
*/
|
||||
async function resolvePackage(): Promise<GamePackage> {
|
||||
@ -144,39 +144,42 @@ async function resolvePackage(): Promise<GamePackage> {
|
||||
const fetcher = props.fetchPackage ?? getRuntimePackage;
|
||||
respVO = await fetcher(vid, props.mode);
|
||||
} catch (e) {
|
||||
// 取清单失败:可恢复场景——demo 兜底即可正常试玩,故不向上 emit('error')
|
||||
// (父级 Play 把 error 当致命态、会 v-else-if 隐藏宿主显示「加载失败」,与 demo 兜底冲突)。仅 console.warn 记录。
|
||||
const message = e instanceof Error ? e.message : '取包清单失败';
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GamePlayer] 取包清单失败,回退 demo 兜底(不致命):', message);
|
||||
return buildDemoPackage(props.gameId, props.versionId, traceId.value);
|
||||
if (!import.meta.env.VITE_API_BASE) {
|
||||
return buildDemoPackage(props.gameId, props.versionId, traceId.value);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// 2a) 清单含 manifestUrl:拉取真包 + checksum 完整性校验后注入
|
||||
if (respVO.manifestUrl) {
|
||||
try {
|
||||
return await fetchAndVerifyManifest(respVO.manifestUrl, respVO.checksum);
|
||||
const pkg = await fetchAndVerifyManifest(respVO.manifestUrl, respVO.checksum);
|
||||
return await materializeVerifiedAssets(pkg, respVO.assetUrlTemplate);
|
||||
} catch (e) {
|
||||
// 拉取/校验失败:可恢复场景——退回 demo 兜底即可正常试玩,故不向上 emit('error')
|
||||
// (父级 Play 把 error 当致命态、会 v-else-if 隐藏宿主显示「加载失败」,与 demo 兜底冲突)。仅 console.warn 记录。
|
||||
const message = e instanceof Error ? e.message : 'manifest 取包/校验失败';
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[GamePlayer] manifest 校验失败,回退 demo 兜底(不致命):', message);
|
||||
const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value);
|
||||
demo.templateId = respVO.templateId || demo.templateId;
|
||||
return demo;
|
||||
if (!import.meta.env.VITE_API_BASE) {
|
||||
const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value);
|
||||
demo.templateId = respVO.templateId || demo.templateId;
|
||||
return demo;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2b) 清单无 manifestUrl(后端尚未回填 / mock 阶段):退回 demo 兜底,用清单元信息充实
|
||||
// 2b) 清单无 manifestUrl:仅本地 mock 退回 demo;配置真实 API 时必须 fail-closed。
|
||||
// —— GAP-2 约定:仅在无 manifestUrl 时兜底(去掉原「无条件 demo」逻辑)。
|
||||
const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value);
|
||||
demo.templateId = respVO.templateId || demo.templateId;
|
||||
return demo;
|
||||
if (!import.meta.env.VITE_API_BASE) {
|
||||
const demo = buildDemoPackage(props.gameId, props.versionId, respVO.traceId || traceId.value);
|
||||
demo.templateId = respVO.templateId || demo.templateId;
|
||||
return demo;
|
||||
}
|
||||
throw new Error('运行包缺少 manifestUrl');
|
||||
}
|
||||
|
||||
// 3) versionId 非法:demo 兜底
|
||||
return buildDemoPackage(props.gameId, props.versionId, traceId.value);
|
||||
if (!import.meta.env.VITE_API_BASE) return buildDemoPackage(props.gameId, props.versionId, traceId.value);
|
||||
throw new Error('versionId 非法');
|
||||
}
|
||||
|
||||
/** 启动一局:取包 → 构 srcdoc → 等 iframe 挂载 → 建桥 */
|
||||
@ -200,7 +203,16 @@ async function launch(): Promise<void> {
|
||||
/* 忽略:白名单缺省由 buildOriginAllowlist 兜底 */
|
||||
}
|
||||
|
||||
const pkg = await resolvePackage();
|
||||
let pkg: GamePackage;
|
||||
try {
|
||||
pkg = await resolvePackage();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : '游戏内容加载失败';
|
||||
phase.value = 'error';
|
||||
errorMsg.value = '游戏内容加载失败,请重试';
|
||||
emit('error', { message });
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建 iframe 内联文档(注入 SDK + Runtime + 引擎包/demo 包)
|
||||
srcdoc.value = buildIframeSrcdoc(pkg, traceId.value, props.gameId, props.versionId);
|
||||
@ -228,15 +240,14 @@ async function launch(): Promise<void> {
|
||||
/** 暂存的契约白名单(launch 与建桥异步衔接) */
|
||||
let pendingAllowOrigins: string[] | undefined;
|
||||
|
||||
/** 在 iframe 渲染后建立桥(含双校验) */
|
||||
/** 在 iframe 渲染后建立桥(含来源窗口/origin/schema 三校验) */
|
||||
function attachBridge(): void {
|
||||
const iframe = currentIframe(); // v-for 模板 ref 是数组,必须解包(见 iframeRef 声明注释)
|
||||
if (!iframe) return;
|
||||
// 先销毁旧桥
|
||||
bridge?.dispose();
|
||||
|
||||
// origin 白名单:srcdoc 沙箱按 sandbox 形态 origin 可能为 'null'(无 allow-same-origin)
|
||||
// 或与宿主同源(现行 allow-same-origin),两种均入白名单(includeNull=true + 自身 origin)。
|
||||
// srcdoc 不授予 allow-same-origin,消息 origin 固定为 opaque/null;白名单显式包含 null。
|
||||
const allowed = buildOriginAllowlist(pendingAllowOrigins, /* includeNull */ true);
|
||||
|
||||
const win = iframe.contentWindow;
|
||||
@ -261,7 +272,7 @@ function requestAttachBridge(): void {
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 桥消息分发:已通过 origin+schema 双校验
|
||||
* 桥消息分发:已通过来源窗口、origin 与 schema 三校验
|
||||
* ========================================================================== */
|
||||
function handleEnvelope(env: PostMessageEnvelope): void {
|
||||
switch (env.type) {
|
||||
@ -322,7 +333,7 @@ function handleLifecycle(p: LifecyclePayload): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 非法消息审计回调(双校验拒绝时;security §6 可观测性) */
|
||||
/** 非法消息审计回调(三校验拒绝时;security §6 可观测性) */
|
||||
function handleReject(reason: RejectReason, _raw: unknown, origin: string): void {
|
||||
// 仅记录,不影响「丢弃」行为。生产接入结构化日志/告警。
|
||||
// eslint-disable-next-line no-console
|
||||
@ -552,15 +563,15 @@ function onIframeLoad(): void {
|
||||
|
||||
<!--
|
||||
iframe 沙箱(security §1.1):
|
||||
sandbox="allow-scripts allow-same-origin" —— 允许脚本与同源(postMessage 需要),
|
||||
但无 allow-top-navigation / allow-popups 等,限制逃逸面。
|
||||
sandbox="allow-scripts" —— 只允许运行游戏脚本;postMessage 不需要同源权限。
|
||||
不授予 allow-same-origin / allow-top-navigation / allow-popups,避免脚本移除 sandbox 逃逸。
|
||||
srcdoc 注入 SDK+Runtime+demo(inject.ts)。
|
||||
-->
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
class="gp-frame"
|
||||
:class="{ 'is-hidden': phase !== 'ready' }"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
sandbox="allow-scripts"
|
||||
:srcdoc="srcdoc"
|
||||
title="wanxiang-game"
|
||||
@load="onIframeLoad"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* ----------------------------------------------------------------------------
|
||||
* 职责(spec §3.4 + security-and-reliability §1.1):
|
||||
* 1. 封装/解析协议信封:channel='wanxiang-game-sdk'(契约 #3)。
|
||||
* 2. 【双校验】非法 origin(不在白名单)或非法 schema(payload 结构不合法)→
|
||||
* 2. 【三校验】消息来源窗口、origin 白名单和 schema 任一非法 →
|
||||
* 立即【拒绝并丢弃】,绝不向上层分发。这是沙箱安全红线,不可绕过。
|
||||
* 3. requestId 配对回调:ad/pay 这类「请求-响应」消息,宿主回包时带回同一 requestId,
|
||||
* 游戏侧据此找到对应回调;本桥维护 requestId → resolve 的映射并支持超时清理。
|
||||
@ -20,7 +20,6 @@ import {
|
||||
SDK_CHANNEL,
|
||||
type PostMessageEnvelope,
|
||||
type PostMessageType,
|
||||
type MessageDirection,
|
||||
} from './contract';
|
||||
|
||||
/** 桥配置 */
|
||||
@ -33,7 +32,7 @@ export interface BridgeOptions {
|
||||
* 需由调用方按沙箱形态决定是否纳入(见 GamePlayer 注入逻辑)。
|
||||
*/
|
||||
allowedOrigins: string[];
|
||||
/** 合法消息分发回调(已通过双校验) */
|
||||
/** 合法消息分发回调(已通过来源窗口、origin 与 schema 三校验) */
|
||||
onMessage: (envelope: PostMessageEnvelope) => void;
|
||||
/**
|
||||
* 非法消息回调(可选):用于审计/日志(security-and-reliability §6 可观测性)。
|
||||
@ -44,6 +43,7 @@ export interface BridgeOptions {
|
||||
|
||||
/** 拒绝原因(审计用枚举) */
|
||||
export type RejectReason =
|
||||
| 'source_not_target' // 消息不是目标 iframe 的 contentWindow 发出
|
||||
| 'origin_not_allowed' // origin 不在白名单
|
||||
| 'not_object' // 数据不是对象
|
||||
| 'channel_mismatch' // channel 字段不匹配
|
||||
@ -63,12 +63,73 @@ const VALID_TYPES: ReadonlySet<PostMessageType> = new Set<PostMessageType>([
|
||||
'storage',
|
||||
]);
|
||||
|
||||
/** 合法 direction 全集 */
|
||||
const VALID_DIRECTIONS: ReadonlySet<MessageDirection> = new Set<MessageDirection>([
|
||||
'game_to_host',
|
||||
'host_to_game',
|
||||
/** 宿主允许接收的生命周期事件全集。 */
|
||||
const VALID_LIFECYCLE_EVENTS = new Set([
|
||||
'sdk_ready',
|
||||
'game_loaded',
|
||||
'game_start',
|
||||
'game_end',
|
||||
'game_error',
|
||||
]);
|
||||
|
||||
/** postMessage payload 必须是普通对象,禁止 null、数组和标量穿过受信边界。 */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 必填标识、事件名等协议字段不能只满足 string 类型,还必须有实际内容。 */
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
/** 可选对象字段存在时仍须保持普通对象形状。 */
|
||||
function isOptionalRecord(value: unknown): boolean {
|
||||
return value === undefined || isRecord(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 game→host 消息类型执行最小字段校验。
|
||||
* 这里只验证 GamePlayer 会直接解引用的字段;具体存档键白名单和业务额度仍由上层可信闸负责。
|
||||
*/
|
||||
function isValidInboundPayload(
|
||||
type: PostMessageType,
|
||||
payload: Record<string, unknown>,
|
||||
requestId: unknown,
|
||||
): boolean {
|
||||
switch (type) {
|
||||
case 'init':
|
||||
// init 只允许宿主下发,游戏不能反向伪造。
|
||||
return false;
|
||||
case 'lifecycle':
|
||||
return typeof payload.event === 'string'
|
||||
&& VALID_LIFECYCLE_EVENTS.has(payload.event)
|
||||
&& isOptionalRecord(payload.data);
|
||||
case 'telemetry':
|
||||
return isNonEmptyString(payload.event) && isOptionalRecord(payload.props);
|
||||
case 'error':
|
||||
return isNonEmptyString(payload.message)
|
||||
&& (payload.stack === undefined || typeof payload.stack === 'string');
|
||||
case 'ad':
|
||||
return isNonEmptyString(requestId)
|
||||
&& isNonEmptyString(payload.slotId)
|
||||
&& (payload.adType === 'rewarded' || payload.adType === 'interstitial');
|
||||
case 'pay':
|
||||
return isNonEmptyString(requestId)
|
||||
&& isNonEmptyString(payload.orderId)
|
||||
&& typeof payload.amount === 'number'
|
||||
&& Number.isFinite(payload.amount);
|
||||
case 'storage':
|
||||
// 无 requestId 是写入,有 requestId 是读取;两路字段形状不可混用。
|
||||
return isNonEmptyString(payload.key)
|
||||
&& (requestId === undefined
|
||||
? typeof payload.value === 'string'
|
||||
: isNonEmptyString(requestId) && !('value' in payload));
|
||||
case 'social':
|
||||
// social 仍是契约挂点,当前只执行普通对象这一层通用约束。
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** requestId 配对回调表项 */
|
||||
interface PendingRequest {
|
||||
/** 收到配对响应时调用 */
|
||||
@ -102,20 +163,26 @@ export class HostBridge {
|
||||
|
||||
/**
|
||||
* window message 事件入口。
|
||||
* 【安全红线】这里是不可信边界:先做 origin 校验,再做 schema 校验,
|
||||
* 【安全红线】这里是不可信边界:先锁定目标窗口,再做 origin 与 schema 校验,
|
||||
* 任一不过即丢弃,绝不分发。
|
||||
*/
|
||||
private onWindowMessage(ev: MessageEvent): void {
|
||||
if (this.disposed) return;
|
||||
|
||||
// —— 第一道:origin 白名单校验 ——
|
||||
// opaque sandbox 的 origin 固定为 null,必须先用 WindowProxy 身份区分目标 iframe 与其它 frame。
|
||||
if (this.opts.targetWindow === null || ev.source !== this.opts.targetWindow) {
|
||||
this.opts.onReject?.('source_not_target', ev.data, ev.origin);
|
||||
return;
|
||||
}
|
||||
|
||||
// —— 第二道:origin 白名单校验 ——
|
||||
// 注:srcdoc/blob 注入文档的 origin 可能是 'null'(字符串),需调用方显式加入白名单。
|
||||
if (!this.isOriginAllowed(ev.origin)) {
|
||||
this.opts.onReject?.('origin_not_allowed', ev.data, ev.origin);
|
||||
return; // 丢弃
|
||||
}
|
||||
|
||||
// —— 第二道:schema 校验 ——
|
||||
// —— 第三道:schema 校验 ——
|
||||
const result = this.validateEnvelope(ev.data);
|
||||
if (!result.ok) {
|
||||
this.opts.onReject?.(result.reason, ev.data, ev.origin);
|
||||
@ -166,23 +233,21 @@ export class HostBridge {
|
||||
if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type as PostMessageType)) {
|
||||
return { ok: false, reason: 'bad_type' };
|
||||
}
|
||||
// direction 必须合法
|
||||
if (
|
||||
typeof obj.direction !== 'string' ||
|
||||
!VALID_DIRECTIONS.has(obj.direction as MessageDirection)
|
||||
) {
|
||||
// 这是宿主入站边界,只接受游戏发往宿主的方向;host_to_game 只能由 post()/request() 发出。
|
||||
if (obj.direction !== 'game_to_host') {
|
||||
return { ok: false, reason: 'bad_direction' };
|
||||
}
|
||||
// traceId 必须为字符串(贯穿链路必备)
|
||||
if (typeof obj.traceId !== 'string') {
|
||||
return { ok: false, reason: 'bad_payload' };
|
||||
}
|
||||
// payload 必须存在(允许 null 之外的任意结构,按 type 由上层细分;此处只挡 undefined)
|
||||
if (!('payload' in obj)) {
|
||||
// requestId 若存在必须是非空字符串。
|
||||
if ('requestId' in obj && obj.requestId !== undefined && !isNonEmptyString(obj.requestId)) {
|
||||
return { ok: false, reason: 'bad_payload' };
|
||||
}
|
||||
// requestId 若存在必须是字符串
|
||||
if ('requestId' in obj && obj.requestId !== undefined && typeof obj.requestId !== 'string') {
|
||||
// payload 先收紧为普通对象,再按消息类型验证 GamePlayer 会解引用的最小字段。
|
||||
if (!isRecord(obj.payload)
|
||||
|| !isValidInboundPayload(obj.type as PostMessageType, obj.payload, obj.requestId)) {
|
||||
return { ok: false, reason: 'bad_payload' };
|
||||
}
|
||||
|
||||
@ -190,7 +255,7 @@ export class HostBridge {
|
||||
const envelope: PostMessageEnvelope = {
|
||||
channel: SDK_CHANNEL,
|
||||
type: obj.type as PostMessageType,
|
||||
direction: obj.direction as MessageDirection,
|
||||
direction: 'game_to_host',
|
||||
traceId: obj.traceId,
|
||||
payload: obj.payload,
|
||||
};
|
||||
|
||||
@ -67,7 +67,7 @@ export interface WanxiangGameSDKInitOptions {
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/* ----- 各类型消息的 payload 形状(前端内部约定,便于双校验与类型收窄) ----- */
|
||||
/* ----- 各类型消息的 payload 形状(前端内部约定,便于三校验与类型收窄) ----- */
|
||||
|
||||
/** lifecycle 消息 payload:生命周期事件 + 可选业务数据 */
|
||||
export interface LifecyclePayload {
|
||||
@ -235,9 +235,9 @@ export interface GamePackage {
|
||||
|
||||
/** iframe 沙箱隔离策略(对齐 runtime.yaml SandboxPolicyVO,T-RT-04 / T-RT-14) */
|
||||
export interface SandboxPolicyVO {
|
||||
/** iframe sandbox 属性值(如 'allow-scripts allow-same-origin') */
|
||||
/** iframe sandbox 属性值(当前固定为 'allow-scripts') */
|
||||
sandboxAttr?: string;
|
||||
/** postMessage 允许的 origin 白名单(宿主侧 #3 双校验用) */
|
||||
/** postMessage 允许的 origin 白名单(宿主侧来源窗口/origin/schema 三校验用) */
|
||||
allowOrigins?: string[];
|
||||
}
|
||||
|
||||
@ -250,9 +250,11 @@ export interface RuntimePackageRespVO {
|
||||
/**
|
||||
* manifest.json 直取 URL(GAP-2,additive,对齐 runtime.yaml RuntimePackageRespVO.manifestUrl)。
|
||||
* 宿主据此 fetch 解析为 GamePackage(#4),并按本结构 checksum 做 sha256 完整性校验后注入 iframe(T-RT-15)。
|
||||
* 后端由 packageUrl 同前缀派生或单列回填;缺省时宿主退回 demo 兜底(见 GamePlayer.resolvePackage)。
|
||||
* 后端固定回填同源 Runtime manifest 端点;staging 缺失时 fail-closed,本地 mock 才允许 demo 兜底。
|
||||
*/
|
||||
manifestUrl?: string;
|
||||
/** 对象素材同源代理模板,宿主校验素材后只把内存 URL 注入 iframe。 */
|
||||
assetUrlTemplate?: string;
|
||||
entry: string;
|
||||
runtimeVersion: string;
|
||||
preloadPolicy: 'eager' | 'lazy';
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
* 仅存在于 TS 的运行期符号(如 import 的值、装饰器、枚举值引用)——本实现均满足。
|
||||
*
|
||||
* 安全:iframe 文档内联,origin 为 'null';CSP 限制 connect-src 'none'(游戏内禁网络)。
|
||||
* 与宿主通信仅 postMessage,宿主侧 bridge 做 origin+schema 双校验。
|
||||
* 与宿主通信仅 postMessage,宿主侧 bridge 做来源窗口、origin 与 schema 三校验。
|
||||
*
|
||||
* 【T1b-β P2 · 引擎包分支(Runner v2 通用宿主装载)】
|
||||
* 按 `pkg.engineBundle` 是否存在分流(boot() 内):
|
||||
@ -68,15 +68,15 @@ export function buildIframeSrcdoc(
|
||||
// - script-src 'unsafe-inline':内联脚本执行(SDK/Runtime 注入 + P2 引擎 bundle 内联 <script>),不放外域 <script src>;
|
||||
// 两条活路径(A-model esbuild iife bundle / SDK Runtime 内联)皆静态打包、不含 eval/new Function,故不需 'unsafe-eval';
|
||||
// - connect-src 'none':禁一切网络出站。引擎 bundle 文本由【宿主层 fetch】后内联,非 iframe 内 fetch,故无需放宽;
|
||||
// 引擎资产经 GamePackage assets[].url 走 <img>/drawImage 加载(img-src 已放行),不触发 connect-src 死结(shouldFix#2)。
|
||||
// - img-src data: blob: https::放开 demo/引擎资源占位加载(现状即够,引擎不在 iframe 内 fetch 资产)。
|
||||
// OSS 素材由宿主校验后转为 data URL,iframe 不直接访问对象域,不触发 connect-src 死结。
|
||||
// - img-src data: blob::只允许宿主已校验的内联素材与本地对象 URL,禁止游戏借图片标签向外域出站。
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob: https:; connect-src 'none';" />
|
||||
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'none';" />
|
||||
<style>
|
||||
html,body{margin:0;height:100%;background:#070911;overflow:hidden;}
|
||||
#game{display:block;width:100%;height:100%;touch-action:none;}
|
||||
@ -246,7 +246,7 @@ ${engineBundleScript}
|
||||
// bootGameHost:内部动态 import 已内联引擎、强制 engineMode='real'、用标准插件集+视口(entry-bundle.js)。
|
||||
// canvas 传 iframe 内目标画布(real 通道引擎自建 mainCanvas,本 canvas 作占位/输入兜底)。
|
||||
Promise.resolve()
|
||||
.then(function(){ return bundleNS.bootGameHost({ canvas: canvas, seed: seed }); })
|
||||
.then(function(){ return bundleNS.bootGameHost({ canvas: canvas, seed: seed, assets: CTX.pkg.assets || [] }); })
|
||||
.then(function(host){
|
||||
// 引擎掌帧就绪(mainContext 建好 + game.init 跑过 + 首帧已渲)→ game_loaded(门② 就绪锚)。
|
||||
// GamePlayer 收 game_loaded → phase=ready + 清 5s 加载超时;非「模板体系重构中」死路。
|
||||
@ -333,10 +333,17 @@ function escapeBundleForInlineScript(bundle: string): string {
|
||||
async function sha256Hex(text: string): Promise<string> {
|
||||
// 统一以 UTF-8 编码为字节,保证两通道对同一输入摘要(与后端算 checksum 的字节口径一致)。
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return sha256RawBytesHex(bytes);
|
||||
}
|
||||
|
||||
/** 对任意原始字节计算 sha256,供对象素材逐字节复算。 */
|
||||
async function sha256RawBytesHex(bytes: Uint8Array): Promise<string> {
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
if (subtle) {
|
||||
// 安全上下文:用原生 SubtleCrypto(性能最优)。
|
||||
const digest = await subtle.digest('SHA-256', bytes);
|
||||
// TypeScript 6 将 Uint8Array 的底层缓冲泛化为 ArrayBufferLike;复制为标准 ArrayBuffer,
|
||||
// 既满足 Web Crypto 的 BufferSource 边界,也避免把 SharedArrayBuffer 误传给浏览器实现。
|
||||
const digest = await subtle.digest('SHA-256', Uint8Array.from(bytes).buffer);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
@ -410,11 +417,42 @@ function sha256BytesToHex(bytes: Uint8Array): string {
|
||||
return toHex(h0) + toHex(h1) + toHex(h2) + toHex(h3) + toHex(h4) + toHex(h5) + toHex(h6) + toHex(h7);
|
||||
}
|
||||
|
||||
/** 相对 API 路径 resolve 到 VITE_API_BASE(联调指向 staging;绝对 URL 原样;mock 模式空 base=同源)。§3.4 C4 */
|
||||
function resolveApiUrl(url: string): string {
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE as string | undefined) ?? '';
|
||||
return base + url;
|
||||
/** Runtime 只允许读取这两类同源 API;其它路径即使同 origin 也不能携带平台凭据。 */
|
||||
const TRUSTED_RUNTIME_PATH = /^\/app-api\/runtime\/package\/[1-9][0-9]*\/(?:manifest|assets\/[a-f0-9]{64})$/;
|
||||
|
||||
/**
|
||||
* 校验并解析受信 Runtime API 地址。
|
||||
*
|
||||
* 相对地址必须命中精确 Runtime 路径;绝对地址还必须与 VITE_API_BASE(或当前页面)同 origin。
|
||||
* 校验发生在读取登录 token 之前,拒绝任意对象域、用户信息、片段和额外查询参数。
|
||||
*/
|
||||
function resolveTrustedRuntimeApiUrl(url: string): string {
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string | undefined) ?? '';
|
||||
const pageOrigin = typeof globalThis.location?.origin === 'string' ? globalThis.location.origin : '';
|
||||
let parsed: URL;
|
||||
let trustedOrigin = '';
|
||||
try {
|
||||
if (apiBase) {
|
||||
trustedOrigin = new URL(apiBase, pageOrigin || 'http://runtime.invalid').origin;
|
||||
} else if (pageOrigin) {
|
||||
trustedOrigin = pageOrigin;
|
||||
}
|
||||
const absolute = /^https?:\/\//i.test(url);
|
||||
if (!absolute && (!url.startsWith('/') || url.startsWith('//'))) throw new Error('relative path required');
|
||||
parsed = new URL(url, trustedOrigin || 'http://runtime.invalid');
|
||||
if (absolute && (!trustedOrigin || parsed.origin !== trustedOrigin)) throw new Error('origin mismatch');
|
||||
const queryKeys = Array.from(parsed.searchParams.keys());
|
||||
const scene = parsed.searchParams.get('scene');
|
||||
if (!TRUSTED_RUNTIME_PATH.test(parsed.pathname)
|
||||
|| parsed.username || parsed.password || parsed.hash
|
||||
|| queryKeys.some((key) => key !== 'scene')
|
||||
|| (scene !== null && scene !== 'preview' && scene !== 'play')) {
|
||||
throw new Error('path mismatch');
|
||||
}
|
||||
return absolute ? parsed.toString() : apiBase + url;
|
||||
} catch {
|
||||
throw new Error('不受信任的 Runtime API 地址');
|
||||
}
|
||||
}
|
||||
|
||||
/** manifest fetch 鉴权头(tenant-id + Authorization):staging app-api 取包需登录态/租户,缺则 401/查空。动态取 userStore 避免顶层耦合。 */
|
||||
@ -438,7 +476,8 @@ async function buildManifestHeaders(): Promise<Record<string, string>> {
|
||||
* 与运行包清单的 checksum 比对,一致才返回真包,否则抛错(由 GamePlayer 走 demo 兜底 + emit error)。
|
||||
*
|
||||
* 安全:仅 GET 拉取数据(无脚本执行);返回的 GamePackage 仅作为数据注入 iframe(inject 内 JSON.stringify),
|
||||
* 不会被当作可执行内容求值。manifestUrl 的同源/CORS 由 runtime 下发 sandbox.allowOrigins + OSS CORS 约束保障。
|
||||
* 不会被当作可执行内容求值。manifestUrl 只能命中受信 API origin 下的 Runtime manifest 路径;
|
||||
* 平台 Authorization 和 tenant-id 永不发送到对象域或其它 API 路径。
|
||||
*
|
||||
* @param manifestUrl manifest.json 直取 URL(runtime 清单回填)
|
||||
* @param expectedChecksum 期望的整包 sha256(hex,来自运行包清单 checksum)
|
||||
@ -452,7 +491,14 @@ export async function fetchAndVerifyManifest(
|
||||
// 1) 拉取原始文本(保留原始字节序,确保 checksum 可复现)。
|
||||
// §3.4 C4:manifest 端点为相对路径 /app-api/runtime/package/{versionId}/manifest,
|
||||
// 联调须 resolve 到 VITE_API_BASE 并带鉴权头(否则打到前端 origin 或被 staging 401)。
|
||||
const resp = await fetch(resolveApiUrl(manifestUrl), { method: 'GET', headers: await buildManifestHeaders(), credentials: 'omit' });
|
||||
const trustedUrl = resolveTrustedRuntimeApiUrl(manifestUrl);
|
||||
const resp = await fetch(trustedUrl, {
|
||||
method: 'GET', headers: await buildManifestHeaders(), credentials: 'omit', redirect: 'error',
|
||||
});
|
||||
// redirect:'error' 是主防线;redirected 再做响应侧兜底,防自定义 fetch/代理实现静默跟随。
|
||||
if (resp.redirected) {
|
||||
throw new Error('manifest 请求禁止重定向');
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error(`取 manifest 失败:HTTP ${resp.status}`);
|
||||
}
|
||||
@ -479,6 +525,57 @@ export async function fetchAndVerifyManifest(
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Runtime 同源代理下载并校验 GamePackage 的对象素材。
|
||||
*
|
||||
* 原始 GamePackage 已先按 checksum 验证;本函数只生成 iframe 运行期副本,把素材 URL 换成 data URL,
|
||||
* 不重新序列化或覆盖 OSS 中的不可变 manifest。客户端不能提交 bucket/key,只能使用清单内 sha256。
|
||||
*/
|
||||
export async function materializeVerifiedAssets(
|
||||
pkg: GamePackage,
|
||||
assetUrlTemplate?: string,
|
||||
): Promise<GamePackage> {
|
||||
if (!pkg.assets.length) return pkg;
|
||||
// DB/SHADOW 不下发模板:沿用已验 GamePackage 的原素材路径,不触发对象代理或额外网络请求。
|
||||
if (!assetUrlTemplate) return pkg;
|
||||
if (!assetUrlTemplate.includes('{sha256}')) {
|
||||
throw new Error('对象素材代理未就绪');
|
||||
}
|
||||
const maxTotal = 10 * 1024 * 1024;
|
||||
let total = 0;
|
||||
// 先完整验证所有素材和目标 URL,再读取 token、发出第一条请求;后项非法不能造成前项半请求。
|
||||
const requests = pkg.assets.map((asset) => {
|
||||
if (!/^[a-f0-9]{64}$/.test(asset.hash) || !Number.isInteger(asset.bytes) || asset.bytes < 0) {
|
||||
throw new Error(`素材清单非法:${asset.id}`);
|
||||
}
|
||||
total += asset.bytes;
|
||||
if (total > maxTotal) throw new Error('素材总大小超过 10MB');
|
||||
const url = resolveTrustedRuntimeApiUrl(assetUrlTemplate.replace('{sha256}', asset.hash));
|
||||
return { asset, url };
|
||||
});
|
||||
const headers = await buildManifestHeaders();
|
||||
const assets = await Promise.all(requests.map(async ({ asset, url }) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET', headers, credentials: 'omit', redirect: 'error',
|
||||
});
|
||||
if (!response.ok || response.redirected) throw new Error(`素材请求失败:${asset.id}`);
|
||||
const mime = String(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||
if (mime !== asset.mime.toLowerCase()) throw new Error(`素材 MIME 不匹配:${asset.id}`);
|
||||
const declared = response.headers.get('content-length');
|
||||
if (declared && Number(declared) !== asset.bytes) throw new Error(`素材长度不匹配:${asset.id}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength !== asset.bytes || await sha256RawBytesHex(bytes) !== asset.hash) {
|
||||
throw new Error(`素材完整性校验失败:${asset.id}`);
|
||||
}
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return { ...asset, url: `data:${asset.mime};base64,${btoa(binary)}` };
|
||||
}));
|
||||
return { ...pkg, assets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一个 demo 游戏包(契约 #4 形状),用于骨架阶段本地试玩。
|
||||
* 真实链路中 GamePlayer 会用 runtime.getPackage 返回的清单 + 真实 GamePackage。
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
* POST /app-api/runtime/session/end → boolean
|
||||
*
|
||||
* 游戏包清单:字段语义对齐 GamePackage(#4).manifest;entry 约定为 'demo'(与 A2 Runtime demo 对齐),
|
||||
* sandbox.allowOrigins 给本地白名单(宿主 bridge 双校验时放行同源)。
|
||||
* sandbox.allowOrigins 给本地白名单(宿主 bridge 三校验时放行同源)。
|
||||
* 会话:clientPlayToken 幂等(同 token 返回同 sessionId)。
|
||||
*/
|
||||
import type { MockRoute, MockRequest } from './shared'
|
||||
@ -39,7 +39,7 @@ function packageHandler(req: MockRequest): RuntimePackage {
|
||||
// GAP-2:mock 刻意「不」回填 manifestUrl。
|
||||
// 宿主 GamePlayer.resolvePackage 对「无 manifestUrl」走 demo 兜底(assets 为空的 clicker),
|
||||
// 保证本地 mock 链路无需真实 OSS 即可跑通;而 manifestUrl 的 fetch+sha256 校验路径在
|
||||
// 隔离 staging 联调(runtime 回填真实 manifestUrl + OSS CORS)时验证。
|
||||
// 隔离 staging 联调(Runtime 回填同源 manifestUrl 并代理 OSS)时验证。
|
||||
// 若在此填一个 mock 中间件(仅拦 /app-api、/admin-api)无法服务的 /games/... 路径,
|
||||
// 只会触发取包失败兜底,污染 error 事件,故不填。详见返回 openIssues。
|
||||
// manifestUrl: undefined,
|
||||
@ -50,9 +50,9 @@ function packageHandler(req: MockRequest): RuntimePackage {
|
||||
bundleSize,
|
||||
checksum: fakeSha256(`pkg-${gameId}-${versionId}`),
|
||||
sandbox: {
|
||||
// iframe 沙箱属性:允许脚本 + 同源(本地宿主加载内置 demo 需要)
|
||||
sandboxAttr: 'allow-scripts allow-same-origin',
|
||||
// postMessage origin 白名单:本地开发/预览同源(宿主 bridge 双校验放行)
|
||||
// iframe 沙箱属性:只允许脚本;内置 demo 不需要同源权限。
|
||||
sandboxAttr: 'allow-scripts',
|
||||
// postMessage origin 白名单:本地开发/预览同源(宿主 bridge 三校验放行)
|
||||
allowOrigins: [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:4173',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* B1 玩家区 | Play.vue —— 试玩页(全屏宿主,脊柱玩家闭环命门页)
|
||||
* ----------------------------------------------------------------------------
|
||||
* 职责(spec §3.1 / §4.1 / AC3 / AC5):
|
||||
* 1. 全屏挂 <GamePlayer mode="play">(A2 宿主:iframe 沙箱 + SDK + Runtime + bridge 双校验)。
|
||||
* 1. 全屏挂 <GamePlayer mode="play">(A2 宿主:iframe 沙箱 + SDK + Runtime + bridge 三校验)。
|
||||
* 2. 会话开/收:GamePlayer 抛 start → useSessionStore.start(runtime.session/start);
|
||||
* 抛 end / 用户退出 / 组件卸载 → useSessionStore.end(runtime.session/end,幂等)。
|
||||
* 3. 加载进度:loaded 之前用 LoadingBar 顶部进度反馈(P-FED-10);loaded 后隐藏。
|
||||
|
||||
@ -78,3 +78,21 @@ game_source_minio:
|
||||
secret_key: "f059ab4593ba7a679a7dbc2ac22305106fabfe2917f98ffacb4a6d67ef0ab0df"
|
||||
source_bucket: "game-sources"
|
||||
secure: false # 当前 Tailscale 内网;公网/生产必须切 TLS
|
||||
|
||||
# ── 游戏内容受信任控制面:Agent 只经 HMAC prepare/finalize/activate 写 Project/Runtime ──
|
||||
# endpoint 指向 mini-desktop 上 game-cloud 的 admin-api;独立密钥只签原始 JSON body,不复用用户 Token。
|
||||
# 客户端禁止跨 origin 重定向并对连接/读取统一使用 15 秒硬超时。
|
||||
game_content_control:
|
||||
endpoint: "http://100.64.0.7:48090/admin-api/aigc/game-content"
|
||||
secret: "acfe0d5af3da62470f0cf2af4820f1e3fedbe76c81dd905ca847cf2480bf97b9"
|
||||
timeout_s: 15
|
||||
|
||||
# ── 游戏内容 staging 业务库:Agent checkout/commit 的 committed 权威 ──
|
||||
# 仅供 mini-desktop 上的隔离链路验收;127.0.0.1 是远端宿主自身,不能从开发机误连。
|
||||
# CLI 必须显式传 --mysql-section game_content_staging_mysql,默认分区缺失时继续 fail-closed。
|
||||
game_content_staging_mysql:
|
||||
host: "127.0.0.1"
|
||||
port: 13306
|
||||
user: "root"
|
||||
password: "yg1qq6-9RBur-tiil6DKxHtQ"
|
||||
database: "ruoyi-vue-pro"
|
||||
|
||||
168
tier2/gen-worker/scripts/game_content_repository.py
Normal file
168
tier2/gen-worker/scripts/game_content_repository.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""Agent 游戏内容仓库 CLI:按 committed 身份检出,或把新版本提交到对象存储。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from worker.game_artifact_storage_store import MySqlArtifactStorageStore
|
||||
from worker.game_artifact_store import ArtifactError
|
||||
from worker.game_content_repository import (
|
||||
GameContentControlClient,
|
||||
GameContentError,
|
||||
GameContentRepository,
|
||||
)
|
||||
|
||||
|
||||
def _infra_section(name: str, required: tuple[str, ...]) -> dict:
|
||||
"""从项目内网配置读取完整分区;缺任一凭据即拒绝联网。"""
|
||||
from service import infra_config
|
||||
|
||||
values = {key: infra_config.get(name, key, None) for key in required}
|
||||
missing = [key for key, value in values.items() if value in (None, "")]
|
||||
if missing:
|
||||
raise GameContentError(f"内网配置分区 {name} 缺字段:{','.join(missing)}")
|
||||
return values
|
||||
|
||||
|
||||
def _source_config() -> dict:
|
||||
"""读取独立 source writer,只允许访问 game-sources。"""
|
||||
values = _infra_section(
|
||||
"game_source_minio", ("endpoint", "access_key", "secret_key", "source_bucket", "secure"),
|
||||
)
|
||||
values.update({
|
||||
"artifact_bucket": "game-artifacts",
|
||||
"evidence_bucket": "game-evidence",
|
||||
})
|
||||
return values
|
||||
|
||||
|
||||
def _artifact_config() -> dict:
|
||||
"""读取 artifact writer,只允许访问运行制品与证据桶。"""
|
||||
return _infra_section(
|
||||
"game_artifact_minio",
|
||||
("endpoint", "access_key", "secret_key", "artifact_bucket", "evidence_bucket", "secure"),
|
||||
)
|
||||
|
||||
|
||||
def _control_client() -> GameContentControlClient:
|
||||
"""读取独立 HMAC 控制面配置;密钥缺失时在任何网络请求前 fail-closed。"""
|
||||
config = _infra_section(
|
||||
"game_content_control", ("endpoint", "secret", "timeout_s"),
|
||||
)
|
||||
return GameContentControlClient(
|
||||
str(config["endpoint"]), str(config["secret"]), timeout_s=config["timeout_s"],
|
||||
)
|
||||
|
||||
|
||||
def _connection_factory(section: str):
|
||||
"""构造 game-cloud 业务库连接工厂,游标固定为字典行以保字段名边界。"""
|
||||
config = _infra_section(section, ("host", "port", "user", "password", "database"))
|
||||
|
||||
def connect():
|
||||
try:
|
||||
import pymysql
|
||||
except Exception as exc: # pragma: no cover - 部署依赖缺失时给出稳定错误
|
||||
raise GameContentError(f"缺少 pymysql:{exc}") from exc
|
||||
return pymysql.connect(
|
||||
host=str(config["host"]), port=int(config["port"]),
|
||||
user=str(config["user"]), password=str(config["password"]),
|
||||
database=str(config["database"]), charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor, autocommit=False,
|
||||
)
|
||||
|
||||
return connect
|
||||
|
||||
|
||||
def _repository(mysql_section: str) -> GameContentRepository:
|
||||
"""创建统一仓库入口,调用方不能替换桶或对象 key。"""
|
||||
storage = MySqlArtifactStorageStore(_connection_factory(mysql_section))
|
||||
return GameContentRepository(
|
||||
storage, control_client=_control_client(),
|
||||
source_config=_source_config(), artifact_config=_artifact_config(),
|
||||
)
|
||||
|
||||
|
||||
def _positive(value: str) -> int:
|
||||
"""argparse 正整数转换,阻止布尔/负数/零进入业务身份。"""
|
||||
parsed = int(value)
|
||||
if parsed <= 0:
|
||||
raise argparse.ArgumentTypeError("必须是正整数")
|
||||
return parsed
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""执行 prepare、checkout、commit 或 activate,成功只向 stdout 输出结构化回执。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Agent 游戏内容仓库:prepare/committed checkout/commit/activate"
|
||||
)
|
||||
parser.add_argument("--mysql-section", default="game_content_mysql",
|
||||
help="infra.yaml 中的 game-cloud 业务库分区")
|
||||
subparsers = parser.add_subparsers(dest="action", required=True)
|
||||
|
||||
prepare = subparsers.add_parser("prepare", help="向 Project 权威预留或复用内容版本")
|
||||
prepare.add_argument("--tenant-id", type=_positive, required=True)
|
||||
prepare.add_argument("--game-id", type=_positive, required=True)
|
||||
prepare.add_argument("--revision-id", required=True)
|
||||
|
||||
checkout = subparsers.add_parser("checkout", help="按 committed 版本检出 Agent 工作区")
|
||||
checkout.add_argument("--tenant-id", type=_positive, required=True)
|
||||
checkout.add_argument("--game-id", type=_positive, required=True)
|
||||
checkout.add_argument("--version-id", type=_positive, required=True)
|
||||
checkout.add_argument("--target", type=Path, required=True)
|
||||
|
||||
commit = subparsers.add_parser("commit", help="上传并 CAS 提交新的不可变版本")
|
||||
commit.add_argument("--root", type=Path, required=True)
|
||||
commit.add_argument("--tenant-id", type=_positive, required=True)
|
||||
commit.add_argument("--game-id", type=_positive, required=True)
|
||||
commit.add_argument("--version-id", type=_positive, required=True)
|
||||
commit.add_argument("--revision-id", required=True)
|
||||
commit.add_argument("--engine", required=True)
|
||||
commit.add_argument("--package-type", choices=("phaser", "littlejs", "canvas"), required=True)
|
||||
commit.add_argument("--runtime-manifest", default="game-package.json")
|
||||
commit.add_argument("--creator", default="agent")
|
||||
|
||||
activate = subparsers.add_parser("activate", help="浏览器验收后按 expected-current 激活版本")
|
||||
activate.add_argument("--tenant-id", type=_positive, required=True)
|
||||
activate.add_argument("--game-id", type=_positive, required=True)
|
||||
activate.add_argument("--version-id", type=_positive, required=True)
|
||||
activate.add_argument("--revision-id", required=True)
|
||||
activate.add_argument("--expected-current-version-id", type=_positive)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
content = _repository(args.mysql_section)
|
||||
if args.action == "prepare":
|
||||
result = content.prepare(
|
||||
tenant_id=args.tenant_id, game_id=args.game_id, revision_id=args.revision_id,
|
||||
)
|
||||
elif args.action == "checkout":
|
||||
result = content.checkout(
|
||||
tenant_id=args.tenant_id, game_id=args.game_id,
|
||||
version_id=args.version_id, target=args.target,
|
||||
)
|
||||
elif args.action == "activate":
|
||||
result = content.activate(
|
||||
tenant_id=args.tenant_id,
|
||||
game_id=args.game_id,
|
||||
version_id=args.version_id,
|
||||
revision_id=args.revision_id,
|
||||
expected_current_version_id=args.expected_current_version_id,
|
||||
)
|
||||
else:
|
||||
result = content.commit(
|
||||
root=args.root, tenant_id=args.tenant_id, game_id=args.game_id,
|
||||
version_id=args.version_id, revision_id=args.revision_id,
|
||||
engine=args.engine, package_type=args.package_type,
|
||||
runtime_manifest_path=args.runtime_manifest, creator=args.creator,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
except (ArtifactError, ValueError) as exc:
|
||||
print(f"GAME-CONTENT-ERROR:{exc}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,11 +1,17 @@
|
||||
"""对象存储状态记录的 DB 契约与 Flyway 执行副本对账门。"""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
CONTRACT = ROOT / "contracts/db-schemas/V34.0.0__create_game_artifact_storage.sql"
|
||||
EXECUTION = ROOT / "game-cloud/huijing-server/src/main/resources/db/migration/V34.0.0__create_game_artifact_storage.sql"
|
||||
V35_CONTRACT = ROOT / "contracts/db-schemas/V35.0.0__clarify_game_artifact_manifest_hash.sql"
|
||||
V35_EXECUTION = ROOT / "game-cloud/huijing-server/src/main/resources/db/migration/V35.0.0__clarify_game_artifact_manifest_hash.sql"
|
||||
|
||||
# V34 已在隔离 staging 执行。Flyway 迁移一旦落库就必须字节不可变,后续语义修订只能新增版本。
|
||||
V34_APPLIED_SHA256 = "1fa07ee46fe4389de865b1c80bbc926a27efc77d3f088a046eb21f97aaa8d42b"
|
||||
|
||||
|
||||
def _norm_sql(text: str) -> str:
|
||||
@ -22,6 +28,23 @@ def test_storage_record_contract_has_flyway_execution_copy():
|
||||
)
|
||||
|
||||
|
||||
def test_applied_v34_bytes_are_immutable():
|
||||
"""防止修改已执行 V34 导致 Flyway checksum 漂移。"""
|
||||
for path in (CONTRACT, EXECUTION):
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == V34_APPLIED_SHA256
|
||||
|
||||
|
||||
def test_v35_clarifies_contract_hash_semantics_in_both_copies():
|
||||
"""契约 manifestHash 的语义修订必须通过新的 Flyway 版本表达。"""
|
||||
assert V35_CONTRACT.exists(), f"缺 DB 契约:{V35_CONTRACT}"
|
||||
assert V35_EXECUTION.exists(), f"缺 Flyway 执行副本:{V35_EXECUTION}"
|
||||
contract_sql = V35_CONTRACT.read_text(encoding="utf-8")
|
||||
execution_sql = V35_EXECUTION.read_text(encoding="utf-8")
|
||||
assert _norm_sql(contract_sql) == _norm_sql(execution_sql)
|
||||
assert "GameArtifactManifest/1 manifestHash" in contract_sql
|
||||
assert "GameSourceArchive/1 manifestHash" in contract_sql
|
||||
|
||||
|
||||
def test_storage_record_is_immutable_per_tenant_game_version():
|
||||
sql = CONTRACT.read_text(encoding="utf-8")
|
||||
assert "CREATE TABLE `game_artifact_storage`" in sql
|
||||
|
||||
@ -39,6 +39,7 @@ def _record(*, artifact_hash: str = "a" * 64, source_hash: str = "b" * 64) -> di
|
||||
"failure_reason": "",
|
||||
"creator": "test",
|
||||
"updater": "test",
|
||||
"deleted": False,
|
||||
}
|
||||
|
||||
|
||||
@ -60,6 +61,12 @@ class _Cursor:
|
||||
|
||||
def execute(self, sql: str, params=()):
|
||||
upper = " ".join(sql.split()).upper()
|
||||
if "GET_LOCK" in upper:
|
||||
self.last = {"acquired": 1}
|
||||
return 1
|
||||
if "RELEASE_LOCK" in upper:
|
||||
self.last = {"released": 1}
|
||||
return 1
|
||||
if upper.startswith("INSERT INTO"):
|
||||
(
|
||||
tenant_id, game_id, version_id, artifact_bucket, artifact_key, artifact_hash,
|
||||
@ -90,13 +97,16 @@ class _Cursor:
|
||||
return self.rowcount
|
||||
if upper.startswith("SELECT"):
|
||||
tenant_id, game_id, version_id = params[:3]
|
||||
filters_deleted = "DELETED=B'0'" in upper
|
||||
self.last = next((
|
||||
dict(row) for row in self.rows
|
||||
if row["tenant_id"] == tenant_id and row["game_id"] == game_id and row["version_id"] == version_id
|
||||
and (not filters_deleted or not row.get("deleted", False))
|
||||
), None)
|
||||
self.rowcount = 0
|
||||
return 0
|
||||
if upper.startswith("UPDATE"):
|
||||
assert "DELETED=B'0'" in upper
|
||||
committed_at, updater, tenant_id, game_id, version_id, *immutable_values = params
|
||||
immutable_fields = store._IMMUTABLE_FIELDS
|
||||
assert len(immutable_values) == len(immutable_fields)
|
||||
@ -184,6 +194,43 @@ def test_commit_is_hash_conditioned_and_repeat_is_idempotent():
|
||||
writer.commit_pending(wrong_bucket, committed_at=committed_at)
|
||||
|
||||
|
||||
def test_get_committed_record_returns_trusted_locators_only_after_commit():
|
||||
"""Agent/Runtime 只能从 committed 行取得完整可信 locator。"""
|
||||
connection = _Connection()
|
||||
writer = store.MySqlArtifactStorageStore(lambda: connection)
|
||||
record = _record()
|
||||
writer.create_pending(record)
|
||||
|
||||
assert writer.get_committed_record(tenant_id=1, game_id=1024, version_id=2048) is None
|
||||
writer.commit_pending(record, committed_at=datetime(2026, 7, 29, tzinfo=timezone.utc))
|
||||
|
||||
committed = writer.get_committed_record(tenant_id=1, game_id=1024, version_id=2048)
|
||||
assert committed is not None
|
||||
assert committed["status"] == "committed"
|
||||
assert committed["source_manifest_key"].endswith("/source-manifest.json")
|
||||
assert committed["artifact_manifest_key"].endswith("/artifact-manifest.json")
|
||||
|
||||
|
||||
def test_get_committed_record_rejects_logically_deleted_row():
|
||||
"""逻辑删除的 committed 行不能继续作为 Agent checkout 的可信 locator。"""
|
||||
connection = _Connection()
|
||||
writer = store.MySqlArtifactStorageStore(lambda: connection)
|
||||
record = _record()
|
||||
writer.create_pending(record)
|
||||
writer.commit_pending(record, committed_at=datetime(2026, 7, 29, tzinfo=timezone.utc))
|
||||
connection.rows[0]["deleted"] = True
|
||||
|
||||
assert writer.get_committed_record(tenant_id=1, game_id=1024, version_id=2048) is None
|
||||
|
||||
|
||||
def test_version_lock_is_acquired_and_released_on_same_connection():
|
||||
"""对象上传窗口必须由数据库锁串行化,不能只靠 CLI 人工约定。"""
|
||||
connection = _Connection()
|
||||
writer = store.MySqlArtifactStorageStore(lambda: connection)
|
||||
with writer.version_lock(tenant_id=1, game_id=1024, version_id=2048):
|
||||
assert connection.commits == 0
|
||||
|
||||
|
||||
def test_build_storage_record_binds_real_source_and_artifact_manifests(tmp_path: Path):
|
||||
_make_game(tmp_path)
|
||||
source_manifest = source_store.build_source_archive(
|
||||
|
||||
@ -36,7 +36,14 @@ def _make_game(root: Path, *, package_game_id: str = GAME_ID, package_version_id
|
||||
"versionId": package_version_id,
|
||||
"templateId": "runner",
|
||||
"gameConfig": {},
|
||||
"assets": [],
|
||||
"assets": [{
|
||||
"id": "hero",
|
||||
"type": "image",
|
||||
"url": "https://assets.invalid/hero.webp",
|
||||
"hash": hashlib.sha256(b"asset").hexdigest(),
|
||||
"bytes": len(b"asset"),
|
||||
"mime": "image/webp",
|
||||
}],
|
||||
"manifest": {
|
||||
"runtimeVersion": "1.0.0",
|
||||
"entry": "index.html",
|
||||
@ -50,6 +57,7 @@ def _make_game(root: Path, *, package_game_id: str = GAME_ID, package_version_id
|
||||
"cover": "https://assets.invalid/cover.webp",
|
||||
"ageRating": "all",
|
||||
},
|
||||
"engineBundle": "bundle",
|
||||
}
|
||||
(root / "game-package.json").write_text(
|
||||
json.dumps(game_package, ensure_ascii=False, separators=(",", ":")), encoding="utf-8",
|
||||
@ -58,6 +66,18 @@ def _make_game(root: Path, *, package_game_id: str = GAME_ID, package_version_id
|
||||
(root / "src" / "__pycache__" / "ignored.pyc").write_bytes(b"cache")
|
||||
|
||||
|
||||
def _read_game_package(root: Path) -> dict:
|
||||
"""读取测试游戏包,供负样本只改变一个契约轴。"""
|
||||
return json.loads((root / "game-package.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_game_package(root: Path, game_package: dict) -> None:
|
||||
"""按生产构建器接受的紧凑 UTF-8 JSON 口径写回测试游戏包。"""
|
||||
(root / "game-package.json").write_text(
|
||||
json.dumps(game_package, ensure_ascii=False, separators=(",", ":")), encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _source_revision(root: Path, *, source_hash: str | None = None) -> dict:
|
||||
"""构造指向现有 Tier2 SourceProjectStore 的不可变源修订身份。"""
|
||||
if source_hash is None:
|
||||
@ -145,6 +165,141 @@ def test_game_package_contract_and_identity_are_enforced(tmp_path: Path):
|
||||
)
|
||||
|
||||
|
||||
def test_build_rejects_engine_bundle_without_runtime_bundle(tmp_path: Path):
|
||||
"""删除外部 bundle 后,内联 engineBundle 不能被标成 committed 候选。"""
|
||||
_make_game(tmp_path)
|
||||
(tmp_path / "dist/bundle.iife.js").unlink()
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="engineBundle.*runtime bundle"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_rejects_runtime_bundle_without_engine_bundle(tmp_path: Path):
|
||||
"""外部 bundle 存在时,GamePackage 不能省略实际运行的内联代码。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
game_package.pop("engineBundle")
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="engineBundle.*runtime bundle"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_rejects_engine_bundle_content_mismatch(tmp_path: Path):
|
||||
"""GamePackage 与将要上传的 runtime bundle 必须是同一份 UTF-8 字节。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
game_package["engineBundle"] = "different-bundle"
|
||||
game_package["manifest"]["bundleSize"] = len("different-bundle".encode("utf-8"))
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="engineBundle.*内容"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_rejects_engine_bundle_size_mismatch(tmp_path: Path):
|
||||
"""GamePackage 声明的 bundleSize 必须等于宿主实际执行文本的 UTF-8 字节数。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
game_package["manifest"]["bundleSize"] += 1
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="bundleSize.*UTF-8"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_rejects_game_package_asset_hash_not_in_artifact_objects(tmp_path: Path):
|
||||
"""GamePackage 不能声明对象索引中不存在的素材内容。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
game_package["assets"][0]["hash"] = "0" * 64
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="素材.*hash"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_rejects_game_package_asset_bytes_mismatch(tmp_path: Path):
|
||||
"""同一素材 hash 的声明字节数漂移时必须在上传前拒绝。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
game_package["assets"][0]["bytes"] += 1
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="素材.*bytes"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_rejects_game_package_asset_mime_mismatch(tmp_path: Path):
|
||||
"""同一素材 hash 的 MIME 漂移时必须在上传前拒绝。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
game_package["assets"][0]["mime"] = "image/png"
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="素材.*mime"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_allows_distinct_logical_assets_to_reuse_one_content_hash(tmp_path: Path):
|
||||
"""契约只要求逻辑 ID 唯一;相同内容由一个 artifact 对象寻址并可被多个 ID 复用。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
duplicate = dict(game_package["assets"][0])
|
||||
duplicate["id"] = "hero-copy"
|
||||
game_package["assets"].append(duplicate)
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
manifest = _build(tmp_path)
|
||||
|
||||
assert len(game_package["assets"]) == 2
|
||||
assert sum(item["category"] == "asset" for item in manifest["objects"]) == 1
|
||||
|
||||
|
||||
def test_build_rejects_duplicate_game_package_asset_id(tmp_path: Path):
|
||||
"""同一逻辑 ID 不能重复,即使两个声明都精确指向同一物理内容。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
game_package["assets"].append(dict(game_package["assets"][0]))
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="素材 id 重复"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_reused_asset_hash_still_requires_each_logical_mime_to_match(tmp_path: Path):
|
||||
"""内容复用不放松元数据完整性;任一逻辑声明的 MIME 漂移仍须拒绝。"""
|
||||
_make_game(tmp_path)
|
||||
game_package = _read_game_package(tmp_path)
|
||||
duplicate = dict(game_package["assets"][0])
|
||||
duplicate["id"] = "hero-copy"
|
||||
duplicate["mime"] = "image/png"
|
||||
game_package["assets"].append(duplicate)
|
||||
_write_game_package(tmp_path, game_package)
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="素材 mime.*hero-copy"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_rejects_duplicate_artifact_asset_hash(tmp_path: Path):
|
||||
"""一个声明 hash 匹配多个物理对象时必须拒绝,不能任取第一个。"""
|
||||
_make_game(tmp_path)
|
||||
(tmp_path / "assets/hero-copy.webp").write_bytes(b"asset")
|
||||
|
||||
with pytest.raises(store.ArtifactError, match="素材.*重复"):
|
||||
_build(tmp_path)
|
||||
|
||||
|
||||
def test_build_allows_unreferenced_lazy_artifact_asset(tmp_path: Path):
|
||||
"""对象索引可保留 GamePackage 尚未声明的懒加载素材。"""
|
||||
_make_game(tmp_path)
|
||||
(tmp_path / "assets/lazy.webp").write_bytes(b"lazy-asset")
|
||||
|
||||
manifest = _build(tmp_path)
|
||||
|
||||
assert sum(item["category"] == "asset" for item in manifest["objects"]) == 2
|
||||
|
||||
|
||||
class _Response:
|
||||
"""模拟 MinIO HTTPResponse,覆盖连接释放协议。"""
|
||||
|
||||
@ -486,6 +641,23 @@ def test_fetch_manifest_rejects_oversized_remote_payload(monkeypatch: pytest.Mon
|
||||
)
|
||||
|
||||
|
||||
def test_public_fetch_wraps_missing_manifest_sdk_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""公共读取边界不得把 MinIO NoSuchKey 类型泄漏给调用方。"""
|
||||
class MissingClient:
|
||||
def get_object(self, _bucket: str, _key: str):
|
||||
raise _NoSuchKey("missing manifest")
|
||||
|
||||
monkeypatch.setattr(store, "_minio_client", lambda _config: MissingClient())
|
||||
with pytest.raises(store.ArtifactError, match="artifact-manifest 不存在"):
|
||||
store.fetch_manifest(
|
||||
{}, key="tenants/1/games/1024/versions/2048/artifact-manifest.json",
|
||||
expected_manifest_hash="0" * 64,
|
||||
expected_tenant_id=TENANT_ID,
|
||||
expected_game_id=GAME_ID,
|
||||
expected_version_id=VERSION_ID,
|
||||
)
|
||||
|
||||
|
||||
def test_missing_bucket_is_not_treated_as_missing_marker():
|
||||
"""桶缺失表示部署错误,不能降级成尚未上传。"""
|
||||
class MissingBucketClient:
|
||||
|
||||
585
tier2/gen-worker/tests/test_game_content_repository.py
Normal file
585
tier2/gen-worker/tests/test_game_content_repository.py
Normal file
@ -0,0 +1,585 @@
|
||||
"""Agent 游戏内容仓库的受信任 prepare/committed checkout/commit 闭环测试。"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import game_content_repository as repository_cli
|
||||
from test_game_artifact_store import _make_game
|
||||
from worker import game_content_repository as repository
|
||||
from worker.game_artifact_storage_store import StorageRecordError
|
||||
from worker.game_artifact_store import ArtifactError
|
||||
|
||||
|
||||
def _committed_record() -> dict:
|
||||
"""构造只能来自 game_artifact_storage committed 行的可信 locator。"""
|
||||
return {
|
||||
"tenant_id": 1,
|
||||
"game_id": 1024,
|
||||
"version_id": 2048,
|
||||
"artifact_bucket": "game-artifacts",
|
||||
"artifact_manifest_key": "tenants/1/games/1024/versions/2048/artifact-manifest.json",
|
||||
"artifact_manifest_hash": "a" * 64,
|
||||
"source_bucket": "game-sources",
|
||||
"source_provider": "game_source_archive",
|
||||
"source_game_id": "1024",
|
||||
"source_revision_id": "shanhai-r1",
|
||||
"source_manifest_key": "tenants/1/games/1024/source-revisions/shanhai-r1/source-manifest.json",
|
||||
"source_manifest_hash": "b" * 64,
|
||||
"source_hash": "c" * 64,
|
||||
"status": "committed",
|
||||
}
|
||||
|
||||
|
||||
class _Storage:
|
||||
def __init__(self, record: dict | None, order: list[str] | None = None):
|
||||
self.record = record
|
||||
self.calls: list[str] = []
|
||||
self.order = order
|
||||
|
||||
def _record_call(self, name: str) -> None:
|
||||
"""同时记录存储局部顺序和跨控制面/对象存储的全链顺序。"""
|
||||
self.calls.append(name)
|
||||
if self.order is not None:
|
||||
self.order.append(name)
|
||||
|
||||
def get_committed_record(self, **_identity):
|
||||
self._record_call("get")
|
||||
return self.record
|
||||
|
||||
@contextmanager
|
||||
def version_lock(self, **_identity):
|
||||
self._record_call("lock")
|
||||
yield
|
||||
|
||||
def create_pending(self, record: dict):
|
||||
self._record_call("pending")
|
||||
assert record["status"] == "pending"
|
||||
return {"status": "pending", "committed": False}
|
||||
|
||||
def commit_pending(self, record: dict, *, committed_at: datetime):
|
||||
self._record_call("committed")
|
||||
assert record["status"] == "pending"
|
||||
assert committed_at.tzinfo is not None
|
||||
return {
|
||||
"status": "committed",
|
||||
"committed": True,
|
||||
"artifactManifestHash": record["artifact_manifest_hash"],
|
||||
"sourceManifestHash": record["source_manifest_hash"],
|
||||
}
|
||||
|
||||
|
||||
class _Control:
|
||||
"""记录 prepare/finalize/activate 调用,并模拟后端权威版本状态机。"""
|
||||
|
||||
def __init__(self, order: list[str] | None = None, *, version_id: int = 2048,
|
||||
finalize_failure: Exception | None = None):
|
||||
self.order = order
|
||||
self.version_id = version_id
|
||||
self.finalize_failure = finalize_failure
|
||||
self.prepare_calls: list[dict] = []
|
||||
self.finalize_calls: list[dict] = []
|
||||
self.activate_calls: list[dict] = []
|
||||
|
||||
def prepare(self, *, tenant_id: int, game_id: int, revision_id: str) -> int:
|
||||
if self.order is not None:
|
||||
self.order.append("prepare")
|
||||
self.prepare_calls.append({
|
||||
"tenant_id": tenant_id, "game_id": game_id, "revision_id": revision_id,
|
||||
})
|
||||
return self.version_id
|
||||
|
||||
def finalize(self, **payload) -> int:
|
||||
if self.order is not None:
|
||||
self.order.append("finalize")
|
||||
self.finalize_calls.append(payload)
|
||||
if self.finalize_failure is not None:
|
||||
raise self.finalize_failure
|
||||
return 31
|
||||
|
||||
def activate(self, **payload) -> int:
|
||||
if self.order is not None:
|
||||
self.order.append("activate")
|
||||
self.activate_calls.append(payload)
|
||||
return payload["version_id"]
|
||||
|
||||
|
||||
class _HttpResponse:
|
||||
"""模拟 http.client 响应,保留状态、Location 和有限读取语义。"""
|
||||
|
||||
def __init__(self, status: int, body: bytes = b"", headers: dict[str, str] | None = None):
|
||||
self.status = status
|
||||
self.reason = "test"
|
||||
self._body = body
|
||||
self._headers = {key.lower(): value for key, value in (headers or {}).items()}
|
||||
|
||||
def getheader(self, name: str, default=None):
|
||||
return self._headers.get(name.lower(), default)
|
||||
|
||||
def read(self, amount: int | None = None) -> bytes:
|
||||
if amount is None:
|
||||
amount = len(self._body)
|
||||
data, self._body = self._body[:amount], self._body[amount:]
|
||||
return data
|
||||
|
||||
|
||||
def _install_http(
|
||||
monkeypatch: pytest.MonkeyPatch, responses: list[_HttpResponse | Exception],
|
||||
) -> list[dict]:
|
||||
"""安装确定性 HTTP 连接,直接检查实际发出的原始 body、签名和 timeout。"""
|
||||
requests: list[dict] = []
|
||||
|
||||
class Connection:
|
||||
def __init__(self, host: str, port: int | None = None, *, timeout: float):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
|
||||
def request(self, method: str, path: str, *, body: bytes, headers: dict[str, str]):
|
||||
requests.append({
|
||||
"host": self.host, "port": self.port, "timeout": self.timeout,
|
||||
"method": method, "path": path, "body": body, "headers": headers,
|
||||
})
|
||||
|
||||
def getresponse(self):
|
||||
response = responses.pop(0)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(repository.http.client, "HTTPConnection", Connection)
|
||||
return requests
|
||||
|
||||
|
||||
def test_control_client_signs_exact_raw_json_and_returns_backend_ids(monkeypatch: pytest.MonkeyPatch):
|
||||
"""HMAC 必须覆盖实际发送字节,三个写步骤只返回后端 CommonResult.data。"""
|
||||
responses = [
|
||||
_HttpResponse(200, b'{"code":0,"data":2048,"msg":""}'),
|
||||
_HttpResponse(200, b'{"code":0,"data":31,"msg":""}'),
|
||||
_HttpResponse(200, b'{"code":0,"data":2048,"msg":""}'),
|
||||
]
|
||||
requests = _install_http(monkeypatch, responses)
|
||||
client = repository.GameContentControlClient(
|
||||
"http://100.64.0.7:48090/admin-api/aigc/game-content",
|
||||
"content-secret", timeout_s=15,
|
||||
)
|
||||
|
||||
assert client.prepare(tenant_id=7, game_id=1024, revision_id="revision-a") == 2048
|
||||
raw_manifest = '{\n "schemaVersion": "1.0",\n "title": "山海行纪"\n}\n'
|
||||
assert client.finalize(
|
||||
tenant_id=7, game_id=1024, version_id=2048, revision_id="revision-a",
|
||||
artifact_manifest_hash="a" * 64, manifest_json=raw_manifest,
|
||||
) == 31
|
||||
assert client.activate(
|
||||
tenant_id=7, game_id=1024, version_id=2048, revision_id="revision-a",
|
||||
expected_current_version_id=1023,
|
||||
) == 2048
|
||||
|
||||
assert [request["path"] for request in requests] == [
|
||||
"/admin-api/aigc/game-content/prepare",
|
||||
"/admin-api/aigc/game-content/finalize",
|
||||
"/admin-api/aigc/game-content/activate",
|
||||
]
|
||||
assert all(request["timeout"] == 15 for request in requests)
|
||||
for request in requests:
|
||||
expected = hmac.new(b"content-secret", request["body"], hashlib.sha256).hexdigest()
|
||||
assert request["headers"]["X-Game-Content-Signature"] == expected
|
||||
assert json.loads(requests[0]["body"]) == {
|
||||
"tenantId": 7, "gameId": 1024, "revisionId": "revision-a",
|
||||
}
|
||||
assert json.loads(requests[1]["body"])["manifestJson"] == raw_manifest
|
||||
assert json.loads(requests[2]["body"]) == {
|
||||
"tenantId": 7,
|
||||
"gameId": 1024,
|
||||
"versionId": 2048,
|
||||
"revisionId": "revision-a",
|
||||
"expectedCurrentVersionId": 1023,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [500, "0", True])
|
||||
def test_control_client_accepts_only_integer_zero_common_result(
|
||||
monkeypatch: pytest.MonkeyPatch, code,
|
||||
):
|
||||
"""HTTP 200 不是成功证明;只有非布尔整数 code=0 才能进入写链。"""
|
||||
_install_http(monkeypatch, [
|
||||
_HttpResponse(200, json.dumps({"code": code, "data": 2048}).encode("utf-8")),
|
||||
])
|
||||
client = repository.GameContentControlClient("http://control.invalid/base", "secret", timeout_s=2)
|
||||
|
||||
with pytest.raises(repository.GameContentError, match="CommonResult"):
|
||||
client.prepare(tenant_id=7, game_id=1024, revision_id="revision-a")
|
||||
|
||||
|
||||
def test_control_client_rejects_cross_origin_redirect_without_following(monkeypatch: pytest.MonkeyPatch):
|
||||
"""控制面绝不把带 HMAC 的正文或签名头转发到另一个 origin。"""
|
||||
requests = _install_http(monkeypatch, [
|
||||
_HttpResponse(307, headers={"Location": "http://evil.invalid/finalize"}),
|
||||
])
|
||||
client = repository.GameContentControlClient("http://control.invalid/base", "secret", timeout_s=2)
|
||||
|
||||
with pytest.raises(repository.GameContentError, match="跨 origin"):
|
||||
client.prepare(tenant_id=7, game_id=1024, revision_id="revision-a")
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
def test_control_client_applies_strict_timeout_and_wraps_timeout_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""同一硬超时必须进入底层连接;超时只能作为稳定领域错误返回。"""
|
||||
requests = _install_http(monkeypatch, [TimeoutError("timed out")])
|
||||
client = repository.GameContentControlClient(
|
||||
"http://control.invalid/base", "secret", timeout_s=0.25,
|
||||
)
|
||||
|
||||
with pytest.raises(repository.GameContentError, match="网络失败:TimeoutError"):
|
||||
client.prepare(tenant_id=7, game_id=1024, revision_id="revision-a")
|
||||
assert requests[0]["timeout"] == 0.25
|
||||
|
||||
|
||||
def test_checkout_rejects_version_without_committed_record(tmp_path: Path):
|
||||
content = repository.GameContentRepository(
|
||||
_Storage(None), source_config={}, artifact_config={},
|
||||
)
|
||||
with pytest.raises(repository.GameContentError, match="未 committed"):
|
||||
content.checkout(tenant_id=1, game_id=1024, version_id=2048, target=tmp_path / "work")
|
||||
|
||||
|
||||
def test_checkout_materializes_source_assets_and_game_package_atomically(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
record = _committed_record()
|
||||
source_manifest = {
|
||||
"manifestHash": record["source_manifest_hash"],
|
||||
"sourceHash": record["source_hash"],
|
||||
}
|
||||
artifact_manifest = {
|
||||
"manifestHash": record["artifact_manifest_hash"],
|
||||
"objects": [
|
||||
{"category": "asset", "path": "assets/hero.webp"},
|
||||
{"category": "runtime", "path": "runtime/bundle.js"},
|
||||
{"category": "evidence", "path": "evidence/qa.md"},
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(repository, "fetch_source_archive", lambda *_a, **_k: source_manifest)
|
||||
monkeypatch.setattr(repository, "fetch_manifest", lambda *_a, **_k: artifact_manifest)
|
||||
|
||||
def download_source(_manifest, _config, target, **_kwargs):
|
||||
(target / "src").mkdir(parents=True)
|
||||
(target / "src/main.js").write_text("export const GAME = 1;\n", encoding="utf-8")
|
||||
return target
|
||||
|
||||
def download_artifact(_manifest, _config, target, **_kwargs):
|
||||
(target / "assets").mkdir(parents=True)
|
||||
(target / "runtime").mkdir()
|
||||
(target / "evidence").mkdir()
|
||||
(target / "assets/hero.webp").write_bytes(b"asset")
|
||||
(target / "runtime/bundle.js").write_text("bundle", encoding="utf-8")
|
||||
(target / "evidence/qa.md").write_text("qa", encoding="utf-8")
|
||||
(target / "manifest.json").write_text('{"schemaVersion":"1.0"}', encoding="utf-8")
|
||||
(target / "artifact-manifest.json").write_text("{}", encoding="utf-8")
|
||||
return target
|
||||
|
||||
monkeypatch.setattr(repository, "download_source_archive", download_source)
|
||||
monkeypatch.setattr(repository, "download_manifest", download_artifact)
|
||||
|
||||
target = tmp_path / "agent-work"
|
||||
result = repository.GameContentRepository(
|
||||
_Storage(record), source_config={"source_bucket": "game-sources"},
|
||||
artifact_config={"artifact_bucket": "game-artifacts"},
|
||||
).checkout(tenant_id=1, game_id=1024, version_id=2048, target=target)
|
||||
|
||||
assert result["target"] == str(target)
|
||||
assert (target / "src/main.js").is_file()
|
||||
assert (target / "assets/hero.webp").read_bytes() == b"asset"
|
||||
assert (target / "game-package.json").is_file()
|
||||
assert not (target / "runtime").exists()
|
||||
assert not (target / "evidence").exists()
|
||||
assert not list(tmp_path.glob(".agent-work.partial-*"))
|
||||
|
||||
|
||||
def test_prepare_returns_backend_version_id():
|
||||
"""Agent 必须先从 Project 权威 prepare 取得版本 ID,不能自行生成生产身份。"""
|
||||
control = _Control(version_id=2048)
|
||||
content = repository.GameContentRepository(
|
||||
_Storage(None), control_client=control, source_config={}, artifact_config={},
|
||||
)
|
||||
|
||||
assert content.prepare(tenant_id=1, game_id=1024, revision_id="shanhai-r2") == 2048
|
||||
assert control.prepare_calls == [{
|
||||
"tenant_id": 1, "game_id": 1024, "revision_id": "shanhai-r2",
|
||||
}]
|
||||
|
||||
|
||||
def test_activate_uses_expected_current_and_returns_target_version_id():
|
||||
"""浏览器验收后只能通过 expected-current CAS 激活,不能在 commit 内隐式切换。"""
|
||||
control = _Control()
|
||||
content = repository.GameContentRepository(
|
||||
_Storage(None), control_client=control, source_config={}, artifact_config={},
|
||||
)
|
||||
|
||||
assert content.activate(
|
||||
tenant_id=1, game_id=1024, version_id=2048,
|
||||
revision_id="shanhai-r2", expected_current_version_id=1023,
|
||||
) == 2048
|
||||
assert control.activate_calls == [{
|
||||
"tenant_id": 1,
|
||||
"game_id": 1024,
|
||||
"version_id": 2048,
|
||||
"revision_id": "shanhai-r2",
|
||||
"expected_current_version_id": 1023,
|
||||
}]
|
||||
|
||||
|
||||
def test_activate_rejects_backend_version_mismatch():
|
||||
"""控制面若返回其它版本,Agent 必须拒绝把激活结果当成成功。"""
|
||||
class WrongActivationControl(_Control):
|
||||
def activate(self, **payload) -> int:
|
||||
super().activate(**payload)
|
||||
return 4096
|
||||
|
||||
content = repository.GameContentRepository(
|
||||
_Storage(None), control_client=WrongActivationControl(),
|
||||
source_config={}, artifact_config={},
|
||||
)
|
||||
|
||||
with pytest.raises(repository.GameContentError, match="activate.*不一致"):
|
||||
content.activate(
|
||||
tenant_id=1, game_id=1024, version_id=2048,
|
||||
revision_id="shanhai-r2", expected_current_version_id=None,
|
||||
)
|
||||
|
||||
|
||||
def test_commit_uploads_then_pending_then_finalizes_then_database_cas(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
_make_game(tmp_path)
|
||||
order: list[str] = []
|
||||
storage = _Storage(None, order)
|
||||
control = _Control(order)
|
||||
|
||||
monkeypatch.setattr(
|
||||
repository, "upload_source_archive",
|
||||
lambda *_a, **_k: order.append("source") or {"status": "verified_pending"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
repository, "upload_manifest",
|
||||
lambda *_a, **_k: order.append("artifact") or {"status": "verified_pending"},
|
||||
)
|
||||
|
||||
content = repository.GameContentRepository(
|
||||
storage, control_client=control,
|
||||
source_config={"source_bucket": "game-sources"},
|
||||
artifact_config={"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"},
|
||||
now=lambda: datetime(2026, 7, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
result = content.commit(
|
||||
root=tmp_path, tenant_id=1, game_id=1024, version_id=2048,
|
||||
revision_id="shanhai-r2", engine="custom-canvas-littlejs",
|
||||
package_type="littlejs", runtime_manifest_path="game-package.json",
|
||||
creator="agent",
|
||||
)
|
||||
|
||||
assert order == ["prepare", "lock", "source", "artifact", "pending", "finalize", "committed"]
|
||||
assert storage.calls == ["lock", "pending", "committed"]
|
||||
assert control.finalize_calls == [{
|
||||
"tenant_id": 1,
|
||||
"game_id": 1024,
|
||||
"version_id": 2048,
|
||||
"revision_id": "shanhai-r2",
|
||||
"artifact_manifest_hash": result["artifactManifestHash"],
|
||||
"manifest_json": (tmp_path / "game-package.json").read_text(encoding="utf-8"),
|
||||
}]
|
||||
assert result["committed"] is True
|
||||
assert result["versionId"] == "2048"
|
||||
assert result["runtimePackageId"] == 31
|
||||
assert result["activated"] is False
|
||||
assert result["sourceHash"]
|
||||
assert result["artifactManifestHash"]
|
||||
|
||||
|
||||
def test_commit_rejects_version_not_returned_by_idempotent_prepare_before_upload(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""commit 必须复核 prepare 幂等身份;版本不一致时不能触碰对象或 V34。"""
|
||||
_make_game(tmp_path)
|
||||
storage = _Storage(None)
|
||||
control = _Control(version_id=4096)
|
||||
upload_calls: list[str] = []
|
||||
monkeypatch.setattr(repository, "upload_source_archive", lambda *_a, **_k: upload_calls.append("source"))
|
||||
monkeypatch.setattr(repository, "upload_manifest", lambda *_a, **_k: upload_calls.append("artifact"))
|
||||
content = repository.GameContentRepository(
|
||||
storage, control_client=control,
|
||||
source_config={"source_bucket": "game-sources"},
|
||||
artifact_config={"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"},
|
||||
)
|
||||
|
||||
with pytest.raises(repository.GameContentError, match="versionId.*不一致"):
|
||||
content.commit(
|
||||
root=tmp_path, tenant_id=1, game_id=1024, version_id=2048,
|
||||
revision_id="shanhai-r2", engine="custom-canvas-littlejs", package_type="littlejs",
|
||||
)
|
||||
assert upload_calls == []
|
||||
assert storage.calls == []
|
||||
|
||||
|
||||
def test_finalize_failure_leaves_v34_pending_and_never_commits(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""后端 Finalize 失败时 pending 可供排障/重试,但绝不能暴露为 committed。"""
|
||||
_make_game(tmp_path)
|
||||
order: list[str] = []
|
||||
storage = _Storage(None, order)
|
||||
control = _Control(order, finalize_failure=repository.GameContentError("finalize failed"))
|
||||
monkeypatch.setattr(
|
||||
repository, "upload_source_archive",
|
||||
lambda *_a, **_k: order.append("source") or {"status": "verified_pending"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
repository, "upload_manifest",
|
||||
lambda *_a, **_k: order.append("artifact") or {"status": "verified_pending"},
|
||||
)
|
||||
content = repository.GameContentRepository(
|
||||
storage, control_client=control,
|
||||
source_config={"source_bucket": "game-sources"},
|
||||
artifact_config={"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"},
|
||||
)
|
||||
|
||||
with pytest.raises(repository.GameContentError, match="finalize failed"):
|
||||
content.commit(
|
||||
root=tmp_path, tenant_id=1, game_id=1024, version_id=2048,
|
||||
revision_id="shanhai-r2", engine="custom-canvas-littlejs", package_type="littlejs",
|
||||
)
|
||||
assert order == ["prepare", "lock", "source", "artifact", "pending", "finalize"]
|
||||
assert storage.calls == ["lock", "pending"]
|
||||
|
||||
|
||||
def test_cas_failure_is_safe_to_retry_after_idempotent_finalize(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Finalize 已成功但 V34 CAS 失败时,整次 commit 可按同一修订安全重放。"""
|
||||
class FailFirstCasStorage(_Storage):
|
||||
def __init__(self, order: list[str]):
|
||||
super().__init__(None, order)
|
||||
self.cas_attempts = 0
|
||||
|
||||
def commit_pending(self, record: dict, *, committed_at: datetime):
|
||||
self._record_call("cas")
|
||||
self.cas_attempts += 1
|
||||
if self.cas_attempts == 1:
|
||||
raise StorageRecordError("transient CAS failed")
|
||||
return {
|
||||
"status": "committed", "committed": True, "versionId": str(record["version_id"]),
|
||||
"artifactManifestHash": record["artifact_manifest_hash"],
|
||||
"sourceManifestHash": record["source_manifest_hash"],
|
||||
}
|
||||
|
||||
_make_game(tmp_path)
|
||||
order: list[str] = []
|
||||
storage = FailFirstCasStorage(order)
|
||||
control = _Control(order)
|
||||
monkeypatch.setattr(
|
||||
repository, "upload_source_archive",
|
||||
lambda *_a, **_k: order.append("source") or {"status": "verified_pending"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
repository, "upload_manifest",
|
||||
lambda *_a, **_k: order.append("artifact") or {"status": "verified_pending"},
|
||||
)
|
||||
content = repository.GameContentRepository(
|
||||
storage, control_client=control,
|
||||
source_config={"source_bucket": "game-sources"},
|
||||
artifact_config={"artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence"},
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"root": tmp_path, "tenant_id": 1, "game_id": 1024, "version_id": 2048,
|
||||
"revision_id": "shanhai-r2", "engine": "custom-canvas-littlejs", "package_type": "littlejs",
|
||||
}
|
||||
with pytest.raises(StorageRecordError, match="transient CAS failed"):
|
||||
content.commit(**kwargs)
|
||||
result = content.commit(**kwargs)
|
||||
|
||||
one_attempt = ["prepare", "lock", "source", "artifact", "pending", "finalize", "cas"]
|
||||
assert order == one_attempt + one_attempt
|
||||
assert len(control.prepare_calls) == 2
|
||||
assert len(control.finalize_calls) == 2
|
||||
assert result["committed"] is True
|
||||
assert result["runtimePackageId"] == 31
|
||||
|
||||
|
||||
def test_cli_prepare_prints_backend_version_id(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
):
|
||||
"""CLI 提供显式 prepare 步骤,Agent 可据后端 ID 生成匹配的 GamePackage。"""
|
||||
class PreparingRepository:
|
||||
def prepare(self, **kwargs):
|
||||
assert kwargs == {"tenant_id": 1, "game_id": 1024, "revision_id": "shanhai-r2"}
|
||||
return 2048
|
||||
|
||||
monkeypatch.setattr(repository_cli, "_repository", lambda _section: PreparingRepository())
|
||||
|
||||
exit_code = repository_cli.main([
|
||||
"prepare", "--tenant-id", "1", "--game-id", "1024", "--revision-id", "shanhai-r2",
|
||||
])
|
||||
|
||||
assert exit_code == 0
|
||||
assert capsys.readouterr().out == "2048\n"
|
||||
|
||||
|
||||
def test_cli_activate_passes_expected_current_and_prints_version_id(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
):
|
||||
"""CLI 把人工/浏览器验收后的激活动作保持为独立显式步骤。"""
|
||||
class ActivatingRepository:
|
||||
def activate(self, **kwargs):
|
||||
assert kwargs == {
|
||||
"tenant_id": 1,
|
||||
"game_id": 1024,
|
||||
"version_id": 2048,
|
||||
"revision_id": "shanhai-r2",
|
||||
"expected_current_version_id": 1023,
|
||||
}
|
||||
return 2048
|
||||
|
||||
monkeypatch.setattr(repository_cli, "_repository", lambda _section: ActivatingRepository())
|
||||
|
||||
exit_code = repository_cli.main([
|
||||
"activate", "--tenant-id", "1", "--game-id", "1024", "--version-id", "2048",
|
||||
"--revision-id", "shanhai-r2", "--expected-current-version-id", "1023",
|
||||
])
|
||||
|
||||
assert exit_code == 0
|
||||
assert capsys.readouterr().out == "2048\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [
|
||||
ArtifactError("artifact failed"),
|
||||
StorageRecordError("storage failed"),
|
||||
])
|
||||
def test_cli_wraps_all_content_domain_errors_without_traceback(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], failure: Exception,
|
||||
):
|
||||
"""CLI 必须把上传与 CAS 领域错误收敛成稳定退出码,不能泄漏 traceback。"""
|
||||
class FailingRepository:
|
||||
def checkout(self, **_kwargs):
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(repository_cli, "_repository", lambda _section: FailingRepository())
|
||||
|
||||
exit_code = repository_cli.main([
|
||||
"checkout", "--tenant-id", "1", "--game-id", "1024", "--version-id", "2048",
|
||||
"--target", str(tmp_path / "work"),
|
||||
])
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert exit_code == 2
|
||||
assert captured.out == f"GAME-CONTENT-ERROR:{failure}\n"
|
||||
assert captured.err == ""
|
||||
@ -310,3 +310,21 @@ def test_download_failure_leaves_no_partial_directory(tmp_path: Path, monkeypatc
|
||||
store.download_source_archive(manifest, config, target, expected_manifest_hash=manifest["manifestHash"])
|
||||
assert not target.exists()
|
||||
assert not list(tmp_path.glob(".materialized.partial-*"))
|
||||
|
||||
|
||||
def test_public_fetch_wraps_missing_source_manifest_sdk_error(monkeypatch: pytest.MonkeyPatch):
|
||||
"""公共源归档读取边界不得把 MinIO NoSuchKey 类型泄漏给 Agent。"""
|
||||
class MissingClient:
|
||||
def get_object(self, _bucket: str, _key: str):
|
||||
raise _NoSuchKey("missing source manifest")
|
||||
|
||||
monkeypatch.setattr(store, "_minio_client", lambda _config: MissingClient())
|
||||
with pytest.raises(store.ArtifactError, match="source-manifest 不存在"):
|
||||
store.fetch_source_archive(
|
||||
{"source_bucket": "game-sources"},
|
||||
key="tenants/1/games/1024/source-revisions/shanhai-r1/source-manifest.json",
|
||||
expected_manifest_hash="0" * 64,
|
||||
expected_tenant_id=TENANT_ID,
|
||||
expected_game_id=GAME_ID,
|
||||
expected_revision_id=REVISION_ID,
|
||||
)
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Callable, Mapping
|
||||
|
||||
@ -55,6 +56,13 @@ def _bucket(value: str, label: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _validate_identity(tenant_id: int, game_id: int, version_id: int) -> None:
|
||||
"""统一校验数据库三元身份,所有锁与读取入口保持同一 fail-closed 口径。"""
|
||||
for name, value in (("tenant_id", tenant_id), ("game_id", game_id), ("version_id", version_id)):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise StorageRecordError(f"{name} 必须是正整数")
|
||||
|
||||
|
||||
def _validate_record(record: Mapping) -> None:
|
||||
"""校验 writer 输入的不可变字段,避免绕过 manifest builder 写入伪身份。"""
|
||||
for field in ("tenant_id", "game_id", "version_id"):
|
||||
@ -210,13 +218,40 @@ class MySqlArtifactStorageStore:
|
||||
def _select_identity(self, connection, record: Mapping) -> dict | None:
|
||||
sql = (
|
||||
f"SELECT {_SELECT_COLUMNS} FROM {_TABLE} "
|
||||
"WHERE tenant_id=%s AND game_id=%s AND version_id=%s LIMIT 1"
|
||||
"WHERE tenant_id=%s AND game_id=%s AND version_id=%s AND deleted=b'0' LIMIT 1"
|
||||
)
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(sql, (record["tenant_id"], record["game_id"], record["version_id"]))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@contextmanager
|
||||
def version_lock(self, *, tenant_id: int, game_id: int, version_id: int, timeout_s: int = 30):
|
||||
"""用 MySQL advisory lock 串行化同一生产版本的对象上传与数据库提交。"""
|
||||
_validate_identity(tenant_id, game_id, version_id)
|
||||
if isinstance(timeout_s, bool) or not isinstance(timeout_s, int) or timeout_s < 0 or timeout_s > 300:
|
||||
raise StorageRecordError("version lock timeout_s 非法")
|
||||
lock_name = f"game-content:{tenant_id}:{game_id}:{version_id}"
|
||||
connection = self._connection_factory()
|
||||
acquired = False
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT GET_LOCK(%s, %s) AS acquired", (lock_name, timeout_s))
|
||||
row = cursor.fetchone()
|
||||
acquired_value = row.get("acquired") if isinstance(row, Mapping) else (row[0] if row else None)
|
||||
acquired = int(acquired_value or 0) == 1
|
||||
if not acquired:
|
||||
raise StorageRecordError("同一游戏版本正在发布,获取对象上传锁超时")
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT RELEASE_LOCK(%s) AS released", (lock_name,))
|
||||
except Exception as exc: # noqa: BLE001 - 连接关闭也会由 MySQL 自动释放,仍留痕给调用方
|
||||
raise StorageRecordError(f"释放对象上传锁失败:{type(exc).__name__}:{exc}") from exc
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _same_immutable_fields(existing: Mapping, record: Mapping) -> bool:
|
||||
return all(existing.get(field) == record.get(field) for field in _IMMUTABLE_FIELDS)
|
||||
@ -284,7 +319,8 @@ class MySqlArtifactStorageStore:
|
||||
immutable_predicate = " AND ".join(f"{field}=%s" for field in _IMMUTABLE_FIELDS)
|
||||
sql = (
|
||||
f"UPDATE {_TABLE} SET status='committed', committed_at=%s, updater=%s "
|
||||
f"WHERE tenant_id=%s AND game_id=%s AND version_id=%s AND status='pending' AND {immutable_predicate}"
|
||||
f"WHERE tenant_id=%s AND game_id=%s AND version_id=%s AND deleted=b'0' "
|
||||
f"AND status='pending' AND {immutable_predicate}"
|
||||
)
|
||||
params = (
|
||||
committed_at, record.get("updater", ""), record["tenant_id"], record["game_id"], record["version_id"],
|
||||
@ -315,9 +351,14 @@ class MySqlArtifactStorageStore:
|
||||
|
||||
def get_committed(self, *, tenant_id: int, game_id: int, version_id: int) -> dict | None:
|
||||
"""只读取 committed 记录,pending/abandoned 对生产消费不可见。"""
|
||||
for name, value in (("tenant_id", tenant_id), ("game_id", game_id), ("version_id", version_id)):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise StorageRecordError(f"{name} 必须是正整数")
|
||||
row = self.get_committed_record(
|
||||
tenant_id=tenant_id, game_id=game_id, version_id=version_id,
|
||||
)
|
||||
return self._receipt(row, idempotent=True) if row else None
|
||||
|
||||
def get_committed_record(self, *, tenant_id: int, game_id: int, version_id: int) -> dict | None:
|
||||
"""返回 committed 行的完整可信 locator,供 Agent/Runtime 物化,不接受调用方自带 key。"""
|
||||
_validate_identity(tenant_id, game_id, version_id)
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
row = self._select_identity(connection, {
|
||||
@ -325,6 +366,6 @@ class MySqlArtifactStorageStore:
|
||||
})
|
||||
if not row or row.get("status") != "committed":
|
||||
return None
|
||||
return self._receipt(row, idempotent=True)
|
||||
return row
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@ -210,6 +210,58 @@ def _bundle_object(objects: Iterable[Mapping]) -> Mapping | None:
|
||||
return runtime_js[0]
|
||||
|
||||
|
||||
def _validate_game_package_bundle(game_package: Mapping, bundle: Mapping | None, root: Path) -> None:
|
||||
"""校验宿主实际执行的内联 bundle 与对象索引中的外部 bundle 是同一份字节。"""
|
||||
engine_bundle = game_package.get("engineBundle")
|
||||
inline_present = engine_bundle is not None
|
||||
external_present = bundle is not None
|
||||
if inline_present != external_present:
|
||||
raise ArtifactError("GamePackage.engineBundle 与 runtime bundle 必须同时存在或同时缺失")
|
||||
if not inline_present:
|
||||
return
|
||||
if not isinstance(engine_bundle, str) or not engine_bundle:
|
||||
raise ArtifactError("GamePackage.engineBundle 必须是非空 UTF-8 文本")
|
||||
|
||||
inline_bytes = engine_bundle.encode("utf-8")
|
||||
declared_bundle_size = game_package.get("manifest", {}).get("bundleSize")
|
||||
if declared_bundle_size != len(inline_bytes):
|
||||
raise ArtifactError("GamePackage manifest.bundleSize 与 engineBundle UTF-8 字节数不一致")
|
||||
external_path = root / _safe_rel(str(bundle["sourcePath"]))
|
||||
external_bytes = external_path.read_bytes()
|
||||
if (
|
||||
inline_bytes != external_bytes
|
||||
or len(inline_bytes) != bundle["bytes"]
|
||||
or _hash_bytes(inline_bytes) != bundle["sha256"]
|
||||
):
|
||||
raise ArtifactError("GamePackage.engineBundle 与 runtime bundle 内容不一致")
|
||||
|
||||
|
||||
def _validate_game_package_assets(game_package: Mapping, artifact_assets: Iterable[Mapping]) -> None:
|
||||
"""把每个逻辑素材绑定到唯一内容对象;同一内容 hash 可被不同逻辑 ID 复用。"""
|
||||
objects_by_hash: dict[str, list[Mapping]] = {}
|
||||
for item in artifact_assets:
|
||||
objects_by_hash.setdefault(str(item["sha256"]), []).append(item)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
for asset in game_package.get("assets", []):
|
||||
asset_id = str(asset["id"])
|
||||
asset_hash = str(asset["hash"])
|
||||
if asset_id in seen_ids:
|
||||
raise ArtifactError(f"GamePackage 素材 id 重复:{asset_id}")
|
||||
seen_ids.add(asset_id)
|
||||
|
||||
candidates = objects_by_hash.get(asset_hash, [])
|
||||
if not candidates:
|
||||
raise ArtifactError(f"GamePackage 素材 hash 未绑定 artifact 对象:{asset_id}")
|
||||
if len(candidates) != 1:
|
||||
raise ArtifactError(f"GamePackage 素材 hash 重复绑定多个 artifact 对象:{asset_id}")
|
||||
item = candidates[0]
|
||||
if asset["bytes"] != item["bytes"]:
|
||||
raise ArtifactError(f"GamePackage 素材 bytes 与 artifact 不一致:{asset_id}")
|
||||
if asset["mime"] != item["mime"]:
|
||||
raise ArtifactError(f"GamePackage 素材 mime 与 artifact 不一致:{asset_id}")
|
||||
|
||||
|
||||
def _classify(rel: str) -> tuple[str, str]:
|
||||
"""按路径把游戏输入映射为 source/asset/runtime/evidence 和包内路径。"""
|
||||
path = _safe_rel(rel)
|
||||
@ -271,13 +323,14 @@ def build_manifest(root: Path, tenant_id: str, game_id: str, version_id: str, *,
|
||||
root = Path(root).resolve()
|
||||
runtime_manifest_rel = _safe_rel(runtime_manifest_path)
|
||||
runtime_manifest = None
|
||||
game_package = None
|
||||
source_files: list[tuple[str, Path]] = []
|
||||
for rel, file_path in _iter_files(root):
|
||||
if rel == runtime_manifest_rel:
|
||||
if file_path.stat().st_size > _MAX_OBJECT_BYTES:
|
||||
raise ArtifactError(f"GamePackage manifest 超过 {_MAX_OBJECT_BYTES} 字节")
|
||||
data = file_path.read_bytes()
|
||||
_validate_game_package(data, game_id, version_id)
|
||||
game_package = _validate_game_package(data, game_id, version_id)
|
||||
runtime_manifest = {
|
||||
"store": "artifact",
|
||||
"key": f"{key_prefix}/manifest.json",
|
||||
@ -342,6 +395,8 @@ def build_manifest(root: Path, tenant_id: str, game_id: str, version_id: str, *,
|
||||
for category in _CATEGORY_ORDER
|
||||
}
|
||||
bundle = _bundle_object(by_category["runtime"])
|
||||
_validate_game_package_bundle(game_package, bundle, root)
|
||||
_validate_game_package_assets(game_package, by_category["asset"])
|
||||
runtime_asset_bytes = sum(item["bytes"] for item in by_category["runtime"] + by_category["asset"])
|
||||
if runtime_asset_bytes > _MAX_RUNTIME_ASSET_BYTES:
|
||||
raise ArtifactError(f"运行包与素材合计超过 {_MAX_RUNTIME_ASSET_BYTES} 字节")
|
||||
@ -542,6 +597,19 @@ def _read_remote(client, bucket: str, key: str, *, expected_bytes: int) -> bytes
|
||||
response.release_conn()
|
||||
|
||||
|
||||
def _read_required_remote(client, bucket: str, key: str, *, expected_bytes: int,
|
||||
object_name: str) -> bytes:
|
||||
"""读取生产消费必需对象,并把 SDK 缺对象异常收敛为稳定领域错误。"""
|
||||
try:
|
||||
return _read_remote(client, bucket, key, expected_bytes=expected_bytes)
|
||||
except ArtifactError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - _read_remote 故意为上传幂等保留 NoSuchKey
|
||||
if getattr(exc, "code", None) in {"NoSuchKey", "NoSuchObject"}:
|
||||
raise ArtifactError(f"{object_name} 不存在:{bucket}/{key}") from exc
|
||||
raise ArtifactError(f"读取 {object_name} 失败:{bucket}/{key}:{type(exc).__name__}:{exc}") from exc
|
||||
|
||||
|
||||
def _bucket(config: Mapping, store: str) -> str:
|
||||
"""把稳定 store 身份映射到环境桶名;manifest 不保存凭据或临时 URL。"""
|
||||
if store == "artifact":
|
||||
@ -694,7 +762,10 @@ def fetch_manifest(config: Mapping, *, key: str, expected_manifest_hash: str,
|
||||
_validate_id(expected_tenant_id, "expectedTenantId")
|
||||
_validate_db_id(expected_game_id, "expectedGameId")
|
||||
_validate_db_id(expected_version_id, "expectedVersionId")
|
||||
payload = _read_remote(client, bucket, key, expected_bytes=_MAX_MANIFEST_BYTES)
|
||||
payload = _read_required_remote(
|
||||
client, bucket, key, expected_bytes=_MAX_MANIFEST_BYTES,
|
||||
object_name="artifact-manifest",
|
||||
)
|
||||
try:
|
||||
manifest = json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
@ -729,18 +800,18 @@ def download_manifest(manifest: Mapping, config: Mapping, target: Path, *, expec
|
||||
if staging not in destination.parents:
|
||||
raise ArtifactError(f"下载路径越界:{rel}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = _read_remote(
|
||||
data = _read_required_remote(
|
||||
client, _bucket(config, str(item["store"])), item["key"],
|
||||
expected_bytes=item["bytes"],
|
||||
expected_bytes=item["bytes"], object_name=f"artifact 对象 {rel}",
|
||||
)
|
||||
if len(data) != item["bytes"] or _hash_bytes(data) != item["sha256"]:
|
||||
raise ArtifactError(f"下载对象 hash 不匹配:{rel}")
|
||||
destination.write_bytes(data)
|
||||
runtime_manifest = manifest["runtimeManifest"]
|
||||
destination = staging / "manifest.json"
|
||||
data = _read_remote(
|
||||
data = _read_required_remote(
|
||||
client, _bucket(config, "artifact"), runtime_manifest["key"],
|
||||
expected_bytes=runtime_manifest["bytes"],
|
||||
expected_bytes=runtime_manifest["bytes"], object_name="GamePackage manifest",
|
||||
)
|
||||
if len(data) != runtime_manifest["bytes"] or _hash_bytes(data) != runtime_manifest["sha256"]:
|
||||
raise ArtifactError("下载 GamePackage manifest hash 不匹配")
|
||||
|
||||
398
tier2/gen-worker/worker/game_content_repository.py
Normal file
398
tier2/gen-worker/worker/game_content_repository.py
Normal file
@ -0,0 +1,398 @@
|
||||
"""供 Agent 使用的游戏内容仓库:从 committed 对象版本检出,并发布新的不可变版本。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Mapping
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from .game_artifact_storage_store import build_storage_record
|
||||
from .game_artifact_store import (
|
||||
ArtifactError,
|
||||
_safe_rel,
|
||||
build_manifest,
|
||||
download_manifest,
|
||||
fetch_manifest,
|
||||
upload_manifest,
|
||||
)
|
||||
from .game_source_archive_store import (
|
||||
build_source_archive,
|
||||
download_source_archive,
|
||||
fetch_source_archive,
|
||||
upload_source_archive,
|
||||
)
|
||||
|
||||
|
||||
class GameContentError(ArtifactError):
|
||||
"""游戏内容仓库的身份、物化或提交边界失败。"""
|
||||
|
||||
|
||||
class GameContentControlClient:
|
||||
"""使用原始 JSON body HMAC 调用后端受信任 prepare/finalize/activate 控制面。"""
|
||||
|
||||
_SIGNATURE_HEADER = "X-Game-Content-Signature"
|
||||
_MAX_RESPONSE_BYTES = 1024 * 1024
|
||||
|
||||
def __init__(self, endpoint: str, secret: str, *, timeout_s: float):
|
||||
if not isinstance(endpoint, str) or not endpoint.strip():
|
||||
raise GameContentError("游戏内容控制面 endpoint 不能为空")
|
||||
parsed = urlsplit(endpoint.strip())
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise GameContentError("游戏内容控制面 endpoint 只接受 http/https 绝对地址")
|
||||
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
|
||||
raise GameContentError("游戏内容控制面 endpoint 不能包含用户信息、query 或 fragment")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise GameContentError("游戏内容控制面 endpoint 端口非法") from exc
|
||||
if not isinstance(secret, str) or not secret:
|
||||
raise GameContentError("游戏内容控制面 HMAC 密钥不能为空")
|
||||
if isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)) or timeout_s <= 0:
|
||||
raise GameContentError("游戏内容控制面 timeout_s 必须为正数")
|
||||
|
||||
self._scheme = parsed.scheme
|
||||
self._host = parsed.hostname
|
||||
self._port = port
|
||||
self._base_path = parsed.path.rstrip("/")
|
||||
self._origin = self._origin_of(parsed)
|
||||
self._endpoint = endpoint.strip().rstrip("/")
|
||||
self._secret = secret.encode("utf-8")
|
||||
self._timeout_s = float(timeout_s)
|
||||
|
||||
@staticmethod
|
||||
def _origin_of(parsed) -> tuple[str, str | None, int | None]:
|
||||
"""按 scheme/host/有效端口比较 origin,默认端口与显式端口视为相同。"""
|
||||
default_port = 443 if parsed.scheme == "https" else 80 if parsed.scheme == "http" else None
|
||||
try:
|
||||
port = parsed.port or default_port
|
||||
except ValueError:
|
||||
port = None
|
||||
return parsed.scheme.lower(), parsed.hostname.lower() if parsed.hostname else None, port
|
||||
|
||||
@staticmethod
|
||||
def _positive_result(value, label: str) -> int:
|
||||
"""控制面 ID 必须是非布尔正整数,禁止字符串化 ID 混入生产身份。"""
|
||||
if type(value) is not int or value <= 0:
|
||||
raise GameContentError(f"游戏内容控制面 {label} 返回的 data 不是正整数")
|
||||
return value
|
||||
|
||||
def _post(self, operation: str, payload: Mapping) -> int:
|
||||
"""发送一次不自动重定向的 POST;HTTP 与 CommonResult 任一失败都 fail-closed。"""
|
||||
try:
|
||||
raw_body = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise GameContentError(f"游戏内容控制面 {operation} 请求无法编码为 JSON") from exc
|
||||
signature = hmac.new(self._secret, raw_body, hashlib.sha256).hexdigest()
|
||||
connection_type = (
|
||||
http.client.HTTPSConnection if self._scheme == "https" else http.client.HTTPConnection
|
||||
)
|
||||
connection = connection_type(self._host, self._port, timeout=self._timeout_s)
|
||||
path = f"{self._base_path}/{operation}" if self._base_path else f"/{operation}"
|
||||
try:
|
||||
connection.request("POST", path, body=raw_body, headers={
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Accept": "application/json",
|
||||
self._SIGNATURE_HEADER: signature,
|
||||
})
|
||||
response = connection.getresponse()
|
||||
if 300 <= response.status < 400:
|
||||
location = response.getheader("Location")
|
||||
if location:
|
||||
redirected = urlsplit(urljoin(f"{self._endpoint}/", location))
|
||||
if self._origin_of(redirected) != self._origin:
|
||||
raise GameContentError(
|
||||
f"游戏内容控制面 {operation} 拒绝跨 origin 重定向"
|
||||
)
|
||||
raise GameContentError(f"游戏内容控制面 {operation} 不接受重定向")
|
||||
|
||||
response_body = response.read(self._MAX_RESPONSE_BYTES + 1)
|
||||
if len(response_body) > self._MAX_RESPONSE_BYTES:
|
||||
raise GameContentError(f"游戏内容控制面 {operation} 响应超过 1MiB")
|
||||
if not 200 <= response.status < 300:
|
||||
raise GameContentError(
|
||||
f"游戏内容控制面 {operation} HTTP 失败:{response.status}"
|
||||
)
|
||||
try:
|
||||
envelope = json.loads(response_body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise GameContentError(
|
||||
f"游戏内容控制面 {operation} 响应不是合法 UTF-8 JSON"
|
||||
) from exc
|
||||
if not isinstance(envelope, dict) or type(envelope.get("code")) is not int \
|
||||
or envelope["code"] != 0:
|
||||
raise GameContentError(f"游戏内容控制面 {operation} CommonResult 失败")
|
||||
return self._positive_result(envelope.get("data"), f"{operation} ID")
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
raise GameContentError(
|
||||
f"游戏内容控制面 {operation} 网络失败:{type(exc).__name__}"
|
||||
) from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def prepare(self, *, tenant_id: int, game_id: int, revision_id: str) -> int:
|
||||
"""请求 Project 创建或幂等复用内容版本,并返回权威 versionId。"""
|
||||
return self._post("prepare", {
|
||||
"tenantId": tenant_id,
|
||||
"gameId": game_id,
|
||||
"revisionId": revision_id,
|
||||
})
|
||||
|
||||
def finalize(self, *, tenant_id: int, game_id: int, version_id: int, revision_id: str,
|
||||
artifact_manifest_hash: str, manifest_json: str) -> int:
|
||||
"""提交已上传 GamePackage 原文和摘要身份,并返回 Runtime packageId。"""
|
||||
return self._post("finalize", {
|
||||
"tenantId": tenant_id,
|
||||
"gameId": game_id,
|
||||
"versionId": version_id,
|
||||
"revisionId": revision_id,
|
||||
"artifactManifestHash": artifact_manifest_hash,
|
||||
"manifestJson": manifest_json,
|
||||
})
|
||||
|
||||
def activate(self, *, tenant_id: int, game_id: int, version_id: int, revision_id: str,
|
||||
expected_current_version_id: int | None) -> int:
|
||||
"""浏览器验收通过后,按 expected-current CAS 激活目标版本。"""
|
||||
return self._post("activate", {
|
||||
"tenantId": tenant_id,
|
||||
"gameId": game_id,
|
||||
"versionId": version_id,
|
||||
"revisionId": revision_id,
|
||||
"expectedCurrentVersionId": expected_current_version_id,
|
||||
})
|
||||
|
||||
|
||||
class GameContentRepository:
|
||||
"""把源归档、制品清单和数据库 committed 状态组合为一个 Agent 入口。"""
|
||||
|
||||
_REVISION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
def __init__(self, storage_store, *, control_client=None,
|
||||
source_config: Mapping, artifact_config: Mapping,
|
||||
now: Callable[[], datetime] | None = None):
|
||||
self._storage = storage_store
|
||||
self._control = control_client
|
||||
self._source_config = dict(source_config)
|
||||
self._artifact_config = dict(artifact_config)
|
||||
self._now = now or (lambda: datetime.now(timezone.utc))
|
||||
|
||||
@staticmethod
|
||||
def _validate_positive_ids(identity: Mapping[str, int]) -> None:
|
||||
"""生产身份字段必须是数据库正整数,不能把对象前缀或布尔值当业务身份。"""
|
||||
for name, value in identity.items():
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise GameContentError(f"{name} 必须是正整数")
|
||||
|
||||
@classmethod
|
||||
def _identity(cls, tenant_id: int, game_id: int, version_id: int) -> dict[str, int]:
|
||||
"""构造并校验完整生产版本身份。"""
|
||||
identity = {"tenant_id": tenant_id, "game_id": game_id, "version_id": version_id}
|
||||
cls._validate_positive_ids(identity)
|
||||
return identity
|
||||
|
||||
@classmethod
|
||||
def _prepare_identity(cls, tenant_id: int, game_id: int, revision_id: str) -> None:
|
||||
"""在联网前校验 prepare 身份,错误输入不能触碰受信控制面。"""
|
||||
cls._validate_positive_ids({"tenant_id": tenant_id, "game_id": game_id})
|
||||
if not isinstance(revision_id, str) or not cls._REVISION_RE.fullmatch(revision_id):
|
||||
raise GameContentError("revision_id 格式非法")
|
||||
|
||||
def prepare(self, *, tenant_id: int, game_id: int, revision_id: str) -> int:
|
||||
"""从 Project 权威控制面取得给定不可变修订的生产 versionId。"""
|
||||
self._prepare_identity(tenant_id, game_id, revision_id)
|
||||
if self._control is None:
|
||||
raise GameContentError("游戏内容控制面未配置,拒绝 prepare")
|
||||
version_id = self._control.prepare(
|
||||
tenant_id=tenant_id, game_id=game_id, revision_id=revision_id,
|
||||
)
|
||||
self._identity(tenant_id, game_id, version_id)
|
||||
return version_id
|
||||
|
||||
def activate(self, *, tenant_id: int, game_id: int, version_id: int, revision_id: str,
|
||||
expected_current_version_id: int | None) -> int:
|
||||
"""将浏览器已验收版本显式切为当前版本;commit 本身永不隐式激活。"""
|
||||
self._identity(tenant_id, game_id, version_id)
|
||||
self._prepare_identity(tenant_id, game_id, revision_id)
|
||||
if expected_current_version_id is not None:
|
||||
self._validate_positive_ids({"expected_current_version_id": expected_current_version_id})
|
||||
if self._control is None:
|
||||
raise GameContentError("游戏内容控制面未配置,拒绝 activate")
|
||||
activated_version_id = self._control.activate(
|
||||
tenant_id=tenant_id,
|
||||
game_id=game_id,
|
||||
version_id=version_id,
|
||||
revision_id=revision_id,
|
||||
expected_current_version_id=expected_current_version_id,
|
||||
)
|
||||
if activated_version_id != version_id:
|
||||
raise GameContentError(
|
||||
f"activate 返回 versionId 不一致:{activated_version_id}!={version_id}"
|
||||
)
|
||||
return activated_version_id
|
||||
|
||||
@staticmethod
|
||||
def _copy_file(source: Path, target: Path) -> None:
|
||||
"""合并已验证快照时拒绝路径碰撞,避免源码与素材静默互相覆盖。"""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
if target.is_file() and source.read_bytes() == target.read_bytes():
|
||||
return
|
||||
raise GameContentError(f"物化路径冲突:{target}")
|
||||
shutil.copy2(source, target)
|
||||
|
||||
def checkout(self, *, tenant_id: int, game_id: int, version_id: int, target: Path) -> dict:
|
||||
"""按 committed 行原子物化 Agent 工作区:源码、发布素材和原始 GamePackage。"""
|
||||
identity = self._identity(tenant_id, game_id, version_id)
|
||||
record = self._storage.get_committed_record(**identity)
|
||||
if not record:
|
||||
raise GameContentError("游戏内容版本未 committed,拒绝 Agent 检出")
|
||||
if record.get("source_provider") != "game_source_archive":
|
||||
raise GameContentError("当前 Agent 检出入口只接受 game_source_archive;Tier2 源继续走 SourceProjectStore")
|
||||
if self._source_config.get("source_bucket") != record.get("source_bucket"):
|
||||
raise GameContentError("源码桶配置与 committed 记录不一致")
|
||||
if self._artifact_config.get("artifact_bucket", "game-artifacts") != record.get("artifact_bucket"):
|
||||
raise GameContentError("制品桶配置与 committed 记录不一致")
|
||||
|
||||
target = Path(target).resolve()
|
||||
if target.exists():
|
||||
raise GameContentError(f"Agent 工作区已存在:{target}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging_root = Path(tempfile.mkdtemp(prefix=f".{target.name}.partial-", dir=target.parent)).resolve()
|
||||
source_snapshot = staging_root / "source"
|
||||
artifact_snapshot = staging_root / "artifact"
|
||||
payload = staging_root / "payload"
|
||||
payload.mkdir()
|
||||
try:
|
||||
source_manifest = fetch_source_archive(
|
||||
self._source_config,
|
||||
key=record["source_manifest_key"],
|
||||
expected_manifest_hash=record["source_manifest_hash"],
|
||||
expected_tenant_id=str(tenant_id),
|
||||
expected_game_id=str(record["source_game_id"]),
|
||||
expected_revision_id=str(record["source_revision_id"]),
|
||||
)
|
||||
if source_manifest.get("sourceHash") != record.get("source_hash"):
|
||||
raise GameContentError("源归档与 committed sourceHash 不一致")
|
||||
download_source_archive(
|
||||
source_manifest, self._source_config, source_snapshot,
|
||||
expected_manifest_hash=record["source_manifest_hash"],
|
||||
)
|
||||
|
||||
artifact_manifest = fetch_manifest(
|
||||
self._artifact_config,
|
||||
key=record["artifact_manifest_key"],
|
||||
expected_manifest_hash=record["artifact_manifest_hash"],
|
||||
expected_tenant_id=str(tenant_id),
|
||||
expected_game_id=str(game_id),
|
||||
expected_version_id=str(version_id),
|
||||
)
|
||||
download_manifest(
|
||||
artifact_manifest, self._artifact_config, artifact_snapshot,
|
||||
expected_manifest_hash=record["artifact_manifest_hash"],
|
||||
)
|
||||
|
||||
for source in sorted(source_snapshot.rglob("*")):
|
||||
if source.is_file():
|
||||
self._copy_file(source, payload / source.relative_to(source_snapshot))
|
||||
for item in artifact_manifest.get("objects", []):
|
||||
if item.get("category") != "asset":
|
||||
continue
|
||||
rel = _safe_rel(str(item["path"]))
|
||||
self._copy_file(artifact_snapshot / rel, payload / rel)
|
||||
self._copy_file(artifact_snapshot / "manifest.json", payload / "game-package.json")
|
||||
payload.replace(target)
|
||||
except Exception:
|
||||
shutil.rmtree(staging_root, ignore_errors=True)
|
||||
raise
|
||||
shutil.rmtree(staging_root, ignore_errors=True)
|
||||
return {
|
||||
"tenantId": str(tenant_id),
|
||||
"gameId": str(game_id),
|
||||
"versionId": str(version_id),
|
||||
"sourceRevisionId": str(record["source_revision_id"]),
|
||||
"sourceHash": record["source_hash"],
|
||||
"artifactManifestHash": record["artifact_manifest_hash"],
|
||||
"target": str(target),
|
||||
}
|
||||
|
||||
def commit(self, *, root: Path, tenant_id: int, game_id: int, version_id: int,
|
||||
revision_id: str, engine: str, package_type: str,
|
||||
runtime_manifest_path: str = "game-package.json", creator: str = "agent") -> dict:
|
||||
"""重验版本后上传对象,由后端 Finalize 完成 Runtime 与 V34 提交,再做幂等确认。"""
|
||||
identity = self._identity(tenant_id, game_id, version_id)
|
||||
prepared_version_id = self.prepare(
|
||||
tenant_id=tenant_id, game_id=game_id, revision_id=revision_id,
|
||||
)
|
||||
if prepared_version_id != version_id:
|
||||
raise GameContentError(
|
||||
f"commit versionId 与后端 prepare 不一致:{version_id}!={prepared_version_id}"
|
||||
)
|
||||
if not hasattr(self._storage, "version_lock"):
|
||||
raise GameContentError("对象状态存储缺少版本级互斥锁,拒绝发布")
|
||||
root = Path(root).resolve()
|
||||
with self._storage.version_lock(**identity):
|
||||
source_manifest = build_source_archive(
|
||||
root, str(tenant_id), str(game_id), revision_id,
|
||||
engine=engine, runtime_manifest_path=runtime_manifest_path,
|
||||
)
|
||||
upload_source_archive(
|
||||
root, source_manifest, self._source_config, external_single_writer=True,
|
||||
)
|
||||
artifact_manifest = build_manifest(
|
||||
root, str(tenant_id), str(game_id), str(version_id),
|
||||
package_type=package_type, runtime_manifest_path=runtime_manifest_path,
|
||||
source_revision={
|
||||
"provider": "game_source_archive",
|
||||
"gameId": str(game_id),
|
||||
"revisionId": revision_id,
|
||||
"manifestRef": f"game-source-archive:{game_id}:{revision_id}",
|
||||
"sourceHash": source_manifest["sourceHash"],
|
||||
},
|
||||
)
|
||||
upload_manifest(
|
||||
root, artifact_manifest, self._artifact_config, external_single_writer=True,
|
||||
)
|
||||
record = build_storage_record(
|
||||
artifact_manifest,
|
||||
artifact_bucket=str(self._artifact_config.get("artifact_bucket", "")),
|
||||
source_bucket=str(self._source_config.get("source_bucket", "")),
|
||||
source_archive=source_manifest,
|
||||
creator=creator,
|
||||
updater=creator,
|
||||
)
|
||||
self._storage.create_pending(record)
|
||||
runtime_manifest = root / _safe_rel(runtime_manifest_path)
|
||||
try:
|
||||
# read_bytes 后严格解码可保留 CRLF 等原始字符;read_text 会做通用换行转换。
|
||||
manifest_json = runtime_manifest.read_bytes().decode("utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise GameContentError("GamePackage 原文读取失败或不是合法 UTF-8") from exc
|
||||
runtime_package_id = self._control.finalize(
|
||||
tenant_id=tenant_id,
|
||||
game_id=game_id,
|
||||
version_id=version_id,
|
||||
revision_id=revision_id,
|
||||
artifact_manifest_hash=artifact_manifest["manifestHash"],
|
||||
manifest_json=manifest_json,
|
||||
)
|
||||
receipt = self._storage.commit_pending(record, committed_at=self._now())
|
||||
return {
|
||||
**receipt,
|
||||
"versionId": str(version_id),
|
||||
"runtimePackageId": runtime_package_id,
|
||||
"activated": False,
|
||||
"sourceHash": source_manifest["sourceHash"],
|
||||
"sourceManifestHash": source_manifest["manifestHash"],
|
||||
"artifactManifestHash": artifact_manifest["manifestHash"],
|
||||
"runtimeManifestHash": artifact_manifest["runtimeManifest"]["sha256"],
|
||||
"bundleHash": artifact_manifest["bundleHash"],
|
||||
}
|
||||
@ -32,6 +32,7 @@ from .game_artifact_store import (
|
||||
_minio_client,
|
||||
_put_remote,
|
||||
_read_remote,
|
||||
_read_required_remote,
|
||||
_safe_rel,
|
||||
_validate_contract,
|
||||
_validate_db_id,
|
||||
@ -304,7 +305,10 @@ def fetch_source_archive(config: Mapping, *, key: str, expected_manifest_hash: s
|
||||
_validate_db_id(expected_game_id, "expectedGameId")
|
||||
_validate_id(expected_revision_id, "expectedRevisionId")
|
||||
client = _minio_client(config)
|
||||
payload = _read_remote(client, _bucket(config, "source"), key, expected_bytes=_MAX_MANIFEST_BYTES)
|
||||
payload = _read_required_remote(
|
||||
client, _bucket(config, "source"), key,
|
||||
expected_bytes=_MAX_MANIFEST_BYTES, object_name="source-manifest",
|
||||
)
|
||||
try:
|
||||
manifest = json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
@ -340,8 +344,9 @@ def download_source_archive(manifest: Mapping, config: Mapping, target: Path, *,
|
||||
if staging not in destination.parents:
|
||||
raise ArtifactError(f"下载源路径越界:{rel}")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = _read_remote(
|
||||
data = _read_required_remote(
|
||||
client, _bucket(config, "source"), item["key"], expected_bytes=item["bytes"],
|
||||
object_name=f"source 对象 {rel}",
|
||||
)
|
||||
if len(data) != item["bytes"] or _hash_bytes(data) != item["sha256"]:
|
||||
raise ArtifactError(f"下载对象 hash 不匹配:{rel}")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user