#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.dev.yml" BACKEND_ENV="${ROOT_DIR}/backend/.env" BACKEND_ENV_EXAMPLE="${ROOT_DIR}/backend/.env.example" FRONTEND_DIR="${ROOT_DIR}/frontend" FRONTEND_PACKAGE_JSON="${FRONTEND_DIR}/package.json" FRONTEND_PACKAGE_LOCK="${FRONTEND_DIR}/package-lock.json" FRONTEND_NODE_MODULES="${FRONTEND_DIR}/node_modules" FRONTEND_NODE_MODULES_LOCK="${FRONTEND_NODE_MODULES}/.package-lock.json" MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations" MYSQL_DATABASE="${DEV_MYSQL_DATABASE:-hfb_sys}" BACKEND_PORT="${DEV_BACKEND_PORT:-8080}" FRONTEND_PORT="${DEV_FRONTEND_PORT:-5173}" BACKEND_HEALTH_URL="${DEV_BACKEND_HEALTH_URL:-http://127.0.0.1:${BACKEND_PORT}/health}" FRONTEND_HEALTH_URL="${DEV_FRONTEND_HEALTH_URL:-http://127.0.0.1:${FRONTEND_PORT}/}" HTTP_READY_TIMEOUT="${DEV_HTTP_READY_TIMEOUT:-120}" AUTO_KILL_PORTS="${DEV_AUTO_KILL_PORTS:-1}" FORCE_NPM_CI="${DEV_FORCE_NPM_CI:-0}" BACKEND_PID="" FRONTEND_PID="" CLEANED_UP=0 # 颜色定义 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[1;34m' NC='\033[0m' log() { printf "${BLUE}[dev]${NC} %s\n" "$*" } log_success() { printf "${GREEN}[dev]${NC} %s\n" "$*" } log_warn() { printf "${YELLOW}[dev]${NC} %s\n" "$*" } log_error() { printf "${RED}[dev]${NC} %s\n" "$*" >&2 } need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then log_error "缺少命令:$1" exit 1 fi } show_help() { cat << EOF 用法: ./scripts/dev.sh [选项] 选项: --reset-db 重置数据库(删除并重建,自动清理旧迁移记录) --seed-only 仅重新同步基础数据 --no-migrate 不执行数据库迁移 --no-frontend 不启动前端 --no-backend 不启动后端 -h, --help 显示帮助信息 环境变量: DEV_RESET_DB=1 等同于 --reset-db DEV_MYSQL_DATABASE=hfb_sys 开发数据库名 DEV_BACKEND_PORT=8080 后端启动端口 DEV_FRONTEND_PORT=5173 前端启动端口 DEV_BACKEND_HEALTH_URL=http://127.0.0.1:8080/health 后端健康检查地址 DEV_FRONTEND_HEALTH_URL=http://127.0.0.1:5173/ 前端健康检查地址 DEV_HTTP_READY_TIMEOUT=120 HTTP 服务就绪等待秒数 DEV_AUTO_KILL_PORTS=1 前后端端口被占用时自动停止占用的 Docker 容器或本机进程 DEV_FORCE_NPM_CI=0 强制重新执行前端 npm ci 示例: ./scripts/dev.sh # 正常启动 ./scripts/dev.sh --reset-db # 重置数据库后启动(推荐迁移后使用) ./scripts/dev.sh --seed-only # 重新同步基础数据 EOF exit 0 } # 参数解析 RESET_DB="${DEV_RESET_DB:-0}" SEED_ONLY=0 NO_MIGRATE=0 NO_FRONTEND=0 NO_BACKEND=0 while [[ $# -gt 0 ]]; do case "$1" in --reset-db) RESET_DB=1 shift ;; --seed-only) SEED_ONLY=1 shift ;; --no-migrate) NO_MIGRATE=1 shift ;; --no-frontend) NO_FRONTEND=1 shift ;; --no-backend) NO_BACKEND=1 shift ;; -h|--help) show_help ;; *) log_error "未知选项: $1" show_help ;; esac done cleanup() { if [[ "${CLEANED_UP}" == "1" ]]; then return fi CLEANED_UP=1 if [[ -z "${FRONTEND_PID}" && -z "${BACKEND_PID}" ]]; then return fi log "正在停止前后端开发进程..." if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then kill "${FRONTEND_PID}" >/dev/null 2>&1 || true fi if [[ -n "${BACKEND_PID}" ]] && kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then kill "${BACKEND_PID}" >/dev/null 2>&1 || true fi } wait_container_healthy() { local container_name="$1" local label="$2" local attempt log "等待 ${label} 就绪..." for attempt in {1..60}; do local status status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_name}" 2>/dev/null || true)" if [[ "${status}" == "healthy" || "${status}" == "running" ]]; then log "${label} 已就绪" return 0 fi sleep 2 done printf "%s 启动超时,请检查:docker logs %s\n" "${label}" "${container_name}" >&2 exit 1 } wait_http_ready() { local url="$1" local label="$2" local pid="${3:-}" local attempt log "等待 ${label} HTTP 服务就绪..." for ((attempt = 1; attempt <= HTTP_READY_TIMEOUT; attempt++)); do if [[ -n "${pid}" ]] && ! kill -0 "${pid}" >/dev/null 2>&1; then wait "${pid}" || true log_error "${label} 进程已退出,请检查上方日志" exit 1 fi if curl -fsS --max-time 2 "${url}" >/dev/null 2>&1; then log_success "${label} 已就绪:${url}" return 0 fi sleep 1 done log_error "${label} HTTP 服务启动超时:${url}" exit 1 } port_in_use() { local port="$1" if command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 return $? fi if command -v nc >/dev/null 2>&1; then nc -z 127.0.0.1 "${port}" >/dev/null 2>&1 return $? fi return 1 } wait_port_released() { local port="$1" local timeout="${2:-5}" local attempt for ((attempt = 1; attempt <= timeout; attempt++)); do if ! port_in_use "${port}"; then return 0 fi sleep 1 done return 1 } is_allowed_container() { local container_name="$1" shift local allowed_name for allowed_name in "$@"; do if [[ "${container_name}" == "${allowed_name}" ]]; then return 0 fi done return 1 } docker_containers_on_host_port() { local port="$1" docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}' 2>/dev/null | awk -F '\t' -v port="${port}" 'index($3, ":" port "->") || $3 ~ ("(^|[ ,])" port "->") { print $1 "\t" $2 }' } stop_docker_containers_on_port() { local port="$1" local label="$2" shift 2 local containers container_id container_name allowed_found=0 stopped_found=0 containers="$(docker_containers_on_host_port "${port}" || true)" if [[ -z "${containers}" ]]; then return 1 fi while IFS=$'\t' read -r container_id container_name; do [[ -z "${container_id}" ]] && continue if is_allowed_container "${container_name}" "$@"; then allowed_found=1 log "${label}端口 ${port} 已由本项目容器 ${container_name} 使用,保留运行" continue fi if [[ "${AUTO_KILL_PORTS}" != "1" ]]; then log_error "${label}端口 ${port} 被 Docker 容器 ${container_name} 占用" exit 1 fi log_warn "${label}端口 ${port} 被 Docker 容器 ${container_name} 占用,正在停止..." docker stop "${container_id}" >/dev/null stopped_found=1 log_success "已停止容器 ${container_name}" done <<< "${containers}" if [[ "${allowed_found}" == "1" ]]; then return 0 fi if [[ "${stopped_found}" == "1" ]]; then wait_port_released "${port}" 5 || true fi return 1 } process_args() { local pid="$1" ps -p "${pid}" -o args= 2>/dev/null || true } is_docker_proxy_process() { local args="$1" [[ "${args}" == *"com.docker"* || "${args}" == *"Docker Desktop"* || "${args}" == *"vpnkit"* || "${args}" == *"OrbStack"* ]] } ensure_port_free() { local port="$1" local label="$2" shift 2 if stop_docker_containers_on_port "${port}" "${label}" "$@"; then return 0 fi if command -v lsof >/dev/null 2>&1; then local pids pid args pids="$(lsof -nP -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u || true)" if [[ -z "${pids}" ]]; then return 0 fi if [[ "${AUTO_KILL_PORTS}" != "1" ]]; then log_error "${label}端口 ${port} 已被占用,请先停止旧进程或调整端口" exit 1 fi log_warn "${label}端口 ${port} 已被占用,正在停止占用进程..." while IFS= read -r pid; do [[ -z "${pid}" ]] && continue args="$(process_args "${pid}")" if is_docker_proxy_process "${args}"; then log_error "${label}端口 ${port} 仍由 Docker 代理进程占用,但未找到可自动停止的容器,请执行 docker ps 检查" exit 1 fi log_warn "停止 PID ${pid}: ${args:-未知进程}" kill "${pid}" >/dev/null 2>&1 || true done <<< "${pids}" if ! wait_port_released "${port}" 5; then log_warn "${label}端口 ${port} 未及时释放,尝试强制停止..." pids="$(lsof -nP -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | sort -u || true)" while IFS= read -r pid; do [[ -z "${pid}" ]] && continue args="$(process_args "${pid}")" if is_docker_proxy_process "${args}"; then log_error "${label}端口 ${port} 仍由 Docker 代理进程占用,但未找到可自动停止的容器,请执行 docker ps 检查" exit 1 fi log_warn "强制停止 PID ${pid}: ${args:-未知进程}" kill -9 "${pid}" >/dev/null 2>&1 || true done <<< "${pids}" if ! wait_port_released "${port}" 5; then log_error "${label}端口 ${port} 释放失败,请手动检查占用进程" exit 1 fi fi log_success "${label}端口 ${port} 已释放" return 0 fi if command -v nc >/dev/null 2>&1; then if nc -z 127.0.0.1 "${port}" >/dev/null 2>&1; then log_error "${label}端口 ${port} 已被占用,但未找到 lsof,无法自动定位并停止占用进程" exit 1 fi return 0 fi log_warn "未找到 lsof 或 nc,跳过 ${label}端口 ${port} 占用检查" } mysql_root() { docker exec -i -e MYSQL_PWD=rootsecret hfb-mysql mysql -uroot "$@" } mysql_hfb() { docker exec -i -e MYSQL_PWD=secret hfb-mysql mysql -uhfb "$@" } mysql_scalar() { mysql_hfb --default-character-set=utf8mb4 -N -s -e "$1" "${MYSQL_DATABASE}" } validate_database_name() { if [[ ! "${MYSQL_DATABASE}" =~ ^[A-Za-z0-9_]+$ ]]; then log_error "DEV_MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${MYSQL_DATABASE}" exit 1 fi } reset_database() { if [[ "${RESET_DB}" != "1" ]]; then return 0 fi log_warn "重置数据库 ${MYSQL_DATABASE}..." mysql_root -e \ "DROP DATABASE IF EXISTS \`${MYSQL_DATABASE}\`; CREATE DATABASE \`${MYSQL_DATABASE}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" log_success "数据库已重建" } ensure_migration_table() { mysql_hfb --default-character-set=utf8mb4 "${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 } clean_legacy_migrations() { local has_legacy has_legacy="$(mysql_scalar "SELECT COUNT(*) FROM schema_migrations WHERE version IN ('000002_payment_refund_schema', '000003_add_indexes', '000004_add_support_status', '000005_add_announcements', '000006_insert_sample_announcements', '000007_add_announcement_permissions', '000008_fix_announcement_charset', '000002_add_withdrawal_tables', '000003_add_encrypted_realname_fields');")" if [[ "${has_legacy}" != "0" ]]; then log_warn "检测到旧的迁移记录,正在清理..." mysql_hfb --default-character-set=utf8mb4 -e \ "DELETE FROM schema_migrations WHERE version IN ('000002_payment_refund_schema', '000003_add_indexes', '000004_add_support_status', '000005_add_announcements', '000006_insert_sample_announcements', '000007_add_announcement_permissions', '000008_fix_announcement_charset', '000002_add_withdrawal_tables', '000003_add_encrypted_realname_fields');" "${MYSQL_DATABASE}" log_success "已清理旧迁移记录" fi } bootstrap_existing_schema_history() { local migration_count users_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';")" if [[ "${migration_count}" == "0" && "${users_exists}" != "0" ]]; then log_warn "检测到已有表结构但没有迁移记录,补记当前所有迁移文件为已应用" local file version shopt -s nullglob for file in "${MIGRATIONS_DIR}"/*.sql; do version="$(basename "${file}" .sql)" mysql_hfb --default-character-set=utf8mb4 -e \ "INSERT IGNORE INTO schema_migrations (version) VALUES ('${version}');" "${MYSQL_DATABASE}" done shopt -u nullglob fi } run_migrations() { if [[ "${NO_MIGRATE}" == "1" ]]; then log_warn "已跳过数据库迁移" return 0 fi log "自动发现并执行数据库迁移..." ensure_migration_table bootstrap_existing_schema_history # 只有在数据库未重置的情况下才需要清理旧迁移记录 if [[ "${RESET_DB}" != "1" ]]; then clean_legacy_migrations fi local file version applied local found=0 shopt -s nullglob for file in "${MIGRATIONS_DIR}"/*.sql; do found=1 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_hfb --default-character-set=utf8mb4 "${MYSQL_DATABASE}" < "${file}" mysql_hfb --default-character-set=utf8mb4 -e \ "INSERT INTO schema_migrations (version) VALUES ('${version}');" "${MYSQL_DATABASE}" done shopt -u nullglob if [[ "${found}" == "0" ]]; then log_warn "未发现迁移文件:${MIGRATIONS_DIR}/*.sql" else log_success "数据库迁移完成" fi } load_env_file() { local env_file="$1" local line while IFS= read -r line || [[ -n "${line}" ]]; do [[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue line="${line#export }" export "${line}" done < "${env_file}" } start_backend() { if [[ ! -f "${BACKEND_ENV}" ]]; then log "创建 backend/.env" cp "${BACKEND_ENV_EXAMPLE}" "${BACKEND_ENV}" fi log "启动后端:http://localhost:8080" ( cd "${ROOT_DIR}/backend" load_env_file "${BACKEND_ENV}" go run ./cmd/api ) & BACKEND_PID="$!" } frontend_dependencies_need_ci() { if [[ "${FORCE_NPM_CI}" == "1" ]]; then return 0 fi if [[ ! -d "${FRONTEND_NODE_MODULES}" ]]; then return 0 fi if [[ ! -f "${FRONTEND_NODE_MODULES_LOCK}" ]]; then return 0 fi if [[ "${FRONTEND_PACKAGE_JSON}" -nt "${FRONTEND_NODE_MODULES_LOCK}" || "${FRONTEND_PACKAGE_LOCK}" -nt "${FRONTEND_NODE_MODULES_LOCK}" ]]; then return 0 fi ( cd "${FRONTEND_DIR}" npm ls --depth=0 >/dev/null 2>&1 ) || return 0 return 1 } ensure_frontend_dependencies() { if [[ ! -f "${FRONTEND_PACKAGE_LOCK}" ]]; then log_error "缺少 frontend/package-lock.json,无法执行 npm ci" exit 1 fi if frontend_dependencies_need_ci; then log "同步前端依赖(npm ci)..." ( cd "${FRONTEND_DIR}" npm ci ) log_success "前端依赖已同步" else log "前端依赖已同步,跳过 npm ci" fi } start_frontend() { ensure_frontend_dependencies log "启动前端:http://localhost:5173" ( cd "${FRONTEND_DIR}" npm run dev ) & FRONTEND_PID="$!" } watch_processes() { while true; do if [[ -n "${BACKEND_PID}" ]] && ! kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then wait "${BACKEND_PID}" || true printf "后端进程已退出\n" >&2 exit 1 fi if [[ -n "${FRONTEND_PID}" ]] && ! kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then wait "${FRONTEND_PID}" || true printf "前端进程已退出\n" >&2 exit 1 fi sleep 2 done } main() { need_cmd docker if [[ "${SEED_ONLY}" != "1" && "${NO_BACKEND}" == "0" ]]; then need_cmd go fi if [[ "${SEED_ONLY}" != "1" && "${NO_FRONTEND}" == "0" ]]; then need_cmd npm fi if [[ "${SEED_ONLY}" != "1" && ( "${NO_BACKEND}" == "0" || "${NO_FRONTEND}" == "0" ) ]]; then need_cmd curl fi trap cleanup EXIT trap 'cleanup; exit 130' INT TERM validate_database_name if [[ "${RESET_DB}" == "1" && "${NO_MIGRATE}" == "1" ]]; then log_error "--reset-db 会清空数据库,不能和 --no-migrate 同时使用" exit 1 fi log "启动开发依赖:MySQL、Redis、MinIO" docker compose -f "${COMPOSE_FILE}" up -d wait_container_healthy hfb-mysql "MySQL" wait_container_healthy hfb-redis "Redis" wait_container_healthy hfb-minio "MinIO" reset_database run_migrations # 仅同步基础数据模式 if [[ "${SEED_ONLY}" == "1" ]]; then log_success "基础数据同步完成,退出" exit 0 fi if [[ "${NO_BACKEND}" == "0" ]]; then ensure_port_free "${BACKEND_PORT}" "后端" start_backend wait_http_ready "${BACKEND_HEALTH_URL}" "后端" "${BACKEND_PID}" fi if [[ "${NO_FRONTEND}" == "0" ]]; then ensure_port_free "${FRONTEND_PORT}" "前端" start_frontend wait_http_ready "${FRONTEND_HEALTH_URL}" "前端" "${FRONTEND_PID}" fi if [[ -z "${BACKEND_PID}" && -z "${FRONTEND_PID}" ]]; then log "仅启动 Docker 开发依赖,前后端未启动" return 0 fi log_success "开发环境已启动" if [[ "${NO_FRONTEND}" == "0" ]]; then log "前台:http://localhost:5173" log "后台:http://localhost:5173/admin/login(admin / admin123456)" fi log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行" watch_processes } main "$@"