耳机频响文件改为s3 存储,修改了x9 升级包的存储路径
This commit is contained in:
@@ -7,7 +7,7 @@ DATABASE_PASSWORD=root123
|
||||
|
||||
# Application Settings
|
||||
APP_NAME=Audio Dashboard API
|
||||
DEBUG=True
|
||||
APP_ENV=development
|
||||
PORT=8083
|
||||
|
||||
# JWT
|
||||
@@ -20,19 +20,22 @@ MEILISEARCH_URL=http://localhost:7700
|
||||
MEILISEARCH_API_KEY=
|
||||
MEILISEARCH_INDEX=models
|
||||
|
||||
# AWS S3 (OTA X8)
|
||||
# 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
|
||||
OTA_UPLOAD_DIR_DEV=
|
||||
OTA_UPLOAD_DIR_PROD=/data/project/dashboard/upload
|
||||
# X9 升级包存储根目录(正式环境默认 /data/projects/source,路径规则:{root}/ota/{YYYYMM}/x9/{md5前5位}/LUXSIN.PKG)
|
||||
# 开发环境自动使用系统临时目录,无需配置
|
||||
OTA_UPLOAD_DIR=/data/projects/source
|
||||
|
||||
# File Upload
|
||||
UPLOAD_FOLDER=/data/project/autoeq/measurements
|
||||
# File Upload (S3)
|
||||
# 频响文件上传到 S3: autoeq/measurements/{source}/data/{form}/{brandChar}/{brand model}.csv
|
||||
# 正式环境通过 IAM 角色访问 S3,无需配置 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
const { isDevelopment } = require('./env');
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DATABASE_NAME || 'audio',
|
||||
@@ -11,7 +12,7 @@ const sequelize = new Sequelize(
|
||||
dialectOptions: {
|
||||
charset: 'utf8mb4',
|
||||
},
|
||||
logging: process.env.DEBUG === 'True' ? (msg) => console.log(msg) : false,
|
||||
logging: isDevelopment ? (msg) => console.log(msg) : false,
|
||||
define: {
|
||||
timestamps: false,
|
||||
freezeTableName: true,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 统一环境判断工具
|
||||
* 通过 APP_ENV 环境变量区分开发/正式环境
|
||||
* - development: 本地开发
|
||||
* - production: 正式环境(Docker 部署)
|
||||
*/
|
||||
const APP_ENV = (process.env.APP_ENV || 'development').trim().toLowerCase();
|
||||
|
||||
const isDevelopment = APP_ENV === 'development';
|
||||
const isProduction = APP_ENV === 'production';
|
||||
|
||||
module.exports = { APP_ENV, isDevelopment, isProduction };
|
||||
@@ -5,13 +5,13 @@ const router = require('express').Router();
|
||||
const { Op } = require('sequelize');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
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 } = require('../services/measurementStorage');
|
||||
|
||||
/**
|
||||
* 将 TXT 频响文件内容转换为 CSV 格式
|
||||
@@ -78,7 +78,6 @@ const MEILISEARCH_API_KEY = process.env.MEILISEARCH_API_KEY || '';
|
||||
const MEILISEARCH_INDEX = process.env.MEILISEARCH_INDEX || 'models';
|
||||
|
||||
// 文件上传配置
|
||||
const UPLOAD_FOLDER = process.env.UPLOAD_FOLDER || '/data/project/autoeq/measurements';
|
||||
const ALLOWED_EXTENSIONS = ['.csv', '.txt', '.json'];
|
||||
|
||||
// GET /api/models/
|
||||
@@ -178,12 +177,14 @@ router.post('/api/models/', upload.single('measurement_file'), async (req, res)
|
||||
return res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`));
|
||||
}
|
||||
|
||||
const { buffer: fileBuffer, filename: savedFilename } = processUploadedFile(req.file.buffer, req.file.originalname);
|
||||
const saveDir = path.join(UPLOAD_FOLDER, source || '', 'data', form || '');
|
||||
fs.mkdirSync(saveDir, { recursive: true });
|
||||
const filePath = path.join(saveDir, savedFilename);
|
||||
fs.writeFileSync(filePath, fileBuffer);
|
||||
logger.info(`File saved: ${filePath}`);
|
||||
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({
|
||||
@@ -251,12 +252,14 @@ router.put('/api/models/:model_id', upload.single('measurement_file'), async (re
|
||||
if (!effSource || !effForm) {
|
||||
return res.json(ApiResponse.error('上传频响文件需要来源与形式字段'));
|
||||
}
|
||||
const { buffer: fileBuffer, filename: savedFilename } = processUploadedFile(req.file.buffer, req.file.originalname);
|
||||
const saveDir = path.join(UPLOAD_FOLDER, effSource, 'data', effForm);
|
||||
fs.mkdirSync(saveDir, { recursive: true });
|
||||
const filePath = path.join(saveDir, savedFilename);
|
||||
fs.writeFileSync(filePath, fileBuffer);
|
||||
logger.info(`File saved: ${filePath}`);
|
||||
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}`);
|
||||
}
|
||||
|
||||
if (brand_name !== undefined && brand_name !== 'null') dbModel.brand_name = brand_name;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 频响文件 S3 存储服务
|
||||
* 本地开发环境:使用 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
|
||||
* 正式环境:通过 IAM 角色访问 S3(无需配置密钥)
|
||||
*/
|
||||
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const AWS_REGION = process.env.AWS_REGION || 'eu-central-1';
|
||||
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || '';
|
||||
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || '';
|
||||
const S3_BUCKET = process.env.AWS_S3_MEASUREMENT_BUCKET || process.env.AWS_S3_OTA_BUCKET || 'luxsin-app-bucket';
|
||||
|
||||
/**
|
||||
* 获取 S3 客户端
|
||||
* 有显式凭证则用凭证,否则走 IAM 角色
|
||||
*/
|
||||
function getS3Client() {
|
||||
const options = { region: AWS_REGION };
|
||||
if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) {
|
||||
options.credentials = {
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
};
|
||||
}
|
||||
return new S3Client(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取品牌首字符(字母则转大写)
|
||||
*/
|
||||
function getBrandFirstChar(brandName) {
|
||||
if (!brandName || !brandName.length) return '_';
|
||||
const first = brandName[0];
|
||||
return /[a-zA-Z]/.test(first) ? first.toUpperCase() : first;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 S3 Key
|
||||
* 路径:autoeq/measurements/{source}/data/{form}/{brandFirstChar}/{brand model}.csv
|
||||
* @param {object} params
|
||||
* @param {string} params.source - 来源,默认 Eafonyoung
|
||||
* @param {string} params.form - 佩戴方式 (in-ear / over-ear / earbud)
|
||||
* @param {string} params.brandName - 品牌名称
|
||||
* @param {string} params.modelName - 型号名称
|
||||
* @returns {string} S3 Key
|
||||
*/
|
||||
function buildMeasurementKey({ source, form, brandName, modelName }) {
|
||||
const src = source || 'Eafonyoung';
|
||||
const brandChar = getBrandFirstChar(brandName);
|
||||
const fileName = `${brandName} ${modelName}.csv`;
|
||||
return `autoeq/measurements/${src}/data/${form}/${brandChar}/${fileName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传频响文件到 S3
|
||||
* @param {Buffer} buffer - 文件内容(已转换为 CSV)
|
||||
* @param {object} params - 路径参数
|
||||
* @returns {Promise<string>} S3 Key
|
||||
*/
|
||||
async function uploadMeasurementToS3(buffer, params) {
|
||||
const key = buildMeasurementKey(params);
|
||||
const client = getS3Client();
|
||||
|
||||
try {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: 'text/csv',
|
||||
})
|
||||
);
|
||||
logger.info(`Measurement uploaded to s3://${S3_BUCKET}/${key}`);
|
||||
return key;
|
||||
} catch (e) {
|
||||
logger.error(`S3 measurement upload failed: ${e.message}`);
|
||||
throw new Error(`S3 上传失败:${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadMeasurementToS3,
|
||||
buildMeasurementKey,
|
||||
};
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* OTA 升级包存储:Luxsin-X8 -> S3,Luxsin-X9 -> 本地目录
|
||||
* 对应 Python: ota_storage.py
|
||||
* S3 客户端:有显式凭证用凭证(本地开发),无凭证走 IAM 角色(正式环境)
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const logger = require('../config/logger');
|
||||
const { isDevelopment } = require('../config/env');
|
||||
|
||||
const OTA_MODEL_X8 = 'Luxsin-X8';
|
||||
const OTA_MODEL_X9 = 'Luxsin-X9';
|
||||
const OTA_UPLOAD_MODELS = new Set([OTA_MODEL_X8, OTA_MODEL_X9]);
|
||||
|
||||
const _OTA_UPLOAD_DIR_DEV_DEFAULT = 'H:/soft/projects/luxsin/dashboard/ota';
|
||||
const _OTA_UPLOAD_DIR_PROD_DEFAULT = '/data/project/dashboard/upload';
|
||||
const OTA_UPLOAD_DIR = process.env.OTA_UPLOAD_DIR
|
||||
|| (isDevelopment ? path.join(require('os').tmpdir(), 'dashboard') : '/data/projects/source');
|
||||
|
||||
const AWS_REGION = process.env.AWS_REGION || 'eu-central-1';
|
||||
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || '';
|
||||
@@ -27,11 +28,7 @@ const OTA_FILENAME_X8 = 'LUXSIN_X8.PKG';
|
||||
const OTA_FILENAME_X9 = 'LUXSIN.PKG';
|
||||
|
||||
function getOtaUploadDir() {
|
||||
const debugVal = (process.env.DEBUG || 'false').trim().toLowerCase();
|
||||
const isDev = ['true', '1', 'yes', 'on'].includes(debugVal);
|
||||
const dev = process.env.OTA_UPLOAD_DIR_DEV || _OTA_UPLOAD_DIR_DEV_DEFAULT;
|
||||
const prod = process.env.OTA_UPLOAD_DIR_PROD || _OTA_UPLOAD_DIR_PROD_DEFAULT;
|
||||
return isDev ? dev : prod;
|
||||
return OTA_UPLOAD_DIR;
|
||||
}
|
||||
|
||||
function md5Prefix5(md5Hex) {
|
||||
@@ -52,10 +49,12 @@ async function readUploadContentAndMd5(fileBuffer) {
|
||||
}
|
||||
|
||||
function saveX9PackageLocal(content, md5Hex) {
|
||||
const root = getOtaUploadDir();
|
||||
const prefix = md5Prefix5(md5Hex);
|
||||
const ym = new Date().toISOString().slice(0, 7).replace('-', '');
|
||||
const destDir = path.join(root, prefix);
|
||||
|
||||
// 正式环境: /data/projects/source/ota/{YYYYMM}/x9/{md5前5位}/LUXSIN.PKG
|
||||
// 开发环境: {tmpdir}/dashboard/ota/{YYYYMM}/x9/{md5前5位}/LUXSIN.PKG
|
||||
const destDir = path.join(OTA_UPLOAD_DIR, 'ota', ym, 'x9', prefix);
|
||||
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const finalPath = path.join(destDir, OTA_FILENAME_X9);
|
||||
@@ -71,21 +70,18 @@ function saveX9PackageLocal(content, md5Hex) {
|
||||
}
|
||||
|
||||
async function uploadX8PackageToS3(content, md5Hex) {
|
||||
if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) {
|
||||
throw new Error('未配置 AWS 访问密钥(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)');
|
||||
}
|
||||
|
||||
const ym = new Date().toISOString().slice(0, 7).replace('-', '');
|
||||
const prefix = md5Prefix5(md5Hex);
|
||||
const s3Key = `ota/${ym}/x8/${prefix}/${OTA_FILENAME_X8}`;
|
||||
|
||||
const client = new S3Client({
|
||||
region: AWS_REGION,
|
||||
credentials: {
|
||||
const options = { region: AWS_REGION };
|
||||
if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) {
|
||||
options.credentials = {
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
const client = new S3Client(options);
|
||||
|
||||
try {
|
||||
await client.send(
|
||||
|
||||
+3
-3
@@ -13,8 +13,8 @@ services:
|
||||
- DATABASE_USER=${DATABASE_USER:-root}
|
||||
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-root123}
|
||||
- APP_NAME=${APP_NAME:-Audio Dashboard API}
|
||||
- DEBUG=${DEBUG:-True}
|
||||
- MEILISEARCH_URL=${MEILISEARCH_URL:-http://ec2-18-184-205-87.eu-central-1.compute.amazonaws.com:7700}
|
||||
- APP_ENV=${APP_ENV:-production}
|
||||
- MEILISEARCH_URL=${MEILISEARCH_URL:-http://ip-172-31-22-170.eu-central-1.compute.internal:7700}
|
||||
- MEILISEARCH_API_KEY=${MEILISEARCH_API_KEY:-young9#!UJsD219921031}
|
||||
- MEILISEARCH_INDEX=${MEILISEARCH_INDEX:-models}
|
||||
ports:
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
networks:
|
||||
- audio-network
|
||||
volumes:
|
||||
- /data/project/autoeq/measurements:/data/project/autoeq/measurements
|
||||
- /data/project/dashboard/upload:/data/project/dashboard/upload
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# 部署上传脚本
|
||||
# 将前端/后端/docker-compose 文件上传至正式服务器
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/upload.sh # 上传所有
|
||||
# ./scripts/upload.sh frontend # 仅上传前端
|
||||
# ./scripts/upload.sh backend # 仅上传后端
|
||||
# ./scripts/upload.sh compose # 仅上传 docker-compose
|
||||
# ./scripts/upload.sh ./frontend/dist/assets # 上传指定路径
|
||||
# ============================================================
|
||||
|
||||
SERVER="ubuntu@ec2-3-71-153-45.eu-central-1.compute.amazonaws.com"
|
||||
REMOTE_ROOT="/data/project/dashboard"
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SSH_KEY="$HOME/.ssh/aws_eafon.pem"
|
||||
|
||||
# SSH / SCP 通用选项
|
||||
SSH_OPTS="-i $SSH_KEY -o StrictHostKeyChecking=no"
|
||||
SCP_OPTS="-i $SSH_KEY -o StrictHostKeyChecking=no"
|
||||
|
||||
# 颜色输出
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
|
||||
# ---- 上传函数 ----
|
||||
|
||||
upload_frontend() {
|
||||
info "上传前端 dist/ 和 nginx.conf ..."
|
||||
ssh $SSH_OPTS "$SERVER" "mkdir -p ${REMOTE_ROOT}/frontend"
|
||||
scp $SCP_OPTS -r "${PROJECT_ROOT}/frontend/dist" "${SERVER}:${REMOTE_ROOT}/frontend/"
|
||||
scp $SCP_OPTS "${PROJECT_ROOT}/frontend/nginx.conf" "${SERVER}:${REMOTE_ROOT}/frontend/"
|
||||
ok "前端上传完成"
|
||||
}
|
||||
|
||||
upload_backend() {
|
||||
info "上传后端 src/ 和 Dockerfile ..."
|
||||
ssh $SSH_OPTS "$SERVER" "mkdir -p ${REMOTE_ROOT}/backend"
|
||||
scp $SCP_OPTS -r "${PROJECT_ROOT}/backend/src" "${SERVER}:${REMOTE_ROOT}/backend/"
|
||||
scp $SCP_OPTS "${PROJECT_ROOT}/backend/Dockerfile" "${SERVER}:${REMOTE_ROOT}/backend/"
|
||||
ok "后端上传完成"
|
||||
}
|
||||
|
||||
upload_compose() {
|
||||
info "上传 docker-compose.yml ..."
|
||||
scp $SCP_OPTS "${PROJECT_ROOT}/docker-compose.yml" "${SERVER}:${REMOTE_ROOT}/"
|
||||
ok "docker-compose.yml 上传完成"
|
||||
}
|
||||
|
||||
upload_all() {
|
||||
upload_frontend
|
||||
upload_backend
|
||||
upload_compose
|
||||
}
|
||||
|
||||
# ---- 上传指定路径 ----
|
||||
# 根据传入的相对/绝对路径,映射到服务器对应位置
|
||||
# 例如: ./frontend/dist/assets → ${REMOTE_ROOT}/frontend/dist/assets
|
||||
|
||||
upload_path() {
|
||||
local input_path="$1"
|
||||
# 转为项目根目录下的相对路径
|
||||
local rel_path
|
||||
rel_path="$(cd "$(dirname "$input_path")" 2>/dev/null && realpath --relative-to="$PROJECT_ROOT" "$(pwd)/$(basename "$input_path")")" \
|
||||
|| rel_path="${input_path#"$PROJECT_ROOT"/}"
|
||||
|
||||
if [[ -z "$rel_path" || "$rel_path" == /* ]]; then
|
||||
# 如果去掉项目根前缀失败,尝试直接去掉 ./ 前缀
|
||||
rel_path="${input_path#./}"
|
||||
fi
|
||||
|
||||
local local_full="${PROJECT_ROOT}/${rel_path}"
|
||||
local remote_path="${REMOTE_ROOT}/${rel_path}"
|
||||
|
||||
if [[ ! -e "$local_full" ]]; then
|
||||
warn "路径不存在: ${local_full}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 确保远程父目录存在
|
||||
ssh $SSH_OPTS "$SERVER" "mkdir -p $(dirname "$remote_path")"
|
||||
|
||||
if [[ -d "$local_full" ]]; then
|
||||
info "上传目录: ${rel_path}/ → ${SERVER}:${remote_path}/"
|
||||
scp $SCP_OPTS -r "$local_full" "${SERVER}:${remote_path}"
|
||||
else
|
||||
info "上传文件: ${rel_path} → ${SERVER}:${remote_path}"
|
||||
scp $SCP_OPTS "$local_full" "${SERVER}:${remote_path}"
|
||||
fi
|
||||
ok "上传完成: ${rel_path}"
|
||||
}
|
||||
|
||||
# ---- 主逻辑 ----
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
info "上传全部文件至 ${SERVER}:${REMOTE_ROOT}/"
|
||||
upload_all
|
||||
else
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
frontend|f) upload_frontend ;;
|
||||
backend|b) upload_backend ;;
|
||||
compose|c) upload_compose ;;
|
||||
all|a) upload_all ;;
|
||||
*) upload_path "$arg" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "完成!"
|
||||
Reference in New Issue
Block a user