import { createHash } from 'crypto' import { mkdir, readFile, writeFile } from 'fs/promises' import path from 'path' import { PrismaClient } from '@prisma/client' const prisma = new PrismaClient() const mode = readArg('mode') || 'audit' const outputPath = readArg('output') || path.join( '.performance-survey-audits', `test-surveys-${new Date().toISOString().replace(/[:.]/g, '-')}.json`, ) const planPath = readArg('plan') const confirmed = process.argv.includes('--confirm-all-current-surveys-are-test-data') const expectedSurveyCount = Number(readArg('expected-survey-count')) function readArg(name: string) { const prefix = `--${name}=` const inline = process.argv.find((item) => item.startsWith(prefix)) if (inline) return inline.slice(prefix.length) const index = process.argv.indexOf(`--${name}`) return index >= 0 ? process.argv[index + 1] || '' : '' } function hashIds(ids: string[]) { return createHash('sha256').update([...ids].sort().join('\n')).digest('hex') } function getDatabaseIdentity() { const raw = process.env.DATABASE_URL if (!raw) throw new Error('缺少 DATABASE_URL') const url = new URL(raw) return { provider: url.protocol.replace(':', ''), host: url.hostname, port: url.port || '5432', database: url.pathname.replace(/^\//, ''), schema: url.searchParams.get('schema') || 'public', } } async function collectSnapshot() { const surveys = await prisma.performanceSurvey.findMany({ orderBy: { createdAt: 'asc' }, select: { id: true, title: true, templateCode: true, status: true, createdAt: true, _count: { select: { targets: true, assignments: true, responses: true, workItems: true, workItemScores: true, notifications: true, }, }, }, }) const surveyIds = surveys.map((survey) => survey.id) const totals = surveys.reduce((result, survey) => { result.targets += survey._count.targets result.assignments += survey._count.assignments result.responses += survey._count.responses result.workItems += survey._count.workItems result.workItemScores += survey._count.workItemScores result.notifications += survey._count.notifications return result }, { targets: 0, assignments: 0, responses: 0, workItems: 0, workItemScores: 0, notifications: 0 }) return { database: getDatabaseIdentity(), surveyIds, surveyIdsHash: hashIds(surveyIds), counts: { surveys: surveys.length, ...totals }, surveys: surveys.map((survey) => ({ id: survey.id, title: survey.title, templateCode: survey.templateCode, status: survey.status, createdAt: survey.createdAt.toISOString(), counts: survey._count, })), } } async function audit() { const snapshot = await collectSnapshot() const manifest = { manifestVersion: 1, purpose: 'performance-survey-test-data-clearance', createdAt: new Date().toISOString(), ...snapshot, } const absoluteOutput = path.resolve(outputPath) await mkdir(path.dirname(absoluteOutput), { recursive: true }) await writeFile(absoluteOutput, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') console.log(JSON.stringify({ output: absoluteOutput, database: manifest.database, counts: manifest.counts }, null, 2)) } async function clear() { if (!planPath) throw new Error('清理模式必须传入 --plan <审计清单>') if (!confirmed) throw new Error('清理模式必须显式传入 --confirm-all-current-surveys-are-test-data') if (!Number.isInteger(expectedSurveyCount) || expectedSurveyCount < 0) { throw new Error('清理模式必须传入 --expected-survey-count <审计确认的调研数量>') } const manifest = JSON.parse(await readFile(path.resolve(planPath), 'utf8')) as { manifestVersion: number purpose: string database: ReturnType surveyIds: string[] surveyIdsHash: string counts: { surveys: number } } if (manifest.manifestVersion !== 1 || manifest.purpose !== 'performance-survey-test-data-clearance') { throw new Error('审计清单类型或版本无效') } if (JSON.stringify(manifest.database) !== JSON.stringify(getDatabaseIdentity())) { throw new Error('审计清单数据库身份与当前连接不一致,已拒绝清理') } if (hashIds(manifest.surveyIds) !== manifest.surveyIdsHash) throw new Error('审计清单校验失败') if (manifest.counts.surveys !== expectedSurveyCount) { throw new Error(`审计清单包含 ${manifest.counts.surveys} 条调研,与明确确认数量 ${expectedSurveyCount} 不一致`) } const current = await collectSnapshot() if (current.surveyIdsHash !== manifest.surveyIdsHash || current.counts.surveys !== manifest.counts.surveys) { throw new Error('审计后调研数据已经变化,请重新生成审计清单') } const result = await prisma.performanceSurvey.deleteMany({ where: { id: { in: manifest.surveyIds } } }) const remaining = await prisma.performanceSurvey.count() if (result.count !== manifest.surveyIds.length || remaining !== 0) { throw new Error(`清理结果不一致:计划 ${manifest.surveyIds.length},已删除 ${result.count},剩余 ${remaining}`) } console.log(JSON.stringify({ deletedSurveys: result.count, remainingSurveys: remaining }, null, 2)) } async function main() { if (mode === 'audit') return audit() if (mode === 'clear') return clear() throw new Error(`未知模式:${mode}`) } main() .catch((error) => { console.error(error instanceof Error ? error.message : error) process.exitCode = 1 }) .finally(async () => { await prisma.$disconnect() })