668 lines
23 KiB
JavaScript
668 lines
23 KiB
JavaScript
/**
|
||
* 型号路由 — 对应 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;
|