diff --git a/.env.example b/.env.example index 797c8e5..7b8b1dd 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,42 @@ DATABASE_PASSWORD=root123 # Application Settings APP_NAME=Audio Dashboard API -DEBUG=True +APP_ENV=development +# 本地开发用 8083;Docker 部署时 compose 会覆盖为容器内 8000 +PORT=8083 -# Meilisearch Configuration -MEILISEARCH_URL=http://ec2-18-184-205-87.eu-central-1.compute.amazonaws.com:7700 -MEILISEARCH_API_KEY=young9#!UJsD219921031 +# 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 + +# Redis EQ Cache +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_EQ_DB=1 + +# 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/DEPLOY.md b/DEPLOY.md index 6e632b1..2525fc7 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,257 +1,268 @@ -# 前端部署指南 +# 部署指南 -## 📦 部署方式 +Audio Dashboard 使用 **Docker Compose** 部署:前端为 Nginx 静态站点,后端为 **Node.js** 服务。 -### 方式一:本地构建后上传(推荐) +## 架构概览 -#### 1. 本地构建 +| 服务 | 说明 | 宿主机端口 | 容器内端口 | +|------|------|-----------|-----------| +| frontend | Nginx 托管 `frontend/dist` | 8082 | 80 | +| backend | Node.js API | 8083 | 8000 | + +- 前端通过 Nginx 将 `/api` 代理到 `http://backend:8000` +- 环境变量统一使用项目根目录 **`.env`**(与 `docker-compose.yml` 同级) +- **不要**再使用 `backend/.env` 或 Python 虚拟环境 + +--- + +## 首次部署(服务器准备) + +### 1. 创建目录 -**Windows:** ```bash -cd frontend -pnpm build -``` - -**或者使用脚本:** -```bash -cd frontend -.\build-and-deploy.bat -``` - -**Linux/Mac:** -```bash -cd frontend -pnpm build -``` - -构建完成后会在 `frontend/dist` 目录生成静态文件。 - -#### 2. 上传到服务器 - -将 `dist` 文件夹上传到服务器的指定目录,例如: -```bash -# 在服务器上创建目录 sudo mkdir -p /data/project/dashboard/frontend/dist - -# 上传文件(使用 scp、rsync 或 FTP 工具) -# 示例:使用 scp -scp -r dist/* user@server:/data/project/dashboard/frontend/dist/ - -# 或使用 rsync(推荐) -rsync -avz --progress dist/ user@server:/data/project/dashboard/frontend/dist/ +sudo mkdir -p /data/projects/source # X9 OTA 升级包存储 ``` -#### 3. 修改 docker-compose.yml +### 2. 配置环境变量 -确保使用简单的 Nginx 配置(不构建镜像): +在服务器 `/data/project/dashboard/` 创建 `.env`(参考 `.env.example`): -```yaml -version: '3.8' - -services: - backend: - build: - context: ./backend - dockerfile: Dockerfile - container_name: audio-dashboard-backend - environment: - - DATABASE_HOST=${DATABASE_HOST:-localhost} - - DATABASE_PORT=${DATABASE_PORT:-3306} - - DATABASE_NAME=${DATABASE_NAME:-audio} - - 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} - - MEILISEARCH_API_KEY=${MEILISEARCH_API_KEY:-young9#!UJsD219921031} - - MEILISEARCH_INDEX=${MEILISEARCH_INDEX:-models} - ports: - - "8083:8000" - restart: unless-stopped - networks: - - audio-network - - frontend: - image: nginx:alpine - container_name: audio-dashboard-frontend - ports: - - "8082:80" - volumes: - - ./frontend/dist:/usr/share/nginx/html:ro - - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro - depends_on: - - backend - restart: unless-stopped - networks: - - audio-network - -networks: - audio-network: - driver: bridge +```bash +cp .env.example .env +# 编辑 .env,填写数据库、JWT、Meilisearch、Redis、AWS 等配置 ``` -#### 4. 启动服务 +重要说明: + +- Docker 部署时,Compose 会将容器内 `PORT` 固定为 **8000**,与 Nginx 一致 +- 正式环境请修改 `JWT_SECRET`、`DASHBOARD_ADMIN_PASSWORD` +- 正式环境 S3 通常通过 EC2 **IAM 角色**访问,无需配置 `AWS_ACCESS_KEY_ID` +- `.env` **不会**被 `upload.sh` 上传,需在服务器单独维护 + +### 3. 上传代码并启动 + +见下方「本地构建 + 上传」章节。 + +--- + +## 本地构建 + 上传(推荐) + +### 1. 本地构建前端 + +```bash +cd frontend +pnpm install +pnpm build +``` + +构建产物位于 `frontend/dist/`。 + +### 2. 上传到服务器 + +**方式 A:使用上传脚本(推荐)** + +```bash +# 上传前端 dist、nginx.conf、后端代码、docker-compose.yml +./scripts/upload.sh all + +# 或分别上传 +./scripts/upload.sh frontend +./scripts/upload.sh backend +./scripts/upload.sh compose +``` + +脚本默认上传到 `ubuntu@<服务器>:/data/project/dashboard/`。可在 `scripts/upload.sh` 中修改 `SERVER`、`REMOTE_ROOT`、`SSH_KEY`。 + +**方式 B:手动 rsync** + +```bash +# 前端 +rsync -avz --progress frontend/dist/ user@server:/data/project/dashboard/frontend/dist/ +scp frontend/nginx.conf user@server:/data/project/dashboard/frontend/ + +# 后端(Docker 构建所需文件) +rsync -avz backend/src/ user@server:/data/project/dashboard/backend/src/ +scp backend/Dockerfile backend/package.json backend/pnpm-lock.yaml user@server:/data/project/dashboard/backend/ + +# Compose +scp docker-compose.yml user@server:/data/project/dashboard/ +``` + +### 3. 服务器上构建并启动 -在服务器上执行: ```bash cd /data/project/dashboard + +# 首次或依赖变更时 +docker compose build --no-cache backend + +# 启动 / 更新 docker compose up -d ``` -#### 5. 查看日志 +### 4. 验证 ```bash -# 查看所有服务日志 -docker compose logs -f - -# 查看前端日志 -docker compose logs -f frontend - -# 查看后端日志 +docker compose ps docker compose logs -f backend + +# 健康检查 +curl http://localhost:8083/health ``` -#### 6. 访问应用 +访问地址: -- 前端地址:http://服务器 IP:8082 -- 后端 API:http://服务器 IP:8083 -- API 文档:http://服务器 IP:8083/docs +- 前端:http://服务器 IP:8082 +- 后端 API:http://服务器 IP:8083/api/... +- 健康检查:http://服务器 IP:8083/health --- -### 方式二:使用 Docker 构建(备选) - -如果你想让 Docker 自动构建前端: - -1. 上传整个 `frontend` 目录到服务器 -2. 使用原始的 `docker-compose.yml`(包含 `build` 配置) -3. 执行 `docker compose build --no-cache frontend` -4. 执行 `docker compose up -d` - ---- - -## 🔄 更新部署 +## 更新部署 ### 仅更新前端 ```bash -# 1. 本地重新构建 -cd frontend -pnpm build +# 本地 +cd frontend && pnpm build +./scripts/upload.sh frontend -# 2. 上传 dist 目录到服务器 -rsync -avz --progress dist/ user@server:/data/project/dashboard/frontend/dist/ - -# 3. 重启前端容器 +# 服务器 docker compose restart frontend ``` ### 仅更新后端 ```bash -# 1. 上传修改的后端文件到服务器 +# 本地 +./scripts/upload.sh backend -# 2. 重新构建后端镜像 +# 服务器 docker compose build --no-cache backend - -# 3. 重启后端容器 docker compose up -d backend ``` ---- +### 一键:构建前端 + 上传 + 服务器部署 -## 📝 注意事项 - -1. **确保后端端口正确** - - 后端服务运行在 8083 端口 - - Nginx 配置中已设置代理到 `http://backend:8000`(容器内部) - -2. **文件权限** - - 确保 dist 目录有读取权限 - - Nginx 容器需要能读取 dist 目录中的文件 - -3. **跨域问题** - - Nginx 已配置 `/api` 代理,前端请求会自动转发到后端 - - 不需要额外配置 CORS - -4. **缓存问题** - - 静态资源已配置长期缓存(1 年) - - 更新后如果浏览器缓存未更新,可以强制刷新(Ctrl+F5) - ---- - -## 🛠️ 常见问题 - -### Q: 前端页面空白 -A: 打开浏览器控制台查看错误,可能是: -- 后端服务未启动 -- API 代理配置错误 -- 静态文件路径错误 - -### Q: API 请求失败 -A: 检查: -- 后端容器是否正常运行:`docker compose ps` -- 后端日志是否有错误:`docker compose logs backend` -- 网络连接是否正常 - -### Q: 如何停止服务 -A: ```bash -# 停止所有服务 -docker compose down +pnpm --dir frontend build && ./scripts/upload.sh all -# 停止并删除数据卷(谨慎使用) -docker compose down -v -``` - -### Q: 如何查看服务状态 -A: -```bash -# 查看运行状态 -docker compose ps - -# 查看磁盘使用 -docker system df +# 服务器上执行 +cd /data/project/dashboard && docker compose build --no-cache backend && docker compose up -d ``` --- -## 📂 目录结构 +## docker-compose.yml 说明 + +当前配置要点: + +- **backend**:`env_file: .env` 读取根目录环境变量;`PORT=8000` 覆盖容器内端口 +- **frontend**:挂载服务器上的 `dist` 与 `nginx.conf`,不构建前端镜像 +- **日志轮转**:每个服务 `max-size: 10m`,`max-file: 5` +- **OTA 卷**:`/data/projects/source` 挂载到后端容器,供 X9 升级包本地存储 + +```bash +# 修改 compose 后 +./scripts/upload.sh compose +docker compose up -d +``` + +--- + +## 后端部署文件清单 + +`docker compose build backend` 需要以下文件: + +``` +backend/ +├── Dockerfile +├── package.json +├── pnpm-lock.yaml +└── src/ +``` + +`upload.sh backend` 已包含上述全部文件。 + +--- + +## 环境变量参考 + +完整示例见项目根目录 `.env.example`,主要包括: + +| 分类 | 变量 | +|------|------| +| 数据库 | `DATABASE_HOST`、`DATABASE_PORT`、`DATABASE_NAME`、`DATABASE_USER`、`DATABASE_PASSWORD` | +| 应用 | `APP_NAME`、`APP_ENV` | +| 认证 | `JWT_SECRET`、`DASHBOARD_ADMIN_USERNAME`、`DASHBOARD_ADMIN_PASSWORD` | +| 搜索 | `MEILISEARCH_URL`、`MEILISEARCH_API_KEY`、`MEILISEARCH_INDEX` | +| S3 | `AWS_REGION`、`AWS_S3_OTA_BUCKET`、`AWS_S3_MEASUREMENT_BUCKET` | +| OTA | `OTA_X8_PUBLIC_BASE`、`OTA_X9_URL_BASE`、`OTA_UPLOAD_DIR` | +| Redis EQ | `REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD`、`REDIS_EQ_DB` | + +--- + +## 目录结构(服务器) ``` /data/project/dashboard/ -├── docker-compose.yml # Docker 编排配置 +├── .env # 环境变量(仅服务器维护,勿提交 Git) +├── docker-compose.yml ├── backend/ -│ ├── Dockerfile # 后端镜像配置 -│ ├── main.py # 后端主程序 -│ ├── requirements.txt # Python 依赖 -│ └── ... +│ ├── Dockerfile +│ ├── package.json +│ ├── pnpm-lock.yaml +│ └── src/ └── frontend/ - ├── dist/ # 构建产物(上传到这里) - │ ├── index.html - │ ├── assets/ - │ └── ... - └── nginx.conf # Nginx 配置 + ├── dist/ # 前端构建产物 + └── nginx.conf + +/data/projects/source/ # X9 OTA 包存储(宿主机) ``` --- -## 🚀 快速部署命令汇总 +## 常见问题 + +### 前端页面空白 + +- 检查 `frontend/dist` 是否已上传且路径正确 +- 浏览器控制台是否有 404 / API 错误 +- `docker compose logs frontend` + +### API 请求失败 + +- `docker compose ps` 确认 backend 为 running +- `docker compose logs backend` 查看数据库、Redis、Meilisearch 连接错误 +- 确认 Nginx 代理目标为 `http://backend:8000`(见 `frontend/nginx.conf`) + +### 后端构建失败(pnpm / Node 版本) + +- 后端镜像基于 `node:22-alpine`,`package.json` 中已固定 `packageManager: pnpm@11.5.2` +- 确保上传了 `package.json` 和 `pnpm-lock.yaml` + +### 修改 .env 后不生效 ```bash -# 1. 本地构建 -cd frontend && pnpm build - -# 2. 上传到服务器 -rsync -avz --progress dist/ user@server:/data/project/dashboard/frontend/dist/ - -# 3. 在服务器上启动 -cd /data/project/dashboard -docker compose up -d - -# 4. 查看状态 -docker compose ps - -# 5. 查看日志 -docker compose logs -f +docker compose up -d --force-recreate backend ``` + +### 停止服务 + +```bash +docker compose down +``` + +--- + +## 本地开发(非 Docker) + +```bash +# 根目录配置 .env 后 +cd backend && pnpm install && pnpm start # http://localhost:8083 + +cd frontend && pnpm install && pnpm dev # http://localhost:3000,/api 代理到 8083 +``` + +本地开发使用根目录 `.env` 中的 `PORT=8083`;Docker 部署时由 Compose 覆盖为容器内 `8000`。 diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 9ca0885..0000000 --- a/backend/.env.example +++ /dev/null @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 4da02a1..81f5963 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/package.json b/backend/package.json index 83fe38f..99f8dd0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 14d30e4..813b74c 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -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: {} diff --git a/backend/src/app.js b/backend/src/app.js index 70b0e0d..670ada4 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -1,7 +1,7 @@ /** * 入口文件 — 对应 Python: main.py */ -require('dotenv').config({ override: true }); +require('./config/loadEnv'); const express = require('express'); const cors = require('cors'); diff --git a/backend/src/config/loadEnv.js b/backend/src/config/loadEnv.js new file mode 100644 index 0000000..ec58853 --- /dev/null +++ b/backend/src/config/loadEnv.js @@ -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 }); +} diff --git a/backend/src/config/redis.js b/backend/src/config/redis.js new file mode 100644 index 0000000..7386ad8 --- /dev/null +++ b/backend/src/config/redis.js @@ -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 }; diff --git a/backend/src/routes/models.js b/backend/src/routes/models.js index 99ae322..4d3dfa8 100644 --- a/backend/src/routes/models.js +++ b/backend/src/routes/models.js @@ -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)) { diff --git a/backend/src/services/eqCacheStorage.js b/backend/src/services/eqCacheStorage.js new file mode 100644 index 0000000..da50273 --- /dev/null +++ b/backend/src/services/eqCacheStorage.js @@ -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, +}; diff --git a/backend/src/services/measurementStorage.js b/backend/src/services/measurementStorage.js index a04ed76..5b3e8a2 100644 --- a/backend/src/services/measurementStorage.js +++ b/backend/src/services/measurementStorage.js @@ -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, }; diff --git a/docker-compose.yml b/docker-compose.yml index 2cf9948..3ce7ea0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,8 @@ -version: '3.8' +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "5" services: backend: @@ -6,31 +10,20 @@ services: context: ./backend dockerfile: Dockerfile container_name: dashboard-backend + env_file: + - .env environment: - - DATABASE_HOST=${DATABASE_HOST:-localhost} - - DATABASE_PORT=${DATABASE_PORT:-3306} - - DATABASE_NAME=${DATABASE_NAME:-audio} - - DATABASE_USER=${DATABASE_USER:-root} - - DATABASE_PASSWORD=${DATABASE_PASSWORD:-root123} - - APP_NAME=${APP_NAME:-Audio Dashboard API} - - 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} - - AWS_REGION=${AWS_REGION:-eu-central-1} - - AWS_S3_OTA_BUCKET=${AWS_S3_OTA_BUCKET:-luxsin-app-bucket} - - AWS_S3_MEASUREMENT_BUCKET=${AWS_S3_MEASUREMENT_BUCKET:-luxsin-app-bucket} - - OTA_UPLOAD_DIR=${OTA_UPLOAD_DIR:-/data/projects/source} - - OTA_X8_PUBLIC_BASE=${OTA_X8_PUBLIC_BASE:-http://am.luxsinaudio.com} - - OTA_X9_URL_BASE=${OTA_X9_URL_BASE:-http://source.luxsin.net} + # 容器内固定 8000,与 nginx proxy_pass 和 ports 映射一致 - PORT=8000 + - APP_ENV=${APP_ENV:-production} ports: - "8083:8000" restart: unless-stopped + logging: *default-logging networks: - audio-network volumes: - - /data/projects/source:/data/projects/source + - /data/projects/source:/data/projects/source frontend: image: nginx:alpine @@ -43,6 +36,7 @@ services: depends_on: - backend restart: unless-stopped + logging: *default-logging networks: - audio-network diff --git a/frontend/package.json b/frontend/package.json index 9a16ff1..ffebf8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,11 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.3.4", - "vue-router": "^4.2.4", + "@element-plus/icons-vue": "^2.1.0", "axios": "^1.5.0", "element-plus": "^2.3.14", - "@element-plus/icons-vue": "^2.1.0" + "vue": "^3.3.4", + "vue-json-pretty": "^2.6.0", + "vue-router": "^4.2.4" }, "devDependencies": { "@vitejs/plugin-vue": "^4.3.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index dd9879d..543efe1 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: vue: specifier: ^3.3.4 version: 3.5.35 + vue-json-pretty: + specifier: ^2.6.0 + version: 2.6.0(vue@3.5.35) vue-router: specifier: ^4.2.4 version: 4.6.4(vue@3.5.35) @@ -486,6 +489,12 @@ packages: vue-component-type-helpers@3.3.3: resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==} + vue-json-pretty@2.6.0: + resolution: {integrity: sha512-glz1aBVS35EO8+S9agIl3WOQaW2cJZW192UVKTuGmryx01ZvOVWc4pR3t+5UcyY4jdOfBUgVHjcpRpcnjRhCAg==} + engines: {node: '>= 10.0.0', npm: '>= 5.0.0'} + peerDependencies: + vue: '>=3.0.0' + vue-router@4.6.4: resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} peerDependencies: @@ -899,6 +908,10 @@ snapshots: vue-component-type-helpers@3.3.3: {} + vue-json-pretty@2.6.0(vue@3.5.35): + dependencies: + vue: 3.5.35 + vue-router@4.6.4(vue@3.5.35): dependencies: '@vue/devtools-api': 6.6.4 diff --git a/frontend/src/api/model.js b/frontend/src/api/model.js index 06d046e..28e74bf 100644 --- a/frontend/src/api/model.js +++ b/frontend/src/api/model.js @@ -60,6 +60,56 @@ export function updateModel(id, data, config = {}) { }) } +/** + * 获取型号 EQ 缓存(Redis) + * @param {number} id - 型号 ID + */ +export function getModelEqCache(id) { + return request({ + url: `/models/${id}/eq-cache`, + method: 'get', + timeout: 30000 + }) +} + +/** + * 获取型号 EQ 缓存单个 hash field 的 value + * @param {number} id - 型号 ID + * @param {string} fieldKey - hash field 名称 + */ +export function getModelEqCacheField(id, fieldKey) { + return request({ + url: `/models/${id}/eq-cache/field`, + method: 'get', + params: { key: fieldKey }, + timeout: 30000 + }) +} + +/** + * 获取型号在 Meilisearch 中的推送数据 + * @param {number} id - 型号 ID + */ +export function getModelMeilisearch(id) { + return request({ + url: `/models/${id}/meilisearch`, + method: 'get', + timeout: 30000 + }) +} + +/** + * 获取型号在 S3 上的频响 CSV 内容(仅 Eafonyoung) + * @param {number} id - 型号 ID + */ +export function getModelMeasurement(id) { + return request({ + url: `/models/${id}/measurement`, + method: 'get', + timeout: 30000 + }) +} + /** * 删除型号 * @param {number} id - 型号 ID diff --git a/frontend/src/views/model/index.vue b/frontend/src/views/model/index.vue index 944d7b4..2e318a1 100644 --- a/frontend/src/views/model/index.vue +++ b/frontend/src/views/model/index.vue @@ -81,25 +81,56 @@ {{ scope.row.form || '-' }} - - - + + + + @@ -208,6 +239,114 @@ + +
+ + 加载中… +
+ + + {{ pushViewDocument.id }} + {{ pushViewDocument.brand_name }} + {{ pushViewDocument.name }} + {{ formatFormLabel(pushViewDocument.form) }} + {{ pushViewDocument.rig || '-' }} + {{ pushViewDocument.source || '-' }} + + +
+ + +
+ + 加载中… +
+
+ +

Redis Key: {{ eqCacheRedisKey }}

+
+
+

Redis Key: {{ eqCacheRedisKey }}

+

Hash Keys({{ eqCacheFieldKeys.length }})

+
    +
  • + +
    + +
    +
  • +
+
+ +
+ + +

{{ csvS3Key }}

+ + +
+