import path from 'path' import { promises as fs } from 'fs' import { HeadBucketCommand, S3Client } from '@aws-sdk/client-s3' import { importHistoryMaterials, resolveImportOperator, type HistoryImportRecord, } from '@/lib/history-material-import' import { prisma } from '@/lib/prisma' import { assertRemoteUploadSyncAccess, getRemoteUploadSyncConfig, } from '@/lib/remote-upload-sync' function getDatabaseHost() { const rawUrl = String(process.env.DATABASE_URL || '').trim() if (!rawUrl) { return '' } try { return new URL(rawUrl).hostname } catch { return '' } } function assertApplyEnvironment() { const databaseHost = getDatabaseHost() const s3Bucket = String(process.env.S3_BUCKET || '').trim() const s3Endpoint = String(process.env.S3_ENDPOINT || '').trim().replace(/\/+$/, '') const s3PublicDomain = String(process.env.S3_PUBLIC_DOMAIN || '').trim() const s3FilePath = String(process.env.S3_FILE_PATH || '').trim().replace(/\\/g, '/') const uploadDir = String(process.env.UPLOAD_DIR || '').trim() const remoteUploadSyncConfig = getRemoteUploadSyncConfig() const isProductionDatabase = databaseHost === '192.168.58.20' const isExpectedEndpoint = s3Endpoint === 'http://192.168.58.20:9000' const isExpectedPreviewBucket = s3Bucket === 'gongxiangpan' && /^https:\/\/s3\.jiansun\.vip\/gongxiangpan\/?$/i.test(s3PublicDomain) const isExpectedFilePath = s3FilePath === '由壹点AI/' if (!isExpectedEndpoint || !isExpectedPreviewBucket || !isExpectedFilePath) { throw new Error( [ '正式导入环境不正确:预览文件必须写入新桶 gongxiangpan 的正确前缀。', `当前 S3_ENDPOINT=${s3Endpoint || '(empty)'}`, `当前 S3_BUCKET=${s3Bucket || '(empty)'}`, `当前 S3_PUBLIC_DOMAIN=${s3PublicDomain || '(empty)'}`, `当前 S3_FILE_PATH=${s3FilePath || '(empty)'}`, ].join('\n'), ) } if (isProductionDatabase && process.platform === 'win32' && !remoteUploadSyncConfig) { throw new Error( [ '正式导入环境不正确:当前是 Windows 工作站,但 DATABASE_URL 指向生产库。', '直接导入会把下载 zip / PSD 源文件写到本机 uploads,生产站点无法下载。', '如确需在 Windows 读取 Z 盘源文件并写生产库,必须配置远程 uploads 同步:', 'HISTORY_IMPORT_REMOTE_UPLOADS_HOST=prod', 'HISTORY_IMPORT_REMOTE_UPLOADS_DIR=/mnt/PoolOne/youyidian-uploads', `当前 UPLOAD_DIR=${uploadDir || '(empty)'}`, ].join('\n'), ) } } async function assertApplyStorageAccess() { const s3Endpoint = String(process.env.S3_ENDPOINT || '').trim() const s3Bucket = String(process.env.S3_BUCKET || '').trim() const accessKeyId = String(process.env.S3_ACCESS_KEY_ID || '').trim() const secretAccessKey = String(process.env.S3_SECRET_ACCESS_KEY || '').trim() if (!accessKeyId || !secretAccessKey) { throw new Error('正式导入环境不正确:缺少 S3 访问凭据,不能验证新桶写入权限。') } const client = new S3Client({ endpoint: s3Endpoint, region: process.env.S3_REGION || 'us-east-1', credentials: { accessKeyId, secretAccessKey, }, forcePathStyle: String(process.env.S3_ENABLE_PATH_STYLE || '').trim() !== '0', }) try { await client.send(new HeadBucketCommand({ Bucket: s3Bucket })) } catch (error) { const statusCode = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode throw new Error( [ '正式导入环境不正确:当前 S3 凭据无法访问新桶,已阻止写入数据库。', `S3_BUCKET=${s3Bucket || '(empty)'}`, `S3_ENDPOINT=${s3Endpoint || '(empty)'}`, `HTTP_STATUS=${statusCode || 'unknown'}`, ].join('\n'), ) } } function parseArgs() { const args = process.argv.slice(2) const getArg = (name: string) => { const match = args.find((arg) => arg.startsWith(`--${name}=`)) return match ? match.slice(name.length + 3) : '' } const input = getArg('input') const operator = getArg('operator') const limitRaw = getArg('limit') const apply = args.includes('--apply') if (!input) { throw new Error('缺少 --input=扫描结果文件') } if (!operator) { throw new Error('缺少 --operator=用户名/邮箱/姓名') } return { input, operator, apply, limit: limitRaw ? Number(limitRaw) : undefined, } } async function main() { const options = parseArgs() if (options.apply) { assertApplyEnvironment() await assertApplyStorageAccess() await assertRemoteUploadSyncAccess() } const raw = await fs.readFile(options.input, 'utf8') const records = JSON.parse(raw) as HistoryImportRecord[] const operator = await resolveImportOperator(options.operator) console.log('历史素材导入开始') console.log(`- 输入文件: ${options.input}`) console.log(`- 执行人: ${operator.realName || operator.username}`) console.log(`- 模式: ${options.apply ? '正式导入' : '仅演练'}`) const results = await importHistoryMaterials({ records, operator, apply: options.apply, limit: options.limit, }) const outputPath = path.join( path.dirname(options.input), `history-material-import-result-${Date.now()}.json`, ) await fs.writeFile(outputPath, JSON.stringify(results, null, 2), 'utf8') const summary = results.reduce( (acc, item) => { acc.total += 1 acc[item.status] += 1 return acc }, { total: 0, imported: 0, dry_run: 0, skipped: 0, failed: 0 } as Record, ) console.log('\n导入完成') console.log(`- 总处理数: ${summary.total}`) console.log(`- 已导入: ${summary.imported}`) console.log(`- 演练记录: ${summary.dry_run}`) console.log(`- 已跳过: ${summary.skipped}`) console.log(`- 失败: ${summary.failed}`) console.log(`- 输出文件: ${outputPath}`) console.table(results.slice(0, 12)) } main() .catch((error) => { console.error('历史素材导入失败:', error) process.exitCode = 1 }) .finally(async () => { await prisma.$disconnect() process.exit(process.exitCode ?? 0) })