/** * 型号路由 — 对应 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;