#!/usr/bin/env bash # ============================================================================ # Foresight 先见之明 - 一键部署脚本 # ============================================================================ # # 用法: # ./scripts/deploy.sh # 默认: 部署后端 + 重启 Flask # ./scripts/deploy.sh --backend # 只部署后端 # ./scripts/deploy.sh --frontend # 只部署前端 (build + COS + CDN purge) # ./scripts/deploy.sh --full # 后端 + 前端全量部署 # ./scripts/deploy.sh --no-restart # 部署后端但不重启 Flask # ./scripts/deploy.sh --dry-run # 只显示会做什么,不实际执行 # # 前置要求: # - SSH key: /Users/liyizhouai/Desktop/openclaw/船长/liyizhouAI.pem # - SSH passwordless sudo on ubuntu@124.223.92.72 # - coscmd 已安装并配置 (~/.cos.conf 指向 foresight-1317962478) # - tccli 已安装并配置 (用于 CDN 刷新) # - 本地有 rsync # # 脚本保证: # - 后端 Python 语法检查后才上传 # - Flask 重启后自动 verify /health,失败则报错 # - 前端 vite build 失败则停止 # - 不上传 .venv/ / __pycache__/ / uploads/ 等运行时文件 # ============================================================================ set -euo pipefail # ---------- 配置 ---------- SSH_KEY="/Users/liyizhouai/Desktop/openclaw/船长/liyizhouAI.pem" SSH_HOST="ubuntu@124.223.92.72" REMOTE_BACKEND="/opt/foresight/backend" API_HEALTH_URL="https://api.foresight.yizhou.chat/health" PROD_URL="https://foresight.yizhou.chat" COS_BUCKET="foresight-1317962478" # 定位项目根目录 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" LOCAL_BACKEND="$PROJECT_ROOT/backend" LOCAL_FRONTEND="$PROJECT_ROOT/frontend" # ---------- 参数 ---------- DEPLOY_BACKEND=true DEPLOY_FRONTEND=false RESTART_FLASK=true DRY_RUN=false for arg in "$@"; do case "$arg" in --backend) DEPLOY_BACKEND=true; DEPLOY_FRONTEND=false ;; --frontend) DEPLOY_BACKEND=false; DEPLOY_FRONTEND=true ;; --full) DEPLOY_BACKEND=true; DEPLOY_FRONTEND=true ;; --no-restart) RESTART_FLASK=false ;; --dry-run) DRY_RUN=true ;; -h|--help) sed -n '2,20p' "$0" | sed 's/^# \?//' exit 0 ;; *) echo "❌ 未知参数: $arg" echo "用 --help 查看用法" exit 1 ;; esac done # ---------- 颜色 ---------- C_BLUE='\033[0;34m' C_GREEN='\033[0;32m' C_YELLOW='\033[0;33m' C_RED='\033[0;31m' C_DIM='\033[2m' C_RESET='\033[0m' log() { printf "${C_BLUE}▶${C_RESET} %s\n" "$*"; } ok() { printf "${C_GREEN}✓${C_RESET} %s\n" "$*"; } warn() { printf "${C_YELLOW}⚠${C_RESET} %s\n" "$*"; } fail() { printf "${C_RED}✗${C_RESET} %s\n" "$*" >&2; exit 1; } section() { printf "\n${C_BLUE}=== %s ===${C_RESET}\n" "$*"; } maybe() { if $DRY_RUN; then printf "${C_DIM}(dry-run) %s${C_RESET}\n" "$*" else "$@" fi } # ---------- 前置检查 ---------- preflight() { section "前置检查" [[ -f "$SSH_KEY" ]] || fail "SSH key 不存在: $SSH_KEY" ok "SSH key 存在" ssh -i "$SSH_KEY" -o ConnectTimeout=10 "$SSH_HOST" 'echo ok' >/dev/null 2>&1 \ || fail "SSH 连接失败" ok "SSH 可达 $SSH_HOST" if $DEPLOY_FRONTEND; then command -v coscmd >/dev/null 2>&1 || fail "coscmd 未安装 (pip install coscmd)" ok "coscmd 可用" [[ -f ~/.cos.conf ]] || fail "~/.cos.conf 不存在" ok "~/.cos.conf 存在" command -v tccli >/dev/null 2>&1 || warn "tccli 未安装,跳过 CDN 自动刷新" fi if $DEPLOY_BACKEND; then [[ -d "$LOCAL_BACKEND" ]] || fail "本地 backend 目录不存在" ok "本地 backend: $LOCAL_BACKEND" fi if $DEPLOY_FRONTEND; then [[ -d "$LOCAL_FRONTEND" ]] || fail "本地 frontend 目录不存在" ok "本地 frontend: $LOCAL_FRONTEND" fi } # ---------- 后端语法检查 ---------- backend_syntax_check() { log "Python 语法检查..." local failed=0 while IFS= read -r f; do if ! python3 -m py_compile "$f" 2>/dev/null; then warn "语法错误: $f" failed=1 fi done < <(find "$LOCAL_BACKEND/app" "$LOCAL_BACKEND/scripts" -name '*.py' -type f 2>/dev/null) (( failed == 0 )) || fail "语法检查未通过,部署中止" ok "全部通过" } # ---------- 后端 rsync ---------- deploy_backend() { section "部署后端" backend_syntax_check log "rsync backend/app → $SSH_HOST:$REMOTE_BACKEND/app" maybe rsync -az --delete \ --exclude '__pycache__/' \ --exclude '*.pyc' \ --exclude '.pytest_cache/' \ -e "ssh -i $SSH_KEY" \ --rsync-path="sudo rsync" \ "$LOCAL_BACKEND/app/" \ "$SSH_HOST:$REMOTE_BACKEND/app/" log "rsync backend/scripts → $SSH_HOST:$REMOTE_BACKEND/scripts" maybe rsync -az --delete \ --exclude '__pycache__/' \ --exclude '*.pyc' \ -e "ssh -i $SSH_KEY" \ --rsync-path="sudo rsync" \ "$LOCAL_BACKEND/scripts/" \ "$SSH_HOST:$REMOTE_BACKEND/scripts/" log "chown ubuntu:ubuntu" maybe ssh -i "$SSH_KEY" "$SSH_HOST" \ "sudo chown -R ubuntu:ubuntu $REMOTE_BACKEND/app $REMOTE_BACKEND/scripts" ok "后端代码已同步" } # ---------- 重启 Flask ---------- restart_flask() { section "重启 Flask" if $DRY_RUN; then warn "dry-run: 不实际重启 Flask" return fi log "kill 旧 Flask 进程..." ssh -i "$SSH_KEY" "$SSH_HOST" ' OLD=$(sudo ss -tlnp 2>/dev/null | grep 5001 | grep -oP "pid=\K[0-9]+" | head -1) if [[ -n "$OLD" ]]; then echo "killing PID $OLD" sudo kill -9 "$OLD" 2>/dev/null || true else echo "no old Flask running" fi ' sleep 2 log "启动新 Flask..." ssh -i "$SSH_KEY" "$SSH_HOST" ' sudo -u ubuntu bash -c "cd /opt/foresight/backend && nohup ./.venv-311/bin/python run.py --host 0.0.0.0 >> logs/server.log 2>&1 < /dev/null & disown" ' sleep 4 log "验证 /health..." local attempts=0 local max_attempts=5 while (( attempts < max_attempts )); do if curl -sf -m 5 "$API_HEALTH_URL" >/dev/null 2>&1; then local new_pid new_pid=$(ssh -i "$SSH_KEY" "$SSH_HOST" 'sudo ss -tlnp 2>/dev/null | grep 5001 | grep -oP "pid=\K[0-9]+" | head -1') ok "Flask 已就绪 (PID $new_pid)" return 0 fi attempts=$(( attempts + 1 )) sleep 2 done fail "Flask 健康检查超时 ($max_attempts 次尝试失败)" } # ---------- 前端构建 + 部署 ---------- deploy_frontend() { section "部署前端" log "vite build..." if $DRY_RUN; then warn "dry-run: 跳过 vite build" else ( cd "$LOCAL_FRONTEND" && npx vite build 2>&1 | tail -10 ) \ || fail "vite build 失败" fi ok "构建完成" log "coscmd upload dist/ → cos://$COS_BUCKET/" maybe bash -c "cd '$LOCAL_FRONTEND' && coscmd upload -r dist/ / --ignore .DS_Store" 2>&1 | tail -15 ok "已上传到 COS" # CDN 刷新 (可选) if command -v tccli >/dev/null 2>&1; then log "刷新 CDN 缓存..." if $DRY_RUN; then warn "dry-run: 跳过 CDN 刷新" else local task_id task_id=$(tccli cdn PurgePathCache --cli-unfold-argument \ --Paths "$PROD_URL/" --FlushType flush 2>&1 \ | python3 -c "import json,sys; print(json.load(sys.stdin).get('TaskId','unknown'))" 2>/dev/null \ || echo "failed") ok "CDN 刷新任务: $task_id (1-3 分钟生效)" fi else warn "tccli 未安装,需要手动刷新 CDN" fi } # ---------- 最终验证 ---------- final_verify() { section "最终验证" if $DEPLOY_BACKEND && ! $DRY_RUN; then log "API health check..." if curl -sf -m 5 "$API_HEALTH_URL" >/dev/null 2>&1; then ok "API $API_HEALTH_URL" else warn "API 不可达: $API_HEALTH_URL" fi fi if $DEPLOY_FRONTEND && ! $DRY_RUN; then log "前端 HTML check..." if curl -sfI -m 5 "$PROD_URL" 2>&1 | head -1 | grep -q "200"; then ok "前端 $PROD_URL" else warn "前端 HTML 不可达: $PROD_URL" fi fi } # ---------- main ---------- main() { section "Foresight 部署" printf "Mode: " $DEPLOY_BACKEND && printf "backend " $DEPLOY_FRONTEND && printf "frontend " $RESTART_FLASK && printf "+restart " $DRY_RUN && printf "(DRY-RUN) " printf "\n" preflight if $DEPLOY_BACKEND; then deploy_backend $RESTART_FLASK && restart_flask fi if $DEPLOY_FRONTEND; then deploy_frontend fi final_verify section "部署完成" ok "全部步骤成功" } main "$@"