From 4a185ac8c2a767eee8c9757fed10ea957b18bdf7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 15:58:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20runtime=20clie?= =?UTF-8?q?nt=20=E8=AF=B7=E6=B1=82=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 ++ docs/README.md | 2 + ...构】RuntimeClientFamily收口计划-2026-06-03.md | 32 +++++ .../match3d-runtime/match3dRuntimeClient.ts | 106 ++++++--------- src/services/runtimeRequest.test.ts | 88 ++++++++++++ src/services/runtimeRequest.ts | 62 +++++++++ .../squareHoleRuntimeClient.ts | 126 +++++++++--------- 7 files changed, 300 insertions(+), 124 deletions(-) create mode 100644 docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md create mode 100644 src/services/runtimeRequest.test.ts create mode 100644 src/services/runtimeRequest.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 6b02e60d..cadfeb83 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -40,6 +40,14 @@ - 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 +## 2026-06-03 Runtime Client Family 请求骨架收口 + +- 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。 +- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。 +- 影响范围:`src/services/runtimeRequest.ts`、Match3D runtime client、SquareHole runtime client、后续 Puzzle / Jump Hop / Visual Novel / Bark Battle / Big Fish runtime client 迁移。 +- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。 + ## 2026-06-03 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 diff --git a/docs/README.md b/docs/README.md index 5bb6fbd1..374f24a9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D 与 SquareHole 已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md new file mode 100644 index 00000000..4c055fc3 --- /dev/null +++ b/docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md @@ -0,0 +1,32 @@ +# 【前端架构】Runtime Client Family 收口计划 + +## 背景 + +多个小游戏 runtime client 都重复实现路径编码、JSON header / body、runtime guest token、认证影响策略和重试参数。重复逻辑分散在各玩法文件后,新增玩法容易遗漏 guest auth 或 retry 语义,也让测试必须逐玩法检查同一请求骨架。 + +## 决策 + +新增 `src/services/runtimeRequest.ts`,作为 Runtime Client Family 的请求 **Module**。其 **Interface** 包含: + +- `buildRuntimeApiPath(basePath, ...segments)`:统一对 runtime path segment 执行 `encodeURIComponent`。 +- `requestRuntimeJson(params)`:统一设置 method、JSON body、`Content-Type`、runtime guest `Authorization`、auth options 和 retry options。 + +`match3dRuntimeClient.ts` 与 `squareHoleRuntimeClient.ts` 已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入等玩法专属返回映射仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**。 + +## 约定 + +- Runtime client 不再手写 `encodeURIComponent` 拼 path;应优先使用 `buildRuntimeApiPath`。 +- Runtime JSON 请求不再手写 `Content-Type`、guest `Authorization` 和 `buildRuntimeGuestAuthOptions` 合并;应优先使用 `requestRuntimeJson`。 +- 玩法专属 payload 归一化、返回值适配和中文错误文案仍属于各玩法 client。 +- 每迁移一个 client,必须保留原导出函数名与原调用方契约。 + +## 后续深化 + +下一批可迁移 Puzzle、Jump Hop、Visual Novel、Bark Battle 与 Big Fish runtime client。迁移顺序以测试覆盖和请求形状接近度为准,优先迁移已有 guest launch 或 client 单测覆盖的函数。 + +## 验证 + +- `npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index f1b0b5ec..721306c5 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -10,14 +10,11 @@ import type { } from '../../../packages/shared/src/contracts/match3dRuntime'; import { type ApiRetryOptions, - requestJson, } from '../apiClient'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; +const MATCH3D_RUNTIME_API_BASE = '/api/runtime/match3d'; const MATCH3D_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, baseDelayMs: 120, @@ -74,39 +71,30 @@ export function startMatch3DRun( profileId: string, options: Match3DRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); const payload: StartMatch3DRunRequest = { profileId, itemTypeCountOverride: options.itemTypeCountOverride ?? null, }; - return requestJson( - `/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify(payload), - }, - '启动抓大鹅玩法失败', - { - retry: MATCH3D_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'works', profileId, 'runs'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '启动抓大鹅玩法失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } /** * 读取抓大鹅运行态快照。 */ export function getMatch3DRun(runId: string) { - return requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}`, - { method: 'GET' }, - '读取抓大鹅运行快照失败', - { retry: MATCH3D_RUNTIME_READ_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId), + fallbackMessage: '读取抓大鹅运行快照失败', + retry: MATCH3D_RUNTIME_READ_RETRY, + }); } /** @@ -116,19 +104,16 @@ export async function clickMatch3DItem( runId: string, payload: Match3DClickItemRequest, ) { - const response = await requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}/click`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...payload, - runId: payload.runId ?? runId, - }), + const response = await requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'click'), + method: 'POST', + jsonBody: { + ...payload, + runId: payload.runId ?? runId, }, - '确认抓大鹅点击失败', - { retry: MATCH3D_RUNTIME_WRITE_RETRY }, - ); + fallbackMessage: '确认抓大鹅点击失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + }); return mapClickConfirmation(payload, response.confirmation); } @@ -142,40 +127,37 @@ export function stopMatch3DRun( clientActionId: `match3d-stop-${Date.now()}`, }, ) { - return requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}/stop`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '停止抓大鹅玩法失败', - { retry: MATCH3D_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'stop'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '停止抓大鹅玩法失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + }); } /** * 基于当前 run 重开一局。 */ export function restartMatch3DRun(runId: string) { - return requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}/restart`, - { method: 'POST' }, - '重新开始抓大鹅玩法失败', - { retry: MATCH3D_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'restart'), + method: 'POST', + fallbackMessage: '重新开始抓大鹅玩法失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + }); } /** * 前端倒计时归零后通知后端确认失败状态。 */ export function finishMatch3DTimeUp(runId: string) { - return requestJson( - `/api/runtime/match3d/runs/${encodeURIComponent(runId)}/time-up`, - { method: 'POST' }, - '同步抓大鹅倒计时失败', - { retry: MATCH3D_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(MATCH3D_RUNTIME_API_BASE, 'runs', runId, 'time-up'), + method: 'POST', + fallbackMessage: '同步抓大鹅倒计时失败', + retry: MATCH3D_RUNTIME_WRITE_RETRY, + }); } export const match3dRuntimeClient = { diff --git a/src/services/runtimeRequest.test.ts b/src/services/runtimeRequest.test.ts new file mode 100644 index 00000000..a771f5f1 --- /dev/null +++ b/src/services/runtimeRequest.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiClientMocks = vi.hoisted(() => ({ + requestJson: vi.fn(), +})); + +vi.mock('./apiClient', async () => { + const actual = + await vi.importActual('./apiClient'); + return { + ...actual, + requestJson: apiClientMocks.requestJson, + }; +}); + +import { + buildRuntimeApiPath, + requestRuntimeJson, +} from './runtimeRequest'; + +describe('runtimeRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiClientMocks.requestJson.mockResolvedValue({ ok: true }); + }); + + it('builds encoded runtime api paths', () => { + expect(buildRuntimeApiPath('/api/runtime/demo/', 'work/a b', 'run/1')).toBe( + '/api/runtime/demo/work%2Fa%20b/run%2F1', + ); + }); + + it('sends json runtime requests with guest auth and retry options', async () => { + const retry = { maxRetries: 1, retryUnsafeMethods: true }; + + await requestRuntimeJson({ + url: '/api/runtime/demo/runs', + method: 'POST', + jsonBody: { profileId: 'profile-1' }, + fallbackMessage: '启动失败', + retry, + requestOptions: { + runtimeGuestToken: 'runtime-guest-token', + }, + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/demo/runs', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer runtime-guest-token', + }, + body: JSON.stringify({ profileId: 'profile-1' }), + }, + '启动失败', + { + retry, + authImpact: undefined, + skipAuth: true, + skipRefresh: true, + notifyAuthStateChange: undefined, + clearAuthOnUnauthorized: undefined, + }, + ); + }); + + it('omits empty headers and body for plain runtime reads', async () => { + await requestRuntimeJson({ + url: '/api/runtime/demo/runs/run-1', + fallbackMessage: '读取失败', + }); + + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/runtime/demo/runs/run-1', + { method: 'GET' }, + '读取失败', + { + authImpact: undefined, + skipAuth: undefined, + skipRefresh: undefined, + notifyAuthStateChange: undefined, + clearAuthOnUnauthorized: undefined, + }, + ); + }); +}); diff --git a/src/services/runtimeRequest.ts b/src/services/runtimeRequest.ts new file mode 100644 index 00000000..8a0465f4 --- /dev/null +++ b/src/services/runtimeRequest.ts @@ -0,0 +1,62 @@ +import { + type ApiRetryOptions, + requestJson, +} from './apiClient'; +import { + buildRuntimeGuestAuthOptions, + buildRuntimeGuestHeaders, + type RuntimeGuestRequestOptions, +} from './runtimeGuestAuth'; + +export type RuntimeJsonRequestParams = { + url: string; + method?: string; + jsonBody?: unknown; + headers?: Record; + fallbackMessage: string; + retry?: ApiRetryOptions; + requestOptions?: RuntimeGuestRequestOptions; +}; + +export function buildRuntimeApiPath( + basePath: string, + ...segments: string[] +) { + const normalizedBasePath = basePath.endsWith('/') + ? basePath.slice(0, -1) + : basePath; + return [ + normalizedBasePath, + ...segments.map((segment) => encodeURIComponent(segment)), + ].join('/'); +} + +export function requestRuntimeJson(params: RuntimeJsonRequestParams) { + const { + fallbackMessage, + headers = {}, + jsonBody, + method = 'GET', + requestOptions = {}, + retry, + url, + } = params; + const hasJsonBody = jsonBody !== undefined; + const requestHeaders = buildRuntimeGuestHeaders(requestOptions, { + ...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}), + ...headers, + }); + const init: RequestInit = { + method, + ...(Object.keys(requestHeaders).length > 0 + ? { headers: requestHeaders } + : {}), + ...(hasJsonBody ? { body: JSON.stringify(jsonBody) } : {}), + }; + const authOptions = buildRuntimeGuestAuthOptions(requestOptions); + + return requestJson(url, init, fallbackMessage, { + ...(retry ? { retry } : {}), + ...authOptions, + }); +} diff --git a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts index 083c9dec..e26d7907 100644 --- a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts +++ b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts @@ -6,14 +6,11 @@ import type { } from '../../../packages/shared/src/contracts/squareHoleRuntime'; import { type ApiRetryOptions, - requestJson, } from '../apiClient'; -import { - buildRuntimeGuestAuthOptions, - buildRuntimeGuestHeaders, - type RuntimeGuestRequestOptions, -} from '../runtimeGuestAuth'; +import { type RuntimeGuestRequestOptions } from '../runtimeGuestAuth'; +import { buildRuntimeApiPath, requestRuntimeJson } from '../runtimeRequest'; +const SQUARE_HOLE_RUNTIME_API_BASE = '/api/runtime/square-hole'; const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = { maxRetries: 1, baseDelayMs: 120, @@ -34,34 +31,30 @@ export function startSquareHoleRun( profileId: string, options: SquareHoleRuntimeRequestOptions = {}, ) { - const requestOptions = buildRuntimeGuestAuthOptions(options); - return requestJson( - `/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`, - { - method: 'POST', - headers: buildRuntimeGuestHeaders(options, { - 'Content-Type': 'application/json', - }), - body: JSON.stringify({ profileId }), - }, - '启动方洞挑战失败', - { - retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, - ...requestOptions, - }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + SQUARE_HOLE_RUNTIME_API_BASE, + 'works', + profileId, + 'runs', + ), + method: 'POST', + jsonBody: { profileId }, + fallbackMessage: '启动方洞挑战失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + requestOptions: options, + }); } /** * 读取方洞挑战运行态快照。 */ export function getSquareHoleRun(runId: string) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}`, - { method: 'GET' }, - '读取方洞挑战运行快照失败', - { retry: SQUARE_HOLE_RUNTIME_READ_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId), + fallbackMessage: '读取方洞挑战运行快照失败', + retry: SQUARE_HOLE_RUNTIME_READ_RETRY, + }); } /** @@ -71,19 +64,21 @@ export function dropSquareHoleShape( runId: string, payload: DropSquareHoleShapeRequest, ) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/drop`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...payload, - runId: payload.runId ?? runId, - }), + return requestRuntimeJson({ + url: buildRuntimeApiPath( + SQUARE_HOLE_RUNTIME_API_BASE, + 'runs', + runId, + 'drop', + ), + method: 'POST', + jsonBody: { + ...payload, + runId: payload.runId ?? runId, }, - '确认方洞挑战投入失败', - { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY }, - ); + fallbackMessage: '确认方洞挑战投入失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + }); } /** @@ -95,40 +90,47 @@ export function stopSquareHoleRun( clientActionId: `square-hole-stop-${Date.now()}`, }, ) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/stop`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }, - '停止方洞挑战失败', - { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath(SQUARE_HOLE_RUNTIME_API_BASE, 'runs', runId, 'stop'), + method: 'POST', + jsonBody: payload, + fallbackMessage: '停止方洞挑战失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + }); } /** * 基于当前 run 重开一局。 */ export function restartSquareHoleRun(runId: string) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/restart`, - { method: 'POST' }, - '重新开始方洞挑战失败', - { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + SQUARE_HOLE_RUNTIME_API_BASE, + 'runs', + runId, + 'restart', + ), + method: 'POST', + fallbackMessage: '重新开始方洞挑战失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + }); } /** * 前端倒计时归零后通知后端确认失败状态。 */ export function finishSquareHoleTimeUp(runId: string) { - return requestJson( - `/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/time-up`, - { method: 'POST' }, - '同步方洞挑战倒计时失败', - { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY }, - ); + return requestRuntimeJson({ + url: buildRuntimeApiPath( + SQUARE_HOLE_RUNTIME_API_BASE, + 'runs', + runId, + 'time-up', + ), + method: 'POST', + fallbackMessage: '同步方洞挑战倒计时失败', + retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + }); } export const squareHoleRuntimeClient = {