651 lines
22 KiB
Bash
Executable File
651 lines
22 KiB
Bash
Executable File
#!/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 "$@"
|