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 {
// 读取文件头部的元数据信息
// 读取文件头部(前 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);
+4 -1
View File
@@ -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 },
],
},
});
+17 -6
View File
@@ -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,
+9 -1
View File
@@ -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
View File
@@ -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;
-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) => {
const t = await require('../db').sequelize.transaction();
try {
+2 -2
View File
@@ -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({
+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 {
const defaultRoles = [
{
+37 -1
View File
@@ -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 };
+2 -2
View File
@@ -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();
+13 -3
View File
@@ -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 };
}
};
+1 -1
View File
@@ -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);
}
+3 -1
View File
@@ -49,7 +49,9 @@ const Login = () => {
}
}
} catch (error) {
console.error('检查用户状态失败:', error);
// 静默处理:登录页调用 check-admin 失败是正常的(未登录状态)
// 不显示错误信息,避免触发 401 重定向循环
console.log('检查管理员状态:', error);
}
};