refactor(api-server): narrow puzzle state surface
This commit is contained in:
@@ -13,6 +13,153 @@ export type PuzzleHistoryAsset = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PuzzleReferenceAsset = PuzzleHistoryAsset & {
|
||||
objectKey: string;
|
||||
};
|
||||
|
||||
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: 'puzzle_cover_image';
|
||||
ownerUserId?: string | null;
|
||||
profileId?: string | null;
|
||||
entityId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
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 validatePuzzleReferenceImageFile(file: File) {
|
||||
const contentType = resolvePuzzleImageContentType(file);
|
||||
if (file.size <= 0) {
|
||||
throw new Error('参考图文件为空,请重新选择。');
|
||||
}
|
||||
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
|
||||
throw new Error('参考图过大,请压缩后再上传。');
|
||||
}
|
||||
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<PuzzleReferenceAsset> {
|
||||
validatePuzzleReferenceImageFile(payload.file);
|
||||
const contentType = resolvePuzzleImageContentType(payload.file);
|
||||
const uploadedAt = Date.now();
|
||||
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||
'/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<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: 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: validatePuzzleReferenceImageFile,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
|
||||
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
|
||||
@@ -34,4 +181,5 @@ export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
|
||||
|
||||
export const puzzleAssetClient = {
|
||||
listHistoryAssets: listPuzzleHistoryAssets,
|
||||
uploadReferenceImage: uploadPuzzleReferenceImage,
|
||||
};
|
||||
|
||||
@@ -238,3 +238,18 @@ export async function cropPuzzleReferenceImageDataUrl({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function puzzleReferenceImageDataUrlToFile(
|
||||
dataUrl: string,
|
||||
fileName = 'puzzle-reference.jpg',
|
||||
) {
|
||||
const [metadata = '', encoded = ''] = dataUrl.split(',', 2);
|
||||
const mimeType =
|
||||
metadata.match(/^data:([^;]+);base64$/u)?.[1] ?? 'image/jpeg';
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new File([bytes], fileName, { type: mimeType });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user