增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
399 lines
19 KiB
Python
399 lines
19 KiB
Python
"""供 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"],
|
||
}
|