fix: 修复14个中等问题(安全+稳定性)
后端安全修复: - devices.js: page/pageSize parseInt+范围限制、CSV导入path.basename防路径遍历 - tickets.js: CSV导出收集所有metadata key、评价限制completed状态 - users.js: 重置密码加权限检查(仅管理员或本人) - systemSettings.js: Boolean改显式判断(修复false存储为true) - inventory.js: 空数组Array.isArray检查(修复无法清空目标机房) - statistics.js: 在线率改用runningDevices计算 - operationLogs.js: DATE()改strftime兼容SQLite - devicePorts.js: 端口更新白名单过滤(防修改portId/deviceId) - server.js: 生产环境禁用sync alter 前端修复: - api/index.js: 401优先React Router导航(保留SPA状态) - Scene.jsx: useFrame仅target变化时更新(减少不必要矩阵计算) - vite.config.mjs: 移除Vue插件残留(unplugin-vue-components)
This commit is contained in:
@@ -304,7 +304,20 @@ router.post('/batch', async (req, res) => {
|
||||
|
||||
router.put('/:portId', async (req, res) => {
|
||||
try {
|
||||
const [updated] = await DevicePort.update(req.body, {
|
||||
// 白名单过滤:只允许更新安全字段
|
||||
const ALLOWED_FIELDS = ['portName', 'portType', 'portSpeed', 'status', 'vlanId', 'description', 'connectedDevice', 'macAddress'];
|
||||
const updateData = {};
|
||||
ALLOWED_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: '没有可更新的字段' });
|
||||
}
|
||||
|
||||
const [updated] = await DevicePort.update(updateData, {
|
||||
where: { portId: req.params.portId },
|
||||
});
|
||||
|
||||
|
||||
@@ -520,7 +520,9 @@ router.post('/import-preview', async (req, res) => {
|
||||
|
||||
router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
|
||||
try {
|
||||
const { keyword, status, type, rackId, roomId, page = 1, pageSize = 10, isIdle } = req.query;
|
||||
const { keyword, status, type, rackId, roomId, isIdle } = req.query;
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 10));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 构建查询条件
|
||||
@@ -1078,8 +1080,12 @@ router.post('/import', async (req, res) => {
|
||||
fs.mkdirSync(path.join(__dirname, '../temp'));
|
||||
}
|
||||
|
||||
// 保存上传的文件
|
||||
const filePath = path.join(__dirname, '../temp', csvFile.name);
|
||||
// 保存上传的文件(使用 path.basename 防止路径遍历)
|
||||
const safeFileName = path.basename(csvFile.name);
|
||||
if (!safeFileName || safeFileName.startsWith('.') || safeFileName !== csvFile.name) {
|
||||
return res.status(400).json({ error: '文件名不合法' });
|
||||
}
|
||||
const filePath = path.join(__dirname, '../temp', safeFileName);
|
||||
await csvFile.mv(filePath);
|
||||
|
||||
// 读取并解析CSV文件(GBK编码)
|
||||
|
||||
@@ -150,9 +150,9 @@ router.put('/plans/:planId', async (req, res) => {
|
||||
type: type || plan.type,
|
||||
description: description !== undefined ? description : plan.description,
|
||||
scheduledDate: scheduledDate ? new Date(scheduledDate) : plan.scheduledDate,
|
||||
targetRooms: targetRooms || plan.targetRooms,
|
||||
targetRacks: targetRacks || plan.targetRacks,
|
||||
status: status || plan.status,
|
||||
targetRooms: Array.isArray(targetRooms) ? targetRooms : plan.targetRooms,
|
||||
targetRacks: Array.isArray(targetRacks) ? targetRacks : plan.targetRacks,
|
||||
status: status !== undefined ? status : plan.status,
|
||||
});
|
||||
|
||||
res.json(plan);
|
||||
|
||||
@@ -183,11 +183,11 @@ router.get('/statistics', authMiddleware, async (req, res) => {
|
||||
OperationLog.findAll({
|
||||
where,
|
||||
attributes: [
|
||||
[sequelize.fn('DATE', sequelize.col('createdAt')), 'date'],
|
||||
[sequelize.literal("strftime('%Y-%m-%d', \"createdAt\")"), 'date'],
|
||||
[sequelize.fn('COUNT', '*'), 'count'],
|
||||
],
|
||||
group: [sequelize.fn('DATE', sequelize.col('createdAt'))],
|
||||
order: [[sequelize.fn('DATE', sequelize.col('createdAt')), 'DESC']],
|
||||
group: [sequelize.literal("strftime('%Y-%m-%d', \"createdAt\")")],
|
||||
order: [[sequelize.literal("strftime('%Y-%m-%d', \"createdAt\")"), 'DESC']],
|
||||
limit: 30,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -130,7 +130,7 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
|
||||
const onlineRate =
|
||||
totalDevices > 0 ? (((totalDevices - faultDevices) / totalDevices) * 100).toFixed(1) : 100;
|
||||
totalDevices > 0 ? ((runningDevices / totalDevices) * 100).toFixed(1) : 100;
|
||||
|
||||
const totalRooms = rooms.length;
|
||||
|
||||
|
||||
@@ -350,7 +350,7 @@ router.put('/:key', async (req, res) => {
|
||||
return res.status(400).json({ error: '值必须是有效的数字' });
|
||||
}
|
||||
} else if (setting.settingType === 'boolean') {
|
||||
parsedValue = Boolean(value);
|
||||
parsedValue = value === true || value === 'true' || value === '1' || value === 1;
|
||||
}
|
||||
|
||||
await setting.update({
|
||||
|
||||
@@ -390,6 +390,14 @@ router.get('/export', async (req, res) => {
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
// 收集所有工单的自定义字段 key,确保导出列完整
|
||||
const allCustomKeys = new Set();
|
||||
tickets.forEach(ticket => {
|
||||
if (ticket.metadata && typeof ticket.metadata === 'object') {
|
||||
Object.keys(ticket.metadata).forEach(key => allCustomKeys.add(key));
|
||||
}
|
||||
});
|
||||
|
||||
const exportData = tickets.map(ticket => {
|
||||
const item = {};
|
||||
TICKET_EXPORT_FIELDS.forEach(({ fieldName, displayName }) => {
|
||||
@@ -418,12 +426,11 @@ router.get('/export', async (req, res) => {
|
||||
item[displayName] = value !== null && value !== undefined ? String(value) : '';
|
||||
});
|
||||
|
||||
if (ticket.metadata && typeof ticket.metadata === 'object') {
|
||||
Object.entries(ticket.metadata).forEach(([key, val]) => {
|
||||
const customDisplayName = key;
|
||||
item[customDisplayName] = val !== null && val !== undefined ? String(val) : '';
|
||||
});
|
||||
}
|
||||
// 填充所有自定义字段(缺失的填空字符串,保证每行列数一致)
|
||||
allCustomKeys.forEach(key => {
|
||||
const val = ticket.metadata && ticket.metadata[key];
|
||||
item[key] = val !== null && val !== undefined ? String(val) : '';
|
||||
});
|
||||
|
||||
return item;
|
||||
});
|
||||
@@ -857,6 +864,10 @@ router.post('/:ticketId/evaluate', async (req, res) => {
|
||||
return res.status(404).json({ error: '工单不存在' });
|
||||
}
|
||||
|
||||
if (ticket.status !== 'completed') {
|
||||
return res.status(400).json({ error: '只有已完成的工单才能评价' });
|
||||
}
|
||||
|
||||
await ticket.update({ evaluation, evaluationRating });
|
||||
|
||||
await TicketOperationRecord.create({
|
||||
|
||||
@@ -433,6 +433,14 @@ router.put('/:userId/password', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 权限检查:只能重置自己的密码,或管理员重置他人密码
|
||||
if (req.user.userId !== user.userId && req.user.role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '无权重置其他用户的密码',
|
||||
});
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < PASSWORD_MIN_LENGTH) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
|
||||
+12
-4
@@ -93,10 +93,18 @@ async function syncBusinessModels() {
|
||||
const DeviceBusiness = require('./models/DeviceBusiness');
|
||||
const Warehouse = require('./models/Warehouse');
|
||||
|
||||
await Business.sync({ alter: true });
|
||||
await DeviceBusiness.sync({ alter: true });
|
||||
await Warehouse.sync({ alter: true });
|
||||
console.log('业务/设备关联/库房模型同步完成');
|
||||
// 仅在开发环境使用 sync({ alter: true }),生产环境应使用 migrations
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
await Business.sync({ alter: true });
|
||||
await DeviceBusiness.sync({ alter: true });
|
||||
await Warehouse.sync({ alter: true });
|
||||
console.log('业务/设备关联/库房模型同步完成(alter mode)');
|
||||
} else {
|
||||
await Business.sync();
|
||||
await DeviceBusiness.sync();
|
||||
await Warehouse.sync();
|
||||
console.log('业务/设备关联/库房模型同步完成(safe mode)');
|
||||
}
|
||||
}
|
||||
|
||||
async function initDefaultSystemSettings() {
|
||||
|
||||
@@ -75,7 +75,12 @@ api.interceptors.response.use(
|
||||
if (!currentPath.startsWith('/login')) {
|
||||
secureStorage.remove(TOKEN_KEY);
|
||||
secureStorage.remove('user');
|
||||
window.location.href = '/login';
|
||||
// 使用 React Router 导航而非强制刷新,保留 SPA 状态
|
||||
if (window.__navigate) {
|
||||
window.__navigate('/login');
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,11 +62,16 @@ const Controls = ({ rack, onControlsReady }) => {
|
||||
}
|
||||
}, [controlsRef.current, camera, initialCameraPosition, fixedTarget, onControlsReady]);
|
||||
|
||||
const prevTargetRef = React.useRef(null);
|
||||
|
||||
useFrame(() => {
|
||||
if (controlsRef.current) {
|
||||
// 强制保持 target 在机柜中轴线
|
||||
controlsRef.current.target.copy(fixedTarget);
|
||||
controlsRef.current.update();
|
||||
// 仅在 target 变化时更新,避免每帧执行矩阵计算
|
||||
if (prevTargetRef.current !== fixedTarget) {
|
||||
controlsRef.current.target.copy(fixedTarget);
|
||||
controlsRef.current.update();
|
||||
prevTargetRef.current = fixedTarget;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import react from '@vitejs/plugin-react';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -37,14 +35,6 @@ const port = getFrontendPort();
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
Components({
|
||||
resolvers: [
|
||||
AntDesignVueResolver({
|
||||
importStyle: false,
|
||||
}),
|
||||
],
|
||||
dts: false,
|
||||
}),
|
||||
],
|
||||
assetsInclude: ['**/*.hdr', '**/*.woff2'],
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user