import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core' import { createHash } from 'node:crypto' import { promises as fs } from 'node:fs' import path from 'node:path' import COS from 'cos-nodejs-sdk-v5' import { prisma } from '@/lib/prisma' type SnapshotTask = { name: string tableName: string } type StorageIndexRow = { key: string hash: string | null sizeBytes: number lastModified: string | null } type CosBucketPage = { Contents?: Array<{ Key: string ETag?: string Size?: string | number LastModified?: string }> IsTruncated?: string | boolean NextMarker?: string } const BACKUP_ROOT = path.join(process.cwd(), 'data', 'migration-backups') function nowStamp() { return new Date().toISOString().replace(/[:.]/g, '-') } function toJson(value: unknown) { return JSON.stringify( value, (_key, val) => (typeof val === 'bigint' ? String(val) : val), 2, ) } async function writeJson(filePath: string, data: unknown) { await fs.writeFile(filePath, toJson(data), 'utf8') } function sha256Text(input: string) { return createHash('sha256').update(input).digest('hex') } function resolveDatabaseFilePath(databaseUrl: string | undefined): string | null { if (!databaseUrl) return null if (databaseUrl.startsWith('file:')) { const raw = databaseUrl.slice('file:'.length) if (!raw) return null return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw) } return null } async function listLocalFilesRecursively(rootDir: string, prefix = ''): Promise { const fullDir = path.join(rootDir, prefix) const entries = await fs.readdir(fullDir, { withFileTypes: true }) const out: StorageIndexRow[] = [] for (const entry of entries) { const rel = path.join(prefix, entry.name) if (entry.isDirectory()) { out.push(...(await listLocalFilesRecursively(rootDir, rel))) continue } if (!entry.isFile()) continue const filePath = path.join(rootDir, rel) const stat = await fs.stat(filePath) const buf = await fs.readFile(filePath) out.push({ key: rel.split(path.sep).join('/'), hash: createHash('sha256').update(buf).digest('hex'), sizeBytes: stat.size, lastModified: stat.mtime.toISOString(), }) } return out } async function listCosObjects(): Promise { const secretId = process.env.COS_SECRET_ID const secretKey = process.env.COS_SECRET_KEY const bucket = process.env.COS_BUCKET const region = process.env.COS_REGION if (!secretId || !secretKey || !bucket || !region) { throw new Error('Missing COS env: COS_SECRET_ID/COS_SECRET_KEY/COS_BUCKET/COS_REGION') } const cos = new COS({ SecretId: secretId, SecretKey: secretKey, Timeout: 60_000 }) const out: StorageIndexRow[] = [] let marker = '' while (true) { const page = await new Promise((resolve, reject) => { cos.getBucket( { Bucket: bucket, Region: region, Marker: marker, MaxKeys: 1000, }, (err, data) => (err ? reject(err) : resolve((data || {}) as CosBucketPage)), ) }) const contents = page.Contents || [] for (const item of contents) { out.push({ key: item.Key, hash: item.ETag ? String(item.ETag).replaceAll('"', '') : null, sizeBytes: Number(item.Size || 0), lastModified: item.LastModified || null, }) } const truncated = String(page.IsTruncated || 'false') === 'true' if (!truncated) break marker = page.NextMarker || (contents.length ? contents[contents.length - 1].Key : '') if (!marker) break } return out } async function buildStorageIndex(): Promise<{ storageType: string; rows: StorageIndexRow[] }> { const storageType = process.env.STORAGE_TYPE || 'cos' if (storageType === 'local') { const uploadDir = process.env.UPLOAD_DIR || './data/uploads' const rootDir = path.isAbsolute(uploadDir) ? uploadDir : path.join(process.cwd(), uploadDir) const exists = await fs.stat(rootDir).then(() => true).catch(() => false) if (!exists) { return { storageType, rows: [] } } const rows = await listLocalFilesRecursively(rootDir) return { storageType, rows } } const rows = await listCosObjects() return { storageType, rows } } async function snapshotTables(backupDir: string) { const tasks: SnapshotTask[] = [ { name: 'projects', tableName: 'projects' }, { name: 'novel_promotion_projects', tableName: 'novel_promotion_projects' }, { name: 'novel_promotion_episodes', tableName: 'novel_promotion_episodes' }, { name: 'novel_promotion_panels', tableName: 'novel_promotion_panels' }, { name: 'novel_promotion_voice_lines', tableName: 'novel_promotion_voice_lines' }, { name: 'global_characters', tableName: 'global_characters' }, { name: 'global_character_appearances', tableName: 'global_character_appearances' }, { name: 'global_locations', tableName: 'global_locations' }, { name: 'global_location_images', tableName: 'global_location_images' }, { name: 'global_voices', tableName: 'global_voices' }, { name: 'tasks', tableName: 'tasks' }, { name: 'task_events', tableName: 'task_events' }, ] const counts: Record = {} for (const task of tasks) { const rows = (await prisma.$queryRawUnsafe(`SELECT * FROM \`${task.tableName}\``)) as unknown[] counts[task.name] = rows.length await writeJson(path.join(backupDir, `${task.name}.json`), rows) } return counts } async function writeChecksums(backupDir: string) { const files = (await fs.readdir(backupDir)).sort() const sums: Record = {} for (const file of files) { const filePath = path.join(backupDir, file) const stat = await fs.stat(filePath) if (!stat.isFile()) continue const buf = await fs.readFile(filePath) sums[file] = createHash('sha256').update(buf).digest('hex') } await writeJson(path.join(backupDir, 'checksums.json'), sums) } async function backupDbFile(backupDir: string) { const dbFile = resolveDatabaseFilePath(process.env.DATABASE_URL) if (!dbFile) return null const stat = await fs.stat(dbFile).catch(() => null) if (!stat || !stat.isFile()) return null const fileName = path.basename(dbFile) const target = path.join(backupDir, `db-file-${fileName}`) await fs.copyFile(dbFile, target) return path.basename(target) } async function main() { const stamp = nowStamp() const backupDir = path.join(BACKUP_ROOT, stamp) await fs.mkdir(backupDir, { recursive: true }) const meta: Record = { createdAt: new Date().toISOString(), backupDir, databaseUrl: process.env.DATABASE_URL || null, storageType: process.env.STORAGE_TYPE || 'cos', nodeEnv: process.env.NODE_ENV || null, } const copiedDbFile = await backupDbFile(backupDir) meta.copiedDbFile = copiedDbFile const tableCounts = await snapshotTables(backupDir) meta.tableCounts = tableCounts const storage = await buildStorageIndex() meta.storageType = storage.storageType meta.storageObjectCount = storage.rows.length await writeJson(path.join(backupDir, 'storage-object-index.json'), storage.rows) await writeChecksums(backupDir) meta.metadataChecksum = sha256Text(toJson(meta)) await writeJson(path.join(backupDir, 'metadata.json'), meta) _ulogInfo(`[media-safety-backup] done: ${backupDir}`) _ulogInfo(`[media-safety-backup] tableCounts=${JSON.stringify(tableCounts)}`) _ulogInfo(`[media-safety-backup] storageObjects=${storage.rows.length}`) } main() .catch((error) => { _ulogError('[media-safety-backup] failed:', error) process.exitCode = 1 }) .finally(async () => { await prisma.$disconnect() })