Files
photo-live/apps/admin/e2e/seed-fixture.cjs
T
2026-09-20 21:10:30 +08:00

329 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <account> <displayName> 员工账号(不可登录)
// photos <albumId> <uploaderAccount> published/failed 各 1 张
// gov-photos <albumId> <uploaderAccount> <count> [bytes] 批量隐藏/用量旅程:
// N 张 published/ready
// DB 直种(含 thumb/browse
// 变体行——cn 公开列表计入;
// bytes 可调大以跨阈值)
// wm-photos <albumId> <uploaderAccount> <count> 水印旅程:N 张已发布
// 照片 + 真实 MinIO 对象
// (原图/browse/thumb,键
// 对齐 worker 发布后形态)
// watermark-run <albumId> 直驱 worker dist 的
// WatermarkRegenService 把
// 最新任务跑到终态(e2e 无
// 常驻 worker,同 services/
// api/test/watermark.e2e-
// spec.ts 直驱模式)
// 幂等:账号存在即跳过;照片按确定性 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 } = 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(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
Array.from(
{ length: 12 },
(_, i) =>
`<rect x="${(i * 83) % width}" y="${(i * 47) % height}" width="${50 + i * 9}" height="${35 + i * 7}" fill="#${(0x50 + i * 7).toString(16).padStart(2, '0')}6030" fill-opacity="0.85"/>`
).join('') +
'</svg>'
)
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)')
}
}
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 {
throw new Error(`seed-fixture: unknown mode "${mode}"`)
}
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())