diff --git a/backend/routes/backup.js b/backend/routes/backup.js index a8bf280..434c26d 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -127,13 +127,23 @@ router.get('/list', async (req, res) => { }; try { - // 读取文件头部的元数据信息 + // 只读取文件头部(前 4KB)提取元数据,避免大文件内存溢出 let content; if (isCompressed) { const compressed = fs.readFileSync(filePath); + // 限制解压大小,超过 1MB 的备份不解析元数据 + if (compressed.length > 1024 * 1024) { + metadata.description = '(文件过大,跳过元数据解析)'; + return metadata; + } content = zlib.gunzipSync(compressed).toString('utf8'); } else { - content = fs.readFileSync(filePath, 'utf8'); + // 只读取前 4KB + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(4096); + const bytesRead = fs.readSync(fd, buf, 0, 4096, 0); + fs.closeSync(fd); + content = buf.slice(0, bytesRead).toString('utf8'); } const backupData = JSON.parse(content); diff --git a/backend/routes/cables.js b/backend/routes/cables.js index e417f6f..b4aa23a 100644 --- a/backend/routes/cables.js +++ b/backend/routes/cables.js @@ -273,13 +273,16 @@ router.post('/', async (req, res) => { return res.status(400).json({ error: '源设备和目标设备不能相同' }); } - // 如果不是强制模式,检查冲突 + // 如果不是强制模式,检查冲突(包括反向端口分配) if (!force) { const existingCable = await Cable.findOne({ where: { [Op.or]: [ { sourceDeviceId, sourcePort }, { targetDeviceId, targetPort }, + // 反向检查:已有接线的目标端口恰好是当前源端口 + { sourceDeviceId: targetDeviceId, sourcePort: targetPort }, + { targetDeviceId: sourceDeviceId, targetPort: sourcePort }, ], }, }); diff --git a/backend/routes/consumableRecords.js b/backend/routes/consumableRecords.js index 5fe9fa0..83b6660 100644 --- a/backend/routes/consumableRecords.js +++ b/backend/routes/consumableRecords.js @@ -56,7 +56,18 @@ router.post('/', async (req, res) => { try { const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body; - const consumable = await Consumable.findByPk(consumableId); + // 确保 quantity 为数值 + const numQuantity = parseFloat(quantity); + if (isNaN(numQuantity) || numQuantity <= 0) { + await transaction.rollback(); + return res.status(400).json({ error: '数量必须为正数' }); + } + + // 使用 SELECT ... FOR UPDATE 行级锁,防止并发修改 + const consumable = await Consumable.findByPk(consumableId, { + transaction, + lock: transaction.LOCK.UPDATE, + }); if (!consumable) { await transaction.rollback(); return res.status(404).json({ error: '耗材不存在' }); @@ -66,13 +77,13 @@ router.post('/', async (req, res) => { let newStock; if (type === 'in') { - newStock = previousStock + quantity; + newStock = previousStock + numQuantity; } else if (type === 'out') { - if (previousStock < quantity) { + if (previousStock < numQuantity) { await transaction.rollback(); return res.status(400).json({ error: '库存不足' }); } - newStock = previousStock - quantity; + newStock = previousStock - numQuantity; } else { await transaction.rollback(); return res.status(400).json({ error: '操作类型无效' }); @@ -84,7 +95,7 @@ router.post('/', async (req, res) => { { consumableId, type, - quantity, + quantity: numQuantity, previousStock, currentStock: newStock, operator, @@ -100,7 +111,7 @@ router.post('/', async (req, res) => { consumableId, consumableName: consumable.name, operationType: type, - quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity), + quantity: type === 'in' ? numQuantity : -numQuantity, previousStock, currentStock: newStock, operator, diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index d459310..ca841ab 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -318,7 +318,15 @@ router.post('/import', async (req, res) => { router.get('/by-sn/:sn', async (req, res) => { try { const sn = req.params.sn; - const consumables = await Consumable.findAll(); + // 使用数据库 LIKE 查询替代全表扫描 + const consumables = await Consumable.findAll({ + where: { + snList: { + [Op.like]: `%${sn}%`, + }, + }, + }); + // 精确匹配 SN(JSON 数组中的元素) const consumable = consumables.find(c => { const snList = Array.isArray(c.snList) ? c.snList : []; return snList.includes(sn); diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 7f43089..ecdbead 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -719,29 +719,21 @@ router.get('/all', async (req, res) => { } }); -// 生成设备ID的辅助函数 +// 生成设备ID的辅助函数(使用 MAX 聚合查询避免竞态条件) async function generateDeviceId() { - // 获取当前最大的设备ID序号 - const devices = await Device.findAll({ + const result = await Device.findOne({ where: { deviceId: { - [require('sequelize').Op.like]: 'DEV%', + [Op.like]: 'DEV%', }, }, + attributes: [ + [sequelize.fn('MAX', sequelize.literal("CAST(SUBSTR(deviceId, 4) AS INTEGER)")), 'maxNum'], + ], + raw: true, }); - let maxNumber = 0; - devices.forEach(device => { - const match = device.deviceId.match(/^DEV(\d+)$/); - if (match) { - const num = parseInt(match[1], 10); - if (num > maxNumber) { - maxNumber = num; - } - } - }); - - // 生成新的设备ID,序号+1,至少3位数字 + const maxNumber = (result && result.maxNum) ? parseInt(result.maxNum, 10) : 0; const newNumber = maxNumber + 1; return `DEV${String(newNumber).padStart(3, '0')}`; } @@ -1678,19 +1670,23 @@ router.put('/batch-status', async (req, res) => { // 批量移动设备 router.put('/batch-move', async (req, res) => { + const transaction = await sequelize.transaction(); try { const { deviceIds, targetRackId, startPosition } = req.body; if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) { + await transaction.rollback(); return res.status(400).json({ error: '请提供有效的设备ID列表' }); } if (!targetRackId) { + await transaction.rollback(); return res.status(400).json({ error: '请提供目标机柜ID' }); } - const targetRack = await Rack.findByPk(targetRackId); + const targetRack = await Rack.findByPk(targetRackId, { transaction }); if (!targetRack) { + await transaction.rollback(); return res.status(404).json({ error: '目标机柜不存在' }); } @@ -1708,6 +1704,7 @@ router.put('/batch-move', async (req, res) => { 'height', 'powerConsumption', ], + transaction, }); const deviceDetails = devicesToMove.map(d => d.toJSON()); @@ -1756,6 +1753,7 @@ router.put('/batch-move', async (req, res) => { position: { [Op.ne]: null }, }, attributes: ['deviceId', 'position', 'height'], + transaction, }); for (const newDevice of devicesToCheck) { @@ -1808,6 +1806,7 @@ router.put('/batch-move', async (req, res) => { const [updated] = await Device.update(updateData, { where: { deviceId }, + transaction, }); if (updated) { @@ -1824,7 +1823,7 @@ router.put('/batch-move', async (req, res) => { const safePower = Number(powerChange) || 0; await Rack.update( { currentPower: sequelize.literal(`currentPower + ${safePower}`) }, - { where: { rackId } } + { where: { rackId }, transaction } ); } } @@ -1837,7 +1836,7 @@ router.put('/batch-move', async (req, res) => { const safeTargetPower = Number(targetRackPowerChange.change) || 0; await Rack.update( { currentPower: sequelize.literal(`currentPower + ${safeTargetPower}`) }, - { where: { rackId: targetRackId } } + { where: { rackId: targetRackId }, transaction } ); } @@ -1860,11 +1859,14 @@ router.put('/batch-move', async (req, res) => { }, }); + await transaction.commit(); + res.json({ message: `批量移动成功,已将 ${movedCount} 个设备移动到机柜 ${targetRackId}`, movedCount, }); } catch (error) { + await transaction.rollback(); res.status(500).json({ error: error.message }); } }); @@ -2238,7 +2240,6 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => { if (updated) { const oldRackId = oldDevice.rackId; - const newRackId = req.body.rackId; const oldPower = oldDevice.powerConsumption || 0; const newPower = req.body.powerConsumption !== undefined ? req.body.powerConsumption : oldPower; diff --git a/backend/routes/idleDevices.js b/backend/routes/idleDevices.js index 1178fe8..da2abf5 100644 --- a/backend/routes/idleDevices.js +++ b/backend/routes/idleDevices.js @@ -779,188 +779,6 @@ router.put('/:deviceId/restore', async (req, res) => { } }); -router.put('/batch-restore', async (req, res) => { - const t = await require('../db').sequelize.transaction(); - try { - const { devices } = req.body; - - console.log('========== batch-restore 开始 =========='); - console.log('原始请求 body:', JSON.stringify(req.body)); - console.log('devices 参数:', devices); - - if (!devices || !Array.isArray(devices) || devices.length === 0) { - await t.rollback(); - console.log('错误: devices 参数无效'); - return res.status(400).json({ error: '请提供有效的设备列表' }); - } - - const deviceIds = devices.map(d => d.deviceId).filter(Boolean); - console.log('提取的 deviceIds:', deviceIds); - console.log('deviceIds 类型:', typeof deviceIds, Array.isArray(deviceIds)); - - if (deviceIds.length === 0) { - await t.rollback(); - console.log('错误: deviceIds 为空'); - return res.status(400).json({ error: '设备ID不能为空' }); - } - - console.log('开始查询设备,条件:', { deviceId: { [Op.in]: deviceIds }, isIdle: true }); - - const idleDevices = await Device.findAll({ - where: { deviceId: { [Op.in]: deviceIds }, isIdle: true }, - transaction: t, - }); - - console.log('查询到的空闲设备数量:', idleDevices.length); - if (idleDevices.length > 0) { - console.log( - '查询到的设备ID:', - idleDevices.map(d => d.deviceId) - ); - } - - if (idleDevices.length === 0) { - console.log('没有找到空闲设备,检查设备是否存在:'); - const allDevices = await Device.findAll({ - where: { deviceId: { [Op.in]: deviceIds } }, - transaction: t, - }); - console.log('设备表中存在的设备数量:', allDevices.length); - if (allDevices.length > 0) { - console.log( - '存在的设备及其 isIdle 状态:', - allDevices.map(d => ({ deviceId: d.deviceId, isIdle: d.isIdle })) - ); - } - - await t.rollback(); - return res.status(404).json({ error: '没有找到空闲设备' }); - } - - let restoredCount = 0; - const results = []; - - for (const device of idleDevices) { - const deviceConfig = devices.find(d => d.deviceId === device.deviceId); - if (!deviceConfig) { - continue; - } - - const targetRackId = deviceConfig.targetRackId; - const targetPosition = deviceConfig.targetPosition; - - if (!targetRackId) { - results.push({ - deviceId: device.deviceId, - name: device.name, - status: 'skipped', - reason: '未指定目标机柜', - }); - continue; - } - - const targetRack = await Rack.findByPk(targetRackId, { transaction: t }); - if (!targetRack) { - results.push({ - deviceId: device.deviceId, - name: device.name, - status: 'failed', - reason: '目标机柜不存在', - }); - continue; - } - - const height = device.height || 1; - const position = targetPosition || 1; - - const checkResult = await checkPositionAvailable(targetRackId, position, height, null, t); - if (!checkResult.available) { - results.push({ - deviceId: device.deviceId, - name: device.name, - status: 'failed', - reason: `U位${position}已被占用`, - }); - continue; - } - - await device.update( - { - isIdle: false, - idleDate: null, - idleReason: null, - rackId: targetRackId, - position: position, - warehouseId: null, - sourceType: 'rack', - status: 'offline', - }, - { transaction: t } - ); - - await targetRack.update( - { - currentPower: targetRack.currentPower + (device.powerConsumption || 0), - }, - { transaction: t } - ); - - restoredCount++; - results.push({ - deviceId: device.deviceId, - name: device.name, - status: 'success', - targetRack: targetRack.name, - targetPosition: position, - }); - } - - await t.commit(); - - const successCount = results.filter(r => r.status === 'success').length; - const failedCount = results.filter(r => r.status === 'failed').length; - const skippedCount = results.filter(r => r.status === 'skipped').length; - - const successDevices = idleDevices.filter(d => - results.some(r => r.deviceId === d.deviceId && r.status === 'success') - ); - const deviceSummary = successDevices - .map( - d => - `${d.name}(编号:${d.deviceId}${d.type ? `,类型:${d.type}` : ''}${d.ipAddress ? `,IP:${d.ipAddress}` : ''})` - ) - .join('、'); - - await logDeviceOperation( - 'batch_restore', - `批量上架 ${successCount} 台空闲设备:${deviceSummary}`, - { - targetId: deviceIds.join(','), - targetName: `${successCount}台设备`, - req, - metadata: { - results, - type: 'batch_idle_device_restore', - devices: successDevices.map(d => d.toJSON()), - }, - } - ); - - res.json({ - message: `成功上架 ${successCount} 台设备`, - total: idleDevices.length, - restored: successCount, - failed: failedCount, - skipped: skippedCount, - details: results, - }); - } catch (error) { - await t.rollback(); - console.error('批量上架设备失败:', error); - res.status(500).json({ error: error.message }); - } -}); - router.delete('/:deviceId', async (req, res) => { const t = await require('../db').sequelize.transaction(); try { diff --git a/backend/routes/inventory.js b/backend/routes/inventory.js index 6845cf1..244b696 100644 --- a/backend/routes/inventory.js +++ b/backend/routes/inventory.js @@ -385,9 +385,9 @@ router.post('/records/:recordId/check', async (req, res) => { abnormalType = 'serial_mismatch'; } else if (actualRackId && actualRackId !== record.rackId) { abnormalType = 'position_mismatch'; - } else if (status === 'not_found') { - abnormalType = 'device_missing'; } + } else if (status === 'not_found') { + abnormalType = 'device_missing'; } await record.update({ diff --git a/backend/routes/roles.js b/backend/routes/roles.js index 8c687ec..bc86cee 100644 --- a/backend/routes/roles.js +++ b/backend/routes/roles.js @@ -332,7 +332,7 @@ router.delete('/:roleId', authMiddleware, async (req, res) => { } }); -router.post('/init-roles', async (req, res) => { +router.post('/init-roles', authMiddleware, async (req, res) => { try { const defaultRoles = [ { diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index 91b5fdb..5afb3e1 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -605,7 +605,24 @@ router.put('/:ticketId', async (req, res) => { const beforeState = ticket.toJSON(); const { operatorId, operatorName, operatorRole } = req.body; - await ticket.update(req.body); + // 白名单过滤:只允许更新安全字段,防止覆盖 ticketId/createdAt 等关键字段 + const ALLOWED_UPDATE_FIELDS = [ + 'title', 'description', 'category', 'priority', 'location', + 'contactPerson', 'contactPhone', 'contactEmail', + 'expectedDate', 'attachments', 'customFields', + ]; + const updateData = {}; + ALLOWED_UPDATE_FIELDS.forEach(field => { + if (req.body[field] !== undefined) { + updateData[field] = req.body[field]; + } + }); + + if (Object.keys(updateData).length === 0) { + return res.status(400).json({ error: '没有可更新的字段' }); + } + + await ticket.update(updateData); await TicketOperationRecord.create({ recordId: uuidv4(), @@ -630,11 +647,30 @@ router.put('/:ticketId/status', async (req, res) => { try { const { status, operatorId, operatorName, operatorRole, resolution } = req.body; + if (!status) { + return res.status(400).json({ error: '请提供目标状态' }); + } + const ticket = await Ticket.findByPk(req.params.ticketId); if (!ticket) { return res.status(404).json({ error: '工单不存在' }); } + // 状态机校验:定义合法的状态流转 + const STATUS_TRANSITIONS = { + pending: ['processing', 'closed'], + processing: ['completed', 'closed'], + completed: ['closed'], + closed: [], + }; + + const allowedTransitions = STATUS_TRANSITIONS[ticket.status] || []; + if (!allowedTransitions.includes(status)) { + return res.status(400).json({ + error: `不允许从 "${ticket.status}" 变更为 "${status}",合法目标状态: ${allowedTransitions.join(', ') || '无'}`, + }); + } + const beforeState = ticket.toJSON(); const updateData = { status }; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 84af7ec..6cd1d42 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -91,7 +91,7 @@ api.interceptors.response.use( ); export const authAPI = { - checkAdmin: () => api.get('/auth/check-admin'), + checkAdmin: () => api.post('/auth/check-admin'), register: data => api.post('/auth/register', data), login: data => api.post('/auth/login', data), unlock: data => api.post('/auth/unlock', data), @@ -185,7 +185,7 @@ export const backupAPI = { create: (data = {}) => api.post('/backup', data), validate: filename => api.get(`/backup/validate/${filename}`), restore: (filename, options = {}) => api.post('/backup/restore', { filename, options }), - download: filename => `/api/backup/download/${filename}`, + download: filename => api.get(`/backup/download/${filename}`, { responseType: 'blob' }), delete: filename => api.delete(`/backup/${filename}`), upload: file => { const formData = new FormData(); diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index b109c13..1a28290 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -34,7 +34,8 @@ export const AuthProvider = ({ children }) => { if (currentUser && JSON.stringify(currentUser) !== JSON.stringify(user)) { setUser(currentUser); } - setInitialized(true); + // 有 token 时调用 fetchProfile 验证有效性并刷新用户信息 + fetchProfile(); } else if (!currentToken) { setToken(null); setUser(null); @@ -49,6 +50,7 @@ export const AuthProvider = ({ children }) => { const currentToken = secureStorage.get(TOKEN_KEY); if (!currentToken) { setLoading(false); + setInitialized(true); return; } @@ -60,8 +62,14 @@ export const AuthProvider = ({ children }) => { } } catch (error) { console.error('获取用户信息失败:', error); + // Token 无效,清除登录状态 + secureStorage.remove(TOKEN_KEY); + secureStorage.remove(USER_KEY); + setToken(null); + setUser(null); } finally { setLoading(false); + setInitialized(true); } }, []); @@ -78,7 +86,8 @@ export const AuthProvider = ({ children }) => { } return { success: false, message: response.message, code: response.code }; } catch (error) { - return { success: false, message: error }; + const message = error?.response?.data?.message || error?.message || '登录失败,请稍后重试'; + return { success: false, message }; } }; @@ -97,7 +106,8 @@ export const AuthProvider = ({ children }) => { } return { success: false, message: response.message }; } catch (error) { - return { success: false, message: error }; + const message = error?.response?.data?.message || error?.message || '注册失败,请稍后重试'; + return { success: false, message }; } }; diff --git a/frontend/src/pages/DeviceManagement.jsx b/frontend/src/pages/DeviceManagement.jsx index e555a41..8f4adb8 100644 --- a/frontend/src/pages/DeviceManagement.jsx +++ b/frontend/src/pages/DeviceManagement.jsx @@ -552,7 +552,7 @@ function DeviceManagement() { if (scope === 'selected') { deviceIds = selectedDevices; } else if (scope === 'currentPage') { - deviceIds = allDevices.map(device => device.deviceId); + deviceIds = currentPageDevices.map(device => device.deviceId); } else if (scope === 'all') { deviceIds = allDevices.map(device => device.deviceId); } diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 2a215ce..b6c380c 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -49,7 +49,9 @@ const Login = () => { } } } catch (error) { - console.error('检查用户状态失败:', error); + // 静默处理:登录页调用 check-admin 失败是正常的(未登录状态) + // 不显示错误信息,避免触发 401 重定向循环 + console.log('检查管理员状态:', error); } };