diff --git a/backend/src/models/BlackList.js b/backend/src/models/BlackList.js new file mode 100644 index 0000000..352df39 --- /dev/null +++ b/backend/src/models/BlackList.js @@ -0,0 +1,36 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); +const Ota = require('./Ota'); + +const BlackList = sequelize.define('BlackList', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + comment: 'ID', + }, + ota_id: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'ota_id', + comment: '关联 OTA 版本 ID', + }, + mac: { + type: DataTypes.STRING(100), + allowNull: false, + comment: 'MAC 地址', + }, + create_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: '创建时间', + }, +}, { + tableName: 'black_list', + comment: 'OTA 黑名单', +}); + +BlackList.belongsTo(Ota, { foreignKey: 'ota_id', as: 'ota' }); + +module.exports = BlackList; diff --git a/backend/src/models/OtaTargetDevice.js b/backend/src/models/OtaTargetDevice.js new file mode 100644 index 0000000..5bd969c --- /dev/null +++ b/backend/src/models/OtaTargetDevice.js @@ -0,0 +1,37 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); +const Ota = require('./Ota'); + +const OtaTargetDevice = sequelize.define('OtaTargetDevice', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + comment: 'ID', + }, + ota_id: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'ota_id', + comment: '关联 OTA 版本 ID', + }, + mac_addr: { + type: DataTypes.STRING(17), + allowNull: false, + field: 'mac_addr', + comment: 'MAC 地址', + }, + create_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: '创建时间', + }, +}, { + tableName: 'ota_target_device', + comment: '定向升级设备', +}); + +OtaTargetDevice.belongsTo(Ota, { foreignKey: 'ota_id', as: 'ota' }); + +module.exports = OtaTargetDevice; diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 4ee3589..c1e1052 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -1,7 +1,9 @@ const Brand = require('./Brand'); const Model = require('./Model'); const Ota = require('./Ota'); +const BlackList = require('./BlackList'); +const OtaTargetDevice = require('./OtaTargetDevice'); const ShareCodeLog = require('./ShareCodeLog'); const DashboardUser = require('./DashboardUser'); -module.exports = { Brand, Model, Ota, ShareCodeLog, DashboardUser }; +module.exports = { Brand, Model, Ota, BlackList, OtaTargetDevice, ShareCodeLog, DashboardUser }; diff --git a/backend/src/routes/blacklist.js b/backend/src/routes/blacklist.js new file mode 100644 index 0000000..cf0b0ff --- /dev/null +++ b/backend/src/routes/blacklist.js @@ -0,0 +1,222 @@ +/** + * 黑名单路由 + */ +const router = require('express').Router(); +const { Op } = require('sequelize'); +const BlackList = require('../models/BlackList'); +const Ota = require('../models/Ota'); +const logger = require('../config/logger'); +const { ApiResponse } = require('../utils/response'); +const { authMiddleware } = require('../middleware/auth'); +const { BlackListCreateSchema, BlackListUpdateSchema } = require('../validators/blacklist'); + +// 所有接口需登录 +router.use('/api/blacklist', authMiddleware); + +// GET /api/blacklist/ +router.get('/api/blacklist/', async (req, res) => { + try { + const skip = parseInt(req.query.skip || '0', 10); + const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); + const otaId = req.query.ota_id !== undefined ? parseInt(req.query.ota_id, 10) : null; + const model = req.query.model; + const mac = req.query.mac; + + const where = {}; + if (otaId !== null && !isNaN(otaId)) where.ota_id = otaId; + if (mac) where.mac = { [Op.like]: `%${mac}%` }; + + // 如果按 model 筛选,需先查出对应 model 的 ota_id 列表 + if (model) { + const otaIds = await Ota.findAll({ + where: { model: { [Op.like]: `%${model}%` } }, + attributes: ['id'], + }); + const ids = otaIds.map((o) => o.id); + if (ids.length === 0) { + return res.json(ApiResponse.success({ items: [], total: 0, skip, limit })); + } + if (where.ota_id !== undefined) { + where.ota_id = { [Op.and]: [where.ota_id, { [Op.in]: ids }] }; + } else { + where.ota_id = { [Op.in]: ids }; + } + } + + const total = await BlackList.count({ where }); + const rows = await BlackList.findAll({ + where, + include: [{ + model: Ota, + as: 'ota', + attributes: ['id', 'verCode', 'verName', 'model'], + }], + offset: skip, + limit, + order: [['id', 'DESC']], + }); + + logger.info(`Found ${rows.length} BlackList records, total=${total}`); + + if (!rows.length) { + return res.json(ApiResponse.noData('empty')); + } + + const items = rows.map(blacklistToDict); + return res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e) { + logger.error(`Error getting BlackList: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// GET /api/blacklist/:id +router.get('/api/blacklist/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + logger.info(`Getting BlackList: id=${id}`); + const item = await BlackList.findByPk(id, { + include: [{ + model: Ota, + as: 'ota', + attributes: ['id', 'verCode', 'verName', 'model'], + }], + }); + if (!item) { + logger.warn(`BlackList not found: id=${id}`); + return res.json(ApiResponse.noData('empty')); + } + return res.json(ApiResponse.success(blacklistToDict(item))); + } catch (e) { + logger.error(`Error getting BlackList ${req.params.id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// POST /api/blacklist/ +router.post('/api/blacklist/', async (req, res) => { + try { + const parsed = BlackListCreateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + return res.json(ApiResponse.error(errors)); + } + + const data = parsed.data; + logger.info(`Creating BlackList: ota_id=${data.ota_id}, mac=${data.mac}`); + + // 校验 OTA 是否存在 + const ota = await Ota.findByPk(data.ota_id); + if (!ota) { + return res.json(ApiResponse.error('OTA 版本不存在')); + } + + const item = await BlackList.create(data); + logger.info(`BlackList created: id=${item.id}`); + + // 重新加载关联数据 + const created = await BlackList.findByPk(item.id, { + include: [{ + model: Ota, + as: 'ota', + attributes: ['id', 'verCode', 'verName', 'model'], + }], + }); + + return res.json(ApiResponse.success(blacklistToDict(created))); + } catch (e) { + logger.error(`Error creating BlackList: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/blacklist/:id +router.put('/api/blacklist/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + + const parsed = BlackListUpdateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + return res.json(ApiResponse.error(errors)); + } + + const data = parsed.data; + logger.info(`Updating BlackList: id=${id}`); + + const item = await BlackList.findByPk(id); + if (!item) { + logger.warn(`BlackList not found: id=${id}`); + return res.json(ApiResponse.noData('empty')); + } + + // 如果更新 ota_id,校验 OTA 是否存在 + if (data.ota_id !== undefined && data.ota_id !== null && data.ota_id !== item.ota_id) { + const ota = await Ota.findByPk(data.ota_id); + if (!ota) { + return res.json(ApiResponse.error('OTA 版本不存在')); + } + } + + const updateFields = Object.entries(data).filter(([_, v]) => v !== undefined && v !== null); + for (const [field, value] of updateFields) { + item[field] = value; + } + + await item.save(); + logger.info(`BlackList updated: id=${id}`); + + // 重新加载关联数据 + const updated = await BlackList.findByPk(id, { + include: [{ + model: Ota, + as: 'ota', + attributes: ['id', 'verCode', 'verName', 'model'], + }], + }); + + return res.json(ApiResponse.success(blacklistToDict(updated))); + } catch (e) { + logger.error(`Error updating BlackList ${req.params.id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/blacklist/:id +router.delete('/api/blacklist/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + logger.info(`Deleting BlackList: id=${id}`); + + const item = await BlackList.findByPk(id); + if (!item) { + logger.warn(`BlackList not found: id=${id}`); + return res.json(ApiResponse.noData('empty')); + } + + await item.destroy(); + logger.info(`BlackList deleted: id=${id}`); + return res.json(ApiResponse.success(null, 'success')); + } catch (e) { + logger.error(`Error deleting BlackList ${req.params.id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// BlackList 对象转字典 +function blacklistToDict(item) { + return { + id: item.id, + ota_id: item.ota_id, + mac: item.mac, + create_at: item.create_at ? item.create_at.toISOString() : null, + ota: item.ota ? { + id: item.ota.id, + verCode: item.ota.verCode, + verName: item.ota.verName, + model: item.ota.model, + } : null, + }; +} + +module.exports = router; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index d93aa6d..9230159 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -5,8 +5,10 @@ const authRouter = require('./auth'); const brandsRouter = require('./brands'); const modelsRouter = require('./models'); const otaRouter = require('./ota'); +const blacklistRouter = require('./blacklist'); +const otaTargetDeviceRouter = require('./otaTargetDevice'); const shareCodeLogsRouter = require('./shareCodeLogs'); const usersRouter = require('./users'); const dashboardRouter = require('./dashboard'); -module.exports = [authRouter, brandsRouter, modelsRouter, otaRouter, shareCodeLogsRouter, usersRouter, dashboardRouter]; +module.exports = [authRouter, brandsRouter, modelsRouter, otaRouter, blacklistRouter, otaTargetDeviceRouter, shareCodeLogsRouter, usersRouter, dashboardRouter]; diff --git a/backend/src/routes/otaTargetDevice.js b/backend/src/routes/otaTargetDevice.js new file mode 100644 index 0000000..30d69b6 --- /dev/null +++ b/backend/src/routes/otaTargetDevice.js @@ -0,0 +1,247 @@ +/** + * 定向升级路由 + */ +const router = require('express').Router(); +const { Op } = require('sequelize'); +const OtaTargetDevice = require('../models/OtaTargetDevice'); +const Ota = require('../models/Ota'); +const logger = require('../config/logger'); +const { ApiResponse } = require('../utils/response'); +const { authMiddleware } = require('../middleware/auth'); +const { OtaTargetDeviceCreateSchema, OtaTargetDeviceUpdateSchema } = require('../validators/otaTargetDevice'); + +// 所有接口需登录 +router.use('/api/ota-target-device', authMiddleware); + +// GET /api/ota-target-device/ +router.get('/api/ota-target-device/', async (req, res) => { + try { + const skip = parseInt(req.query.skip || '0', 10); + const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); + const otaId = req.query.ota_id !== undefined ? parseInt(req.query.ota_id, 10) : null; + const model = req.query.model; + const macAddr = req.query.mac_addr; + + const where = {}; + if (otaId !== null && !isNaN(otaId)) where.ota_id = otaId; + if (macAddr) where.mac_addr = { [Op.like]: `%${macAddr}%` }; + + // 如果按 model 筛选,需先查出对应 model 的 ota_id 列表 + if (model) { + const otaIds = await Ota.findAll({ + where: { model: { [Op.like]: `%${model}%` } }, + attributes: ['id'], + }); + const ids = otaIds.map((o) => o.id); + if (ids.length === 0) { + return res.json(ApiResponse.success({ items: [], total: 0, skip, limit })); + } + if (where.ota_id !== undefined) { + where.ota_id = { [Op.and]: [where.ota_id, { [Op.in]: ids }] }; + } else { + where.ota_id = { [Op.in]: ids }; + } + } + + const total = await OtaTargetDevice.count({ where }); + const rows = await OtaTargetDevice.findAll({ + where, + include: [{ + model: Ota, + as: 'ota', + attributes: ['id', 'verCode', 'verName', 'model'], + }], + offset: skip, + limit, + order: [['id', 'DESC']], + }); + + logger.info(`Found ${rows.length} OtaTargetDevice records, total=${total}`); + + if (!rows.length) { + return res.json(ApiResponse.noData('empty')); + } + + const items = rows.map(toDict); + return res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e) { + logger.error(`Error getting OtaTargetDevice: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// GET /api/ota-target-device/:id +router.get('/api/ota-target-device/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + logger.info(`Getting OtaTargetDevice: id=${id}`); + const item = await OtaTargetDevice.findByPk(id, { + include: [{ + model: Ota, + as: 'ota', + attributes: ['id', 'verCode', 'verName', 'model'], + }], + }); + if (!item) { + logger.warn(`OtaTargetDevice not found: id=${id}`); + return res.json(ApiResponse.noData('empty')); + } + return res.json(ApiResponse.success(toDict(item))); + } catch (e) { + logger.error(`Error getting OtaTargetDevice ${req.params.id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// POST /api/ota-target-device/ +router.post('/api/ota-target-device/', async (req, res) => { + try { + const parsed = OtaTargetDeviceCreateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + return res.json(ApiResponse.error(errors)); + } + + const data = parsed.data; + logger.info(`Creating OtaTargetDevice: ota_id=${data.ota_id}, mac_addr=${data.mac_addr}`); + + // 校验 OTA 是否存在 + const ota = await Ota.findByPk(data.ota_id); + if (!ota) { + return res.json(ApiResponse.error('OTA 版本不存在')); + } + + // 唯一性校验:同一 ota_id 下 mac_addr 不能重复 + const existing = await OtaTargetDevice.findOne({ + where: { ota_id: data.ota_id, mac_addr: data.mac_addr }, + }); + if (existing) { + return res.json(ApiResponse.error('该 MAC 地址已在此 OTA 版本中')); + } + + const item = await OtaTargetDevice.create(data); + logger.info(`OtaTargetDevice created: id=${item.id}`); + + // 重新加载关联数据 + const created = await OtaTargetDevice.findByPk(item.id, { + include: [{ + model: Ota, + as: 'ota', + attributes: ['id', 'verCode', 'verName', 'model'], + }], + }); + + return res.json(ApiResponse.success(toDict(created))); + } catch (e) { + logger.error(`Error creating OtaTargetDevice: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/ota-target-device/:id +router.put('/api/ota-target-device/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + + const parsed = OtaTargetDeviceUpdateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + return res.json(ApiResponse.error(errors)); + } + + const data = parsed.data; + logger.info(`Updating OtaTargetDevice: id=${id}`); + + const item = await OtaTargetDevice.findByPk(id); + if (!item) { + logger.warn(`OtaTargetDevice not found: id=${id}`); + return res.json(ApiResponse.noData('empty')); + } + + // 如果更新 ota_id,校验 OTA 是否存在 + if (data.ota_id !== undefined && data.ota_id !== null && data.ota_id !== item.ota_id) { + const ota = await Ota.findByPk(data.ota_id); + if (!ota) { + return res.json(ApiResponse.error('OTA 版本不存在')); + } + } + + // 唯一性校验:如果 ota_id 或 mac_addr 有变更 + const newOtaId = data.ota_id !== undefined && data.ota_id !== null ? data.ota_id : item.ota_id; + const newMacAddr = data.mac_addr !== undefined && data.mac_addr !== null ? data.mac_addr : item.mac_addr; + + if (newOtaId !== item.ota_id || newMacAddr !== item.mac_addr) { + const existing = await OtaTargetDevice.findOne({ + where: { + ota_id: newOtaId, + mac_addr: newMacAddr, + id: { [Op.ne]: id }, + }, + }); + if (existing) { + return res.json(ApiResponse.error('该 MAC 地址已在此 OTA 版本中')); + } + } + + const updateFields = Object.entries(data).filter(([_, v]) => v !== undefined && v !== null); + for (const [field, value] of updateFields) { + item[field] = value; + } + + await item.save(); + logger.info(`OtaTargetDevice updated: id=${id}`); + + // 重新加载关联数据 + const updated = await OtaTargetDevice.findByPk(id, { + include: [{ + model: Ota, + as: 'ota', + attributes: ['id', 'verCode', 'verName', 'model'], + }], + }); + + return res.json(ApiResponse.success(toDict(updated))); + } catch (e) { + logger.error(`Error updating OtaTargetDevice ${req.params.id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/ota-target-device/:id +router.delete('/api/ota-target-device/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + logger.info(`Deleting OtaTargetDevice: id=${id}`); + + const item = await OtaTargetDevice.findByPk(id); + if (!item) { + logger.warn(`OtaTargetDevice not found: id=${id}`); + return res.json(ApiResponse.noData('empty')); + } + + await item.destroy(); + logger.info(`OtaTargetDevice deleted: id=${id}`); + return res.json(ApiResponse.success(null, 'success')); + } catch (e) { + logger.error(`Error deleting OtaTargetDevice ${req.params.id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// OtaTargetDevice 对象转字典 +function toDict(item) { + return { + id: item.id, + ota_id: item.ota_id, + mac_addr: item.mac_addr, + create_at: item.create_at ? item.create_at.toISOString() : null, + ota: item.ota ? { + id: item.ota.id, + verCode: item.ota.verCode, + verName: item.ota.verName, + model: item.ota.model, + } : null, + }; +} + +module.exports = router; diff --git a/backend/src/validators/blacklist.js b/backend/src/validators/blacklist.js new file mode 100644 index 0000000..797d9cf --- /dev/null +++ b/backend/src/validators/blacklist.js @@ -0,0 +1,13 @@ +const { z } = require('zod'); + +const BlackListCreateSchema = z.object({ + ota_id: z.number().int({ message: 'ota_id 须为整数' }), + mac: z.string().min(1, 'MAC 地址不能为空').max(100, 'MAC 地址最多100字符'), +}); + +const BlackListUpdateSchema = z.object({ + ota_id: z.number().int({ message: 'ota_id 须为整数' }).optional().nullable(), + mac: z.string().min(1).max(100).optional().nullable(), +}); + +module.exports = { BlackListCreateSchema, BlackListUpdateSchema }; diff --git a/backend/src/validators/otaTargetDevice.js b/backend/src/validators/otaTargetDevice.js new file mode 100644 index 0000000..1a829fa --- /dev/null +++ b/backend/src/validators/otaTargetDevice.js @@ -0,0 +1,13 @@ +const { z } = require('zod'); + +const OtaTargetDeviceCreateSchema = z.object({ + ota_id: z.number().int({ message: 'ota_id 须为整数' }), + mac_addr: z.string().min(1, 'MAC 地址不能为空').max(17, 'MAC 地址最多17字符'), +}); + +const OtaTargetDeviceUpdateSchema = z.object({ + ota_id: z.number().int({ message: 'ota_id 须为整数' }).optional().nullable(), + mac_addr: z.string().min(1).max(17).optional().nullable(), +}); + +module.exports = { OtaTargetDeviceCreateSchema, OtaTargetDeviceUpdateSchema }; diff --git a/frontend/src/api/blacklist.js b/frontend/src/api/blacklist.js new file mode 100644 index 0000000..af1abe2 --- /dev/null +++ b/frontend/src/api/blacklist.js @@ -0,0 +1,56 @@ +import request from '@/utils/request' + +/** + * 黑名单列表 + * @param {Object} params + * @param {number} [params.skip] + * @param {number} [params.limit] + * @param {number} [params.ota_id] + * @param {string} [params.model] + * @param {string} [params.mac] + */ +export function getBlackList(params) { + return request({ + url: '/blacklist/', + method: 'get', + params + }) +} + +/** + * 新增黑名单 + * @param {Object} data + * @param {number} data.ota_id + * @param {string} data.mac + */ +export function createBlackList(data) { + return request({ + url: '/blacklist/', + method: 'post', + data + }) +} + +/** + * 更新黑名单 + * @param {number} id + * @param {Object} data + */ +export function updateBlackList(id, data) { + return request({ + url: `/blacklist/${id}`, + method: 'put', + data + }) +} + +/** + * 删除黑名单 + * @param {number} id + */ +export function deleteBlackList(id) { + return request({ + url: `/blacklist/${id}`, + method: 'delete' + }) +} diff --git a/frontend/src/api/otaTargetDevice.js b/frontend/src/api/otaTargetDevice.js new file mode 100644 index 0000000..483e10c --- /dev/null +++ b/frontend/src/api/otaTargetDevice.js @@ -0,0 +1,56 @@ +import request from '@/utils/request' + +/** + * 定向升级设备列表 + * @param {Object} params + * @param {number} [params.skip] + * @param {number} [params.limit] + * @param {number} [params.ota_id] + * @param {string} [params.model] + * @param {string} [params.mac_addr] + */ +export function getOtaTargetDeviceList(params) { + return request({ + url: '/ota-target-device/', + method: 'get', + params + }) +} + +/** + * 新增定向升级设备 + * @param {Object} data + * @param {number} data.ota_id + * @param {string} data.mac_addr + */ +export function createOtaTargetDevice(data) { + return request({ + url: '/ota-target-device/', + method: 'post', + data + }) +} + +/** + * 更新定向升级设备 + * @param {number} id + * @param {Object} data + */ +export function updateOtaTargetDevice(id, data) { + return request({ + url: `/ota-target-device/${id}`, + method: 'put', + data + }) +} + +/** + * 删除定向升级设备 + * @param {number} id + */ +export function deleteOtaTargetDevice(id) { + return request({ + url: `/ota-target-device/${id}`, + method: 'delete' + }) +} diff --git a/frontend/src/layout/index.vue b/frontend/src/layout/index.vue index 2d4742b..583e9de 100644 --- a/frontend/src/layout/index.vue +++ b/frontend/src/layout/index.vue @@ -69,6 +69,14 @@ OTA 管理 + + + 黑名单管理 + + + + 定向升级 +