refactor(api-server): narrow puzzle state surface

This commit is contained in:
kdletters
2026-05-21 18:55:25 +08:00
parent cc23b6020d
commit 5834a99107
31 changed files with 1087 additions and 169 deletions

View File

@@ -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,
};

View File

@@ -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 });
}