#!/usr/bin/env bash DEPLOY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$DEPLOY_LIB_DIR/.." && pwd)" COMPOSE_FILE="$ROOT/docker-compose.prod.yml" ENV_FILE="$ROOT/.env.docker" ENV_EXAMPLE_FILE="$ROOT/.env.docker.example" BACKUP_DIR="$ROOT/backups" DEPLOY_STATE_DIR="$ROOT/.deploy" RELEASES_DIR="$DEPLOY_STATE_DIR/releases" CURRENT_RELEASE_FILE="$DEPLOY_STATE_DIR/current-release.env" PREVIOUS_RELEASE_FILE="$DEPLOY_STATE_DIR/previous-release.env" DEPLOY_IMAGE_TAG="${DEPLOY_IMAGE_TAG:-}" LAST_DB_BACKUP="" LAST_UPLOADS_BACKUP="" log() { printf '%s\n' "[$(basename "$0")] $*" } warn() { printf '%s\n' "警告: $*" >&2 } die() { printf '%s\n' "错误: $*" >&2 exit 1 } require_command() { command -v "$1" >/dev/null 2>&1 || die "未找到命令: $1" } require_docker() { require_command docker docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2,请确认可执行 docker compose" } validate_image_tag() { local tag="$1" printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' || die "镜像 tag 不合法: $tag" } set_deploy_image_tag() { local tag="$1" [ -n "$tag" ] || die "镜像 tag 不能为空" validate_image_tag "$tag" DEPLOY_IMAGE_TAG="$tag" } infer_image_tag_from_tar() { local image_tar="$1" local base tag base="$(basename "$image_tar")" tag="${base#thebet365-images-}" tag="${tag%.tar}" if [ "$tag" != "$base" ] && [ -n "$tag" ]; then printf '%s' "$tag" fi } compose() { local tag tag="$(current_image_tag)" IMAGE_TAG="$tag" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@" } ensure_env_file() { if [ -f "$ENV_FILE" ]; then return 0 fi if [ ! -f "$ENV_EXAMPLE_FILE" ]; then die "未找到 .env.docker 或 .env.docker.example" fi cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" warn "已从 .env.docker.example 创建 .env.docker" warn "请先修改 POSTGRES_PASSWORD、JWT_SECRET、短信配置和端口后再重新执行部署脚本" return 1 } env_value() { local key="$1" local line line="$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" | tail -n 1 || true)" line="${line#*=}" line="${line%$'\r'}" line="${line%\"}" line="${line#\"}" line="${line%\'}" line="${line#\'}" printf '%s' "$line" } set_env_value() { local key="$1" local value="$2" local tmp tmp="$(mktemp)" if grep -Eq "^[[:space:]]*${key}=" "$ENV_FILE"; then awk -v key="$key" -v value="$value" ' BEGIN { done = 0 } $0 ~ "^[[:space:]]*" key "=" { if (!done) { print key "=" value done = 1 } next } { print } END { if (!done) print key "=" value } ' "$ENV_FILE" > "$tmp" else cp "$ENV_FILE" "$tmp" printf '\n%s=%s\n' "$key" "$value" >> "$tmp" fi mv "$tmp" "$ENV_FILE" } current_image_tag() { local tag="${DEPLOY_IMAGE_TAG:-}" if [ -z "$tag" ] && [ -f "$ENV_FILE" ]; then tag="$(env_value IMAGE_TAG)" fi tag="${tag:-latest}" validate_image_tag "$tag" printf '%s' "$tag" } persist_image_tag() { local tag tag="$(current_image_tag)" set_env_value IMAGE_TAG "$tag" log "当前发布镜像 tag: $tag" } validate_prod_env() { local allow_defaults="${1:-false}" local postgres_password jwt_secret seed_database bind_addr postgres_password="$(env_value POSTGRES_PASSWORD)" jwt_secret="$(env_value JWT_SECRET)" seed_database="$(env_value SEED_DATABASE)" bind_addr="$(env_value BIND_ADDR)" [ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD" [ -n "$jwt_secret" ] || die ".env.docker 缺少 JWT_SECRET" if [ "$allow_defaults" != "true" ]; then [ "$postgres_password" != "thebet365" ] || die "POSTGRES_PASSWORD 仍是示例值;如确为测试环境,请加 --allow-default-secrets" [ "$jwt_secret" != "change-me-in-production-use-long-random-string" ] || die "JWT_SECRET 仍是示例值;如确为测试环境,请加 --allow-default-secrets" fi if [ "$seed_database" = "true" ]; then warn ".env.docker 中 SEED_DATABASE=true 会让 api 每次启动都执行 seed;生产建议设为 false,首次部署脚本会按需一次性 seed" fi if [ "${bind_addr:-127.0.0.1}" = "0.0.0.0" ]; then warn "BIND_ADDR=0.0.0.0 会让 player/admin 端口直接对外监听;生产建议经宝塔反代访问" fi if [ "$(env_value CHUANGLAN_ACCOUNT)" = "your_account" ] || [ "$(env_value CHUANGLAN_PASSWORD)" = "your_password" ]; then warn "创蓝短信账号仍是示例值,短信验证码功能上线前需要改为真实配置" fi } wait_for_service_health() { local service="$1" local timeout="${2:-120}" local start now container_id status start="$(date +%s)" while true; do 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 return 0 fi fi now="$(date +%s)" if [ $((now - start)) -ge "$timeout" ]; then compose logs --tail=120 "$service" || true die "$service 在 ${timeout}s 内未就绪" fi sleep 2 done } wait_for_stack_ready() { wait_for_service_health api 180 wait_for_service_health player 120 wait_for_service_health admin 120 } start_infra() { log "启动 PostgreSQL / Redis" compose up -d postgres redis wait_for_service_health postgres 120 wait_for_service_health redis 120 } build_app_images() { log "构建 api / player / admin 镜像: $(current_image_tag)" compose build api player admin } load_image_tar() { local image_tar="$1" [ -f "$image_tar" ] || die "镜像包不存在: $image_tar" log "加载镜像包: $image_tar" docker load -i "$image_tar" } require_images_for_current_tag() { local tag tag="$(current_image_tag)" docker image inspect "thebet365-api:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-api:$tag" docker image inspect "thebet365-player:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-player:$tag" docker image inspect "thebet365-admin:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-admin:$tag" } run_prisma_migrations() { log "执行 Prisma 迁移" compose run --rm --no-deps --entrypoint sh api -c 'cd /app/apps/api && npx prisma migrate deploy && npx prisma generate' } show_prisma_status() { log "检查 Prisma 迁移状态" compose exec -T api sh -c 'cd /app/apps/api && npx prisma migrate status' } safe_backup_prefix() { local prefix="${1:-manual}" prefix="$(printf '%s' "$prefix" | tr -c 'A-Za-z0-9_.-' '-')" prefix="${prefix:-manual}" printf '%s' "$prefix" } write_checksum() { local file="$1" local dir base dir="$(dirname "$file")" base="$(basename "$file")" if command -v sha256sum >/dev/null 2>&1; then (cd "$dir" && sha256sum "$base" > "$base.sha256") elif command -v shasum >/dev/null 2>&1; then (cd "$dir" && shasum -a 256 "$base" > "$base.sha256") else warn "未找到 sha256sum 或 shasum,跳过校验文件: $file" return 0 fi log "校验文件完成: $file.sha256" } backup_database() { local prefix="${1:-manual}" local stamp backup_file require_command gzip mkdir -p "$BACKUP_DIR" prefix="$(safe_backup_prefix "$prefix")" stamp="$(date +%Y%m%d-%H%M%S)" backup_file="$BACKUP_DIR/thebet365-db-${prefix}-${stamp}.sql.gz" log "备份 PostgreSQL -> $backup_file" compose exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | gzip -c > "$backup_file" write_checksum "$backup_file" LAST_DB_BACKUP="$backup_file" log "数据库备份完成: $backup_file" } backup_uploads() { local prefix="${1:-manual}" local stamp backup_file mkdir -p "$BACKUP_DIR" prefix="$(safe_backup_prefix "$prefix")" stamp="$(date +%Y%m%d-%H%M%S)" backup_file="$BACKUP_DIR/thebet365-uploads-${prefix}-${stamp}.tar.gz" log "备份 uploads 卷 -> $backup_file" compose exec -T api sh -c 'cd /app && mkdir -p uploads && tar -czf - uploads' > "$backup_file" write_checksum "$backup_file" LAST_UPLOADS_BACKUP="$backup_file" log "uploads 备份完成: $backup_file" } prune_old_backups() { local days days="$(env_value BACKUP_RETENTION_DAYS)" [ -n "$days" ] || return 0 case "$days" in *[!0-9]*) warn "BACKUP_RETENTION_DAYS 不是数字,跳过自动清理: $days" return 0 ;; esac [ "$days" -gt 0 ] || return 0 [ -d "$BACKUP_DIR" ] || return 0 log "清理 ${days} 天前的备份" find "$BACKUP_DIR" -type f \( -name '*.sql.gz' -o -name '*.tar.gz' -o -name '*.sha256' \) -mtime +"$days" -delete } admin_user_count() { compose exec -T postgres psql -U thebet365 -d thebet365 -tAc "select count(*) from users where username = 'admin';" 2>/dev/null | tr -d '[:space:]' } seed_production_if_missing_admin() { local count count="$(admin_user_count || true)" [ -n "$count" ] || die "无法检查 admin 用户,请确认数据库迁移已成功" if [ "$count" = "0" ]; then log "未发现 admin 用户,执行一次生产 seed" compose run --rm --no-deps --entrypoint sh -e SEED_MODE=production -e NODE_ENV=production api -c 'cd /app/apps/api && node dist/infrastructure/database/seed-cli.js' else log "已存在 admin 用户,跳过生产 seed" fi } current_release_tag() { if [ -f "$CURRENT_RELEASE_FILE" ]; then grep -E '^tag=' "$CURRENT_RELEASE_FILE" | tail -n 1 | cut -d= -f2- || true fi } record_release_state() { local source="${1:-deploy}" local tag stamp safe_tag manifest previous_tag tag="$(current_image_tag)" stamp="$(date -u +%Y%m%dT%H%M%SZ)" safe_tag="$(printf '%s' "$tag" | tr -c 'A-Za-z0-9_.-' '-')" manifest="$RELEASES_DIR/${stamp}-${safe_tag}.env" previous_tag="$(current_release_tag)" mkdir -p "$RELEASES_DIR" if [ -f "$CURRENT_RELEASE_FILE" ]; then cp "$CURRENT_RELEASE_FILE" "$PREVIOUS_RELEASE_FILE" fi { printf 'tag=%s\n' "$tag" printf 'previous_tag=%s\n' "$previous_tag" printf 'deployed_at=%s\n' "$stamp" printf 'source=%s\n' "$source" printf 'api_image=thebet365-api:%s\n' "$tag" printf 'player_image=thebet365-player:%s\n' "$tag" printf 'admin_image=thebet365-admin:%s\n' "$tag" [ -n "$LAST_DB_BACKUP" ] && printf 'db_backup=%s\n' "$LAST_DB_BACKUP" [ -n "$LAST_UPLOADS_BACKUP" ] && printf 'uploads_backup=%s\n' "$LAST_UPLOADS_BACKUP" } > "$manifest" cp "$manifest" "$CURRENT_RELEASE_FILE" log "发布状态已记录: $manifest" } print_stack_urls() { local player_port admin_port bind_addr tag player_port="$(env_value PLAYER_PORT)" admin_port="$(env_value ADMIN_PORT)" bind_addr="$(env_value BIND_ADDR)" tag="$(current_image_tag)" player_port="${player_port:-8082}" admin_port="${admin_port:-8081}" bind_addr="${bind_addr:-127.0.0.1}" printf '\n' printf '%s\n' "部署完成:" printf '%s\n' " 镜像 tag: ${tag}" printf '%s\n' " 玩家端: http://${bind_addr}:${player_port}" printf '%s\n' " 管理端: http://${bind_addr}:${admin_port}" printf '%s\n' " API: 仅在 Docker 网络内暴露,由 player/admin 容器和宝塔反代链路访问" printf '%s\n' " 状态: docker compose -f docker-compose.prod.yml --env-file .env.docker ps" printf '%s\n' " 当前发布: .deploy/current-release.env" }