"""供 Agent 使用的游戏内容仓库:从 committed 对象版本检出,并发布新的不可变版本。""" from __future__ import annotations import hashlib import hmac import http.client import json import re import shutil import tempfile from datetime import datetime, timezone from pathlib import Path from typing import Callable, Mapping from urllib.parse import urljoin, urlsplit from .game_artifact_storage_store import build_storage_record from .game_artifact_store import ( ArtifactError, _safe_rel, build_manifest, download_manifest, fetch_manifest, upload_manifest, ) from .game_source_archive_store import ( build_source_archive, download_source_archive, fetch_source_archive, upload_source_archive, ) class GameContentError(ArtifactError): """游戏内容仓库的身份、物化或提交边界失败。""" class GameContentControlClient: """使用原始 JSON body HMAC 调用后端受信任 prepare/finalize/activate 控制面。""" _SIGNATURE_HEADER = "X-Game-Content-Signature" _MAX_RESPONSE_BYTES = 1024 * 1024 def __init__(self, endpoint: str, secret: str, *, timeout_s: float): if not isinstance(endpoint, str) or not endpoint.strip(): raise GameContentError("游戏内容控制面 endpoint 不能为空") parsed = urlsplit(endpoint.strip()) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise GameContentError("游戏内容控制面 endpoint 只接受 http/https 绝对地址") if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment: raise GameContentError("游戏内容控制面 endpoint 不能包含用户信息、query 或 fragment") try: port = parsed.port except ValueError as exc: raise GameContentError("游戏内容控制面 endpoint 端口非法") from exc if not isinstance(secret, str) or not secret: raise GameContentError("游戏内容控制面 HMAC 密钥不能为空") if isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)) or timeout_s <= 0: raise GameContentError("游戏内容控制面 timeout_s 必须为正数") self._scheme = parsed.scheme self._host = parsed.hostname self._port = port self._base_path = parsed.path.rstrip("/") self._origin = self._origin_of(parsed) self._endpoint = endpoint.strip().rstrip("/") self._secret = secret.encode("utf-8") self._timeout_s = float(timeout_s) @staticmethod def _origin_of(parsed) -> tuple[str, str | None, int | None]: """按 scheme/host/有效端口比较 origin,默认端口与显式端口视为相同。""" default_port = 443 if parsed.scheme == "https" else 80 if parsed.scheme == "http" else None try: port = parsed.port or default_port except ValueError: port = None return parsed.scheme.lower(), parsed.hostname.lower() if parsed.hostname else None, port @staticmethod def _positive_result(value, label: str) -> int: """控制面 ID 必须是非布尔正整数,禁止字符串化 ID 混入生产身份。""" if type(value) is not int or value <= 0: raise GameContentError(f"游戏内容控制面 {label} 返回的 data 不是正整数") return value def _post(self, operation: str, payload: Mapping) -> int: """发送一次不自动重定向的 POST;HTTP 与 CommonResult 任一失败都 fail-closed。""" try: raw_body = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False, ).encode("utf-8") except (TypeError, ValueError) as exc: raise GameContentError(f"游戏内容控制面 {operation} 请求无法编码为 JSON") from exc signature = hmac.new(self._secret, raw_body, hashlib.sha256).hexdigest() connection_type = ( http.client.HTTPSConnection if self._scheme == "https" else http.client.HTTPConnection ) connection = connection_type(self._host, self._port, timeout=self._timeout_s) path = f"{self._base_path}/{operation}" if self._base_path else f"/{operation}" try: connection.request("POST", path, body=raw_body, headers={ "Content-Type": "application/json; charset=utf-8", "Accept": "application/json", self._SIGNATURE_HEADER: signature, }) response = connection.getresponse() if 300 <= response.status < 400: location = response.getheader("Location") if location: redirected = urlsplit(urljoin(f"{self._endpoint}/", location)) if self._origin_of(redirected) != self._origin: raise GameContentError( f"游戏内容控制面 {operation} 拒绝跨 origin 重定向" ) raise GameContentError(f"游戏内容控制面 {operation} 不接受重定向") response_body = response.read(self._MAX_RESPONSE_BYTES + 1) if len(response_body) > self._MAX_RESPONSE_BYTES: raise GameContentError(f"游戏内容控制面 {operation} 响应超过 1MiB") if not 200 <= response.status < 300: raise GameContentError( f"游戏内容控制面 {operation} HTTP 失败:{response.status}" ) try: envelope = json.loads(response_body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise GameContentError( f"游戏内容控制面 {operation} 响应不是合法 UTF-8 JSON" ) from exc if not isinstance(envelope, dict) or type(envelope.get("code")) is not int \ or envelope["code"] != 0: raise GameContentError(f"游戏内容控制面 {operation} CommonResult 失败") return self._positive_result(envelope.get("data"), f"{operation} ID") except (OSError, http.client.HTTPException) as exc: raise GameContentError( f"游戏内容控制面 {operation} 网络失败:{type(exc).__name__}" ) from exc finally: connection.close() def prepare(self, *, tenant_id: int, game_id: int, revision_id: str) -> int: """请求 Project 创建或幂等复用内容版本,并返回权威 versionId。""" return self._post("prepare", { "tenantId": tenant_id, "gameId": game_id, "revisionId": revision_id, }) def finalize(self, *, tenant_id: int, game_id: int, version_id: int, revision_id: str, artifact_manifest_hash: str, manifest_json: str) -> int: """提交已上传 GamePackage 原文和摘要身份,并返回 Runtime packageId。""" return self._post("finalize", { "tenantId": tenant_id, "gameId": game_id, "versionId": version_id, "revisionId": revision_id, "artifactManifestHash": artifact_manifest_hash, "manifestJson": manifest_json, }) def activate(self, *, tenant_id: int, game_id: int, version_id: int, revision_id: str, expected_current_version_id: int | None) -> int: """浏览器验收通过后,按 expected-current CAS 激活目标版本。""" return self._post("activate", { "tenantId": tenant_id, "gameId": game_id, "versionId": version_id, "revisionId": revision_id, "expectedCurrentVersionId": expected_current_version_id, }) class GameContentRepository: """把源归档、制品清单和数据库 committed 状态组合为一个 Agent 入口。""" _REVISION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") def __init__(self, storage_store, *, control_client=None, source_config: Mapping, artifact_config: Mapping, now: Callable[[], datetime] | None = None): self._storage = storage_store self._control = control_client self._source_config = dict(source_config) self._artifact_config = dict(artifact_config) self._now = now or (lambda: datetime.now(timezone.utc)) @staticmethod def _validate_positive_ids(identity: Mapping[str, int]) -> None: """生产身份字段必须是数据库正整数,不能把对象前缀或布尔值当业务身份。""" for name, value in identity.items(): if isinstance(value, bool) or not isinstance(value, int) or value <= 0: raise GameContentError(f"{name} 必须是正整数") @classmethod def _identity(cls, tenant_id: int, game_id: int, version_id: int) -> dict[str, int]: """构造并校验完整生产版本身份。""" identity = {"tenant_id": tenant_id, "game_id": game_id, "version_id": version_id} cls._validate_positive_ids(identity) return identity @classmethod def _prepare_identity(cls, tenant_id: int, game_id: int, revision_id: str) -> None: """在联网前校验 prepare 身份,错误输入不能触碰受信控制面。""" cls._validate_positive_ids({"tenant_id": tenant_id, "game_id": game_id}) if not isinstance(revision_id, str) or not cls._REVISION_RE.fullmatch(revision_id): raise GameContentError("revision_id 格式非法") def prepare(self, *, tenant_id: int, game_id: int, revision_id: str) -> int: """从 Project 权威控制面取得给定不可变修订的生产 versionId。""" self._prepare_identity(tenant_id, game_id, revision_id) if self._control is None: raise GameContentError("游戏内容控制面未配置,拒绝 prepare") version_id = self._control.prepare( tenant_id=tenant_id, game_id=game_id, revision_id=revision_id, ) self._identity(tenant_id, game_id, version_id) return version_id def activate(self, *, tenant_id: int, game_id: int, version_id: int, revision_id: str, expected_current_version_id: int | None) -> int: """将浏览器已验收版本显式切为当前版本;commit 本身永不隐式激活。""" self._identity(tenant_id, game_id, version_id) self._prepare_identity(tenant_id, game_id, revision_id) if expected_current_version_id is not None: self._validate_positive_ids({"expected_current_version_id": expected_current_version_id}) if self._control is None: raise GameContentError("游戏内容控制面未配置,拒绝 activate") activated_version_id = self._control.activate( tenant_id=tenant_id, game_id=game_id, version_id=version_id, revision_id=revision_id, expected_current_version_id=expected_current_version_id, ) if activated_version_id != version_id: raise GameContentError( f"activate 返回 versionId 不一致:{activated_version_id}!={version_id}" ) return activated_version_id @staticmethod def _copy_file(source: Path, target: Path) -> None: """合并已验证快照时拒绝路径碰撞,避免源码与素材静默互相覆盖。""" target.parent.mkdir(parents=True, exist_ok=True) if target.exists(): if target.is_file() and source.read_bytes() == target.read_bytes(): return raise GameContentError(f"物化路径冲突:{target}") shutil.copy2(source, target) def checkout(self, *, tenant_id: int, game_id: int, version_id: int, target: Path) -> dict: """按 committed 行原子物化 Agent 工作区:源码、发布素材和原始 GamePackage。""" identity = self._identity(tenant_id, game_id, version_id) record = self._storage.get_committed_record(**identity) if not record: raise GameContentError("游戏内容版本未 committed,拒绝 Agent 检出") if record.get("source_provider") != "game_source_archive": raise GameContentError("当前 Agent 检出入口只接受 game_source_archive;Tier2 源继续走 SourceProjectStore") if self._source_config.get("source_bucket") != record.get("source_bucket"): raise GameContentError("源码桶配置与 committed 记录不一致") if self._artifact_config.get("artifact_bucket", "game-artifacts") != record.get("artifact_bucket"): raise GameContentError("制品桶配置与 committed 记录不一致") target = Path(target).resolve() if target.exists(): raise GameContentError(f"Agent 工作区已存在:{target}") target.parent.mkdir(parents=True, exist_ok=True) staging_root = Path(tempfile.mkdtemp(prefix=f".{target.name}.partial-", dir=target.parent)).resolve() source_snapshot = staging_root / "source" artifact_snapshot = staging_root / "artifact" payload = staging_root / "payload" payload.mkdir() try: source_manifest = fetch_source_archive( self._source_config, key=record["source_manifest_key"], expected_manifest_hash=record["source_manifest_hash"], expected_tenant_id=str(tenant_id), expected_game_id=str(record["source_game_id"]), expected_revision_id=str(record["source_revision_id"]), ) if source_manifest.get("sourceHash") != record.get("source_hash"): raise GameContentError("源归档与 committed sourceHash 不一致") download_source_archive( source_manifest, self._source_config, source_snapshot, expected_manifest_hash=record["source_manifest_hash"], ) artifact_manifest = fetch_manifest( self._artifact_config, key=record["artifact_manifest_key"], expected_manifest_hash=record["artifact_manifest_hash"], expected_tenant_id=str(tenant_id), expected_game_id=str(game_id), expected_version_id=str(version_id), ) download_manifest( artifact_manifest, self._artifact_config, artifact_snapshot, expected_manifest_hash=record["artifact_manifest_hash"], ) for source in sorted(source_snapshot.rglob("*")): if source.is_file(): self._copy_file(source, payload / source.relative_to(source_snapshot)) for item in artifact_manifest.get("objects", []): if item.get("category") != "asset": continue rel = _safe_rel(str(item["path"])) self._copy_file(artifact_snapshot / rel, payload / rel) self._copy_file(artifact_snapshot / "manifest.json", payload / "game-package.json") payload.replace(target) except Exception: shutil.rmtree(staging_root, ignore_errors=True) raise shutil.rmtree(staging_root, ignore_errors=True) return { "tenantId": str(tenant_id), "gameId": str(game_id), "versionId": str(version_id), "sourceRevisionId": str(record["source_revision_id"]), "sourceHash": record["source_hash"], "artifactManifestHash": record["artifact_manifest_hash"], "target": str(target), } def commit(self, *, root: Path, tenant_id: int, game_id: int, version_id: int, revision_id: str, engine: str, package_type: str, runtime_manifest_path: str = "game-package.json", creator: str = "agent") -> dict: """重验版本后上传对象,由后端 Finalize 完成 Runtime 与 V34 提交,再做幂等确认。""" identity = self._identity(tenant_id, game_id, version_id) prepared_version_id = self.prepare( tenant_id=tenant_id, game_id=game_id, revision_id=revision_id, ) if prepared_version_id != version_id: raise GameContentError( f"commit versionId 与后端 prepare 不一致:{version_id}!={prepared_version_id}" ) if not hasattr(self._storage, "version_lock"): raise GameContentError("对象状态存储缺少版本级互斥锁,拒绝发布") root = Path(root).resolve() with self._storage.version_lock(**identity): source_manifest = build_source_archive( root, str(tenant_id), str(game_id), revision_id, engine=engine, runtime_manifest_path=runtime_manifest_path, ) upload_source_archive( root, source_manifest, self._source_config, external_single_writer=True, ) artifact_manifest = build_manifest( root, str(tenant_id), str(game_id), str(version_id), package_type=package_type, runtime_manifest_path=runtime_manifest_path, source_revision={ "provider": "game_source_archive", "gameId": str(game_id), "revisionId": revision_id, "manifestRef": f"game-source-archive:{game_id}:{revision_id}", "sourceHash": source_manifest["sourceHash"], }, ) upload_manifest( root, artifact_manifest, self._artifact_config, external_single_writer=True, ) record = build_storage_record( artifact_manifest, artifact_bucket=str(self._artifact_config.get("artifact_bucket", "")), source_bucket=str(self._source_config.get("source_bucket", "")), source_archive=source_manifest, creator=creator, updater=creator, ) self._storage.create_pending(record) runtime_manifest = root / _safe_rel(runtime_manifest_path) try: # read_bytes 后严格解码可保留 CRLF 等原始字符;read_text 会做通用换行转换。 manifest_json = runtime_manifest.read_bytes().decode("utf-8") except (OSError, UnicodeDecodeError) as exc: raise GameContentError("GamePackage 原文读取失败或不是合法 UTF-8") from exc runtime_package_id = self._control.finalize( tenant_id=tenant_id, game_id=game_id, version_id=version_id, revision_id=revision_id, artifact_manifest_hash=artifact_manifest["manifestHash"], manifest_json=manifest_json, ) receipt = self._storage.commit_pending(record, committed_at=self._now()) return { **receipt, "versionId": str(version_id), "runtimePackageId": runtime_package_id, "activated": False, "sourceHash": source_manifest["sourceHash"], "sourceManifestHash": source_manifest["manifestHash"], "artifactManifestHash": artifact_manifest["manifestHash"], "runtimeManifestHash": artifact_manifest["runtimeManifest"]["sha256"], "bundleHash": artifact_manifest["bundleHash"], }