This commit is contained in:
2026-05-08 20:48:29 +08:00
parent abf1f1ebea
commit 94975e4735
82 changed files with 7786 additions and 1012 deletions

View File

@@ -94,6 +94,88 @@ describe('assetReadUrlService', () => {
);
});
test('resolveAssetReadUrl normalizes generated object key without leading slash', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey:
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
signedUrl: 'https://signed.example.com/puzzle.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
);
await expect(
resolveAssetReadUrl(
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
),
).resolves.toBe('https://signed.example.com/puzzle.png');
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png',
);
});
test('resolveAssetReadUrl does not append cache busting query to OSS signed url', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey:
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
signedUrl:
'https://bucket.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle.png?x-oss-signature=abc&x-oss-expires=600',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-04-08',
routeVersion: '2026-04-08',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
);
await expect(
resolveAssetReadUrl(
'/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
{ refreshKey: 'latest-result' },
),
).resolves.toBe(
'https://bucket.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle.png?x-oss-signature=abc&x-oss-expires=600',
);
});
test('getSignedAssetReadUrl reuses cached signed url before expiry', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));

View File

@@ -11,8 +11,9 @@ type AssetReadUrlResolveOptions = {
expireSeconds?: number;
/**
* 图片内容可能在同一路径下被重新写入。
* 这时需要显式跳过本地签名缓存,并在最终 URL 上追加一次性参数
* 避免结果页仍命中旧签名地址或浏览器图片缓存
* 对 generated 私有资源只跳过本地签名缓存并重新换签
* 不能给 OSS V4 签名 URL 追加一次性参数
* 普通非签名 URL 仍可追加 `_v` 避免浏览器图片缓存。
*/
refreshKey?: string | number | null;
};
@@ -45,7 +46,7 @@ const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
export function isGeneratedLegacyPath(value: string) {
return /^\/generated-[^/?#]+\/.+/u.test(value.trim());
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function normalizeLegacyPublicPath(value: string) {
@@ -219,8 +220,17 @@ function appendCacheBustParam(
return url;
}
// OSS V4 签名会把 query 纳入签名计算,前端不能追加 `_v` 之类的缓存参数。
// 需要刷新时通过 refreshKey 绕过本地签名缓存,重新请求 `/api/assets/read-url`。
if (/[?&]x-oss-signature(?:=|&|$)/u.test(url)) {
return url;
}
try {
const parsedUrl = new URL(url, globalThis.location?.origin ?? 'http://localhost');
if (parsedUrl.searchParams.has('x-oss-signature')) {
return url;
}
parsedUrl.searchParams.set('_v', normalizedRefreshKey);
if (/^(?:https?:)?\/\//u.test(url)) {
return parsedUrl.toString();
@@ -262,7 +272,7 @@ export async function resolveAssetReadUrl(
options.refreshKey !== null && options.refreshKey !== undefined,
},
);
return appendCacheBustParam(signedUrl, options.refreshKey);
return signedUrl;
}
return appendCacheBustParam(value, options.refreshKey);

View File

@@ -26,8 +26,53 @@ describe('miniGameDraftGenerationProgress', () => {
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'根据画面描述生成首关名称和结果页草稿。',
'理解画面描述生成首关名称与可编辑草稿。',
);
expect(progress?.estimatedRemainingMs).toBe(59_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
test('puzzle draft generation advances steps across the 60 second estimate', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 16_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 56_000);
expect(imageProgress?.phaseId).toBe('puzzle-images');
expect(imageProgress?.estimatedRemainingMs).toBe(45_000);
expect(imageProgress?.steps[0]?.status).toBe('completed');
expect(imageProgress?.steps[1]?.status).toBe('active');
expect(imageProgress?.steps[1]?.completed).toBeGreaterThan(0);
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
expect(writeBackProgress?.steps[1]?.status).toBe('completed');
expect(writeBackProgress?.steps[2]?.status).toBe('active');
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 80_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[2]?.completed).toBe(1);
});
test('puzzle ready copy points to result page work info completion', () => {

View File

@@ -53,23 +53,37 @@ const PUZZLE_STEPS = [
{
id: 'compile',
label: '编译首关草稿',
detail: '根据画面描述生成首关名称和结果页草稿。',
weight: 34,
detail: '理解画面描述生成首关名称与可编辑草稿。',
weight: 20,
},
{
id: 'puzzle-images',
label: '生成首关画面',
detail: '按画面描述和参考图生成第一张拼图图。',
weight: 33,
detail: '调用图片模型生成适合切块的正方形首图。',
weight: 70,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '把首图设为正式图并同步到结果页。',
weight: 33,
detail: '确认首图并同步关卡数据,准备进入结果页。',
weight: 10,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_ESTIMATED_WAIT_MS = 60_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const PUZZLE_PHASE_TIMELINE: Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
'compile' | 'puzzle-images' | 'puzzle-select-image'
>;
durationMs: number;
}> = [
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-images', durationMs: 42_000 },
{ phase: 'puzzle-select-image', durationMs: 6_000 },
];
const BIG_FISH_STEPS = [
{
id: 'big-fish-draft',
@@ -141,6 +155,7 @@ function buildMiniGameProgressSteps(
steps: ReadonlyArray<MiniGameStepDefinition>,
activeStepIndex: number,
state: MiniGameDraftGenerationState,
activeStepProgressRatio: number,
) {
return steps.map((step, index) => {
const isCompleted = state.phase === 'ready' || index < activeStepIndex;
@@ -152,7 +167,13 @@ function buildMiniGameProgressSteps(
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted ? 1 : isAssetStep ? state.completedAssetCount : 0,
completed: isCompleted
? 1
: isAssetStep
? state.completedAssetCount
: isActive
? activeStepProgressRatio
: 0,
total: isAssetStep ? state.totalAssetCount : 1,
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
} satisfies CustomWorldGenerationStep;
@@ -201,6 +222,31 @@ function resolveSquareHolePhaseByElapsedMs(
return 'square-hole-draft';
}
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
let elapsedBeforePhase = 0;
for (const item of PUZZLE_PHASE_TIMELINE) {
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
if (elapsedInPhase < item.durationMs) {
return {
phase: item.phase,
activeStepProgressRatio: Math.max(
0,
Math.min(1, elapsedInPhase / item.durationMs),
),
};
}
elapsedBeforePhase += item.durationMs;
}
return {
phase: 'puzzle-select-image' as const,
activeStepProgressRatio: 1,
};
}
export function buildMiniGameDraftGenerationProgress(
state: MiniGameDraftGenerationState | null,
nowMs = Date.now(),
@@ -210,8 +256,19 @@ export function buildMiniGameDraftGenerationProgress(
}
const elapsedMs = Math.max(0, nowMs - state.startedAtMs);
const puzzleTimeline =
state.kind === 'puzzle' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? resolvePuzzleTimelineByElapsedMs(elapsedMs)
: null;
const normalizedState =
state.kind === 'big-fish' &&
puzzleTimeline != null
? {
...state,
phase: puzzleTimeline.phase,
}
: state.kind === 'big-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
@@ -244,6 +301,8 @@ export function buildMiniGameDraftGenerationProgress(
)
: normalizedState.phase === 'ready'
? 1
: normalizedState.kind === 'puzzle'
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
: normalizedState.kind === 'big-fish'
? 0.55
: normalizedState.kind === 'square-hole'
@@ -255,6 +314,12 @@ export function buildMiniGameDraftGenerationProgress(
: normalizedState.phase === 'ready'
? 100
: completedWeight + activeStep.weight * assetRatio;
const cappedOverallProgress =
normalizedState.phase === 'ready' || normalizedState.phase === 'failed'
? overallProgress
: normalizedState.kind === 'puzzle'
? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress)
: overallProgress;
return {
phaseId: normalizedState.phase,
@@ -272,20 +337,27 @@ export function buildMiniGameDraftGenerationProgress(
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail),
batchLabel: activeStep.label,
overallProgress: clampProgress(overallProgress),
completedWeight: clampProgress(overallProgress),
overallProgress: clampProgress(cappedOverallProgress),
completedWeight: clampProgress(cappedOverallProgress),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
normalizedState.phase === 'ready'
? 0
: normalizedState.kind === 'puzzle'
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'big-fish'
? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState),
steps: buildMiniGameProgressSteps(
steps,
activeStepIndex,
normalizedState,
assetRatio,
),
};
}

View File

@@ -1,2 +1,3 @@
export * from './visualNovelCreationClient';
export * from './visualNovelAssetClient';
export * from './visualNovelAudioGenerationClient';

View File

@@ -6,7 +6,8 @@ export type VisualNovelUploadAssetKind =
| 'cover'
| 'scene_background'
| 'character_standee'
| 'music';
| 'music'
| 'ambient_sound';
export type VisualNovelHistoryAssetKind =
| 'character_visual'
@@ -97,6 +98,11 @@ const VISUAL_NOVEL_UPLOAD_CONFIG = {
assetKind: 'visual_novel_music',
maxSizeBytes: 20 * 1024 * 1024,
},
ambient_sound: {
legacyPrefix: 'generated-custom-world-scenes',
assetKind: 'visual_novel_ambient_sound',
maxSizeBytes: 20 * 1024 * 1024,
},
} satisfies Record<
VisualNovelUploadAssetKind,
{ legacyPrefix: string; assetKind: string; maxSizeBytes: number }

View File

@@ -0,0 +1,79 @@
import type {
CreateVisualNovelBackgroundMusicRequest,
CreateVisualNovelSoundEffectRequest,
PublishVisualNovelGeneratedAudioAssetRequest,
VisualNovelAudioGenerationTaskResponse,
VisualNovelGeneratedAudioAssetResponse,
} from '../../../packages/shared/src/contracts/visualNovel';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const VISUAL_NOVEL_AUDIO_API_BASE = '/api/creation/visual-novel/audio';
const VISUAL_NOVEL_AUDIO_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 500,
maxDelayMs: 1200,
retryUnsafeMethods: true,
};
export function createVisualNovelBackgroundMusicTask(
payload: CreateVisualNovelBackgroundMusicRequest,
) {
return requestJson<VisualNovelAudioGenerationTaskResponse>(
`${VISUAL_NOVEL_AUDIO_API_BASE}/background-music`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'提交背景音乐生成失败',
{ retry: VISUAL_NOVEL_AUDIO_RETRY, timeoutMs: 20000 },
);
}
export function publishVisualNovelBackgroundMusicAsset(
taskId: string,
payload: PublishVisualNovelGeneratedAudioAssetRequest,
) {
return requestJson<VisualNovelGeneratedAudioAssetResponse>(
`${VISUAL_NOVEL_AUDIO_API_BASE}/background-music/${encodeURIComponent(taskId)}/asset`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成背景音乐素材失败',
{ retry: VISUAL_NOVEL_AUDIO_RETRY, timeoutMs: 30000 },
);
}
export function createVisualNovelSoundEffectTask(
payload: CreateVisualNovelSoundEffectRequest,
) {
return requestJson<VisualNovelAudioGenerationTaskResponse>(
`${VISUAL_NOVEL_AUDIO_API_BASE}/sound-effect`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'提交音效生成失败',
{ retry: VISUAL_NOVEL_AUDIO_RETRY, timeoutMs: 20000 },
);
}
export function publishVisualNovelSoundEffectAsset(
taskId: string,
payload: PublishVisualNovelGeneratedAudioAssetRequest,
) {
return requestJson<VisualNovelGeneratedAudioAssetResponse>(
`${VISUAL_NOVEL_AUDIO_API_BASE}/sound-effect/${encodeURIComponent(taskId)}/asset`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成音效素材失败',
{ retry: VISUAL_NOVEL_AUDIO_RETRY, timeoutMs: 30000 },
);
}