Merge branch 'master' into codex/tiaoyitiao
This commit is contained in:
212
src/services/wooden-fish/woodenFishAssetClient.test.ts
Normal file
212
src/services/wooden-fish/woodenFishAssetClient.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
141
src/services/wooden-fish/woodenFishAssetClient.ts
Normal file
141
src/services/wooden-fish/woodenFishAssetClient.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user