合并 master 到跳一跳分支
合入 origin/master 最新变更 解决跳一跳提示词测试与图集去背冲突
This commit is contained in:
@@ -423,7 +423,7 @@ function createApiEnvelopeResponse(data: unknown) {
|
||||
data,
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
} as Response;
|
||||
@@ -1181,7 +1181,7 @@ describe('ai runtime client orchestration', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('apiClient', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -103,7 +103,7 @@ describe('apiClient', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -164,7 +164,7 @@ describe('apiClient', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -179,7 +179,7 @@ describe('apiClient', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -409,7 +409,7 @@ describe('apiClient', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -440,7 +440,7 @@ describe('apiClient', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -502,7 +502,7 @@ describe('apiClient', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
@@ -572,7 +572,7 @@ describe('apiClient', () => {
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
requestId: 'req-body',
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveAppTitleForSelectionStage,
|
||||
syncAppTitle,
|
||||
} from './appTitle';
|
||||
|
||||
const hostBridgeMock = vi.hoisted(() => ({
|
||||
setHostAppTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./host-bridge/hostBridge', () => ({
|
||||
setHostAppTitle: hostBridgeMock.setHostAppTitle,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
document.title = '';
|
||||
hostBridgeMock.setHostAppTitle.mockReset();
|
||||
});
|
||||
|
||||
describe('appTitle', () => {
|
||||
test('按平台阶段生成可读页面标题', () => {
|
||||
expect(resolveAppTitleForSelectionStage('platform')).toBe('陶泥儿');
|
||||
expect(resolveAppTitleForSelectionStage('work-detail')).toBe(
|
||||
'作品详情 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('match3d-runtime')).toBe(
|
||||
'抓大鹅 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('puzzle-generating')).toBe(
|
||||
'拼图生成中 - 陶泥儿',
|
||||
);
|
||||
});
|
||||
|
||||
test('同步浏览器标题并请求宿主窗口标题', () => {
|
||||
syncAppTitle(' 拼图 - 陶泥儿 ');
|
||||
|
||||
expect(document.title).toBe('拼图 - 陶泥儿');
|
||||
expect(hostBridgeMock.setHostAppTitle).toHaveBeenCalledWith({
|
||||
title: '拼图 - 陶泥儿',
|
||||
});
|
||||
});
|
||||
|
||||
test('空标题回退到品牌名', () => {
|
||||
syncAppTitle(' ');
|
||||
|
||||
expect(document.title).toBe('陶泥儿');
|
||||
expect(hostBridgeMock.setHostAppTitle).toHaveBeenCalledWith({
|
||||
title: '陶泥儿',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { SelectionStage } from '../components/platform-entry';
|
||||
import { setHostAppTitle } from './host-bridge/hostBridge';
|
||||
|
||||
const APP_TITLE_BRAND = '陶泥儿';
|
||||
|
||||
const APP_TITLE_BY_SELECTION_STAGE: Partial<Record<SelectionStage, string>> = {
|
||||
'agent-workspace': 'RPG 创作',
|
||||
'baby-love-drawing-runtime': '宝贝爱画',
|
||||
'baby-object-match-generating': '宝贝识物生成中',
|
||||
'baby-object-match-result': '宝贝识物结果',
|
||||
'baby-object-match-runtime': '宝贝识物',
|
||||
'baby-object-match-workspace': '宝贝识物创作',
|
||||
'bark-battle-generating': '汪汪声浪生成中',
|
||||
'bark-battle-result': '汪汪声浪结果',
|
||||
'bark-battle-runtime': '汪汪声浪',
|
||||
'bark-battle-workspace': '汪汪声浪创作',
|
||||
'big-fish-agent-workspace': '大鱼吃小鱼创作',
|
||||
'big-fish-generating': '大鱼吃小鱼生成中',
|
||||
'big-fish-result': '大鱼吃小鱼结果',
|
||||
'big-fish-runtime': '大鱼吃小鱼',
|
||||
'creative-agent-workspace': '创意 Agent',
|
||||
'custom-world-generating': 'RPG 生成中',
|
||||
'custom-world-result': 'RPG 结果',
|
||||
detail: '世界详情',
|
||||
'jump-hop-gallery-detail': '跳一跳详情',
|
||||
'jump-hop-generating': '跳一跳生成中',
|
||||
'jump-hop-result': '跳一跳结果',
|
||||
'jump-hop-runtime': '跳一跳',
|
||||
'jump-hop-workspace': '跳一跳创作',
|
||||
'match3d-agent-workspace': '抓大鹅创作',
|
||||
'match3d-generating': '抓大鹅生成中',
|
||||
'match3d-result': '抓大鹅结果',
|
||||
'match3d-runtime': '抓大鹅',
|
||||
platform: APP_TITLE_BRAND,
|
||||
'profile-feedback': '反馈与投诉',
|
||||
'puzzle-agent-workspace': '拼图创作',
|
||||
'puzzle-clear-generating': '拼消消生成中',
|
||||
'puzzle-clear-result': '拼消消结果',
|
||||
'puzzle-clear-runtime': '拼消消',
|
||||
'puzzle-clear-workspace': '拼消消创作',
|
||||
'puzzle-gallery-detail': '拼图详情',
|
||||
'puzzle-generating': '拼图生成中',
|
||||
'puzzle-onboarding': '拼图教学',
|
||||
'puzzle-result': '拼图结果',
|
||||
'puzzle-runtime': '拼图',
|
||||
'square-hole-agent-workspace': '方洞挑战创作',
|
||||
'square-hole-generating': '方洞挑战生成中',
|
||||
'square-hole-result': '方洞挑战结果',
|
||||
'square-hole-runtime': '方洞挑战',
|
||||
'visual-novel-agent-workspace': '视觉小说创作',
|
||||
'visual-novel-gallery-detail': '视觉小说详情',
|
||||
'visual-novel-generating': '视觉小说生成中',
|
||||
'visual-novel-result': '视觉小说结果',
|
||||
'visual-novel-runtime': '视觉小说',
|
||||
'wooden-fish-generating': '敲木鱼生成中',
|
||||
'wooden-fish-result': '敲木鱼结果',
|
||||
'wooden-fish-runtime': '敲木鱼',
|
||||
'wooden-fish-workspace': '敲木鱼创作',
|
||||
'work-detail': '作品详情',
|
||||
};
|
||||
|
||||
export function resolveAppTitleForSelectionStage(stage: SelectionStage) {
|
||||
const title = APP_TITLE_BY_SELECTION_STAGE[stage] ?? APP_TITLE_BRAND;
|
||||
if (title === APP_TITLE_BRAND) {
|
||||
return title;
|
||||
}
|
||||
|
||||
return `${title} - ${APP_TITLE_BRAND}`;
|
||||
}
|
||||
|
||||
export function syncAppTitle(title: string) {
|
||||
const normalizedTitle = title.trim() || APP_TITLE_BRAND;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.title = normalizedTitle;
|
||||
}
|
||||
|
||||
void setHostAppTitle({ title: normalizedTitle });
|
||||
}
|
||||
@@ -33,10 +33,13 @@ function createLocalStorageMock() {
|
||||
|
||||
describe('assetReadUrlService', () => {
|
||||
beforeEach(() => {
|
||||
const sessionStorage = createLocalStorageMock();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createLocalStorageMock(),
|
||||
sessionStorage,
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('sessionStorage', sessionStorage);
|
||||
clearSignedAssetReadUrlCache();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
@@ -74,8 +77,8 @@ describe('assetReadUrlService', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
@@ -114,8 +117,8 @@ describe('assetReadUrlService', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
@@ -155,8 +158,8 @@ describe('assetReadUrlService', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
@@ -198,8 +201,8 @@ describe('assetReadUrlService', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
@@ -240,8 +243,8 @@ describe('assetReadUrlService', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
@@ -285,8 +288,8 @@ describe('assetReadUrlService', () => {
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
@@ -312,6 +315,109 @@ describe('assetReadUrlService', () => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('getSignedAssetReadUrl reuses session cached signed url after module reload', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/specs/spec-image.png',
|
||||
signedUrl: 'https://signed.example.com/spec-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',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const request = {
|
||||
objectKey: 'generated-character-drafts/editor/specs/spec-image.png',
|
||||
};
|
||||
const first = await getSignedAssetReadUrl(request);
|
||||
vi.resetModules();
|
||||
const { getSignedAssetReadUrl: getSignedAssetReadUrlAfterReload } =
|
||||
await import('./assetReadUrlService');
|
||||
const second = await getSignedAssetReadUrlAfterReload(request);
|
||||
|
||||
expect(first).toBe('https://signed.example.com/spec-image.png');
|
||||
expect(second).toBe(first);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('getSignedAssetReadUrl throttles distinct signed url bursts', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input) => {
|
||||
const url = new URL(
|
||||
String(input),
|
||||
globalThis.location?.origin ?? 'https://www.genarrative.world',
|
||||
);
|
||||
const objectKey = url.searchParams.get('objectKey') ?? 'missing.png';
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey,
|
||||
signedUrl: `https://signed.example.com/${encodeURIComponent(objectKey)}`,
|
||||
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',
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const requests = Array.from({ length: 28 }, (_, index) =>
|
||||
getSignedAssetReadUrl({
|
||||
objectKey: `generated-character-drafts/editor/ui-design-assets/result/asset-${String(index + 1).padStart(3, '0')}.png`,
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(24);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(16);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(25);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(48);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(28);
|
||||
await expect(Promise.all(requests)).resolves.toHaveLength(28);
|
||||
});
|
||||
|
||||
test('getSignedAssetReadUrl caches not-found failures for the same legacy path', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
@@ -323,8 +429,8 @@ describe('assetReadUrlService', () => {
|
||||
message: '对象不存在',
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
@@ -363,8 +469,8 @@ describe('assetReadUrlService', () => {
|
||||
message: '登录状态已失效',
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
apiVersion: '2026-06-16',
|
||||
routeVersion: '2026-06-16',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
@@ -390,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',
|
||||
@@ -409,22 +544,113 @@ 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 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 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',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'objectKey=generated-editor-images%2Fproject%2Fimage.png',
|
||||
);
|
||||
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 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',
|
||||
@@ -433,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?',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ type AssetReadUrlResolveOptions = {
|
||||
type AssetReadBytesOptions = {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
objectKey?: string | null;
|
||||
};
|
||||
|
||||
export type AssetReadUrlResponse = {
|
||||
@@ -59,9 +60,15 @@ const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
||||
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
|
||||
const ASSET_READ_URL_BACKGROUND_OPTIONS =
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions;
|
||||
const SIGNED_READ_URL_SESSION_CACHE_PREFIX =
|
||||
'genarrative.assetReadUrlCache.v1:';
|
||||
const SIGNED_READ_URL_INITIAL_DISPATCH_BURST = 24;
|
||||
const SIGNED_READ_URL_DISPATCH_SPACING_MS = 16;
|
||||
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
|
||||
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
|
||||
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
||||
let signedReadUrlDispatchBurstUsed = 0;
|
||||
let signedReadUrlNextDispatchAtMs = 0;
|
||||
|
||||
export function isGeneratedLegacyPath(value: string) {
|
||||
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
@@ -193,6 +200,149 @@ function shouldReuseCachedReadUrlFailure(
|
||||
return entry.expiresAtMs > Date.now();
|
||||
}
|
||||
|
||||
function signedReadUrlSessionCacheKey(cacheKey: string) {
|
||||
return `${SIGNED_READ_URL_SESSION_CACHE_PREFIX}${encodeURIComponent(cacheKey)}`;
|
||||
}
|
||||
|
||||
function getSignedReadUrlSessionStorage() {
|
||||
try {
|
||||
return globalThis.sessionStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readSignedUrlSessionCache(
|
||||
cacheKey: string,
|
||||
): CachedReadUrlEntry | undefined {
|
||||
const storage = getSignedReadUrlSessionStorage();
|
||||
if (!storage || !cacheKey) {
|
||||
return undefined;
|
||||
}
|
||||
const storageKey = signedReadUrlSessionCacheKey(cacheKey);
|
||||
try {
|
||||
const rawValue = storage.getItem(storageKey);
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(rawValue) as Partial<CachedReadUrlEntry>;
|
||||
if (
|
||||
typeof parsed.signedUrl !== 'string' ||
|
||||
typeof parsed.expiresAtMs !== 'number' ||
|
||||
!Number.isFinite(parsed.expiresAtMs)
|
||||
) {
|
||||
storage.removeItem(storageKey);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
signedUrl: parsed.signedUrl,
|
||||
expiresAtMs: parsed.expiresAtMs,
|
||||
};
|
||||
} catch {
|
||||
try {
|
||||
storage.removeItem(storageKey);
|
||||
} catch {
|
||||
// ignore storage cleanup errors
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSignedUrlSessionCache(
|
||||
cacheKey: string,
|
||||
entry: CachedReadUrlEntry,
|
||||
) {
|
||||
const storage = getSignedReadUrlSessionStorage();
|
||||
if (!storage || !cacheKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.setItem(
|
||||
signedReadUrlSessionCacheKey(cacheKey),
|
||||
JSON.stringify(entry),
|
||||
);
|
||||
} catch {
|
||||
// ignore storage quota or privacy mode failures
|
||||
}
|
||||
}
|
||||
|
||||
function resetSignedReadUrlDispatchLimiter() {
|
||||
signedReadUrlDispatchBurstUsed = 0;
|
||||
signedReadUrlNextDispatchAtMs = 0;
|
||||
}
|
||||
|
||||
function reserveSignedReadUrlDispatchDelayMs(nowMs = Date.now()) {
|
||||
if (signedReadUrlNextDispatchAtMs < nowMs) {
|
||||
signedReadUrlDispatchBurstUsed = 0;
|
||||
signedReadUrlNextDispatchAtMs = nowMs;
|
||||
}
|
||||
|
||||
if (
|
||||
signedReadUrlDispatchBurstUsed < SIGNED_READ_URL_INITIAL_DISPATCH_BURST
|
||||
) {
|
||||
signedReadUrlDispatchBurstUsed += 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const dispatchAtMs = Math.max(
|
||||
signedReadUrlNextDispatchAtMs + SIGNED_READ_URL_DISPATCH_SPACING_MS,
|
||||
nowMs + SIGNED_READ_URL_DISPATCH_SPACING_MS,
|
||||
);
|
||||
signedReadUrlNextDispatchAtMs = dispatchAtMs;
|
||||
return Math.max(0, dispatchAtMs - nowMs);
|
||||
}
|
||||
|
||||
function createSignedReadUrlAbortError() {
|
||||
if (typeof DOMException === 'function') {
|
||||
return new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
return new Error('The operation was aborted.');
|
||||
}
|
||||
|
||||
async function waitForSignedReadUrlDispatch(signal?: AbortSignal) {
|
||||
if (signal?.aborted) {
|
||||
throw createSignedReadUrlAbortError();
|
||||
}
|
||||
|
||||
const delayMs = reserveSignedReadUrlDispatchDelayMs();
|
||||
if (delayMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', handleAbort);
|
||||
resolve();
|
||||
}, delayMs);
|
||||
|
||||
function handleAbort() {
|
||||
clearTimeout(timer);
|
||||
reject(createSignedReadUrlAbortError());
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', handleAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function clearSignedUrlSessionCache() {
|
||||
const storage = getSignedReadUrlSessionStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key?.startsWith(SIGNED_READ_URL_SESSION_CACHE_PREFIX)) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
keys.forEach((key) => storage.removeItem(key));
|
||||
} catch {
|
||||
// ignore storage cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSignedAssetReadUrl(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
@@ -211,6 +361,12 @@ export async function getSignedAssetReadUrl(
|
||||
if (cached && shouldReuseCachedReadUrl(cached)) {
|
||||
return cached.signedUrl;
|
||||
}
|
||||
const sessionCached =
|
||||
!bypassCache && cacheKey ? readSignedUrlSessionCache(cacheKey) : undefined;
|
||||
if (sessionCached && shouldReuseCachedReadUrl(sessionCached)) {
|
||||
signedReadUrlCache.set(cacheKey, sessionCached);
|
||||
return sessionCached.signedUrl;
|
||||
}
|
||||
|
||||
const cachedFailure = !bypassCache && cacheKey
|
||||
? signedReadUrlFailureCache.get(cacheKey)
|
||||
@@ -230,6 +386,7 @@ export async function getSignedAssetReadUrl(
|
||||
const searchParams = buildAssetReadSearchParams(request);
|
||||
|
||||
try {
|
||||
await waitForSignedReadUrlDispatch(signal);
|
||||
const response = await requestJson<AssetReadUrlResponse>(
|
||||
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
|
||||
{
|
||||
@@ -250,10 +407,12 @@ export async function getSignedAssetReadUrl(
|
||||
}
|
||||
|
||||
if (cacheKey && expiresAtMs > 0) {
|
||||
signedReadUrlCache.set(cacheKey, {
|
||||
const entry = {
|
||||
signedUrl: payload.signedUrl,
|
||||
expiresAtMs,
|
||||
});
|
||||
};
|
||||
signedReadUrlCache.set(cacheKey, entry);
|
||||
writeSignedUrlSessionCache(cacheKey, entry);
|
||||
}
|
||||
|
||||
return payload.signedUrl;
|
||||
@@ -376,28 +535,54 @@ export async function readAssetBytes(
|
||||
throw new Error('资源路径不能为空');
|
||||
}
|
||||
|
||||
const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? '';
|
||||
const legacyPath = isGeneratedLegacyPath(value)
|
||||
? value
|
||||
: resolveGeneratedLegacyPathFromUrl(value);
|
||||
|
||||
if (!legacyPath) {
|
||||
if (!objectKey && !legacyPath) {
|
||||
const response = await fetch(value, { signal: options.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error('读取资源内容失败');
|
||||
throw new Error(`读取资源内容失败(HTTP ${response.status})`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
legacyPublicPath: legacyPath,
|
||||
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,
|
||||
@@ -423,4 +608,6 @@ export function clearSignedAssetReadUrlCache() {
|
||||
signedReadUrlCache.clear();
|
||||
signedReadUrlFailureCache.clear();
|
||||
pendingSignedReadUrlRequests.clear();
|
||||
resetSignedReadUrlDispatchLimiter();
|
||||
clearSignedUrlSessionCache();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ const apiClientMocks = vi.hoisted(() => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
const hostBridgeMocks = vi.hoisted(() => ({
|
||||
openHostExternalUrl: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
@@ -15,6 +19,17 @@ vi.mock('./apiClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./host-bridge/hostBridge', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./host-bridge/hostBridge')>(
|
||||
'./host-bridge/hostBridge',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
openHostExternalUrl: hostBridgeMocks.openHostExternalUrl,
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiClientError } from './apiClient';
|
||||
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
@@ -83,6 +98,7 @@ describe('authService', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
hostBridgeMocks.openHostExternalUrl.mockResolvedValue(false);
|
||||
vi.stubGlobal('window', createWindowMock());
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
@@ -394,11 +410,40 @@ describe('authService', () => {
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
expect(hostBridgeMocks.openHostExternalUrl).toHaveBeenCalledWith({
|
||||
url: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
});
|
||||
expect(assignMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts wechat login through native host external browser when available', async () => {
|
||||
const assignMock = vi.fn();
|
||||
hostBridgeMocks.openHostExternalUrl.mockResolvedValue(true);
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
}),
|
||||
);
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
authorizationUrl: 'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||||
});
|
||||
|
||||
await startWechatLogin();
|
||||
|
||||
expect(hostBridgeMocks.openHostExternalUrl).toHaveBeenCalledWith({
|
||||
url: 'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||||
});
|
||||
expect(assignMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests mini program phone login by opening the native auth page', async () => {
|
||||
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
|
||||
+11
-97
@@ -36,9 +36,14 @@ import {
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import { openHostExternalUrl } from './host-bridge/hostBridge';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||
export {
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
requestHostLogin as requestWechatMiniProgramPhoneLogin,
|
||||
} from './host-bridge/hostBridge';
|
||||
|
||||
export type AuthSessionSnapshot = {
|
||||
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
||||
@@ -55,10 +60,6 @@ export type ConsumedAuthCallback = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const MINI_PROGRAM_AUTH_PAGE_URL =
|
||||
'/pages/web-view/index?authAction=login&returnTo=previous';
|
||||
|
||||
// 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测,
|
||||
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
|
||||
const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||
@@ -84,98 +85,6 @@ export function clearRuntimeGuestTokenCache() {
|
||||
runtimeGuestTokenCache.value = null;
|
||||
}
|
||||
|
||||
export function isWechatMiniProgramWebViewRuntime() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const userAgent =
|
||||
typeof navigator === 'undefined' ? '' : navigator.userAgent || '';
|
||||
const normalizedUserAgent = userAgent.toLowerCase();
|
||||
|
||||
return (
|
||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||
params.get('clientType') === 'mini_program' ||
|
||||
(normalizedUserAgent.includes('micromessenger') &&
|
||||
normalizedUserAgent.includes('miniprogram')) ||
|
||||
Boolean(window.wx?.miniProgram?.postMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function loadWechatMiniProgramBridge() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.reject(new Error('请在微信小程序内完成登录'));
|
||||
}
|
||||
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
return Promise.resolve(window.wx);
|
||||
}
|
||||
|
||||
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`script[src="${WECHAT_JS_SDK_URL}"]`,
|
||||
);
|
||||
const complete = () => {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
resolve(window.wx);
|
||||
} else {
|
||||
reject(new Error('请在微信小程序内完成登录'));
|
||||
}
|
||||
};
|
||||
|
||||
if (existingScript) {
|
||||
if (window.wx?.miniProgram?.navigateTo) {
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
|
||||
existingScript.addEventListener('load', complete, { once: true });
|
||||
existingScript.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('请在微信小程序内完成登录')),
|
||||
{ once: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = WECHAT_JS_SDK_URL;
|
||||
script.async = true;
|
||||
script.onload = complete;
|
||||
script.onerror = () => reject(new Error('请在微信小程序内完成登录'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestWechatMiniProgramPhoneLogin() {
|
||||
if (!isWechatMiniProgramWebViewRuntime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wxBridge = await loadWechatMiniProgramBridge();
|
||||
const miniProgram = wxBridge.miniProgram;
|
||||
const navigateTo = miniProgram?.navigateTo;
|
||||
if (typeof navigateTo !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
navigateTo({
|
||||
url: MINI_PROGRAM_AUTH_PAGE_URL,
|
||||
success() {
|
||||
resolve();
|
||||
},
|
||||
fail(error) {
|
||||
reject(
|
||||
new Error(error?.errMsg || '请在微信小程序内完成登录'),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function ensureRuntimeGuestToken() {
|
||||
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
|
||||
return runtimeGuestTokenCache.value!;
|
||||
@@ -372,7 +281,12 @@ export async function startWechatLogin() {
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
window.location.assign(response.authorizationUrl);
|
||||
const openedByHost = await openHostExternalUrl({
|
||||
url: response.authorizationUrl,
|
||||
});
|
||||
if (!openedByHost) {
|
||||
window.location.assign(response.authorizationUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthLoginOptions() {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { copyTextToClipboard } from './clipboard';
|
||||
import * as hostBridge from './host-bridge/hostBridge';
|
||||
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyTextToClipboard', () => {
|
||||
test('原生 App 优先通过 HostBridge 写入剪贴板', async () => {
|
||||
const writeHostClipboardText = vi
|
||||
.spyOn(hostBridge, 'writeHostClipboardText')
|
||||
.mockResolvedValue(true);
|
||||
const writeText = vi.fn();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
await expect(copyTextToClipboard(' PZ-00000001 ')).resolves.toBe(true);
|
||||
|
||||
expect(writeHostClipboardText).toHaveBeenCalledWith({
|
||||
text: 'PZ-00000001',
|
||||
});
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('HostBridge 未处理时回退到浏览器 Clipboard API', async () => {
|
||||
vi.spyOn(hostBridge, 'writeHostClipboardText').mockResolvedValue(false);
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
await expect(copyTextToClipboard(' PZ-00000002 ')).resolves.toBe(true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-00000002');
|
||||
});
|
||||
|
||||
test('空文本不触发宿主或浏览器复制', async () => {
|
||||
const writeHostClipboardText = vi
|
||||
.spyOn(hostBridge, 'writeHostClipboardText')
|
||||
.mockResolvedValue(true);
|
||||
const writeText = vi.fn();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
|
||||
await expect(copyTextToClipboard(' ')).resolves.toBe(false);
|
||||
|
||||
expect(writeHostClipboardText).not.toHaveBeenCalled();
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { writeHostClipboardText } from './host-bridge/hostBridge';
|
||||
|
||||
export async function copyTextToClipboard(value: string) {
|
||||
const text = value.trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await writeHostClipboardText({ text })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { requestJson } from '../apiClient';
|
||||
import { acknowledgeExternalGenerationTasks } from './externalGenerationClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS: {
|
||||
skipAuth: false,
|
||||
},
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
const requestJsonMock = vi.mocked(requestJson);
|
||||
|
||||
describe('externalGenerationClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ acknowledgedTasks: [] });
|
||||
});
|
||||
|
||||
it('posts acknowledge requests as JSON', async () => {
|
||||
await acknowledgeExternalGenerationTasks({ jobIds: ['job-1'] });
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/external-generation/jobs/acknowledge',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jobIds: ['job-1'] }),
|
||||
}),
|
||||
'确认生成任务通知失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
ExternalGenerationJobStatusResponse,
|
||||
ExternalGenerationQueueOverviewResponse,
|
||||
ExternalGenerationTaskAcknowledgeRequest,
|
||||
ExternalGenerationTaskAcknowledgeResponse,
|
||||
ExternalGenerationTaskListResponse,
|
||||
} from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson } from '../apiClient';
|
||||
|
||||
const EXTERNAL_GENERATION_API_BASE = '/api/runtime/external-generation';
|
||||
const EXTERNAL_GENERATION_READ_OPTIONS = {
|
||||
...BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
retry: {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 200,
|
||||
maxDelayMs: 600,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function getExternalGenerationQueueOverview(
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestJson<ExternalGenerationQueueOverviewResponse>(
|
||||
`${EXTERNAL_GENERATION_API_BASE}/queue-overview`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal,
|
||||
},
|
||||
'读取生成队列状态失败',
|
||||
EXTERNAL_GENERATION_READ_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getExternalGenerationJobStatus(
|
||||
jobId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestJson<ExternalGenerationJobStatusResponse>(
|
||||
`${EXTERNAL_GENERATION_API_BASE}/jobs/${encodeURIComponent(jobId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal,
|
||||
},
|
||||
'读取生成任务状态失败',
|
||||
EXTERNAL_GENERATION_READ_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listExternalGenerationTasks(
|
||||
options: {
|
||||
limit?: number;
|
||||
includeAcknowledgedTerminal?: boolean;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (typeof options.limit === 'number') {
|
||||
params.set('limit', String(options.limit));
|
||||
}
|
||||
if (typeof options.includeAcknowledgedTerminal === 'boolean') {
|
||||
params.set(
|
||||
'includeAcknowledgedTerminal',
|
||||
options.includeAcknowledgedTerminal ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
const query = params.toString();
|
||||
return requestJson<ExternalGenerationTaskListResponse>(
|
||||
`${EXTERNAL_GENERATION_API_BASE}/jobs${query ? `?${query}` : ''}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
},
|
||||
'读取生成任务列表失败',
|
||||
EXTERNAL_GENERATION_READ_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function acknowledgeExternalGenerationTasks(
|
||||
payload: ExternalGenerationTaskAcknowledgeRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestJson<ExternalGenerationTaskAcknowledgeResponse>(
|
||||
`${EXTERNAL_GENERATION_API_BASE}/jobs/acknowledge`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
},
|
||||
'确认生成任务通知失败',
|
||||
EXTERNAL_GENERATION_READ_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export const externalGenerationClient = {
|
||||
getQueueOverview: getExternalGenerationQueueOverview,
|
||||
getJobStatus: getExternalGenerationJobStatus,
|
||||
listTasks: listExternalGenerationTasks,
|
||||
acknowledgeTasks: acknowledgeExternalGenerationTasks,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
acknowledgeExternalGenerationTasks,
|
||||
externalGenerationClient,
|
||||
getExternalGenerationJobStatus,
|
||||
getExternalGenerationQueueOverview,
|
||||
listExternalGenerationTasks,
|
||||
} from './externalGenerationClient';
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS,
|
||||
HOST_BRIDGE_PROTOCOL,
|
||||
HOST_BRIDGE_TAURI_COMMAND,
|
||||
HOST_BRIDGE_VERSION,
|
||||
type HostBridgeRequest,
|
||||
type HostBridgeResponse,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import {
|
||||
canUseNativeAppHostBridge,
|
||||
canUseReactNativeHostBridge,
|
||||
canUseTauriHostBridge,
|
||||
requestNativeAppHostBridge,
|
||||
resetNativeAppHostBridgeForTest,
|
||||
subscribeNativeAppHostBridgeEvent,
|
||||
} from './nativeAppHostBridge';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
resetNativeAppHostBridgeForTest();
|
||||
delete window.ReactNativeWebView;
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
function dispatchHostBridgeResponse(response: HostBridgeResponse) {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify(response),
|
||||
origin: window.location.origin,
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function asTauriInvoke(
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||||
) {
|
||||
return async function tauriInvoke<Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) {
|
||||
return (await invoke(command, args)) as Result;
|
||||
};
|
||||
}
|
||||
|
||||
describe('nativeAppHostBridge', () => {
|
||||
test('通过 React Native WebView postMessage 发送请求并接收回包', async () => {
|
||||
const postMessage = vi.fn((message: string) => {
|
||||
const request = JSON.parse(message) as HostBridgeRequest;
|
||||
dispatchHostBridgeResponse({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
handled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
window.ReactNativeWebView = { postMessage };
|
||||
|
||||
await expect(
|
||||
requestNativeAppHostBridge('share.open', { title: '测试作品' }),
|
||||
).resolves.toEqual({
|
||||
handled: true,
|
||||
});
|
||||
|
||||
expect(canUseReactNativeHostBridge()).toBe(true);
|
||||
expect(canUseNativeAppHostBridge()).toBe(true);
|
||||
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||
const request = JSON.parse(postMessage.mock.calls[0]?.[0] ?? '{}');
|
||||
expect(request).toMatchObject({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
method: 'share.open',
|
||||
payload: {
|
||||
title: '测试作品',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('通过 Tauri invoke 发送请求并处理错误回包', async () => {
|
||||
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: HostBridgeRequest }).request;
|
||||
return {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'unsupported_method',
|
||||
message: 'unsupported_method',
|
||||
},
|
||||
} satisfies HostBridgeResponse;
|
||||
});
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestNativeAppHostBridge('auth.requestLogin'),
|
||||
).rejects.toMatchObject({
|
||||
name: 'unsupported_method',
|
||||
message: 'unsupported_method',
|
||||
});
|
||||
|
||||
expect(canUseTauriHostBridge()).toBe(true);
|
||||
expect(canUseNativeAppHostBridge()).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(HOST_BRIDGE_TAURI_COMMAND, {
|
||||
request: expect.objectContaining({
|
||||
method: 'auth.requestLogin',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('Tauri invoke 超时时按 HostBridge 超时错误拒绝请求', async () => {
|
||||
vi.useFakeTimers();
|
||||
const invoke = vi.fn(
|
||||
() =>
|
||||
new Promise<HostBridgeResponse>(() => {
|
||||
// 保持宿主命令挂起,让 H5 超时先返回。
|
||||
}),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
const pending = requestNativeAppHostBridge('share.open', undefined, {
|
||||
timeoutMs: 10,
|
||||
});
|
||||
const assertion = expect(pending).rejects.toMatchObject({
|
||||
name: 'timeout',
|
||||
message: 'host_bridge_timeout',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(11);
|
||||
|
||||
await assertion;
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('未指定超时时使用共享默认请求超时', async () => {
|
||||
vi.useFakeTimers();
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
|
||||
const pending = requestNativeAppHostBridge('share.open');
|
||||
const assertion = expect(pending).rejects.toMatchObject({
|
||||
name: 'timeout',
|
||||
message: 'host_bridge_timeout',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS - 1);
|
||||
await Promise.resolve();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
test('超出上限的请求超时会收敛到共享最大值', async () => {
|
||||
vi.useFakeTimers();
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
|
||||
const pending = requestNativeAppHostBridge('share.open', undefined, {
|
||||
timeoutMs: HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS + 1000,
|
||||
});
|
||||
const assertion = expect(pending).rejects.toMatchObject({
|
||||
name: 'timeout',
|
||||
message: 'host_bridge_timeout',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS - 1);
|
||||
await Promise.resolve();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
test('Tauri invoke 成功回包后清理超时计时器', async () => {
|
||||
vi.useFakeTimers();
|
||||
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: HostBridgeRequest }).request;
|
||||
return {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
handled: true,
|
||||
},
|
||||
} satisfies HostBridgeResponse;
|
||||
});
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestNativeAppHostBridge('share.open', undefined, {
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
handled: true,
|
||||
});
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('Tauri invoke 超时后的迟到回包不改变已拒绝结果', async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveInvoke: (
|
||||
response: HostBridgeResponse<{ handled: boolean }>,
|
||||
) => void = () => {
|
||||
throw new Error('Tauri invoke promise was not created');
|
||||
};
|
||||
const invoke = vi.fn(
|
||||
(_command: string, _args?: Record<string, unknown>) =>
|
||||
new Promise<HostBridgeResponse<{ handled: boolean }>>((resolve) => {
|
||||
resolveInvoke = resolve;
|
||||
}),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
const pending = requestNativeAppHostBridge('share.open', undefined, {
|
||||
timeoutMs: 10,
|
||||
});
|
||||
const assertion = expect(pending).rejects.toMatchObject({
|
||||
name: 'timeout',
|
||||
message: 'host_bridge_timeout',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(11);
|
||||
await assertion;
|
||||
|
||||
const request = (invoke.mock.calls[0]?.[1] as { request: HostBridgeRequest })
|
||||
.request;
|
||||
resolveInvoke({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
handled: true,
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
name: 'timeout',
|
||||
message: 'host_bridge_timeout',
|
||||
});
|
||||
});
|
||||
|
||||
test('没有原生宿主 bridge 时返回 null', async () => {
|
||||
await expect(
|
||||
requestNativeAppHostBridge('host.getRuntime'),
|
||||
).resolves.toBeNull();
|
||||
expect(canUseNativeAppHostBridge()).toBe(false);
|
||||
});
|
||||
|
||||
test('React Native WebView 回包超时时拒绝请求', async () => {
|
||||
vi.useFakeTimers();
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
|
||||
const pending = requestNativeAppHostBridge('share.open', undefined, {
|
||||
timeoutMs: 10,
|
||||
});
|
||||
const assertion = expect(pending).rejects.toMatchObject({
|
||||
name: 'timeout',
|
||||
message: 'host_bridge_timeout',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(11);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
test('忽略非当前窗口来源伪造的 HostBridge 回包', async () => {
|
||||
const channel = new MessageChannel();
|
||||
const postMessage = vi.fn((message: string) => {
|
||||
const request = JSON.parse(message) as HostBridgeRequest;
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
handled: 'forged',
|
||||
},
|
||||
} satisfies HostBridgeResponse),
|
||||
origin: window.location.origin,
|
||||
source: channel.port1,
|
||||
}),
|
||||
);
|
||||
dispatchHostBridgeResponse({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
handled: 'native',
|
||||
},
|
||||
});
|
||||
});
|
||||
window.ReactNativeWebView = { postMessage };
|
||||
|
||||
await expect(requestNativeAppHostBridge('share.open')).resolves.toEqual({
|
||||
handled: 'native',
|
||||
});
|
||||
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
});
|
||||
|
||||
test('忽略非当前页面来源伪造的宿主事件', () => {
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
const listener = vi.fn();
|
||||
subscribeNativeAppHostBridgeEvent('navigation.canGoBack', listener);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
event: 'navigation.canGoBack',
|
||||
payload: {
|
||||
canGoBack: true,
|
||||
},
|
||||
}),
|
||||
origin: 'https://sandbox.genarrative.invalid',
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('订阅 React Native WebView 注入的宿主事件', () => {
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeNativeAppHostBridgeEvent<{
|
||||
canGoBack: boolean;
|
||||
}>('navigation.canGoBack', listener);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
event: 'navigation.canGoBack',
|
||||
payload: {
|
||||
canGoBack: true,
|
||||
},
|
||||
}),
|
||||
origin: window.location.origin,
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
unsubscribe();
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
event: 'navigation.canGoBack',
|
||||
payload: {
|
||||
canGoBack: false,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenCalledWith({ canGoBack: true });
|
||||
});
|
||||
|
||||
test('忽略未知宿主事件名', () => {
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
const listener = vi.fn();
|
||||
subscribeNativeAppHostBridgeEvent('navigation.canGoBack', listener);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
event: 'unknown.event',
|
||||
payload: {
|
||||
canGoBack: true,
|
||||
},
|
||||
}),
|
||||
origin: window.location.origin,
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS,
|
||||
HOST_BRIDGE_PROTOCOL,
|
||||
HOST_BRIDGE_TAURI_COMMAND,
|
||||
HOST_BRIDGE_VERSION,
|
||||
type HostBridgeError,
|
||||
type HostBridgeEvent,
|
||||
type HostBridgeEventName,
|
||||
type HostBridgeMethod,
|
||||
type HostBridgeRequest,
|
||||
type HostBridgeResponse,
|
||||
isHostBridgeEventName,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
|
||||
type NativeAppBridgeWindow = Window & {
|
||||
ReactNativeWebView?: {
|
||||
postMessage?: (message: string) => void;
|
||||
};
|
||||
__TAURI__?: {
|
||||
core?: {
|
||||
invoke?: <Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<Result>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PendingNativeRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type NativeTauriInvoke = <Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<Result>;
|
||||
|
||||
const pendingNativeRequests = new Map<string, PendingNativeRequest>();
|
||||
const nativeEventListeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
|
||||
let nativeBridgeListenerInstalled = false;
|
||||
let nextNativeRequestSequence = 0;
|
||||
|
||||
function resolveNativeWindow() {
|
||||
return typeof window === 'undefined'
|
||||
? null
|
||||
: (window as NativeAppBridgeWindow);
|
||||
}
|
||||
|
||||
function buildNativeRequestId(method: HostBridgeMethod) {
|
||||
nextNativeRequestSequence += 1;
|
||||
return `host_${method.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}_${nextNativeRequestSequence}`;
|
||||
}
|
||||
|
||||
function resolveTimeoutMs(timeoutMs: number | undefined) {
|
||||
if (!Number.isFinite(timeoutMs ?? Number.NaN) || !timeoutMs) {
|
||||
return HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
Math.max(1, Math.trunc(timeoutMs)),
|
||||
HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function createHostBridgeError(error: HostBridgeError) {
|
||||
const result = new Error(error.message);
|
||||
result.name = error.code;
|
||||
return result;
|
||||
}
|
||||
|
||||
function createNativeHostBridgeTimeoutError() {
|
||||
return createHostBridgeError({
|
||||
code: 'timeout',
|
||||
message: 'host_bridge_timeout',
|
||||
});
|
||||
}
|
||||
|
||||
function isHostBridgeResponse(value: unknown): value is HostBridgeResponse {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<HostBridgeResponse>;
|
||||
return (
|
||||
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
|
||||
candidate.version === HOST_BRIDGE_VERSION &&
|
||||
typeof candidate.id === 'string' &&
|
||||
typeof candidate.ok === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
function isHostBridgeEvent(value: unknown): value is HostBridgeEvent {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<HostBridgeEvent>;
|
||||
return (
|
||||
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
|
||||
candidate.version === HOST_BRIDGE_VERSION &&
|
||||
isHostBridgeEventName(candidate.event)
|
||||
);
|
||||
}
|
||||
|
||||
function parseNativeMessage(data: unknown) {
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
return JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function settleNativeResponse(response: HostBridgeResponse) {
|
||||
const pending = pendingNativeRequests.get(response.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingNativeRequests.delete(response.id);
|
||||
clearTimeout(pending.timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
pending.resolve(response.result);
|
||||
return;
|
||||
}
|
||||
|
||||
pending.reject(createHostBridgeError(response.error));
|
||||
}
|
||||
|
||||
function dispatchNativeEvent(event: HostBridgeEvent) {
|
||||
const listeners = nativeEventListeners.get(event.event);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener(event.payload);
|
||||
}
|
||||
}
|
||||
|
||||
function isNativeInjectedMessageEvent(event: MessageEvent) {
|
||||
const nativeWindow = resolveNativeWindow();
|
||||
if (!nativeWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.source && event.source !== nativeWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.origin && event.origin !== nativeWindow.location.origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureNativeBridgeListener() {
|
||||
const nativeWindow = resolveNativeWindow();
|
||||
if (!nativeWindow || nativeBridgeListenerInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
nativeWindow.addEventListener('message', (event) => {
|
||||
if (!isNativeInjectedMessageEvent(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = parseNativeMessage(event.data);
|
||||
if (isHostBridgeResponse(value)) {
|
||||
settleNativeResponse(value);
|
||||
return;
|
||||
}
|
||||
if (isHostBridgeEvent(value)) {
|
||||
dispatchNativeEvent(value);
|
||||
}
|
||||
});
|
||||
|
||||
nativeBridgeListenerInstalled = true;
|
||||
}
|
||||
|
||||
async function invokeTauriHostBridgeWithTimeout<Result>(
|
||||
tauriInvoke: NativeTauriInvoke,
|
||||
request: HostBridgeRequest,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(createNativeHostBridgeTimeoutError());
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
tauriInvoke<HostBridgeResponse<Result>>(HOST_BRIDGE_TAURI_COMMAND, {
|
||||
request,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function canUseReactNativeHostBridge() {
|
||||
return (
|
||||
typeof resolveNativeWindow()?.ReactNativeWebView?.postMessage ===
|
||||
'function'
|
||||
);
|
||||
}
|
||||
|
||||
export function canUseTauriHostBridge() {
|
||||
return (
|
||||
typeof resolveNativeWindow()?.__TAURI__?.core?.invoke === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
export function canUseNativeAppHostBridge() {
|
||||
return canUseReactNativeHostBridge() || canUseTauriHostBridge();
|
||||
}
|
||||
|
||||
export function subscribeNativeAppHostBridgeEvent<Payload = unknown>(
|
||||
eventName: HostBridgeEventName,
|
||||
listener: (payload: Payload | undefined) => void,
|
||||
) {
|
||||
ensureNativeBridgeListener();
|
||||
|
||||
const listeners = nativeEventListeners.get(eventName) ?? new Set();
|
||||
listeners.add(listener as (payload: unknown) => void);
|
||||
nativeEventListeners.set(eventName, listeners);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener as (payload: unknown) => void);
|
||||
if (listeners.size === 0) {
|
||||
nativeEventListeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestNativeAppHostBridge<Result = unknown>(
|
||||
method: HostBridgeMethod,
|
||||
payload?: unknown,
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
} = {},
|
||||
) {
|
||||
const nativeWindow = resolveNativeWindow();
|
||||
if (!nativeWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs);
|
||||
const request: HostBridgeRequest = {
|
||||
bridge: HOST_BRIDGE_PROTOCOL,
|
||||
version: HOST_BRIDGE_VERSION,
|
||||
id: buildNativeRequestId(method),
|
||||
method,
|
||||
timeoutMs,
|
||||
...(payload === undefined ? {} : { payload }),
|
||||
};
|
||||
|
||||
const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke;
|
||||
if (typeof tauriInvoke === 'function') {
|
||||
const response = await invokeTauriHostBridgeWithTimeout<Result>(
|
||||
tauriInvoke,
|
||||
request,
|
||||
timeoutMs,
|
||||
);
|
||||
if (!isHostBridgeResponse(response)) {
|
||||
throw new Error('host_bridge_invalid_response');
|
||||
}
|
||||
if (response.ok) {
|
||||
return response.result as Result;
|
||||
}
|
||||
throw createHostBridgeError(response.error);
|
||||
}
|
||||
|
||||
const postMessage = nativeWindow.ReactNativeWebView?.postMessage;
|
||||
if (typeof postMessage !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
ensureNativeBridgeListener();
|
||||
|
||||
return await new Promise<Result>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
pendingNativeRequests.delete(request.id);
|
||||
reject(createNativeHostBridgeTimeoutError());
|
||||
}, timeoutMs);
|
||||
|
||||
pendingNativeRequests.set(request.id, {
|
||||
resolve: (value) => resolve(value as Result),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
try {
|
||||
postMessage(JSON.stringify(request));
|
||||
} catch (error) {
|
||||
pendingNativeRequests.delete(request.id);
|
||||
clearTimeout(timeoutId);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resetNativeAppHostBridgeForTest() {
|
||||
for (const pending of pendingNativeRequests.values()) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
pendingNativeRequests.clear();
|
||||
nativeEventListeners.clear();
|
||||
nextNativeRequestSequence = 0;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
compressEditorImageReferenceDataUrlForGeneration,
|
||||
resolveEditorImageReferenceDataUrl,
|
||||
} from './editorImageReference';
|
||||
|
||||
describe('editorImageReference', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('passes through base64 image data urls without fetching', async () => {
|
||||
vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
await expect(
|
||||
resolveEditorImageReferenceDataUrl('data:image/png;base64,c291cmNl'),
|
||||
).resolves.toBe('data:image/png;base64,c291cmNl');
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('converts public image paths to base64 image data urls', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveEditorImageReferenceDataUrl(
|
||||
'/creation-type-references/puzzle.webp',
|
||||
),
|
||||
).resolves.toBe('data:image/webp;base64,aGVsbG8=');
|
||||
});
|
||||
|
||||
it('keeps generation references unchanged when browser canvas is unavailable', async () => {
|
||||
await expect(
|
||||
compressEditorImageReferenceDataUrlForGeneration(
|
||||
'data:image/png;base64,large-reference',
|
||||
),
|
||||
).resolves.toBe('data:image/png;base64,large-reference');
|
||||
});
|
||||
|
||||
it('falls back to the original generation reference when browser image decoding stalls', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('document', {
|
||||
createElement: vi.fn(),
|
||||
} as unknown as Document);
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class PendingImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
set src(_value: string) {
|
||||
void _value;
|
||||
}
|
||||
} as unknown as typeof Image,
|
||||
);
|
||||
|
||||
const result = compressEditorImageReferenceDataUrlForGeneration(
|
||||
'data:image/png;base64,pending-reference',
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(251);
|
||||
|
||||
await expect(result).resolves.toBe(
|
||||
'data:image/png;base64,pending-reference',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { readAssetBytes } from '../assetReadUrlService';
|
||||
|
||||
const GENERATION_REFERENCE_MAX_EDGE = 1280;
|
||||
const GENERATION_REFERENCE_QUALITY = 0.82;
|
||||
const GENERATION_REFERENCE_MIME_TYPE = 'image/jpeg';
|
||||
const GENERATION_REFERENCE_IMAGE_LOAD_TIMEOUT_MS = 250;
|
||||
|
||||
type EditorImageReferenceCompressionOptions = {
|
||||
maxEdge?: number;
|
||||
quality?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
function normalizeImageContentType(contentType: string | null) {
|
||||
const mimeType = contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
|
||||
return mimeType.startsWith('image/') ? mimeType : 'image/png';
|
||||
}
|
||||
|
||||
function encodeBytesAsBase64(bytes: Uint8Array) {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(
|
||||
...bytes.subarray(offset, offset + chunkSize),
|
||||
);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片画布的生成编辑接口只接收图片 Data URL。
|
||||
* 画布里既有上传 / 生成图,也有内置 public 参考图和历史 generated 路径;
|
||||
* 提交给后端前统一读取成 Data URL,避免后端误收到普通 URL 后进入 Data URL 校验错误。
|
||||
*/
|
||||
export async function resolveEditorImageReferenceDataUrl(
|
||||
source: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const normalizedSource = source.trim();
|
||||
if (!normalizedSource) {
|
||||
throw new Error('图片参考图不能为空');
|
||||
}
|
||||
if (normalizedSource.startsWith('data:image/')) {
|
||||
return normalizedSource;
|
||||
}
|
||||
if (normalizedSource.startsWith('data:')) {
|
||||
throw new Error('图片参考图必须是图片 Data URL');
|
||||
}
|
||||
|
||||
const response = await readAssetBytes(normalizedSource, {
|
||||
signal,
|
||||
expireSeconds: 300,
|
||||
});
|
||||
const mimeType = normalizeImageContentType(
|
||||
response.headers.get('Content-Type'),
|
||||
);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength <= 0) {
|
||||
throw new Error('图片参考图为空');
|
||||
}
|
||||
|
||||
return `data:${mimeType};base64,${encodeBytesAsBase64(bytes)}`;
|
||||
}
|
||||
|
||||
function loadImageDataUrl(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
if (typeof Image === 'undefined') {
|
||||
reject(new Error('当前环境不支持图片压缩'));
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
reject(new Error('参考图读取超时'));
|
||||
}, GENERATION_REFERENCE_IMAGE_LOAD_TIMEOUT_MS);
|
||||
const finish = (callback: () => void) => {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
callback();
|
||||
};
|
||||
image.onload = () => finish(() => resolve(image));
|
||||
image.onerror = () => finish(() => reject(new Error('参考图读取失败')));
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export async function compressEditorImageReferenceDataUrlForGeneration(
|
||||
dataUrl: string,
|
||||
options: EditorImageReferenceCompressionOptions = {},
|
||||
) {
|
||||
if (!dataUrl.startsWith('data:image/') || typeof document === 'undefined') {
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
const maxEdge = Math.max(1, options.maxEdge ?? GENERATION_REFERENCE_MAX_EDGE);
|
||||
const quality = Math.min(
|
||||
1,
|
||||
Math.max(0.1, options.quality ?? GENERATION_REFERENCE_QUALITY),
|
||||
);
|
||||
const mimeType = options.mimeType ?? GENERATION_REFERENCE_MIME_TYPE;
|
||||
|
||||
try {
|
||||
const image = await loadImageDataUrl(dataUrl);
|
||||
const sourceWidth = image.naturalWidth || image.width;
|
||||
const sourceHeight = image.naturalHeight || image.height;
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
|
||||
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
// 中文注释:参考图只用于模型理解视觉方向,提交前压缩可避免原图 Data URL 撑爆 JSON 请求体。
|
||||
if (mimeType === 'image/jpeg') {
|
||||
context.fillStyle = '#fff';
|
||||
context.fillRect(0, 0, targetWidth, targetHeight);
|
||||
}
|
||||
context.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
const compressed = canvas.toDataURL(mimeType, quality);
|
||||
return compressed.startsWith('data:image/') &&
|
||||
compressed.length < dataUrl.length
|
||||
? compressed
|
||||
: dataUrl;
|
||||
} catch {
|
||||
return dataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveEditorImageReferenceDataUrlForGeneration(
|
||||
source: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const dataUrl = await resolveEditorImageReferenceDataUrl(source, signal);
|
||||
return compressEditorImageReferenceDataUrlForGeneration(dataUrl);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { requestJson } from '../apiClient';
|
||||
import { getSignedAssetReadUrl } from '../assetReadUrlService';
|
||||
import { uploadEditorMediaAssetFile } from './editorMediaAssetUploadClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../assetReadUrlService', () => ({
|
||||
getSignedAssetReadUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const requestJsonMock = vi.mocked(requestJson);
|
||||
const getSignedAssetReadUrlMock = vi.mocked(getSignedAssetReadUrl);
|
||||
const editorRetryOptionsExpectation = expect.objectContaining({
|
||||
maxRetries: 2,
|
||||
retryUnsafeMethods: true,
|
||||
retryableStatusCodes: expect.arrayContaining([429]),
|
||||
});
|
||||
|
||||
describe('editorMediaAssetUploadClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1771400000000);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 200 })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uploads editor MP4 assets through direct OSS upload and confirms object metadata', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: {
|
||||
bucket: 'bucket',
|
||||
host: 'https://oss.example.com',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
formFields: {
|
||||
key: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
policy: 'policy',
|
||||
'x-oss-signature': 'signature',
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetObject: {
|
||||
assetObjectId: 'assetobj-video',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
assetKind: 'editor_uploaded_video',
|
||||
},
|
||||
});
|
||||
getSignedAssetReadUrlMock.mockResolvedValueOnce(
|
||||
'https://signed.example.com/demo.mp4',
|
||||
);
|
||||
|
||||
const result = await uploadEditorMediaAssetFile(
|
||||
new File(['video'], 'demo.mp4', { type: 'video/mp4' }),
|
||||
'video',
|
||||
);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/assets/direct-upload-tickets',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'创建素材上传凭证失败',
|
||||
expect.objectContaining({ retry: editorRetryOptionsExpectation }),
|
||||
);
|
||||
const ticketBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(ticketBody).toMatchObject({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['editor', 'asset-library', 'video', '1771400000000'],
|
||||
fileName: 'demo.mp4',
|
||||
contentType: 'video/mp4',
|
||||
access: 'private',
|
||||
maxSizeBytes: 5,
|
||||
successActionStatus: 200,
|
||||
metadata: {
|
||||
asset_kind: 'editor_uploaded_video',
|
||||
editor_media_type: 'video',
|
||||
},
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://oss.example.com',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
);
|
||||
const confirmBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/assets/objects/confirm',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'确认素材资产失败',
|
||||
expect.objectContaining({ retry: editorRetryOptionsExpectation }),
|
||||
);
|
||||
expect(confirmBody).toMatchObject({
|
||||
bucket: 'bucket',
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
contentType: 'video/mp4',
|
||||
contentLength: 5,
|
||||
assetKind: 'editor_uploaded_video',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'editor-asset-library-video',
|
||||
});
|
||||
expect(getSignedAssetReadUrlMock).toHaveBeenCalledWith(
|
||||
{
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
expireSeconds: 3600,
|
||||
},
|
||||
undefined,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
src: 'https://signed.example.com/demo.mp4',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
assetObjectId: 'assetobj-video',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads editor image assets through direct OSS upload and confirms object metadata', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: {
|
||||
bucket: 'bucket',
|
||||
host: 'https://oss.example.com',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
formFields: {
|
||||
key: 'generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
policy: 'policy',
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetObject: {
|
||||
assetObjectId: 'assetobj-image',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
assetKind: 'editor_uploaded_image',
|
||||
},
|
||||
});
|
||||
getSignedAssetReadUrlMock.mockResolvedValueOnce(
|
||||
'https://signed.example.com/spec.png',
|
||||
);
|
||||
|
||||
const result = await uploadEditorMediaAssetFile(
|
||||
new File(['image'], 'spec.png', { type: 'image/png' }),
|
||||
'image',
|
||||
);
|
||||
|
||||
const ticketBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(ticketBody).toMatchObject({
|
||||
pathSegments: ['editor', 'asset-library', 'image', '1771400000000'],
|
||||
fileName: 'spec.png',
|
||||
contentType: 'image/png',
|
||||
metadata: {
|
||||
asset_kind: 'editor_uploaded_image',
|
||||
editor_media_type: 'image',
|
||||
},
|
||||
});
|
||||
const confirmBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(confirmBody).toMatchObject({
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
contentType: 'image/png',
|
||||
contentLength: 5,
|
||||
assetKind: 'editor_uploaded_image',
|
||||
entityId: 'editor-asset-library-image',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
src: 'https://signed.example.com/spec.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
assetObjectId: 'assetobj-image',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('supports project cover snapshot upload metadata and object binding', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: {
|
||||
bucket: 'bucket',
|
||||
host: 'https://oss.example.com',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
|
||||
formFields: {
|
||||
key: 'generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
|
||||
policy: 'policy',
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetObject: {
|
||||
assetObjectId: 'assetobj-cover',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
|
||||
assetKind: 'editor_project_cover_snapshot',
|
||||
},
|
||||
});
|
||||
getSignedAssetReadUrlMock.mockResolvedValueOnce(
|
||||
'https://signed.example.com/project-cover.png',
|
||||
);
|
||||
|
||||
const result = await uploadEditorMediaAssetFile(
|
||||
new File(['cover'], 'project-1-cover.png', { type: 'image/png' }),
|
||||
'image',
|
||||
{
|
||||
assetKind: 'editor_project_cover_snapshot',
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'project-covers',
|
||||
'project-1',
|
||||
'1771400000000',
|
||||
],
|
||||
entityId: 'editor-project-cover-project-1',
|
||||
metadata: {
|
||||
editor_project_id: 'project-1',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const ticketBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(ticketBody).toMatchObject({
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'project-covers',
|
||||
'project-1',
|
||||
'1771400000000',
|
||||
],
|
||||
fileName: 'project-1-cover.png',
|
||||
metadata: {
|
||||
asset_kind: 'editor_project_cover_snapshot',
|
||||
editor_media_type: 'image',
|
||||
editor_project_id: 'project-1',
|
||||
},
|
||||
});
|
||||
const confirmBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(confirmBody).toMatchObject({
|
||||
assetKind: 'editor_project_cover_snapshot',
|
||||
entityId: 'editor-project-cover-project-1',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
src: 'https://signed.example.com/project-cover.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
|
||||
assetObjectId: 'assetobj-cover',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/project-covers/project-1/1771400000000/project-1-cover.png',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { requestJson } from '../apiClient';
|
||||
import { getSignedAssetReadUrl } from '../assetReadUrlService';
|
||||
import {
|
||||
EDITOR_REQUEST_RETRY_OPTIONS,
|
||||
postEditorDirectUploadFile,
|
||||
} from './editorRetryOptions';
|
||||
|
||||
export type EditorMediaAssetUploadType = 'image' | 'video' | 'audio';
|
||||
|
||||
type DirectUploadTicketResponse = {
|
||||
upload: {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfirmAssetObjectResponse = {
|
||||
assetObject: {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
assetKind: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UploadedEditorMediaAsset = {
|
||||
src: string;
|
||||
objectKey: string;
|
||||
assetObjectId: string;
|
||||
legacyPublicPath: string;
|
||||
};
|
||||
|
||||
export type EditorMediaAssetUploadOptions = {
|
||||
assetKind?: string;
|
||||
pathSegments?: string[];
|
||||
entityId?: string;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
const EDITOR_MEDIA_READ_EXPIRE_SECONDS = 60 * 60;
|
||||
|
||||
function resolveEditorMediaAssetKind(mediaType: EditorMediaAssetUploadType) {
|
||||
if (mediaType === 'video') {
|
||||
return 'editor_uploaded_video';
|
||||
}
|
||||
if (mediaType === 'audio') {
|
||||
return 'editor_uploaded_audio';
|
||||
}
|
||||
return 'editor_uploaded_image';
|
||||
}
|
||||
|
||||
function resolveFallbackFileName(mediaType: EditorMediaAssetUploadType) {
|
||||
if (mediaType === 'video') {
|
||||
return 'editor-video.mp4';
|
||||
}
|
||||
if (mediaType === 'audio') {
|
||||
return 'editor-audio.mp3';
|
||||
}
|
||||
return 'editor-image.png';
|
||||
}
|
||||
|
||||
function sanitizeEditorMediaFileName(
|
||||
fileName: string,
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
) {
|
||||
const trimmedName = fileName.trim();
|
||||
return trimmedName || resolveFallbackFileName(mediaType);
|
||||
}
|
||||
|
||||
export async function uploadEditorMediaAssetFile(
|
||||
file: File,
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
options: EditorMediaAssetUploadOptions = {},
|
||||
): Promise<UploadedEditorMediaAsset> {
|
||||
const assetKind = options.assetKind ?? resolveEditorMediaAssetKind(mediaType);
|
||||
const fileName = sanitizeEditorMediaFileName(file.name, mediaType);
|
||||
const contentType = file.type.trim() || 'application/octet-stream';
|
||||
|
||||
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: options.pathSegments ?? [
|
||||
'editor',
|
||||
'asset-library',
|
||||
mediaType,
|
||||
`${Date.now()}`,
|
||||
],
|
||||
fileName,
|
||||
contentType,
|
||||
access: 'private',
|
||||
maxSizeBytes: file.size,
|
||||
successActionStatus: 200,
|
||||
metadata: {
|
||||
asset_kind: assetKind,
|
||||
editor_media_type: mediaType,
|
||||
...options.metadata,
|
||||
},
|
||||
}),
|
||||
},
|
||||
'创建素材上传凭证失败',
|
||||
{ retry: EDITOR_REQUEST_RETRY_OPTIONS },
|
||||
);
|
||||
|
||||
await postEditorDirectUploadFile(ticket.upload, file, '上传素材失败');
|
||||
|
||||
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bucket: ticket.upload.bucket,
|
||||
objectKey: ticket.upload.objectKey,
|
||||
contentType,
|
||||
contentLength: file.size,
|
||||
assetKind,
|
||||
accessPolicy: 'private',
|
||||
entityId: options.entityId ?? `editor-asset-library-${mediaType}`,
|
||||
}),
|
||||
},
|
||||
'确认素材资产失败',
|
||||
{ retry: EDITOR_REQUEST_RETRY_OPTIONS },
|
||||
);
|
||||
|
||||
// 中文注释:素材库立即预览使用短期 signed URL;持久化仍记录 legacy path 和 objectKey。
|
||||
const signedUrl = await getSignedAssetReadUrl(
|
||||
{
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
undefined,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
|
||||
return {
|
||||
src: signedUrl,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
legacyPublicPath: ticket.upload.legacyPublicPath,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
const PROJECT_COVER_CACHE_DB_NAME = 'genarrative-editor-project-covers';
|
||||
const PROJECT_COVER_CACHE_DB_VERSION = 1;
|
||||
const PROJECT_COVER_CACHE_STORE_NAME = 'covers';
|
||||
const PROJECT_COVER_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type CachedEditorProjectCover = {
|
||||
projectId: string;
|
||||
blob: Blob;
|
||||
updatedAt: string;
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
function canUseIndexedDb() {
|
||||
return typeof indexedDB !== 'undefined';
|
||||
}
|
||||
|
||||
function idbRequestToPromise<T>(request: IDBRequest<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
request.addEventListener('success', () => resolve(request.result));
|
||||
request.addEventListener('error', () => {
|
||||
reject(request.error ?? new Error('IndexedDB request failed'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openProjectCoverCacheDb() {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
if (!canUseIndexedDb()) {
|
||||
reject(new Error('IndexedDB unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(
|
||||
PROJECT_COVER_CACHE_DB_NAME,
|
||||
PROJECT_COVER_CACHE_DB_VERSION,
|
||||
);
|
||||
request.addEventListener('upgradeneeded', () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(PROJECT_COVER_CACHE_STORE_NAME)) {
|
||||
db.createObjectStore(PROJECT_COVER_CACHE_STORE_NAME, {
|
||||
keyPath: 'projectId',
|
||||
});
|
||||
}
|
||||
});
|
||||
request.addEventListener('success', () => resolve(request.result));
|
||||
request.addEventListener('error', () => {
|
||||
reject(request.error ?? new Error('IndexedDB open failed'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function withProjectCoverCacheStore<T>(
|
||||
mode: IDBTransactionMode,
|
||||
run: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
) {
|
||||
const db = await openProjectCoverCacheDb();
|
||||
try {
|
||||
const transaction = db.transaction(PROJECT_COVER_CACHE_STORE_NAME, mode);
|
||||
const result = await idbRequestToPromise(
|
||||
run(transaction.objectStore(PROJECT_COVER_CACHE_STORE_NAME)),
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.addEventListener('complete', () => resolve());
|
||||
transaction.addEventListener('abort', () => {
|
||||
reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
||||
});
|
||||
transaction.addEventListener('error', () => {
|
||||
reject(transaction.error ?? new Error('IndexedDB transaction failed'));
|
||||
});
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function isFreshCachedCover(
|
||||
cover: CachedEditorProjectCover | undefined,
|
||||
): cover is CachedEditorProjectCover {
|
||||
return Boolean(
|
||||
cover?.projectId &&
|
||||
cover.blob instanceof Blob &&
|
||||
cover.cachedAt > Date.now() - PROJECT_COVER_CACHE_MAX_AGE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putEditorProjectCoverCache({
|
||||
projectId,
|
||||
blob,
|
||||
updatedAt = new Date().toISOString(),
|
||||
}: {
|
||||
projectId: string;
|
||||
blob: Blob;
|
||||
updatedAt?: string;
|
||||
}) {
|
||||
const normalizedProjectId = projectId.trim();
|
||||
if (!normalizedProjectId || !canUseIndexedDb()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withProjectCoverCacheStore('readwrite', (store) =>
|
||||
store.put({
|
||||
projectId: normalizedProjectId,
|
||||
blob,
|
||||
updatedAt,
|
||||
cachedAt: Date.now(),
|
||||
} satisfies CachedEditorProjectCover),
|
||||
);
|
||||
} catch {
|
||||
// IndexedDB is best-effort local fallback only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function readEditorProjectCoverCache(
|
||||
projectIds: readonly string[],
|
||||
) {
|
||||
const normalizedProjectIds = [
|
||||
...new Set(projectIds.map((id) => id.trim())),
|
||||
].filter(Boolean);
|
||||
if (!normalizedProjectIds.length || !canUseIndexedDb()) {
|
||||
return new Map<string, CachedEditorProjectCover>();
|
||||
}
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await openProjectCoverCacheDb();
|
||||
const transaction = db.transaction(
|
||||
PROJECT_COVER_CACHE_STORE_NAME,
|
||||
'readonly',
|
||||
);
|
||||
const store = transaction.objectStore(PROJECT_COVER_CACHE_STORE_NAME);
|
||||
const covers = await Promise.all(
|
||||
normalizedProjectIds.map((projectId) =>
|
||||
idbRequestToPromise(
|
||||
store.get(projectId) as IDBRequest<
|
||||
CachedEditorProjectCover | undefined
|
||||
>,
|
||||
),
|
||||
),
|
||||
);
|
||||
return new Map(
|
||||
covers
|
||||
.filter(isFreshCachedCover)
|
||||
.map((cover) => [cover.projectId, cover]),
|
||||
);
|
||||
} catch {
|
||||
return new Map<string, CachedEditorProjectCover>();
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { requestJson } from '../apiClient';
|
||||
import { getSignedAssetReadUrl } from '../assetReadUrlService';
|
||||
import { uploadEditorSeedanceReferenceFile } from './editorReferenceUploadClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../assetReadUrlService', () => ({
|
||||
getSignedAssetReadUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const requestJsonMock = vi.mocked(requestJson);
|
||||
const getSignedAssetReadUrlMock = vi.mocked(getSignedAssetReadUrl);
|
||||
const editorRetryOptionsExpectation = expect.objectContaining({
|
||||
maxRetries: 2,
|
||||
retryUnsafeMethods: true,
|
||||
retryableStatusCodes: expect.arrayContaining([429]),
|
||||
});
|
||||
|
||||
describe('editorReferenceUploadClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1771400000000);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 200 })),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uploads Seedance reference videos through direct OSS upload and returns object metadata', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: {
|
||||
bucket: 'bucket',
|
||||
host: 'https://oss.example.com',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
formFields: {
|
||||
key: 'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
policy: 'policy',
|
||||
'x-oss-signature': 'signature',
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetObject: {
|
||||
assetObjectId: 'assetobj-video',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
assetKind: 'editor_seedance_reference_video',
|
||||
},
|
||||
});
|
||||
getSignedAssetReadUrlMock.mockResolvedValueOnce(
|
||||
'https://signed.example.com/ref.mp4',
|
||||
);
|
||||
|
||||
const result = await uploadEditorSeedanceReferenceFile(
|
||||
new File(['video'], 'ref.mp4', { type: 'video/mp4' }),
|
||||
'video',
|
||||
);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/assets/direct-upload-tickets',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'创建参考素材上传凭证失败',
|
||||
expect.objectContaining({ retry: editorRetryOptionsExpectation }),
|
||||
);
|
||||
const ticketBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(ticketBody).toMatchObject({
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'seedance-references',
|
||||
'video',
|
||||
'1771400000000',
|
||||
],
|
||||
fileName: 'ref.mp4',
|
||||
contentType: 'video/mp4',
|
||||
access: 'private',
|
||||
maxSizeBytes: 5,
|
||||
successActionStatus: 200,
|
||||
metadata: {
|
||||
asset_kind: 'editor_seedance_reference_video',
|
||||
editor_seedance_reference_type: 'video',
|
||||
},
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://oss.example.com',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/assets/objects/confirm',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'确认参考素材资产失败',
|
||||
expect.objectContaining({ retry: editorRetryOptionsExpectation }),
|
||||
);
|
||||
const confirmBody = JSON.parse(
|
||||
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(confirmBody).toMatchObject({
|
||||
bucket: 'bucket',
|
||||
objectKey: 'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
contentType: 'video/mp4',
|
||||
contentLength: 5,
|
||||
assetKind: 'editor_seedance_reference_video',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'editor-seedance-reference-video',
|
||||
});
|
||||
expect(getSignedAssetReadUrlMock).toHaveBeenCalledWith(
|
||||
{
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
expireSeconds: 3600,
|
||||
},
|
||||
undefined,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
src: 'https://signed.example.com/ref.mp4',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
assetObjectId: 'assetobj-video',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/seedance-references/video/ref.mp4',
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user