diff --git a/deploy/README.md b/deploy/README.md index 7a3ed8f..43e9db0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -29,13 +29,33 @@ STORAGE_SECRET_ACCESS_KEY 如果正式对外运营,还需要配置真实短信和实名服务,避免继续使用测试配置。 -## 2. 启动服务 +## 2. 一键部署 + +```bash +./scripts/deploy-prod.sh +``` + +脚本会自动完成: + +- 检查 `backend/.env` 是否仍指向 `127.0.0.1`、`localhost` 或占位值。 +- 构建并启动生产容器。 +- 等待 MySQL、Redis、MinIO 就绪。 +- 按顺序执行尚未应用的数据库迁移。 +- 重启后端并检查 `http://127.0.0.1:7890/api/health`。 + +如需部署完成后直接跟随查看后端日志: + +```bash +./scripts/deploy-prod.sh --logs +``` + +## 3. 手动启动服务 ```bash docker compose -f deploy/docker-compose.prod.yml up -d --build ``` -## 3. 首次初始化数据库 +## 4. 手动初始化数据库 将命令中的 `change-hfb-password` 替换为 `backend/.env` 里的 `MYSQL_PASSWORD`。 @@ -46,7 +66,7 @@ for file in backend/migrations/*.sql; do done ``` -## 4. 验证 +## 5. 验证 ```bash curl http://127.0.0.1:7890/health diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh new file mode 100755 index 0000000..1faf991 --- /dev/null +++ b/scripts/deploy-prod.sh @@ -0,0 +1,297 @@ +#!/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" +MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations" +HEALTH_URL="${PROD_HEALTH_URL:-http://127.0.0.1:7890/api/health}" +PUBLIC_URL="${PROD_PUBLIC_URL:-http://hfb.221329.cc.cd:7890}" +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 不执行数据库迁移 + --logs 部署完成后跟随查看后端日志 + -h, --help 显示帮助信息 + +环境变量: + PROD_HEALTH_URL=http://127.0.0.1:7890/api/health + 部署后健康检查地址 + PROD_PUBLIC_URL=http://hfb.221329.cc.cd:7890 + 输出给用户看的访问地址 + PROD_READY_TIMEOUT=120 + 等待服务就绪秒数 + +示例: + ./scripts/deploy-prod.sh + ./scripts/deploy-prod.sh --no-build + ./scripts/deploy-prod.sh --logs +EOF +} + +NO_BUILD=0 +NO_MIGRATE=0 +FOLLOW_LOGS=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-build) + NO_BUILD=1 + shift + ;; + --no-migrate) + NO_MIGRATE=1 + shift + ;; + --logs) + FOLLOW_LOGS=1 + shift + ;; + -h|--help) + show_help + exit 0 + ;; + *) + log_error "未知选项:$1" + show_help + exit 1 + ;; + esac +done + +validate_env() { + if [[ ! -f "${BACKEND_ENV}" ]]; then + log_error "未找到 backend/.env,请先执行:cp backend/.env.prod.example backend/.env 并填写生产配置" + exit 1 + fi + + local app_env mysql_dsn redis_addr storage_endpoint + app_env="$(require_env APP_ENV)" + 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_USER >/dev/null + require_env MYSQL_PASSWORD >/dev/null + require_env JWT_SECRET >/dev/null + require_env MINIO_ROOT_USER >/dev/null + require_env MINIO_ROOT_PASSWORD >/dev/null + require_env STORAGE_ACCESS_KEY_ID >/dev/null + require_env STORAGE_SECRET_ACCESS_KEY >/dev/null + + if [[ "${app_env}" != "production" ]]; then + log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production" + 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 grep -Eq '=(change-|保持你的|你的)' "${BACKEND_ENV}"; then + log_error "backend/.env 里还有占位值,请先替换后再部署" + exit 1 + fi +} + +compose() { + docker compose -f "${COMPOSE_FILE}" "$@" +} + +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} 启动超时,请执行:docker compose -f deploy/docker-compose.prod.yml logs ${service}" + exit 1 +} + +mysql_exec() { + local password + password="$(require_env MYSQL_PASSWORD)" + compose exec -T -e MYSQL_PWD="${password}" mysql mysql -u"$(require_env MYSQL_USER)" "$@" +} + +mysql_scalar() { + mysql_exec -N -s "$(require_env MYSQL_DATABASE)" -e "$1" +} + +ensure_migration_table() { + mysql_exec "$(require_env MYSQL_DATABASE)" <<'SQL' +CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +SQL +} + +bootstrap_existing_schema_history() { + local migration_count users_exists system_configs_exists + migration_count="$(mysql_scalar "SELECT COUNT(*) FROM schema_migrations;")" + users_exists="$(mysql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'users';")" + system_configs_exists="$(mysql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'system_configs';")" + + if [[ "${migration_count}" == "0" && "${users_exists}" != "0" && "${system_configs_exists}" != "0" ]]; then + log_warn "检测到已有表结构但没有迁移记录,将当前 migration 文件标记为已应用" + local file version + for file in "${MIGRATIONS_DIR}"/*.sql; do + version="$(basename "${file}" .sql)" + mysql_exec "$(require_env MYSQL_DATABASE)" -e \ + "INSERT IGNORE INTO schema_migrations (version) VALUES ('${version}');" + done + fi +} + +run_migrations() { + if [[ "${NO_MIGRATE}" == "1" ]]; then + log_warn "已跳过数据库迁移" + return 0 + fi + + log "执行数据库迁移..." + ensure_migration_table + bootstrap_existing_schema_history + + local file version applied + shopt -s nullglob + for file in "${MIGRATIONS_DIR}"/*.sql; do + version="$(basename "${file}" .sql)" + applied="$(mysql_scalar "SELECT COUNT(*) FROM schema_migrations WHERE version = '${version}';")" + if [[ "${applied}" != "0" ]]; then + log "跳过已应用迁移:${version}" + continue + fi + + log "导入迁移:${version}" + mysql_exec "$(require_env MYSQL_DATABASE)" < "${file}" + mysql_exec "$(require_env MYSQL_DATABASE)" -e \ + "INSERT INTO schema_migrations (version) VALUES ('${version}');" + done + shopt -u nullglob + + log_success "数据库迁移完成" +} + +wait_http_ready() { + local attempt + log "等待接口健康检查通过..." + for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do + if curl -fsS --max-time 3 "${HEALTH_URL}" >/dev/null 2>&1; then + log_success "健康检查通过:${HEALTH_URL}" + return 0 + fi + sleep 1 + done + + log_error "健康检查超时:${HEALTH_URL}" + exit 1 +} + +main() { + cd "${ROOT_DIR}" + need_cmd docker + need_cmd curl + validate_env + + log "检查 Docker Compose 配置..." + compose config >/dev/null + + if [[ "${NO_BUILD}" == "1" ]]; then + log "启动生产服务..." + compose up -d + else + log "构建并启动生产服务..." + compose up -d --build + fi + + wait_service_healthy mysql "MySQL" + wait_service_healthy redis "Redis" + wait_service_healthy minio "MinIO" + run_migrations + + log "重启后端以加载最新数据库状态..." + compose up -d --no-deps --force-recreate backend + wait_service_healthy backend "后端" + wait_http_ready + + log_success "部署完成:${PUBLIC_URL}" + log "常用命令:docker compose -f deploy/docker-compose.prod.yml logs -f backend" + + if [[ "${FOLLOW_LOGS}" == "1" ]]; then + compose logs -f backend + fi +} + +main "$@"