fix(脚本): 移除旧迁移工具生产凭据
This commit is contained in:
parent
f280380720
commit
a484eb9e0d
73
backend/scripts/__tests__/legacyRemoteScriptSafety.test.js
Normal file
73
backend/scripts/__tests__/legacyRemoteScriptSafety.test.js
Normal 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']), /不支持的参数/)
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user