Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
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<string, number>,
|
||
)
|
||
|
||
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)
|
||
})
|