import { createHash, createHmac } from 'node:crypto'; import { GetObjectCommand, PutObjectCommand, type S3Client, } from '@aws-sdk/client-s3'; import sharp from 'sharp'; import type { OzonPublishItemSnapshot } from '@atlas/contracts'; export type PublishMediaConfig = { provider: string; bucket: string; downloadOrigin: string; accessKey: string; secretKey: string; ttlSeconds: number; }; /** 七牛私有下载凭证签署包含过期时间的完整 URL,凭证只在提交期间保留。 */ export function qiniuPrivateDownloadUrl( origin: string, key: string, deadline: number, accessKey: string, secretKey: string, ): string { const base = new URL(origin); if ( base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || base.pathname !== '/' || !accessKey || !secretKey || !Number.isSafeInteger(deadline) || deadline <= 0 ) throw new Error('请配置七牛公网 HTTPS 下载域名及签名凭据'); const url = `${base.origin}/${key.split('/').map(encodeURIComponent).join('/')}?e=${deadline}`; const signature = createHmac('sha1', secretKey) .update(url) .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_'); return `${url}&token=${accessKey}:${signature}`; } /** 为本次发布复制不可变图片,源 checksum 不符时禁止交付;WebP 转为 JPEG。 */ export async function prepareOzonImages( storage: S3Client, snapshot: OzonPublishItemSnapshot, workspaceId: string, jobId: string, config: PublishMediaConfig, ): Promise { if ( config.provider !== 'qiniu_kodo' || !config.downloadOrigin || !Number.isSafeInteger(config.ttlSeconds) || config.ttlSeconds < 3600 ) throw new Error( '上架图片交付尚未配置,请配置七牛 HTTPS 下载域名和有效期', ); const urls: string[] = []; for (const media of snapshot.media) { if ( media.storage_provider !== config.provider || media.bucket !== config.bucket ) throw new Error('图片存储环境与当前七牛配置不一致,请先迁移素材'); const object = await storage.send( new GetObjectCommand({ Bucket: media.bucket, Key: media.object_key, }), { abortSignal: AbortSignal.timeout(60_000) }, ); if (!object.Body) throw new Error('发布源图片不存在'); const chunks: Buffer[] = []; let size = 0; for await (const chunk of object.Body as AsyncIterable) { size += chunk.byteLength; if (size > 20 * 1024 * 1024) throw new Error('发布图片超过 20 MB'); chunks.push(Buffer.from(chunk)); } const source = Buffer.concat(chunks); if ( createHash('sha256').update(source).digest('hex') !== media.checksum_sha256 ) throw new Error('图片内容已变更,请重新保存草稿后上架'); const image = sharp(source, { limitInputPixels: 100_000_000, failOn: 'error', }); const meta = await image.metadata(); if (!['jpeg', 'png', 'webp'].includes(meta.format ?? '')) throw new Error('发布图片实际格式不受支持'); const webp = meta.format === 'webp'; const bytes = webp ? await image .rotate() .flatten({ background: '#ffffff' }) .jpeg({ quality: 95 }) .toBuffer() : source; const ext = meta.format === 'png' ? 'png' : 'jpg'; const key = `publishing/${workspaceId}/${jobId}/${media.asset_id}-${media.checksum_sha256}.${ext}`; await storage.send( new PutObjectCommand({ Bucket: media.bucket, Key: key, Body: bytes, ContentType: ext === 'png' ? 'image/png' : 'image/jpeg', }), { abortSignal: AbortSignal.timeout(60_000) }, ); urls.push( qiniuPrivateDownloadUrl( config.downloadOrigin, key, Math.floor(Date.now() / 1000) + config.ttlSeconds, config.accessKey, config.secretKey, ), ); } return urls; }