#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prod.yml" BACKEND_ENV="${ROOT_DIR}/backend/.env" BACKEND_LOG_DIR="${ROOT_DIR}/backend/logs" MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations" HEALTH_URL="${PROD_HEALTH_URL:-http://127.0.0.1:7890/api/health}" PUBLIC_URL="${PROD_PUBLIC_URL:-http://hfb.221329.cc.cd:7890}" READY_TIMEOUT="${PROD_READY_TIMEOUT:-120}" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[1;34m' NC='\033[0m' log() { printf "${BLUE}[deploy]${NC} %s\n" "$*" } log_success() { printf "${GREEN}[deploy]${NC} %s\n" "$*" } log_warn() { printf "${YELLOW}[deploy]${NC} %s\n" "$*" } log_error() { printf "${RED}[deploy]${NC} %s\n" "$*" >&2 } need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then log_error "缺少命令:$1" exit 1 fi } env_value() { local key="$1" awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKEND_ENV}" } require_env() { local key="$1" local value value="$(env_value "${key}")" if [[ -z "${value}" ]]; then log_error "backend/.env 缺少或未填写 ${key}" exit 1 fi printf "%s" "${value}" } show_help() { cat << EOF 用法: ./scripts/deploy-prod.sh [选项] 选项: --no-build 不重新构建镜像,仅启动已有镜像 --no-migrate 不执行数据库迁移 --reset-db 重置数据库(测试阶段使用,会删除并重建) --logs 部署完成后跟随查看后端日志 -h, --help 显示帮助信息 环境变量: PROD_HEALTH_URL=http://127.0.0.1:7890/api/health 部署后健康检查地址 PROD_PUBLIC_URL=http://hfb.221329.cc.cd:7890 输出给用户看的访问地址 PROD_READY_TIMEOUT=120 等待服务就绪秒数 示例: ./scripts/deploy-prod.sh ./scripts/deploy-prod.sh --no-build ./scripts/deploy-prod.sh --reset-db ./scripts/deploy-prod.sh --logs EOF } NO_BUILD=0 NO_MIGRATE=0 RESET_DB=0 FOLLOW_LOGS=0 while [[ $# -gt 0 ]]; do case "$1" in --no-build) NO_BUILD=1 shift ;; --no-migrate) NO_MIGRATE=1 shift ;; --reset-db) RESET_DB=1 shift ;; --logs) FOLLOW_LOGS=1 shift ;; -h|--help) show_help exit 0 ;; *) log_error "未知选项:$1" show_help exit 1 ;; esac done if [[ "${RESET_DB}" == "1" && "${NO_MIGRATE}" == "1" ]]; then log_error "--reset-db 会清空数据库,不能和 --no-migrate 同时使用" exit 1 fi validate_env() { if [[ ! -f "${BACKEND_ENV}" ]]; then log_error "未找到 backend/.env,请先执行:cp backend/.env.prod.example backend/.env 并填写生产配置" exit 1 fi local app_env mysql_dsn redis_addr storage_endpoint app_env="$(require_env APP_ENV)" mysql_dsn="$(require_env MYSQL_DSN)" redis_addr="$(require_env REDIS_ADDR)" storage_endpoint="$(require_env STORAGE_ENDPOINT)" require_env MYSQL_DATABASE >/dev/null require_env MYSQL_ROOT_PASSWORD >/dev/null require_env MYSQL_USER >/dev/null require_env MYSQL_PASSWORD >/dev/null require_env JWT_SECRET >/dev/null require_env MINIO_ROOT_USER >/dev/null require_env MINIO_ROOT_PASSWORD >/dev/null require_env STORAGE_ACCESS_KEY_ID >/dev/null require_env STORAGE_SECRET_ACCESS_KEY >/dev/null if [[ "${app_env}" != "production" ]]; then log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production" fi if [[ "${mysql_dsn}" == *"127.0.0.1"* || "${mysql_dsn}" == *"localhost"* ]]; then log_error "MYSQL_DSN 仍指向本机,请改为 tcp(mysql:3306)" exit 1 fi if [[ "${redis_addr}" == "127.0.0.1:"* || "${redis_addr}" == "localhost:"* ]]; then log_error "REDIS_ADDR 仍指向本机,请改为 redis:6379" exit 1 fi if [[ "${storage_endpoint}" == *"127.0.0.1"* || "${storage_endpoint}" == *"localhost"* ]]; then log_error "STORAGE_ENDPOINT 仍指向本机,请改为 http://minio:9000" exit 1 fi if awk -F= '/^[A-Z0-9_]+=/{ if ($0 ~ /=(change-|保持你的|你的)/) found=1 } END { exit found ? 0 : 1 }' "${BACKEND_ENV}"; then log_error "backend/.env 里还有占位值,请先替换后再部署" exit 1 fi } compose() { docker compose -f "${COMPOSE_FILE}" "$@" } prepare_log_dir() { mkdir -p "${BACKEND_LOG_DIR}" } wait_service_healthy() { local service="$1" local label="$2" local attempt log "等待 ${label} 就绪..." for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do local container_id status container_id="$(compose ps -q "${service}" 2>/dev/null || true)" if [[ -n "${container_id}" ]]; then status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_id}" 2>/dev/null || true)" if [[ "${status}" == "healthy" || "${status}" == "running" ]]; then log_success "${label} 已就绪" return 0 fi fi sleep 1 done log_error "${label} 启动超时,请执行:docker compose -f deploy/docker-compose.prod.yml logs ${service}" exit 1 } mysql_exec() { compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u"$MYSQL_USER" "$@"' sh "$@" } mysql_root_exec() { compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$@"' sh "$@" } mysql_scalar() { mysql_exec -N -s "$(require_env MYSQL_DATABASE)" -e "$1" } ensure_migration_table() { mysql_exec "$(require_env MYSQL_DATABASE)" <<'SQL' CREATE TABLE IF NOT EXISTS schema_migrations ( version VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; SQL } bootstrap_existing_schema_history() { local migration_count users_exists system_configs_exists migration_count="$(mysql_scalar "SELECT COUNT(*) FROM schema_migrations;")" users_exists="$(mysql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'users';")" system_configs_exists="$(mysql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'system_configs';")" if [[ "${migration_count}" == "0" && "${users_exists}" != "0" && "${system_configs_exists}" != "0" ]]; then log_warn "检测到已有表结构但没有迁移记录,将当前 migration 文件标记为已应用" local file version for file in "${MIGRATIONS_DIR}"/*.sql; do version="$(basename "${file}" .sql)" mysql_exec "$(require_env MYSQL_DATABASE)" -e \ "INSERT IGNORE INTO schema_migrations (version) VALUES ('${version}');" done fi } reset_database() { if [[ "${RESET_DB}" != "1" ]]; then return 0 fi local database database="$(require_env MYSQL_DATABASE)" if [[ ! "${database}" =~ ^[A-Za-z0-9_]+$ ]]; then log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${database}" exit 1 fi log_warn "重置数据库 ${database}..." mysql_root_exec -e \ "DROP DATABASE IF EXISTS \`${database}\`; CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" log_success "数据库已重建" } run_migrations() { if [[ "${NO_MIGRATE}" == "1" ]]; then log_warn "已跳过数据库迁移" return 0 fi log "执行数据库迁移..." ensure_migration_table bootstrap_existing_schema_history local file version applied shopt -s nullglob for file in "${MIGRATIONS_DIR}"/*.sql; do version="$(basename "${file}" .sql)" applied="$(mysql_scalar "SELECT COUNT(*) FROM schema_migrations WHERE version = '${version}';")" if [[ "${applied}" != "0" ]]; then if [[ "${version}" == "000001_init" ]]; then log "同步初始化脚本:${version}" mysql_exec "$(require_env MYSQL_DATABASE)" < "${file}" else log "跳过已应用迁移:${version}" fi continue fi log "导入迁移:${version}" mysql_exec "$(require_env MYSQL_DATABASE)" < "${file}" mysql_exec "$(require_env MYSQL_DATABASE)" -e \ "INSERT INTO schema_migrations (version) VALUES ('${version}');" done shopt -u nullglob log_success "数据库迁移完成" } wait_http_ready() { local attempt log "等待接口健康检查通过..." for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do if curl -fsS --max-time 3 "${HEALTH_URL}" >/dev/null 2>&1; then log_success "健康检查通过:${HEALTH_URL}" return 0 fi sleep 1 done log_error "健康检查超时:${HEALTH_URL}" exit 1 } main() { cd "${ROOT_DIR}" need_cmd docker need_cmd curl validate_env prepare_log_dir log "检查 Docker Compose 配置..." compose config >/dev/null if [[ "${NO_BUILD}" == "1" ]]; then log "启动生产服务..." compose up -d else log "构建并启动生产服务..." compose up -d --build fi wait_service_healthy mysql "MySQL" wait_service_healthy redis "Redis" wait_service_healthy minio "MinIO" reset_database run_migrations log "重启后端以加载最新数据库状态..." compose up -d --no-deps --force-recreate backend wait_service_healthy backend "后端" wait_http_ready log_success "部署完成:${PUBLIC_URL}" log "常用命令:docker compose -f deploy/docker-compose.prod.yml logs -f backend" if [[ "${FOLLOW_LOGS}" == "1" ]]; then compose logs -f backend fi } main "$@"