From bc704d0c22015a7286d496954aeba4ca8d8e4d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 9 May 2026 18:24:08 +0800 Subject: [PATCH] 1 --- .codex/skills/gpt-image-2-apimart/SKILL.md | 33 +-- .../gpt-image-2-apimart/agents/openai.yaml | 6 +- .../scripts/generate-template-samples.mjs | 82 ++---- .hermes/shared-memory/decision-log.md | 2 + .hermes/shared-memory/pitfalls.md | 17 +- ...NG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md | 2 +- ...NG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md | 6 +- ..._APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md | 48 ++-- ...ATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md | 16 +- docs/technical/README.md | 2 +- ...E_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md | 21 +- ...OPENING_CG_MANUAL_GENERATION_2026-05-03.md | 2 +- ...HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md | 2 +- .../api-server/src/character_visual_assets.rs | 8 +- .../crates/api-server/src/custom_world_ai.rs | 19 +- .../api-server/src/openai_image_generation.rs | 14 +- server-rs/crates/api-server/src/puzzle.rs | 252 ++++++------------ .../crates/api-server/src/square_hole.rs | 2 +- .../PlatformEntryFlowShellImpl.tsx | 12 +- ...gEntryFlowShell.agent.interaction.test.tsx | 8 + .../rpg-entry/useRpgEntryBootstrap.ts | 43 ++- src/services/apiClient.test.ts | 47 ++++ src/services/apiClient.ts | 64 ++++- src/services/assetReadUrlService.ts | 8 +- .../big-fish-runtime/bigFishRuntimeClient.ts | 7 +- .../match3d-runtime/match3dRuntimeClient.ts | 6 +- .../puzzle-runtime/puzzleRuntimeClient.ts | 10 +- .../rpg-creation/rpgCreationRuntimeClient.ts | 16 +- .../rpg-creation/rpgCreationWorkClient.ts | 10 +- .../rpg-entry/rpgEntryClients.routing.test.ts | 18 ++ .../rpg-entry/rpgEntryLibraryClient.test.ts | 6 + .../rpg-entry/rpgEntryLibraryClient.ts | 6 +- .../rpg-entry/rpgProfileClient.test.ts | 18 ++ src/services/rpg-entry/rpgProfileClient.ts | 16 +- src/services/rpg-runtime/rpgRuntimeRequest.ts | 12 +- .../squareHoleRuntimeClient.ts | 6 +- .../visualNovelRuntimeClient.ts | 6 +- .../visualNovelWorksClient.ts | 6 +- 38 files changed, 481 insertions(+), 378 deletions(-) diff --git a/.codex/skills/gpt-image-2-apimart/SKILL.md b/.codex/skills/gpt-image-2-apimart/SKILL.md index 543ed3be..d9e8aae6 100644 --- a/.codex/skills/gpt-image-2-apimart/SKILL.md +++ b/.codex/skills/gpt-image-2-apimart/SKILL.md @@ -1,11 +1,11 @@ --- name: gpt-image-2-apimart -description: Generate or inspect project image assets through this repository's APIMart OpenAI-compatible gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug APIMART_BASE_URL / APIMART_API_KEY image-generation configuration without exposing secrets. +description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical. --- -# gpt-image-2 APIMart +# gpt-image-2 VectorEngine -Use this skill for project-local image asset generation that must match the repository's `server-rs` APIMart `gpt-image-2` path. +Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2-all` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references. ## Workflow @@ -24,15 +24,15 @@ Use this skill for project-local image asset generation that must match the repo ``` 5. Save final project assets under `public/` or another explicitly requested workspace path. -6. Never print `APIMART_API_KEY`. Report only whether configuration exists. +6. Never print `VECTOR_ENGINE_API_KEY`. Report only whether configuration exists. ## Request Contract The repository image path uses: ```text -POST {APIMART_BASE_URL}/images/generations -Authorization: Bearer {APIMART_API_KEY} +POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations +Authorization: Bearer {VECTOR_ENGINE_API_KEY} Content-Type: application/json ``` @@ -40,11 +40,10 @@ Default body: ```json { - "model": "gpt-image-2", + "model": "gpt-image-2-all", "prompt": "", "n": 1, - "official_fallback": true, - "size": "1:1" + "size": "1024x1024" } ``` @@ -52,17 +51,11 @@ For a reference image, add: ```json { - "image_urls": ["data:image/png;base64,..."] + "image": ["data:image/png;base64,..."] } ``` -Poll async responses with: - -```text -GET {APIMART_BASE_URL}/tasks/{task_id} -``` - -Accept image output from `data[].url`, `data[].b64_json`, direct nested `url` fields, or async task results. +Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2-all currently returns synchronously; do not poll APIMart task endpoints. ## Environment @@ -70,12 +63,12 @@ Load environment values from process env first, then `.env.secrets.local`, `.env Required for live generation: -- `APIMART_BASE_URL` -- `APIMART_API_KEY` +- `VECTOR_ENGINE_BASE_URL` +- `VECTOR_ENGINE_API_KEY` Optional: -- `APIMART_IMAGE_REQUEST_TIMEOUT_MS` +- `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat. diff --git a/.codex/skills/gpt-image-2-apimart/agents/openai.yaml b/.codex/skills/gpt-image-2-apimart/agents/openai.yaml index 2debf01b..5f675d57 100644 --- a/.codex/skills/gpt-image-2-apimart/agents/openai.yaml +++ b/.codex/skills/gpt-image-2-apimart/agents/openai.yaml @@ -1,7 +1,7 @@ interface: - display_name: "GPT Image 2 APIMart" - short_description: "Generate project thumbnails through APIMart" + display_name: "GPT Image 2 VectorEngine" + short_description: "Generate project thumbnails through VectorEngine" brand_color: "#10B981" - default_prompt: "Use $gpt-image-2-apimart to dry-run or generate puzzle template thumbnails through APIMart." + default_prompt: "Use $gpt-image-2-apimart to dry-run or generate puzzle template thumbnails through VectorEngine." policy: allow_implicit_invocation: true diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs index dbecf356..6bce81f2 100644 --- a/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-template-samples.mjs @@ -14,7 +14,6 @@ const promptsPath = path.join( ); const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates'); const defaultTimeoutMs = 180000; -const pollDelayMs = 3000; const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { @@ -66,15 +65,23 @@ function resolveEnv() { ...process.env, }; return { - baseUrl: String(loaded.APIMART_BASE_URL || '').trim().replace(/\/+$/u, ''), - apiKey: String(loaded.APIMART_API_KEY || '').trim(), + baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + .trim() + .replace(/\/+$/u, ''), + apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), timeoutMs: Number.parseInt( - String(loaded.APIMART_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; } +function buildVectorEngineImagesGenerationUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/generations` + : `${baseUrl}/v1/images/generations`; +} + function buildPrompt(template) { return [ '请生成一张高清 1:1 方形插画,用作拼图创作模板样例图。', @@ -124,14 +131,6 @@ function extractBase64Images(payload) { return values; } -function extractTaskId(payload) { - const ids = []; - collectStringsByKey(payload, 'task_id', ids); - collectStringsByKey(payload, 'taskId', ids); - collectStringsByKey(payload, 'id', ids); - return ids[0] || null; -} - function inferExtensionFromContentType(contentType) { const normalized = contentType.split(';')[0]?.trim().toLowerCase(); if (normalized === 'image/png') { @@ -172,7 +171,7 @@ async function fetchJson(url, options, timeoutMs) { }); const text = await response.text(); if (!response.ok) { - throw new Error(`APIMart ${response.status}: ${text.slice(0, 600)}`); + throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); } return JSON.parse(text); } finally { @@ -200,52 +199,20 @@ async function downloadUrl(url, timeoutMs) { } } -async function waitForTask(env, taskId) { - const deadline = Date.now() + env.timeoutMs; - await new Promise((resolve) => setTimeout(resolve, 10000)); - - while (Date.now() < deadline) { - const payload = await fetchJson( - `${env.baseUrl}/tasks/${encodeURIComponent(taskId)}`, - { - headers: { - Authorization: `Bearer ${env.apiKey}`, - }, - }, - env.timeoutMs, - ); - const statuses = []; - collectStringsByKey(payload, 'status', statuses); - collectStringsByKey(payload, 'task_status', statuses); - const status = String(statuses[0] || '').trim().toLowerCase(); - - if (['completed', 'succeeded', 'success'].includes(status)) { - return payload; - } - if (['failed', 'error', 'canceled', 'cancelled', 'unknown'].includes(status)) { - throw new Error(`APIMart task ${taskId} failed: ${JSON.stringify(payload).slice(0, 600)}`); - } - - await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); - } - - throw new Error(`APIMart task ${taskId} timed out`); -} - async function generateOne(env, template, outDir) { const requestBody = { - model: 'gpt-image-2', + model: 'gpt-image-2-all', prompt: buildPrompt(template), n: 1, - official_fallback: true, - size: '1:1', + size: '1024x1024', }; const payload = await fetchJson( - `${env.baseUrl}/images/generations`, + buildVectorEngineImagesGenerationUrl(env.baseUrl), { method: 'POST', headers: { Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), @@ -253,12 +220,8 @@ async function generateOne(env, template, outDir) { env.timeoutMs, ); - const resolvedPayload = - extractImageUrls(payload).length || extractBase64Images(payload).length - ? payload - : await waitForTask(env, extractTaskId(payload)); - const urls = extractImageUrls(resolvedPayload); - const b64Images = extractBase64Images(resolvedPayload); + const urls = extractImageUrls(payload); + const b64Images = extractBase64Images(payload); let image; if (urls[0]) { @@ -270,7 +233,7 @@ async function generateOne(env, template, outDir) { extension: inferExtensionFromBytes(bytes), }; } else { - throw new Error(`APIMart returned no image for ${template.id}`); + throw new Error(`VectorEngine returned no image for ${template.id}`); } mkdirSync(outDir, { recursive: true }); @@ -302,11 +265,10 @@ if (dryRun) { id: template.id, title: template.title, body: { - model: 'gpt-image-2', + model: 'gpt-image-2-all', prompt: buildPrompt(template), n: 1, - official_fallback: true, - size: '1:1', + size: '1024x1024', }, })), }, @@ -322,7 +284,7 @@ if (!env.baseUrl || !env.apiKey) { console.error( JSON.stringify({ ok: false, - error: 'Missing APIMART_BASE_URL or APIMART_API_KEY', + error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index a1ce36ee..54f6f6e6 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -34,6 +34,8 @@ ## 2026-05-08 APIMart 接口统一携带 `official_fallback` +> 2026-05-09 追认:本决策中的图片生成部分已被“GPT-image-2 图片生成统一迁移到 VectorEngine”覆盖;当前只保留 APIMart `gpt-5` Responses 文本/多模态链路继续显式携带 `official_fallback`。 + - 背景:APIMart 的图片生成和 Responses 接口在仓库内分散于 `api-server`、`platform-llm` 和本地 skill 脚本,若只修单点,容易出现不同入口的上游请求体不一致。 - 决策:凡是仓库内调用 APIMart 的 OpenAI 兼容接口,请求体统一携带 `official_fallback: true`;其中图片生成请求直接固定写入,`platform-llm` 的 APIMart GPT-5 client 通过显式开关开启,不默认扩散到 Ark 等其它 provider。 - 影响范围:`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/puzzle.rs`、`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/platform-llm/src/lib.rs`、`.codex/skills/gpt-image-2-apimart/` 和相关技术文档。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index a19f550e..58f03673 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -99,13 +99,13 @@ - 验证:`PlatformFeedbackView.test.tsx` 用 mock `FileReader` 断言选择图片后出现 `反馈凭证预览`,且提交 payload 带 `evidenceItems[].dataUrl`。 - 关联:`src/components/platform-entry/PlatformFeedbackView.tsx`、`docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`。 -## 拼图 APIMart 图片生成密钥不能复用 DashScope / ARK key +## 拼图 VectorEngine 图片生成密钥不能复用 DashScope / ARK key -- 现象:拼图新手引导或拼图创作点击生成后返回 `APIMart 图片生成密钥未配置`。 -- 原因:拼图 `gpt-image-2` / `nanobanana2` 图片生成已按技术方案统一走 APIMart;后端只读取 `APIMART_BASE_URL`、`APIMART_API_KEY`、`APIMART_IMAGE_REQUEST_TIMEOUT_MS`,不会用 `DASHSCOPE_API_KEY`、`LLM_API_KEY` 或 `ARK_API_KEY` 兜底。 -- 处理:在本机私密配置 `.env.secrets.local` 或进程环境中配置真实 `APIMART_API_KEY`,不要提交到 Git;填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。 -- 验证:不打印密钥内容,只检查 `APIMART_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。 -- 关联:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md`、`.codex/skills/gpt-image-2-apimart/SKILL.md`。 +- 现象:拼图新手引导或拼图创作点击生成后返回 `VectorEngine 图片生成密钥未配置`。 +- 原因:拼图 `gpt-image-2` / 历史 `nanobanana2` 图片生成已统一走 VectorEngine;后端只读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,不会用 `DASHSCOPE_API_KEY`、`LLM_API_KEY`、`ARK_API_KEY` 或 `APIMART_API_KEY` 兜底。 +- 处理:在本机私密配置 `.env.secrets.local` 或进程环境中配置真实 `VECTOR_ENGINE_API_KEY`,不要提交到 Git;填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。 +- 验证:不打印密钥内容,只检查 `VECTOR_ENGINE_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。 +- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`.codex/skills/gpt-image-2-apimart/SKILL.md`。 ## 拼图图片生成 98% 后报 OSS V4 签名时间格式化失败 @@ -154,10 +154,11 @@ ## 登录后推荐页加载出作品又回到未登录 - 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。 -- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。 -- 处理:推荐页自动运行态请求传 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的受保护动作仍保留默认鉴权失败处理。 +- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。更隐蔽的是,`refreshAccessToken()` 自身曾在 refresh 失败时静默清 token,即便调用方关闭了 `clearAuthOnUnauthorized`,也可能让后续 hydrate 变成未登录。 +- 处理:请求层统一使用 `authImpact: 'global' | 'local'` 区分账号权威请求与局部后台请求;推荐页自动运行态、图片换签、公开拼图运行态和平台 bootstrap 私有投影刷新统一使用 `BACKGROUND_AUTH_REQUEST_OPTIONS` / `RUNTIME_BACKGROUND_AUTH_OPTIONS`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的账号动作仍保留默认全局鉴权失败处理。 - 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。 - 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。 +- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。 - 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。 - 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。 diff --git a/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md index b260b218..c02cf0b5 100644 --- a/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md +++ b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md @@ -79,7 +79,7 @@ AI原生游戏框架: ## 4. 生成方式 -主视觉底图使用仓库内 APIMart OpenAI 兼容 `gpt-image-2` 工作流生成: +主视觉底图使用仓库内 `gpt-image-2` 工作流生成;2026-05-09 起同类工作流走 VectorEngine: ```text model: gpt-image-2 diff --git a/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md b/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md index 07225930..035d9c75 100644 --- a/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md +++ b/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md @@ -247,11 +247,11 @@ python "C:\Users\wuxiangwanzi\.codex\skills\.system\imagegen\scripts\image_gen.p --size 1024x1024 ``` -若继续使用仓库现有 APIMart 路由,则需要配置: +若继续使用仓库现有 VectorEngine GPT-image-2 路由,则需要配置: ```text -APIMART_BASE_URL=https://api.apimart.ai/v1 -APIMART_API_KEY=... +VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai +VECTOR_ENGINE_API_KEY=... ``` ## 13. 04/07 气泡方向单独优化补充 diff --git a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md index b1732ad7..985c0971 100644 --- a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md +++ b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md @@ -1,15 +1,16 @@ -# 拼图 APIMart 图片模型路由接入 2026-05-01 +# 拼图图片模型路由接入 2026-05-01 + +> 2026-05-09 更新:GPT-image-2 图片生成已从 APIMart 迁移到 VectorEngine。本文保留前端模型选择和拼图扣费/持久化历史设计,图片上游接口、环境变量和请求体以 `VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md` 为准。 ## 背景 拼图创作已收口为填表式流程,首图生成和结果页关卡重新生成都由 `server-rs/crates/api-server/src/puzzle.rs` 执行外部图片 I/O,再把正式图写入 OSS 与 SpacetimeDB。新的模型选择只影响图片生成上游,不改变 SpacetimeDB 表结构、拼图草稿结构或前端运行时规则。 -本轮参考 APIMart 文档: +历史版本曾参考 APIMart 文档;当前 GPT-image-2 图片生成参考 VectorEngine Apifox 文档: -1. `https://docs.apimart.ai/cn/api-reference/images/gpt-image-2/generation` -2. `https://docs.apimart.ai/cn/api-reference/images/gemini-3.1-flash/generation` +1. `https://vectorengine.apifox.cn/api-448710071` -两条文档均指向 OpenAI 兼容风格的图片生成入口:`POST https://api.apimart.ai/v1/images/generations`,头部使用 `Authorization: Bearer {APIMART_API_KEY}`。请求体至少包含 `model`、`prompt`、`n`、`official_fallback = true`、`size`。返回体按 OpenAI images 兼容格式优先读取 `data[].url`,若供应商返回异步任务结构,则继续按 `task_id` / `tasks/{task_id}` 轮询并提取图片 URL。 +当前图片生成入口:`POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations`,头部使用 `Authorization: Bearer {VECTOR_ENGINE_API_KEY}`。请求体至少包含 `model = gpt-image-2-all`、`prompt`、`n`、`size`,参考图使用 `image` 数组。返回体按同步 OpenAI images 结构读取 `data[].url` 或 `data[].b64_json`,不再轮询 APIMart `tasks/{task_id}`。 ## 模型选项 @@ -17,8 +18,8 @@ | 前端显示 | 请求值 | 上游 | | --- | --- | --- | -| `gpt-image-2` | `gpt-image-2` | APIMart `/v1/images/generations` | -| `nanobanana2` | `gemini-3.1-flash-image-preview` | APIMart `/v1/images/generations` | +| `gpt-image-2` | `gpt-image-2` | VectorEngine `/v1/images/generations`,上游模型 `gpt-image-2-all` | +| `nanobanana2` | `gemini-3.1-flash-image-preview` | 历史兼容选项,后端回落到 VectorEngine `gpt-image-2-all` | 默认值为 `gpt-image-2`。前端只负责展示和传递所选模型,不能把模型路由逻辑、上游请求体拼装或 API Key 暴露到浏览器。历史草稿或旧请求中的空值、`original`、未知值统一按 `gpt-image-2` 处理,不再把拼图生图路由回 DashScope 原模型。 @@ -40,16 +41,15 @@ 2. `compile_puzzle_draft_with_initial_cover` 与 `generate_puzzle_image_candidates` 增加图片模型参数。 3. `imageModel` 归一化规则: - 空值、`original` 或未知值统一回落为 `gpt-image-2`; - - `gpt-image-2` 走 APIMart; - - `gemini-3.1-flash-image-preview` 走 APIMart,前端显示名为 `nanobanana2`。 -4. APIMart 文生图和图生图共用 `POST /v1/images/generations`。有参考图时,后端将参考图 Data URL 作为 `image_urls` 数组传入;若上游不接受该字段,错误按上游失败返回,不在前端降级伪造结果。 -5. APIMart 尺寸使用文档要求的比例写法 `1:1`,所有 APIMart 图片请求体固定携带 `official_fallback = true`。`gemini-3.1-flash-image-preview` 额外带 `resolution = "1K"`,对齐约 1024px 的拼图正方形素材。 -6. APIMart 生成成功后仍下载远程图片,沿用现有 OSS 私有对象、`asset_object` 和 `asset_entity_binding` 写入流程。若图片已成功上传 OSS,但 Maincloud / SpacetimeDB 短暂返回 `503 Service Unavailable`,资产索引写入允许降级跳过,并返回本次生成图片;日志必须记录 `拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过`。 -7. `save_puzzle_generated_images` 写回草稿时若遇到 Maincloud 连接级 `503` 或断线,API 层基于本次生成结果合成 session 快照返回给前端,避免 APIMart 已成功出图却被后置持久化误报成服务不可用。余额不足、参数错误、上游生图失败仍按原错误返回,不做伪成功。 -8. 结果页 `generate_puzzle_images` 会携带当前作品信息和 `levelsJson`。当 Maincloud / SpacetimeDB 在读取 session 阶段就返回连接级 `503` 或断线时,后端必须先用这份结果页快照构造最小内存 session,再继续调用 APIMart;外部图片已经生成后仍按第 6、7 条处理持久化降级。余额不足、参数错误、缺少草稿快照、关卡不存在等业务错误不走此降级。 -9. APIMart 异步任务轮询按文档口径在提交后先等待 `10s`,再调用 `GET /v1/tasks/{task_id}`;图片地址提取同时支持 `url: "..."` 与 `url: ["..."]` 两种结构。 -10. APIMart 错误统一映射为 `502 UPSTREAM_ERROR`,`details.provider = "apimart"`,保留上游状态码、业务 message 和截断后的 raw excerpt。 -11. 拼图首图生成 `compile_puzzle_draft` 与关卡图片生成 `generate_puzzle_images` 每次预扣 `2` 光点;余额不足仍返回 `409 CONFLICT`,Maincloud 连接级 503 仍按既有降级策略处理。 + - `gpt-image-2` 走 VectorEngine; + - `gemini-3.1-flash-image-preview` 不再走 APIMart,前端显示名仍为 `nanobanana2`,后端统一回落到 VectorEngine `gpt-image-2-all`。 +4. VectorEngine 文生图和图生图共用 `POST /v1/images/generations`。有参考图时,后端将参考图 Data URL 作为 `image` 数组传入;若上游不接受该字段,错误按上游失败返回,不在前端降级伪造结果。 +5. VectorEngine 拼图尺寸使用 `1024x1024`,请求体不携带 `official_fallback`。 +6. VectorEngine 生成成功后仍下载远程图片,沿用现有 OSS 私有对象、`asset_object` 和 `asset_entity_binding` 写入流程。若图片已成功上传 OSS,但 SpacetimeDB 短暂返回 `503 Service Unavailable`,资产索引写入允许降级跳过,并返回本次生成图片;日志必须记录 `拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过`。 +7. `save_puzzle_generated_images` 写回草稿时若遇到 SpacetimeDB 连接级 `503` 或断线,API 层基于本次生成结果合成 session 快照返回给前端,避免 VectorEngine 已成功出图却被后置持久化误报成服务不可用。余额不足、参数错误、上游生图失败仍按原错误返回,不做伪成功。 +8. 结果页 `generate_puzzle_images` 会携带当前作品信息和 `levelsJson`。当 SpacetimeDB 在读取 session 阶段就返回连接级 `503` 或断线时,后端必须先用这份结果页快照构造最小内存 session,再继续调用 VectorEngine;外部图片已经生成后仍按第 6、7 条处理持久化降级。余额不足、参数错误、缺少草稿快照、关卡不存在等业务错误不走此降级。 +9. VectorEngine 错误统一映射为 `502 UPSTREAM_ERROR`,`details.provider = "vector-engine"`,保留上游状态码、业务 message 和截断后的 raw excerpt。 +10. 拼图首图生成 `compile_puzzle_draft` 与关卡图片生成 `generate_puzzle_images` 每次预扣 `2` 光点;余额不足仍返回 `409 CONFLICT`,SpacetimeDB 连接级 503 仍按既有降级策略处理。 ## 关卡名多模态生成 @@ -64,21 +64,21 @@ 新增服务端环境变量: ```text -APIMART_BASE_URL="https://api.apimart.ai/v1" -APIMART_API_KEY="YOUR_APIMART_API_KEY" -APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 +VECTOR_ENGINE_BASE_URL="https://api.vectorengine.ai" +VECTOR_ENGINE_API_KEY="YOUR_VECTOR_ENGINE_API_KEY" +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 ``` -`APIMART_API_KEY` 只能存在于本地或部署环境,不写入 Git 跟踪文件。若选择 APIMart 模型但缺少 key,后端返回服务不可用错误,前端展示现有错误面板。 +`VECTOR_ENGINE_API_KEY` 只能存在于本地或部署环境,不写入 Git 跟踪文件。若缺少 key,后端返回服务不可用错误,前端展示现有错误面板。 ## 验收 1. 创作表单和关卡详情的画面描述框左下角能切换 `gpt-image-2`、`nanobanana2`,默认显示 `gpt-image-2`。 2. 点击“生成草稿”时,后端首图生成使用当前表单选择的模型。 3. 点击“生成画面 / 重新生成画面”时,后端当前关卡图片生成使用关卡详情选择的模型。 -4. 历史 `original` 或空模型值不会再触发 DashScope,统一按 `gpt-image-2` 请求 APIMart。 -5. 选择 APIMart 模型时,请求 `POST {APIMART_BASE_URL}/images/generations`,使用 `Authorization: Bearer {APIMART_API_KEY}`,`model` 等于请求值,`official_fallback = true`,`size = 1:1`。 -6. 首图和结果页关卡重新生图成功后,Network 中应先完成图片生成,再调用 APIMart `POST {APIMART_BASE_URL}/chat/completions`,请求模型为 `gpt-4o-mini`,消息同时包含画面描述文本和正式图 `image_url` Data URL。 +4. 历史 `original` 或空模型值不会再触发 DashScope,统一按 `gpt-image-2` 请求 VectorEngine。 +5. 选择图片模型时,请求 `POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations`,使用 `Authorization: Bearer {VECTOR_ENGINE_API_KEY}`,上游 `model = gpt-image-2-all`,不携带 `official_fallback`,`size = 1024x1024`。 +6. 首图和结果页关卡重新生图成功后,Network 中应先完成 VectorEngine 图片生成,再调用 APIMart `POST {APIMART_BASE_URL}/chat/completions`,请求模型为 `gpt-4o-mini`,消息同时包含画面描述文本和正式图 `image_url` Data URL。 7. “生成草稿”和关卡详情生图按钮展示 `消耗2光点`;关卡详情确认后展示 30 秒预计剩余进度条。 8. 不改 SpacetimeDB 表结构,因此无需更新 `migration.rs` 或重新生成 bindings。 9. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server` 重启验证。 diff --git a/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md b/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md index af43dbe1..5c68a59d 100644 --- a/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md +++ b/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md @@ -22,7 +22,7 @@ - 每个玩法一个参考图,首版用于视觉识别,不承载规则说明。 - 当前创作 Tab 顶部玩法卡带必须直接渲染这些图片,避免参考图只出现在隐藏弹层里。 5. `.codex/skills/gpt-image-2-apimart/` - - 封装仓库内 `gpt-image-2` 的 APIMart OpenAI 兼容调用流程。 + - 历史目录名保留,实际封装仓库内 `gpt-image-2` 的 VectorEngine 调用流程。 - Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。 ## UI 规则 @@ -74,19 +74,17 @@ Skill 封装仓库现有后端口径: ```text -POST {APIMART_BASE_URL}/images/generations -Authorization: Bearer {APIMART_API_KEY} -model = gpt-image-2 +POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations +Authorization: Bearer {VECTOR_ENGINE_API_KEY} +model = gpt-image-2-all n = 1 -official_fallback = true -size = 1:1 +size = 1024x1024 ``` 响应兼容: 1. `data[].url` 2. `data[].b64_json` -3. `task_id` 后续 `GET /tasks/{task_id}` 本次 Skill 只封装生成样例图和研发复用流程,不改变正式后端接口、扣费、OSS、SpacetimeDB 写入和发布链路。 @@ -99,12 +97,12 @@ size = 1:1 - 未上传图片时,输入框标题为 `画面描述`。 - 已上传图片时,输入框标题为 `画面AI重绘要求(提示词)`。 - 展示图片模型切换。 - - `compile_puzzle_draft` 携带 `aiRedraw: true`,继续走 APIMart 生图与 `PUZZLE_IMAGE_GENERATION_POINTS_COST = 2` 扣费链路。 + - `compile_puzzle_draft` 携带 `aiRedraw: true`,继续走 VectorEngine 生图与 `PUZZLE_IMAGE_GENERATION_POINTS_COST = 2` 扣费链路。 - 生成按钮展示 `消耗2光点`。 2. `AI重绘=false` - 隐藏画面描述输入框和模型切换。 - 必须上传拼图图片,按钮不展示 `消耗2光点`。 - - `compile_puzzle_draft` 携带 `aiRedraw: false`,后端只编译草稿和生成首关名,不调用 APIMart,不进入光点扣费 wrapper。 + - `compile_puzzle_draft` 携带 `aiRedraw: false`,后端只编译草稿和生成首关名,不调用 VectorEngine,不进入光点扣费 wrapper。 - 后端把上传图片 Data URL 按拼图资产路径持久化,构造 `sourceType=uploaded` 的候选图并直接选为第一关正式图。 3. 上传裁剪 - 前端读取上传图原始宽高。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 41b74eff..91c427ee 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -7,7 +7,7 @@ - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 - [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。 - [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、`dev-rust-stack` 自动降级策略和手动排障命令。 -- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品、公开拼图作品完整运行态和展示层图片换签的局部请求 `401` 不应扩散成全局登出的修复,覆盖请求层局部鉴权失败隔离、推荐页 embedded 运行态启动、拼图开局/排行榜/下一关和回归测试。 +- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品、公开拼图作品完整运行态、平台 bootstrap 私有投影刷新和展示层图片换签的局部请求 `401` 不应扩散成全局登出的修复,覆盖 `authImpact: local` 请求策略、推荐页 embedded 运行态启动、拼图开局/排行榜/下一关和回归测试。 - [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。 - [HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md](./HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md):记录 Hyper3D Rodin Gen-2 文生 3D 模型、图生 3D 模型、状态查询和下载列表的后端代理、环境变量、请求约束与验收边界。 - [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。 diff --git a/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md b/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md index 89e09032..fa2813f2 100644 --- a/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md +++ b/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md @@ -18,6 +18,10 @@ 这些请求一旦遇到本地代理错配、后端短暂不可用或 token 刷新失败,原请求层会按普通受保护请求处理 `401`,清空 access token 并广播全局鉴权变更。`AuthGate` 收到事件后重新 hydrate,于是当前用户界面被切回未登录态。 +再次复测确认还有更深一层根因:即使单个业务请求显式传了 `clearAuthOnUnauthorized: false`,`refreshAccessToken()` 自身在 refresh 失败时也会先静默清空本地 access token。这样局部请求可能没有广播事件,却已经把本地凭证掏空;后续任意一次默认鉴权探测或 `AuthGate` hydrate 都会变成未登录。 + +推荐页进入公开拼图作品后还会伴随平台侧私有投影刷新,例如存档列表、浏览历史、个人看板和作品架列表。这些请求用于页面展示与局部缓存同步,不是账号会话权威;其中任意一个 401 都不应把整站登录态改写为未登录。 + 推荐页里还有一类更隐蔽的触发点:`ResolvedAssetImage` / `useResolvedAssetReadUrl` 在挂载时会请求 `/api/assets/read-url` 给 generated 私有图片换签。它本质上也是展示层后台请求,若按普通受保护请求处理 `401`,同样会把一次图片换签失败放大成全局掉线。 公开拼图作品的完整运行态还会在用户进入作品后自动发起 `startPuzzleRun`,通关后自动 `submitPuzzleLeaderboard`,点击下一关时 `advancePuzzleNextLevel`。这些请求属于当前玩法的运行态同步,失败时应该落到当前拼图错误态;它们不能清空全局 access token,也不能触发 `AuthGate` 重新 hydrate。 @@ -26,12 +30,14 @@ 本次把推荐页自动运行态请求定义为“卡片级后台请求”: -1. `apiClient` 增加 `clearAuthOnUnauthorized` 选项,允许局部请求在 `401` 时不清空全局 token。 -2. 推荐页嵌入式运行态请求统一传入 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`。 +1. `apiClient` 增加 `authImpact: 'global' | 'local'` 策略,并导出 `BACKGROUND_AUTH_REQUEST_OPTIONS`。`local` 请求统一跳过 refresh,不清空 token,不广播 `AUTH_STATE_EVENT`。 +2. `refreshAccessToken()` 不再自行清空 token;只有 `refreshStoredAccessToken()` 这类全局会话恢复入口和默认全局请求策略能决定清 token。 +3. 推荐页嵌入式运行态请求统一使用 `BACKGROUND_AUTH_REQUEST_OPTIONS`。 3. 推荐页自动启动作品前必须满足 `canReadProtectedData`,避免 `AuthGate` 仍在恢复阶段就提前发起受保护写请求。 4. generated 图片换签请求同样使用局部后台鉴权选项并跳过 refresh,失败只让当前图片为空,不触发全局登录态清理。 5. 公开拼图作品进入完整运行态后,把本次 run 标记为 `isolated` 鉴权模式;开局、重开、排行榜提交和下一关推进都沿用局部鉴权选项。 -6. Remix、发布、点赞、账号设置、退出登录等真正账号动作继续保留默认全局鉴权处理。 +6. 平台 bootstrap 的私有投影读写,包括个人看板、私有作品架、创作作品列表、浏览历史写入和存档列表刷新,也统一作为局部后台请求处理。 +7. Remix、发布、点赞、账号设置、退出登录等真正账号动作继续保留默认全局鉴权处理。 ## 验证 @@ -45,6 +51,9 @@ ## 关联文件 1. `src/services/apiClient.ts` -2. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` -3. `src/services/*-runtime/*RuntimeClient.ts` -4. `src/services/visual-novel-works/visualNovelWorksClient.ts` +2. `src/services/rpg-runtime/rpgRuntimeRequest.ts` +3. `src/services/rpg-creation/rpgCreationRuntimeClient.ts` +4. `src/components/rpg-entry/useRpgEntryBootstrap.ts` +5. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +6. `src/services/*-runtime/*RuntimeClient.ts` +7. `src/services/visual-novel-works/visualNovelWorksClient.ts` diff --git a/docs/technical/RPG_OPENING_CG_MANUAL_GENERATION_2026-05-03.md b/docs/technical/RPG_OPENING_CG_MANUAL_GENERATION_2026-05-03.md index 30bc9505..aecb6f0a 100644 --- a/docs/technical/RPG_OPENING_CG_MANUAL_GENERATION_2026-05-03.md +++ b/docs/technical/RPG_OPENING_CG_MANUAL_GENERATION_2026-05-03.md @@ -100,7 +100,7 @@ type GenerateCustomWorldOpeningCgResponse = { ### 5.1 故事板 -图片模型固定使用 `gpt-image-2`,尺寸语义为 `2k`、`16:9`,当前 APIMart/OpenAI 兼容入口用 `2048x1152` 作为下游 size。 +图片模型固定使用 `gpt-image-2`,尺寸语义为 `2k`、`16:9`;2026-05-09 起实际请求 VectorEngine `gpt-image-2-all`,下游 size 按迁移文档归一为 `1536x1024`。 模板: diff --git a/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md b/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md index e17bedbd..7f204bf5 100644 --- a/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md +++ b/docs/technical/SQUARE_HOLE_AGENT_LLM_TIMEOUT_GUARD_2026-05-05.md @@ -39,7 +39,7 @@ status=503 method=POST uri=/api/creation/square-hole/sessions/{sessionId}/actions ``` -该请求落在方洞 `/actions`,草稿编译成功后前端会自动追加执行 `square_hole_generate_visual_assets`。视觉资产生成依赖 APIMart OpenAI 兼容图片入口;当本地或部署环境缺少 `APIMART_API_KEY` 时,后端会在 `require_openai_image_settings()` 阶段快速返回 `503 SERVICE_UNAVAILABLE`,因此日志 latency 只有个位毫秒。 +该请求落在方洞 `/actions`,草稿编译成功后前端会自动追加执行 `square_hole_generate_visual_assets`。视觉资产生成依赖 VectorEngine GPT-image-2 图片入口;当本地或部署环境缺少 `VECTOR_ENGINE_API_KEY` 时,后端会在 `require_openai_image_settings()` 阶段快速返回 `503 SERVICE_UNAVAILABLE`,因此日志 latency 只有个位毫秒。 这类错误只表示“图片自动生成服务不可用”,不代表方洞草稿编译失败。前端处理规则: diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index e89c7157..b119453d 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -121,7 +121,7 @@ pub async fn generate_character_visual( "sourceMode": payload.source_mode, "size": size, "referenceImageCount": payload.reference_image_data_urls.len(), - "provider": "apimart", + "provider": "vector-engine", }) .to_string(), ), @@ -193,7 +193,7 @@ pub async fn generate_character_visual( ), structured_payload_json: Some( json!({ - "provider": "apimart", + "provider": "vector-engine", "taskId": generated.task_id, "model": model, "imageCount": generated.images.len(), @@ -1172,7 +1172,7 @@ fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError { fn map_image_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": "vector-engine", "message": message, })) } @@ -1184,7 +1184,7 @@ fn map_image_upstream_error(raw_text: &str, fallback_message: &str) -> AppError value => value.to_string(), }; AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": "vector-engine", "message": message, "raw": raw_text.trim(), })) diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 9f4f14bf..8bed094a 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -583,7 +583,7 @@ pub async fn generate_custom_world_scene_image( .map(downloaded_openai_to_custom_world_image) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": "vector-engine", "message": "场景图片生成成功但未返回图片。", })) })?; @@ -687,7 +687,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile( .map(downloaded_openai_to_custom_world_image) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": "vector-engine", "message": "场景图片生成成功但未返回图片。", })) })?; @@ -1200,7 +1200,7 @@ async fn generate_opening_cg_storyboard( .map(downloaded_openai_to_custom_world_image) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": "vector-engine", "message": "开局 CG 故事板生成成功但未返回图片。", })) })?; @@ -3274,9 +3274,10 @@ mod tests { serde_json::from_slice(&body).expect("body should be valid json") } - fn build_state_without_apimart_key() -> AppState { + fn build_state_without_vector_engine_key() -> AppState { let mut config = AppConfig::default(); - config.apimart_api_key = None; + config.vector_engine_base_url = "https://api.vectorengine.test".to_string(); + config.vector_engine_api_key = None; AppState::new(config).expect("state should build") } @@ -3287,8 +3288,8 @@ mod tests { } #[tokio::test] - async fn scene_image_returns_service_unavailable_when_apimart_missing() { - let state = build_state_without_apimart_key(); + async fn scene_image_returns_service_unavailable_when_vector_engine_missing() { + let state = build_state_without_vector_engine_key(); let request_context = build_request_context("POST /api/runtime/custom-world/scene-image"); let authenticated = build_authenticated(&state); @@ -3311,7 +3312,7 @@ mod tests { })), ) .await - .expect_err("missing apimart should fail"); + .expect_err("missing vector engine should fail"); let payload = read_error_response(response).await; assert_eq!( @@ -3320,7 +3321,7 @@ mod tests { ); assert_eq!( payload["error"]["details"]["provider"], - Value::String("apimart".to_string()) + Value::String("vector-engine".to_string()) ); } diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 040f8993..ab2e6c5d 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -149,10 +149,12 @@ pub(crate) async fn create_openai_image_generation( return Ok(generated); } - Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:VectorEngine 未返回图片地址"), - }))) + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("{failure_context}:VectorEngine 未返回图片地址"), + })), + ) } pub(crate) fn build_openai_image_request_body( @@ -200,8 +202,8 @@ fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> St fn normalize_image_size(size: &str) -> String { match size.trim() { "1024*1024" | "1024x1024" | "1:1" => "1024x1024", - "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" - | "1536x1024" | "2048x1152" | "2k" => "1536x1024", + "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" + | "2k" => "1536x1024", "1024*1536" | "1024x1536" | "9:16" => "1024x1536", value if !value.is_empty() => value, _ => "1024x1024", diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 17e39ef2..fd6851fd 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -3877,9 +3877,6 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { let provider = if message.contains("VectorEngine") || message.contains("vector-engine") || message.contains("VECTOR_ENGINE") - || message.contains("APIMart") - || message.contains("apimart") - || message.contains("APIMART") { VECTOR_ENGINE_PROVIDER } else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") { @@ -3890,8 +3887,6 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { let status = if provider == VECTOR_ENGINE_PROVIDER && (message.contains("VECTOR_ENGINE_API_KEY") || message.contains("VECTOR_ENGINE_BASE_URL") - || message.contains("APIMART_API_KEY") - || message.contains("APIMART_BASE_URL") || message.contains("未配置")) { StatusCode::SERVICE_UNAVAILABLE @@ -3907,9 +3902,6 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { || message.contains("VectorEngine") || message.contains("vector-engine") || message.contains("VECTOR_ENGINE") - || message.contains("APIMart") - || message.contains("apimart") - || message.contains("APIMART") || message.contains("参考图") || message.contains("图片") || message.contains("OSS") @@ -4039,14 +4031,14 @@ async fn generate_puzzle_image_candidates( }; // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 - let settings = require_puzzle_apimart_settings(state)?; - let generated = create_puzzle_apimart_image_generation( + let settings = require_puzzle_vector_engine_settings(state)?; + let generated = create_puzzle_vector_engine_image_generation( &http_client, &settings, resolved_model, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_APIMART_GENERATED_IMAGE_SIZE, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, reference_image.as_deref(), ) @@ -4097,26 +4089,25 @@ mod tests { #[test] fn puzzle_generated_image_size_is_square_1_1() { assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); - assert_eq!(PUZZLE_APIMART_GENERATED_IMAGE_SIZE, "1:1"); + assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024"); } #[test] - fn puzzle_apimart_request_uses_selected_model_and_reference_images() { - let body = build_puzzle_apimart_image_request_body( + fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() { + let body = build_puzzle_vector_engine_image_request_body( PuzzleImageModel::Gemini31FlashPreview, "一只猫在雨夜灯牌下回头。", PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_APIMART_GENERATED_IMAGE_SIZE, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, 4, Some("data:image/png;base64,abcd"), ); - assert_eq!(body["model"], PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW); - assert_eq!(body["size"], PUZZLE_APIMART_GENERATED_IMAGE_SIZE); - assert_eq!(body["resolution"], PUZZLE_APIMART_GEMINI_RESOLUTION); + assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE); assert_eq!(body["n"], 1); - assert_eq!(body["official_fallback"], true); - assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd"); + assert!(body.get("official_fallback").is_none()); + assert_eq!(body["image"][0], "data:image/png;base64,abcd"); assert!( body["prompt"] .as_str() @@ -4126,9 +4117,9 @@ mod tests { } #[test] - fn puzzle_compile_error_preserves_apimart_unavailable_status() { + fn puzzle_compile_error_preserves_vector_engine_unavailable_status() { let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( - "APIMART_API_KEY 未配置".to_string(), + "VECTOR_ENGINE_API_KEY 未配置".to_string(), )); let response = error.into_response(); @@ -4313,14 +4304,11 @@ enum PuzzleImageModel { impl PuzzleImageModel { fn provider_name(self) -> &'static str { - "apimart" + VECTOR_ENGINE_PROVIDER } fn request_model_name(self) -> &'static str { - match self { - Self::GptImage2 => PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, - Self::Gemini31FlashPreview => PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, - } + VECTOR_ENGINE_GPT_IMAGE_2_MODEL } fn candidate_source_type(self) -> &'static str { @@ -4331,10 +4319,9 @@ impl PuzzleImageModel { } } -struct PuzzleApimartSettings { +struct PuzzleVectorEngineSettings { base_url: String, api_key: String, - request_timeout_ms: u64, } struct PuzzleGeneratedImages { @@ -4384,41 +4371,53 @@ struct GeneratedPuzzleAssetResponse { fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel { match value.map(str::trim).filter(|value| !value.is_empty()) { - Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => PuzzleImageModel::Gemini31FlashPreview, + Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => { + tracing::warn!( + requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, + effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL, + "拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all" + ); + PuzzleImageModel::Gemini31FlashPreview + } _ => PuzzleImageModel::GptImage2, } } -fn require_puzzle_apimart_settings(state: &AppState) -> Result { - let base_url = state.config.apimart_base_url.trim().trim_end_matches('/'); +fn require_puzzle_vector_engine_settings( + state: &AppState, +) -> Result { + let base_url = state + .config + .vector_engine_base_url + .trim() + .trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "apimart", - "message": "APIMart 图片生成地址未配置", - "reason": "APIMART_BASE_URL 未配置", + "provider": VECTOR_ENGINE_PROVIDER, + "message": "VectorEngine 图片生成地址未配置", + "reason": "VECTOR_ENGINE_BASE_URL 未配置", })), ); } let api_key = state .config - .apimart_api_key + .vector_engine_api_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "apimart", - "message": "APIMart 图片生成密钥未配置", - "reason": "APIMART_API_KEY 未配置", + "provider": VECTOR_ENGINE_PROVIDER, + "message": "VectorEngine 图片生成密钥未配置", + "reason": "VECTOR_ENGINE_API_KEY 未配置", })) })?; - Ok(PuzzleApimartSettings { + Ok(PuzzleVectorEngineSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), - request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1), }) } @@ -4427,7 +4426,7 @@ fn build_puzzle_image_http_client( image_model: PuzzleImageModel, ) -> Result { let provider = image_model.provider_name(); - let request_timeout_ms = state.config.apimart_image_request_timeout_ms; + let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms; reqwest::Client::builder() .timeout(Duration::from_millis(request_timeout_ms.max(1))) @@ -4455,9 +4454,9 @@ fn to_puzzle_generated_image_candidate( } } -async fn create_puzzle_apimart_image_generation( +async fn create_puzzle_vector_engine_image_generation( http_client: &reqwest::Client, - settings: &PuzzleApimartSettings, + settings: &PuzzleVectorEngineSettings, image_model: PuzzleImageModel, prompt: &str, negative_prompt: &str, @@ -4465,7 +4464,7 @@ async fn create_puzzle_apimart_image_generation( candidate_count: u32, reference_image: Option<&str>, ) -> Result { - let request_body = build_puzzle_apimart_image_request_body( + let request_body = build_puzzle_vector_engine_image_request_body( image_model, prompt, negative_prompt, @@ -4474,61 +4473,59 @@ async fn create_puzzle_apimart_image_generation( reference_image, ); let response = http_client - .post(format!("{}/images/generations", settings.base_url)) + .post(puzzle_vector_engine_images_generation_url(settings)) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) + .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::CONTENT_TYPE, "application/json") .json(&request_body) .send() .await .map_err(|error| { - map_puzzle_apimart_request_error(format!("创建拼图 APIMart 图片生成任务失败:{error}")) + map_puzzle_vector_engine_request_error(format!( + "创建拼图 VectorEngine 图片生成任务失败:{error}" + )) })?; let status = response.status(); let response_text = response.text().await.map_err(|error| { - map_puzzle_apimart_request_error(format!("读取拼图 APIMart 图片生成响应失败:{error}")) + map_puzzle_vector_engine_request_error(format!( + "读取拼图 VectorEngine 图片生成响应失败:{error}" + )) })?; if !status.is_success() { - return Err(map_puzzle_apimart_upstream_error( + return Err(map_puzzle_vector_engine_upstream_error( status, response_text.as_str(), - "创建拼图 APIMart 图片生成任务失败", + "创建拼图 VectorEngine 图片生成任务失败", )); } - let payload = - parse_puzzle_json_payload(response_text.as_str(), "解析拼图 APIMart 图片生成响应失败")?; + let payload = parse_puzzle_json_payload( + response_text.as_str(), + "解析拼图 VectorEngine 图片生成响应失败", + )?; let image_urls = extract_puzzle_image_urls(&payload); if !image_urls.is_empty() { return download_puzzle_images_from_urls( http_client, - format!("apimart-{}", current_utc_micros()), + format!("vector-engine-{}", current_utc_micros()), image_urls, candidate_count, ) .await; } - let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| { + Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", - "message": "拼图 APIMart 图片生成未返回 task_id 或图片地址", - })) - })?; - - wait_puzzle_apimart_generated_images( - http_client, - settings, - task_id.as_str(), - candidate_count, - "拼图 APIMart 图片生成任务失败", + "provider": VECTOR_ENGINE_PROVIDER, + "message": "拼图 VectorEngine 图片生成未返回图片地址", + })), ) - .await } -fn build_puzzle_apimart_image_request_body( +fn build_puzzle_vector_engine_image_request_body( image_model: PuzzleImageModel, prompt: &str, negative_prompt: &str, @@ -4543,34 +4540,23 @@ fn build_puzzle_apimart_image_request_body( ), ( "prompt".to_string(), - Value::String(build_puzzle_apimart_prompt(prompt, negative_prompt)), + Value::String(build_puzzle_vector_engine_prompt(prompt, negative_prompt)), ), ("n".to_string(), json!(candidate_count.clamp(1, 1))), - ("official_fallback".to_string(), Value::Bool(true)), ("size".to_string(), Value::String(size.to_string())), ]); - body.insert( - "resolution".to_string(), - Value::String( - match image_model { - PuzzleImageModel::Gemini31FlashPreview => PUZZLE_APIMART_GEMINI_RESOLUTION, - _ => "1k", - } - .to_string(), - ), - ); if let Some(reference_image) = reference_image .map(str::trim) .filter(|value| !value.is_empty()) { - body.insert("image_urls".to_string(), json!([reference_image])); + body.insert("image".to_string(), json!([reference_image])); } Value::Object(body) } -fn build_puzzle_apimart_prompt(prompt: &str, negative_prompt: &str) -> String { +fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String { let prompt = prompt.trim(); let negative_prompt = negative_prompt.trim(); if negative_prompt.is_empty() { @@ -4580,88 +4566,12 @@ fn build_puzzle_apimart_prompt(prompt: &str, negative_prompt: &str) -> String { format!("{prompt}\n避免:{negative_prompt}") } -async fn wait_puzzle_apimart_generated_images( - http_client: &reqwest::Client, - settings: &PuzzleApimartSettings, - task_id: &str, - candidate_count: u32, - failure_message: &str, -) -> Result { - let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); - sleep(Duration::from_secs(10)).await; - - while Instant::now() < deadline { - let poll_response = http_client - .get(format!("{}/tasks/{}", settings.base_url, task_id)) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .send() - .await - .map_err(|error| { - map_puzzle_apimart_request_error(format!( - "查询拼图 APIMart 图片生成任务失败:{error}" - )) - })?; - let poll_status = poll_response.status(); - let poll_text = poll_response.text().await.map_err(|error| { - map_puzzle_apimart_request_error(format!( - "读取拼图 APIMart 图片生成任务响应失败:{error}" - )) - })?; - if !poll_status.is_success() { - return Err(map_puzzle_apimart_upstream_error( - poll_status, - poll_text.as_str(), - "查询拼图 APIMart 图片生成任务失败", - )); - } - - let poll_payload = - parse_puzzle_json_payload(poll_text.as_str(), "解析拼图 APIMart 图片生成任务响应失败")?; - let task_status = find_first_puzzle_string_by_key(&poll_payload, "status") - .unwrap_or_default() - .trim() - .to_ascii_lowercase(); - if matches!(task_status.as_str(), "completed" | "succeeded" | "success") { - let image_urls = extract_puzzle_image_urls(&poll_payload); - if image_urls.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", - "message": "拼图 APIMart 图片生成成功但未返回图片地址", - })), - ); - } - - return download_puzzle_images_from_urls( - http_client, - task_id.to_string(), - image_urls, - candidate_count, - ) - .await; - } - if matches!( - task_status.as_str(), - "failed" | "error" | "canceled" | "cancelled" - ) { - return Err(map_puzzle_apimart_upstream_error( - poll_status, - poll_text.as_str(), - failure_message, - )); - } - sleep(Duration::from_secs(3)).await; +fn puzzle_vector_engine_images_generation_url(settings: &PuzzleVectorEngineSettings) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/generations", settings.base_url) + } else { + format!("{}/v1/images/generations", settings.base_url) } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", - "message": "拼图 APIMart 图片生成超时或未返回图片地址", - })), - ) } async fn download_puzzle_images_from_urls( @@ -4965,7 +4875,7 @@ fn build_puzzle_asset_metadata( fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result { serde_json::from_str::(raw_text).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{fallback_message}:{error}"), })) }) @@ -5011,10 +4921,6 @@ fn decode_puzzle_base64(value: &str) -> Option> { Some(output) } -fn extract_puzzle_task_id(payload: &Value) -> Option { - find_first_puzzle_string_by_key(payload, "task_id") -} - fn extract_puzzle_image_urls(payload: &Value) -> Vec { let mut urls = Vec::new(); collect_puzzle_strings_by_key(payload, "image", &mut urls); @@ -5095,14 +5001,14 @@ fn map_puzzle_image_request_error(message: String) -> AppError { })) } -fn map_puzzle_apimart_request_error(message: String) -> AppError { +fn map_puzzle_vector_engine_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": VECTOR_ENGINE_PROVIDER, "message": message, })) } -fn map_puzzle_apimart_upstream_error( +fn map_puzzle_vector_engine_upstream_error( upstream_status: reqwest::StatusCode, raw_text: &str, fallback_message: &str, @@ -5110,15 +5016,15 @@ fn map_puzzle_apimart_upstream_error( let message = parse_puzzle_api_error_message(raw_text, fallback_message); let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); tracing::warn!( - provider = "apimart", + provider = VECTOR_ENGINE_PROVIDER, upstream_status = upstream_status.as_u16(), message = %message, raw_excerpt = %raw_excerpt, - "拼图 APIMart 上游请求失败" + "拼图 VectorEngine 上游请求失败" ); AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": VECTOR_ENGINE_PROVIDER, "upstreamStatus": upstream_status.as_u16(), "message": message, "rawExcerpt": raw_excerpt, diff --git a/server-rs/crates/api-server/src/square_hole.rs b/server-rs/crates/api-server/src/square_hole.rs index 3a6e1b1e..4c9bbcfb 100644 --- a/server-rs/crates/api-server/src/square_hole.rs +++ b/server-rs/crates/api-server/src/square_hole.rs @@ -1517,7 +1517,7 @@ async fn generate_square_hole_image_data_url( .await?; let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": "vector-engine", "message": format!("{failure_context}:上游未返回图片"), })) })?; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1ad96eb7..cc895dcb 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -99,7 +99,10 @@ import { buildPublicWorkStagePath, pushAppHistoryPath, } from '../../routing/appPageRoutes'; -import { ApiClientError } from '../../services/apiClient'; +import { + ApiClientError, + BACKGROUND_AUTH_REQUEST_OPTIONS, +} from '../../services/apiClient'; import { getPublicAuthUserByCode, getPublicAuthUserById, @@ -383,11 +386,8 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([ 'publish_missing_main_chapter', 'publish_missing_first_act', ]); -const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = { - skipRefresh: true, - notifyAuthStateChange: false, - clearAuthOnUnauthorized: false, -}; +const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = + BACKGROUND_AUTH_REQUEST_OPTIONS; const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS = RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index cfdb0e2c..5098fcf0 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -211,6 +211,7 @@ async function openExistingRpgDraft( } const ISOLATED_RUNTIME_AUTH_OPTIONS = { + authImpact: 'local', skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, @@ -3682,6 +3683,10 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa }, ISOLATED_RUNTIME_AUTH_OPTIONS, ); + vi.mocked(listProfileSaveArchives).mockClear(); + vi.mocked(listProfileSaveArchives).mockRejectedValueOnce( + new Error('后台存档刷新 401'), + ); await user.click(document.querySelector('[data-piece-id="piece-0"]')!); await user.click(document.querySelector('[data-piece-id="piece-1"]')!); @@ -3711,6 +3716,9 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa ); expect(dialog).toBeTruthy(); expect(screen.getByText('测试玩家')).toBeTruthy(); + expect(listProfileSaveArchives).toHaveBeenCalledWith( + ISOLATED_RUNTIME_AUTH_OPTIONS, + ); await user.click(within(dialog).getByRole('button', { name: '下一关' })); diff --git a/src/components/rpg-entry/useRpgEntryBootstrap.ts b/src/components/rpg-entry/useRpgEntryBootstrap.ts index af192446..9aec011f 100644 --- a/src/components/rpg-entry/useRpgEntryBootstrap.ts +++ b/src/components/rpg-entry/useRpgEntryBootstrap.ts @@ -22,14 +22,22 @@ import { resumeRpgProfileSaveArchive, upsertRpgProfileBrowseHistory, } from '../../services/rpg-entry'; +import { + RUNTIME_BACKGROUND_AUTH_OPTIONS, + type RuntimeRequestOptions, +} from '../../services/rpg-runtime/rpgRuntimeRequest'; import type { CustomWorldProfile } from '../../types'; import type { PlatformHomeTab } from './RpgEntryHomeView'; import { resolveRpgEntryErrorMessage } from './rpgEntryShared'; +const PLATFORM_BOOTSTRAP_AUTH_OPTIONS = RUNTIME_BACKGROUND_AUTH_OPTIONS; + type UseRpgEntryBootstrapParams = { user: AuthUser | null | undefined; canAccessProtectedData?: boolean | undefined; - getProfileDashboard: () => Promise; + getProfileDashboard: ( + options?: RuntimeRequestOptions, + ) => Promise; handleContinueGame: ( snapshot?: HydratedSavedGameSnapshot | null, ) => void; @@ -99,7 +107,9 @@ export function useRpgEntryBootstrap( setDashboardError(null); try { - setProfileDashboard(await getProfileDashboard()); + setProfileDashboard( + await getProfileDashboard(PLATFORM_BOOTSTRAP_AUTH_OPTIONS), + ); } catch (error) { setDashboardError( resolveRpgEntryErrorMessage(error, '读取个人数据看板失败。'), @@ -115,7 +125,9 @@ export function useRpgEntryBootstrap( return []; } - const nextItems = await listRpgCreationWorks(); + const nextItems = await listRpgCreationWorks( + PLATFORM_BOOTSTRAP_AUTH_OPTIONS, + ); setCustomWorldWorkEntries(nextItems); return nextItems; }, [canReadProtectedData, user]); @@ -132,7 +144,9 @@ export function useRpgEntryBootstrap( return []; } - const nextEntries = await listRpgEntryWorldLibrary(); + const nextEntries = await listRpgEntryWorldLibrary( + PLATFORM_BOOTSTRAP_AUTH_OPTIONS, + ); setSavedCustomWorldEntries(nextEntries); return nextEntries; }, [canReadProtectedData, user]); @@ -147,7 +161,9 @@ export function useRpgEntryBootstrap( setSaveError(null); try { - const nextEntries = await listRpgProfileSaveArchives(); + const nextEntries = await listRpgProfileSaveArchives( + PLATFORM_BOOTSTRAP_AUTH_OPTIONS, + ); setSaveEntries(nextEntries); return nextEntries; } catch (error) { @@ -161,7 +177,10 @@ export function useRpgEntryBootstrap( setHistoryError(null); try { - const syncedEntries = await upsertRpgProfileBrowseHistory(entry); + const syncedEntries = await upsertRpgProfileBrowseHistory( + entry, + PLATFORM_BOOTSTRAP_AUTH_OPTIONS, + ); setHistoryEntries(syncedEntries); } catch (error) { setHistoryError( @@ -237,18 +256,20 @@ export function useRpgEntryBootstrap( saveArchivesResult, ] = await Promise.allSettled([ canReadProtectedData - ? listRpgEntryWorldLibrary() + ? listRpgEntryWorldLibrary(PLATFORM_BOOTSTRAP_AUTH_OPTIONS) : Promise.resolve([]), canReadProtectedData - ? listRpgCreationWorks() + ? listRpgCreationWorks(PLATFORM_BOOTSTRAP_AUTH_OPTIONS) : Promise.resolve([]), listRpgEntryWorldGallery(), - canReadProtectedData ? getProfileDashboard() : Promise.resolve(null), canReadProtectedData - ? listRpgProfileBrowseHistory() + ? getProfileDashboard(PLATFORM_BOOTSTRAP_AUTH_OPTIONS) + : Promise.resolve(null), + canReadProtectedData + ? listRpgProfileBrowseHistory(PLATFORM_BOOTSTRAP_AUTH_OPTIONS) : Promise.resolve([]), canReadProtectedData - ? listRpgProfileSaveArchives() + ? listRpgProfileSaveArchives(PLATFORM_BOOTSTRAP_AUTH_OPTIONS) : Promise.resolve([]), ]); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 4fcc7076..38b73a13 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AUTH_STATE_EVENT, ApiClientError, + BACKGROUND_AUTH_REQUEST_OPTIONS, clearStoredAccessToken, fetchWithApiAuth, getStoredAccessToken, @@ -265,6 +266,52 @@ describe('apiClient', () => { expect(getStoredAccessToken()).toBe('still-valid-token'); }); + it('keeps auth state untouched when local auth impact receives 401 with bearer token', async () => { + setStoredAccessToken('still-valid-token', { emit: false }); + fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + + const response = await fetchWithApiAuth( + '/api/profile/save-archives', + { method: 'GET' }, + { + authImpact: 'local', + }, + ); + + expect(response.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(dispatchEventMock).not.toHaveBeenCalled(); + expect(getStoredAccessToken()).toBe('still-valid-token'); + }); + + it('does not clear local token when background refresh fails before a request', async () => { + setStoredAccessToken('still-valid-token', { emit: false }); + fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + + const response = await fetchWithApiAuth( + '/api/runtime/puzzle/runs', + { method: 'POST' }, + BACKGROUND_AUTH_REQUEST_OPTIONS, + ); + + expect(response.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + '/api/runtime/puzzle/runs', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer still-valid-token', + }), + }), + ); + expect(fetchMock).not.toHaveBeenCalledWith( + '/api/auth/refresh', + expect.anything(), + ); + expect(dispatchEventMock).not.toHaveBeenCalled(); + expect(getStoredAccessToken()).toBe('still-valid-token'); + }); + it('keeps the refreshed token when the retried protected request is still unauthorized', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 599471e9..afac88c9 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -26,18 +26,29 @@ export type ApiRetryOptions = { allowRetryMethods?: string[]; }; +export type ApiAuthImpact = 'global' | 'local'; + export type ApiRequestOptions = { retry?: ApiRetryOptions; timeoutMs?: number; skipAuth?: boolean; omitEnvelopeHeader?: boolean; skipRefresh?: boolean; + // global:请求失败可影响整站登录态;local:失败只属于当前卡片、图片或运行态。 + authImpact?: ApiAuthImpact; // 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。 notifyAuthStateChange?: boolean; // 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。 clearAuthOnUnauthorized?: boolean; }; +export const BACKGROUND_AUTH_REQUEST_OPTIONS = { + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, +} satisfies ApiRequestOptions; + type ResolvedRetryOptions = { maxRetries: number; baseDelayMs: number; @@ -54,6 +65,12 @@ type ParsedApiErrorShape = { meta: Partial; }; +type ResolvedAuthFailurePolicy = { + skipRefresh: boolean; + notifyAuthStateChange: boolean; + clearAuthOnUnauthorized: boolean; +}; + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -342,6 +359,24 @@ function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) { return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt)); } +function resolveAuthFailurePolicy( + options: ApiRequestOptions, +): ResolvedAuthFailurePolicy { + const isLocalAuthImpact = options.authImpact === 'local'; + + return { + // 局部后台请求可以携带已有 token,但不能主动 refresh; + // 否则 refresh 失败会把一次卡片/图片/运行态失败放大成全局掉线。 + skipRefresh: isLocalAuthImpact || options.skipRefresh === true, + notifyAuthStateChange: isLocalAuthImpact + ? false + : options.notifyAuthStateChange !== false, + clearAuthOnUnauthorized: isLocalAuthImpact + ? false + : options.clearAuthOnUnauthorized !== false, + }; +} + export class ApiClientError extends Error { status: number; code: string; @@ -477,7 +512,6 @@ async function refreshAccessToken() { }); if (!response.ok) { - clearStoredAccessToken({ emit: false }); throw await buildApiClientError(response, '刷新登录状态失败'); } @@ -489,7 +523,6 @@ async function refreshAccessToken() { : null; if (payload?.ok !== true || !payload.token?.trim()) { - clearStoredAccessToken({ emit: false }); throw new Error('刷新登录状态失败'); } @@ -516,7 +549,12 @@ export async function ensureStoredAccessToken() { } export async function refreshStoredAccessToken() { - return refreshAccessToken(); + try { + return await refreshAccessToken(); + } catch (error) { + clearStoredAccessToken({ emit: false }); + throw error; + } } export async function fetchWithApiAuth( @@ -526,9 +564,7 @@ export async function fetchWithApiAuth( ) { const method = (init.method ?? 'GET').toUpperCase(); const retry = resolveRetryOptions(method, options.retry); - const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false; - const shouldClearAuthOnUnauthorized = - options.clearAuthOnUnauthorized !== false; + const authFailurePolicy = resolveAuthFailurePolicy(options); const requestSignal = init.signal ?? undefined; let attempt = 0; let refreshAttempted = false; @@ -541,7 +577,11 @@ export async function fetchWithApiAuth( requestHeaders.authorization?.trim(), ); - if (!hasAuthHeader && !options.skipAuth && !options.skipRefresh) { + if ( + !hasAuthHeader && + !options.skipAuth && + !authFailurePolicy.skipRefresh + ) { try { // 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票, // 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。 @@ -573,7 +613,7 @@ export async function fetchWithApiAuth( response.status === 401 && hasAuthHeader && !options.skipAuth && - !options.skipRefresh && + !authFailurePolicy.skipRefresh && !refreshAttempted ) { try { @@ -584,10 +624,10 @@ export async function fetchWithApiAuth( // 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。 continue; } catch { - if (hasAuthHeader && shouldClearAuthOnUnauthorized) { + if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) { clearStoredAccessToken({ emit: false }); } - if (shouldNotifyAuthStateChange) { + if (authFailurePolicy.notifyAuthStateChange) { emitAuthStateChange(); } } @@ -597,10 +637,10 @@ export async function fetchWithApiAuth( !options.skipAuth && !refreshAttempted ) { - if (shouldClearAuthOnUnauthorized) { + if (authFailurePolicy.clearAuthOnUnauthorized) { clearStoredAccessToken({ emit: false }); } - if (shouldNotifyAuthStateChange) { + if (authFailurePolicy.notifyAuthStateChange) { emitAuthStateChange(); } } diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index c515b84f..15225b47 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -1,5 +1,6 @@ import { ApiClientError, + BACKGROUND_AUTH_REQUEST_OPTIONS, type ApiRequestOptions, requestJson, } from './apiClient'; @@ -45,11 +46,8 @@ type CachedReadUrlFailureEntry = { const ASSET_READ_URL_API_PATH = '/api/assets/read-url'; const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000; const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000; -const ASSET_READ_URL_BACKGROUND_OPTIONS = { - skipRefresh: true, - notifyAuthStateChange: false, - clearAuthOnUnauthorized: false, -} satisfies ApiRequestOptions; +const ASSET_READ_URL_BACKGROUND_OPTIONS = + BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions; const signedReadUrlCache = new Map(); const signedReadUrlFailureCache = new Map(); const pendingSignedReadUrlRequests = new Map>(); diff --git a/src/services/big-fish-runtime/bigFishRuntimeClient.ts b/src/services/big-fish-runtime/bigFishRuntimeClient.ts index fc5ce283..204be416 100644 --- a/src/services/big-fish-runtime/bigFishRuntimeClient.ts +++ b/src/services/big-fish-runtime/bigFishRuntimeClient.ts @@ -18,7 +18,10 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = { }; type BigFishRuntimeRequestOptions = Pick< ApiRequestOptions, - 'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized' + | 'authImpact' + | 'skipRefresh' + | 'notifyAuthStateChange' + | 'clearAuthOnUnauthorized' >; /** @@ -39,6 +42,7 @@ export function recordBigFishPlay( '记录大鱼吃小鱼游玩失败', { retry: BIG_FISH_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, @@ -58,6 +62,7 @@ export function startBigFishRun( '启动大鱼吃小鱼玩法失败', { retry: BIG_FISH_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, diff --git a/src/services/match3d-runtime/match3dRuntimeClient.ts b/src/services/match3d-runtime/match3dRuntimeClient.ts index d9bcbda6..44a7f0b2 100644 --- a/src/services/match3d-runtime/match3dRuntimeClient.ts +++ b/src/services/match3d-runtime/match3dRuntimeClient.ts @@ -26,7 +26,10 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = { }; type Match3DRuntimeRequestOptions = Pick< ApiRequestOptions, - 'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized' + | 'authImpact' + | 'skipRefresh' + | 'notifyAuthStateChange' + | 'clearAuthOnUnauthorized' >; function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) { @@ -80,6 +83,7 @@ export function startMatch3DRun( '启动抓大鹅玩法失败', { retry: MATCH3D_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index 7fb8eb9c..53d1211f 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -28,7 +28,10 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = { }; type PuzzleRuntimeRequestOptions = Pick< ApiRequestOptions, - 'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized' + | 'authImpact' + | 'skipRefresh' + | 'notifyAuthStateChange' + | 'clearAuthOnUnauthorized' >; /** @@ -48,6 +51,7 @@ export async function startPuzzleRun( '启动拼图玩法失败', { retry: PUZZLE_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, @@ -136,6 +140,7 @@ export async function advancePuzzleNextLevel( '进入下一关失败', { retry: PUZZLE_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, @@ -161,6 +166,7 @@ export async function submitPuzzleLeaderboard( '提交拼图排行榜失败', { retry: PUZZLE_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, @@ -186,6 +192,7 @@ export async function updatePuzzleRunPause( '更新拼图计时状态失败', { retry: PUZZLE_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, @@ -211,6 +218,7 @@ export async function usePuzzleRuntimeProp( '使用拼图道具失败', { retry: PUZZLE_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, diff --git a/src/services/rpg-creation/rpgCreationRuntimeClient.ts b/src/services/rpg-creation/rpgCreationRuntimeClient.ts index b18a4214..cb509d1f 100644 --- a/src/services/rpg-creation/rpgCreationRuntimeClient.ts +++ b/src/services/rpg-creation/rpgCreationRuntimeClient.ts @@ -1,4 +1,9 @@ -import { type ApiRetryOptions, requestJson } from '../apiClient'; +import { + BACKGROUND_AUTH_REQUEST_OPTIONS, + type ApiAuthImpact, + type ApiRetryOptions, + requestJson, +} from '../apiClient'; const RUNTIME_API_BASE = '/api/runtime'; const RUNTIME_READ_RETRY: ApiRetryOptions = { @@ -18,9 +23,15 @@ export type RpgCreationRuntimeRequestOptions = { retry?: ApiRetryOptions; skipAuth?: boolean; skipRefresh?: boolean; + authImpact?: ApiAuthImpact; + notifyAuthStateChange?: boolean; + clearAuthOnUnauthorized?: boolean; timeoutMs?: number; }; +export const RPG_CREATION_BACKGROUND_AUTH_OPTIONS = + BACKGROUND_AUTH_REQUEST_OPTIONS satisfies RpgCreationRuntimeRequestOptions; + export function requestRpgCreationRuntimeJson( path: string, init: RequestInit, @@ -43,6 +54,9 @@ export function requestRpgCreationRuntimeJson( retry, skipAuth: options.skipAuth, skipRefresh: options.skipRefresh, + authImpact: options.authImpact, + notifyAuthStateChange: options.notifyAuthStateChange, + clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, timeoutMs: options.timeoutMs, }, ); diff --git a/src/services/rpg-creation/rpgCreationWorkClient.ts b/src/services/rpg-creation/rpgCreationWorkClient.ts index 259d7f72..ad8f2700 100644 --- a/src/services/rpg-creation/rpgCreationWorkClient.ts +++ b/src/services/rpg-creation/rpgCreationWorkClient.ts @@ -1,11 +1,17 @@ import type { ListRpgCreationWorksResponse } from '../../../packages/shared/src'; -import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient'; +import { + requestRpgCreationRuntimeJson, + type RpgCreationRuntimeRequestOptions, +} from './rpgCreationRuntimeClient'; -export async function listRpgCreationWorks() { +export async function listRpgCreationWorks( + options: RpgCreationRuntimeRequestOptions = {}, +) { const response = await requestRpgCreationRuntimeJson( '/custom-world/works', { method: 'GET' }, '读取创作作品列表失败', + options, ); return Array.isArray(response?.items) ? response.items : []; diff --git a/src/services/rpg-entry/rpgEntryClients.routing.test.ts b/src/services/rpg-entry/rpgEntryClients.routing.test.ts index f4e640f0..fbbcb3e9 100644 --- a/src/services/rpg-entry/rpgEntryClients.routing.test.ts +++ b/src/services/rpg-entry/rpgEntryClients.routing.test.ts @@ -18,6 +18,12 @@ import { } from './rpgEntryLibraryClient'; vi.mock('../apiClient', () => ({ + BACKGROUND_AUTH_REQUEST_OPTIONS: { + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }, requestJson: requestJsonMock, })); @@ -35,7 +41,11 @@ describe('rpgEntry profile browse history routes', () => { expect.objectContaining({ method: 'GET' }), '读取浏览历史失败', expect.objectContaining({ + authImpact: 'local', retry: expect.objectContaining({ maxRetries: 1 }), + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, }), ); }); @@ -60,10 +70,14 @@ describe('rpgEntry profile browse history routes', () => { }), '写入浏览历史失败', expect.objectContaining({ + authImpact: 'local', retry: expect.objectContaining({ maxRetries: 1, retryUnsafeMethods: true, }), + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, }), ); }); @@ -188,7 +202,11 @@ describe('rpgEntry save archive routes', () => { expect.objectContaining({ method: 'GET' }), '读取存档列表失败', expect.objectContaining({ + authImpact: 'local', retry: expect.objectContaining({ maxRetries: 1 }), + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, }), ); }); diff --git a/src/services/rpg-entry/rpgEntryLibraryClient.test.ts b/src/services/rpg-entry/rpgEntryLibraryClient.test.ts index 98372f32..030d7c96 100644 --- a/src/services/rpg-entry/rpgEntryLibraryClient.test.ts +++ b/src/services/rpg-entry/rpgEntryLibraryClient.test.ts @@ -16,6 +16,12 @@ import { } from './rpgEntryLibraryClient'; vi.mock('../apiClient', () => ({ + BACKGROUND_AUTH_REQUEST_OPTIONS: { + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }, requestJson: requestJsonMock, })); diff --git a/src/services/rpg-entry/rpgEntryLibraryClient.ts b/src/services/rpg-entry/rpgEntryLibraryClient.ts index 20a51596..7a58a116 100644 --- a/src/services/rpg-entry/rpgEntryLibraryClient.ts +++ b/src/services/rpg-entry/rpgEntryLibraryClient.ts @@ -1,4 +1,5 @@ import { + RUNTIME_BACKGROUND_AUTH_OPTIONS, type RuntimeRequestOptions, requestPublicRpgRuntimeJson, requestRpgRuntimeJson, @@ -26,7 +27,10 @@ export async function listRpgEntryWorldLibrary( '/custom-world-library', { method: 'GET' }, '读取自定义世界库失败', - options, + { + ...RUNTIME_BACKGROUND_AUTH_OPTIONS, + ...options, + }, ); return Array.isArray(response?.entries) ? response.entries : []; diff --git a/src/services/rpg-entry/rpgProfileClient.test.ts b/src/services/rpg-entry/rpgProfileClient.test.ts index d36f965a..19f96353 100644 --- a/src/services/rpg-entry/rpgProfileClient.test.ts +++ b/src/services/rpg-entry/rpgProfileClient.test.ts @@ -15,6 +15,12 @@ import { } from './rpgProfileClient'; vi.mock('../apiClient', () => ({ + BACKGROUND_AUTH_REQUEST_OPTIONS: { + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }, requestJson: requestJsonMock, })); @@ -32,7 +38,11 @@ describe('rpgProfileClient browse history routes', () => { expect.objectContaining({ method: 'GET' }), '读取浏览历史失败', expect.objectContaining({ + authImpact: 'local', retry: expect.objectContaining({ maxRetries: 1 }), + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, }), ); }); @@ -57,10 +67,14 @@ describe('rpgProfileClient browse history routes', () => { }), '写入浏览历史失败', expect.objectContaining({ + authImpact: 'local', retry: expect.objectContaining({ maxRetries: 1, retryUnsafeMethods: true, }), + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, }), ); }); @@ -121,7 +135,11 @@ describe('rpgProfileClient save archive routes', () => { expect.objectContaining({ method: 'GET' }), '读取存档列表失败', expect.objectContaining({ + authImpact: 'local', retry: expect.objectContaining({ maxRetries: 1 }), + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, }), ); }); diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 5da1b211..6bb44c52 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -21,6 +21,7 @@ import type { import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { + RUNTIME_BACKGROUND_AUTH_OPTIONS, requestRpgRuntimeJson, type RuntimeRequestOptions, } from '../rpg-runtime/rpgRuntimeRequest'; @@ -199,7 +200,10 @@ export async function listRpgProfileSaveArchives( '/profile/save-archives', { method: 'GET' }, '读取存档列表失败', - options, + { + ...RUNTIME_BACKGROUND_AUTH_OPTIONS, + ...options, + }, ); return Array.isArray(response?.entries) ? response.entries : []; @@ -231,7 +235,10 @@ export async function listRpgProfileBrowseHistory( '/profile/browse-history', { method: 'GET' }, '读取浏览历史失败', - options, + { + ...RUNTIME_BACKGROUND_AUTH_OPTIONS, + ...options, + }, ); return Array.isArray(response?.entries) ? response.entries : []; @@ -249,7 +256,10 @@ export async function upsertRpgProfileBrowseHistory( body: JSON.stringify(entry), }, '写入浏览历史失败', - options, + { + ...RUNTIME_BACKGROUND_AUTH_OPTIONS, + ...options, + }, ); return Array.isArray(response?.entries) ? response.entries : []; diff --git a/src/services/rpg-runtime/rpgRuntimeRequest.ts b/src/services/rpg-runtime/rpgRuntimeRequest.ts index 2c44ee7c..bfe25d3e 100644 --- a/src/services/rpg-runtime/rpgRuntimeRequest.ts +++ b/src/services/rpg-runtime/rpgRuntimeRequest.ts @@ -1,4 +1,9 @@ -import { type ApiRetryOptions, requestJson } from '../apiClient'; +import { + BACKGROUND_AUTH_REQUEST_OPTIONS, + type ApiAuthImpact, + type ApiRetryOptions, + requestJson, +} from '../apiClient'; const RUNTIME_API_BASE = '/api/runtime'; const RUNTIME_READ_RETRY: ApiRetryOptions = { @@ -18,10 +23,14 @@ export type RuntimeRequestOptions = { retry?: ApiRetryOptions; skipAuth?: boolean; skipRefresh?: boolean; + authImpact?: ApiAuthImpact; notifyAuthStateChange?: boolean; clearAuthOnUnauthorized?: boolean; }; +export const RUNTIME_BACKGROUND_AUTH_OPTIONS = + BACKGROUND_AUTH_REQUEST_OPTIONS satisfies RuntimeRequestOptions; + /** * 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。 */ @@ -52,6 +61,7 @@ export function requestRpgRuntimeJson( retry, skipAuth: options.skipAuth, skipRefresh: options.skipRefresh, + authImpact: options.authImpact, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, }, diff --git a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts index 7d8b3fd7..ff32786f 100644 --- a/src/services/square-hole-runtime/squareHoleRuntimeClient.ts +++ b/src/services/square-hole-runtime/squareHoleRuntimeClient.ts @@ -23,7 +23,10 @@ const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = { }; type SquareHoleRuntimeRequestOptions = Pick< ApiRequestOptions, - 'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized' + | 'authImpact' + | 'skipRefresh' + | 'notifyAuthStateChange' + | 'clearAuthOnUnauthorized' >; /** @@ -43,6 +46,7 @@ export function startSquareHoleRun( '启动方洞挑战失败', { retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, diff --git a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts index 4a7c116e..338cc1ab 100644 --- a/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts +++ b/src/services/visual-novel-runtime/visualNovelRuntimeClient.ts @@ -44,7 +44,10 @@ export type VisualNovelRuntimeStreamOptions = TextStreamOptions & { }; type VisualNovelRuntimeRequestOptions = Pick< ApiRequestOptions, - 'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized' + | 'authImpact' + | 'skipRefresh' + | 'notifyAuthStateChange' + | 'clearAuthOnUnauthorized' >; export type VisualNovelSaveArchiveResumeResponse = @@ -111,6 +114,7 @@ export async function startVisualNovelRun( { retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY, timeoutMs: 15000, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized, diff --git a/src/services/visual-novel-works/visualNovelWorksClient.ts b/src/services/visual-novel-works/visualNovelWorksClient.ts index e2d0a181..6e3316d5 100644 --- a/src/services/visual-novel-works/visualNovelWorksClient.ts +++ b/src/services/visual-novel-works/visualNovelWorksClient.ts @@ -23,7 +23,10 @@ const VISUAL_NOVEL_WORKS_WRITE_RETRY: ApiRetryOptions = { }; type VisualNovelWorksRequestOptions = Pick< ApiRequestOptions, - 'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized' + | 'authImpact' + | 'skipRefresh' + | 'notifyAuthStateChange' + | 'clearAuthOnUnauthorized' >; export function listVisualNovelWorks() { @@ -47,6 +50,7 @@ export function getVisualNovelWorkDetail( '读取视觉小说作品详情失败', { retry: VISUAL_NOVEL_WORKS_READ_RETRY, + authImpact: options.authImpact, skipRefresh: options.skipRefresh, notifyAuthStateChange: options.notifyAuthStateChange, clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,