From 316852274a7d239b2e7b513abef16e034d30c449 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Tue, 9 Jun 2026 19:44:51 +0800 Subject: [PATCH] =?UTF-8?q?=E8=80=B3=E6=9C=BA=E9=A2=91=E5=93=8D=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E4=B8=BAs3=20=E5=AD=98=E5=82=A8=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86x9=20=E5=8D=87=E7=BA=A7=E5=8C=85?= =?UTF-8?q?=E7=9A=84=E5=AD=98=E5=82=A8=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 15 +-- backend/src/config/database.js | 3 +- backend/src/config/env.js | 12 +++ backend/src/routes/models.js | 31 +++--- backend/src/services/measurementStorage.js | 85 +++++++++++++++ backend/src/services/otaStorage.js | 34 +++--- docker-compose.yml | 6 +- scripts/upload.sh | 120 +++++++++++++++++++++ 8 files changed, 263 insertions(+), 43 deletions(-) create mode 100644 backend/src/config/env.js create mode 100644 backend/src/services/measurementStorage.js create mode 100755 scripts/upload.sh diff --git a/backend/.env.example b/backend/.env.example index dde6f8b..9ca0885 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/src/config/database.js b/backend/src/config/database.js index 851138e..a0e4f85 100644 --- a/backend/src/config/database.js +++ b/backend/src/config/database.js @@ -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, diff --git a/backend/src/config/env.js b/backend/src/config/env.js new file mode 100644 index 0000000..ce8cda9 --- /dev/null +++ b/backend/src/config/env.js @@ -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 }; diff --git a/backend/src/routes/models.js b/backend/src/routes/models.js index 965a7fc..99ae322 100644 --- a/backend/src/routes/models.js +++ b/backend/src/routes/models.js @@ -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; diff --git a/backend/src/services/measurementStorage.js b/backend/src/services/measurementStorage.js new file mode 100644 index 0000000..a04ed76 --- /dev/null +++ b/backend/src/services/measurementStorage.js @@ -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} 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, +}; diff --git a/backend/src/services/otaStorage.js b/backend/src/services/otaStorage.js index 8a759e4..fd949f2 100644 --- a/backend/src/services/otaStorage.js +++ b/backend/src/services/otaStorage.js @@ -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( diff --git a/docker-compose.yml b/docker-compose.yml index 6af669a..349d4ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/scripts/upload.sh b/scripts/upload.sh new file mode 100755 index 0000000..21356b8 --- /dev/null +++ b/scripts/upload.sh @@ -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 "完成!"