#!/usr/bin/env bash # ============================================ # shanxia2.0 生产一键发版(前端 → 后端) # # 用法(在仓库任意目录执行均可): # ./生产环境部署/deploy-release.sh # ./生产环境部署/deploy-release.sh --skip-build # 跳过本地 pnpm build,使用已有 dist # # 凭据:仓库根目录「生产机器账号密码」 # 生产路径: # 前端 /iflytek/shanxia2.0/web/dist (保留远程 dist/src,禁止删除) # 后端 /iflytek/shanxia2.0/backend/app # 备份 /iflytek/shanxia2.0/bak/ # ============================================ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CRED_FILE="${CRED_FILE:-$REPO_ROOT/生产机器账号密码}" REMOTE_BASE="/iflytek/shanxia2.0" REMOTE_WEB="$REMOTE_BASE/web" REMOTE_DIST="$REMOTE_WEB/dist" REMOTE_BACKEND="$REMOTE_BASE/backend" REMOTE_BAK="$REMOTE_BASE/bak" LOCAL_WEB_ADMIN="$REPO_ROOT/web-admin" LOCAL_DIST="$LOCAL_WEB_ADMIN/dist" LOCAL_APP="$REPO_ROOT/backend/app" SSH_USER="root" SSH_OPTS=(-o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30) SKIP_BUILD=0 for arg in "$@"; do case "$arg" in --skip-build) SKIP_BUILD=1 ;; -h|--help) sed -n '2,16p' "$0" | sed 's/^# \?//' exit 0 ;; *) echo "未知参数: $arg(支持 --skip-build)" >&2 exit 1 ;; esac done RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } log_err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } die() { log_err "$@" exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1。$2" } # ---------- 前置检查 ---------- require_cmd ssh "macOS 系统自带即可。" require_cmd rsync "macOS 系统自带即可。" require_cmd sshpass "请执行: brew install hudochenkov/sshpass/sshpass" [[ -f "$CRED_FILE" ]] || die "凭据文件不存在: $CRED_FILE" [[ -d "$LOCAL_APP" ]] || die "本地后端不存在: $LOCAL_APP" # ---------- 解析凭据 ---------- # 支持: # ip: 1.2.3.4 # root账号:xxx / root密码:xxx / 密码:xxx parse_credentials() { local ip="" pass="" local line key val while IFS= read -r line || [[ -n "$line" ]]; do line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" [[ -z "$line" || "$line" == \#* ]] && continue # 勿用 [::] 字符类:bash 会把全角冒号与 : 当成范围,误匹配中文 if [[ "$line" == *:* ]]; then key="${line%%:*}" val="${line#*:}" elif [[ "$line" == *:* ]]; then key="${line%%:*}" val="${line#*:}" else continue fi key="${key//[[:space:]]/}" val="${val#"${val%%[![:space:]]*}"}" val="${val%"${val##*[![:space:]]}"}" case "$key" in ip|IP|host|Host|HOST) ip="$val" ;; root账号|root密码|密码|password|Password|PASSWORD|passwd) pass="$val" ;; esac done < "$CRED_FILE" [[ -n "$ip" ]] || die "凭据文件未解析到 IP(期望行如: ip: 121.x.x.x)" [[ -n "$pass" ]] || die "凭据文件未解析到密码(期望行如: root账号:***)" REMOTE_HOST="$ip" REMOTE_PASS="$pass" } parse_credentials log_info "目标主机: ${SSH_USER}@${REMOTE_HOST}" export SSHPASS="$REMOTE_PASS" trap 'unset SSHPASS REMOTE_PASS 2>/dev/null || true' EXIT remote_ssh() { sshpass -e ssh "${SSH_OPTS[@]}" "${SSH_USER}@${REMOTE_HOST}" "$@" } remote_rsync() { # 用法: remote_rsync [rsync-args...] src dest sshpass -e rsync -e "ssh ${SSH_OPTS[*]}" "$@" } TIMESTAMP="$(date +%Y%m%d-%H%M%S)" GIT_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" log_info "发版时间戳: $TIMESTAMP commit: $GIT_SHA" # ---------- 1. 本地构建前端 ---------- if [[ "$SKIP_BUILD" -eq 1 ]]; then log_warn "已跳过 pnpm build,使用现有 dist" else require_cmd pnpm "请安装 pnpm: npm i -g pnpm" [[ -f "$LOCAL_WEB_ADMIN/.env.production" ]] || log_warn "未找到 web-admin/.env.production,请确认生产构建配置正确" log_info "开始构建前端: cd web-admin && pnpm build" ( cd "$LOCAL_WEB_ADMIN" pnpm build ) fi [[ -d "$LOCAL_DIST" ]] || die "前端产物不存在: $LOCAL_DIST(请先构建或去掉 --skip-build)" [[ -f "$LOCAL_DIST/index.html" ]] || die "前端产物异常: 缺少 $LOCAL_DIST/index.html" # ---------- 2. 备份远程 web ---------- log_info "备份远程 web → ${REMOTE_BAK}/web-${TIMESTAMP}" remote_ssh "mkdir -p '${REMOTE_BAK}' && \ if [[ -d '${REMOTE_WEB}' ]]; then \ cp -a '${REMOTE_WEB}' '${REMOTE_BAK}/web-${TIMESTAMP}'; \ else \ echo '远程 web 目录不存在,跳过备份' >&2; \ exit 1; \ fi" # ---------- 3. 同步前端 dist(保留远程 dist/src)---------- log_info "同步前端到 ${REMOTE_DIST}(rsync --delete,排除 src/)" remote_ssh "mkdir -p '${REMOTE_DIST}'" # --exclude 'src/':不同步也不删除远程 src # --delete:删除远程 dist 中本地已不存在的构建文件(不含被 exclude 的 src) remote_rsync -az --delete --exclude 'src/' --exclude '.DS_Store' \ "${LOCAL_DIST}/" \ "${SSH_USER}@${REMOTE_HOST}:${REMOTE_DIST}/" remote_ssh "[[ -d '${REMOTE_DIST}/src' ]] && echo 'OK: dist/src 仍存在' || echo 'NOTE: 远程 dist/src 当前不存在(若业务依赖请检查)'" log_info "前端同步完成" # ---------- 4. 备份远程 backend ---------- log_info "备份远程 backend → ${REMOTE_BAK}/backend-${TIMESTAMP}" remote_ssh "mkdir -p '${REMOTE_BAK}' && \ if [[ -d '${REMOTE_BACKEND}' ]]; then \ cp -a '${REMOTE_BACKEND}' '${REMOTE_BAK}/backend-${TIMESTAMP}'; \ else \ echo '远程 backend 目录不存在,跳过备份' >&2; \ exit 1; \ fi" # ---------- 5. 替换 backend/app,并同步依赖清单 ---------- log_info "替换远程 ${REMOTE_BACKEND}/app" remote_ssh "rm -rf '${REMOTE_BACKEND}/app'" remote_rsync -az --delete \ --exclude '__pycache__/' \ --exclude '*.pyc' \ --exclude '.DS_Store' \ "${LOCAL_APP}/" \ "${SSH_USER}@${REMOTE_HOST}:${REMOTE_BACKEND}/app/" if [[ -f "$REPO_ROOT/backend/requirements.txt" ]]; then log_info "同步 requirements.txt 并安装依赖" remote_rsync -az \ "$REPO_ROOT/backend/requirements.txt" \ "${SSH_USER}@${REMOTE_HOST}:${REMOTE_BACKEND}/requirements.txt" remote_ssh "cd '${REMOTE_BACKEND}' && \ ./venv/bin/pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple" fi log_info "后端 app 同步完成" # ---------- 6. 重启后端 ---------- log_info "重启后端服务 (PORT=9111 WORKERS=1)" remote_ssh "cd '${REMOTE_BACKEND}' && \ chmod +x ./start-prod.sh && \ PORT=9111 WORKERS=1 ENV_FILE=./.env.production ./start-prod.sh restart" # ---------- 7. 健康检查(重启后进程可能尚未监听,重试数秒)---------- log_info "健康检查 /health" HEALTH_OUT="" for _i in 1 2 3 4 5 6 7 8 9 10; do HEALTH_OUT="$(remote_ssh "curl -fsS --max-time 5 http://127.0.0.1:9111/health" 2>/dev/null || true)" if [[ "$HEALTH_OUT" == *healthy* ]]; then break fi sleep 2 done if [[ "$HEALTH_OUT" == *healthy* ]]; then log_info "健康检查通过: $HEALTH_OUT" else log_err "健康检查未通过,远程输出: ${HEALTH_OUT:-}" log_warn "可手动排查: ssh ${SSH_USER}@${REMOTE_HOST} 'cd ${REMOTE_BACKEND} && ./start-prod.sh status|logs'" log_warn "备份位置: ${REMOTE_BAK}/web-${TIMESTAMP} ${REMOTE_BAK}/backend-${TIMESTAMP}" exit 1 fi unset SSHPASS REMOTE_PASS echo log_info "发版成功" echo " commit: $GIT_SHA" echo " 前端备份: ${REMOTE_BAK}/web-${TIMESTAMP}" echo " 后端备份: ${REMOTE_BAK}/backend-${TIMESTAMP}" echo " 管理端: http://${REMOTE_HOST}:9911" echo