diff --git a/README.md b/README.md index df42faa..0df0649 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,42 @@ YG_FT/ - 前端新增 `/compute` 算力节点页面,展示节点地址、权重、标签、启用状态、GPU、队列和资源副本。 - `compute/engines/llama_factory/adapter.py` 提供 LLaMA-Factory 参数校验、命令生成和日志解析基础能力。 +## 前后端一键启动 + +首次使用前,请先按下方“后端启动”和“前端启动”说明安装依赖,并确保 +PostgreSQL 已可用。之后在项目根目录执行: + +```bash +bash ./start.sh +``` + +脚本会同时启动前端 `http://localhost:16801` 和后端 +`http://127.0.0.1:17861`,按 `Ctrl+C` 会同时停止两个服务。脚本只负责 +启动前后端,不会自动安装依赖,也不会启动 PostgreSQL、Redis 或算力服务。 + +仅检查依赖和端口而不启动服务: + +```bash +bash ./start.sh --check +``` + +本地启动推荐只配置数据库主机。脚本会复用 `docker/app/.env` 中已有的 +`POSTGRES_USER`、`POSTGRES_PASSWORD` 和 `POSTGRES_DB`,端口默认使用 +PostgreSQL 标准端口 `5432`: + +```bash +DATABASE_HOST='www.caoxiaozhu.com' bash ./start.sh +``` + +也可以在 `docker/app/.env` 中增加: + +```env +DATABASE_HOST=www.caoxiaozhu.com +``` + +需要使用非标准端口时再设置 `DATABASE_PORT`。`DATABASE_URL` 仍可作为完整连接串 +高级覆盖项;终端环境变量优先级最高。脚本不会输出数据库密码。 + ## 后端启动 ```bash diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..7485bef --- /dev/null +++ b/start.sh @@ -0,0 +1,381 @@ +#!/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 "$@"