修复图片编辑器生图登录提示

调整编辑器 API 鉴权策略,允许通过 refresh cookie 静默补 access token

真实生图遇到未授权时显示登录提示,不再直接暴露 requestId

补充编辑器组件和客户端测试,并更新方案文档与跟踪记录
This commit is contained in:
2026-06-13 17:48:21 +08:00
parent 96c36f0b50
commit 3f4dba97ba
6 changed files with 68 additions and 13 deletions

View File

@@ -63,3 +63,4 @@
- 2026-06-12 生成工具修正:移除拼图素材的生成图 mock 元数据,使其作为普通素材显示;底部生成工具改为先打开生成图片对话框,再进入生成中状态并把生成结果加入画布,生成结果保留元数据与修改入口。 - 2026-06-12 生成工具修正:移除拼图素材的生成图 mock 元数据,使其作为普通素材显示;底部生成工具改为先打开生成图片对话框,再进入生成中状态并把生成结果加入画布,生成结果保留元数据与修改入口。
- 2026-06-13 真实生图修正:`/api/editor/images/generations``/api/editor/images/edits` 统一走 api-server VectorEngine `gpt-image-2` BFF前端不再创建 mock 成功图,生成 / 修改失败会留在对话框内显示错误;生成图右上角 `{}` 元数据按钮可直接点击打开元数据窗口。 - 2026-06-13 真实生图修正:`/api/editor/images/generations``/api/editor/images/edits` 统一走 api-server VectorEngine `gpt-image-2` BFF前端不再创建 mock 成功图,生成 / 修改失败会留在对话框内显示错误;生成图右上角 `{}` 元数据按钮可直接点击打开元数据窗口。
- 2026-06-13 素材库修正:素材栏按文件夹分组,文件夹支持折叠和新建;上传入口可定向到当前文件夹,上传素材进入素材库并支持删除,内置素材只保留添加和重命名。 - 2026-06-13 素材库修正:素材栏按文件夹分组,文件夹支持折叠和新建;上传入口可定向到当前文件夹,上传素材进入素材库并支持删除,内置素材只保留添加和重命名。
- 2026-06-13 生图鉴权修正:编辑器工程和真实生图请求不再使用禁止 refresh 的局部鉴权策略,可通过 refresh cookie 静默补 access token真实生图遇到 401 / 403 时弹窗显示“请先登录后再生成图片”,不再暴露后端 requestId 主文案。

View File

@@ -34,7 +34,7 @@
- 图片文件本体继续走 OSS浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 - 图片文件本体继续走 OSS浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
- 资源表只保存资源元数据;图层位置、尺寸、缩放、层级和选中所需 ID 保存在 `editor_project` 的布局 JSON。 - 资源表只保存资源元数据;图层位置、尺寸、缩放、层级和选中所需 ID 保存在 `editor_project` 的布局 JSON。
- 前端不直接订阅 SpacetimeDB统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 前端不直接订阅 SpacetimeDB统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
- 未登录用户可以使用本地演示态,但不触发工程自动保存。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案
## 后端接口 ## 后端接口

View File

