feat(storage): 闭合 Agent 到 Runtime 的 OSS 游戏链路
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
This commit is contained in:
lili 2026-07-30 05:21:44 -07:00
parent a513ae38fd
commit 1981a6be01
89 changed files with 5541 additions and 330 deletions

View File

@ -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` 也成功,问题全在环境和镜像。起容器前按这四条自查,能省一整轮排障。

View File

@ -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

View File

@ -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; }

View File

@ -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:

View File

@ -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)';

View File

@ -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();
}

View 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 });
}
});

View File

@ -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",

View File

@ -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 |

View File

@ -43,7 +43,7 @@ flowchart TB
| **跨仓 #1 API** | `contracts/api-schemas/*.yaml`(12 个:aigc/ad/biz/community/compliance/feed/passport/project/runtime/studio/telemetry/trade) | WS1 lead | 后端定义 → 前端 mock / 各模块互调 | 已建 |
| **跨仓 #2 DB** | `contracts/db-schemas/V*.sql` | WS1 | 后端各模块 | 已建(镜像已漂移,见下节) |
| **跨仓 #3 SDK** | `contracts/sdk-interface.d.ts` | WS3 | game-studio 宿主 ↔ 游戏侧 postMessage | 已建 |
| **跨仓 #4 GamePackage** | `contracts/game-package.schema.json` + `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 的口径和代码实情对上。

View File

@ -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` 生产切换和正式金标重新绑定均未执行。
---

View File

@ -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}$");
}
}

View File

@ -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 = "";
}

View File

@ -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);
}
}
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}

View File

@ -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)));
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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;
/**

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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, "游戏内容版本身份无效");
}

View File

@ -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();

View File

@ -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(发布编排取当前生效版本以做版本态流转)
*

View File

@ -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 重复落包安全)

View File

@ -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);

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,编译期对接点判定) */

View File

@ -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>

View File

@ -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,

View File

@ -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 归属")

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}
// ============================== 编译任务 ==============================

View File

@ -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 校验失败)。
*/

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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) {
}

View File

@ -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);
}

View File

@ -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 语义)
*

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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} 调用方不变。
*

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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());
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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());
}
}

View File

@ -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

View File

@ -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)';

View File

@ -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 轨接)。
});

View File

@ -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 已逐字节校验后注入的内存素材描述
});
}

View File

@ -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 已逐字节校验后注入的内存素材描述
});
}

View File

@ -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,

View File

@ -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 = [];

View File

@ -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",

View 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);
});

View File

@ -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) */

View File

@ -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"

View File

@ -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,
};

View File

@ -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';

View File

@ -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。

View File

@ -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',

View File

@ -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 后隐藏。

View File

@ -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"

View 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())

View File

@ -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

View File

@ -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(

View File

@ -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:

View 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 == ""

View File

@ -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,
)

View File

@ -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()

View File

@ -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 不匹配")

View 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"],
}

View File

@ -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}")