优化体验,新增多项功能,美化界面
This commit is contained in:
@@ -1,41 +0,0 @@
|
||||
# Database Configuration
|
||||
DATABASE_HOST=192.168.9.137
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_NAME=audio
|
||||
DATABASE_USER=root
|
||||
DATABASE_PASSWORD=root123
|
||||
|
||||
# Application Settings
|
||||
APP_NAME=Audio Dashboard API
|
||||
APP_ENV=development
|
||||
PORT=8083
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=dev-only-change-me-for-production
|
||||
DASHBOARD_ADMIN_USERNAME=admin
|
||||
DASHBOARD_ADMIN_PASSWORD=Eafon123
|
||||
|
||||
# Meilisearch
|
||||
MEILISEARCH_URL=http://localhost:7700
|
||||
MEILISEARCH_API_KEY=
|
||||
MEILISEARCH_INDEX=models
|
||||
|
||||
# AWS S3
|
||||
AWS_REGION=eu-central-1
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_S3_OTA_BUCKET=luxsin-app-bucket
|
||||
AWS_S3_MEASUREMENT_BUCKET=luxsin-app-bucket
|
||||
|
||||
# OTA URLs
|
||||
OTA_X8_PUBLIC_BASE=http://am.luxsinaudio.com
|
||||
OTA_X9_URL_BASE=http://source.luxsin.net
|
||||
|
||||
# OTA Upload Directories
|
||||
# X9 升级包存储根目录(正式环境默认 /data/projects/source,路径规则:{root}/ota/{YYYYMM}/x9/{md5前5位}/LUXSIN.PKG)
|
||||
# 开发环境自动使用系统临时目录,无需配置
|
||||
OTA_UPLOAD_DIR=/data/projects/source
|
||||
|
||||
# File Upload (S3)
|
||||
# 频响文件上传到 S3: autoeq/measurements/{source}/data/{form}/{brandChar}/{brand model}.csv
|
||||
# 正式环境通过 IAM 角色访问 S3,无需配置 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
|
||||
+1
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,7 +11,6 @@ RUN corepack enable pnpm && pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Copy application code
|
||||
COPY src/ ./src/
|
||||
COPY .env ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "audio-dashboard-api",
|
||||
"version": "1.0.0",
|
||||
"description": "耳机品牌与型号管理平台后端 API",
|
||||
"packageManager": "pnpm@11.5.2",
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
@@ -18,7 +19,8 @@
|
||||
"dotenv": "^16.4",
|
||||
"zod": "^3.23",
|
||||
"@aws-sdk/client-s3": "^3.650",
|
||||
"winston": "^3.14"
|
||||
"winston": "^3.14",
|
||||
"ioredis": "^5.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1"
|
||||
|
||||
Generated
+57
-8
@@ -13,7 +13,7 @@ importers:
|
||||
version: 3.1063.0
|
||||
axios:
|
||||
specifier: ^1.7
|
||||
version: 1.17.0
|
||||
version: 1.17.0(supports-color@5.5.0)
|
||||
cors:
|
||||
specifier: ^2.8
|
||||
version: 2.8.6
|
||||
@@ -23,6 +23,9 @@ importers:
|
||||
express:
|
||||
specifier: ^4.21
|
||||
version: 4.22.2
|
||||
ioredis:
|
||||
specifier: ^5.6
|
||||
version: 5.11.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0
|
||||
version: 9.0.3
|
||||
@@ -34,7 +37,7 @@ importers:
|
||||
version: 3.22.5(@types/node@25.9.2)
|
||||
sequelize:
|
||||
specifier: ^6.37
|
||||
version: 6.37.8(mysql2@3.22.5(@types/node@25.9.2))
|
||||
version: 6.37.8(mysql2@3.22.5(@types/node@25.9.2))(supports-color@5.5.0)
|
||||
winston:
|
||||
specifier: ^3.14
|
||||
version: 3.19.0
|
||||
@@ -158,6 +161,9 @@ packages:
|
||||
'@dabh/diagnostics@2.0.8':
|
||||
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
|
||||
|
||||
'@ioredis/commands@1.10.0':
|
||||
resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
|
||||
|
||||
'@nodable/entities@2.1.1':
|
||||
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
|
||||
|
||||
@@ -295,6 +301,10 @@ packages:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
cluster-key-slot@1.1.1:
|
||||
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
color-convert@3.1.3:
|
||||
resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==}
|
||||
engines: {node: '>=14.6'}
|
||||
@@ -541,6 +551,10 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ioredis@5.11.1:
|
||||
resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -774,6 +788,14 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
retry-as-promised@7.1.1:
|
||||
resolution: {integrity: sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==}
|
||||
|
||||
@@ -870,6 +892,9 @@ packages:
|
||||
stack-trace@0.0.10:
|
||||
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1218,6 +1243,8 @@ snapshots:
|
||||
enabled: 2.0.0
|
||||
kuler: 2.0.0
|
||||
|
||||
'@ioredis/commands@1.10.0': {}
|
||||
|
||||
'@nodable/entities@2.1.1': {}
|
||||
|
||||
'@smithy/core@3.24.6':
|
||||
@@ -1292,7 +1319,7 @@ snapshots:
|
||||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
|
||||
agent-base@6.0.2:
|
||||
agent-base@6.0.2(supports-color@5.5.0):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
@@ -1313,11 +1340,11 @@ snapshots:
|
||||
|
||||
aws-ssl-profiles@1.1.2: {}
|
||||
|
||||
axios@1.17.0:
|
||||
axios@1.17.0(supports-color@5.5.0):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.5
|
||||
https-proxy-agent: 5.0.1
|
||||
https-proxy-agent: 5.0.1(supports-color@5.5.0)
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
@@ -1387,6 +1414,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
cluster-key-slot@1.1.1: {}
|
||||
|
||||
color-convert@3.1.3:
|
||||
dependencies:
|
||||
color-name: 2.1.0
|
||||
@@ -1627,9 +1656,9 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
https-proxy-agent@5.0.1(supports-color@5.5.0):
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
agent-base: 6.0.2(supports-color@5.5.0)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -1648,6 +1677,18 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ioredis@5.11.1:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.10.0
|
||||
cluster-key-slot: 1.1.1
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
denque: 2.1.0
|
||||
redis-errors: 1.2.0
|
||||
redis-parser: 3.0.0
|
||||
standard-as-callback: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
@@ -1878,6 +1919,12 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
retry-as-promised@7.1.1: {}
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
@@ -1910,7 +1957,7 @@ snapshots:
|
||||
|
||||
sequelize-pool@7.1.0: {}
|
||||
|
||||
sequelize@6.37.8(mysql2@3.22.5(@types/node@25.9.2)):
|
||||
sequelize@6.37.8(mysql2@3.22.5(@types/node@25.9.2))(supports-color@5.5.0):
|
||||
dependencies:
|
||||
'@types/debug': 4.1.13
|
||||
'@types/validator': 13.15.10
|
||||
@@ -1980,6 +2027,8 @@ snapshots:
|
||||
|
||||
stack-trace@0.0.10: {}
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
streamsearch@0.1.2: {}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 入口文件 — 对应 Python: main.py
|
||||
*/
|
||||
require('dotenv').config({ override: true });
|
||||
require('./config/loadEnv');
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 统一从项目根目录加载 .env(dashboard/.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 });
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user