382 lines
9.7 KiB
Bash
382 lines
9.7 KiB
Bash
|
|
#!/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 "$@"
|