This commit is contained in:
2026-05-09 18:24:08 +08:00
parent a0ed128bde
commit bc704d0c22
38 changed files with 481 additions and 378 deletions

View File

@@ -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": "<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.

View File

@@ -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

View File

@@ -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),
}),

View File

@@ -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/` 和相关技术文档。

View File

@@ -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`

View File

@@ -79,7 +79,7 @@ AI原生游戏框架
## 4. 生成方式
主视觉底图使用仓库内 APIMart OpenAI 兼容 `gpt-image-2` 工作流生成:
主视觉底图使用仓库内 `gpt-image-2` 工作流生成2026-05-09 起同类工作流走 VectorEngine
```text
model: gpt-image-2

View File

@@ -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 气泡方向单独优化补充

View File

@@ -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` 重启验证。

View File

@@ -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. 上传裁剪
- 前端读取上传图原始宽高。

View File

@@ -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 单向流式的后端代理、环境变量、协议帧和验收边界。

View File

@@ -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`

View File

@@ -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`
模板:

View File

@@ -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 只有个位毫秒。
这类错误只表示“图片自动生成服务不可用”,不代表方洞草稿编译失败。前端处理规则:

View File

@@ -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(),
}))

View File

@@ -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())
);
}

View File

@@ -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",

View File

@@ -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<PuzzleApimartSettings, AppError> {
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
fn require_puzzle_vector_engine_settings(
state: &AppState,
) -> Result<PuzzleVectorEngineSettings, AppError> {
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<reqwest::Client, AppError> {
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<PuzzleGeneratedImages, AppError> {
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<PuzzleGeneratedImages, AppError> {
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<Value, AppError> {
serde_json::from_str::<Value>(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<Vec<u8>> {
Some(output)
}
fn extract_puzzle_task_id(payload: &Value) -> Option<String> {
find_first_puzzle_string_by_key(payload, "task_id")
}
fn extract_puzzle_image_urls(payload: &Value) -> Vec<String> {
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,

View File

@@ -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}:上游未返回图片"),
}))
})?;

View File

@@ -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;

View File

@@ -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: '下一关' }));

View File

@@ -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<ProfileDashboardSummary | null>;
getProfileDashboard: (
options?: RuntimeRequestOptions,
) => Promise<ProfileDashboardSummary | null>;
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([]),
]);

View File

@@ -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

View File

@@ -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<ApiMeta>;
};
type ResolvedAuthFailurePolicy = {
skipRefresh: boolean;
notifyAuthStateChange: boolean;
clearAuthOnUnauthorized: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
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();
}
}

View File

@@ -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<string, CachedReadUrlEntry>();
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<T>(
path: string,
init: RequestInit,
@@ -43,6 +54,9 @@ export function requestRpgCreationRuntimeJson<T>(
retry,
skipAuth: options.skipAuth,
skipRefresh: options.skipRefresh,
authImpact: options.authImpact,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
timeoutMs: options.timeoutMs,
},
);

View File

@@ -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<ListRpgCreationWorksResponse>(
'/custom-world/works',
{ method: 'GET' },
'读取创作作品列表失败',
options,
);
return Array.isArray(response?.items) ? response.items : [];

View File

@@ -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,
}),
);
});

View File

@@ -16,6 +16,12 @@ import {
} from './rpgEntryLibraryClient';
vi.mock('../apiClient', () => ({
BACKGROUND_AUTH_REQUEST_OPTIONS: {
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
requestJson: requestJsonMock,
}));

View File

@@ -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 : [];

View File

@@ -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,
}),
);
});

View File

@@ -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 : [];

View File

@@ -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<T>(
retry,
skipAuth: options.skipAuth,
skipRefresh: options.skipRefresh,
authImpact: options.authImpact,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,