更新 package.json 启动命令,删除不再使用的部署相关文件和脚本,提升项目结构的简洁性和可维护性。
This commit is contained in:
108
.github/workflows/README.md
vendored
Normal file
108
.github/workflows/README.md
vendored
Normal file
@@ -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**:适合本地快速测试,需要手动控制部署时机
|
||||
|
||||
两种方式可以并存,根据场景选择使用。
|
||||
117
.github/workflows/deploy.yml
vendored
Normal file
117
.github/workflows/deploy.yml
vendored
Normal file
@@ -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 "无法获取日志"
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# 仅用于「部署到宝塔」脚本,非项目运行依赖
|
||||
# 使用: pip install -r requirements-deploy.txt
|
||||
paramiko>=2.9.0
|
||||
requests>=2.28.0
|
||||
BIN
scripts/__pycache__/deploy.cpython-37.pyc
Normal file
BIN
scripts/__pycache__/deploy.cpython-37.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/deploy_soul.cpython-38.pyc
Normal file
BIN
scripts/__pycache__/deploy_soul.cpython-38.pyc
Normal file
Binary file not shown.
147
scripts/demo.py
Normal file
147
scripts/demo.py
Normal file
@@ -0,0 +1,147 @@
|
||||
#coding: utf-8
|
||||
# +-------------------------------------------------------------------
|
||||
# | 宝塔Linux面板
|
||||
# +-------------------------------------------------------------------
|
||||
# | Copyright (c) 2015-2099 宝塔软件(http://bt.cn) All rights reserved.
|
||||
# +-------------------------------------------------------------------
|
||||
# | Author: 黄文良 <2879625666@qq.com>
|
||||
# +-------------------------------------------------------------------
|
||||
|
||||
#------------------------------
|
||||
# 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()
|
||||
@@ -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()
|
||||
847
scripts/deploy_soul.py
Normal file
847
scripts/deploy_soul.py
Normal file
@@ -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())
|
||||
@@ -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())
|
||||
228
开发文档/8、部署/Standalone模式说明.md
Normal file
228
开发文档/8、部署/Standalone模式说明.md
Normal file
@@ -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`
|
||||
Reference in New Issue
Block a user