fix(脚本): 移除旧迁移工具生产凭据

This commit is contained in:
WuYongJie 2026-07-10 11:36:13 +08:00
parent f280380720
commit a484eb9e0d
5 changed files with 482 additions and 144 deletions

View File

@ -0,0 +1,73 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const backfillScript = require('../backfillImportedEquipmentLocations')
const exportScript = require('../exportAdminManualReviewWorkbook')
const importScript = require('../importConfirmedEquipmentToLive')
const prepareScript = require('../prepareEquipmentMigrationDryRun')
const scripts = [backfillScript, exportScript, importScript, prepareScript]
const writerScripts = [backfillScript, importScript]
const explicitConnectionArgs = [
'--plink-path', 'plink-test.exe',
'--ssh-host', 'deploy@example.invalid',
'--ssh-hostkey', 'SHA256:test-host-key',
'--ssh-password', 'test-secret-value'
]
test('两个旧写入脚本默认只预览,必须显式 --apply 才允许写入', () => {
for (const script of writerScripts) {
const preview = script.parseArgs([])
assert.equal(preview.apply, false)
assert.equal(preview.dryRun, true)
const apply = script.parseArgs(['--apply'])
assert.equal(apply.apply, true)
assert.equal(apply.dryRun, false)
assert.throws(() => script.parseArgs(['--apply', '--dry-run']), /不能同时使用/)
assert.throws(() => script.parseArgs(['--aply']), /不支持的参数/)
}
})
test('四个旧脚本缺少连接参数时在联网前失败且不回显密码', () => {
for (const script of scripts) {
const options = script.parseArgs(['--ssh-password', 'test-secret-value'])
assert.throws(
() => script.resolveRemoteConnection(options, {}),
(error) => {
assert.match(error.message, /缺少固定资产远程连接参数/)
assert.match(error.message, /未发起网络连接/)
assert.doesNotMatch(error.message, /test-secret-value/)
return true
}
)
}
})
test('四个旧脚本仅接受显式连接配置且 CLI 优先于环境变量', () => {
const env = {
FIXED_ASSET_PLINK_PATH: 'env-plink.exe',
FIXED_ASSET_SSH_HOST: 'env@example.invalid',
FIXED_ASSET_SSH_HOSTKEY: 'SHA256:env-key',
FIXED_ASSET_SSH_PASSWORD: 'env-secret'
}
for (const script of scripts) {
const options = script.parseArgs(explicitConnectionArgs)
assert.deepEqual(script.resolveRemoteConnection(options, env), {
plinkPath: 'plink-test.exe',
sshHost: 'deploy@example.invalid',
sshHostkey: 'SHA256:test-host-key',
sshPassword: 'test-secret-value'
})
}
})
test('两个旧只读脚本拒绝未知参数且不暴露写入开关', () => {
for (const script of [exportScript, prepareScript]) {
const options = script.parseArgs([])
assert.equal(Object.prototype.hasOwnProperty.call(options, 'apply'), false)
assert.throws(() => script.parseArgs(['--apply']), /不支持的参数/)
}
})

View File

