350 lines
12 KiB
Python
350 lines
12 KiB
Python
import os
|
|
import sys
|
|
import subprocess
|
|
import paramiko
|
|
from stat import S_ISDIR
|
|
|
|
# 强制配置标准输出为 UTF-8 编码,防止 Windows 控制台因 GBK 导致 Unicode 编码报错
|
|
if hasattr(sys.stdout, 'reconfigure'):
|
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
|
|
# ==========================================
|
|
# To_Docx 自动化部署脚本 (deploy.py)
|
|
# ==========================================
|
|
|
|
# 远程服务器配置
|
|
HOST = "175.178.162.18"
|
|
PORT = 22
|
|
USERNAME = "root"
|
|
PASSWORD = "sp-cc123"
|
|
REMOTE_DIR = "/root/to_docx"
|
|
|
|
# 排除上传的文件和目录列表
|
|
EXCLUDE_DIRS = {
|
|
".git",
|
|
"node_modules",
|
|
"venv",
|
|
"__pycache__",
|
|
".idea",
|
|
".vscode"
|
|
}
|
|
|
|
EXCLUDE_FILES = {
|
|
"deploy.py",
|
|
".DS_Store",
|
|
"database.json",
|
|
"settings.json"
|
|
}
|
|
|
|
def run_local_build():
|
|
"""在本地构建前端静态文件"""
|
|
print("=== [1/5] 开始在本地构建前端静态资源 ===")
|
|
client_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "client")
|
|
|
|
# 检查本地 node_modules 是否存在,若不存在则先安装依赖
|
|
if not os.path.exists(os.path.join(client_dir, "node_modules")):
|
|
print("[INFO] 本地未检测到 node_modules,正在执行 npm install...")
|
|
subprocess.run("npm install", shell=True, cwd=client_dir, check=True)
|
|
|
|
print("[INFO] 正在执行 npm run build...")
|
|
try:
|
|
subprocess.run("npm run build", shell=True, cwd=client_dir, check=True)
|
|
except subprocess.CalledProcessError as e:
|
|
# 兼容 Windows 系统下 Node.js 在编译完成后进程退出时偶尔出现的 Libuv 崩溃报错 (如 3221226505)
|
|
# 只要检测到 dist 目录下的入口 index.html 已正常生成,我们就允许继续同步和部署流程
|
|
dist_index = os.path.join(client_dir, "dist", "index.html")
|
|
if os.path.exists(dist_index):
|
|
print("[WARN] npm run build 虽然返回了异常退出码,但检测到目标 dist/index.html 已存在,判定构建正常,继续部署...")
|
|
else:
|
|
raise e
|
|
print("[SUCCESS] 本地前端构建成功!\n")
|
|
|
|
def connect_ssh():
|
|
"""建立 SSH 连接"""
|
|
ssh = paramiko.SSHClient()
|
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
print(f"[INFO] 正在连接远程服务器 {HOST}...")
|
|
ssh.connect(HOST, port=PORT, username=USERNAME, password=PASSWORD, timeout=10)
|
|
print("[SUCCESS] SSH 连接成功!\n")
|
|
return ssh
|
|
|
|
def execute_remote_cmd(ssh, cmd):
|
|
"""在远程服务器执行命令并打印输出"""
|
|
print(f"[CMD] 远程执行: {cmd}")
|
|
stdin, stdout, stderr = ssh.exec_command(cmd)
|
|
|
|
# 获取输出
|
|
out = stdout.read().decode('utf-8').strip()
|
|
err = stderr.read().decode('utf-8').strip()
|
|
|
|
if out:
|
|
print(f"[STDOUT]:\n{out}")
|
|
if err:
|
|
print(f"[STDERR]:\n{err}")
|
|
|
|
# 返回执行的状态码
|
|
exit_status = stdout.channel.recv_exit_status()
|
|
return exit_status, out, err
|
|
|
|
def sftp_upload_dir(sftp, local_dir, remote_dir):
|
|
"""递归上传目录并过滤不需要的文件"""
|
|
# 确保远程父目录存在
|
|
try:
|
|
sftp.mkdir(remote_dir)
|
|
except IOError:
|
|
pass
|
|
|
|
for item in os.listdir(local_dir):
|
|
# 排除过滤文件与文件夹
|
|
if item in EXCLUDE_FILES or item in EXCLUDE_DIRS:
|
|
continue
|
|
|
|
local_path = os.path.join(local_dir, item)
|
|
remote_path = os.path.join(remote_dir, item).replace('\\', '/')
|
|
|
|
if os.path.isdir(local_path):
|
|
sftp_upload_dir(sftp, local_path, remote_path)
|
|
else:
|
|
print(f"[UPLOAD] 上传文件: {item} -> {remote_path}")
|
|
sftp.put(local_path, remote_path)
|
|
|
|
def upload_project_files(ssh):
|
|
"""同步本地文件到远程服务器"""
|
|
print("=== [2/5] 开始增量同步文件到远程服务器 ===")
|
|
transport = ssh.get_transport()
|
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
|
|
local_root = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# 确保远程项目根目录存在
|
|
try:
|
|
sftp.mkdir(REMOTE_DIR)
|
|
except IOError:
|
|
pass
|
|
|
|
# 同步根目录下的脚本文件
|
|
for item in os.listdir(local_root):
|
|
local_path = os.path.join(local_root, item)
|
|
remote_path = os.path.join(REMOTE_DIR, item).replace('\\', '/')
|
|
|
|
if os.path.isdir(local_path):
|
|
if item in EXCLUDE_DIRS:
|
|
continue
|
|
sftp_upload_dir(sftp, local_path, remote_path)
|
|
else:
|
|
if item in EXCLUDE_FILES:
|
|
continue
|
|
print(f"[UPLOAD] 上传根目录文件: {item}")
|
|
sftp.put(local_path, remote_path)
|
|
|
|
print("[SUCCESS] 文件同步完成!\n")
|
|
|
|
def setup_remote_environment(ssh):
|
|
"""安装系统依赖如 Nginx, Pandoc 等"""
|
|
print("=== [3/5] 检查并配置服务器基础系统依赖 ===")
|
|
|
|
# 检查并安装 Nginx 和 Pandoc
|
|
print("[INFO] 正在检查并安装 Nginx 和 Pandoc...")
|
|
cmd_install = "apt-get update && apt-get install -y nginx pandoc"
|
|
execute_remote_cmd(ssh, cmd_install)
|
|
|
|
# 寻找服务器上的 Conda 可执行路径
|
|
print("[INFO] 正在定位服务器 Conda 路径...")
|
|
_, out_conda, _ = execute_remote_cmd(ssh, "which conda")
|
|
|
|
conda_path = out_conda.strip()
|
|
if not conda_path:
|
|
# 尝试常见路径
|
|
common_paths = [
|
|
"/root/miniconda3/bin/conda",
|
|
"/root/anaconda3/bin/conda",
|
|
"/usr/bin/conda"
|
|
]
|
|
for path in common_paths:
|
|
status, _, _ = execute_remote_cmd(ssh, f"test -f {path}")
|
|
if status == 0:
|
|
conda_path = path
|
|
break
|
|
|
|
if not conda_path:
|
|
print("[ERROR] 远程服务器上未找到 Conda。请先在服务器上安装 Miniconda 并在远程配置好环境。")
|
|
sys.exit(1)
|
|
|
|
print(f"[SUCCESS] 找到远程 Conda 路径: {conda_path}")
|
|
return conda_path
|
|
|
|
def configure_nginx(ssh):
|
|
"""写入 Nginx 配置并重新加载"""
|
|
print("=== [4/5] 正在配置域名 Nginx 反向代理 ===")
|
|
|
|
# 远程创建可被 Nginx (www-data) 正常访问的目录
|
|
remote_web_dir = "/var/www/to_docx"
|
|
print(f"[INFO] 远程创建静态文件托管目录并设置权限: {remote_web_dir}")
|
|
execute_remote_cmd(ssh, f"mkdir -p {remote_web_dir}")
|
|
execute_remote_cmd(ssh, f"rm -rf {remote_web_dir}/*")
|
|
execute_remote_cmd(ssh, f"cp -r /root/to_docx/client/dist/* {remote_web_dir}/")
|
|
execute_remote_cmd(ssh, f"chown -R www-data:www-data {remote_web_dir}")
|
|
execute_remote_cmd(ssh, f"chmod -R 755 {remote_web_dir}")
|
|
|
|
nginx_conf = """server {
|
|
server_name aiformat.cn www.aiformat.cn;
|
|
|
|
root /var/www/to_docx;
|
|
index index.html;
|
|
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
|
|
location /api {
|
|
proxy_pass http://127.0.0.1:3001;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
}
|
|
|
|
location /code/ {
|
|
proxy_pass http://127.0.0.1:3000/;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
|
|
client_max_body_size 512M;
|
|
proxy_connect_timeout 600s;
|
|
proxy_read_timeout 600s;
|
|
proxy_send_timeout 600s;
|
|
}
|
|
|
|
location /code {
|
|
return 301 $scheme://$host$request_uri/;
|
|
}
|
|
|
|
# 兼容 Git Credential Manager (GCM) 客户端在非根路径下克隆时丢失子路径前缀的 OAuth2 认证 Bug
|
|
location /login/oauth/ {
|
|
proxy_pass http://127.0.0.1:3000/login/oauth/;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
}
|
|
|
|
listen 443 ssl; # managed by Certbot
|
|
ssl_certificate /etc/letsencrypt/live/www.aiformat.cn/fullchain.pem; # managed by Certbot
|
|
ssl_certificate_key /etc/letsencrypt/live/www.aiformat.cn/privkey.pem; # managed by Certbot
|
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
|
}
|
|
|
|
server {
|
|
if ($host = www.aiformat.cn) {
|
|
return 301 https://$host$request_uri;
|
|
} # managed by Certbot
|
|
|
|
if ($host = aiformat.cn) {
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
|
|
listen 80;
|
|
server_name aiformat.cn www.aiformat.cn;
|
|
return 404; # managed by Certbot
|
|
}
|
|
"""
|
|
# 临时写入远程文件
|
|
remote_conf_path = "/etc/nginx/sites-available/to-docx"
|
|
print(f"[INFO] 写入 Nginx 配置文件: {remote_conf_path}")
|
|
|
|
# 使用 SFTP 写入
|
|
transport = ssh.get_transport()
|
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
with sftp.file(remote_conf_path, 'w') as f:
|
|
f.write(nginx_conf)
|
|
|
|
# 启用配置并删除默认配置
|
|
execute_remote_cmd(ssh, "ln -sf /etc/nginx/sites-available/to-docx /etc/nginx/sites-enabled/")
|
|
execute_remote_cmd(ssh, "rm -f /etc/nginx/sites-enabled/default")
|
|
|
|
# 检查并重启 Nginx
|
|
status, _, _ = execute_remote_cmd(ssh, "nginx -t")
|
|
if status == 0:
|
|
execute_remote_cmd(ssh, "systemctl reload nginx")
|
|
print("[SUCCESS] Nginx 配置完成并成功重载!\n")
|
|
else:
|
|
print("[ERROR] Nginx 配置文件格式错误,请检查!\n")
|
|
|
|
def configure_systemd(ssh, conda_path):
|
|
"""配置 Systemd 后端服务并启动"""
|
|
print("=== [5/5] 正在配置后端为 Systemd 服务 ===")
|
|
|
|
service_content = f"""[Unit]
|
|
Description=To_Docx Backend Service
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=root
|
|
WorkingDirectory=/root/to_docx/server
|
|
ExecStart={conda_path} run --no-capture-output -n to_docx uvicorn main:app --host 127.0.0.1 --port 3001
|
|
Restart=always
|
|
RestartSec=5
|
|
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
"""
|
|
|
|
service_path = "/etc/systemd/system/to-docx-backend.service"
|
|
print(f"[INFO] 写入 Systemd 配置文件: {service_path}")
|
|
|
|
transport = ssh.get_transport()
|
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
|
with sftp.file(service_path, 'w') as f:
|
|
f.write(service_content)
|
|
|
|
# 远程更新 Python 依赖包 (如果有新增)
|
|
print("[INFO] 正在在服务器上更新 Python 依赖...")
|
|
execute_remote_cmd(ssh, f"{conda_path} run -n to_docx pip install -r {REMOTE_DIR}/server/requirements.txt")
|
|
|
|
# 重启并启用 Systemd 服务
|
|
execute_remote_cmd(ssh, "systemctl daemon-reload")
|
|
execute_remote_cmd(ssh, "systemctl enable to-docx-backend")
|
|
execute_remote_cmd(ssh, "systemctl restart to-docx-backend")
|
|
|
|
# 检查服务状态
|
|
execute_remote_cmd(ssh, "systemctl status to-docx-backend --no-pager")
|
|
print("[SUCCESS] Systemd 后端服务部署并重启成功!\n")
|
|
|
|
def main():
|
|
try:
|
|
# 1. 本地前端构建
|
|
run_local_build()
|
|
|
|
# 2. 建立 SSH 连接
|
|
ssh = connect_ssh()
|
|
|
|
# 3. 上传最新代码
|
|
upload_project_files(ssh)
|
|
|
|
# 4. 配置远程基础依赖及定位 Conda
|
|
conda_path = setup_remote_environment(ssh)
|
|
|
|
# 5. 部署 Nginx 域名反向代理
|
|
configure_nginx(ssh)
|
|
|
|
# 6. 部署 Systemd 服务并启动后端
|
|
configure_systemd(ssh, conda_path)
|
|
|
|
print("[SUCCESS] To_Docx 已经成功一键部署!")
|
|
print(f"URL: https://www.aiformat.cn")
|
|
|
|
except Exception as e:
|
|
print(f"[ERROR] 部署失败,错误信息: {str(e)}")
|
|
sys.exit(1)
|
|
finally:
|
|
if 'ssh' in locals():
|
|
ssh.close()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|