#!/bin/bash # =========================================== # Rust Backend Deployment Script # =========================================== # Usage: ./deploy.sh [development|production] [options] # # Options: # --dry-run 预览模式,不执行实际操作 # --yes, -y 跳过确认提示 # --skip-tests 跳过部署后测试 # --rollback 回滚到上一个备份版本 # --backup-list 列出可用备份 # =========================================== set -euo pipefail PROJECT_NAME="rust-backend" REMOTE_USER="root" REMOTE_HOST="1panel-server" APP_ENV="production" # 默认选项 DRY_RUN=false SKIP_CONFIRM=false SKIP_TESTS=false ROLLBACK=false LIST_BACKUPS=false VIEW_LOGS=false TAIL_LOGS=false LOG_LINES=50 VIEW_STATUS=false POST_DEPLOY_LOGS=false INIT_ENV=false # 全局变量(部署流程中使用) BACKUP_PATH="" # 解析选项(先处理选项,再处理位置参数) while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true ;; --yes|-y) SKIP_CONFIRM=true ;; --skip-tests) SKIP_TESTS=true ;; --rollback) ROLLBACK=true ;; --backup-list) LIST_BACKUPS=true ;; --logs) VIEW_LOGS=true ;; --tail) TAIL_LOGS=true ;; --logs=*) VIEW_LOGS=true LOG_LINES="${1#*=}" ;; --tail=*) TAIL_LOGS=true LOG_LINES="${1#*=}" ;; --status) VIEW_STATUS=true ;; --post-logs) POST_DEPLOY_LOGS=true ;; --init-env) INIT_ENV=true ;; --help|-h) echo "用法: $0 [development|production] [options]" echo "" echo "选项:" echo " --dry-run 预览模式,不执行实际操作" echo " --yes, -y 跳过确认提示" echo " --skip-tests 跳过部署后测试" echo " --rollback 回滚到上一个备份版本" echo " --backup-list 列出可用备份" echo " --logs [N] 查看后端日志(默认50行)" echo " --tail [N] 实时跟踪后端日志(Ctrl+C 退出)" echo " --status 查看服务状态" echo " --post-logs 部署后自动显示日志" echo " --init-env 初始化远程 .env 文件(生成随机 JWT_SECRET,推送模板到服务器)" exit 0 ;; *) # 位置参数:development 或 production if [[ "$1" == "development" || "$1" == "production" ]]; then APP_ENV="$1" else echo "未知选项: $1" echo "用法: $0 [development|production] [options]" exit 1 fi ;; esac shift done # 根据环境选择远程目录和服务 case "${APP_ENV}" in development) REMOTE_DIR="/root/rust/rust_backend_dev" SERVICE_NAME="rust-backend-dev.service" TEST_DOMAIN="https://dev.xmclassmate.top" BACKEND_PORT="8080" DB_CONTAINER="1Panel-postgresql-FtMo" DB_NAME="milkydata_dev" DB_USER="milkydata" ;; production) REMOTE_DIR="/root/rust/rust_backend" SERVICE_NAME="rust-backend.service" TEST_DOMAIN="https://xmclassmate.top" BACKEND_PORT="4433" DB_CONTAINER="1Panel-postgresql-FtMo" DB_NAME="milkydata" DB_USER="milkydata" ;; *) echo "无效的环境: ${APP_ENV}" echo "支持的環境: development, production" exit 1 ;; esac # 彩色输出 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } log_step() { echo -e "${BLUE}[STEP]${NC} $1"; } log_dry() { echo -e "${YELLOW}[DRY-RUN]${NC} $1"; } # 获取 Git 版本信息 get_git_info() { local git_branch local git_commit local git_version git_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") git_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") git_version="${git_branch}@${git_commit}" echo "$git_version" } # 部署日志路径 get_deploy_log() { echo "${REMOTE_DIR}/deploy.log" } # 记录部署日志 log_to_file() { if [ "$DRY_RUN" = true ]; then return 0 fi local timestamp timestamp=$(date '+%Y-%m-%d %H:%M:%S') local log_entry="[${timestamp}] ${APP_ENV} ${GIT_VERSION} $1" ssh "${REMOTE_USER}@${REMOTE_HOST}" "echo '${log_entry}' >> $(get_deploy_log)" 2>/dev/null || true } # Webhook 通知 send_webhook() { local status="$1" local message="$2" if [ -z "${WEBHOOK_URL:-}" ]; then return 0 fi local payload payload=$(cat </dev/null 2>&1 || true } # 列出备份 list_backups() { log_step "可用备份列表..." echo "" if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'ls -la ${REMOTE_DIR}/${PROJECT_NAME}.backup.*'" return 0 fi ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls -la ${REMOTE_DIR}/${PROJECT_NAME}.backup.*" 2>/dev/null || echo "无备份文件" echo "" } # 查看后端日志 view_logs() { local lines="${LOG_LINES:-50}" log_step "查看后端日志(最后 ${lines} 行)..." echo "" if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'tail -${lines} ${REMOTE_DIR}/logs/rust-backend.*.log'" return 0 fi # 获取最新的日志文件 local log_file log_file=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") if [ -z "$log_file" ]; then log_warn "未找到日志文件: ${REMOTE_DIR}/logs/" return 1 fi echo -e "${BLUE}=== ${log_file} ===${NC}" echo "" ssh "${REMOTE_USER}@${REMOTE_HOST}" "tail -${lines} \"${log_file}\"" echo "" } # 实时跟踪后端日志 tail_logs() { local lines="${LOG_LINES:-100}" log_step "实时跟踪后端日志(最后 ${lines} 行,按 Ctrl+C 退出)..." echo "" if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'tail -f -n ${lines} ${REMOTE_DIR}/logs/rust-backend.*.log'" return 0 fi # 获取最新的日志文件 local log_file log_file=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") if [ -z "$log_file" ]; then log_warn "未找到日志文件: ${REMOTE_DIR}/logs/" return 1 fi echo -e "${BLUE}=== 实时日志: ${log_file} ===${NC}" echo -e "${YELLOW}按 Ctrl+C 退出${NC}" echo "" ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "tail -f -n ${lines} \"${log_file}\"" } # 查看服务状态 status() { log_step "服务状态..." echo "" if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'systemctl status ${SERVICE_NAME}'" return 0 fi echo -e "${BLUE}=== ${SERVICE_NAME} ===${NC}" ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl status ${SERVICE_NAME} --no-pager" echo "" echo -e "${BLUE}=== 最近日志 ===${NC}" local log_file log_file=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") if [ -n "$log_file" ]; then echo -e "${BLUE}=== ${log_file} ===${NC}" ssh "${REMOTE_USER}@${REMOTE_HOST}" "tail -20 \"${log_file}\"" fi echo "" } # 回滚(手动触发)- 支持选择特定版本 rollback() { log_step "查找可用备份..." local latest_backup if [ "$DRY_RUN" = true ]; then latest_backup="${REMOTE_DIR}/${PROJECT_NAME}.backup.20240101_120000.unknown.12345678" log_dry "latest_backup=${latest_backup}" else # 列出所有备份,让用户选择 local backups backups=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null" || echo "") if [ -z "$backups" ]; then log_error "没有找到可用的备份" return 1 fi echo "" echo -e "${BLUE}可用备份列表:${NC}" echo "$backups" | head -10 | nl -v 0 echo "" if [ "$SKIP_CONFIRM" = false ]; then local count count=$(echo "$backups" | wc -l) read -p "选择要回滚的备份 [0-$(($count > 10 ? 9 : $count - 1)),默认 0 最新]: " -n 1 -r echo if [[ ! $REPLY =~ ^[0-9]$ ]]; then REPLY=0 fi latest_backup=$(echo "$backups" | sed -n "$((REPLY + 1))p" || echo "") else latest_backup=$(echo "$backups" | head -1) fi if [ -z "$latest_backup" ]; then log_error "无效的备份选择" return 1 fi fi echo "" echo -e "${YELLOW}========== 回滚确认 ==========${NC}" echo -e " 将回滚到: ${latest_backup}" echo -e " 服务: ${SERVICE_NAME}" echo -e "================================${NC}" echo "" if [ "$SKIP_CONFIRM" = false ]; then read -p "确认回滚? [y/N] " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_warn "回滚已取消" return 0 fi fi log_step "停止服务..." ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl stop ${SERVICE_NAME}" 2>/dev/null || log_warn "停止服务失败,继续尝试..." log_step "恢复备份..." if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'cp ${latest_backup} ${REMOTE_DIR}/${PROJECT_NAME}'" else ssh "${REMOTE_USER}@${REMOTE_HOST}" "cp '${latest_backup}' '${REMOTE_DIR}/${PROJECT_NAME}'" fi log_step "启动服务..." if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'systemctl start ${SERVICE_NAME}'" else ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl start ${SERVICE_NAME}" fi log_info "回滚完成" log_to_file "MANUAL_ROLLBACK to $(basename $latest_backup)" send_webhook "rollback" "手动回滚完成: $(basename $latest_backup)" } # 回滚到上一个正常版本(测试失败时自动触发) rollback_on_failure() { local backup_path="$1" if [ -z "$backup_path" ]; then log_error "回滚失败: 未指定备份路径" return 1 fi echo "" log_error "========== 测试失败,自动回滚 ==========" echo -e " 原因: 部署后测试未通过" echo -e " 回滚到: ${backup_path}" echo -e " 服务: ${SERVICE_NAME}" echo -e "==========================================" echo "" log_step "停止服务..." if [ "$DRY_RUN" = false ]; then ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl stop ${SERVICE_NAME}" 2>/dev/null || log_warn "停止服务失败,继续尝试..." else log_dry "systemctl stop ${SERVICE_NAME}" fi log_step "恢复上一个正常版本..." if [ "$DRY_RUN" = false ]; then ssh "${REMOTE_USER}@${REMOTE_HOST}" "cp '${backup_path}' '${REMOTE_DIR}/${PROJECT_NAME}'" else log_dry "cp '${backup_path}' '${REMOTE_DIR}/${PROJECT_NAME}'" fi log_step "启动服务..." if [ "$DRY_RUN" = false ]; then ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl start ${SERVICE_NAME}" sleep 3 else log_dry "systemctl start ${SERVICE_NAME}" fi log_info "自动回滚完成" log_to_file "AUTO_ROLLBACK to $(basename $backup_path) - tests failed" send_webhook "failure" "部署测试失败,已自动回滚到: $(basename $backup_path)" } # 清理旧备份,只保留最近 N 个(按内容去重,相同版本只保留最新的) cleanup_old_backups() { local keep_count="${1:-5}" if [ "$DRY_RUN" = true ]; then log_dry "保留最近 ${keep_count} 个备份(相同版本只保留最新)" return 0 fi # 获取所有备份文件(按时间倒序) local backups backups=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null" || echo "") if [ -z "$backups" ]; then return 0 fi # 提取所有唯一版本的 MD5(从备份文件名中提取,格式:.backup.timestamp.gitHash.md5) # 只保留每个 MD5 最早的备份(即该版本的第一次备份) local seen_md5s="" local to_delete="" local kept_count=0 while IFS= read -r backup; do if [ -z "$backup" ]; then continue fi # 从文件名提取 MD5(最后一段) local md5 md5=$(basename "$backup" | rev | cut -d'.' -f2 | rev || echo "") if [ -z "$md5" ]; then # 旧格式备份,直接删除 to_delete="${to_delete}${backup}"$'\n' continue fi if echo "$seen_md5s" | grep -q "^${md5}$"; then # 已见过该版本,标记删除 to_delete="${to_delete}${backup}"$'\n' else # 新版本,保留 seen_md5s="${seen_md5s}${md5}"$'\n' kept_count=$((kept_count + 1)) fi done <<< "$backups" # 如果保留的数量超过 keep_count,则从最早的开始删除 if [ "$kept_count" -gt "$keep_count" ]; then log_step "保留版本数超过 ${keep_count},按时间清理多余版本..." # 重新处理,删除超出数量的旧备份(基于时间顺序) local count=0 while IFS= read -r backup; do if [ -z "$backup" ]; then continue fi count=$((count + 1)) if [ "$count" -gt "$keep_count" ]; then to_delete="${to_delete}${backup}"$'\n' fi done <<< "$backups" fi if [ -z "$to_delete" ]; then return 0 fi log_step "清理旧备份(保留 ${keep_count} 个不同版本)..." echo "$to_delete" | while IFS= read -r file; do if [ -n "$file" ]; then ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f '${file}'" 2>/dev/null && echo " 已删除: $(basename "$file")" || true fi done } # 检查远程 .env 文件 check_env_file() { log_step "检查远程 .env 文件..." if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'test -f ${REMOTE_DIR}/.env && cat ${REMOTE_DIR}/.env'" return 0 fi # 1. 检查文件是否存在 if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "test -f '${REMOTE_DIR}/.env'" 2>/dev/null; then log_error ".env 文件不存在: ${REMOTE_DIR}/.env" log_error "请先在服务器上创建 .env 文件" exit 1 fi # 2. 检查必需变量 local required_vars=() required_vars+=("JWT_SECRET") required_vars+=("WECHAT_SECRET") required_vars+=("WECHAT_APPID") required_vars+=("DATABASE_URL") # 生产环境额外需要 SSL 证书路径 if [ "${APP_ENV}" = "production" ]; then required_vars+=("SSL_KEY_PATH") required_vars+=("SSL_CERT_PATH") fi local missing_count=0 local placeholder_count=0 local env_content env_content=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat '${REMOTE_DIR}/.env'" 2>/dev/null || echo "") if [ -z "$env_content" ]; then log_error ".env 文件为空: ${REMOTE_DIR}/.env" exit 1 fi for var in "${required_vars[@]}"; do # 提取变量值(支持 VAR=value 和 # VAR= 注释行) local value value=$(echo "$env_content" | grep -E "^${var}=" | head -1 | sed "s/^${var}=//") if [ -z "$value" ]; then log_error " 缺少: ${var}=(未定义)" missing_count=$((missing_count + 1)) elif echo "$value" | grep -qiE "^(your_|placeholder|change_me|xxx|test_)"; then log_warn " 占位符: ${var}=${value}(请替换为真实值)" placeholder_count=$((placeholder_count + 1)) fi done # 3. 检查注释掉的变量(常见疏忽) local commented_vars=0 for var in "${required_vars[@]}"; do if echo "$env_content" | grep -qE "^#\s*${var}="; then log_warn " 已注释: # ${var}=(行被注释,不会生效)" commented_vars=$((commented_vars + 1)) fi done if [ $missing_count -gt 0 ]; then log_error ".env 文件检查失败:${missing_count} 个必需变量缺失" exit 1 fi if [ $placeholder_count -gt 0 ] || [ $commented_vars -gt 0 ]; then log_warn ".env 文件存在 ${placeholder_count} 个占位符和 ${commented_vars} 个注释行" log_warn "部署将继续,但建议在部署前修正" else log_info ".env 文件检查通过(${#required_vars[@]} 个必需变量均已配置)" fi } # 初始化远程 .env 文件(生成随机 JWT_SECRET,推送模板到服务器) init_env() { log_step "初始化远程 .env 文件..." if [ "$DRY_RUN" = true ]; then log_dry "在 ${REMOTE_HOST}:${REMOTE_DIR}/.env 生成带随机 JWT_SECRET 的配置文件" return 0 fi # 1. 检查服务器 .env 是否已存在 local env_exists=false if ssh "${REMOTE_USER}@${REMOTE_HOST}" "test -f '${REMOTE_DIR}/.env'" 2>/dev/null; then env_exists=true log_warn ".env 文件已存在: ${REMOTE_DIR}/.env" echo "" echo -e " ${YELLOW}文件已存在,继续将覆盖现有配置!${NC}" echo "" read -p "是否覆盖? [y/N] " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_warn "已取消" return 0 fi # 备份旧文件 local backup_file=".env.backup.$(date +%Y%m%d_%H%M%S)" ssh "${REMOTE_USER}@${REMOTE_HOST}" "cp '${REMOTE_DIR}/.env' '${REMOTE_DIR}/${backup_file}'" 2>/dev/null || true log_info "旧文件已备份: ${backup_file}" fi # 2. 生成随机 JWT_SECRET local jwt_secret if command -v openssl &>/dev/null; then jwt_secret=$(openssl rand -base64 32 | tr -d '\n') else jwt_secret="change-me-$(date +%s)-$$" log_warn "openssl 未找到,使用临时密钥(请手动替换)" fi # 3. 构建 .env 内容 local env_content env_content=$(cat < '${REMOTE_DIR}/.env'" ssh "${REMOTE_USER}@${REMOTE_HOST}" "chmod 600 '${REMOTE_DIR}/.env'" log_info ".env 文件已生成: ${REMOTE_DIR}/.env" log_warn "请 SSH 到服务器手动填写以下变量:" echo "" echo -e " ${YELLOW}DATABASE_URL${NC} — 数据库连接字符串" echo -e " ${YELLOW}WECHAT_APPID${NC} — 微信小程序 AppID" echo -e " ${YELLOW}WECHAT_SECRET${NC} — 微信小程序 Secret" echo "" log_info "JWT_SECRET 已自动生成(无需修改)" log_info "文件权限已设为 600(仅所有者可读)" echo "" # 5. 确认是否需要重启服务 if [ "$env_exists" = true ]; then log_info "如果已部署的服务正在运行,建议重启:" echo -e " ${GREEN}ssh ${REMOTE_USER}@${REMOTE_HOST} 'systemctl restart ${SERVICE_NAME}'${NC}" fi } check_dependencies() { log_step "检查依赖..." local deps=("cargo" "rsync" "ssh") for cmd in "${deps[@]}"; do if ! command -v "$cmd" &> /dev/null; then log_error "依赖命令 '$cmd' 未找到,请先安装。" fi done log_info "依赖检查通过" } # 获取远程服务器 SSH ControlMaster socket 路径 get_ssh_socket() { echo "/tmp/ssh-master-${REMOTE_HOST}-$$" } # 建立 SSH 连接(复用) ssh_connect() { if [ "$DRY_RUN" = true ]; then log_dry "ssh -o ControlMaster=auto -o ControlPath=$(get_ssh_socket) -o ControlPersist=5 ${REMOTE_USER}@${REMOTE_HOST}" return 0 fi local socket socket=$(get_ssh_socket) # 检查是否已有连接 if ! ssh -o "ControlPath=${socket}" -o "ConnectionAttempts=3" -o "ConnectTimeout=10" \ -o "ControlMaster=no" -o "ControlPersist=no" \ "${REMOTE_USER}@${REMOTE_HOST}" "echo 'connection test'" &>/dev/null; then log_info "建立 SSH 连接..." ssh -o ControlMaster=auto -o ControlPath="${socket}" -o ControlPersist=600 \ -o ConnectionAttempts=3 -o ConnectTimeout=10 \ -N "${REMOTE_USER}@${REMOTE_HOST}" & sleep 2 fi } # 关闭 SSH 连接 ssh_disconnect() { if [ "$DRY_RUN" = true ]; then return 0 fi local socket socket=$(get_ssh_socket) ssh -o "ControlPath=${socket}" -O exit "${REMOTE_USER}@${REMOTE_HOST}" 2>/dev/null || true } # 编译项目 build_project() { log_step "编译项目 (release模式)..." if [ "$DRY_RUN" = true ]; then log_dry "APP_ENV=\"${APP_ENV}\" cargo build --release" return 0 fi if APP_ENV="${APP_ENV}" cargo build --release; then log_info "编译成功" else log_error "编译失败!" fi } # 备份旧版本并保存回滚标记 backup_old_version() { log_step "备份旧版本..." local binary_path="./target/release/${PROJECT_NAME}" local timestamp timestamp=$(date +%Y%m%d_%H%M%S) # 获取当前运行的二进制文件的 MD5,用于区分不同版本 local current_md5="" if [ "$DRY_RUN" = false ]; then current_md5=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "md5sum '${REMOTE_DIR}/${PROJECT_NAME}' 2>/dev/null | cut -d' ' -f1" || echo "") fi # 获取 Git commit hash 用于版本标识 local git_hash git_hash=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") # 组合备份文件名:时间戳 + Git hash + MD5 前8位 BACKUP_PATH="${REMOTE_DIR}/${PROJECT_NAME}.backup.${timestamp}.${git_hash}.${current_md5:0:8}" if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'if [ -f ${REMOTE_DIR}/${PROJECT_NAME} ]; then cp ${REMOTE_DIR}/${PROJECT_NAME} '${BACKUP_PATH}'; echo \"\${BACKUP_PATH}\" > ${REMOTE_DIR}/.last_deployed; fi'" return 0 fi ssh "${REMOTE_USER}@${REMOTE_HOST}" " if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then cp '${REMOTE_DIR}/${PROJECT_NAME}' '${BACKUP_PATH}' echo '${BACKUP_PATH}' > '${REMOTE_DIR}/.last_deployed' echo '已备份到: ${BACKUP_PATH}' else echo '无旧版本需要备份' fi " log_info "备份完成" } # 上传二进制文件 upload_binary() { log_step "上传二进制文件..." local binary_path="./target/release/${PROJECT_NAME}" if [ ! -f "$binary_path" ]; then log_error "编译产物未找到: $binary_path" fi if [ "$DRY_RUN" = true ]; then log_dry "rsync -avzP '$binary_path' '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/'" return 0 fi rsync -avzP --progress "$binary_path" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/" log_info "上传完成" } # 上传配置文件(只上传 .toml 文件) upload_config() { log_step "上传配置文件..." if [ "$DRY_RUN" = true ]; then log_dry "rsync -avzP --include='*.toml' --exclude='*' ./config/ '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/config/'" return 0 fi rsync -avzP --include='*.toml' --exclude='*' ./config/ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/config/" log_info "配置文件上传完成" } # 上传迁移文件 upload_migrations() { log_step "上传迁移文件..." if [ "$DRY_RUN" = true ]; then log_dry "rsync -avzP ./migrations/ '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/migrations/'" return 0 fi if [ ! -d "./migrations" ]; then log_warn "迁移目录不存在,跳过" return 0 fi rsync -avzP --progress ./migrations/ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/migrations/" log_info "迁移文件上传完成" } # 上传测试脚本 upload_test_script() { log_step "上传测试脚本..." if [ "$DRY_RUN" = true ]; then log_dry "rsync -avzP ./test_deployment.sh '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/'" return 0 fi rsync -avzP --progress ./test_deployment.sh "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/" ssh "${REMOTE_USER}@${REMOTE_HOST}" "chmod +x ${REMOTE_DIR}/test_deployment.sh" log_info "测试脚本上传完成" } # 上传清理脚本(定时任务用) upload_cleanup_script() { log_step "上传清理脚本..." if [ "$DRY_RUN" = true ]; then log_dry "rsync -avzP ./scripts/cleanup_refresh_tokens.sh '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/'" return 0 fi mkdir -p ./scripts 2>/dev/null || true if [ -f "./scripts/cleanup_refresh_tokens.sh" ]; then rsync -avzP --progress ./scripts/cleanup_refresh_tokens.sh "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/" ssh "${REMOTE_USER}@${REMOTE_HOST}" "chmod +x ${REMOTE_DIR}/scripts/cleanup_refresh_tokens.sh" log_info "清理脚本上传完成" else log_warn "清理脚本不存在,跳过" fi } # 上传备份脚本 upload_backup_script() { log_step "上传备份脚本..." if [ "$DRY_RUN" = true ]; then log_dry "rsync -avzP ./scripts/backup-db.sh '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/'" return 0 fi mkdir -p ./scripts 2>/dev/null || true if [ -f "./scripts/backup-db.sh" ]; then rsync -avzP --progress ./scripts/backup-db.sh "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/" ssh "${REMOTE_USER}@${REMOTE_HOST}" "chmod +x ${REMOTE_DIR}/scripts/backup-db.sh" log_info "备份脚本上传完成" else log_warn "备份脚本不存在,跳过" fi } # 重启远程服务 restart_service() { log_step "重启服务 '${SERVICE_NAME}'..." if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'systemctl restart ${SERVICE_NAME}'" return 0 fi if ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl restart ${SERVICE_NAME}"; then log_info "服务重启成功" else log_error "服务重启失败!请检查服务状态和日志。" fi # 等待服务启动 sleep 3 } run_tests() { log_step "执行部署后测试..." if [ "$DRY_RUN" = true ]; then log_dry "完整测试套件:" log_dry " 1. test_basic_connectivity - 基础连通性" log_dry " 2. test_database_connection - 数据库连接" log_dry " 3. test_token_flow - Token 完整流程" log_dry " 4. test_weather_details_jwt - weather/details JWT 测试" log_dry " 5. test_invalid_token_401 - 无效 Token 401 测试" log_dry " 6. test_response_content - 响应内容验证" return 0 fi if [ "$SKIP_TESTS" = true ]; then log_warn "跳过测试(--skip-tests)" return 0 fi local failed=0 log_step "测试 1/6: 基础连通性..." if ! test_basic_connectivity; then rollback_and_exit "基础连通性测试失败" fi log_step "测试 2/6: 数据库连接..." if ! test_database_connection; then rollback_and_exit "数据库连接测试失败" fi log_step "测试 3/6: Token 完整流程..." if ! test_token_flow; then rollback_and_exit "Token 流程测试失败" fi log_step "测试 4/6: weather/details JWT 测试..." if ! test_weather_details_jwt; then rollback_and_exit "weather/details JWT 测试失败" fi log_step "测试 5/6: 无效 Token 401 测试..." if ! test_invalid_token_401; then rollback_and_exit "无效 Token 401 测试失败" fi log_step "测试 6/6: 响应内容验证..." if ! test_response_content; then rollback_and_exit "响应内容验证失败" fi log_info "========== 所有测试通过 ==========" return 0 } test_basic_connectivity() { local max_retries=3 local retry=0 while [ $retry -lt $max_retries ]; do retry=$((retry + 1)) log_info "尝试 ${retry}/${max_retries}: 测试本地后端 (127.0.0.1:${BACKEND_PORT})" local all_passed=true log_info " [1/4] 测试 /health..." local health_code health_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:${BACKEND_PORT}/health" 2>/dev/null || echo "000") if [ "$health_code" != "200" ]; then log_warn " /health 异常 (HTTP ${health_code})" all_passed=false else log_info " /health 正常" fi log_info " [2/4] 测试 /api/login..." local login_code login_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test_code\"'" 2>/dev/null || echo "000") if [ "$login_code" != "400" ] && [ "$login_code" != "200" ]; then log_warn " /api/login 异常 (HTTP ${login_code})" all_passed=false else log_info " /api/login 正常 (HTTP ${login_code})" fi log_info " [3/4] 测试 /api/refresh-token..." local refresh_code refresh_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/refresh-token -H 'Content-Type: application/json' -d '{\"refresh_token\":\"invalid\"}'" 2>/dev/null || echo "000") if [ "$refresh_code" != "401" ] && [ "$refresh_code" != "200" ]; then log_warn " /api/refresh-token 异常 (HTTP ${refresh_code})" all_passed=false else log_info " /api/refresh-token 正常 (HTTP ${refresh_code})" fi log_info " [4/4] 测试 / (根路径)..." local root_code root_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:${BACKEND_PORT}/" 2>/dev/null || echo "000") if [ "$root_code" != "200" ] && [ "$root_code" != "404" ]; then log_warn " / 异常 (HTTP ${root_code})" all_passed=false else log_info " / 正常 (HTTP ${root_code})" fi if [ "$all_passed" = true ]; then log_info "基础连通性测试通过" return 0 fi if [ $retry -lt $max_retries ]; then sleep 2 fi done log_error "基础连通性测试失败" return 1 } test_database_connection() { log_info " 测试 Docker 容器连接..." local ping_result ping_result=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "docker exec ${DB_CONTAINER} pg_isready -U ${DB_USER} 2>/dev/null" || echo "failed") if [[ ! "$ping_result" =~ "accepting connections" ]]; then log_warn " PostgreSQL 无法连接: ${ping_result}" return 1 fi log_info " PostgreSQL 连接正常" log_info " 测试数据库查询..." local query_result query_result=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c 'SELECT 1;' 2>/dev/null" || echo "failed") if [[ ! "$query_result" =~ "1 row" ]]; then log_warn " 数据库查询失败: ${query_result}" return 1 fi log_info " 数据库查询正常" log_info " 测试表存在..." local tables_result tables_result=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -t -c 'SELECT tablename FROM pg_tables WHERE schemaname = '\''public'\'' LIMIT 5;' 2>/dev/null" || echo "failed") if [[ ! "$tables_result" =~ "users" ]]; then log_warn " users 表不存在或查询失败: ${tables_result}" return 1 fi log_info " users 表存在" log_info "数据库连接测试通过" return 0 } test_token_flow() { local max_retries=3 local retry=0 while [ $retry -lt $max_retries ]; do retry=$((retry + 1)) log_info "尝试 ${retry}/${max_retries}: Token 完整流程测试" log_info " [1/3] 登录获取 Token..." local login_response login_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test_code_12345\"'" 2>/dev/null || echo "") if [ -z "$login_response" ]; then log_warn " 登录响应为空" sleep 2 continue fi local token token=$(echo "$login_response" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "") local refresh_token refresh_token=$(echo "$login_response" | grep -o '"refresh_token":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "") if [ -z "$token" ]; then log_warn " 未获取到 token(微信 code 无效是正常的)" log_info " 但 refresh_token 端点仍然可测试" else log_info " 获取到 token: ${token:0:50}..." fi log_info " [2/3] 测试 /api/user/profile (受保护接口)..." if [ -z "$token" ]; then log_warn " 跳过:无可用 token" else local profile_response profile_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s --max-time 10 http://127.0.0.1:${BACKEND_PORT}/api/user/profile -H 'Authorization: Bearer ${token}'" 2>/dev/null || echo "") local profile_success profile_success=$(echo "$profile_response" | grep -o '"success":true' || echo "") if [ -n "$profile_success" ]; then log_info " /api/user/profile 正常 (JWT 验证成功)" else log_warn " /api/user/profile 异常: ${profile_response:0:100}" fi fi log_info " [3/3] 测试 refresh_token 端点..." local test_refresh_token="MToxMDAwOmRldi1vbmx5LXNlY3JldC1jaGFuZ2UtaW4tcHJvZHVjdGlvbg==" local refresh_response refresh_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/refresh-token -H 'Content-Type: application/json' -d '{\"refresh_token\":\"${test_refresh_token}\"}'" 2>/dev/null || echo "") local refresh_code refresh_code=$(echo "$refresh_response" | grep -o '"success":true' || echo "") if [ -n "$refresh_code" ]; then log_info " refresh_token 端点正常" else log_warn " refresh_token 端点响应: ${refresh_response:0:100}" fi log_info "Token 流程测试通过" return 0 done log_error "Token 流程测试失败" return 1 } test_weather_details_jwt() { local max_retries=3 local retry=0 while [ $retry -lt $max_retries ]; do retry=$((retry + 1)) log_info "尝试 ${retry}/${max_retries}: weather/details JWT 测试" log_info " [1/2] 获取有效 Token..." local login_response login_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test_code_weather\"'" 2>/dev/null || echo "") local token token=$(echo "$login_response" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "") if [ -z "$token" ]; then log_warn " 未获取到 token,跳过 weather/details 测试" log_info " (这是正常的,因为微信 code 无效)" return 0 fi log_info " [2/2] 测试 /weather/details?id=1 带 JWT..." local weather_response weather_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s --max-time 10 'http://127.0.0.1:${BACKEND_PORT}/weather/details?id=1' -H 'Authorization: Bearer ${token}'" 2>/dev/null || echo "") local weather_success weather_success=$(echo "$weather_response" | grep -o '"success":true' || echo "") local weather_data weather_data=$(echo "$weather_response" | grep -o '"data":{' || echo "") if [ -n "$weather_success" ] || [ -n "$weather_data" ]; then log_info " /weather/details?id=1 JWT 验证成功" return 0 fi local weather_error weather_error=$(echo "$weather_response" | grep -o '"error":"[^"]*"' | head -1 || echo "") local weather_403 weather_403=$(echo "$weather_response" | grep -o "Forbidden" || echo "") if [ -n "$weather_error" ] || [ -n "$weather_403" ]; then log_info " /weather/details?id=1 JWT 验证通过,但资源无权限(正常)" return 0 fi log_warn " /weather/details 响应异常: ${weather_response:0:150}" sleep 2 done log_error "weather/details JWT 测试失败" return 1 } test_invalid_token_401() { local max_retries=3 local retry=0 while [ $retry -lt $max_retries ]; do retry=$((retry + 1)) log_info "尝试 ${retry}/${max_retries}: 无效 Token 401 测试" log_info " [1/3] 测试 /api/user/profile (无效 Token)..." local invalid_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDAwMDAwMDB9.invalid_signature" local response_401 response_401=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:${BACKEND_PORT}/api/user/profile -H 'Authorization: Bearer ${invalid_token}'" 2>/dev/null || echo "000") if [ "$response_401" = "401" ]; then log_info " /api/user/profile 无效 Token 返回 401 ✓" else log_warn " /api/user/profile 异常 (HTTP ${response_401})" sleep 2 continue fi log_info " [2/3] 测试 /weather/details (无效 Token)..." local weather_401 weather_401=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s -o /dev/null -w '%{http_code}' --max-time 10 'http://127.0.0.1:${BACKEND_PORT}/weather/details?id=1' -H 'Authorization: Bearer ${invalid_token}'" 2>/dev/null || echo "000") if [ "$weather_401" = "401" ]; then log_info " /weather/details 无效 Token 返回 401 ✓" else log_warn " /weather/details 异常 (HTTP ${weather_401})" sleep 2 continue fi log_info " [3/3] 测试 /api/weather (受保护接口)..." local api_weather_401 api_weather_401=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s -o /dev/null -w '%{http_code}' --max-time 10 'http://127.0.0.1:${BACKEND_PORT}/api/weather' -H 'Authorization: Bearer ${invalid_token}'" 2>/dev/null || echo "000") if [ "$api_weather_401" = "401" ]; then log_info " /api/weather 无效 Token 返回 401 ✓" else log_warn " /api/weather 异常 (HTTP ${api_weather_401})" sleep 2 continue fi log_info "无效 Token 401 测试通过" return 0 done log_error "无效 Token 401 测试失败" return 1 } test_response_content() { log_info " 测试 /health 响应内容..." local health_resp health_resp=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "curl -s --max-time 10 http://127.0.0.1:${BACKEND_PORT}/health" 2>/dev/null || echo "") if [[ "$health_resp" =~ "\"status\"" ]] && [[ "$health_resp" =~ "\"database\"" ]]; then log_info " /health JSON 格式正确 ✓" else log_warn " /health 响应格式异常: ${health_resp:0:100}" return 1 fi log_info " 测试 /health database 字段..." if [[ "$health_resp" =~ '"database":"connected"' ]]; then log_info " 数据库连接状态正常 ✓" else log_warn " 数据库连接状态异常" return 1 fi log_info "响应内容验证通过" return 0 } rollback_and_exit() { local reason="$1" log_error "测试失败: ${reason}" log_error "自动回滚到上一个正常版本..." local last_known_good last_known_good=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat ${REMOTE_DIR}/.last_deployed 2>/dev/null || echo """) if [ -z "$last_known_good" ]; then log_error "无法回滚: 未找到上一个正常版本记录" exit 1 fi rollback_on_failure "$last_known_good" exit 1 } # 备份数据库 backup_database() { log_step "备份数据库..." if [ "$DRY_RUN" = true ]; then log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'bash -c \"docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > ${REMOTE_DIR}/backups/backup.dump\"'" return 0 fi local timestamp timestamp=$(date +%Y%m%d_%H%M%S) local backup_file="${REMOTE_DIR}/backups/${DB_NAME}_${timestamp}.dump" log_info "创建数据库备份..." # 创建备份目录 ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_DIR}/backups" # 在远程执行备份(使用 stdout 重定向) if ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "bash -c 'docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > /tmp/backup_${timestamp}.dump && mv /tmp/backup_${timestamp}.dump \"${backup_file}\"'" 2>&1; then log_info "数据库备份成功: ${DB_NAME}_${timestamp}.dump" # 清理旧数据库备份(保留2个) cleanup_old_db_backups else log_warn "数据库备份失败,继续部署" fi } # 清理旧数据库备份(保留2个) cleanup_old_db_backups() { local keep_count="${1:-1}" local backup_count backup_count=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump 2>/dev/null | wc -l" || echo "0") if [ "$backup_count" -le "$keep_count" ]; then return 0 fi local to_delete to_delete=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump 2>/dev/null | tail -n +$((keep_count + 1))" || echo "") if [ -z "$to_delete" ]; then return 0 fi log_step "清理旧数据库备份(保留 ${keep_count} 个)..." echo "$to_delete" | while read -r file; do if [ -n "$file" ]; then ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f '${file}'" 2>/dev/null && echo " 已删除: $(basename ${file})" || true fi done } # 回滚数据库(手动触发) rollback_database() { local backup_file="$1" if [ -z "$backup_file" ]; then log_error "回滚失败: 未指定备份文件" return 1 fi log_step "回滚数据库..." log_warn "从备份恢复: ${backup_file}" if [ "$DRY_RUN" = true ]; then log_dry "pg_restore -c -U ${DB_USER} -d ${DB_NAME} ${backup_file}" return 0 fi # 检查备份文件是否存在 if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "[ -f '${backup_file}' ]"; then log_error "备份文件不存在: ${backup_file}" return 1 fi # 停止服务避免数据库冲突 log_info "停止服务..." ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true # 执行恢复(通过 stdin 管道) if ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat '${backup_file}'" | \ ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "docker exec -i ${DB_CONTAINER} pg_restore -c -U ${DB_USER} -d ${DB_NAME}" 2>&1; then log_info "数据库恢复成功" else log_error "数据库恢复失败" return 1 fi # 重启服务 log_info "重启服务..." ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl start ${SERVICE_NAME}" log_info "数据库回滚完成" } # 执行数据库迁移 run_migrations() { log_step "执行数据库迁移..." if [ "$DRY_RUN" = true ]; then log_dry "检查并执行 migrations/*.sql" local migrations migrations=$(ls ./migrations/*.sql 2>/dev/null | wc -l || echo "0") log_dry "发现 ${migrations} 个迁移文件" return 0 fi # 检查是否有迁移文件 local sql_files sql_files=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "") if [ -n "$sql_files" ]; then # 有迁移文件,先备份数据库 backup_database else log_info "没有发现迁移文件,跳过" fi log_info "检查迁移文件..." if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "[ -d ${REMOTE_DIR}/migrations ]"; then log_warn "迁移目录不存在,跳过" return 0 fi local sql_files sql_files=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "") if [ -z "$sql_files" ]; then log_warn "没有找到迁移文件" return 0 fi for sql_file in $sql_files; do local filename filename=$(basename "$sql_file") local table_check=$(echo "$filename" | sed 's/[0-9]*_//' | sed 's/.sql$//') log_info "检查迁移: $filename" if [ "$table_check" = "add_refresh_tokens" ]; then local exists exists=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT 1 FROM information_schema.tables WHERE table_name = 'refresh_tokens'\" 2>/dev/null" || echo "0") if [ "$exists" = "1" ]; then log_info " 表 refresh_tokens 已存在,跳过" continue fi fi log_info " 执行迁移: $filename" if ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat \"${sql_file}\" | docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}"; then log_info " 完成: $filename" else log_warn " 迁移可能已执行或失败,继续" fi done log_info "数据库迁移完成" } # 确认部署 confirm_deploy() { if [ "$DRY_RUN" = true ] || [ "$SKIP_CONFIRM" = true ]; then return 0 fi echo "" echo -e "${YELLOW}========== 部署确认 ==========${NC}" echo -e " 环境: ${APP_ENV}" echo -e " 远程目录: ${REMOTE_DIR}" echo -e " 服务: ${SERVICE_NAME}" echo -e " Git: ${GIT_VERSION}" echo -e " 测试域名: ${TEST_DOMAIN}" echo -e "================================${NC}" echo "" read -p "确认部署? [y/N] " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_warn "部署已取消" exit 0 fi } # 显示部署信息 show_deploy_info() { echo "" echo -e "${BLUE}========================================${NC}" echo -e "${BLUE} Rust Backend 部署脚本${NC}" echo -e "${BLUE}========================================${NC}" echo -e " 项目: ${PROJECT_NAME}" echo -e " 环境: ${APP_ENV}" echo -e " 远程主机: ${REMOTE_USER}@${REMOTE_HOST}" echo -e " 远程目录: ${REMOTE_DIR}" echo -e " 服务: ${SERVICE_NAME}" echo -e " Git 版本: ${GIT_VERSION}" if [ "$DRY_RUN" = true ]; then echo -e " 模式: ${YELLOW}预览模式 (DRY-RUN)${NC}" fi echo -e "${BLUE}========================================${NC}" echo "" } # 清理函数 cleanup() { ssh_disconnect } trap cleanup EXIT # 主函数 main() { GIT_VERSION=$(get_git_info) if [ "$LIST_BACKUPS" = true ]; then ssh_connect list_backups exit 0 fi if [ "$INIT_ENV" = true ]; then ssh_connect init_env exit 0 fi if [ "$VIEW_LOGS" = true ]; then ssh_connect view_logs exit 0 fi if [ "$TAIL_LOGS" = true ]; then ssh_connect tail_logs exit 0 fi if [ "$VIEW_STATUS" = true ]; then ssh_connect status exit 0 fi if [ "$ROLLBACK" = true ]; then ssh_connect rollback exit 0 fi show_deploy_info confirm_deploy check_dependencies ssh_connect check_env_file build_project backup_old_version cleanup_old_backups 5 upload_binary upload_config upload_migrations upload_test_script upload_cleanup_script upload_backup_script restart_service run_migrations if run_tests; then log_to_file "DEPLOY success" send_webhook "success" "部署完成" echo "" log_info "========== 部署完成! ==========" echo -e " Git: ${GIT_VERSION}" echo -e " 环境: ${APP_ENV}" echo "" if [ "$POST_DEPLOY_LOGS" = true ]; then echo "" log_step "显示最近日志..." view_logs fi else log_error "========== 部署失败,已自动回滚 ==========" echo -e " Git: ${GIT_VERSION}" echo -e " 环境: ${APP_ENV}" echo "" exit 1 fi } main