// e2e 数据种子夹具(Node 直跑,Playwright 用例内按需调用)。 // 为什么不用 API 造数:目标照片需要 published/processing_failed 等终态, // 真实链路要 worker 处理流水线;e2e 只验证管理页展示与操作,直插 DB 行 // (uploadedAt 置位即可入管理列表,见 services/api listAlbumPhotos 过滤条件)。 // 依赖经环境变量注入包绝对路径(apps/admin 不依赖这些包,node 从文件位置 // 解析不到;services/api 的 node_modules 里有现成链接): // PHOTO_DB_CN=@photo/db-cn(必填) PHOTO_STORAGE=@photo/storage(水印模式) // 模式: // staff 员工账号(不可登录) // photos published/failed 各 1 张 // gov-photos [bytes] 批量隐藏/用量旅程: // N 张 published/ready // DB 直种(含 thumb/browse // 变体行——cn 公开列表计入; // bytes 可调大以跨阈值) // wm-photos 水印旅程:N 张已发布 // 照片 + 真实 MinIO 对象 // (原图/browse/thumb,键 // 对齐 worker 发布后形态) // watermark-run 直驱 worker dist 的 // WatermarkRegenService 把 // 最新任务跑到终态(e2e 无 // 常驻 worker,同 services/ // api/test/watermark.e2e- // spec.ts 直驱模式) // sync-flush [albumId] cn→ov 种子同步(P4b Task 5 // 展示位 e2e):albumId 提供 // 时先打 cn bootstrap(当前 // 态快照写回 outbox,直种 // photos 行入公开 feed 的 // 必经通道),再经 services/ // sync dist 的 // deliverPendingOnce 排空 // outbox(真实 HMAC 发送 // 管线;slot.updated 事件不 // 随 bootstrap 走,须此排空; // albumId 省略或 '-' = 仅排空) // 幂等:账号存在即跳过;照片按确定性 photoId/objectKey upsert(同相册重复种 // 不重复建行);watermark-run 对已 done 的任务返回 skipped 视为成功。 /* eslint-disable @typescript-eslint/no-require-imports */ const path = require('node:path') const { execSync } = require('node:child_process') const { existsSync, statSync } = require('node:fs') const { createHash, createHmac } = require('node:crypto') const dbCn = process.env.PHOTO_DB_CN const databaseUrl = process.env.DATABASE_URL_CN if (!dbCn || !databaseUrl) { console.error('seed-fixture: PHOTO_DB_CN and DATABASE_URL_CN env vars are required') process.exit(1) } // CommonJS(node 直跑,无构建步骤):动态 require 经环境变量注入的包路径 const { PrismaClient } = require(dbCn) const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } }) /** worker 包根(sharp / dist 直驱均从这里解析)。 */ const workerRoot = path.resolve(__dirname, '..', '..', '..', 'services', 'worker') async function seedStaff(account, displayName) { const existing = await prisma.users.findUnique({ where: { account } }) if (existing) { console.log(`seed-fixture: staff "${account}" exists, skipping`) return } // passwordHash 占位(不可登录):e2e 只经账号查询邀请该用户,从不登录 await prisma.users.create({ data: { account, passwordHash: 'seeded-no-login', displayName: displayName || account, role: 'staff', credentialStatus: 'active' } }) console.log(`seed-fixture: staff "${account}" created`) } async function seedPhotos(albumId, uploaderAccount) { const uploader = await prisma.users.findUnique({ where: { account: uploaderAccount } }) if (!uploader) { throw new Error(`seed-fixture: uploader "${uploaderAccount}" not found`) } const now = new Date() const rows = [ { suffix: 'published', visibility: 'published', processingState: 'ready', width: 1200, height: 800, publishedAt: now, errorCode: null }, { suffix: 'failed', visibility: 'unpublished', processingState: 'processing_failed', width: null, height: null, publishedAt: null, errorCode: 'DECODE_FAILED' } ] for (const row of rows) { const objectKey = `e2e-seed/albums/${albumId}/${row.suffix}.jpg` await prisma.photos.upsert({ where: { objectKey }, update: {}, create: { albumId, uploaderId: uploader.id, objectKey, bytes: 2048, width: row.width, height: row.height, uploadedAt: now, publishedAt: row.publishedAt, processingState: row.processingState, errorCode: row.errorCode, visibility: row.visibility } }) } console.log(`seed-fixture: 2 photos seeded for album ${albumId}`) } /** 确定性 photoId(同相册同序号重复种不重复建行;长度/字符集适配 cuid 列)。 */ function deterministicPhotoId(scope, index) { return `e2e${createHash('md5').update(`${scope}:${index}`).digest('hex')}` } async function findUploader(account) { const uploader = await prisma.users.findUnique({ where: { account } }) if (!uploader) { throw new Error(`seed-fixture: uploader "${account}" not found`) } return uploader } /** * gov-photos:N 张 published/ready 照片 DB 直种。带 thumb/browse 变体行 * (cn 公开列表计入的必要条件——服务端只现签行上的键,不校验对象存在, * 故无需真实对象);bytes 可调(用量旅程用大值跨 perAlbum 阈值)。 */ async function seedGovPhotos(albumId, uploaderAccount, count, bytesEach) { const uploader = await findUploader(uploaderAccount) const total = Math.max(1, Number(count) || 1) const bytes = Math.max(1, Number(bytesEach) || 2048) const now = new Date() for (let i = 0; i < total; i++) { const photoId = deterministicPhotoId(`${albumId}:gov`, i) const objectKey = `e2e-gov/albums/${albumId}/originals/${photoId}.jpg` await prisma.photos.upsert({ where: { objectKey }, update: { visibility: 'published', processingState: 'ready' }, create: { id: photoId, albumId, uploaderId: uploader.id, objectKey, bytes, width: 1200, height: 800, uploadedAt: now, publishedAt: now, processingState: 'ready', visibility: 'published' } }) // 变体行(thumb/browse):公开列表计入 + 现签 URL 可用;对象不落(e2e 不取图) const variants = [ { variant: 'thumb', width: 480, height: 320 }, { variant: 'browse', width: 1200, height: 800 } ] for (const v of variants) { await prisma.photo_variants.upsert({ where: { photoId_variant: { photoId, variant: v.variant } }, update: {}, create: { photoId, variant: v.variant, objectKey: `e2e-gov/albums/${albumId}/variants/${photoId}/1/${v.variant}.jpg`, bytes: Math.round(bytes / 4), version: 1, width: v.width, height: v.height } }) } } console.log(`seed-fixture: ${total} gov photos seeded for album ${albumId} (${bytes} B each)`) } /** 测试内生成真实 JPEG(带纹理;与 worker test makeJpeg 同法,水印差异可观测)。 */ function makeJpegBuffer(sharp, width, height) { const svg = Buffer.from( `` + Array.from( { length: 12 }, (_, i) => `` ).join('') + '' ) return sharp(svg).jpeg({ quality: 85 }).toBuffer() } /** * wm-photos:N 张已发布照片(原图 + browse/thumb 对象与行,形状对齐 worker * 发布后形态——watermark regen 读原图、公开列表需 thumb/browse 行)。 * 键布局走 @photo/storage OBJECT_KEY_ROOT 约定(photo-live/dev)。 */ async function seedWmPhotos(albumId, uploaderAccount, count) { const storagePath = process.env.PHOTO_STORAGE if (!storagePath) { throw new Error('seed-fixture: PHOTO_STORAGE env var is required for wm-photos') } const { OBJECT_KEY_ROOT, createStorageFromEnv } = require(storagePath) const sharp = require(path.join(workerRoot, 'node_modules', 'sharp')) const uploader = await findUploader(uploaderAccount) const storage = createStorageFromEnv() const total = Math.max(1, Number(count) || 1) const now = new Date() for (let i = 0; i < total; i++) { const photoId = deterministicPhotoId(`${albumId}:wm`, i) const objectKey = `${OBJECT_KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg` const browseKey = `${OBJECT_KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/browse.jpg` const thumbKey = `${OBJECT_KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/thumb.jpg` const original = await makeJpegBuffer(sharp, 900, 600) // browse/thumb 与原图同缓冲即可(e2e 断言 URL 指向,不比对变体字节) await storage.put(objectKey, original, 'image/jpeg') await storage.put(browseKey, original, 'image/jpeg') await storage.put(thumbKey, original, 'image/jpeg') await prisma.photos.upsert({ where: { objectKey }, update: {}, create: { id: photoId, albumId, uploaderId: uploader.id, objectKey, bytes: original.length, width: 900, height: 600, uploadedAt: now, publishedAt: now, processingState: 'ready', visibility: 'published' } }) const variants = [ { variant: 'thumb', key: thumbKey, width: 480, height: 320 }, { variant: 'browse', key: browseKey, width: 900, height: 600 } ] for (const v of variants) { await prisma.photo_variants.upsert({ where: { photoId_variant: { photoId, variant: v.variant } }, update: {}, create: { photoId, variant: v.variant, objectKey: v.key, bytes: original.length, version: 1, width: v.width, height: v.height } }) } } console.log(`seed-fixture: ${total} wm photos (with real objects) seeded for album ${albumId}`) } /** * watermark-run:把相册最新水印任务直驱到终态。e2e 环境无常驻 worker * (Playwright 只起 api + admin dev),借 worker dist 的服务核心直跑——与 * services/api/test/watermark.e2e-spec.ts 同一入口(processJob),API 侧 * POST 已落好 config/job 行。dist 过期(缺文件或源更新)则先 tsc 构建。 */ async function runWatermark(albumId) { const distService = path.join(workerRoot, 'dist', 'watermark.service.js') const srcService = path.join(workerRoot, 'src', 'watermark.service.ts') const stale = !existsSync(distService) || (existsSync(srcService) && statSync(srcService).mtimeMs > statSync(distService).mtimeMs) if (stale) { console.log('seed-fixture: building services/worker dist (stale or missing)...') execSync('npx tsc -p tsconfig.build.json', { cwd: workerRoot, stdio: 'inherit' }) } const { WatermarkRegenService } = require(distService) const storagePath = process.env.PHOTO_STORAGE if (!storagePath) { throw new Error('seed-fixture: PHOTO_STORAGE env var is required for watermark-run') } const { createStorageFromEnv } = require(storagePath) const storage = createStorageFromEnv() const job = await prisma.watermark_jobs.findFirst({ where: { albumId }, orderBy: { createdAt: 'desc' } }) if (!job) { throw new Error(`seed-fixture: no watermark job found for album ${albumId}`) } const service = new WatermarkRegenService(prisma, storage) const outcome = await service.processJob(job.id) console.log(`seed-fixture: watermark job ${job.id} → ${outcome}`) if (outcome === 'failed') { throw new Error('seed-fixture: watermark job failed (task-level abort)') } } /** * sync-flush:cn→ov 种子同步(apps/viewer e2e helpers flushSync/flushOutbox * 的 execSync 版——apps/admin 不依赖 @photo/db-cn/@photo/contracts,经 * services/{api,sync} 的 node_modules/dist 借道)。bootstrap 为 cn 内部 * HMAC 端点(签名 = HMAC-SHA256(key, rawBody).hex,同 contracts * signSyncBody);发送器 dist 过期则先 tsc 构建(同 watermark-run 口径)。 */ async function syncFlush(albumId) { const internalKey = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me' const cnBase = (process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '') if (albumId && albumId !== '-') { const rawBody = JSON.stringify({ albumId }) const bootstrap = await fetch(`${cnBase}/v1/internal/sync/bootstrap`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-sync-timestamp': String(Math.floor(Date.now() / 1000)), 'x-sync-signature': createHmac('sha256', internalKey).update(rawBody).digest('hex') }, body: rawBody }) if (!bootstrap.ok) { throw new Error( `seed-fixture: bootstrap 失败(${bootstrap.status}):${(await bootstrap.text()).slice(0, 300)}` ) } console.log(`seed-fixture: bootstrap ok for album ${albumId}`) } const syncRoot = path.resolve(__dirname, '..', '..', '..', 'services', 'sync') const distSender = path.join(syncRoot, 'dist', 'sender.js') const srcSender = path.join(syncRoot, 'src', 'sender.ts') const stale = !existsSync(distSender) || (existsSync(srcSender) && statSync(srcSender).mtimeMs > statSync(distSender).mtimeMs) if (stale) { console.log('seed-fixture: building services/sync dist (stale or missing)...') execSync('npx tsc -p tsconfig.build.json', { cwd: syncRoot, stdio: 'inherit' }) } const { deliverPendingOnce } = require(distSender) await deliverPendingOnce({ prisma, ovApiUrl: process.env.OV_API_URL ?? 'http://127.0.0.1:8081', internalKey }) console.log('seed-fixture: outbox drained to ov') } async function main() { const [mode, argA, argB, argC, argD] = process.argv.slice(2) if (mode === 'staff') { await seedStaff(argA, argB) } else if (mode === 'photos') { await seedPhotos(argA, argB) } else if (mode === 'gov-photos') { await seedGovPhotos(argA, argB, argC, argD) } else if (mode === 'wm-photos') { await seedWmPhotos(argA, argB, argC) } else if (mode === 'watermark-run') { await runWatermark(argA) } else if (mode === 'sync-flush') { await syncFlush(argA) } else { throw new Error(`seed-fixture: unknown mode "${mode}"`) } } main() .catch((error) => { console.error(error) process.exitCode = 1 }) .finally(() => prisma.$disconnect())