From 2ce73b8fc34a5dd05fe91367f42347d04a4588f4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 6 Jul 2026 10:12:05 +0800 Subject: [PATCH] feat: PM2 + Docker deployment config (MySQL container, PM2 for backend+frontend proxy) --- .env.example | 15 ++++++++ apps/admin/nginx.conf | 21 ++++++++++- apps/server/src/app.module.ts | 4 +-- deploy.sh | 59 ++++++++++++++++++++++++++++++ docker-compose.yml | 38 +++----------------- ecosystem.config.cjs | 50 ++++++++++++++++++++++++++ serve-proxy.js | 67 +++++++++++++++++++++++++++++++++++ 7 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 .env.example create mode 100755 deploy.sh create mode 100644 ecosystem.config.cjs create mode 100644 serve-proxy.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..345418b --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# 恭学教育 — 生产环境配置 +# 复制为 .env 并修改密码 + +# MySQL 数据库 +MYSQL_ROOT_PASSWORD=change-me-to-a-strong-password + +# 后端 +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USERNAME=root +DB_DATABASE=gongxue +DB_SYNCHRONIZE=false +JWT_SECRET=change-me-to-a-random-string-at-least-32-chars +JWT_EXPIRES_IN=24h +PORT=3000 diff --git a/apps/admin/nginx.conf b/apps/admin/nginx.conf index 081b418..ce1d6dd 100644 --- a/apps/admin/nginx.conf +++ b/apps/admin/nginx.conf @@ -1,11 +1,28 @@ server { listen 80; - server_name localhost; + server_name _; root /usr/share/nginx/html; index index.html; + # gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript; + gzip_min_length 256; + + # security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # static assets with long cache + location /assets/ { + expires 7d; + add_header Cache-Control "public, immutable"; + } + location / { try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; } location /api/ { @@ -13,5 +30,7 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; } } diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 546072c..4daf829 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -108,7 +108,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware'; password: config.get('DB_PASSWORD', ''), database: config.get('DB_DATABASE', 'dorm_billing'), entities: allEntities, - synchronize: true, + synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', charset: 'utf8mb4', }; } @@ -116,7 +116,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware'; type: 'better-sqlite3' as const, database: config.get('DB_DATABASE', 'dorm_billing.db'), entities: allEntities, - synchronize: true, + synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', }; }, }), diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..964ddc2 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# 恭学教育 — PM2 部署脚本 +# 用法: ./deploy.sh [ssh_host] +# ssh_host: SSH 主机别名,默认 tencent +set -euo pipefail + +SSH_HOST="${1:-tencent}" +REMOTE_DIR="/opt/gongxue" + +echo "=== 1/5 本地构建后端 ===" +npm run build -w @gongxue/server + +echo "=== 2/5 本地构建前端 ===" +npm run build -w @gongxue/admin + +echo "=== 3/5 同步到 ${SSH_HOST} ===" +rsync -avz --delete \ + --exclude='node_modules' \ + --exclude='.git' \ + --exclude='*.db' \ + --exclude='.DS_Store' \ + --exclude='logs/' \ + --exclude='.turbo/' \ + ./ "${SSH_HOST}:${REMOTE_DIR}/" + +echo "=== 4/5 启动 MySQL ===" +ssh "${SSH_HOST}" " + cd ${REMOTE_DIR} + docker compose up -d mysql + mkdir -p logs + # 等 MySQL 就绪 + for i in \$(seq 1 30); do + docker compose exec -T mysql mysqladmin ping -h localhost --silent 2>/dev/null && break + echo '等待 MySQL...' + sleep 2 + done + # 首次部署需安装依赖,后续跳过 + if [ ! -d node_modules ]; then + echo '首次部署,安装依赖...' + npm ci --omit=dev + fi +" + +echo "=== 5/5 PM2 重载 ===" +ssh "${SSH_HOST}" " + cd ${REMOTE_DIR} + pm2 startOrReload ecosystem.config.cjs --update-env + pm2 save + pm2 status +" + +echo "" +echo "部署完成!" +echo "访问: http://$(ssh "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')" +echo "" +echo "首次部署还需在服务器执行:" +echo " ssh ${SSH_HOST} && cd ${REMOTE_DIR}" +echo " cp .env.example .env && vim .env # 修改密码和 JWT_SECRET" +echo " sudo setcap 'cap_net_bind_service=+ep' \$(which node) # PM2 绑 80 端口" diff --git a/docker-compose.yml b/docker-compose.yml index 0af190b..d5d6ff4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,48 +3,18 @@ version: '3.8' services: mysql: image: mysql:8.0 - container_name: dorm_billing_mysql + container_name: gongxue_mysql restart: always environment: - MYSQL_ROOT_PASSWORD: dorm_billing_2024 - MYSQL_DATABASE: dorm_billing + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024} + MYSQL_DATABASE: ${DB_DATABASE:-gongxue} MYSQL_CHARSET: utf8mb4 MYSQL_COLLATION: utf8mb4_unicode_ci ports: - - "3306:3306" + - "127.0.0.1:3306:3306" volumes: - mysql_data:/var/lib/mysql command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci - backend: - build: - context: . - dockerfile: apps/server/Dockerfile - container_name: dorm_billing_backend - restart: always - ports: - - "3000:3000" - environment: - DB_HOST: mysql - DB_PORT: 3306 - DB_USERNAME: root - DB_PASSWORD: dorm_billing_2024 - DB_DATABASE: dorm_billing - JWT_SECRET: dorm-billing-jwt-secret-key-2024 - JWT_EXPIRES_IN: 24h - depends_on: - - mysql - - frontend: - build: - context: . - dockerfile: apps/admin/Dockerfile - container_name: dorm_billing_frontend - restart: always - ports: - - "80:80" - depends_on: - - backend - volumes: mysql_data: diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..39af6de --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,50 @@ +// PM2 进程配置 — 恭学教育 +// 用法: pm2 start ecosystem.config.cjs +// 前提: 项目部署在 /opt/gongxue(deploy.sh 默认路径) +// .env 文件在 /opt/gongxue/.env +// docker compose up -d 已启动 MySQL + +const DEPLOY_DIR = process.env.DEPLOY_DIR || '/opt/gongxue'; + +module.exports = { + apps: [ + { + name: 'gongxue-backend', + cwd: DEPLOY_DIR, + script: 'apps/server/dist/main.js', + // 集群模式(2 实例,按 CPU 核数可调) + instances: 2, + exec_mode: 'cluster', + // 环境变量 + env: { + NODE_ENV: 'production', + }, + // 内存限制 + max_memory_restart: '512M', + max_restarts: 10, + restart_delay: 5000, + // 日志 + error_file: 'logs/backend-error.log', + out_file: 'logs/backend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss', + autorestart: true, + watch: false, + }, + { + name: 'gongxue-frontend', + cwd: DEPLOY_DIR, + script: 'serve-proxy.js', + env: { + NODE_ENV: 'production', + FRONTEND_PORT: 80, // 需 root 或 setcap: sudo setcap 'cap_net_bind_service=+ep' $(which node) + API_TARGET: 'http://127.0.0.1:3000', + }, + max_memory_restart: '256M', + max_restarts: 5, + error_file: 'logs/frontend-error.log', + out_file: 'logs/frontend-out.log', + autorestart: true, + watch: false, + }, + ], +}; diff --git a/serve-proxy.js b/serve-proxy.js new file mode 100644 index 0000000..ffb64a9 --- /dev/null +++ b/serve-proxy.js @@ -0,0 +1,67 @@ +// 轻量静态文件 + API 代理服务器 +// PM2 启动: node serve-proxy.js +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = process.env.FRONTEND_PORT || 5173; +const API_TARGET = process.env.API_TARGET || 'http://127.0.0.1:3000'; +const STATIC_DIR = path.join(__dirname, 'apps/admin/dist'); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', +}; + +function serveStatic(res, filePath) { + const ext = path.extname(filePath); + const mime = MIME[ext] || 'application/octet-stream'; + try { + const content = fs.readFileSync(filePath); + res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' }); + res.end(content); + } catch { + // SPA fallback: return index.html + const index = fs.readFileSync(path.join(STATIC_DIR, 'index.html')); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(index); + } +} + +const server = http.createServer((req, res) => { + // API 代理 + if (req.url.startsWith('/api/')) { + const opts = { + hostname: '127.0.0.1', + port: 3000, + path: req.url, + method: req.method, + headers: { ...req.headers, host: '127.0.0.1:3000' }, + }; + const proxy = http.request(opts, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + }); + proxy.on('error', () => { + res.writeHead(502); + res.end('API unavailable'); + }); + req.pipe(proxy); + return; + } + + // 静态文件 + const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0]; + const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, ''); + serveStatic(res, path.join(STATIC_DIR, safePath)); +}); + +server.listen(PORT, () => { + console.log(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET}`); +});