diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx index 419977ef0..de3169194 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.test.tsx @@ -252,6 +252,85 @@ test('角色动作预览打开超过五分钟后回绕播放会在过期窗口 expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); }); +test('角色动作预览提前换签失败时保留已就绪帧并有限重试', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2099-01-01T00:00:00Z')); + const frameObjectKeys = [ + 'generated-animations/editor/task-refresh-failure/frame00.png', + 'generated-animations/editor/task-refresh-failure/frame01.png', + ] as const; + const requestCounts = new Map(); + vi.mocked(getAdminAssetReadUrl).mockImplementation((_token, request) => { + const objectKey = request.objectKey ?? ''; + const requestCount = (requestCounts.get(objectKey) ?? 0) + 1; + requestCounts.set(objectKey, requestCount); + if (objectKey === frameObjectKeys[0] && requestCount > 1) { + return Promise.reject(new Error('refresh unavailable')); + } + return Promise.resolve({ + read: { + objectKey, + signedUrl: `https://signed.example.com/v${requestCount}/${objectKey}`, + expiresAt: + requestCount === 1 ? '2099-01-01T00:05:00Z' : '2099-01-01T00:10:00Z', + }, + }); + }); + + render( + ({ + imageSrc: `/${objectKey}`, + objectKey, + width: 192, + height: 256, + })), + imageSequenceDurationMs: 2_000, + }} + token="admin-token" + onClose={vi.fn()} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '素材预览' }); + fireEvent.click(within(dialog).getByRole('button', { name: '暂停角色动作' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + loadMountedFrames(dialog); + const readyFrameSrc = resolveVisibleFrameSrc(dialog); + expect(readyFrameSrc).toBe( + `https://signed.example.com/v1/${frameObjectKeys[0]}`, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(270_100); + }); + expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); + expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); + + for (const retryWindowMs of [450, 1_250, 3_050]) { + await act(async () => { + await vi.advanceTimersByTimeAsync(retryWindowMs); + }); + expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); + } + expect(requestCounts.get(frameObjectKeys[0])).toBe(5); + expect(within(dialog).queryByText(/帧加载失败/u)).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(requestCounts.get(frameObjectKeys[0])).toBe(5); + expect(resolveVisibleFrameSrc(dialog)).toBe(readyFrameSrc); +}); + function loadMountedFrames(dialog: HTMLElement) { dialog .querySelectorAll('.admin-asset-query-sequence-frame') diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx index dc14da339..c2f6fba40 100644 --- a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { parseSignedReadUrlExpiresAtMs, - shouldReuseSignedReadUrlCacheEntry, SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS, } from '../../../../packages/shared/src/utils/signedReadUrlCache'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; @@ -355,8 +354,10 @@ function AdminEditorImageSequencePreview({ ) { return earliest; } - const candidate = - cached.expiresAtMs - SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS; + const candidate = cached.nextRefreshAtMs; + if (candidate === null) { + return earliest; + } return earliest === null || candidate < earliest ? candidate : earliest; }, null as number | null, @@ -481,6 +482,8 @@ type AdminImageSequenceFrameItem = { type AdminImageSequenceFrameCacheEntry = { expiresAtMs: number | null; + nextRefreshAtMs: number | null; + refreshRetryIndex: number; resolvedUrl: string; status: 'resolving' | 'refreshing' | 'loading' | 'ready' | 'failed'; }; @@ -574,6 +577,8 @@ function useAdminImageSequenceFrameCache( ? { ...current, status: 'refreshing' } : { expiresAtMs: null, + nextRefreshAtMs: null, + refreshRetryIndex: 0, resolvedUrl: '', status: 'resolving', }, @@ -587,16 +592,49 @@ function useAdminImageSequenceFrameCache( .then((resolved) => { updateFrameCacheEntry(item.key, () => ({ ...resolved, + nextRefreshAtMs: + resolved.expiresAtMs === null + ? null + : resolved.expiresAtMs - + SIGNED_READ_URL_CACHE_SAFETY_WINDOW_MS, + refreshRetryIndex: 0, status: refreshReadyFrame ? 'refreshing' : 'loading', })); }) - .catch(() => { + .catch((error: unknown) => { if (!controller.signal.aborted) { - updateFrameCacheEntry(item.key, () => ({ - expiresAtMs: null, - resolvedUrl: '', - status: 'failed', - })); + updateFrameCacheEntry(item.key, (current) => { + if (!isAdminImageSequenceFrameUrlUsable(current)) { + return { + expiresAtMs: null, + nextRefreshAtMs: null, + refreshRetryIndex: 0, + resolvedUrl: '', + status: 'failed', + }; + } + const retryDelay = + isAdminApiError(error) && error.status === 429 + ? undefined + : ADMIN_ASSET_READ_RETRY_DELAYS_MS[ + current.refreshRetryIndex + ]; + return { + ...current, + nextRefreshAtMs: + typeof retryDelay === 'number' + ? Math.min( + current.expiresAtMs ?? Number.POSITIVE_INFINITY, + Date.now() + retryDelay, + ) + : current.expiresAtMs, + refreshRetryIndex: + typeof retryDelay === 'number' + ? current.refreshRetryIndex + 1 + : current.refreshRetryIndex, + status: 'ready', + }; + }); } }) .finally(() => { @@ -626,6 +664,8 @@ function useAdminImageSequenceFrameCache( resolutionControllersRef.current.delete(frameKey); updateFrameCacheEntry(frameKey, () => ({ expiresAtMs: null, + nextRefreshAtMs: null, + refreshRetryIndex: 0, resolvedUrl: '', status: 'failed', })); @@ -668,13 +708,16 @@ function shouldReuseAdminImageSequenceFrameCacheEntry( if (cached.status === 'failed') { return false; } - if (cached.expiresAtMs === null) { - return true; - } - return shouldReuseSignedReadUrlCacheEntry({ - signedUrl: cached.resolvedUrl, - expiresAtMs: cached.expiresAtMs, - }); + return cached.nextRefreshAtMs === null || cached.nextRefreshAtMs > Date.now(); +} + +function isAdminImageSequenceFrameUrlUsable( + cached: AdminImageSequenceFrameCacheEntry | undefined, +): cached is AdminImageSequenceFrameCacheEntry { + return Boolean( + cached?.resolvedUrl && + (cached.expiresAtMs === null || cached.expiresAtMs > Date.now()), + ); } function findNextAvailableFrameIndex( diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ffc0998e3..84c5fa994 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -69,7 +69,7 @@ npm run check:server-rs-ddd - 现役 runtime 前缀公共能力:`GET /api/runtime/frontend-config`、鉴权的 `GET/PUT /api/runtime/settings` 以及鉴权的 `/api/runtime/external-generation/*`。这些分别承载非敏感前端开关、账号级公共设置和外部生成队列观测 / 确认,路径保留 `runtime` 前缀不代表旧玩法运行态恢复。`/api/runtime/frontend-config` 由 `api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭。 - 公共设置数据链:`GET/PUT /api/runtime/settings` 从 access token 取得 `user_id`,经 `spacetime-client` facade 调用 `get_runtime_setting_or_default` / `upsert_runtime_setting_and_return`,读写现役 `runtime_setting` 表的 `music_volume` 和 `platform_theme`。该表不是历史数据壳,也不得为保留它而恢复任何旧存档、游玩历史或玩法 settings 路由。 - 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset` 中 `source_type = 'generated'` 的素材,支持 `ownerUserId`、`keyword`、`createdAfter`、`createdBefore`、`cursor` 和 `limit`;`ownerUserId` 接受内部用户 ID 或精确陶泥号,精确 `SY-*` keyword 也会在调用 procedure 前经认证服务解析成内部 `user_id`,未知陶泥号直接返回空列表。带 owner 条件时 procedure 直接走 `by_editor_asset_owner_user_id`,再按任务分组并以最终产物父项分页;响应中每个父项通过 `children` 携带可展开的中间产物,子项各占一行而不单独占分页名额。手动重拆图集保留真实 `editor-atlas-split-*` `task_id`;来源任务只能通过私有 provenance 由服务端生成账号素材的 source resource / asset object / Object Key 推导,再写入 `group_task_id`,不能信任素材库兼容推断值或普通资源创建接口可提交的 `task_id/asset_kind`;procedure 只有当前 editor generation runtime service identity 才能写入归组与完成事实。没有可信来源的现代拆分显式归到自身任务,不进入 legacy 回溯。每个切片同时写入 `group_task_expected_asset_count`,全部预期 asset ID 成功落库并逐行校验后写入不可逆 cohort 完成事实;read model 只让具有完成事实的一个拆分批次并入原图集父项,用户后来删除单片不会改变初始完成状态,部分失败批次与后续重复拆分按真实任务独立分页,避免残缺批次抢占根任务、单组无限增长或丢素材。历史行兼容沿项目资源链回溯,删除项目时只固化直接引用待删资源且尚未固化的历史行,持久化真实来源任务,不把有界展示 ID 反写覆盖来源。父项返回任务生成器和任务总成本,子项返回阶段生成器和阶段成本。provider 原图 / 角色动作预览承载模型生成成本,抠图、逐帧处理、透明图集和切片成本为 0;中间产物使用所属任务真实 `asset_kind`,不再新写 `editor_green_screen_source`,历史旧值只在 read model 中按 Object Key 映射为 `character`、`icon-spritesheet` 或 `character-animation` 并把旧任务成本只读归回 provider 原始产物。游标固定由父任务微秒时间和素材 ID 组成,API 必须同时识别整数微秒、`seconds.microsZ` 与 RFC3339 时间文本;内部时间无法编码时返回服务错误,不得静默返回 `nextCursor = null`。接口只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。图片放大、视频和音频预览统一调用仅后台管理员会话可访问的 `GET /admin/api/assets/read-url`;该入口仅为预览允许后台跨 owner 签名,不改变主站 `/api/assets/read-*` 或 External API 的 owner 边界。成功换签后必须写入 `event_key = admin_asset_read_url` 的 `tracking_event`,记录管理员 subject、Object Key / legacy path 和有效期,不记录 signed URL。 -- 后台精选审核:`GET /admin/api/editor-showcase/assets` 的素材 payload 与素材查询共同返回稳定媒体引用,并只透传底层正式 `thumbnailSrc`、`imageSequenceFrames` 与 `imageSequenceDurationMs`;缺少正式动作字段的存量数据必须先经角色动作规范化 procedure 迁移,不在 admin mapper 回读旧 `generationInputs`。两页前端共用同一缩略图、媒体类型、管理员换签和预览弹窗:动作列表缩略图只换签首帧,打开弹窗后才逐帧换签并按完整时长播放,不得在精选审核中复制一套立即全量换签或把绝对 OSS 私有地址直接交给浏览器的简化实现。角色动作弹窗由预览父组件持有当前序列有界的 signed URL、`expiresAt`、解析中、已加载和失败状态,帧元素只消费缓存 URL;缓存沿用主站 signed URL 读取缓存的 30 秒过期安全窗口,当前挂载帧进入窗口时自动换签,已经离开 DOM 的帧只在未临近过期时复用 URL,过期后再次进入窗口必须先换签。缓存最多覆盖当前序列帧,DOM 仍最多挂载当前可见窗口的 3 帧,切换序列、管理员 token 或关闭弹窗时中止在途换签并释放缓存;换签期间继续展示上一已就绪帧,不得在新地址尚未加载完成时提前隐藏可见帧。 +- 后台精选审核:`GET /admin/api/editor-showcase/assets` 的素材 payload 与素材查询共同返回稳定媒体引用,并只透传底层正式 `thumbnailSrc`、`imageSequenceFrames` 与 `imageSequenceDurationMs`;缺少正式动作字段的存量数据必须先经角色动作规范化 procedure 迁移,不在 admin mapper 回读旧 `generationInputs`。两页前端共用同一缩略图、媒体类型、管理员换签和预览弹窗:动作列表缩略图只换签首帧,打开弹窗后才逐帧换签并按完整时长播放,不得在精选审核中复制一套立即全量换签或把绝对 OSS 私有地址直接交给浏览器的简化实现。角色动作弹窗由预览父组件持有当前序列有界的 signed URL、`expiresAt`、解析中、已加载和失败状态,帧元素只消费缓存 URL;缓存沿用主站 signed URL 读取缓存的 30 秒过期安全窗口,当前挂载帧进入窗口时自动换签,已经离开 DOM 的帧只在未临近过期时复用 URL,过期后再次进入窗口必须先换签。提前换签失败时必须在真实过期前继续展示已有已就绪 URL,并按有界退避自动重试;只有当前不存在可用 URL 或图片解码失败时才把该帧标记为失败。缓存最多覆盖当前序列帧,DOM 仍最多挂载当前可见窗口的 3 帧,切换序列、管理员 token 或关闭弹窗时中止在途换签并释放缓存;换签期间继续展示上一已就绪帧,不得在新地址尚未加载完成时提前隐藏可见帧。 现役路由只以 `app.rs::build_router` 实际 `.merge(...)` 的 module 和支付回调为准。`modules/` 中仍保留但未被 `app.rs` 声明 / 合并的 RPG、拼图、Match3D、敲木鱼、方洞、视觉小说、大鱼、跳一跳、汪汪声浪、儿童向、旧公开作品与 `play_flow` 源码都是历史追溯材料,不得由新的 `.merge(...)`、handler 转发或兼容 router 重新挂载。`/api/creation-entry/config`、`/admin/api/creation-entry/config*`、旧 `/api/creation/*`、旧 `/api/runtime//*`、旧存档 / 游玩历史 / 公开作品路由均不是现役 API。新增公共路由仍必须经 `app.rs` 的统一中间件、鉴权、背压、埋点与可观测边界。 ### 图片画布 Agent 对话