From a484eb9e0d14470bc7ff8b2bdea4e56389c6b43c Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 10 Jul 2026 11:36:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=84=9A=E6=9C=AC):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A7=E8=BF=81=E7=A7=BB=E5=B7=A5=E5=85=B7=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../legacyRemoteScriptSafety.test.js | 73 ++++++++ .../backfillImportedEquipmentLocations.js | 157 +++++++++++++----- .../exportAdminManualReviewWorkbook.js | 121 ++++++++++---- .../scripts/importConfirmedEquipmentToLive.js | 153 +++++++++++++---- .../prepareEquipmentMigrationDryRun.js | 122 ++++++++++---- 5 files changed, 482 insertions(+), 144 deletions(-) create mode 100644 backend/scripts/__tests__/legacyRemoteScriptSafety.test.js diff --git a/backend/scripts/__tests__/legacyRemoteScriptSafety.test.js b/backend/scripts/__tests__/legacyRemoteScriptSafety.test.js new file mode 100644 index 0000000..3b68e72 --- /dev/null +++ b/backend/scripts/__tests__/legacyRemoteScriptSafety.test.js @@ -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']), /不支持的参数/) + } +}) diff --git a/backend/scripts/backfillImportedEquipmentLocations.js b/backend/scripts/backfillImportedEquipmentLocations.js index c27b7a0..1b2a964 100644 --- a/backend/scripts/backfillImportedEquipmentLocations.js +++ b/backend/scripts/backfillImportedEquipmentLocations.js @@ -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 } diff --git a/backend/scripts/exportAdminManualReviewWorkbook.js b/backend/scripts/exportAdminManualReviewWorkbook.js index a7eed81..8654366 100644 --- a/backend/scripts/exportAdminManualReviewWorkbook.js +++ b/backend/scripts/exportAdminManualReviewWorkbook.js @@ -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 } diff --git a/backend/scripts/importConfirmedEquipmentToLive.js b/backend/scripts/importConfirmedEquipmentToLive.js index 2bc987b..3e04ac5 100644 --- a/backend/scripts/importConfirmedEquipmentToLive.js +++ b/backend/scripts/importConfirmedEquipmentToLive.js @@ -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 } diff --git a/backend/scripts/prepareEquipmentMigrationDryRun.js b/backend/scripts/prepareEquipmentMigrationDryRun.js index d8d5046..9b30929 100644 --- a/backend/scripts/prepareEquipmentMigrationDryRun.js +++ b/backend/scripts/prepareEquipmentMigrationDryRun.js @@ -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 }