import 'reflect-metadata'; import { createHash, randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { parseArgs, parseEnv } from 'node:util'; import { loadConfig, validateApplyConfig, } from '../../../packages/asset-sync/src/config.mjs'; import { readSource } from '../../../packages/asset-sync/src/files.mjs'; import { createQiniuClient } from '../../../packages/asset-sync/src/qiniu-client.mjs'; import { buildAssetMappings } from './asset-mapping.mjs'; const require = createRequire(import.meta.url); const { PrismaService } = require('../dist/database/prisma.service.js'); const { getServerConfig } = require('../dist/config/server-config.js'); let database; try { const { values } = parseArgs({ options: { report: { type: 'string' }, apply: { type: 'boolean', default: false }, }, }); if (!values.report) throw new Error('REPORT_REQUIRED'); const local = parseEnv( await readFile(new URL('../.env', import.meta.url), 'utf8'), ); const server = getServerConfig({ ...local, ...process.env }); const url = new URL(server.databaseUrl); // 本次数据迁移仅授权本机开发库;部署环境需独立迁移流程。 if ( !['localhost', '127.0.0.1'].includes(url.hostname) || url.pathname !== '/doodle_dev' ) throw new Error('LOCAL_DATABASE_REQUIRED'); const config = await loadConfig(); if ( config.publicBucket !== server.qiniuBucket || config.prefix !== server.qiniuKeyPrefix || config.publicDomain !== server.qiniuPublicDomain ) throw new Error('SERVER_SYNC_CONFIG_MISMATCH'); const raw = await readFile(values.report, 'utf8'); database = new PrismaService(server); const assets = await database.client.asset.findMany(); const mappings = buildAssetMappings(JSON.parse(raw), assets, config); validateApplyConfig( config, mappings.map((item) => item.file), ); const client = createQiniuClient(config); if ((await client.bucketInfo(config.publicBucket)).private !== 0) throw new Error('BUCKET_VISIBILITY_MISMATCH'); // 数据库事务外完成全部本地和远端核对,不将网络等待放进事务。 for (const { file } of mappings) { const source = await readSource(config, file.relativePath); const remote = await client.stat(file.bucket, file.key); if ( source.sha256 !== file.sha256 || source.etag !== file.etag || source.size !== file.size || remote?.hash !== file.etag || remote.fsize !== file.size || remote.mimeType !== file.mimeType ) throw new Error('ASSET_VERIFICATION_FAILED'); } const requestId = `asset-mapping-${randomUUID()}`; if (values.apply) { await database.client.$transaction( async (tx) => { for (const { asset, data, unchanged } of mappings) { if (unchanged) continue; // 乐观并发检查与整批事务保证不会覆盖回填期间的其他编辑。 const result = await tx.asset.updateMany({ where: { id: asset.id, updatedAt: asset.updatedAt, status: asset.status, }, data, }); if (result.count !== 1) throw new Error('CONCURRENT_ASSET_CHANGE'); await tx.adminAuditLog.create({ data: { id: randomUUID(), actorType: 'migration', action: 'asset.map-qiniu', resourceType: 'assets', resourceId: asset.id, requestId, beforeData: { status: asset.status, bucket: asset.bucket, objectKey: asset.objectKey, }, afterData: { status: data.status, bucket: data.bucket, objectKey: data.objectKey, checksum: data.checksum, }, reason: `sync-report sha256=${createHash('sha256').update(raw).digest('hex')}`, }, }); } }, { timeout: 30000 }, ); } console.log( JSON.stringify({ dryRun: !values.apply, requestId, matched: mappings.length, updated: mappings.filter((item) => !item.unchanged).length, unchanged: mappings.filter((item) => item.unchanged).length, }), ); } catch (error) { // 不输出数据库连接、SDK 原始异常或密钥;错误保留有限自定义代码。 console.error( /^[A-Z_]+$/.test(error.message ?? '') ? error.message : 'ASSET_MAPPING_FAILED', ); process.exitCode = 1; } finally { await database?.onModuleDestroy(); }