#!/usr/bin/env bash # ═══════════════════════════════════════════════════════════════════ # 个人资料库 — 一键部署脚本 # 支持发行版:Ubuntu / Debian / CentOS / RHEL / Rocky / AlmaLinux / # Fedora / Alpine / Arch / Manjaro # # 用法: # curl -fsSL https://your-gitea/raw/.../scripts/deploy.sh | bash # 或:bash scripts/deploy.sh # ═══════════════════════════════════════════════════════════════════ set -euo pipefail IFS=$'\n\t' # ── 脚本自身位置 & 项目根目录 ───────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" STATE_FILE="$PROJECT_DIR/.deploy-state" LOG_FILE="/tmp/resource-library-deploy-$(date +%Y%m%d%H%M%S).log" # ── 颜色 & 输出 ────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' log() { echo -e "$(date '+%H:%M:%S') $*" | tee -a "$LOG_FILE"; } info() { echo -e "${CYAN}[INFO]${NC} $*" | tee -a "$LOG_FILE"; } ok() { echo -e "${GREEN}[ OK ]${NC} $*" | tee -a "$LOG_FILE"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "$LOG_FILE"; } err() { echo -e "${RED}[ERR ]${NC} $*" | tee -a "$LOG_FILE" >&2; } die() { err "$*"; exit 1; } section() { echo -e "\n${BOLD}${BLUE}── $* ─────────────────────────────────────${NC}" | tee -a "$LOG_FILE"; } ask() { echo -e "${YELLOW}[?]${NC} $*"; } banner() { cat << 'EOF' ╔═══════════════════════════════════════════╗ ║ 个人资料库 一键部署脚本 ║ ║ Personal Resource Library Deployer ║ ╚═══════════════════════════════════════════╝ EOF } # ── 工具函数 ────────────────────────────────────────────────────── command_exists() { command -v "$1" &>/dev/null; } # 生成安全随机字符串 gen_secret() { python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null || openssl rand -hex 32 2>/dev/null || tr -dc 'A-Za-z0-9@#$%' < /dev/urandom | head -c 64 } # 读取用户输入,支持默认值和可选的不回显(密码) prompt() { local var_name="$1" prompt_text="$2" default="${3:-}" secret="${4:-no}" local value="" if [ -n "$default" ]; then ask "$prompt_text [默认: ${secret:+***}${default:-}]: " else ask "$prompt_text: " fi if [ "$secret" = "yes" ]; then read -r -s value echo else read -r value fi value="${value:-$default}" eval "$var_name='$value'" } # ── 环境检测 ────────────────────────────────────────────────────── check_bash_version() { local ver="${BASH_VERSINFO[0]:-0}" [ "$ver" -ge 4 ] || die "需要 Bash 4.0+,当前版本: $BASH_VERSION" } check_root() { if [ "$(id -u)" -ne 0 ]; then if command_exists sudo; then warn "建议以 root 或 sudo 运行;继续尝试…" SUDO="sudo" else die "需要 root 权限(或安装 sudo)" fi else SUDO="" fi } check_internet() { info "检测网络连通性…" if ! curl -fsSL --max-time 8 https://get.docker.com > /dev/null 2>&1 && \ ! wget -q --timeout=8 -O /dev/null https://get.docker.com 2>/dev/null; then warn "无法访问 https://get.docker.com,Docker 自动安装可能失败" warn "请手动安装 Docker 后重新运行此脚本" else ok "网络正常" fi } # ── 发行版检测 ──────────────────────────────────────────────────── detect_os() { OS_ID="unknown"; OS_PRETTY="Unknown"; PKG_MGR="unknown" if [ -f /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}" OS_PRETTY="${PRETTY_NAME:-$OS_ID}" OS_ID_LIKE="${ID_LIKE:-}" fi # 根据 ID 或 ID_LIKE 映射包管理器 case "$OS_ID" in ubuntu|debian|linuxmint|pop|raspbian|kali) PKG_MGR="apt" ;; centos|rhel|ol|scientific) PKG_MGR="yum_or_dnf" ;; rocky|almalinux|eurolinux|springdale) PKG_MGR="dnf" ;; fedora) PKG_MGR="dnf" ;; alpine) PKG_MGR="apk" ;; arch|manjaro|endeavouros|garuda|artix) PKG_MGR="pacman" ;; opensuse*|sles) PKG_MGR="zypper" ;; *) if echo "${OS_ID_LIKE:-}" | grep -qiE "debian|ubuntu"; then PKG_MGR="apt" elif echo "${OS_ID_LIKE:-}" | grep -qiE "rhel|centos|fedora"; then PKG_MGR="yum_or_dnf" elif echo "${OS_ID_LIKE:-}" | grep -qiE "suse"; then PKG_MGR="zypper" fi ;; esac info "操作系统: $OS_PRETTY (ID=$OS_ID, PKG=$PKG_MGR)" } # ── Docker 安装 ─────────────────────────────────────────────────── # 使用 Docker 官方安装脚本(支持大多数发行版),Alpine/Arch 走包管理器 install_docker() { section "安装 Docker" if command_exists docker; then local ver ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1) ok "Docker 已安装: $ver" return 0 fi info "开始安装 Docker…" case "$PKG_MGR" in apk) $SUDO apk add --no-cache docker docker-cli-compose $SUDO rc-update add docker boot 2>/dev/null || true $SUDO service docker start 2>/dev/null || true ;; pacman) $SUDO pacman -Sy --noconfirm docker docker-compose $SUDO systemctl enable --now docker ;; zypper) $SUDO zypper install -y docker docker-compose $SUDO systemctl enable --now docker ;; *) # 官方安装脚本(Ubuntu/Debian/CentOS/RHEL/Rocky/Fedora 等) local tmp tmp=$(mktemp) curl -fsSL https://get.docker.com -o "$tmp" || \ wget -qO "$tmp" https://get.docker.com || \ die "下载 Docker 安装脚本失败" $SUDO sh "$tmp" rm -f "$tmp" ;; esac # 启动并设置开机自启(systemd 系统) if command_exists systemctl && [ "$PKG_MGR" != "apk" ]; then $SUDO systemctl enable docker 2>/dev/null || true $SUDO systemctl start docker 2>/dev/null || true fi # 将当前用户加入 docker 组(后续免 sudo) if [ -n "${SUDO_USER:-}" ] && getent group docker &>/dev/null; then $SUDO usermod -aG docker "$SUDO_USER" warn "已将 $SUDO_USER 加入 docker 组,需重新登录后生效" fi ok "Docker 安装完成: $(docker --version 2>/dev/null || echo '(请重新登录后验证)')" } # ── Docker Compose 检测 ─────────────────────────────────────────── detect_compose() { if docker compose version &>/dev/null 2>&1; then COMPOSE_CMD="docker compose" ok "Docker Compose v2 (plugin): $(docker compose version --short 2>/dev/null)" elif command_exists docker-compose; then COMPOSE_CMD="docker-compose" ok "Docker Compose v1 (standalone): $(docker-compose --version 2>/dev/null)" else info "未找到 Docker Compose,尝试安装 Compose 插件…" install_compose_plugin COMPOSE_CMD="docker compose" fi } install_compose_plugin() { local COMPOSE_VER="v2.27.1" local ARCH; ARCH=$(uname -m) case "$ARCH" in x86_64) ARCH="x86_64" ;; aarch64|arm64) ARCH="aarch64" ;; armv7l) ARCH="armv7" ;; *) die "不支持的 CPU 架构: $ARCH" ;; esac local URL="https://github.com/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${ARCH}" local DEST="/usr/local/lib/docker/cli-plugins/docker-compose" $SUDO mkdir -p "$(dirname "$DEST")" $SUDO curl -fsSL "$URL" -o "$DEST" || $SUDO wget -qO "$DEST" "$URL" || \ die "下载 Docker Compose 插件失败" $SUDO chmod +x "$DEST" ok "Docker Compose 插件安装完成" } # ── 交互配置 ────────────────────────────────────────────────────── collect_config() { section "部署配置" echo echo " 请选择部署模式:" echo " 1) 新建 MySQL(全栈,适合新服务器)" echo " 2) 使用现有 MySQL(适合已有数据库)" echo ask "请输入选项 [1/2](默认: 1): " read -r DEPLOY_MODE_INPUT DEPLOY_MODE_INPUT="${DEPLOY_MODE_INPUT:-1}" case "$DEPLOY_MODE_INPUT" in 1) DEPLOY_MODE="new-mysql" ; COMPOSE_FILE="docker-compose.yml" ; ENV_FILE=".env.docker" ;; 2) DEPLOY_MODE="external-db" ; COMPOSE_FILE="docker-compose.external-db.yml" ; ENV_FILE=".env.external-db" ;; *) die "无效选项: $DEPLOY_MODE_INPUT" ;; esac ok "部署模式: $DEPLOY_MODE" echo echo " 请选择镜像来源:" echo " 1) 从 Gitea 镜像仓库拉取(需已完成 CI 构建)" echo " 2) 本地构建(无需 CI,首次部署推荐)" echo ask "请输入选项 [1/2](默认: 2): " read -r IMG_SRC_INPUT IMG_SRC_INPUT="${IMG_SRC_INPUT:-2}" case "$IMG_SRC_INPUT" in 1) IMAGE_SOURCE="registry" ;; 2) IMAGE_SOURCE="build" ;; *) die "无效选项: $IMG_SRC_INPUT" ;; esac if [ "$IMAGE_SOURCE" = "registry" ]; then prompt REGISTRY_HOST "Gitea 镜像仓库地址" "git.hty1024.com" prompt REGISTRY_IMAGE "镜像名(小写)" "${REGISTRY_HOST}/hty1024/ai-app-database" prompt REGISTRY_USER "登录用户名" "" prompt REGISTRY_PASS "登录密码 / Token" "" yes info "登录镜像仓库…" echo "$REGISTRY_PASS" | docker login "$REGISTRY_HOST" \ -u "$REGISTRY_USER" --password-stdin || die "登录失败,请检查凭据" ok "镜像仓库登录成功" else REGISTRY_IMAGE="" fi echo # ── 公共配置 ────────────────────────────────────────────────── local auto_secret; auto_secret=$(gen_secret) prompt APP_PORT "应用端口" "5000" prompt SECRET_KEY "应用密钥(留空自动生成)" "$auto_secret" yes SECRET_KEY="${SECRET_KEY:-$auto_secret}" prompt ADMIN_USERNAME "管理员用户名" "admin" prompt ADMIN_PASSWORD "管理员密码" "Admin@123456" yes prompt ADMIN_EMAIL "管理员邮箱" "admin@example.com" prompt MAX_UPLOAD_MB "最大上传大小(MB)" "500" prompt GUNICORN_WORKERS "Gunicorn worker 数量" "4" # ── 模式专属配置 ────────────────────────────────────────────── if [ "$DEPLOY_MODE" = "new-mysql" ]; then echo info "MySQL 配置(新建数据库)" local auto_root_pw; auto_root_pw=$(gen_secret | cut -c1-24) local auto_user_pw; auto_user_pw=$(gen_secret | cut -c1-24) prompt MYSQL_ROOT_PASSWORD "MySQL root 密码(留空自动生成)" "$auto_root_pw" yes MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-$auto_root_pw}" prompt MYSQL_PASSWORD "MySQL 应用用户密码(留空自动生成)" "$auto_user_pw" yes MYSQL_PASSWORD="${MYSQL_PASSWORD:-$auto_user_pw}" prompt MYSQL_EXPOSE_PORT "MySQL 对外端口(留空=不暴露)" "127.0.0.1:3306" else echo info "外部数据库配置" echo " 格式: mysql+pymysql://用户名:密码@主机:端口/数据库名" prompt DATABASE_URL "数据库连接串" "" [ -n "$DATABASE_URL" ] || die "外部 MySQL 模式必须提供 DATABASE_URL" prompt DB_WAIT_SECONDS "等待数据库就绪超时(秒)" "30" fi # ── 是否启用 Nginx ────────────────────────────────────────── echo ask "是否启用 Nginx 反向代理?[y/N](默认: N): " read -r NGINX_INPUT USE_NGINX="${NGINX_INPUT:-N}" if echo "$USE_NGINX" | grep -qiE "^y(es)?$"; then USE_NGINX="yes" prompt NGINX_HTTP_PORT "Nginx HTTP 端口" "80" prompt NGINX_HTTPS_PORT "Nginx HTTPS 端口" "443" else USE_NGINX="no" fi } # ── 写入 env 文件 ───────────────────────────────────────────────── write_env_file() { section "写入配置文件" local env_path="$PROJECT_DIR/$ENV_FILE" # 备份旧文件 if [ -f "$env_path" ]; then cp "$env_path" "${env_path}.bak.$(date +%Y%m%d%H%M%S)" warn "已备份旧配置: ${env_path}.bak.*" fi { echo "# 个人资料库部署配置 — 生成于 $(date '+%Y-%m-%d %H:%M:%S')" echo "# 请勿提交此文件到版本控制系统!" echo "" echo "SECRET_KEY=${SECRET_KEY}" echo "FLASK_ENV=production" echo "APP_PORT=${APP_PORT}" echo "MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_MB}" echo "GUNICORN_WORKERS=${GUNICORN_WORKERS}" echo "GUNICORN_TIMEOUT=120" echo "LOG_LEVEL=info" echo "" echo "ADMIN_USERNAME=${ADMIN_USERNAME}" echo "ADMIN_PASSWORD=${ADMIN_PASSWORD}" echo "ADMIN_EMAIL=${ADMIN_EMAIL}" echo "" if [ "$DEPLOY_MODE" = "new-mysql" ]; then echo "MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}" echo "MYSQL_DATABASE=resource_library" echo "MYSQL_USER=resource_library" echo "MYSQL_PASSWORD=${MYSQL_PASSWORD}" echo "MYSQL_EXPOSE_PORT=${MYSQL_EXPOSE_PORT:-127.0.0.1:3306}" else echo "DATABASE_URL=${DATABASE_URL}" echo "DB_WAIT_SECONDS=${DB_WAIT_SECONDS:-30}" fi if [ "${USE_NGINX}" = "yes" ]; then echo "" echo "NGINX_HTTP_PORT=${NGINX_HTTP_PORT:-80}" echo "NGINX_HTTPS_PORT=${NGINX_HTTPS_PORT:-443}" fi } > "$env_path" chmod 600 "$env_path" ok "配置文件已写入: $env_path" } # ── 写入部署状态文件 ────────────────────────────────────────────── write_state_file() { { echo "DEPLOY_MODE=${DEPLOY_MODE}" echo "COMPOSE_FILE=${COMPOSE_FILE}" echo "ENV_FILE=${ENV_FILE}" echo "IMAGE_SOURCE=${IMAGE_SOURCE}" echo "REGISTRY_IMAGE=${REGISTRY_IMAGE:-}" echo "APP_PORT=${APP_PORT}" echo "USE_NGINX=${USE_NGINX}" echo "DEPLOYED_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ')" } > "$STATE_FILE" ok "状态文件已写入: $STATE_FILE" } # ── 构建或拉取镜像 ──────────────────────────────────────────────── prepare_image() { section "准备镜像" cd "$PROJECT_DIR" if [ "$IMAGE_SOURCE" = "registry" ]; then info "从镜像仓库拉取: ${REGISTRY_IMAGE}:latest …" docker pull "${REGISTRY_IMAGE}:latest" || die "镜像拉取失败" ok "镜像拉取完成" else info "本地构建镜像(首次构建可能需要几分钟)…" docker build -t resource-library:latest . 2>&1 | tee -a "$LOG_FILE" ok "本地构建完成" fi } # ── 启动服务 ────────────────────────────────────────────────────── start_services() { section "启动服务" cd "$PROJECT_DIR" local compose_args=(--env-file "$ENV_FILE" -f "$COMPOSE_FILE") if [ "$USE_NGINX" = "yes" ]; then compose_args+=(--profile nginx) fi info "执行: $COMPOSE_CMD ${compose_args[*]} up -d" $COMPOSE_CMD "${compose_args[@]}" up -d 2>&1 | tee -a "$LOG_FILE" ok "容器已启动" } # ── 健康检查 ────────────────────────────────────────────────────── health_check() { section "健康检查" local max_wait=120 elapsed=0 interval=5 local url="http://localhost:${APP_PORT}/auth/login" info "等待服务就绪(最长 ${max_wait}s)…" while [ "$elapsed" -lt "$max_wait" ]; do if curl -fsSL --max-time 5 "$url" > /dev/null 2>&1; then ok "服务已就绪!耗时: ${elapsed}s" return 0 fi printf '.' sleep "$interval" elapsed=$((elapsed + interval)) done echo err "健康检查超时(${max_wait}s)" warn "查看日志: $COMPOSE_CMD -f $COMPOSE_FILE logs --tail=50" return 1 } # ── 部署完成提示 ────────────────────────────────────────────────── print_success() { local local_ip local_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || local_ip="<服务器IP>" echo echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}" echo -e "${GREEN}${BOLD} 🎉 部署成功! ${NC}" echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}" echo echo -e " 访问地址: ${CYAN}http://${local_ip}:${APP_PORT}${NC}" if [ "$USE_NGINX" = "yes" ]; then echo -e " Nginx: ${CYAN}http://${local_ip}:${NGINX_HTTP_PORT:-80}${NC}" fi echo echo -e " 管理员账号: ${YELLOW}${ADMIN_USERNAME}${NC}" echo -e " 管理员密码: ${YELLOW}(部署时设置)${NC}" echo echo -e " 查看日志: ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE logs -f${NC}" echo -e " 更新应用: ${BLUE}bash scripts/update.sh${NC}" echo -e " 停止服务: ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE down${NC}" echo echo -e " 部署日志: $LOG_FILE" echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}" echo } # ── 主流程 ──────────────────────────────────────────────────────── main() { banner log "部署日志: $LOG_FILE" check_bash_version check_root check_internet detect_os install_docker detect_compose collect_config write_env_file write_state_file prepare_image start_services health_check print_success } main "$@"