/** * runtime 模块 mock handler(严格贴 contracts/api-schemas/runtime.yaml)。 * 覆盖端点: * GET /app-api/runtime/package/:versionId → RuntimePackageRespVO * POST /app-api/runtime/session/start → SessionStartRespVO * POST /app-api/runtime/session/end → boolean * * 游戏包清单:字段语义对齐 GamePackage(#4).manifest;entry 约定为 'demo'(与 A2 Runtime demo 对齐), * sandbox.allowOrigins 给本地白名单(宿主 bridge 三校验时放行同源)。 * 会话:clientPlayToken 幂等(同 token 返回同 sessionId)。 */ import type { MockRoute, MockRequest } from './shared' import { MockError, fakeSha256, nowISO } from './shared' import type { RuntimePackage, SessionStartResp, SessionStartReq } from '../api/types' /** demo 版本 ID(与 feed/aigc 对齐:全链路指向同一 demo 包) */ const DEMO_VERSION_ID = 9001 /** clientPlayToken → sessionId 幂等表 */ const tokenToSession = new Map() /** 已结束会话集合(幂等:重复 end 直接成功) */ const endedSessions = new Set() let sessionSeq = 50000 /** GET /app-api/runtime/package/:versionId?scene */ function packageHandler(req: MockRequest): RuntimePackage { const versionId = Number(req.params.versionId) if (!versionId) throw new MockError(1102001001, '无对应就绪运行包') // gameId 在真实后端由 version 关联得到;mock 给一个稳定派生值 const gameId = versionId === DEMO_VERSION_ID ? 1 : versionId // 整包字节:贴 §8 AC7 Runtime <15KB 语境,这里给一个合理 demo 包体积 const bundleSize = 14_336 return { gameId, versionId, // 模板:clicker(与 demo 点击计数玩法一致) templateId: 'clicker', packageUrl: `/games/${gameId}/versions/${versionId}/manifest.json`, // GAP-2:mock 刻意「不」回填 manifestUrl。 // 宿主 GamePlayer.resolvePackage 对「无 manifestUrl」走 demo 兜底(assets 为空的 clicker), // 保证本地 mock 链路无需真实 OSS 即可跑通;而 manifestUrl 的 fetch+sha256 校验路径在 // 隔离 staging 联调(Runtime 回填同源 manifestUrl 并代理 OSS)时验证。 // 若在此填一个 mock 中间件(仅拦 /app-api、/admin-api)无法服务的 /games/... 路径, // 只会触发取包失败兜底,污染 error 事件,故不填。详见返回 openIssues。 // manifestUrl: undefined, // 入口约定为 'demo':A2 Runtime 据此加载内置 demo 游戏(非真实文件路径,链路约定值) entry: 'demo', runtimeVersion: '1.0.0', preloadPolicy: 'eager', bundleSize, checksum: fakeSha256(`pkg-${gameId}-${versionId}`), sandbox: { // iframe 沙箱属性:只允许脚本;内置 demo 不需要同源权限。 sandboxAttr: 'allow-scripts', // postMessage origin 白名单:本地开发/预览同源(宿主 bridge 三校验放行) allowOrigins: [ 'http://localhost:5173', 'http://localhost:4173', 'http://127.0.0.1:5173', 'http://127.0.0.1:4173', ], }, traceId: `tr-pkg-${versionId}`, } } /** POST /app-api/runtime/session/start */ function sessionStartHandler(req: MockRequest): SessionStartResp { const body = (req.body ?? {}) as Partial const token = body.clientPlayToken if (!body.gameId || !body.versionId || !token) { throw new MockError(1102003001, 'gameId/versionId/clientPlayToken 必填') } // 幂等:同 token 复用同 sessionId(防抖/重连不重复计数) let sessionId = tokenToSession.get(token) if (sessionId === undefined) { sessionId = ++sessionSeq tokenToSession.set(token, sessionId) } return { sessionId, startTime: nowISO() } } /** POST /app-api/runtime/session/end */ function sessionEndHandler(req: MockRequest): boolean { const body = (req.body ?? {}) as { sessionId?: number } const sessionId = Number(body.sessionId) if (!sessionId) throw new MockError(1102003002, 'sessionId 必填') // 幂等:已结束直接成功,不二次累加 endedSessions.add(sessionId) return true } /** 导出本模块路由表 */ export const runtimeRoutes: MockRoute[] = [ { method: 'GET', pattern: '/app-api/runtime/package/:versionId', handler: packageHandler }, { method: 'POST', pattern: '/app-api/runtime/session/start', handler: sessionStartHandler }, { method: 'POST', pattern: '/app-api/runtime/session/end', handler: sessionEndHandler }, ]