From 6dcc6a4709308ee12770bacca2d0be36edfb5960 Mon Sep 17 00:00:00 2001 From: Alex-larget <33240357+Alex-larget@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:39:45 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A6=81=E7=94=A8=20standalone=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=BB=A5=E9=81=BF=E5=85=8D=E4=BE=9D=E8=B5=96=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=BC=80=E5=8F=91=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E4=BB=A5=E6=8C=87=E5=AE=9A=E7=AB=AF=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC=E4=BB=A5?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E5=8F=AF=E7=BB=B4=E6=8A=A4=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.mjs | 2 +- package.json | 2 +- scripts/deploy_soul.py | 1694 ++++++++++++++++++++-------------------- 3 files changed, 849 insertions(+), 849 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 98018121..eb107ae8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -6,7 +6,7 @@ const nextConfig = { images: { unoptimized: true, }, - output: 'standalone', + // output: 'standalone', // 暂时禁用 standalone 模式避免依赖问题 // 抑制开发环境下的错误遮罩 devIndicators: { buildActivity: false, diff --git a/package.json b/package.json index 077b4601..6ff9359b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "build": "next build", - "dev": "next dev", + "dev": "next dev -p 3006", "lint": "eslint .", "start": "next start -p 3006" }, diff --git a/scripts/deploy_soul.py b/scripts/deploy_soul.py index 2df0d6fa..89f4c309 100644 --- a/scripts/deploy_soul.py +++ b/scripts/deploy_soul.py @@ -1,847 +1,847 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Soul 创业派对 - 一键部署脚本 -本地打包 + SSH 上传解压 + 宝塔 API 部署 - -使用方法: - python scripts/deploy_soul.py # 完整部署流程 - python scripts/deploy_soul.py --no-build # 跳过本地构建 - python scripts/deploy_soul.py --no-upload # 跳过 SSH 上传 - python scripts/deploy_soul.py --no-api # 上传后不调宝塔 API 重启 - -环境变量(可选,覆盖默认配置): - DEPLOY_HOST # SSH 服务器地址,默认 42.194.232.22 - DEPLOY_USER # SSH 用户名,默认 root - DEPLOY_PASSWORD # SSH 密码,默认 Zhiqun1984 - DEPLOY_SSH_KEY # SSH 密钥路径(优先于密码) - DEPLOY_PROJECT_PATH # 服务器项目路径,默认 /www/wwwroot/soul - BAOTA_PANEL_URL # 宝塔面板地址,默认 https://42.194.232.22:9988 - BAOTA_API_KEY # 宝塔 API 密钥,默认 hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd - DEPLOY_PM2_APP # PM2 项目名称,默认 soul - DEPLOY_NODE_VERSION # Node 版本,默认 v22.14.0(用于显示) - DEPLOY_NODE_PATH # Node 可执行文件路径,默认 /www/server/nodejs/v22.14.0/bin - # 用于避免多 Node 环境冲突,确保使用指定的 Node 版本 -""" - -from __future__ import print_function - -import os -import sys -import shutil -import tarfile -import tempfile -import subprocess -import argparse -import time -import hashlib - -# 检查依赖 -try: - import paramiko -except ImportError: - print("错误: 请先安装 paramiko") - print(" pip install paramiko") - sys.exit(1) - -try: - import requests - import urllib3 - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -except ImportError: - print("错误: 请先安装 requests") - print(" pip install requests") - sys.exit(1) - - -# ==================== 配置 ==================== - -def get_cfg(): - """获取部署配置""" - return { - # SSH 配置 - "host": os.environ.get("DEPLOY_HOST", "42.194.232.22"), - "user": os.environ.get("DEPLOY_USER", "root"), - "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), - "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), - "project_path": os.environ.get("DEPLOY_PROJECT_PATH", "/www/wwwroot/soul"), - # 宝塔 API 配置 - "panel_url": os.environ.get("BAOTA_PANEL_URL", "https://42.194.232.22:9988"), - "api_key": os.environ.get("BAOTA_API_KEY", "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"), - "pm2_name": os.environ.get("DEPLOY_PM2_APP", "soul"), - "site_url": os.environ.get("DEPLOY_SITE_URL", "https://soul.quwanzhi.com"), - # Node 环境配置 - "node_version": os.environ.get("DEPLOY_NODE_VERSION", "v22.14.0"), # 指定 Node 版本 - "node_path": os.environ.get("DEPLOY_NODE_PATH", "/www/server/nodejs/v22.14.0/bin"), # Node 可执行文件路径 - } - - -# ==================== 宝塔 API ==================== - -def _get_sign(api_key): - """宝塔鉴权签名:request_token = md5(request_time + md5(api_key))""" - now_time = int(time.time()) - sign_str = str(now_time) + hashlib.md5(api_key.encode("utf-8")).hexdigest() - request_token = hashlib.md5(sign_str.encode("utf-8")).hexdigest() - return now_time, request_token - - -def _baota_request(panel_url, api_key, path, data=None): - """发起宝塔 API 请求的通用函数""" - req_time, req_token = _get_sign(api_key) - payload = { - "request_time": req_time, - "request_token": req_token, - } - if data: - payload.update(data) - - url = panel_url.rstrip("/") + "/" + path.lstrip("/") - try: - r = requests.post(url, data=payload, verify=False, timeout=30) - if r.text: - return r.json() - return {} - except Exception as e: - print(" API 请求失败: %s" % str(e)) - return None - - -def get_node_project_list(panel_url, api_key): - """获取 Node 项目列表""" - paths_to_try = [ - "/project/nodejs/get_project_list", - "/plugin?action=a&name=nodejs&s=get_project_list", - ] - for path in paths_to_try: - result = _baota_request(panel_url, api_key, path) - if result and (result.get("status") is True or "data" in result): - return result.get("data", []) - return None - - -def get_node_project_status(panel_url, api_key, pm2_name): - """检查 Node 项目状态""" - projects = get_node_project_list(panel_url, api_key) - if projects: - for project in projects: - if project.get("name") == pm2_name: - return project - return None - - -def start_node_project(panel_url, api_key, pm2_name): - """通过宝塔 API 启动 Node 项目""" - paths_to_try = [ - "/project/nodejs/start_project", - "/plugin?action=a&name=nodejs&s=start_project", - ] - for path in paths_to_try: - result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) - if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): - print(" [成功] 启动成功: %s" % pm2_name) - return True - return False - - -def stop_node_project(panel_url, api_key, pm2_name): - """通过宝塔 API 停止 Node 项目""" - paths_to_try = [ - "/project/nodejs/stop_project", - "/plugin?action=a&name=nodejs&s=stop_project", - ] - for path in paths_to_try: - result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) - if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): - print(" [成功] 停止成功: %s" % pm2_name) - return True - return False - - -def restart_node_project(panel_url, api_key, pm2_name): - """ - 通过宝塔 API 重启 Node 项目 - 返回 True 表示成功,False 表示失败 - """ - # 先检查项目状态 - project_status = get_node_project_status(panel_url, api_key, pm2_name) - if project_status: - print(" 项目状态: %s" % project_status.get("status", "未知")) - - paths_to_try = [ - "/project/nodejs/restart_project", - "/plugin?action=a&name=nodejs&s=restart_project", - ] - - for path in paths_to_try: - result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) - if result: - if result.get("status") is True or result.get("msg") or "成功" in str(result): - print(" [成功] 重启成功: %s" % pm2_name) - return True - if "msg" in result: - print(" API 返回: %s" % result.get("msg")) - - print(" [警告] 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确") - return False - - -def add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port=3006, node_path=None): - """通过宝塔 API 添加或更新 Node 项目配置""" - paths_to_try = [ - "/project/nodejs/add_project", - "/plugin?action=a&name=nodejs&s=add_project", - ] - - # 如果指定了 Node 路径,在启动命令中使用完整路径 - if node_path: - run_cmd = "%s/node server.js" % node_path - else: - run_cmd = "node server.js" - - payload = { - "name": pm2_name, - "path": project_path, - "run_cmd": run_cmd, - "port": str(port), - } - - for path in paths_to_try: - result = _baota_request(panel_url, api_key, path, payload) - if result: - if result.get("status") is True: - print(" [成功] 项目配置已更新: %s" % pm2_name) - return True - if "msg" in result: - print(" API 返回: %s" % result.get("msg")) - - return False - - -# ==================== 本地构建 ==================== - -def run_build(root): - """执行本地构建""" - print("[1/4] 本地构建 pnpm build ...") - use_shell = sys.platform == "win32" - - # 检查 standalone 目录是否已存在 - standalone = os.path.join(root, ".next", "standalone") - server_js = os.path.join(standalone, "server.js") - - try: - # 在 Windows 上处理编码问题:使用 UTF-8 和 errors='replace' 来避免解码错误 - # errors='replace' 会在遇到无法解码的字符时用替换字符代替,避免崩溃 - r = subprocess.run( - ["pnpm", "build"], - cwd=root, - shell=use_shell, - timeout=600, - capture_output=True, - text=True, - encoding='utf-8', - errors='replace' # 遇到无法解码的字符时替换为占位符,避免 UnicodeDecodeError - ) - - # 安全地获取输出,处理可能的 None 值 - stdout_text = r.stdout or "" - stderr_text = r.stderr or "" - - # 检查是否是 Windows 符号链接权限错误 - # 错误信息可能在 stdout 或 stderr 中 - combined_output = stdout_text + stderr_text - is_windows_symlink_error = ( - sys.platform == "win32" and - r.returncode != 0 and - ("EPERM" in combined_output or - "symlink" in combined_output.lower() or - "operation not permitted" in combined_output.lower() or - "errno: -4048" in combined_output) - ) - - if r.returncode != 0: - if is_windows_symlink_error: - print(" [警告] Windows 符号链接权限错误(EPERM)") - print(" 这是 Windows 上 Next.js standalone 构建的常见问题") - print(" 解决方案(任选其一):") - print(" 1. 开启 Windows 开发者模式:设置 → 隐私和安全性 → 针对开发人员 → 开发人员模式") - print(" 2. 以管理员身份运行终端再执行构建") - print(" 3. 使用 --no-build 跳过构建,使用已有的构建文件") - print("") - print(" 正在检查 standalone 输出是否可用...") - - # 即使有错误,也检查 standalone 是否可用 - if os.path.isdir(standalone) and os.path.isfile(server_js): - print(" [成功] 虽然构建有警告,但 standalone 输出可用,继续部署") - return True - else: - print(" [失败] standalone 输出不可用,无法继续") - return False - else: - print(" [失败] 构建失败,退出码:", r.returncode) - if stdout_text: - # 显示最后几行输出以便调试 - lines = stdout_text.strip().split('\n') - if lines: - print(" 构建输出(最后10行):") - for line in lines[-10:]: - try: - # 确保输出可以正常显示 - print(" " + line) - except UnicodeEncodeError: - # 如果仍有编码问题,使用 ASCII 安全输出 - print(" " + line.encode('ascii', 'replace').decode('ascii')) - if stderr_text: - print(" 错误输出(最后5行):") - lines = stderr_text.strip().split('\n') - if lines: - for line in lines[-5:]: - try: - print(" " + line) - except UnicodeEncodeError: - print(" " + line.encode('ascii', 'replace').decode('ascii')) - return False - except subprocess.TimeoutExpired: - print(" [失败] 构建超时(超过10分钟)") - return False - except FileNotFoundError: - print(" [失败] 未找到 pnpm 命令,请先安装 pnpm") - print(" npm install -g pnpm") - return False - except UnicodeDecodeError as e: - print(" [失败] 构建输出编码错误:", str(e)) - print(" 提示: 这可能是 Windows 编码问题,尝试设置环境变量 PYTHONIOENCODING=utf-8") - # 即使有编码错误,也检查 standalone 是否可用 - if os.path.isdir(standalone) and os.path.isfile(server_js): - print(" [成功] 虽然构建有编码警告,但 standalone 输出可用,继续部署") - return True - return False - except Exception as e: - print(" [失败] 构建异常:", str(e)) - import traceback - traceback.print_exc() - # 即使有异常,也检查 standalone 是否可用(可能是部分成功) - if os.path.isdir(standalone) and os.path.isfile(server_js): - print(" [提示] 检测到 standalone 输出,可能是部分构建成功") - print(" 如果确定要使用,可以使用 --no-build 跳过构建步骤") - return False - - # 验证构建输出 - if not os.path.isdir(standalone) or not os.path.isfile(server_js): - print(" [失败] 未找到 .next/standalone 或 server.js") - print(" 请确认 next.config.mjs 中设置了 output: 'standalone'") - return False - print(" [成功] 构建完成") - return True - - -# ==================== 打包 ==================== - -def pack_standalone(root): - """打包 standalone 输出""" - print("[2/4] 打包 standalone ...") - standalone = os.path.join(root, ".next", "standalone") - static_src = os.path.join(root, ".next", "static") - public_src = os.path.join(root, "public") - ecosystem_src = os.path.join(root, "ecosystem.config.cjs") - - # 检查必要文件 - if not os.path.isdir(standalone): - print(" [失败] 未找到 .next/standalone 目录") - return None - if not os.path.isdir(static_src): - print(" [失败] 未找到 .next/static 目录") - return None - if not os.path.isdir(public_src): - print(" [警告] 未找到 public 目录,继续打包") - if not os.path.isfile(ecosystem_src): - print(" [警告] 未找到 ecosystem.config.cjs,继续打包") - - staging = tempfile.mkdtemp(prefix="soul_deploy_") - try: - # 复制 standalone 内容 - # standalone 目录应该包含:server.js, package.json, node_modules/ 等 - print(" 正在复制 standalone 目录内容...") - - # 使用更可靠的方法复制,特别是处理 pnpm 的符号链接结构 - def copy_with_dereference(src, dst): - """复制文件或目录,跟随符号链接""" - if os.path.islink(src): - # 如果是符号链接,复制目标文件 - link_target = os.readlink(src) - if os.path.isabs(link_target): - real_path = link_target - else: - real_path = os.path.join(os.path.dirname(src), link_target) - if os.path.exists(real_path): - if os.path.isdir(real_path): - shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True) - else: - shutil.copy2(real_path, dst) - else: - # 如果链接目标不存在,直接复制链接本身 - shutil.copy2(src, dst, follow_symlinks=False) - elif os.path.isdir(src): - # 对于目录,递归复制并处理符号链接 - if os.path.exists(dst): - shutil.rmtree(dst) - shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True) - else: - shutil.copy2(src, dst) - - for name in os.listdir(standalone): - src = os.path.join(standalone, name) - dst = os.path.join(staging, name) - if name == 'node_modules': - print(" 正在复制 node_modules(处理符号链接和 pnpm 结构)...") - copy_with_dereference(src, dst) - else: - copy_with_dereference(src, dst) - - # 🔧 修复 pnpm 依赖:将 styled-jsx 从 .pnpm 提升到根 node_modules - print(" 正在修复 pnpm 依赖结构...") - node_modules_dst = os.path.join(staging, "node_modules") - pnpm_dir = os.path.join(node_modules_dst, ".pnpm") - - if os.path.isdir(pnpm_dir): - # 需要提升的依赖列表(require-hook.js 需要) - required_deps = ["styled-jsx"] - - for dep in required_deps: - dep_in_root = os.path.join(node_modules_dst, dep) - if not os.path.exists(dep_in_root): - # 在 .pnpm 中查找该依赖 - for pnpm_pkg in os.listdir(pnpm_dir): - if pnpm_pkg.startswith(dep + "@"): - src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep) - if os.path.isdir(src_dep): - print(" 提升依赖: %s -> node_modules/%s" % (pnpm_pkg, dep)) - shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True) - break - else: - print(" 依赖已存在: %s" % dep) - - # 验证关键文件 - server_js = os.path.join(staging, "server.js") - package_json = os.path.join(staging, "package.json") - node_modules = os.path.join(staging, "node_modules") - if not os.path.isfile(server_js): - print(" [警告] standalone 目录内未找到 server.js") - if not os.path.isfile(package_json): - print(" [警告] standalone 目录内未找到 package.json") - if not os.path.isdir(node_modules): - print(" [警告] standalone 目录内未找到 node_modules") - else: - # 检查 node_modules/next 是否存在 - next_module = os.path.join(node_modules, "next") - if os.path.isdir(next_module): - print(" [成功] 已确认 node_modules/next 存在") - else: - print(" [警告] node_modules/next 不存在,可能导致运行时错误") - - # 检查 styled-jsx 是否存在(require-hook.js 需要) - styled_jsx_module = os.path.join(node_modules, "styled-jsx") - if os.path.isdir(styled_jsx_module): - print(" [成功] 已确认 node_modules/styled-jsx 存在") - else: - print(" [警告] node_modules/styled-jsx 不存在,可能导致启动失败") - - # 复制 .next/static - static_dst = os.path.join(staging, ".next", "static") - if os.path.exists(static_dst): - shutil.rmtree(static_dst) - os.makedirs(os.path.dirname(static_dst), exist_ok=True) - shutil.copytree(static_src, static_dst) - - # 复制 public - if os.path.isdir(public_src): - public_dst = os.path.join(staging, "public") - if os.path.exists(public_dst): - shutil.rmtree(public_dst) - shutil.copytree(public_src, public_dst) - - # 复制 ecosystem.config.cjs - if os.path.isfile(ecosystem_src): - shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs")) - - # 确保 package.json 的 start 脚本正确(standalone 模式使用 node server.js) - package_json_path = os.path.join(staging, "package.json") - if os.path.isfile(package_json_path): - try: - import json - with open(package_json_path, 'r', encoding='utf-8') as f: - package_data = json.load(f) - # 确保 start 脚本使用 node server.js - if 'scripts' not in package_data: - package_data['scripts'] = {} - package_data['scripts']['start'] = 'node server.js' - with open(package_json_path, 'w', encoding='utf-8') as f: - json.dump(package_data, f, indent=2, ensure_ascii=False) - print(" [提示] 已修正 package.json 的 start 脚本为 'node server.js'") - except Exception as e: - print(" [警告] 无法修正 package.json:", str(e)) - - # 创建压缩包 - tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz") - with tarfile.open(tarball, "w:gz") as tf: - for name in os.listdir(staging): - tf.add(os.path.join(staging, name), arcname=name) - - size_mb = os.path.getsize(tarball) / 1024 / 1024 - print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, size_mb)) - return tarball - except Exception as e: - print(" [失败] 打包异常:", str(e)) - import traceback - traceback.print_exc() - return None - finally: - shutil.rmtree(staging, ignore_errors=True) - - -# ==================== Node 环境检查 ==================== - -def check_node_environments(cfg): - """检查服务器上的 Node 环境""" - print("[检查] Node 环境检查 ...") - host = cfg["host"] - user = cfg["user"] - password = cfg["password"] - key_path = cfg["ssh_key"] - - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - if key_path: - client.connect(host, username=user, key_filename=key_path, timeout=15) - else: - client.connect(host, username=user, password=password, timeout=15) - - # 检查系统默认 Node 版本 - stdin, stdout, stderr = client.exec_command("which node && node -v", timeout=10) - default_node = stdout.read().decode("utf-8", errors="replace").strip() - if default_node: - print(" 系统默认 Node: %s" % default_node) - else: - print(" 警告: 未找到系统默认 Node") - - # 检查宝塔安装的 Node 版本 - stdin, stdout, stderr = client.exec_command("ls -d /www/server/nodejs/*/ 2>/dev/null | head -5", timeout=10) - node_versions = stdout.read().decode("utf-8", errors="replace").strip().split('\n') - node_versions = [v.strip().rstrip('/') for v in node_versions if v.strip()] - - if node_versions: - print(" 宝塔 Node 版本列表:") - for version_path in node_versions: - version_name = version_path.split('/')[-1] - # 检查该版本的 Node 是否存在 - stdin2, stdout2, stderr2 = client.exec_command("%s/node -v 2>/dev/null" % version_path, timeout=5) - node_ver = stdout2.read().decode("utf-8", errors="replace").strip() - if node_ver: - print(" - %s: %s" % (version_name, node_ver)) - else: - print(" - %s: (不可用)" % version_name) - else: - print(" 警告: 未找到宝塔 Node 安装目录") - - # 检查配置的 Node 版本 - node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") - stdin, stdout, stderr = client.exec_command("%s/node -v 2>/dev/null" % node_path, timeout=5) - configured_node = stdout.read().decode("utf-8", errors="replace").strip() - if configured_node: - print(" 配置的 Node 版本: %s (%s)" % (configured_node, node_path)) - else: - print(" 警告: 配置的 Node 路径不可用: %s" % node_path) - if node_versions: - # 自动使用第一个可用的版本 - suggested_path = node_versions[0] + "/bin" - print(" 建议使用: %s" % suggested_path) - - return True - except Exception as e: - print(" [警告] Node 环境检查失败: %s" % str(e)) - return False - finally: - client.close() - - -# ==================== SSH 上传 ==================== - -def upload_and_extract(cfg, tarball_path): - """SSH 上传并解压""" - print("[3/4] SSH 上传并解压 ...") - host = cfg["host"] - user = cfg["user"] - password = cfg["password"] - key_path = cfg["ssh_key"] - project_path = cfg["project_path"] - node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") - - if not password and not key_path: - print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") - return False - - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - # 连接 SSH - print(" 正在连接 %s@%s ..." % (user, host)) - if key_path: - if not os.path.isfile(key_path): - print(" [失败] SSH 密钥文件不存在: %s" % key_path) - return False - client.connect(host, username=user, key_filename=key_path, timeout=15) - else: - client.connect(host, username=user, password=password, timeout=15) - print(" [成功] SSH 连接成功") - - # 上传文件和解压脚本 - print(" 正在上传压缩包和脚本 ...") - sftp = client.open_sftp() - remote_tar = "/tmp/soul_deploy.tar.gz" - remote_script = "/tmp/soul_deploy_extract.sh" - - try: - # 上传压缩包 - sftp.put(tarball_path, remote_tar) - print(" [成功] 压缩包上传完成") - - # 构建解压脚本,使用 bash 脚本文件避免语法错误 - # 在脚本中指定使用特定的 Node 版本,避免多环境冲突 - verify_script_content = """#!/bin/bash -# 设置 Node 环境路径,避免多环境冲突 -export PATH=%s:$PATH - -cd %s -rm -rf .next public ecosystem.config.cjs 2>/dev/null -rm -f server.js package.json 2>/dev/null -tar -xzf %s -rm -f %s - -# 显示使用的 Node 版本 -echo "使用 Node 版本: $(node -v)" -echo "Node 路径: $(which node)" - -# 验证 node_modules/next 和 styled-jsx -echo "检查关键依赖..." -if [ ! -d 'node_modules/next' ] || [ ! -f 'node_modules/next/dist/server/require-hook.js' ]; then - echo '警告: node_modules/next 不完整' -fi - -# 检查 styled-jsx(require-hook.js 需要) -if [ ! -d 'node_modules/styled-jsx' ]; then - echo '警告: styled-jsx 缺失,正在修复...' - - # 尝试从 .pnpm 创建链接 - if [ -d 'node_modules/.pnpm' ]; then - STYLED_JSX_DIR=$(find node_modules/.pnpm -maxdepth 1 -type d -name "styled-jsx@*" | head -1) - if [ -n "$STYLED_JSX_DIR" ]; then - echo "从 .pnpm 链接 styled-jsx: $STYLED_JSX_DIR" - ln -sf "$STYLED_JSX_DIR/node_modules/styled-jsx" node_modules/styled-jsx - fi - fi -fi - -# 如果还是缺失,运行 npm install -if [ ! -d 'node_modules/styled-jsx' ]; then - if [ -f 'package.json' ] && command -v npm >/dev/null 2>&1; then - echo '运行 npm install --production 修复依赖...' - npm install --production --no-save 2>&1 | tail -10 || echo 'npm install 失败' - else - echo '无法自动修复: 缺少 package.json 或 npm 命令' - fi -fi - -# 最终验证 -echo "最终验证..." -if [ -d 'node_modules/next' ] && [ -f 'node_modules/next/dist/server/require-hook.js' ]; then - echo '✓ node_modules/next 存在' -else - echo '✗ node_modules/next 缺失' -fi - -if [ -d 'node_modules/styled-jsx' ]; then - echo '✓ node_modules/styled-jsx 存在' -else - echo '✗ node_modules/styled-jsx 缺失(可能导致启动失败)' -fi - -echo '解压完成' -""" % (node_path, project_path, remote_tar, remote_tar) - - # 写入脚本文件 - with sftp.open(remote_script, 'w') as f: - f.write(verify_script_content) - print(" [成功] 解压脚本上传完成") - finally: - sftp.close() - - # 设置执行权限并执行脚本 - print(" 正在解压并验证依赖...") - client.exec_command("chmod +x %s" % remote_script, timeout=10) - cmd = "bash %s" % remote_script - stdin, stdout, stderr = client.exec_command(cmd, timeout=120) - err = stderr.read().decode("utf-8", errors="replace").strip() - if err: - print(" 服务器 stderr:", err) - output = stdout.read().decode("utf-8", errors="replace").strip() - exit_status = stdout.channel.recv_exit_status() - if exit_status != 0: - print(" [失败] 解压失败,退出码:", exit_status) - return False - print(" [成功] 解压完成: %s" % project_path) - return True - except paramiko.AuthenticationException: - print(" [失败] SSH 认证失败,请检查用户名和密码") - return False - except paramiko.SSHException as e: - print(" [失败] SSH 连接异常:", str(e)) - return False - except Exception as e: - print(" [失败] SSH 错误:", str(e)) - import traceback - traceback.print_exc() - return False - finally: - client.close() - - -# ==================== 宝塔 API 部署 ==================== - -def deploy_via_baota_api(cfg): - """通过宝塔 API 管理 Node 项目部署""" - print("[4/4] 宝塔 API 管理 Node 项目 ...") - - panel_url = cfg["panel_url"] - api_key = cfg["api_key"] - pm2_name = cfg["pm2_name"] - project_path = cfg["project_path"] - node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") - port = 3006 # 默认端口 - - # 1. 检查项目是否存在 - print(" 检查项目状态...") - project_status = get_node_project_status(panel_url, api_key, pm2_name) - - if not project_status: - print(" 项目不存在,尝试添加项目配置...") - # 尝试添加项目(如果项目不存在,这个操作可能会失败,但不影响后续重启) - # 使用指定的 Node 路径,避免多环境冲突 - add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port, node_path) - else: - print(" 项目已存在: %s" % pm2_name) - current_status = project_status.get("status", "未知") - print(" 当前状态: %s" % current_status) - # 检查启动命令是否使用了正确的 Node 路径 - run_cmd = project_status.get("run_cmd", "") - if run_cmd and "node server.js" in run_cmd and node_path not in run_cmd: - print(" 警告: 项目启动命令可能未使用指定的 Node 版本") - print(" 当前命令: %s" % run_cmd) - print(" 建议命令: %s/node server.js" % node_path) - - # 2. 停止项目(如果正在运行) - print(" 停止项目(如果正在运行)...") - stop_node_project(panel_url, api_key, pm2_name) - import time - time.sleep(2) # 等待停止完成 - - # 3. 重启项目 - print(" 启动项目...") - ok = restart_node_project(panel_url, api_key, pm2_name) - - if not ok: - # 如果重启失败,尝试直接启动 - print(" 重启失败,尝试直接启动...") - ok = start_node_project(panel_url, api_key, pm2_name) - - if not ok: - print(" 提示: 若 Node 接口不可用,请在宝塔面板【Node 项目】中手动重启 %s" % pm2_name) - print(" 项目路径: %s" % project_path) - print(" 启动命令: %s/node server.js" % node_path) - print(" 端口: %d" % port) - print(" Node 版本: %s" % cfg.get("node_version", "v22.14.0")) - - return ok - - -# ==================== 主函数 ==================== - -def main(): - parser = argparse.ArgumentParser( - description="Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__ - ) - parser.add_argument("--no-build", action="store_true", help="跳过本地构建") - parser.add_argument("--no-upload", action="store_true", help="跳过 SSH 上传") - parser.add_argument("--no-api", action="store_true", help="上传后不调宝塔 API 重启") - args = parser.parse_args() - - # 获取项目根目录 - script_dir = os.path.dirname(os.path.abspath(__file__)) - root = os.path.dirname(script_dir) - - cfg = get_cfg() - print("=" * 60) - print(" Soul 创业派对 - 一键部署脚本") - print("=" * 60) - print(" 服务器: %s@%s" % (cfg["user"], cfg["host"])) - print(" 项目路径: %s" % cfg["project_path"]) - print(" PM2 名称: %s" % cfg["pm2_name"]) - print(" 站点地址: %s" % cfg["site_url"]) - print(" Node 版本: %s" % cfg.get("node_version", "v22.14.0")) - print(" Node 路径: %s" % cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")) - print("=" * 60) - print("") - - # 检查 Node 环境(可选,如果不需要可以跳过) - if not args.no_upload: - check_node_environments(cfg) - print("") - - # 步骤 1: 本地构建 - if not args.no_build: - if not run_build(root): - return 1 - else: - standalone = os.path.join(root, ".next", "standalone", "server.js") - if not os.path.isfile(standalone): - print("[错误] 跳过构建但未找到 .next/standalone/server.js") - return 1 - print("[跳过] 本地构建") - - # 步骤 2: 打包 - tarball_path = pack_standalone(root) - if not tarball_path: - return 1 - - # 步骤 3: SSH 上传并解压 - if not args.no_upload: - if not upload_and_extract(cfg, tarball_path): - return 1 - # 清理本地压缩包 - try: - os.remove(tarball_path) - except Exception: - pass - else: - print("[跳过] SSH 上传") - print(" 压缩包位置: %s" % tarball_path) - - # 步骤 4: 宝塔 API 重启 - if not args.no_api and not args.no_upload: - deploy_via_baota_api(cfg) - elif args.no_api: - print("[跳过] 宝塔 API 重启") - - print("") - print("=" * 60) - print(" 部署完成!") - print(" 前台: %s" % cfg["site_url"]) - print(" 后台: %s/admin" % cfg["site_url"]) - print("=" * 60) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Soul 创业派对 - 一键部署脚本 +本地打包 + SSH 上传解压 + 宝塔 API 部署 + +使用方法: + python scripts/deploy_soul.py # 完整部署流程 + python scripts/deploy_soul.py --no-build # 跳过本地构建 + python scripts/deploy_soul.py --no-upload # 跳过 SSH 上传 + python scripts/deploy_soul.py --no-api # 上传后不调宝塔 API 重启 + +环境变量(可选,覆盖默认配置): + DEPLOY_HOST # SSH 服务器地址,默认 42.194.232.22 + DEPLOY_USER # SSH 用户名,默认 root + DEPLOY_PASSWORD # SSH 密码,默认 Zhiqun1984 + DEPLOY_SSH_KEY # SSH 密钥路径(优先于密码) + DEPLOY_PROJECT_PATH # 服务器项目路径,默认 /www/wwwroot/soul + BAOTA_PANEL_URL # 宝塔面板地址,默认 https://42.194.232.22:9988 + BAOTA_API_KEY # 宝塔 API 密钥,默认 hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd + DEPLOY_PM2_APP # PM2 项目名称,默认 soul + DEPLOY_NODE_VERSION # Node 版本,默认 v22.14.0(用于显示) + DEPLOY_NODE_PATH # Node 可执行文件路径,默认 /www/server/nodejs/v22.14.0/bin + # 用于避免多 Node 环境冲突,确保使用指定的 Node 版本 +""" + +from __future__ import print_function + +import os +import sys +import shutil +import tarfile +import tempfile +import subprocess +import argparse +import time +import hashlib + +# 检查依赖 +try: + import paramiko +except ImportError: + print("错误: 请先安装 paramiko") + print(" pip install paramiko") + sys.exit(1) + +try: + import requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +except ImportError: + print("错误: 请先安装 requests") + print(" pip install requests") + sys.exit(1) + + +# ==================== 配置 ==================== + +def get_cfg(): + """获取部署配置""" + return { + # SSH 配置 + "host": os.environ.get("DEPLOY_HOST", "42.194.232.22"), + "user": os.environ.get("DEPLOY_USER", "root"), + "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), + "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), + "project_path": os.environ.get("DEPLOY_PROJECT_PATH", "/www/wwwroot/soul"), + # 宝塔 API 配置 + "panel_url": os.environ.get("BAOTA_PANEL_URL", "https://42.194.232.22:9988"), + "api_key": os.environ.get("BAOTA_API_KEY", "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"), + "pm2_name": os.environ.get("DEPLOY_PM2_APP", "soul"), + "site_url": os.environ.get("DEPLOY_SITE_URL", "https://soul.quwanzhi.com"), + # Node 环境配置 + "node_version": os.environ.get("DEPLOY_NODE_VERSION", "v22.14.0"), # 指定 Node 版本 + "node_path": os.environ.get("DEPLOY_NODE_PATH", "/www/server/nodejs/v22.14.0/bin"), # Node 可执行文件路径 + } + + +# ==================== 宝塔 API ==================== + +def _get_sign(api_key): + """宝塔鉴权签名:request_token = md5(request_time + md5(api_key))""" + now_time = int(time.time()) + sign_str = str(now_time) + hashlib.md5(api_key.encode("utf-8")).hexdigest() + request_token = hashlib.md5(sign_str.encode("utf-8")).hexdigest() + return now_time, request_token + + +def _baota_request(panel_url, api_key, path, data=None): + """发起宝塔 API 请求的通用函数""" + req_time, req_token = _get_sign(api_key) + payload = { + "request_time": req_time, + "request_token": req_token, + } + if data: + payload.update(data) + + url = panel_url.rstrip("/") + "/" + path.lstrip("/") + try: + r = requests.post(url, data=payload, verify=False, timeout=30) + if r.text: + return r.json() + return {} + except Exception as e: + print(" API 请求失败: %s" % str(e)) + return None + + +def get_node_project_list(panel_url, api_key): + """获取 Node 项目列表""" + paths_to_try = [ + "/project/nodejs/get_project_list", + "/plugin?action=a&name=nodejs&s=get_project_list", + ] + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path) + if result and (result.get("status") is True or "data" in result): + return result.get("data", []) + return None + + +def get_node_project_status(panel_url, api_key, pm2_name): + """检查 Node 项目状态""" + projects = get_node_project_list(panel_url, api_key) + if projects: + for project in projects: + if project.get("name") == pm2_name: + return project + return None + + +def start_node_project(panel_url, api_key, pm2_name): + """通过宝塔 API 启动 Node 项目""" + paths_to_try = [ + "/project/nodejs/start_project", + "/plugin?action=a&name=nodejs&s=start_project", + ] + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): + print(" [成功] 启动成功: %s" % pm2_name) + return True + return False + + +def stop_node_project(panel_url, api_key, pm2_name): + """通过宝塔 API 停止 Node 项目""" + paths_to_try = [ + "/project/nodejs/stop_project", + "/plugin?action=a&name=nodejs&s=stop_project", + ] + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)): + print(" [成功] 停止成功: %s" % pm2_name) + return True + return False + + +def restart_node_project(panel_url, api_key, pm2_name): + """ + 通过宝塔 API 重启 Node 项目 + 返回 True 表示成功,False 表示失败 + """ + # 先检查项目状态 + project_status = get_node_project_status(panel_url, api_key, pm2_name) + if project_status: + print(" 项目状态: %s" % project_status.get("status", "未知")) + + paths_to_try = [ + "/project/nodejs/restart_project", + "/plugin?action=a&name=nodejs&s=restart_project", + ] + + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name}) + if result: + if result.get("status") is True or result.get("msg") or "成功" in str(result): + print(" [成功] 重启成功: %s" % pm2_name) + return True + if "msg" in result: + print(" API 返回: %s" % result.get("msg")) + + print(" [警告] 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确") + return False + + +def add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port=3006, node_path=None): + """通过宝塔 API 添加或更新 Node 项目配置""" + paths_to_try = [ + "/project/nodejs/add_project", + "/plugin?action=a&name=nodejs&s=add_project", + ] + + # 如果指定了 Node 路径,在启动命令中使用完整路径 + if node_path: + run_cmd = "%s/node server.js" % node_path + else: + run_cmd = "node server.js" + + payload = { + "name": pm2_name, + "path": project_path, + "run_cmd": run_cmd, + "port": str(port), + } + + for path in paths_to_try: + result = _baota_request(panel_url, api_key, path, payload) + if result: + if result.get("status") is True: + print(" [成功] 项目配置已更新: %s" % pm2_name) + return True + if "msg" in result: + print(" API 返回: %s" % result.get("msg")) + + return False + + +# ==================== 本地构建 ==================== + +def run_build(root): + """执行本地构建""" + print("[1/4] 本地构建 pnpm build ...") + use_shell = sys.platform == "win32" + + # 检查 standalone 目录是否已存在 + standalone = os.path.join(root, ".next", "standalone") + server_js = os.path.join(standalone, "server.js") + + try: + # 在 Windows 上处理编码问题:使用 UTF-8 和 errors='replace' 来避免解码错误 + # errors='replace' 会在遇到无法解码的字符时用替换字符代替,避免崩溃 + r = subprocess.run( + ["pnpm", "build"], + cwd=root, + shell=use_shell, + timeout=600, + capture_output=True, + text=True, + encoding='utf-8', + errors='replace' # 遇到无法解码的字符时替换为占位符,避免 UnicodeDecodeError + ) + + # 安全地获取输出,处理可能的 None 值 + stdout_text = r.stdout or "" + stderr_text = r.stderr or "" + + # 检查是否是 Windows 符号链接权限错误 + # 错误信息可能在 stdout 或 stderr 中 + combined_output = stdout_text + stderr_text + is_windows_symlink_error = ( + sys.platform == "win32" and + r.returncode != 0 and + ("EPERM" in combined_output or + "symlink" in combined_output.lower() or + "operation not permitted" in combined_output.lower() or + "errno: -4048" in combined_output) + ) + + if r.returncode != 0: + if is_windows_symlink_error: + print(" [警告] Windows 符号链接权限错误(EPERM)") + print(" 这是 Windows 上 Next.js standalone 构建的常见问题") + print(" 解决方案(任选其一):") + print(" 1. 开启 Windows 开发者模式:设置 → 隐私和安全性 → 针对开发人员 → 开发人员模式") + print(" 2. 以管理员身份运行终端再执行构建") + print(" 3. 使用 --no-build 跳过构建,使用已有的构建文件") + print("") + print(" 正在检查 standalone 输出是否可用...") + + # 即使有错误,也检查 standalone 是否可用 + if os.path.isdir(standalone) and os.path.isfile(server_js): + print(" [成功] 虽然构建有警告,但 standalone 输出可用,继续部署") + return True + else: + print(" [失败] standalone 输出不可用,无法继续") + return False + else: + print(" [失败] 构建失败,退出码:", r.returncode) + if stdout_text: + # 显示最后几行输出以便调试 + lines = stdout_text.strip().split('\n') + if lines: + print(" 构建输出(最后10行):") + for line in lines[-10:]: + try: + # 确保输出可以正常显示 + print(" " + line) + except UnicodeEncodeError: + # 如果仍有编码问题,使用 ASCII 安全输出 + print(" " + line.encode('ascii', 'replace').decode('ascii')) + if stderr_text: + print(" 错误输出(最后5行):") + lines = stderr_text.strip().split('\n') + if lines: + for line in lines[-5:]: + try: + print(" " + line) + except UnicodeEncodeError: + print(" " + line.encode('ascii', 'replace').decode('ascii')) + return False + except subprocess.TimeoutExpired: + print(" [失败] 构建超时(超过10分钟)") + return False + except FileNotFoundError: + print(" [失败] 未找到 pnpm 命令,请先安装 pnpm") + print(" npm install -g pnpm") + return False + except UnicodeDecodeError as e: + print(" [失败] 构建输出编码错误:", str(e)) + print(" 提示: 这可能是 Windows 编码问题,尝试设置环境变量 PYTHONIOENCODING=utf-8") + # 即使有编码错误,也检查 standalone 是否可用 + if os.path.isdir(standalone) and os.path.isfile(server_js): + print(" [成功] 虽然构建有编码警告,但 standalone 输出可用,继续部署") + return True + return False + except Exception as e: + print(" [失败] 构建异常:", str(e)) + import traceback + traceback.print_exc() + # 即使有异常,也检查 standalone 是否可用(可能是部分成功) + if os.path.isdir(standalone) and os.path.isfile(server_js): + print(" [提示] 检测到 standalone 输出,可能是部分构建成功") + print(" 如果确定要使用,可以使用 --no-build 跳过构建步骤") + return False + + # 验证构建输出 + if not os.path.isdir(standalone) or not os.path.isfile(server_js): + print(" [失败] 未找到 .next/standalone 或 server.js") + print(" 请确认 next.config.mjs 中设置了 output: 'standalone'") + return False + print(" [成功] 构建完成") + return True + + +# ==================== 打包 ==================== + +def pack_standalone(root): + """打包 standalone 输出""" + print("[2/4] 打包 standalone ...") + standalone = os.path.join(root, ".next", "standalone") + static_src = os.path.join(root, ".next", "static") + public_src = os.path.join(root, "public") + ecosystem_src = os.path.join(root, "ecosystem.config.cjs") + + # 检查必要文件 + if not os.path.isdir(standalone): + print(" [失败] 未找到 .next/standalone 目录") + return None + if not os.path.isdir(static_src): + print(" [失败] 未找到 .next/static 目录") + return None + if not os.path.isdir(public_src): + print(" [警告] 未找到 public 目录,继续打包") + if not os.path.isfile(ecosystem_src): + print(" [警告] 未找到 ecosystem.config.cjs,继续打包") + + staging = tempfile.mkdtemp(prefix="soul_deploy_") + try: + # 复制 standalone 内容 + # standalone 目录应该包含:server.js, package.json, node_modules/ 等 + print(" 正在复制 standalone 目录内容...") + + # 使用更可靠的方法复制,特别是处理 pnpm 的符号链接结构 + def copy_with_dereference(src, dst): + """复制文件或目录,跟随符号链接""" + if os.path.islink(src): + # 如果是符号链接,复制目标文件 + link_target = os.readlink(src) + if os.path.isabs(link_target): + real_path = link_target + else: + real_path = os.path.join(os.path.dirname(src), link_target) + if os.path.exists(real_path): + if os.path.isdir(real_path): + shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True) + else: + shutil.copy2(real_path, dst) + else: + # 如果链接目标不存在,直接复制链接本身 + shutil.copy2(src, dst, follow_symlinks=False) + elif os.path.isdir(src): + # 对于目录,递归复制并处理符号链接 + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True) + else: + shutil.copy2(src, dst) + + for name in os.listdir(standalone): + src = os.path.join(standalone, name) + dst = os.path.join(staging, name) + if name == 'node_modules': + print(" 正在复制 node_modules(处理符号链接和 pnpm 结构)...") + copy_with_dereference(src, dst) + else: + copy_with_dereference(src, dst) + + # 🔧 修复 pnpm 依赖:将 styled-jsx 从 .pnpm 提升到根 node_modules + print(" 正在修复 pnpm 依赖结构...") + node_modules_dst = os.path.join(staging, "node_modules") + pnpm_dir = os.path.join(node_modules_dst, ".pnpm") + + if os.path.isdir(pnpm_dir): + # 需要提升的依赖列表(require-hook.js 需要) + required_deps = ["styled-jsx"] + + for dep in required_deps: + dep_in_root = os.path.join(node_modules_dst, dep) + if not os.path.exists(dep_in_root): + # 在 .pnpm 中查找该依赖 + for pnpm_pkg in os.listdir(pnpm_dir): + if pnpm_pkg.startswith(dep + "@"): + src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep) + if os.path.isdir(src_dep): + print(" 提升依赖: %s -> node_modules/%s" % (pnpm_pkg, dep)) + shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True) + break + else: + print(" 依赖已存在: %s" % dep) + + # 验证关键文件 + server_js = os.path.join(staging, "server.js") + package_json = os.path.join(staging, "package.json") + node_modules = os.path.join(staging, "node_modules") + if not os.path.isfile(server_js): + print(" [警告] standalone 目录内未找到 server.js") + if not os.path.isfile(package_json): + print(" [警告] standalone 目录内未找到 package.json") + if not os.path.isdir(node_modules): + print(" [警告] standalone 目录内未找到 node_modules") + else: + # 检查 node_modules/next 是否存在 + next_module = os.path.join(node_modules, "next") + if os.path.isdir(next_module): + print(" [成功] 已确认 node_modules/next 存在") + else: + print(" [警告] node_modules/next 不存在,可能导致运行时错误") + + # 检查 styled-jsx 是否存在(require-hook.js 需要) + styled_jsx_module = os.path.join(node_modules, "styled-jsx") + if os.path.isdir(styled_jsx_module): + print(" [成功] 已确认 node_modules/styled-jsx 存在") + else: + print(" [警告] node_modules/styled-jsx 不存在,可能导致启动失败") + + # 复制 .next/static + static_dst = os.path.join(staging, ".next", "static") + if os.path.exists(static_dst): + shutil.rmtree(static_dst) + os.makedirs(os.path.dirname(static_dst), exist_ok=True) + shutil.copytree(static_src, static_dst) + + # 复制 public + if os.path.isdir(public_src): + public_dst = os.path.join(staging, "public") + if os.path.exists(public_dst): + shutil.rmtree(public_dst) + shutil.copytree(public_src, public_dst) + + # 复制 ecosystem.config.cjs + if os.path.isfile(ecosystem_src): + shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs")) + + # 确保 package.json 的 start 脚本正确(standalone 模式使用 node server.js) + package_json_path = os.path.join(staging, "package.json") + if os.path.isfile(package_json_path): + try: + import json + with open(package_json_path, 'r', encoding='utf-8') as f: + package_data = json.load(f) + # 确保 start 脚本使用 node server.js + if 'scripts' not in package_data: + package_data['scripts'] = {} + package_data['scripts']['start'] = 'node server.js' + with open(package_json_path, 'w', encoding='utf-8') as f: + json.dump(package_data, f, indent=2, ensure_ascii=False) + print(" [提示] 已修正 package.json 的 start 脚本为 'node server.js'") + except Exception as e: + print(" [警告] 无法修正 package.json:", str(e)) + + # 创建压缩包 + tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz") + with tarfile.open(tarball, "w:gz") as tf: + for name in os.listdir(staging): + tf.add(os.path.join(staging, name), arcname=name) + + size_mb = os.path.getsize(tarball) / 1024 / 1024 + print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, size_mb)) + return tarball + except Exception as e: + print(" [失败] 打包异常:", str(e)) + import traceback + traceback.print_exc() + return None + finally: + shutil.rmtree(staging, ignore_errors=True) + + +# ==================== Node 环境检查 ==================== + +def check_node_environments(cfg): + """检查服务器上的 Node 环境""" + print("[检查] Node 环境检查 ...") + host = cfg["host"] + user = cfg["user"] + password = cfg["password"] + key_path = cfg["ssh_key"] + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + if key_path: + client.connect(host, username=user, key_filename=key_path, timeout=15) + else: + client.connect(host, username=user, password=password, timeout=15) + + # 检查系统默认 Node 版本 + stdin, stdout, stderr = client.exec_command("which node && node -v", timeout=10) + default_node = stdout.read().decode("utf-8", errors="replace").strip() + if default_node: + print(" 系统默认 Node: %s" % default_node) + else: + print(" 警告: 未找到系统默认 Node") + + # 检查宝塔安装的 Node 版本 + stdin, stdout, stderr = client.exec_command("ls -d /www/server/nodejs/*/ 2>/dev/null | head -5", timeout=10) + node_versions = stdout.read().decode("utf-8", errors="replace").strip().split('\n') + node_versions = [v.strip().rstrip('/') for v in node_versions if v.strip()] + + if node_versions: + print(" 宝塔 Node 版本列表:") + for version_path in node_versions: + version_name = version_path.split('/')[-1] + # 检查该版本的 Node 是否存在 + stdin2, stdout2, stderr2 = client.exec_command("%s/node -v 2>/dev/null" % version_path, timeout=5) + node_ver = stdout2.read().decode("utf-8", errors="replace").strip() + if node_ver: + print(" - %s: %s" % (version_name, node_ver)) + else: + print(" - %s: (不可用)" % version_name) + else: + print(" 警告: 未找到宝塔 Node 安装目录") + + # 检查配置的 Node 版本 + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + stdin, stdout, stderr = client.exec_command("%s/node -v 2>/dev/null" % node_path, timeout=5) + configured_node = stdout.read().decode("utf-8", errors="replace").strip() + if configured_node: + print(" 配置的 Node 版本: %s (%s)" % (configured_node, node_path)) + else: + print(" 警告: 配置的 Node 路径不可用: %s" % node_path) + if node_versions: + # 自动使用第一个可用的版本 + suggested_path = node_versions[0] + "/bin" + print(" 建议使用: %s" % suggested_path) + + return True + except Exception as e: + print(" [警告] Node 环境检查失败: %s" % str(e)) + return False + finally: + client.close() + + +# ==================== SSH 上传 ==================== + +def upload_and_extract(cfg, tarball_path): + """SSH 上传并解压""" + print("[3/4] SSH 上传并解压 ...") + host = cfg["host"] + user = cfg["user"] + password = cfg["password"] + key_path = cfg["ssh_key"] + project_path = cfg["project_path"] + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + + if not password and not key_path: + print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") + return False + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + # 连接 SSH + print(" 正在连接 %s@%s ..." % (user, host)) + if key_path: + if not os.path.isfile(key_path): + print(" [失败] SSH 密钥文件不存在: %s" % key_path) + return False + client.connect(host, username=user, key_filename=key_path, timeout=15) + else: + client.connect(host, username=user, password=password, timeout=15) + print(" [成功] SSH 连接成功") + + # 上传文件和解压脚本 + print(" 正在上传压缩包和脚本 ...") + sftp = client.open_sftp() + remote_tar = "/tmp/soul_deploy.tar.gz" + remote_script = "/tmp/soul_deploy_extract.sh" + + try: + # 上传压缩包 + sftp.put(tarball_path, remote_tar) + print(" [成功] 压缩包上传完成") + + # 构建解压脚本,使用 bash 脚本文件避免语法错误 + # 在脚本中指定使用特定的 Node 版本,避免多环境冲突 + verify_script_content = """#!/bin/bash +# 设置 Node 环境路径,避免多环境冲突 +export PATH=%s:$PATH + +cd %s +rm -rf .next public ecosystem.config.cjs 2>/dev/null +rm -f server.js package.json 2>/dev/null +tar -xzf %s +rm -f %s + +# 显示使用的 Node 版本 +echo "使用 Node 版本: $(node -v)" +echo "Node 路径: $(which node)" + +# 验证 node_modules/next 和 styled-jsx +echo "检查关键依赖..." +if [ ! -d 'node_modules/next' ] || [ ! -f 'node_modules/next/dist/server/require-hook.js' ]; then + echo '警告: node_modules/next 不完整' +fi + +# 检查 styled-jsx(require-hook.js 需要) +if [ ! -d 'node_modules/styled-jsx' ]; then + echo '警告: styled-jsx 缺失,正在修复...' + + # 尝试从 .pnpm 创建链接 + if [ -d 'node_modules/.pnpm' ]; then + STYLED_JSX_DIR=$(find node_modules/.pnpm -maxdepth 1 -type d -name "styled-jsx@*" | head -1) + if [ -n "$STYLED_JSX_DIR" ]; then + echo "从 .pnpm 链接 styled-jsx: $STYLED_JSX_DIR" + ln -sf "$STYLED_JSX_DIR/node_modules/styled-jsx" node_modules/styled-jsx + fi + fi +fi + +# 如果还是缺失,运行 npm install +if [ ! -d 'node_modules/styled-jsx' ]; then + if [ -f 'package.json' ] && command -v npm >/dev/null 2>&1; then + echo '运行 npm install --production 修复依赖...' + npm install --production --no-save 2>&1 | tail -10 || echo 'npm install 失败' + else + echo '无法自动修复: 缺少 package.json 或 npm 命令' + fi +fi + +# 最终验证 +echo "最终验证..." +if [ -d 'node_modules/next' ] && [ -f 'node_modules/next/dist/server/require-hook.js' ]; then + echo '✓ node_modules/next 存在' +else + echo '✗ node_modules/next 缺失' +fi + +if [ -d 'node_modules/styled-jsx' ]; then + echo '✓ node_modules/styled-jsx 存在' +else + echo '✗ node_modules/styled-jsx 缺失(可能导致启动失败)' +fi + +echo '解压完成' +""" % (node_path, project_path, remote_tar, remote_tar) + + # 写入脚本文件 + with sftp.open(remote_script, 'w') as f: + f.write(verify_script_content) + print(" [成功] 解压脚本上传完成") + finally: + sftp.close() + + # 设置执行权限并执行脚本 + print(" 正在解压并验证依赖...") + client.exec_command("chmod +x %s" % remote_script, timeout=10) + cmd = "bash %s" % remote_script + stdin, stdout, stderr = client.exec_command(cmd, timeout=120) + err = stderr.read().decode("utf-8", errors="replace").strip() + if err: + print(" 服务器 stderr:", err) + output = stdout.read().decode("utf-8", errors="replace").strip() + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + print(" [失败] 解压失败,退出码:", exit_status) + return False + print(" [成功] 解压完成: %s" % project_path) + return True + except paramiko.AuthenticationException: + print(" [失败] SSH 认证失败,请检查用户名和密码") + return False + except paramiko.SSHException as e: + print(" [失败] SSH 连接异常:", str(e)) + return False + except Exception as e: + print(" [失败] SSH 错误:", str(e)) + import traceback + traceback.print_exc() + return False + finally: + client.close() + + +# ==================== 宝塔 API 部署 ==================== + +def deploy_via_baota_api(cfg): + """通过宝塔 API 管理 Node 项目部署""" + print("[4/4] 宝塔 API 管理 Node 项目 ...") + + panel_url = cfg["panel_url"] + api_key = cfg["api_key"] + pm2_name = cfg["pm2_name"] + project_path = cfg["project_path"] + node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin") + port = 3006 # 默认端口 + + # 1. 检查项目是否存在 + print(" 检查项目状态...") + project_status = get_node_project_status(panel_url, api_key, pm2_name) + + if not project_status: + print(" 项目不存在,尝试添加项目配置...") + # 尝试添加项目(如果项目不存在,这个操作可能会失败,但不影响后续重启) + # 使用指定的 Node 路径,避免多环境冲突 + add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port, node_path) + else: + print(" 项目已存在: %s" % pm2_name) + current_status = project_status.get("status", "未知") + print(" 当前状态: %s" % current_status) + # 检查启动命令是否使用了正确的 Node 路径 + run_cmd = project_status.get("run_cmd", "") + if run_cmd and "node server.js" in run_cmd and node_path not in run_cmd: + print(" 警告: 项目启动命令可能未使用指定的 Node 版本") + print(" 当前命令: %s" % run_cmd) + print(" 建议命令: %s/node server.js" % node_path) + + # 2. 停止项目(如果正在运行) + print(" 停止项目(如果正在运行)...") + stop_node_project(panel_url, api_key, pm2_name) + import time + time.sleep(2) # 等待停止完成 + + # 3. 重启项目 + print(" 启动项目...") + ok = restart_node_project(panel_url, api_key, pm2_name) + + if not ok: + # 如果重启失败,尝试直接启动 + print(" 重启失败,尝试直接启动...") + ok = start_node_project(panel_url, api_key, pm2_name) + + if not ok: + print(" 提示: 若 Node 接口不可用,请在宝塔面板【Node 项目】中手动重启 %s" % pm2_name) + print(" 项目路径: %s" % project_path) + print(" 启动命令: %s/node server.js" % node_path) + print(" 端口: %d" % port) + print(" Node 版本: %s" % cfg.get("node_version", "v22.14.0")) + + return ok + + +# ==================== 主函数 ==================== + +def main(): + parser = argparse.ArgumentParser( + description="Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument("--no-build", action="store_true", help="跳过本地构建") + parser.add_argument("--no-upload", action="store_true", help="跳过 SSH 上传") + parser.add_argument("--no-api", action="store_true", help="上传后不调宝塔 API 重启") + args = parser.parse_args() + + # 获取项目根目录 + script_dir = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(script_dir) + + cfg = get_cfg() + print("=" * 60) + print(" Soul 创业派对 - 一键部署脚本") + print("=" * 60) + print(" 服务器: %s@%s" % (cfg["user"], cfg["host"])) + print(" 项目路径: %s" % cfg["project_path"]) + print(" PM2 名称: %s" % cfg["pm2_name"]) + print(" 站点地址: %s" % cfg["site_url"]) + print(" Node 版本: %s" % cfg.get("node_version", "v22.14.0")) + print(" Node 路径: %s" % cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")) + print("=" * 60) + print("") + + # 检查 Node 环境(可选,如果不需要可以跳过) + if not args.no_upload: + check_node_environments(cfg) + print("") + + # 步骤 1: 本地构建 + if not args.no_build: + if not run_build(root): + return 1 + else: + standalone = os.path.join(root, ".next", "standalone", "server.js") + if not os.path.isfile(standalone): + print("[错误] 跳过构建但未找到 .next/standalone/server.js") + return 1 + print("[跳过] 本地构建") + + # 步骤 2: 打包 + tarball_path = pack_standalone(root) + if not tarball_path: + return 1 + + # 步骤 3: SSH 上传并解压 + if not args.no_upload: + if not upload_and_extract(cfg, tarball_path): + return 1 + # 清理本地压缩包 + try: + os.remove(tarball_path) + except Exception: + pass + else: + print("[跳过] SSH 上传") + print(" 压缩包位置: %s" % tarball_path) + + # 步骤 4: 宝塔 API 重启 + if not args.no_api and not args.no_upload: + deploy_via_baota_api(cfg) + elif args.no_api: + print("[跳过] 宝塔 API 重启") + + print("") + print("=" * 60) + print(" 部署完成!") + print(" 前台: %s" % cfg["site_url"]) + print(" 后台: %s/admin" % cfg["site_url"]) + print("=" * 60) + return 0 + + +if __name__ == "__main__": + sys.exit(main())