133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
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<string[]> {
|
|
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<Uint8Array>) {
|
|
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;
|
|
}
|