Files
photo-live/apps/admin/e2e/seed-fixture.cjs
T
浅念 0238f505c4 feat(admin): embed slot management with code snippet
P4b Task 5:/system/embed-slots 管理页(Task 8/9 页面模式:useCursorList
游标累积 + 对话框治理)。列表(名称/绑定相册/启停/configVersion/更新时间,
启停开关行内 PATCH → configVersion+1 toast 提示)+ 新建/编辑对话框
(设置表单位置/尺寸/间隔/语言/默认收起,intervalMs 契约区间夹取;编辑按
整体替换语义提交完整 settings)+ 绑定相册对话框(相册标题/ID 搜索选中 →
确认框携带 A22 文案「configVersion 将递增,官网无需改动」;清空即解绑)+
嵌入代码卡({EMBED_ORIGIN}/embed/v1/loader.js + data-slot 令牌,一键复制;
EMBED_ORIGIN=VITE_EMBED_ORIGIN 缺省本机 viewer dev,.env.example 登记)。

- API 层 src/api/embedSlots.ts + types/api/embedSlot.d.ts(契约镜像)
- vitest 9 例:表单→载荷(缺省+夹取)、编辑载荷收敛、snippet 拼接、
  环境基址兜底、绑定变更分类、zh/en 键一致 + A22 短语断言
- Playwright e2e(真实 cn+ov 后端):新建→绑定(gov-photos 直种 +
  seed-fixture 新增 sync-flush 模式经 bootstrap/真实发送管线投影 photos_ov)
  →嵌入代码卡含 token→ov 公开 feed 返回相册照片→换绑 configVersion+1 +
  feed 切换(A22)→停用 feed 404;admin playwright 配置补 ov webServer
- i18n zh/en(embedSlotsManage.* + menus.system.embedSlots)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 23:25:59 +08:00

393 lines
16 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 直驱模式)
// 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(
`<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)')
}
}
/**
* 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())