From 3861c9c99ac3985827f87b0c8283095f3b88ee98 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 13:39:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E9=A2=84=E8=A7=88=E8=BF=9E=E7=BB=AD=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按视口懒加载并错峰执行素材换签请求 增加限流退避与卸载清理,避免后续素材永久占位 补充滚动、分页、大批量排队和缩略图路径回归测试 记录后台素材换签限流排障与验证口径 --- .../pages/AdminEditorAssetQueryPage.test.tsx | 441 ++++++++++++++++-- .../src/pages/AdminEditorAssetQueryPage.tsx | 168 ++++++- docs/project-memory/shared-memory/pitfalls.md | 8 + 3 files changed, 562 insertions(+), 55 deletions(-) diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 5786bc43c..ccabfe74d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { + act, fireEvent, render, screen, @@ -8,7 +9,7 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { getAdminAssetReadUrl, @@ -19,9 +20,92 @@ import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage'; vi.mock('../api/adminApiClient', () => ({ getAdminAssetReadUrl: vi.fn(), + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), listAdminEditorAssets: vi.fn(), })); +interface MockIntersectionObserverController { + enter: (target: Element) => void; + isObserved: (target: Element) => boolean; +} + +function installIntersectionObserverMock(): MockIntersectionObserverController { + const observed = new Map< + Element, + { + callback: IntersectionObserverCallback; + observer: IntersectionObserver; + } + >(); + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = null; + readonly rootMargin: string; + readonly thresholds = [0]; + private readonly targets = new Set(); + + constructor( + private readonly callback: IntersectionObserverCallback, + options: IntersectionObserverInit = {}, + ) { + this.rootMargin = options.rootMargin ?? '0px'; + } + + observe(target: Element) { + this.targets.add(target); + observed.set(target, { + callback: this.callback, + observer: this as unknown as IntersectionObserver, + }); + } + + unobserve(target: Element) { + this.targets.delete(target); + observed.delete(target); + } + + disconnect() { + this.targets.forEach((target) => observed.delete(target)); + this.targets.clear(); + } + + takeRecords() { + return []; + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + + return { + enter(target) { + const record = observed.get(target); + if (!record) { + throw new Error('目标缩略图尚未进入 IntersectionObserver'); + } + act(() => { + record.callback( + [ + { + isIntersecting: true, + target, + } as IntersectionObserverEntry, + ], + record.observer, + ); + }); + }, + isObserved(target) { + return observed.has(target); + }, + }; +} + const generatedAsset: AdminEditorAssetPayload = { assetId: 'asset-1', ownerUserId: 'user-1', @@ -65,6 +149,31 @@ beforeEach(() => { }); }); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +function generatedAssetAt(index: number): AdminEditorAssetPayload { + const objectKey = `generated-character-drafts/editor/spec-${index}.png`; + return { + ...generatedAsset, + assetId: `asset-${index}`, + label: `角色形象 ${index}`, + imageSrc: `/${objectKey}`, + objectKey, + }; +} + +function thumbnailElementForLabel(label: string) { + const row = screen.getByText(label).closest('tr'); + const thumbnail = row?.querySelector('.admin-asset-query-thumb'); + if (!thumbnail) { + throw new Error(`未找到素材缩略图:${label}`); + } + return thumbnail; +} + test('后台素材查询展示作者昵称和陶泥号', async () => { render( , @@ -134,6 +243,236 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () => }); }); +test('后台素材查询只为进入可视区域的缩略图换签并能展示后续行', async () => { + const observer = installIntersectionObserverMock(); + const entries = [ + generatedAssetAt(1), + generatedAssetAt(2), + generatedAssetAt(3), + ]; + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries, + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => ({ + read: { + objectKey: request.objectKey ?? '', + signedUrl: `https://signed.example.com/${request.objectKey}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }), + ); + + render( + , + ); + + expect(await screen.findByText('角色形象 3')).toBeTruthy(); + const firstThumbnail = thumbnailElementForLabel('角色形象 1'); + const laterThumbnail = thumbnailElementForLabel('角色形象 3'); + await waitFor(() => { + expect(observer.isObserved(firstThumbnail)).toBe(true); + expect(observer.isObserved(laterThumbnail)).toBe(true); + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); + + observer.enter(laterThumbnail); + + const laterImage = await screen.findByRole('img', { + name: '素材:角色形象 3', + }); + expect(laterImage.getAttribute('src')).toBe( + 'https://signed.example.com/generated-character-drafts/editor/spec-3.png', + ); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1); + expect(getAdminAssetReadUrl).toHaveBeenLastCalledWith('admin-token', { + objectKey: 'generated-character-drafts/editor/spec-3.png', + expireSeconds: 300, + }); + + observer.enter(firstThumbnail); + expect( + await screen.findByRole('img', { name: '素材:角色形象 1' }), + ).toBeTruthy(); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(2); +}); + +test('后台素材查询为大量同时可见的缩略图持续错峰换签', async () => { + vi.useFakeTimers(); + const observer = installIntersectionObserverMock(); + const entries = Array.from({ length: 105 }, (_, index) => + generatedAssetAt(index + 1), + ); + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries, + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => ({ + read: { + objectKey: request.objectKey ?? '', + signedUrl: `https://signed.example.com/${request.objectKey}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }), + ); + + render( + , + ); + await act(async () => { + await Promise.resolve(); + }); + + const thumbnails = entries.map((entry) => + thumbnailElementForLabel(entry.label), + ); + thumbnails.forEach((thumbnail) => { + expect(observer.isObserved(thumbnail)).toBe(true); + observer.enter(thumbnail); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(39); + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_961); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(100); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105); +}); + +test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => { + const observer = installIntersectionObserverMock(); + const firstEntry = generatedAssetAt(1); + const nextEntry = generatedAssetAt(81); + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [firstEntry], + nextCursor: 'cursor-1', + }) + .mockResolvedValueOnce({ + entries: [nextEntry], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => ({ + read: { + objectKey: request.objectKey ?? '', + signedUrl: `https://signed.example.com/${request.objectKey}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }), + ); + + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '读取更多' })); + expect(await screen.findByText('角色形象 81')).toBeTruthy(); + const nextThumbnail = thumbnailElementForLabel('角色形象 81'); + await waitFor(() => expect(observer.isObserved(nextThumbnail)).toBe(true)); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); + + observer.enter(nextThumbnail); + + expect( + await screen.findByRole('img', { name: '素材:角色形象 81' }), + ).toBeTruthy(); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-character-drafts/editor/spec-81.png', + expireSeconds: 300, + }); + expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', { + ownerUserId: null, + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + cursor: 'cursor-1', + }); +}); + +test('后台素材查询缩略图换签被限流后有限重试并恢复', async () => { + const observer = installIntersectionObserverMock(); + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [generatedAsset], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl) + .mockRejectedValueOnce({ status: 429 }) + .mockResolvedValueOnce({ + read: { + objectKey: generatedAsset.objectKey ?? '', + signedUrl: + 'https://signed.example.com/generated-character-drafts/editor/spec.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + + render( + , + ); + + expect(await screen.findByText('角色形象 1')).toBeTruthy(); + const thumbnail = thumbnailElementForLabel('角色形象 1'); + await waitFor(() => expect(observer.isObserved(thumbnail)).toBe(true)); + observer.enter(thumbnail); + + const image = await screen.findByRole( + 'img', + { name: '素材:角色形象 1' }, + { timeout: 2_000 }, + ); + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/generated-character-drafts/editor/spec.png', + ); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(2); +}); + +test('后台素材查询缩略图卸载后停止限流重试', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date()); + const observer = installIntersectionObserverMock(); + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [generatedAsset], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockRejectedValue({ status: 429 }); + + const { unmount } = render( + , + ); + await act(async () => { + await Promise.resolve(); + }); + const thumbnail = thumbnailElementForLabel('角色形象 1'); + expect(observer.isObserved(thumbnail)).toBe(true); + observer.enter(thumbnail); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1); + + unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1); +}); + test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展示', async () => { vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ entries: [ @@ -170,6 +509,42 @@ test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展 }); }); +test('后台素材查询为独立缩略图路径换签而不误用原图 objectKey', async () => { + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + thumbnailSrc: '/generated-character-drafts/editor/spec-thumbnail.png', + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockResolvedValue({ + read: { + objectKey: 'generated-character-drafts/editor/spec-thumbnail.png', + signedUrl: 'https://signed.example.com/spec-thumbnail.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + + render( + , + ); + + const image = await screen.findByRole('img', { name: '素材:角色形象 1' }); + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/spec-thumbnail.png', + ); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + legacyPublicPath: '/generated-character-drafts/editor/spec-thumbnail.png', + expireSeconds: 300, + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', { + objectKey: generatedAsset.objectKey, + expireSeconds: 300, + }); +}); + test('后台素材查询点击图片缩略图可打开放大预览', async () => { const user = userEvent.setup(); @@ -182,7 +557,7 @@ test('后台素材查询点击图片缩略图可打开放大预览', async () => ); const dialog = await screen.findByRole('dialog', { name: '素材预览' }); - const image = within(dialog).getByRole('img', { + const image = await within(dialog).findByRole('img', { name: '图片预览:角色形象 1', }); await waitFor(() => { @@ -226,7 +601,7 @@ test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () ); const dialog = await screen.findByRole('dialog', { name: '素材预览' }); - const image = within(dialog).getByRole('img', { + const image = await within(dialog).findByRole('img', { name: '图片预览:角色动作首帧', }); await waitFor(() => { @@ -325,27 +700,29 @@ test('后台素材查询视频缩略图使用封面并在预览中播放视频', ], nextCursor: null, }); - vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => { - if ('legacyPublicPath' in request) { + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => { + if ('legacyPublicPath' in request) { + return { + read: { + objectKey: 'generated-editor-videos/task-1/cover.png', + signedUrl: 'https://signed.example.com/video-cover.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }; + } + const objectKey = request.objectKey ?? ''; return { read: { - objectKey: 'generated-editor-videos/task-1/cover.png', - signedUrl: 'https://signed.example.com/video-cover.png', + objectKey, + signedUrl: objectKey.endsWith('.mp4') + ? 'https://signed.example.com/video-preview.mp4' + : 'https://signed.example.com/video-cover-by-key.png', expiresAt: '2026-07-04T11:00:00Z', }, }; - } - const objectKey = request.objectKey ?? ''; - return { - read: { - objectKey, - signedUrl: objectKey.endsWith('.mp4') - ? 'https://signed.example.com/video-preview.mp4' - : 'https://signed.example.com/video-cover-by-key.png', - expiresAt: '2026-07-04T11:00:00Z', - }, - }; - }); + }, + ); render( , @@ -399,18 +776,20 @@ test('后台素材查询将无 objectKey 的绝对 OSS 视频和封面分别换 ], nextCursor: null, }); - vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => { - const legacyPublicPath = request.legacyPublicPath ?? ''; - return { - read: { - objectKey: legacyPublicPath.replace(/^\//u, ''), - signedUrl: legacyPublicPath.endsWith('/cover.png') - ? 'https://signed.example.com/video-2-cover.png' - : 'https://signed.example.com/video-2-preview.mp4', - expiresAt: '2026-07-04T11:00:00Z', - }, - }; - }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => { + const legacyPublicPath = request.legacyPublicPath ?? ''; + return { + read: { + objectKey: legacyPublicPath.replace(/^\//u, ''), + signedUrl: legacyPublicPath.endsWith('/cover.png') + ? 'https://signed.example.com/video-2-cover.png' + : 'https://signed.example.com/video-2-preview.mp4', + expiresAt: '2026-07-04T11:00:00Z', + }, + }; + }, + ); render( , diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index 25b9e17bc..f012d54fe 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -1,9 +1,10 @@ import { Eye, FileText, RefreshCcw, X } from 'lucide-react'; import type { ReactNode } from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { getAdminAssetReadUrl, + isAdminApiError, listAdminEditorAssets, } from '../api/adminApiClient'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; @@ -19,7 +20,11 @@ interface AdminEditorAssetQueryPageProps { } const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; +const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; +const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const; +const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px'; const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; +let adminAssetReadDispatchTail = Promise.resolve(); export function AdminEditorAssetQueryPage({ token, @@ -295,17 +300,27 @@ function AdminAssetThumbnail({ token: string; }) { const thumbnailSource = resolveAdminAssetThumbnailSource(entry); + const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); const imageSrc = useAdminResolvedAssetUrl( token, thumbnailSource.src, thumbnailSource.objectKey, + shouldLoad, ); const alt = `素材:${entry.label || entry.assetId}`; return imageSrc ? ( - {alt} + {alt} ) : ( -
+
); } @@ -317,12 +332,52 @@ function resolveAdminAssetThumbnailSource(entry: AdminEditorAssetPayload) { if (mediaKind === 'video') { return { src: entry.thumbnailSrc || '', objectKey: null }; } + if (entry.thumbnailSrc?.trim()) { + return { + src: entry.thumbnailSrc, + objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc) + ? entry.objectKey + : null, + }; + } return { - src: entry.thumbnailSrc || entry.imageSrc, + src: entry.imageSrc, objectKey: entry.objectKey, }; } +function useAdminAssetThumbnailVisibility() { + const [element, setElement] = useState(null); + const [shouldLoad, setShouldLoad] = useState(false); + const observeElement = useCallback((nextElement: HTMLElement | null) => { + setElement(nextElement); + }, []); + + useEffect(() => { + if (shouldLoad || !element) { + return; + } + if (typeof IntersectionObserver === 'undefined') { + setShouldLoad(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setShouldLoad(true); + observer.disconnect(); + } + }, + { rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [element, shouldLoad]); + + return { observeElement, shouldLoad }; +} + type AdminAssetMediaKind = 'image' | 'audio' | 'video'; function resolveAdminAssetMediaKind( @@ -604,6 +659,7 @@ function useAdminResolvedAssetUrl( token: string, imageSrc: string | null | undefined, objectKey: string | null | undefined, + enabled = true, ) { const normalizedImageSrc = imageSrc?.trim() ?? ''; const normalizedObjectKey = normalizeAdminObjectKey(objectKey); @@ -625,38 +681,67 @@ function useAdminResolvedAssetUrl( setResolvedImageSrc(normalizedImageSrc); return; } + if (!enabled) { + setResolvedImageSrc(''); + return; + } let cancelled = false; + let retryTimer: ReturnType | null = null; + let retryIndex = 0; + const dispatchController = new AbortController(); setResolvedImageSrc(''); - void getAdminAssetReadUrl( - token, - normalizedObjectKey - ? { - objectKey: normalizedObjectKey, - expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, - } - : { - legacyPublicPath: normalizedLegacyPublicPath, - expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, - }, - ) - .then(resolveAdminAssetReadSignedUrl) - .then((signedUrl) => { - if (!cancelled) { - setResolvedImageSrc(signedUrl); + const resolveReadUrl = async () => { + try { + await waitForAdminAssetReadDispatch(dispatchController.signal); + if (cancelled) { + return; } - }) - .catch(() => { + const response = await getAdminAssetReadUrl( + token, + normalizedObjectKey + ? { + objectKey: normalizedObjectKey, + expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, + } + : { + legacyPublicPath: normalizedLegacyPublicPath, + expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, + }, + ); if (!cancelled) { - setResolvedImageSrc(''); + setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response)); } - }); + } catch (error: unknown) { + if (cancelled) { + return; + } + const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex]; + if ( + isAdminApiError(error) && + error.status === 429 && + typeof retryDelay === 'number' + ) { + retryIndex += 1; + retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay); + return; + } + setResolvedImageSrc(''); + } + }; + + void resolveReadUrl(); return () => { cancelled = true; + dispatchController.abort(); + if (retryTimer !== null) { + clearTimeout(retryTimer); + } }; }, [ + enabled, normalizedImageSrc, normalizedLegacyPublicPath, normalizedObjectKey, @@ -667,10 +752,45 @@ function useAdminResolvedAssetUrl( return resolvedImageSrc; } +async function waitForAdminAssetReadDispatch(signal: AbortSignal) { + const dispatch = adminAssetReadDispatchTail.then( + () => waitForAdminAssetReadDispatchSpacing(signal), + () => waitForAdminAssetReadDispatchSpacing(signal), + ); + adminAssetReadDispatchTail = dispatch.catch(() => undefined); + await dispatch; +} + +async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) { + if (signal.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', handleAbort); + resolve(); + }, ADMIN_ASSET_READ_DISPATCH_SPACING_MS); + + function handleAbort() { + clearTimeout(timer); + reject(new DOMException('The operation was aborted.', 'AbortError')); + } + + signal.addEventListener('abort', handleAbort, { once: true }); + }); +} + function normalizeAdminObjectKey(value: string | null | undefined) { return value?.trim().replace(/^\/+/u, '') ?? ''; } +function adminAssetPathsMatch(left: string, right: string) { + return ( + left.trim().replace(/^\/+|[?#].*$/gu, '') === + right.trim().replace(/^\/+|[?#].*$/gu, '') + ); +} + function isGeneratedLegacyPath(value: string) { return /^\/?generated-[^/?#]+\/.+/u.test(value.trim()); } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7b76e0806..7b3ed4179 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -47,6 +47,14 @@ - 验证:`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。 - 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs`、`server-rs/crates/api-server/src/admin.rs`。 +## 后台素材缩略图不要在首次挂载时全量换签 + +- 现象:后台“素材查询”首批缩略图正常,继续向下滚动或读取更多后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`。 +- 原因:列表一次挂载 80 条私有素材时,每个缩略图同时换签,会在同秒突发请求。production Nginx 的 `genarrative_admin_rps` 为 `30r/s burst=16`,超出部分在进入 api-server 前已返回 `429`,因此仅查 api-server 日志会漏掉失败请求。 +- 处理:缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。 +- 验证:前端定向测试覆盖首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。 +- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`、`apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`。 + ## 陶泥儿精选重复先查同源同媒体画布副本 - 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。