#!/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" HEALTH_URL="${PROD_HEALTH_URL:-}" PUBLIC_URL="${PROD_PUBLIC_URL:-}" 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 部署完成后跟随查看后端日志 --skip-check 跳过部署前代码门禁(不推荐) --no-clean 跳过部署后的 Docker 垃圾清理 -h, --help 显示帮助信息 环境变量: PROD_HEALTH_URL=https://你的域名/api/health 部署后健康检查地址,默认使用 CADDY_DOMAIN PROD_PUBLIC_URL=https://你的域名 输出给用户看的访问地址 PROD_READY_TIMEOUT=120 等待服务就绪秒数 选号网(hfb_show,可选,写在 backend/.env): CADDY_SHOW_DOMAIN=show.example.com 选号网独立域名,A 记录指向本机;留空则不启用 HFB_SHOW_DIR=../hfb_show 选号网源码目录(相对 hfb_sys 根目录或绝对路径) 构建优先用本机 npm;没有 npm 时自动用 Docker node 镜像,服务器不必装 Node 示例: ./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 SKIP_CHECK=0 NO_CLEAN=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 ;; --skip-check) SKIP_CHECK=1 shift ;; --no-clean) NO_CLEAN=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 caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint local minio_root_user minio_root_password storage_access_key_id storage_secret_access_key app_env="$(require_env APP_ENV)" caddy_domain="$(require_env CADDY_DOMAIN)" caddy_email="$(require_env CADDY_EMAIL)" 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 minio_root_user="$(require_env MINIO_ROOT_USER)" minio_root_password="$(require_env MINIO_ROOT_PASSWORD)" storage_access_key_id="$(require_env STORAGE_ACCESS_KEY_ID)" storage_secret_access_key="$(require_env STORAGE_SECRET_ACCESS_KEY)" if [[ "${app_env}" != "production" ]]; then log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production" fi if [[ "${caddy_domain}" == http://* || "${caddy_domain}" == https://* || "${caddy_domain}" == */* ]]; then log_error "CADDY_DOMAIN 只填写域名本身,不要包含 http(s):// 或路径,当前值:${caddy_domain}" exit 1 fi if [[ "${caddy_domain}" == *"127.0.0.1"* || "${caddy_domain}" == *"localhost"* || "${caddy_domain}" != *"."* ]]; then log_error "CADDY_DOMAIN 必须填写可公网解析到本机的正式域名,当前值:${caddy_domain}" exit 1 fi if [[ "${caddy_email}" == "admin@example.com" || "${caddy_email}" != *"@"* ]]; then log_error "CADDY_EMAIL 需要填写真实邮箱,用于 ACME 证书通知" exit 1 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 [[ "${storage_endpoint}" == "http://minio:9000" || "${storage_endpoint}" == "minio:9000" ]]; then if [[ "${storage_access_key_id}" != "${minio_root_user}" || "${storage_secret_access_key}" != "${minio_root_password}" ]]; then log_error "使用内置 MinIO 时,STORAGE_ACCESS_KEY_ID/SECRET_ACCESS_KEY 必须和 MINIO_ROOT_USER/ROOT_PASSWORD 保持一致" exit 1 fi 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 --env-file "${BACKEND_ENV}" -f "${COMPOSE_FILE}" "$@" } compose_user_cmd() { printf "docker compose --env-file backend/.env -f deploy/docker-compose.prod.yml" } export_caddy_env() { export CADDY_DOMAIN export CADDY_EMAIL export CADDY_SHOW_DOMAIN CADDY_DOMAIN="$(require_env CADDY_DOMAIN)" CADDY_EMAIL="$(require_env CADDY_EMAIL)" CADDY_SHOW_DOMAIN="$(env_value CADDY_SHOW_DOMAIN || true)" export CADDY_SHOW_DOMAIN } # 准备选号网静态资源与 Caddy 站点配置(可选) prepare_show_site() { local conf_dir="${ROOT_DIR}/deploy/caddy/conf.d" local show_dist="${ROOT_DIR}/deploy/caddy/show-dist" local show_tmpl="${ROOT_DIR}/deploy/caddy/show.caddy.tmpl" local show_conf="${conf_dir}/show.caddy" local show_dir show_domain mkdir -p "${conf_dir}" rm -f "${show_conf}" show_domain="$(env_value CADDY_SHOW_DOMAIN || true)" if [[ -z "${show_domain}" ]]; then log "未配置 CADDY_SHOW_DOMAIN,跳过选号网站点" # 保留占位页,避免 Caddy 镜像 COPY 目录为空时异常 if [[ ! -f "${show_dist}/index.html" ]]; then mkdir -p "${show_dist}" printf '%s\n' 'show disabled' >"${show_dist}/index.html" fi return 0 fi if [[ "${show_domain}" == http://* || "${show_domain}" == https://* || "${show_domain}" == */* ]]; then log_error "CADDY_SHOW_DOMAIN 只填写域名本身,不要包含 http(s):// 或路径,当前值:${show_domain}" exit 1 fi if [[ "${show_domain}" == "${CADDY_DOMAIN}" ]]; then log_error "CADDY_SHOW_DOMAIN 不能与 CADDY_DOMAIN 相同" exit 1 fi show_dir="$(env_value HFB_SHOW_DIR || true)" if [[ -z "${show_dir}" ]]; then show_dir="${ROOT_DIR}/../hfb_show" elif [[ "${show_dir}" != /* ]]; then show_dir="${ROOT_DIR}/${show_dir}" fi if [[ ! -d "${show_dir}" ]]; then log_error "已配置 CADDY_SHOW_DOMAIN,但找不到选号网目录:${show_dir}" log_error "请设置 HFB_SHOW_DIR,或把仓库放在与 hfb_sys 同级的 hfb_show" exit 1 fi if [[ ! -f "${show_dir}/package.json" ]]; then log_error "选号网目录无效(缺少 package.json):${show_dir}" exit 1 fi if [[ "${NO_BUILD}" == "1" ]]; then if [[ ! -f "${show_dist}/index.html" ]] || grep -q '选号网静态资源尚未构建' "${show_dist}/index.html" 2>/dev/null; then log_error "--no-build 时需要已有 deploy/caddy/show-dist 构建产物,请先完整部署一次" exit 1 fi log_warn "使用已有 show-dist(--no-build)" else # 服务器通常无本机 Node:优先本机 npm,否则用 Docker 内 node 镜像构建。 # 国内机访问 registry.npmjs.org 易 ECONNRESET,默认走 npmmirror,可用 NPM_REGISTRY 覆盖。 local npm_registry npm_install npm_registry="$(env_value NPM_REGISTRY || true)" if [[ -z "${npm_registry}" ]]; then npm_registry="${NPM_REGISTRY:-https://registry.npmmirror.com}" fi if [[ -f "${show_dir}/package-lock.json" ]]; then npm_install="npm ci" else npm_install="npm install" fi log "构建选号网静态资源:${show_dir}(registry=${npm_registry})" if command -v npm >/dev/null 2>&1; then ( cd "${show_dir}" ${npm_install} \ --registry="${npm_registry}" \ --fetch-retries=5 \ --fetch-retry-mintimeout=20000 \ --fetch-retry-maxtimeout=120000 npm run build ) else log_warn "本机无 npm,使用 Docker 镜像 node:24-alpine 构建选号网(无需安装 Node)" need_cmd docker # 挂载源码目录;node_modules/dist 写在挂载卷上,与本机 npm 行为一致 docker run --rm \ -e npm_config_registry="${npm_registry}" \ -v "${show_dir}:/app" \ -w /app \ node:24-alpine \ sh -c "${npm_install} --registry=${npm_registry} --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 && npm run build" fi if [[ ! -d "${show_dir}/dist" ]]; then log_error "选号网构建失败:未生成 dist/" log_error "若仍网络失败,可在 backend/.env 设置 NPM_REGISTRY=https://registry.npmmirror.com 后重试" exit 1 fi rm -rf "${show_dist}" mkdir -p "${show_dist}" cp -a "${show_dir}/dist/." "${show_dist}/" log_success "选号网 dist 已同步到 deploy/caddy/show-dist" fi if [[ ! -f "${show_tmpl}" ]]; then log_error "缺少 ${show_tmpl}" exit 1 fi # 域名仅允许主机名字符,避免注入 Caddyfile if [[ ! "${show_domain}" =~ ^[A-Za-z0-9.-]+$ ]]; then log_error "CADDY_SHOW_DOMAIN 含非法字符:${show_domain}" exit 1 fi sed "s/__CADDY_SHOW_DOMAIN__/${show_domain}/g" "${show_tmpl}" >"${show_conf}" log_success "已启用选号网站点:https://${show_domain}" } public_url() { if [[ -n "${PUBLIC_URL}" ]]; then printf "%s" "${PUBLIC_URL}" return fi printf "https://%s" "$(require_env CADDY_DOMAIN)" } health_url() { if [[ -n "${HEALTH_URL}" ]]; then printf "%s" "${HEALTH_URL}" return fi printf "%s/api/health" "$(public_url)" } 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} 启动超时" log_warn "最近 ${service} 日志:" compose logs --tail=120 "${service}" || true log_error "可继续执行:$(compose_user_cmd) logs ${service}" exit 1 } mysql_root_exec() { compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$@"' sh "$@" } goose_dsn() { local dsn="$1" if [[ "${dsn}" != *"multiStatements="* ]]; then if [[ "${dsn}" == *"?"* ]]; then dsn="${dsn}&multiStatements=true" else dsn="${dsn}?multiStatements=true" fi fi printf "%s" "${dsn}" } goose_cmd() { compose run --rm --no-deps backend /app/goose -dir /app/migrations mysql "$(goose_dsn "$(require_env MYSQL_DSN)")" "$@" } 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 "使用 goose 执行数据库迁移..." goose_cmd up log_success "数据库迁移完成" } wait_http_ready() { local attempt local url url="$(health_url)" log "等待接口健康检查通过..." for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do if curl -fsS --max-time 5 "${url}" >/dev/null 2>&1; then log_success "健康检查通过:${url}" return 0 fi sleep 1 done log_error "健康检查超时:${url}" exit 1 } # 清理部署过程中产生的 Docker 垃圾(旧镜像、构建缓存) # 每次 docker compose build 会生成新镜像,旧镜像变为 : # 构建缓存(BuildKit)也会持续增长,不清理会轻松堆积几十 GB。 # 关键:用 --filter until=168h 只清超过 7 天的旧缓存,保留近期缓存(基础镜像层、 # Go module / go-build 缓存),让下次构建复用,避免每次重新下载 node 镜像、重拉依赖。 docker_cleanup() { log "清理 Docker 垃圾..." local before after before=$(docker system df --format '{{.TotalCount}}' 2>/dev/null || echo "?") docker image prune -f 2>/dev/null || true docker builder prune -f --filter until=168h 2>/dev/null || true after=$(docker system df --format '{{.TotalCount}}' 2>/dev/null || echo "?") log_success "Docker 清理完成(对象数: ${before} -> ${after})" } main() { cd "${ROOT_DIR}" need_cmd docker need_cmd curl validate_env export_caddy_env prepare_log_dir # 选号网需在 build caddy 之前准备 show-dist 与 conf.d prepare_show_site log "检查 Docker Compose 配置..." compose config >/dev/null # 部署前代码门禁:本地有 go + node 时跑全量检查,挡住未通过的提交进镜像。 # 部署机无 go/node 或 --skip-check 时跳过并告警,不阻断(避免环境耦合)。 if [[ "${NO_BUILD}" == "0" && "${SKIP_CHECK}" == "0" ]]; then if command -v go >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then log "执行部署前代码门禁 (scripts/check.sh)..." if ! bash "${ROOT_DIR}/scripts/check.sh"; then log_error "代码门禁未通过,已中止部署。确认修复后重试,或用 --skip-check 强制跳过。" exit 1 fi else log_warn "本地缺少 go 或 npm,跳过代码门禁。建议在开发机执行 ./scripts/check.sh 后再部署。" fi fi if [[ "${NO_BUILD}" == "1" ]]; then log "启动生产服务..." compose up -d else log "清理旧镜像以释放空间..." docker image prune -f 2>/dev/null || true log "构建后端镜像..." compose build backend log "构建 Caddy 镜像(含主站 + 选号网静态资源)..." compose build caddy log "启动生产服务..." compose up -d --no-build --remove-orphans fi wait_service_healthy mysql "MySQL" wait_service_healthy redis "Redis" wait_service_healthy minio "MinIO" wait_service_healthy caddy "Caddy" reset_database run_migrations log "重启后端以加载最新数据库状态..." compose up -d --no-deps --force-recreate backend wait_service_healthy backend "后端" compose up -d --no-deps --force-recreate caddy wait_service_healthy caddy "Caddy" wait_http_ready if [[ "${NO_CLEAN}" != "1" ]]; then docker_cleanup fi log_success "部署完成:$(public_url)" if [[ -n "$(env_value CADDY_SHOW_DOMAIN || true)" ]]; then log_success "选号网:https://$(env_value CADDY_SHOW_DOMAIN)" fi log "常用命令:$(compose_user_cmd) logs -f caddy backend" if [[ "${FOLLOW_LOGS}" == "1" ]]; then compose logs -f backend fi } main "$@"