更新 package.json 启动命令,删除不再使用的部署相关文件和脚本,提升项目结构的简洁性和可维护性。

This commit is contained in:
Alex-larget
2026-02-01 12:06:56 +08:00
parent bd23273190
commit 0078cb0e2a
11 changed files with 1448 additions and 403 deletions

108
.github/workflows/README.md vendored Normal file
View 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
View 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 "无法获取日志"

View File

@@ -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",

View File

@@ -1,4 +0,0 @@
# 仅用于「部署到宝塔」脚本,非项目运行依赖
# 使用: pip install -r requirements-deploy.txt
paramiko>=2.9.0
requests>=2.28.0

Binary file not shown.

Binary file not shown.

147
scripts/demo.py Normal file
View 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()

View File

@@ -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
View 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-jsxrequire-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())

View File

@@ -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 buildstandalone 输出)"""
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/staticstandalone 可能已有 .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)
# publicstandalone 可能已带 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())

View 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`