feat: 添加请求参数验证中间件和设计令牌Hook
feat(validation): 为机房、机柜和设备路由添加Joi验证中间件 feat(hooks): 创建useDesignTokens Hook集中管理主题配置 feat(models): 为耗材模型添加乐观锁version字段和updatedAt索引 feat(3d): 增强3D场景和设备模型视觉效果与交互 refactor: 移除数据备份相关功能并优化代码结构 fix: 修复前端安全日志和密码加密工具 chore: 更新依赖并添加axios和joi库 docs: 更新注释和文档说明 style: 改进代码格式和命名一致性
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 请求参数验证中间件
|
||||
* 使用Joi进行参数校验
|
||||
*/
|
||||
|
||||
const validate = (schema, source = 'body') => {
|
||||
return (req, res, next) => {
|
||||
const data = source === 'query' ? req.query : req.body;
|
||||
|
||||
const { error, value } = schema.validate(data, {
|
||||
abortEarly: false, // 返回所有错误
|
||||
stripUnknown: true, // 移除未定义的字段
|
||||
allowUnknown: source === 'query' // 查询参数允许未知字段
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const errorMessages = error.details.map(detail => ({
|
||||
field: detail.path.join('.'),
|
||||
message: detail.message
|
||||
}));
|
||||
|
||||
return res.status(400).json({
|
||||
error: '参数验证失败',
|
||||
details: errorMessages
|
||||
});
|
||||
}
|
||||
|
||||
// 将验证后的值替换到请求对象
|
||||
if (source === 'query') {
|
||||
req.query = value;
|
||||
} else {
|
||||
req.body = value;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// 验证查询参数
|
||||
const validateQuery = (schema) => validate(schema, 'query');
|
||||
|
||||
// 验证请求体
|
||||
const validateBody = (schema) => validate(schema, 'body');
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
validateQuery,
|
||||
validateBody
|
||||
};
|
||||
@@ -54,6 +54,12 @@ const Consumable = sequelize.define('Consumable', {
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '乐观锁版本号'
|
||||
}
|
||||
}, {
|
||||
tableName: 'consumables',
|
||||
@@ -61,7 +67,8 @@ const Consumable = sequelize.define('Consumable', {
|
||||
indexes: [
|
||||
{ fields: ['category'] },
|
||||
{ fields: ['status'] },
|
||||
{ fields: ['category', 'status'] }
|
||||
{ fields: ['category', 'status'] },
|
||||
{ fields: ['updatedAt'] }
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
Generated
+111
-6
@@ -8,6 +8,7 @@
|
||||
"name": "idc-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
@@ -17,6 +18,7 @@
|
||||
"express": "^4.18.2",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"joi": "^18.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.16.0",
|
||||
"sequelize": "^6.32.1",
|
||||
@@ -677,6 +679,54 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@hapi/address": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
|
||||
"integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/formula": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz",
|
||||
"integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
|
||||
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/pinpoint": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
|
||||
"integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/tlds": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.4.tgz",
|
||||
"integrity": "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/topo": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz",
|
||||
"integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -1379,6 +1429,12 @@
|
||||
"text-hex": "1.0.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@@ -2013,7 +2069,6 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
@@ -2025,6 +2080,17 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "30.2.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
|
||||
@@ -2726,7 +2792,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -2935,7 +3000,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -3182,7 +3246,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3458,6 +3521,26 @@
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -3492,7 +3575,6 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
@@ -3795,7 +3877,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -5067,6 +5148,24 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.0.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz",
|
||||
"integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/address": "^5.1.1",
|
||||
"@hapi/formula": "^3.0.2",
|
||||
"@hapi/hoek": "^11.0.7",
|
||||
"@hapi/pinpoint": "^2.0.1",
|
||||
"@hapi/tlds": "^1.1.1",
|
||||
"@hapi/topo": "^6.0.2",
|
||||
"@standard-schema/spec": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -6281,6 +6380,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"drop-indexes": "node create_indexes.js drop"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
@@ -19,6 +20,7 @@
|
||||
"express": "^4.18.2",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"joi": "^18.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.16.0",
|
||||
"sequelize": "^6.32.1",
|
||||
|
||||
+256
-162
@@ -250,187 +250,281 @@ router.get('/inout/records', async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/quick-inout', async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const { consumableId, type, quantity, operator, reason, notes } = req.body;
|
||||
|
||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||
if (!consumable) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ error: '耗材不存在' });
|
||||
}
|
||||
|
||||
const previousStock = parseFloat(consumable.currentStock);
|
||||
let newStock;
|
||||
|
||||
if (type === 'in') {
|
||||
newStock = previousStock + parseFloat(quantity);
|
||||
} else if (type === 'out') {
|
||||
newStock = previousStock - parseFloat(quantity);
|
||||
if (newStock < 0) {
|
||||
const MAX_RETRIES = 3;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < MAX_RETRIES) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const { consumableId, type, quantity, operator, reason, notes } = req.body;
|
||||
|
||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||
if (!consumable) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '库存不足' });
|
||||
return res.status(404).json({ error: '耗材不存在' });
|
||||
}
|
||||
} else {
|
||||
|
||||
const previousStock = parseFloat(consumable.currentStock);
|
||||
let newStock;
|
||||
|
||||
if (type === 'in') {
|
||||
newStock = previousStock + parseFloat(quantity);
|
||||
} else if (type === 'out') {
|
||||
newStock = previousStock - parseFloat(quantity);
|
||||
if (newStock < 0) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '库存不足' });
|
||||
}
|
||||
} else {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '操作类型无效' });
|
||||
}
|
||||
|
||||
const [affectedRows] = await Consumable.update(
|
||||
{
|
||||
currentStock: newStock,
|
||||
version: sequelize.literal('version + 1')
|
||||
},
|
||||
{
|
||||
where: {
|
||||
consumableId,
|
||||
version: consumable.version
|
||||
},
|
||||
transaction
|
||||
}
|
||||
);
|
||||
|
||||
if (affectedRows === 0) {
|
||||
await transaction.rollback();
|
||||
attempt++;
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
return res.status(409).json({ error: '并发冲突,请稍后重试' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = await ConsumableRecord.create({
|
||||
consumableId,
|
||||
type,
|
||||
quantity,
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason,
|
||||
notes
|
||||
}, { transaction });
|
||||
|
||||
await ConsumableLog.create({
|
||||
consumableId,
|
||||
consumableName: consumable.name,
|
||||
operationType: type,
|
||||
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason,
|
||||
notes
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
message: '操作成功',
|
||||
record,
|
||||
consumable: await Consumable.findByPk(consumableId)
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '操作类型无效' });
|
||||
if (attempt >= MAX_RETRIES - 1) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
|
||||
await consumable.update({ currentStock: newStock }, { transaction });
|
||||
|
||||
const record = await ConsumableRecord.create({
|
||||
consumableId,
|
||||
type,
|
||||
quantity,
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason,
|
||||
notes
|
||||
}, { transaction });
|
||||
|
||||
await ConsumableLog.create({
|
||||
consumableId,
|
||||
consumableName: consumable.name,
|
||||
operationType: type,
|
||||
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason,
|
||||
notes
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
message: '操作成功',
|
||||
record,
|
||||
consumable: await consumable.reload()
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/inout', async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body;
|
||||
|
||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||
if (!consumable) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ error: '耗材不存在' });
|
||||
}
|
||||
|
||||
const previousStock = parseFloat(consumable.currentStock);
|
||||
let newStock;
|
||||
|
||||
if (type === 'in') {
|
||||
newStock = previousStock + parseFloat(quantity);
|
||||
} else {
|
||||
newStock = previousStock - parseFloat(quantity);
|
||||
if (newStock < 0) {
|
||||
const MAX_RETRIES = 3;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < MAX_RETRIES) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body;
|
||||
|
||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||
if (!consumable) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '库存不足' });
|
||||
return res.status(404).json({ error: '耗材不存在' });
|
||||
}
|
||||
|
||||
const previousStock = parseFloat(consumable.currentStock);
|
||||
let newStock;
|
||||
|
||||
if (type === 'in') {
|
||||
newStock = previousStock + parseFloat(quantity);
|
||||
} else {
|
||||
newStock = previousStock - parseFloat(quantity);
|
||||
if (newStock < 0) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '库存不足' });
|
||||
}
|
||||
}
|
||||
|
||||
const [affectedRows] = await Consumable.update(
|
||||
{
|
||||
currentStock: newStock,
|
||||
version: sequelize.literal('version + 1')
|
||||
},
|
||||
{
|
||||
where: {
|
||||
consumableId,
|
||||
version: consumable.version
|
||||
},
|
||||
transaction
|
||||
}
|
||||
);
|
||||
|
||||
if (affectedRows === 0) {
|
||||
await transaction.rollback();
|
||||
attempt++;
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
return res.status(409).json({ error: '并发冲突,请稍后重试' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = await ConsumableRecord.create({
|
||||
consumableId,
|
||||
type,
|
||||
quantity,
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason,
|
||||
recipient,
|
||||
notes
|
||||
}, { transaction });
|
||||
|
||||
await ConsumableLog.create({
|
||||
consumableId,
|
||||
consumableName: consumable.name,
|
||||
operationType: type,
|
||||
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason,
|
||||
notes
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
message: '操作成功',
|
||||
record,
|
||||
consumable: await Consumable.findByPk(consumableId)
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (attempt >= MAX_RETRIES - 1) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
|
||||
await consumable.update({ currentStock: newStock }, { transaction });
|
||||
|
||||
const record = await ConsumableRecord.create({
|
||||
consumableId,
|
||||
type,
|
||||
quantity,
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason,
|
||||
recipient,
|
||||
notes
|
||||
}, { transaction });
|
||||
|
||||
await ConsumableLog.create({
|
||||
consumableId,
|
||||
consumableName: consumable.name,
|
||||
operationType: type,
|
||||
quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity),
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason,
|
||||
notes
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
message: '操作成功',
|
||||
record,
|
||||
consumable: await consumable.reload()
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/adjust', async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const { consumableId, adjustType, quantity, operator, reason, notes } = req.body;
|
||||
|
||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||
if (!consumable) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ error: '耗材不存在' });
|
||||
}
|
||||
|
||||
const previousStock = parseFloat(consumable.currentStock);
|
||||
let newStock;
|
||||
|
||||
if (adjustType === 'add') {
|
||||
newStock = previousStock + parseFloat(quantity);
|
||||
} else if (adjustType === 'subtract') {
|
||||
newStock = previousStock - parseFloat(quantity);
|
||||
if (newStock < 0) {
|
||||
const MAX_RETRIES = 3;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < MAX_RETRIES) {
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const { consumableId, adjustType, quantity, operator, reason, notes } = req.body;
|
||||
|
||||
const consumable = await Consumable.findByPk(consumableId, { transaction });
|
||||
if (!consumable) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '调整后库存不能为负' });
|
||||
return res.status(404).json({ error: '耗材不存在' });
|
||||
}
|
||||
} else if (adjustType === 'set') {
|
||||
newStock = parseFloat(quantity);
|
||||
} else {
|
||||
|
||||
const previousStock = parseFloat(consumable.currentStock);
|
||||
let newStock;
|
||||
|
||||
if (adjustType === 'add') {
|
||||
newStock = previousStock + parseFloat(quantity);
|
||||
} else if (adjustType === 'subtract') {
|
||||
newStock = previousStock - parseFloat(quantity);
|
||||
if (newStock < 0) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '调整后库存不能为负' });
|
||||
}
|
||||
} else if (adjustType === 'set') {
|
||||
newStock = parseFloat(quantity);
|
||||
if (newStock < 0) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '库存不能设置为负数' });
|
||||
}
|
||||
} else {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '调整类型无效' });
|
||||
}
|
||||
|
||||
const [affectedRows] = await Consumable.update(
|
||||
{
|
||||
currentStock: newStock,
|
||||
version: sequelize.literal('version + 1')
|
||||
},
|
||||
{
|
||||
where: {
|
||||
consumableId,
|
||||
version: consumable.version
|
||||
},
|
||||
transaction
|
||||
}
|
||||
);
|
||||
|
||||
if (affectedRows === 0) {
|
||||
await transaction.rollback();
|
||||
attempt++;
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
return res.status(409).json({ error: '并发冲突,请稍后重试' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const changeQuantity = newStock - previousStock;
|
||||
|
||||
await ConsumableLog.create({
|
||||
consumableId,
|
||||
consumableName: consumable.name,
|
||||
operationType: 'adjust',
|
||||
quantity: changeQuantity,
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason),
|
||||
notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
message: '调整成功',
|
||||
consumable: await Consumable.findByPk(consumableId)
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ error: '调整类型无效' });
|
||||
if (attempt >= MAX_RETRIES - 1) {
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
|
||||
await consumable.update({ currentStock: newStock }, { transaction });
|
||||
|
||||
const changeQuantity = newStock - previousStock;
|
||||
|
||||
await ConsumableLog.create({
|
||||
consumableId,
|
||||
consumableName: consumable.name,
|
||||
operationType: 'adjust',
|
||||
quantity: changeQuantity,
|
||||
previousStock,
|
||||
currentStock: newStock,
|
||||
operator,
|
||||
reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason),
|
||||
notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
message: '调整成功',
|
||||
consumable: await consumable.reload()
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+15
-18
@@ -14,9 +14,18 @@ const DeviceField = require('../models/DeviceField');
|
||||
const Ticket = require('../models/Ticket');
|
||||
const DevicePort = require('../models/DevicePort'); // Import DevicePort
|
||||
const Cable = require('../models/Cable'); // Import Cable
|
||||
const { validateBody, validateQuery } = require('../middleware/validation');
|
||||
const {
|
||||
createDeviceSchema,
|
||||
updateDeviceSchema,
|
||||
batchDeviceIdsSchema,
|
||||
batchStatusSchema,
|
||||
batchMoveSchema,
|
||||
queryDeviceSchema
|
||||
} = require('../validation/deviceSchema');
|
||||
|
||||
// 获取所有设备(支持搜索和筛选)
|
||||
router.get('/', async (req, res) => {
|
||||
router.get('/', validateQuery(queryDeviceSchema), async (req, res) => {
|
||||
try {
|
||||
const { keyword, status, type, rackId, page = 1, pageSize = 10 } = req.query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
@@ -80,7 +89,7 @@ router.get('/', async (req, res) => {
|
||||
});
|
||||
|
||||
// 创建设备
|
||||
router.post('/', async (req, res) => {
|
||||
router.post('/', validateBody(createDeviceSchema), async (req, res) => {
|
||||
try {
|
||||
const device = await Device.create(req.body);
|
||||
|
||||
@@ -602,7 +611,7 @@ router.post('/import', async (req, res) => {
|
||||
});
|
||||
|
||||
// 批量上线设备
|
||||
router.put('/batch-online', async (req, res) => {
|
||||
router.put('/batch-online', validateBody(batchDeviceIdsSchema), async (req, res) => {
|
||||
try {
|
||||
const { deviceIds } = req.body;
|
||||
|
||||
@@ -626,7 +635,7 @@ router.put('/batch-online', async (req, res) => {
|
||||
});
|
||||
|
||||
// 批量下线设备
|
||||
router.put('/batch-offline', async (req, res) => {
|
||||
router.put('/batch-offline', validateBody(batchDeviceIdsSchema), async (req, res) => {
|
||||
try {
|
||||
const { deviceIds } = req.body;
|
||||
|
||||
@@ -654,8 +663,6 @@ router.put('/batch-status', async (req, res) => {
|
||||
try {
|
||||
const { deviceIds, status } = req.body;
|
||||
|
||||
console.log('批量状态变更请求:', { deviceIds, status });
|
||||
|
||||
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||
return res.status(400).json({ error: '请提供有效的设备ID列表' });
|
||||
}
|
||||
@@ -666,15 +673,11 @@ router.put('/batch-status', async (req, res) => {
|
||||
attributes: ['deviceId']
|
||||
});
|
||||
|
||||
console.log('数据库中找到的设备:', existingDevices.map(d => d.deviceId));
|
||||
console.log('请求的设备ID:', deviceIds);
|
||||
|
||||
// 检查是否有不存在的设备
|
||||
const existingIds = existingDevices.map(d => d.deviceId);
|
||||
const missingIds = deviceIds.filter(id => !existingIds.includes(id));
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
console.log('不存在的设备ID:', missingIds);
|
||||
return res.status(404).json({ error: `设备不存在: ${missingIds.join(', ')}` });
|
||||
}
|
||||
|
||||
@@ -780,7 +783,7 @@ router.get('/:deviceId', async (req, res) => {
|
||||
});
|
||||
|
||||
// 更新设备
|
||||
router.put('/:deviceId', async (req, res) => {
|
||||
router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
|
||||
try {
|
||||
// 获取旧设备信息以更新功率
|
||||
const oldDevice = await Device.findByPk(req.params.deviceId);
|
||||
@@ -843,7 +846,7 @@ router.put('/batch-offline', async (req, res) => {
|
||||
});
|
||||
|
||||
// 批量删除设备
|
||||
router.delete('/batch-delete', async (req, res) => {
|
||||
router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { deviceIds } = req.body;
|
||||
@@ -1045,8 +1048,6 @@ router.put('/batch-status', async (req, res) => {
|
||||
try {
|
||||
const { deviceIds, status } = req.body;
|
||||
|
||||
console.log('批量状态变更请求:', { deviceIds, status });
|
||||
|
||||
if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) {
|
||||
return res.status(400).json({ error: '请提供有效的设备ID列表' });
|
||||
}
|
||||
@@ -1057,15 +1058,11 @@ router.put('/batch-status', async (req, res) => {
|
||||
attributes: ['deviceId']
|
||||
});
|
||||
|
||||
console.log('数据库中找到的设备:', existingDevices.map(d => d.deviceId));
|
||||
console.log('请求的设备ID:', deviceIds);
|
||||
|
||||
// 检查是否有不存在的设备
|
||||
const existingIds = existingDevices.map(d => d.deviceId);
|
||||
const missingIds = deviceIds.filter(id => !existingIds.includes(id));
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
console.log('不存在的设备ID:', missingIds);
|
||||
return res.status(404).json({ error: `设备不存在: ${missingIds.join(', ')}` });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ const Room = require('../models/Room');
|
||||
const XLSX = require('xlsx');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { validateBody, validateQuery } = require('../middleware/validation');
|
||||
const { createRackSchema, updateRackSchema, queryRackSchema } = require('../validation/rackSchema');
|
||||
|
||||
// 获取所有机柜
|
||||
router.get('/', async (req, res) => {
|
||||
@@ -57,7 +59,7 @@ router.get('/:rackId', async (req, res) => {
|
||||
});
|
||||
|
||||
// 创建机柜
|
||||
router.post('/', async (req, res) => {
|
||||
router.post('/', validateBody(createRackSchema), async (req, res) => {
|
||||
try {
|
||||
const rack = await Rack.create(req.body);
|
||||
res.status(201).json(rack);
|
||||
@@ -67,7 +69,7 @@ router.post('/', async (req, res) => {
|
||||
});
|
||||
|
||||
// 更新机柜
|
||||
router.put('/:rackId', async (req, res) => {
|
||||
router.put('/:rackId', validateBody(updateRackSchema), async (req, res) => {
|
||||
try {
|
||||
const [updated] = await Rack.update(req.body, {
|
||||
where: { rackId: req.params.rackId }
|
||||
|
||||
@@ -2,6 +2,8 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const Room = require('../models/Room');
|
||||
const Rack = require('../models/Rack');
|
||||
const { validateBody } = require('../middleware/validation');
|
||||
const { createRoomSchema, updateRoomSchema } = require('../validation/roomSchema');
|
||||
|
||||
// 获取所有机房
|
||||
router.get('/', async (req, res) => {
|
||||
@@ -41,7 +43,7 @@ router.post('/', async (req, res) => {
|
||||
});
|
||||
|
||||
// 更新机房
|
||||
router.put('/:roomId', async (req, res) => {
|
||||
router.put('/:roomId', validateBody(updateRoomSchema), async (req, res) => {
|
||||
try {
|
||||
const [updated] = await Room.update(req.body, {
|
||||
where: { roomId: req.params.roomId }
|
||||
|
||||
@@ -20,19 +20,11 @@ const initDefaultSettings = async () => {
|
||||
// 外观设置
|
||||
{ settingKey: 'primary_color', settingValue: JSON.stringify('#667eea'), settingType: 'string', category: 'appearance', description: '主题主色调', isEditable: true },
|
||||
{ settingKey: 'secondary_color', settingValue: JSON.stringify('#764ba2'), settingType: 'string', category: 'appearance', description: '主题辅助色调', isEditable: true },
|
||||
{ settingKey: 'compact_mode', settingValue: JSON.stringify(false), type: 'boolean', category: 'appearance', description: '紧凑模式', isEditable: true },
|
||||
{ settingKey: 'compact_mode', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'appearance', description: '紧凑模式', isEditable: true },
|
||||
{ settingKey: 'sidebar_collapsed', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'appearance', description: '侧边栏默认折叠', isEditable: true },
|
||||
{ settingKey: 'table_row_height', settingValue: JSON.stringify('default'), settingType: 'string', category: 'appearance', description: '表格行高: small/default/middle/large', isEditable: true },
|
||||
{ settingKey: 'animation_enabled', settingValue: JSON.stringify(true), settingType: 'boolean', category: 'appearance', description: '启用动画效果', isEditable: true },
|
||||
|
||||
// 数据备份设置
|
||||
{ settingKey: 'auto_backup_enabled', settingValue: JSON.stringify(false), settingType: 'boolean', category: 'backup', description: '启用自动备份', isEditable: true },
|
||||
{ settingKey: 'backup_interval', settingValue: JSON.stringify(24), settingType: 'number', category: 'backup', description: '备份间隔(小时)', isEditable: true },
|
||||
{ settingKey: 'backup_retention', settingValue: JSON.stringify(7), settingType: 'number', category: 'backup', description: '备份保留天数', isEditable: true },
|
||||
{ settingKey: 'backup_path', settingValue: JSON.stringify('./backups'), settingType: 'string', category: 'backup', description: '备份存储路径', isEditable: true },
|
||||
{ settingKey: 'last_backup_time', settingValue: JSON.stringify(null), settingType: 'string', category: 'backup', description: '上次备份时间', isEditable: false },
|
||||
{ settingKey: 'backup_count', settingValue: JSON.stringify(0), settingType: 'number', category: 'backup', description: '备份文件数量', isEditable: false },
|
||||
|
||||
// 关于页面
|
||||
{ settingKey: 'app_version', settingValue: JSON.stringify('1.0.0'), settingType: 'string', category: 'about', description: '应用版本', isEditable: false },
|
||||
{ settingKey: 'company_name', settingValue: JSON.stringify(''), settingType: 'string', category: 'about', description: '公司/组织名称', isEditable: true },
|
||||
@@ -241,10 +233,6 @@ router.post('/reset/:key', async (req, res) => {
|
||||
sidebar_collapsed: false,
|
||||
table_row_height: 'default',
|
||||
animation_enabled: true,
|
||||
auto_backup_enabled: false,
|
||||
backup_interval: 24,
|
||||
backup_retention: 7,
|
||||
backup_path: './backups',
|
||||
company_name: '',
|
||||
contact_email: '',
|
||||
contact_phone: '',
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 耗材管理乐观锁迁移脚本
|
||||
* 为 consumables 表添加 version 字段
|
||||
*/
|
||||
|
||||
const { sequelize, dbDialect } = require('../db');
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
console.log('开始执行耗材乐观锁迁移...');
|
||||
console.log('数据库类型:', dbDialect);
|
||||
|
||||
if (dbDialect === 'sqlite') {
|
||||
// SQLite: 检查字段是否存在
|
||||
const tableInfo = await sequelize.query(
|
||||
"PRAGMA table_info(consumables)",
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
const hasVersion = tableInfo.some(col => col.name === 'version');
|
||||
|
||||
if (!hasVersion) {
|
||||
console.log('添加 version 字段...');
|
||||
await sequelize.query(
|
||||
"ALTER TABLE consumables ADD COLUMN version INTEGER DEFAULT 0"
|
||||
);
|
||||
console.log('version 字段添加成功');
|
||||
} else {
|
||||
console.log('version 字段已存在,跳过');
|
||||
}
|
||||
|
||||
// 检查 updatedAt 索引
|
||||
const indexes = await sequelize.query(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='consumables'",
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
const hasUpdatedAtIndex = indexes.some(idx => idx.name === 'consumables_updatedAt');
|
||||
|
||||
if (!hasUpdatedAtIndex) {
|
||||
console.log('添加 updatedAt 索引...');
|
||||
await sequelize.query(
|
||||
"CREATE INDEX consumables_updatedAt ON consumables(updatedAt)"
|
||||
);
|
||||
console.log('updatedAt 索引添加成功');
|
||||
} else {
|
||||
console.log('updatedAt 索引已存在,跳过');
|
||||
}
|
||||
|
||||
} else if (dbDialect === 'mysql') {
|
||||
// MySQL: 检查并添加字段
|
||||
try {
|
||||
console.log('添加 version 字段...');
|
||||
await sequelize.query(
|
||||
"ALTER TABLE consumables ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"
|
||||
);
|
||||
console.log('version 字段添加成功');
|
||||
} catch (err) {
|
||||
if (err.message.includes('Duplicate column')) {
|
||||
console.log('version 字段已存在,跳过');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加索引
|
||||
try {
|
||||
console.log('添加 updatedAt 索引...');
|
||||
await sequelize.query(
|
||||
"CREATE INDEX idx_consumables_updatedAt ON consumables(updatedAt)"
|
||||
);
|
||||
console.log('updatedAt 索引添加成功');
|
||||
} catch (err) {
|
||||
if (err.message.includes('Duplicate key')) {
|
||||
console.log('updatedAt 索引已存在,跳过');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化现有数据的 version 值
|
||||
console.log('初始化现有数据的 version 值...');
|
||||
await sequelize.query(
|
||||
"UPDATE consumables SET version = 0 WHERE version IS NULL"
|
||||
);
|
||||
console.log('version 值初始化完成');
|
||||
|
||||
console.log('迁移完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('迁移失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@@ -0,0 +1,327 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
// 设备类型枚举
|
||||
const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other'];
|
||||
|
||||
// 设备状态枚举
|
||||
const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault'];
|
||||
|
||||
// 创建设备验证Schema
|
||||
const createDeviceSchema = Joi.object({
|
||||
deviceId: Joi.string()
|
||||
.required()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||
.messages({
|
||||
'string.empty': '设备ID不能为空',
|
||||
'string.max': '设备ID不能超过50个字符',
|
||||
'string.pattern.base': '设备ID只能包含字母、数字、下划线和横线',
|
||||
'any.required': '设备ID是必填字段'
|
||||
}),
|
||||
|
||||
name: Joi.string()
|
||||
.required()
|
||||
.max(100)
|
||||
.messages({
|
||||
'string.empty': '设备名称不能为空',
|
||||
'string.max': '设备名称不能超过100个字符',
|
||||
'any.required': '设备名称是必填字段'
|
||||
}),
|
||||
|
||||
type: Joi.string()
|
||||
.valid(...DEVICE_TYPES)
|
||||
.required()
|
||||
.messages({
|
||||
'string.empty': '设备类型不能为空',
|
||||
'any.only': `设备类型必须是以下之一: ${DEVICE_TYPES.join(', ')}`,
|
||||
'any.required': '设备类型是必填字段'
|
||||
}),
|
||||
|
||||
model: Joi.string()
|
||||
.max(100)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '型号不能超过100个字符'
|
||||
}),
|
||||
|
||||
serialNumber: Joi.string()
|
||||
.max(100)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '序列号不能超过100个字符'
|
||||
}),
|
||||
|
||||
rackId: Joi.string()
|
||||
.required()
|
||||
.max(50)
|
||||
.messages({
|
||||
'string.empty': '机柜ID不能为空',
|
||||
'string.max': '机柜ID不能超过50个字符',
|
||||
'any.required': '机柜ID是必填字段'
|
||||
}),
|
||||
|
||||
position: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.required()
|
||||
.messages({
|
||||
'number.base': '位置必须是数字',
|
||||
'number.integer': '位置必须是整数',
|
||||
'number.min': '位置不能小于1',
|
||||
'number.max': '位置不能大于100',
|
||||
'any.required': '位置是必填字段'
|
||||
}),
|
||||
|
||||
height: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.required()
|
||||
.messages({
|
||||
'number.base': '高度必须是数字',
|
||||
'number.integer': '高度必须是整数',
|
||||
'number.min': '高度不能小于1',
|
||||
'number.max': '高度不能大于50',
|
||||
'any.required': '高度是必填字段'
|
||||
}),
|
||||
|
||||
powerConsumption: Joi.number()
|
||||
.min(0)
|
||||
.max(100000)
|
||||
.default(0)
|
||||
.messages({
|
||||
'number.base': '功率必须是数字',
|
||||
'number.min': '功率不能小于0',
|
||||
'number.max': '功率不能超过100000'
|
||||
}),
|
||||
|
||||
ipAddress: Joi.string()
|
||||
.ip({ version: ['ipv4', 'ipv6'] })
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.ip': 'IP地址格式无效'
|
||||
}),
|
||||
|
||||
status: Joi.string()
|
||||
.valid(...DEVICE_STATUS)
|
||||
.default('offline')
|
||||
.messages({
|
||||
'any.only': `状态必须是以下之一: ${DEVICE_STATUS.join(', ')}`
|
||||
}),
|
||||
|
||||
purchaseDate: Joi.date()
|
||||
.allow(null)
|
||||
.messages({
|
||||
'date.base': '购买日期格式无效'
|
||||
}),
|
||||
|
||||
warrantyExpiry: Joi.date()
|
||||
.allow(null)
|
||||
.messages({
|
||||
'date.base': '保修到期日期格式无效'
|
||||
}),
|
||||
|
||||
description: Joi.string()
|
||||
.max(500)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '描述不能超过500个字符'
|
||||
}),
|
||||
|
||||
customFields: Joi.object()
|
||||
.allow(null)
|
||||
}).custom((value, helpers) => {
|
||||
// 验证保修日期必须晚于购买日期
|
||||
if (value.purchaseDate && value.warrantyExpiry) {
|
||||
const purchase = new Date(value.purchaseDate);
|
||||
const warranty = new Date(value.warrantyExpiry);
|
||||
if (warranty <= purchase) {
|
||||
return helpers.error('date.warrantyAfterPurchase');
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}).messages({
|
||||
'date.warrantyAfterPurchase': '保修到期日期必须晚于购买日期'
|
||||
});
|
||||
|
||||
// 更新设备验证Schema(所有字段可选)
|
||||
const updateDeviceSchema = Joi.object({
|
||||
deviceId: Joi.string()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||
.messages({
|
||||
'string.max': '设备ID不能超过50个字符',
|
||||
'string.pattern.base': '设备ID只能包含字母、数字、下划线和横线'
|
||||
}),
|
||||
|
||||
name: Joi.string()
|
||||
.max(100)
|
||||
.messages({
|
||||
'string.max': '设备名称不能超过100个字符'
|
||||
}),
|
||||
|
||||
type: Joi.string()
|
||||
.valid(...DEVICE_TYPES)
|
||||
.messages({
|
||||
'any.only': `设备类型必须是以下之一: ${DEVICE_TYPES.join(', ')}`
|
||||
}),
|
||||
|
||||
model: Joi.string()
|
||||
.max(100)
|
||||
.allow('', null),
|
||||
|
||||
serialNumber: Joi.string()
|
||||
.max(100)
|
||||
.allow('', null),
|
||||
|
||||
rackId: Joi.string()
|
||||
.max(50),
|
||||
|
||||
position: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.messages({
|
||||
'number.base': '位置必须是数字',
|
||||
'number.integer': '位置必须是整数',
|
||||
'number.min': '位置不能小于1',
|
||||
'number.max': '位置不能大于100'
|
||||
}),
|
||||
|
||||
height: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.messages({
|
||||
'number.base': '高度必须是数字',
|
||||
'number.integer': '高度必须是整数',
|
||||
'number.min': '高度不能小于1',
|
||||
'number.max': '高度不能大于50'
|
||||
}),
|
||||
|
||||
powerConsumption: Joi.number()
|
||||
.min(0)
|
||||
.max(100000)
|
||||
.messages({
|
||||
'number.base': '功率必须是数字',
|
||||
'number.min': '功率不能小于0',
|
||||
'number.max': '功率不能超过100000'
|
||||
}),
|
||||
|
||||
ipAddress: Joi.string()
|
||||
.ip({ version: ['ipv4', 'ipv6'] })
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.ip': 'IP地址格式无效'
|
||||
}),
|
||||
|
||||
status: Joi.string()
|
||||
.valid(...DEVICE_STATUS)
|
||||
.messages({
|
||||
'any.only': `状态必须是以下之一: ${DEVICE_STATUS.join(', ')}`
|
||||
}),
|
||||
|
||||
purchaseDate: Joi.date()
|
||||
.allow(null),
|
||||
|
||||
warrantyExpiry: Joi.date()
|
||||
.allow(null),
|
||||
|
||||
description: Joi.string()
|
||||
.max(500)
|
||||
.allow('', null),
|
||||
|
||||
customFields: Joi.object()
|
||||
.allow(null)
|
||||
}).min(1).messages({
|
||||
'object.min': '至少需要提供一个字段进行更新'
|
||||
});
|
||||
|
||||
// 批量操作验证Schema
|
||||
const batchDeviceIdsSchema = Joi.object({
|
||||
deviceIds: Joi.array()
|
||||
.items(Joi.string().required())
|
||||
.min(1)
|
||||
.required()
|
||||
.messages({
|
||||
'array.base': '设备ID列表必须是数组',
|
||||
'array.min': '至少需要提供一个设备ID',
|
||||
'any.required': '设备ID列表是必填字段'
|
||||
})
|
||||
});
|
||||
|
||||
// 批量状态变更验证Schema
|
||||
const batchStatusSchema = Joi.object({
|
||||
deviceIds: Joi.array()
|
||||
.items(Joi.string().required())
|
||||
.min(1)
|
||||
.required()
|
||||
.messages({
|
||||
'array.base': '设备ID列表必须是数组',
|
||||
'array.min': '至少需要提供一个设备ID',
|
||||
'any.required': '设备ID列表是必填字段'
|
||||
}),
|
||||
status: Joi.string()
|
||||
.valid(...DEVICE_STATUS)
|
||||
.required()
|
||||
.messages({
|
||||
'any.only': `状态必须是以下之一: ${DEVICE_STATUS.join(', ')}`,
|
||||
'any.required': '状态是必填字段'
|
||||
})
|
||||
});
|
||||
|
||||
// 批量移动验证Schema
|
||||
const batchMoveSchema = Joi.object({
|
||||
deviceIds: Joi.array()
|
||||
.items(Joi.string().required())
|
||||
.min(1)
|
||||
.required(),
|
||||
targetRackId: Joi.string()
|
||||
.required()
|
||||
.max(50)
|
||||
.messages({
|
||||
'string.empty': '目标机柜ID不能为空',
|
||||
'any.required': '目标机柜ID是必填字段'
|
||||
}),
|
||||
startPosition: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.allow(null)
|
||||
});
|
||||
|
||||
// 查询参数验证Schema
|
||||
const queryDeviceSchema = Joi.object({
|
||||
keyword: Joi.string()
|
||||
.max(100)
|
||||
.allow(''),
|
||||
status: Joi.string()
|
||||
.valid(...DEVICE_STATUS, 'all')
|
||||
.allow(''),
|
||||
type: Joi.string()
|
||||
.valid(...DEVICE_TYPES, 'all')
|
||||
.allow(''),
|
||||
rackId: Joi.string()
|
||||
.max(50)
|
||||
.allow(''),
|
||||
page: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.default(1),
|
||||
pageSize: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(10)
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createDeviceSchema,
|
||||
updateDeviceSchema,
|
||||
batchDeviceIdsSchema,
|
||||
batchStatusSchema,
|
||||
batchMoveSchema,
|
||||
queryDeviceSchema,
|
||||
DEVICE_TYPES,
|
||||
DEVICE_STATUS
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
// 机柜状态枚举
|
||||
const RACK_STATUS = ['active', 'inactive', 'maintenance'];
|
||||
|
||||
// 创建机柜验证Schema
|
||||
const createRackSchema = Joi.object({
|
||||
rackId: Joi.string()
|
||||
.required()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||
.messages({
|
||||
'string.empty': '机柜ID不能为空',
|
||||
'string.max': '机柜ID不能超过50个字符',
|
||||
'string.pattern.base': '机柜ID只能包含字母、数字、下划线和横线',
|
||||
'any.required': '机柜ID是必填字段'
|
||||
}),
|
||||
|
||||
name: Joi.string()
|
||||
.required()
|
||||
.max(100)
|
||||
.messages({
|
||||
'string.empty': '机柜名称不能为空',
|
||||
'string.max': '机柜名称不能超过100个字符',
|
||||
'any.required': '机柜名称是必填字段'
|
||||
}),
|
||||
|
||||
height: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(42)
|
||||
.messages({
|
||||
'number.base': '高度必须是数字',
|
||||
'number.integer': '高度必须是整数',
|
||||
'number.min': '高度不能小于1',
|
||||
'number.max': '高度不能大于100'
|
||||
}),
|
||||
|
||||
maxPower: Joi.number()
|
||||
.min(0)
|
||||
.max(1000000)
|
||||
.default(10000)
|
||||
.messages({
|
||||
'number.base': '最大功率必须是数字',
|
||||
'number.min': '最大功率不能小于0',
|
||||
'number.max': '最大功率不能超过1000000'
|
||||
}),
|
||||
|
||||
currentPower: Joi.number()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.messages({
|
||||
'number.base': '当前功率必须是数字',
|
||||
'number.min': '当前功率不能小于0'
|
||||
}),
|
||||
|
||||
status: Joi.string()
|
||||
.valid(...RACK_STATUS)
|
||||
.default('active')
|
||||
.messages({
|
||||
'any.only': `状态必须是以下之一: ${RACK_STATUS.join(', ')}`
|
||||
}),
|
||||
|
||||
roomId: Joi.string()
|
||||
.required()
|
||||
.max(50)
|
||||
.messages({
|
||||
'string.empty': '机房ID不能为空',
|
||||
'string.max': '机房ID不能超过50个字符',
|
||||
'any.required': '机房ID是必填字段'
|
||||
}),
|
||||
|
||||
description: Joi.string()
|
||||
.max(500)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '描述不能超过500个字符'
|
||||
})
|
||||
});
|
||||
|
||||
// 更新机柜验证Schema
|
||||
const updateRackSchema = Joi.object({
|
||||
rackId: Joi.string()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||
.messages({
|
||||
'string.max': '机柜ID不能超过50个字符',
|
||||
'string.pattern.base': '机柜ID只能包含字母、数字、下划线和横线'
|
||||
}),
|
||||
|
||||
name: Joi.string()
|
||||
.max(100)
|
||||
.messages({
|
||||
'string.max': '机柜名称不能超过100个字符'
|
||||
}),
|
||||
|
||||
height: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.messages({
|
||||
'number.base': '高度必须是数字',
|
||||
'number.integer': '高度必须是整数',
|
||||
'number.min': '高度不能小于1',
|
||||
'number.max': '高度不能大于100'
|
||||
}),
|
||||
|
||||
maxPower: Joi.number()
|
||||
.min(0)
|
||||
.max(1000000)
|
||||
.messages({
|
||||
'number.base': '最大功率必须是数字',
|
||||
'number.min': '最大功率不能小于0',
|
||||
'number.max': '最大功率不能超过1000000'
|
||||
}),
|
||||
|
||||
currentPower: Joi.number()
|
||||
.min(0)
|
||||
.messages({
|
||||
'number.base': '当前功率必须是数字',
|
||||
'number.min': '当前功率不能小于0'
|
||||
}),
|
||||
|
||||
status: Joi.string()
|
||||
.valid(...RACK_STATUS)
|
||||
.messages({
|
||||
'any.only': `状态必须是以下之一: ${RACK_STATUS.join(', ')}`
|
||||
}),
|
||||
|
||||
roomId: Joi.string()
|
||||
.max(50),
|
||||
|
||||
description: Joi.string()
|
||||
.max(500)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '描述不能超过500个字符'
|
||||
})
|
||||
}).min(1).messages({
|
||||
'object.min': '至少需要提供一个字段进行更新'
|
||||
});
|
||||
|
||||
// 查询机柜验证Schema
|
||||
const queryRackSchema = Joi.object({
|
||||
roomId: Joi.string()
|
||||
.max(50)
|
||||
.allow(''),
|
||||
status: Joi.string()
|
||||
.valid(...RACK_STATUS, 'all')
|
||||
.allow(''),
|
||||
keyword: Joi.string()
|
||||
.max(100)
|
||||
.allow(''),
|
||||
page: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.default(1),
|
||||
pageSize: Joi.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(10)
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createRackSchema,
|
||||
updateRackSchema,
|
||||
queryRackSchema,
|
||||
RACK_STATUS
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
// 创建机房验证Schema
|
||||
const createRoomSchema = Joi.object({
|
||||
roomId: Joi.string()
|
||||
.required()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||
.messages({
|
||||
'string.empty': '机房ID不能为空',
|
||||
'string.max': '机房ID不能超过50个字符',
|
||||
'string.pattern.base': '机房ID只能包含字母、数字、下划线和横线',
|
||||
'any.required': '机房ID是必填字段'
|
||||
}),
|
||||
|
||||
name: Joi.string()
|
||||
.required()
|
||||
.max(100)
|
||||
.messages({
|
||||
'string.empty': '机房名称不能为空',
|
||||
'string.max': '机房名称不能超过100个字符',
|
||||
'any.required': '机房名称是必填字段'
|
||||
}),
|
||||
|
||||
location: Joi.string()
|
||||
.max(200)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '位置不能超过200个字符'
|
||||
}),
|
||||
|
||||
area: Joi.number()
|
||||
.min(0)
|
||||
.max(1000000)
|
||||
.allow(null)
|
||||
.messages({
|
||||
'number.base': '面积必须是数字',
|
||||
'number.min': '面积不能小于0',
|
||||
'number.max': '面积不能超过1000000'
|
||||
}),
|
||||
|
||||
capacity: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(10000)
|
||||
.allow(null)
|
||||
.messages({
|
||||
'number.base': '容量必须是数字',
|
||||
'number.integer': '容量必须是整数',
|
||||
'number.min': '容量不能小于0',
|
||||
'number.max': '容量不能超过10000'
|
||||
}),
|
||||
|
||||
description: Joi.string()
|
||||
.max(500)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '描述不能超过500个字符'
|
||||
})
|
||||
});
|
||||
|
||||
// 更新机房验证Schema
|
||||
const updateRoomSchema = Joi.object({
|
||||
roomId: Joi.string()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||
.messages({
|
||||
'string.max': '机房ID不能超过50个字符',
|
||||
'string.pattern.base': '机房ID只能包含字母、数字、下划线和横线'
|
||||
}),
|
||||
|
||||
name: Joi.string()
|
||||
.max(100)
|
||||
.messages({
|
||||
'string.max': '机房名称不能超过100个字符'
|
||||
}),
|
||||
|
||||
location: Joi.string()
|
||||
.max(200)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '位置不能超过200个字符'
|
||||
}),
|
||||
|
||||
area: Joi.number()
|
||||
.min(0)
|
||||
.max(1000000)
|
||||
.allow(null)
|
||||
.messages({
|
||||
'number.base': '面积必须是数字',
|
||||
'number.min': '面积不能小于0',
|
||||
'number.max': '面积不能超过1000000'
|
||||
}),
|
||||
|
||||
capacity: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(10000)
|
||||
.allow(null)
|
||||
.messages({
|
||||
'number.base': '容量必须是数字',
|
||||
'number.integer': '容量必须是整数',
|
||||
'number.min': '容量不能小于0',
|
||||
'number.max': '容量不能超过10000'
|
||||
}),
|
||||
|
||||
description: Joi.string()
|
||||
.max(500)
|
||||
.allow('', null)
|
||||
.messages({
|
||||
'string.max': '描述不能超过500个字符'
|
||||
})
|
||||
}).min(1).messages({
|
||||
'object.min': '至少需要提供一个字段进行更新'
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createRoomSchema,
|
||||
updateRoomSchema
|
||||
};
|
||||
Reference in New Issue
Block a user