import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient'; import { requestJson } from '../apiClient'; import { PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, validatePuzzleReferenceImageFile, } from '../puzzleReferenceImage'; export type PuzzleHistoryAsset = { assetObjectId: string; assetKind: 'puzzle_cover_image'; imageSrc: string; ownerUserId?: string | null; ownerLabel: string; profileId?: string | null; entityId?: string | null; createdAt: string; updatedAt: string; }; export type PuzzleReferenceAsset = PuzzleHistoryAsset & { objectKey: string; }; type DirectUploadTicketResponse = { upload: { bucket: string; host: string; objectKey: string; legacyPublicPath: string; formFields: Record; }; }; type ConfirmAssetObjectResponse = { assetObject: { assetObjectId: string; objectKey: string; assetKind: 'puzzle_cover_image'; ownerUserId?: string | null; profileId?: string | null; entityId?: string | null; createdAt?: string; updatedAt?: string; }; }; const MIME_BY_EXTENSION: Record = { jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp', }; function resolvePuzzleImageContentType(file: File) { if (file.type.trim()) { return file.type.trim(); } const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? ''; return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream'; } function validatePuzzleReferenceImageUploadFile(file: File) { const contentType = resolvePuzzleImageContentType(file); validatePuzzleReferenceImageFile(file); if (!contentType.startsWith('image/')) { throw new Error('参考图必须是图片文件。'); } } async function postDirectUploadFile( upload: DirectUploadTicketResponse['upload'], file: File, ) { const formData = new FormData(); Object.entries(upload.formFields).forEach(([key, value]) => { if (value !== null && value !== undefined) { formData.append(key, value); } }); formData.append('file', file); const response = await fetch(upload.host, { method: 'POST', body: formData, }); if (!response.ok) { throw new Error('上传拼图参考图失败。'); } } export async function uploadPuzzleReferenceImage(payload: { file: File; }): Promise { validatePuzzleReferenceImageUploadFile(payload.file); const contentType = resolvePuzzleImageContentType(payload.file); const uploadedAt = Date.now(); const ticket = await requestJson( '/api/assets/direct-upload-tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ legacyPrefix: 'generated-puzzle-assets', pathSegments: ['puzzle-reference', 'draft', `${uploadedAt}`], fileName: payload.file.name, contentType, access: 'private', maxSizeBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, metadata: { asset_kind: 'puzzle_cover_image', puzzle_slot: 'reference_image', }, }), }, '创建拼图参考图上传凭证失败', ); await postDirectUploadFile(ticket.upload, payload.file); const confirmed = await requestJson( '/api/assets/objects/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bucket: ticket.upload.bucket, objectKey: ticket.upload.objectKey, contentType, contentLength: payload.file.size, assetKind: 'puzzle_cover_image', accessPolicy: 'private', }), }, '确认拼图参考图失败', ); return { assetObjectId: confirmed.assetObject.assetObjectId, assetKind: confirmed.assetObject.assetKind, objectKey: confirmed.assetObject.objectKey, imageSrc: ticket.upload.legacyPublicPath, ownerUserId: confirmed.assetObject.ownerUserId, ownerLabel: confirmed.assetObject.ownerUserId ? `账号 ${confirmed.assetObject.ownerUserId}` : '当前账号', profileId: confirmed.assetObject.profileId, entityId: confirmed.assetObject.entityId, createdAt: confirmed.assetObject.createdAt ?? '', updatedAt: confirmed.assetObject.updatedAt ?? '', }; } export const puzzleReferenceAssetTestUtils = { maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, validateFile: validatePuzzleReferenceImageUploadFile, }; export { PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, validatePuzzleReferenceImageUploadFile as validatePuzzleReferenceImageFile, }; /** * 读取历史拼图图片素材。结果页只把它们作为参考图来源, * 不直接替换当前正式图,正式图仍由后端单图生成链路写回。 */ export async function listPuzzleHistoryAssets(payload: { limit?: number }) { const params = new URLSearchParams({ kind: 'puzzle_cover_image' }); if (payload.limit) { params.set('limit', String(payload.limit)); } const response = await requestJson<{ assets: PuzzleHistoryAsset[] }>( `${ASSET_API_PATHS.assetHistory}?${params.toString()}`, { method: 'GET' }, '读取历史拼图素材失败', ); return response.assets; } export const puzzleAssetClient = { listHistoryAssets: listPuzzleHistoryAssets, uploadReferenceImage: uploadPuzzleReferenceImage, };