优化体验,新增多项功能,美化界面

This commit is contained in:
eafonyang
2026-06-10 15:37:20 +08:00
parent bb1c851e39
commit 25a45c7cbd
18 changed files with 1213 additions and 302 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 入口文件 — 对应 Python: main.py
*/
require('dotenv').config({ override: true });
require('./config/loadEnv');
const express = require('express');
const cors = require('cors');
+14
View File
@@ -0,0 +1,14 @@
/**
* 统一从项目根目录加载 .envdashboard/.env
* - 本地开发:读取根目录 .env
* - Docker:由 compose env_file 注入,容器内无 .env 文件,不覆盖已有环境变量
*/
const path = require('path');
const fs = require('fs');
const dotenv = require('dotenv');
const rootEnvPath = path.resolve(__dirname, '../../../.env');
if (fs.existsSync(rootEnvPath)) {
dotenv.config({ path: rootEnvPath });
}
+31
View File
@@ -0,0 +1,31 @@
const Redis = require('ioredis');
const logger = require('./logger');
const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1';
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
const REDIS_PASSWORD = process.env.REDIS_PASSWORD || '';
const REDIS_EQ_DB = parseInt(process.env.REDIS_EQ_DB || '1', 10);
let eqCacheClient = null;
function getEqCacheRedis() {
if (!eqCacheClient) {
const options = {
host: REDIS_HOST,
port: REDIS_PORT,
db: REDIS_EQ_DB,
maxRetriesPerRequest: 2,
connectTimeout: 10000,
};
if (REDIS_PASSWORD) {
options.password = REDIS_PASSWORD;
}
eqCacheClient = new Redis(options);
eqCacheClient.on('error', (err) => {
logger.error(`Redis EQ cache error: ${err.message}`);
});
}
return eqCacheClient;
}
module.exports = { getEqCacheRedis };
+156 -7
View File
@@ -11,7 +11,8 @@ const logger = require('../config/logger');
const { ApiResponse } = require('../utils/response');
const { authMiddleware } = require('../middleware/auth');
const { fetchAndValidateCurve } = require('../services/curveClient');
const { uploadMeasurementToS3 } = require('../services/measurementStorage');
const { uploadMeasurementToS3, getMeasurementFromS3 } = require('../services/measurementStorage');
const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage');
/**
* 将 TXT 频响文件内容转换为 CSV 格式
@@ -77,6 +78,55 @@ 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'];
@@ -130,6 +180,103 @@ router.get('/api/models/', async (req, res) => {
}
});
// 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 {
@@ -300,6 +447,13 @@ router.delete('/api/models/:model_id', async (req, res) => {
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'));
@@ -386,16 +540,11 @@ router.post('/api/models/push-to-search', async (req, res) => {
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 }
{ headers: meilisearchHeaders(), timeout: 30000 }
);
if (![200, 202].includes(response.status)) {
+72
View File
@@ -0,0 +1,72 @@
const { getEqCacheRedis } = require('../config/redis');
const logger = require('../config/logger');
/**
* EQ 缓存 Redis Key{品牌名称} {型号名称}
*/
function buildEqCacheRedisKey(brandName, modelName) {
return `${brandName} ${modelName}`;
}
function parseHashValue(raw) {
if (raw === null || raw === undefined) return null;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
/**
* 读取型号 EQ 缓存的 hash field 列表(不含 value,避免响应过大)
* @returns {Promise<{ redis_key: string, field_keys: string[] }>}
*/
async function getEqCacheKeys(brandName, modelName) {
const redisKey = buildEqCacheRedisKey(brandName, modelName);
const redis = getEqCacheRedis();
try {
const exists = await redis.exists(redisKey);
if (!exists) {
logger.info(`EQ cache not found: ${redisKey}`);
return { redis_key: redisKey, field_keys: [] };
}
const fieldKeys = await redis.hkeys(redisKey);
logger.info(`EQ cache loaded: ${redisKey}, fields=${fieldKeys.length}`);
return { redis_key: redisKey, field_keys: fieldKeys };
} catch (e) {
logger.error(`EQ cache read failed for ${redisKey}: ${e.message}`);
throw new Error(`Redis 读取失败:${e.message}`);
}
}
/**
* 读取单个 hash field 的 value
*/
async function getEqCacheField(brandName, modelName, fieldKey) {
const redisKey = buildEqCacheRedisKey(brandName, modelName);
const redis = getEqCacheRedis();
try {
const raw = await redis.hget(redisKey, fieldKey);
if (raw === null) {
throw new Error('缓存字段不存在');
}
return {
redis_key: redisKey,
key: fieldKey,
value: parseHashValue(raw),
};
} catch (e) {
if (e.message === '缓存字段不存在') throw e;
logger.error(`EQ cache field read failed for ${redisKey}/${fieldKey}: ${e.message}`);
throw new Error(`Redis 读取失败:${e.message}`);
}
}
module.exports = {
buildEqCacheRedisKey,
getEqCacheKeys,
getEqCacheField,
};
+30 -1
View File
@@ -3,7 +3,7 @@
* 本地开发环境:使用 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
* 正式环境:通过 IAM 角色访问 S3(无需配置密钥)
*/
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const logger = require('../config/logger');
const AWS_REGION = process.env.AWS_REGION || 'eu-central-1';
@@ -79,7 +79,36 @@ async function uploadMeasurementToS3(buffer, params) {
}
}
/**
* 从 S3 读取频响 CSV 文件
* @param {object} params - 路径参数(同 buildMeasurementKey
* @returns {Promise<{ key: string, content: string }>}
*/
async function getMeasurementFromS3(params) {
const key = buildMeasurementKey(params);
const client = getS3Client();
try {
const response = await client.send(
new GetObjectCommand({
Bucket: S3_BUCKET,
Key: key,
})
);
const content = await response.Body.transformToString('utf-8');
logger.info(`Measurement read from s3://${S3_BUCKET}/${key}`);
return { key, content };
} catch (e) {
if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) {
throw new Error('S3 上未找到该型号的频响文件');
}
logger.error(`S3 measurement read failed: ${e.message}`);
throw new Error(`S3 读取失败:${e.message}`);
}
}
module.exports = {
uploadMeasurementToS3,
getMeasurementFromS3,
buildMeasurementKey,
};