收紧生成任务轮询与资产读取路径
空生成任务列表不再保持四秒轮询 读取资源字节优先通过read-url直连OSS 保留read-bytes作为OSS读取失败兜底 更新资产导出与项目记忆文档口径
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
|
||||
- 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。
|
||||
- 决策:`external_generation_job` 同时作为正式生成任务列表事实源,保存 `price_mud_points`、`refund_ledger_id` 和 `notification_acknowledged_at`;新增 `external_generation_job_event` 追加状态转换审计。BFF 新增当前账号任务列表和 acknowledge 接口;前端只展示后端任务状态,完成 / 失败提示关闭时由后端写确认时间,未确认终态任务在下次登录后按列表集中弹出。任务触发的钱包扣费 / 退款流水 metadata 必须写 `externalGenerationJobId`,本机退款 outbox 重放也保留该任务 ID。
|
||||
- 2026-06-25 追加:平台壳的当前账号任务列表只在登录、网络恢复、页面回到前台或已有 queued/running/未确认终态任务时刷新;空队列刷新一次后不保持 4 秒轮询,避免 `/api/runtime/external-generation/jobs` 在无任务时持续请求。
|
||||
- 影响范围:`spacetime-module` 外部生成 schema / procedure、`spacetime-client` bindings/facade、`api-server` 外部生成 BFF、worker 失败回写和资产计费退款链路、平台入口“我的”页任务卡和完成提示弹窗。
|
||||
- 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wallet_refund_outbox --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
@@ -1030,6 +1031,7 @@
|
||||
|
||||
- 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。
|
||||
- 决策:OSS 继续作为 generated 私有资产源站,api-server 只签发短期读 URL,不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path,再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。
|
||||
- 2026-06-25 追加:前端需要读取 generated/private 资源字节时,也应先通过 `/api/assets/read-url` 获取 signed OSS URL 并由浏览器直接下载字节;`/api/assets/read-bytes` 只作为换签或 OSS 读取失败后的 fallback,不作为默认文件代理路径。
|
||||
- 影响范围:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss`、`shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。
|
||||
- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径、同一 `refreshKey` 版本且未临近过期时复用本地 signed URL;`platform-oss` 的 `PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/platform-oss/README.md`。
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
- 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`。
|
||||
- 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations` 的 `referenceImageSrcs`;默认示例图和部分持久化图层的 `src` 是 `/creation-type-references/*.webp`、`/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`。
|
||||
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 走 `/api/assets/read-bytes` 避免 CORS,普通 public 路径直接 fetch。
|
||||
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 先走 `/api/assets/read-url` 换签后由浏览器直读 OSS,直读失败时才 fallback 到 `/api/assets/read-bytes`,普通 public 路径直接 fetch。
|
||||
- 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`。
|
||||
- 关联:`src/services/image-editor/editorImageReference.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ assetObjectId > objectKey > sourceAssetId > src
|
||||
4. 对每个素材源读取 Blob:
|
||||
- `data:image/...` 直接转换为 Blob。
|
||||
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
|
||||
- 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。
|
||||
- 私有 generated / OSS 素材先走 `/api/assets/read-url` 换签并由浏览器直接 `fetch` OSS 签名 URL;只有换签或 OSS 字节读取失败时,才 fallback 到同源 `/api/assets/read-bytes`。
|
||||
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。
|
||||
- 画布素材 ZIP 内的 `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。
|
||||
- 单图层普通序列帧导出写入 `preview.gif`,由前端基于成功读取的帧生成动画预览,不依赖压缩包读取时再临时播放 PNG。
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createMiniGameDraftGenerationState } from '../../services/miniGameDraft
|
||||
import {
|
||||
buildExternalGenerationQueuePresentation,
|
||||
buildExternalGenerationQueueStatus,
|
||||
shouldUseFastExternalGenerationTaskPolling,
|
||||
} from './platformExternalGenerationQueueStatusModel';
|
||||
import {
|
||||
resolveFinishedMiniGameDraftGenerationState,
|
||||
@@ -129,4 +130,50 @@ describe('buildExternalGenerationQueueStatus', () => {
|
||||
}).shouldShow,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('只在队列活跃或存在未确认终态任务时使用快速轮询', () => {
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
currentStatus: 'queued',
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [
|
||||
{
|
||||
jobId: 'extgen-completed',
|
||||
jobKind: 'editor_image_generation',
|
||||
sourceModule: 'editor',
|
||||
sourceEntityId: 'project-1',
|
||||
requestLabel: '图片生成',
|
||||
status: 'completed',
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '已完成',
|
||||
progress: 100,
|
||||
priceMudPoints: 1,
|
||||
createdAt: '2026-06-25T00:00:00.000Z',
|
||||
updatedAt: '2026-06-25T00:00:00.000Z',
|
||||
updatedAtMicros: 1_782_348_800_000_000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -544,7 +544,10 @@ import type {
|
||||
} from './platformEntryTypes';
|
||||
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
|
||||
import { PlatformErrorDialog } from './PlatformErrorDialog';
|
||||
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
|
||||
import {
|
||||
buildExternalGenerationQueueStatus,
|
||||
shouldUseFastExternalGenerationTaskPolling,
|
||||
} from './platformExternalGenerationQueueStatusModel';
|
||||
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
|
||||
import {
|
||||
sendPlatformHostDraftNotificationToHost,
|
||||
@@ -707,6 +710,7 @@ type PuzzleBackgroundCompileTask = {
|
||||
generationState: MiniGameDraftGenerationState;
|
||||
error: string | null;
|
||||
};
|
||||
const EXTERNAL_GENERATION_TASK_FAST_POLL_INTERVAL_MS = 4_000;
|
||||
|
||||
function isUnacknowledgedExternalGenerationTerminalTask(
|
||||
task: ExternalGenerationTaskRecord,
|
||||
@@ -4967,6 +4971,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
woodenFishGenerationState,
|
||||
);
|
||||
const isHostNetworkOnline = useHostNetworkOnline();
|
||||
const shouldFastPollKnownExternalGenerationTask =
|
||||
puzzleOperation?.queueState?.status === 'queued' ||
|
||||
puzzleOperation?.queueState?.status === 'running' ||
|
||||
jumpHopQueueState?.status === 'queued' ||
|
||||
jumpHopQueueState?.status === 'running' ||
|
||||
puzzleClearQueueState?.status === 'queued' ||
|
||||
puzzleClearQueueState?.status === 'running' ||
|
||||
woodenFishQueueState?.status === 'queued' ||
|
||||
woodenFishQueueState?.status === 'running';
|
||||
const shouldPollExternalGenerationTasks =
|
||||
platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id);
|
||||
const externalGenerationQueueOwnerKey =
|
||||
@@ -4991,6 +5004,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
let disposed = false;
|
||||
let controller: AbortController | null = null;
|
||||
let timerId: number | null = null;
|
||||
|
||||
const scheduleNextRefresh = (delayMs: number) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
if (timerId != null) {
|
||||
window.clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
if (delayMs <= 0) {
|
||||
return;
|
||||
}
|
||||
timerId = window.setTimeout(refreshTaskList, delayMs);
|
||||
};
|
||||
|
||||
const refreshTaskList = () => {
|
||||
controller?.abort();
|
||||
@@ -5010,6 +5038,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
),
|
||||
);
|
||||
setExternalGenerationTaskOverview(response.overview);
|
||||
scheduleNextRefresh(
|
||||
shouldFastPollKnownExternalGenerationTask ||
|
||||
shouldUseFastExternalGenerationTaskPolling(
|
||||
buildExternalGenerationQueueStatus(
|
||||
response.overview,
|
||||
null,
|
||||
response.tasks,
|
||||
),
|
||||
)
|
||||
? EXTERNAL_GENERATION_TASK_FAST_POLL_INTERVAL_MS
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -5021,16 +5061,28 @@ export function PlatformEntryFlowShellImpl({
|
||||
};
|
||||
|
||||
refreshTaskList();
|
||||
const intervalId = window.setInterval(refreshTaskList, 4000);
|
||||
const handleResume = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
refreshTaskList();
|
||||
};
|
||||
window.addEventListener('focus', handleResume);
|
||||
document.addEventListener('visibilitychange', handleResume);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller?.abort();
|
||||
window.clearInterval(intervalId);
|
||||
if (timerId != null) {
|
||||
window.clearTimeout(timerId);
|
||||
}
|
||||
window.removeEventListener('focus', handleResume);
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
};
|
||||
}, [
|
||||
externalGenerationQueueOwnerKey,
|
||||
isHostNetworkOnline,
|
||||
shouldFastPollKnownExternalGenerationTask,
|
||||
shouldPollExternalGenerationTasks,
|
||||
]);
|
||||
const activeExternalGenerationJobState = useMemo(() => {
|
||||
|
||||
@@ -55,6 +55,48 @@ export function buildExternalGenerationQueueStatus(
|
||||
};
|
||||
}
|
||||
|
||||
function isActiveExternalGenerationStatus(
|
||||
status: ExternalGenerationQueueStatus['currentStatus'],
|
||||
) {
|
||||
return status === 'queued' || status === 'running';
|
||||
}
|
||||
|
||||
function isUnacknowledgedExternalGenerationTerminalTask(
|
||||
task: ExternalGenerationTaskRecord,
|
||||
) {
|
||||
return (
|
||||
(task.status === 'completed' || task.status === 'failed') &&
|
||||
!task.notificationAcknowledgedAt
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldUseFastExternalGenerationTaskPolling(
|
||||
status: ExternalGenerationQueueStatus | null | undefined,
|
||||
) {
|
||||
const pendingCount = normalizeExternalGenerationQueueCount(
|
||||
status?.pendingCount,
|
||||
);
|
||||
const runningCount = normalizeExternalGenerationQueueCount(
|
||||
status?.runningCount,
|
||||
);
|
||||
const unacknowledgedTerminalCount = normalizeExternalGenerationQueueCount(
|
||||
status?.unacknowledgedTerminalCount,
|
||||
);
|
||||
|
||||
return Boolean(
|
||||
isActiveExternalGenerationStatus(status?.currentStatus ?? null) ||
|
||||
pendingCount > 0 ||
|
||||
runningCount > 0 ||
|
||||
unacknowledgedTerminalCount > 0 ||
|
||||
(status?.tasks ?? []).some(
|
||||
(task) =>
|
||||
task.status === 'queued' ||
|
||||
task.status === 'running' ||
|
||||
isUnacknowledgedExternalGenerationTerminalTask(task),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExternalGenerationQueueStatusLabel(
|
||||
status: ExternalGenerationQueueStatus['currentStatus'],
|
||||
) {
|
||||
|
||||
@@ -496,15 +496,44 @@ describe('assetReadUrlService', () => {
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('readAssetBytes reads generated resources through same-origin bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes reads generated resources through signed OSS url first', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
signedUrl: 'https://signed.example.com/match3d-item.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
@@ -515,22 +544,53 @@ describe('assetReadUrlService', () => {
|
||||
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/match3d-item.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes reads object-key resources through same-origin bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes reads object-key resources through signed OSS url first', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
signedUrl: 'https://signed.example.com/editor-image.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes('/generated-editor-images/image.png', {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
@@ -540,7 +600,7 @@ describe('assetReadUrlService', () => {
|
||||
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'objectKey=generated-editor-images%2Fproject%2Fimage.png',
|
||||
@@ -548,17 +608,49 @@ describe('assetReadUrlService', () => {
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).not.toContain(
|
||||
'legacyPublicPath=',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/editor-image.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes normalizes full OSS generated urls through signed read url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/session/profile/covers/main.png',
|
||||
signedUrl: 'https://signed.example.com/main.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-puzzle-assets/session/profile/covers/main.png?x-oss-signature=abc',
|
||||
@@ -567,10 +659,70 @@ describe('assetReadUrlService', () => {
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fsession%2Fprofile%2Fcovers%2Fmain.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/main.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes falls back to same-origin bytes endpoint when OSS read fails', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
signedUrl: 'https://signed.example.com/editor-image.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('', { status: 403 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([9, 8, 7]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes('/generated-editor-images/image.png', {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
expect(Array.from(bytes)).toEqual([9, 8, 7]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/editor-image.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -548,17 +548,41 @@ export async function readAssetBytes(
|
||||
return response;
|
||||
}
|
||||
|
||||
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
const readRequest = {
|
||||
objectKey,
|
||||
legacyPublicPath: objectKey ? undefined : legacyPath,
|
||||
expireSeconds: options.expireSeconds,
|
||||
};
|
||||
try {
|
||||
const signedUrl = await getSignedAssetReadUrl(readRequest, options.signal);
|
||||
const response = await fetch(signedUrl, { signal: options.signal });
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
} catch {
|
||||
if (options.signal?.aborted) {
|
||||
throw createSignedReadUrlAbortError();
|
||||
}
|
||||
// 中文注释:浏览器直读 OSS 失败时再走同源字节代理兜底。
|
||||
}
|
||||
|
||||
return readAssetBytesViaFallbackApi(readRequest, options.signal);
|
||||
}
|
||||
|
||||
async function readAssetBytesViaFallbackApi(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
objectKey: request.objectKey,
|
||||
legacyPublicPath: request.legacyPublicPath,
|
||||
expireSeconds: request.expireSeconds,
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
`${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
signal,
|
||||
},
|
||||
{
|
||||
...ASSET_READ_URL_BACKGROUND_OPTIONS,
|
||||
|
||||
@@ -27,14 +27,29 @@ describe('match3dGeneratedModelCache', () => {
|
||||
|
||||
test('预加载生成模型字节并复用本地缓存', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([103, 108, 84, 70]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'model/gltf-binary',
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl: 'https://oss.example.com/model.glb',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([103, 108, 84, 70]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'model/gltf-binary',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
[
|
||||
@@ -61,7 +76,13 @@ describe('match3dGeneratedModelCache', () => {
|
||||
);
|
||||
|
||||
expect(Array.from(new Uint8Array(bytes))).toEqual([103, 108, 84, 70]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://oss.example.com/model.glb',
|
||||
);
|
||||
});
|
||||
|
||||
test('模型源列表会去重并兼容 modelObjectKey', () => {
|
||||
|
||||
Reference in New Issue
Block a user