From 0078cb0e2aba15e17e8c569a68b64c527be7c5d4 Mon Sep 17 00:00:00 2001 From: Alex-larget <33240357+Alex-larget@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:06:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20package.json=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=91=BD=E4=BB=A4=EF=BC=8C=E5=88=A0=E9=99=A4=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=BD=BF=E7=94=A8=E7=9A=84=E9=83=A8=E7=BD=B2=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=96=87=E4=BB=B6=E5=92=8C=E8=84=9A=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84=E7=9A=84?= =?UTF-8?q?=E7=AE=80=E6=B4=81=E6=80=A7=E5=92=8C=E5=8F=AF=E7=BB=B4=E6=8A=A4?= =?UTF-8?q?=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/README.md | 108 +++ .github/workflows/deploy.yml | 117 +++ package.json | 2 +- requirements-deploy.txt | 4 - scripts/__pycache__/deploy.cpython-37.pyc | Bin 0 -> 13286 bytes .../__pycache__/deploy_soul.cpython-38.pyc | Bin 0 -> 22894 bytes scripts/demo.py | 147 +++ scripts/deploy_baota_pure_api.py | 155 ---- scripts/deploy_soul.py | 847 ++++++++++++++++++ scripts/devlop.py | 243 ----- 开发文档/8、部署/Standalone模式说明.md | 228 +++++ 11 files changed, 1448 insertions(+), 403 deletions(-) create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/deploy.yml delete mode 100644 requirements-deploy.txt create mode 100644 scripts/__pycache__/deploy.cpython-37.pyc create mode 100644 scripts/__pycache__/deploy_soul.cpython-38.pyc create mode 100644 scripts/demo.py delete mode 100644 scripts/deploy_baota_pure_api.py create mode 100644 scripts/deploy_soul.py delete mode 100644 scripts/devlop.py create mode 100644 开发文档/8、部署/Standalone模式说明.md diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 00000000..72051b87 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,108 @@ +# GitHub Actions 自动化部署配置说明 + +## 📋 概述 + +本项目已配置 GitHub Actions 工作流,支持在推送代码到 `soul-content` 分支时自动部署到宝塔服务器。 + +## ✅ 项目兼容性 + +当前项目**完全支持** GitHub Actions 部署方式,因为: + +- ✅ 使用 `standalone` 模式,构建产物独立完整 +- ✅ 使用 pnpm 包管理器 +- ✅ 已配置 PM2 启动方式(`node server.js`) +- ✅ 端口配置为 3006 + +## 🔧 配置步骤 + +### 1. 在服务器上生成 SSH 密钥对 + +```bash +ssh root@42.194.232.22 +ssh-keygen -t rsa -b 4096 -C "github-actions-deploy" +cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys +cat ~/.ssh/id_rsa # 复制私钥内容 +``` + +### 2. 在 GitHub 仓库添加 Secrets + +进入 GitHub 仓库:`Settings` → `Secrets and variables` → `Actions` → `New repository secret` + +添加以下三个 Secrets: + +| Secret 名称 | 值 | 说明 | +|------------|-----|------| +| `SSH_HOST` | `42.194.232.22` | 服务器 IP | +| `SSH_USERNAME` | `root` | SSH 用户名 | +| `SSH_PRIVATE_KEY` | `-----BEGIN OPENSSH PRIVATE KEY-----...` | 服务器 SSH 私钥(完整内容) | + +### 3. 修改工作流分支(如需要) + +编辑 `.github/workflows/deploy.yml`,修改触发分支: + +```yaml +on: + push: + branches: + - soul-content # 改为你的分支名 +``` + +### 4. 提交并推送 + +```bash +git add .github/workflows/deploy.yml +git commit -m "添加 GitHub Actions 自动化部署" +git push origin soul-content +``` + +## 🚀 工作流程 + +1. **构建阶段**: + - 安装 Node.js 22 + - 安装 pnpm + - 安装项目依赖 + - 执行 `pnpm build`(生成 standalone 输出) + +2. **打包阶段**: + - 复制 `.next/standalone` 内容 + - 复制 `.next/static` 静态资源 + - 复制 `public` 目录 + - 复制 `ecosystem.config.cjs` PM2 配置 + - 打包为 `deploy.tar.gz` + +3. **部署阶段**: + - 通过 SCP 上传到服务器 `/tmp/` + - SSH 连接到服务器 + - 备份当前版本(可选) + - 解压到 `/www/wwwroot/soul` + - 重启 PM2 应用 `soul` + +## 📊 与当前部署方式对比 + +| 特性 | GitHub Actions | deploy_soul.py | +|------|---------------|----------------| +| **触发方式** | 自动(Push 代码) | 手动执行脚本 | +| **构建环境** | GitHub Ubuntu | 本地环境 | +| **构建速度** | 较慢(每次安装依赖) | 较快(本地缓存) | +| **适用场景** | 团队协作、CI/CD | 本地开发、快速部署 | +| **Windows 兼容** | ✅ 完美(云端构建) | ⚠️ 需处理符号链接 | + +## ⚠️ 注意事项 + +1. **首次部署**:确保服务器上 `/www/wwwroot/soul` 目录存在且 PM2 已配置项目 +2. **环境变量**:如果项目需要环境变量,需要在服务器上配置(宝塔面板或 `.env` 文件) +3. **数据库连接**:确保服务器能访问数据库 +4. **构建时间**:首次构建可能需要 5-10 分钟,后续会更快(GitHub Actions 缓存) + +## 🔍 查看部署日志 + +1. 在 GitHub 仓库点击 `Actions` 标签 +2. 选择最新的工作流运行 +3. 查看各步骤的详细日志 + +## 🆚 两种部署方式选择 + +- **使用 GitHub Actions**:适合团队协作,代码推送即自动部署 +- **使用 deploy_soul.py**:适合本地快速测试,需要手动控制部署时机 + +两种方式可以并存,根据场景选择使用。 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..fe4b68e9 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,117 @@ +name: Deploy Next.js to Baota (Standalone) + +on: + push: + branches: + - soul-content # 你的分支名 + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install + + - name: Build project (standalone mode) + run: pnpm build + env: + NODE_ENV: production + + - name: Package standalone output + run: | + # 创建临时打包目录 + mkdir -p /tmp/deploy_package + + # 复制 standalone 目录内容 + cp -r .next/standalone/* /tmp/deploy_package/ + + # 复制 static 目录 + mkdir -p /tmp/deploy_package/.next/static + cp -r .next/static/* /tmp/deploy_package/.next/static/ + + # 复制 public 目录 + cp -r public /tmp/deploy_package/ 2>/dev/null || true + + # 复制 PM2 配置文件 + cp ecosystem.config.cjs /tmp/deploy_package/ + + # 打包 + cd /tmp/deploy_package + tar -czf /tmp/deploy.tar.gz . + cd - + + - name: Deploy to server via SCP + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + source: "/tmp/deploy.tar.gz" + target: "/tmp/" + strip_components: 0 + + - name: Extract and restart on server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd /www/wwwroot/soul + + # 备份当前版本(可选) + if [ -d ".next" ]; then + echo "备份当前版本..." + tar -czf /tmp/soul_backup_$(date +%Y%m%d_%H%M%S).tar.gz .next public ecosystem.config.cjs server.js package.json 2>/dev/null || true + fi + + # 清理旧文件(保留 node_modules 如果存在) + rm -rf .next public ecosystem.config.cjs server.js package.json 2>/dev/null || true + + # 解压新版本 + echo "解压新版本..." + tar -xzf /tmp/deploy.tar.gz -C /www/wwwroot/soul + + # 验证关键文件 + if [ ! -f "server.js" ]; then + echo "错误: server.js 不存在,部署失败" + exit 1 + fi + + if [ ! -d ".next/static" ]; then + echo "警告: .next/static 目录不存在" + fi + + # 设置权限 + chmod +x server.js 2>/dev/null || true + + # 重启 PM2 应用 + echo "重启 PM2 应用..." + pm2 restart soul || pm2 start ecosystem.config.cjs || pm2 start server.js --name soul --env production + + # 清理临时文件 + rm -f /tmp/deploy.tar.gz + + echo "部署完成!" + + - name: Verify deployment + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + pm2 list + pm2 logs soul --lines 10 --nostream || echo "无法获取日志" diff --git a/package.json b/package.json index 6fb06997..077b4601 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "dev": "next dev", "lint": "eslint .", - "start": "npx next start -p 3006" + "start": "next start -p 3006" }, "dependencies": { "@emotion/is-prop-valid": "latest", diff --git a/requirements-deploy.txt b/requirements-deploy.txt deleted file mode 100644 index c57c6708..00000000 --- a/requirements-deploy.txt +++ /dev/null @@ -1,4 +0,0 @@ -# 仅用于「部署到宝塔」脚本,非项目运行依赖 -# 使用: pip install -r requirements-deploy.txt -paramiko>=2.9.0 -requests>=2.28.0 diff --git a/scripts/__pycache__/deploy.cpython-37.pyc b/scripts/__pycache__/deploy.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd4f8acf0e1004a99e31377f2791a8a3d84fc52e GIT binary patch literal 13286 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 literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/deploy_soul.cpython-38.pyc b/scripts/__pycache__/deploy_soul.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eebf9ae539ade3c0a6da55cd28fa8a8355fed4e7 GIT binary patch literal 22894 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 +# +------------------------------------------------------------------- + +#------------------------------ +# API-Demo of Python +#------------------------------ +import time,hashlib,sys,os,json + +# 从 deploy_baota_pure_api.py 提取的配置 +class bt_api: + __BT_KEY = 'hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd' + __BT_PANEL = 'https://42.194.232.22:9988' + + #如果希望多台面板,可以在实例化对象时,将面板地址与密钥传入 + def __init__(self,bt_panel = None,bt_key = None): + if bt_panel: + self.__BT_PANEL = bt_panel + self.__BT_KEY = bt_key + + + #取面板日志 + def get_logs(self): + #拼接URL地址 + url = self.__BT_PANEL + '/data?action=getData' + + #准备POST数据 + p_data = self.__get_key_data() #取签名 + p_data['table'] = 'logs' + p_data['limit'] = 10 + p_data['tojs'] = 'test' + + #请求面板接口 + result = self.__http_post_cookie(url,p_data) + + #解析JSON数据 + return json.loads(result) + + + #计算MD5 + def __get_md5(self,s): + m = hashlib.md5() + m.update(s.encode('utf-8')) + return m.hexdigest() + + #构造带有签名的关联数组 + def __get_key_data(self): + now_time = int(time.time()) + p_data = { + 'request_token':self.__get_md5(str(now_time) + '' + self.__get_md5(self.__BT_KEY)), + 'request_time':now_time + } + return p_data + + + #发送POST请求并保存Cookie + #@url 被请求的URL地址(必需) + #@data POST参数,可以是字符串或字典(必需) + #@timeout 超时时间默认1800秒 + #return string + def __http_post_cookie(self,url,p_data,timeout=1800): + cookie_file = './' + self.__get_md5(self.__BT_PANEL) + '.cookie'; + if sys.version_info[0] == 2: + #Python2 + import urllib,urllib2,ssl,cookielib + + #创建cookie对象 + cookie_obj = cookielib.MozillaCookieJar(cookie_file) + + #加载已保存的cookie + if os.path.exists(cookie_file):cookie_obj.load(cookie_file,ignore_discard=True,ignore_expires=True) + + ssl._create_default_https_context = ssl._create_unverified_context + + data = urllib.urlencode(p_data) + req = urllib2.Request(url, data) + opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookie_obj)) + response = opener.open(req,timeout=timeout) + + #保存cookie + cookie_obj.save(ignore_discard=True, ignore_expires=True) + return response.read() + else: + #Python3 + import urllib.request,ssl,http.cookiejar + # 禁用SSL证书验证(用于HTTPS连接) + ssl._create_default_https_context = ssl._create_unverified_context + + cookie_obj = http.cookiejar.MozillaCookieJar(cookie_file) + # 加载已保存的cookie(如果存在) + if os.path.exists(cookie_file): + cookie_obj.load(cookie_file,ignore_discard=True,ignore_expires=True) + handler = urllib.request.HTTPCookieProcessor(cookie_obj) + data = urllib.parse.urlencode(p_data).encode('utf-8') + req = urllib.request.Request(url, data) + opener = urllib.request.build_opener(handler) + response = opener.open(req,timeout = timeout) + cookie_obj.save(ignore_discard=True, ignore_expires=True) + result = response.read() + if type(result) == bytes: result = result.decode('utf-8') + return result + + +if __name__ == '__main__': + #实例化宝塔API对象 + print("=" * 50) + print("宝塔面板 API 测试") + print("=" * 50) + + # 创建实例以访问配置 + my_api = bt_api() + panel_url = 'https://42.194.232.22:9988' + api_key = 'hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd' + + print("面板地址:", panel_url) + print("API密钥:", api_key[:10] + "..." + api_key[-5:]) + print("=" * 50) + + try: + #调用get_logs方法 + print("\n正在获取面板日志...") + r_data = my_api.get_logs() + + #打印响应数据 + print("\n响应结果:") + print(json.dumps(r_data, indent=2, ensure_ascii=False)) + + # 检查响应状态 + if isinstance(r_data, dict): + if r_data.get('status') is True: + print("\n[成功] 连接成功!") + elif 'data' in r_data: + print("\n[成功] 已获取日志数据,共 %d 条记录" % len(r_data.get('data', []))) + else: + print("\n[失败] 连接失败:", r_data.get('msg', '未知错误')) + else: + print("\n响应格式:", type(r_data)) + + except Exception as e: + print("\n[错误] 请求异常:", str(e)) + import traceback + traceback.print_exc() \ No newline at end of file diff --git a/scripts/deploy_baota_pure_api.py b/scripts/deploy_baota_pure_api.py deleted file mode 100644 index 08279dc5..00000000 --- a/scripts/deploy_baota_pure_api.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -宝塔面板 API 模块 - Node 项目重启 / 计划任务触发 - -被 devlop.py 内部调用;也可单独使用: - python scripts/deploy_baota_pure_api.py # 重启 Node 项目 - python scripts/deploy_baota_pure_api.py --create-dir # 并创建项目目录 - python scripts/deploy_baota_pure_api.py --task-id 1 # 触发计划任务 ID=1 - -环境变量: - BAOTA_PANEL_URL # 宝塔面板地址,如 https://42.194.232.22:9988 或带安全入口 - BAOTA_API_KEY # 宝塔 API 密钥(面板 → 设置 → API 接口) - DEPLOY_PM2_APP # PM2 项目名称,默认 soul -""" - -from __future__ import print_function - -import os -import sys -import time -import hashlib - -try: - import requests - import urllib3 - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -except ImportError: - print("请先安装: pip install requests") - sys.exit(1) - -# 配置:可通过环境变量覆盖 -CFG = { - "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"), - "project_path": os.environ.get("DEPLOY_PROJECT_PATH", "/www/wwwroot/soul"), - "site_url": os.environ.get("DEPLOY_SITE_URL", "https://soul.quwanzhi.com"), -} - - -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 _request(base_url, path, data=None, timeout=30): - """发起宝塔 API 请求""" - url = base_url.rstrip("/") + "/" + path.lstrip("/") - api_key = CFG["api_key"] - if not api_key: - print("请设置 BAOTA_API_KEY(宝塔面板 → 设置 → API 接口)") - return None - req_time, req_token = _get_sign(api_key) - payload = { - "request_time": req_time, - "request_token": req_token, - } - if data: - payload.update(data) - try: - r = requests.post( - url, - data=payload, - verify=False, - timeout=timeout, - ) - return r.json() if r.text else None - except Exception as e: - print("请求失败:", e) - return None - - -def restart_node_project(panel_url, api_key, pm2_name): - """ - 通过宝塔 API 重启 Node 项目 - 返回 True 表示成功,False 表示失败 - """ - # Node 项目管理为插件接口,路径可能因版本不同 - paths_to_try = [ - "/plugin?action=a&name=nodejs&s=restart_project", - "/project/nodejs/restart_project", - ] - payload = {"project_name": pm2_name} - req_time, req_token = _get_sign(api_key) - payload["request_time"] = req_time - payload["request_token"] = req_token - - url_base = panel_url.rstrip("/") - for path in paths_to_try: - url = url_base + path - try: - r = requests.post(url, data=payload, verify=False, timeout=30) - j = r.json() if r.text else {} - if j.get("status") is True or j.get("msg") or r.status_code == 200: - print(" 重启成功: %s" % pm2_name) - return True - # 某些版本返回不同结构 - if "msg" in j: - print(" API 返回:", j.get("msg", j)) - except Exception as e: - print(" 尝试 %s 失败: %s" % (path, e)) - print(" 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确") - return False - - -def create_project_dir(): - """通过宝塔文件接口创建项目目录""" - path = "/files?action=CreateDir" - data = {"path": CFG["project_path"]} - j = _request(CFG["panel_url"], path, data) - if j and j.get("status") is True: - print(" 目录已创建: %s" % CFG["project_path"]) - return True - print(" 创建目录失败:", j) - return False - - -def trigger_crontab_task(task_id): - """触发计划任务""" - path = "/crontab?action=StartTask" - data = {"id": str(task_id)} - j = _request(CFG["panel_url"], path, data) - if j and j.get("status") is True: - print(" 计划任务 %s 已触发" % task_id) - return True - print(" 触发失败:", j) - return False - - -def main(): - import argparse - parser = argparse.ArgumentParser(description="宝塔 API - 重启 Node / 触发计划任务") - parser.add_argument("--create-dir", action="store_true", help="创建项目目录") - parser.add_argument("--task-id", type=int, default=0, help="触发计划任务 ID") - args = parser.parse_args() - - if args.create_dir: - create_project_dir() - if args.task_id: - ok = trigger_crontab_task(args.task_id) - sys.exit(0 if ok else 1) - ok = restart_node_project( - CFG["panel_url"], - CFG["api_key"], - CFG["pm2_name"], - ) - sys.exit(0 if ok else 1) - - -if __name__ == "__main__": - main() diff --git a/scripts/deploy_soul.py b/scripts/deploy_soul.py new file mode 100644 index 00000000..2df0d6fa --- /dev/null +++ b/scripts/deploy_soul.py @@ -0,0 +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()) diff --git a/scripts/devlop.py b/scripts/devlop.py deleted file mode 100644 index 63435d32..00000000 --- a/scripts/devlop.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署 - -流程:本地 pnpm build → 打包 .next/standalone → SSH 上传并解压到服务器 → 宝塔 API 重启 Node 项目 - -使用(在项目根目录): - python scripts/devlop.py - python scripts/devlop.py --no-build # 跳过构建,仅上传 + API 重启 - python scripts/devlop.py --no-api # 上传后不调宝塔 API 重启 - -环境变量: - DEPLOY_HOST, DEPLOY_USER, DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY - DEPLOY_PROJECT_PATH(如 /www/wwwroot/soul) - BAOTA_PANEL_URL, BAOTA_API_KEY - DEPLOY_PM2_APP(如 soul) - -依赖:pip install -r requirements-deploy.txt (paramiko, requests) -""" - -from __future__ import print_function - -import os -import sys -import shutil -import tarfile -import tempfile -import subprocess -import argparse - -# 项目根目录(scripts 的上一级) -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -# 不在本文件重写 sys.stdout/stderr,否则与 deploy_baota_pure_api 导入时的重写叠加会导致 -# 旧包装被 GC 关闭底层 buffer,后续 print 报 ValueError: I/O operation on closed file - -try: - import paramiko -except ImportError: - print("请先安装: pip install paramiko") - sys.exit(1) - -try: - import requests - import urllib3 - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -except ImportError: - print("请先安装: pip install requests") - sys.exit(1) - -# 导入宝塔 API 重启逻辑 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from deploy_baota_pure_api import CFG as BAOTA_CFG, restart_node_project - - -# 部署配置(与 .cursorrules、DEPLOYMENT.md、deploy_baota_pure_api 一致) -# 未设置环境变量时使用 .cursorrules 中的服务器信息,可用 DEPLOY_* 覆盖 -def get_cfg(): - return { - "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"), - "app_port": os.environ.get("DEPLOY_APP_PORT", "3006"), - "pm2_name": os.environ.get("DEPLOY_PM2_APP", BAOTA_CFG["pm2_name"]), - } - - -def run_build(root): - """本地执行 pnpm build(standalone 输出)""" - print("[1/4] 本地构建 pnpm build ...") - use_shell = sys.platform == "win32" - r = subprocess.run( - ["pnpm", "build"], - cwd=root, - shell=use_shell, - timeout=300, - ) - if r.returncode != 0: - print("构建失败,退出码:", r.returncode) - return False - standalone = os.path.join(root, ".next", "standalone") - if not os.path.isdir(standalone) or not os.path.isfile(os.path.join(standalone, "server.js")): - print("未找到 .next/standalone 或 server.js,请确认 next.config 中 output: 'standalone'") - return False - print(" 构建完成.") - return True - - -def pack_standalone(root): - """打包 standalone + .next/static + public + ecosystem.config.cjs,返回 tarball 路径""" - 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") - - staging = tempfile.mkdtemp(prefix="soul_deploy_") - try: - # 复制 standalone 目录内容到 staging - for name in os.listdir(standalone): - src = os.path.join(standalone, name) - dst = os.path.join(staging, name) - if os.path.isdir(src): - shutil.copytree(src, dst) - else: - shutil.copy2(src, dst) - # .next/static(standalone 可能已有 .next,先删再拷以用项目 static 覆盖) - static_dst = os.path.join(staging, ".next", "static") - shutil.rmtree(static_dst, ignore_errors=True) - os.makedirs(os.path.dirname(static_dst), exist_ok=True) - shutil.copytree(static_src, static_dst) - # public(standalone 可能已带 public 目录,先删再拷) - public_dst = os.path.join(staging, "public") - shutil.rmtree(public_dst, ignore_errors=True) - shutil.copytree(public_src, public_dst) - # ecosystem.config.cjs - shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs")) - - 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) - print(" 打包完成: %s" % tarball) - return tarball - finally: - shutil.rmtree(staging, ignore_errors=True) - - -def upload_and_extract(cfg, tarball_path): - """SSH 上传 tarball 并解压到服务器项目目录""" - print("[3/4] SSH 上传并解压 ...") - host = cfg["host"] - user = cfg["user"] - password = cfg["password"] - key_path = cfg["ssh_key"] - project_path = cfg["project_path"] - - 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: - if key_path: - client.connect(host, username=user, key_filename=key_path, timeout=15) - else: - client.connect(host, username=user, password=password, timeout=15) - - sftp = client.open_sftp() - remote_tar = "/tmp/soul_deploy.tar.gz" - sftp.put(tarball_path, remote_tar) - sftp.close() - - # 解压到项目目录:先清空再解压(保留 .env 等若存在可后续再配) - cmd = ( - "cd %s && " - "rm -rf .next server.js node_modules public ecosystem.config.cjs 2>/dev/null; " - "tar -xzf %s && " - "rm -f %s" - ) % (project_path, remote_tar, remote_tar) - stdin, stdout, stderr = client.exec_command(cmd, timeout=60) - err = stderr.read().decode("utf-8", errors="replace").strip() - if err: - print(" 服务器 stderr:", err) - code = stdout.channel.recv_exit_status() - if code != 0: - print(" 解压命令退出码:", code) - return False - print(" 上传并解压完成: %s" % project_path) - return True - except Exception as e: - print(" SSH 错误:", e) - return False - finally: - client.close() - - -def deploy_via_baota_api(cfg): - """宝塔 API 重启 Node 项目""" - print("[4/4] 宝塔 API 重启 Node 项目 ...") - panel_url = BAOTA_CFG["panel_url"] - api_key = BAOTA_CFG["api_key"] - pm2_name = cfg["pm2_name"] - ok = restart_node_project(panel_url, api_key, pm2_name) - if not ok: - print("提示:若 Node 接口不可用,请在宝塔面板【Node 项目】中手动重启 %s" % pm2_name) - return ok - - -def main(): - parser = argparse.ArgumentParser(description="本地打包 + SSH 上传 + 宝塔 API 部署") - parser.add_argument("--no-build", action="store_true", help="跳过本地构建(使用已有 .next/standalone)") - parser.add_argument("--no-upload", action="store_true", help="跳过 SSH 上传(仅构建+打包或仅 API)") - parser.add_argument("--no-api", action="store_true", help="上传后不调用宝塔 API 重启") - args = parser.parse_args() - - cfg = get_cfg() - print("=" * 60) - print(" Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署") - print("=" * 60) - print(" 服务器: %s@%s | 路径: %s | PM2: %s" % (cfg["user"], cfg["host"], cfg["project_path"], cfg["pm2_name"])) - print("=" * 60) - - tarball_path = None - - if not args.no_build: - if not run_build(ROOT): - return 1 - else: - # 若跳过构建,需已有 standalone,仍要打包 - if not os.path.isfile(os.path.join(ROOT, ".next", "standalone", "server.js")): - print("跳过构建但未找到 .next/standalone/server.js,请先执行一次完整部署或 pnpm build") - return 1 - - tarball_path = pack_standalone(ROOT) - if not tarball_path: - return 1 - - if not args.no_upload: - if not upload_and_extract(cfg, tarball_path): - return 1 - if os.path.isfile(tarball_path): - try: - os.remove(tarball_path) - except Exception: - pass - - if not args.no_api and not args.no_upload: - if not deploy_via_baota_api(cfg): - pass # 已打印提示 - - print("") - print(" 站点: %s | 后台: %s/admin" % (BAOTA_CFG["site_url"], BAOTA_CFG["site_url"])) - print("=" * 60) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/开发文档/8、部署/Standalone模式说明.md b/开发文档/8、部署/Standalone模式说明.md new file mode 100644 index 00000000..776a3951 --- /dev/null +++ b/开发文档/8、部署/Standalone模式说明.md @@ -0,0 +1,228 @@ +# Next.js Standalone 模式详解 + +## 📖 什么是 Standalone 模式? + +**Standalone 模式**是 Next.js 提供的一种**独立部署模式**,它会将应用及其所有运行时依赖打包成一个**自包含的独立目录**,可以直接在服务器上运行,**无需安装完整的项目依赖**。 + +## 🔧 如何启用? + +在 `next.config.mjs` 中配置: + +```javascript +const nextConfig = { + output: 'standalone', // 启用 standalone 模式 + // ... 其他配置 +} +``` + +你的项目已经启用: + +```9:9:next.config.mjs + output: 'standalone', +``` + +## 📦 构建产物结构 + +### 普通模式(非 standalone) + +``` +项目根目录/ +├── .next/ # 构建输出 +│ ├── static/ # 静态资源 +│ └── server/ # 服务端代码(不完整) +├── node_modules/ # 需要完整安装所有依赖 +├── package.json +└── public/ # 静态文件 +``` + +**部署时需要**: +- 上传整个项目 +- 在服务器上运行 `npm install` 安装所有依赖 +- 使用 `npm start` 或 `next start` 启动 + +### Standalone 模式 + +``` +.next/ +└── standalone/ # 独立部署目录(包含所有运行时依赖) + ├── server.js # 主启动文件 ⭐ + ├── package.json # 精简的依赖列表 + ├── node_modules/ # 只包含运行时必需的依赖(已优化) + │ └── next/ # Next.js 核心 + └── ... # 其他运行时文件 +``` + +**部署时只需要**: +- 上传 `.next/standalone` 目录 +- 复制 `.next/static` 静态资源 +- 复制 `public` 目录 +- 使用 `node server.js` 启动(**不需要 npm/next 命令**) + +## 🚀 启动方式对比 + +### 普通模式 + +```bash +# 需要先安装依赖 +npm install --production + +# 使用 next 命令启动 +npm start +# 或 +next start -p 3006 +``` + +### Standalone 模式 + +```bash +# 不需要安装依赖,直接启动 +node server.js + +# 或指定端口 +PORT=3006 node server.js + +# 使用 PM2 +pm2 start server.js --name soul +``` + +你的项目 PM2 配置: + +```10:10:ecosystem.config.cjs + script: 'server.js', +``` + +## ✨ Standalone 模式的优势 + +### 1. **部署包更小** 📉 +- 只包含运行时必需的依赖 +- 不包含开发依赖(如 TypeScript、ESLint 等) +- 通常比完整 `node_modules` 小 50-70% + +### 2. **启动更快** ⚡ +- 无需在服务器上运行 `npm install` +- 直接运行 `node server.js` 即可 +- 减少部署时间 + +### 3. **环境独立** 🔒 +- 不依赖服务器上的 Node.js 版本(只要兼容) +- 不依赖全局安装的 npm 包 +- 减少环境配置问题 + +### 4. **适合容器化** 🐳 +- Docker 镜像更小 +- 构建和运行环境分离 +- 你的项目 Dockerfile 也使用了 standalone: + +```23:23:DEPLOYMENT.md +| Docker | `Dockerfile` | Next.js 独立构建(`output: 'standalone'`) | +``` + +### 5. **安全性更好** 🛡️ +- 不暴露开发依赖 +- 减少攻击面 +- 生产环境更干净 + +## ⚠️ 注意事项 + +### 1. **启动方式不同** + +❌ **错误**: +```bash +npm start # standalone 模式下没有 next 命令 +next start # 命令不存在 +``` + +✅ **正确**: +```bash +node server.js # 直接运行 Node.js +``` + +### 2. **需要手动复制静态资源** + +Standalone 输出**不包含**: +- `.next/static` - 静态资源(CSS、JS 等) +- `public` - 公共静态文件 + +**部署时需要手动复制**: + +```bash +# 复制静态资源 +cp -r .next/static /www/wwwroot/soul/.next/ +cp -r public /www/wwwroot/soul/ +``` + +你的部署脚本已经处理了: + +```407:424:scripts/deploy_soul.py + # 复制 .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")) +``` + +### 3. **Windows 构建问题** + +Windows 上构建 standalone 可能遇到符号链接权限问题: + +``` +EPERM: operation not permitted, symlink +``` + +**解决方案**(你的文档已说明): + +```181:196:DEPLOYMENT.md +### Windows 本地执行 `pnpm build` 报 EPERM symlink + +本项目使用 `output: 'standalone'`,构建时 Next.js 会创建符号链接。**Windows 默认不允许普通用户创建符号链接**,会报错: + +- `EPERM: operation not permitted, symlink ... -> .next\standalone\node_modules\...` + +**可选做法(任选其一):** + +1. **开启 Windows 开发者模式(推荐,一劳永逸)** + - 设置 → 隐私和安全性 → 针对开发人员 → **开发人员模式** 打开 + - 开启后无需管理员即可创建符号链接,本地 `pnpm build` 可正常完成。 + +2. **以管理员身份运行终端再执行构建** + - 右键 Cursor/终端 → "以管理员身份运行",在项目根目录执行 `pnpm build`。 + +若只做部署、不在本机打 standalone 包,可用 `python scripts/devlop.py --no-build` 跳过构建后上传已有包,或由服务器/计划任务在服务器上执行构建。 +``` + +## 📊 对比总结 + +| 特性 | 普通模式 | Standalone 模式 | +|------|---------|----------------| +| **部署包大小** | 大(完整 node_modules) | 小(仅运行时依赖) | +| **服务器安装** | 需要 `npm install` | 不需要 | +| **启动命令** | `npm start` / `next start` | `node server.js` | +| **部署时间** | 较慢(需安装依赖) | 较快(直接运行) | +| **环境要求** | 需要 npm/next 命令 | 只需要 Node.js | +| **适用场景** | 传统部署 | 容器化、独立部署 | + +## 🎯 你的项目使用 Standalone 的原因 + +1. **宝塔服务器部署**:减少服务器上的依赖安装 +2. **Docker 容器化**:镜像更小,启动更快 +3. **GitHub Actions 部署**:构建和运行环境分离 +4. **团队协作**:减少环境配置问题 + +## 📚 相关文档 + +- [Next.js Standalone 官方文档](https://nextjs.org/docs/pages/api-reference/next-config-js/output#standalone) +- 你的项目部署文档:`DEPLOYMENT.md` +- PM2 配置:`ecosystem.config.cjs` +- 部署脚本:`scripts/deploy_soul.py`