@ -12,11 +12,6 @@ const { spawnSync } = require('child_process')
const { resolveStorageLocationName } = require('./locationNormalization')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || ''
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
@ -58,23 +53,81 @@ const resolveLatestMigrationDir = () => {
return path.join(OUTPUT_ROOT, candidates[candidates.length - 1])
}
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
inputDir: ''
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
inputDir: '',
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
let explicitDryRun = false
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = args[index + 1] || ''
options.inputDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--dry-run') {
explicitDryRun = true
} else if (arg === '--apply') {
options.apply = true
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
if (options.apply && explicitDryRun) {
throw new Error('--apply 与 --dry-run 不能同时使用')
}
options.dryRun = !options.apply
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const resolveInputDir = (inputDirArg) => {
if (!inputDirArg) {
return resolveLatestMigrationDir()
@ -140,18 +193,19 @@ const buildPayload = (records) => {
return updates
}
const runRemoteNodeScript = (script) => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量')
}
const runRemoteNodeScript = (script, connection) => {
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
@ -161,11 +215,11 @@ const runRemoteNodeScript = (script) => {
)
if (result.error) {
throw result.error
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`)
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
@ -173,10 +227,14 @@ const runRemoteNodeScript = (script) => {
throw new Error('远程脚本返回为空')
}
return JSON.parse(stdout)
try {
return JSON.parse(stdout)
} catch {
throw new Error('远程脚本返回格式无效')
}
}
const buildRemoteScript = (payload) => {
const buildRemoteScript = (payload, apply) => {
const payloadBase64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
return `
@ -184,6 +242,7 @@ let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf8'))
const shouldApply = ${apply ? 'true' : 'false'}
;(async () => {
const locationNames = [...new Set(payload.map((item) => item.resolvedLocationName))]
const [locations, equipmentsBefore] = await Promise.all([
@ -242,18 +301,20 @@ const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('u
}
const updated = []
for (const item of planned) {
const result = await prisma.equipment.update({
where: { id: item.equipmentId },
data: { storage_location_id: item.storageLocationId },
select: {
id: true,
equipment_code: true,
storage_location_id: true,
storageLocation: { select: { id: true, name: true } }
}
})
updated.push(result)
if (shouldApply) {
for (const item of planned) {
const result = await prisma.equipment.update({
where: { id: item.equipmentId },
data: { storage_location_id: item.storageLocationId },
select: {
id: true,
equipment_code: true,
storage_location_id: true,
storageLocation: { select: { id: true, name: true } }
}
})
updated.push(result)
}
}
const verification = {
@ -262,6 +323,7 @@ const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('u
}
console.log(JSON.stringify({
applied: shouldApply,
payloadCount: payload.length,
plannedCount: planned.length,
updatedCount: updated.length,
@ -282,6 +344,7 @@ const payload = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('u
const run = () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const inputDir = resolveInputDir(options.inputDir)
const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl')
if (!fs.existsSync(normalizedPath)) {
@ -301,7 +364,7 @@ const run = () => {
payload
})
const result = runRemoteNodeScript(buildRemoteScript(payload))
const result = runRemoteNodeScript(buildRemoteScript(payload, options.apply), connection)
writeJson(path.join(outputDir, 'result.json'), result)
console.log(
@ -309,6 +372,7 @@ const run = () => {
{
outputDir,
inputDir,
applied: options.apply,
payloadCount: payload.length,
result
},
@ -318,9 +382,16 @@ const run = () => {
)
}
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -6,11 +6,6 @@ const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DEFAULT_OUTPUT = path.resolve(__dirname, '../../行政手动补录清单.xlsx')
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || ''
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
@ -20,35 +15,81 @@ const normalizeText = (value) =>
.replace(/\s+/g, ' ')
.trim()
const parseArgs = () => {
const args = process.argv.slice(2)
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
inputDir: '',
importBackupDir: '',
output: DEFAULT_OUTPUT,
nullLocationJson: ''
nullLocationJson: '',
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = args[index + 1] || ''
options.inputDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--import-backup-dir') {
options.importBackupDir = args[index + 1] || ''
options.importBackupDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--output') {
options.output = args[index + 1] || DEFAULT_OUTPUT
options.output = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--null-location-json') {
options.nullLocationJson = args[index + 1] || ''
options.nullLocationJson = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const resolveLatestDir = (prefix) => {
const candidates = fs
.readdirSync(OUTPUT_ROOT, { withFileTypes: true })
@ -165,18 +206,19 @@ const flattenDuplicateRows = (duplicateDetails) =>
}))
)
const runRemoteNodeScript = (script) => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量')
}
const runRemoteNodeScript = (script, connection) => {
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
@ -186,11 +228,11 @@ const runRemoteNodeScript = (script) => {
)
if (result.error) {
throw result.error
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`)
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
@ -198,10 +240,14 @@ const runRemoteNodeScript = (script) => {
throw new Error('远程脚本返回为空')
}
return JSON.parse(stdout)
try {
return JSON.parse(stdout)
} catch {
throw new Error('远程脚本返回格式无效')
}
}
const readNullLocationRows = (nullLocationJsonArg) => {
const readNullLocationRows = (nullLocationJsonArg, options) => {
const resolvedPath = nullLocationJsonArg
? path.isAbsolute(nullLocationJsonArg)
? nullLocationJsonArg
@ -216,7 +262,7 @@ const readNullLocationRows = (nullLocationJsonArg) => {
return readJson(resolvedPath)
}
return runRemoteNodeScript(buildRemoteNullLocationScript())
return runRemoteNodeScript(buildRemoteNullLocationScript(), resolveRemoteConnection(options))
}
const buildRemoteNullLocationScript = () => `
@ -394,7 +440,7 @@ const run = () => {
const missingCode = readJson(path.join(importBackupDir, 'excluded_missing_code_rows.json'))
const duplicateCodes = readJson(path.join(importBackupDir, 'excluded_duplicate_codes.json'))
const plannedSummary = readJson(path.join(importBackupDir, 'planned_import_summary.json'))
const liveNullLocationRows = readNullLocationRows(options.nullLocationJson)
const liveNullLocationRows = readNullLocationRows(options.nullLocationJson, options)
const duplicateCodeSet = new Set(duplicateCodes.duplicateDetails.map((item) => item.legacyCode))
const pendingRows = buildPendingRows(normalizedRecords)
@ -453,9 +499,16 @@ const run = () => {
)
}
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -12,11 +12,6 @@ const { spawnSync } = require('child_process')
const { resolveStorageLocationName } = require('./locationNormalization')
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || ''
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const normalizeText = (value) =>
@ -26,29 +21,87 @@ const normalizeText = (value) =>
.replace(/\s+/g, ' ')
.trim()
const parseArgs = () => {
const args = process.argv.slice(2)
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
inputDir: '',
keepExisting: false,
includeOwnerless: false
includeOwnerless: false,
apply: false,
dryRun: true,
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
let explicitDryRun = false
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--input-dir') {
options.inputDir = args[index + 1] || ''
options.inputDir = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--keep-existing') {
options.keepExisting = true
} else if (arg === '--include-ownerless') {
options.includeOwnerless = true
} else if (arg === '--dry-run') {
explicitDryRun = true
} else if (arg === '--apply') {
options.apply = true
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
if (options.apply && explicitDryRun) {
throw new Error('--apply 与 --dry-run 不能同时使用')
}
options.dryRun = !options.apply
return options
}
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const ensureOutputDir = (prefix) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const outputDir = path.join(OUTPUT_ROOT, `${prefix}_${timestamp}`)
@ -262,18 +315,19 @@ const buildImportPayload = (records) => {
return payload
}
const runRemoteNodeScript = (script) => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量')
}
const runRemoteNodeScript = (script, connection) => {
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: script,
@ -283,11 +337,11 @@ const runRemoteNodeScript = (script) => {
)
if (result.error) {
throw result.error
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error(`远程脚本执行失败: ${result.stderr || result.stdout}`)
throw new Error('远程脚本执行失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = normalizeText(result.stdout)
@ -295,7 +349,11 @@ const runRemoteNodeScript = (script) => {
throw new Error('远程脚本返回为空')
}
return JSON.parse(stdout)
try {
return JSON.parse(stdout)
} catch {
throw new Error('远程脚本返回格式无效')
}
}
const buildRemoteBackupScript = () => `
@ -681,6 +739,7 @@ const codes = JSON.parse(Buffer.from('${payloadBase64}', 'base64').toString('utf
const run = () => {
const options = parseArgs()
const connection = resolveRemoteConnection(options)
const inputDir = resolveInputDir(options.inputDir)
const normalizedPath = path.join(inputDir, 'normalized_assets.jsonl')
@ -701,7 +760,7 @@ const run = () => {
const partitioned = partitionImportCandidates(importCandidates)
const payload = buildImportPayload(partitioned.filteredRecords)
const backupDir = ensureOutputDir('live_import_backup')
const backupDir = ensureOutputDir(options.apply ? 'live_import_backup' : 'live_import_preview')
writeJson(path.join(backupDir, 'excluded_missing_code_rows.json'), {
excludedMissingCodeCount: partitioned.missingCodeRows.length,
@ -724,6 +783,7 @@ const run = () => {
finalImportCount: payload.length,
finalImportReadyCount: payload.filter((item) => item.importMode === 'with_owner').length,
finalImportWithoutOwnerCount: payload.filter((item) => item.importMode === 'without_owner').length,
apply: options.apply,
keepExisting: options.keepExisting,
includeOwnerless: options.includeOwnerless
})
@ -732,19 +792,39 @@ const run = () => {
payload
})
const backup = runRemoteNodeScript(buildRemoteBackupScript())
const backup = runRemoteNodeScript(buildRemoteBackupScript(), connection)
writeJson(path.join(backupDir, 'pre_import_live_backup.json'), backup)
if (!options.apply) {
console.log(
JSON.stringify(
{
backupDir,
inputDir,
dryRun: true,
plannedImportCount: payload.length,
message: '当前仅完成 preview,未执行清空或导入;如确认写入,请显式传入 --apply。'
},
null,
2
)
)
return
}
let wipeResult = { skipped: true }
if (!options.keepExisting) {
wipeResult = runRemoteNodeScript(buildRemoteWipeScript())
wipeResult = runRemoteNodeScript(buildRemoteWipeScript(), connection)
writeJson(path.join(backupDir, 'wipe_result.json'), wipeResult)
}
const importResult = runRemoteNodeScript(buildRemoteImportScript(payload, options.keepExisting))
const importResult = runRemoteNodeScript(
buildRemoteImportScript(payload, options.keepExisting),
connection
)
writeJson(path.join(backupDir, 'import_result.json'), importResult)
const verification = runRemoteNodeScript(buildRemoteVerifyScript(payload))
const verification = runRemoteNodeScript(buildRemoteVerifyScript(payload), connection)
writeJson(path.join(backupDir, 'post_import_verification.json'), verification)
console.log(
@ -763,9 +843,16 @@ const run = () => {
)
}
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}

View File

@ -21,11 +21,6 @@ const SHEET_NAME = '📜固定资产明细'
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DEFAULT_MANUAL_OVERRIDES_PATH = path.resolve(__dirname, '../../migration_manual_overrides.json')
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || ''
const REMOTE_NODE_COMMAND = 'docker exec -i equipment-backend node'
const OWNER_SEPARATORS = [',', ',', '、', ';', ';', '/', '|', '|']
@ -115,25 +110,50 @@ const CSV_IMPORT_HEADERS = [
'description'
]
const parseArgs = () => {
const args = process.argv.slice(2)
const requireArgValue = (args, index, argumentName) => {
const value = args[index + 1]
if (!value || String(value).startsWith('--')) {
throw new Error(`${argumentName} 缺少参数值`)
}
return value
}
const parseArgs = (args = process.argv.slice(2)) => {
const options = {
workbookPath: WORKBOOK_PATH,
overridesPath: '',
liveSnapshotPath: ''
liveSnapshotPath: '',
plinkPath: '',
sshHost: '',
sshHostkey: '',
sshPassword: ''
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--workbook') {
options.workbookPath = args[index + 1] || WORKBOOK_PATH
options.workbookPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--overrides') {
options.overridesPath = args[index + 1] || ''
options.overridesPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--live-snapshot') {
options.liveSnapshotPath = args[index + 1] || ''
options.liveSnapshotPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--plink-path') {
options.plinkPath = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-host') {
options.sshHost = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-hostkey') {
options.sshHostkey = requireArgValue(args, index, arg)
index += 1
} else if (arg === '--ssh-password') {
options.sshPassword = requireArgValue(args, index, arg)
index += 1
} else {
throw new Error(`不支持的参数: ${arg}`)
}
}
@ -149,6 +169,27 @@ const normalizeText = (value) =>
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const resolveRemoteConnection = (options, env = process.env) => {
const connection = {
plinkPath: normalizeText(options.plinkPath || env.FIXED_ASSET_PLINK_PATH),
sshHost: normalizeText(options.sshHost || env.FIXED_ASSET_SSH_HOST),
sshHostkey: normalizeText(options.sshHostkey || env.FIXED_ASSET_SSH_HOSTKEY),
sshPassword: options.sshPassword || env.FIXED_ASSET_SSH_PASSWORD || ''
}
const missing = []
if (!connection.plinkPath) missing.push('FIXED_ASSET_PLINK_PATH / --plink-path')
if (!connection.sshHost) missing.push('FIXED_ASSET_SSH_HOST / --ssh-host')
if (!connection.sshHostkey) missing.push('FIXED_ASSET_SSH_HOSTKEY / --ssh-hostkey')
if (!connection.sshPassword) missing.push('FIXED_ASSET_SSH_PASSWORD / --ssh-password')
if (missing.length > 0) {
throw new Error(`缺少固定资产远程连接参数:${missing.join('、')}。未发起网络连接。`)
}
return connection
}
const isBlank = (value) => normalizeText(value) === ''
const resolvePathArg = (value, fallbackPath = '') => {
@ -586,7 +627,7 @@ const prisma = new PrismaClient()
})
`
const readLiveSnapshot = (snapshotPathArg = '') => {
const readLiveSnapshot = (snapshotPathArg = '', options = {}) => {
const snapshotPath = resolvePathArg(snapshotPathArg)
if (snapshotPath) {
if (!fs.existsSync(snapshotPath)) {
@ -600,18 +641,20 @@ const readLiveSnapshot = (snapshotPathArg = '') => {
}
}
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 FIXED_ASSET_SSH_PASSWORD 环境变量')
}
const connection = resolveRemoteConnection(options)
const remoteScript = buildRemoteSnapshotScript()
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
connection.plinkPath,
[
'-batch',
'-ssh',
'-hostkey',
connection.sshHostkey,
'-pw',
connection.sshPassword,
connection.sshHost,
REMOTE_NODE_COMMAND
],
{
encoding: 'utf8',
input: remoteScript,
@ -621,11 +664,11 @@ const readLiveSnapshot = (snapshotPathArg = '') => {
)
if (result.error) {
throw result.error
throw new Error('无法启动远程连接工具,请检查 FIXED_ASSET_PLINK_PATH / --plink-path。')
}
if (result.status !== 0) {
throw new Error(`读取 live 快照失败: ${result.stderr || result.stdout}`)
throw new Error('读取 live 快照失败,请检查连接参数、远程服务状态和服务器日志。')
}
const stdout = (result.stdout || '').trim()
@ -633,10 +676,14 @@ const readLiveSnapshot = (snapshotPathArg = '') => {
throw new Error('live 快照返回为空')
}
return {
source: 'remote',
path: '',
data: JSON.parse(stdout)
try {
return {
source: 'remote',
path: '',
data: JSON.parse(stdout)
}
} catch {
throw new Error('live 快照返回格式无效')
}
}
@ -1094,7 +1141,7 @@ const run = () => {
}
const workbook = loadWorkbookRows(workbookPath, manualOverrides.data)
const liveSnapshotResult = readLiveSnapshot(options.liveSnapshotPath)
const liveSnapshotResult = readLiveSnapshot(options.liveSnapshotPath, options)
const liveSnapshot = liveSnapshotResult.data
const refs = buildReferenceMaps(liveSnapshot)
const personIndexes = buildPersonIndexes(liveSnapshot.persons)
@ -1216,9 +1263,16 @@ const run = () => {
)
}
try {
run()
} catch (error) {
console.error(error?.stack ? error.stack : String(error))
process.exit(1)
if (require.main === module) {
try {
run()
} catch (error) {
console.error(error?.stack ? error.stack : String(error))
process.exit(1)
}
}
module.exports = {
parseArgs,
resolveRemoteConnection
}