重构后端,改为 node 技术栈
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 型号路由 — 对应 Python: routes/models.py
|
||||
*/
|
||||
const router = require('express').Router();
|
||||
const { Op } = require('sequelize');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const axios = require('axios');
|
||||
const Model = require('../models/Model');
|
||||
const logger = require('../config/logger');
|
||||
const { ApiResponse } = require('../utils/response');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { fetchAndValidateCurve } = require('../services/curveClient');
|
||||
|
||||
/**
|
||||
* 将 TXT 频响文件内容转换为 CSV 格式
|
||||
* 支持两种格式:
|
||||
* 1. REW 导出格式:以 "* Freq(Hz)" 行作为数据起点,空格分隔
|
||||
* 2. 纯数据格式:直接 tab/空格分隔,无前导注释
|
||||
* @param {Buffer} buffer - TXT 文件 Buffer
|
||||
* @returns {string} CSV 内容(含 frequency,raw 表头)
|
||||
*/
|
||||
function convertTxtToCsv(buffer) {
|
||||
const text = buffer.toString('utf-8');
|
||||
const lines = text.split(/\r?\n/);
|
||||
|
||||
// 查找 "* Freq(Hz)" 头行索引
|
||||
let startIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('* Freq(Hz)')) {
|
||||
startIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 数据行:头行之后,或无前导注释时从第一行开始
|
||||
const dataLines = startIdx >= 0 ? lines.slice(startIdx + 1) : lines;
|
||||
|
||||
const csvRows = ['frequency,raw'];
|
||||
for (const line of dataLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('*')) continue;
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
csvRows.push(`${parts[0]},${parts[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return csvRows.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理上传的频响文件:
|
||||
* - .txt → 转换为 CSV,返回 { buffer, filename } (扩展名改为 .csv)
|
||||
* - 其他格式 → 原样返回
|
||||
*/
|
||||
function processUploadedFile(buffer, originalname) {
|
||||
const ext = path.extname(originalname).toLowerCase();
|
||||
if (ext === '.txt') {
|
||||
const csvContent = convertTxtToCsv(buffer);
|
||||
const csvFilename = originalname.replace(/\.txt$/i, '.csv');
|
||||
logger.info(`Converted TXT to CSV: ${originalname} -> ${csvFilename}`);
|
||||
return { buffer: Buffer.from(csvContent, 'utf-8'), filename: csvFilename };
|
||||
}
|
||||
return { buffer, filename: originalname };
|
||||
}
|
||||
|
||||
// 所有型号接口需登录
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Multer 配置 — 内存存储
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// Meilisearch 配置
|
||||
const MEILISEARCH_URL = process.env.MEILISEARCH_URL || 'http://localhost:7700';
|
||||
const MEILISEARCH_API_KEY = process.env.MEILISEARCH_API_KEY || '';
|
||||
const MEILISEARCH_INDEX = process.env.MEILISEARCH_INDEX || 'models';
|
||||
|
||||
// 文件上传配置
|
||||
const UPLOAD_FOLDER = process.env.UPLOAD_FOLDER || '/data/project/autoeq/measurements';
|
||||
const ALLOWED_EXTENSIONS = ['.csv', '.txt', '.json'];
|
||||
|
||||
// GET /api/models/
|
||||
router.get('/api/models/', async (req, res) => {
|
||||
try {
|
||||
const skip = parseInt(req.query.skip || '0', 10);
|
||||
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
const brandName = req.query.brand_name;
|
||||
const name = req.query.name;
|
||||
const sortBy = req.query.sort_by || 'id';
|
||||
const sortOrder = (req.query.sort_order || 'desc').toLowerCase();
|
||||
|
||||
const where = {};
|
||||
if (brandName) where.brand_name = { [Op.like]: `%${brandName}%` };
|
||||
if (name) where.name = { [Op.like]: `%${name}%` };
|
||||
|
||||
const total = await Model.count({ where });
|
||||
|
||||
const orderDir = sortOrder === 'asc' ? 'ASC' : 'DESC';
|
||||
const orderCol = sortBy === 'create_at' ? 'create_at' : 'id';
|
||||
|
||||
const rows = await Model.findAll({
|
||||
where,
|
||||
offset: skip,
|
||||
limit,
|
||||
order: [[orderCol, orderDir]],
|
||||
});
|
||||
|
||||
logger.info(`Found ${rows.length} models, total=${total}`);
|
||||
|
||||
if (!rows.length) {
|
||||
return res.json(ApiResponse.noData('empty'));
|
||||
}
|
||||
|
||||
const items = rows.map((m) => ({
|
||||
id: m.id,
|
||||
brand_name: m.brand_name,
|
||||
name: m.name,
|
||||
form: m.form,
|
||||
rig: m.rig,
|
||||
source: m.source,
|
||||
eq_key: m.eq_key,
|
||||
create_at: m.create_at ? m.create_at.toISOString() : null,
|
||||
}));
|
||||
|
||||
return res.json(ApiResponse.success({ items, total, skip, limit }));
|
||||
} catch (e) {
|
||||
logger.error(`Error getting models: ${e.message}`);
|
||||
return res.json(ApiResponse.error('error'));
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/models/:model_id
|
||||
router.get('/api/models/:model_id', async (req, res) => {
|
||||
try {
|
||||
const modelId = parseInt(req.params.model_id, 10);
|
||||
logger.info(`Getting model: id=${modelId}`);
|
||||
const model = await Model.findByPk(modelId);
|
||||
if (!model) {
|
||||
logger.warn(`Model not found: id=${modelId}`);
|
||||
return res.json(ApiResponse.noData('empty'));
|
||||
}
|
||||
return res.json(ApiResponse.success({
|
||||
id: model.id,
|
||||
brand_name: model.brand_name,
|
||||
name: model.name,
|
||||
form: model.form,
|
||||
rig: model.rig,
|
||||
source: model.source,
|
||||
eq_key: model.eq_key,
|
||||
create_at: model.create_at ? model.create_at.toISOString() : null,
|
||||
}));
|
||||
} catch (e) {
|
||||
logger.error(`Error getting model ${req.params.model_id}: ${e.message}`);
|
||||
return res.json(ApiResponse.error('error'));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/models/ (multipart/form-data)
|
||||
router.post('/api/models/', upload.single('measurement_file'), async (req, res) => {
|
||||
try {
|
||||
const { brand_name, name, form, rig, source, eq_key } = req.body;
|
||||
logger.info(`Creating model: brand_name=${brand_name}, name=${name}, form=${form}, source=${source}`);
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await Model.findOne({ where: { brand_name, name } });
|
||||
if (existing) {
|
||||
logger.warn(`Model already exists: brand_name=${brand_name}, name=${name}`);
|
||||
return res.json(ApiResponse.error('该品牌下型号名称已存在'));
|
||||
}
|
||||
|
||||
// 处理文件上传
|
||||
if (req.file && req.file.originalname) {
|
||||
const fileExt = path.extname(req.file.originalname).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(fileExt)) {
|
||||
logger.error(`Unsupported file format: ${fileExt}`);
|
||||
return res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`));
|
||||
}
|
||||
|
||||
const { buffer: fileBuffer, filename: savedFilename } = processUploadedFile(req.file.buffer, req.file.originalname);
|
||||
const saveDir = path.join(UPLOAD_FOLDER, source || '', 'data', form || '');
|
||||
fs.mkdirSync(saveDir, { recursive: true });
|
||||
const filePath = path.join(saveDir, savedFilename);
|
||||
fs.writeFileSync(filePath, fileBuffer);
|
||||
logger.info(`File saved: ${filePath}`);
|
||||
}
|
||||
|
||||
const dbModel = await Model.create({
|
||||
brand_name,
|
||||
name,
|
||||
form: form || null,
|
||||
rig: rig || null,
|
||||
source: source || null,
|
||||
eq_key: eq_key || null,
|
||||
});
|
||||
|
||||
logger.info(`Model created successfully: id=${dbModel.id}`);
|
||||
return res.json(ApiResponse.success({
|
||||
id: dbModel.id,
|
||||
brand_name: dbModel.brand_name,
|
||||
name: dbModel.name,
|
||||
form: dbModel.form,
|
||||
rig: dbModel.rig,
|
||||
source: dbModel.source,
|
||||
eq_key: dbModel.eq_key,
|
||||
create_at: dbModel.create_at ? dbModel.create_at.toISOString() : null,
|
||||
}));
|
||||
} catch (e) {
|
||||
logger.error(`Error creating model: ${e.message}`);
|
||||
return res.json(ApiResponse.error('error'));
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/models/:model_id (multipart/form-data)
|
||||
router.put('/api/models/:model_id', upload.single('measurement_file'), async (req, res) => {
|
||||
try {
|
||||
const modelId = parseInt(req.params.model_id, 10);
|
||||
const { brand_name, name, form, rig, source, eq_key } = req.body;
|
||||
|
||||
logger.info(`Updating model: id=${modelId}, brand_name=${brand_name}, name=${name}, form=${form}`);
|
||||
|
||||
const dbModel = await Model.findByPk(modelId);
|
||||
if (!dbModel) {
|
||||
logger.warn(`Model not found: id=${modelId}`);
|
||||
return res.json(ApiResponse.noData('empty'));
|
||||
}
|
||||
|
||||
const newBrandName = brand_name === undefined || brand_name === 'null' ? dbModel.brand_name : brand_name;
|
||||
const newName = name === undefined || name === 'null' ? dbModel.name : name;
|
||||
|
||||
if (newBrandName !== dbModel.brand_name || newName !== dbModel.name) {
|
||||
const existing = await Model.findOne({
|
||||
where: { brand_name: newBrandName, name: newName },
|
||||
});
|
||||
if (existing) {
|
||||
logger.warn(`Model already exists: brand_name=${newBrandName}, name=${newName}`);
|
||||
return res.json(ApiResponse.error('该品牌下型号名称已存在'));
|
||||
}
|
||||
}
|
||||
|
||||
const effSource = source === undefined || source === 'null' ? dbModel.source : source;
|
||||
const effForm = form === undefined || form === 'null' ? dbModel.form : form;
|
||||
|
||||
if (req.file && req.file.originalname) {
|
||||
const fileExt = path.extname(req.file.originalname).toLowerCase();
|
||||
if (!ALLOWED_EXTENSIONS.includes(fileExt)) {
|
||||
logger.error(`Unsupported file format: ${fileExt}`);
|
||||
return res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`));
|
||||
}
|
||||
if (!effSource || !effForm) {
|
||||
return res.json(ApiResponse.error('上传频响文件需要来源与形式字段'));
|
||||
}
|
||||
const { buffer: fileBuffer, filename: savedFilename } = processUploadedFile(req.file.buffer, req.file.originalname);
|
||||
const saveDir = path.join(UPLOAD_FOLDER, effSource, 'data', effForm);
|
||||
fs.mkdirSync(saveDir, { recursive: true });
|
||||
const filePath = path.join(saveDir, savedFilename);
|
||||
fs.writeFileSync(filePath, fileBuffer);
|
||||
logger.info(`File saved: ${filePath}`);
|
||||
}
|
||||
|
||||
if (brand_name !== undefined && brand_name !== 'null') dbModel.brand_name = brand_name;
|
||||
if (name !== undefined && name !== 'null') dbModel.name = name;
|
||||
if (form !== undefined && form !== 'null') dbModel.form = form;
|
||||
if (rig !== undefined && rig !== 'null') dbModel.rig = rig;
|
||||
if (source !== undefined && source !== 'null') dbModel.source = source;
|
||||
if (eq_key !== undefined && eq_key !== 'null') dbModel.eq_key = eq_key;
|
||||
|
||||
await dbModel.save();
|
||||
logger.info(`Model updated successfully: id=${dbModel.id}`);
|
||||
|
||||
return res.json(ApiResponse.success({
|
||||
id: dbModel.id,
|
||||
brand_name: dbModel.brand_name,
|
||||
name: dbModel.name,
|
||||
form: dbModel.form,
|
||||
rig: dbModel.rig,
|
||||
source: dbModel.source,
|
||||
eq_key: dbModel.eq_key,
|
||||
create_at: dbModel.create_at ? dbModel.create_at.toISOString() : null,
|
||||
}));
|
||||
} catch (e) {
|
||||
logger.error(`Error updating model ${req.params.model_id}: ${e.message}`);
|
||||
return res.json(ApiResponse.error('error'));
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/models/:model_id
|
||||
router.delete('/api/models/:model_id', async (req, res) => {
|
||||
try {
|
||||
const modelId = parseInt(req.params.model_id, 10);
|
||||
logger.info(`Deleting model: id=${modelId}`);
|
||||
|
||||
const dbModel = await Model.findByPk(modelId);
|
||||
if (!dbModel) {
|
||||
logger.warn(`Model not found: id=${modelId}`);
|
||||
return res.json(ApiResponse.noData('empty'));
|
||||
}
|
||||
|
||||
await dbModel.destroy();
|
||||
logger.info(`Model deleted successfully: id=${modelId}`);
|
||||
return res.json(ApiResponse.success(null, 'success'));
|
||||
} catch (e) {
|
||||
logger.error(`Error deleting model ${req.params.model_id}: ${e.message}`);
|
||||
return res.json(ApiResponse.error('error'));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/models/push-to-search/validate
|
||||
router.post('/api/models/push-to-search/validate', async (req, res) => {
|
||||
try {
|
||||
const { model_ids } = req.body || {};
|
||||
if (!Array.isArray(model_ids) || !model_ids.length) {
|
||||
return res.json(ApiResponse.error('请选择要推送的型号'));
|
||||
}
|
||||
|
||||
const models = await Model.findAll({ where: { id: { [Op.in]: model_ids } } });
|
||||
if (!models.length) {
|
||||
return res.json(ApiResponse.error('未找到选中的型号数据'));
|
||||
}
|
||||
|
||||
const validationErrors = [];
|
||||
for (const model of models) {
|
||||
const [ok, reason] = await fetchAndValidateCurve(
|
||||
model.brand_name,
|
||||
model.name,
|
||||
model.form || ''
|
||||
);
|
||||
if (!ok) {
|
||||
validationErrors.push({
|
||||
id: model.id,
|
||||
brand_name: model.brand_name,
|
||||
name: model.name,
|
||||
form: model.form,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length) {
|
||||
const names = validationErrors.slice(0, 5)
|
||||
.map((e) => `${e.brand_name} ${e.name}`)
|
||||
.join('、');
|
||||
const suffix = validationErrors.length > 5 ? ' 等' : '';
|
||||
return res.json({
|
||||
code: 0,
|
||||
msg: `曲线数据校验未通过:${names}${suffix}`,
|
||||
data: { errors: validationErrors, validated_count: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(ApiResponse.success({ validated_count: models.length }));
|
||||
} catch (e) {
|
||||
logger.error(`validate_push_to_search failed: ${e.message}`);
|
||||
return res.json(ApiResponse.error(`校验失败:${e.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/models/push-to-search
|
||||
router.post('/api/models/push-to-search', async (req, res) => {
|
||||
try {
|
||||
const { model_ids } = req.body || {};
|
||||
if (!Array.isArray(model_ids) || !model_ids.length) {
|
||||
return res.json(ApiResponse.error('请选择要推送的型号'));
|
||||
}
|
||||
|
||||
const models = await Model.findAll({ where: { id: { [Op.in]: model_ids } } });
|
||||
if (!models.length) {
|
||||
return res.json(ApiResponse.error('未找到选中的型号数据'));
|
||||
}
|
||||
|
||||
const pushData = models.map((m) => {
|
||||
const doc = {
|
||||
id: m.id,
|
||||
brand_name: m.brand_name,
|
||||
name: m.name,
|
||||
rig: m.rig,
|
||||
form: m.form,
|
||||
source: m.source,
|
||||
};
|
||||
// 移除 null 值
|
||||
Object.keys(doc).forEach((k) => doc[k] === null && delete doc[k]);
|
||||
return doc;
|
||||
});
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${MEILISEARCH_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents`,
|
||||
pushData,
|
||||
{ headers, timeout: 30000 }
|
||||
);
|
||||
|
||||
if (![200, 202].includes(response.status)) {
|
||||
return res.json(ApiResponse.error(`推送到 Meilisearch 失败:${JSON.stringify(response.data)}`));
|
||||
}
|
||||
|
||||
return res.json(ApiResponse.success({
|
||||
pushed_count: pushData.length,
|
||||
task_uid: response.data?.taskUid,
|
||||
models: pushData,
|
||||
}));
|
||||
} catch (e) {
|
||||
return res.json(ApiResponse.error(`连接 Meilisearch 失败:${e.message}`));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`push_to_search failed: ${e.message}`);
|
||||
return res.json(ApiResponse.error(`推送失败:${e.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user