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:
zhang96110
2026-04-02 03:10:06 +00:00
parent 7f1df28858
commit dc20bdba1f
12 changed files with 82 additions and 36 deletions
+14 -1
View File
@@ -304,7 +304,20 @@ router.post('/batch', async (req, res) => {
router.put('/:portId', async (req, res) => { router.put('/:portId', async (req, res) => {
try { 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 }, where: { portId: req.params.portId },
}); });
+9 -3
View File
@@ -520,7 +520,9 @@ router.post('/import-preview', async (req, res) => {
router.get('/', validateQuery(queryDeviceSchema), async (req, res) => { router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
try { 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; const offset = (page - 1) * pageSize;
// 构建查询条件 // 构建查询条件
@@ -1078,8 +1080,12 @@ router.post('/import', async (req, res) => {
fs.mkdirSync(path.join(__dirname, '../temp')); fs.mkdirSync(path.join(__dirname, '../temp'));
} }
// 保存上传的文件 // 保存上传的文件(使用 path.basename 防止路径遍历)
const filePath = path.join(__dirname, '../temp', csvFile.name); 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); await csvFile.mv(filePath);
// 读取并解析CSV文件(GBK编码) // 读取并解析CSV文件(GBK编码)
+3 -3
View File
@@ -150,9 +150,9 @@ router.put('/plans/:planId', async (req, res) => {
type: type || plan.type, type: type || plan.type,
description: description !== undefined ? description : plan.description, description: description !== undefined ? description : plan.description,
scheduledDate: scheduledDate ? new Date(scheduledDate) : plan.scheduledDate, scheduledDate: scheduledDate ? new Date(scheduledDate) : plan.scheduledDate,
targetRooms: targetRooms || plan.targetRooms, targetRooms: Array.isArray(targetRooms) ? targetRooms : plan.targetRooms,
targetRacks: targetRacks || plan.targetRacks, targetRacks: Array.isArray(targetRacks) ? targetRacks : plan.targetRacks,
status: status || plan.status, status: status !== undefined ? status : plan.status,
}); });
res.json(plan); res.json(plan);
+3 -3
View File
@@ -183,11 +183,11 @@ router.get('/statistics', authMiddleware, async (req, res) => {
OperationLog.findAll({ OperationLog.findAll({
where, where,
attributes: [ attributes: [
[sequelize.fn('DATE', sequelize.col('createdAt')), 'date'], [sequelize.literal("strftime('%Y-%m-%d', \"createdAt\")"), 'date'],
[sequelize.fn('COUNT', '*'), 'count'], [sequelize.fn('COUNT', '*'), 'count'],
], ],
group: [sequelize.fn('DATE', sequelize.col('createdAt'))], group: [sequelize.literal("strftime('%Y-%m-%d', \"createdAt\")")],
order: [[sequelize.fn('DATE', sequelize.col('createdAt')), 'DESC']], order: [[sequelize.literal("strftime('%Y-%m-%d', \"createdAt\")"), 'DESC']],
limit: 30, limit: 30,
}), }),
]); ]);
+1 -1
View File
@@ -130,7 +130,7 @@ router.get('/', async (req, res) => {
} }
const onlineRate = const onlineRate =
totalDevices > 0 ? (((totalDevices - faultDevices) / totalDevices) * 100).toFixed(1) : 100; totalDevices > 0 ? ((runningDevices / totalDevices) * 100).toFixed(1) : 100;
const totalRooms = rooms.length; const totalRooms = rooms.length;
+1 -1
View File
@@ -350,7 +350,7 @@ router.put('/:key', async (req, res) => {
return res.status(400).json({ error: '值必须是有效的数字' }); return res.status(400).json({ error: '值必须是有效的数字' });
} }
} else if (setting.settingType === 'boolean') { } else if (setting.settingType === 'boolean') {
parsedValue = Boolean(value); parsedValue = value === true || value === 'true' || value === '1' || value === 1;
} }
await setting.update({ await setting.update({
+16 -5
View File
@@ -390,6 +390,14 @@ router.get('/export', async (req, res) => {
order: [['createdAt', 'DESC']], 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 exportData = tickets.map(ticket => {
const item = {}; const item = {};
TICKET_EXPORT_FIELDS.forEach(({ fieldName, displayName }) => { TICKET_EXPORT_FIELDS.forEach(({ fieldName, displayName }) => {
@@ -418,12 +426,11 @@ router.get('/export', async (req, res) => {
item[displayName] = value !== null && value !== undefined ? String(value) : ''; item[displayName] = value !== null && value !== undefined ? String(value) : '';
}); });
if (ticket.metadata && typeof ticket.metadata === 'object') { // 填充所有自定义字段(缺失的填空字符串,保证每行列数一致)
Object.entries(ticket.metadata).forEach(([key, val]) => { allCustomKeys.forEach(key => {
const customDisplayName = key; const val = ticket.metadata && ticket.metadata[key];
item[customDisplayName] = val !== null && val !== undefined ? String(val) : ''; item[key] = val !== null && val !== undefined ? String(val) : '';
}); });
}
return item; return item;
}); });
@@ -857,6 +864,10 @@ router.post('/:ticketId/evaluate', async (req, res) => {
return res.status(404).json({ error: '工单不存在' }); return res.status(404).json({ error: '工单不存在' });
} }
if (ticket.status !== 'completed') {
return res.status(400).json({ error: '只有已完成的工单才能评价' });
}
await ticket.update({ evaluation, evaluationRating }); await ticket.update({ evaluation, evaluationRating });
await TicketOperationRecord.create({ await TicketOperationRecord.create({
+8
View File
@@ -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) { if (!newPassword || newPassword.length < PASSWORD_MIN_LENGTH) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
+9 -1
View File
@@ -93,10 +93,18 @@ async function syncBusinessModels() {
const DeviceBusiness = require('./models/DeviceBusiness'); const DeviceBusiness = require('./models/DeviceBusiness');
const Warehouse = require('./models/Warehouse'); const Warehouse = require('./models/Warehouse');
// 仅在开发环境使用 sync({ alter: true }),生产环境应使用 migrations
if (process.env.NODE_ENV !== 'production') {
await Business.sync({ alter: true }); await Business.sync({ alter: true });
await DeviceBusiness.sync({ alter: true }); await DeviceBusiness.sync({ alter: true });
await Warehouse.sync({ alter: true }); await Warehouse.sync({ alter: true });
console.log('业务/设备关联/库房模型同步完成'); console.log('业务/设备关联/库房模型同步完成alter mode');
} else {
await Business.sync();
await DeviceBusiness.sync();
await Warehouse.sync();
console.log('业务/设备关联/库房模型同步完成(safe mode');
}
} }
async function initDefaultSystemSettings() { async function initDefaultSystemSettings() {
+5
View File
@@ -75,9 +75,14 @@ api.interceptors.response.use(
if (!currentPath.startsWith('/login')) { if (!currentPath.startsWith('/login')) {
secureStorage.remove(TOKEN_KEY); secureStorage.remove(TOKEN_KEY);
secureStorage.remove('user'); secureStorage.remove('user');
// 使用 React Router 导航而非强制刷新,保留 SPA 状态
if (window.__navigate) {
window.__navigate('/login');
} else {
window.location.href = '/login'; window.location.href = '/login';
} }
} }
}
return Promise.reject(data.message || '请求失败'); return Promise.reject(data.message || '请求失败');
} }
+6 -1
View File
@@ -62,11 +62,16 @@ const Controls = ({ rack, onControlsReady }) => {
} }
}, [controlsRef.current, camera, initialCameraPosition, fixedTarget, onControlsReady]); }, [controlsRef.current, camera, initialCameraPosition, fixedTarget, onControlsReady]);
const prevTargetRef = React.useRef(null);
useFrame(() => { useFrame(() => {
if (controlsRef.current) { if (controlsRef.current) {
// 强制保持 target 在机柜中轴线 // 仅在 target 变化时更新,避免每帧执行矩阵计算
if (prevTargetRef.current !== fixedTarget) {
controlsRef.current.target.copy(fixedTarget); controlsRef.current.target.copy(fixedTarget);
controlsRef.current.update(); controlsRef.current.update();
prevTargetRef.current = fixedTarget;
}
} }
}); });
-10
View File
@@ -3,8 +3,6 @@ import react from '@vitejs/plugin-react';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; 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 __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -37,14 +35,6 @@ const port = getFrontendPort();
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
Components({
resolvers: [
AntDesignVueResolver({
importStyle: false,
}),
],
dts: false,
}),
], ],
assetsInclude: ['**/*.hdr', '**/*.woff2'], assetsInclude: ['**/*.hdr', '**/*.woff2'],
server: { server: {