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()