lili 1981a6be01
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(storage): 闭合 Agent 到 Runtime 的 OSS 游戏链路
增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
2026-07-30 05:21:44 -07:00

347 lines
22 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

openapi: 3.0.3
# 契约 #1 API | 模块:runtime(编译 / 沙箱预览 / 试玩会话)| owner:WS3(runtime 工位主笔,全员 review)
# 端:/app-api(产品端 game-studio,用户 Token+DataPermission) /admin-api(管理端 game-admin,RBAC);前缀由 huijing 框架按 controller.app/admin 包名自动添加
# 错误码段:runtime = 1-102-***-***(本模块独占,禁止与他模块重叠)
# 响应统一 Huijing CommonResult 信封:{ code, data, msg };code=0 成功
#
# 脊柱对接(闭环贯通关键):
# - 上游输入:GamePackage(契约 #4 game-package.schema.json)—— 由 aigc/studio 生成、project 持久化版本元数据;runtime 编译产出版本化运行包。
# - 对外提供:版本运行包清单(manifest URL + 入口 + checksum + 预加载策略)给「试玩宿主」(game-studio iframe 宿主)。
# - 宿主↔游戏边界:postMessage 协议(契约 #3 sdk-interface.d.ts)由宿主与游戏侧落地;runtime 不实现 postMessage,只为宿主提供加载清单与会话开/收端点。
# - 试玩会话:game_runtime_session 记录 play_start/play_end/时长,供回灌 telemetry(契约 #5 events.schema.json,由 telemetry 模块消费)。
#
# MVP 范围纪律:只做核心闭环所需端点 —— 编译产出运行包 → 预览/试玩取包清单 → 试玩会话开/收 → 会话回灌。
# 渠道转换(微信/抖音/快手/TapTap 试玩包,T-RT-21~24/31/32)、云游戏容器(T-RT-30)属"扩"远期,不进 MVP,仅在 §对接点 留契约挂点。
info:
title: 绘境AI runtime 模块 API
version: 1.0.0
description: GameConfig→版本化可运行包编译、iframe 沙箱预览/试玩、运行包清单分发、试玩会话开收(回灌 telemetry)。前端据此 vite-plugin-mock 自动生成 mock。
servers:
- url: http://localhost:48080
description: 本地(Swagger/Knife4j http://localhost:48080/doc.html)
paths:
# ==========================================================================
# 产品端 /app-api —— 预览/试玩宿主取包清单 + 试玩会话开收(用户 Token)
# ==========================================================================
/app-api/runtime/package/{versionId}:
get:
tags: [app-runtime]
summary: 取版本运行包清单(预览/试玩宿主据此渲染 iframe、桥接 SDK ←#3)
description: >
宿主据返回的 manifest(入口/包URL/checksum/预加载策略)加载游戏并做完整性校验(门禁 Manifest 完整性 T-RT-15)。
取包门禁判定字段 = game_runtime_package.status(运行包状态,非 project.game_version.status):
scene=preview 放行 status∈{0 预览就绪, 1 已发布},并校验调用者为版本 owner(创作者本人预览未发布版本);
scene=play 仅放行 status=1(已发布);status=2(已失效/下架)继续返回未发布错误 1-102-001-002。
无对应就绪运行包返回 1-102-001-001。
parameters:
- { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: 版本 ID(project.game_version.id) }
- { name: scene, in: query, required: false, schema: { type: string, enum: [preview, play], default: play }, description: 'preview=创作者预览未发布版本 / play=玩家试玩已发布版本' }
responses:
'200':
description: 成功
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultRuntimePackage' }
/app-api/runtime/package/{versionId}/manifest:
get:
tags: [app-runtime]
summary: 取版本运行包 manifest 原始 JSON(宿主 fetch 此端点取 manifest 并做 sha256 完整性校验)
description: >
【§3.4 C4 关键约束】返回 manifest 的原始 JSON 文本,**不包 CommonResult、不 parse/re-serialize、不加换行**——
宿主对该响应文本原始字节算 sha256,须与 RuntimePackageRespVO.checksum 严格一致(PackageFactory 写入时按相同字节计算并存 game_runtime_package.checksum),
任何包裹/重序列化都会破坏字节一致性导致校验失败。
存储介质由服务端 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) }
- { name: scene, in: query, required: false, schema: { type: string, enum: [preview, play], default: play }, description: 'preview=创作者预览未发布版本 / play=玩家试玩已发布版本' }
responses:
'200':
description: manifest 原始 JSON 文本(application/json,不包 CommonResult;sha256(响应文本)==checksum)
content:
application/json:
schema:
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]
summary: 开始试玩会话(即点即玩 P-PLZ-03 / 预览试玩 P-CRT-08)
description: >
宿主在游戏 game_start(生命周期 ←#3)时调用,落 game_runtime_session 一行并返回 sessionId。
sessionId 作为本局唯一键,贯穿后续 end 与 telemetry 回灌的关联键。
clientPlayToken 幂等:同一 token 重复提交返回同一 sessionId(防抖/重连不重复计数)。
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SessionStartReqVO' }
responses:
'200':
description: 返回会话 ID
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultSessionStart' }
/app-api/runtime/session/end:
post:
tags: [app-runtime]
summary: 结束试玩会话(仅记录时长/质量观测,不计 play_count)
description: >
宿主在游戏 game_end 或容器卸载(sendBeacon 兜底)时调用,写 play_end 与 duration_ms,置会话状态=1已结束。
服务端以 sessionId 为准计算时长(end-start),客户端上报 durationMs 仅作离线兜底。
幂等:会话已结束再次提交直接返回成功,不二次累加。
注:runtime session 仅作时长/质量观测,不计 play_count;play_count 权威源 = telemetry 的 game_play_start 事件聚合
(见 telemetry V5.0.0 / telemetry.yaml)。runtime sessionId(int64) 透传进 telemetry envelope.session_id(转 string)作对账键。
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/SessionEndReqVO' }
responses:
'200':
description: 成功
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultBoolean' }
# ==========================================================================
# 管理端 /admin-api —— 编译编排 + 编译状态 + 会话/质量观测(管理 RBAC)
# 注:编译由 project 统一发布编排(T-PRJ-08)或运营触发;此处暴露管理侧入口与查询。
# ==========================================================================
/admin-api/runtime/compile:
post:
tags: [admin-runtime]
summary: 触发编译(GameConfig→版本化运行包,消费 aigc 产物 ←#4)
description: >
以 versionId 为入参,读取该版本的 GamePackage(#4),执行 GameConfig 静态校验(门禁 T-RT-16)、
体积门禁(≤10MB/首屏≤2MB,T-RT-08)、Manifest 生成与打包(T-RT-02)、资源版本化上传(T-RT-03)。
编译为异步幂等任务:返回 buildId;同一 versionId 重复触发命中进行中任务返回同一 buildId。
编译成功后回写 game_runtime_package(manifest/包URL/checksum),并置 game_runtime_package.status=0(预览就绪);
发布态(status=1 已发布 + 回写 project.game_version.status=3)不在此处写,统一由 publish 对接点
POST /admin-api/runtime/package/{versionId}/publish 在发布编排成功后回写。
同时按「决策5 写权属」回写 project.game_version 的 package_url/checksum/bundle_size(runtime 为该三字段权威写者,与 game_runtime_package 同源)。
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CompileReqVO' }
responses:
'200':
description: 返回编译任务 ID
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultCompile' }
/admin-api/runtime/build/{buildId}:
get:
tags: [admin-runtime]
summary: 查询编译任务状态(发布编排/运营轮询)
parameters:
- { name: buildId, in: path, required: true, schema: { type: integer, format: int64 }, description: 编译任务 ID }
responses:
'200':
description: 成功
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultBuild' }
/admin-api/runtime/package/{versionId}/publish:
post:
tags: [admin-runtime]
summary: 发布态回写对接点(编译就绪运行包 → 置为已发布,补全发布态写入链路)
description: >
非狗粮环境供管理端运营发布;dogfood=true 时该 HTTP 入口固定拒绝并返回 1-102-001-005,
必须由 project 审核通过后经 RuntimePackageApi 本地编排发布,防止绕过审核绑定。
作用:仅置 game_runtime_package.status=1(已发布,玩家可试玩);project.game_version.status=3 由 project 编排负责。
幂等:运行包已是 status=1 时重复调用直接返回成功,不二次写入。
前置:该 versionId 必须已编译成功且存在就绪运行包(status=0 预览就绪);否则返回 1-102-001-001。
parameters:
- { name: versionId, in: path, required: true, schema: { type: integer, format: int64 }, description: '版本 ID(project.game_version.id)' }
- { name: expectedArtifactHash, in: query, required: true, schema: { type: string, pattern: '^[0-9a-f]{64}$' }, description: '审核绑定的运行产物 SHA-256;与运行包 checksum 不一致时拒发' }
responses:
'200':
description: 成功(运行包已置已发布)
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultBoolean' }
/admin-api/runtime/session/page:
get:
tags: [admin-runtime]
summary: 试玩会话列表(运行时质量观测 / 回灌核对)
parameters:
- { name: gameId, in: query, required: false, schema: { type: integer, format: int64 }, description: 按游戏筛选 }
- { name: versionId, in: query, required: false, schema: { type: integer, format: int64 }, description: 按版本筛选 }
- { name: status, in: query, required: false, schema: { type: integer }, description: '会话状态:0进行中 1已结束 2异常' }
- { name: pageNo, in: query, required: false, schema: { type: integer, default: 1 } }
- { name: pageSize, in: query, required: false, schema: { type: integer, default: 10 } }
responses:
'200':
description: 成功
content:
application/json:
schema: { $ref: '#/components/schemas/CommonResultSessionPage' }
components:
schemas:
# ---- Huijing CommonResult 信封 ----
CommonResultBoolean:
type: object
properties:
code: { type: integer, description: '0=成功,非0=错误码 1-102-***-***' }
data: { type: boolean }
msg: { type: string }
CommonResultRuntimePackage:
type: object
properties:
code: { type: integer }
data: { $ref: '#/components/schemas/RuntimePackageRespVO' }
msg: { type: string }
CommonResultSessionStart:
type: object
properties:
code: { type: integer }
data: { $ref: '#/components/schemas/SessionStartRespVO' }
msg: { type: string }
CommonResultCompile:
type: object
properties:
code: { type: integer }
data: { $ref: '#/components/schemas/CompileRespVO' }
msg: { type: string }
CommonResultBuild:
type: object
properties:
code: { type: integer }
data: { $ref: '#/components/schemas/BuildRespVO' }
msg: { type: string }
CommonResultSessionPage:
type: object
properties:
code: { type: integer }
data:
type: object
properties:
list: { type: array, items: { $ref: '#/components/schemas/SessionRespVO' } }
total: { type: integer, format: int64 }
msg: { type: string }
# ---- 运行包清单(脊柱对外产物:宿主据此加载 + 校验,对齐 #4 manifest 段)----
RuntimePackageRespVO:
type: object
description: >-
版本运行包清单。字段语义对齐 GamePackage(#4).manifest,宿主据此渲染 iframe 并桥接 SDK(#3)。
【取包约束】① 宿主只从受信 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: '历史兼容字段;浏览器不得据此直连对象域,现行取包统一使用 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)' }
bundleSize: { type: integer, format: int64, description: 包总字节(#4 manifest.bundleSize) }
checksum: { type: string, description: '整包 sha256(hex),宿主做 Manifest 完整性校验 T-RT-15(#4 manifest.checksum)' }
sandbox: { $ref: '#/components/schemas/SandboxPolicyVO' }
traceId: { type: string, description: '贯穿 生成→编译→加载→运行 的 trace_id(#3 init / #4 provenance)' }
SandboxPolicyVO:
type: object
description: >-
iframe 沙箱隔离策略(T-RT-04),宿主据此设置 iframe sandbox 属性与 postMessage origin 白名单(T-RT-14)。
manifest 与对象素材均由受信 Runtime API 同源代理,本字段不承担对象存储 CORS 配置。
properties:
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:
type: object
required: [gameId, versionId, clientPlayToken]
properties:
gameId: { type: integer, format: int64, description: 游戏 ID }
versionId: { type: integer, format: int64, description: 版本 ID }
clientPlayToken: { type: string, maxLength: 64, description: 客户端本局唯一 token(幂等键,防抖/重连不重复计数) }
scene: { type: string, enum: [preview, play], default: play, description: 'preview=创作者预览 / play=玩家试玩' }
traceId: { type: string, description: '贯穿链路 trace_id(透传至 telemetry 回灌)' }
SessionStartRespVO:
type: object
properties:
sessionId: { type: integer, format: int64, description: '试玩会话 ID(本局唯一键,贯穿 end 与 telemetry 回灌关联)。透传进 telemetry envelope.session_id(int64→string)作对账键;session 仅观测时长,play_count 权威源=telemetry game_play_start(见 telemetry V5.0.0 / telemetry.yaml)' }
startTime: { type: string, format: date-time, description: 服务端记录的开局时间 }
SessionEndReqVO:
type: object
required: [sessionId]
properties:
sessionId: { type: integer, format: int64, description: 试玩会话 ID(start 返回) }
durationMs: { type: integer, format: int64, description: 客户端上报时长毫秒(离线兜底;服务端以 end-start 为准) }
endReason: { type: string, enum: [game_end, quit, error, timeout], description: '结束原因(game_end 正常结束 / quit 主动退出 / error 运行错误 / timeout 加载或心跳超时)' }
# ---- 编译编排(消费 #4 产物,产出运行包;外部 OSS/打包为对接点不实现)----
CompileReqVO:
type: object
required: [versionId]
properties:
versionId: { type: integer, format: int64, description: '待编译版本 ID(读取该版本 GamePackage #4)' }
force: { type: boolean, default: false, description: 是否强制重编(忽略已就绪缓存) }
CompileRespVO:
type: object
properties:
buildId: { type: integer, format: int64, description: '编译任务 ID(异步幂等,轮询 /admin-api/runtime/build/{buildId})' }
reused: { type: boolean, description: 是否命中进行中/已就绪任务复用(幂等结果) }
BuildRespVO:
type: object
properties:
buildId: { type: integer, format: int64 }
versionId: { type: integer, format: int64 }
status: { type: integer, description: '编译状态机:0排队 1编译中 2成功 3失败' }
packageUrl: { type: string, description: 成功后产出包 URL(失败为空) }
checksum: { type: string, description: 成功后整包 sha256 }
bundleSize: { type: integer, format: int64, description: 包总字节(体积门禁 T-RT-08 校验值) }
failCode: { type: string, description: '失败错误码(如 1-102-002-002 体积超限 / 1-102-002-001 GameConfig 校验失败),成功为空' }
failReason: { type: string, description: 失败原因(回填运营,成功为空) }
finishTime: { type: string, format: date-time, description: 完成时间 }
# ---- 会话观测 VO ----
SessionRespVO:
type: object
properties:
id: { type: integer, format: int64, description: 会话 ID }
gameId: { type: integer, format: int64 }
versionId: { type: integer, format: int64 }
playerUserId: { type: integer, format: int64, description: 试玩玩家用户 ID }
scene: { type: string, enum: [preview, play] }
status: { type: integer, description: '会话状态:0进行中 1已结束 2异常' }
durationMs: { type: integer, format: int64, description: 时长毫秒(服务端 end-start) }
startTime: { type: string, format: date-time }
endTime: { type: string, format: date-time }