feat(worker): sharp pipeline with orientation, variants, exif strip
Task 20: services/worker package (@photo/worker, commonjs) with jest unit/e2e configs mirroring api conventions (root .env via test/load-env.ts, moduleNameMapper to @photo/contracts/db-cn/storage TS sources, maxWorkers 1, test/reset.ts = MySQL TRUNCATE + MinIO photo-live/dev prefix wipe) and the sharp processing pipeline runPipeline(): metadata whitelist gate (jpeg/png/webp/avif/heif/tiff, else PipelineError UNSUPPORTED_TYPE), decode failures mapped to PipelineError DECODE_FAILED, no-arg .rotate() auto-orientation (EXIF 5-8 swaps stored width/height), thumb <=480w q75 and browse <=1920 q82 JPEG variants with withoutEnlargement, and no withMetadata so outputs carry no EXIF/GPS at all. sharp 0.35 prebuilt loads script-free under pnpm 10; its win32 libvips lacks a HEVC encoder, so the HEVC-HEIF unit case self-skips with a console note (G2 verifies with real iPhone samples) while the AV1-HEIF case covers the same libheif decode path / 'heif' format bucket. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
// services/worker/test/load-env.ts
|
||||
// 全仓库唯一 env 加载机制(Jest 侧):仓库根 .env(与 services/api 同约定)。
|
||||
// 开发/生产经根 dotenv-cli 注入;运行时代码只读 process.env。
|
||||
import { join } from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config({ path: join(__dirname, '../../../.env') });
|
||||
@@ -0,0 +1,104 @@
|
||||
// services/worker/test/reset.ts
|
||||
// worker e2e 共用隔离工具(镜像 services/api/test/reset.ts):每个 e2e spec 的
|
||||
// beforeAll 调用 resetDb()。MySQL 逐表 TRUNCATE(SET FOREIGN_KEY_CHECKS=0 包裹,
|
||||
// users 被子表引用);MinIO 侧清理 photo-live/dev 前缀对象(worker 的 variants
|
||||
// 写入与 api 的 staging/originals 同前缀)。S3Client 为 devDependencies
|
||||
// (与 @photo/storage 内部同款)——StoragePort 不含列举/删除(YAGNI),隔离工具
|
||||
// 直连 S3 协议。
|
||||
import {
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
|
||||
export const RESET_TABLES: readonly string[] = [
|
||||
// 与 api/test/reset.ts 同表同序(子表在前)。
|
||||
'outbox_events',
|
||||
'photo_jobs',
|
||||
'photo_variants',
|
||||
'upload_sessions',
|
||||
'photos',
|
||||
'category_translations',
|
||||
'categories',
|
||||
'album_assets',
|
||||
'album_translations',
|
||||
'album_members',
|
||||
'albums',
|
||||
'audit_logs',
|
||||
'sessions',
|
||||
'users',
|
||||
];
|
||||
|
||||
/** e2e 对象隔离边界:与 api src/uploads/support.ts 的 OBJECT_KEY_ROOT 一致。 */
|
||||
const RESET_OBJECT_PREFIX = 'photo-live/dev/';
|
||||
|
||||
/** 删除 MinIO 中 RESET_OBJECT_PREFIX 前缀的全部对象(分页列举 + 批量删除)。 */
|
||||
async function resetMinioObjects(): Promise<void> {
|
||||
const required = [
|
||||
'S3_ENDPOINT',
|
||||
'S3_REGION',
|
||||
'S3_ACCESS_KEY',
|
||||
'S3_SECRET_KEY',
|
||||
'S3_BUCKET',
|
||||
] as const;
|
||||
const missing = required.filter((key) => !process.env[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`resetMinioObjects: 缺少环境变量 ${missing.join(', ')}`);
|
||||
}
|
||||
const client = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY as string,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY as string,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const bucket = process.env.S3_BUCKET as string;
|
||||
try {
|
||||
for (;;) {
|
||||
const listed = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: RESET_OBJECT_PREFIX,
|
||||
MaxKeys: 1000,
|
||||
}),
|
||||
);
|
||||
const objects = (listed.Contents ?? []).filter(
|
||||
(obj) => typeof obj.Key === 'string',
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
break;
|
||||
}
|
||||
await client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: objects.map((obj) => ({ Key: obj.Key as string })),
|
||||
Quiet: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!listed.IsTruncated) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetDb(): Promise<void> {
|
||||
await resetMinioObjects();
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
||||
for (const table of RESET_TABLES) {
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table}`);
|
||||
}
|
||||
} finally {
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user