1
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: gpt-image-2-apimart
|
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
|
## 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.
|
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
|
## Request Contract
|
||||||
|
|
||||||
The repository image path uses:
|
The repository image path uses:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST {APIMART_BASE_URL}/images/generations
|
POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations
|
||||||
Authorization: Bearer {APIMART_API_KEY}
|
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,11 +40,10 @@ Default body:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "gpt-image-2",
|
"model": "gpt-image-2-all",
|
||||||
"prompt": "<prompt>",
|
"prompt": "<prompt>",
|
||||||
"n": 1,
|
"n": 1,
|
||||||
"official_fallback": true,
|
"size": "1024x1024"
|
||||||
"size": "1:1"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,17 +51,11 @@ For a reference image, add:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"image_urls": ["data:image/png;base64,..."]
|
"image": ["data:image/png;base64,..."]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Poll async responses with:
|
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.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -70,12 +63,12 @@ Load environment values from process env first, then `.env.secrets.local`, `.env
|
|||||||
|
|
||||||
Required for live generation:
|
Required for live generation:
|
||||||
|
|
||||||
- `APIMART_BASE_URL`
|
- `VECTOR_ENGINE_BASE_URL`
|
||||||
- `APIMART_API_KEY`
|
- `VECTOR_ENGINE_API_KEY`
|
||||||
|
|
||||||
Optional:
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "GPT Image 2 APIMart"
|
display_name: "GPT Image 2 VectorEngine"
|
||||||
short_description: "Generate project thumbnails through APIMart"
|
short_description: "Generate project thumbnails through VectorEngine"
|
||||||
brand_color: "#10B981"
|
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:
|
policy:
|
||||||
allow_implicit_invocation: true
|
allow_implicit_invocation: true
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const promptsPath = path.join(
|
|||||||
);
|
);
|
||||||
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
||||||
const defaultTimeoutMs = 180000;
|
const defaultTimeoutMs = 180000;
|
||||||
const pollDelayMs = 3000;
|
|
||||||
|
|
||||||
const args = new Map();
|
const args = new Map();
|
||||||
for (let index = 2; index < process.argv.length; index += 1) {
|
for (let index = 2; index < process.argv.length; index += 1) {
|
||||||
@@ -66,15 +65,23 @@ function resolveEnv() {
|
|||||||
...process.env,
|
...process.env,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
baseUrl: String(loaded.APIMART_BASE_URL || '').trim().replace(/\/+$/u, ''),
|
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||||
apiKey: String(loaded.APIMART_API_KEY || '').trim(),
|
.trim()
|
||||||
|
.replace(/\/+$/u, ''),
|
||||||
|
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||||
timeoutMs: Number.parseInt(
|
timeoutMs: Number.parseInt(
|
||||||
String(loaded.APIMART_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||||
10,
|
10,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildVectorEngineImagesGenerationUrl(baseUrl) {
|
||||||
|
return baseUrl.endsWith('/v1')
|
||||||
|
? `${baseUrl}/images/generations`
|
||||||
|
: `${baseUrl}/v1/images/generations`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPrompt(template) {
|
function buildPrompt(template) {
|
||||||
return [
|
return [
|
||||||
'请生成一张高清 1:1 方形插画,用作拼图创作模板样例图。',
|
'请生成一张高清 1:1 方形插画,用作拼图创作模板样例图。',
|
||||||
@@ -124,14 +131,6 @@ function extractBase64Images(payload) {
|
|||||||
return values;
|
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) {
|
function inferExtensionFromContentType(contentType) {
|
||||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||||
if (normalized === 'image/png') {
|
if (normalized === 'image/png') {
|
||||||
@@ -172,7 +171,7 @@ async function fetchJson(url, options, timeoutMs) {
|
|||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
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);
|
return JSON.parse(text);
|
||||||
} finally {
|
} 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) {
|
async function generateOne(env, template, outDir) {
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
model: 'gpt-image-2',
|
model: 'gpt-image-2-all',
|
||||||
prompt: buildPrompt(template),
|
prompt: buildPrompt(template),
|
||||||
n: 1,
|
n: 1,
|
||||||
official_fallback: true,
|
size: '1024x1024',
|
||||||
size: '1:1',
|
|
||||||
};
|
};
|
||||||
const payload = await fetchJson(
|
const payload = await fetchJson(
|
||||||
`${env.baseUrl}/images/generations`,
|
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${env.apiKey}`,
|
Authorization: `Bearer ${env.apiKey}`,
|
||||||
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
@@ -253,12 +220,8 @@ async function generateOne(env, template, outDir) {
|
|||||||
env.timeoutMs,
|
env.timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolvedPayload =
|
const urls = extractImageUrls(payload);
|
||||||
extractImageUrls(payload).length || extractBase64Images(payload).length
|
const b64Images = extractBase64Images(payload);
|
||||||
? payload
|
|
||||||
: await waitForTask(env, extractTaskId(payload));
|
|
||||||
const urls = extractImageUrls(resolvedPayload);
|
|
||||||
const b64Images = extractBase64Images(resolvedPayload);
|
|
||||||
|
|
||||||
let image;
|
let image;
|
||||||
if (urls[0]) {
|
if (urls[0]) {
|
||||||
@@ -270,7 +233,7 @@ async function generateOne(env, template, outDir) {
|
|||||||
extension: inferExtensionFromBytes(bytes),
|
extension: inferExtensionFromBytes(bytes),
|
||||||
};
|
};
|
||||||
} else {
|
} 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 });
|
mkdirSync(outDir, { recursive: true });
|
||||||
@@ -302,11 +265,10 @@ if (dryRun) {
|
|||||||
id: template.id,
|
id: template.id,
|
||||||
title: template.title,
|
title: template.title,
|
||||||
body: {
|
body: {
|
||||||
model: 'gpt-image-2',
|
model: 'gpt-image-2-all',
|
||||||
prompt: buildPrompt(template),
|
prompt: buildPrompt(template),
|
||||||
n: 1,
|
n: 1,
|
||||||
official_fallback: true,
|
size: '1024x1024',
|
||||||
size: '1:1',
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -322,7 +284,7 @@ if (!env.baseUrl || !env.apiKey) {
|
|||||||
console.error(
|
console.error(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ok: false,
|
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),
|
hasBaseUrl: Boolean(env.baseUrl),
|
||||||
hasApiKey: Boolean(env.apiKey),
|
hasApiKey: Boolean(env.apiKey),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
|
|
||||||
## 2026-05-08 APIMart 接口统一携带 `official_fallback`
|
## 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 的图片生成和 Responses 接口在仓库内分散于 `api-server`、`platform-llm` 和本地 skill 脚本,若只修单点,容易出现不同入口的上游请求体不一致。
|
||||||
- 决策:凡是仓库内调用 APIMart 的 OpenAI 兼容接口,请求体统一携带 `official_fallback: true`;其中图片生成请求直接固定写入,`platform-llm` 的 APIMart GPT-5 client 通过显式开关开启,不默认扩散到 Ark 等其它 provider。
|
- 决策:凡是仓库内调用 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/` 和相关技术文档。
|
- 影响范围:`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/` 和相关技术文档。
|
||||||
|
|||||||
@@ -99,13 +99,13 @@
|
|||||||
- 验证:`PlatformFeedbackView.test.tsx` 用 mock `FileReader` 断言选择图片后出现 `反馈凭证预览`,且提交 payload 带 `evidenceItems[].dataUrl`。
|
- 验证:`PlatformFeedbackView.test.tsx` 用 mock `FileReader` 断言选择图片后出现 `反馈凭证预览`,且提交 payload 带 `evidenceItems[].dataUrl`。
|
||||||
- 关联:`src/components/platform-entry/PlatformFeedbackView.tsx`、`docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`。
|
- 关联:`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 图片生成密钥未配置`。
|
- 现象:拼图新手引导或拼图创作点击生成后返回 `VectorEngine 图片生成密钥未配置`。
|
||||||
- 原因:拼图 `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` 兜底。
|
- 原因:拼图 `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` 或进程环境中配置真实 `APIMART_API_KEY`,不要提交到 Git;填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。
|
- 处理:在本机私密配置 `.env.secrets.local` 或进程环境中配置真实 `VECTOR_ENGINE_API_KEY`,不要提交到 Git;填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。
|
||||||
- 验证:不打印密钥内容,只检查 `APIMART_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。
|
- 验证:不打印密钥内容,只检查 `VECTOR_ENGINE_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。
|
||||||
- 关联:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md`、`.codex/skills/gpt-image-2-apimart/SKILL.md`。
|
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`.codex/skills/gpt-image-2-apimart/SKILL.md`。
|
||||||
|
|
||||||
## 拼图图片生成 98% 后报 OSS V4 签名时间格式化失败
|
## 拼图图片生成 98% 后报 OSS V4 签名时间格式化失败
|
||||||
|
|
||||||
@@ -154,10 +154,11 @@
|
|||||||
## 登录后推荐页加载出作品又回到未登录
|
## 登录后推荐页加载出作品又回到未登录
|
||||||
|
|
||||||
- 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。
|
- 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。
|
||||||
- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。
|
- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。更隐蔽的是,`refreshAccessToken()` 自身曾在 refresh 失败时静默清 token,即便调用方关闭了 `clearAuthOnUnauthorized`,也可能让后续 hydrate 变成未登录。
|
||||||
- 处理:推荐页自动运行态请求传 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的受保护动作仍保留默认鉴权失败处理。
|
- 处理:请求层统一使用 `authImpact: 'global' | 'local'` 区分账号权威请求与局部后台请求;推荐页自动运行态、图片换签、公开拼图运行态和平台 bootstrap 私有投影刷新统一使用 `BACKGROUND_AUTH_REQUEST_OPTIONS` / `RUNTIME_BACKGROUND_AUTH_OPTIONS`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的账号动作仍保留默认全局鉴权失败处理。
|
||||||
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
||||||
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
|
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`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"`。
|
- 验证:`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`。
|
- 关联:`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`。
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ AI原生游戏框架:
|
|||||||
|
|
||||||
## 4. 生成方式
|
## 4. 生成方式
|
||||||
|
|
||||||
主视觉底图使用仓库内 APIMart OpenAI 兼容 `gpt-image-2` 工作流生成:
|
主视觉底图使用仓库内 `gpt-image-2` 工作流生成;2026-05-09 起同类工作流走 VectorEngine:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
model: gpt-image-2
|
model: gpt-image-2
|
||||||
|
|||||||
@@ -247,11 +247,11 @@ python "C:\Users\wuxiangwanzi\.codex\skills\.system\imagegen\scripts\image_gen.p
|
|||||||
--size 1024x1024
|
--size 1024x1024
|
||||||
```
|
```
|
||||||
|
|
||||||
若继续使用仓库现有 APIMart 路由,则需要配置:
|
若继续使用仓库现有 VectorEngine GPT-image-2 路由,则需要配置:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
APIMART_BASE_URL=https://api.apimart.ai/v1
|
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai
|
||||||
APIMART_API_KEY=...
|
VECTOR_ENGINE_API_KEY=...
|
||||||
```
|
```
|
||||||
|
|
||||||
## 13. 04/07 气泡方向单独优化补充
|
## 13. 04/07 气泡方向单独优化补充
|
||||||
|
|||||||
@@ -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 表结构、拼图草稿结构或前端运行时规则。
|
拼图创作已收口为填表式流程,首图生成和结果页关卡重新生成都由 `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`
|
1. `https://vectorengine.apifox.cn/api-448710071`
|
||||||
2. `https://docs.apimart.ai/cn/api-reference/images/gemini-3.1-flash/generation`
|
|
||||||
|
|
||||||
两条文档均指向 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` |
|
| `gpt-image-2` | `gpt-image-2` | VectorEngine `/v1/images/generations`,上游模型 `gpt-image-2-all` |
|
||||||
| `nanobanana2` | `gemini-3.1-flash-image-preview` | APIMart `/v1/images/generations` |
|
| `nanobanana2` | `gemini-3.1-flash-image-preview` | 历史兼容选项,后端回落到 VectorEngine `gpt-image-2-all` |
|
||||||
|
|
||||||
默认值为 `gpt-image-2`。前端只负责展示和传递所选模型,不能把模型路由逻辑、上游请求体拼装或 API Key 暴露到浏览器。历史草稿或旧请求中的空值、`original`、未知值统一按 `gpt-image-2` 处理,不再把拼图生图路由回 DashScope 原模型。
|
默认值为 `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` 增加图片模型参数。
|
2. `compile_puzzle_draft_with_initial_cover` 与 `generate_puzzle_image_candidates` 增加图片模型参数。
|
||||||
3. `imageModel` 归一化规则:
|
3. `imageModel` 归一化规则:
|
||||||
- 空值、`original` 或未知值统一回落为 `gpt-image-2`;
|
- 空值、`original` 或未知值统一回落为 `gpt-image-2`;
|
||||||
- `gpt-image-2` 走 APIMart;
|
- `gpt-image-2` 走 VectorEngine;
|
||||||
- `gemini-3.1-flash-image-preview` 走 APIMart,前端显示名为 `nanobanana2`。
|
- `gemini-3.1-flash-image-preview` 不再走 APIMart,前端显示名仍为 `nanobanana2`,后端统一回落到 VectorEngine `gpt-image-2-all`。
|
||||||
4. APIMart 文生图和图生图共用 `POST /v1/images/generations`。有参考图时,后端将参考图 Data URL 作为 `image_urls` 数组传入;若上游不接受该字段,错误按上游失败返回,不在前端降级伪造结果。
|
4. VectorEngine 文生图和图生图共用 `POST /v1/images/generations`。有参考图时,后端将参考图 Data URL 作为 `image` 数组传入;若上游不接受该字段,错误按上游失败返回,不在前端降级伪造结果。
|
||||||
5. APIMart 尺寸使用文档要求的比例写法 `1:1`,所有 APIMart 图片请求体固定携带 `official_fallback = true`。`gemini-3.1-flash-image-preview` 额外带 `resolution = "1K"`,对齐约 1024px 的拼图正方形素材。
|
5. VectorEngine 拼图尺寸使用 `1024x1024`,请求体不携带 `official_fallback`。
|
||||||
6. APIMart 生成成功后仍下载远程图片,沿用现有 OSS 私有对象、`asset_object` 和 `asset_entity_binding` 写入流程。若图片已成功上传 OSS,但 Maincloud / SpacetimeDB 短暂返回 `503 Service Unavailable`,资产索引写入允许降级跳过,并返回本次生成图片;日志必须记录 `拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过`。
|
6. VectorEngine 生成成功后仍下载远程图片,沿用现有 OSS 私有对象、`asset_object` 和 `asset_entity_binding` 写入流程。若图片已成功上传 OSS,但 SpacetimeDB 短暂返回 `503 Service Unavailable`,资产索引写入允许降级跳过,并返回本次生成图片;日志必须记录 `拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过`。
|
||||||
7. `save_puzzle_generated_images` 写回草稿时若遇到 Maincloud 连接级 `503` 或断线,API 层基于本次生成结果合成 session 快照返回给前端,避免 APIMart 已成功出图却被后置持久化误报成服务不可用。余额不足、参数错误、上游生图失败仍按原错误返回,不做伪成功。
|
7. `save_puzzle_generated_images` 写回草稿时若遇到 SpacetimeDB 连接级 `503` 或断线,API 层基于本次生成结果合成 session 快照返回给前端,避免 VectorEngine 已成功出图却被后置持久化误报成服务不可用。余额不足、参数错误、上游生图失败仍按原错误返回,不做伪成功。
|
||||||
8. 结果页 `generate_puzzle_images` 会携带当前作品信息和 `levelsJson`。当 Maincloud / SpacetimeDB 在读取 session 阶段就返回连接级 `503` 或断线时,后端必须先用这份结果页快照构造最小内存 session,再继续调用 APIMart;外部图片已经生成后仍按第 6、7 条处理持久化降级。余额不足、参数错误、缺少草稿快照、关卡不存在等业务错误不走此降级。
|
8. 结果页 `generate_puzzle_images` 会携带当前作品信息和 `levelsJson`。当 SpacetimeDB 在读取 session 阶段就返回连接级 `503` 或断线时,后端必须先用这份结果页快照构造最小内存 session,再继续调用 VectorEngine;外部图片已经生成后仍按第 6、7 条处理持久化降级。余额不足、参数错误、缺少草稿快照、关卡不存在等业务错误不走此降级。
|
||||||
9. APIMart 异步任务轮询按文档口径在提交后先等待 `10s`,再调用 `GET /v1/tasks/{task_id}`;图片地址提取同时支持 `url: "..."` 与 `url: ["..."]` 两种结构。
|
9. VectorEngine 错误统一映射为 `502 UPSTREAM_ERROR`,`details.provider = "vector-engine"`,保留上游状态码、业务 message 和截断后的 raw excerpt。
|
||||||
10. APIMart 错误统一映射为 `502 UPSTREAM_ERROR`,`details.provider = "apimart"`,保留上游状态码、业务 message 和截断后的 raw excerpt。
|
10. 拼图首图生成 `compile_puzzle_draft` 与关卡图片生成 `generate_puzzle_images` 每次预扣 `2` 光点;余额不足仍返回 `409 CONFLICT`,SpacetimeDB 连接级 503 仍按既有降级策略处理。
|
||||||
11. 拼图首图生成 `compile_puzzle_draft` 与关卡图片生成 `generate_puzzle_images` 每次预扣 `2` 光点;余额不足仍返回 `409 CONFLICT`,Maincloud 连接级 503 仍按既有降级策略处理。
|
|
||||||
|
|
||||||
## 关卡名多模态生成
|
## 关卡名多模态生成
|
||||||
|
|
||||||
@@ -64,21 +64,21 @@
|
|||||||
新增服务端环境变量:
|
新增服务端环境变量:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
APIMART_BASE_URL="https://api.apimart.ai/v1"
|
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.ai"
|
||||||
APIMART_API_KEY="YOUR_APIMART_API_KEY"
|
VECTOR_ENGINE_API_KEY="YOUR_VECTOR_ENGINE_API_KEY"
|
||||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
|
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`。
|
1. 创作表单和关卡详情的画面描述框左下角能切换 `gpt-image-2`、`nanobanana2`,默认显示 `gpt-image-2`。
|
||||||
2. 点击“生成草稿”时,后端首图生成使用当前表单选择的模型。
|
2. 点击“生成草稿”时,后端首图生成使用当前表单选择的模型。
|
||||||
3. 点击“生成画面 / 重新生成画面”时,后端当前关卡图片生成使用关卡详情选择的模型。
|
3. 点击“生成画面 / 重新生成画面”时,后端当前关卡图片生成使用关卡详情选择的模型。
|
||||||
4. 历史 `original` 或空模型值不会再触发 DashScope,统一按 `gpt-image-2` 请求 APIMart。
|
4. 历史 `original` 或空模型值不会再触发 DashScope,统一按 `gpt-image-2` 请求 VectorEngine。
|
||||||
5. 选择 APIMart 模型时,请求 `POST {APIMART_BASE_URL}/images/generations`,使用 `Authorization: Bearer {APIMART_API_KEY}`,`model` 等于请求值,`official_fallback = true`,`size = 1:1`。
|
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 中应先完成图片生成,再调用 APIMart `POST {APIMART_BASE_URL}/chat/completions`,请求模型为 `gpt-4o-mini`,消息同时包含画面描述文本和正式图 `image_url` Data URL。
|
6. 首图和结果页关卡重新生图成功后,Network 中应先完成 VectorEngine 图片生成,再调用 APIMart `POST {APIMART_BASE_URL}/chat/completions`,请求模型为 `gpt-4o-mini`,消息同时包含画面描述文本和正式图 `image_url` Data URL。
|
||||||
7. “生成草稿”和关卡详情生图按钮展示 `消耗2光点`;关卡详情确认后展示 30 秒预计剩余进度条。
|
7. “生成草稿”和关卡详情生图按钮展示 `消耗2光点`;关卡详情确认后展示 30 秒预计剩余进度条。
|
||||||
8. 不改 SpacetimeDB 表结构,因此无需更新 `migration.rs` 或重新生成 bindings。
|
8. 不改 SpacetimeDB 表结构,因此无需更新 `migration.rs` 或重新生成 bindings。
|
||||||
9. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server` 重启验证。
|
9. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server` 重启验证。
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
- 每个玩法一个参考图,首版用于视觉识别,不承载规则说明。
|
- 每个玩法一个参考图,首版用于视觉识别,不承载规则说明。
|
||||||
- 当前创作 Tab 顶部玩法卡带必须直接渲染这些图片,避免参考图只出现在隐藏弹层里。
|
- 当前创作 Tab 顶部玩法卡带必须直接渲染这些图片,避免参考图只出现在隐藏弹层里。
|
||||||
5. `.codex/skills/gpt-image-2-apimart/`
|
5. `.codex/skills/gpt-image-2-apimart/`
|
||||||
- 封装仓库内 `gpt-image-2` 的 APIMart OpenAI 兼容调用流程。
|
- 历史目录名保留,实际封装仓库内 `gpt-image-2` 的 VectorEngine 调用流程。
|
||||||
- Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。
|
- Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。
|
||||||
|
|
||||||
## UI 规则
|
## UI 规则
|
||||||
@@ -74,19 +74,17 @@
|
|||||||
Skill 封装仓库现有后端口径:
|
Skill 封装仓库现有后端口径:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST {APIMART_BASE_URL}/images/generations
|
POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations
|
||||||
Authorization: Bearer {APIMART_API_KEY}
|
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
|
||||||
model = gpt-image-2
|
model = gpt-image-2-all
|
||||||
n = 1
|
n = 1
|
||||||
official_fallback = true
|
size = 1024x1024
|
||||||
size = 1:1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
响应兼容:
|
响应兼容:
|
||||||
|
|
||||||
1. `data[].url`
|
1. `data[].url`
|
||||||
2. `data[].b64_json`
|
2. `data[].b64_json`
|
||||||
3. `task_id` 后续 `GET /tasks/{task_id}`
|
|
||||||
|
|
||||||
本次 Skill 只封装生成样例图和研发复用流程,不改变正式后端接口、扣费、OSS、SpacetimeDB 写入和发布链路。
|
本次 Skill 只封装生成样例图和研发复用流程,不改变正式后端接口、扣费、OSS、SpacetimeDB 写入和发布链路。
|
||||||
|
|
||||||
@@ -99,12 +97,12 @@ size = 1:1
|
|||||||
- 未上传图片时,输入框标题为 `画面描述`。
|
- 未上传图片时,输入框标题为 `画面描述`。
|
||||||
- 已上传图片时,输入框标题为 `画面AI重绘要求(提示词)`。
|
- 已上传图片时,输入框标题为 `画面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光点`。
|
||||||
2. `AI重绘=false`
|
2. `AI重绘=false`
|
||||||
- 隐藏画面描述输入框和模型切换。
|
- 隐藏画面描述输入框和模型切换。
|
||||||
- 必须上传拼图图片,按钮不展示 `消耗2光点`。
|
- 必须上传拼图图片,按钮不展示 `消耗2光点`。
|
||||||
- `compile_puzzle_draft` 携带 `aiRedraw: false`,后端只编译草稿和生成首关名,不调用 APIMart,不进入光点扣费 wrapper。
|
- `compile_puzzle_draft` 携带 `aiRedraw: false`,后端只编译草稿和生成首关名,不调用 VectorEngine,不进入光点扣费 wrapper。
|
||||||
- 后端把上传图片 Data URL 按拼图资产路径持久化,构造 `sourceType=uploaded` 的候选图并直接选为第一关正式图。
|
- 后端把上传图片 Data URL 按拼图资产路径持久化,构造 `sourceType=uploaded` 的候选图并直接选为第一关正式图。
|
||||||
3. 上传裁剪
|
3. 上传裁剪
|
||||||
- 前端读取上传图原始宽高。
|
- 前端读取上传图原始宽高。
|
||||||
|
|||||||
@@ -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 差异。
|
- [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` 的接口、环境变量、尺寸映射、错误口径和验收命令。
|
- [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` 自动降级策略和手动排障命令。
|
- [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 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。
|
- [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 模型、状态查询和下载列表的后端代理、环境变量、请求约束与验收边界。
|
- [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 单向流式的后端代理、环境变量、协议帧和验收边界。
|
- [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
|
|
||||||
这些请求一旦遇到本地代理错配、后端短暂不可用或 token 刷新失败,原请求层会按普通受保护请求处理 `401`,清空 access token 并广播全局鉴权变更。`AuthGate` 收到事件后重新 hydrate,于是当前用户界面被切回未登录态。
|
这些请求一旦遇到本地代理错配、后端短暂不可用或 token 刷新失败,原请求层会按普通受保护请求处理 `401`,清空 access token 并广播全局鉴权变更。`AuthGate` 收到事件后重新 hydrate,于是当前用户界面被切回未登录态。
|
||||||
|
|
||||||
|
再次复测确认还有更深一层根因:即使单个业务请求显式传了 `clearAuthOnUnauthorized: false`,`refreshAccessToken()` 自身在 refresh 失败时也会先静默清空本地 access token。这样局部请求可能没有广播事件,却已经把本地凭证掏空;后续任意一次默认鉴权探测或 `AuthGate` hydrate 都会变成未登录。
|
||||||
|
|
||||||
|
推荐页进入公开拼图作品后还会伴随平台侧私有投影刷新,例如存档列表、浏览历史、个人看板和作品架列表。这些请求用于页面展示与局部缓存同步,不是账号会话权威;其中任意一个 401 都不应把整站登录态改写为未登录。
|
||||||
|
|
||||||
推荐页里还有一类更隐蔽的触发点:`ResolvedAssetImage` / `useResolvedAssetReadUrl` 在挂载时会请求 `/api/assets/read-url` 给 generated 私有图片换签。它本质上也是展示层后台请求,若按普通受保护请求处理 `401`,同样会把一次图片换签失败放大成全局掉线。
|
推荐页里还有一类更隐蔽的触发点:`ResolvedAssetImage` / `useResolvedAssetReadUrl` 在挂载时会请求 `/api/assets/read-url` 给 generated 私有图片换签。它本质上也是展示层后台请求,若按普通受保护请求处理 `401`,同样会把一次图片换签失败放大成全局掉线。
|
||||||
|
|
||||||
公开拼图作品的完整运行态还会在用户进入作品后自动发起 `startPuzzleRun`,通关后自动 `submitPuzzleLeaderboard`,点击下一关时 `advancePuzzleNextLevel`。这些请求属于当前玩法的运行态同步,失败时应该落到当前拼图错误态;它们不能清空全局 access token,也不能触发 `AuthGate` 重新 hydrate。
|
公开拼图作品的完整运行态还会在用户进入作品后自动发起 `startPuzzleRun`,通关后自动 `submitPuzzleLeaderboard`,点击下一关时 `advancePuzzleNextLevel`。这些请求属于当前玩法的运行态同步,失败时应该落到当前拼图错误态;它们不能清空全局 access token,也不能触发 `AuthGate` 重新 hydrate。
|
||||||
@@ -26,12 +30,14 @@
|
|||||||
|
|
||||||
本次把推荐页自动运行态请求定义为“卡片级后台请求”:
|
本次把推荐页自动运行态请求定义为“卡片级后台请求”:
|
||||||
|
|
||||||
1. `apiClient` 增加 `clearAuthOnUnauthorized` 选项,允许局部请求在 `401` 时不清空全局 token。
|
1. `apiClient` 增加 `authImpact: 'global' | 'local'` 策略,并导出 `BACKGROUND_AUTH_REQUEST_OPTIONS`。`local` 请求统一跳过 refresh,不清空 token,不广播 `AUTH_STATE_EVENT`。
|
||||||
2. 推荐页嵌入式运行态请求统一传入 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`。
|
2. `refreshAccessToken()` 不再自行清空 token;只有 `refreshStoredAccessToken()` 这类全局会话恢复入口和默认全局请求策略能决定清 token。
|
||||||
|
3. 推荐页嵌入式运行态请求统一使用 `BACKGROUND_AUTH_REQUEST_OPTIONS`。
|
||||||
3. 推荐页自动启动作品前必须满足 `canReadProtectedData`,避免 `AuthGate` 仍在恢复阶段就提前发起受保护写请求。
|
3. 推荐页自动启动作品前必须满足 `canReadProtectedData`,避免 `AuthGate` 仍在恢复阶段就提前发起受保护写请求。
|
||||||
4. generated 图片换签请求同样使用局部后台鉴权选项并跳过 refresh,失败只让当前图片为空,不触发全局登录态清理。
|
4. generated 图片换签请求同样使用局部后台鉴权选项并跳过 refresh,失败只让当前图片为空,不触发全局登录态清理。
|
||||||
5. 公开拼图作品进入完整运行态后,把本次 run 标记为 `isolated` 鉴权模式;开局、重开、排行榜提交和下一关推进都沿用局部鉴权选项。
|
5. 公开拼图作品进入完整运行态后,把本次 run 标记为 `isolated` 鉴权模式;开局、重开、排行榜提交和下一关推进都沿用局部鉴权选项。
|
||||||
6. Remix、发布、点赞、账号设置、退出登录等真正账号动作继续保留默认全局鉴权处理。
|
6. 平台 bootstrap 的私有投影读写,包括个人看板、私有作品架、创作作品列表、浏览历史写入和存档列表刷新,也统一作为局部后台请求处理。
|
||||||
|
7. Remix、发布、点赞、账号设置、退出登录等真正账号动作继续保留默认全局鉴权处理。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
@@ -45,6 +51,9 @@
|
|||||||
## 关联文件
|
## 关联文件
|
||||||
|
|
||||||
1. `src/services/apiClient.ts`
|
1. `src/services/apiClient.ts`
|
||||||
2. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
2. `src/services/rpg-runtime/rpgRuntimeRequest.ts`
|
||||||
3. `src/services/*-runtime/*RuntimeClient.ts`
|
3. `src/services/rpg-creation/rpgCreationRuntimeClient.ts`
|
||||||
4. `src/services/visual-novel-works/visualNovelWorksClient.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`
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ type GenerateCustomWorldOpeningCgResponse = {
|
|||||||
|
|
||||||
### 5.1 故事板
|
### 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`。
|
||||||
|
|
||||||
模板:
|
模板:
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
status=503 method=POST uri=/api/creation/square-hole/sessions/{sessionId}/actions
|
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 只有个位毫秒。
|
||||||
|
|
||||||
这类错误只表示“图片自动生成服务不可用”,不代表方洞草稿编译失败。前端处理规则:
|
这类错误只表示“图片自动生成服务不可用”,不代表方洞草稿编译失败。前端处理规则:
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ pub async fn generate_character_visual(
|
|||||||
"sourceMode": payload.source_mode,
|
"sourceMode": payload.source_mode,
|
||||||
"size": size,
|
"size": size,
|
||||||
"referenceImageCount": payload.reference_image_data_urls.len(),
|
"referenceImageCount": payload.reference_image_data_urls.len(),
|
||||||
"provider": "apimart",
|
"provider": "vector-engine",
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
@@ -193,7 +193,7 @@ pub async fn generate_character_visual(
|
|||||||
),
|
),
|
||||||
structured_payload_json: Some(
|
structured_payload_json: Some(
|
||||||
json!({
|
json!({
|
||||||
"provider": "apimart",
|
"provider": "vector-engine",
|
||||||
"taskId": generated.task_id,
|
"taskId": generated.task_id,
|
||||||
"model": model,
|
"model": model,
|
||||||
"imageCount": generated.images.len(),
|
"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 {
|
fn map_image_request_error(message: String) -> AppError {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": "vector-engine",
|
||||||
"message": message,
|
"message": message,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1184,7 @@ fn map_image_upstream_error(raw_text: &str, fallback_message: &str) -> AppError
|
|||||||
value => value.to_string(),
|
value => value.to_string(),
|
||||||
};
|
};
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": "vector-engine",
|
||||||
"message": message,
|
"message": message,
|
||||||
"raw": raw_text.trim(),
|
"raw": raw_text.trim(),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ pub async fn generate_custom_world_scene_image(
|
|||||||
.map(downloaded_openai_to_custom_world_image)
|
.map(downloaded_openai_to_custom_world_image)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": "vector-engine",
|
||||||
"message": "场景图片生成成功但未返回图片。",
|
"message": "场景图片生成成功但未返回图片。",
|
||||||
}))
|
}))
|
||||||
})?;
|
})?;
|
||||||
@@ -687,7 +687,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
|
|||||||
.map(downloaded_openai_to_custom_world_image)
|
.map(downloaded_openai_to_custom_world_image)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": "vector-engine",
|
||||||
"message": "场景图片生成成功但未返回图片。",
|
"message": "场景图片生成成功但未返回图片。",
|
||||||
}))
|
}))
|
||||||
})?;
|
})?;
|
||||||
@@ -1200,7 +1200,7 @@ async fn generate_opening_cg_storyboard(
|
|||||||
.map(downloaded_openai_to_custom_world_image)
|
.map(downloaded_openai_to_custom_world_image)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": "vector-engine",
|
||||||
"message": "开局 CG 故事板生成成功但未返回图片。",
|
"message": "开局 CG 故事板生成成功但未返回图片。",
|
||||||
}))
|
}))
|
||||||
})?;
|
})?;
|
||||||
@@ -3274,9 +3274,10 @@ mod tests {
|
|||||||
serde_json::from_slice(&body).expect("body should be valid json")
|
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();
|
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")
|
AppState::new(config).expect("state should build")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3287,8 +3288,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scene_image_returns_service_unavailable_when_apimart_missing() {
|
async fn scene_image_returns_service_unavailable_when_vector_engine_missing() {
|
||||||
let state = build_state_without_apimart_key();
|
let state = build_state_without_vector_engine_key();
|
||||||
let request_context = build_request_context("POST /api/runtime/custom-world/scene-image");
|
let request_context = build_request_context("POST /api/runtime/custom-world/scene-image");
|
||||||
let authenticated = build_authenticated(&state);
|
let authenticated = build_authenticated(&state);
|
||||||
|
|
||||||
@@ -3311,7 +3312,7 @@ mod tests {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("missing apimart should fail");
|
.expect_err("missing vector engine should fail");
|
||||||
|
|
||||||
let payload = read_error_response(response).await;
|
let payload = read_error_response(response).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -3320,7 +3321,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["error"]["details"]["provider"],
|
payload["error"]["details"]["provider"],
|
||||||
Value::String("apimart".to_string())
|
Value::String("vector-engine".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,10 +149,12 @@ pub(crate) async fn create_openai_image_generation(
|
|||||||
return Ok(generated);
|
return Ok(generated);
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
Err(
|
||||||
"provider": VECTOR_ENGINE_PROVIDER,
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"message": format!("{failure_context}:VectorEngine 未返回图片地址"),
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
})))
|
"message": format!("{failure_context}:VectorEngine 未返回图片地址"),
|
||||||
|
})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_openai_image_request_body(
|
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 {
|
fn normalize_image_size(size: &str) -> String {
|
||||||
match size.trim() {
|
match size.trim() {
|
||||||
"1024*1024" | "1024x1024" | "1:1" => "1024x1024",
|
"1024*1024" | "1024x1024" | "1:1" => "1024x1024",
|
||||||
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9"
|
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152"
|
||||||
| "1536x1024" | "2048x1152" | "2k" => "1536x1024",
|
| "2k" => "1536x1024",
|
||||||
"1024*1536" | "1024x1536" | "9:16" => "1024x1536",
|
"1024*1536" | "1024x1536" | "9:16" => "1024x1536",
|
||||||
value if !value.is_empty() => value,
|
value if !value.is_empty() => value,
|
||||||
_ => "1024x1024",
|
_ => "1024x1024",
|
||||||
|
|||||||
@@ -3877,9 +3877,6 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
|||||||
let provider = if message.contains("VectorEngine")
|
let provider = if message.contains("VectorEngine")
|
||||||
|| message.contains("vector-engine")
|
|| message.contains("vector-engine")
|
||||||
|| message.contains("VECTOR_ENGINE")
|
|| message.contains("VECTOR_ENGINE")
|
||||||
|| message.contains("APIMart")
|
|
||||||
|| message.contains("apimart")
|
|
||||||
|| message.contains("APIMART")
|
|
||||||
{
|
{
|
||||||
VECTOR_ENGINE_PROVIDER
|
VECTOR_ENGINE_PROVIDER
|
||||||
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
|
} 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
|
let status = if provider == VECTOR_ENGINE_PROVIDER
|
||||||
&& (message.contains("VECTOR_ENGINE_API_KEY")
|
&& (message.contains("VECTOR_ENGINE_API_KEY")
|
||||||
|| message.contains("VECTOR_ENGINE_BASE_URL")
|
|| message.contains("VECTOR_ENGINE_BASE_URL")
|
||||||
|| message.contains("APIMART_API_KEY")
|
|
||||||
|| message.contains("APIMART_BASE_URL")
|
|
||||||
|| message.contains("未配置"))
|
|| message.contains("未配置"))
|
||||||
{
|
{
|
||||||
StatusCode::SERVICE_UNAVAILABLE
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
@@ -3907,9 +3902,6 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
|||||||
|| message.contains("VectorEngine")
|
|| message.contains("VectorEngine")
|
||||||
|| message.contains("vector-engine")
|
|| message.contains("vector-engine")
|
||||||
|| message.contains("VECTOR_ENGINE")
|
|| message.contains("VECTOR_ENGINE")
|
||||||
|| message.contains("APIMart")
|
|
||||||
|| message.contains("apimart")
|
|
||||||
|| message.contains("APIMART")
|
|
||||||
|| message.contains("参考图")
|
|| message.contains("参考图")
|
||||||
|| message.contains("图片")
|
|| message.contains("图片")
|
||||||
|| message.contains("OSS")
|
|| message.contains("OSS")
|
||||||
@@ -4039,14 +4031,14 @@ async fn generate_puzzle_image_candidates(
|
|||||||
};
|
};
|
||||||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
||||||
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||||||
let settings = require_puzzle_apimart_settings(state)?;
|
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||||
let generated = create_puzzle_apimart_image_generation(
|
let generated = create_puzzle_vector_engine_image_generation(
|
||||||
&http_client,
|
&http_client,
|
||||||
&settings,
|
&settings,
|
||||||
resolved_model,
|
resolved_model,
|
||||||
actual_prompt.as_str(),
|
actual_prompt.as_str(),
|
||||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
PUZZLE_APIMART_GENERATED_IMAGE_SIZE,
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
count,
|
count,
|
||||||
reference_image.as_deref(),
|
reference_image.as_deref(),
|
||||||
)
|
)
|
||||||
@@ -4097,26 +4089,25 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn puzzle_generated_image_size_is_square_1_1() {
|
fn puzzle_generated_image_size_is_square_1_1() {
|
||||||
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
|
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]
|
#[test]
|
||||||
fn puzzle_apimart_request_uses_selected_model_and_reference_images() {
|
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||||
let body = build_puzzle_apimart_image_request_body(
|
let body = build_puzzle_vector_engine_image_request_body(
|
||||||
PuzzleImageModel::Gemini31FlashPreview,
|
PuzzleImageModel::Gemini31FlashPreview,
|
||||||
"一只猫在雨夜灯牌下回头。",
|
"一只猫在雨夜灯牌下回头。",
|
||||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
PUZZLE_APIMART_GENERATED_IMAGE_SIZE,
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
4,
|
4,
|
||||||
Some("data:image/png;base64,abcd"),
|
Some("data:image/png;base64,abcd"),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(body["model"], PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW);
|
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||||
assert_eq!(body["size"], PUZZLE_APIMART_GENERATED_IMAGE_SIZE);
|
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
|
||||||
assert_eq!(body["resolution"], PUZZLE_APIMART_GEMINI_RESOLUTION);
|
|
||||||
assert_eq!(body["n"], 1);
|
assert_eq!(body["n"], 1);
|
||||||
assert_eq!(body["official_fallback"], true);
|
assert!(body.get("official_fallback").is_none());
|
||||||
assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd");
|
assert_eq!(body["image"][0], "data:image/png;base64,abcd");
|
||||||
assert!(
|
assert!(
|
||||||
body["prompt"]
|
body["prompt"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -4126,9 +4117,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(
|
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||||
"APIMART_API_KEY 未配置".to_string(),
|
"VECTOR_ENGINE_API_KEY 未配置".to_string(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let response = error.into_response();
|
let response = error.into_response();
|
||||||
@@ -4313,14 +4304,11 @@ enum PuzzleImageModel {
|
|||||||
|
|
||||||
impl PuzzleImageModel {
|
impl PuzzleImageModel {
|
||||||
fn provider_name(self) -> &'static str {
|
fn provider_name(self) -> &'static str {
|
||||||
"apimart"
|
VECTOR_ENGINE_PROVIDER
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_model_name(self) -> &'static str {
|
fn request_model_name(self) -> &'static str {
|
||||||
match self {
|
VECTOR_ENGINE_GPT_IMAGE_2_MODEL
|
||||||
Self::GptImage2 => PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
|
||||||
Self::Gemini31FlashPreview => PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn candidate_source_type(self) -> &'static str {
|
fn candidate_source_type(self) -> &'static str {
|
||||||
@@ -4331,10 +4319,9 @@ impl PuzzleImageModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PuzzleApimartSettings {
|
struct PuzzleVectorEngineSettings {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
request_timeout_ms: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PuzzleGeneratedImages {
|
struct PuzzleGeneratedImages {
|
||||||
@@ -4384,41 +4371,53 @@ struct GeneratedPuzzleAssetResponse {
|
|||||||
|
|
||||||
fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
|
fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
|
||||||
match value.map(str::trim).filter(|value| !value.is_empty()) {
|
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,
|
_ => PuzzleImageModel::GptImage2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_puzzle_apimart_settings(state: &AppState) -> Result<PuzzleApimartSettings, AppError> {
|
fn require_puzzle_vector_engine_settings(
|
||||||
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
|
state: &AppState,
|
||||||
|
) -> Result<PuzzleVectorEngineSettings, AppError> {
|
||||||
|
let base_url = state
|
||||||
|
.config
|
||||||
|
.vector_engine_base_url
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches('/');
|
||||||
if base_url.is_empty() {
|
if base_url.is_empty() {
|
||||||
return Err(
|
return Err(
|
||||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": "APIMart 图片生成地址未配置",
|
"message": "VectorEngine 图片生成地址未配置",
|
||||||
"reason": "APIMART_BASE_URL 未配置",
|
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let api_key = state
|
let api_key = state
|
||||||
.config
|
.config
|
||||||
.apimart_api_key
|
.vector_engine_api_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": "APIMart 图片生成密钥未配置",
|
"message": "VectorEngine 图片生成密钥未配置",
|
||||||
"reason": "APIMART_API_KEY 未配置",
|
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||||
}))
|
}))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(PuzzleApimartSettings {
|
Ok(PuzzleVectorEngineSettings {
|
||||||
base_url: base_url.to_string(),
|
base_url: base_url.to_string(),
|
||||||
api_key: api_key.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,
|
image_model: PuzzleImageModel,
|
||||||
) -> Result<reqwest::Client, AppError> {
|
) -> Result<reqwest::Client, AppError> {
|
||||||
let provider = image_model.provider_name();
|
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()
|
reqwest::Client::builder()
|
||||||
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
|
.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,
|
http_client: &reqwest::Client,
|
||||||
settings: &PuzzleApimartSettings,
|
settings: &PuzzleVectorEngineSettings,
|
||||||
image_model: PuzzleImageModel,
|
image_model: PuzzleImageModel,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
negative_prompt: &str,
|
negative_prompt: &str,
|
||||||
@@ -4465,7 +4464,7 @@ async fn create_puzzle_apimart_image_generation(
|
|||||||
candidate_count: u32,
|
candidate_count: u32,
|
||||||
reference_image: Option<&str>,
|
reference_image: Option<&str>,
|
||||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||||
let request_body = build_puzzle_apimart_image_request_body(
|
let request_body = build_puzzle_vector_engine_image_request_body(
|
||||||
image_model,
|
image_model,
|
||||||
prompt,
|
prompt,
|
||||||
negative_prompt,
|
negative_prompt,
|
||||||
@@ -4474,61 +4473,59 @@ async fn create_puzzle_apimart_image_generation(
|
|||||||
reference_image,
|
reference_image,
|
||||||
);
|
);
|
||||||
let response = http_client
|
let response = http_client
|
||||||
.post(format!("{}/images/generations", settings.base_url))
|
.post(puzzle_vector_engine_images_generation_url(settings))
|
||||||
.header(
|
.header(
|
||||||
reqwest::header::AUTHORIZATION,
|
reqwest::header::AUTHORIZATION,
|
||||||
format!("Bearer {}", settings.api_key),
|
format!("Bearer {}", settings.api_key),
|
||||||
)
|
)
|
||||||
|
.header(reqwest::header::ACCEPT, "application/json")
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||||
.json(&request_body)
|
.json(&request_body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.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 status = response.status();
|
||||||
let response_text = response.text().await.map_err(|error| {
|
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() {
|
if !status.is_success() {
|
||||||
return Err(map_puzzle_apimart_upstream_error(
|
return Err(map_puzzle_vector_engine_upstream_error(
|
||||||
status,
|
status,
|
||||||
response_text.as_str(),
|
response_text.as_str(),
|
||||||
"创建拼图 APIMart 图片生成任务失败",
|
"创建拼图 VectorEngine 图片生成任务失败",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload =
|
let payload = parse_puzzle_json_payload(
|
||||||
parse_puzzle_json_payload(response_text.as_str(), "解析拼图 APIMart 图片生成响应失败")?;
|
response_text.as_str(),
|
||||||
|
"解析拼图 VectorEngine 图片生成响应失败",
|
||||||
|
)?;
|
||||||
let image_urls = extract_puzzle_image_urls(&payload);
|
let image_urls = extract_puzzle_image_urls(&payload);
|
||||||
if !image_urls.is_empty() {
|
if !image_urls.is_empty() {
|
||||||
return download_puzzle_images_from_urls(
|
return download_puzzle_images_from_urls(
|
||||||
http_client,
|
http_client,
|
||||||
format!("apimart-{}", current_utc_micros()),
|
format!("vector-engine-{}", current_utc_micros()),
|
||||||
image_urls,
|
image_urls,
|
||||||
candidate_count,
|
candidate_count,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
|
Err(
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": "拼图 APIMart 图片生成未返回 task_id 或图片地址",
|
"message": "拼图 VectorEngine 图片生成未返回图片地址",
|
||||||
}))
|
})),
|
||||||
})?;
|
|
||||||
|
|
||||||
wait_puzzle_apimart_generated_images(
|
|
||||||
http_client,
|
|
||||||
settings,
|
|
||||||
task_id.as_str(),
|
|
||||||
candidate_count,
|
|
||||||
"拼图 APIMart 图片生成任务失败",
|
|
||||||
)
|
)
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_puzzle_apimart_image_request_body(
|
fn build_puzzle_vector_engine_image_request_body(
|
||||||
image_model: PuzzleImageModel,
|
image_model: PuzzleImageModel,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
negative_prompt: &str,
|
negative_prompt: &str,
|
||||||
@@ -4543,34 +4540,23 @@ fn build_puzzle_apimart_image_request_body(
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"prompt".to_string(),
|
"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))),
|
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||||||
("official_fallback".to_string(), Value::Bool(true)),
|
|
||||||
("size".to_string(), Value::String(size.to_string())),
|
("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
|
if let Some(reference_image) = reference_image
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.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)
|
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 prompt = prompt.trim();
|
||||||
let negative_prompt = negative_prompt.trim();
|
let negative_prompt = negative_prompt.trim();
|
||||||
if negative_prompt.is_empty() {
|
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}")
|
format!("{prompt}\n避免:{negative_prompt}")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_puzzle_apimart_generated_images(
|
fn puzzle_vector_engine_images_generation_url(settings: &PuzzleVectorEngineSettings) -> String {
|
||||||
http_client: &reqwest::Client,
|
if settings.base_url.ends_with("/v1") {
|
||||||
settings: &PuzzleApimartSettings,
|
format!("{}/images/generations", settings.base_url)
|
||||||
task_id: &str,
|
} else {
|
||||||
candidate_count: u32,
|
format!("{}/v1/images/generations", settings.base_url)
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(
|
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
||||||
"provider": "apimart",
|
|
||||||
"message": "拼图 APIMart 图片生成超时或未返回图片地址",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_puzzle_images_from_urls(
|
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> {
|
fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
|
||||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": format!("{fallback_message}:{error}"),
|
"message": format!("{fallback_message}:{error}"),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
@@ -5011,10 +4921,6 @@ fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
|
|||||||
Some(output)
|
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> {
|
fn extract_puzzle_image_urls(payload: &Value) -> Vec<String> {
|
||||||
let mut urls = Vec::new();
|
let mut urls = Vec::new();
|
||||||
collect_puzzle_strings_by_key(payload, "image", &mut urls);
|
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!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": message,
|
"message": message,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_puzzle_apimart_upstream_error(
|
fn map_puzzle_vector_engine_upstream_error(
|
||||||
upstream_status: reqwest::StatusCode,
|
upstream_status: reqwest::StatusCode,
|
||||||
raw_text: &str,
|
raw_text: &str,
|
||||||
fallback_message: &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 message = parse_puzzle_api_error_message(raw_text, fallback_message);
|
||||||
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
|
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
provider = "apimart",
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
upstream_status = upstream_status.as_u16(),
|
upstream_status = upstream_status.as_u16(),
|
||||||
message = %message,
|
message = %message,
|
||||||
raw_excerpt = %raw_excerpt,
|
raw_excerpt = %raw_excerpt,
|
||||||
"拼图 APIMart 上游请求失败"
|
"拼图 VectorEngine 上游请求失败"
|
||||||
);
|
);
|
||||||
|
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"upstreamStatus": upstream_status.as_u16(),
|
"upstreamStatus": upstream_status.as_u16(),
|
||||||
"message": message,
|
"message": message,
|
||||||
"rawExcerpt": raw_excerpt,
|
"rawExcerpt": raw_excerpt,
|
||||||
|
|||||||
@@ -1517,7 +1517,7 @@ async fn generate_square_hole_image_data_url(
|
|||||||
.await?;
|
.await?;
|
||||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": "vector-engine",
|
||||||
"message": format!("{failure_context}:上游未返回图片"),
|
"message": format!("{failure_context}:上游未返回图片"),
|
||||||
}))
|
}))
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -99,7 +99,10 @@ import {
|
|||||||
buildPublicWorkStagePath,
|
buildPublicWorkStagePath,
|
||||||
pushAppHistoryPath,
|
pushAppHistoryPath,
|
||||||
} from '../../routing/appPageRoutes';
|
} from '../../routing/appPageRoutes';
|
||||||
import { ApiClientError } from '../../services/apiClient';
|
import {
|
||||||
|
ApiClientError,
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||||
|
} from '../../services/apiClient';
|
||||||
import {
|
import {
|
||||||
getPublicAuthUserByCode,
|
getPublicAuthUserByCode,
|
||||||
getPublicAuthUserById,
|
getPublicAuthUserById,
|
||||||
@@ -383,11 +386,8 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
|||||||
'publish_missing_main_chapter',
|
'publish_missing_main_chapter',
|
||||||
'publish_missing_first_act',
|
'publish_missing_first_act',
|
||||||
]);
|
]);
|
||||||
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = {
|
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
|
||||||
skipRefresh: true,
|
BACKGROUND_AUTH_REQUEST_OPTIONS;
|
||||||
notifyAuthStateChange: false,
|
|
||||||
clearAuthOnUnauthorized: false,
|
|
||||||
};
|
|
||||||
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS =
|
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS =
|
||||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ async function openExistingRpgDraft(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ISOLATED_RUNTIME_AUTH_OPTIONS = {
|
const ISOLATED_RUNTIME_AUTH_OPTIONS = {
|
||||||
|
authImpact: 'local',
|
||||||
skipRefresh: true,
|
skipRefresh: true,
|
||||||
notifyAuthStateChange: false,
|
notifyAuthStateChange: false,
|
||||||
clearAuthOnUnauthorized: false,
|
clearAuthOnUnauthorized: false,
|
||||||
@@ -3682,6 +3683,10 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
|||||||
},
|
},
|
||||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
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-0"]')!);
|
||||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
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(dialog).toBeTruthy();
|
||||||
expect(screen.getByText('测试玩家')).toBeTruthy();
|
expect(screen.getByText('测试玩家')).toBeTruthy();
|
||||||
|
expect(listProfileSaveArchives).toHaveBeenCalledWith(
|
||||||
|
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||||
|
);
|
||||||
|
|
||||||
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,22 @@ import {
|
|||||||
resumeRpgProfileSaveArchive,
|
resumeRpgProfileSaveArchive,
|
||||||
upsertRpgProfileBrowseHistory,
|
upsertRpgProfileBrowseHistory,
|
||||||
} from '../../services/rpg-entry';
|
} from '../../services/rpg-entry';
|
||||||
|
import {
|
||||||
|
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
type RuntimeRequestOptions,
|
||||||
|
} from '../../services/rpg-runtime/rpgRuntimeRequest';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import type { PlatformHomeTab } from './RpgEntryHomeView';
|
import type { PlatformHomeTab } from './RpgEntryHomeView';
|
||||||
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
|
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
|
||||||
|
|
||||||
|
const PLATFORM_BOOTSTRAP_AUTH_OPTIONS = RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||||
|
|
||||||
type UseRpgEntryBootstrapParams = {
|
type UseRpgEntryBootstrapParams = {
|
||||||
user: AuthUser | null | undefined;
|
user: AuthUser | null | undefined;
|
||||||
canAccessProtectedData?: boolean | undefined;
|
canAccessProtectedData?: boolean | undefined;
|
||||||
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
|
getProfileDashboard: (
|
||||||
|
options?: RuntimeRequestOptions,
|
||||||
|
) => Promise<ProfileDashboardSummary | null>;
|
||||||
handleContinueGame: (
|
handleContinueGame: (
|
||||||
snapshot?: HydratedSavedGameSnapshot | null,
|
snapshot?: HydratedSavedGameSnapshot | null,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -99,7 +107,9 @@ export function useRpgEntryBootstrap(
|
|||||||
setDashboardError(null);
|
setDashboardError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setProfileDashboard(await getProfileDashboard());
|
setProfileDashboard(
|
||||||
|
await getProfileDashboard(PLATFORM_BOOTSTRAP_AUTH_OPTIONS),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDashboardError(
|
setDashboardError(
|
||||||
resolveRpgEntryErrorMessage(error, '读取个人数据看板失败。'),
|
resolveRpgEntryErrorMessage(error, '读取个人数据看板失败。'),
|
||||||
@@ -115,7 +125,9 @@ export function useRpgEntryBootstrap(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextItems = await listRpgCreationWorks();
|
const nextItems = await listRpgCreationWorks(
|
||||||
|
PLATFORM_BOOTSTRAP_AUTH_OPTIONS,
|
||||||
|
);
|
||||||
setCustomWorldWorkEntries(nextItems);
|
setCustomWorldWorkEntries(nextItems);
|
||||||
return nextItems;
|
return nextItems;
|
||||||
}, [canReadProtectedData, user]);
|
}, [canReadProtectedData, user]);
|
||||||
@@ -132,7 +144,9 @@ export function useRpgEntryBootstrap(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEntries = await listRpgEntryWorldLibrary();
|
const nextEntries = await listRpgEntryWorldLibrary(
|
||||||
|
PLATFORM_BOOTSTRAP_AUTH_OPTIONS,
|
||||||
|
);
|
||||||
setSavedCustomWorldEntries(nextEntries);
|
setSavedCustomWorldEntries(nextEntries);
|
||||||
return nextEntries;
|
return nextEntries;
|
||||||
}, [canReadProtectedData, user]);
|
}, [canReadProtectedData, user]);
|
||||||
@@ -147,7 +161,9 @@ export function useRpgEntryBootstrap(
|
|||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextEntries = await listRpgProfileSaveArchives();
|
const nextEntries = await listRpgProfileSaveArchives(
|
||||||
|
PLATFORM_BOOTSTRAP_AUTH_OPTIONS,
|
||||||
|
);
|
||||||
setSaveEntries(nextEntries);
|
setSaveEntries(nextEntries);
|
||||||
return nextEntries;
|
return nextEntries;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -161,7 +177,10 @@ export function useRpgEntryBootstrap(
|
|||||||
setHistoryError(null);
|
setHistoryError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const syncedEntries = await upsertRpgProfileBrowseHistory(entry);
|
const syncedEntries = await upsertRpgProfileBrowseHistory(
|
||||||
|
entry,
|
||||||
|
PLATFORM_BOOTSTRAP_AUTH_OPTIONS,
|
||||||
|
);
|
||||||
setHistoryEntries(syncedEntries);
|
setHistoryEntries(syncedEntries);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setHistoryError(
|
setHistoryError(
|
||||||
@@ -237,18 +256,20 @@ export function useRpgEntryBootstrap(
|
|||||||
saveArchivesResult,
|
saveArchivesResult,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
canReadProtectedData
|
canReadProtectedData
|
||||||
? listRpgEntryWorldLibrary()
|
? listRpgEntryWorldLibrary(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
canReadProtectedData
|
canReadProtectedData
|
||||||
? listRpgCreationWorks()
|
? listRpgCreationWorks(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
listRpgEntryWorldGallery(),
|
listRpgEntryWorldGallery(),
|
||||||
canReadProtectedData ? getProfileDashboard() : Promise.resolve(null),
|
|
||||||
canReadProtectedData
|
canReadProtectedData
|
||||||
? listRpgProfileBrowseHistory()
|
? getProfileDashboard(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
canReadProtectedData
|
||||||
|
? listRpgProfileBrowseHistory(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
canReadProtectedData
|
canReadProtectedData
|
||||||
? listRpgProfileSaveArchives()
|
? listRpgProfileSaveArchives(PLATFORM_BOOTSTRAP_AUTH_OPTIONS)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
AUTH_STATE_EVENT,
|
AUTH_STATE_EVENT,
|
||||||
ApiClientError,
|
ApiClientError,
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||||
clearStoredAccessToken,
|
clearStoredAccessToken,
|
||||||
fetchWithApiAuth,
|
fetchWithApiAuth,
|
||||||
getStoredAccessToken,
|
getStoredAccessToken,
|
||||||
@@ -265,6 +266,52 @@ describe('apiClient', () => {
|
|||||||
expect(getStoredAccessToken()).toBe('still-valid-token');
|
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 () => {
|
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||||
setStoredAccessToken('expired-token', { emit: false });
|
setStoredAccessToken('expired-token', { emit: false });
|
||||||
fetchMock
|
fetchMock
|
||||||
|
|||||||
@@ -26,18 +26,29 @@ export type ApiRetryOptions = {
|
|||||||
allowRetryMethods?: string[];
|
allowRetryMethods?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiAuthImpact = 'global' | 'local';
|
||||||
|
|
||||||
export type ApiRequestOptions = {
|
export type ApiRequestOptions = {
|
||||||
retry?: ApiRetryOptions;
|
retry?: ApiRetryOptions;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
omitEnvelopeHeader?: boolean;
|
omitEnvelopeHeader?: boolean;
|
||||||
skipRefresh?: boolean;
|
skipRefresh?: boolean;
|
||||||
|
// global:请求失败可影响整站登录态;local:失败只属于当前卡片、图片或运行态。
|
||||||
|
authImpact?: ApiAuthImpact;
|
||||||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||||||
notifyAuthStateChange?: boolean;
|
notifyAuthStateChange?: boolean;
|
||||||
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
|
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
|
||||||
clearAuthOnUnauthorized?: boolean;
|
clearAuthOnUnauthorized?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
|
||||||
|
authImpact: 'local',
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
} satisfies ApiRequestOptions;
|
||||||
|
|
||||||
type ResolvedRetryOptions = {
|
type ResolvedRetryOptions = {
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
baseDelayMs: number;
|
baseDelayMs: number;
|
||||||
@@ -54,6 +65,12 @@ type ParsedApiErrorShape = {
|
|||||||
meta: Partial<ApiMeta>;
|
meta: Partial<ApiMeta>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResolvedAuthFailurePolicy = {
|
||||||
|
skipRefresh: boolean;
|
||||||
|
notifyAuthStateChange: boolean;
|
||||||
|
clearAuthOnUnauthorized: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null;
|
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));
|
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 {
|
export class ApiClientError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -477,7 +512,6 @@ async function refreshAccessToken() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
clearStoredAccessToken({ emit: false });
|
|
||||||
throw await buildApiClientError(response, '刷新登录状态失败');
|
throw await buildApiClientError(response, '刷新登录状态失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +523,6 @@ async function refreshAccessToken() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (payload?.ok !== true || !payload.token?.trim()) {
|
if (payload?.ok !== true || !payload.token?.trim()) {
|
||||||
clearStoredAccessToken({ emit: false });
|
|
||||||
throw new Error('刷新登录状态失败');
|
throw new Error('刷新登录状态失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +549,12 @@ export async function ensureStoredAccessToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshStoredAccessToken() {
|
export async function refreshStoredAccessToken() {
|
||||||
return refreshAccessToken();
|
try {
|
||||||
|
return await refreshAccessToken();
|
||||||
|
} catch (error) {
|
||||||
|
clearStoredAccessToken({ emit: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWithApiAuth(
|
export async function fetchWithApiAuth(
|
||||||
@@ -526,9 +564,7 @@ export async function fetchWithApiAuth(
|
|||||||
) {
|
) {
|
||||||
const method = (init.method ?? 'GET').toUpperCase();
|
const method = (init.method ?? 'GET').toUpperCase();
|
||||||
const retry = resolveRetryOptions(method, options.retry);
|
const retry = resolveRetryOptions(method, options.retry);
|
||||||
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
|
const authFailurePolicy = resolveAuthFailurePolicy(options);
|
||||||
const shouldClearAuthOnUnauthorized =
|
|
||||||
options.clearAuthOnUnauthorized !== false;
|
|
||||||
const requestSignal = init.signal ?? undefined;
|
const requestSignal = init.signal ?? undefined;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
let refreshAttempted = false;
|
let refreshAttempted = false;
|
||||||
@@ -541,7 +577,11 @@ export async function fetchWithApiAuth(
|
|||||||
requestHeaders.authorization?.trim(),
|
requestHeaders.authorization?.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasAuthHeader && !options.skipAuth && !options.skipRefresh) {
|
if (
|
||||||
|
!hasAuthHeader &&
|
||||||
|
!options.skipAuth &&
|
||||||
|
!authFailurePolicy.skipRefresh
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
|
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
|
||||||
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
||||||
@@ -573,7 +613,7 @@ export async function fetchWithApiAuth(
|
|||||||
response.status === 401 &&
|
response.status === 401 &&
|
||||||
hasAuthHeader &&
|
hasAuthHeader &&
|
||||||
!options.skipAuth &&
|
!options.skipAuth &&
|
||||||
!options.skipRefresh &&
|
!authFailurePolicy.skipRefresh &&
|
||||||
!refreshAttempted
|
!refreshAttempted
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -584,10 +624,10 @@ export async function fetchWithApiAuth(
|
|||||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||||
continue;
|
continue;
|
||||||
} catch {
|
} catch {
|
||||||
if (hasAuthHeader && shouldClearAuthOnUnauthorized) {
|
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
|
||||||
clearStoredAccessToken({ emit: false });
|
clearStoredAccessToken({ emit: false });
|
||||||
}
|
}
|
||||||
if (shouldNotifyAuthStateChange) {
|
if (authFailurePolicy.notifyAuthStateChange) {
|
||||||
emitAuthStateChange();
|
emitAuthStateChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,10 +637,10 @@ export async function fetchWithApiAuth(
|
|||||||
!options.skipAuth &&
|
!options.skipAuth &&
|
||||||
!refreshAttempted
|
!refreshAttempted
|
||||||
) {
|
) {
|
||||||
if (shouldClearAuthOnUnauthorized) {
|
if (authFailurePolicy.clearAuthOnUnauthorized) {
|
||||||
clearStoredAccessToken({ emit: false });
|
clearStoredAccessToken({ emit: false });
|
||||||
}
|
}
|
||||||
if (shouldNotifyAuthStateChange) {
|
if (authFailurePolicy.notifyAuthStateChange) {
|
||||||
emitAuthStateChange();
|
emitAuthStateChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ApiClientError,
|
ApiClientError,
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||||
type ApiRequestOptions,
|
type ApiRequestOptions,
|
||||||
requestJson,
|
requestJson,
|
||||||
} from './apiClient';
|
} from './apiClient';
|
||||||
@@ -45,11 +46,8 @@ type CachedReadUrlFailureEntry = {
|
|||||||
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
|
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
|
||||||
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
||||||
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
|
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
|
||||||
const ASSET_READ_URL_BACKGROUND_OPTIONS = {
|
const ASSET_READ_URL_BACKGROUND_OPTIONS =
|
||||||
skipRefresh: true,
|
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions;
|
||||||
notifyAuthStateChange: false,
|
|
||||||
clearAuthOnUnauthorized: false,
|
|
||||||
} satisfies ApiRequestOptions;
|
|
||||||
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
|
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
|
||||||
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
|
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
|
||||||
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
};
|
};
|
||||||
type BigFishRuntimeRequestOptions = Pick<
|
type BigFishRuntimeRequestOptions = Pick<
|
||||||
ApiRequestOptions,
|
ApiRequestOptions,
|
||||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
| 'authImpact'
|
||||||
|
| 'skipRefresh'
|
||||||
|
| 'notifyAuthStateChange'
|
||||||
|
| 'clearAuthOnUnauthorized'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +42,7 @@ export function recordBigFishPlay(
|
|||||||
'记录大鱼吃小鱼游玩失败',
|
'记录大鱼吃小鱼游玩失败',
|
||||||
{
|
{
|
||||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
@@ -58,6 +62,7 @@ export function startBigFishRun(
|
|||||||
'启动大鱼吃小鱼玩法失败',
|
'启动大鱼吃小鱼玩法失败',
|
||||||
{
|
{
|
||||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
};
|
};
|
||||||
type Match3DRuntimeRequestOptions = Pick<
|
type Match3DRuntimeRequestOptions = Pick<
|
||||||
ApiRequestOptions,
|
ApiRequestOptions,
|
||||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
| 'authImpact'
|
||||||
|
| 'skipRefresh'
|
||||||
|
| 'notifyAuthStateChange'
|
||||||
|
| 'clearAuthOnUnauthorized'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
||||||
@@ -80,6 +83,7 @@ export function startMatch3DRun(
|
|||||||
'启动抓大鹅玩法失败',
|
'启动抓大鹅玩法失败',
|
||||||
{
|
{
|
||||||
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
retry: MATCH3D_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
};
|
};
|
||||||
type PuzzleRuntimeRequestOptions = Pick<
|
type PuzzleRuntimeRequestOptions = Pick<
|
||||||
ApiRequestOptions,
|
ApiRequestOptions,
|
||||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
| 'authImpact'
|
||||||
|
| 'skipRefresh'
|
||||||
|
| 'notifyAuthStateChange'
|
||||||
|
| 'clearAuthOnUnauthorized'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +51,7 @@ export async function startPuzzleRun(
|
|||||||
'启动拼图玩法失败',
|
'启动拼图玩法失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
@@ -136,6 +140,7 @@ export async function advancePuzzleNextLevel(
|
|||||||
'进入下一关失败',
|
'进入下一关失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
@@ -161,6 +166,7 @@ export async function submitPuzzleLeaderboard(
|
|||||||
'提交拼图排行榜失败',
|
'提交拼图排行榜失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
@@ -186,6 +192,7 @@ export async function updatePuzzleRunPause(
|
|||||||
'更新拼图计时状态失败',
|
'更新拼图计时状态失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
@@ -211,6 +218,7 @@ export async function usePuzzleRuntimeProp(
|
|||||||
'使用拼图道具失败',
|
'使用拼图道具失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
|||||||
@@ -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_API_BASE = '/api/runtime';
|
||||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||||
@@ -18,9 +23,15 @@ export type RpgCreationRuntimeRequestOptions = {
|
|||||||
retry?: ApiRetryOptions;
|
retry?: ApiRetryOptions;
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
skipRefresh?: boolean;
|
skipRefresh?: boolean;
|
||||||
|
authImpact?: ApiAuthImpact;
|
||||||
|
notifyAuthStateChange?: boolean;
|
||||||
|
clearAuthOnUnauthorized?: boolean;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const RPG_CREATION_BACKGROUND_AUTH_OPTIONS =
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies RpgCreationRuntimeRequestOptions;
|
||||||
|
|
||||||
export function requestRpgCreationRuntimeJson<T>(
|
export function requestRpgCreationRuntimeJson<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit,
|
init: RequestInit,
|
||||||
@@ -43,6 +54,9 @@ export function requestRpgCreationRuntimeJson<T>(
|
|||||||
retry,
|
retry,
|
||||||
skipAuth: options.skipAuth,
|
skipAuth: options.skipAuth,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
|
authImpact: options.authImpact,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
timeoutMs: options.timeoutMs,
|
timeoutMs: options.timeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { ListRpgCreationWorksResponse } from '../../../packages/shared/src';
|
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>(
|
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
|
||||||
'/custom-world/works',
|
'/custom-world/works',
|
||||||
{ method: 'GET' },
|
{ method: 'GET' },
|
||||||
'读取创作作品列表失败',
|
'读取创作作品列表失败',
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.items) ? response.items : [];
|
return Array.isArray(response?.items) ? response.items : [];
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
} from './rpgEntryLibraryClient';
|
} from './rpgEntryLibraryClient';
|
||||||
|
|
||||||
vi.mock('../apiClient', () => ({
|
vi.mock('../apiClient', () => ({
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS: {
|
||||||
|
authImpact: 'local',
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
},
|
||||||
requestJson: requestJsonMock,
|
requestJson: requestJsonMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -35,7 +41,11 @@ describe('rpgEntry profile browse history routes', () => {
|
|||||||
expect.objectContaining({ method: 'GET' }),
|
expect.objectContaining({ method: 'GET' }),
|
||||||
'读取浏览历史失败',
|
'读取浏览历史失败',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
authImpact: 'local',
|
||||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -60,10 +70,14 @@ describe('rpgEntry profile browse history routes', () => {
|
|||||||
}),
|
}),
|
||||||
'写入浏览历史失败',
|
'写入浏览历史失败',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
authImpact: 'local',
|
||||||
retry: expect.objectContaining({
|
retry: expect.objectContaining({
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
retryUnsafeMethods: true,
|
retryUnsafeMethods: true,
|
||||||
}),
|
}),
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -188,7 +202,11 @@ describe('rpgEntry save archive routes', () => {
|
|||||||
expect.objectContaining({ method: 'GET' }),
|
expect.objectContaining({ method: 'GET' }),
|
||||||
'读取存档列表失败',
|
'读取存档列表失败',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
authImpact: 'local',
|
||||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ import {
|
|||||||
} from './rpgEntryLibraryClient';
|
} from './rpgEntryLibraryClient';
|
||||||
|
|
||||||
vi.mock('../apiClient', () => ({
|
vi.mock('../apiClient', () => ({
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS: {
|
||||||
|
authImpact: 'local',
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
},
|
||||||
requestJson: requestJsonMock,
|
requestJson: requestJsonMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
type RuntimeRequestOptions,
|
type RuntimeRequestOptions,
|
||||||
requestPublicRpgRuntimeJson,
|
requestPublicRpgRuntimeJson,
|
||||||
requestRpgRuntimeJson,
|
requestRpgRuntimeJson,
|
||||||
@@ -26,7 +27,10 @@ export async function listRpgEntryWorldLibrary(
|
|||||||
'/custom-world-library',
|
'/custom-world-library',
|
||||||
{ method: 'GET' },
|
{ method: 'GET' },
|
||||||
'读取自定义世界库失败',
|
'读取自定义世界库失败',
|
||||||
options,
|
{
|
||||||
|
...RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.entries) ? response.entries : [];
|
return Array.isArray(response?.entries) ? response.entries : [];
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import {
|
|||||||
} from './rpgProfileClient';
|
} from './rpgProfileClient';
|
||||||
|
|
||||||
vi.mock('../apiClient', () => ({
|
vi.mock('../apiClient', () => ({
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS: {
|
||||||
|
authImpact: 'local',
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
},
|
||||||
requestJson: requestJsonMock,
|
requestJson: requestJsonMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -32,7 +38,11 @@ describe('rpgProfileClient browse history routes', () => {
|
|||||||
expect.objectContaining({ method: 'GET' }),
|
expect.objectContaining({ method: 'GET' }),
|
||||||
'读取浏览历史失败',
|
'读取浏览历史失败',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
authImpact: 'local',
|
||||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -57,10 +67,14 @@ describe('rpgProfileClient browse history routes', () => {
|
|||||||
}),
|
}),
|
||||||
'写入浏览历史失败',
|
'写入浏览历史失败',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
authImpact: 'local',
|
||||||
retry: expect.objectContaining({
|
retry: expect.objectContaining({
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
retryUnsafeMethods: true,
|
retryUnsafeMethods: true,
|
||||||
}),
|
}),
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -121,7 +135,11 @@ describe('rpgProfileClient save archive routes', () => {
|
|||||||
expect.objectContaining({ method: 'GET' }),
|
expect.objectContaining({ method: 'GET' }),
|
||||||
'读取存档列表失败',
|
'读取存档列表失败',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
authImpact: 'local',
|
||||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import {
|
import {
|
||||||
|
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
requestRpgRuntimeJson,
|
requestRpgRuntimeJson,
|
||||||
type RuntimeRequestOptions,
|
type RuntimeRequestOptions,
|
||||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||||
@@ -199,7 +200,10 @@ export async function listRpgProfileSaveArchives(
|
|||||||
'/profile/save-archives',
|
'/profile/save-archives',
|
||||||
{ method: 'GET' },
|
{ method: 'GET' },
|
||||||
'读取存档列表失败',
|
'读取存档列表失败',
|
||||||
options,
|
{
|
||||||
|
...RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.entries) ? response.entries : [];
|
return Array.isArray(response?.entries) ? response.entries : [];
|
||||||
@@ -231,7 +235,10 @@ export async function listRpgProfileBrowseHistory(
|
|||||||
'/profile/browse-history',
|
'/profile/browse-history',
|
||||||
{ method: 'GET' },
|
{ method: 'GET' },
|
||||||
'读取浏览历史失败',
|
'读取浏览历史失败',
|
||||||
options,
|
{
|
||||||
|
...RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.entries) ? response.entries : [];
|
return Array.isArray(response?.entries) ? response.entries : [];
|
||||||
@@ -249,7 +256,10 @@ export async function upsertRpgProfileBrowseHistory(
|
|||||||
body: JSON.stringify(entry),
|
body: JSON.stringify(entry),
|
||||||
},
|
},
|
||||||
'写入浏览历史失败',
|
'写入浏览历史失败',
|
||||||
options,
|
{
|
||||||
|
...RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return Array.isArray(response?.entries) ? response.entries : [];
|
return Array.isArray(response?.entries) ? response.entries : [];
|
||||||
|
|||||||
@@ -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_API_BASE = '/api/runtime';
|
||||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||||
@@ -18,10 +23,14 @@ export type RuntimeRequestOptions = {
|
|||||||
retry?: ApiRetryOptions;
|
retry?: ApiRetryOptions;
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
skipRefresh?: boolean;
|
skipRefresh?: boolean;
|
||||||
|
authImpact?: ApiAuthImpact;
|
||||||
notifyAuthStateChange?: boolean;
|
notifyAuthStateChange?: boolean;
|
||||||
clearAuthOnUnauthorized?: boolean;
|
clearAuthOnUnauthorized?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const RUNTIME_BACKGROUND_AUTH_OPTIONS =
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies RuntimeRequestOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。
|
* 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。
|
||||||
*/
|
*/
|
||||||
@@ -52,6 +61,7 @@ export function requestRpgRuntimeJson<T>(
|
|||||||
retry,
|
retry,
|
||||||
skipAuth: options.skipAuth,
|
skipAuth: options.skipAuth,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
|
authImpact: options.authImpact,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
};
|
};
|
||||||
type SquareHoleRuntimeRequestOptions = Pick<
|
type SquareHoleRuntimeRequestOptions = Pick<
|
||||||
ApiRequestOptions,
|
ApiRequestOptions,
|
||||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
| 'authImpact'
|
||||||
|
| 'skipRefresh'
|
||||||
|
| 'notifyAuthStateChange'
|
||||||
|
| 'clearAuthOnUnauthorized'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +46,7 @@ export function startSquareHoleRun(
|
|||||||
'启动方洞挑战失败',
|
'启动方洞挑战失败',
|
||||||
{
|
{
|
||||||
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ export type VisualNovelRuntimeStreamOptions = TextStreamOptions & {
|
|||||||
};
|
};
|
||||||
type VisualNovelRuntimeRequestOptions = Pick<
|
type VisualNovelRuntimeRequestOptions = Pick<
|
||||||
ApiRequestOptions,
|
ApiRequestOptions,
|
||||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
| 'authImpact'
|
||||||
|
| 'skipRefresh'
|
||||||
|
| 'notifyAuthStateChange'
|
||||||
|
| 'clearAuthOnUnauthorized'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type VisualNovelSaveArchiveResumeResponse =
|
export type VisualNovelSaveArchiveResumeResponse =
|
||||||
@@ -111,6 +114,7 @@ export async function startVisualNovelRun(
|
|||||||
{
|
{
|
||||||
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
retry: VISUAL_NOVEL_RUNTIME_WRITE_RETRY,
|
||||||
timeoutMs: 15000,
|
timeoutMs: 15000,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ const VISUAL_NOVEL_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
|||||||
};
|
};
|
||||||
type VisualNovelWorksRequestOptions = Pick<
|
type VisualNovelWorksRequestOptions = Pick<
|
||||||
ApiRequestOptions,
|
ApiRequestOptions,
|
||||||
'skipRefresh' | 'notifyAuthStateChange' | 'clearAuthOnUnauthorized'
|
| 'authImpact'
|
||||||
|
| 'skipRefresh'
|
||||||
|
| 'notifyAuthStateChange'
|
||||||
|
| 'clearAuthOnUnauthorized'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function listVisualNovelWorks() {
|
export function listVisualNovelWorks() {
|
||||||
@@ -47,6 +50,7 @@ export function getVisualNovelWorkDetail(
|
|||||||
'读取视觉小说作品详情失败',
|
'读取视觉小说作品详情失败',
|
||||||
{
|
{
|
||||||
retry: VISUAL_NOVEL_WORKS_READ_RETRY,
|
retry: VISUAL_NOVEL_WORKS_READ_RETRY,
|
||||||
|
authImpact: options.authImpact,
|
||||||
skipRefresh: options.skipRefresh,
|
skipRefresh: options.skipRefresh,
|
||||||
notifyAuthStateChange: options.notifyAuthStateChange,
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
|
|||||||
Reference in New Issue
Block a user