重构后端,改为 node 技术栈

This commit is contained in:
eafonyang
2026-06-09 16:36:23 +08:00
parent abab985769
commit e4e6529577
59 changed files with 4901 additions and 1916 deletions
+416
View File
@@ -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;