diff --git a/conf/nginx-emotion-museum-fix.conf b/conf/nginx-emotion-museum-fix.conf new file mode 100644 index 0000000..bc5afe6 --- /dev/null +++ b/conf/nginx-emotion-museum-fix.conf @@ -0,0 +1,161 @@ +# Emotion Museum 前端应用 Nginx 配置 +# 配置路径:/www/server/panel/vhost/nginx/emotion-museum.conf + +server { + listen 80; + server_name lifescript.happylifeos.com; + + # Let's Encrypt ACME 挑战位置(必须在其他 location 之前) + location ^~ /.well-known/acme-challenge/ { + root /data/www/acme-challenge; + allow all; + try_files $uri =404; + } + + # 根路径不提供站点,避免跳转或兜底到其他 server + location = / { + return 404; + } + + # 前端应用路径 + location /emotion-museum/ { + alias /data/www/emotion-museum/; + + # 启用目录索引(可选) + autoindex off; + + # 处理 Vue Router 的 history 模式 + # 所有非文件请求都重定向到 index.html + try_files $uri $uri/ /emotion-museum/index.html; + + # 设置缓存策略 + # HTML 文件不缓存 + location ~ \.html?$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # 静态资源缓存 1 年 + location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + } + + # 处理不带末尾斜杠的 /emotion-museum 请求 + location = /emotion-museum { + rewrite ^(.*)$ $1/ permanent; + } + + # 管理后台应用路径 + location /emotion-museum-admin/ { + alias /data/www/emotion-museum-admin/; + + # 启用目录索引(可选) + autoindex off; + + # 处理 Vue Router 的 history 模式 + # 所有非文件请求都重定向到 index.html + try_files $uri $uri/ /emotion-museum-admin/index.html; + + # 设置缓存策略 + # HTML 文件不缓存 + location ~ \.html?$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # 静态资源缓存 1 年 + location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + } + + # 处理不带末尾斜杠的 /emotion-museum-admin 请求 + location = /emotion-museum-admin { + rewrite ^(.*)$ $1/ permanent; + } + + # 体验前端应用路径 (course-web) + location /course-of-life/ { + alias /data/www/course-of-life/; + + # 启用目录索引(可选) + autoindex off; + + # 处理 SPA 的 history 模式 (React Router) + # 所有非文件请求都重定向到 index.html + try_files $uri $uri/ /course-of-life/index.html; + + # 设置缓存策略 + # HTML 文件不缓存 + location ~ \.html?$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # 静态资源缓存 1 年 + location ~ \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + } + + # 处理不带末尾斜杠的 /course-of-life 请求 + location = /course-of-life { + # 不进行 301/302 外部跳转:内部改写到 /course-of-life/ 交给下方 SPA location 处理 + # 这样 URL 仍是 /course-of-life,但返回内容与 /course-of-life/ 完全一致(且不会触发"下载") + rewrite ^ /course-of-life/ last; + } + + # 后端 API 代理 + location /api { + proxy_pass http://127.0.0.1:19089; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # WebSocket 代理 + location /ws { + proxy_pass http://127.0.0.1:19089; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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; + + # WebSocket 超时设置 + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # 健康检查端点 + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # 禁止访问敏感文件 + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + access_log /www/wwwlogs/access.log; +} diff --git a/conf/nginx-emotion-museum-ssl.conf b/conf/nginx-emotion-museum-ssl.conf new file mode 100644 index 0000000..008f1fb --- /dev/null +++ b/conf/nginx-emotion-museum-ssl.conf @@ -0,0 +1,101 @@ +# Emotion Museum HTTPS Nginx 配置 +# 配置路径:/www/server/panel/vhost/nginx/emotion-museum.conf + +server { + listen 443 ssl; + http2 on; + server_name lifescript.happylifeos.com; + + ssl_certificate /etc/letsencrypt/live/lifescript.happylifeos.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/lifescript.happylifeos.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + location ^~ /.well-known/acme-challenge/ { + root /data/www/acme-challenge; + allow all; + try_files $uri =404; + } + + location = / { + return 404; + } + + location /emotion-museum/ { + alias /data/www/emotion-museum/; + autoindex off; + try_files $uri $uri/ /emotion-museum/index.html; + } + + location = /emotion-museum { + rewrite ^(.*)$ $1/ permanent; + } + + location /emotion-museum-admin/ { + alias /data/www/emotion-museum-admin/; + autoindex off; + try_files $uri $uri/ /emotion-museum-admin/index.html; + } + + location = /emotion-museum-admin { + rewrite ^(.*)$ $1/ permanent; + } + + location /course-of-life/ { + alias /data/www/course-of-life/; + autoindex off; + try_files $uri $uri/ /course-of-life/index.html; + } + + location = /course-of-life { + rewrite ^ /course-of-life/ last; + } + + location /api { + proxy_pass http://127.0.0.1:19089; + 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_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /ws { + proxy_pass http://127.0.0.1:19089; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + access_log /www/wwwlogs/ssl-access.log; +} + +server { + listen 80; + server_name lifescript.happylifeos.com; + return 301 https://$host$request_uri; +} diff --git a/deploy-domain.sh b/deploy-domain.sh new file mode 100644 index 0000000..54c4aeb --- /dev/null +++ b/deploy-domain.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# +# Author: Emotion Museum Team +# Created: 2026-03-18 +# Purpose: 一键部署域名配置(SSL + Nginx + 后端 + 前端) +# +# 使用方法: +# ./deploy-domain.sh +# +# 前置条件: +# - 已配置 SSH 免密登录到服务器 +# - 服务器已安装 certbot 和 nginx +# + +set -e + +# 配置 +SERVER="101.200.208.45" +DOMAIN="lifescript.happylifeos.com" +EMAIL="admin@happylifeos.com" + +# 颜色 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_section() { echo -e "\n${BLUE}==== $1 ====${NC}\n"; } + +log_section "情绪博物馆 - 域名部署" + +# 1. 检查 SSH 连接 +log_info "检查 SSH 连接..." +ssh root@$SERVER "echo 'SSH 连接正常'" || { log_error "SSH 连接失败"; exit 1; } + +# 2. 上传 SSL 证书脚本 +log_info "上传 SSL 证书脚本..." +scp tools/deploy-ssl-cert.py root@$SERVER:/tmp/deploy-ssl-cert.py + +# 3. 执行 SSL 证书验证(证书应该已申请) +log_info "验证 SSL 证书..." +ssh root@$SERVER "python3 /tmp/deploy-ssl-cert.py --verify" || log_warn "证书验证失败,请检查" + +# 4. 上传并应用 Nginx 配置 +log_info "上传 Nginx 配置..." +scp conf/nginx-emotion-museum-fix.conf root@$SERVER:/tmp/nginx-fix.conf +ssh root@$SERVER " + # 备份当前配置 + cp /www/server/panel/vhost/nginx/emotion-museum.conf /www/server/panel/vhost/nginx/emotion-museum.conf.bak 2>/dev/null || true + # 应用新配置 + cp /tmp/nginx-fix.conf /www/server/panel/vhost/nginx/emotion-museum.conf + # 验证并重载 + /www/server/nginx/sbin/nginx -t && /www/server/nginx/sbin/nginx -s reload +" +log_info "Nginx 配置已应用" + +# 5. 部署后端 +log_info "部署后端服务..." +cd backend-single && mvn clean package -DskipTests && cd .. +scp backend-single/target/backend-single-1.0.0.jar root@$SERVER:/opt/emotion-museum/backend.jar +ssh root@$SERVER " + systemctl stop emotion-museum-backend 2>/dev/null || true + sleep 2 + # 备份旧版本 + cp /opt/emotion-museum/backend.jar /opt/emotion-museum/backend.jar.bak 2>/dev/null || true + # 后端已在上面 scp 覆盖,重启服务 + systemctl restart emotion-museum-backend 2>/dev/null || (cd /opt/emotion-museum && nohup java -jar backend.jar > /dev/null 2>&1 &) + sleep 3 + echo '后端服务状态:' + systemctl status emotion-museum-backend 2>/dev/null || ps aux | grep backend.jar | grep -v grep +" + +# 6. 部署前端 +log_info "部署前端..." +cd web && npm run build && cd .. +scp -r web/dist/* root@$SERVER:/data/www/emotion-museum/ +log_info "前端部署完成" + +# 7. 部署管理后台 +log_info "部署管理后台..." +cd web-admin && npm run build && cd .. +scp -r web-admin/dist/* root@$SERVER:/data/www/emotion-museum-admin/ +log_info "管理后台部署完成" + +# 8. 验证部署 +log_section "验证部署" +log_info "验证 HTTPS 访问..." +curl -k -s -o /dev/null -w " 前端页面:HTTP %{http_code}\n" https://$DOMAIN/emotion-museum/ +curl -k -s -o /dev/null -w " 管理后台:HTTP %{http_code}\n" https://$DOMAIN/emotion-museum-admin/ +curl -k -s -o /dev/null -w " API 代理:HTTP %{http_code}\n" https://$DOMAIN/api/health +curl -s -o /dev/null -w " HTTP 跳转:HTTP %{http_code}\n" http://$DOMAIN/ + +log_section "部署完成" +log_info "访问地址:" +log_info " 用户前端:https://$DOMAIN/emotion-museum/" +log_info " 管理后台:https://$DOMAIN/emotion-museum-admin/" +log_info " API 地址:https://$DOMAIN/api/" +log_info " WebSocket: wss://$DOMAIN/ws" diff --git a/tools/deploy-ssl-cert.py b/tools/deploy-ssl-cert.py index 8caadb3..ba88398 100644 --- a/tools/deploy-ssl-cert.py +++ b/tools/deploy-ssl-cert.py @@ -50,7 +50,7 @@ def run_command(cmd, capture=False): """执行 shell 命令""" try: if capture: - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) return result.returncode, result.stdout, result.stderr else: result = subprocess.run(cmd, shell=True) @@ -70,17 +70,62 @@ def check_certbot(): log_warn("certbot 未安装,尝试安装...") return install_certbot() +def check_nginx(): + """检查 nginx 是否已安装""" + log_info("检查 nginx 是否已安装...") + returncode, _, _ = run_command("which nginx", capture=True) + if returncode == 0: + log_info("nginx 已安装") + return True + else: + log_warn("nginx 未安装,正在安装...") + return install_nginx() + +def install_nginx(): + """安装 nginx""" + log_info("正在安装 nginx...") + + # 检查系统类型 + if os.path.exists("/etc/debian_version") or os.path.exists("/etc/ubuntu-version"): + # Debian/Ubuntu + run_command("apt-get update") + returncode, _, _ = run_command("apt-get install -y nginx") + elif os.path.exists("/etc/redhat-release") or os.path.exists("/etc/centos-release") or os.path.exists("/etc/aliyun-release"): + # CentOS/RHEL/Alibaba Cloud Linux + # 先尝试安装 EPEL 仓库 + run_command("yum install -y epel-release") + # 使用 yum 安装 nginx,Alibaba Cloud Linux 8 中 nginx 包名可能是 nginx-all + returncode, _, _ = run_command("yum install -y nginx-all || yum install -y nginx") + else: + # 尝试通用安装方法 + returncode, _, _ = run_command("snap install --classic certbot") + + if returncode == 0: + log_info("nginx 安装成功") + # 启动 nginx + run_command("systemctl start nginx") + run_command("systemctl enable nginx") + return True + else: + log_error("nginx 安装失败") + return False + def install_certbot(): """安装 certbot""" log_info("正在安装 certbot...") + # 先确保 nginx 已安装 + if not check_nginx(): + log_error("nginx 安装失败,无法继续") + return False + # 检查系统类型 if os.path.exists("/etc/debian_version") or os.path.exists("/etc/ubuntu-version"): # Debian/Ubuntu run_command("apt-get update") returncode, _, _ = run_command("apt-get install -y certbot python3-certbot-nginx") elif os.path.exists("/etc/redhat-release") or os.path.exists("/etc/centos-release"): - # CentOS/RHEL + # CentOS/RHEL/Alibaba Cloud Linux returncode, _, _ = run_command("yum install -y certbot python3-certbot-nginx") else: # 尝试通用安装方法 @@ -119,13 +164,31 @@ def apply_certificate(): returncode, _, _ = run_command("systemctl is-active nginx", capture=True) if returncode != 0: log_warn("Nginx 未运行,尝试启动...") - run_command("systemctl start nginx") + # 宝塔面板 nginx 路径 + if os.path.exists("/www/server/nginx/sbin/nginx"): + run_command("/www/server/nginx/sbin/nginx") + else: + run_command("systemctl start nginx") - # 使用 standalone 模式申请证书(不需要 nginx 配置) - log_info("使用 standalone 模式申请证书...") + # 检测宝塔面板 webroot 路径 + webroot_path = "/www/server/nginx/html" + if not os.path.exists(webroot_path): + # 尝试宝塔默认站点目录 + webroot_path = "/www/wwwroot/default" + if not os.path.exists(webroot_path): + # 尝试当前域名配置目录 + returncode, stdout, _ = run_command( + f"grep -r 'root' /www/server/panel/vhost/nginx/ | grep -v '#' | head -1", + capture=True + ) + if stdout: + webroot_path = stdout.split(':')[1].strip().rstrip(';').strip() + + log_info(f"使用 webroot 模式申请证书,路径:{webroot_path}") cmd = ( f"certbot certonly " - f"--standalone " + f"--webroot " + f"-w {webroot_path} " f"-d {DOMAIN} " f"--email {EMAIL} " f"--agree-tos " @@ -142,25 +205,7 @@ def apply_certificate(): return True else: log_error(f"证书申请失败:{stderr}") - - # 尝试使用 nginx 插件 - log_warn("尝试使用 nginx 插件模式...") - cmd = ( - f"certbot --nginx " - f"-d {DOMAIN} " - f"--email {EMAIL} " - f"--agree-tos " - f"--non-interactive " - f"--force-renewal" - ) - returncode, stdout, stderr = run_command(cmd) - - if returncode == 0: - log_info("✅ SSL 证书申请成功(nginx 模式)") - return True - else: - log_error(f"证书申请失败:{stderr}") - return False + return False def setup_auto_renewal(): """配置自动续期""" @@ -175,7 +220,13 @@ def setup_auto_renewal(): # 添加定时任务(每天凌晨 2 点检查) log_info("添加 certbot 自动续期定时任务...") - cron_job = "0 2 * * * /usr/bin/certbot renew --quiet --deploy-hook 'systemctl reload nginx'" + # 检测 nginx 重载命令 + if os.path.exists("/www/server/nginx/sbin/nginx"): + reload_cmd = "/www/server/nginx/sbin/nginx -s reload" + else: + reload_cmd = "systemctl reload nginx" + + cron_job = f"0 2 * * * /usr/bin/certbot renew --quiet --deploy-hook '{reload_cmd}'" # 备份并添加新的 cron 任务 run_command("(crontab -l 2>/dev/null | grep -v certbot; echo '%s') | crontab -" % cron_job) @@ -237,7 +288,13 @@ def renew_certificate(): """手动续期证书""" log_section("手动续期 SSL 证书") - cmd = "certbot renew --force-renewal --deploy-hook 'systemctl reload nginx'" + # 检测 nginx 重载命令 + if os.path.exists("/www/server/nginx/sbin/nginx"): + reload_cmd = "/www/server/nginx/sbin/nginx -s reload" + else: + reload_cmd = "systemctl reload nginx" + + cmd = f"certbot renew --force-renewal --deploy-hook '{reload_cmd}'" log_info(f"执行命令:{cmd}") returncode, stdout, stderr = run_command(cmd) @@ -298,6 +355,9 @@ def main(): else: # 申请模式 + # 先检查并安装 nginx + check_nginx() + if not check_certbot(): log_error("certbot 未安装,无法继续") sys.exit(1) diff --git a/web/package-lock.json b/web/package-lock.json index 4acf8a1..0d952fd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2408,21 +2408,6 @@ "node": "*" } }, - "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmmirror.com/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", @@ -5616,19 +5601,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz", @@ -7409,21 +7381,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmmirror.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",