@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import { ImageCanvasEditorView } from './ImageCanvasEditorView'; import { ImageCanvasEditorView } from './ImageCanvasEditorView';
const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorImageMock = vi.hoisted(() => vi.fn());
@@ -341,6 +342,30 @@ describe('ImageCanvasEditorView', () => {
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
}); });
it('asks the user to log in when real generation is unauthorized', async () => {
generateEditorImageMock.mockRejectedValueOnce(
new ApiClientError({
message: '未授权访问requestId: web-login-required',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张需要登录生成的图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe(
'请先登录后再生成图片',
);
});
expect(screen.queryByText(/requestId/u)).toBeNull();
});
it('switches tools and restores the previous tool after holding Space', async () => { it('switches tools and restores the previous tool after holding Space', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<ImageCanvasEditorView />); render(<ImageCanvasEditorView />);

View File

@@ -43,6 +43,7 @@ import {
loadOrCreateRecentEditorProject, loadOrCreateRecentEditorProject,
saveEditorProjectLayout, saveEditorProjectLayout,
} from '../../services/image-editor/editorProjectClient'; } from '../../services/image-editor/editorProjectClient';
import { ApiClientError } from '../../services/apiClient';
type EditorAsset = { type EditorAsset = {
id: string; id: string;
@@ -427,6 +428,19 @@ function getPointerId(event: ReactPointerEvent<HTMLElement>) {
return Number.isFinite(nativeId) ? nativeId : -1; return Number.isFinite(nativeId) ? nativeId : -1;
} }
function resolveImageGenerationErrorMessage(error: unknown) {
if (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
) {
return '请先登录后再生成图片';
}
return error instanceof Error && error.message.trim()
? error.message
: '生成图片失败';
}
export function ImageCanvasEditorView() { export function ImageCanvasEditorView() {
const canvasViewportRef = useRef<HTMLDivElement | null>(null); const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null); const uploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -1016,10 +1030,7 @@ export function ImageCanvasEditorView() {
...dialog, ...dialog,
prompt: normalizedPrompt, prompt: normalizedPrompt,
status: 'failed', status: 'failed',
errorMessage: errorMessage: resolveImageGenerationErrorMessage(error),
error instanceof Error && error.message.trim()
? error.message
: '生成图片失败',
}); });
} }
}; };

View File

@@ -40,7 +40,10 @@ describe('editorProjectClient', () => {
'/api/editor/projects/recent', '/api/editor/projects/recent',
{ method: 'GET' }, { method: 'GET' },
'读取图片画布工程失败', '读取图片画布工程失败',
expect.objectContaining({ authImpact: 'local' }), expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
}),
); );
}); });
@@ -70,7 +73,10 @@ describe('editorProjectClient', () => {
body: JSON.stringify({ title: '未命名画布' }), body: JSON.stringify({ title: '未命名画布' }),
}), }),
'创建图片画布工程失败', '创建图片画布工程失败',
expect.objectContaining({ authImpact: 'local' }), expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
}),
); );
}); });
@@ -102,7 +108,10 @@ describe('editorProjectClient', () => {
}), }),
}), }),
'保存图片画布工程失败', '保存图片画布工程失败',
expect.objectContaining({ authImpact: 'local' }), expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
}),
); );
}); });
@@ -149,7 +158,10 @@ describe('editorProjectClient', () => {
}), }),
}), }),
'创建图片画布资源失败', '创建图片画布资源失败',
expect.objectContaining({ authImpact: 'local' }), expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
}),
); );
}); });
@@ -175,7 +187,10 @@ describe('editorProjectClient', () => {
body: JSON.stringify({ title: '角色设定板' }), body: JSON.stringify({ title: '角色设定板' }),
}), }),
'创建图片画布工程失败', '创建图片画布工程失败',
expect.objectContaining({ authImpact: 'local' }), expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
}),
); );
}); });
@@ -204,7 +219,8 @@ describe('editorProjectClient', () => {
}), }),
'生成图片失败', '生成图片失败',
expect.objectContaining({ expect.objectContaining({
authImpact: 'local', clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
timeoutMs: 1_200_000, timeoutMs: 1_200_000,
retry: { maxRetries: 0 }, retry: { maxRetries: 0 },
}), }),
@@ -242,7 +258,8 @@ describe('editorProjectClient', () => {
}), }),
'修改图片失败', '修改图片失败',
expect.objectContaining({ expect.objectContaining({
authImpact: 'local', clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
timeoutMs: 1_200_000, timeoutMs: 1_200_000,
retry: { maxRetries: 0 }, retry: { maxRetries: 0 },
}), }),

View File

@@ -5,7 +5,8 @@ const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits'; const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const DEFAULT_PROJECT_TITLE = '未命名画布'; const DEFAULT_PROJECT_TITLE = '未命名画布';
const EDITOR_PROJECT_REQUEST_OPTIONS = { const EDITOR_PROJECT_REQUEST_OPTIONS = {
authImpact: 'local' as const, clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
}; };
export type EditorCanvasViewport = { export type EditorCanvasViewport = {