Files
hfb_sys/scripts/dev.sh
T
2026-06-07 10:06:18 +08:00

649 lines
17 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/loginadmin / admin123456"
fi
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"
watch_processes
}
main "$@"