#!/usr/bin/env bash set -Eeuo pipefail umask 027 PROJECT_DIR="${PROJECT_DIR:-/www/wwwroot/Hulumath-Web}" PYTHON_BIN="${PYTHON_BIN:-/www/server/pyporject_evn/versions/3.12.13/bin/python3}" VENV_DIR="${VENV_DIR:-$PROJECT_DIR/.venv-production}" ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env.production}" SERVICE_NAME="${SERVICE_NAME:-hulumath-web}" BACKUP_DIR="${BACKUP_DIR:-/www/backup/hulumath}" MYSQLDUMP_BIN="${MYSQLDUMP_BIN:-}" MYSQL_CONTAINER="${MYSQL_CONTAINER:-}" PREVIOUS_REVISION="${PREVIOUS_REVISION:-}" HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}" SMOKE_BASE_URL="${DEPLOY_SMOKE_BASE_URL:-}" SMOKE_WS_URL="${DEPLOY_SMOKE_WS_URL:-}" ROLLBACK_ENABLED=0 MYSQL_CREDENTIALS_FILE="" MYSQL_CONTAINER_CREDENTIALS_PATH="" log() { printf '\n[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" } fail() { printf '\n[ERROR] %s\n' "$*" >&2 if [ "$ROLLBACK_ENABLED" -eq 1 ]; then rollback_application 1 fi exit 1 } if [ "$(id -u)" -eq 0 ]; then SUDO="" elif sudo -n true 2>/dev/null; then SUDO="sudo" else fail "部署用户必须是 root,或具备免密 sudo 权限" fi DEPLOY_OWNER="$(id -un):$(id -gn)" rollback_application() { local exit_code="${1:-$?}" trap - ERR if [ -n "$MYSQL_CONTAINER_CREDENTIALS_PATH" ] && [ -n "$MYSQL_CONTAINER" ]; then docker exec "$MYSQL_CONTAINER" rm -f "$MYSQL_CONTAINER_CREDENTIALS_PATH" \ >/dev/null 2>&1 || true fi if [ -n "$MYSQL_CREDENTIALS_FILE" ]; then rm -f "$MYSQL_CREDENTIALS_FILE" fi log "部署失败,开始应用代码回退" if [ -z "$PREVIOUS_REVISION" ]; then log "未提供 PREVIOUS_REVISION,跳过自动回退" exit "$exit_code" fi if ! git cat-file -e "${PREVIOUS_REVISION}^{commit}" 2>/dev/null; then log "上一版本 $PREVIOUS_REVISION 不存在,跳过自动回退" exit "$exit_code" fi git reset --hard "$PREVIOUS_REVISION" if [ ! -f "$PROJECT_DIR/backend/manage.py" ]; then log "上一版本不是 Django 版本,无法自动恢复;数据库备份仍保留在 $BACKUP_DIR" exit "$exit_code" fi $SUDO chown -R "$DEPLOY_OWNER" "$VENV_DIR" if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then $SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles" fi "$VENV_DIR/bin/pip" install --disable-pip-version-check -r "$PROJECT_DIR/requirements.txt" ( set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a cd "$PROJECT_DIR/backend" "$VENV_DIR/bin/python" manage.py collectstatic --noinput ) $SUDO chown -R www:www "$PROJECT_DIR/backend/staticfiles" $SUDO chown -R www:www "$VENV_DIR" $SUDO systemctl restart "$SERVICE_NAME" log "应用代码已回退到 $PREVIOUS_REVISION;数据库未执行反向迁移" exit "$exit_code" } trap rollback_application ERR ROLLBACK_ENABLED=1 cd "$PROJECT_DIR" [ -d .git ] || fail "$PROJECT_DIR 不是 Git 仓库" [ -f requirements.txt ] || fail "缺少 requirements.txt" [ -f backend/manage.py ] || fail "缺少 backend/manage.py" [ -f "$ENV_FILE" ] || fail "缺少 $ENV_FILE,请先按 .env.production.example 配置" if [ ! -x "$PYTHON_BIN" ] && [ -x "${PYTHON_BIN%3}" ]; then PYTHON_BIN="${PYTHON_BIN%3}" fi [ -x "$PYTHON_BIN" ] || fail "Python 不存在或不可执行: $PYTHON_BIN" set -a # .env.production 必须使用 KEY=value 格式;包含特殊字符的值请用单引号包裹。 # shellcheck disable=SC1090 source "$ENV_FILE" set +a if [ -z "$HEALTH_HOST" ]; then HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}" fi [ "${DJANGO_DEBUG:-}" = "false" ] || fail "生产环境必须设置 DJANGO_DEBUG=false" [[ "${DATABASE_URL:-}" == mysql://* ]] \ || fail "DATABASE_URL 必须是 MySQL 地址" [[ "${REDIS_URL:-}" == redis://* || "${REDIS_URL:-}" == rediss://* ]] \ || fail "REDIS_URL 必须是 Redis 地址" [ -n "${DJANGO_SECRET_KEY:-}" ] || fail "缺少 DJANGO_SECRET_KEY" [ -n "${DJANGO_ALLOWED_HOSTS:-}" ] || fail "缺少 DJANGO_ALLOWED_HOSTS" if [ -z "$HEALTH_HOST" ]; then HEALTH_HOST="${DJANGO_ALLOWED_HOSTS%%,*}" fi log "创建或更新生产虚拟环境" if [ ! -x "$VENV_DIR/bin/python" ]; then "$PYTHON_BIN" -m venv "$VENV_DIR" fi $SUDO chown -R "$DEPLOY_OWNER" "$VENV_DIR" "$VENV_DIR/bin/pip" install --disable-pip-version-check --upgrade pip "$VENV_DIR/bin/pip" install --disable-pip-version-check -r requirements.txt log "备份 MySQL" if [ -n "$MYSQL_CONTAINER" ]; then command -v docker >/dev/null 2>&1 || fail "配置了 MYSQL_CONTAINER,但服务器没有 docker 命令" docker inspect "$MYSQL_CONTAINER" >/dev/null 2>&1 \ || fail "MySQL 容器不存在: $MYSQL_CONTAINER" docker exec "$MYSQL_CONTAINER" mysqldump --version \ | grep -Eq '(Ver|Distrib)[[:space:]]+8\.' \ || fail "容器内 mysqldump 不是 MySQL 8: $MYSQL_CONTAINER" else if [ -z "$MYSQLDUMP_BIN" ]; then MYSQLDUMP_BIN="$(command -v mysqldump || true)" fi if [ -z "$MYSQLDUMP_BIN" ]; then for candidate in \ /www/server/mysql/bin/mysqldump \ /usr/bin/mysqldump; do if [ -x "$candidate" ]; then MYSQLDUMP_BIN="$candidate" break fi done fi [ -x "$MYSQLDUMP_BIN" ] \ || fail "未找到 mysqldump;Docker MySQL 请设置 MYSQL_CONTAINER" "$MYSQLDUMP_BIN" --version | grep -Eq '(Ver|Distrib)[[:space:]]+8\.' \ || fail "宿主机 mysqldump 不是 MySQL 8;请设置 MYSQL_CONTAINER 使用容器内客户端" fi $SUDO mkdir -p "$BACKUP_DIR" $SUDO chown "$(id -un):$(id -gn)" "$BACKUP_DIR" BACKUP_FILE="$BACKUP_DIR/hulumath_$(date '+%Y%m%d_%H%M%S').sql.gz" MYSQL_CREDENTIALS_FILE="$(mktemp)" chmod 600 "$MYSQL_CREDENTIALS_FILE" MYSQL_DATABASE_NAME="$("$VENV_DIR/bin/python" - "$MYSQL_CREDENTIALS_FILE" <<'PY' import os import sys from urllib.parse import unquote, urlparse parsed = urlparse(os.environ["DATABASE_URL"]) database = unquote(parsed.path.lstrip("/")) values = { "host": parsed.hostname or "127.0.0.1", "port": str(parsed.port or 3306), "user": unquote(parsed.username or ""), "password": unquote(parsed.password or ""), "database": database, } if not values["user"] or not database: raise SystemExit("DATABASE_URL 缺少用户名或数据库名") if any("\n" in value or "\r" in value for value in values.values()): raise SystemExit("DATABASE_URL 包含非法换行") def quote(value): return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' with open(sys.argv[1], "w", encoding="utf-8") as target: target.write("[client]\n") if not os.environ.get("MYSQL_CONTAINER"): target.write(f"host={quote(values['host'])}\n") target.write(f"port={values['port']}\n") target.write(f"user={quote(values['user'])}\n") target.write(f"password={quote(values['password'])}\n") target.write("default-character-set=utf8mb4\n") print(database) PY )" if [ -n "$MYSQL_CONTAINER" ]; then MYSQL_CONTAINER_CREDENTIALS_PATH="/tmp/hulumath-dump-$$.cnf" docker cp "$MYSQL_CREDENTIALS_FILE" \ "$MYSQL_CONTAINER:$MYSQL_CONTAINER_CREDENTIALS_PATH" docker exec "$MYSQL_CONTAINER" mysqldump \ --defaults-extra-file="$MYSQL_CONTAINER_CREDENTIALS_PATH" \ --single-transaction \ --quick \ --triggers \ --hex-blob \ --no-tablespaces \ --set-gtid-purged=OFF \ "$MYSQL_DATABASE_NAME" | gzip -c > "$BACKUP_FILE" docker exec "$MYSQL_CONTAINER" rm -f "$MYSQL_CONTAINER_CREDENTIALS_PATH" MYSQL_CONTAINER_CREDENTIALS_PATH="" else "$MYSQLDUMP_BIN" \ --defaults-extra-file="$MYSQL_CREDENTIALS_FILE" \ --single-transaction \ --quick \ --triggers \ --hex-blob \ --no-tablespaces \ --set-gtid-purged=OFF \ "$MYSQL_DATABASE_NAME" | gzip -c > "$BACKUP_FILE" fi rm -f "$MYSQL_CREDENTIALS_FILE" MYSQL_CREDENTIALS_FILE="" test -s "$BACKUP_FILE" || fail "数据库备份为空: $BACKUP_FILE" find "$BACKUP_DIR" -type f -name 'hulumath_*.sql.gz' -mtime +14 -delete log "数据库备份完成: $BACKUP_FILE" log "执行 Django 发布检查、静态资源收集和迁移" if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then $SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles" fi cd "$PROJECT_DIR/backend" if [ "${DJANGO_USE_HTTPS:-true}" = "true" ]; then "$VENV_DIR/bin/python" manage.py check --deploy --fail-level WARNING --database default else log "临时 HTTP 模式:部署安全检查只阻断 ERROR;绑定域名后应启用 HTTPS" "$VENV_DIR/bin/python" manage.py check --deploy --fail-level ERROR --database default fi "$VENV_DIR/bin/python" manage.py check_mysql "$VENV_DIR/bin/python" manage.py collectstatic --noinput "$VENV_DIR/bin/python" manage.py migrate --noinput cd "$PROJECT_DIR" log "安装或更新 systemd 服务" $SUDO install -m 0644 deploy/hulumath-web.service /etc/systemd/system/hulumath-web.service $SUDO systemctl daemon-reload $SUDO systemctl enable "$SERVICE_NAME" if ! $SUDO systemctl is-active --quiet "$SERVICE_NAME"; then if command -v ss >/dev/null 2>&1 && ss -ltn | grep -q ':8000 '; then fail "8000 端口仍被旧 Flask/Gunicorn 占用。首次切换需先停止宝塔中的旧 Flask 进程" fi fi log "修正运行目录权限并重启 ASGI 服务" $SUDO chown -R www:www "$PROJECT_DIR/backend/staticfiles" $SUDO chown -R www:www "$VENV_DIR" $SUDO systemctl restart "$SERVICE_NAME" $SUDO systemctl is-active --quiet "$SERVICE_NAME" log "检查 HTTP 健康状态" HTTP_OK=0 for _ in $(seq 1 20); do if curl --fail --silent --show-error \ --header "Host: $HEALTH_HOST" \ --header "X-Forwarded-Proto: https" \ http://127.0.0.1:8000/health/ | grep -q '"status": "ok"'; then HTTP_OK=1 break fi sleep 2 done [ "$HTTP_OK" -eq 1 ] || fail "HTTP 健康检查失败" log "检查 WebSocket 与 Redis Channel Layer" "$VENV_DIR/bin/python" - <<'PY' import asyncio import json from websockets.asyncio.client import connect async def check(): async with connect("ws://127.0.0.1:8000/ws/health/", open_timeout=5) as websocket: payload = json.loads(await asyncio.wait_for(websocket.recv(), timeout=5)) if payload != {"status": "ok", "channel_layer": "ok"}: raise RuntimeError(f"unexpected WebSocket health payload: {payload}") asyncio.run(check()) PY if [ -n "$SMOKE_BASE_URL" ] || [ -n "$SMOKE_WS_URL" ]; then [ -n "$SMOKE_BASE_URL" ] && [ -n "$SMOKE_WS_URL" ] \ || fail "DEPLOY_SMOKE_BASE_URL 和 DEPLOY_SMOKE_WS_URL 必须同时设置" [ -f "$PROJECT_DIR/scripts/smoke_production.py" ] \ || fail "缺少生产冒烟脚本: scripts/smoke_production.py" log "检查 Nginx、业务页面与公网 WebSocket" "$VENV_DIR/bin/python" "$PROJECT_DIR/scripts/smoke_production.py" \ --base-url "$SMOKE_BASE_URL" \ --ws-url "$SMOKE_WS_URL" fi trap - ERR ROLLBACK_ENABLED=0 log "部署完成,当前版本: $(git rev-parse --short HEAD)" log "备份文件: $BACKUP_FILE"