#!/usr/bin/env bash set -Eeuo pipefail # Mac 本机 Docker 开发环境启动脚本。 # 用法: # bash deploy/mac-dev.sh # bash deploy/mac-dev.sh --reset-db PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="${PROJECT_ROOT}/.env" ENV_EXAMPLE="${PROJECT_ROOT}/.env.mac-docker.example" COMPOSE_FILE="${PROJECT_ROOT}/docker-compose.dev.yml" BACKEND_DATA_DIR="${PROJECT_ROOT}/apps/backend/data" READY_TIMEOUT="${READY_TIMEOUT:-120}" HEALTH_URL="${DEV_HEALTH_URL:-http://localhost/health/ready}" RESET_DB=false RESET_DB_CONFIRM="${RESET_DB_CONFIRM:-}" COMPOSE_CMD=(docker compose -f "${COMPOSE_FILE}") log() { printf '\033[1;34m[mac-dev]\033[0m %s\n' "$*" } warn() { printf '\033[1;33m[mac-dev]\033[0m %s\n' "$*" } fail() { printf '\033[1;31m[mac-dev]\033[0m %s\n' "$*" >&2 exit 1 } print_usage() { cat <<'EOF' 用法: bash deploy/mac-dev.sh bash deploy/mac-dev.sh --reset-db 参数: --reset-db 删除 dev Compose PostgreSQL 数据卷 postgres_dev_data,并用当前迁移重新初始化数据库。 --yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。 -h, --help 显示帮助。 环境变量: READY_TIMEOUT 等待容器和健康检查的秒数,默认 120。 DEV_HEALTH_URL 本机健康检查地址,默认 http://localhost/health/ready。 注意: --reset-db 会删除本机开发库里的订单、任务、后台用户、审计日志和迁移记录。 它不会删除 apps/backend/data 下的平台 JSON 配置、MinIO 文件数据和 node_modules 缓存 volume。 EOF } parse_args() { while (($# > 0)); do case "$1" in --reset-db) RESET_DB=true ;; --yes) RESET_DB_CONFIRM=1 ;; -h|--help) print_usage exit 0 ;; *) fail "未知参数:$1。执行 bash deploy/mac-dev.sh --help 查看用法。" ;; esac shift done } read_env_value() { local key="$1" awk -F= -v key="${key}" ' $0 !~ /^[[:space:]]*#/ && $1 == key { value = substr($0, index($0, "=") + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) gsub(/^"|"$/, "", value) gsub(/^'\''|'\''$/, "", value) print value exit } ' "${ENV_FILE}" } read_effective_env_value() { local key="$1" local default_value="$2" local value="${!key:-}" if [[ -z "${value}" ]]; then value="$(read_env_value "${key}" || true)" fi printf '%s\n' "${value:-${default_value}}" } ensure_docker() { command -v docker >/dev/null 2>&1 || fail "未找到 docker,请先安装并启动 Docker Desktop for Mac。" docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件,请升级 Docker Desktop。" command -v curl >/dev/null 2>&1 || fail "未找到 curl,请先安装 curl。" } ensure_env_file() { if [[ -f "${ENV_FILE}" ]]; then return fi [[ -f "${ENV_EXAMPLE}" ]] || fail "未找到本机开发环境模板:${ENV_EXAMPLE}" cp "${ENV_EXAMPLE}" "${ENV_FILE}" log "未找到 .env,已从 .env.mac-docker.example 生成本机开发配置。" } validate_env_file() { [[ "${READY_TIMEOUT}" =~ ^[1-9][0-9]*$ ]] || fail "READY_TIMEOUT 必须是正整数秒数,当前值:${READY_TIMEOUT}" validate_database_url_for_dev_compose } validate_database_url_for_dev_compose() { local database_url database_url="$(read_env_value DATABASE_URL)" if [[ -z "${database_url}" ]]; then return fi if [[ "${database_url}" == *"@localhost:"* \ || "${database_url}" == *"@127.0.0.1:"* \ || "${database_url}" == *"@[::1]:"* ]]; then fail ".env 的 DATABASE_URL 当前指向本机地址。Docker dev 后端容器内的 localhost 不是 postgres 容器,请改为 postgres 服务名。" fi } validate_reset_database_target() { local database_url database_url="$(read_env_value DATABASE_URL)" if [[ -n "${database_url}" && "${database_url}" != *"@postgres:"* ]]; then fail "--reset-db 只支持重置本脚本管理的 dev Compose PostgreSQL。当前 DATABASE_URL 未指向 postgres 服务,请手动确认外部数据库后再处理。" fi } confirm_reset_database() { if [[ "${RESET_DB}" != "true" ]]; then return fi validate_reset_database_target warn "即将重置 Mac 本机开发 PostgreSQL 数据库:会删除订单、任务、后台用户、审计日志和所有迁移记录。" warn "不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 node_modules 缓存 volume。" if [[ "${RESET_DB_CONFIRM}" == "1" ]]; then warn "检测到 RESET_DB_CONFIRM=1 或 --yes,跳过交互确认。" return fi if [[ ! -t 0 ]]; then fail "当前不是交互式终端。若确认要重置数据库,请执行:RESET_DB_CONFIRM=1 bash deploy/mac-dev.sh --reset-db" fi local answer printf '请输入 RESET 确认重置数据库:' read -r answer [[ "${answer}" == "RESET" ]] || fail "未确认重置数据库,已停止。" } ensure_data_dir() { mkdir -p "${BACKEND_DATA_DIR}" } check_compose_config() { log "检查 Docker Compose dev 配置" "${COMPOSE_CMD[@]}" config >/dev/null || fail "Docker Compose dev 配置校验失败。" } resolve_compose_project_name() { local project_name project_name="$(read_env_value COMPOSE_PROJECT_NAME || true)" if [[ -z "${project_name}" ]]; then project_name="$(basename "${PROJECT_ROOT}")" fi printf '%s\n' "${project_name}" } resolve_postgres_volume_name() { local container_id volume_name project_name expected_volume container_id="$("${COMPOSE_CMD[@]}" ps -aq postgres 2>/dev/null | head -n 1 || true)" if [[ -n "${container_id}" ]]; then volume_name="$( docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}{{end}}' "${container_id}" 2>/dev/null || true )" if [[ -n "${volume_name}" ]]; then printf '%s\n' "${volume_name}" return fi fi project_name="$(resolve_compose_project_name)" expected_volume="${project_name}_postgres_dev_data" if docker volume inspect "${expected_volume}" >/dev/null 2>&1; then printf '%s\n' "${expected_volume}" return fi volume_name="$( docker volume ls --format '{{.Name}}' \ | awk '$0 ~ /(^|_)postgres_dev_data$/ { print; exit }' || true )" if [[ -n "${volume_name}" ]]; then printf '%s\n' "${volume_name}" return fi printf '%s\n' "${expected_volume}" } reset_database() { if [[ "${RESET_DB}" != "true" ]]; then return fi local volume_name volume_name="$(resolve_postgres_volume_name)" warn "开始重置本机开发数据库,将删除 Docker volume:${volume_name}" "${COMPOSE_CMD[@]}" stop web frontend backend postgres >/dev/null 2>&1 || true "${COMPOSE_CMD[@]}" rm -f -s postgres >/dev/null 2>&1 || true if docker volume inspect "${volume_name}" >/dev/null 2>&1; then docker volume rm "${volume_name}" >/dev/null || fail "删除 PostgreSQL 开发数据卷失败:${volume_name}" log "已删除 PostgreSQL 开发数据卷:${volume_name}" return fi warn "未找到 PostgreSQL 开发数据卷:${volume_name},将按空库继续启动。" } wait_for_service() { local service="$1" local label="$2" local attempt container_id status health_status log "等待 ${label} 就绪" for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do container_id="$("${COMPOSE_CMD[@]}" ps -q "${service}" 2>/dev/null || true)" if [[ -n "${container_id}" ]]; then status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)" health_status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "${container_id}" 2>/dev/null || true)" if [[ -n "${health_status}" && "${health_status}" == "healthy" ]]; then log "${label} 已就绪" return fi if [[ -z "${health_status}" && "${status}" == "running" ]]; then log "${label} 已启动" return fi if [[ "${status}" == "exited" || "${status}" == "dead" ]]; then break fi fi sleep 1 done print_diagnostics fail "${label} 启动超时,请根据上面的日志排查。" } start_dev_stack() { log "启动 Mac 本机 Docker 开发环境" if "${COMPOSE_CMD[@]}" up -d --build; then wait_for_service postgres "PostgreSQL" wait_for_service minio "MinIO" wait_for_service backend "后端" wait_for_service frontend "前端" wait_for_service web "Web 入口" return fi print_diagnostics fail "Mac 本机 Docker 开发环境启动失败,请根据上面的日志排查。" } run_database_migrations() { log "执行数据库迁移" if "${COMPOSE_CMD[@]}" exec -T backend npm run db:migrate; then log "数据库迁移完成" return fi print_diagnostics fail "数据库迁移失败,请根据上面的日志排查。" } wait_for_health() { log "等待开发入口健康检查:${HEALTH_URL}" for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do if curl -fsS "${HEALTH_URL}" >/dev/null 2>&1; then log "开发环境已就绪" return fi sleep 1 done print_diagnostics fail "开发环境已启动但健康检查未通过,请根据日志排查。" } print_applied_migrations() { log "已应用数据库迁移" local sql migrations sql='SELECT COALESCE(filename, name) FROM schema_migrations ORDER BY COALESCE(filename, name);' if migrations="$("${COMPOSE_CMD[@]}" exec -T postgres sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "$1"' sh "${sql}" 2>&1)"; then printf '%s\n' "${migrations:-"(无迁移记录)"}" return fi warn "读取迁移记录失败:" printf '%s\n' "${migrations}" } print_diagnostics() { warn "当前 dev 容器状态:" "${COMPOSE_CMD[@]}" ps || true warn "后端最近日志:" local backend_logs backend_logs="$("${COMPOSE_CMD[@]}" logs --tail=120 backend 2>&1 || true)" printf '%s\n' "${backend_logs}" if printf '%s\n' "${backend_logs}" | grep -q 'password authentication failed for user "postgres"'; then warn "检测到 PostgreSQL 密码认证失败。通常是 postgres_dev_data 已用旧密码初始化,而 .env 改成了新密码。" warn "无本地数据保留需求时可执行:RESET_DB_CONFIRM=1 bash deploy/mac-dev.sh --reset-db" fi warn "前端最近日志:" "${COMPOSE_CMD[@]}" logs --tail=80 frontend || true warn "MinIO 最近日志:" "${COMPOSE_CMD[@]}" logs --tail=80 minio || true warn "后端 ready 接口:" "${COMPOSE_CMD[@]}" exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true print_applied_migrations || true } print_endpoints() { local minio_console_port minio_console_port="$(read_effective_env_value MINIO_CONSOLE_PORT 19001)" log "常用入口" printf '%s\n' " 前端、后台、领取页:http://localhost" printf '%s\n' " 后端健康检查:http://localhost/health" printf '%s\n' " 后端 ready 检查:http://localhost/health/ready" printf '%s\n' " MinIO 控制台:http://localhost:${minio_console_port}" } main() { parse_args "$@" cd "${PROJECT_ROOT}" ensure_docker ensure_env_file validate_env_file confirm_reset_database ensure_data_dir check_compose_config reset_database start_dev_stack run_database_migrations wait_for_health print_applied_migrations log "Mac 本机开发环境启动完成" "${COMPOSE_CMD[@]}" ps print_endpoints } main "$@"