Files
hfb_sys/scripts/deploy-prod.sh
T

651 lines
22 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 backend_uid backend_gid backup_dir backup_status_dir backup_restore_dir backup_oss_uri backup_user
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)"
local mysql_database
mysql_database="$(require_env MYSQL_DATABASE)"
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 STORAGE_ACCESS_KEY_ID >/dev/null
require_env STORAGE_SECRET_ACCESS_KEY >/dev/null
backup_dir="$(require_env BACKUP_DIR)"
backup_status_dir="$(require_env BACKUP_STATUS_DIR)"
backup_restore_dir="$(require_env BACKUP_RESTORE_DIR)"
backup_oss_uri="$(require_env BACKUP_OSS_URI)"
require_env BACKUP_PASSPHRASE >/dev/null
backup_user="$(require_env BACKUP_MYSQL_USER)"
require_env BACKUP_MYSQL_PASSWORD >/dev/null
backend_uid="$(require_env BACKEND_UID)"
backend_gid="$(require_env BACKEND_GID)"
if [[ ! "${backend_uid}" =~ ^[1-9][0-9]*$ || ! "${backend_gid}" =~ ^[1-9][0-9]*$ ]]; then
log_error "BACKEND_UID 和 BACKEND_GID 必须是大于 0 的数字,禁止后端容器使用 root"
exit 1
fi
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 仍指向本机,请改为阿里云 OSS Endpoint(如 https://oss-cn-hangzhou.aliyuncs.com"
exit 1
fi
if [[ "${storage_endpoint}" == *"minio"* ]]; then
log_error "STORAGE_ENDPOINT 仍指向已下线的 MinIO,请改为阿里云 OSS Endpoint"
exit 1
fi
if [[ ! "${storage_endpoint}" =~ ^https://oss-[A-Za-z0-9-]+\.aliyuncs\.com/?$ ]]; then
log_error "STORAGE_ENDPOINT 必须是阿里云 OSS HTTPS Endpoint,当前值:${storage_endpoint}"
exit 1
fi
if [[ "${backup_dir}" != /data/* || "${backup_dir}" == "/data" ]]; then
log_error "BACKUP_DIR 必须是 /data 之下的具体绝对路径,当前值:${backup_dir}"
exit 1
fi
if [[ "${backup_status_dir}" != "${backup_dir}/"* ]]; then
log_error "BACKUP_STATUS_DIR 必须位于 BACKUP_DIR 之下,当前值:${backup_status_dir}"
exit 1
fi
if [[ "${backup_restore_dir}" != /data/* || "${backup_restore_dir}" == "/data" || "${backup_restore_dir}" == "${backup_dir}" ]]; then
log_error "BACKUP_RESTORE_DIR 必须是与 BACKUP_DIR 不同的 /data 子目录,当前值:${backup_restore_dir}"
exit 1
fi
if [[ ! "${backup_oss_uri}" =~ ^oss://[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
log_error "BACKUP_OSS_URI 必须是 oss://bucket 格式,当前值:${backup_oss_uri}"
exit 1
fi
if [[ ! "${backup_user}" =~ ^[A-Za-z0-9_]{1,32}$ ]]; then
log_error "BACKUP_MYSQL_USER 只能包含字母、数字和下划线,长度不超过 32"
exit 1
fi
if [[ ! "${mysql_database}" =~ ^[A-Za-z0-9_]+$ ]]; then
log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${mysql_database}"
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 --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' '<!doctype html><title>show disabled</title>' >"${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() {
local backend_uid backend_gid owner_mismatch
backend_uid="$(require_env BACKEND_UID)"
backend_gid="$(require_env BACKEND_GID)"
mkdir -p "${BACKEND_LOG_DIR}"
owner_mismatch="$(find "${BACKEND_LOG_DIR}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)"
if [[ -n "${owner_mismatch}" ]]; then
if [[ "${EUID}" -ne 0 ]]; then
log_error "日志目录存在属主不匹配的文件:${owner_mismatch}"
log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${BACKEND_LOG_DIR}"
exit 1
fi
log "修正日志目录属主为 ${backend_uid}:${backend_gid}..."
chown -R "${backend_uid}:${backend_gid}" "${BACKEND_LOG_DIR}"
fi
chmod 700 "${BACKEND_LOG_DIR}"
}
prepare_backup_status_dir() {
local backend_uid backend_gid status_dir owner_mismatch
backend_uid="$(require_env BACKEND_UID)"
backend_gid="$(require_env BACKEND_GID)"
status_dir="$(require_env BACKUP_STATUS_DIR)"
mkdir -p "${status_dir}"
owner_mismatch="$(find "${status_dir}" \( ! -uid "${backend_uid}" -o ! -gid "${backend_gid}" \) -print -quit)"
if [[ -n "${owner_mismatch}" ]]; then
if [[ "${EUID}" -ne 0 ]]; then
log_error "备份状态目录存在属主不匹配的文件:${owner_mismatch}"
log_error "请先执行:sudo chown -R ${backend_uid}:${backend_gid} ${status_dir}"
exit 1
fi
chown -R "${backend_uid}:${backend_gid}" "${status_dir}"
fi
chmod 700 "${status_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 "$@"
}
sql_string_literal() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\'/\'\'}"
printf '%s' "${value}"
}
ensure_mysql_backup_user() {
local database backup_user backup_password escaped_password
database="$(require_env MYSQL_DATABASE)"
backup_user="$(require_env BACKUP_MYSQL_USER)"
backup_password="$(require_env BACKUP_MYSQL_PASSWORD)"
escaped_password="$(sql_string_literal "${backup_password}")"
unset backup_password
log "配置 MySQL 在线备份账号 ${backup_user}"
printf "CREATE USER IF NOT EXISTS \`%s\`@'%%' IDENTIFIED BY '%s';\n" "${backup_user}" "${escaped_password}" |
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
printf "ALTER USER \`%s\`@'%%' IDENTIFIED BY '%s';\n" "${backup_user}" "${escaped_password}" |
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
printf "GRANT BACKUP_ADMIN, PROCESS, RELOAD, LOCK TABLES, REPLICATION CLIENT ON *.* TO \`%s\`@'%%';\n" "${backup_user}" |
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
printf "GRANT SELECT ON performance_schema.log_status TO \`%s\`@'%%';\n" "${backup_user}" |
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
# MySQL 8.4 的 XtraBackup 会读取以下只读状态表,即使未启用对应组件也会探测。
printf "GRANT SELECT ON performance_schema.replication_group_members TO \`%s\`@'%%';\n" "${backup_user}" |
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
printf "GRANT SELECT ON performance_schema.keyring_component_status TO \`%s\`@'%%';\n" "${backup_user}" |
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
printf "GRANT SELECT ON \`%s\`.* TO \`%s\`@'%%';\n" "${database}" "${backup_user}" |
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=socket'
}
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 会生成新镜像,旧镜像变为 <none>:<none>
# 构建缓存(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
need_cmd openssl
need_cmd ossutil
need_cmd flock
validate_env
export_caddy_env
prepare_log_dir
prepare_backup_status_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 caddy "Caddy"
ensure_mysql_backup_user
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 "$@"