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) => {
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 },
});
+9 -3
View File
@@ -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编码)
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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,
}),
]);
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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({
+16 -5
View File
@@ -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({
+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) {
return res.status(400).json({
success: false,
+9 -1
View File
@@ -93,10 +93,18 @@ async function syncBusinessModels() {
const DeviceBusiness = require('./models/DeviceBusiness');
const Warehouse = require('./models/Warehouse');
// 仅在开发环境使用 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('业务/设备关联/库房模型同步完成');
console.log('业务/设备关联/库房模型同步完成alter mode');
} else {
await Business.sync();
await DeviceBusiness.sync();
await Warehouse.sync();
console.log('业务/设备关联/库房模型同步完成(safe mode');
}
}
async function initDefaultSystemSettings() {
+5
View File
@@ -75,9 +75,14 @@ api.interceptors.response.use(
if (!currentPath.startsWith('/login')) {
secureStorage.remove(TOKEN_KEY);
secureStorage.remove('user');
// 使用 React Router 导航而非强制刷新,保留 SPA 状态
if (window.__navigate) {
window.__navigate('/login');
} else {
window.location.href = '/login';
}
}
}
return Promise.reject(data.message || '请求失败');
}
+6 -1
View File
@@ -62,11 +62,16 @@ const Controls = ({ rack, onControlsReady }) => {
}
}, [controlsRef.current, camera, initialCameraPosition, fixedTarget, onControlsReady]);
const prevTargetRef = React.useRef(null);
useFrame(() => {
if (controlsRef.current) {
// 强制保持 target 在机柜中轴线
// 仅在 target 变化时更新,避免每帧执行矩阵计算
if (prevTargetRef.current !== fixedTarget) {
controlsRef.current.target.copy(fixedTarget);
controlsRef.current.update();
prevTargetRef.current = fixedTarget;
}
}
});
-10
View File
@@ -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: {