gongxiangp2/scripts/import-history-materials.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

190 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
})