#!/usr/bin/env bash set -Eeuo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BACKEND_DIR="${ROOT_DIR}/backend" FRONTEND_DIR="${ROOT_DIR}/frontend" BACKEND_PORT=17861 FRONTEND_PORT=16801 BACKEND_PYTHON="" BACKEND_PID="" FRONTEND_PID="" SHUTTING_DOWN=0 CHECK_ONLY=0 DATABASE_URL_SOURCE="" info() { printf '[启动] %s\n' "$*" } warn() { printf '[警告] %s\n' "$*" >&2 } fail() { printf '[错误] %s\n' "$*" >&2 exit 1 } usage() { cat <<'EOF' 用法:bash ./start.sh [--check] [--help] --check 仅检查运行环境和端口,不启动服务 --help 显示帮助 脚本会继承当前终端的环境变量,例如: DATABASE_HOST='db.example.com' bash ./start.sh EOF } parse_args() { if [ "$#" -gt 1 ]; then usage >&2 exit 2 fi case "${1:-}" in "") ;; --check) CHECK_ONLY=1 ;; --help|-h) usage exit 0 ;; *) usage >&2 exit 2 ;; esac } select_backend_python() { if [ -x "${BACKEND_DIR}/.venv/bin/python" ]; then BACKEND_PYTHON="${BACKEND_DIR}/.venv/bin/python" elif command -v python3 >/dev/null 2>&1; then BACKEND_PYTHON="$(command -v python3)" warn "未找到 backend/.venv,将使用 ${BACKEND_PYTHON}。" else fail "未找到 Python 3。请先创建 backend/.venv 并安装后端依赖。" fi } load_database_url() { local env_file="${ROOT_DIR}/docker/app/.env" local value="" if ! value="$(DATABASE_CONFIG_FILE="${env_file}" "${BACKEND_PYTHON}" -c ' import ipaddress import os from pathlib import Path from urllib.parse import quote def read_env(path: str) -> dict[str, str]: values: dict[str, str] = {} env_path = Path(path) if not env_path.is_file(): return values for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in {"\x27", "\""}: value = value[1:-1] values[key.strip()] = value return values file_values = read_env(os.environ["DATABASE_CONFIG_FILE"]) def setting(*names: str, default: str = "") -> str: for name in names: env_value = os.environ.get(name, "").strip() if env_value: return env_value for name in names: file_value = file_values.get(name, "").strip() if file_value: return file_value return default shell_url = os.environ.get("DATABASE_URL", "").strip() shell_host = os.environ.get("DATABASE_HOST", "").strip() file_host = file_values.get("DATABASE_HOST", "").strip() file_url = file_values.get("DATABASE_URL", "").strip() candidate = shell_url or shell_host or file_host or file_url if not candidate: raise SystemExit(0) if "://" in candidate: print(candidate) raise SystemExit(0) host = candidate.strip().rstrip("/") if not host or any(character in host for character in "/@"): raise SystemExit(1) port_text = setting("DATABASE_PORT", default="5432") try: port = int(port_text) except ValueError: raise SystemExit(1) if not 1 <= port <= 65535: raise SystemExit(1) username = setting("DATABASE_USER", "POSTGRES_USER", default="yg_ft") password = setting("DATABASE_PASSWORD", "POSTGRES_PASSWORD", default="change_me") database = setting("DATABASE_NAME", "POSTGRES_DB", default="yg_ft") if not username or not database: raise SystemExit(1) try: parsed_host = ipaddress.ip_address(host) url_host = f"[{host}]" if parsed_host.version == 6 else host except ValueError: url_host = host credentials = quote(username, safe="") if password: credentials += ":" + quote(password, safe="") encoded_database = quote(database, safe="") print(f"postgresql+psycopg://{credentials}@{url_host}:{port}/{encoded_database}") ' 2>/dev/null)"; then fail "数据库分项配置无效,请检查 DATABASE_HOST 和 DATABASE_PORT。" fi if [ -z "${value}" ]; then return fi if [ -n "${DATABASE_URL:-}" ] || [ -n "${DATABASE_HOST:-}" ]; then DATABASE_URL_SOURCE="当前终端环境变量" elif [ -f "${env_file}" ] && grep -Eq '^[[:space:]]*DATABASE_HOST=' "${env_file}"; then DATABASE_URL_SOURCE="docker/app/.env 的 DATABASE_HOST" else DATABASE_URL_SOURCE="docker/app/.env 的 DATABASE_URL" fi export DATABASE_URL="${value}" } validate_database_url() { if [ -z "${DATABASE_URL:-}" ]; then return fi case "${DATABASE_URL}" in *"@postgres:"*|*"//postgres:"*) fail "DATABASE_URL 使用了 Docker 服务名 postgres;本地启动请填写真实数据库主机名或 IP。" ;; esac if ! "${BACKEND_PYTHON}" -c ' import os from urllib.parse import urlsplit try: parsed = urlsplit(os.environ["DATABASE_URL"]) port = parsed.port or 5432 except (KeyError, ValueError): raise SystemExit(1) valid = ( parsed.scheme in {"postgresql", "postgresql+psycopg"} and bool(parsed.hostname) and bool(parsed.path.strip("/")) and 1 <= port <= 65535 ) raise SystemExit(0 if valid else 1) '; then fail "${DATABASE_URL_SOURCE:-DATABASE_URL} 中的数据库连接串格式无效。格式应为:postgresql+psycopg://用户名:密码@主机:端口/数据库名" fi info "已使用 ${DATABASE_URL_SOURCE:-DATABASE_URL} 中的数据库连接配置(敏感信息已隐藏)。" } check_backend_dependencies() { if ! "${BACKEND_PYTHON}" -c \ 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)'; then fail "后端要求 Python >= 3.12,当前解释器为 $(${BACKEND_PYTHON} --version 2>&1)。" fi if ! "${BACKEND_PYTHON}" -c \ 'import alembic, dotenv, fastapi, httpx, jwt, multipart, passlib, psycopg, pydantic, redis, sqlalchemy, uvicorn' \ >/dev/null 2>&1; then fail "后端依赖不完整。请执行:${BACKEND_PYTHON} -m pip install -r backend/requirements.txt" fi } check_frontend_dependencies() { command -v node >/dev/null 2>&1 || fail "未找到 Node.js。" command -v npm >/dev/null 2>&1 || fail "未找到 npm。" if [ ! -x "${FRONTEND_DIR}/node_modules/.bin/vite" ]; then fail "前端依赖未安装。请执行:cd frontend && npm ci" fi if ! npm --prefix "${FRONTEND_DIR}" ls --depth=0 >/dev/null 2>&1; then fail "前端依赖状态异常。请执行:cd frontend && npm ci" fi } check_port() { local port="$1" local service_name="$2" if command -v lsof >/dev/null 2>&1 && \ lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then fail "${service_name}端口 ${port} 已被占用,请先停止占用该端口的进程。" fi } warn_if_default_database_is_unavailable() { if [ -n "${DATABASE_URL:-}" ] || ! command -v lsof >/dev/null 2>&1; then return fi if ! lsof -nP -iTCP:15432 -sTCP:LISTEN >/dev/null 2>&1; then warn "未检测到默认 PostgreSQL 端口 15432;后端可启动,但健康检查和业务接口可能失败。" fi } preflight() { [ -d "${BACKEND_DIR}" ] || fail "缺少后端目录:${BACKEND_DIR}" [ -d "${FRONTEND_DIR}" ] || fail "缺少前端目录:${FRONTEND_DIR}" [ -f "${FRONTEND_DIR}/package-lock.json" ] || fail "缺少 frontend/package-lock.json" select_backend_python load_database_url validate_database_url check_backend_dependencies check_frontend_dependencies check_port "${BACKEND_PORT}" "后端" check_port "${FRONTEND_PORT}" "前端" warn_if_default_database_is_unavailable info "环境检查通过:$(${BACKEND_PYTHON} --version 2>&1),Node $(node --version)。" } terminate_process_tree() { local pid="$1" local child="" if [ -z "${pid}" ] || ! kill -0 "${pid}" 2>/dev/null; then return fi if command -v pgrep >/dev/null 2>&1; then for child in $(pgrep -P "${pid}" 2>/dev/null || true); do terminate_process_tree "${child}" done fi kill -TERM "${pid}" 2>/dev/null || true } cleanup() { local exit_code="${1:-0}" if [ "${SHUTTING_DOWN}" -eq 1 ]; then return fi SHUTTING_DOWN=1 trap - EXIT INT TERM if [ -n "${BACKEND_PID}" ] || [ -n "${FRONTEND_PID}" ]; then info "正在停止前端和后端服务……" fi terminate_process_tree "${FRONTEND_PID}" terminate_process_tree "${BACKEND_PID}" [ -z "${FRONTEND_PID}" ] || wait "${FRONTEND_PID}" 2>/dev/null || true [ -z "${BACKEND_PID}" ] || wait "${BACKEND_PID}" 2>/dev/null || true exit "${exit_code}" } start_backend() { info "启动后端:http://127.0.0.1:${BACKEND_PORT}/modelTF/health" ( cd "${BACKEND_DIR}" exec "${BACKEND_PYTHON}" -m uvicorn app.main:app \ --reload \ --host 127.0.0.1 \ --port "${BACKEND_PORT}" ) & BACKEND_PID=$! } start_frontend() { info "启动前端:http://localhost:${FRONTEND_PORT}" ( cd "${FRONTEND_DIR}" exec npm run dev -- --strictPort ) & FRONTEND_PID=$! } wait_for_services() { local exit_code=0 while :; do if ! kill -0 "${BACKEND_PID}" 2>/dev/null; then if wait "${BACKEND_PID}"; then exit_code=0 else exit_code=$? fi warn "后端服务已退出(状态码 ${exit_code})。" [ "${exit_code}" -ne 0 ] || exit_code=1 return "${exit_code}" fi if ! kill -0 "${FRONTEND_PID}" 2>/dev/null; then if wait "${FRONTEND_PID}"; then exit_code=0 else exit_code=$? fi warn "前端服务已退出(状态码 ${exit_code})。" [ "${exit_code}" -ne 0 ] || exit_code=1 return "${exit_code}" fi sleep 1 done } main() { parse_args "$@" preflight if [ "${CHECK_ONLY}" -eq 1 ]; then info "前后端均可启动。" return fi trap 'cleanup $?' EXIT trap 'cleanup 130' INT trap 'cleanup 143' TERM start_backend start_frontend info "前后端已启动,按 Ctrl+C 同时停止。" wait_for_services } main "$@"