feat: 实现ID生成器并重构模型ID生成逻辑

refactor: 统一ID生成方式,使用新的idGenerator模块
refactor: 重构模型ID生成逻辑,允许空ID并在创建前自动生成
fix(roomSchema): 使机房ID字段变为可选并更新前端表单验证
style: 格式化代码并优化导入语句
test: 添加操作日志集成测试文件
docs: 添加错误处理模块文档
This commit is contained in:
zhang1106
2026-04-02 10:34:13 +08:00
parent ea67951dd4
commit 7f1df28858
28 changed files with 451 additions and 91 deletions
+9 -1
View File
@@ -1,6 +1,7 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
const Device = require('./Device'); const Device = require('./Device');
const { generateId } = require('../utils/idGenerator');
const Cable = sequelize.define( const Cable = sequelize.define(
'Cable', 'Cable',
@@ -8,7 +9,7 @@ const Cable = sequelize.define(
cableId: { cableId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
unique: true, unique: true,
}, },
sourceDeviceId: { sourceDeviceId: {
@@ -90,6 +91,13 @@ const Cable = sequelize.define(
{ fields: ['sourceDeviceId', 'targetDeviceId'] }, { fields: ['sourceDeviceId', 'targetDeviceId'] },
{ fields: ['cableLabel'] }, { fields: ['cableLabel'] },
], ],
hooks: {
beforeCreate: (cable) => {
if (!cable.cableId) {
cable.cableId = generateId({ prefix: 'CBL' });
}
},
},
} }
); );
+9 -1
View File
@@ -1,5 +1,6 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
const { generateId } = require('../utils/idGenerator');
const Consumable = sequelize.define( const Consumable = sequelize.define(
'Consumable', 'Consumable',
@@ -7,7 +8,7 @@ const Consumable = sequelize.define(
consumableId: { consumableId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
}, },
name: { name: {
type: DataTypes.STRING, type: DataTypes.STRING,
@@ -79,6 +80,13 @@ const Consumable = sequelize.define(
{ fields: ['category', 'status'] }, { fields: ['category', 'status'] },
{ fields: ['updatedAt'] }, { fields: ['updatedAt'] },
], ],
hooks: {
beforeCreate: (consumable) => {
if (!consumable.consumableId) {
consumable.consumableId = generateId({ prefix: 'CON' });
}
},
},
} }
); );
+9 -1
View File
@@ -1,5 +1,6 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
const { generateId } = require('../utils/idGenerator');
const Rack = require('./Rack'); const Rack = require('./Rack');
const Warehouse = require('./Warehouse'); const Warehouse = require('./Warehouse');
@@ -9,7 +10,7 @@ const Device = sequelize.define(
deviceId: { deviceId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
unique: true, unique: true,
}, },
name: { name: {
@@ -107,6 +108,13 @@ const Device = sequelize.define(
{ fields: ['rackId', 'position'] }, { fields: ['rackId', 'position'] },
{ fields: ['rackId', 'position', 'isIdle'] }, { fields: ['rackId', 'position', 'isIdle'] },
], ],
hooks: {
beforeCreate: (device) => {
if (!device.deviceId) {
device.deviceId = generateId({ prefix: 'DEV' });
}
},
},
} }
); );
+9 -1
View File
@@ -1,5 +1,6 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
const { generateId } = require('../utils/idGenerator');
const Room = require('./Room'); const Room = require('./Room');
const Rack = sequelize.define( const Rack = sequelize.define(
@@ -8,7 +9,7 @@ const Rack = sequelize.define(
rackId: { rackId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
unique: true, unique: true,
}, },
name: { name: {
@@ -45,6 +46,13 @@ const Rack = sequelize.define(
tableName: 'racks', tableName: 'racks',
timestamps: true, timestamps: true,
indexes: [{ fields: ['roomId'] }, { fields: ['status'] }, { fields: ['roomId', 'status'] }], indexes: [{ fields: ['roomId'] }, { fields: ['status'] }, { fields: ['roomId', 'status'] }],
hooks: {
beforeCreate: (rack) => {
if (!rack.rackId) {
rack.rackId = generateId({ prefix: 'RCK' });
}
},
},
} }
); );
+9 -1
View File
@@ -1,5 +1,6 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
const { generateId } = require('../utils/idGenerator');
const Role = sequelize.define( const Role = sequelize.define(
'Role', 'Role',
@@ -7,7 +8,7 @@ const Role = sequelize.define(
roleId: { roleId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
}, },
roleName: { roleName: {
type: DataTypes.STRING, type: DataTypes.STRING,
@@ -38,6 +39,13 @@ const Role = sequelize.define(
{ {
tableName: 'roles', tableName: 'roles',
timestamps: true, timestamps: true,
hooks: {
beforeCreate: (role) => {
if (!role.roleId) {
role.roleId = generateId({ prefix: 'ROLE' });
}
},
},
} }
); );
+40 -1
View File
@@ -1,13 +1,45 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
async function generateRoomSequenceId() {
const RoomModel = require('./Room');
const prefix = 'ROOM';
const paddingLength = 6;
try {
const latestRoom = await RoomModel.findOne({
attributes: ['roomId'],
where: {
roomId: {
[require('sequelize').Op.like]: `${prefix}%`,
},
},
order: [['roomId', 'DESC']],
});
let nextNumber = 1;
if (latestRoom && latestRoom.roomId) {
const numStr = latestRoom.roomId.replace(prefix, '');
const currentNum = parseInt(numStr, 10);
if (!isNaN(currentNum)) {
nextNumber = currentNum + 1;
}
}
return `${prefix}${nextNumber.toString().padStart(paddingLength, '0')}`;
} catch (error) {
const fallbackId = `${prefix}${Date.now().toString().slice(-6)}`;
return fallbackId;
}
}
const Room = sequelize.define( const Room = sequelize.define(
'Room', 'Room',
{ {
roomId: { roomId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
unique: true, unique: true,
}, },
name: { name: {
@@ -38,6 +70,13 @@ const Room = sequelize.define(
tableName: 'rooms', tableName: 'rooms',
timestamps: true, timestamps: true,
indexes: [{ fields: ['status'] }, { fields: ['name'] }], indexes: [{ fields: ['status'] }, { fields: ['name'] }],
hooks: {
beforeCreate: async (room) => {
if (!room.roomId) {
room.roomId = await generateRoomSequenceId();
}
},
},
} }
); );
+9 -1
View File
@@ -2,6 +2,7 @@ const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
const User = require('./User'); const User = require('./User');
const Device = require('./Device'); const Device = require('./Device');
const { generateId } = require('../utils/idGenerator');
const Ticket = sequelize.define( const Ticket = sequelize.define(
'Ticket', 'Ticket',
@@ -9,7 +10,7 @@ const Ticket = sequelize.define(
ticketId: { ticketId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
unique: true, unique: true,
}, },
title: { title: {
@@ -128,6 +129,13 @@ const Ticket = sequelize.define(
{ fields: ['assigneeId'] }, { fields: ['assigneeId'] },
{ fields: ['createdAt'] }, { fields: ['createdAt'] },
], ],
hooks: {
beforeCreate: (ticket) => {
if (!ticket.ticketId) {
ticket.ticketId = generateId({ prefix: 'TKT' });
}
},
},
} }
); );
+9 -1
View File
@@ -1,5 +1,6 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
const { generateId } = require('../utils/idGenerator');
const User = sequelize.define( const User = sequelize.define(
'User', 'User',
@@ -7,7 +8,7 @@ const User = sequelize.define(
userId: { userId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
}, },
username: { username: {
type: DataTypes.STRING, type: DataTypes.STRING,
@@ -62,6 +63,13 @@ const User = sequelize.define(
tableName: 'users', tableName: 'users',
timestamps: true, timestamps: true,
indexes: [{ fields: ['status'] }, { fields: ['username'] }, { fields: ['email'] }], indexes: [{ fields: ['status'] }, { fields: ['username'] }, { fields: ['email'] }],
hooks: {
beforeCreate: (user) => {
if (!user.userId) {
user.userId = generateId({ prefix: 'USR' });
}
},
},
} }
); );
+9 -1
View File
@@ -1,5 +1,6 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const { sequelize } = require('../db'); const { sequelize } = require('../db');
const { generateId } = require('../utils/idGenerator');
const Warehouse = sequelize.define( const Warehouse = sequelize.define(
'Warehouse', 'Warehouse',
@@ -7,7 +8,7 @@ const Warehouse = sequelize.define(
warehouseId: { warehouseId: {
type: DataTypes.STRING, type: DataTypes.STRING,
primaryKey: true, primaryKey: true,
allowNull: false, allowNull: true,
unique: true, unique: true,
}, },
name: { name: {
@@ -36,6 +37,13 @@ const Warehouse = sequelize.define(
tableName: 'warehouses', tableName: 'warehouses',
timestamps: true, timestamps: true,
indexes: [{ fields: ['status'] }, { fields: ['name'] }], indexes: [{ fields: ['status'] }, { fields: ['name'] }],
hooks: {
beforeCreate: (warehouse) => {
if (!warehouse.warehouseId) {
warehouse.warehouseId = generateId({ prefix: 'WH' });
}
},
},
} }
); );
+2 -5
View File
@@ -11,13 +11,10 @@ const {
USERNAME_MIN_LENGTH, USERNAME_MIN_LENGTH,
USERNAME_MAX_LENGTH, USERNAME_MAX_LENGTH,
} = require('../config'); } = require('../config');
const { generateId } = require('../utils/idGenerator');
const router = express.Router(); const router = express.Router();
const generateId = () => {
return 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
};
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
try { try {
const { username, password, email, phone, realName } = req.body; const { username, password, email, phone, realName } = req.body;
@@ -57,7 +54,7 @@ router.post('/register', async (req, res) => {
const isFirstUser = userCount === 0; const isFirstUser = userCount === 0;
const user = await User.create({ const user = await User.create({
userId: generateId(), userId: generateId({ prefix: 'USR' }),
username, username,
password: hashedPassword, password: hashedPassword,
email, email,
+32 -10
View File
@@ -5,6 +5,7 @@ const { sequelize } = require('../db');
const Consumable = require('../models/Consumable'); const Consumable = require('../models/Consumable');
const ConsumableLog = require('../models/ConsumableLog'); const ConsumableLog = require('../models/ConsumableLog');
const { importJobManager } = require('../utils/importJobManager'); const { importJobManager } = require('../utils/importJobManager');
const { generateId } = require('../utils/idGenerator');
const SUPPORTED_FIELDS = [ const SUPPORTED_FIELDS = [
'consumableId', 'consumableId',
@@ -54,7 +55,10 @@ const parseSnList = snStr => {
if (!snStr) return []; if (!snStr) return [];
if (Array.isArray(snStr)) return snStr; if (Array.isArray(snStr)) return snStr;
if (typeof snStr === 'string') { if (typeof snStr === 'string') {
return snStr.split(/[,;\n]/).map(s => s.trim()).filter(Boolean); return snStr
.split(/[,;\n]/)
.map(s => s.trim())
.filter(Boolean);
} }
return []; return [];
}; };
@@ -108,8 +112,7 @@ router.post('/consumables/background', async (req, res) => {
} }
} }
const consumableId = const consumableId = mappedItem.consumableId || generateId({ prefix: 'CON' });
mappedItem.consumableId || mappedItem.name + `_${Date.now()}`;
const name = mappedItem.name; const name = mappedItem.name;
const category = mappedItem.category; const category = mappedItem.category;
@@ -128,9 +131,7 @@ router.post('/consumables/background', async (req, res) => {
category, category,
unit: mappedItem.unit || '个', unit: mappedItem.unit || '个',
currentStock: currentStock:
snList.length > 0 snList.length > 0 ? snList.length : parseInt(mappedItem.currentStock) || 0,
? snList.length
: parseInt(mappedItem.currentStock) || 0,
minStock: parseInt(mappedItem.minStock) || 10, minStock: parseInt(mappedItem.minStock) || 10,
maxStock: parseInt(mappedItem.maxStock) || 0, maxStock: parseInt(mappedItem.maxStock) || 0,
unitPrice: parseFloat(mappedItem.unitPrice) || 0, unitPrice: parseFloat(mappedItem.unitPrice) || 0,
@@ -210,7 +211,13 @@ router.post('/consumables/background', async (req, res) => {
{ transaction } { transaction }
); );
importJobManager.incrementProgress(jobId, existingConsumable && mode === 'update' ? 0 : 1, 0, 0, existingConsumable ? 1 : 0); importJobManager.incrementProgress(
jobId,
existingConsumable && mode === 'update' ? 0 : 1,
0,
0,
existingConsumable ? 1 : 0
);
} catch (error) { } catch (error) {
results.failed++; results.failed++;
results.errors.push(`${rowNumber} 行: ${error.message}`); results.errors.push(`${rowNumber} 行: ${error.message}`);
@@ -294,13 +301,28 @@ router.get('/consumables/field-mappings', async (req, res) => {
{ source: '单位', target: 'unit', required: false, description: '计量单位,默认"个"' }, { source: '单位', target: 'unit', required: false, description: '计量单位,默认"个"' },
{ source: '当前库存', target: 'currentStock', required: false, description: '当前库存数量' }, { source: '当前库存', target: 'currentStock', required: false, description: '当前库存数量' },
{ source: '最小库存', target: 'minStock', required: false, description: '安全库存预警值' }, { source: '最小库存', target: 'minStock', required: false, description: '安全库存预警值' },
{ source: '最大库存', target: 'maxStock', required: false, description: '最大库存限制,0表示无限制' }, {
source: '最大库存',
target: 'maxStock',
required: false,
description: '最大库存限制,0表示无限制',
},
{ source: '单价', target: 'unitPrice', required: false, description: '耗材单价' }, { source: '单价', target: 'unitPrice', required: false, description: '耗材单价' },
{ source: '供应商', target: 'supplier', required: false, description: '供应商名称' }, { source: '供应商', target: 'supplier', required: false, description: '供应商名称' },
{ source: '存放位置', target: 'location', required: false, description: '仓库内存放位置' }, { source: '存放位置', target: 'location', required: false, description: '仓库内存放位置' },
{ source: '描述', target: 'description', required: false, description: '耗材详细描述' }, { source: '描述', target: 'description', required: false, description: '耗材详细描述' },
{ source: 'SN序列号', target: 'snList', required: false, description: '序列号列表,用逗号分隔' }, {
{ source: '状态', target: 'status', required: false, description: '状态:active启用,inactive停用' }, source: 'SN序列号',
target: 'snList',
required: false,
description: '序列号列表,用逗号分隔',
},
{
source: '状态',
target: 'status',
required: false,
description: '状态:active启用,inactive停用',
},
]; ];
const systemFields = [ const systemFields = [
+1 -10
View File
@@ -471,16 +471,7 @@ router.post('/quick-inout', async (req, res) => {
while (attempt < RETRY.MAX_RETRIES) { while (attempt < RETRY.MAX_RETRIES) {
const transaction = await sequelize.transaction(); const transaction = await sequelize.transaction();
try { try {
const { const { consumableId, type, quantity, operator, reason, notes, snList, deviceId } = req.body;
consumableId,
type,
quantity,
operator,
reason,
notes,
snList,
deviceId,
} = req.body;
const consumable = await Consumable.findByPk(consumableId, { transaction }); const consumable = await Consumable.findByPk(consumableId, { transaction });
if (!consumable) { if (!consumable) {
+4 -1
View File
@@ -171,7 +171,10 @@ router.post('/batch', async (req, res) => {
let device; let device;
if (portData.deviceSn) { if (portData.deviceSn) {
device = await Device.findOne({ where: { serialNumber: portData.deviceSn }, transaction }); device = await Device.findOne({
where: { serialNumber: portData.deviceSn },
transaction,
});
if (!device) { if (!device) {
throw new Error(`设备SN ${portData.deviceSn} 不存在`); throw new Error(`设备SN ${portData.deviceSn} 不存在`);
} }
+2 -2
View File
@@ -728,12 +728,12 @@ async function generateDeviceId() {
}, },
}, },
attributes: [ attributes: [
[sequelize.fn('MAX', sequelize.literal("CAST(SUBSTR(deviceId, 4) AS INTEGER)")), 'maxNum'], [sequelize.fn('MAX', sequelize.literal('CAST(SUBSTR(deviceId, 4) AS INTEGER)')), 'maxNum'],
], ],
raw: true, raw: true,
}); });
const maxNumber = (result && result.maxNum) ? parseInt(result.maxNum, 10) : 0; const maxNumber = result && result.maxNum ? parseInt(result.maxNum, 10) : 0;
const newNumber = maxNumber + 1; const newNumber = maxNumber + 1;
return `DEV${String(newNumber).padStart(3, '0')}`; return `DEV${String(newNumber).padStart(3, '0')}`;
} }
+4 -7
View File
@@ -12,6 +12,7 @@ const User = require('../models/User');
const PendingDevice = require('../models/PendingDevice'); const PendingDevice = require('../models/PendingDevice');
const { authMiddleware, authorize } = require('../middleware/auth'); const { authMiddleware, authorize } = require('../middleware/auth');
const { PAGINATION } = require('../config'); const { PAGINATION } = require('../config');
const { generateId } = require('../utils/idGenerator');
InventoryTask.belongsTo(InventoryPlan, { foreignKey: 'planId', as: 'Plan' }); InventoryTask.belongsTo(InventoryPlan, { foreignKey: 'planId', as: 'Plan' });
InventoryPlan.hasMany(InventoryTask, { foreignKey: 'planId', as: 'Tasks' }); InventoryPlan.hasMany(InventoryTask, { foreignKey: 'planId', as: 'Tasks' });
@@ -23,20 +24,16 @@ InventoryPlan.hasMany(InventoryRecord, { foreignKey: 'planId', as: 'Records' });
InventoryRecord.belongsTo(Device, { foreignKey: 'deviceId', as: 'Device' }); InventoryRecord.belongsTo(Device, { foreignKey: 'deviceId', as: 'Device' });
InventoryRecord.belongsTo(User, { foreignKey: 'checkedBy', as: 'Checker' }); InventoryRecord.belongsTo(User, { foreignKey: 'checkedBy', as: 'Checker' });
function generateId(prefix) {
return `${prefix}${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
}
function generatePlanId() { function generatePlanId() {
return `PLAN${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`; return generateId({ prefix: 'PLAN' });
} }
function generateTaskId() { function generateTaskId() {
return `TASK${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`; return generateId({ prefix: 'TASK' });
} }
function generateRecordId() { function generateRecordId() {
return `REC${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`; return generateId({ prefix: 'REC' });
} }
router.use(authMiddleware); router.use(authMiddleware);
+4 -1
View File
@@ -281,7 +281,10 @@ router.post('/batch', async (req, res) => {
let device; let device;
if (cardData.deviceSn) { if (cardData.deviceSn) {
device = await Device.findOne({ where: { serialNumber: cardData.deviceSn }, transaction }); device = await Device.findOne({
where: { serialNumber: cardData.deviceSn },
transaction,
});
if (!device) { if (!device) {
throw new Error(`设备SN ${cardData.deviceSn} 不存在`); throw new Error(`设备SN ${cardData.deviceSn} 不存在`);
} }
+2 -5
View File
@@ -5,13 +5,10 @@ const UserRole = require('../models/UserRole');
const User = require('../models/User'); const User = require('../models/User');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
const { logRoleOperation } = require('../utils/operationLogger'); const { logRoleOperation } = require('../utils/operationLogger');
const { generateId } = require('../utils/idGenerator');
const router = express.Router(); const router = express.Router();
const generateId = () => {
return 'role_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
};
const { Op } = require('sequelize'); const { Op } = require('sequelize');
router.get('/', authMiddleware, async (req, res) => { router.get('/', authMiddleware, async (req, res) => {
@@ -129,7 +126,7 @@ router.post('/', authMiddleware, async (req, res) => {
} }
const role = await Role.create({ const role = await Role.create({
roleId: generateId(), roleId: generateId({ prefix: 'ROLE' }),
roleName, roleName,
roleCode, roleCode,
description, description,
+39 -9
View File
@@ -338,7 +338,15 @@ const TICKET_EXPORT_FIELDS = [
router.get('/export', async (req, res) => { router.get('/export', async (req, res) => {
try { try {
const { keyword, status, priority, faultCategory, deviceId, format = 'csv', ticketIds } = req.query; const {
keyword,
status,
priority,
faultCategory,
deviceId,
format = 'csv',
ticketIds,
} = req.query;
const where = {}; const where = {};
@@ -391,9 +399,19 @@ router.get('/export', async (req, res) => {
const priorityMap = { low: '低', medium: '中', high: '高', urgent: '紧急' }; const priorityMap = { low: '低', medium: '中', high: '高', urgent: '紧急' };
value = priorityMap[value] || value; value = priorityMap[value] || value;
} else if (fieldName === 'status') { } else if (fieldName === 'status') {
const statusMap = { pending: '待处理', in_progress: '处理中', completed: '已完成', closed: '已关闭' }; const statusMap = {
pending: '待处理',
in_progress: '处理中',
completed: '已完成',
closed: '已关闭',
};
value = statusMap[value] || value; value = statusMap[value] || value;
} else if (fieldName === 'expectedCompletionDate' || fieldName === 'completionDate' || fieldName === 'createdAt' || fieldName === 'updatedAt') { } else if (
fieldName === 'expectedCompletionDate' ||
fieldName === 'completionDate' ||
fieldName === 'createdAt' ||
fieldName === 'updatedAt'
) {
value = value ? new Date(value).toLocaleString('zh-CN') : ''; value = value ? new Date(value).toLocaleString('zh-CN') : '';
} }
@@ -411,7 +429,8 @@ router.get('/export', async (req, res) => {
}); });
if (format === 'json') { if (format === 'json') {
return res.setHeader('Content-Type', 'application/json; charset=utf-8') return res
.setHeader('Content-Type', 'application/json; charset=utf-8')
.setHeader('Content-Disposition', `attachment; filename=tickets_${Date.now()}.json`) .setHeader('Content-Disposition', `attachment; filename=tickets_${Date.now()}.json`)
.json({ success: true, data: exportData, total: exportData.length }); .json({ success: true, data: exportData, total: exportData.length });
} }
@@ -422,7 +441,10 @@ router.get('/export', async (req, res) => {
XLSX.utils.book_append_sheet(workbook, worksheet, '工单数据'); XLSX.utils.book_append_sheet(workbook, worksheet, '工单数据');
const xlsxBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' }); const xlsxBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
);
res.setHeader('Content-Disposition', `attachment; filename=tickets_${Date.now()}.xlsx`); res.setHeader('Content-Disposition', `attachment; filename=tickets_${Date.now()}.xlsx`);
return res.send(xlsxBuffer); return res.send(xlsxBuffer);
} }
@@ -554,7 +576,7 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: '请选择设备或手动输入设备信息' }); return res.status(400).json({ error: '请选择设备或手动输入设备信息' });
} }
const ticketId = `TKT${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`; const ticketId = generateId({ prefix: 'TKT' });
const ticket = await Ticket.create({ const ticket = await Ticket.create({
ticketId, ticketId,
@@ -607,9 +629,17 @@ router.put('/:ticketId', async (req, res) => {
// 白名单过滤:只允许更新安全字段,防止覆盖 ticketId/createdAt 等关键字段 // 白名单过滤:只允许更新安全字段,防止覆盖 ticketId/createdAt 等关键字段
const ALLOWED_UPDATE_FIELDS = [ const ALLOWED_UPDATE_FIELDS = [
'title', 'description', 'category', 'priority', 'location', 'title',
'contactPerson', 'contactPhone', 'contactEmail', 'description',
'expectedDate', 'attachments', 'customFields', 'category',
'priority',
'location',
'contactPerson',
'contactPhone',
'contactEmail',
'expectedDate',
'attachments',
'customFields',
]; ];
const updateData = {}; const updateData = {};
ALLOWED_UPDATE_FIELDS.forEach(field => { ALLOWED_UPDATE_FIELDS.forEach(field => {
+7 -5
View File
@@ -109,7 +109,8 @@ const migrations = [
}, },
{ {
name: '耗材日志设备关联', name: '耗材日志设备关联',
description: '为 consumable_logs 表添加 deviceId、deviceName、rackId、rackName、roomId、roomName 字段', description:
'为 consumable_logs 表添加 deviceId、deviceName、rackId、rackName、roomId、roomName 字段',
migrate: migrateConsumableLogDeviceAssociation, migrate: migrateConsumableLogDeviceAssociation,
}, },
]; ];
@@ -761,9 +762,7 @@ async function migrateDevicePositionIndexes() {
const existingIndexes = await sequelize.query(`SHOW INDEX FROM ${tableName}`, { const existingIndexes = await sequelize.query(`SHOW INDEX FROM ${tableName}`, {
type: sequelize.QueryTypes.SELECT, type: sequelize.QueryTypes.SELECT,
}); });
const indexExists = existingIndexes.some( const indexExists = existingIndexes.some(existing => existing.Key_name === idx.name);
existing => existing.Key_name === idx.name
);
if (indexExists) { if (indexExists) {
console.log(' → 索引已存在,跳过'); console.log(' → 索引已存在,跳过');
@@ -780,7 +779,10 @@ async function migrateDevicePositionIndexes() {
console.log(' ✓ 索引创建成功'); console.log(' ✓ 索引创建成功');
} }
} catch (error) { } catch (error) {
if (error.message.includes('already exists') || error.message.includes('Duplicate key name')) { if (
error.message.includes('already exists') ||
error.message.includes('Duplicate key name')
) {
console.log(' → 索引已存在,跳过'); console.log(' → 索引已存在,跳过');
} else { } else {
throw error; throw error;
+6 -2
View File
@@ -33,9 +33,13 @@ async function migrateCableFields() {
for (const column of newColumns) { for (const column of newColumns) {
if (!existingColumns.includes(column.name)) { if (!existingColumns.includes(column.name)) {
if (dbDialect === 'sqlite') { if (dbDialect === 'sqlite') {
await sequelize.query(`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.type}`); await sequelize.query(
`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.type}`
);
} else { } else {
await sequelize.query(`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.type}`); await sequelize.query(
`ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.type}`
);
} }
console.log(` ✓ 添加字段: ${column.name}`); console.log(` ✓ 添加字段: ${column.name}`);
} else { } else {
+9 -6
View File
@@ -8,6 +8,7 @@ const cors = require('cors');
const fileUpload = require('express-fileupload'); const fileUpload = require('express-fileupload');
const { sequelize } = require('./db'); const { sequelize } = require('./db');
const { FILE_UPLOAD } = require('./config'); const { FILE_UPLOAD } = require('./config');
const { generateId } = require('./utils/idGenerator');
const app = express(); const app = express();
const PORT = process.env.PORT || 8000; const PORT = process.env.PORT || 8000;
@@ -175,7 +176,7 @@ async function initFaultCategories() {
for (const cat of defaultCategories) { for (const cat of defaultCategories) {
const existing = await FaultCategory.findOne({ where: { name: cat.name } }); const existing = await FaultCategory.findOne({ where: { name: cat.name } });
if (!existing) { if (!existing) {
const categoryId = `CAT${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).substr(2, 4).toUpperCase()}`; const categoryId = generateId({ prefix: 'CAT' });
await FaultCategory.create({ await FaultCategory.create({
categoryId, categoryId,
...cat, ...cat,
@@ -226,14 +227,16 @@ const { specs, customCSS } = require('./swagger');
const { authMiddleware } = require('./middleware/auth'); const { authMiddleware } = require('./middleware/auth');
const loadRoutes = require('./utils/routeLoader'); const loadRoutes = require('./utils/routeLoader');
initializeApp().then(() => { initializeApp()
.then(() => {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`); console.log(`服务器运行在 http://localhost:${PORT}`);
}); });
}).catch(err => { })
.catch(err => {
console.error('应用初始化失败:', err); console.error('应用初始化失败:', err);
process.exit(1); process.exit(1);
}); });
const PUBLIC_PATHS = [ const PUBLIC_PATHS = [
'/auth', '/auth',
@@ -244,11 +247,11 @@ const PUBLIC_PATHS = [
'/system-settings/system/info', '/system-settings/system/info',
]; ];
const isPublicPath = (path) => { const isPublicPath = path => {
if (path === '' || path === '/') { if (path === '' || path === '/') {
return true; return true;
} }
return PUBLIC_PATHS.some((publicPath) => path === publicPath || path.startsWith(publicPath + '/')); return PUBLIC_PATHS.some(publicPath => path === publicPath || path.startsWith(publicPath + '/'));
}; };
app.use('/api', (req, res, next) => { app.use('/api', (req, res, next) => {
+137
View File
@@ -0,0 +1,137 @@
class AppError extends Error {
constructor(code, message, statusCode = 500, details = null) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.details = details;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
const ERROR_CODES = {
BAD_REQUEST: 'BAD_REQUEST',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_ERROR: 'INTERNAL_ERROR',
DATABASE_ERROR: 'DATABASE_ERROR',
NETWORK_ERROR: 'NETWORK_ERROR',
};
const STATUS_CODE_MAP = {
400: ERROR_CODES.BAD_REQUEST,
401: ERROR_CODES.UNAUTHORIZED,
403: ERROR_CODES.FORBIDDEN,
404: ERROR_CODES.NOT_FOUND,
409: ERROR_CODES.CONFLICT,
500: ERROR_CODES.INTERNAL_ERROR,
};
const FRIENDLY_MESSAGES = {
[ERROR_CODES.BAD_REQUEST]: '请求参数错误,请检查输入',
[ERROR_CODES.UNAUTHORIZED]: '未授权访问,请先登录',
[ERROR_CODES.FORBIDDEN]: '无权执行此操作',
[ERROR_CODES.NOT_FOUND]: '请求的资源不存在',
[ERROR_CODES.CONFLICT]: '数据冲突,请刷新后重试',
[ERROR_CODES.VALIDATION_ERROR]: '数据验证失败',
[ERROR_CODES.INTERNAL_ERROR]: '服务器内部错误,请稍后重试',
[ERROR_CODES.DATABASE_ERROR]: '数据库操作失败',
[ERROR_CODES.NETWORK_ERROR]: '网络连接错误',
};
function logError(error, req = null) {
const timestamp = new Date().toISOString();
const logData = {
timestamp,
message: error.message,
code: error.code,
statusCode: error.statusCode,
stack: error.stack,
details: error.details,
userAgent: req?.headers?.['user-agent'],
ip: req?.ip,
path: req?.path,
method: req?.method,
};
console.error('=== ERROR LOG ===');
console.error(JSON.stringify(logData, null, 2));
}
function buildErrorResponse(error) {
const code = error.code || STATUS_CODE_MAP[error.statusCode] || ERROR_CODES.INTERNAL_ERROR;
const message =
error.message || FRIENDLY_MESSAGES[code] || FRIENDLY_MESSAGES[ERROR_CODES.INTERNAL_ERROR];
const response = {
success: false,
error: {
code,
message,
},
};
if (error.details) {
response.error.details = error.details;
}
return response;
}
function createError(code, message, statusCode = 500, details = null) {
return new AppError(code, message, statusCode, details);
}
function sendErrorResponse(res, error, req = null) {
logError(error, req);
let statusCode = 500;
let appError = error;
if (!(error instanceof AppError)) {
if (error.name === 'SequelizeValidationError') {
statusCode = 400;
const details = error.errors.map(err => ({
field: err.path,
message: err.message,
}));
appError = createError(
ERROR_CODES.VALIDATION_ERROR,
FRIENDLY_MESSAGES[ERROR_CODES.VALIDATION_ERROR],
statusCode,
details
);
} else if (error.name === 'SequelizeUniqueConstraintError') {
statusCode = 409;
appError = createError(ERROR_CODES.CONFLICT, '数据已存在', statusCode);
} else {
statusCode = error.statusCode || 500;
appError = createError(
STATUS_CODE_MAP[statusCode] || ERROR_CODES.INTERNAL_ERROR,
FRIENDLY_MESSAGES[STATUS_CODE_MAP[statusCode] || ERROR_CODES.INTERNAL_ERROR],
statusCode,
process.env.NODE_ENV === 'development' ? error.stack : null
);
}
}
statusCode = appError.statusCode || statusCode;
res.status(statusCode).json(buildErrorResponse(appError));
}
function errorHandlerMiddleware(err, req, res, next) {
sendErrorResponse(res, err, req);
}
module.exports = {
AppError,
ERROR_CODES,
createError,
logError,
buildErrorResponse,
sendErrorResponse,
errorHandlerMiddleware,
};
+71
View File
@@ -0,0 +1,71 @@
const DEFAULT_CONFIG = {
prefix: '',
timestampBase: 36,
timestampUpperCase: true,
randomLength: 4,
randomBase: 36,
randomUpperCase: true,
};
function generateId(config = {}) {
const {
prefix,
timestampBase,
timestampUpperCase,
randomLength,
randomBase,
randomUpperCase,
} = { ...DEFAULT_CONFIG, ...config };
const timestamp = Date.now().toString(timestampBase);
const formattedTimestamp = timestampUpperCase ? timestamp.toUpperCase() : timestamp;
let randomStr = '';
for (let i = 0; i < randomLength; i++) {
randomStr += Math.floor(Math.random() * randomBase).toString(randomBase);
}
const formattedRandom = randomUpperCase ? randomStr.toUpperCase() : randomStr;
return `${prefix}${formattedTimestamp}${formattedRandom}`;
}
function generateTicketId() {
return generateId({ prefix: 'TKT', randomLength: 4 });
}
function generateRoleId() {
return generateId({
prefix: 'role_',
timestampUpperCase: false,
randomLength: 9,
randomUpperCase: false
});
}
function generatePlanId() {
return generateId({ prefix: 'PLAN', randomLength: 4 });
}
function generateTaskId() {
return generateId({ prefix: 'TASK', randomLength: 4 });
}
function generateRecordId() {
return generateId({ prefix: 'REC', randomLength: 6 });
}
function generatePendingId() {
return generateId({ prefix: 'PEND', randomLength: 4 });
}
module.exports = {
generateId,
generateTicketId,
generateRoleId,
generatePlanId,
generateTaskId,
generateRecordId,
generatePendingId,
DEFAULT_CONFIG,
};
+6 -5
View File
@@ -127,9 +127,7 @@ class ImportJobManager extends EventEmitter {
error: job.error, error: job.error,
startTime: job.startTime, startTime: job.startTime,
endTime: job.endTime, endTime: job.endTime,
elapsedTime: job.startTime elapsedTime: job.startTime ? Date.now() - new Date(job.startTime).getTime() : null,
? Date.now() - new Date(job.startTime).getTime()
: null,
}; };
return progress; return progress;
@@ -164,9 +162,12 @@ class ImportJobManager extends EventEmitter {
const importJobManager = new ImportJobManager(); const importJobManager = new ImportJobManager();
setInterval(() => { setInterval(
() => {
importJobManager.cleanupOldJobs(); importJobManager.cleanupOldJobs();
}, 60 * 60 * 1000); },
60 * 60 * 1000
);
module.exports = { module.exports = {
importJobManager, importJobManager,
+1 -1
View File
@@ -4,7 +4,7 @@ const routesConfig = require('../config/routes');
function loadRoutes(app) { function loadRoutes(app) {
const routesDir = path.join(__dirname, '../routes'); const routesDir = path.join(__dirname, '../routes');
routesConfig.forEach((routeConfig) => { routesConfig.forEach(routeConfig => {
try { try {
const routerPath = path.join(routesDir, routeConfig.file); const routerPath = path.join(routesDir, routeConfig.file);
const router = require(routerPath); const router = require(routerPath);
+1 -2
View File
@@ -3,14 +3,13 @@ const Joi = require('joi');
// 创建机房验证Schema // 创建机房验证Schema
const createRoomSchema = Joi.object({ const createRoomSchema = Joi.object({
roomId: Joi.string() roomId: Joi.string()
.required() .optional()
.max(50) .max(50)
.pattern(/^[a-zA-Z0-9_-]+$/) .pattern(/^[a-zA-Z0-9_-]+$/)
.messages({ .messages({
'string.empty': '机房ID不能为空', 'string.empty': '机房ID不能为空',
'string.max': '机房ID不能超过50个字符', 'string.max': '机房ID不能超过50个字符',
'string.pattern.base': '机房ID只能包含字母、数字、下划线和横线', 'string.pattern.base': '机房ID只能包含字母、数字、下划线和横线',
'any.required': '机房ID是必填字段',
}), }),
name: Joi.string().required().max(100).messages({ name: Joi.string().required().max(100).messages({
+2 -2
View File
@@ -778,9 +778,9 @@ function RoomManagement() {
<Form.Item <Form.Item
name="roomId" name="roomId"
label="机房ID" label="机房ID"
rules={[{ required: true, message: '请输入机房ID' }]} rules={[]}
> >
<Input placeholder="请输入机房ID" style={{ borderRadius: '8px' }} /> <Input placeholder="不填写则自动生成" style={{ borderRadius: '8px' }} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>