From f9f1ffeff61bad35ca5002ec768a78b17c7e7ad9 Mon Sep 17 00:00:00 2001 From: milky0217 Date: Mon, 18 May 2026 10:09:37 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20deploy.sh=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E4=B8=BA=E6=A8=A1=E5=9D=97=E5=8C=96=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy.sh: 从 1574 行精简至 141 行,仅保留主流程编排 - lib/common.sh: 基础设施(日志/SSH复用/备份/上传/迁移/服务管理) - lib/test.sh: 6 项部署后测试函数 - 迁移系统改用 _migrations 追踪表替代文件名模式匹配 - 所有 SSH 操作复用 ControlMaster socket --- deploy.sh | 1605 +++---------------------------------------------- lib/common.sh | 377 ++++++++++++ lib/test.sh | 127 ++++ 3 files changed, 588 insertions(+), 1521 deletions(-) create mode 100644 lib/common.sh create mode 100644 lib/test.sh diff --git a/deploy.sh b/deploy.sh index 391c590..3cae8ec 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,113 +1,78 @@ #!/bin/bash - # =========================================== # Rust Backend Deployment Script # =========================================== # Usage: ./deploy.sh [development|production] [options] # # Options: -# --dry-run 预览模式,不执行实际操作 -# --yes, -y 跳过确认提示 +# --dry-run 预览模式 +# --yes, -y 跳过确认 # --skip-tests 跳过部署后测试 -# --rollback 回滚到上一个备份版本 +# --rollback 回滚到指定备份 # --backup-list 列出可用备份 +# --logs [N] 查看后端日志(默认50行) +# --tail [N] 实时跟踪日志 +# --status 查看服务状态 +# --post-logs 部署后显示日志 +# --init-env 初始化远程 .env # =========================================== set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/lib/common.sh" +source "${SCRIPT_DIR}/lib/test.sh" + +# ---------- 默认值 ---------- 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 - -# 全局变量(部署流程中使用) +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 - ;; + --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 " --dry-run 预览模式" + echo " --yes, -y 跳过确认" echo " --skip-tests 跳过部署后测试" - echo " --rollback 回滚到上一个备份版本" + 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 - ;; + echo " --tail [N] 实时跟踪日志" + echo " --status 查看服务状态" + echo " --post-logs 部署后显示日志" + echo " --init-env 初始化远程 .env" + 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 + echo "未知选项: $1"; exit 1 + fi ;; + esac; shift done -# 根据环境选择远程目录和服务 +# ---------- 环境配置 ---------- case "${APP_ENV}" in development) REMOTE_DIR="/root/rust/rust_backend_dev" @@ -116,8 +81,7 @@ case "${APP_ENV}" in BACKEND_PORT="8080" DB_CONTAINER="1Panel-postgresql-FtMo" DB_NAME="milkydata_dev" - DB_USER="milkydata" - ;; + DB_USER="milkydata" ;; production) REMOTE_DIR="/root/rust/rust_backend" SERVICE_NAME="rust-backend.service" @@ -125,1450 +89,49 @@ case "${APP_ENV}" in BACKEND_PORT="4433" DB_CONTAINER="1Panel-postgresql-FtMo" DB_NAME="milkydata" - DB_USER="milkydata" - ;; - *) - echo "无效的环境: ${APP_ENV}" - echo "支持的環境: development, production" - exit 1 - ;; + DB_USER="milkydata" ;; 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" +# ---------- 快捷命令 ---------- +GIT_VERSION=$(get_git_info) +ssh_connect + +if [ "$LIST_BACKUPS" = true ]; then list_backups; exit 0; fi +if [ "$INIT_ENV" = true ]; then init_env; exit 0; fi +if [ "$VIEW_LOGS" = true ]; then view_logs; exit 0; fi +if [ "$TAIL_LOGS" = true ]; then tail_logs; exit 0; fi +if [ "$VIEW_STATUS" = true ]; then status; exit 0; fi +if [ "$ROLLBACK" = true ]; then rollback; exit 0; fi + +# ---------- 主部署流程 ---------- +show_deploy_info +confirm_deploy +check_dependencies +check_env_file + +build_project +backup_old_version +cleanup_old_backups 5 + +upload_binary +upload_dir "配置文件" config +upload_dir "迁移文件" migrations +upload_script "测试脚本" test_deployment.sh test_deployment.sh +upload_script "清理脚本" scripts/cleanup_refresh_tokens.sh scripts/cleanup_refresh_tokens.sh +upload_script "备份脚本" scripts/backup-db.sh scripts/backup-db.sh + +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 "" + [ "$POST_DEPLOY_LOGS" = true ] && { echo ""; view_logs; } +else + log_error "========== 部署失败,已自动回滚 ==========" exit 1 -} +fi -# 备份数据库 -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 +exit 0 diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..c876b02 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,377 @@ +# =========================================== +# common.sh — deploy.sh 共享基础设施 +# =========================================== + +# ---------- 彩色输出 ---------- +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 b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + local c=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + echo "${b}@${c}" +} + +# ---------- SSH 连接复用 ---------- +get_ssh_socket() { echo "/tmp/ssh-master-${REMOTE_HOST}-$$"; } + +ssh_connect() { + [ "$DRY_RUN" = true ] && { log_dry "ssh -o ControlMaster=auto -o ControlPath=$(get_ssh_socket) ${REMOTE_USER}@${REMOTE_HOST}"; return 0; } + local socket; socket=$(get_ssh_socket) + if ! ssh -o "ControlPath=${socket}" -o "ConnectionAttempts=3" -o "ConnectTimeout=10" \ + -o "ControlMaster=no" "${REMOTE_USER}@${REMOTE_HOST}" "echo ok" &>/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_disconnect() { + [ "$DRY_RUN" = true ] && return 0 + local socket; socket=$(get_ssh_socket) + [ -S "${socket}" ] 2>/dev/null || return 0 + ssh -o "ControlPath=${socket}" -O exit "${REMOTE_USER}@${REMOTE_HOST}" 2>/dev/null || true +} + +# SSH 快捷执行(复用 ControlMaster) +remote() { ssh -o "ControlPath=$(get_ssh_socket)" "${REMOTE_USER}@${REMOTE_HOST}" "$@"; } + +# ---------- 部署日志 ---------- +get_deploy_log() { echo "${REMOTE_DIR}/deploy.log"; } + +log_to_file() { + [ "$DRY_RUN" = true ] && return 0 + remote "echo '[$(date '+%Y-%m-%d %H:%M:%S')] ${APP_ENV} ${GIT_VERSION} $1' >> $(get_deploy_log)" 2>/dev/null || true +} + +# ---------- Webhook ---------- +send_webhook() { + [ -z "${WEBHOOK_URL:-}" ] && return 0 + local payload; payload=$(cat </dev/null 2>&1 || true +} + +# ---------- 环境配置 ---------- +check_env_file() { + log_step "检查远程 .env 文件..." + [ "$DRY_RUN" = true ] && { log_dry "test -f ${REMOTE_DIR}/.env"; return 0; } + + if ! remote "test -f '${REMOTE_DIR}/.env'" 2>/dev/null; then + log_error ".env 文件不存在: ${REMOTE_DIR}/.env"; exit 1 + fi + + local env_content; env_content=$(remote "cat '${REMOTE_DIR}/.env'" 2>/dev/null || echo "") + [ -z "$env_content" ] && { log_error ".env 文件为空"; exit 1; } + + local missing=0 ph=0 commented=0 + local required_vars=("JWT_SECRET" "WECHAT_SECRET" "WECHAT_APPID" "DATABASE_URL") + [ "${APP_ENV}" = "production" ] && required_vars+=("SSL_KEY_PATH" "SSL_CERT_PATH") + + for var in "${required_vars[@]}"; do + local val; val=$(echo "$env_content" | grep -E "^${var}=" | head -1 | sed "s/^${var}=//") + if [ -z "$val" ]; then + log_error " 缺少: ${var}="; missing=$((missing+1)) + elif echo "$val" | grep -qiE "^(your_|placeholder|change_me|xxx|test_)"; then + log_warn " 占位符: ${var}=${val}"; ph=$((ph+1)) + fi + echo "$env_content" | grep -qE "^#\s*${var}=" && { log_warn " 已注释: # ${var}="; commented=$((commented+1)); } + done + + [ $missing -gt 0 ] && { log_error ".env 检查失败:${missing} 个缺失"; exit 1; } + [ $ph -gt 0 ] || [ $commented -gt 0 ] && log_warn ".env 存在 ${ph} 个占位符、${commented} 个注释行,建议修正" + log_info ".env 文件检查通过" +} + +init_env() { + log_step "初始化远程 .env 文件..." + [ "$DRY_RUN" = true ] && { log_dry "在 ${REMOTE_HOST}:${REMOTE_DIR}/.env 生成配置"; return 0; } + + local exists=false + remote "test -f '${REMOTE_DIR}/.env'" 2>/dev/null && exists=true + + if [ "$exists" = true ]; then + log_warn ".env 已存在,继续将覆盖!" + read -p "是否覆盖? [y/N] " -n 1 -r; echo + [[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; return 0; } + remote "cp '${REMOTE_DIR}/.env' '${REMOTE_DIR}/.env.backup.$(date +%Y%m%d_%H%M%S)'" 2>/dev/null || true + fi + + local jwt_secret + jwt_secret=$(openssl rand -base64 32 2>/dev/null | tr -d '\n' || echo "change-me-$(date +%s)-$$") + + cat < '${REMOTE_DIR}/.env' && chmod 600 '${REMOTE_DIR}/.env'" +# ASD Rust Backend - ${APP_ENV} 环境配置 +# 由 deploy.sh --init-env 自动生成于 $(date '+%Y-%m-%d %H:%M:%S') +APP_ENV=${APP_ENV} +DATABASE_URL=请替换为实际数据库连接字符串 +WECHAT_APPID=请替换为实际微信 AppID +WECHAT_SECRET=请替换为实际微信 Secret +JWT_SECRET=${jwt_secret} +RUST_LOG=info +FREE_USER_DATA_LIMIT=20 +# ===== 支付宝支付配置(可选) ===== +# ALIPAY_APP_ID=... +# ALIPAY_GATEWAY=... +ENV_EOF + + log_info ".env 已生成,文件权限 600" + log_warn "请手动填写 DATABASE_URL / WECHAT_APPID / WECHAT_SECRET" +} + +# ---------- 编译 ---------- +build_project() { + log_step "编译项目 (release 模式)..." + [ "$DRY_RUN" = true ] && { log_dry "cargo build --release"; return 0; } + APP_ENV="${APP_ENV}" cargo build --release || { log_error "编译失败!"; exit 1; } + log_info "编译成功" +} + +# ---------- 备份 ---------- +backup_old_version() { + log_step "备份旧版本..." + local ts; ts=$(date +%Y%m%d_%H%M%S) + local gh; gh=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + local md5="" + [ "$DRY_RUN" = false ] && md5=$(remote "md5sum '${REMOTE_DIR}/${PROJECT_NAME}' 2>/dev/null | cut -d' ' -f1" || echo "") + BACKUP_PATH="${REMOTE_DIR}/${PROJECT_NAME}.backup.${ts}.${gh}.${md5:0:8}" + + [ "$DRY_RUN" = true ] && { log_dry "备份到 ${BACKUP_PATH}"; return 0; } + remote "if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then cp '${REMOTE_DIR}/${PROJECT_NAME}' '${BACKUP_PATH}'; echo '${BACKUP_PATH}' > '${REMOTE_DIR}/.last_deployed'; fi" + log_info "备份完成" +} + +cleanup_old_backups() { + local keep="${1:-5}" + [ "$DRY_RUN" = true ] && { log_dry "保留最近 ${keep} 个备份"; return 0; } + + local backups; backups=$(remote "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null" || echo "") + [ -z "$backups" ] && return 0 + + local seen="" to_del="" + while IFS= read -r b; do + [ -z "$b" ] && continue + local md5; md5=$(basename "$b" | rev | cut -d'.' -f2 | rev || echo "") + [ -z "$md5" ] && { to_del="${to_del}${b}"$'\n'; continue; } + echo "$seen" | grep -q "^${md5}$" && to_del="${to_del}${b}"$'\n' || seen="${seen}${md5}"$'\n' + done <<< "$backups" + + # 如果唯一版本数超过 keep,按时间删除最旧的溢出部分 + local count=0 + while IFS= read -r b; do + [ -z "$b" ] && continue; count=$((count+1)) + [ "$count" -gt "$keep" ] && to_del="${to_del}${b}"$'\n' + done <<< "$backups" + + [ -z "$to_del" ] && return 0 + log_step "清理旧备份(保留 ${keep} 个版本)..." + echo "$to_del" | while IFS= read -r f; do + [ -n "$f" ] && remote "rm -f '${f}'" 2>/dev/null && echo " 删除: $(basename "$f")" || true + done +} + +backup_database() { + log_step "备份数据库..." + [ "$DRY_RUN" = true ] && { log_dry "pg_dump → ${REMOTE_DIR}/backups/"; return 0; } + local ts; ts=$(date +%Y%m%d_%H%M%S) + local bf="${REMOTE_DIR}/backups/${DB_NAME}_${ts}.dump" + remote "mkdir -p ${REMOTE_DIR}/backups" + if remote "docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > /tmp/backup_${ts}.dump && mv /tmp/backup_${ts}.dump '${bf}'" 2>&1; then + log_info "数据库备份成功: ${DB_NAME}_${ts}.dump" + cleanup_old_db_backups + else + log_warn "数据库备份失败,继续部署" + fi +} + +cleanup_old_db_backups() { + local keep="${1:-1}" + local to_del; to_del=$(remote "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump 2>/dev/null | tail -n +$((keep+1))" || echo "") + [ -z "$to_del" ] && return 0 + log_step "清理旧数据库备份(保留 ${keep} 个)..." + echo "$to_del" | while read -r f; do [ -n "$f" ] && remote "rm -f '${f}'" 2>/dev/null && echo " 删除: $(basename "$f")" || true; done +} + +list_backups() { + log_step "可用备份列表..." + [ "$DRY_RUN" = true ] && { log_dry "ls ${REMOTE_DIR}/${PROJECT_NAME}.backup.*"; return 0; } + remote "ls -la ${REMOTE_DIR}/${PROJECT_NAME}.backup.*" 2>/dev/null || echo "无备份文件" +} + +# ---------- 上传 ---------- +upload_binary() { + log_step "上传二进制文件..." + local src="./target/release/${PROJECT_NAME}" + [ ! -f "$src" ] && { log_error "编译产物不存在: $src"; exit 1; } + [ "$DRY_RUN" = true ] && { log_dry "rsync ${src} → ${REMOTE_DIR}/"; return 0; } + rsync -avzP --progress "$src" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/" + log_info "上传完成" +} + +upload_dir() { + local label="$1" src="$2" dst="${3:-$2}" + log_step "上传 ${label}..." + [ "$DRY_RUN" = true ] && { log_dry "rsync ${src}/ → ${REMOTE_DIR}/${dst}/"; return 0; } + [ ! -d "$src" ] && { log_warn "${src} 目录不存在,跳过"; return 0; } + rsync -avzP --progress "${src}/" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/${dst}/" + log_info "${label} 上传完成" +} + +upload_script() { + local label="$1" src="$2" dst="$3" + log_step "上传 ${label}..." + [ "$DRY_RUN" = true ] && { log_dry "rsync ${src} → ${REMOTE_DIR}/${dst}"; return 0; } + [ ! -f "$src" ] && { log_warn "${src} 不存在,跳过"; return 0; } + rsync -avzP --progress "$src" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/${dst}" + remote "chmod +x ${REMOTE_DIR}/${dst}" 2>/dev/null || true + log_info "${label} 上传完成" +} + +# ---------- 迁移 ---------- +run_migrations() { + log_step "执行数据库迁移..." + [ "$DRY_RUN" = true ] && { local n; n=$(ls ./migrations/*.sql 2>/dev/null | wc -l || echo 0); log_dry "发现 ${n} 个迁移文件"; return 0; } + + # 先备份再执行 + local sql_files; sql_files=$(remote "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "") + [ -n "$sql_files" ] && backup_database + + # 创建迁移追踪表 + remote "docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" <<<'CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT NOW());' 2>/dev/null || true + + for f in $sql_files; do + local name; name=$(basename "$f") + log_info "检查迁移: ${name}" + local done; done=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT COUNT(*) FROM _migrations WHERE name='${name}'\"" 2>/dev/null || echo "0") + [ "$done" != "0" ] && { log_info " 跳过(已执行): ${name}"; continue; } + log_info " 执行: ${name}" + remote "cat '${f}' | docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" && \ + remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -c \"INSERT INTO _migrations (name) VALUES ('${name}')\"" && \ + log_info " 完成: ${name}" || log_warn " 迁移 ${name} 可能已执行或失败" + done + log_info "数据库迁移完成" +} + +# ---------- 服务 ---------- +restart_service() { + log_step "重启服务 '${SERVICE_NAME}'..." + [ "$DRY_RUN" = true ] && { log_dry "systemctl restart ${SERVICE_NAME}"; return 0; } + remote "systemctl restart ${SERVICE_NAME}" && log_info "服务重启成功" || log_error "服务重启失败!" + sleep 3 +} + +# ---------- 回滚 ---------- +rollback() { + log_step "查找可用备份..." + [ "$DRY_RUN" = true ] && { log_dry "回滚预览"; return 0; } + + local backups + backups=$(remote "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null" || echo "") + [ -z "$backups" ] && { log_error "没有可用备份"; return 1; } + + echo ""; echo -e "${BLUE}可用备份:${NC}"; echo "$backups" | head -10 | nl -v 0 + local count; count=$(echo "$backups" | wc -l) + read -p "选择备份 [0-$(($count>10?9:$count-1)),默认 0]: " -n 1 -r; echo + [[ ! $REPLY =~ ^[0-9]$ ]] && REPLY=0 + local chosen; chosen=$(echo "$backups" | sed -n "$((REPLY+1))p") + + echo -e "${YELLOW}回滚到: ${chosen}${NC}" + [ "$SKIP_CONFIRM" = false ] && { read -p "确认? [y/N] " -n 1 -r; echo; [[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; return 0; } } + + remote "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true + remote "cp '${chosen}' '${REMOTE_DIR}/${PROJECT_NAME}'" + remote "systemctl start ${SERVICE_NAME}" + log_info "回滚完成" + log_to_file "MANUAL_ROLLBACK to $(basename ${chosen})" + send_webhook "rollback" "手动回滚: $(basename ${chosen})" +} + +rollback_on_failure() { + local bp="$1"; [ -z "$bp" ] && { log_error "未指定备份路径"; return 1; } + log_error "========== 测试失败,自动回滚 ==========" + [ "$DRY_RUN" = true ] && { log_dry "回滚到 ${bp}"; return 0; } + remote "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true + remote "cp '${bp}' '${REMOTE_DIR}/${PROJECT_NAME}'" + remote "systemctl start ${SERVICE_NAME}"; sleep 3 + log_info "自动回滚完成" + log_to_file "AUTO_ROLLBACK to $(basename ${bp})" + send_webhook "failure" "部署失败,自动回滚: $(basename ${bp})" +} + +# ---------- 日志查看 ---------- +view_logs() { + local lines="${LOG_LINES:-50}" + log_step "查看后端日志(最后 ${lines} 行)..." + [ "$DRY_RUN" = true ] && { log_dry "tail -${lines} 日志文件"; return 0; } + local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") + [ -z "$lf" ] && { log_warn "未找到日志文件"; return 1; } + echo -e "${BLUE}=== ${lf} ===${NC}"; remote "tail -${lines} '${lf}'" +} + +tail_logs() { + local lines="${LOG_LINES:-100}" + log_step "实时跟踪日志..." + [ "$DRY_RUN" = true ] && return 0 + local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") + [ -z "$lf" ] && { log_warn "未找到日志文件"; return 1; } + ssh -t "${REMOTE_USER}@${REMOTE_HOST}" "tail -f -n ${lines} '${lf}'" +} + +status() { + log_step "服务状态..." + [ "$DRY_RUN" = true ] && { log_dry "systemctl status ${SERVICE_NAME}"; return 0; } + echo -e "${BLUE}=== ${SERVICE_NAME} ===${NC}" + remote "systemctl status ${SERVICE_NAME} --no-pager" + local lf; lf=$(remote "ls -t ${REMOTE_DIR}/logs/rust-backend.*.log 2>/dev/null | head -1" || echo "") + [ -n "$lf" ] && { echo -e "${BLUE}=== 最近日志 ===${NC}"; remote "tail -20 '${lf}'"; } +} + +# ---------- 确认 ---------- +confirm_deploy() { + [ "$DRY_RUN" = true ] || [ "$SKIP_CONFIRM" = true ] && return 0 + echo ""; echo -e "${YELLOW}========== 部署确认 ==========${NC}" + echo -e " 环境: ${APP_ENV}" + echo -e " 远程目录: ${REMOTE_DIR}" + echo -e " 服务: ${SERVICE_NAME}" + echo -e " Git: ${GIT_VERSION}" + echo -e "================================${NC}" + read -p "确认部署? [y/N] " -n 1 -r; echo + [[ ! $REPLY =~ ^[Yy]$ ]] && { log_warn "已取消"; exit 0; } +} + +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}" + [ "$DRY_RUN" = true ] && echo -e " 模式: ${YELLOW}预览 (DRY-RUN)${NC}" + echo -e "${BLUE}========================================${NC}" +} + +# ---------- 依赖检查 ---------- +check_dependencies() { + log_step "检查依赖..." + for cmd in cargo rsync ssh; do + command -v "$cmd" &>/dev/null || { log_error "依赖 '$cmd' 未安装"; exit 1; } + done + log_info "依赖检查通过" +} + +# ---------- 清理 ---------- +cleanup() { ssh_disconnect 2>/dev/null || true; } +trap cleanup EXIT diff --git a/lib/test.sh b/lib/test.sh new file mode 100644 index 0000000..23c8895 --- /dev/null +++ b/lib/test.sh @@ -0,0 +1,127 @@ +# =========================================== +# test.sh — 部署后测试(依赖 common.sh 的 remote()) +# =========================================== + +# 测试失败自动回滚 +rollback_and_exit() { + local reason="$1" + log_error "测试失败: ${reason}" + local last_known_good + last_known_good=$(remote "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 +} + +test_basic_connectivity() { + local max_retries=3 retry=0 + while [ $retry -lt $max_retries ]; do + retry=$((retry+1)); log_info "尝试 ${retry}/${max_retries}: 测试本地后端 (127.0.0.1:${BACKEND_PORT})" + local all=true + for test in \ + "/health:200" \ + "/api/login:400:POST:-H Content-Type:application/json -d '{\"code\":\"test\"}'" \ + "/api/refresh-token:401:POST:-H Content-Type:application/json -d '{\"refresh_token\":\"bad\"}'" \ + "/:200"; do + IFS=':' read -r path expected method extra <<< "$test" + [ -z "$method" ] && method="GET" + local code; code=$(remote "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X ${method} http://127.0.0.1:${BACKEND_PORT}${path} ${extra}" 2>/dev/null || echo "000") + if [ "$code" != "$expected" ] && [ "$expected" != "200,400" ] && ! { [ "$expected" = "400,200" ] && { [ "$code" = "400" ] || [ "$code" = "200" ]; }; }; then + if { [ "$expected" = "400" ] && [ "$code" = "200" ]; } || { [ "$expected" = "401" ] && [ "$code" = "200" ]; }; then + log_info " ${path} 正常 (HTTP ${code})"; continue + fi + log_warn " ${path} 异常 (HTTP ${code})"; all=false + else + log_info " ${path} 正常 (HTTP ${code})" + fi + done + $all && { log_info "基础连通性测试通过"; return 0; } + [ $retry -lt $max_retries ] && sleep 2 + done + log_error "基础连通性测试失败"; return 1 +} + +test_database_connection() { + local r; r=$(remote "docker exec ${DB_CONTAINER} pg_isready -U ${DB_USER} 2>/dev/null" || echo "failed") + [[ "$r" =~ "accepting connections" ]] && log_info " PostgreSQL 连接正常" || { log_warn " PostgreSQL 连接失败"; return 1; } + + r=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT 1'" 2>/dev/null || echo "failed") + [ "$r" = "1" ] && log_info " 数据库查询正常" || { log_warn " 数据库查询失败"; return 1; } + + r=$(remote "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc 'SELECT tablename FROM pg_tables WHERE schemaname='\''public'\'' LIMIT 1'" 2>/dev/null || echo "failed") + [ -n "$r" ] && log_info " 表存在: ${r}" || { log_warn " 表查询失败"; return 1; } + log_info "数据库连接测试通过"; return 0 +} + +test_token_flow() { + local login; login=$(remote "curl -s --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test\"}'" 2>/dev/null || echo "") + local token; token=$(echo "$login" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "") + [ -z "$token" ] && log_warn " 未获取到 token(微信 code 无效是正常的)" || log_info " 获取到 token" + + # 测试受保护端点 + local profile; profile=$(remote "curl -s --max-time 10 http://127.0.0.1:${BACKEND_PORT}/api/user/profile -H 'Authorization: Bearer ${token}'" 2>/dev/null || echo "") + echo "$profile" | grep -q '"success":true' && log_info " /api/user/profile JWT 验证成功" || log_warn " /api/user/profile 无权限(正常)" + + local refresh; refresh=$(remote "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\":\"invalid\"}'" 2>/dev/null || echo "") + log_info " refresh_token 端点响应正常" + log_info "Token 流程测试通过"; return 0 +} + +test_weather_details_jwt() { + local login; login=$(remote "curl -s --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test\"}'" 2>/dev/null || echo "") + local token; token=$(echo "$login" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4 || echo "") + [ -z "$token" ] && { log_warn " 无 token,跳过 weather/details 测试(微信 code 无效是正常的)"; return 0; } + + local resp; resp=$(remote "curl -s --max-time 10 'http://127.0.0.1:${BACKEND_PORT}/weather/details?id=1' -H 'Authorization: Bearer ${token}'" 2>/dev/null || echo "") + if echo "$resp" | grep -q '"success":true' || echo "$resp" | grep -q "Forbidden"; then + log_info " weather/details JWT 验证通过" + else + log_warn " weather/details 响应异常" + fi + return 0 +} + +test_invalid_token_401() { + local bad="eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.bad" + local all=true + for path in "/api/user/profile" "/weather/details?id=1" "/api/weather"; do + local c; c=$(remote "curl -s -o /dev/null -w '%{http_code}' --max-time 10 'http://127.0.0.1:${BACKEND_PORT}${path}' -H 'Authorization: Bearer ${bad}'" 2>/dev/null || echo "000") + [ "$c" = "401" ] && log_info " ${path} 返回 401 ✓" || { log_warn " ${path} 返回 ${c}"; all=false; } + done + $all && log_info "无效 Token 401 测试通过" && return 0 + return 1 +} + +test_response_content() { + local r; r=$(remote "curl -s --max-time 10 http://127.0.0.1:${BACKEND_PORT}/health" 2>/dev/null || echo "") + echo "$r" | grep -q '"status"' && echo "$r" | grep -q '"database"' && \ + log_info " /health JSON 格式正确 ✓" || { log_warn " /health 响应异常"; return 1; } + echo "$r" | grep -q '"database":"connected"' && \ + log_info " 数据库连接状态正常 ✓" || { log_warn " 数据库未连接"; return 1; } + log_info "响应内容验证通过"; return 0 +} + +run_tests() { + log_step "执行部署后测试..." + [ "$DRY_RUN" = true ] && { log_dry "完整测试套件(6 项)"; return 0; } + [ "$SKIP_TESTS" = true ] && { log_warn "跳过测试"; return 0; } + + local tests=( + "test_basic_connectivity:基础连通性" + "test_database_connection:数据库连接" + "test_token_flow:Token 流程" + "test_weather_details_jwt:weather/details JWT" + "test_invalid_token_401:无效 Token 401" + "test_response_content:响应内容验证" + ) + local i=1 total=${#tests[@]} + for entry in "${tests[@]}"; do + IFS=':' read -r fn name <<< "$entry" + log_step "测试 ${i}/${total}: ${name}..." + $fn || rollback_and_exit "${name} 测试失败" + i=$((i+1)) + done + log_info "========== 全部 ${total} 项测试通过 ==========" +}