合并 master 到跳一跳分支

合入 origin/master 最新变更

解决跳一跳提示词测试与图集去背冲突
This commit is contained in:
2026-06-26 21:30:26 +08:00
943 changed files with 266938 additions and 7298 deletions
+2 -2
View File
@@ -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);
+8 -8
View File
@@ -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',
},
}),
+54
View File
@@ -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: '陶泥儿',
});
});
});
+78
View File
@@ -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 });
}
+321 -35
View File
@@ -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?',
);
});
});
+195 -8
View File
@@ -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();
}
+45
View File
@@ -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
View File
@@ -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() {
+66
View File
@@ -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();
});
});
+6
View File
@@ -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