Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
275 lines
8.1 KiB
TypeScript
275 lines
8.1 KiB
TypeScript
import { execFile } from 'child_process'
|
|
import { promises as fs } from 'fs'
|
|
import path from 'path'
|
|
import { promisify } from 'util'
|
|
import { PrismaClient } from '@prisma/client'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const SSH_TIMEOUT_MS = Number(process.env.HISTORY_IMPORT_VALIDATE_SSH_TIMEOUT_MS || 30000)
|
|
const SSH_RETRY_COUNT = Number(process.env.HISTORY_IMPORT_VALIDATE_SSH_RETRY_COUNT || 3)
|
|
const SSH_ARGS = [
|
|
'-o',
|
|
'BatchMode=yes',
|
|
'-o',
|
|
'ConnectTimeout=15',
|
|
'-o',
|
|
'ServerAliveInterval=15',
|
|
'-o',
|
|
'ServerAliveCountMax=2',
|
|
]
|
|
|
|
type ImportResultItem = {
|
|
fingerprint: string
|
|
spu: string
|
|
sourcePath: string
|
|
materialId: string | null
|
|
status: string
|
|
reason: string | null
|
|
}
|
|
|
|
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 remoteHost = getArg('remote-host') || process.env.HISTORY_IMPORT_REMOTE_UPLOADS_HOST || 'prod'
|
|
const remoteUploadsDir =
|
|
getArg('remote-uploads-dir') ||
|
|
process.env.HISTORY_IMPORT_REMOTE_UPLOADS_DIR ||
|
|
'/mnt/PoolOne/youyidian-uploads'
|
|
|
|
if (!input) {
|
|
throw new Error('缺少 --input=导入结果 JSON')
|
|
}
|
|
|
|
return { input, remoteHost, remoteUploadsDir }
|
|
}
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
}
|
|
|
|
function storedPathToRemotePath(remoteUploadsDir: string, storedPath: string) {
|
|
const normalized = String(storedPath || '').replace(/\\/g, '/')
|
|
const relative = normalized.replace(/^\.?\/*uploads\//i, '')
|
|
if (!relative || relative.includes('../')) {
|
|
throw new Error(`下载文件路径无效: ${storedPath}`)
|
|
}
|
|
return path.posix.join(remoteUploadsDir.replace(/\/+$/, ''), relative)
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
async function getRemoteFileSize(remoteHost: string, remotePath: string) {
|
|
let lastError: unknown
|
|
for (let attempt = 1; attempt <= SSH_RETRY_COUNT; attempt += 1) {
|
|
try {
|
|
const { stdout } = await execFileAsync(
|
|
'ssh',
|
|
[
|
|
...SSH_ARGS,
|
|
remoteHost,
|
|
`test -f ${shellQuote(remotePath)} && stat -c %s ${shellQuote(remotePath)}`,
|
|
],
|
|
{ timeout: SSH_TIMEOUT_MS },
|
|
)
|
|
return Number(String(stdout || '').trim())
|
|
} catch (error) {
|
|
lastError = error
|
|
if (attempt < SSH_RETRY_COUNT) {
|
|
await sleep(1000 * attempt)
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs()
|
|
const prisma = new PrismaClient()
|
|
|
|
try {
|
|
const raw = await fs.readFile(options.input, 'utf8')
|
|
const results = JSON.parse(raw) as ImportResultItem[]
|
|
const imported = results.filter((item) => item.status === 'imported' && item.materialId)
|
|
const failures = results.filter((item) => item.status === 'failed')
|
|
const skipped = results.filter((item) => item.status === 'skipped')
|
|
const ids = imported.map((item) => item.materialId!).filter(Boolean)
|
|
|
|
const rows = await prisma.material.findMany({
|
|
where: { id: { in: ids } },
|
|
select: {
|
|
id: true,
|
|
productModel: true,
|
|
previewUrl: true,
|
|
zipFilePath: true,
|
|
zipFileSize: true,
|
|
isFullSet: true,
|
|
deletedAt: true,
|
|
},
|
|
})
|
|
const rowById = new Map(rows.map((row) => [row.id, row]))
|
|
const sourceAssets = await prisma.materialAsset.findMany({
|
|
where: {
|
|
materialId: { in: ids },
|
|
assetType: 'psd',
|
|
},
|
|
select: {
|
|
materialId: true,
|
|
fileUrl: true,
|
|
fileName: true,
|
|
fileSize: true,
|
|
},
|
|
})
|
|
const sourceAssetsByMaterialId = new Map<string, typeof sourceAssets>()
|
|
for (const asset of sourceAssets) {
|
|
const current = sourceAssetsByMaterialId.get(asset.materialId) || []
|
|
current.push(asset)
|
|
sourceAssetsByMaterialId.set(asset.materialId, current)
|
|
}
|
|
const checks = []
|
|
|
|
for (const item of imported) {
|
|
const row = rowById.get(item.materialId!)
|
|
if (!row) {
|
|
checks.push({
|
|
materialId: item.materialId,
|
|
spu: item.spu,
|
|
ok: false,
|
|
issue: '数据库记录不存在',
|
|
})
|
|
continue
|
|
}
|
|
|
|
const remotePath = storedPathToRemotePath(options.remoteUploadsDir, row.zipFilePath || '')
|
|
let remoteSize = -1
|
|
try {
|
|
remoteSize = await getRemoteFileSize(options.remoteHost, remotePath)
|
|
} catch {
|
|
remoteSize = -1
|
|
}
|
|
|
|
let downloadStatus = 0
|
|
try {
|
|
const response = await fetch(
|
|
`https://sc.jiansun.vip/api/materials/download?id=${row.id}&type=all&validate=1&share=1`,
|
|
)
|
|
downloadStatus = response.status
|
|
} catch {
|
|
downloadStatus = -1
|
|
}
|
|
|
|
let previewStatus = 0
|
|
try {
|
|
const response = await fetch(row.previewUrl || '', { method: 'HEAD' })
|
|
previewStatus = response.status
|
|
} catch {
|
|
previewStatus = -1
|
|
}
|
|
|
|
const expectedSize = Number(row.zipFileSize || 0)
|
|
const materialSourceAssets = sourceAssetsByMaterialId.get(row.id) || []
|
|
const sourceChecks = []
|
|
for (const asset of materialSourceAssets) {
|
|
const sourcePathOk =
|
|
String(asset.fileUrl || '').startsWith('uploads/materials/') &&
|
|
!String(asset.fileUrl || '').includes('\\')
|
|
const expectedSourceSize = Number(asset.fileSize || 0)
|
|
let remoteSourceSize = -1
|
|
|
|
if (sourcePathOk) {
|
|
try {
|
|
remoteSourceSize = await getRemoteFileSize(
|
|
options.remoteHost,
|
|
storedPathToRemotePath(options.remoteUploadsDir, asset.fileUrl || ''),
|
|
)
|
|
} catch {
|
|
remoteSourceSize = -1
|
|
}
|
|
}
|
|
|
|
sourceChecks.push({
|
|
fileName: asset.fileName,
|
|
sourcePathOk,
|
|
remoteSize: remoteSourceSize,
|
|
expectedSize: expectedSourceSize,
|
|
ok: sourcePathOk && remoteSourceSize === expectedSourceSize,
|
|
})
|
|
}
|
|
const sourceAssetsOk = sourceChecks.every((asset) => asset.ok)
|
|
const previewOk = String(row.previewUrl || '').startsWith(
|
|
'https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/',
|
|
)
|
|
const zipPathOk =
|
|
String(row.zipFilePath || '').startsWith('uploads/materials/') &&
|
|
!String(row.zipFilePath || '').includes('\\')
|
|
const ok =
|
|
!row.deletedAt &&
|
|
row.isFullSet &&
|
|
previewOk &&
|
|
zipPathOk &&
|
|
sourceAssetsOk &&
|
|
remoteSize === expectedSize &&
|
|
downloadStatus === 200 &&
|
|
previewStatus === 200
|
|
|
|
checks.push({
|
|
materialId: row.id,
|
|
spu: row.productModel,
|
|
ok,
|
|
previewOk,
|
|
zipPathOk,
|
|
sourceAssetCount: materialSourceAssets.length,
|
|
sourceAssetsOk,
|
|
sourceIssues: sourceChecks.filter((asset) => !asset.ok),
|
|
remoteSize,
|
|
expectedSize,
|
|
downloadStatus,
|
|
previewStatus,
|
|
})
|
|
}
|
|
|
|
const summary = {
|
|
input: options.input,
|
|
total: results.length,
|
|
imported: imported.length,
|
|
skipped: skipped.length,
|
|
failed: failures.length,
|
|
checked: checks.length,
|
|
ok: checks.filter((item) => item.ok).length,
|
|
issueCount: checks.filter((item) => !item.ok).length + failures.length,
|
|
issues: [
|
|
...failures.map((item) => ({
|
|
materialId: item.materialId,
|
|
spu: item.spu,
|
|
ok: false,
|
|
issue: item.reason || '导入失败',
|
|
})),
|
|
...checks.filter((item) => !item.ok),
|
|
],
|
|
checkedAt: new Date().toISOString(),
|
|
}
|
|
|
|
const outputPath = options.input.replace(/\.json$/i, '.verification.json')
|
|
await fs.writeFile(outputPath, JSON.stringify(summary, null, 2), 'utf8')
|
|
console.log(JSON.stringify(summary, null, 2))
|
|
|
|
if (summary.failed > 0 || summary.issueCount > 0 || summary.imported + summary.skipped !== summary.total) {
|
|
process.exitCode = 1
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect()
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('导入结果验收失败:', error)
|
|
process.exitCode = 1
|
|
})
|