diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js index bb0446e..743b39e 100644 --- a/backend/middleware/validation.js +++ b/backend/middleware/validation.js @@ -1,10 +1,16 @@ const logger = require('../utils/logger').module('ValidationMiddleware'); -const validate = (schema, source = 'body') => { +const validate = (schemaOrFn, source = 'body') => { return async (req, res, next) => { const data = source === 'query' ? req.query : req.body; try { + // 支持函数形式的schema(动态schema:异步函数或同步函数返回Joi schema) + let schema = schemaOrFn; + if (typeof schemaOrFn === 'function') { + schema = await schemaOrFn(); + } + let value; if (schema.validate && typeof schema.validate === 'function') { diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 6c6e544..f5918f6 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -16,13 +16,12 @@ const { } = require('../utils/operationLogger'); const { validateBody, validateQuery } = require('../middleware/validation'); const { - createDeviceSchema, - updateDeviceSchema, batchDeviceIdsSchema, batchStatusSchema, batchMoveSchema, queryDeviceSchema, } = require('../validation/deviceSchema'); +const { createDeviceSchema } = require('../validation/dynamicDeviceSchema'); const PREVIEW_COUNT = 20; @@ -736,7 +735,7 @@ async function generateDeviceId() { } // 创建设备 -router.post('/', validateBody(createDeviceSchema), async (req, res) => { +router.post('/', validateBody(() => createDeviceSchema(false)), async (req, res) => { try { const deviceData = { ...req.body }; @@ -2285,7 +2284,7 @@ router.get('/:deviceId/tickets', async (req, res) => { }); // 更新设备 -router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => { +router.put('/:deviceId', validateBody(() => createDeviceSchema(true)), async (req, res) => { try { const oldDevice = await Device.findByPk(req.params.deviceId); if (!oldDevice) { diff --git a/backend/validation/deviceSchema.js b/backend/validation/deviceSchema.js index 55b7ac2..98d6499 100644 --- a/backend/validation/deviceSchema.js +++ b/backend/validation/deviceSchema.js @@ -114,8 +114,6 @@ const queryDeviceSchema = Joi.object({ }); module.exports = { - createDeviceSchema, - updateDeviceSchema, batchDeviceIdsSchema, batchStatusSchema, batchMoveSchema, diff --git a/backend/validation/dynamicDeviceSchema.js b/backend/validation/dynamicDeviceSchema.js new file mode 100644 index 0000000..f47795e --- /dev/null +++ b/backend/validation/dynamicDeviceSchema.js @@ -0,0 +1,146 @@ +/** + * 动态设备字段验证Schema生成器 + * 根据数据库DeviceField表的配置动态生成Joi验证Schema + * 系统核心字段(name/serialNumber/position/height)强制锁定必填 + */ + +const Joi = require('joi'); +const DeviceField = require('../models/DeviceField'); + +const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other']; +const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault', 'idle']; + +// 强制锁定必填的字段(不受字段管理配置影响) +const FORCE_REQUIRED_FIELDS = ['name', 'serialNumber', 'position', 'height']; + +/** + * 根据字段配置生成单个字段的Joi校验器 + * @param {Object} field - DeviceField数据库记录 + * @param {boolean} isUpdate - 是否为更新模式 + * @returns {Joi.AnySchema} 该字段对应的Joi校验器 + */ +function buildFieldValidator(field, isUpdate) { + const { fieldName, required: fieldRequired } = field; + // 系统核心字段强制必填 + const isRequired = FORCE_REQUIRED_FIELDS.includes(fieldName) || fieldRequired; + + let validator; + + switch (fieldName) { + case 'name': + validator = Joi.string().max(100).messages({ + 'string.empty': '设备名称不能为空', + 'string.max': '设备名称不能超过100个字符', + 'any.required': '设备名称是必填字段', + }); + if (isRequired && !isUpdate) validator = validator.required(); + else validator = validator.allow('', null); + break; + + case 'type': + validator = Joi.string().valid(...DEVICE_TYPES).messages({ + 'any.only': `设备类型必须是以下之一: ${DEVICE_TYPES.join(', ')}`, + 'any.required': '设备类型是必填字段', + }); + if (isRequired && !isUpdate) validator = validator.required(); + break; + + case 'serialNumber': + validator = Joi.string().max(100).messages({ + 'string.empty': '序列号不能为空', + 'string.max': '序列号不能超过100个字符', + 'any.required': '序列号是必填字段', + }); + if (isRequired && !isUpdate) validator = validator.required(); + else validator = validator.allow('', null); + break; + + case 'rackId': + validator = Joi.string().allow('', null).max(50); + break; + + case 'position': + validator = Joi.number().integer().min(1).max(100).allow(null); + break; + + case 'height': + validator = Joi.number().integer().min(1).max(50).allow(null); + break; + + case 'powerConsumption': + validator = Joi.number().min(0).max(100000).allow(null); + break; + + case 'status': + validator = Joi.string().valid(...DEVICE_STATUS).default('offline'); + break; + + case 'model': + validator = Joi.string().allow('', null).max(100); + break; + + case 'ipAddress': + validator = Joi.string().allow('', null).max(50); + break; + + case 'description': + validator = Joi.string().allow('', null).max(500); + break; + + case 'purchaseDate': + case 'warrantyExpiry': + validator = Joi.date().allow(null); + break; + + case 'brand': + validator = Joi.string().allow('', null).max(100); + break; + + default: + // 自定义字段走宽松校验 + validator = Joi.any().allow(null); + } + + return validator; +} + +/** + * 动态生成设备创建/更新的Joi Schema + * 每次请求时从DeviceField表读取最新配置 + * @param {boolean} isUpdate - 是否为更新模式 + * @returns {Promise} 动态生成的Joi Schema + */ +async function createDeviceSchema(isUpdate = false) { + // 查询所有字段配置 + const fieldConfigs = await DeviceField.findAll({ + order: [['order', 'ASC']], + }); + + const schemaMap = {}; + + // 根据字段配置动态生成每个字段的校验器 + fieldConfigs.forEach(field => { + schemaMap[field.fieldName] = buildFieldValidator(field, isUpdate); + }); + + // 补充customFields(不在DeviceField表中) + schemaMap.customFields = Joi.object().allow(null); + + let schema = Joi.object(schemaMap); + + // 更新模式要求至少传一个字段 + if (isUpdate) { + schema = schema.min(1).messages({ + 'object.min': '至少需要提供一个字段进行更新', + }); + } + + return schema; +} + +module.exports = { + createDeviceSchema, + DEVICE_TYPES, + DEVICE_STATUS, + FORCE_REQUIRED_FIELDS, +}; diff --git a/docs/superpowers/specs/设备字段必填规则同步设计.md b/docs/superpowers/specs/设备字段必填规则同步设计.md new file mode 100644 index 0000000..aacd121 --- /dev/null +++ b/docs/superpowers/specs/设备字段必填规则同步设计.md @@ -0,0 +1,418 @@ +# 设备字段必填规则与可见性同步设计文档 + +> 版本:1.0.0 | 更新日期:2026-06-12 + +--- + +## 1. 问题概述 + +### 1.1 现状 + +设备字段管理页面(`/api/deviceFields`)允许用户配置每个字段的: +- **是否必填**(`required`) +- **是否可见**(`visible`) + +但在设备管理页面添加/编辑设备时,这些配置**完全不生效**。具体表现为: + +| 层面 | 当前行为 | 预期行为 | +|------|---------|---------| +| **后端 Joi Schema** | 硬编码写死,不查询数据库 | 从 `DeviceField` 表动态读取 `required` 配置 | +| **前端表单渲染** | `rackId/position/height` 跳过字段配置,硬编码必填 | 跟随字段配置,但对强依赖字段做防护 | +| **前端默认常量** | `powerConsumption` 与后端不一致 | 与后端 `initDeviceFields.js` 保持同步 | + +### 1.2 影响范围 + +- 用户在字段管理页面修改配置后感到困惑(修改不生效) +- 无法灵活控制不同场景下的业务校验规则 +- `powerConsumption` 等字段的前后端默认值不一致 + +--- + +## 2. 字段强依赖分析 + +### 2.1 系统字段依赖矩阵 + +| 字段名 | 字段标识 | 系统字段 | 下游依赖场景 | 是否可改必填 | 是否可改可见 | +|--------|---------|---------|-------------|------------|------------| +| 设备名称 | `name` | 是 | 操作日志、工单关联、拓扑图标签、导出文件名 | **否**(强制必填) | **否**(强制可见) | +| 设备类型 | `type` | 是 | 搜索筛选、统计分类、3D可视化类型渲染 | 是 | 是(但筛选功能受限) | +| 序列号 | `serialNumber` | 是 | **设备唯一标识**:端口关联(按SN)、盘点扫码匹配、空闲设备恢复、工单关联、导入去重、搜索定位 | **否**(强制必填) | **否**(强制可见) | +| 所在机柜 | `rackId` | 是 | 3D可视化定位、机柜容量统计、U位冲突检测、机柜功率计算 | 是 | 是 | +| 位置(U) | `position` | 是 | 3D可视化定位、U位冲突检测、机柜容量计算 | **否**(强制必填) | 是 | +| 高度(U) | `height` | 是 | 3D可视化渲染、U位占用计算 | **否**(强制必填) | 是 | +| 状态 | `status` | 是 | 搜索筛选、统计卡片、3D可视化颜色 | 是 | 是(但筛选功能受限) | +| 设备型号 | `model` | 是 | 搜索筛选 | 是 | 是 | +| 功率(W) | `powerConsumption` | 否 | 机柜功率统计、机房功率监控 | 是 | 是 | +| IP地址 | `ipAddress` | 否 | 搜索筛选、端口关联 | 是 | 是 | +| 购买日期 | `purchaseDate` | 否 | 无强依赖 | 是 | 是 | +| 保修到期 | `warrantyExpiry` | 否 | 无强依赖 | 是 | 是 | +| 描述 | `description` | 否 | 无强依赖 | 是 | 是 | +| 品牌 | `brand` | 否 | 无强依赖 | 是 | 是 | + +### 2.2 强依赖字段判定标准 + +满足以下任一条件的字段,标记为**强制锁定字段**: + +1. **唯一标识性**:作为设备在系统中的唯一标识,被其他模块硬引用(如 `serialNumber`) +2. **显示必要性**:无此字段设备无法在其他模块中被识别(如 `name`) +3. **物理定位性**:无此字段设备无法在3D/平面图中定位(如 `position`、`height`) + +--- + +## 3. 修复方案 + +### 3.1 总体架构 + +``` ++------------------+ +------------------+ +------------------+ +| 字段管理页面 | ----> | DeviceField表 | <---- | 启动初始化 | +| (FieldConfig) | PUT | (数据库) | | (initDeviceFields)| ++------------------+ +------------------+ +------------------+ + | + +--------------+--------------+ + | | | + v v v + +-----------+ +-----------+ +-----------+ + | 设备添加 | | 设备导入 | | 设备编辑 | + | 前端表单 | | 后端导入 | | 后端接口 | + | (Device | | 验证 | | (Joi | + | FormModal)| | (已正常) | | 动态Schema)| + +-----------+ +-----------+ +-----------+ + | | + | 读取field.required | 动态查询DeviceField + v v + 表单Item rules Joi Schema校验 + (强制锁定字段额外防护) (强制锁定字段额外防护) +``` + +### 3.2 方案一:后端 Joi Schema 动态化(核心) + +#### 3.2.1 新增文件 + +**`backend/validation/dynamicDeviceSchema.js`** + +```javascript +const Joi = require('joi'); +const DeviceField = require('../models/DeviceField'); + +const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other']; +const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault', 'idle']; + +// 强制锁定字段列表(不受字段管理配置影响) +const FORCE_REQUIRED_FIELDS = ['name', 'serialNumber', 'position', 'height']; + +/** + * 动态生建设备创建Joi Schema + * 从DeviceField表读取字段配置,结合强制锁定字段规则 + * @param {boolean} isUpdate - 是否为更新模式 + * @returns {Promise} + */ +async function createDeviceSchema(isUpdate = false) { + const fieldConfigs = await DeviceField.findAll({ + order: [['order', 'ASC']], + }); + + const schemaMap = {}; + + fieldConfigs.forEach(field => { + let validator; + + // 判断是否强制必填 + const isRequired = FORCE_REQUIRED_FIELDS.includes(field.fieldName) || field.required; + + switch (field.fieldName) { + case 'name': + validator = Joi.string().max(100); + if (isRequired) validator = validator.required(); + else validator = validator.allow('', null); + break; + case 'type': + validator = Joi.string().valid(...DEVICE_TYPES); + if (isRequired) validator = validator.required(); + break; + case 'serialNumber': + validator = Joi.string().max(100); + if (isRequired) validator = validator.required(); + else validator = validator.allow('', null); + break; + case 'rackId': + validator = Joi.string().allow('', null).max(50); + break; + case 'position': + validator = Joi.number().integer().min(1).max(100).allow(null); + break; + case 'height': + validator = Joi.number().integer().min(1).max(50).allow(null); + break; + case 'powerConsumption': + validator = Joi.number().min(0).max(100000).allow(null); + break; + case 'status': + validator = Joi.string().valid(...DEVICE_STATUS).default('offline'); + break; + case 'model': + case 'ipAddress': + case 'description': + validator = Joi.string().allow('', null).max(100); + break; + case 'purchaseDate': + case 'warrantyExpiry': + validator = Joi.date().allow(null); + break; + default: + // 自定义字段走宽松校验 + validator = Joi.any().allow(null); + } + + // 仅在创建模式下(非更新模式)且字段非强制锁定、且数据库配置为required时使用required() + // 更新模式下避免object.min(1)策略与动态required冲突 + schemaMap[field.fieldName] = validator; + }); + + // 补充未在DeviceField表中的字段 + schemaMap.customFields = Joi.object().allow(null); + + let schema = Joi.object(schemaMap); + + if (isUpdate) { + schema = schema.min(1).messages({ + 'object.min': '至少需要提供一个字段进行更新', + }); + } + + return schema; +} + +module.exports = { + createDeviceSchema, + DEVICE_TYPES, + DEVICE_STATUS, + FORCE_REQUIRED_FIELDS, +}; +``` + +#### 3.2.2 修改文件 + +**`backend/routes/devices.js`** + +- 移除对静态 `createDeviceSchema`、`updateDeviceSchema` 的引用 +- `router.post('/')` 中请求到来时调用 `createDeviceSchema(false)` 生成动态 schema 进行验证 +- `router.put('/:deviceId')` 中调用 `createDeviceSchema(true)` 生成动态 schema + +```javascript +// 替换: +// const { createDeviceSchema, updateDeviceSchema } = require('../validation/deviceSchema'); +// 为: +const { createDeviceSchema } = require('../validation/dynamicDeviceSchema'); +``` + +**`backend/validation/deviceSchema.js`** + +- 移除 `createDeviceSchema`、`updateDeviceSchema` 导出 +- 保留 `batchDeviceIdsSchema`、`batchStatusSchema`、`batchMoveSchema`、`queryDeviceSchema`(这些与字段配置无关) +- `DEVICE_TYPES`、`DEVICE_STATUS` 枚举可保留供其他模块引用 + +### 3.3 方案二:前端表单完整适配字段配置 + +#### 3.3.1 修改文件 + +**`frontend/src/components/device/DeviceFormModal.jsx`** + +**修改点 A** — 取消 `rackId/position/height` 的排除(第272~278行): + +```javascript +// 修改前:排除rackId/position/height +const filteredFields = deviceFields.filter( + field => + field.fieldName !== 'deviceId' && + field.fieldName !== 'rackId' && // 移除 + field.fieldName !== 'position' && // 移除 + field.fieldName !== 'height' // 移除 +); + +// 修改后:只排除deviceId +const filteredFields = deviceFields.filter( + field => field.fieldName !== 'deviceId' +); +``` + +**修改点 B** — "设备位置选择"区块(第326~429行)的 rules 改为动态: + +```javascript +// 修改前:硬编码required: true +... + +// 修改后:读取字段配置 +const rackField = deviceFields.find(f => f.fieldName === 'rackId'); +// ... 使用 rackField?.required 决定是否需要 required 校验 +// 强制锁定字段:position/height 即使字段配置为非必填,仍强制必填 +const posField = deviceFields.find(f => f.fieldName === 'position'); +const isPositionRequired = true; // 强制锁定 +... +``` + +**修改点 C** — 位置信息如果设为非必填,提交时给警告: + +```javascript +const handleSubmit = values => { + if (!values.position || !values.height) { + Modal.warning({ + title: '位置信息不完整', + content: '位置(U位)或高度信息为空,设备将无法在3D视图中准确定位,建议填写完整。', + }); + } + // ... 继续提交 +}; +``` + +### 3.4 方案三:字段管理页面防护 + +#### 3.4.1 修改文件 + +**`frontend/src/pages/FieldConfig.jsx`**(或对应字段管理页面组件) + +对强制锁定字段的"必填"和"可见"开关做禁用处理: + +| 字段 | 必填开关 | 可见开关 | +|------|---------|---------| +| `name` | 禁用,显示"系统核心字段" | 禁用,显示"系统核心字段" | +| `serialNumber` | 禁用,显示"系统核心字段" | 禁用,显示"系统核心字段" | +| `position` | 禁用,显示"3D定位依赖" | 启用 | +| `height` | 禁用,显示"3D定位依赖" | 启用 | + +```jsx +// 伪代码逻辑 +const isLockedRequired = ['name', 'serialNumber', 'position', 'height'].includes(field.fieldName); +const isLockedVisible = ['name', 'serialNumber'].includes(field.fieldName); + + + + +``` + +### 3.5 方案四:同步默认值 + +#### 3.5.1 修改文件 + +**`frontend/src/constants/deviceManagementConstants.js`** + +| 字段 | 修改前 | 修改后 | +|------|-------|-------| +| `deviceId` 的 `required` | `true` | `false` | +| `powerConsumption` 的 `required` | `false` | `true` | + +#### 3.5.2 修改文件 + +**`backend/validation/deviceSchema.js`** + +- 移除 `createDeviceSchema`、`updateDeviceSchema` 的 `module.exports` +- 保留 `batchDeviceIdsSchema`、`batchStatusSchema`、`batchMoveSchema`、`queryDeviceSchema` + +--- + +## 4. 数据流对比 + +### 4.1 修复前数据流 + +``` +字段管理页面修改 required=true → DeviceField表 更新 + | + ┌─────────────────────┘ + ▼ (无读取) + 后端 Joi Schema (硬编码) ← 忽略数据库配置,直接拦截请求 + 前端 DeviceFormModal ← 只读部分字段配置,position等硬编码 +``` + +### 4.2 修复后数据流 + +``` +字段管理页面修改 required=true/false → DeviceField表 更新 + | + ┌─────────────────────────┴─────────────┐ + ▼ ▼ + 设备添加接口 (POST /api/devices) 设备添加弹窗 (DeviceFormModal) + │ │ + ▼ ▼ + createDeviceSchema(false) GET /api/deviceFields + │ │ + ▼ ▼ + 动态查询DeviceField表 读取 field.required + 强制锁定字段 = required() 强制锁定字段覆盖为必填 + 其他字段跟随数据库配置 其他字段跟随配置 + │ │ + ▼ ▼ + Joi校验 → 通过 → Device.create() Form.Item rules 动态生成 +``` + +--- + +## 5. 风险与注意事项 + +### 5.1 兼容性 + +- 本方案为**新功能**(让字段管理配置真正生效),不涉及向后兼容性破坏 +- 已有数据库中的 `DeviceField` 配置保持不变 +- 已有 `Device` 表中的数据不受影响 + +### 5.2 边界情况 + +| 场景 | 处理策略 | +|------|---------| +| DeviceField 表为空(首次部署) | 动态 schema 降级为最小验证集 | +| 用户在字段管理修改后立即添加设备 | 实时查询 DeviceField 表,无需缓存 | +| 高并发场景 | 每次创建设备都查表,建议后续可加内存缓存+过期机制 | +| 自定义字段(非预定义字段) | 走 `Joi.any().allow(null)` 不限制 | + +### 5.3 性能考量 + +每次创建/更新设备时多一次 `DeviceField.findAll()` 查询。由于: +- `DeviceField` 表数据量很小(<50 条) +- 设备创建/更新频率远低于查询频率 +- 无复杂关联查询 + +该额外查询对性能影响可以忽略不计。 + +--- + +## 6. 实施计划 + +| 步骤 | 文件 | 工作量估算 | 说明 | +|------|------|-----------|------| +| 1 | 新建 `dynamicDeviceSchema.js` | ~60 行 | 核心动态 schema 生成逻辑 | +| 2 | 修改 `devices.js` | ~10 行 | 替换静态 schema 引用 | +| 3 | 修改 `deviceSchema.js` | ~5 行 | 移除已迁移的导出 | +| 4 | 修改 `DeviceFormModal.jsx` | ~30 行 | 适配字段配置 | +| 5 | 修改 `FieldConfig.jsx` | ~20 行 | 锁定字段防护 | +| 6 | 修改 `deviceManagementConstants.js` | ~2 行 | 同步默认值 | + +--- + +## 7. 附录 + +### 7.1 相关文件清单 + +| 文件路径 | 操作类型 | +|----------|---------| +| `backend/validation/dynamicDeviceSchema.js` | **新增** | +| `backend/routes/devices.js` | 修改 | +| `backend/validation/deviceSchema.js` | 修改 | +| `frontend/src/components/device/DeviceFormModal.jsx` | 修改 | +| `frontend/src/pages/FieldConfig.jsx` | 修改 | +| `frontend/src/constants/deviceManagementConstants.js` | 修改 | + +### 7.2 参考 + +- 后端导入功能的必填验证逻辑(`backend/routes/devices.js` 第1191~1215行)已正确查询 `DeviceField` 表,本设计参考其实现模式 +- 设备模型定义:`backend/models/Device.js` +- 设备字段模型定义:`backend/models/DeviceField.js` +- 默认字段初始化:`backend/initDeviceFields.js` diff --git a/frontend/src/components/device/DeviceFormModal.jsx b/frontend/src/components/device/DeviceFormModal.jsx index eec8fa1..710d961 100644 --- a/frontend/src/components/device/DeviceFormModal.jsx +++ b/frontend/src/components/device/DeviceFormModal.jsx @@ -269,14 +269,22 @@ const DeviceFormModal = ({ } }; + // 强制锁定必填的字段(不受字段管理配置影响) + const FORCE_REQUIRED_FIELDS = ['name', 'serialNumber', 'position', 'height']; + const filteredFields = deviceFields.filter( - field => - field.fieldName !== 'deviceId' && - field.fieldName !== 'rackId' && - field.fieldName !== 'position' && - field.fieldName !== 'height' + field => field.fieldName !== 'deviceId' ); + // 获取关键字段的配置(用于设备位置区块) + const rackFieldConfig = deviceFields.find(f => f.fieldName === 'rackId'); + const positionFieldConfig = deviceFields.find(f => f.fieldName === 'position'); + const heightFieldConfig = deviceFields.find(f => f.fieldName === 'height'); + + // 动态判断是否必填(强制锁定字段 > 字段配置) + const isPositionRequired = FORCE_REQUIRED_FIELDS.includes('position') || positionFieldConfig?.required; + const isHeightRequired = FORCE_REQUIRED_FIELDS.includes('height') || heightFieldConfig?.required; + const formItems = []; filteredFields.forEach(field => { if (field.fieldName === 'serialNumber') { @@ -356,10 +364,10 @@ const DeviceFormModal = ({ label={ 机柜 - * + {rackFieldConfig?.required && *} } - rules={[{ required: true, message: '请选择机柜' }]} + rules={rackFieldConfig?.required ? [{ required: true, message: '请选择机柜' }] : []} style={{ marginBottom: '0' }} >