Files
dashboard/backend/src/routes/models.js
T
2026-07-10 10:16:24 +08:00

668 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 型号路由 — 对应 Python: routes/models.py
*/
const router = require('express').Router();
const { Op } = require('sequelize');
const multer = require('multer');
const path = require('path');
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');
const { uploadMeasurementToS3, getMeasurementFromS3, moveMeasurementOnS3 } = require('../services/measurementStorage');
const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage');
const { fetchFromSquigLink, downloadTxtFile } = require('../services/squiglink');
/**
* 将 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 && /^[-+]?\d/.test(parts[0])) {
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';
function meilisearchHeaders() {
return {
Authorization: `Bearer ${MEILISEARCH_API_KEY}`,
'Content-Type': 'application/json',
};
}
/** 从 Meilisearch 获取型号文档(文档 id 与数据库 id 一致) */
async function getModelFromMeilisearch(modelId) {
try {
const response = await axios.get(
`${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents/${modelId}`,
{ headers: meilisearchHeaders(), timeout: 30000 }
);
if (response.status !== 200) {
throw new Error(JSON.stringify(response.data));
}
return response.data;
} catch (e) {
if (e.response?.status === 404) {
return null;
}
const detail = e.response?.data ? JSON.stringify(e.response.data) : e.message;
throw new Error(detail);
}
}
/** 从 Meilisearch 删除型号文档(文档 id 与数据库 id 一致) */
async function deleteModelFromMeilisearch(modelId) {
try {
const response = await axios.delete(
`${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents/${modelId}`,
{ headers: meilisearchHeaders(), timeout: 30000 }
);
if (![200, 202].includes(response.status)) {
throw new Error(JSON.stringify(response.data));
}
logger.info(`Meilisearch document deleted: id=${modelId}, taskUid=${response.data?.taskUid}`);
return response.data;
} catch (e) {
if (e.response?.status === 404) {
logger.warn(`Meilisearch document not found, skip: id=${modelId}`);
return null;
}
const detail = e.response?.data ? JSON.stringify(e.response.data) : e.message;
throw new Error(detail);
}
}
// 文件上传配置
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/eq-cache — 查看 Redis EQ 缓存 field 列表
router.get('/api/models/:model_id/eq-cache', async (req, res) => {
try {
const modelId = parseInt(req.params.model_id, 10);
const model = await Model.findByPk(modelId);
if (!model) {
return res.json(ApiResponse.noData('empty'));
}
const { redis_key, field_keys } = await getEqCacheKeys(model.brand_name, model.name);
return res.json(ApiResponse.success({
redis_key,
field_keys,
}));
} catch (e) {
logger.error(`Error reading EQ cache for model ${req.params.model_id}: ${e.message}`);
return res.json(ApiResponse.error(e.message || 'error'));
}
});
// GET /api/models/:model_id/eq-cache/field?key= — 查看单个 hash field 的 value
router.get('/api/models/:model_id/eq-cache/field', async (req, res) => {
try {
const modelId = parseInt(req.params.model_id, 10);
const fieldKey = (req.query.key || '').trim();
if (!fieldKey) {
return res.json(ApiResponse.error('缺少 key 参数'));
}
const model = await Model.findByPk(modelId);
if (!model) {
return res.json(ApiResponse.noData('empty'));
}
const data = await getEqCacheField(model.brand_name, model.name, fieldKey);
return res.json(ApiResponse.success(data));
} catch (e) {
logger.error(`Error reading EQ cache field for model ${req.params.model_id}: ${e.message}`);
return res.json(ApiResponse.error(e.message || 'error'));
}
});
// GET /api/models/:model_id/meilisearch — 查看 Meilisearch 中的推送数据
router.get('/api/models/:model_id/meilisearch', async (req, res) => {
try {
const modelId = parseInt(req.params.model_id, 10);
const model = await Model.findByPk(modelId);
if (!model) {
return res.json(ApiResponse.noData('empty'));
}
const document = await getModelFromMeilisearch(modelId);
if (!document) {
return res.json(ApiResponse.success({ pushed: false, document: null }));
}
return res.json(ApiResponse.success({ pushed: true, document }));
} catch (e) {
logger.error(`Error reading Meilisearch document for model ${req.params.model_id}: ${e.message}`);
return res.json(ApiResponse.error(`查询 Meilisearch 失败:${e.message}`));
}
});
// GET /api/models/:model_id/measurement — 查看 S3 上的频响 CSV(仅 Eafonyoung
router.get('/api/models/:model_id/measurement', async (req, res) => {
try {
const modelId = parseInt(req.params.model_id, 10);
const model = await Model.findByPk(modelId);
if (!model) {
return res.json(ApiResponse.noData('empty'));
}
const source = (model.source || '').trim();
if (source.toLowerCase() !== 'eafonyoung') {
return res.json(ApiResponse.error('仅支持查看来源为 Eafonyoung 的频响文件'));
}
if (!model.form) {
return res.json(ApiResponse.error('该型号缺少佩戴方式,无法定位 S3 文件'));
}
const { key, content } = await getMeasurementFromS3({
source: model.source,
form: model.form,
brandName: model.brand_name,
modelName: model.name,
});
return res.json(ApiResponse.success({
s3_key: key,
content,
}));
} catch (e) {
logger.error(`Error reading measurement for model ${req.params.model_id}: ${e.message}`);
return res.json(ApiResponse.error(e.message || '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/squiglink-fetch — 从 squig.link share URL 抓取频响数据
router.post('/api/models/squiglink-fetch', async (req, res) => {
try {
const { share_url, selected_file } = req.body;
if (!share_url) {
return res.json(ApiResponse.error('缺少 share_url 参数'));
}
logger.info(`SquigLink fetch: ${share_url}${selected_file ? ' (selected=' + selected_file + ')' : ''}`);
// 如果用户从候选列表中选择了特定文件,直接下载
if (selected_file) {
const { parseShareUrl, detectFormFromUrl } = require('../services/squiglink');
const { baseUrl } = parseShareUrl(share_url);
const [downloadResult, detectedForm] = await Promise.all([
downloadTxtFile(baseUrl, selected_file),
detectFormFromUrl(baseUrl),
]);
const csvContent = convertTxtToCsv(downloadResult.buffer);
// 从 file 名解析品牌和型号
const parts = selected_file.split(' ');
const brandName = parts.length > 1 ? parts[0] : '';
const modelName = parts.length > 1 ? parts.slice(1).join(' ') : selected_file;
return res.json(ApiResponse.success({
brand_name: brandName,
model_name: modelName,
form: detectedForm || null,
csv_content: csvContent,
data_url: downloadResult.url,
matches: [],
}));
}
// 自动抓取
const result = await fetchFromSquigLink(share_url);
// 多个候选,返回给前端选择
if (result.matches.length > 1) {
return res.json(ApiResponse.success({
brand_name: '',
model_name: '',
form: result.form || null,
csv_content: null,
data_url: null,
matches: result.matches,
}));
}
// 单个匹配,返回 CSV 内容
const csvContent = convertTxtToCsv(result.buffer);
return res.json(ApiResponse.success({
brand_name: result.brandName,
model_name: result.modelName,
form: result.form || null,
csv_content: csvContent,
data_url: result.dataUrl,
matches: [],
}));
} catch (e) {
logger.error(`SquigLink fetch error: ${e.message}`);
return res.json(ApiResponse.error(e.message || '抓取失败'));
}
});
// 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('该品牌下型号名称已存在'));
}
// 处理文件上传(优先 squiglink_csv,其次文件上传)
const { squiglink_csv } = req.body;
if (squiglink_csv) {
// 从 squig.link 抓取的 CSV 内容直接上传 S3
const csvBuffer = Buffer.from(squiglink_csv, 'utf-8');
const s3Key = await uploadMeasurementToS3(csvBuffer, {
source: source || 'Eafonyoung',
form: form || 'in-ear',
brandName: brand_name,
modelName: name,
});
logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`);
} else 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 } = processUploadedFile(req.file.buffer, req.file.originalname);
const s3Key = await uploadMeasurementToS3(fileBuffer, {
source: source || 'Eafonyoung',
form: form || 'in-ear',
brandName: brand_name,
modelName: name,
});
logger.info(`Measurement file uploaded to S3: ${s3Key}`);
}
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;
// 处理频响文件(优先 squiglink_csv,其次文件上传)
const { squiglink_csv } = req.body;
if (squiglink_csv) {
if (!effSource || !effForm) {
return res.json(ApiResponse.error('上传频响文件需要来源与形式字段'));
}
const csvBuffer = Buffer.from(squiglink_csv, 'utf-8');
const s3Key = await uploadMeasurementToS3(csvBuffer, {
source: effSource,
form: effForm,
brandName: newBrandName,
modelName: newName,
});
logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`);
} else 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 } = processUploadedFile(req.file.buffer, req.file.originalname);
const s3Key = await uploadMeasurementToS3(fileBuffer, {
source: effSource,
form: effForm,
brandName: newBrandName,
modelName: newName,
});
logger.info(`Measurement file uploaded to S3: ${s3Key}`);
}
// 若未上传新文件,且路径相关字段发生变更,则迁移 S3 上的旧 CSV
const hasNewFile = squiglink_csv || (req.file && req.file.originalname);
if (!hasNewFile) {
await moveMeasurementOnS3(
{ source: dbModel.source, form: dbModel.form, brandName: dbModel.brand_name, modelName: dbModel.name },
{ source: effSource, form: effForm, brandName: newBrandName, modelName: newName }
);
}
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'));
}
try {
await deleteModelFromMeilisearch(modelId);
} catch (e) {
logger.error(`Meilisearch delete failed for model id=${modelId}: ${e.message}`);
return res.json(ApiResponse.error(`从 Meilisearch 删除失败:${e.message}`));
}
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;
});
try {
const response = await axios.post(
`${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents`,
pushData,
{ headers: meilisearchHeaders(), 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;