feat(admin): batch ops, transfer, watermark, usage and qr share pages

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 21:10:30 +08:00
parent 618d1d796c
commit f08d68bebf
31 changed files with 2717 additions and 34 deletions
+281
View File
@@ -0,0 +1,281 @@
// 相册治理增量 e2e(P4a Task 9,真实后端 + compose 基础设施):批量隐藏
// (多选 2 → toast 成功汇总 + 行原位已隐藏 + cn 公开列表仅剩 1)→ 负责人交接
// (前缀搜索 staff → API 断言 ownerId 变更)→ 水印(配置保存 → 任务受理 →
// fixture 直驱 worker 核心到 done → UI 轮询见已完成 + 公开 browse URL 指向
// /watermark.jpg)→ 用量(阈值调小触发告警 → 上调解除)→ 分享二维码
// (canvas 非空 + URL 复制)。选择器一律 data-test;文案断言仅用稳定短语。
import { expect, test, type Page } from '@playwright/test'
import { adminCredentials, loginAsAdmin, seedFixture } from './helpers'
test.use({ testIdAttribute: 'data-test' })
test.beforeAll(() => {
// 幂等种子 staff 账号(交接目标;密码占位不可登录,仅用于查询与交接)
seedFixture('staff', 'staff-e2e', 'Staff E2E')
})
/** UI 创建相册(zh+en 标题),返回标题后缀(同 albums.spec 模式) */
async function createAlbumViaUi(page: Page): Promise<string> {
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
await page.getByTestId('album-create-btn').click()
await expect(page.getByTestId('album-form-dialog')).toBeVisible()
await page.getByTestId('album-form-title-zh-CN').fill(`E2E治理相册 ${suffix}`)
await page.getByRole('tab', { name: '英文' }).click()
await page.getByTestId('album-form-title-en').fill(`E2E Gov Album ${suffix}`)
await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01 10:00:00')
await page.keyboard.press('Enter')
await page.getByTestId('album-form-submit').click()
await expect(page.getByTestId('album-form-dialog')).toBeHidden()
await expect(
page.getByTestId('album-list-table').getByText(`E2E治理相册 ${suffix}`)
).toBeVisible()
return suffix
}
/** 列表内定位目标相册行 */
function albumRow(page: Page, suffix: string) {
return page.getByTestId('album-list-table').locator('tr', { hasText: `E2E治理相册 ${suffix}` })
}
/** 进入详情页,返回 albumId */
async function openAlbumDetail(page: Page, suffix: string): Promise<string> {
await albumRow(page, suffix).getByTestId('album-detail-btn').click()
await expect(page.getByTestId('album-detail-card')).toBeVisible()
const albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? ''
expect(albumId).not.toBe('')
return albumId
}
/** API 登录(UI 之外的断言用):返回 Bearer 会话令牌 */
async function apiLogin(page: Page): Promise<string> {
const { account, password } = adminCredentials()
const res = await page.request.post('/v1/auth/password', { data: { account, password } })
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as { sessionToken: string }
return body.sessionToken
}
/** cn 公开列表条数(批量隐藏的观众侧断言) */
async function publicPhotoCount(page: Page, token: string): Promise<number> {
const res = await page.request.get(`/v1/public/albums/${token}/photos`)
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as { items: unknown[] }
return body.items.length
}
/** ElSwitch 幂等设到目标态(is-checked 类为准;已同态不点击)。 */
async function setSwitchState(page: Page, testId: string, on: boolean): Promise<void> {
const isOn = await page.getByTestId(testId).evaluate((el) => el.classList.contains('is-checked'))
if (isOn !== on) {
await page.getByTestId(testId).click()
}
}
test('批量隐藏:多选 2 → 汇总 toast + 行原位已隐藏 + 公开列表仅剩 1', async ({ page }) => {
await loginAsAdmin(page)
const suffix = await createAlbumViaUi(page)
const albumId = await openAlbumDetail(page, suffix)
seedFixture('gov-photos', albumId, adminCredentials().account, '3')
await page.reload()
await expect(page.getByTestId('photo-list-table')).toBeVisible()
const rows = page.getByTestId('photo-list-table').locator('tbody tr')
await expect(rows).toHaveCount(3)
for (const row of await rows.all()) {
await expect(row.getByTestId('photo-visibility-tag')).toHaveText('已发布')
}
// 勾选前两行 → 批量按钮亮起(计数 2)→ 确认
await rows.nth(0).locator('.el-checkbox').click()
await rows.nth(1).locator('.el-checkbox').click()
const batchBtn = page.getByTestId('photo-batch-hide-btn')
await expect(batchBtn).toBeEnabled()
await expect(batchBtn).toContainText('2')
await batchBtn.click()
await page.getByRole('button', { name: '确定' }).click()
// 汇总 toast(全部成功分支)+ 两行原位已隐藏、一行保持已发布
await expect(page.locator('.el-message').filter({ hasText: '成功 2 张' })).toBeVisible()
await expect(rows.nth(0).getByTestId('photo-visibility-tag')).toHaveText('已隐藏')
await expect(rows.nth(1).getByTestId('photo-visibility-tag')).toHaveText('已隐藏')
await expect(rows.nth(2).getByTestId('photo-visibility-tag')).toHaveText('已发布')
// 观众侧:公开列表 3 → 1
const token = await page.getByTestId('album-detail-token').textContent()
expect(await publicPhotoCount(page, token ?? '')).toBe(1)
})
test('负责人交接:搜索 staff → 交接 → ownerId 变更(API + 详情回显)', async ({ page }) => {
await loginAsAdmin(page)
const suffix = await createAlbumViaUi(page)
const albumId = await openAlbumDetail(page, suffix)
const sessionToken = await apiLogin(page)
// 交接前 owner 是 admin 自己
const before = (await (
await page.request.get(`/v1/albums/${albumId}`, {
headers: { Authorization: `Bearer ${sessionToken}` }
})
).json()) as { ownerId: string }
const staffId = (
(
(await (
await page.request.get('/v1/admin/users', {
params: { accountPrefix: 'staff-e2e' },
headers: { Authorization: `Bearer ${sessionToken}` }
})
).json()) as { items: { id: string }[] }
).items[0] ?? { id: '' }
).id
expect(staffId).not.toBe('')
expect(before.ownerId).not.toBe(staffId)
// 交接对话框:前缀搜索(≥2 字符)→ 选中 → 保留协作者开关默认开 → 提交
await page.getByTestId('album-detail-transfer').click()
await expect(page.getByTestId('transfer-dialog')).toBeVisible()
const targetSelect = page.getByTestId('transfer-target-select')
await targetSelect.click()
await targetSelect.locator('input').fill('staff-e2e')
await page.getByTestId('transfer-option-staff-e2e').click()
await expect(page.getByTestId('transfer-keep-switch')).toHaveClass(/is-checked/)
await page.getByTestId('transfer-submit').click()
await expect(page.getByTestId('transfer-dialog')).toBeHidden()
await expect(page.locator('.el-message').filter({ hasText: '负责人已交接' })).toBeVisible()
// 详情回显 + API 权威断言
await expect(page.getByTestId('album-detail-owner-id')).toHaveText(staffId)
const after = (await (
await page.request.get(`/v1/albums/${albumId}`, {
headers: { Authorization: `Bearer ${sessionToken}` }
})
).json()) as { ownerId: string }
expect(after.ownerId).toBe(staffId)
})
test('水印:配置保存 → 任务受理 → 直驱到已完成 → 公开 browse URL 指向水印图', async ({ page }) => {
await loginAsAdmin(page)
const suffix = await createAlbumViaUi(page)
const albumId = await openAlbumDetail(page, suffix)
const token = ((await page.getByTestId('album-detail-token').textContent()) ?? '').trim()
// 真实对象照片先行(任务 total=3,非空相册 autoDone 分支不触发)
seedFixture('wm-photos', albumId, adminCredentials().account, '3')
await page.getByTestId('album-watermark-tab').click()
await expect(page.getByTestId('watermark-panel')).toBeVisible()
await setSwitchState(page, 'watermark-enabled-switch', true)
await page.getByTestId('watermark-text-content').fill(`E2E水印 ${suffix}`)
// 保存 → 202 受理(v1)→ 任务行进入列表(pending/running)
await page.getByTestId('watermark-save-btn').click()
await expect(page.locator('.el-message').filter({ hasText: 'v1' })).toBeVisible()
const stateTag = page.getByTestId('watermark-jobs-table').locator('[data-state]')
await expect(stateTag.first()).toBeVisible()
await expect(stateTag.first()).toHaveText(/等待中|进行中/)
// e2e 无常驻 worker:fixture 直驱 worker dist 服务核心把任务跑到终态
// (页面进程独立,轮询不受 execSync 阻塞影响)
seedFixture('watermark-run', albumId)
// UI 3s 轮询翻到已完成;配置卡回显开启
await expect(stateTag.first()).toHaveText('已完成')
await expect(page.getByTestId('watermark-current-tag')).toBeVisible()
// 公开侧:album.watermarkEnabled + watermark 行存在 → browse 现签水印键
// (SignedUrlResponse 形状:{url, expiresAt})
const res = await page.request.get(`/v1/public/albums/${token}/photos`)
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as { items: { browse: { url: string } }[] }
expect(body.items.length).toBe(3)
for (const item of body.items) {
expect(item.browse.url).toContain('/watermark.jpg')
}
})
test('用量:阈值调小触发告警 → 上调解除;合计与相册表可见', async ({ page }) => {
await loginAsAdmin(page)
// 独立数据:API 建相册 + 大字节照片(2MB > 0.00001GB 阈值),与 UI 顺序解耦
const sessionToken = await apiLogin(page)
const created = (await (
await page.request.post('/v1/albums', {
headers: { Authorization: `Bearer ${sessionToken}` },
data: {
defaultLocale: 'zh-CN',
startAt: '2026-10-01T10:00:00.000Z',
translations: [{ locale: 'zh-CN', title: `E2E用量相册 ${Date.now().toString(36)}` }]
}
})
).json()) as { id: string }
seedFixture('gov-photos', created.id, adminCredentials().account, '2', '2097152')
await page.goto('/#/system/usage')
await expect(page.getByTestId('usage-totals')).toBeVisible()
await expect(page.getByTestId('usage-albums-table')).toBeVisible()
// 合计已加载(种子照片计入,非空占位)
await expect(page.getByTestId('usage-total-photos')).not.toHaveText('-')
// 阈值调小(perAlbum 0.00001 GB ≈ 10KB < 2MB)→ 即刻评估出未解决告警。
// 开关状态感知:共享开发库可能残留上次运行的已存阈值(开关已开)——
// 幂等设到目标态而非盲目点击。
await page.getByTestId('usage-thresholds-btn').click()
await expect(page.getByTestId('thresholds-dialog')).toBeVisible()
await setSwitchState(page, 'thresholds-per-album-switch', true)
await page.getByTestId('thresholds-per-album-input').locator('input').fill('0.00001')
await page.keyboard.press('Enter')
await page.getByTestId('thresholds-submit').click()
await expect(page.getByTestId('thresholds-dialog')).toBeHidden()
await expect(
page.getByTestId('usage-alerts-table').locator('.alert-row-active').first()
).toBeVisible()
await expect(
page
.getByTestId('usage-alerts-table')
.locator('.alert-row-active')
.first()
.getByTestId('alert-state-tag')
).toHaveText('未解决')
// 上调到 100GB(storage 保持关闭=显式清除,避免历史阈值残留干扰)→ 解除
await page.getByTestId('usage-thresholds-btn').click()
await expect(page.getByTestId('thresholds-dialog')).toBeVisible()
await setSwitchState(page, 'thresholds-per-album-switch', true)
await setSwitchState(page, 'thresholds-storage-switch', false)
await page.getByTestId('thresholds-per-album-input').locator('input').fill('100')
await page.keyboard.press('Enter')
await page.getByTestId('thresholds-submit').click()
await expect(page.getByTestId('thresholds-dialog')).toBeHidden()
await expect(page.getByTestId('usage-alerts-table').locator('.alert-row-active')).toHaveCount(0)
await expect(
page.getByTestId('usage-alerts-table').locator('.alert-row-resolved').first()
).toBeVisible()
})
test('分享二维码:canvas 非空 + viewer URL + 复制链接', async ({ page, context }) => {
await loginAsAdmin(page)
const suffix = await createAlbumViaUi(page)
await openAlbumDetail(page, suffix)
const token = ((await page.getByTestId('album-detail-token').textContent()) ?? '').trim()
await page.getByTestId('album-share-tab').click()
await expect(page.getByTestId('share-panel')).toBeVisible()
// URL = VITE_VIEWER_BASE + /a/{token}
const urlText = ((await page.getByTestId('share-url').textContent()) ?? '').trim()
expect(urlText).toBe(`http://127.0.0.1:5175/a/${token}`)
// canvas 已绘制(空白 canvas 的 dataURL 约几十字节,绘制后远大于 100)
const dataUrlLength = await page
.getByTestId('share-qr-canvas')
.evaluate((canvas: HTMLCanvasElement) => canvas.toDataURL().length)
expect(dataUrlLength).toBeGreaterThan(100)
// 复制 → toast + 剪贴板内容一致(授 chromium 剪贴板权限)
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.getByTestId('share-copy-btn').click()
await expect(page.locator('.el-message').filter({ hasText: '已复制' })).toBeVisible()
const clipped = await page.evaluate(() => navigator.clipboard.readText())
expect(clipped).toBe(urlText)
})
+18 -4
View File
@@ -43,19 +43,33 @@ export async function loginAsAdmin(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/albums\/manage/, { timeout: 20_000 })
}
/** 直种夹具(seed-fixture.cjs):staff 账号 / 相册种子照片(幂等) */
export function seedFixture(mode: 'staff' | 'photos', ...args: string[]): void {
/** 直种夹具(seed-fixture.cjs):staff 账号 / 相册种子照片 / 治理批量照片 /
* 水印旅程真实对象照片 / 水印任务直驱(详见 seed-fixture.cjs 文件头) */
export function seedFixture(
mode: 'staff' | 'photos' | 'gov-photos' | 'wm-photos' | 'watermark-run',
...args: string[]
): void {
const vars = rootEnv()
const databaseUrl = vars.get('DATABASE_URL_CN')
if (!databaseUrl) {
throw new Error('[admin e2e] 根 .env 缺少 DATABASE_URL_CN')
}
// @photo/db-cn 绝对路径:apps/admin 不依赖该包,借 services/api 的 node_modules
// @photo/db-cn / @photo/storage 绝对路径:apps/admin 不依赖这些包,借
// services/api 的 node_modules(node 从 e2e 目录解析不到 workspace 链接)
const dbCn = join(repoRoot, 'services', 'api', 'node_modules', '@photo', 'db-cn')
const storage = join(repoRoot, 'services', 'api', 'node_modules', '@photo', 'storage')
// 全量根 .env 注入(S3_* 供水印对象直写与 worker 直驱;process.env 覆盖
// 优先级更高——playwright 进程自身的环境变量优先于文件值)
execSync(`node seed-fixture.cjs ${mode} ${args.join(' ')}`, {
cwd: fileURLToPath(new URL('.', import.meta.url)),
stdio: 'inherit',
shell: true,
env: { ...process.env, PHOTO_DB_CN: dbCn, DATABASE_URL_CN: databaseUrl }
env: {
...Object.fromEntries(vars),
...process.env,
PHOTO_DB_CN: dbCn,
PHOTO_STORAGE: storage,
DATABASE_URL_CN: databaseUrl
}
})
}
+232 -11
View File
@@ -2,24 +2,50 @@
// 为什么不用 API 造数:目标照片需要 published/processing_failed 等终态,
// 真实链路要 worker 处理流水线;e2e 只验证管理页展示与操作,直插 DB 行
// (uploadedAt 置位即可入管理列表,见 services/api listAlbumPhotos 过滤条件)。
// 依赖经 PHOTO_DB_CN 环境变量注入 @photo/db-cn 绝对路径(apps/admin 不依赖
// 该包,node 从文件位置解析不到;services/api 的 node_modules 里有现成链接)。
// 用法:
// node seed-fixture.cjs staff <account> <displayName>
// node seed-fixture.cjs photos <albumId> <uploaderAccount>
// 幂等:账号存在即跳过;照片按 objectKey upsert(同相册重复种不重复建行)。
const db_cn = process.env.PHOTO_DB_CN
// 依赖经环境变量注入包绝对路径(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 (!db_cn || !databaseUrl) {
if (!dbCn || !databaseUrl) {
console.error('seed-fixture: PHOTO_DB_CN and DATABASE_URL_CN env vars are required')
process.exit(1)
}
// CommonJS(node 直跑,无构建步骤):动态 require 经环境变量注入的包路径
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { PrismaClient } = require(db_cn)
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) {
@@ -88,12 +114,207 @@ async function seedPhotos(albumId, uploaderAccount) {
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] = process.argv.slice(2)
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}"`)
}