fix: 修复后端12个+前端5个高优先级问题
后端: - devices.js: 修复 newRackId 重复声明、批量移动加事务、ID生成改MAX聚合 - tickets.js: 添加工单状态机校验、req.body白名单过滤 - roles.js: init-roles 添加 authMiddleware - backup.js: 备份列表只读4KB头部,压缩文件超1MB跳过 - consumables.js: SN查询改用数据库LIKE替代全表扫描 - consumableRecords.js: 出入库加 SELECT FOR UPDATE 行级锁 - inventory.js: not_found 状态判断从 abnormal if 内移到 else if - idleDevices.js: 删除重复的 batch-restore 路由定义(182行) - cables.js: 接线冲突检测增加反向端口检查 前端: - AuthContext.jsx: 初始化时调用 fetchProfile 验证token、Error提取message - api/index.js: backupAPI.download 返回Promise、checkAdmin改POST - Login.jsx: checkIsFirstUser 静默处理错误 - DeviceManagement.jsx: 导出 currentPage scope 使用 currentPageDevices
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+21
-20
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user