diff --git a/.agents/skills/staging-ops.md b/.agents/skills/staging-ops.md index cc09f41f..d6ed18aa 100644 --- a/.agents/skills/staging-ops.md +++ b/.agents/skills/staging-ops.md @@ -69,6 +69,21 @@ description: "在 mini-desktop/mini-infra 上做 staging 或内测 dev 的部署 - **feed 流端点语义(2026-07-05 真机坐实,防 R5 式假警报)**:主混合流 `GET /app-api/feed/stream` 只收 `cursor`+`size`、**无 zoneId 参**,恒服务 zone0;带 zoneId 的专区流是另一个端点 `GET /app-api/feed/zone/stream?zoneId=N`。给 `/feed/stream` 传 zoneId 会被静默忽略(曾据此误判「新游戏不在流」)。`size` 有 `@Max`(=20,**超限校验失败返空 `data` 而非报错**——排「流空」先核 size)。发布编排(创始人「汇入主混合流」)对每次发布**双写** zone0 主流基线 + 若创作者选非零专区再补写该专区(`PublishOrchestrationServiceImpl.writePublishBaseline`),故一款游戏可同时现于主流与其专区;新游戏 sort_score=0,排在已回灌的高分游戏之后、未刷分组之首。 - **API 全绿 ≠ UI 通**:编排器旁路会掩盖 UI 缺陷,用户可见波次收口前必须做一次真 UI 走查(见 [`ui-walkthrough-cdp.md`](./ui-walkthrough-cdp.md))。 +### 游戏内容 OSS 单款隔离验收 + +这条配方只验证“Agent 从 OSS 续改并回存、Runtime 从 OSS 加载、浏览器可玩”,不切 live,也不代表存量游戏完成迁移。 + +1. 在 `:48090` 起隔离后端前先加载 `/root/game-staging/infra/.env` 的真实 staging MySQL/Redis 凭据;显式打开 `aigc.game-content-control.enabled=true`、注入凭据档中的 HMAC secret,并设置 `game.artifact-storage.enforce-committed=true`、`read-mode=OSS`。漏加载 env 会以 MySQL 拒绝访问表现,不能误判为业务代码故障。 +2. Agent 用 `tier2/gen-worker/scripts/game_content_repository.py` 调 `prepare` 取得后端分配的版本身份,再从 `game-sources` checkout。LittleJS/Canvas 源码依赖 Git 中的平台引擎与插件,检出目标必须是被 `.gitignore` 排除的 `game-runtime/games/<工作目录>`;测试和构建都在这里运行,具体游戏源码与 `dist` 仍不得加入 Git。 +3. 修改完成后只能走 `commit`:源码归档 → 运行包/素材 → V34 pending → 后端 `finalize` 在同一本地事务中写 Runtime 元数据、CAS V34 committed、绑定版本摘要 → Agent 再幂等确认 committed。`finalize` 不修改 `game_project.current_version_id`;任一步失败都不能切走当前可用版本,重试必须幂等;不得直写 project/runtime 表。 +4. 用 `/gstack` 按新 `versionId` 真开预览页并进入游戏。收口证据至少包括:菜单与玩法截图、canvas 非空像素、游戏状态随时间推进、manifest 与声明素材均走 `/app-api/runtime/package/{versionId}/...`、网络中没有 MinIO 直连。截图和结构化 verdict 上传 `game-evidence`,不进 Git。 +5. 只有浏览器验收通过后才调用 `activate --expected-current-version-id <验收前 current>`。后端在 Project 锁内比较 current:同一版本重试幂等;并发旧快照或向旧版本回切都拒绝。激活失败时 current 必须保持原值。 +6. `gameId`、旧 `versionId` 和调用租户必须一致。历史 tenant=0 探针不能在 tenant=1 下复用;控制面拒绝是正确安全行为,不得靠改库绕过。 +7. 前端构建必须显式指定隔离 API:`VITE_API_BASE=http://100.64.0.7:48090 npm run build -- --mode staging`。iframe 只授予 `allow-scripts`;游戏持久化当前会回落内存适配器,禁止为消除 warning 恢复 `allow-same-origin`。 +8. Runtime reader 只能 `GetObject game-artifacts/tenants/*`。验收时同时验证 List、Put、Delete 和 `game-evidence` GET 被拒绝;不得让 Runtime 复用 writer 或 root 身份。 + +验收完成后删除临时 OAuth token、对应 Redis 精确 key、本地 checkout 和临时脚本。保留 OSS 中的不可变失败版本用于审计,项目当前版本只指向最后一个通过验收并显式激活的版本。 + ## 6. docker compose 部署到 mini-infra(观测栈第一波实战蒸馏,2026-07-05) 观测栈五件(Collector/Prometheus/Tempo/Loki/Grafana)起在 mini-infra 时,从 compose 写好到五件全绿踩了四个坑,没一个是 compose 语法错——`docker compose config` 全过、`pull` 也成功,问题全在环境和镜像。起容器前按这四条自查,能省一整轮排障。 diff --git a/.gitea/workflows/contract-gates.yml b/.gitea/workflows/contract-gates.yml index 7dec5b44..2a8713ed 100644 --- a/.gitea/workflows/contract-gates.yml +++ b/.gitea/workflows/contract-gates.yml @@ -2,8 +2,8 @@ # pre-commit 是自愿门(--no-verify / 新 clone 未配 hooksPath 可绕),本工作流是不可绕的服务端门。 # 与 docs-gate.yml 平级、各管一摊:docs-gate 管文档治理七检,本工作流管四类契约门。 # 生效前提:Gitea 实例已配 act_runner;未配时本文件静默不跑,不影响开发。 -# CI 侧全量真跑(不做 pre-commit 的路径条件触发):判例库四段尽量装齐 esbuild + pytest 后全跑, -# 装不齐时 run.mjs 自带零依赖降级、诚实 SKIP 缺依赖段,不假绿。 +# CI 侧全量真跑(不做 pre-commit 的路径条件触发):判例库四段必须装齐 esbuild + pytest, +# 依赖安装失败或任一整段 SKIP 都判红;本地裸环境仍可由 run.mjs 诚实 SKIP。 name: contract-gates on: push: @@ -19,18 +19,70 @@ jobs: # 其余四门只读工作区、不依赖 git 历史,fetch-depth 变化对它们无影响。 fetch-depth: 0 - # 判例库 python-gates 段需 pytest;check-undef 段需 esbuild(随 node_modules)。 - # 尽力预装让四段全量真跑;装不上则相应段 run.mjs 会诚实 SKIP。 - - name: 预装门依赖(尽力) + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: | + game-runtime/package-lock.json + game-studio/package-lock.json + + # 服务端门必须真跑全部判例;安装失败立即终止,不能降级成绿色 SKIP。 + - name: 安装完整门依赖 run: | - pip3 install --quiet pytest || true - npm ci --prefix game-runtime/tools/amodel-gen || true + pip3 install --quiet pytest jsonschema + npm ci --prefix game-runtime - name: 门金标判例库全量回归 - run: node contracts/gate-fixtures/run.mjs + env: + GATE_FIXTURES_REQUIRE_ALL: '1' + run: | + node --test contracts/gate-fixtures/run.test.mjs + node contracts/gate-fixtures/run.mjs - name: play-loop 契约正负样本套件 - run: python3 contracts/play-loop/validate.py --suite + run: | + python3 contracts/play-loop/validate.py --suite + python3 contracts/play-loop/test_reference_asset_trusted_consumption_v2.py + + - name: 游戏内容对象存储契约正负样本套件 + env: + PYTHONPATH: tier2/gen-worker + run: >- + python3 -m pytest -q + tier2/gen-worker/tests/test_game_artifact_store.py + tier2/gen-worker/tests/test_game_source_archive.py + tier2/gen-worker/tests/test_game_artifact_storage_store.py + tier2/gen-worker/tests/test_game_artifact_storage_ddl.py + tier2/gen-worker/tests/test_game_content_repository.py + + - name: 游戏内容控制面与 Runtime 事务边界 + run: | + mvn -f game-cloud/pom.xml \ + -pl huijing-dependencies,game-module-project/game-module-project-server,game-module-runtime/game-module-runtime-server,game-module-aigc/game-module-aigc-server \ + -am -DskipTests install + mvn -f game-cloud/game-module-project/game-module-project-server/pom.xml \ + -Dtest=GameVersionServiceImplTest \ + -Dsurefire.failIfNoSpecifiedTests=true test + mvn -f game-cloud/game-module-runtime/game-module-runtime-server/pom.xml \ + -Dtest=RuntimePackageApiImplDogfoodTest,AppRuntimeControllerTest,RuntimeConvertTest,ArtifactStorageGateTest,RuntimeArtifactContentServiceTest,RuntimeArtifactStoragePropertiesTest,RuntimeOssPackageFinalizeServiceTest,RuntimePackageServiceImplTest,S3ArtifactObjectReaderTest \ + -Dsurefire.failIfNoSpecifiedTests=true test + mvn -f game-cloud/game-module-aigc/game-module-aigc-server/pom.xml \ + -Dtest=GameContentControlControllerTest,GameContentControlServiceTest,GameContentFinalizeTxServiceTest \ + -Dsurefire.failIfNoSpecifiedTests=true test + + - name: 宿主同源取包与 iframe 安全边界 + run: | + npm test --prefix game-runtime + npm ci --prefix game-studio + npm run test:host-security --prefix game-studio + npm run build --prefix game-studio -- --mode staging - name: 品类 rubric fixture↔skill 双写对账 run: python3 .agents/tools/rubric-sync-gate.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit index fa092fa2..cd9ae61e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -24,6 +24,31 @@ if printf '%s\n' "$STAGED" | grep -qE '^contracts/play-loop/'; then python3 "$ROOT/contracts/play-loop/validate.py" --suite || { echo '✘ play-loop 契约正负样本套件未过'; fail=1; } fi +# ── 门 3b:游戏内容对象存储契约正负样本 —— 触发 = 三份清单/状态迁移/实现或判例变更 ── +if printf '%s\n' "$STAGED" | grep -qE '(contracts/(game-(package|artifact-manifest|source-archive)\.schema\.json|db-schemas/V3[45].*artifact)|game-cloud/huijing-server/src/main/resources/db/migration/V3[45].*artifact|tier2/gen-worker/(worker/game_(artifact|source_archive|artifact_storage)_store\.py|worker/game_content_repository\.py|scripts/game_content_repository\.py|tests/test_game_(artifact|source_archive|artifact_storage|content_repository)))'; then + # mise 的默认 Python 可能未装 pytest;仓内测试 venv 可用时复用,否则明确判红,绝不静默跳过。 + PYTEST_PYTHON=python3 + if ! "$PYTEST_PYTHON" -c 'import pytest' >/dev/null 2>&1; then + if [ -x "$ROOT/cheap-worker/.venv/bin/python" ] \ + && "$ROOT/cheap-worker/.venv/bin/python" -c 'import pytest' >/dev/null 2>&1; then + PYTEST_PYTHON="$ROOT/cheap-worker/.venv/bin/python" + else + echo '✘ 游戏内容对象存储门缺少 pytest(默认 Python 与 cheap-worker/.venv 均不可用)' + PYTEST_PYTHON='' + fail=1 + fi + fi + if [ -n "$PYTEST_PYTHON" ]; then + PYTHONPATH="$ROOT/tier2/gen-worker" "$PYTEST_PYTHON" -m pytest -q \ + "$ROOT/tier2/gen-worker/tests/test_game_artifact_store.py" \ + "$ROOT/tier2/gen-worker/tests/test_game_source_archive.py" \ + "$ROOT/tier2/gen-worker/tests/test_game_artifact_storage_store.py" \ + "$ROOT/tier2/gen-worker/tests/test_game_artifact_storage_ddl.py" \ + "$ROOT/tier2/gen-worker/tests/test_game_content_repository.py" \ + || { echo '✘ 游戏内容对象存储契约正负样本未过'; fail=1; } + fi +fi + # ── 门 4:品类 rubric fixture ↔ skill 双写对账 —— 触发 = 触及品类 rubric fixture 或品类 skill ── if printf '%s\n' "$STAGED" | grep -qE '(cheap-worker/fixtures/genre-rubrics/|\.agents/skills/(trpg|heritage|puzzle|narrative|sim-business)-game-design\.md)'; then python3 "$ROOT/.agents/tools/rubric-sync-gate.py" || { echo '✘ rubric 双写对账未过'; fail=1; } diff --git a/contracts/api-schemas/runtime.yaml b/contracts/api-schemas/runtime.yaml index 719efd60..5dd76e89 100644 --- a/contracts/api-schemas/runtime.yaml +++ b/contracts/api-schemas/runtime.yaml @@ -53,7 +53,8 @@ paths: 【§3.4 C4 关键约束】返回 manifest 的原始 JSON 文本,**不包 CommonResult、不 parse/re-serialize、不加换行**—— 宿主对该响应文本原始字节算 sha256,须与 RuntimePackageRespVO.checksum 严格一致(PackageFactory 写入时按相同字节计算并存 game_runtime_package.checksum), 任何包裹/重序列化都会破坏字节一致性导致校验失败。 - MVP 无 OSS:manifest 整包存 game_runtime_package.package_json,由 PackageStore 的 DB impl 读出原样返回(M3 接 OSS 后换 OSS impl,调用方/契约不变,退场契约)。 + 存储介质由服务端 read-mode=db|shadow|oss 切换;浏览器始终访问本同源端点,不向对象域发送平台 Token。 + oss 模式只返回 V34 committed locator 指向且通过原文/bundle 摘要校验的对象,任何失败不回退 DB。 取包门禁与 GET /app-api/runtime/package/{versionId} 同源:scene=preview 放行 status∈{0,1} 且校验版本 owner;scene=play 仅放行 status=1;无就绪运行包返回 1-102-001-001。 parameters: - { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: 版本 ID(project.game_version.id) } @@ -67,6 +68,26 @@ paths: type: string description: 'GamePackage.manifest 的原始 JSON 字符串(原样字节,宿主据此算 sha256 校验后注入运行容器,对齐 #4 manifest 段)' + /app-api/runtime/package/{versionId}/assets/{sha256}: + get: + tags: [app-runtime] + summary: 按内容摘要读取版本素材 + description: > + scene/status/preview owner 门与取包端点同源。服务端只从 V34 committed artifact manifest 中按 sha256 + 映射精确对象 key,逐字节复算 bytes/hash 后返回;客户端不能提交 bucket、key 或 URL。 + parameters: + - { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: 版本 ID } + - { name: sha256, in: path, required: true, schema: { type: string, pattern: '^[a-f0-9]{64}$' }, description: 'GamePackage.assets[].hash' } + - { name: scene, in: query, required: false, schema: { type: string, enum: [preview, play], default: play }, description: 取包场景 } + responses: + '200': + description: > + 素材原始字节;Content-Type/Length/ETag 均来自已验清单和真实对象。 + preview 返回 Cache-Control: private,no-store;play 的 version+sha256 内容寻址素材返回 public,max-age=31536000,immutable。 + content: + application/octet-stream: + schema: { type: string, format: binary } + /app-api/runtime/session/start: post: tags: [app-runtime] @@ -236,15 +257,16 @@ components: type: object description: >- 版本运行包清单。字段语义对齐 GamePackage(#4).manifest,宿主据此渲染 iframe 并桥接 SDK(#3)。 - 【GAP-2 取包约束】① 宿主先 fetch manifestUrl 取 manifest,按 checksum 做 sha256 完整性校验通过后再注入运行容器(T-RT-15),校验失败拒绝加载并落 error; - ② OSS/CDN 须放行宿主 origin 的 CORS(AllowedOrigin=宿主 origin、AllowedMethod=GET/HEAD、ExposeHeader=ETag),否则宿主跨域 fetch 被拦; + 【取包约束】① 宿主只从受信 API origin 的同源 manifestUrl 读取 manifest,按 checksum 做 sha256 完整性校验通过后再注入运行容器(T-RT-15); + ② DB/shadow/oss 的对象读取由 Runtime 服务端完成,浏览器不直连对象域、不向对象域发送平台 Token; ③ scene=preview|play 鉴权与取包门禁须一致:preview 仅版本 owner 可取未发布包、play 仅放行已发布包(与 getPackageManifest 门禁同源,不在前端兜底)。 properties: gameId: { type: integer, format: int64, description: 游戏 ID } versionId: { type: integer, format: int64, description: 版本 ID } templateId: { type: string, description: 玩法模板 ID(宿主据此选 Runtime 容器) } - packageUrl: { type: string, description: 'GamePackage(manifest)OSS/CDN URL,按 /games/{gameId}/versions/{versionId}/ 版本化(#4)' } - manifestUrl: { type: string, description: '§3.4 C4:指向 GET /app-api/runtime/package/{versionId}/manifest 端点(MVP 包存 DB,端点原样服务 manifest JSON)。宿主先 fetch 此端点取 manifest,对响应原始文本算 sha256 与 checksum 严格比对通过后再注入运行容器(相对 URL 须 resolve 到 API base,见 §2.5);M3 接 OSS 后改指向 OSS/CDN 版本化 URL(退场契约)' } + packageUrl: { type: string, description: '历史兼容字段;浏览器不得据此直连对象域,现行取包统一使用 manifestUrl' } + manifestUrl: { type: string, description: '固定指向 GET /app-api/runtime/package/{versionId}/manifest 同源端点;Runtime 服务端按 db/shadow/oss 模式读取原文,宿主校验响应原始 sha256 后注入运行容器' } + assetUrlTemplate: { type: string, description: 'OSS 主读模式下返回的对象素材同源代理模板;宿主把 {sha256} 替换为 GamePackage.assets[].hash 后带平台鉴权读取。DB/shadow 不返回' } entry: { type: string, description: 入口文件相对路径(#4 manifest.entry) } runtimeVersion: { type: string, description: '目标 WanxiangGameSDK / Canvas Runtime 版本(semver,#4 manifest.runtimeVersion)' } preloadPolicy: { type: string, enum: [eager, lazy], description: '预加载策略(#4 manifest.preloadPolicy)' } @@ -255,12 +277,11 @@ components: SandboxPolicyVO: type: object description: >- - iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14)。 - 【GAP-2 CORS 约束】allowOrigins 同时是 OSS/CDN 取包(manifestUrl/packageUrl)须放行的跨域来源: - OSS 桶 CORS 规则 AllowedOrigin 应与本白名单一致(含宿主 origin),AllowedMethod=GET/HEAD、ExposeHeader=ETag,否则宿主跨域 fetch manifest 被拦。 + iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 postMessage origin 白名单(T-RT-14)。 + manifest 与对象素材均由受信 Runtime API 同源代理,本字段不承担对象存储 CORS 配置。 properties: - sandboxAttr: { type: string, description: "iframe sandbox 属性值(如 'allow-scripts allow-same-origin')" } - allowOrigins: { type: array, items: { type: string }, description: 'postMessage 允许的 origin 白名单(宿主侧 #3 双校验);亦为 OSS/CDN 取包 CORS 须放行的宿主 origin' } + sandboxAttr: { type: string, description: "iframe sandbox 属性值(现行固定为 'allow-scripts')" } + allowOrigins: { type: array, items: { type: string }, description: 'postMessage 允许的 origin 白名单(宿主侧来源窗口/origin/schema 三校验)' } # ---- 试玩会话开/收(脊柱:play_start/end/时长 → 回灌 telemetry #5)---- SessionStartReqVO: diff --git a/contracts/db-schemas/V35.0.0__clarify_game_artifact_manifest_hash.sql b/contracts/db-schemas/V35.0.0__clarify_game_artifact_manifest_hash.sql new file mode 100644 index 00000000..6394d588 --- /dev/null +++ b/contracts/db-schemas/V35.0.0__clarify_game_artifact_manifest_hash.sql @@ -0,0 +1,12 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:runtime/storage(对象清单摘要语义)| owner:WS3 + WS2 +-- 文件:V35.0.0__clarify_game_artifact_manifest_hash.sql +-- 目的:澄清 V34 两个契约清单摘要字段保存的是契约 manifestHash,而非清单原始字节摘要。 +-- 边界:V34 已执行且保持字节不可变;本迁移只修改列注释,不改变数据和索引。 +-- ============================================================================= + +ALTER TABLE `game_artifact_storage` + MODIFY COLUMN `artifact_manifest_hash` CHAR(64) NOT NULL DEFAULT '' + COMMENT 'GameArtifactManifest/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)', + MODIFY COLUMN `source_manifest_hash` CHAR(64) NOT NULL DEFAULT '' + COMMENT 'GameSourceArchive/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)'; diff --git a/contracts/gate-fixtures/run.mjs b/contracts/gate-fixtures/run.mjs index 2fd7d12d..ad92f014 100644 --- a/contracts/gate-fixtures/run.mjs +++ b/contracts/gate-fixtures/run.mjs @@ -20,13 +20,15 @@ import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { delimiter, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); // gate-fixtures → contracts → 仓根 const REPO_ROOT = resolve(__dirname, '../..'); const NODE = process.execPath; +// 服务端 CI 要求四段全部真跑;本地裸环境未装可选依赖时仍保留诚实 SKIP。 +const REQUIRE_ALL = process.env.GATE_FIXTURES_REQUIRE_ALL === '1'; /** pytest 是否可用(缺则 python-gates 整段 skip,保持零新依赖)。 */ function pytestAvailable() { @@ -70,14 +72,39 @@ function runNodeStage(stage) { return { ...stage, status, ...c, note, raw: out }; } -/** 跑 python-gates(pytest);缺 pytest 则整段 SKIP。 */ -function runPythonStage(stage) { - if (!pytestAvailable()) { +/** + * 跑 python-gates(pytest);strict 模式下固定判例缺少任意一个都立即判红。 + * 可注入根目录与 pytest 探针,供入口自身用真实临时文件验证缺失判例分支。 + */ +export function runPythonStage(stage, options = {}) { + const repoRoot = options.repoRoot ?? REPO_ROOT; + const requireAll = options.requireAll ?? REQUIRE_ALL; + const hasPytest = options.hasPytest ?? pytestAvailable; + if (!hasPytest()) { return { ...stage, status: 'SKIP', pass: 0, fail: 0, skip: 0, note: '未装 pytest → 整段跳过(零新依赖口径);装后本段覆盖 tier2/cheap 的门' }; } - const files = stage.files.filter((f) => existsSync(resolve(REPO_ROOT, f))); + const missing = stage.files.filter((f) => !existsSync(resolve(repoRoot, f))); + if (requireAll && missing.length > 0) { + return { + ...stage, + status: 'MISSING', + pass: 0, + fail: 0, + skip: 0, + note: `pytest 判例文件缺失:${missing.join(',')}`, + }; + } + const files = stage.files.filter((f) => existsSync(resolve(repoRoot, f))); if (files.length === 0) return { ...stage, status: 'MISSING', pass: 0, fail: 0, skip: 0, note: 'pytest 判例文件全缺失' }; - const r = spawnSync('python3', ['-m', 'pytest', '-q', ...files], { cwd: REPO_ROOT, encoding: 'utf8' }); + // tier2 测试按服务部署布局导入顶层 worker 包;统一门必须复刻该模块根,不能依赖调用者碰巧设置 PYTHONPATH。 + const pythonPath = [resolve(repoRoot, 'tier2/gen-worker'), process.env.PYTHONPATH] + .filter(Boolean) + .join(delimiter); + const r = spawnSync('python3', ['-m', 'pytest', '-q', ...files], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, PYTHONPATH: pythonPath }, + }); const out = `${r.stdout || ''}${r.stderr || ''}`; const c = parsePytest(out); let status; @@ -140,6 +167,7 @@ function main() { if (r.status === 'SKIP' && (r.pass + r.fail === 0)) { console.log(` ${r.note || '整段跳过'}`); skipStages += 1; + if (REQUIRE_ALL) hardFail += 1; } else if (r.status === 'MISSING' || r.status === 'ERROR') { console.log(` ${r.note || '判例执行异常'}`); hardFail += 1; @@ -152,7 +180,7 @@ function main() { console.log(`\n${'='.repeat(72)}`); const green = hardFail === 0 && totFail === 0; - console.log(`总计:${totPass} 过 / ${totFail} 挂 / ${totSkip} 跳;${skipStages} 段整段跳过 → ${green ? '绿(全量通过)' : '红(有判据回归)'}`); + console.log(`总计:${totPass} 过 / ${totFail} 挂 / ${totSkip} 跳;${skipStages} 段整段跳过 → ${green ? '绿(全量通过)' : '红(有判据回归或必跑段跳过)'}`); if (!green) { console.log('失败明细见上;门判据改动前必须先让本回归全绿。'); for (const r of results) if (r.status === 'FAIL' || r.status === 'ERROR' || r.status === 'MISSING') { @@ -162,4 +190,7 @@ function main() { process.exit(green ? 0 : 1); } -main(); +// 被 node:test 导入时只暴露纯入口;直接执行仍保持原 CLI 行为与退出码。 +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/contracts/gate-fixtures/run.test.mjs b/contracts/gate-fixtures/run.test.mjs new file mode 100644 index 00000000..0b000aed --- /dev/null +++ b/contracts/gate-fixtures/run.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { runPythonStage } from './run.mjs'; + +test('strict 模式下固定 Python 判例缺少任意一个都判为 MISSING', () => { + const root = mkdtempSync(join(tmpdir(), 'gate-fixtures-missing-')); + try { + writeFileSync(join(root, 'present.py'), 'def test_present():\n assert True\n'); + const result = runPythonStage( + { name: 'python-gates', files: ['present.py', 'missing.py'] }, + { repoRoot: root, requireAll: true, hasPytest: () => true }, + ); + + assert.equal(result.status, 'MISSING'); + assert.match(result.note, /missing\.py/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py b/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py index 335deb7d..8cd7a5e0 100644 --- a/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py +++ b/contracts/play-loop/test_reference_asset_trusted_consumption_v2.py @@ -294,6 +294,19 @@ def test_task2_manifest_excludes_gitignored_candidates_in_worktree() -> None: if initialized.returncode != 0: return + # 这组 /2 判例复验的是迁移前已经入库的历史金标。仓根现已默认忽略所有具体游戏, + # 因此临时 worktree 必须显式复刻“批准范围已被 Git 跟踪”,新增候选仍保持未跟踪并受 ignore 约束。 + tracked = subprocess.run( + ["git", "add", "-f", "--", *source_paths], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + check("临时 worktree 复刻历史金标已跟踪状态", tracked.returncode == 0, tracked.stderr[-1000:]) + if tracked.returncode != 0: + return + ignored_candidates = ( "game-runtime/games/shanhai-xingji/src/debug.js", "game-runtime/games/shanhai-xingji/src/cache.js", diff --git a/docs/architecture/后端/数据模型.md b/docs/architecture/后端/数据模型.md index a900221f..87281d99 100644 --- a/docs/architecture/后端/数据模型.md +++ b/docs/architecture/后端/数据模型.md @@ -10,7 +10,7 @@ date: 2026-06-22 > **给谁看**:要动表结构或写跨模块查询的后端工程师、做数据侧 code review 的人、想搞清"一款游戏从一句话到能赚钱,数据在库里怎么流转"的人。 > **怎么读**:第 1 章一张跨域主干图建立全局心智;第 2 章按业务链路下钻看每条链的实体关系;第 3 章是按表速查的索引,从这里跳进具体迁移;第 4、5 章是两处最容易误解的真相 —— 软关联和数据语义。 -地面实情以 Flyway 迁移为准。执行权威目录是 `game-cloud/huijing-server/src/main/resources/db/migration/`,共 25 个 `V1` 到 `V25` 的 `.sql`。`contracts/db-schemas/` 是跨模块迁移的授权源副本,但只含 18 个(`V1`-`V13` + `V21`-`V25`):单模块的 additive `ALTER`(`V14`-`V20`)不进 contracts,只在执行目录。只读 contracts 会漏掉 `game_aigc_task` 的 `level/trace/source/modify` 系列列、`game_source_project` 的并发幂等唯一键、以及 `game_feed_rank` 的 `exposure_limit`。每个 `V*.sql` 的头注写满了权属决策、幂等语义、seam 边界和状态机流转,是表级细节的权威源;本页只锚点引用,不抄全文。 +地面实情以 Flyway 执行迁移为准。执行权威目录是 `game-cloud/huijing-server/src/main/resources/db/migration/`,共 35 个 `V1` 到 `V35` 的 `.sql`。`contracts/db-schemas/` 是跨模块镜像,现有 24 个文件,与执行目录仍未全量一致:它缺 `V14`-`V20`、`V26`-`V30`,并多一份未进入执行目录的 `V31.0.1` 回退稿。只读 contracts 会漏掉 `game_aigc_task` 的 `level/trace/source/modify/idempotency` 系列列、`game_source_project` 的并发幂等唯一键和配置治理表。每个执行迁移的头注写明权属、幂等和状态流转,是表级细节的权威源;本页只锚点引用。 迁移覆盖的是 11 个 `game-module-*` 业务模块加 passport 身份层,约 40 张表。蓝图里另列的 `pay`(支付)和 `ip`(素材授权)两个模块当前没有独立的 `game_pay_*` / `game_ip_*` 迁移落地:赋余额走 trade 的 `game_trade_grant`,素材登记走 studio 的 `game_material`,真实支付与版权授权是 M4 之后的事。 @@ -26,6 +26,7 @@ erDiagram game_project ||--o{ game_version : "1:N 版本" game_project ||--|| game_version : "current_version_id 指针" game_version ||--|| game_runtime_package : "一版本一包" + game_version ||--o| game_artifact_storage : "OSS 对象身份与提交态" game_aigc_task ||--o| game_version : "生成产物回填" game_source_project }o--|| game_version : "源↔产物解耦" game_project ||--o{ game_feed_rank : "status=4 入流" @@ -82,7 +83,11 @@ erDiagram ### 2.2 编译发布与合规闸门(runtime + compliance) -aigc 产物交给 runtime 编译。`game_runtime_build` 是编译任务,`status` 走 0 排队 / 1 编译中 / 2 成功 / 3 失败,成功时把 `package_url` / `bundle_size` / `checksum` 回写。编译产出一份对外清单 `game_runtime_package`,一个版本一行(`uk_version`),试玩宿主据它加载游戏:`entry` / `runtime_version` / `preload_policy` / `sandbox_attr` / `allow_origins` 描述 iframe 沙箱;`status` 走 0 预览就绪 / 1 已发布 / 2 已失效,是取包门禁的权威字段,和 `game_version.status` 不是一回事。MVP 没有 OSS,`V10` 给它加的 `package_json LONGTEXT` 让整包内嵌进 DB。 +aigc 产物交给 runtime 编译。`game_runtime_build` 是编译任务,`status` 走 0 排队 / 1 编译中 / 2 成功 / 3 失败,成功时把 `package_url` / `bundle_size` / `checksum` 回写。编译产出一份对外清单 `game_runtime_package`,一个版本一行(`uk_version`),试玩宿主据它加载游戏:`entry` / `runtime_version` / `preload_policy` / `sandbox_attr` / `allow_origins` 描述 iframe 沙箱;`status` 走 0 预览就绪 / 1 已发布 / 2 已失效,是取包门禁的权威字段,和 `game_version.status` 不是一回事。 + +`V10` 增加的 `package_json LONGTEXT` 只服务旧 DB 读取模式。OSS 版本不保存游戏包正文,`package_json` 为 NULL;`game_runtime_package` 只留宿主所需元数据与原始 manifest hash。`V34` 的 `game_artifact_storage` 保存 artifact/source manifest 的 bucket、canonical key、hash、对象总量以及 pending/committed 状态,Runtime 只消费 committed 行;`V35` 以追加迁移澄清 `artifact_manifest_hash` 的语义,没有修改已应用 V34。 + +OSS 内容版本有两道独立门。`finalize` 只把 Runtime 元数据、V34 committed 和 `game_version` 摘要放进同一个本地事务,不写 `game_project.current_version_id`;浏览器按精确版本验收后,`activate` 才锁定 Project,比较 `expectedCurrentVersionId` 并更新当前指针。同一版本重试幂等,过期快照和向旧版本回切都拒绝。2026-07-30 的隔离 staging 实证将 `gameId=80035` 从 `versionId=93159` 显式激活到 `versionId=93160`,失败的旧版回切后当前指针仍为 `93160`。默认生产读取模式与存量游戏尚未迁移。 试玩会话 `game_runtime_session` 既是试玩入口,也是遥测回灌的源头。`client_play_token` 做幂等(`uk_play_token`),`scene` 区分 preview / play;它的 `sessionId` 透传进遥测做 `session_id` 对账键,但试玩本身不计 `play_count`。`V11` 把 `player_user_id` 放宽成可空、加了 `anon_id`,匿名玩家也能试玩。 @@ -94,6 +99,7 @@ erDiagram game_runtime_build }o--|| game_version : "version_id" game_runtime_build ||--|| game_runtime_package : "成功产清单" game_runtime_package ||--|| game_version : "uk_version 一版一行" + game_artifact_storage ||--|| game_version : "uk_version 对象提交态" game_runtime_session }o--|| game_version : "试玩 version_id" game_runtime_package ..> game_version : "回写 package_url/checksum" game_compliance_gate_result }o--|| game_project : "publish 落门" @@ -182,7 +188,8 @@ erDiagram | game_project_zone | project | 游戏↔专区 M:N | uk_game_zone(game_id,zone_id) | V1 L106 | | game_aigc_task | aigc | 无状态生成原子 / 异步队列 | id(=taskId);prompt_hash 缓存键 | V2 L23;V15/16/17/19 ALTER | | game_runtime_build | runtime | 编译任务 | game_id / version_id | V3 L23 | -| game_runtime_package | runtime | 对外清单,试玩宿主据此加载 | uk_version(一版一行) | V3 L56;V10 ALTER | +| game_runtime_package | runtime | 宿主元数据;旧 DB 模式可内嵌正文,OSS 模式正文为 NULL | uk_version(一版一行) | V3 L56;V10 ALTER | +| game_artifact_storage | runtime | OSS artifact/source 定位、摘要与 pending/committed 状态 | uk_version(一版一行) | V34;V35 语义澄清 | | game_runtime_session | runtime | 试玩会话,遥测回灌源 | uk_play_token | V3 L90;V11 ALTER | | game_feed_rank | feed | 排序缓存 / 精选覆盖层 | uk_game_zone(game_id,zone_id) | V4 L19;V25 ALTER | | game_feed_interact_log | feed | 互动信号幂等流水 | uk_user_game_action | V4 L47 | diff --git a/docs/architecture/架构/契约总览.md b/docs/architecture/架构/契约总览.md index e6f744f1..d29acc40 100644 --- a/docs/architecture/架构/契约总览.md +++ b/docs/architecture/架构/契约总览.md @@ -43,7 +43,7 @@ flowchart TB | **跨仓 #1 API** | `contracts/api-schemas/*.yaml`(12 个:aigc/ad/biz/community/compliance/feed/passport/project/runtime/studio/telemetry/trade) | WS1 lead | 后端定义 → 前端 mock / 各模块互调 | 已建 | | **跨仓 #2 DB** | `contracts/db-schemas/V*.sql` | WS1 | 后端各模块 | 已建(镜像已漂移,见下节) | | **跨仓 #3 SDK** | `contracts/sdk-interface.d.ts` | WS3 | game-studio 宿主 ↔ 游戏侧 postMessage | 已建 | -| **跨仓 #4 GamePackage** | `contracts/game-package.schema.json` + `contracts/game-artifact-manifest.schema.json` + `contracts/game-source-archive.schema.json` | WS3 + WS2 | 生成 → 编译 → 源归档/对象存储 → 预览 → 发布 → 运行全链路 | GamePackage 已建;制品索引和引擎无关源归档契约已建、生产接线分波迁移 | +| **跨仓 #4 GamePackage** | `contracts/game-package.schema.json` + `contracts/game-artifact-manifest.schema.json` + `contracts/game-source-archive.schema.json` | WS3 + WS2 | 生成 → 编译 → 源归档/对象存储 → 预览 → 发布 → 运行全链路 | 单款隔离链路已跑通;批量迁移与生产切换未完成 | | **跨仓 #5 events** | `contracts/events.schema.json` | WS5 | 前端埋点 / SDK 上报 → 看板 | 已建 | | **跨仓 #6 Dify I/O** | `contracts/dify-workflow-io.json` | WS2 | ~~aigc 壳 ↔ Dify~~ | **废**(降远期 / 从未部署;现行=new-api,见 `DEPRECATED-dify-workflow-io.md`) | | **跨仓 #7 ad-slot** | `contracts/ad-slot.schema.json` | WS5 | ad 模块 / SDK Plugin.Ad | 已建 | @@ -58,6 +58,10 @@ flowchart TB #4 内部有三份用途互斥的 JSON。`game-package.schema.json` 是宿主真正解析的运行清单,版本前缀下文件名仍为 `manifest.json`;`game-artifact-manifest.schema.json` 是平台上传、下载和逐文件校验使用的运行包/素材/证据对象索引,文件名固定为 `artifact-manifest.json`;`game-source-archive.schema.json` 是引擎无关源码归档索引,文件名固定为 `source-manifest.json`,存放在独立 `game-sources` 桶。对象索引不能作为 `manifestUrl` 返回给宿主,不能携带 candidate/published/retired 状态,也不保存签名 URL。生产版本身份来自 project 数据库,Tier2 源工程继续绑定现有 SourceProjectStore;LittleJS/Canvas 等游戏通过 `game_source_archive` provider 绑定引擎无关源归档。对象存储提交记录由 V34 `game_artifact_storage` 保存,不能把 MinIO marker 当作 committed。 +2026-07-30 的隔离 staging 实证已经把单款链路闭合。Agent 先向 game-cloud 申请版本身份,把 `game-sources` 源码物化到 Git 忽略的游戏工作目录,并使用 Git 中的平台引擎与插件重新测试、构建;`commit/finalize` 上传源码、运行包和素材,在一个本地事务中写 Runtime 元数据、V34 committed 与版本摘要,但不改项目当前版本。浏览器按新 `versionId` 真玩通过后,独立 `activate` 才在 Project 锁内按 `expectedCurrentVersionId` 切换 `current_version_id`;重复激活幂等,并发旧快照和旧版本回切都拒绝。 + +最终激活的是 `gameId=80035/versionId=93160`:`artifactManifestHash=81664710da3c3ed84ca7fbda5c989a2b469df66080c573a1cee00c1b6ae2226c`、`bundleHash=1592c8d10cff2823a1164a9cb3f077b2a0fa8b147b2dd80357ed210853879653`、`runtimeManifestHash=dd0678e160eaf12999e6c9cefb796e5cf2815a8a4c37c982afb6c1e1097dac1e`、`sourceManifestHash=9186f0c3f4e47c6a3682dddfbe03e25367bdd688885199f418423aa048ad5d67`。数据库只保存版本、hash、对象定位和 committed 状态,`game_runtime_package.package_json` 保持空;Runtime 按同源 `/app-api/runtime/package/{versionId}/manifest|assets/**` 从 OSS 代理内容,浏览器不接触 MinIO 凭据或直链。这证明了单款路径,不代表存量游戏已经迁移,也不代表生产 Runtime 已切换 OSS。 + `agent-loop/` 和 `templates/` 这两个目录在 `contracts/README.md` 的目录结构图里没有 —— 那张图只画了跨仓 8 类。新人 `ls` 到它们时无从对应,这是 README 自身的覆盖缺口,在此补登:`agent-loop/` = 生成线的源项目 keystone 加 QA 闭环工件契约族,`templates/` = 13 个品类/形态 schema。 ## "第几类"口径收口 @@ -95,16 +99,17 @@ flowchart LR ## 防漂移门:声明的 vs 代码里的 -`contracts/` 的纪律靠几道"防漂移门"撑着,文档把它们写成 CI 会自动拦截。核到代码里,这些门目前都不存在 —— 它们是文档承诺,不是运行中的机器门。 +`contracts/` 的防漂移门只覆盖已经落成的具体契约,不得把局部机器门外推成全仓完备。游戏内容对象存储契约已有 pre-commit 条件门和 Gitea Actions 全量门;DB 全量镜像、Flyway validate 等旧承诺仍未兑现。 | 防漂移承诺 | 文档出处 | 代码实情 | |---|---|---| +| GamePackage / artifact / source / V34-V35 与生产消费实现 | `.githooks/pre-commit`、`.gitea/workflows/contract-gates.yml` | **已落地**:pre-commit 对相关变更触发 5 组 Python 对象契约测试;CI 对每次 push / PR 另跑 Project/Runtime/AIGC 事务测试、宿主安全测试与前端构建 | | `contracts/db-schemas` ↔ 执行副本 Flyway diff 一致 | [README.md:41](../../../contracts/README.md) | **已漂移**:18 vs 25,加 Huijing/Yudao 审计列注释差 | | CI 跑 `flyway validate` 阻断不合规 | [README.md:62](../../../contracts/README.md) | 全仓唯一 CI = `game-cloud/.github/workflows/maven.yml`,是 yudao fork 继承件:`on push to master`(本仓分支 dev/2.0.0、默认 dev/1.0.0,**永不触发**)、`-Dmaven.test.skip=true`(跳测)、**无 flyway validate、无 contracts↔migration diff 步** | | prompt 变更过"四道闸(CI)" | [registry.yaml:3](../../../contracts/prompts/registry.yaml) | 只是 YAML 注释,未见对应 workflow | | 改契约先改 contracts 再写码 | README.md:4 / [contract-first-development.md](../../../.agents/skills/contract-first-development.md) | 纯口头纪律,无脚本校验 | -`deploy/smoke-test.sh` 里 grep `flyway`/`contracts`/`diff`/`cmp` 零命中,确认这套门在部署 smoke 阶段也不存在。同步当成纯人工纪律在跑,DB 镜像已经因此漂掉。 +`deploy/smoke-test.sh` 里仍没有 `flyway`/`contracts`/`diff`/`cmp` 门;对象存储契约由 pre-commit 与 Gitea Actions 守护,DB 全量镜像同步仍靠人工,已有漂移。 这是一个待决策位,不在本档自裁:要么补一道真门 —— 在 wave-close 或 pre-commit 加一步 `contracts/db-schemas` ↔ migration 的 `cmp`,漂移即红线拦截;要么干脆裁定执行副本为唯一源、把 `contracts/db-schemas` 降为只读快照或删掉,消除"声称授权源却滞后"的矛盾。无论哪条,先得让 README §41 的口径和代码实情对上。 diff --git a/docs/内网凭据与端点.md b/docs/内网凭据与端点.md index 18a36ba7..01f85325 100644 --- a/docs/内网凭据与端点.md +++ b/docs/内网凭据与端点.md @@ -56,7 +56,7 @@ canonical: true | 服务 | 端点(Tailscale) | 凭据 | 用途 | |---|---|---|---| | **Redis** | `100.64.0.8:6379` | `requirepass` = `9ea28f5d28d68b09bfd7ccfc31216a52` | tier2 Agent Service MessageBus / session 状态(create_app) | -| **MySQL** | `100.64.0.8:3306` | root / `ZRH3jwYLOrntBcTAw29MW9BP` | tier2 落库源工程版本表(manifest);game-cloud 业务库 | +| **MySQL** | `100.64.0.8:3306` | root / `ZRH3jwYLOrntBcTAw29MW9BP` | tier2 落库源工程版本表(manifest);不是 game-cloud 业务库 | | **MinIO**(S3 兼容 OSS) | `100.64.0.8:9000`(控制台 9001) | `ragflow` / `6c4b77b2f055c8a66e00e3c38ef3d818c166951d478cc7cc` | tier2 落库源文件全文(key=`tier2-src///<相对路径>`) | | **PostgreSQL** | `100.64.0.8:5433`(外)→5432(内) | root / `f6710e2d0294eb1c10e26a805a64bc54` | new-api 库(users/tokens/quota,WU2 预置池)/ ragflow;超级用户 = **root** 非 postgres | @@ -76,6 +76,17 @@ canonical: true | 验收证据桶 | `game-evidence` | | 允许资源 | `game-artifacts/tenants/*`、`game-evidence/tenants/*` | +Runtime 不复用 writer。`game-artifact-runtime-reader` 只允许读取已提交制品桶的 `tenants/*` 对象,不能列举、上传、删除,也不能读取 `game-evidence`;Spring staging 配置默认仍为 `read-mode=DB`,隔离验收实例才显式覆盖为 `OSS`。 + +| 项 | 值 | +|---|---| +| access key | `game-artifact-runtime-reader` | +| secret key | `b863aa88d40c4a07c4f7ffc44d1b51e016b0014ec570b8010fd6d9315d0ef24c` | +| 策略 | `game-artifact-runtime-reader-policy-v1` | +| 允许资源 | `game-artifacts/tenants/*` 的 `GetObject` | + +Agent 不直写 project/runtime 表。隔离 staging 受信任控制面为 `http://100.64.0.7:48090/admin-api/aigc/game-content/{prepare,finalize,activate}`,HMAC secret 为 `acfe0d5af3da62470f0cf2af4820f1e3fedbe76c81dd905ca847cf2480bf97b9`。主配置默认 `enabled=false` 且空密钥拒绝启动该能力;只有隔离验收实例显式打开。`prepare` 在 project 边界分配并锁定版本身份;Agent 上传时对每个对象做 GET 回读和 hash 对账;`finalize` 校验对象定位、原始 GamePackage、bundle 字节数与 hash,在同一个本地事务内写 Runtime 元数据、V34 CAS committed 和版本摘要,不修改项目当前指针;浏览器验收通过后,`activate` 才按 `expectedCurrentVersionId` 锁定并切换 `current_version_id`。Runtime 消费每个素材时仍会逐字节复算,不把上传回执当成读取信任。 + ### 引擎无关源归档专用身份(2026-07-29) | 项 | 值 | @@ -88,11 +99,15 @@ canonical: true 2026-07-29 权限实测:artifact writer 的两个允许前缀 Put/Get、前缀内 List 和完整 GET/物化流程通过;根列桶只返回该身份可见的制品/证据桶,列/写 `tier2-src`、删除已写对象均被拒绝。当天创建 `game-sources` 与独立 source writer 后,source writer 在 `tenants/*` 下 Put/Get/List 成功,删除、越前缀写入和访问 `game-artifacts` 均被拒绝;root 只用于清理临时权限探针。运行器分别读取 `tier2/config/infra.yaml` 的 `game_artifact_minio` 与 `game_source_minio` 分区,任何一个分区缺失都必须拒绝联网,禁止回落到 `minio` root 或另一身份。 +2026-07-30 新增 Runtime reader 权限实测:已提交制品对象 GET 成功;List、Put、Delete 和 `game-evidence` GET 均返回拒绝。Agent 的 staging committed 权威固定为 mini-desktop 本机 `127.0.0.1:13306/ruoyi-vue-pro`,运行 CLI 时显式选择 `game_content_staging_mysql`;不得改连 mini-infra 的空 `game_cloud`。 + 两类身份都只代表对象上传/回读权限,不单独代表数据库 committed。source CLI 首次上传回执为 `verified_pending`;本轮 staging 随后完成 V34 数据库条件创建/完整不可变字段 CAS,形成 `committed=true` 回执。 -同日生产身份盘点纠偏:mini-infra 的 `game_cloud` 空库没有接入现行 game-cloud,不是游戏身份权威。内网 staging 的真实消费库是 mini-desktop `game-staging-mysql/ruoyi-vue-pro`;2026-07-29 在隔离服务进程中应用 V34 后,库中共有 34 条成功 Flyway 历史、110 个项目、53 个版本和 54 个运行包,并通过正式 App API 创建了对象存储验收项目 `gameId=80034`、版本 `versionId=93156`。V34 存储记录已按 artifact/source manifest、bucket、provider、版本和 hash 完整 CAS 为 `status=committed/committed=true`;该 staging 身份只用于本轮隔离验收,不能当作生产发布或正式金标身份,也禁止在空 `game_cloud` 建无人消费的临时记录。 +同日生产身份盘点纠偏:mini-infra 的 `game_cloud` 空库没有接入现行 game-cloud,不是游戏身份权威。内网 staging 的真实消费库是 mini-desktop `game-staging-mysql/ruoyi-vue-pro`;2026-07-29 的 `gameId=80034/versionId=93156` 是迁移探针,因旧数据租户身份不一致,被新控制面正确拒绝继续复用。2026-07-30 新建的隔离验收项目 `gameId=80035` 有三个 committed OSS 版本 `93158`、`93159`、`93160`,当前指针已经浏览器验收和显式激活切到 `versionId=93160`。该 staging 身份只用于本轮隔离验收,不能当作生产发布或正式金标身份,也禁止在空 `game_cloud` 建无人消费的临时记录。 -运行时对象提交门已接入 game-cloud 的取包、原始 manifest 和发布入口,配置键为 `game.artifact-storage.enforce-committed`,默认 `false` 仅用于生产切换前的兼容窗口。此前隔离 Runtime 打开 `true` 时,真实 `GET /app-api/runtime/package/93156?scene=preview` 对 pending 记录返回 `1102001001`,日志记录 `[artifactStorageGate] 对象记录未 committed,拒绝运行时消费`;同一版本的 artifact manifest 已由专用 writer 全量 GET 回读并物化 263 个对象,`runtimeManifestHash=1191b84776703ce009d361c920da0c3dbb73f328f1383e25580915b10937e4ee`,staging generic 适配 bundle 的 `bundleHash=ef2fff2764227b773e4f4dc092941ddbef7f7062fae2dae80160992de637066d`,下载后逐字节一致。提交后的同一进程因 staging Spring 循环依赖未能启动,未宣称 HTTP 放行已复测;正式金标继续使用已签认 bundle `17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd`,未切换 OSS `PackageStore`。 +运行时对象提交门已接入 game-cloud 的取包、原始 manifest 和发布入口,配置键为 `game.artifact-storage.enforce-committed`,默认 `false` 仅用于生产切换前的兼容窗口。隔离 Runtime 在 `:48090` 同时打开 `enforce-committed=true` 与 `read-mode=OSS`,`versionId=93160` 的 `manifest` 与 6 个声明素材均经同源 Runtime 路径返回 200,浏览器没有请求 `100.64.0.8:9000`。这一版 `artifactManifestHash=81664710da3c3ed84ca7fbda5c989a2b469df66080c573a1cee00c1b6ae2226c`、`bundleHash=1592c8d10cff2823a1164a9cb3f077b2a0fa8b147b2dd80357ed210853879653`、`runtimeManifestHash=dd0678e160eaf12999e6c9cefb796e5cf2815a8a4c37c982afb6c1e1097dac1e`、`sourceManifestHash=9186f0c3f4e47c6a3682dddfbe03e25367bdd688885199f418423aa048ad5d67`、`sourceHash=18451759d2272a1f2e17ec6969309f7e3b6d57c4b22aa8ed54d88cc423d27775`。`finalize` 后当前版本仍为 `93159`;浏览器验收后以 `expectedCurrentVersionId=93159` 激活 `93160`,同请求重试幂等,尝试回切 `93158` 被拒绝且指针留在 `93160`。 + +R4 浏览器证据位于 `game-evidence/tenants/1/games/80035/versions/93160/evidence/browser/`。`post-review-menu.png` 和 `post-review-gameplay.png` 的 SHA-256 分别为 `b4a01427cba73105c5287f5af1586281478a1e7f75efbd5fe91721faa85c0146` 与 `ff442ebcd2e3697493be4e50a188c9ef7102664b1702aa1ef614701064197502`;`browser-chain-verdict.json` 与 `activation-verdict.json` 分别为 `903318f2a551c4edb858131a59a49cced2af9b1acd30bd9954a534e9fceb9701` 与 `67dcd54b2505504ce4589dc441ca100b612a37054e369623565e98ff5a7fe0f6`。真玩在 8.3 秒时仍为 `play`,击杀 6、生命 136、位置已从原点移到 `(527.2,131.8)`;画布抽样 20678 个不透明点、9052 个亮点、975 种量化颜色。激活前后两轮均捕获 9 个 Runtime 请求、0 个 MinIO 直连、0 个非 2xx 请求。这只完成单款隔离链路;存量游戏批量迁移、`:48080` 生产切换和正式金标重新绑定均未执行。 --- diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlController.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlController.java new file mode 100644 index 00000000..54fab160 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlController.java @@ -0,0 +1,151 @@ +package com.wanxiang.huijing.game.module.aigc.controller.admin.content; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import com.wanxiang.huijing.framework.tenant.core.aop.TenantIgnore; +import com.wanxiang.huijing.game.module.aigc.service.content.GameContentControlService; +import com.wanxiang.huijing.game.module.aigc.service.content.GameContentSignatureVerifier; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.util.StringUtils; + +import static com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; +import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; + +/** Agent 游戏内容仓库的受信任 prepare/finalize/activate 控制面。 */ +@Slf4j +@Tag(name = "管理后台 - 游戏内容控制面") +@RestController +@RequestMapping("/aigc/game-content") +public class GameContentControlController { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Resource + private GameContentSignatureVerifier signatureVerifier; + + @Resource + private GameContentControlService gameContentControlService; + + /** project 权威创建或幂等复用内容版本。 */ + @PostMapping("/prepare") + @PermitAll + @TenantIgnore + @Operation(summary = "预留游戏内容版本") + public CommonResult prepare( + @RequestBody String rawBody, + @RequestHeader(value = GameContentSignatureVerifier.SIGNATURE_HEADER, required = false) String signature, + HttpServletResponse response) { + if (!signatureVerifier.verify(rawBody, signature)) { + return unauthorized(response, "prepare"); + } + ProjectContentVersionPrepareReqDTO req; + try { + req = JSON.readValue(rawBody, ProjectContentVersionPrepareReqDTO.class); + } catch (Exception ex) { + return badRequest(response, "prepare body 解析失败", ex); + } + if (req.getTenantId() == null || req.getTenantId() <= 0 + || req.getGameId() == null || req.getGameId() <= 0 + || !validRevision(req.getRevisionId())) { + return badRequest(response, "prepare 身份字段非法", null); + } + return success(gameContentControlService.prepareContentVersion(req)); + } + + /** 在一个本地事务内完成 Project 锁定、Runtime OSS 落元数据和 Project 绑定。 */ + @PostMapping("/finalize") + @PermitAll + @TenantIgnore + @Operation(summary = "收口 OSS 游戏内容版本") + public CommonResult finalizeContent( + @RequestBody String rawBody, + @RequestHeader(value = GameContentSignatureVerifier.SIGNATURE_HEADER, required = false) String signature, + HttpServletResponse response) { + if (!signatureVerifier.verify(rawBody, signature)) { + return unauthorized(response, "finalize"); + } + RuntimeOssPackageFinalizeReqDTO req; + try { + req = JSON.readValue(rawBody, RuntimeOssPackageFinalizeReqDTO.class); + } catch (Exception ex) { + return badRequest(response, "finalize body 解析失败", ex); + } + if (req.getTenantId() == null || req.getTenantId() <= 0 + || req.getGameId() == null || req.getGameId() <= 0 + || req.getVersionId() == null || req.getVersionId() <= 0 + || !validRevision(req.getRevisionId()) + || !validSha256(req.getArtifactManifestHash()) + || !StringUtils.hasText(req.getManifestJson())) { + return badRequest(response, "finalize 身份或摘要字段非法", null); + } + return success(gameContentControlService.finalizeContent(req)); + } + + /** 浏览器按目标版本验收通过后,带 expected-current 前置条件显式激活。 */ + @PostMapping("/activate") + @PermitAll + @TenantIgnore + @Operation(summary = "激活已验收游戏内容版本") + public CommonResult activateContentVersion( + @RequestBody String rawBody, + @RequestHeader(value = GameContentSignatureVerifier.SIGNATURE_HEADER, required = false) String signature, + HttpServletResponse response) { + if (!signatureVerifier.verify(rawBody, signature)) { + return unauthorized(response, "activate"); + } + ProjectContentVersionActivateReqDTO req; + try { + req = JSON.readValue(rawBody, ProjectContentVersionActivateReqDTO.class); + } catch (Exception ex) { + return badRequest(response, "activate body 解析失败", ex); + } + if (req.getTenantId() == null || req.getTenantId() <= 0 + || req.getGameId() == null || req.getGameId() <= 0 + || req.getVersionId() == null || req.getVersionId() <= 0 + || (req.getExpectedCurrentVersionId() != null && req.getExpectedCurrentVersionId() <= 0) + || !validRevision(req.getRevisionId())) { + return badRequest(response, "activate 身份或前置版本字段非法", null); + } + return success(gameContentControlService.activateContentVersion(req)); + } + + /** 验签失败始终以 HTTP 401 收口,且此时尚未解析或调用任何写服务。 */ + private static CommonResult unauthorized(HttpServletResponse response, String operation) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + log.warn("[gameContentControl] HMAC 拒绝 operation={}", operation); + return CommonResult.error(UNAUTHORIZED.getCode(), "游戏内容控制面验签失败"); + } + + /** 原始 body 或必填字段非法时返回 HTTP 400,不进入写链。 */ + private static CommonResult badRequest(HttpServletResponse response, String reason, Exception ex) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + if (ex == null) { + log.warn("[gameContentControl] 请求非法 reason={}", reason); + } else { + log.warn("[gameContentControl] 请求解析失败 reason={}", reason, ex); + } + return CommonResult.error(400, reason); + } + + private static boolean validRevision(String value) { + return StringUtils.hasText(value) && value.length() <= 128 + && value.matches("^[A-Za-z0-9][A-Za-z0-9._-]*$"); + } + + private static boolean validSha256(String value) { + return StringUtils.hasText(value) && value.matches("^[a-f0-9]{64}$"); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlProperties.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlProperties.java new file mode 100644 index 00000000..bf081286 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlProperties.java @@ -0,0 +1,23 @@ +package com.wanxiang.huijing.game.module.aigc.service.content; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 游戏内容控制面配置。 + * + *

