#!/usr/bin/env bash set -Eeuo pipefail # Ubuntu 服务器一键部署脚本。 # 用法:bash deploy/ubuntu-deploy.sh PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="${PROJECT_ROOT}/.env" ENV_EXAMPLE="${PROJECT_ROOT}/.env.server.example" BACKEND_DATA_DIR="${PROJECT_ROOT}/apps/backend/data" BACKEND_CONTAINER_UID=1001 BACKEND_CONTAINER_GID=1001 HEALTH_URL="" READY_TIMEOUT="${READY_TIMEOUT:-120}" log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*" } warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*" } fail() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2 exit 1 } read_env_value() { local key="$1" awk -F= -v key="${key}" ' $0 !~ /^[[:space:]]*#/ && $1 == key { value = substr($0, index($0, "=") + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) gsub(/^"|"$/, "", value) gsub(/^'\''|'\''$/, "", value) print value exit } ' "${ENV_FILE}" } ensure_docker() { command -v docker >/dev/null 2>&1 || fail "未找到 docker,请先在 Ubuntu 上安装 Docker Engine。" docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件,请安装 Docker Compose plugin。" command -v curl >/dev/null 2>&1 || fail "未找到 curl,请先安装:sudo apt-get update && sudo apt-get install -y curl" } ensure_env_file() { if [[ -f "${ENV_FILE}" ]]; then return fi cp "${ENV_EXAMPLE}" "${ENV_FILE}" fail "已生成 .env,请先编辑真实域名、数据库密码、后台账号和密钥后再重新执行。" } validate_env_file() { if grep -nE 'change-me|your-domain|example\.com' "${ENV_FILE}" >/tmp/order-site-env-placeholders.txt; then cat /tmp/order-site-env-placeholders.txt >&2 fail ".env 仍包含示例占位值,请替换后再部署。" fi [[ "${READY_TIMEOUT}" =~ ^[1-9][0-9]*$ ]] || fail "READY_TIMEOUT 必须是正整数秒数,当前值:${READY_TIMEOUT}" local site_addr site_addr="$(read_env_value CADDY_SITE_ADDR)" [[ -n "${site_addr}" ]] || fail ".env 缺少 CADDY_SITE_ADDR。" HEALTH_URL="${site_addr%/}/health/ready" validate_database_url_for_compose } validate_database_url_for_compose() { local database_url database_url="$(read_env_value DATABASE_URL)" if [[ -z "${database_url}" ]]; then return fi if [[ "${database_url}" == *"@localhost:"* \ || "${database_url}" == *"@127.0.0.1:"* \ || "${database_url}" == *"@[::1]:"* ]]; then fail ".env 的 DATABASE_URL 当前指向本机地址。Docker 部署时后端容器内的 localhost 不是 postgres 容器,请改为 postgres 服务名或外部数据库地址。" fi } ensure_data_dir() { mkdir -p "${BACKEND_DATA_DIR}" if [[ "$(id -u)" == "0" ]]; then chown -R "${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID}" "${BACKEND_DATA_DIR}" return fi if command -v sudo >/dev/null 2>&1; then sudo chown -R "${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID}" "${BACKEND_DATA_DIR}" return fi warn "未找到 sudo,跳过目录属主调整。若后端无法写入日志或配置,请执行:" warn "sudo chown -R ${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID} ${BACKEND_DATA_DIR}" } check_compose_config() { log "检查 Docker Compose 配置" docker compose config >/dev/null || fail "Docker Compose 配置校验失败。" } pull_base_images() { log "拉取基础镜像" docker compose pull --ignore-buildable } build_images() { log "构建后端镜像" docker compose build backend || fail "后端镜像构建失败。" log "构建 Web 镜像" docker compose build web || fail "Web 镜像构建失败。" } wait_for_service() { local service="$1" local label="$2" local attempt container_id status health_status log "等待 ${label} 就绪" for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do container_id="$(docker compose ps -q "${service}" 2>/dev/null || true)" if [[ -n "${container_id}" ]]; then status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)" health_status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "${container_id}" 2>/dev/null || true)" if [[ -n "${health_status}" && "${health_status}" == "healthy" ]]; then log "${label} 已就绪" return fi if [[ -z "${health_status}" && "${status}" == "running" ]]; then log "${label} 已启动" return fi if [[ "${status}" == "exited" || "${status}" == "dead" ]]; then break fi fi sleep 1 done print_diagnostics fail "${label} 启动超时,请根据上面的日志排查。" } start_postgres() { log "启动 PostgreSQL" if docker compose up -d --no-deps postgres; then wait_for_service postgres "PostgreSQL" return fi print_diagnostics fail "PostgreSQL 启动失败,请根据上面的日志排查。" } run_database_migrations() { log "执行数据库迁移" if docker compose run --rm --no-deps backend node dist/db/migrate.js; then log "数据库迁移完成" return fi print_diagnostics fail "数据库迁移失败,请根据上面的日志排查。" } restart_backend() { log "重建并启动后端" if docker compose up -d --no-deps --force-recreate backend; then wait_for_service backend "后端" return fi print_diagnostics fail "后端启动失败,请根据上面的日志排查。" } restart_web() { log "重建并启动 Web 入口" if docker compose up -d --no-deps --force-recreate --remove-orphans web; then wait_for_service web "Web" return fi print_diagnostics fail "Web 启动失败,请根据上面的日志排查。" } deploy_stack() { check_compose_config pull_base_images build_images start_postgres run_database_migrations restart_backend restart_web } wait_for_health() { log "等待服务健康检查:${HEALTH_URL}" for _ in $(seq 1 "${READY_TIMEOUT}"); do if curl -fsS "${HEALTH_URL}" >/dev/null 2>&1; then log "服务已就绪" return fi sleep 1 done print_diagnostics fail "部署已启动但健康检查未通过,请根据日志排查。" } print_applied_migrations() { log "已应用数据库迁移" local migrations if migrations="$(docker compose exec -T backend node --input-type=module -e ' const { query, closeDb } = await import("./dist/db/client.js"); try { const result = await query("SELECT filename FROM schema_migrations ORDER BY filename"); console.log(result.rows.map((row) => row.filename).join("\n") || "(无迁移记录)"); } finally { await closeDb(); } ' 2>&1)"; then printf '%s\n' "${migrations}" return fi warn "读取迁移记录失败:" printf '%s\n' "${migrations}" } print_diagnostics() { warn "当前容器状态:" docker compose ps || true warn "后端最近日志:" local backend_logs backend_logs="$(docker compose logs --tail=120 backend 2>&1 || true)" printf '%s\n' "${backend_logs}" if printf '%s\n' "${backend_logs}" | grep -q 'password authentication failed for user "postgres"'; then warn "检测到 PostgreSQL 密码认证失败。通常是 postgres_data 已用旧密码初始化,而 .env 改成了新密码。" warn "无生产数据时可执行:docker compose down -v && bash deploy/ubuntu-deploy.sh" warn "需要保留数据时,请进入 postgres 容器用当前 .env 的 POSTGRES_PASSWORD 修改数据库用户密码。" fi warn "后端 ready 接口:" docker compose exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true print_applied_migrations || true } main() { cd "${PROJECT_ROOT}" ensure_docker ensure_env_file validate_env_file ensure_data_dir deploy_stack wait_for_health print_applied_migrations log "部署完成" docker compose ps } main "$@"