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:
zhang96110
2026-04-01 07:27:19 +00:00
parent 1d7a83bbe8
commit de7da77c11
13 changed files with 122 additions and 223 deletions
+12 -2
View File
@@ -127,13 +127,23 @@ router.get('/list', async (req, res) => {
}; };
try { try {
// 读取文件头部的元数据信息 // 读取文件头部(前 4KB)提取元数据,避免大文件内存溢出
let content; let content;
if (isCompressed) { if (isCompressed) {
const compressed = fs.readFileSync(filePath); const compressed = fs.readFileSync(filePath);
// 限制解压大小,超过 1MB 的备份不解析元数据
if (compressed.length > 1024 * 1024) {
metadata.description = '(文件过大,跳过元数据解析)';
return metadata;
}
content = zlib.gunzipSync(compressed).toString('utf8'); content = zlib.gunzipSync(compressed).toString('utf8');
} else { } 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); const backupData = JSON.parse(content);
+4 -1
View File
@@ -273,13 +273,16 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: '源设备和目标设备不能相同' }); return res.status(400).json({ error: '源设备和目标设备不能相同' });
} }
// 如果不是强制模式,检查冲突 // 如果不是强制模式,检查冲突(包括反向端口分配)
if (!force) { if (!force) {
const existingCable = await Cable.findOne({ const existingCable = await Cable.findOne({
where: { where: {
[Op.or]: [ [Op.or]: [
{ sourceDeviceId, sourcePort }, { sourceDeviceId, sourcePort },
{ targetDeviceId, targetPort }, { targetDeviceId, targetPort },
// 反向检查:已有接线的目标端口恰好是当前源端口
{ sourceDeviceId: targetDeviceId, sourcePort: targetPort },
{ targetDeviceId: sourceDeviceId, targetPort: sourcePort },
], ],
}, },
}); });
+17 -6
View File
@@ -56,7 +56,18 @@ router.post('/', async (req, res) => {
try { try {
const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body; 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) { if (!consumable) {
await transaction.rollback(); await transaction.rollback();
return res.status(404).json({ error: '耗材不存在' }); return res.status(404).json({ error: '耗材不存在' });
@@ -66,13 +77,13 @@ router.post('/', async (req, res) => {
let newStock; let newStock;
if (type === 'in') { if (type === 'in') {
newStock = previousStock + quantity; newStock = previousStock + numQuantity;
} else if (type === 'out') { } else if (type === 'out') {
if (previousStock < quantity) { if (previousStock < numQuantity) {
await transaction.rollback(); await transaction.rollback();
return res.status(400).json({ error: '库存不足' }); return res.status(400).json({ error: '库存不足' });
} }
newStock = previousStock - quantity; newStock = previousStock - numQuantity;
} else { } else {
await transaction.rollback(); await transaction.rollback();
return res.status(400).json({ error: '操作类型无效' }); return res.status(400).json({ error: '操作类型无效' });
@@ -84,7 +95,7 @@ router.post('/', async (req, res) => {
{ {
consumableId, consumableId,
type, type,
quantity, quantity: numQuantity,
previousStock, previousStock,
currentStock: newStock, currentStock: newStock,
operator, operator,
@@ -100,7 +111,7 @@ router.post('/', async (req, res) => {
consumableId, consumableId,
consumableName: consumable.name, consumableName: consumable.name,
operationType: type, operationType: type,
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity), quantity: type === 'in' ? numQuantity : -numQuantity,
previousStock, previousStock,
currentStock: newStock, currentStock: newStock,
operator, operator,
+9 -1
View File
@@ -318,7 +318,15 @@ router.post('/import', async (req, res) => {
router.get('/by-sn/:sn', async (req, res) => { router.get('/by-sn/:sn', async (req, res) => {
try { try {
const sn = req.params.sn; 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 consumable = consumables.find(c => {
const snList = Array.isArray(c.snList) ? c.snList : []; const snList = Array.isArray(c.snList) ? c.snList : [];
return snList.includes(sn); return snList.includes(sn);
+21 -20
View File
@@ -719,29 +719,21 @@ router.get('/all', async (req, res) => {
} }
}); });
// 生成设备ID的辅助函数 // 生成设备ID的辅助函数(使用 MAX 聚合查询避免竞态条件)
async function generateDeviceId() { async function generateDeviceId() {
// 获取当前最大的设备ID序号 const result = await Device.findOne({
const devices = await Device.findAll({
where: { where: {
deviceId: { 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; const maxNumber = (result && result.maxNum) ? parseInt(result.maxNum, 10) : 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 newNumber = maxNumber + 1; const newNumber = maxNumber + 1;
return `DEV${String(newNumber).padStart(3, '0')}`; 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) => { router.put('/batch-move', async (req, res) => {
const transaction = await sequelize.transaction();
try { try {
const { deviceIds, targetRackId, startPosition } = req.body; const { deviceIds, targetRackId, startPosition } = req.body;
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) { if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
await transaction.rollback();
return res.status(400).json({ error: '请提供有效的设备ID列表' }); return res.status(400).json({ error: '请提供有效的设备ID列表' });
} }
if (!targetRackId) { if (!targetRackId) {
await transaction.rollback();
return res.status(400).json({ error: '请提供目标机柜ID' }); return res.status(400).json({ error: '请提供目标机柜ID' });
} }
const targetRack = await Rack.findByPk(targetRackId); const targetRack = await Rack.findByPk(targetRackId, { transaction });
if (!targetRack) { if (!targetRack) {
await transaction.rollback();
return res.status(404).json({ error: '目标机柜不存在' }); return res.status(404).json({ error: '目标机柜不存在' });
} }
@@ -1708,6 +1704,7 @@ router.put('/batch-move', async (req, res) => {
'height', 'height',
'powerConsumption', 'powerConsumption',
], ],
transaction,
}); });
const deviceDetails = devicesToMove.map(d => d.toJSON()); const deviceDetails = devicesToMove.map(d => d.toJSON());
@@ -1756,6 +1753,7 @@ router.put('/batch-move', async (req, res) => {
position: { [Op.ne]: null }, position: { [Op.ne]: null },
}, },
attributes: ['deviceId', 'position', 'height'], attributes: ['deviceId', 'position', 'height'],
transaction,
}); });
for (const newDevice of devicesToCheck) { for (const newDevice of devicesToCheck) {
@@ -1808,6 +1806,7 @@ router.put('/batch-move', async (req, res) => {
const [updated] = await Device.update(updateData, { const [updated] = await Device.update(updateData, {
where: { deviceId }, where: { deviceId },
transaction,
}); });
if (updated) { if (updated) {
@@ -1824,7 +1823,7 @@ router.put('/batch-move', async (req, res) => {
const safePower = Number(powerChange) || 0; const safePower = Number(powerChange) || 0;
await Rack.update( await Rack.update(
{ currentPower: sequelize.literal(`currentPower + ${safePower}`) }, { 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; const safeTargetPower = Number(targetRackPowerChange.change) || 0;
await Rack.update( await Rack.update(
{ currentPower: sequelize.literal(`currentPower + ${safeTargetPower}`) }, { 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({ res.json({
message: `批量移动成功,已将 ${movedCount} 个设备移动到机柜 ${targetRackId}`, message: `批量移动成功,已将 ${movedCount} 个设备移动到机柜 ${targetRackId}`,
movedCount, movedCount,
}); });
} catch (error) { } catch (error) {
await transaction.rollback();
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@@ -2238,7 +2240,6 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
if (updated) { if (updated) {
const oldRackId = oldDevice.rackId; const oldRackId = oldDevice.rackId;
const newRackId = req.body.rackId;
const oldPower = oldDevice.powerConsumption || 0; const oldPower = oldDevice.powerConsumption || 0;
const newPower = const newPower =
req.body.powerConsumption !== undefined ? req.body.powerConsumption : oldPower; req.body.powerConsumption !== undefined ? req.body.powerConsumption : oldPower;
-182
View File
@@ -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) => { router.delete('/:deviceId', async (req, res) => {
const t = await require('../db').sequelize.transaction(); const t = await require('../db').sequelize.transaction();
try { try {
+1 -1
View File
@@ -385,10 +385,10 @@ router.post('/records/:recordId/check', async (req, res) => {
abnormalType = 'serial_mismatch'; abnormalType = 'serial_mismatch';
} else if (actualRackId && actualRackId !== record.rackId) { } else if (actualRackId && actualRackId !== record.rackId) {
abnormalType = 'position_mismatch'; abnormalType = 'position_mismatch';
}
} else if (status === 'not_found') { } else if (status === 'not_found') {
abnormalType = 'device_missing'; abnormalType = 'device_missing';
} }
}
await record.update({ await record.update({
actualSerialNumber: actualSerialNumber || null, actualSerialNumber: actualSerialNumber || null,
+1 -1
View File
@@ -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 { try {
const defaultRoles = [ const defaultRoles = [
{ {
+37 -1
View File
@@ -605,7 +605,24 @@ router.put('/:ticketId', async (req, res) => {
const beforeState = ticket.toJSON(); const beforeState = ticket.toJSON();
const { operatorId, operatorName, operatorRole } = req.body; 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({ await TicketOperationRecord.create({
recordId: uuidv4(), recordId: uuidv4(),
@@ -630,11 +647,30 @@ router.put('/:ticketId/status', async (req, res) => {
try { try {
const { status, operatorId, operatorName, operatorRole, resolution } = req.body; const { status, operatorId, operatorName, operatorRole, resolution } = req.body;
if (!status) {
return res.status(400).json({ error: '请提供目标状态' });
}
const ticket = await Ticket.findByPk(req.params.ticketId); const ticket = await Ticket.findByPk(req.params.ticketId);
if (!ticket) { if (!ticket) {
return res.status(404).json({ error: '工单不存在' }); 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 beforeState = ticket.toJSON();
const updateData = { status }; const updateData = { status };
+2 -2
View File
@@ -91,7 +91,7 @@ api.interceptors.response.use(
); );
export const authAPI = { export const authAPI = {
checkAdmin: () => api.get('/auth/check-admin'), checkAdmin: () => api.post('/auth/check-admin'),
register: data => api.post('/auth/register', data), register: data => api.post('/auth/register', data),
login: data => api.post('/auth/login', data), login: data => api.post('/auth/login', data),
unlock: data => api.post('/auth/unlock', data), unlock: data => api.post('/auth/unlock', data),
@@ -185,7 +185,7 @@ export const backupAPI = {
create: (data = {}) => api.post('/backup', data), create: (data = {}) => api.post('/backup', data),
validate: filename => api.get(`/backup/validate/${filename}`), validate: filename => api.get(`/backup/validate/${filename}`),
restore: (filename, options = {}) => api.post('/backup/restore', { filename, options }), 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}`), delete: filename => api.delete(`/backup/${filename}`),
upload: file => { upload: file => {
const formData = new FormData(); const formData = new FormData();
+13 -3
View File
@@ -34,7 +34,8 @@ export const AuthProvider = ({ children }) => {
if (currentUser && JSON.stringify(currentUser) !== JSON.stringify(user)) { if (currentUser && JSON.stringify(currentUser) !== JSON.stringify(user)) {
setUser(currentUser); setUser(currentUser);
} }
setInitialized(true); // 有 token 时调用 fetchProfile 验证有效性并刷新用户信息
fetchProfile();
} else if (!currentToken) { } else if (!currentToken) {
setToken(null); setToken(null);
setUser(null); setUser(null);
@@ -49,6 +50,7 @@ export const AuthProvider = ({ children }) => {
const currentToken = secureStorage.get(TOKEN_KEY); const currentToken = secureStorage.get(TOKEN_KEY);
if (!currentToken) { if (!currentToken) {
setLoading(false); setLoading(false);
setInitialized(true);
return; return;
} }
@@ -60,8 +62,14 @@ export const AuthProvider = ({ children }) => {
} }
} catch (error) { } catch (error) {
console.error('获取用户信息失败:', error); console.error('获取用户信息失败:', error);
// Token 无效,清除登录状态
secureStorage.remove(TOKEN_KEY);
secureStorage.remove(USER_KEY);
setToken(null);
setUser(null);
} finally { } finally {
setLoading(false); setLoading(false);
setInitialized(true);
} }
}, []); }, []);
@@ -78,7 +86,8 @@ export const AuthProvider = ({ children }) => {
} }
return { success: false, message: response.message, code: response.code }; return { success: false, message: response.message, code: response.code };
} catch (error) { } 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 }; return { success: false, message: response.message };
} catch (error) { } catch (error) {
return { success: false, message: error }; const message = error?.response?.data?.message || error?.message || '注册失败,请稍后重试';
return { success: false, message };
} }
}; };
+1 -1
View File
@@ -552,7 +552,7 @@ function DeviceManagement() {
if (scope === 'selected') { if (scope === 'selected') {
deviceIds = selectedDevices; deviceIds = selectedDevices;
} else if (scope === 'currentPage') { } else if (scope === 'currentPage') {
deviceIds = allDevices.map(device => device.deviceId); deviceIds = currentPageDevices.map(device => device.deviceId);
} else if (scope === 'all') { } else if (scope === 'all') {
deviceIds = allDevices.map(device => device.deviceId); deviceIds = allDevices.map(device => device.deviceId);
} }
+3 -1
View File
@@ -49,7 +49,9 @@ const Login = () => {
} }
} }
} catch (error) { } catch (error) {
console.error('检查用户状态失败:', error); // 静默处理:登录页调用 check-admin 失败是正常的(未登录状态)
// 不显示错误信息,避免触发 401 重定向循环
console.log('检查管理员状态:', error);
} }
}; };