Merge branch 'master' into codex/tiaoyitiao

This commit is contained in:
2026-06-07 00:57:38 +08:00
37 changed files with 2734 additions and 194 deletions

View File

@@ -0,0 +1,212 @@
/* @vitest-environment jsdom */
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
const requestJsonMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
import { uploadWoodenFishHitSoundAsset } from './woodenFishAssetClient';
const uploadTicket = {
upload: {
bucket: 'private-bucket',
host: 'https://oss.example.test/upload',
objectKey: 'generated-wooden-fish-assets/draft/hit-sound/uploaded/1/hit.wav',
legacyPublicPath: '/generated-wooden-fish-assets/draft/hit-sound.wav',
formFields: {
key: 'object-key',
policy: 'policy-value',
empty: null,
},
},
};
const confirmedAsset = {
assetObject: {
assetObjectId: 'asset-object-hit-sound',
objectKey: uploadTicket.upload.objectKey,
assetKind: 'wooden_fish_hit_sound',
},
};
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock
.mockResolvedValueOnce(uploadTicket)
.mockResolvedValueOnce(confirmedAsset);
globalThis.fetch = vi.fn(async () => new Response(null, { status: 204 }));
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-06T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
test('空音频 Blob 不创建上传凭证', async () => {
await expect(
uploadWoodenFishHitSoundAsset(
new Blob([], { type: 'audio/wav' }),
'uploaded',
'empty.wav',
),
).rejects.toThrow('音频文件为空,请重新选择。');
expect(requestJsonMock).not.toHaveBeenCalled();
});
test('超过 20MB 的音频不创建上传凭证', async () => {
await expect(
uploadWoodenFishHitSoundAsset(
new Blob([new Uint8Array(20 * 1024 * 1024 + 1)], {
type: 'audio/wav',
}),
'uploaded',
'large.wav',
),
).rejects.toThrow('音频文件过大,请压缩后再上传。');
expect(requestJsonMock).not.toHaveBeenCalled();
});
test('非音频 MIME 不创建上传凭证', async () => {
await expect(
uploadWoodenFishHitSoundAsset(
new Blob(['text'], { type: 'text/plain' }),
'uploaded',
'note.txt',
),
).rejects.toThrow('请选择音频文件。');
expect(requestJsonMock).not.toHaveBeenCalled();
});
test('File 上传使用自身 MIME 和文件名创建上传凭证', async () => {
const file = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
const asset = await uploadWoodenFishHitSoundAsset(file, 'uploaded');
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/assets/direct-upload-tickets',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'创建敲击音效上传凭证失败',
);
const ticketBody = JSON.parse(requestJsonMock.mock.calls[0]![1].body);
expect(ticketBody).toMatchObject({
legacyPrefix: 'generated-wooden-fish-assets',
fileName: 'hit.webm',
contentType: 'audio/webm',
access: 'private',
maxSizeBytes: 20 * 1024 * 1024,
metadata: {
asset_kind: 'wooden_fish_hit_sound',
wooden_fish_slot: 'hit_sound',
wooden_fish_audio_source: 'uploaded',
},
});
expect(ticketBody.pathSegments).toEqual([
'wooden-fish',
'draft',
'hit-sound',
'uploaded',
String(Date.now()),
]);
expect(fetch).toHaveBeenCalledWith(uploadTicket.upload.host, {
method: 'POST',
body: expect.any(FormData),
});
expect(asset).toMatchObject({
assetId: 'asset-object-hit-sound',
audioSrc: uploadTicket.upload.legacyPublicPath,
audioObjectKey: uploadTicket.upload.objectKey,
assetObjectId: 'asset-object-hit-sound',
source: 'uploaded',
prompt: 'hit.webm',
});
});
test('Blob 上传可通过文件名扩展推断音频 MIME', async () => {
const blob = new Blob(['audio']);
await uploadWoodenFishHitSoundAsset(blob, 'recorded', 'recorded.wav');
const ticketBody = JSON.parse(requestJsonMock.mock.calls[0]![1].body);
expect(ticketBody).toMatchObject({
fileName: 'recorded.wav',
contentType: 'audio/wav',
metadata: {
wooden_fish_audio_source: 'recorded',
},
});
});
test('Blob 缺少 MIME 且文件扩展未知时拒绝上传', async () => {
await expect(
uploadWoodenFishHitSoundAsset(new Blob(['audio']), 'uploaded', 'hit.bin'),
).rejects.toThrow('请选择音频文件。');
expect(requestJsonMock).not.toHaveBeenCalled();
});
test('OSS POST 失败时不确认资产对象', async () => {
globalThis.fetch = vi.fn(async () => new Response(null, { status: 500 }));
await expect(
uploadWoodenFishHitSoundAsset(
new File(['audio'], 'hit.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('上传敲击音效失败。');
expect(requestJsonMock).toHaveBeenCalledTimes(1);
});
test('确认资产对象失败时透传确认错误', async () => {
requestJsonMock.mockReset();
requestJsonMock
.mockResolvedValueOnce(uploadTicket)
.mockRejectedValueOnce(new Error('确认敲击音效资产失败'));
await expect(
uploadWoodenFishHitSoundAsset(
new File(['audio'], 'hit.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('确认敲击音效资产失败');
expect(fetch).toHaveBeenCalledTimes(1);
});
test('确认资产对象时提交 OSS 对象和内容长度', async () => {
const file = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
await uploadWoodenFishHitSoundAsset(file, 'uploaded');
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/assets/objects/confirm',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'确认敲击音效资产失败',
);
const confirmBody = JSON.parse(requestJsonMock.mock.calls[1]![1].body);
expect(confirmBody).toMatchObject({
bucket: uploadTicket.upload.bucket,
objectKey: uploadTicket.upload.objectKey,
contentType: 'audio/webm',
contentLength: file.size,
assetKind: 'wooden_fish_hit_sound',
accessPolicy: 'private',
entityId: 'hit_sound',
});
});

View File

@@ -0,0 +1,141 @@
import type { WoodenFishAudioAsset } from '../../../packages/shared/src/contracts/woodenFish';
import { requestJson } from '../apiClient';
const WOODEN_FISH_AUDIO_UPLOAD_MAX_BYTES = 20 * 1024 * 1024;
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;
};
};
const MIME_BY_EXTENSION: Record<string, string> = {
m4a: 'audio/mp4',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
webm: 'audio/webm',
};
function resolveAudioContentType(file: Blob, fileName: string) {
if (file.type.trim()) {
return file.type.trim();
}
const extension = fileName.split('.').pop()?.trim().toLowerCase() ?? '';
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
}
function validateWoodenFishAudioFile(file: Blob, fileName: string) {
const contentType = resolveAudioContentType(file, fileName);
if (file.size <= 0) {
throw new Error('音频文件为空,请重新选择。');
}
if (file.size > WOODEN_FISH_AUDIO_UPLOAD_MAX_BYTES) {
throw new Error('音频文件过大,请压缩后再上传。');
}
if (!contentType.startsWith('audio/')) {
throw new Error('请选择音频文件。');
}
return contentType;
}
function buildWoodenFishAudioPathSegments(source: 'uploaded' | 'recorded') {
return ['wooden-fish', 'draft', 'hit-sound', source, `${Date.now()}`];
}
async function postDirectUploadFile(
upload: DirectUploadTicketResponse['upload'],
file: Blob,
fileName: string,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file, fileName);
const response = await fetch(upload.host, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传敲击音效失败。');
}
}
export async function uploadWoodenFishHitSoundAsset(
file: Blob,
source: 'uploaded' | 'recorded',
fileName =
typeof File !== 'undefined' && file instanceof File
? file.name
: 'wooden-fish-hit-sound.wav',
): Promise<WoodenFishAudioAsset> {
const contentType = validateWoodenFishAudioFile(file, fileName);
const ticket = await requestJson<DirectUploadTicketResponse>(
'/api/assets/direct-upload-tickets',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: 'generated-wooden-fish-assets',
pathSegments: buildWoodenFishAudioPathSegments(source),
fileName,
contentType,
access: 'private',
maxSizeBytes: WOODEN_FISH_AUDIO_UPLOAD_MAX_BYTES,
metadata: {
asset_kind: 'wooden_fish_hit_sound',
wooden_fish_slot: 'hit_sound',
wooden_fish_audio_source: source,
},
}),
},
'创建敲击音效上传凭证失败',
);
await postDirectUploadFile(ticket.upload, file, fileName);
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: 'wooden_fish_hit_sound',
accessPolicy: 'private',
entityId: 'hit_sound',
}),
},
'确认敲击音效资产失败',
);
return {
assetId: confirmed.assetObject.assetObjectId,
audioSrc: ticket.upload.legacyPublicPath,
audioObjectKey: confirmed.assetObject.objectKey,
assetObjectId: confirmed.assetObject.assetObjectId,
source,
prompt: fileName,
durationMs: null,
};
}