/** 仅使用独立的本机 PostgreSQL 和临时上传目录,不读取生产配置。 */ import assert from 'node:assert/strict' import { randomUUID } from 'node:crypto' import { promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' import type { Prisma } from '@prisma/client' import type { PurgeFile } from '../lib/material-purge-files' async function main() { const url = new URL(process.env.DATABASE_URL || '') assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.port === '55439' && /^\/material_purge(?:_migration)?_qa$/.test(url.pathname), '拒绝连接非独立验收数据库') process.env.NEXT_BUILD = '1' process.env.NODE_ENV = 'production' const s3Enabled = process.argv.includes('--s3') if (s3Enabled) { Object.assign(process.env, { S3_ENDPOINT: 'http://127.0.0.1:59000', S3_PUBLIC_DOMAIN: 'http://127.0.0.1:59000/purge-qa', S3_BUCKET: 'purge-qa', S3_FILE_PATH: 'project-purge-qa', S3_ACCESS_KEY_ID: 'purgeqa', S3_SECRET_ACCESS_KEY: 'purge_local_storage_qa' }) } const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gongxiangp2-purge-integration-')) process.env.UPLOAD_DIR = root // 每次使用独立数据库,原生 SQL 与 Prisma 使用同一个 public schema。 const { PrismaClient } = await import('@prisma/client') const database = `material_purge_qa_${randomUUID().replace(/-/g, '')}` const setup = new PrismaClient({ datasourceUrl: url.toString() }) await setup.$executeRawUnsafe(`CREATE DATABASE "${database}"`) await setup.$disconnect() url.pathname = `/${database}` url.searchParams.set('schema', 'public') process.env.DATABASE_URL = url.toString() const { spawnSync } = await import('node:child_process') const pushed = spawnSync(process.execPath, ['node_modules/prisma/build/index.js', 'db', 'push', '--skip-generate'], { encoding: 'utf8', env: process.env }) assert.equal(pushed.status, 0, pushed.stderr) // 重建迁移前的日志级联关系,在空的专用数据库中执行真实 reviewed SQL。 const migrationSql = await fs.readFile('prisma/migrations/20261009_material_purge_outbox/migration.sql', 'utf8') const referenceSql = await fs.readFile('prisma/migrations/20261009_material_purge_reference_indexes/migration.sql', 'utf8') const baselineSql = `DROP TABLE material_purge_audits; DROP TABLE material_purge_jobs; ALTER TABLE material_logs ADD CONSTRAINT "material_logs_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES materials(id) ON DELETE CASCADE; ALTER TABLE material_download_logs ADD CONSTRAINT "material_download_logs_materialId_fkey" FOREIGN KEY ("materialId") REFERENCES materials(id) ON DELETE CASCADE;` const migrated = spawnSync('docker', ['exec', '-i', 'gongxiangp2-purge-qa-20261009', 'psql', '-U', 'postgres', '-d', database, '-v', 'ON_ERROR_STOP=1'], { encoding: 'utf8', input: `${baselineSql}\n${migrationSql}\n${referenceSql}`, }) assert.equal(migrated.status, 0, migrated.stderr) const { prisma } = await import('../lib/prisma') const { enqueueMaterialPurge, processNextMaterialPurge, retryMaterialPurge, ensureMaterialPurgeWorkerStarted } = await import('../lib/material-purge') const { collectPurgeFiles, findPurgeFileReferences, purgeFileIdentity, identifyPurgeFile } = await import('../lib/material-purge-files') const { findIndexedPurgeReferenceSources } = await import('../lib/material-purge-reference-query') async function explainReferences(db: Prisma.TransactionClient, files: PurgeFile[]) { let query: { strings: TemplateStringsArray; values: unknown[] } | undefined const wrapped = { $queryRaw: (strings: TemplateStringsArray, ...values: unknown[]) => { query = { strings, values } return db.$queryRaw(strings, ...values) } } as Prisma.TransactionClient await findIndexedPurgeReferenceSources(wrapped, files) assert(query) const sql = query.strings.reduce((value, part, index) => value + (index ? '$' + index : '') + part, '') return db.$queryRawUnsafe('EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ' + sql, ...query.values) } const run = randomUUID() const passed: string[] = [] passed.push('真实升级 SQL 创建清理表、重试索引与审计表,并解除历史日志的素材级联删除') const admin = await prisma.user.create({ data: { email: `${run}@purge.test`, username: run, password: 'local-qa-only', role: 'ADMIN' } }) const make = async (name: string, extra: any = {}) => prisma.material.create({ data: { productModel: `${run}-${name}`, previewUrl: 'data:image/svg+xml;base64,Zm9v', categoryName: '验收', sceneName: '验收', materialType: '标准模型', authorName: '隔离验收', userId: admin.id, deletedAt: new Date(), ...extra, } }) const file = async (name: string) => { await fs.writeFile(path.join(root, name), 'isolated content'); return `uploads/${name}` } const exists = async (name: string) => fs.stat(path.join(root, name)).then(() => true, () => false) try { const active = await make('active', { deletedAt: null }) const bin = await make('atomic', { zipFilePath: await file('atomic.bin') }) await assert.rejects(enqueueMaterialPurge([bin.id, active.id], admin.id), /只能彻底删除/) assert(await prisma.material.findUnique({ where: { id: bin.id } })) assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: bin.id } }), 0) assert(await exists('atomic.bin')) passed.push('混合批量整批拒绝,素材/文件/任务均无部分提交') await assert.rejects(enqueueMaterialPurge([bin.id], 'missing-operator'), /Foreign key/) assert(await prisma.material.findUnique({ where: { id: bin.id } })) assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: bin.id } }), 0) passed.push('审计写入失败时整个删除事务回滚') const uploading = await make('uploading') await prisma.productPackageUploadJob.create({ data: { materialId: uploading.id, operatorUserId: admin.id, assets: [], status: 'queued', stage: 'queued' } }) await assert.rejects(enqueueMaterialPurge([uploading.id], admin.id), /仍在处理中/) const transcoding = await make('transcoding', { transcodeLeaseToken: 'active-lease' }) await assert.rejects(enqueueMaterialPurge([transcoding.id], admin.id), /仍在处理中/) passed.push('上传/转码未结束的素材拒绝进入清理队列') const own = await make('single', { zipFilePath: await file('single.bin') }) await prisma.materialAsset.create({ data: { materialId: own.id, assetType: 'arbitrary-type', fileUrl: await file('asset.bin') } }) await prisma.materialSku.create({ data: { materialId: own.id, skuName: 'test', filePath: await file('sku.bin') } }) await prisma.materialLog.create({ data: { materialId: own.id, userId: admin.id, action: 'UPLOAD' } }) await prisma.materialDownloadLog.create({ data: { materialId: own.id, userId: admin.id } }) const requests = await Promise.all([enqueueMaterialPurge([own.id, own.id], admin.id), enqueueMaterialPurge([own.id], admin.id)]) assert.equal(requests[0][0].id, requests[1][0].id) assert.equal(await prisma.materialPurgeJob.count({ where: { materialId: own.id } }), 1) assert.equal(await prisma.materialLog.count({ where: { materialId: own.id } }), 2) assert.equal(await prisma.materialDownloadLog.count({ where: { materialId: own.id } }), 1) assert.equal(await prisma.materialAsset.count({ where: { materialId: own.id } }), 0) assert.equal(await prisma.materialSku.count({ where: { materialId: own.id } }), 0) assert(await exists('single.bin')) await processNextMaterialPurge() assert(!await exists('single.bin') && !await exists('asset.bin') && !await exists('sku.bin')) assert.equal((await prisma.materialPurgeJob.findUniqueOrThrow({ where: { materialId: own.id } })).status, 'completed') passed.push('并发/重复提交幂等;关联记录清理,旧日志和下载日志保留;所有类型资产纳入清单') const sharedSource = await file('shared.bin') const keeper = await make('shared-keeper', { previewUrl: '/uploads/shared.bin' }) const sharer = await make('shared-delete', { zipFilePath: sharedSource }) await enqueueMaterialPurge([sharer.id], admin.id) await processNextMaterialPurge() assert(await exists('shared.bin')) const sharedJob = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { materialId: sharer.id } }) assert.equal((sharedJob.files as any)[0].outcome, 'shared') assert(await prisma.material.findUnique({ where: { id: keeper.id } })) passed.push('URL 等价引用与回收站引用保护共用文件') const specialSource = await file('shared[1]+.bin') const specialKeeper = await make('special-keeper') await prisma.materialAsset.create({ data: { materialId: specialKeeper.id, assetType: 'test', fileUrl: '/uploads/shared[1]+.bin' } }) const special = await make('special-delete', { zipFilePath: specialSource }) const specialFiles = await collectPurgeFiles({ ...special, assets: [], skus: [] }) const fullReferences = await findPurgeFileReferences(prisma) const targetedReferences = await findPurgeFileReferences(prisma, specialFiles) for (const target of specialFiles) assert.equal(targetedReferences.has(purgeFileIdentity(target)), fullReferences.has(purgeFileIdentity(target))) await enqueueMaterialPurge([special.id], admin.id) await processNextMaterialPurge() assert(await exists('shared[1]+.bin')) const otherDirectory = await make('same-filename', { zipFilePath: await file('same-filename.bin') }) await prisma.materialAsset.create({ data: { materialId: specialKeeper.id, assetType: 'test', fileUrl: 'uploads/other/same-filename.bin' } }) await enqueueMaterialPurge([otherDirectory.id], admin.id) await processNextMaterialPurge() assert(!await exists('same-filename.bin')) await findPurgeFileReferences(prisma, Array.from({ length: 2000 }, () => ({ source: '', storage: 'local' as const, key: `${randomUUID()}.bin`, outcome: 'pending' as const, }))) passed.push('真实 PostgreSQL 候选筛选与全量引用结果一致:特殊文件名共用资产保留,同名不同路径文件正常删除') const batch = await Promise.all([make('batch-a', { zipFilePath: await file('batch-a.bin') }), make('batch-b', { zipFilePath: await file('batch-b.bin') })]) const batchJobs = await enqueueMaterialPurge(batch.map(material => material.id), admin.id) assert.equal(batchJobs.length, 2) // 模拟作业进程中断:持久化处理中状态和已过期租约,下一轮自动接管。 await prisma.materialPurgeJob.update({ where: { id: batchJobs[0].id }, data: { status: 'processing', leaseToken: 'interrupted-process', leaseExpiresAt: new Date(0) } }) await Promise.all([processNextMaterialPurge(), processNextMaterialPurge()]) assert(!await exists('batch-a.bin') && !await exists('batch-b.bin')) assert.equal(await prisma.materialPurgeJob.count({ where: { id: { in: batchJobs.map(job => job.id) }, status: 'completed' } }), 2) passed.push('批量作业并发领取不重复,重启后过期租约可接管并完成') const wide = await make('wide-batch') const wideFiles = await Promise.all(Array.from({ length: 19 }, (_, index) => file(`wide-${index}.bin`))) await prisma.materialAsset.createMany({ data: wideFiles.map(fileUrl => ({ materialId: wide.id, assetType: 'test', fileUrl })) }) const [wideJob] = await enqueueMaterialPurge([wide.id], admin.id) const wideStarted = Date.now() await processNextMaterialPurge() const wideResult = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: wideJob.id } }) assert.equal(wideResult.status, 'completed') assert.equal((wideResult.files as any[]).filter(f => f.outcome === 'deleted').length, 19) assert((await Promise.all(wideFiles.map((_, index) => exists(`wide-${index}.bin`)))).every(value => !value)) passed.push(`真实文件系统跨三个清理批次完成 19 个文件,结果全部持久化,耗时 ${Date.now() - wideStarted}ms`) const broken = await make('retry', { zipFilePath: await file('retry-first.bin'), displayImages: [await file('retry-second.bin')] }) const [retryJob] = await enqueueMaterialPurge([broken.id], admin.id) await fs.rename(path.join(root, 'retry-second.bin'), path.join(root, 'saved-second.bin')) await fs.mkdir(path.join(root, 'retry-second.bin')) for (let attempt = 0; attempt < 3; attempt += 1) { await prisma.materialPurgeJob.update({ where: { id: retryJob.id }, data: { nextAttemptAt: new Date(0) } }) await processNextMaterialPurge() } let failed = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: retryJob.id } }) assert.equal(failed.status, 'failed') assert(!await exists('retry-first.bin')) assert.equal((failed.files as any)[0].outcome, 'deleted') await fs.rmdir(path.join(root, 'retry-second.bin')) await fs.rename(path.join(root, 'saved-second.bin'), path.join(root, 'retry-second.bin')) await retryMaterialPurge(retryJob.id, admin.id) await processNextMaterialPurge() failed = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: retryJob.id } }) assert.equal(failed.status, 'completed') assert(!await exists('retry-second.bin')) const audits = await prisma.materialPurgeAudit.findMany({ where: { jobId: retryJob.id }, orderBy: { createdAt: 'asc' } }) assert(audits.some(audit => audit.event === 'failed')) assert(audits.some(audit => audit.event === 'manual_retry' && audit.operatorUserId === admin.id)) passed.push('部分文件失败保存进度,自动重试耗尽后人工续跑;失败/重试操作人审计完整') const indexedSource = await file('indexed-变更[1]%.bin') const indexedFiles = [{ ...identifyPurgeFile(indexedSource)!, outcome: 'pending' as const }] const indexedKeeper = await make('index-maintenance') const indexedAsset = await prisma.materialAsset.create({ data: { materialId: indexedKeeper.id, assetType: 'test', fileUrl: indexedSource } }) assert((await findPurgeFileReferences(prisma, indexedFiles)).has(purgeFileIdentity(indexedFiles[0]))) await prisma.$executeRaw`UPDATE material_assets SET file_url = 'uploads/unrelated.bin' WHERE id = ${indexedAsset.id}` assert(!(await findPurgeFileReferences(prisma, indexedFiles)).has(purgeFileIdentity(indexedFiles[0]))) const jsonJob = await prisma.productPackageUploadJob.create({ data: { materialId: indexedKeeper.id, operatorUserId: admin.id, assets: [{ nested: { fileUrl: indexedSource } }], status: 'queued', stage: 'queued' } }) assert((await findPurgeFileReferences(prisma, indexedFiles)).has(purgeFileIdentity(indexedFiles[0]))) await prisma.productPackageUploadJob.update({ where: { id: jsonJob.id }, data: { status: 'completed' } }) assert(!(await findPurgeFileReferences(prisma, indexedFiles)).has(purgeFileIdentity(indexedFiles[0]))) const aliases = [indexedSource, `/uploads/./indexed-变更[1]%.bin`, path.join(root, 'indexed-变更[1]%.bin'), '\u00a0/UPLOADS/INDEXED-变更[1]%.BIN\ufeff', 'uploads\\indexed-变更[1]%.bin'] for (const previewUrl of aliases) { await prisma.material.update({ where: { id: indexedKeeper.id }, data: { previewUrl } }) const full = await findPurgeFileReferences(prisma) const targeted = await findPurgeFileReferences(prisma, indexedFiles) assert.equal(targeted.has(purgeFileIdentity(indexedFiles[0])), full.has(purgeFileIdentity(indexedFiles[0])), previewUrl) } await prisma.material.update({ where: { id: indexedKeeper.id }, data: { previewUrl: 'data:image/png;base64,Zm9v' } }) passed.push('数据库索引随 ORM/直接 SQL 写入和队列状态变化同步;Unicode、百分号、大小写、绝对路径、点路径引用与全量审计一致') if (s3Enabled) { const { S3Client, CreateBucketCommand, PutObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3') const client = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: 'us-east-1', forcePathStyle: true, credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID!, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY! }, maxAttempts: 1 }) const Bucket = process.env.S3_BUCKET! try { await client.send(new CreateBucketCommand({ Bucket })) } catch (error: any) { if (error.name !== 'BucketAlreadyOwnedByYou') throw error } const ownKey = `project-purge-qa/${run}/owned.bin` const sharedKey = `project-purge-qa/${run}/shared.bin` const foreignKey = `other-project/${run}/sentinel.bin` for (const Key of [ownKey, sharedKey, foreignKey]) await client.send(new PutObjectCommand({ Bucket, Key, Body: 'isolated-object' })) const source = (key: string) => `http://127.0.0.1:59000/${Bucket}/${key}` const objectMaterial = await make('object-own', { zipFilePath: source(ownKey) }) await enqueueMaterialPurge([objectMaterial.id], admin.id) await processNextMaterialPurge() await assert.rejects(client.send(new HeadObjectCommand({ Bucket, Key: ownKey })), error => (error as any).$metadata.httpStatusCode === 404) const objectKeeper = await make('object-keeper', { previewUrl: `${source(sharedKey)}?cache=keeper` }) const objectShared = await make('object-shared', { zipFilePath: source(sharedKey) }) await enqueueMaterialPurge([objectShared.id], admin.id) await processNextMaterialPurge() await client.send(new HeadObjectCommand({ Bucket, Key: sharedKey })) assert(await prisma.material.findUnique({ where: { id: objectKeeper.id } })) const encodedKey = `project-purge-qa/${run}/encoded[1]+.bin` await client.send(new PutObjectCommand({ Bucket, Key: encodedKey, Body: 'isolated-object' })) await prisma.materialAsset.create({ data: { materialId: objectKeeper.id, assetType: 'test', fileUrl: `https://legacy-storage.test/${Bucket}/${encodedKey.replace('encoded', '%65ncoded')}?cache=1` } }) const encoded = await make('encoded-shared', { zipFilePath: source(encodedKey) }) await enqueueMaterialPurge([encoded.id], admin.id) await processNextMaterialPurge() await client.send(new HeadObjectCommand({ Bucket, Key: encodedKey })) const localWhiteBackground = `uploads/skus/${objectKeeper.id}/white-background/${run}/preview/shared-display.png` const whiteAsset = await prisma.materialAsset.create({ data: { materialId: objectKeeper.id, assetType: 'test', fileUrl: localWhiteBackground } }) const { buildPublicDisplayUrl, buildWhiteBackgroundPublicDisplayPathFromAssetPath } = await import('../lib/public-display') const displaySource = buildPublicDisplayUrl(buildWhiteBackgroundPublicDisplayPathFromAssetPath(localWhiteBackground)!)! const { getTrustedS3MutationKey } = await import('../lib/s3') const displayKey = getTrustedS3MutationKey(displaySource) await client.send(new PutObjectCommand({ Bucket, Key: displayKey, Body: 'isolated-display' })) const display = await make('derived-shared', { zipFilePath: displaySource }) await enqueueMaterialPurge([display.id], admin.id) await processNextMaterialPurge() await client.send(new HeadObjectCommand({ Bucket, Key: displayKey })) const displayTarget = [{ ...identifyPurgeFile(displaySource)!, outcome: 'pending' as const }] for (const fileUrl of [localWhiteBackground + '#preview', localWhiteBackground + '?cache=1', localWhiteBackground.replace('/preview/shared-display', '/preview/./shared-display'), localWhiteBackground.replace('/preview/shared-display', '/preview/sub/../shared-display'), localWhiteBackground.replace('/preview/shared-display', '/preview/%2e/shared-display')]) { await prisma.materialAsset.update({ where: { id: whiteAsset.id }, data: { fileUrl } }) const full = await findPurgeFileReferences(prisma) const next = await findPurgeFileReferences(prisma, displayTarget) assert(full.has(purgeFileIdentity(displayTarget[0])), '白底派生引用确实存在') assert(next.has(purgeFileIdentity(displayTarget[0])), `不得漏掉派生 URL 引用 ${fileUrl}`) } await prisma.materialAsset.update({ where: { id: whiteAsset.id }, data: { fileUrl: localWhiteBackground } }) const referenceTarget = [{ ...identifyPurgeFile(source(sharedKey))!, outcome: 'pending' as const }] for (const previewUrl of [ source(sharedKey).replace('shared', '%73hared'), source(sharedKey).replace('shared', '%2573hared'), `https://legacy.test/${sharedKey.replace('shared', '%73hared')}?cache=1#fragment`, `https://legacy.test/${sharedKey.replace('shared', 'sha\tred')}`, source(sharedKey).replace('/shared', '/unused/../shared'), source(sharedKey).replace('/shared', '/%2e/shared'), source(sharedKey).replace(`/${Bucket}/`, `//${Bucket}/`), ]) { await prisma.material.update({ where: { id: objectKeeper.id }, data: { previewUrl } }) const full = await findPurgeFileReferences(prisma) const targeted = await findPurgeFileReferences(prisma, referenceTarget) assert.equal(targeted.has(purgeFileIdentity(referenceTarget[0])), full.has(purgeFileIdentity(referenceTarget[0])), previewUrl) } const objectForeign = await make('object-foreign', { zipFilePath: source(foreignKey) }) await assert.rejects(enqueueMaterialPurge([objectForeign.id], admin.id), /业务前缀之外/) await client.send(new HeadObjectCommand({ Bucket, Key: foreignKey })) assert(await prisma.material.findUnique({ where: { id: objectForeign.id } })) passed.push('真实 MinIO 验证:本项目对象删除成功,共用对象保留,其他项目前缀拒绝删除') client.destroy() } if (process.argv.includes('--scale')) { const scaleKeeper = await make('400k-reference-keeper') for (let offset = 0; offset < 400_000; offset += 80_000) { await prisma.$executeRaw`INSERT INTO material_assets (id, material_id, asset_type, file_url, sort_order, updated_at) SELECT ${run + '-scale-'} || i::text, ${scaleKeeper.id}, 'qa-scale', 'https://isolated.test/project/scale/' || i::text || '/image.bin', 0, now() FROM generate_series(${offset}, ${offset + 79_999}) AS i` console.log(JSON.stringify({ mode: 'isolated-scale-setup', assets: offset + 80_000 })) } await prisma.$executeRaw`ANALYZE material_assets` const scaleSources = await Promise.all(Array.from({ length: 4 }, (_, i) => file(`scale-delete-${i}.bin`))) const scaleMaterial = await make('scale-delete', { zipFilePath: scaleSources[0], displayImages: scaleSources.slice(1) }) const scaleFiles = await collectPurgeFiles({ ...scaleMaterial, assets: [], skus: [] }) const plan = await explainReferences(prisma, scaleFiles) assert(JSON.stringify(plan).includes('material_assets_purge_references_idx'), '40万行必须实际使用引用索引') const referenceStarted = Date.now() await findPurgeFileReferences(prisma, scaleFiles) const referenceMs = Date.now() - referenceStarted const submitted = Date.now() const [scaleJob] = await enqueueMaterialPurge([scaleMaterial.id], admin.id) const acceptedMs = Date.now() - submitted await processNextMaterialPurge() const completedMs = Date.now() - submitted const result = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: scaleJob.id } }) assert.equal(result.status, 'completed') assert((await Promise.all(scaleSources.map((_, i) => exists(`scale-delete-${i}.bin`)))).every(value => !value)) assert(referenceMs < 500 && completedMs < 1500, `索引检查 ${referenceMs}ms / 实际删除 ${completedMs}ms 超出验收线`) const bulkFiles: PurgeFile[] = Array.from({ length: 106 }, (_, index) => ({ source: `https://isolated.test/project/bulk/${index}.bin`, storage: 's3', bucket: process.env.S3_BUCKET || 'qa-bucket', key: `project/bulk/${index}.bin`, outcome: 'pending', })) bulkFiles.push(...specialFiles) const bulkPlans = [] for (const mode of ['force_custom_plan', 'force_generic_plan']) { const measurement = await prisma.$transaction(async tx => { await tx.$executeRawUnsafe('SET LOCAL plan_cache_mode = ' + mode) const started = Date.now() const refs = await findPurgeFileReferences(tx, bulkFiles) const ms = Date.now() - started assert(refs.has(purgeFileIdentity(specialFiles[0])), '大批量查询仍须保留共享文件') const executionPlan = await explainReferences(tx, bulkFiles) assert(JSON.stringify(executionPlan).includes('material_assets_purge_references_idx'), `${mode} 大批量必须使用索引`) assert(ms < 1500, `${mode} 大批量引用查询 ${ms}ms 超出验收线`) return { mode, files: bulkFiles.length, ms, plan: executionPlan } }, { timeout: 30_000 }) bulkPlans.push(measurement) } await fs.mkdir('tmp/material-purge-index-qa', { recursive: true }) await fs.writeFile('tmp/material-purge-index-qa/scale.json', JSON.stringify({ assets: await prisma.materialAsset.count(), referenceMs, acceptedMs, completedMs, plan, bulkPlans }, null, 2)) passed.push(`40万资产下只查目标引用 ${referenceMs}ms;提交 ${acceptedMs}ms、4个文件真实删除及审计完成 ${completedMs}ms,执行计划使用 GIN 索引`) passed.push(`107个文件在 custom/generic 两种执行计划下均使用索引并保护共享引用:${bulkPlans.map(p => p.ms + 'ms').join(' / ')}`) } delete process.env.NEXT_BUILD ensureMaterialPurgeWorkerStarted() await new Promise(resolve => setTimeout(resolve, 100)) const wake = await make('wake-up', { zipFilePath: await file('wake-up.bin') }) const [wakeJob] = await enqueueMaterialPurge([wake.id], admin.id) const wakeStarted = Date.now() ensureMaterialPurgeWorkerStarted() let wakeResult do { await new Promise(resolve => setTimeout(resolve, 25)) wakeResult = await prisma.materialPurgeJob.findUniqueOrThrow({ where: { id: wakeJob.id } }) } while (wakeResult.status !== 'completed' && Date.now() - wakeStarted < 1500) assert.equal(wakeResult.status, 'completed', '新请求必须直接唤醒已启动的 worker,不能等 5 秒轮询') process.env.NEXT_BUILD = '1' passed.push(`已启动的 worker 收到提交立即清理,${Date.now() - wakeStarted}ms 完成,未等待5秒轮询`) const output = { database: url.pathname.slice(1), passed, checks: passed.length, at: new Date().toISOString() } await fs.mkdir('tmp/material-purge-qa', { recursive: true }) await fs.writeFile('tmp/material-purge-qa/integration.json', JSON.stringify(output, null, 2)) console.log(JSON.stringify(output, null, 2)) } finally { await prisma.$disconnect() // root 是本次 mkdtemp 创建的目录,仅移除这次隔离文件。 assert(path.dirname(root) === os.tmpdir() && path.basename(root).startsWith('gongxiangp2-purge-integration-')) await fs.rm(root, { recursive: true, force: true }) } } main().catch(error => { console.error(error); process.exitCode = 1 })