该 Bean 始终装配且默认关闭,不受 {@code aigc.executor.enabled} 影响; + * 只有 enabled=true 且独立 secret 非空时,HMAC 入口才会执行任何写操作。

+ */ +@Data +@Component +@ConfigurationProperties(prefix = "aigc.game-content-control") +public class GameContentControlProperties { + + /** 控制面总开关,默认关闭。 */ + private boolean enabled = false; + + /** 独立于生成 worker callback-secret 的 HMAC 共享密钥。 */ + private String secret = ""; +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlService.java new file mode 100644 index 00000000..ebc17425 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlService.java @@ -0,0 +1,72 @@ +package com.wanxiang.huijing.game.module.aigc.service.content; + +import com.wanxiang.huijing.framework.common.enums.UserTypeEnum; +import com.wanxiang.huijing.framework.security.core.LoginUser; +import com.wanxiang.huijing.framework.tenant.core.util.TenantUtils; +import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import jakarta.annotation.Resource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +/** HMAC 控制器后的系统身份与显式租户执行边界。 */ +@Service +public class GameContentControlService { + + @Resource + private ProjectVersionApi projectVersionApi; + + @Resource + private GameContentFinalizeTxService finalizeTxService; + + /** 以目标租户和系统身份调用 project 权威 prepare。 */ + public Long prepareContentVersion(ProjectContentVersionPrepareReqDTO req) { + return executeAsSystem(req.getTenantId(), + () -> projectVersionApi.prepareContentVersion(req).getCheckedData()); + } + + /** 以目标租户和系统身份进入单一 Finalize 事务。 */ + public Long finalizeContent(RuntimeOssPackageFinalizeReqDTO req) { + return executeAsSystem(req.getTenantId(), () -> finalizeTxService.finalizeContent(req)); + } + + /** 以目标租户和系统身份执行验收后的 expected-current CAS 激活。 */ + public Long activateContentVersion(ProjectContentVersionActivateReqDTO req) { + return executeAsSystem(req.getTenantId(), () -> { + projectVersionApi.activateContentVersion(req).getCheckedData(); + return req.getVersionId(); + }); + } + + /** + * 设置 tenant + system LoginUser,并在所有成功/异常路径恢复线程上下文。 + * + *

使用 TenantUtils 的 Runnable 重载,保留 ServiceException 原类型,不被 Callable 重载包装。

+ */ + private static T executeAsSystem(Long tenantId, Supplier action) { + Authentication previous = SecurityContextHolder.getContext().getAuthentication(); + LoginUser systemUser = new LoginUser().setId(0L) + .setUserType(UserTypeEnum.ADMIN.getValue()).setTenantId(tenantId); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(systemUser, null, Collections.emptyList())); + AtomicReference result = new AtomicReference<>(); + try { + TenantUtils.execute(tenantId, () -> result.set(action.get())); + return result.get(); + } finally { + if (previous == null) { + SecurityContextHolder.clearContext(); + } else { + SecurityContextHolder.getContext().setAuthentication(previous); + } + } + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxService.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxService.java new file mode 100644 index 00000000..5bc6f304 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxService.java @@ -0,0 +1,59 @@ +package com.wanxiang.huijing.game.module.aigc.service.content; + +import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO; +import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 游戏内容 Finalize 的单体本地事务编排。 */ +@Slf4j +@Service +public class GameContentFinalizeTxService { + + @Resource + private ProjectVersionApi projectVersionApi; + + @Resource + private RuntimePackageApi runtimePackageApi; + + /** + * 按统一锁序完成 Project 身份锁定、Runtime OSS 元数据落库和 Project 摘要绑定。 + * + *

三个 API 在 MVP 单体内均解析为本地 {@code @Primary} Bean,加入本事务;任一步失败整体回滚。 + * 第一调用会在 project 模块按 project -> version 加行锁,后续不得调整顺序。

+ */ + @Transactional(rollbackFor = Exception.class) + public Long finalizeContent(RuntimeOssPackageFinalizeReqDTO req) { + ProjectContentVersionIdentityReqDTO identityReq = new ProjectContentVersionIdentityReqDTO(); + identityReq.setTenantId(req.getTenantId()); + identityReq.setGameId(req.getGameId()); + identityReq.setVersionId(req.getVersionId()); + identityReq.setRevisionId(req.getRevisionId()); + projectVersionApi.validateContentVersionIdentity(identityReq).getCheckedData(); + + RuntimeOssPackageFinalizeRespDTO runtimeResult = + runtimePackageApi.finalizeOssPackage(req).getCheckedData(); + if (runtimeResult == null || runtimeResult.getPackageId() == null + || runtimeResult.getChecksum() == null || runtimeResult.getBundleSize() == null) { + throw new IllegalStateException("Runtime OSS Finalize 未返回完整权威元数据"); + } + + ProjectContentVersionBindReqDTO bindReq = new ProjectContentVersionBindReqDTO(); + bindReq.setTenantId(req.getTenantId()); + bindReq.setGameId(req.getGameId()); + bindReq.setVersionId(req.getVersionId()); + bindReq.setRevisionId(req.getRevisionId()); + bindReq.setChecksum(runtimeResult.getChecksum()); + bindReq.setBundleSize(runtimeResult.getBundleSize()); + projectVersionApi.bindContentRuntimeArtifact(bindReq).getCheckedData(); + log.info("[gameContentFinalize] 内容版本已提交并绑定摘要,等待浏览器验收激活 tenantId={}, gameId={}, versionId={}, packageId={}", + req.getTenantId(), req.getGameId(), req.getVersionId(), runtimeResult.getPackageId()); + return runtimeResult.getPackageId(); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentSignatureVerifier.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentSignatureVerifier.java new file mode 100644 index 00000000..c7b4c930 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentSignatureVerifier.java @@ -0,0 +1,65 @@ +package com.wanxiang.huijing.game.module.aigc.service.content; + +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; + +/** 游戏内容 prepare/finalize 原始请求体的 fail-closed HMAC-SHA256 验签器。 */ +@Slf4j +@Component +public class GameContentSignatureVerifier { + + /** Agent 调用内容控制面必须携带的签名头。 */ + public static final String SIGNATURE_HEADER = "X-Game-Content-Signature"; + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + @Resource + private GameContentControlProperties properties; + + /** 测试和轻量装配使用的显式构造器。 */ + public GameContentSignatureVerifier(GameContentControlProperties properties) { + this.properties = properties; + } + + /** Spring 字段注入使用的默认构造器。 */ + public GameContentSignatureVerifier() { + } + + /** + * 对 HTTP 实际收到的原始 UTF-8 body 验签。 + * + *

禁用、空密钥、缺签、格式错误、算法异常或摘要不一致一律返回 false。

+ */ + public boolean verify(String rawBody, String signature) { + String secret = properties == null ? null : properties.getSecret(); + if (properties == null || !properties.isEnabled() || !StringUtils.hasText(secret)) { + log.warn("[gameContentSignature] 控制面未启用或独立密钥为空,拒绝请求"); + return false; + } + if (rawBody == null || !StringUtils.hasText(signature) || !signature.matches("^[a-f0-9]{64}$")) { + log.warn("[gameContentSignature] 原始 body 或签名头缺失/格式非法,拒绝请求"); + return false; + } + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + String expected = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8))); + boolean matched = MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), + signature.getBytes(StandardCharsets.US_ASCII)); + if (!matched) { + log.warn("[gameContentSignature] HMAC 不匹配,拒绝请求"); + } + return matched; + } catch (Exception ex) { + log.error("[gameContentSignature] HMAC-SHA256 计算异常,拒绝请求", ex); + return false; + } + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlControllerTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlControllerTest.java new file mode 100644 index 00000000..ae7f0c2d --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/controller/admin/content/GameContentControlControllerTest.java @@ -0,0 +1,160 @@ +package com.wanxiang.huijing.game.module.aigc.controller.admin.content; + +import com.wanxiang.huijing.game.module.aigc.service.content.GameContentControlProperties; +import com.wanxiang.huijing.game.module.aigc.service.content.GameContentControlService; +import com.wanxiang.huijing.game.module.aigc.service.content.GameContentSignatureVerifier; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 游戏内容 HMAC 控制面真实路由与零写拒绝测试。 */ +class GameContentControlControllerTest { + + private static final String SECRET = "content-control-secret"; + + @Test + void prepare_disabledControlPlaneReturnsUnauthorizedWithoutWrite() throws Exception { + GameContentControlService service = mock(GameContentControlService.class); + MockMvc mvc = mvc(false, SECRET, service); + String body = prepareBody(); + + mvc.perform(post("/aigc/game-content/prepare") + .contentType(APPLICATION_JSON).content(body) + .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET))) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(service); + } + + @Test + void prepare_emptySecretFailsClosedWithoutWrite() throws Exception { + GameContentControlService service = mock(GameContentControlService.class); + MockMvc mvc = mvc(true, "", service); + + mvc.perform(post("/aigc/game-content/prepare") + .contentType(APPLICATION_JSON).content(prepareBody())) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(service); + } + + @Test + void prepare_wrongSignatureReturnsUnauthorizedWithoutWrite() throws Exception { + GameContentControlService service = mock(GameContentControlService.class); + MockMvc mvc = mvc(true, SECRET, service); + + mvc.perform(post("/aigc/game-content/prepare") + .contentType(APPLICATION_JSON).content(prepareBody()) + .header(GameContentSignatureVerifier.SIGNATURE_HEADER, "0".repeat(64))) + .andExpect(status().isUnauthorized()); + + verifyNoInteractions(service); + } + + @Test + void prepare_validSignatureCallsTrustedService() throws Exception { + GameContentControlService service = mock(GameContentControlService.class); + when(service.prepareContentVersion(any())).thenReturn(2048L); + MockMvc mvc = mvc(true, SECRET, service); + String body = prepareBody(); + + mvc.perform(post("/aigc/game-content/prepare") + .contentType(APPLICATION_JSON).content(body) + .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value(2048)); + + verify(service).prepareContentVersion(any()); + verify(service, never()).finalizeContent(any()); + } + + @Test + void finalize_validSignatureUsesFinalizeRoute() throws Exception { + GameContentControlService service = mock(GameContentControlService.class); + when(service.finalizeContent(any())).thenReturn(31L); + MockMvc mvc = mvc(true, SECRET, service); + String body = "{\"tenantId\":7,\"gameId\":1024,\"versionId\":2048," + + "\"revisionId\":\"revision-a\",\"artifactManifestHash\":\"" + "a".repeat(64) + + "\",\"manifestJson\":\"{}\"}"; + + mvc.perform(post("/aigc/game-content/finalize") + .contentType(APPLICATION_JSON).content(body) + .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value(31)); + + verify(service).finalizeContent(any()); + } + + @Test + void activate_validSignatureUsesActivateRoute() throws Exception { + GameContentControlService service = mock(GameContentControlService.class); + when(service.activateContentVersion(any())).thenReturn(2048L); + MockMvc mvc = mvc(true, SECRET, service); + String body = "{\"tenantId\":7,\"gameId\":1024,\"versionId\":2048," + + "\"revisionId\":\"revision-a\",\"expectedCurrentVersionId\":1023}"; + + mvc.perform(post("/aigc/game-content/activate") + .contentType(APPLICATION_JSON).content(body) + .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value(2048)); + + verify(service).activateContentVersion(any()); + verify(service, never()).finalizeContent(any()); + } + + @Test + void activate_nonPositiveExpectedCurrentReturnsBadRequestWithoutWrite() throws Exception { + GameContentControlService service = mock(GameContentControlService.class); + MockMvc mvc = mvc(true, SECRET, service); + String body = "{\"tenantId\":7,\"gameId\":1024,\"versionId\":2048," + + "\"revisionId\":\"revision-a\",\"expectedCurrentVersionId\":0}"; + + mvc.perform(post("/aigc/game-content/activate") + .contentType(APPLICATION_JSON).content(body) + .header(GameContentSignatureVerifier.SIGNATURE_HEADER, sign(body, SECRET))) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(service); + } + + /** 以真实 verifier 和真实 Spring handler mapping 建独立控制器测试。 */ + private static MockMvc mvc(boolean enabled, String secret, GameContentControlService service) { + GameContentControlProperties properties = new GameContentControlProperties(); + properties.setEnabled(enabled); + properties.setSecret(secret); + GameContentControlController controller = new GameContentControlController(); + ReflectionTestUtils.setField(controller, "signatureVerifier", new GameContentSignatureVerifier(properties)); + ReflectionTestUtils.setField(controller, "gameContentControlService", service); + return MockMvcBuilders.standaloneSetup(controller).build(); + } + + private static String prepareBody() { + return "{\"tenantId\":7,\"gameId\":1024,\"revisionId\":\"revision-a\"}"; + } + + /** 独立按原始 UTF-8 body 计算 HMAC,避免复用生产验签逻辑形成镜像断言。 */ + private static String sign(String body, String secret) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlServiceTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlServiceTest.java new file mode 100644 index 00000000..a313c02e --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentControlServiceTest.java @@ -0,0 +1,86 @@ +package com.wanxiang.huijing.game.module.aigc.service.content; + +import com.wanxiang.huijing.framework.common.enums.UserTypeEnum; +import com.wanxiang.huijing.framework.security.core.LoginUser; +import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils; +import com.wanxiang.huijing.framework.tenant.core.context.TenantContextHolder; +import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; +import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +/** HMAC 通过后的系统身份、租户隔离和线程上下文清理测试。 */ +class GameContentControlServiceTest extends BaseMockitoUnitTest { + + @InjectMocks + private GameContentControlService controlService; + + @Mock + private ProjectVersionApi projectVersionApi; + @Mock + private GameContentFinalizeTxService finalizeTxService; + + @AfterEach + void clearThreadContext() { + TenantContextHolder.clear(); + org.springframework.security.core.context.SecurityContextHolder.clearContext(); + } + + @Test + void prepareContentVersion_runsUnderRequestedTenantAndSystemIdentityThenCleansContext() { + ProjectContentVersionPrepareReqDTO req = new ProjectContentVersionPrepareReqDTO(); + req.setTenantId(7L); + req.setGameId(1024L); + req.setRevisionId("revision-a"); + when(projectVersionApi.prepareContentVersion(req)).thenAnswer(invocation -> { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + assertEquals(0L, loginUser.getId()); + assertEquals(UserTypeEnum.ADMIN.getValue(), loginUser.getUserType()); + assertEquals(7L, loginUser.getTenantId()); + assertEquals(7L, TenantContextHolder.getTenantId()); + assertFalse(TenantContextHolder.isIgnore()); + return success(2048L); + }); + + assertEquals(2048L, controlService.prepareContentVersion(req)); + + assertNull(SecurityFrameworkUtils.getLoginUser()); + assertNull(TenantContextHolder.getTenantId()); + assertFalse(TenantContextHolder.isIgnore()); + } + + @Test + void activateContentVersion_runsUnderRequestedTenantAndSystemIdentityThenCleansContext() { + ProjectContentVersionActivateReqDTO req = new ProjectContentVersionActivateReqDTO(); + req.setTenantId(7L); + req.setGameId(1024L); + req.setVersionId(2048L); + req.setRevisionId("revision-a"); + req.setExpectedCurrentVersionId(1023L); + when(projectVersionApi.activateContentVersion(req)).thenAnswer(invocation -> { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + assertEquals(0L, loginUser.getId()); + assertEquals(UserTypeEnum.ADMIN.getValue(), loginUser.getUserType()); + assertEquals(7L, loginUser.getTenantId()); + assertEquals(7L, TenantContextHolder.getTenantId()); + assertFalse(TenantContextHolder.isIgnore()); + return success(Boolean.TRUE); + }); + + assertEquals(2048L, controlService.activateContentVersion(req)); + + assertNull(SecurityFrameworkUtils.getLoginUser()); + assertNull(TenantContextHolder.getTenantId()); + assertFalse(TenantContextHolder.isIgnore()); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxServiceTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxServiceTest.java new file mode 100644 index 00000000..d4394b47 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/content/GameContentFinalizeTxServiceTest.java @@ -0,0 +1,103 @@ +package com.wanxiang.huijing.game.module.aigc.service.content; + +import com.wanxiang.huijing.framework.common.exception.ServiceException; +import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; +import com.wanxiang.huijing.game.module.project.api.ProjectVersionApi; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO; +import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static com.wanxiang.huijing.framework.common.pojo.CommonResult.error; +import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 游戏内容 Finalize 单事务编排顺序测试。 */ +class GameContentFinalizeTxServiceTest extends BaseMockitoUnitTest { + + @InjectMocks + private GameContentFinalizeTxService finalizeTxService; + + @Mock + private ProjectVersionApi projectVersionApi; + @Mock + private RuntimePackageApi runtimePackageApi; + + @Test + void finalizeContent_locksProjectIdentityThenStoresRuntimeThenBindsProject() { + RuntimeOssPackageFinalizeReqDTO req = request(); + when(projectVersionApi.validateContentVersionIdentity(any())).thenReturn(success(Boolean.TRUE)); + when(runtimePackageApi.finalizeOssPackage(req)).thenReturn(success( + new RuntimeOssPackageFinalizeRespDTO(31L, "b".repeat(64), 4096L))); + when(projectVersionApi.bindContentRuntimeArtifact(any())).thenReturn(success(Boolean.TRUE)); + + Long packageId = finalizeTxService.finalizeContent(req); + + assertEquals(31L, packageId); + InOrder order = inOrder(projectVersionApi, runtimePackageApi); + order.verify(projectVersionApi).validateContentVersionIdentity(any()); + order.verify(runtimePackageApi).finalizeOssPackage(req); + order.verify(projectVersionApi).bindContentRuntimeArtifact(any()); + + ArgumentCaptor identityCaptor = + ArgumentCaptor.forClass(ProjectContentVersionIdentityReqDTO.class); + verify(projectVersionApi).validateContentVersionIdentity(identityCaptor.capture()); + assertEquals(7L, identityCaptor.getValue().getTenantId()); + assertEquals(1024L, identityCaptor.getValue().getGameId()); + assertEquals(2048L, identityCaptor.getValue().getVersionId()); + assertEquals("revision-a", identityCaptor.getValue().getRevisionId()); + + ArgumentCaptor bindCaptor = + ArgumentCaptor.forClass(ProjectContentVersionBindReqDTO.class); + verify(projectVersionApi).bindContentRuntimeArtifact(bindCaptor.capture()); + assertEquals("b".repeat(64), bindCaptor.getValue().getChecksum()); + assertEquals(4096L, bindCaptor.getValue().getBundleSize()); + } + + @Test + void finalizeContent_identityFailurePerformsNoRuntimeWrite() { + RuntimeOssPackageFinalizeReqDTO req = request(); + when(projectVersionApi.validateContentVersionIdentity(any())) + .thenReturn(error(1_100_000_009, "invalid")); + + assertThrows(ServiceException.class, () -> finalizeTxService.finalizeContent(req)); + + verify(runtimePackageApi, never()).finalizeOssPackage(any()); + verify(projectVersionApi, never()).bindContentRuntimeArtifact(any()); + } + + @Test + void finalizeContent_runtimeFailureDoesNotBindProject() { + RuntimeOssPackageFinalizeReqDTO req = request(); + when(projectVersionApi.validateContentVersionIdentity(any())).thenReturn(success(Boolean.TRUE)); + when(runtimePackageApi.finalizeOssPackage(req)).thenReturn(error(1_102_001_009, "invalid")); + + assertThrows(ServiceException.class, () -> finalizeTxService.finalizeContent(req)); + + verify(projectVersionApi, never()).bindContentRuntimeArtifact(any()); + } + + /** 构造已由 HMAC 控制器解析的 Finalize 入参。 */ + private static RuntimeOssPackageFinalizeReqDTO request() { + RuntimeOssPackageFinalizeReqDTO req = new RuntimeOssPackageFinalizeReqDTO(); + req.setTenantId(7L); + req.setGameId(1024L); + req.setVersionId(2048L); + req.setRevisionId("revision-a"); + req.setArtifactManifestHash("a".repeat(64)); + req.setManifestJson("{\"gameId\":\"1024\"}"); + return req; + } +} diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java index cf590afc..b7215dc1 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/service/feed/FeedServiceImpl.java @@ -29,6 +29,7 @@ import com.wanxiang.huijing.framework.common.pojo.PageResult; import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -92,6 +93,7 @@ public class FeedServiceImpl implements FeedService { * MVP 单体:由 project 模块 ProjectApiImpl(@RestController @Primary) 就地解析。 */ @Resource + @Lazy private ProjectApi projectApi; /** diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApi.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApi.java index c86814ef..b3ec423f 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApi.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApi.java @@ -2,6 +2,10 @@ package com.wanxiang.huijing.game.module.project.api; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO; import com.wanxiang.huijing.game.module.project.enums.ApiConstants; import com.wanxiang.huijing.framework.common.pojo.CommonResult; import io.swagger.v3.oas.annotations.Operation; @@ -55,4 +59,25 @@ public interface ProjectVersionApi { @Operation(summary = "绑定版本运行产物摘要") CommonResult bindRuntimeArtifact(@RequestBody @Valid ProjectVersionBindArtifactReqDTO req); + /** 由 project 权威创建或复用指定源修订的内容版本。 */ + @PostMapping(PREFIX + "/version/prepare-content") + @Operation(summary = "预留游戏内容版本") + CommonResult prepareContentVersion(@RequestBody @Valid ProjectContentVersionPrepareReqDTO req); + + /** Finalize 写 Runtime 前校验 tenant/game/version/revision 四元身份。 */ + @PostMapping(PREFIX + "/version/validate-content-identity") + @Operation(summary = "校验游戏内容版本身份") + CommonResult validateContentVersionIdentity( + @RequestBody @Valid ProjectContentVersionIdentityReqDTO req); + + /** Runtime 已写入同一预览元数据后,只绑定版本摘要,不激活项目指针。 */ + @PostMapping(PREFIX + "/version/bind-content-artifact") + @Operation(summary = "绑定游戏内容运行产物") + CommonResult bindContentRuntimeArtifact(@RequestBody @Valid ProjectContentVersionBindReqDTO req); + + /** 浏览器验收通过后,按 expected-current CAS 激活内容版本。 */ + @PostMapping(PREFIX + "/version/activate-content") + @Operation(summary = "激活已验收游戏内容版本") + CommonResult activateContentVersion(@RequestBody @Valid ProjectContentVersionActivateReqDTO req); + } diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionActivateReqDTO.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionActivateReqDTO.java new file mode 100644 index 00000000..b3851698 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionActivateReqDTO.java @@ -0,0 +1,41 @@ +package com.wanxiang.huijing.game.module.project.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** 浏览器验收通过后,以期望当前版本 CAS 激活内容版本的入参。 */ +@Data +public class ProjectContentVersionActivateReqDTO { + + /** 目标租户 ID。 */ + @NotNull(message = "tenantId 不能为空") + @Positive(message = "tenantId 必须大于 0") + private Long tenantId; + + /** 目标游戏项目 ID。 */ + @NotNull(message = "gameId 不能为空") + @Positive(message = "gameId 必须大于 0") + private Long gameId; + + /** 已完成 finalize 和浏览器验收的版本 ID。 */ + @NotNull(message = "versionId 不能为空") + @Positive(message = "versionId 必须大于 0") + private Long versionId; + + /** prepare 时使用的不可变源修订 ID。 */ + @NotBlank(message = "revisionId 不能为空") + @Size(max = 128, message = "revisionId 长度不能超过 128") + @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法") + private String revisionId; + + /** + * 浏览器验收开始时观察到的项目当前版本。 + * 新游戏首次激活时为 null;其余情况必须与持锁后项目指针完全一致。 + */ + @Positive(message = "expectedCurrentVersionId 必须大于 0") + private Long expectedCurrentVersionId; +} diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionBindReqDTO.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionBindReqDTO.java new file mode 100644 index 00000000..79ce1bf7 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionBindReqDTO.java @@ -0,0 +1,41 @@ +package com.wanxiang.huijing.game.module.project.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** Runtime 已完成 OSS 元数据落库后,将同一制品摘要绑定到内容版本的入参。 */ +@Data +public class ProjectContentVersionBindReqDTO { + + /** 目标租户 ID。 */ + @NotNull(message = "tenantId 不能为空") + private Long tenantId; + + /** 目标游戏项目 ID。 */ + @NotNull(message = "gameId 不能为空") + private Long gameId; + + /** 由 prepare 返回的权威版本 ID。 */ + @NotNull(message = "versionId 不能为空") + private Long versionId; + + /** prepare 时使用的不可变源修订 ID。 */ + @NotBlank(message = "revisionId 不能为空") + @Size(max = 128, message = "revisionId 长度不能超过 128") + @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法") + private String revisionId; + + /** GamePackage 原始 UTF-8 字节 SHA-256。 */ + @NotBlank(message = "checksum 不能为空") + @Pattern(regexp = "^[a-f0-9]{64}$", message = "checksum 必须为 64 位小写十六进制 sha256") + private String checksum; + + /** GamePackage manifest 声明的运行包字节数。 */ + @NotNull(message = "bundleSize 不能为空") + @Positive(message = "bundleSize 必须大于 0") + private Long bundleSize; +} diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionIdentityReqDTO.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionIdentityReqDTO.java new file mode 100644 index 00000000..8648776e --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionIdentityReqDTO.java @@ -0,0 +1,30 @@ +package com.wanxiang.huijing.game.module.project.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** 游戏内容 Finalize 前的版本身份校验入参。 */ +@Data +public class ProjectContentVersionIdentityReqDTO { + + /** 目标租户 ID。 */ + @NotNull(message = "tenantId 不能为空") + private Long tenantId; + + /** 目标游戏项目 ID。 */ + @NotNull(message = "gameId 不能为空") + private Long gameId; + + /** 由 prepare 返回的权威版本 ID。 */ + @NotNull(message = "versionId 不能为空") + private Long versionId; + + /** prepare 时使用的不可变源修订 ID。 */ + @NotBlank(message = "revisionId 不能为空") + @Size(max = 128, message = "revisionId 长度不能超过 128") + @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法") + private String revisionId; +} diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionPrepareReqDTO.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionPrepareReqDTO.java new file mode 100644 index 00000000..51d0eb20 --- /dev/null +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/dto/ProjectContentVersionPrepareReqDTO.java @@ -0,0 +1,31 @@ +package com.wanxiang.huijing.game.module.project.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 游戏内容仓库预留权威版本入参。 + * + *

租户、项目和不可变源修订共同定义幂等身份;版本 ID 只能由 project 模块创建, + * 内容 worker 不得自行指定或直接写 {@code game_version}。

+ */ +@Data +public class ProjectContentVersionPrepareReqDTO { + + /** 目标租户 ID。 */ + @NotNull(message = "tenantId 不能为空") + private Long tenantId; + + /** 已存在且归属于目标租户的游戏项目 ID。 */ + @NotNull(message = "gameId 不能为空") + private Long gameId; + + /** 引擎无关源归档的不可变修订 ID。 */ + @NotBlank(message = "revisionId 不能为空") + @Size(max = 128, message = "revisionId 长度不能超过 128") + @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法") + private String revisionId; +} diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java index 3f9f1172..2718acb0 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/com/wanxiang/huijing/game/module/project/enums/ErrorCodeConstants.java @@ -31,5 +31,7 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_REVIEW_ARTIFACT_HASH_INVALID = new ErrorCode(1_100_000_007, "审核版本产物摘要无效"); /** callback 绑定的运行产物摘要非法,或目标版本不存在。 */ ErrorCode PROJECT_VERSION_ARTIFACT_BIND_FAILED = new ErrorCode(1_100_000_008, "版本运行产物摘要绑定失败"); + /** 内容控制面的租户、项目、版本或源修订身份不一致。 */ + ErrorCode PROJECT_CONTENT_VERSION_INVALID = new ErrorCode(1_100_000_009, "游戏内容版本身份无效"); } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java index 47323984..38d6088c 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java @@ -2,6 +2,10 @@ package com.wanxiang.huijing.game.module.project.api; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO; import com.wanxiang.huijing.game.module.project.service.version.GameVersionService; import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.common.util.servlet.ServletUtils; @@ -51,6 +55,33 @@ public class ProjectVersionApiImpl implements ProjectVersionApi { return success(Boolean.TRUE); } + @Override + public CommonResult prepareContentVersion(ProjectContentVersionPrepareReqDTO req) { + rejectExternalRpcWrite("project.rpc.prepare-content"); + return success(gameVersionService.prepareContentVersion(req)); + } + + @Override + public CommonResult validateContentVersionIdentity(ProjectContentVersionIdentityReqDTO req) { + rejectExternalRpcWrite("project.rpc.validate-content-identity"); + gameVersionService.validateContentVersionIdentity(req); + return success(Boolean.TRUE); + } + + @Override + public CommonResult bindContentRuntimeArtifact(ProjectContentVersionBindReqDTO req) { + rejectExternalRpcWrite("project.rpc.bind-content-artifact"); + gameVersionService.bindContentRuntimeArtifact(req); + return success(Boolean.TRUE); + } + + @Override + public CommonResult activateContentVersion(ProjectContentVersionActivateReqDTO req) { + rejectExternalRpcWrite("project.rpc.activate-content"); + gameVersionService.activateContentVersion(req); + return success(Boolean.TRUE); + } + /** 当前单体只信任本地 Java 调用;拆微服务前须先建立服务身份,不能直接放开此 HTTP 写入口。 */ private void rejectExternalRpcWrite(String operation) { HttpServletRequest request = ServletUtils.getRequest(); diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionService.java index d0bda2cd..b3eb2a39 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionService.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionService.java @@ -3,6 +3,10 @@ package com.wanxiang.huijing.game.module.project.service.version; import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO; /** * 游戏版本 Service 接口(黄金闭环 §3.3 C3 新增) @@ -32,6 +36,18 @@ public interface GameVersionService { */ void bindRuntimeArtifact(ProjectVersionBindArtifactReqDTO req); + /** 预留或幂等复用一个由源修订唯一绑定的内容版本。 */ + Long prepareContentVersion(ProjectContentVersionPrepareReqDTO req); + + /** Finalize 写 Runtime 前校验内容版本四元身份。 */ + void validateContentVersionIdentity(ProjectContentVersionIdentityReqDTO req); + + /** Runtime 预览包就绪后绑定摘要,但不切换项目当前版本。 */ + void bindContentRuntimeArtifact(ProjectContentVersionBindReqDTO req); + + /** 浏览器验收通过后,按 expected-current CAS 切换项目当前版本。 */ + void activateContentVersion(ProjectContentVersionActivateReqDTO req); + /** * 取版本 DO(发布编排取当前生效版本以做版本态流转) * diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java index 7ef5aff7..c9c04682 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java @@ -6,6 +6,10 @@ import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper; import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO; import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi; import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; import jakarta.annotation.Resource; @@ -16,7 +20,11 @@ import org.springframework.util.StringUtils; import java.util.Objects; import java.util.regex.Pattern; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_CONTENT_VERSION_INVALID; import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED; import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -39,6 +47,10 @@ public class GameVersionServiceImpl implements GameVersionService { private static final int DEFAULT_VERSION_NO = 1; /** 审核门认可的权威产物摘要格式,与 runtime 落包契约保持一致。 */ private static final Pattern SHA256_PATTERN = Pattern.compile("^[a-f0-9]{64}$"); + /** 源修订 ID 与对象存储 writer 使用同一安全字符集,禁止路径注入。 */ + private static final Pattern REVISION_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"); + /** 内容修订幂等键的域标签,避免与普通 AIGC taskId 共用命名空间。 */ + private static final String CONTENT_REVISION_KEY_DOMAIN = "game-content-version/v1\0"; @Resource private GameVersionMapper gameVersionMapper; @@ -55,6 +67,231 @@ public class GameVersionServiceImpl implements GameVersionService { @Resource private RuntimePackageApi runtimePackageApi; + @Override + @Transactional(rollbackFor = Exception.class) + public Long prepareContentVersion(ProjectContentVersionPrepareReqDTO req) { + if (!validContentIdentityInput(req == null ? null : req.getTenantId(), + req == null ? null : req.getGameId(), req == null ? null : req.getRevisionId())) { + rejectContentIdentity("prepare 入参非法", req == null ? null : req.getTenantId(), + req == null ? null : req.getGameId(), null); + } + + // 项目行锁同时承担存在性、租户归属和同项目修订幂等串行化,避免并发重复建版本。 + ProjectDO project = projectMapper.selectByIdForUpdate(req.getGameId()); + if (!matchesProject(project, req.getTenantId(), req.getGameId())) { + rejectContentIdentity("prepare 项目不存在或跨租户", req.getTenantId(), req.getGameId(), null); + } + + String revisionKey = contentRevisionKey(req.getTenantId(), req.getGameId(), req.getRevisionId()); + GameVersionDO existing = gameVersionMapper.selectByGenTaskId(revisionKey); + if (existing != null) { + if (!matchesVersion(existing, req.getTenantId(), req.getGameId(), revisionKey)) { + rejectContentIdentity("prepare 幂等键命中错误版本", req.getTenantId(), req.getGameId(), existing.getId()); + } + log.info("[prepareContentVersion] 内容修订幂等命中 tenantId={}, gameId={}, versionId={}, revisionId={}", + req.getTenantId(), req.getGameId(), existing.getId(), req.getRevisionId()); + return existing.getId(); + } + + GameVersionDO version = new GameVersionDO(); + // 显式写租户列,使关闭租户拦截器的隔离 staging 也不会回落表默认 tenant_id=0。 + version.setTenantId(req.getTenantId()); + version.setGameId(req.getGameId()); + version.setVersionNo(DEFAULT_VERSION_NO); + version.setGenTaskId(revisionKey); + version.setPackageUrl(""); + version.setBundleSize(0L); + version.setChecksum(""); + version.setStatus(STATUS_PREVIEW_READY); + if (gameVersionMapper.insert(version) != 1 || version.getId() == null) { + rejectContentIdentity("prepare 版本创建失败", req.getTenantId(), req.getGameId(), null); + } + // 此处不改 currentVersionId;finalize 只会绑定摘要,浏览器验收后由 activate 单独切换。 + log.info("[prepareContentVersion] 已预留内容版本 tenantId={}, gameId={}, versionId={}, revisionId={}", + req.getTenantId(), req.getGameId(), version.getId(), req.getRevisionId()); + return version.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void validateContentVersionIdentity(ProjectContentVersionIdentityReqDTO req) { + if (req == null || req.getVersionId() == null + || !validContentIdentityInput(req.getTenantId(), req.getGameId(), req.getRevisionId())) { + rejectContentIdentity("identity 入参非法", req == null ? null : req.getTenantId(), + req == null ? null : req.getGameId(), req == null ? null : req.getVersionId()); + } + // Finalize 外层事务首先按 project -> version 统一锁序持锁,后续 Runtime/V34 写入不得反向抢锁。 + ProjectDO project = projectMapper.selectByIdForUpdate(req.getGameId()); + GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId()); + String revisionKey = contentRevisionKey(req.getTenantId(), req.getGameId(), req.getRevisionId()); + if (!matchesProject(project, req.getTenantId(), req.getGameId()) + || !matchesVersion(version, req.getTenantId(), req.getGameId(), revisionKey)) { + rejectContentIdentity("identity 四元身份不一致", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + log.info("[validateContentVersionIdentity] 内容版本身份通过 tenantId={}, gameId={}, versionId={}, revisionId={}", + req.getTenantId(), req.getGameId(), req.getVersionId(), req.getRevisionId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void bindContentRuntimeArtifact(ProjectContentVersionBindReqDTO req) { + if (req == null || req.getVersionId() == null || req.getBundleSize() == null || req.getBundleSize() <= 0 + || !StringUtils.hasText(req.getChecksum()) || !SHA256_PATTERN.matcher(req.getChecksum()).matches() + || !validContentIdentityInput(req.getTenantId(), req.getGameId(), req.getRevisionId())) { + rejectContentIdentity("bind 入参非法", req == null ? null : req.getTenantId(), + req == null ? null : req.getGameId(), req == null ? null : req.getVersionId()); + } + + // 与审核发布保持 project -> version 锁序;持锁后重新验证全部身份,不能信任 Finalize 前的旧读。 + ProjectDO project = projectMapper.selectByIdForUpdate(req.getGameId()); + GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId()); + String revisionKey = contentRevisionKey(req.getTenantId(), req.getGameId(), req.getRevisionId()); + if (!matchesProject(project, req.getTenantId(), req.getGameId()) + || !matchesVersion(version, req.getTenantId(), req.getGameId(), revisionKey)) { + rejectContentIdentity("bind 持锁后身份不一致", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + + boolean sameArtifact = Objects.equals(version.getChecksum(), req.getChecksum()) + && Objects.equals(version.getBundleSize(), req.getBundleSize()); + if (!Objects.equals(version.getStatus(), STATUS_PREVIEW_READY)) { + if (Objects.equals(version.getStatus(), STATUS_PUBLISHED) && sameArtifact + && Objects.equals(project.getCurrentVersionId(), version.getId())) { + log.info("[bindContentRuntimeArtifact] 已发布内容版本同摘要幂等返回 versionId={}", version.getId()); + return; + } + rejectContentIdentity("bind 版本状态或摘要不可改写", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + if (StringUtils.hasText(version.getChecksum()) && !sameArtifact) { + rejectContentIdentity("bind 预览版本已有不同摘要", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + + Integer runtimeStatus; + try { + runtimeStatus = runtimePackageApi.getStatus(req.getVersionId()).getCheckedData(); + } catch (Exception ex) { + log.warn("[bindContentRuntimeArtifact] Runtime 状态读取失败 versionId={}", req.getVersionId(), ex); + throw exception(PROJECT_CONTENT_VERSION_INVALID); + } + if (!PackageStatusEnum.PREVIEW_READY.getStatus().equals(runtimeStatus) + && !PackageStatusEnum.PUBLISHED.getStatus().equals(runtimeStatus)) { + rejectContentIdentity("bind Runtime 包未就绪", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + + if (!sameArtifact) { + GameVersionDO update = new GameVersionDO(); + update.setId(req.getVersionId()); + update.setChecksum(req.getChecksum()); + update.setBundleSize(req.getBundleSize()); + if (gameVersionMapper.updateById(update) != 1) { + rejectContentIdentity("bind 版本摘要更新失败", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + } + log.info("[bindContentRuntimeArtifact] 内容版本摘要绑定完成,等待验收激活 tenantId={}, gameId={}, versionId={}, checksum={}", + req.getTenantId(), req.getGameId(), req.getVersionId(), req.getChecksum()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void activateContentVersion(ProjectContentVersionActivateReqDTO req) { + if (req == null || req.getVersionId() == null + || (req.getExpectedCurrentVersionId() != null && req.getExpectedCurrentVersionId() <= 0) + || !validContentIdentityInput(req.getTenantId(), req.getGameId(), req.getRevisionId())) { + rejectContentIdentity("activate 入参非法", req == null ? null : req.getTenantId(), + req == null ? null : req.getGameId(), req == null ? null : req.getVersionId()); + } + + // 与 finalize 保持 project -> version 锁序,锁内同时校验归属、摘要和 CAS 前置。 + ProjectDO project = projectMapper.selectByIdForUpdate(req.getGameId()); + GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId()); + String revisionKey = contentRevisionKey(req.getTenantId(), req.getGameId(), req.getRevisionId()); + if (!matchesProject(project, req.getTenantId(), req.getGameId()) + || !matchesVersion(version, req.getTenantId(), req.getGameId(), revisionKey) + || !StringUtils.hasText(version.getChecksum()) + || !SHA256_PATTERN.matcher(version.getChecksum()).matches() + || version.getBundleSize() == null || version.getBundleSize() <= 0) { + rejectContentIdentity("activate 版本未完成 finalize", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + + Long currentVersionId = project.getCurrentVersionId(); + if (Objects.equals(currentVersionId, req.getVersionId())) { + // 激活成功后发布编排会把版本推进为 PUBLISHED;迟到重试仍须保持幂等。 + if (!Objects.equals(version.getStatus(), STATUS_PREVIEW_READY) + && !Objects.equals(version.getStatus(), STATUS_PUBLISHED)) { + rejectContentIdentity("activate 当前版本状态非法", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + log.info("[activateContentVersion] 目标版本已激活,幂等返回 gameId={}, versionId={}", + req.getGameId(), req.getVersionId()); + return; + } + if (!Objects.equals(version.getStatus(), STATUS_PREVIEW_READY)) { + rejectContentIdentity("activate 非当前版本必须处于预览态", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + if (!Objects.equals(currentVersionId, req.getExpectedCurrentVersionId())) { + rejectContentIdentity("activate expected-current 已漂移", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + // game_version.id 由同库自增分配;只允许首次激活或向更大的新代际前进。 + if (currentVersionId != null && currentVersionId > req.getVersionId()) { + rejectContentIdentity("activate 禁止回退到旧版本", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + + Integer runtimeStatus; + try { + runtimeStatus = runtimePackageApi.getStatus(req.getVersionId()).getCheckedData(); + } catch (Exception ex) { + log.warn("[activateContentVersion] Runtime 状态读取失败 versionId={}", req.getVersionId(), ex); + throw exception(PROJECT_CONTENT_VERSION_INVALID); + } + if (!PackageStatusEnum.PREVIEW_READY.getStatus().equals(runtimeStatus)) { + rejectContentIdentity("activate Runtime 预览包未就绪", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + + ProjectDO update = new ProjectDO(); + update.setId(req.getGameId()); + update.setCurrentVersionId(req.getVersionId()); + if (projectMapper.updateById(update) != 1) { + rejectContentIdentity("activate 项目当前版本更新失败", req.getTenantId(), req.getGameId(), req.getVersionId()); + } + log.info("[activateContentVersion] 验收版本已激活 tenantId={}, gameId={}, versionId={}, previousVersionId={}", + req.getTenantId(), req.getGameId(), req.getVersionId(), currentVersionId); + } + + /** 为内容源修订构造固定 64 位幂等键,适配 game_version.gen_task_id 现有列宽。 */ + static String contentRevisionKey(long tenantId, long gameId, String revisionId) { + String material = CONTENT_REVISION_KEY_DOMAIN + tenantId + "\0" + gameId + "\0" + revisionId; + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(material.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("JDK 缺少 SHA-256", ex); + } + } + + /** 校验内容控制面基础身份字段,防止直接 Java 调用绕过 DTO 校验。 */ + private static boolean validContentIdentityInput(Long tenantId, Long gameId, String revisionId) { + return tenantId != null && tenantId > 0 && gameId != null && gameId > 0 + && StringUtils.hasText(revisionId) && REVISION_PATTERN.matcher(revisionId).matches(); + } + + /** 项目必须是未删除且精确属于请求租户的同一行。 */ + private static boolean matchesProject(ProjectDO project, Long tenantId, Long gameId) { + return project != null && !Boolean.TRUE.equals(project.getDeleted()) + && Objects.equals(project.getId(), gameId) && Objects.equals(project.getTenantId(), tenantId); + } + + /** 版本必须精确属于租户、项目和源修订幂等键,且未逻辑删除。 */ + private static boolean matchesVersion(GameVersionDO version, Long tenantId, Long gameId, String revisionKey) { + return version != null && !Boolean.TRUE.equals(version.getDeleted()) + && Objects.equals(version.getTenantId(), tenantId) && Objects.equals(version.getGameId(), gameId) + && Objects.equals(version.getGenTaskId(), revisionKey); + } + + /** 统一记录不含密钥或正文的拒绝日志并抛稳定业务码。 */ + private static void rejectContentIdentity(String reason, Long tenantId, Long gameId, Long versionId) { + log.warn("[gameContentVersion] 拒绝内容版本操作 reason={}, tenantId={}, gameId={}, versionId={}", + reason, tenantId, gameId, versionId); + throw exception(PROJECT_CONTENT_VERSION_INVALID); + } + @Override public Long createForPackage(ProjectVersionCreateForPackageReqDTO req) { // 幂等:同 genTaskId 已建版本则直接复用,不重复建(PackageFactory 重复落包安全) diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java index 48b3c31a..2a1610de 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java @@ -6,7 +6,11 @@ import com.wanxiang.huijing.game.module.project.dal.dataobject.project.ProjectDO import com.wanxiang.huijing.game.module.project.dal.dataobject.version.GameVersionDO; import com.wanxiang.huijing.game.module.project.dal.mysql.project.ProjectMapper; import com.wanxiang.huijing.game.module.project.dal.mysql.version.GameVersionMapper; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionActivateReqDTO; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionBindReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionIdentityReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectContentVersionPrepareReqDTO; import com.wanxiang.huijing.game.module.runtime.api.RuntimePackageApi; import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; import org.junit.jupiter.api.Test; @@ -16,9 +20,12 @@ import org.mockito.InOrder; import org.mockito.Mock; import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED; +import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_CONTENT_VERSION_INVALID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verify; @@ -45,6 +52,178 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { @Mock private RuntimePackageApi runtimePackageApi; + @Test + void prepareContentVersion_createsStablePreviewReservationWithoutExposingCurrentVersion() { + ProjectDO project = project(1024L, 7L, null); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + when(gameVersionMapper.selectByGenTaskId(anyString())).thenReturn(null); + doAnswer(invocation -> { + GameVersionDO version = invocation.getArgument(0); + version.setId(2048L); + return 1; + }).when(gameVersionMapper).insert(any(GameVersionDO.class)); + + Long versionId = gameVersionService.prepareContentVersion(prepareReq(7L, 1024L, "revision-a")); + + assertEquals(2048L, versionId); + ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(GameVersionDO.class); + verify(gameVersionMapper).insert(versionCaptor.capture()); + assertEquals(1024L, versionCaptor.getValue().getGameId()); + assertEquals(2, versionCaptor.getValue().getStatus()); + assertEquals(64, versionCaptor.getValue().getGenTaskId().length()); + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + } + + @Test + void prepareContentVersion_reusesSameRevisionUnderProjectLock() { + ProjectDO project = project(1024L, 7L, null); + GameVersionDO existing = contentVersion(2048L, 1024L, 7L, "revision-a"); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + when(gameVersionMapper.selectByGenTaskId(anyString())).thenReturn(existing); + + Long versionId = gameVersionService.prepareContentVersion(prepareReq(7L, 1024L, "revision-a")); + + assertEquals(2048L, versionId); + verify(gameVersionMapper, never()).insert(any(GameVersionDO.class)); + } + + @Test + void prepareContentVersion_rejectsCrossTenantProjectWithoutWriting() { + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 8L, null)); + + ServiceException error = assertThrows(ServiceException.class, + () -> gameVersionService.prepareContentVersion(prepareReq(7L, 1024L, "revision-a"))); + + assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), error.getCode()); + verify(gameVersionMapper, never()).insert(any(GameVersionDO.class)); + } + + @Test + void validateContentVersionIdentity_rejectsMissingOrCrossGameVersion() { + ProjectContentVersionIdentityReqDTO req = identityReq(7L, 1024L, 2048L, "revision-a"); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, null)); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(null); + ServiceException missing = assertThrows(ServiceException.class, + () -> gameVersionService.validateContentVersionIdentity(req)); + assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), missing.getCode()); + + GameVersionDO crossGame = contentVersion(2048L, 2049L, 7L, "revision-a"); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(crossGame); + ServiceException mismatch = assertThrows(ServiceException.class, + () -> gameVersionService.validateContentVersionIdentity(req)); + assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), mismatch.getCode()); + } + + @Test + void bindContentRuntimeArtifact_recordsSummaryWithoutActivatingProject() { + ProjectContentVersionBindReqDTO req = bindContentReq(); + ProjectDO project = project(1024L, 7L, null); + GameVersionDO version = contentVersion(2048L, 1024L, 7L, "revision-a"); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version); + when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.PREVIEW_READY.getStatus())); + when(gameVersionMapper.updateById(any(GameVersionDO.class))).thenReturn(1); + + gameVersionService.bindContentRuntimeArtifact(req); + + InOrder order = org.mockito.Mockito.inOrder(projectMapper, gameVersionMapper, runtimePackageApi); + order.verify(projectMapper).selectByIdForUpdate(1024L); + order.verify(gameVersionMapper).selectByIdForUpdate(2048L); + order.verify(runtimePackageApi).getStatus(2048L); + ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(GameVersionDO.class); + verify(gameVersionMapper).updateById(versionCaptor.capture()); + assertEquals("a".repeat(64), versionCaptor.getValue().getChecksum()); + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + } + + @Test + void bindContentRuntimeArtifact_oldReplayOnlyConfirmsOwnSummary() { + ProjectContentVersionBindReqDTO req = bindContentReq(); + ProjectDO project = project(1024L, 7L, 2050L); + GameVersionDO oldVersion = contentVersion(2048L, 1024L, 7L, "revision-a"); + oldVersion.setChecksum("a".repeat(64)); + oldVersion.setBundleSize(4096L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(oldVersion); + when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.PREVIEW_READY.getStatus())); + + gameVersionService.bindContentRuntimeArtifact(req); + + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class)); + } + + @Test + void activateContentVersion_switchesPointerOnlyAfterExpectedCurrentMatches() { + ProjectContentVersionActivateReqDTO req = activateContentReq(2000L, 2048L); + ProjectDO project = project(1024L, 7L, 2000L); + GameVersionDO version = finalizedContentVersion(2048L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version); + when(runtimePackageApi.getStatus(2048L)).thenReturn(success(PackageStatusEnum.PREVIEW_READY.getStatus())); + when(projectMapper.updateById(any(ProjectDO.class))).thenReturn(1); + + gameVersionService.activateContentVersion(req); + + ArgumentCaptor projectCaptor = ArgumentCaptor.forClass(ProjectDO.class); + verify(projectMapper).updateById(projectCaptor.capture()); + assertEquals(2048L, projectCaptor.getValue().getCurrentVersionId()); + } + + @Test + void activateContentVersion_rejectsStaleExpectedCurrentWithoutWriting() { + ProjectContentVersionActivateReqDTO req = activateContentReq(1999L, 2048L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, 2000L)); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(finalizedContentVersion(2048L)); + + ServiceException error = assertThrows(ServiceException.class, + () -> gameVersionService.activateContentVersion(req)); + + assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), error.getCode()); + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + verifyNoInteractions(runtimePackageApi); + } + + @Test + void activateContentVersion_rejectsRollbackEvenWhenExpectedCurrentMatches() { + ProjectContentVersionActivateReqDTO req = activateContentReq(2050L, 2048L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, 2050L)); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(finalizedContentVersion(2048L)); + + ServiceException error = assertThrows(ServiceException.class, + () -> gameVersionService.activateContentVersion(req)); + + assertEquals(PROJECT_CONTENT_VERSION_INVALID.getCode(), error.getCode()); + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + verifyNoInteractions(runtimePackageApi); + } + + @Test + void activateContentVersion_sameCurrentIsIdempotent() { + ProjectContentVersionActivateReqDTO req = activateContentReq(2048L, 2048L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, 2048L)); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(finalizedContentVersion(2048L)); + + gameVersionService.activateContentVersion(req); + + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + verifyNoInteractions(runtimePackageApi); + } + + @Test + void activateContentVersion_publishedSameCurrentRetryIsIdempotent() { + ProjectContentVersionActivateReqDTO req = activateContentReq(2048L, 2048L); + GameVersionDO publishedVersion = finalizedContentVersion(2048L); + publishedVersion.setStatus(3); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project(1024L, 7L, 2048L)); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(publishedVersion); + + gameVersionService.activateContentVersion(req); + + verify(projectMapper, never()).updateById(any(ProjectDO.class)); + verifyNoInteractions(runtimePackageApi); + } + @Test void bindRuntimeArtifact_updatesAuthoritativeChecksumForApproveGate() { ProjectVersionBindArtifactReqDTO req = artifactReq("a".repeat(64)); @@ -202,6 +381,77 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { return req; } + /** 构造内容仓库预留版本入参。 */ + private static ProjectContentVersionPrepareReqDTO prepareReq(long tenantId, long gameId, String revisionId) { + ProjectContentVersionPrepareReqDTO req = new ProjectContentVersionPrepareReqDTO(); + req.setTenantId(tenantId); + req.setGameId(gameId); + req.setRevisionId(revisionId); + return req; + } + + /** 构造内容版本身份校验入参。 */ + private static ProjectContentVersionIdentityReqDTO identityReq( + long tenantId, long gameId, long versionId, String revisionId) { + ProjectContentVersionIdentityReqDTO req = new ProjectContentVersionIdentityReqDTO(); + req.setTenantId(tenantId); + req.setGameId(gameId); + req.setVersionId(versionId); + req.setRevisionId(revisionId); + return req; + } + + /** 构造内容运行包绑定入参。 */ + private static ProjectContentVersionBindReqDTO bindContentReq() { + ProjectContentVersionBindReqDTO req = new ProjectContentVersionBindReqDTO(); + req.setTenantId(7L); + req.setGameId(1024L); + req.setVersionId(2048L); + req.setRevisionId("revision-a"); + req.setChecksum("a".repeat(64)); + req.setBundleSize(4096L); + return req; + } + + /** 构造验收后激活请求,expected 是验收开始时的项目指针。 */ + private static ProjectContentVersionActivateReqDTO activateContentReq(Long expectedCurrentVersionId, + long versionId) { + ProjectContentVersionActivateReqDTO req = new ProjectContentVersionActivateReqDTO(); + req.setTenantId(7L); + req.setGameId(1024L); + req.setVersionId(versionId); + req.setRevisionId("revision-a"); + req.setExpectedCurrentVersionId(expectedCurrentVersionId); + return req; + } + + /** 构造含显式租户归属的项目。 */ + private static ProjectDO project(long id, long tenantId, Long currentVersionId) { + ProjectDO project = new ProjectDO(); + project.setId(id); + project.setTenantId(tenantId); + project.setCurrentVersionId(currentVersionId); + return project; + } + + /** 构造与内容修订幂等键一致的版本;幂等键算法由生产服务统一提供。 */ + private static GameVersionDO contentVersion(long id, long gameId, long tenantId, String revisionId) { + GameVersionDO version = version(2, "", 0L); + version.setId(id); + version.setGameId(gameId); + version.setTenantId(tenantId); + version.setGenTaskId(GameVersionServiceImpl.contentRevisionKey(tenantId, gameId, revisionId)); + return version; + } + + /** 构造 finalize 已写入权威摘要的内容版本。 */ + private static GameVersionDO finalizedContentVersion(long id) { + GameVersionDO version = contentVersion(id, 1024L, 7L, "revision-a"); + version.setChecksum("a".repeat(64)); + version.setBundleSize(4096L); + return version; + } + /** 准备版本、项目、runtime 三方都指向同一待绑定版本的正常场景。 */ private void prepareBindableVersion() { GameVersionDO version = version(2, "", 0L); diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java index 76fb871d..f39ec307 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java +++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApi.java @@ -1,6 +1,8 @@ package com.wanxiang.huijing.game.module.runtime.api; import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO; import com.wanxiang.huijing.game.module.runtime.enums.ApiConstants; import com.wanxiang.huijing.framework.common.pojo.CommonResult; import io.swagger.v3.oas.annotations.Operation; @@ -85,4 +87,14 @@ public interface RuntimePackageApi { @Operation(summary = "落包:建运行包行(status=0)+写 manifest(agent 闭环/Dify 共用)") CommonResult storeForVersion(@RequestBody @Valid RuntimePackageStoreReqDTO req); + /** + * 受信任内容控制面在同 JVM 事务内落 OSS 运行包预览元数据。 + * + *

Feign 契约保留映射以保证代理可装配,但 Runtime 实现会无条件拒绝直接命中该 RPC 的 HTTP 请求; + * 外部唯一可执行入口是 AIGC HMAC 控制面内的同 JVM 调用。

+ */ + @PostMapping(PREFIX + "/finalize-oss-package") + CommonResult finalizeOssPackage( + @RequestBody @Valid RuntimeOssPackageFinalizeReqDTO req); + } diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeReqDTO.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeReqDTO.java new file mode 100644 index 00000000..8d9bfd02 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeReqDTO.java @@ -0,0 +1,45 @@ +package com.wanxiang.huijing.game.module.runtime.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * OSS GamePackage Finalize 入参。 + * + *

{@code manifestJson} 只在同 JVM 事务调用中用于现场复算摘要和提取预览元数据, + * Runtime 数据库严禁持久化该正文。

+ */ +@Data +public class RuntimeOssPackageFinalizeReqDTO { + + /** 目标租户 ID。 */ + @NotNull(message = "tenantId 不能为空") + private Long tenantId; + + /** 目标游戏项目 ID。 */ + @NotNull(message = "gameId 不能为空") + private Long gameId; + + /** project prepare 返回的权威版本 ID。 */ + @NotNull(message = "versionId 不能为空") + private Long versionId; + + /** V34 记录绑定的不可变源修订 ID。 */ + @NotBlank(message = "revisionId 不能为空") + @Size(max = 128, message = "revisionId 长度不能超过 128") + @Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9._-]*$", message = "revisionId 格式非法") + private String revisionId; + + /** GameArtifactManifest/1 的域分隔 canonical 摘要。 */ + @NotBlank(message = "artifactManifestHash 不能为空") + @Pattern(regexp = "^[a-f0-9]{64}$", message = "artifactManifestHash 必须为 64 位小写十六进制 sha256") + private String artifactManifestHash; + + /** 已上传至 canonical runtime key 的 GamePackage 原始 UTF-8 JSON 文本。 */ + @NotBlank(message = "manifestJson 不能为空") + @Size(max = 16777216, message = "manifestJson 超过 16MiB") + private String manifestJson; +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeRespDTO.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeRespDTO.java new file mode 100644 index 00000000..0f19c9b9 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/dto/RuntimeOssPackageFinalizeRespDTO.java @@ -0,0 +1,21 @@ +package com.wanxiang.huijing.game.module.runtime.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Runtime 已现场验证并落库的 OSS 预览元数据。 */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RuntimeOssPackageFinalizeRespDTO { + + /** game_runtime_package 主键。 */ + private Long packageId; + + /** GamePackage 原始 UTF-8 字节 SHA-256。 */ + private String checksum; + + /** GamePackage manifest 声明的运行包字节数。 */ + private Long bundleSize; +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java index a688a5ba..33cf1e1c 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-runtime/game-module-runtime-api/src/main/java/com/wanxiang/huijing/game/module/runtime/enums/ErrorCodeConstants.java @@ -31,6 +31,12 @@ public interface ErrorCodeConstants { ErrorCode RUNTIME_PACKAGE_INVALIDATE_REJECTED = new ErrorCode(1_102_001_006, "运行包不存在或状态不可失效"); /** 狗粮态禁止通过 runtime RPC 的外部 HTTP 入口写运行包。 */ ErrorCode RUNTIME_DOGFOOD_RPC_WRITE_DISABLED = new ErrorCode(1_102_001_007, "内部狗粮环境禁止直接写运行包"); + /** OSS 内容 Finalize 只允许在 Runtime 已切到 OSS 读取模式时执行。 */ + ErrorCode RUNTIME_OSS_FINALIZE_DISABLED = new ErrorCode(1_102_001_008, "Runtime 尚未启用 OSS 内容模式"); + /** OSS 内容 Finalize 的身份、locator、摘要或 GamePackage 元数据不一致。 */ + ErrorCode RUNTIME_OSS_FINALIZE_INVALID = new ErrorCode(1_102_001_009, "OSS 运行包身份或摘要无效"); + /** Runtime OSS Finalize RPC 禁止外部 HTTP 直调,只允许 AIGC HMAC 控制面本地调用。 */ + ErrorCode RUNTIME_OSS_FINALIZE_RPC_DISABLED = new ErrorCode(1_102_001_010, "OSS Finalize 仅允许受信任控制面调用"); // ========== 编译 1-102-002-*** (对齐契约 #1 BuildRespVO.failCode)========== /** GameConfig 静态校验失败(门禁 T-RT-16,编译期对接点判定) */ diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml b/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml index d234c00c..5c7ac9b2 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml +++ b/game-cloud/game-module-runtime/game-module-runtime-server/pom.xml @@ -72,6 +72,12 @@ huijing-spring-boot-starter-rpc + + + software.amazon.awssdk + s3 + + com.wanxiang diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java index 220ad2d2..2262be00 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImpl.java @@ -1,16 +1,24 @@ package com.wanxiang.huijing.game.module.runtime.api; import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO; import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageService; import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import com.wanxiang.huijing.framework.common.util.servlet.ServletUtils; import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.HandlerMapping; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_DOGFOOD_RPC_WRITE_DISABLED; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_RPC_DISABLED; +import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; /** * 运行包发布 API 实现(黄金闭环 §3.2 C2,提供 RESTful 接口给跨模块 Feign 调用:发布编排翻包 + 可见态校验 + 落包) @@ -24,6 +32,7 @@ import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants. @RestController // 提供 RESTful API 接口,给 Feign 调用 @Validated @Primary // 与 @FeignClient 接口同名 Bean 冲突时优先用本地实现(同进程调用就地解析,发布编排事务内本地调用) +@Slf4j public class RuntimePackageApiImpl implements RuntimePackageApi { @Resource @@ -60,6 +69,32 @@ public class RuntimePackageApiImpl implements RuntimePackageApi { return success(runtimePackageService.storeForVersion(req)); } + @Override + public CommonResult finalizeOssPackage(RuntimeOssPackageFinalizeReqDTO req) { + rejectExternalOssFinalize(); + return success(runtimePackageService.finalizeOssPackage(req)); + } + + /** OSS Finalize 无论环境都禁止外部直调,只信任 AIGC 请求内的同 JVM 方法调用。 */ + private void rejectExternalOssFinalize() { + HttpServletRequest request = ServletUtils.getRequest(); + if (request == null) { + return; + } + Object patternAttribute = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + if (patternAttribute == null) { + return; + } + String pattern = patternAttribute.toString(); + if (!pattern.equals(RuntimePackageApi.PREFIX) + && !pattern.startsWith(RuntimePackageApi.PREFIX + "/")) { + return; + } + log.warn("[runtimeRpcGuard] 已拒绝外部 OSS Finalize HTTP 写入口 code={}", + RUNTIME_OSS_FINALIZE_RPC_DISABLED.getCode()); + throw exception(RUNTIME_OSS_FINALIZE_RPC_DISABLED); + } + /** 仅封锁 runtime RPC 的外部 HTTP 写入;project/aigc 请求内的本地 Java 调用和无请求上下文调用保持可用。 */ private void rejectExternalRpcWrite(String operation) { dogfoodAccessGuard.rejectHttpPathInDogfood(operation, RuntimePackageApi.PREFIX, diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java index 4324d7b0..7a13a940 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeController.java @@ -20,6 +20,8 @@ import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; import org.springframework.http.MediaType; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -59,7 +61,15 @@ public class AppRuntimeController { // 取包门禁(scene+status 权威判定 + 预览归属)在 Service 承载 RuntimePackageDO pkg = runtimePackageService.getPackageManifest(versionId, scene, userId); // manifestUrl 继承本次请求的 scene:preview 场景拼 ?scene=preview,避免宿主裸 GET 落 play 缺省门禁(冒烟⑥缝隙修复) - return success(RuntimeConvert.toPackageRespVO(pkg, scene)); + RuntimePackageRespVO response = RuntimeConvert.toPackageRespVO(pkg, scene); + if (runtimePackageService.isObjectAssetProxyEnabled()) { + String template = "/app-api/runtime/package/" + versionId + "/assets/{sha256}"; + if (RuntimeSceneEnum.isPreview(scene)) { + template += "?scene=preview"; + } + response.setAssetUrlTemplate(template); + } + return success(response); } @GetMapping(value = "/package/{versionId}/manifest", produces = MediaType.APPLICATION_JSON_VALUE) @@ -76,6 +86,31 @@ public class AppRuntimeController { return runtimePackageService.getPackageManifestRaw(versionId, scene, userId); } + @GetMapping(value = "/package/{versionId}/assets/{sha256}") + @PermitAll + @Operation(summary = "按内容摘要读取版本素材", + description = "复用取包场景/归属门,从 committed artifact manifest 映射精确对象并逐字节复算") + public ResponseEntity getPackageAsset( + @PathVariable("versionId") Long versionId, + @PathVariable("sha256") String sha256, + @RequestParam(value = "scene", required = false, defaultValue = "play") String scene) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimeAssetContent asset = + runtimePackageService.getPackageAsset(versionId, scene, userId, sha256); + CacheControl cacheControl = RuntimeSceneEnum.isPreview(scene) + // 未发布预览素材受 owner 门保护,任何共享缓存和浏览器持久缓存都不得留副本。 + ? CacheControl.noStore().cachePrivate() + // 已发布素材由 version + sha256 内容寻址,可安全长期公开缓存。 + : CacheControl.maxAge(java.time.Duration.ofDays(365)).cachePublic().immutable(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(asset.mime())) + .contentLength(asset.bytes().length) + .eTag('"' + asset.sha256() + '"') + .cacheControl(cacheControl) + .header("X-Content-Type-Options", "nosniff") + .body(asset.bytes()); + } + @PostMapping("/session/start") @PermitAll // 2026-06-10 鉴权件放行,匿名合法:匿名试玩开会话(§6.1 #7);userId 可空时落 player_user_id=NULL + reqVO.anonId 归属(对齐 V11 ALTER) @Operation(summary = "开始试玩会话", description = "宿主在游戏 game_start(←#3) 时调用;clientPlayToken 幂等;匿名携带 anonId 归属") diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java index ed7d64db..a19cb383 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/RuntimePackageRespVO.java @@ -26,10 +26,13 @@ public class RuntimePackageRespVO { @Schema(description = "GamePackage(manifest)OSS/CDN URL(按 /games/{gameId}/versions/{versionId}/ 版本化)") private String packageUrl; - @Schema(description = "manifest.json 取包清单 URL(GAP-2 分支A:由 packageUrl 同前缀派生 .../manifest.json,宿主先 fetch 校验 sha256 再注入)", + @Schema(description = "同源 Runtime manifest URL;服务端按读取模式取原文,宿主校验 sha256 后注入", example = "https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json") private String manifestUrl; + @Schema(description = "对象素材同源代理模板;宿主把 {sha256} 替换为 assets[].hash 后带平台鉴权读取") + private String assetUrlTemplate; + @Schema(description = "入口文件相对路径(#4 manifest.entry)", example = "index.html") private String entry; diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/SandboxPolicyVO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/SandboxPolicyVO.java index 68c72bc6..bf43c030 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/SandboxPolicyVO.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/vo/SandboxPolicyVO.java @@ -8,7 +8,7 @@ import java.util.List; /** * iframe 沙箱隔离策略 VO(对齐契约 SandboxPolicyVO,T-RT-04) * - * 宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14,#3 双校验)。 + * 宿主据此设置 iframe sandbox 属性与 CSP、postMessage origin 白名单(T-RT-14,来源窗口/origin/schema 三校验)。 * * @author 绘境AI */ @@ -16,10 +16,10 @@ import java.util.List; @Data public class SandboxPolicyVO { - @Schema(description = "iframe sandbox 属性值", example = "allow-scripts allow-same-origin") + @Schema(description = "iframe sandbox 属性值", example = "allow-scripts") private String sandboxAttr; - @Schema(description = "postMessage 允许的 origin 白名单(宿主侧 #3 双校验)") + @Schema(description = "postMessage 允许的 origin 白名单(宿主侧来源窗口/origin/schema 三校验)") private List allowOrigins; } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvert.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvert.java index 575153a0..3670d245 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvert.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvert.java @@ -55,11 +55,8 @@ public class RuntimeConvert { } // 平铺字段(gameId/versionId/templateId/packageUrl/entry/runtimeVersion/preloadPolicy/bundleSize/checksum 同名直拷) RuntimePackageRespVO vo = BeanUtils.toBean(pkg, RuntimePackageRespVO.class); - // manifestUrl 解析(§3.4 C4 退场契约): - // - MVP 无 OSS(packageUrl 空,整包存 DB):指向 manifest 端点 /app-api/runtime/package/{versionId}/manifest, - // 宿主 fetch 该端点取原始 JSON、对响应文本算 sha256 与 checksum 比对后注入(相对 URL 由前端 resolve 到 API base,§2.5); - // preview 场景必须拼 ?scene=preview,使 manifest 端点门禁与取包端点同场景判定(见上方法注释的缝隙说明); - // - M3 接 OSS(packageUrl 非空):回落 GAP-2 分支A 由 packageUrl 同前缀派生 .../manifest.json(OSS/CDN 版本化 URL,静态文件无门禁不拼参)。 + // manifestUrl 始终指向同源 Runtime API。后端可在 DB/shadow/OSS 三模式切流,浏览器不感知对象域, + // 也不会把平台 Authorization/tenant-id 发往 MinIO 或 CDN。 vo.setManifestUrl(resolveManifestUrl(pkg, scene)); // 沙箱策略单独组装:sandboxAttr 直拷,allowOrigins 逗号串拆 List SandboxPolicyVO sandbox = new SandboxPolicyVO(); @@ -70,49 +67,18 @@ public class RuntimeConvert { } /** - * 解析 manifestUrl(§3.4 C4 退场契约:MVP 走 DB 端点 / M3 走 OSS 派生) + * 解析同源 manifestUrl;存储介质切换由服务端 read-mode 承担。 * - * @param pkg 运行包 DO(取 packageUrl 判 MVP/OSS,versionId 拼端点路径) - * @param scene 取包请求场景:仅 preview 在 DB 端点分支拼 ?scene=preview(play/null 保持历史裸路径,行为零变化) - * @return manifestUrl:packageUrl 空 → manifest 端点相对路径(preview 带 scene 参数);非空 → 同前缀派生 .json + * @param pkg 运行包 DO(只使用 versionId 拼同源端点路径) + * @param scene 取包请求场景:preview 拼 ?scene=preview,play/null 保持裸路径 + * @return 固定的同源 Runtime manifest 端点 */ public static String resolveManifestUrl(RuntimePackageDO pkg, String scene) { - if (!StringUtils.hasText(pkg.getPackageUrl())) { - // MVP 无 OSS:整包存 DB,manifestUrl 指向原样服务端点(相对路径,前端 resolve 到 API base) - String url = "/app-api/runtime/package/" + pkg.getVersionId() + "/manifest"; - // preview 场景拼参:manifest 端点 scene 缺省为 play(仅放行 status=1), - // 预览 status=0 包必须显式声明 preview 才能过同场景门禁(缝隙修复方案 A) - if ("preview".equals(scene)) { - url += "?scene=preview"; - } - return url; + String url = "/app-api/runtime/package/" + pkg.getVersionId() + "/manifest"; + if ("preview".equals(scene)) { + url += "?scene=preview"; } - // M3 OSS:保留 GAP-2 分支A 派生(静态文件无门禁,scene 不参与) - return deriveManifestUrl(pkg.getPackageUrl()); - } - - /** - * 由 packageUrl 同前缀派生 manifest.json 取包清单 URL(GAP-2 分支A,纯派生不增列) - * - * 规则:取 packageUrl 末段 '/' 之前的同级目录前缀,拼接 manifest.json。 - * - 以 '/' 结尾(目录形态,如 .../versions/2048/):直接追加 manifest.json; - * - 含文件名(如 .../2048/package.zip):替换末段文件名为 manifest.json,保持同前缀同级; - * - 空/空白:返回 null(无包则无清单,宿主走兜底,不构造非法 URL)。 - * - * @param packageUrl 运行包 OSS/CDN URL(按 /games/{gameId}/versions/{versionId}/ 版本化) - * @return manifest.json 清单 URL;packageUrl 空时返回 null - */ - public static String deriveManifestUrl(String packageUrl) { - if (!StringUtils.hasText(packageUrl)) { - return null; - } - int lastSlash = packageUrl.lastIndexOf('/'); - // 无 '/'(异常退化形态):直接当作前缀目录,拼接 manifest.json,避免越权改写其它路径 - if (lastSlash < 0) { - return packageUrl + "/manifest.json"; - } - // 保留含末位 '/' 的同级前缀,替换末段文件名(或空段)为 manifest.json - return packageUrl.substring(0, lastSlash + 1) + "manifest.json"; + return url; } // ============================== 编译任务 ============================== diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java index 530918f4..42906a5d 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/pkg/RuntimePackageDO.java @@ -69,7 +69,7 @@ public class RuntimePackageDO extends TenantBaseDO { */ private String sandboxAttr; /** - * postMessage 允许 origin 白名单(逗号分隔,宿主侧 #3 双校验 T-RT-14;VO 层转 List) + * postMessage 允许 origin 白名单(逗号分隔,宿主侧来源窗口/origin/schema 三校验;VO 层转 List) */ private String allowOrigins; /** @@ -82,7 +82,7 @@ public class RuntimePackageDO extends TenantBaseDO { /** * 整包 manifest 原始 JSON(黄金闭环 §3.4 C4,V10 新增 package_json LONGTEXT) * - * MVP 无 OSS:整包存 DB,由 {@code PackageStore} 的 DB impl 读写本列(M3 接 OSS 后下线,退场契约)。 + * 旧 DB 模式由 {@code PackageStore} 读写;OSS 版本保持 NULL,具体游戏正文只存在对象存储。 * manifest 端点 GET /app-api/runtime/package/{versionId}/manifest 经 PackageStore 读出原样返回, * 不包 CommonResult、不 parse/re-serialize(任何重序列化都破坏字节一致性导致宿主 sha256 校验失败)。 */ diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/storage/ArtifactStorageDO.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/storage/ArtifactStorageDO.java index c16c13bc..406c2624 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/storage/ArtifactStorageDO.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/dataobject/storage/ArtifactStorageDO.java @@ -28,7 +28,7 @@ public class ArtifactStorageDO extends TenantBaseDO { private String artifactBucket; /** artifact-manifest.json 对象 key。 */ private String artifactManifestKey; - /** artifact-manifest.json 原始字节 SHA-256。 */ + /** GameArtifactManifest/1 的 manifestHash(域标签加 canonical JSON 摘要)。 */ private String artifactManifestHash; /** artifact-manifest.json 字节数。 */ private Long artifactManifestBytes; @@ -46,7 +46,7 @@ public class ArtifactStorageDO extends TenantBaseDO { private String sourceRevisionId; /** source-manifest.json 对象 key。 */ private String sourceManifestKey; - /** source-manifest.json 原始字节 SHA-256。 */ + /** GameSourceArchive/1 的 manifestHash(域标签加 canonical JSON 摘要)。 */ private String sourceManifestHash; /** 源文件树 SHA-256。 */ private String sourceHash; diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/mysql/storage/ArtifactStorageMapper.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/mysql/storage/ArtifactStorageMapper.java index 2b51f2ca..507efc17 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/mysql/storage/ArtifactStorageMapper.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/dal/mysql/storage/ArtifactStorageMapper.java @@ -30,4 +30,10 @@ public interface ArtifactStorageMapper extends BaseMapperX { .eq(ArtifactStorageDO::getVersionId, versionId) .eq(ArtifactStorageDO::getStatus, "committed")); } + + /** Finalize 事务锁定指定三元身份的 pending/committed 状态记录。 */ + default ArtifactStorageDO selectByIdentityForUpdate(Long tenantId, Long gameId, Long versionId) { + return selectOneForUpdate(ArtifactStorageDO::getTenantId, tenantId, + ArtifactStorageDO::getGameId, gameId, ArtifactStorageDO::getVersionId, versionId); + } } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGate.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGate.java index ef2b9d9d..b556827d 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGate.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGate.java @@ -3,16 +3,11 @@ package com.wanxiang.huijing.game.module.runtime.service.pkg; import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO; import com.wanxiang.huijing.game.module.runtime.dal.mysql.storage.ArtifactStorageMapper; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; import java.util.Objects; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_ARTIFACT_MISMATCH; @@ -24,111 +19,56 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept * *

开关默认关闭,允许 V34 尚未部署的环境继续使用旧 DB manifest 路径;打开后,运行时必须 * 找到 committed 记录,且记录中的 GamePackage manifest hash 必须与 runtime package checksum - * 一致;若 manifest 含 engineBundle,还必须把实际 bundle 字节 hash 与对象记录对账。

+ * 一致,并且桶和 key 必须与三元业务身份的 canonical locator 一致。

*/ @Slf4j @Component public class ArtifactStorageGate { - /** 只读解析原始 GamePackage,不对外重新序列化,避免破坏 checksum 字节语义。 */ - private static final ObjectMapper PACKAGE_MAPPER = new ObjectMapper(); - @Resource private ArtifactStorageMapper artifactStorageMapper; - /** - * 生产切换开关。只有 V34 已落库且上传器完成 pending→committed 后才允许打开。 - */ - @Value("${game.artifact-storage.enforce-committed:false}") - private boolean enforceCommitted; + @Resource + private RuntimeArtifactStorageProperties properties; /** * 校验运行包是否已经绑定到已提交对象记录。 * * @param runtimePackage 当前运行包记录 */ - public void requireCommitted(RuntimePackageDO runtimePackage) { - if (!enforceCommitted) { - return; + public ArtifactStorageDO requireCommitted(RuntimePackageDO runtimePackage) { + if (!properties.requiresCommitted() && !properties.isShadowRead()) { + return null; } ArtifactStorageDO storage = artifactStorageMapper.selectCommittedByIdentity( runtimePackage.getTenantId(), runtimePackage.getGameId(), runtimePackage.getVersionId()); if (storage == null) { + if (!properties.requiresCommitted()) { + return null; + } log.warn("[artifactStorageGate] 对象记录未 committed,拒绝运行时消费 versionId={}", runtimePackage.getVersionId()); throw exception(RUNTIME_PACKAGE_NOT_READY); } - BundleCheck bundleCheck = inspectBundle(runtimePackage); - boolean bundleMatches = bundleCheck.readable() - && (bundleCheck.present() - ? StringUtils.hasText(storage.getBundleHash()) - && Objects.equals(storage.getBundleHash(), bundleCheck.sha256()) - : !StringUtils.hasText(storage.getBundleHash())); + String expectedPrefix = "tenants/" + runtimePackage.getTenantId() + "/games/" + + runtimePackage.getGameId() + "/versions/" + runtimePackage.getVersionId(); + boolean locatorMatches = Objects.equals(storage.getTenantId(), runtimePackage.getTenantId()) + && Objects.equals(storage.getGameId(), runtimePackage.getGameId()) + && Objects.equals(storage.getVersionId(), runtimePackage.getVersionId()) + && Objects.equals(storage.getArtifactBucket(), properties.getArtifactBucket()) + && Objects.equals(storage.getArtifactManifestKey(), expectedPrefix + "/artifact-manifest.json") + && Objects.equals(storage.getRuntimeManifestKey(), expectedPrefix + "/manifest.json") + && StringUtils.hasText(storage.getArtifactManifestHash()) + && storage.getArtifactManifestHash().matches("^[a-f0-9]{64}$") + && storage.getArtifactManifestBytes() != null && storage.getArtifactManifestBytes() > 0; if (!Objects.equals(storage.getRuntimeManifestHash(), runtimePackage.getChecksum()) - || !bundleMatches) { + || !locatorMatches) { log.error("[artifactStorageGate] 对象记录与运行包摘要不一致,拒绝消费 versionId={}, " - + "runtimeManifestHash={}, runtimeChecksum={}, bundleHash={}, actualBundleHash={}", + + "runtimeManifestHash={}, runtimeChecksum={}, artifactBucket={}, runtimeManifestKey={}", runtimePackage.getVersionId(), storage.getRuntimeManifestHash(), runtimePackage.getChecksum(), - storage.getBundleHash(), bundleCheck.sha256()); + storage.getArtifactBucket(), storage.getRuntimeManifestKey()); throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); } - } - - /** - * 从当前实际消费的 DB manifest 提取 engineBundle 并计算 UTF-8 SHA-256。 - * - *

对象上传器已经对远端 bundle 做逐字节回读;消费侧再对 DB manifest 中即将交给宿主的 - * engineBundle 做一次对账,避免只检查数据库里的 bundle_hash 非空而放过陈旧内容。

- */ - private BundleCheck inspectBundle(RuntimePackageDO runtimePackage) { - if (!StringUtils.hasText(runtimePackage.getPackageJson())) { - log.error("[artifactStorageGate] 运行包缺少 DB manifest,无法校验实际 bundle versionId={}", - runtimePackage.getVersionId()); - return BundleCheck.unreadable(); - } - try { - JsonNode root = PACKAGE_MAPPER.readTree(runtimePackage.getPackageJson()); - JsonNode bundle = root == null ? null : root.get("engineBundle"); - if (bundle == null || bundle.isNull()) { - // GamePackage.engineBundle 是可选字段;缺失时必须同时没有 bundle_hash。 - return BundleCheck.noBundle(); - } - if (!bundle.isTextual() || !StringUtils.hasText(bundle.asText())) { - log.error("[artifactStorageGate] DB manifest 的 engineBundle 类型或内容非法 versionId={}", - runtimePackage.getVersionId()); - return BundleCheck.unreadable(); - } - return BundleCheck.present(sha256Hex(bundle.asText())); - } catch (Exception ex) { - log.error("[artifactStorageGate] DB manifest 无法解析,拒绝 bundle 校验 versionId={}", - runtimePackage.getVersionId(), ex); - return BundleCheck.unreadable(); - } - } - - /** 当前 manifest 是否可读、是否包含 bundle,以及 bundle 的真实摘要。 */ - private record BundleCheck(boolean readable, boolean present, String sha256) { - private static BundleCheck unreadable() { - return new BundleCheck(false, false, null); - } - - private static BundleCheck noBundle() { - return new BundleCheck(true, false, null); - } - - private static BundleCheck present(String sha256) { - return new BundleCheck(true, true, sha256); - } - } - - /** 计算 bundle 文本 UTF-8 字节摘要,与对象归档及生成链路使用同一 sha256 口径。 */ - private static String sha256Hex(String value) { - try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(value.getBytes(StandardCharsets.UTF_8)); - return java.util.HexFormat.of().formatHex(digest); - } catch (Exception ex) { - throw new IllegalStateException("JDK 缺少 SHA-256 算法", ex); - } + return storage; } } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentService.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentService.java new file mode 100644 index 00000000..ea5b89ce --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentService.java @@ -0,0 +1,219 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO; +import com.wanxiang.huijing.game.module.runtime.service.pkg.store.ArtifactObjectReadException; +import com.wanxiang.huijing.game.module.runtime.service.pkg.store.ArtifactObjectReader; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.Objects; +import java.util.TreeSet; +import java.util.regex.Pattern; + +import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_ARTIFACT_MISMATCH; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_READY; + +/** + * 从对象存储读取并校验 GamePackage、artifact manifest 和素材原始字节。 + * + *

对象 locator 只来自 {@link ArtifactStorageDO};客户端只能提交素材 hash,不能提交 bucket、key 或 URL。

+ */ +@Slf4j +@Component +public class RuntimeArtifactContentService { + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Pattern SHA256 = Pattern.compile("^[a-f0-9]{64}$"); + private static final byte[] ARTIFACT_HASH_DOMAIN = "GameArtifactManifest/1\0" + .getBytes(StandardCharsets.UTF_8); + + @Resource + private ArtifactObjectReader objectReader; + + @Resource + private RuntimeArtifactStorageProperties properties; + + /** 读取将要返回宿主的 GamePackage 原文,并校验原文、身份和内联 bundle。 */ + public String readManifest(RuntimePackageDO runtimePackage, ArtifactStorageDO storage) { + byte[] bytes; + try { + bytes = objectReader.read(storage.getArtifactBucket(), storage.getRuntimeManifestKey(), + properties.getMaxManifestBytes()); + } catch (ArtifactObjectReadException ex) { + log.error("[runtimeArtifact] OSS GamePackage 读取失败 versionId={}", runtimePackage.getVersionId(), ex); + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + if (!Objects.equals(sha256Hex(bytes), storage.getRuntimeManifestHash()) + || !Objects.equals(storage.getRuntimeManifestHash(), runtimePackage.getChecksum())) { + log.error("[runtimeArtifact] OSS GamePackage 原文摘要漂移 versionId={}", runtimePackage.getVersionId()); + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + String raw = strictUtf8(bytes, "GamePackage"); + try { + JsonNode root = JSON.readTree(raw); + if (root == null || !root.isObject() + || !Objects.equals(root.path("gameId").asText(), String.valueOf(runtimePackage.getGameId())) + || !Objects.equals(root.path("versionId").asText(), String.valueOf(runtimePackage.getVersionId()))) { + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + JsonNode bundle = root.get("engineBundle"); + if (bundle == null || bundle.isNull()) { + if (StringUtils.hasText(storage.getBundleHash())) { + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + } else if (!bundle.isTextual() || !StringUtils.hasText(bundle.asText()) + || !Objects.equals(sha256Hex(bundle.asText().getBytes(StandardCharsets.UTF_8)), storage.getBundleHash())) { + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + return raw; + } catch (com.wanxiang.huijing.framework.common.exception.ServiceException ex) { + throw ex; + } catch (Exception ex) { + log.error("[runtimeArtifact] OSS GamePackage 解析或 bundle 校验失败 versionId={}", + runtimePackage.getVersionId(), ex); + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + } + + /** 按素材内容 hash 从已验 artifact manifest 映射精确对象并复算原始字节。 */ + public RuntimeAssetContent readAsset(RuntimePackageDO runtimePackage, ArtifactStorageDO storage, String assetHash) { + if (!SHA256.matcher(String.valueOf(assetHash)).matches()) { + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + JsonNode manifest = readArtifactManifest(runtimePackage, storage); + JsonNode selected = null; + for (JsonNode item : manifest.path("objects")) { + if ("asset".equals(item.path("category").asText()) + && "artifact".equals(item.path("store").asText()) + && assetHash.equals(item.path("sha256").asText())) { + selected = item; + break; + } + } + if (selected == null) { + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + String path = selected.path("path").asText(); + String key = selected.path("key").asText(); + String prefix = manifest.path("keyPrefix").asText(); + long expectedBytes = selected.path("bytes").asLong(-1); + String mime = selected.path("mime").asText(); + if (!path.startsWith("assets/") || !Objects.equals(key, prefix + "/" + path) + || expectedBytes < 0 || expectedBytes > properties.getMaxAssetBytes() + || !StringUtils.hasText(mime)) { + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + byte[] bytes; + try { + bytes = objectReader.read(storage.getArtifactBucket(), key, expectedBytes); + } catch (ArtifactObjectReadException ex) { + log.error("[runtimeArtifact] OSS 素材读取失败 versionId={}, hash={}", + runtimePackage.getVersionId(), assetHash, ex); + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + if (bytes.length != expectedBytes || !Objects.equals(sha256Hex(bytes), assetHash)) { + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + return new RuntimeAssetContent(bytes, mime, assetHash); + } + + /** 读取并验证 artifact manifest 的契约摘要、身份和 canonical key。 */ + private JsonNode readArtifactManifest(RuntimePackageDO runtimePackage, ArtifactStorageDO storage) { + byte[] bytes; + try { + bytes = objectReader.read(storage.getArtifactBucket(), storage.getArtifactManifestKey(), + Math.min(properties.getMaxManifestBytes(), storage.getArtifactManifestBytes())); + } catch (ArtifactObjectReadException ex) { + log.error("[runtimeArtifact] artifact manifest 读取失败 versionId={}", runtimePackage.getVersionId(), ex); + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + if (bytes.length != storage.getArtifactManifestBytes()) { + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + try { + JsonNode root = JSON.readTree(strictUtf8(bytes, "artifact manifest")); + String expectedPrefix = "tenants/" + runtimePackage.getTenantId() + "/games/" + + runtimePackage.getGameId() + "/versions/" + runtimePackage.getVersionId(); + if (root == null || !root.isObject() + || !"GameArtifactManifest/1".equals(root.path("schemaVersion").asText()) + || !String.valueOf(runtimePackage.getTenantId()).equals(root.path("tenantId").asText()) + || !String.valueOf(runtimePackage.getGameId()).equals(root.path("gameId").asText()) + || !String.valueOf(runtimePackage.getVersionId()).equals(root.path("versionId").asText()) + || !expectedPrefix.equals(root.path("keyPrefix").asText()) + || !root.path("objects").isArray()) { + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + ObjectNode unsigned = ((ObjectNode) root).deepCopy(); + unsigned.remove("manifestHash"); + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(ARTIFACT_HASH_DOMAIN); + payload.write(JSON.writeValueAsBytes(sortNode(unsigned))); + String actual = sha256Hex(payload.toByteArray()); + if (!Objects.equals(actual, storage.getArtifactManifestHash()) + || !Objects.equals(root.path("manifestHash").asText(), storage.getArtifactManifestHash())) { + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + return root; + } catch (com.wanxiang.huijing.framework.common.exception.ServiceException ex) { + throw ex; + } catch (Exception ex) { + log.error("[runtimeArtifact] artifact manifest 校验失败 versionId={}", runtimePackage.getVersionId(), ex); + throw exception(RUNTIME_PACKAGE_ARTIFACT_MISMATCH); + } + } + + /** 递归按字段名排序对象节点;数组保持契约顺序,对齐 Python canonical JSON。 */ + private static JsonNode sortNode(JsonNode node) { + if (node.isObject()) { + ObjectNode sorted = JSON.createObjectNode(); + TreeSet names = new TreeSet<>(); + Iterator iterator = node.fieldNames(); + iterator.forEachRemaining(names::add); + names.forEach(name -> sorted.set(name, sortNode(node.get(name)))); + return sorted; + } + if (node.isArray()) { + ArrayNode sorted = JSON.createArrayNode(); + node.forEach(item -> sorted.add(sortNode(item))); + return sorted; + } + return node; + } + + /** 严格 UTF-8 解码,非法序列不能被替换字符悄悄吞掉。 */ + private static String strictUtf8(byte[] bytes, String label) { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString(); + } catch (CharacterCodingException ex) { + throw new IllegalArgumentException(label + " 不是合法 UTF-8", ex); + } + } + + /** 统一计算小写 SHA-256。 */ + private static String sha256Hex(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (Exception ex) { + throw new IllegalStateException("JDK 缺少 SHA-256", ex); + } + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStorageProperties.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStorageProperties.java new file mode 100644 index 00000000..ce5a4eab --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStorageProperties.java @@ -0,0 +1,63 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Runtime 游戏内容对象存储配置。 + * + *

读取模式独立于 committed 强制开关:db 保持旧路径,shadow 对账但返回 DB,oss 只返回对象原文。 + * oss 模式天然强制 committed,不能通过关闭兼容开关绕过。

+ */ +@Data +@Component +@ConfigurationProperties(prefix = "game.artifact-storage") +public class RuntimeArtifactStorageProperties { + + /** 运行清单读取模式。 */ + public enum ReadMode { DB, SHADOW, OSS } + + /** 默认保持历史 DB 路径。 */ + private ReadMode readMode = ReadMode.DB; + /** 兼容期独立提交门;oss 模式无论该值如何都强制 committed。 */ + private boolean enforceCommitted = false; + /** S3 兼容 API 根地址。 */ + private String endpoint = ""; + /** S3 区域;MinIO 使用固定占位区域即可。 */ + private String region = "us-east-1"; + /** Runtime 专用只读访问键。 */ + private String accessKey = ""; + /** Runtime 专用只读密钥。 */ + private String secretKey = ""; + /** MinIO 需要 path-style,云 S3 可按环境关闭。 */ + private boolean pathStyle = true; + /** 允许运行时读取的唯一制品桶。 */ + private String artifactBucket = "game-artifacts"; + /** GamePackage 和 artifact manifest 单对象读取上限。 */ + private long maxManifestBytes = 16L * 1024 * 1024; + /** 单素材读取上限。 */ + private long maxAssetBytes = 100L * 1024 * 1024; + /** 一次 S3 API 调用的总超时,覆盖重试在内的完整调用。 */ + private Duration apiCallTimeout = Duration.ofSeconds(15); + /** 单次 S3 API 尝试超时,避免一次网络尝试占满总调用预算。 */ + private Duration apiCallAttemptTimeout = Duration.ofSeconds(5); + + public boolean isOssRead() { + return readMode == ReadMode.OSS; + } + + public boolean isShadowRead() { + return readMode == ReadMode.SHADOW; + } + + public boolean requiresCommitted() { + return enforceCommitted || isOssRead(); + } + + public boolean isObjectAssetProxyEnabled() { + return readMode == ReadMode.OSS; + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeAssetContent.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeAssetContent.java new file mode 100644 index 00000000..24c99573 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeAssetContent.java @@ -0,0 +1,5 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg; + +/** 经 committed artifact manifest 定位并逐字节校验的素材响应。 */ +public record RuntimeAssetContent(byte[] bytes, String mime, String sha256) { +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java index cd332c25..dd4b8935 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageService.java @@ -2,12 +2,14 @@ package com.wanxiang.huijing.game.module.runtime.service.pkg; import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO; /** * 版本运行包 Service 接口 * * 承载取包门禁(决策1:scene+status 权威判定 + 预览归属校验)、发布态回写(status 0→1 + 回写 game_version)。 - * OSS/CDN 包存取与刷新为外部对接点,骨架不实现。 + * DB/shadow/OSS 读取均由实现层收口,调用方不接触对象存储 locator。 * * @author 绘境AI */ @@ -30,12 +32,17 @@ public interface RuntimePackageService { /** * 取已通过同一门禁的原始 manifest 字节。 * - *

MVP 的 DB PackageStore 直接返回刚完成门禁校验的运行包行中的 packageJson,避免 - * controller 先校验 A 行、再二次查询 B 行造成 hash/提交状态竞态;未来 OSS store 仍由 - * 实现按不可变版本 key 返回原始字节。

+ *

DB 模式返回同一运行包行的 packageJson;shadow 模式读取 OSS 对账后仍返回 DB; + * oss 模式只返回 committed locator 指向且通过摘要校验的对象原文,失败不回退 DB。

*/ String getPackageManifestRaw(Long versionId, String scene, Long userId); + /** 按内容 hash 读取经 artifact manifest 映射和复算的素材原始字节。 */ + RuntimeAssetContent getPackageAsset(Long versionId, String scene, Long userId, String assetHash); + + /** 当前是否启用对象素材代理;仅 OSS 主读模式为 true,shadow 不改变浏览器素材路径。 */ + boolean isObjectAssetProxyEnabled(); + /** * 发布态回写对接点(编译就绪运行包 → 置为已发布,补全发布态写入链路) * @@ -78,4 +85,7 @@ public interface RuntimePackageService { */ Long storeForVersion(RuntimePackageStoreReqDTO req); + /** 校验 V34 与 GamePackage 摘要链后,仅落预览元数据,数据库不保存具体游戏正文。 */ + RuntimeOssPackageFinalizeRespDTO finalizeOssPackage(RuntimeOssPackageFinalizeReqDTO req); + } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java index 1976b219..0d27617e 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImpl.java @@ -1,7 +1,14 @@ package com.wanxiang.huijing.game.module.runtime.service.pkg; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO; import com.wanxiang.huijing.game.module.runtime.dal.mysql.pkg.RuntimePackageMapper; +import com.wanxiang.huijing.game.module.runtime.dal.mysql.storage.ArtifactStorageMapper; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO; import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO; import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; import com.wanxiang.huijing.game.module.runtime.enums.RuntimeSceneEnum; @@ -12,24 +19,34 @@ import com.wanxiang.huijing.framework.security.core.LoginUser; import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.Objects; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Set; +import java.util.regex.Pattern; +import java.time.LocalDateTime; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_PUBLISHED; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_NOT_READY; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_PREVIEW_NOT_OWNER; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_ARTIFACT_MISMATCH; import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_INVALIDATE_REJECTED; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_DISABLED; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_INVALID; import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; /** * 版本运行包 Service 实现 * * 业务规则(取包门禁 / 预览归属 / 发布态状态机)在此承载,可单测、可追溯。 - * OSS/CDN 包存取与刷新、project.game_version 回写为外部对接点,骨架不实现(留 TODO)。 + * DB/shadow/OSS 三种读取模式在服务端收口,浏览器只访问同源 Runtime API。 * * @author 绘境AI */ @@ -37,6 +54,15 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept @Service public class RuntimePackageServiceImpl implements RuntimePackageService { + /** Finalize 只解析固定 GamePackage 字段,不做重新序列化。 */ + private static final ObjectMapper JSON = new ObjectMapper(); + /** 所有对象身份摘要统一使用小写 SHA-256。 */ + private static final Pattern SHA256_PATTERN = Pattern.compile("^[a-f0-9]{64}$"); + /** 源修订与对象 key 的安全字符集。 */ + private static final Pattern REVISION_PATTERN = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"); + /** GamePackage 只允许两种既有预加载策略。 */ + private static final Set PRELOAD_POLICIES = Set.of("eager", "lazy"); + /** 落包入参缺省值:入口文件相对路径(对齐 GamePackage manifest.entry MVP 形态) */ private static final String DEFAULT_ENTRY = "index.html"; /** 落包入参缺省值:Canvas Runtime 版本(对齐 GamePackage manifest.runtimeVersion MVP 形态) */ @@ -47,30 +73,44 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { @Resource private RuntimePackageMapper runtimePackageMapper; - /** - * 整包 manifest 存储抽象(§3.4 C4):MVP=DbPackageStore(写 game_runtime_package.package_json),M3 换 OSS impl 调用方不变。 - */ + /** Finalize 直接锁定 V34 状态行,不能经 committed-only 消费门丢失 pending 状态。 */ + @Resource + private ArtifactStorageMapper artifactStorageMapper; + + /** 旧 DB 主读与 shadow 对账使用的 manifest 存储抽象;OSS 主读不写 package_json。 */ @Resource private PackageStore packageStore; /** project 权威归属只读 seam;预览门禁不信任前端传入的 owner 信息。 */ @Resource + @Lazy private ProjectApi projectApi; /** 对象存储提交状态门;开关关闭时保持现有 DB manifest 兼容路径。 */ @Resource private ArtifactStorageGate artifactStorageGate; + /** 对象存储原文与素材的可信读取/摘要校验。 */ + @Resource + private RuntimeArtifactContentService runtimeArtifactContentService; + + /** db/shadow/oss 切流配置。 */ + @Resource + private RuntimeArtifactStorageProperties runtimeArtifactStorageProperties; + @Override public RuntimePackageDO getPackageManifest(Long versionId, String scene, Long userId) { + return authorizePackage(versionId, scene, userId).runtimePackage(); + } + + /** 先完成场景授权,再暴露对象状态;避免越权调用者通过错误差异探测对象是否存在。 */ + private AuthorizedPackage authorizePackage(Long versionId, String scene, Long userId) { // 取就绪运行包(uk_version 唯一) RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId); if (pkg == null) { // 无对应就绪运行包(编译未完成或版本不存在),对齐契约 #1:返回 1-102-001-001 throw exception(RUNTIME_PACKAGE_NOT_READY); } - // 生产切换后只允许消费已经完成对象上传、回读校验和数据库 CAS 的版本。 - artifactStorageGate.requireCommitted(pkg); // 取包门禁(决策1,以 game_runtime_package.status 为权威判定字段,不与 project.game_version.status 混用) if (RuntimeSceneEnum.isPreview(scene)) { // 预览:放行 status∈{0 预览就绪,1 已发布},且校验调用者为项目 owner 或服务端已认证管理员。 @@ -84,25 +124,69 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { throw exception(RUNTIME_PACKAGE_NOT_PUBLISHED); } } + // 授权完成后再读取 committed 记录;oss 模式缺记录必须失败,shadow/db 按配置兼容。 + ArtifactStorageDO storage = artifactStorageGate.requireCommitted(pkg); log.info("[getPackageManifest] 取包成功 versionId={}, scene={}, pkgStatus={}", versionId, scene, pkg.getStatus()); - return pkg; + return new AuthorizedPackage(pkg, storage); } @Override public String getPackageManifestRaw(Long versionId, String scene, Long userId) { - RuntimePackageDO pkg = getPackageManifest(versionId, scene, userId); - // MVP DB store 的 packageJson 就是刚完成门禁校验的同一行,避免二次 mapper 查询竞态。 + AuthorizedPackage authorized = authorizePackage(versionId, scene, userId); + RuntimePackageDO pkg = authorized.runtimePackage(); + ArtifactStorageDO storage = authorized.storage(); + if (runtimeArtifactStorageProperties.isOssRead()) { + if (storage == null) { + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + // OSS 模式只返回已验远端原文;任何读取/摘要失败由内容服务 fail-closed,禁止回 DB。 + return runtimeArtifactContentService.readManifest(pkg, storage); + } + String dbManifest = readDbManifest(pkg); + if (runtimeArtifactStorageProperties.isShadowRead() && storage != null) { + try { + String ossManifest = runtimeArtifactContentService.readManifest(pkg, storage); + if (!Objects.equals(dbManifest, ossManifest)) { + log.warn("[getPackageManifestRaw] shadow 对账发现 DB/OSS 原文字节不同 versionId={}", versionId); + } + } catch (Exception ex) { + // shadow 只观测,不改变旧 DB 返回;日志保留版本身份和堆栈供切流前排障。 + log.error("[getPackageManifestRaw] shadow OSS 对账失败,继续返回 DB versionId={}", versionId, ex); + } + } + return dbManifest; + } + + /** 读取已授权同一 DB 行的原始清单;仅历史空列才走 PackageStore 兼容入口。 */ + private String readDbManifest(RuntimePackageDO pkg) { if (StringUtils.hasText(pkg.getPackageJson())) { return pkg.getPackageJson(); } - // OSS PackageStore 退场接线保留:其实现必须按不可变 version key 返回原始字节。 - String manifest = packageStore.getManifest(versionId); + String manifest = packageStore.getManifest(pkg.getVersionId()); if (!StringUtils.hasText(manifest)) { throw exception(RUNTIME_PACKAGE_NOT_READY); } return manifest; } + @Override + public RuntimeAssetContent getPackageAsset(Long versionId, String scene, Long userId, String assetHash) { + if (!runtimeArtifactStorageProperties.isObjectAssetProxyEnabled()) { + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + AuthorizedPackage authorized = authorizePackage(versionId, scene, userId); + if (authorized.storage() == null) { + throw exception(RUNTIME_PACKAGE_NOT_READY); + } + return runtimeArtifactContentService.readAsset( + authorized.runtimePackage(), authorized.storage(), assetHash); + } + + @Override + public boolean isObjectAssetProxyEnabled() { + return runtimeArtifactStorageProperties.isObjectAssetProxyEnabled(); + } + @Override public void publishPackage(Long versionId, String expectedArtifactHash) { RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId); @@ -147,6 +231,10 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { versionId, pkg.getId()); } + /** 同一次运行包行读取与 committed locator 查询的授权快照。 */ + private record AuthorizedPackage(RuntimePackageDO runtimePackage, ArtifactStorageDO storage) { + } + @Override public void invalidate(Long versionId) { RuntimePackageDO pkg = runtimePackageMapper.selectByVersionId(versionId); @@ -193,7 +281,7 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { insert.setChecksum(req.getChecksum()); insert.setStatus(PackageStatusEnum.PREVIEW_READY.getStatus()); // package_url/sandbox_attr/allow_origins 不设:沿 V3 DDL 表默认值 - // (packageUrl 留空串 → RuntimeConvert 回落 DB manifest 路径,MVP 无 OSS 形态自动成立) + // packageUrl 保持历史兼容空串;浏览器始终使用 RuntimeConvert 生成的同源 manifest 端点。 runtimePackageMapper.insert(insert); // 行已存在后写整包 manifest(putManifest 未命中行显式抛错回滚,Z1 带病链在此杜绝) packageStore.putManifest(req.getVersionId(), req.getManifestJson()); @@ -256,6 +344,234 @@ public class RuntimePackageServiceImpl implements RuntimePackageService { return existing.getId(); } + @Override + @Transactional(rollbackFor = Exception.class) + public RuntimeOssPackageFinalizeRespDTO finalizeOssPackage(RuntimeOssPackageFinalizeReqDTO req) { + if (!runtimeArtifactStorageProperties.isOssRead()) { + log.warn("[finalizeOssPackage] Runtime 非 OSS 模式,拒绝内容 Finalize"); + throw exception(RUNTIME_OSS_FINALIZE_DISABLED); + } + if (!validFinalizeRequest(req)) { + rejectOssFinalize("Finalize 入参非法", req); + } + + byte[] rawManifest = req.getManifestJson().getBytes(StandardCharsets.UTF_8); + if (rawManifest.length > runtimeArtifactStorageProperties.getMaxManifestBytes()) { + rejectOssFinalize("GamePackage 超过读取上限", req); + } + String runtimeManifestHash = sha256Hex(rawManifest); + GamePackageMetadata metadata = parseGamePackage(req, rawManifest); + + ArtifactStorageDO storage = artifactStorageMapper.selectByIdentityForUpdate( + req.getTenantId(), req.getGameId(), req.getVersionId()); + if (!matchesStorageIdentity(storage, req, runtimeManifestHash, metadata.bundleHash())) { + rejectOssFinalize("V34 身份、locator 或摘要链不一致", req); + } + + RuntimePackageDO existing = runtimePackageMapper.selectByVersionId(req.getVersionId()); + if (existing != null) { + if (!sameOssPackage(existing, req, metadata, runtimeManifestHash)) { + rejectOssFinalize("既有运行包与本次 Finalize 摘要不同", req); + } + commitArtifactStorage(storage, req); + log.info("[finalizeOssPackage] 同摘要幂等返回 tenantId={}, gameId={}, versionId={}, packageId={}", + req.getTenantId(), req.getGameId(), req.getVersionId(), existing.getId()); + return new RuntimeOssPackageFinalizeRespDTO( + existing.getId(), runtimeManifestHash, metadata.bundleSize()); + } + + RuntimePackageDO insert = new RuntimePackageDO(); + // 显式写租户列,保证 tenant 插件关闭的隔离 staging 仍落入请求的真实租户。 + insert.setTenantId(req.getTenantId()); + insert.setGameId(req.getGameId()); + insert.setVersionId(req.getVersionId()); + insert.setTemplateId(metadata.templateId()); + insert.setEntry(metadata.entry()); + insert.setRuntimeVersion(metadata.runtimeVersion()); + insert.setPreloadPolicy(metadata.preloadPolicy()); + insert.setBundleSize(metadata.bundleSize()); + insert.setChecksum(runtimeManifestHash); + insert.setStatus(PackageStatusEnum.PREVIEW_READY.getStatus()); + // packageJson 故意不赋值:具体游戏内容只存在对象存储,数据库仅保存平台预览元数据。 + if (runtimePackageMapper.insert(insert) != 1 || insert.getId() == null) { + rejectOssFinalize("运行包预览元数据写入失败", req); + } + commitArtifactStorage(storage, req); + log.info("[finalizeOssPackage] OSS 运行包元数据已落库 tenantId={}, gameId={}, versionId={}, " + + "packageId={}, runtimeManifestHash={}, bundleHash={}", + req.getTenantId(), req.getGameId(), req.getVersionId(), insert.getId(), + runtimeManifestHash, metadata.bundleHash()); + return new RuntimeOssPackageFinalizeRespDTO(insert.getId(), runtimeManifestHash, metadata.bundleSize()); + } + + /** 在当前 Finalize 事务内把已锁定 V34 行 CAS 为 committed;Project 只能在本方法返回后绑定。 */ + private void commitArtifactStorage(ArtifactStorageDO storage, RuntimeOssPackageFinalizeReqDTO req) { + if (Objects.equals(storage.getStatus(), "committed")) { + return; + } + ArtifactStorageDO update = new ArtifactStorageDO(); + update.setStatus("committed"); + update.setCommittedAt(LocalDateTime.now()); + int affected = artifactStorageMapper.update(update, new LambdaUpdateWrapper() + .eq(ArtifactStorageDO::getId, storage.getId()) + .eq(ArtifactStorageDO::getTenantId, req.getTenantId()) + .eq(ArtifactStorageDO::getGameId, req.getGameId()) + .eq(ArtifactStorageDO::getVersionId, req.getVersionId()) + .eq(ArtifactStorageDO::getStatus, "pending") + .eq(ArtifactStorageDO::getArtifactManifestHash, req.getArtifactManifestHash()) + .eq(ArtifactStorageDO::getRuntimeManifestHash, storage.getRuntimeManifestHash()) + .eq(ArtifactStorageDO::getSourceRevisionId, req.getRevisionId()) + .eq(ArtifactStorageDO::getBundleHash, storage.getBundleHash())); + if (affected != 1) { + rejectOssFinalize("V34 committed CAS 未命中", req); + } + storage.setStatus("committed"); + storage.setCommittedAt(update.getCommittedAt()); + } + + /** 校验调用方直接 Java 调用也无法绕过 DTO 边界。 */ + private static boolean validFinalizeRequest(RuntimeOssPackageFinalizeReqDTO req) { + return req != null && req.getTenantId() != null && req.getTenantId() > 0 + && req.getGameId() != null && req.getGameId() > 0 + && req.getVersionId() != null && req.getVersionId() > 0 + && StringUtils.hasText(req.getRevisionId()) + && REVISION_PATTERN.matcher(req.getRevisionId()).matches() + && StringUtils.hasText(req.getArtifactManifestHash()) + && SHA256_PATTERN.matcher(req.getArtifactManifestHash()).matches() + && StringUtils.hasText(req.getManifestJson()); + } + + /** 解析并校验 Runtime 落库所需的 GamePackage 权威字段。 */ + private static GamePackageMetadata parseGamePackage(RuntimeOssPackageFinalizeReqDTO req, byte[] rawManifest) { + try { + JsonNode root = JSON.readTree(rawManifest); + JsonNode manifest = root == null ? null : root.get("manifest"); + JsonNode engineBundleNode = root == null ? null : root.get("engineBundle"); + boolean topLevelValid = root != null && root.isObject() + && "1.0".equals(text(root, "schemaVersion")) + && String.valueOf(req.getGameId()).equals(text(root, "gameId")) + && String.valueOf(req.getVersionId()).equals(text(root, "versionId")) + && StringUtils.hasText(text(root, "templateId")) + && root.path("gameConfig").isObject() && root.path("assets").isArray() + && root.path("meta").isObject() && manifest != null && manifest.isObject() + && engineBundleNode != null && engineBundleNode.isTextual() + && StringUtils.hasText(engineBundleNode.textValue()) + && engineBundleNode.textValue().contains("__GameBundle"); + if (!topLevelValid) { + rejectOssFinalize("GamePackage 顶层身份或必填结构非法", req); + } + + String runtimeVersion = text(manifest, "runtimeVersion"); + String entry = text(manifest, "entry"); + String preloadPolicy = text(manifest, "preloadPolicy"); + JsonNode bundleSizeNode = manifest.get("bundleSize"); + String innerChecksum = text(manifest, "checksum"); + if (!StringUtils.hasText(runtimeVersion) || !StringUtils.hasText(entry) + || !PRELOAD_POLICIES.contains(preloadPolicy) + || bundleSizeNode == null || !bundleSizeNode.canConvertToLong() + || bundleSizeNode.longValue() <= 0 + || !StringUtils.hasText(innerChecksum) || !SHA256_PATTERN.matcher(innerChecksum).matches()) { + rejectOssFinalize("GamePackage manifest 字段非法", req); + } + String engineBundle = engineBundleNode.textValue(); + long actualBundleSize = engineBundle.getBytes(StandardCharsets.UTF_8).length; + if (bundleSizeNode.longValue() != actualBundleSize) { + rejectOssFinalize("GamePackage bundleSize 与 engineBundle UTF-8 字节数不一致", req); + } + return new GamePackageMetadata(text(root, "templateId"), runtimeVersion, entry, preloadPolicy, + actualBundleSize, sha256Hex(engineBundle.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + if (ex instanceof com.wanxiang.huijing.framework.common.exception.ServiceException serviceException) { + throw serviceException; + } + log.warn("[finalizeOssPackage] GamePackage JSON 解析失败 tenantId={}, gameId={}, versionId={}", + req.getTenantId(), req.getGameId(), req.getVersionId(), ex); + throw exception(RUNTIME_OSS_FINALIZE_INVALID); + } + } + + /** 只接受 JSON 文本字段,数字或布尔值不能经字符串化冒充契约值。 */ + private static String text(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + return value != null && value.isTextual() ? value.textValue() : null; + } + + /** 校验 V34 pending/committed 记录及全部 canonical locator 和摘要链。 */ + private boolean matchesStorageIdentity(ArtifactStorageDO storage, RuntimeOssPackageFinalizeReqDTO req, + String runtimeManifestHash, String bundleHash) { + if (storage == null || Boolean.TRUE.equals(storage.getDeleted())) { + return false; + } + String artifactPrefix = "tenants/" + req.getTenantId() + "/games/" + req.getGameId() + + "/versions/" + req.getVersionId(); + String sourcePrefix = "tenants/" + req.getTenantId() + "/games/" + req.getGameId() + + "/source-revisions/" + req.getRevisionId(); + return storage.getId() != null && storage.getId() > 0 + && Objects.equals(storage.getTenantId(), req.getTenantId()) + && Objects.equals(storage.getGameId(), req.getGameId()) + && Objects.equals(storage.getVersionId(), req.getVersionId()) + && (Objects.equals(storage.getStatus(), "pending") || Objects.equals(storage.getStatus(), "committed")) + && Objects.equals(storage.getArtifactBucket(), runtimeArtifactStorageProperties.getArtifactBucket()) + && Objects.equals(storage.getArtifactManifestKey(), artifactPrefix + "/artifact-manifest.json") + && Objects.equals(storage.getArtifactManifestHash(), req.getArtifactManifestHash()) + && storage.getArtifactManifestBytes() != null && storage.getArtifactManifestBytes() > 0 + && Objects.equals(storage.getRuntimeManifestKey(), artifactPrefix + "/manifest.json") + && Objects.equals(storage.getRuntimeManifestHash(), runtimeManifestHash) + && Objects.equals(storage.getSourceProvider(), "game_source_archive") + && StringUtils.hasText(storage.getSourceBucket()) + && !Objects.equals(storage.getSourceBucket(), storage.getArtifactBucket()) + && Objects.equals(storage.getSourceGameId(), String.valueOf(req.getGameId())) + && Objects.equals(storage.getSourceRevisionId(), req.getRevisionId()) + && Objects.equals(storage.getSourceManifestKey(), sourcePrefix + "/source-manifest.json") + && validSha256(storage.getSourceManifestHash()) && validSha256(storage.getSourceHash()) + && Objects.equals(storage.getBundleHash(), bundleHash); + } + + /** 同 versionId 重放必须逐字段等同,且既有行从未保存 GamePackage 正文。 */ + private static boolean sameOssPackage(RuntimePackageDO existing, RuntimeOssPackageFinalizeReqDTO req, + GamePackageMetadata metadata, String runtimeManifestHash) { + return !Boolean.TRUE.equals(existing.getDeleted()) + && Objects.equals(existing.getTenantId(), req.getTenantId()) + && Objects.equals(existing.getGameId(), req.getGameId()) + && Objects.equals(existing.getVersionId(), req.getVersionId()) + && Objects.equals(existing.getTemplateId(), metadata.templateId()) + && Objects.equals(existing.getEntry(), metadata.entry()) + && Objects.equals(existing.getRuntimeVersion(), metadata.runtimeVersion()) + && Objects.equals(existing.getPreloadPolicy(), metadata.preloadPolicy()) + && Objects.equals(existing.getBundleSize(), metadata.bundleSize()) + && Objects.equals(existing.getChecksum(), runtimeManifestHash) + && (PackageStatusEnum.isPreviewReady(existing.getStatus()) + || PackageStatusEnum.isPublished(existing.getStatus())) + && existing.getPackageJson() == null; + } + + /** 判断可用 SHA-256 字段。 */ + private static boolean validSha256(String value) { + return StringUtils.hasText(value) && SHA256_PATTERN.matcher(value).matches(); + } + + /** 现场计算原始 UTF-8 字节摘要。 */ + private static String sha256Hex(byte[] value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("JDK 缺少 SHA-256", ex); + } + } + + /** 统一记录不含正文的拒绝日志并抛稳定业务码。 */ + private static void rejectOssFinalize(String reason, RuntimeOssPackageFinalizeReqDTO req) { + log.warn("[finalizeOssPackage] 拒绝 OSS 内容 Finalize reason={}, tenantId={}, gameId={}, versionId={}", + reason, req == null ? null : req.getTenantId(), req == null ? null : req.getGameId(), + req == null ? null : req.getVersionId()); + throw exception(RUNTIME_OSS_FINALIZE_INVALID); + } + + /** 已校验的 GamePackage 预览元数据和运行 bundle 摘要。 */ + private record GamePackageMetadata(String templateId, String runtimeVersion, String entry, + String preloadPolicy, Long bundleSize, String bundleHash) { + } + /** * 落包入参缺省值兜底(DTO 可空字段统一在服务端落缺省,保证表列 NOT NULL 语义) * diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReadException.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReadException.java new file mode 100644 index 00000000..11f17114 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReadException.java @@ -0,0 +1,12 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg.store; + +/** 对象缺失、配置、网络或读取上限失败;不向 API 泄漏 SDK 异常类型。 */ +public class ArtifactObjectReadException extends RuntimeException { + public ArtifactObjectReadException(String message) { + super(message); + } + + public ArtifactObjectReadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReader.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReader.java new file mode 100644 index 00000000..889ac75e --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/ArtifactObjectReader.java @@ -0,0 +1,15 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg.store; + +/** Runtime 对象存储只读边界;bucket/key 必须由 committed 数据库记录派生。 */ +public interface ArtifactObjectReader { + + /** + * 有界读取对象原始字节。 + * + * @param bucket 受信 committed 行中的桶 + * @param key 受信 committed 行或已验 artifact manifest 中的 key + * @param maxBytes 最大允许字节数 + * @return 对象原始字节 + */ + byte[] read(String bucket, String key, long maxBytes); +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/DbPackageStore.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/DbPackageStore.java index 5be05327..31567eed 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/DbPackageStore.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/DbPackageStore.java @@ -10,7 +10,7 @@ import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants. import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; /** - * 整包 manifest 存储 DB 实现(黄金闭环 §3.4 C4,MVP 无 OSS:读写 game_runtime_package.package_json) + * 旧 DB 读取模式的整包 manifest 实现,读写 game_runtime_package.package_json。 * * 退场契约:M3 接 OSS 后新增 OSS impl 并切换 @Primary,本类下线,{@link PackageStore} 调用方不变。 * diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReader.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReader.java new file mode 100644 index 00000000..b8286754 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/main/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReader.java @@ -0,0 +1,89 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg.store; + +import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimeArtifactStorageProperties; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; + +import java.net.URI; + +/** S3/MinIO 有界 GET 实现;不提供 List、Put 或 Delete。 */ +@Component +public class S3ArtifactObjectReader implements ArtifactObjectReader { + + @Resource + private RuntimeArtifactStorageProperties properties; + + private volatile S3Client client; + + @Override + public byte[] read(String bucket, String key, long maxBytes) { + if (!StringUtils.hasText(bucket) || !StringUtils.hasText(key) + || maxBytes < 0 || maxBytes > Integer.MAX_VALUE - 1L) { + throw new ArtifactObjectReadException("对象读取参数非法"); + } + try (ResponseInputStream input = client().getObject( + GetObjectRequest.builder().bucket(bucket).key(key).build())) { + Long declared = input.response().contentLength(); + if (declared != null && declared > maxBytes) { + throw new ArtifactObjectReadException("对象超过读取上限"); + } + byte[] bytes = input.readNBytes((int) maxBytes + 1); + if (bytes.length > maxBytes) { + throw new ArtifactObjectReadException("对象超过读取上限"); + } + if (declared != null && declared != bytes.length) { + throw new ArtifactObjectReadException("对象声明长度与实际字节不一致"); + } + return bytes; + } catch (ArtifactObjectReadException ex) { + throw ex; + } catch (Exception ex) { + throw new ArtifactObjectReadException("对象读取失败", ex); + } + } + + /** 惰性建只读客户端,DB 模式不会因未配置 OSS 凭据而影响启动。 */ + private S3Client client() { + S3Client current = client; + if (current != null) { + return current; + } + synchronized (this) { + if (client == null) { + if (!StringUtils.hasText(properties.getEndpoint()) + || !StringUtils.hasText(properties.getAccessKey()) + || !StringUtils.hasText(properties.getSecretKey())) { + throw new ArtifactObjectReadException("Runtime OSS 只读端点或凭据未配置"); + } + client = S3Client.builder() + .endpointOverride(URI.create(properties.getEndpoint())) + .region(Region.of(properties.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(properties.isPathStyle()).build()) + .overrideConfiguration(buildOverrideConfiguration(properties)) + .build(); + } + return client; + } + } + + /** 把总调用和单次尝试超时显式交给 SDK;禁止对象 GET 因网络半开无限等待。 */ + static ClientOverrideConfiguration buildOverrideConfiguration(RuntimeArtifactStorageProperties properties) { + return ClientOverrideConfiguration.builder() + .apiCallTimeout(properties.getApiCallTimeout()) + .apiCallAttemptTimeout(properties.getApiCallAttemptTimeout()) + .build(); + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImplDogfoodTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImplDogfoodTest.java index c76d70a9..7a7c08ef 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImplDogfoodTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/api/RuntimePackageApiImplDogfoodTest.java @@ -4,6 +4,8 @@ import com.wanxiang.huijing.framework.common.exception.ServiceException; import com.wanxiang.huijing.framework.env.config.DogfoodProperties; import com.wanxiang.huijing.framework.env.core.DogfoodAccessGuard; import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO; import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -11,11 +13,14 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.HandlerMapping; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -101,6 +106,29 @@ class RuntimePackageApiImplDogfoodTest { verify(runtimePackageService).publishPackage(2048L, "a".repeat(64)); } + @Test + void ossFinalizeAlwaysRejectsDirectRuntimeHttpEvenOutsideDogfood() { + RuntimePackageApiImpl api = createApi(false); + bindRequest("/rpc-api/runtime/finalize-oss-package"); + + assertThrows(ServiceException.class, () -> api.finalizeOssPackage(finalizeReq())); + + verify(runtimePackageService, never()).finalizeOssPackage(any(RuntimeOssPackageFinalizeReqDTO.class)); + } + + @Test + void ossFinalizeAllowsTrustedAigcLocalCall() { + RuntimePackageApiImpl api = createApi(false); + RuntimeOssPackageFinalizeReqDTO req = finalizeReq(); + bindRequest("/admin-api/aigc/game-content/finalize"); + when(runtimePackageService.finalizeOssPackage(req)).thenReturn( + new RuntimeOssPackageFinalizeRespDTO(31L, "b".repeat(64), 4096L)); + + assertEquals(31L, api.finalizeOssPackage(req).getData().getPackageId()); + + verify(runtimePackageService).finalizeOssPackage(req); + } + private RuntimePackageApiImpl createApi(boolean dogfoodEnabled) { RuntimePackageApiImpl api = new RuntimePackageApiImpl(); ReflectionTestUtils.setField(api, "runtimePackageService", runtimePackageService); @@ -112,6 +140,7 @@ class RuntimePackageApiImplDogfoodTest { private static void bindRequest(String requestUri) { MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, requestUri); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); } @@ -126,4 +155,16 @@ class RuntimePackageApiImplDogfoodTest { return req; } + /** 构造仅用于验证 HTTP 旁路被拒绝的 OSS Finalize 入参。 */ + private static RuntimeOssPackageFinalizeReqDTO finalizeReq() { + RuntimeOssPackageFinalizeReqDTO req = new RuntimeOssPackageFinalizeReqDTO(); + req.setTenantId(7L); + req.setGameId(1024L); + req.setVersionId(2048L); + req.setRevisionId("revision-a"); + req.setArtifactManifestHash("a".repeat(64)); + req.setManifestJson("{}"); + return req; + } + } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeControllerTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeControllerTest.java new file mode 100644 index 00000000..fca8e07c --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/controller/app/runtime/AppRuntimeControllerTest.java @@ -0,0 +1,95 @@ +package com.wanxiang.huijing.game.module.runtime.controller.app.runtime; + +import com.wanxiang.huijing.framework.common.pojo.CommonResult; +import com.wanxiang.huijing.game.module.runtime.controller.app.runtime.vo.RuntimePackageRespVO; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; +import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; +import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimeAssetContent; +import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimePackageService; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** App Runtime 响应中的对象代理与缓存可信边界测试。 */ +class AppRuntimeControllerTest { + + @Test + void shadowModeDoesNotAdvertiseAssetProxy() { + RuntimePackageService service = mock(RuntimePackageService.class); + when(service.getPackageManifest(2048L, "play", null)).thenReturn(pkg()); + when(service.isObjectAssetProxyEnabled()).thenReturn(false); + + CommonResult result = controller(service).getPackage(2048L, "play"); + + assertNotNull(result.getData()); + assertNull(result.getData().getAssetUrlTemplate()); + } + + @Test + void ossModeAdvertisesSceneBoundAssetProxy() { + RuntimePackageService service = mock(RuntimePackageService.class); + when(service.getPackageManifest(2048L, "preview", null)).thenReturn(pkg()); + when(service.isObjectAssetProxyEnabled()).thenReturn(true); + + CommonResult result = controller(service).getPackage(2048L, "preview"); + + assertNotNull(result.getData()); + assertTrue(result.getData().getAssetUrlTemplate().endsWith("/{sha256}?scene=preview")); + } + + @Test + void previewAssetIsPrivateAndNeverCached() { + RuntimePackageService service = assetService(); + + ResponseEntity response = controller(service).getPackageAsset(2048L, "a".repeat(64), "preview"); + + String cacheControl = response.getHeaders().getCacheControl(); + assertTrue(cacheControl.contains("private")); + assertTrue(cacheControl.contains("no-store")); + assertFalse(cacheControl.contains("public")); + assertFalse(cacheControl.contains("immutable")); + } + + @Test + void playAssetUsesImmutablePublicCache() { + RuntimePackageService service = assetService(); + + ResponseEntity response = controller(service).getPackageAsset(2048L, "a".repeat(64), "play"); + + String cacheControl = response.getHeaders().getCacheControl(); + assertTrue(cacheControl.contains("public")); + assertTrue(cacheControl.contains("immutable")); + assertTrue(cacheControl.contains("max-age=31536000")); + } + + private static AppRuntimeController controller(RuntimePackageService service) { + AppRuntimeController controller = new AppRuntimeController(); + ReflectionTestUtils.setField(controller, "runtimePackageService", service); + return controller; + } + + private static RuntimePackageService assetService() { + RuntimePackageService service = mock(RuntimePackageService.class); + when(service.getPackageAsset(2048L, "preview", null, "a".repeat(64))) + .thenReturn(new RuntimeAssetContent(new byte[]{1}, "image/png", "a".repeat(64))); + when(service.getPackageAsset(2048L, "play", null, "a".repeat(64))) + .thenReturn(new RuntimeAssetContent(new byte[]{1}, "image/png", "a".repeat(64))); + return service; + } + + private static RuntimePackageDO pkg() { + RuntimePackageDO pkg = new RuntimePackageDO(); + pkg.setGameId(1024L); + pkg.setVersionId(2048L); + pkg.setStatus(PackageStatusEnum.PUBLISHED.getStatus()); + pkg.setChecksum("b".repeat(64)); + return pkg; + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvertTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvertTest.java index 80cbfd3e..2e10e6c1 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvertTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/convert/runtime/RuntimeConvertTest.java @@ -9,42 +9,12 @@ import static org.junit.jupiter.api.Assertions.*; /** * {@link RuntimeConvert} 单元测试(纯静态方法,不依赖 Spring/DB) * - * 覆盖 GAP-2 分支A:manifestUrl 由 packageUrl 同前缀派生 .../manifest.json(含目录形态/文件名形态/空值/退化形态各路)。 + * 覆盖同源 manifest 端点、scene 门禁继承和 sandbox 策略转换。 * * @author 绘境AI */ class RuntimeConvertTest { - // ============================== deriveManifestUrl 同前缀派生 ============================== - - @Test - void testDeriveManifestUrl_dirForm() { - // packageUrl 以 '/' 结尾(版本化目录形态)→ 直接追加 manifest.json - assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", - RuntimeConvert.deriveManifestUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/")); - } - - @Test - void testDeriveManifestUrl_fileForm() { - // packageUrl 含末段文件名 → 替换为同级 manifest.json,保持同前缀 - assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", - RuntimeConvert.deriveManifestUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip")); - } - - @Test - void testDeriveManifestUrl_noSlashDegraded() { - // 无 '/' 退化形态 → 当作前缀目录拼接,不越权改写其它路径 - assertEquals("pkg/manifest.json", RuntimeConvert.deriveManifestUrl("pkg")); - } - - @Test - void testDeriveManifestUrl_blankReturnsNull() { - // 空/空白 packageUrl → 返回 null(无包则无清单,不构造非法 URL) - assertNull(RuntimeConvert.deriveManifestUrl(null)); - assertNull(RuntimeConvert.deriveManifestUrl("")); - assertNull(RuntimeConvert.deriveManifestUrl(" ")); - } - // ============================== toPackageRespVO 整体回填 ============================== @Test @@ -59,10 +29,10 @@ class RuntimeConvertTest { RuntimePackageRespVO vo = RuntimeConvert.toPackageRespVO(pkg); assertNotNull(vo); - // 平铺字段直拷 + manifestUrl 同前缀派生 + // 平铺字段直拷;manifest 始终经同源 Runtime API,避免平台 Token 发往对象域。 assertEquals(1024L, vo.getGameId()); assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip", vo.getPackageUrl()); - assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", vo.getManifestUrl()); + assertEquals("/app-api/runtime/package/2048/manifest", vo.getManifestUrl()); // 沙箱策略 allowOrigins 字符串拆 List assertNotNull(vo.getSandbox()); assertEquals("allow-scripts", vo.getSandbox().getSandboxAttr()); @@ -74,11 +44,11 @@ class RuntimeConvertTest { assertNull(RuntimeConvert.toPackageRespVO(null)); } - // ============================== §3.4 C4:MVP 无 OSS manifestUrl 指向 DB 端点 ============================== + // ============================== DB 兼容模式与 OSS 模式共用同源端点 ============================== @Test void testToPackageRespVO_mvpEmptyPackageUrl_manifestUrlPointsToEndpoint() { - // MVP 无 OSS(packageUrl 空,整包存 DB):manifestUrl 指向 /app-api/runtime/package/{versionId}/manifest 端点 + // 旧 DB 模式下 packageUrl 为空,manifestUrl 仍指向统一 Runtime 端点。 RuntimePackageDO pkg = new RuntimePackageDO(); pkg.setGameId(1024L); pkg.setVersionId(2048L); @@ -124,9 +94,9 @@ class RuntimeConvertTest { RuntimeConvert.toPackageRespVO(pkg, "play").getManifestUrl()); assertEquals("/app-api/runtime/package/2048/manifest", RuntimeConvert.toPackageRespVO(pkg, null).getManifestUrl()); - // OSS 分支(packageUrl 非空)scene 不参与:静态文件无门禁 + // 即使历史 packageUrl 非空,preview 仍经同源端点并继承 scene 门禁。 pkg.setPackageUrl("https://cdn.wanxiang.ai/games/1024/versions/2048/package.zip"); - assertEquals("https://cdn.wanxiang.ai/games/1024/versions/2048/manifest.json", + assertEquals("/app-api/runtime/package/2048/manifest?scene=preview", RuntimeConvert.toPackageRespVO(pkg, "preview").getManifestUrl()); } diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGateTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGateTest.java index d625bde6..dc338f38 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGateTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/ArtifactStorageGateTest.java @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; /** committed 对象记录与运行包 checksum/bundle hash 的消费门测试。 */ @@ -26,9 +27,10 @@ class ArtifactStorageGateTest { ArtifactStorageMapper mapper = mock(ArtifactStorageMapper.class); ArtifactStorageGate gate = new ArtifactStorageGate(); ReflectionTestUtils.setField(gate, "artifactStorageMapper", mapper); - ReflectionTestUtils.setField(gate, "enforceCommitted", false); + ReflectionTestUtils.setField(gate, "properties", properties(false)); - assertDoesNotThrow(() -> gate.requireCommitted(pkg("a".repeat(64)))); + assertEquals(null, gate.requireCommitted(pkg("a".repeat(64)))); + verify(mapper, never()).selectCommittedByIdentity(1L, 1024L, 2048L); } @Test @@ -54,7 +56,8 @@ class ArtifactStorageGateTest { assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode()); stale.setRuntimeManifestHash("a".repeat(64)); - stale.setBundleHash(""); + stale.setRuntimeManifestHash("a".repeat(64)); + stale.setArtifactBucket("wrong-bucket"); ex = assertThrows(ServiceException.class, () -> gate.requireCommitted(pkg("a".repeat(64)))); assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode()); } @@ -85,10 +88,17 @@ class ArtifactStorageGateTest { private static ArtifactStorageGate configuredGate(ArtifactStorageMapper mapper) { ArtifactStorageGate gate = new ArtifactStorageGate(); ReflectionTestUtils.setField(gate, "artifactStorageMapper", mapper); - ReflectionTestUtils.setField(gate, "enforceCommitted", true); + ReflectionTestUtils.setField(gate, "properties", properties(true)); return gate; } + private static RuntimeArtifactStorageProperties properties(boolean enforceCommitted) { + RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties(); + properties.setEnforceCommitted(enforceCommitted); + properties.setArtifactBucket("game-artifacts"); + return properties; + } + private static RuntimePackageDO pkg(String checksum) { RuntimePackageDO pkg = new RuntimePackageDO(); pkg.setTenantId(1L); @@ -102,7 +112,14 @@ class ArtifactStorageGateTest { private static ArtifactStorageDO committed(String runtimeManifestHash, String bundleHash) { ArtifactStorageDO storage = new ArtifactStorageDO(); storage.setVersionId(2048L); + storage.setTenantId(1L); + storage.setGameId(1024L); storage.setStatus("committed"); + storage.setArtifactBucket("game-artifacts"); + storage.setArtifactManifestKey("tenants/1/games/1024/versions/2048/artifact-manifest.json"); + storage.setArtifactManifestHash("d".repeat(64)); + storage.setArtifactManifestBytes(512L); + storage.setRuntimeManifestKey("tenants/1/games/1024/versions/2048/manifest.json"); storage.setRuntimeManifestHash(runtimeManifestHash); storage.setBundleHash(bundleHash); return storage; diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentServiceTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentServiceTest.java new file mode 100644 index 00000000..1ea23b83 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactContentServiceTest.java @@ -0,0 +1,100 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg; + +import com.wanxiang.huijing.framework.common.exception.ServiceException; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO; +import com.wanxiang.huijing.game.module.runtime.service.pkg.store.ArtifactObjectReader; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.charset.StandardCharsets; + +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_PACKAGE_ARTIFACT_MISMATCH; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** OSS GamePackage 与素材的原始字节/hash/身份消费测试。 */ +class RuntimeArtifactContentServiceTest { + + private static final String BUNDLE_HASH = "1e6ed65d77d6364eeaed5a745ba5c4985ae2b700dd85d7cf7f027bdf294a33fc"; + private static final String RUNTIME_HASH = "df5535659c8682a0edbde5b7be6eb26fc8a7d15c9de054c79227ac55e94f3e76"; + private static final String ASSET_HASH = "d59386e0ae435e292fbe0ebcdb954b75ed5fb3922091277cb19f798fc5d50718"; + private static final String ARTIFACT_HASH = "d20b7124e197b1e08f936c2fdd5adb6bded18b0c9e12dd901a761899097a07af"; + private static final String RUNTIME_JSON = + "{\"engineBundle\":\"bundle\",\"gameId\":\"1024\",\"schemaVersion\":\"1.0\",\"versionId\":\"2048\"}"; + private static final String ARTIFACT_JSON = + "{\"gameId\":\"1024\",\"keyPrefix\":\"tenants/1/games/1024/versions/2048\"," + + "\"manifestHash\":\"" + ARTIFACT_HASH + "\",\"objects\":[{\"bytes\":5," + + "\"category\":\"asset\",\"key\":\"tenants/1/games/1024/versions/2048/assets/hero.webp\"," + + "\"mime\":\"image/webp\",\"path\":\"assets/hero.webp\",\"sha256\":\"" + ASSET_HASH + "\"," + + "\"store\":\"artifact\"}],\"schemaVersion\":\"GameArtifactManifest/1\"," + + "\"tenantId\":\"1\",\"versionId\":\"2048\"}"; + + @Test + void readsRemoteManifestAndVerifiesRuntimeAndBundleHashes() { + RuntimeArtifactContentService service = service((bucket, key, maxBytes) -> { + assertEquals("game-artifacts", bucket); + assertEquals("tenants/1/games/1024/versions/2048/manifest.json", key); + return RUNTIME_JSON.getBytes(StandardCharsets.UTF_8); + }); + + assertEquals(RUNTIME_JSON, service.readManifest(pkg(), storage())); + } + + @Test + void rejectsRemoteManifestHashDrift() { + RuntimeArtifactContentService service = service((bucket, key, maxBytes) -> + RUNTIME_JSON.replace("bundle", "changed").getBytes(StandardCharsets.UTF_8)); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.readManifest(pkg(), storage())); + assertEquals(RUNTIME_PACKAGE_ARTIFACT_MISMATCH.getCode(), ex.getCode()); + } + + @Test + void resolvesAssetOnlyThroughVerifiedArtifactManifestHash() { + RuntimeArtifactContentService service = service((bucket, key, maxBytes) -> { + if (key.endsWith("artifact-manifest.json")) { + return ARTIFACT_JSON.getBytes(StandardCharsets.UTF_8); + } + assertEquals("tenants/1/games/1024/versions/2048/assets/hero.webp", key); + return "asset".getBytes(StandardCharsets.UTF_8); + }); + + RuntimeAssetContent asset = service.readAsset(pkg(), storage(), ASSET_HASH); + assertEquals("image/webp", asset.mime()); + assertEquals(ASSET_HASH, asset.sha256()); + assertArrayEquals("asset".getBytes(StandardCharsets.UTF_8), asset.bytes()); + } + + private static RuntimeArtifactContentService service(ArtifactObjectReader reader) { + RuntimeArtifactContentService service = new RuntimeArtifactContentService(); + ReflectionTestUtils.setField(service, "objectReader", reader); + ReflectionTestUtils.setField(service, "properties", new RuntimeArtifactStorageProperties()); + return service; + } + + private static RuntimePackageDO pkg() { + RuntimePackageDO pkg = new RuntimePackageDO(); + pkg.setTenantId(1L); + pkg.setGameId(1024L); + pkg.setVersionId(2048L); + pkg.setChecksum(RUNTIME_HASH); + return pkg; + } + + private static ArtifactStorageDO storage() { + ArtifactStorageDO storage = new ArtifactStorageDO(); + storage.setTenantId(1L); + storage.setGameId(1024L); + storage.setVersionId(2048L); + storage.setArtifactBucket("game-artifacts"); + storage.setArtifactManifestKey("tenants/1/games/1024/versions/2048/artifact-manifest.json"); + storage.setArtifactManifestHash(ARTIFACT_HASH); + storage.setArtifactManifestBytes((long) ARTIFACT_JSON.getBytes(StandardCharsets.UTF_8).length); + storage.setRuntimeManifestKey("tenants/1/games/1024/versions/2048/manifest.json"); + storage.setRuntimeManifestHash(RUNTIME_HASH); + storage.setBundleHash(BUNDLE_HASH); + return storage; + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStoragePropertiesTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStoragePropertiesTest.java new file mode 100644 index 00000000..ea27b269 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeArtifactStoragePropertiesTest.java @@ -0,0 +1,37 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Runtime 对象读取模式与网络超时边界测试。 */ +class RuntimeArtifactStoragePropertiesTest { + + @Test + void shadowModeDoesNotExposeObjectAssetProxy() { + RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties(); + properties.setReadMode(RuntimeArtifactStorageProperties.ReadMode.SHADOW); + + assertFalse(properties.isObjectAssetProxyEnabled()); + } + + @Test + void ossModeExposesObjectAssetProxy() { + RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties(); + properties.setReadMode(RuntimeArtifactStorageProperties.ReadMode.OSS); + + assertTrue(properties.isObjectAssetProxyEnabled()); + } + + @Test + void s3CallsHaveFiniteDefaultTimeouts() { + RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties(); + + assertEquals(Duration.ofSeconds(15), properties.getApiCallTimeout()); + assertEquals(Duration.ofSeconds(5), properties.getApiCallAttemptTimeout()); + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeOssPackageFinalizeServiceTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeOssPackageFinalizeServiceTest.java new file mode 100644 index 00000000..1744aca5 --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimeOssPackageFinalizeServiceTest.java @@ -0,0 +1,284 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.wanxiang.huijing.framework.common.exception.ServiceException; +import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; +import com.wanxiang.huijing.game.module.project.api.ProjectApi; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO; +import com.wanxiang.huijing.game.module.runtime.dal.mysql.pkg.RuntimePackageMapper; +import com.wanxiang.huijing.game.module.runtime.dal.mysql.storage.ArtifactStorageMapper; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeReqDTO; +import com.wanxiang.huijing.game.module.runtime.dto.RuntimeOssPackageFinalizeRespDTO; +import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; +import com.wanxiang.huijing.game.module.runtime.service.pkg.store.PackageStore; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_DISABLED; +import static com.wanxiang.huijing.game.module.runtime.enums.ErrorCodeConstants.RUNTIME_OSS_FINALIZE_INVALID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** OSS GamePackage Finalize 的身份、摘要与无正文落库边界测试。 */ +class RuntimeOssPackageFinalizeServiceTest extends BaseMockitoUnitTest { + + private static final String ENGINE_BUNDLE = "window.__GameBundle={};"; + + @InjectMocks + private RuntimePackageServiceImpl runtimePackageService; + + @Mock + private RuntimePackageMapper runtimePackageMapper; + @Mock + private ArtifactStorageMapper artifactStorageMapper; + @Mock + private RuntimeArtifactStorageProperties properties; + @Mock + private PackageStore packageStore; + @Mock + private ProjectApi projectApi; + @Mock + private ArtifactStorageGate artifactStorageGate; + @Mock + private RuntimeArtifactContentService runtimeArtifactContentService; + + @Test + void finalizeOssPackage_rejectsNonOssModeWithoutDatabaseWrite() { + when(properties.isOssRead()).thenReturn(false); + + ServiceException error = assertThrows(ServiceException.class, + () -> runtimePackageService.finalizeOssPackage(request(gamePackage()))); + + assertEquals(RUNTIME_OSS_FINALIZE_DISABLED.getCode(), error.getCode()); + verifyNoInteractions(artifactStorageMapper); + verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class)); + } + + @Test + void finalizeOssPackage_rejectsMissingOrWrongArtifactIdentity() { + RuntimeOssPackageFinalizeReqDTO req = request(gamePackage()); + enableOssMode(); + when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(null); + + ServiceException missing = assertThrows(ServiceException.class, + () -> runtimePackageService.finalizeOssPackage(req)); + assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), missing.getCode()); + + ArtifactStorageDO storage = storage(req.getManifestJson()); + storage.setArtifactManifestHash("b".repeat(64)); + when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(storage); + ServiceException mismatch = assertThrows(ServiceException.class, + () -> runtimePackageService.finalizeOssPackage(req)); + assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), mismatch.getCode()); + verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class)); + } + + @Test + void finalizeOssPackage_rejectsRuntimeManifestOrBundleHashDrift() { + String raw = gamePackage(); + RuntimeOssPackageFinalizeReqDTO req = request(raw); + enableOssMode(); + ArtifactStorageDO manifestDrift = storage(raw); + manifestDrift.setRuntimeManifestHash("d".repeat(64)); + when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(manifestDrift); + ServiceException manifestError = assertThrows(ServiceException.class, + () -> runtimePackageService.finalizeOssPackage(req)); + assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), manifestError.getCode()); + + ArtifactStorageDO bundleDrift = storage(raw); + bundleDrift.setBundleHash("e".repeat(64)); + when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(bundleDrift); + ServiceException bundleError = assertThrows(ServiceException.class, + () -> runtimePackageService.finalizeOssPackage(req)); + assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), bundleError.getCode()); + verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class)); + } + + @Test + void finalizeOssPackage_rejectsDeclaredBundleSizeDifferentFromUtf8Bytes() { + String raw = gamePackageWithBundleSize(1L); + RuntimeOssPackageFinalizeReqDTO req = request(raw); + when(properties.isOssRead()).thenReturn(true); + when(properties.getMaxManifestBytes()).thenReturn(16L * 1024 * 1024); + + ServiceException error = assertThrows(ServiceException.class, + () -> runtimePackageService.finalizeOssPackage(req)); + + assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), error.getCode()); + verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class)); + } + + @Test + void finalizeOssPackage_insertsPreviewMetadataWithoutManifestBody() { + String raw = gamePackage(); + RuntimeOssPackageFinalizeReqDTO req = request(raw); + ArtifactStorageDO pending = storage(raw); + enableOssMode(); + when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(pending); + when(artifactStorageMapper.update(any(ArtifactStorageDO.class), + org.mockito.ArgumentMatchers.>any())).thenReturn(1); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(null); + when(runtimePackageMapper.insert(any(RuntimePackageDO.class))).thenAnswer(invocation -> { + RuntimePackageDO value = invocation.getArgument(0); + value.setId(31L); + return 1; + }); + + RuntimeOssPackageFinalizeRespDTO result = runtimePackageService.finalizeOssPackage(req); + + assertEquals(31L, result.getPackageId()); + assertEquals(sha256(raw), result.getChecksum()); + ArgumentCaptor captor = ArgumentCaptor.forClass(RuntimePackageDO.class); + verify(runtimePackageMapper).insert(captor.capture()); + RuntimePackageDO saved = captor.getValue(); + assertEquals(PackageStatusEnum.PREVIEW_READY.getStatus(), saved.getStatus()); + assertEquals(sha256(raw), saved.getChecksum()); + assertEquals((long) ENGINE_BUNDLE.getBytes(StandardCharsets.UTF_8).length, saved.getBundleSize()); + assertNull(saved.getPackageJson()); + ArgumentCaptor storageCaptor = ArgumentCaptor.forClass(ArtifactStorageDO.class); + verify(artifactStorageMapper).update(storageCaptor.capture(), + org.mockito.ArgumentMatchers.>any()); + assertEquals("committed", storageCaptor.getValue().getStatus()); + assertNotNull(storageCaptor.getValue().getCommittedAt()); + verifyNoInteractions(packageStore); + } + + @Test + void finalizeOssPackage_sameDigestReplayIsIdempotentAndKeepsBodyEmpty() { + String raw = gamePackage(); + RuntimeOssPackageFinalizeReqDTO req = request(raw); + ArtifactStorageDO storage = storage(raw); + RuntimePackageDO existing = new RuntimePackageDO(); + existing.setId(31L); + existing.setTenantId(7L); + existing.setGameId(1024L); + existing.setVersionId(2048L); + existing.setTemplateId("phaser"); + existing.setEntry("index.html"); + existing.setRuntimeVersion("1.0.0"); + existing.setPreloadPolicy("eager"); + existing.setBundleSize((long) ENGINE_BUNDLE.getBytes(StandardCharsets.UTF_8).length); + existing.setChecksum(sha256(raw)); + existing.setStatus(PackageStatusEnum.PREVIEW_READY.getStatus()); + existing.setPackageJson(null); + enableOssMode(); + when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(storage); + when(artifactStorageMapper.update(any(ArtifactStorageDO.class), + org.mockito.ArgumentMatchers.>any())).thenReturn(1); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(existing); + + assertEquals(31L, runtimePackageService.finalizeOssPackage(req).getPackageId()); + + verify(runtimePackageMapper, never()).insert(any(RuntimePackageDO.class)); + verify(runtimePackageMapper, never()).updateById(any(RuntimePackageDO.class)); + verifyNoInteractions(packageStore); + } + + @Test + void finalizeOssPackage_rejectsWhenStorageCommitCasMisses() { + String raw = gamePackage(); + RuntimeOssPackageFinalizeReqDTO req = request(raw); + enableOssMode(); + when(artifactStorageMapper.selectByIdentityForUpdate(7L, 1024L, 2048L)).thenReturn(storage(raw)); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(null); + when(runtimePackageMapper.insert(any(RuntimePackageDO.class))).thenAnswer(invocation -> { + RuntimePackageDO value = invocation.getArgument(0); + value.setId(31L); + return 1; + }); + when(artifactStorageMapper.update(any(ArtifactStorageDO.class), + org.mockito.ArgumentMatchers.>any())).thenReturn(0); + + ServiceException error = assertThrows(ServiceException.class, + () -> runtimePackageService.finalizeOssPackage(req)); + + assertEquals(RUNTIME_OSS_FINALIZE_INVALID.getCode(), error.getCode()); + } + + /** 构造完整且可手工核对的最小 GamePackage 原文。 */ + private static String gamePackage() { + long bundleBytes = ENGINE_BUNDLE.getBytes(StandardCharsets.UTF_8).length; + return gamePackageWithBundleSize(bundleBytes); + } + + /** 构造可单独改变 bundleSize 的 GamePackage,供字节完整性负样本使用。 */ + private static String gamePackageWithBundleSize(long bundleBytes) { + return "{\"schemaVersion\":\"1.0\",\"gameId\":\"1024\",\"versionId\":\"2048\"," + + "\"templateId\":\"phaser\",\"gameConfig\":{},\"assets\":[],\"manifest\":{" + + "\"runtimeVersion\":\"1.0.0\",\"entry\":\"index.html\",\"preloadPolicy\":\"eager\"," + + "\"bundleSize\":" + bundleBytes + ",\"checksum\":\"" + "c".repeat(64) + "\"}," + + "\"meta\":{\"title\":\"测试\",\"summary\":\"\",\"cover\":\"https://example.com/c.webp\"," + + "\"ageRating\":\"all\"},\"engineBundle\":\"" + ENGINE_BUNDLE + "\"}"; + } + + /** 构造 Runtime Finalize 入参。 */ + private static RuntimeOssPackageFinalizeReqDTO request(String raw) { + RuntimeOssPackageFinalizeReqDTO req = new RuntimeOssPackageFinalizeReqDTO(); + req.setTenantId(7L); + req.setGameId(1024L); + req.setVersionId(2048L); + req.setRevisionId("revision-a"); + req.setArtifactManifestHash("a".repeat(64)); + req.setManifestJson(raw); + return req; + } + + /** 构造与请求和 GamePackage 两条摘要链均一致的 V34 记录。 */ + private static ArtifactStorageDO storage(String raw) { + ArtifactStorageDO storage = new ArtifactStorageDO(); + storage.setId(5L); + storage.setTenantId(7L); + storage.setGameId(1024L); + storage.setVersionId(2048L); + storage.setArtifactBucket("game-artifacts"); + storage.setArtifactManifestKey("tenants/7/games/1024/versions/2048/artifact-manifest.json"); + storage.setArtifactManifestHash("a".repeat(64)); + storage.setArtifactManifestBytes(1024L); + storage.setRuntimeManifestKey("tenants/7/games/1024/versions/2048/manifest.json"); + storage.setRuntimeManifestHash(sha256(raw)); + storage.setSourceBucket("game-sources"); + storage.setSourceProvider("game_source_archive"); + storage.setSourceGameId("1024"); + storage.setSourceRevisionId("revision-a"); + storage.setSourceManifestKey( + "tenants/7/games/1024/source-revisions/revision-a/source-manifest.json"); + storage.setSourceManifestHash("f".repeat(64)); + storage.setSourceHash("1".repeat(64)); + storage.setBundleHash(sha256(ENGINE_BUNDLE)); + storage.setStatus("pending"); + return storage; + } + + /** 为需要进入摘要校验的用例开启 OSS 模式和固定读取上限。 */ + private void enableOssMode() { + when(properties.isOssRead()).thenReturn(true); + when(properties.getArtifactBucket()).thenReturn("game-artifacts"); + when(properties.getMaxManifestBytes()).thenReturn(16L * 1024 * 1024); + } + + /** 使用 JDK 标准实现独立计算测试期望 SHA-256。 */ + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException ex) { + throw new AssertionError(ex); + } + } +} diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java index 86262fa4..b4a9ee26 100644 --- a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/RuntimePackageServiceImplTest.java @@ -1,6 +1,7 @@ package com.wanxiang.huijing.game.module.runtime.service.pkg; import com.wanxiang.huijing.game.module.runtime.dal.dataobject.pkg.RuntimePackageDO; +import com.wanxiang.huijing.game.module.runtime.dal.dataobject.storage.ArtifactStorageDO; import com.wanxiang.huijing.game.module.runtime.dal.mysql.pkg.RuntimePackageMapper; import com.wanxiang.huijing.game.module.runtime.dto.RuntimePackageStoreReqDTO; import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; @@ -52,6 +53,12 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { @Mock private ArtifactStorageGate artifactStorageGate; + @Mock + private RuntimeArtifactContentService runtimeArtifactContentService; + + @Mock + private RuntimeArtifactStorageProperties runtimeArtifactStorageProperties; + @AfterEach void clearSecurityContext() { org.springframework.security.core.context.SecurityContextHolder.clearContext(); @@ -98,6 +105,37 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { verify(runtimePackageMapper, times(1)).selectByVersionId(2048L); } + @Test + void testGetPackageRawOssModeReturnsVerifiedRemoteManifestWithoutDbFallback() { + RuntimePackageDO p = pkg(PackageStatusEnum.PUBLISHED.getStatus()); + p.setPackageJson("{\"source\":\"db\"}"); + ArtifactStorageDO storage = new ArtifactStorageDO(); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p); + when(artifactStorageGate.requireCommitted(p)).thenReturn(storage); + when(runtimeArtifactStorageProperties.isOssRead()).thenReturn(true); + when(runtimeArtifactContentService.readManifest(p, storage)).thenReturn("{\"source\":\"oss\"}"); + + assertEquals("{\"source\":\"oss\"}", runtimePackageService.getPackageManifestRaw( + 2048L, RuntimeSceneEnum.PLAY.getScene(), 99L)); + verify(packageStore, never()).getManifest(anyLong()); + verify(runtimePackageMapper, times(1)).selectByVersionId(2048L); + } + + @Test + void testGetPackageRawShadowModeReadsOssButReturnsDbManifest() { + RuntimePackageDO p = pkg(PackageStatusEnum.PUBLISHED.getStatus()); + p.setPackageJson("{\"source\":\"db\"}"); + ArtifactStorageDO storage = new ArtifactStorageDO(); + when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(p); + when(artifactStorageGate.requireCommitted(p)).thenReturn(storage); + when(runtimeArtifactStorageProperties.isShadowRead()).thenReturn(true); + when(runtimeArtifactContentService.readManifest(p, storage)).thenReturn("{\"source\":\"oss\"}"); + + assertEquals(p.getPackageJson(), runtimePackageService.getPackageManifestRaw( + 2048L, RuntimeSceneEnum.PLAY.getScene(), 99L)); + verify(runtimeArtifactContentService).readManifest(p, storage); + } + @Test void testGetPackage_playRejectsExpiredAfterUnlist() { when(runtimePackageMapper.selectByVersionId(2048L)).thenReturn(pkg(PackageStatusEnum.EXPIRED.getStatus())); @@ -125,6 +163,7 @@ class RuntimePackageServiceImplTest extends BaseMockitoUnitTest { () -> runtimePackageService.getPackageManifest(2048L, RuntimeSceneEnum.PREVIEW.getScene(), 100L)); assertEquals(RUNTIME_PACKAGE_PREVIEW_NOT_OWNER.getCode(), ex.getCode()); + verify(artifactStorageGate, never()).requireCommitted(any()); } @Test diff --git a/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReaderTest.java b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReaderTest.java new file mode 100644 index 00000000..9d6cf74c --- /dev/null +++ b/game-cloud/game-module-runtime/game-module-runtime-server/src/test/java/com/wanxiang/huijing/game/module/runtime/service/pkg/store/S3ArtifactObjectReaderTest.java @@ -0,0 +1,25 @@ +package com.wanxiang.huijing.game.module.runtime.service.pkg.store; + +import com.wanxiang.huijing.game.module.runtime.service.pkg.RuntimeArtifactStorageProperties; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** S3 客户端必须把配置的总调用和单次尝试超时交给 SDK。 */ +class S3ArtifactObjectReaderTest { + + @Test + void appliesApiCallTimeoutsToSdkClient() { + RuntimeArtifactStorageProperties properties = new RuntimeArtifactStorageProperties(); + properties.setApiCallTimeout(Duration.ofSeconds(12)); + properties.setApiCallAttemptTimeout(Duration.ofSeconds(4)); + + ClientOverrideConfiguration configuration = S3ArtifactObjectReader.buildOverrideConfiguration(properties); + + assertEquals(Duration.ofSeconds(12), configuration.apiCallTimeout().orElseThrow()); + assertEquals(Duration.ofSeconds(4), configuration.apiCallAttemptTimeout().orElseThrow()); + } +} diff --git a/game-cloud/huijing-server/src/main/resources/application-staging.yaml b/game-cloud/huijing-server/src/main/resources/application-staging.yaml index 0e931961..f3ef8683 100644 --- a/game-cloud/huijing-server/src/main/resources/application-staging.yaml +++ b/game-cloud/huijing-server/src/main/resources/application-staging.yaml @@ -3,6 +3,24 @@ server: port: 48080 +--- #################### 游戏内容对象存储 #################### +# staging 默认继续读 DB,隔离验收实例通过命令行只覆盖 read-mode=OSS;这样不会让尚未迁移的旧版本失效。 +# Runtime 使用独立只读服务身份,只能 GET game-artifacts/tenants/*,不能列举、上传、删除或读取证据桶。 +game: + artifact-storage: + read-mode: DB + enforce-committed: false + endpoint: http://100.64.0.8:9000 + region: us-east-1 + access-key: game-artifact-runtime-reader + secret-key: b863aa88d40c4a07c4f7ffc44d1b51e016b0014ec570b8010fd6d9315d0ef24c + path-style: true + artifact-bucket: game-artifacts + max-manifest-bytes: 16777216 + max-asset-bytes: 104857600 + api-call-timeout: 15s + api-call-attempt-timeout: 5s + --- #################### 单体启动:允许 Bean 覆盖 #################### # 多个 huijing 模块声明同名 @FeignClient,单体内会产生同名 FeignClientSpecification,开启 Bean 覆盖(huijing 单体标准接法)。 spring: @@ -182,6 +200,10 @@ pf4j: --- #################### aigc 生成执行器(M-b 生成半下沉 HJ-AGENT-LOOP-EXEC-002)#################### aigc: + # Agent 游戏内容仓库的独立受信任控制面。默认关闭;隔离 OSS 链路实例显式打开。 + game-content-control: + enabled: false + secret: acfe0d5af3da62470f0cf2af4820f1e3fedbe76c81dd905ca847cf2480bf97b9 executor: enabled: ${AIGC_EXECUTOR_ENABLED:false} # 灰度开关:经 ~/game-staging/infra/.env 注入,默认关 api-key: ${NEWAPI_KEY:} # 密钥只走环境变量占位符,严禁写真值入 repo diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V35.0.0__clarify_game_artifact_manifest_hash.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V35.0.0__clarify_game_artifact_manifest_hash.sql new file mode 100644 index 00000000..6394d588 --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V35.0.0__clarify_game_artifact_manifest_hash.sql @@ -0,0 +1,12 @@ +-- ============================================================================= +-- 契约 #2 DB 迁移 | 模块:runtime/storage(对象清单摘要语义)| owner:WS3 + WS2 +-- 文件:V35.0.0__clarify_game_artifact_manifest_hash.sql +-- 目的:澄清 V34 两个契约清单摘要字段保存的是契约 manifestHash,而非清单原始字节摘要。 +-- 边界:V34 已执行且保持字节不可变;本迁移只修改列注释,不改变数据和索引。 +-- ============================================================================= + +ALTER TABLE `game_artifact_storage` + MODIFY COLUMN `artifact_manifest_hash` CHAR(64) NOT NULL DEFAULT '' + COMMENT 'GameArtifactManifest/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)', + MODIFY COLUMN `source_manifest_hash` CHAR(64) NOT NULL DEFAULT '' + COMMENT 'GameSourceArchive/1 manifestHash(域标签 + 去自身字段 canonical JSON SHA-256)'; diff --git a/game-runtime/games/_generic/entry-generic.js b/game-runtime/games/_generic/entry-generic.js index f1caade3..d5b56ad9 100644 --- a/game-runtime/games/_generic/entry-generic.js +++ b/game-runtime/games/_generic/entry-generic.js @@ -84,6 +84,7 @@ export async function bootGameHost(opts) { plugins: cfg.plugins, registerOrder: cfg.registerOrder, buildFactoryOpts: cfg.buildFactoryOpts, + assets: o.assets || [], // studio 渠道产物:不注 recHook(引擎 call-ID 取证是 ref harness 专属;渠道产物无副作用)。 // 无 onReady:studio 不消费 ref 取证全局;就绪信号经 SDK lifecycle game_loaded/game_end(studio 轨接)。 }); diff --git a/game-runtime/games/_wg1-gen/_shared/entry-bundle.amodel.template.js b/game-runtime/games/_wg1-gen/_shared/entry-bundle.amodel.template.js index 1b7bc070..56d5dcc6 100644 --- a/game-runtime/games/_wg1-gen/_shared/entry-bundle.amodel.template.js +++ b/game-runtime/games/_wg1-gen/_shared/entry-bundle.amodel.template.js @@ -34,7 +34,7 @@ export default gameFactory; /** * 【SAA boot 入口】host 页(index.template.html)调 window.__GameBundle.bootGameHost。 - * @param {{canvas:HTMLCanvasElement, statusEl?:HTMLElement, seed?:number, recHook?:Function, onReady?:Function, engine?:any, mode?:string, allowedGameEventTypes?:Iterable, interactionProfileId?:string}} opts + * @param {{canvas:HTMLCanvasElement, statusEl?:HTMLElement, seed?:number, recHook?:Function, onReady?:Function, engine?:any, mode?:string, allowedGameEventTypes?:Iterable, interactionProfileId?:string, assets?:Array}} opts * @returns {Promise} 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 已逐字节校验后注入的内存素材描述 }); } diff --git a/game-runtime/games/_wg1-gen/_shared/entry-bundle.template.js b/game-runtime/games/_wg1-gen/_shared/entry-bundle.template.js index f6942e9f..3b5e23c6 100644 --- a/game-runtime/games/_wg1-gen/_shared/entry-bundle.template.js +++ b/game-runtime/games/_wg1-gen/_shared/entry-bundle.template.js @@ -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} [opts.allowedGameEventTypes] 当前可信 profile 的业务事件 type 白名单。 + * @param {Array} [opts.assets] Studio 已完成字节校验的素材描述。 * @param {any} [opts.engine] 覆盖引擎名字空间(缺省动态 import littlejsengine)。 * @returns {Promise} 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 已逐字节校验后注入的内存素材描述 }); } diff --git a/game-runtime/src/host/boot-game-host.js b/game-runtime/src/host/boot-game-host.js index 3a5bf315..207fdec1 100644 --- a/game-runtime/src/host/boot-game-host.js +++ b/game-runtime/src/host/boot-game-host.js @@ -456,8 +456,23 @@ function resolveActionProvenanceBoundary() { * @param {string} [baseUrl] 资产根(缺省 './assets/',相对 index.html;play.cdp/studio iframe 同源可达)。 * @returns {Promise>} */ -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} [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, diff --git a/game-runtime/src/host/boot-game-host.test.mjs b/game-runtime/src/host/boot-game-host.test.mjs index 3bcf6c74..58c02553 100644 --- a/game-runtime/src/host/boot-game-host.test.mjs +++ b/game-runtime/src/host/boot-game-host.test.mjs @@ -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 = []; diff --git a/game-studio/package.json b/game-studio/package.json index 73779a7e..fc7675d6 100644 --- a/game-studio/package.json +++ b/game-studio/package.json @@ -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", diff --git a/game-studio/scripts/inject-security.test.mjs b/game-studio/scripts/inject-security.test.mjs new file mode 100644 index 00000000..d0c4a8fa --- /dev/null +++ b/game-studio/scripts/inject-security.test.mjs @@ -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); +}); diff --git a/game-studio/src/api/types.ts b/game-studio/src/api/types.ts index 9859960b..5f81bba5 100644 --- a/game-studio/src/api/types.ts +++ b/game-studio/src/api/types.ts @@ -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) */ diff --git a/game-studio/src/host/GamePlayer.vue b/game-studio/src/host/GamePlayer.vue index 1b2eed52..c695e4e9 100644 --- a/game-studio/src/host/GamePlayer.vue +++ b/game-studio/src/host/GamePlayer.vue @@ -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 { @@ -144,39 +144,42 @@ async function resolvePackage(): Promise { 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 { /* 忽略:白名单缺省由 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 { /** 暂存的契约白名单(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 {