#!/usr/bin/env bash # # NEX Docus 一键启动脚本(本地开发) # # ./scripts/start.sh 准备环境并启动后端 + 前端 # ./scripts/start.sh --backend 只启动后端 # ./scripts/start.sh --frontend 只启动前端 # ./scripts/start.sh --install 只准备环境(虚拟环境 + 依赖 + 数据库初始化),不启动 # ./scripts/start.sh --docker 用 Docker Compose 启动(等价 scripts/deploy.sh start) # ./scripts/start.sh --daemon 启动后立即返回(服务后台常驻,用 scripts/stop.sh 停止) # ./scripts/start.sh --help 查看全部参数 # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" RUN_DIR="${PROJECT_DIR}/.run" RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' info() { echo -e "${BLUE}▸${NC} $*"; } ok() { echo -e "${GREEN}✓${NC} $*"; } warn() { echo -e "${YELLOW}!${NC} $*"; } fail() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } usage() { sed -n '3,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } START_BACKEND=1 START_FRONTEND=1 INSTALL_ONLY=0 USE_DOCKER=0 DAEMON=0 INIT_DB=0 BACKEND_PORT="" while [ $# -gt 0 ]; do case "$1" in --backend) START_FRONTEND=0 ;; --frontend) START_BACKEND=0 ;; --install) INSTALL_ONLY=1 ;; --init-db) INIT_DB=1 ;; --docker) USE_DOCKER=1 ;; --daemon|-d) DAEMON=1 ;; --port) shift; [ $# -gt 0 ] || fail "--port 需要一个端口号"; BACKEND_PORT="$1" ;; -h|--help) usage; exit 0 ;; *) fail "未知参数:$1(使用 --help 查看用法)" ;; esac shift done cd "$PROJECT_DIR" mkdir -p "$RUN_DIR" # ---------------------------------------------------------------- Docker 模式 if [ "$USE_DOCKER" = "1" ]; then info "切换到 Docker 部署模式" exec "${SCRIPT_DIR}/deploy.sh" start fi # ---------------------------------------------------------------- 环境检查 require_cmd() { command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1。$2" } require_cmd python3 "请先安装 Python 3.10+(https://www.python.org/downloads/)" require_cmd npm "请先安装 Node.js 18+(https://nodejs.org/)" if [ ! -f "backend/.env" ]; then warn "backend/.env 不存在,正在从模板创建(请填写数据库/Redis 连接信息)" cat > backend/.env << 'ENV_EOF' # NEX Docus 本地开发配置(由 scripts/start.sh 生成,请按实际情况修改) APP_NAME=Nex Docus DEBUG=True HOST=0.0.0.0 PORT=8000 DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=root DB_PASSWORD= DB_NAME=nex_docus DB_CHARSET=utf8mb4 REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 SECRET_KEY=please-change-me-in-production ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=1440 STORAGE_ROOT=./storage PROJECTS_PATH=./storage/projects USERS_PATH=./storage/users TEMP_PATH=./storage/temp CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] LOG_LEVEL=INFO LOG_PATH=logs ENV_EOF fail "请编辑 backend/.env 填写数据库与 Redis 连接信息后重新运行" fi # ---------------------------------------------------------------- 后端环境 VENV_DIR="backend/venv" if [ ! -x "${VENV_DIR}/bin/python" ]; then info "创建 Python 虚拟环境 backend/venv" python3 -m venv "$VENV_DIR" fi PY="${VENV_DIR}/bin/python" if ! "$PY" -c "import fastapi, uvicorn" >/dev/null 2>&1; then info "安装后端依赖(首次启动较慢,请稍候)" "$PY" -m pip install --upgrade pip setuptools wheel >/dev/null "$PY" -m pip install -r backend/requirements.txt || \ "$PY" -m pip install -r backend/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple ok "后端依赖安装完成" fi # ---------------------------------------------------------------- 前端环境 if [ ! -d "frontend/node_modules" ]; then info "安装前端依赖(首次启动较慢,请稍候)" ( cd frontend && npm install ) ok "前端依赖安装完成" fi # ---------------------------------------------------------------- 连通性检查 # 只做 TCP 探测会掩盖真实故障(Redis 落盘失败、密码不匹配等), # 因此这里用后端虚拟环境里的客户端做一次真实的读写握手。 check_redis() { REDIS_HOST="$1" REDIS_PORT="$2" REDIS_PASSWORD="$3" REDIS_DB="$4" "$PY" - << 'PY_EOF' import os, sys try: import redis except ImportError: sys.exit(3) cfg = dict( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]), password=os.environ.get("REDIS_PASSWORD") or None, db=int(os.environ.get("REDIS_DB") or 0), socket_connect_timeout=3, ) try: client = redis.Redis(**cfg) client.ping() client.set("nexdocus:healthcheck", "1", ex=10) except redis.exceptions.AuthenticationError: print("AUTH 失败:REDIS_PASSWORD 与服务端不一致(服务端未设密码时请留空)") sys.exit(2) except redis.exceptions.ResponseError as exc: print("Redis 拒绝写入:%s" % exc) if "MISCONF" in str(exc): print(" 常见原因:RDB/AOF 落盘失败(磁盘已满或目录权限不足)。请先修复磁盘问题;") print(" 应急可执行 redis-cli CONFIG SET stop-writes-on-bgsave-error no 临时解封。") sys.exit(2) except Exception as exc: # noqa: BLE001 print("%s: %s" % (type(exc).__name__, exc)) sys.exit(2) PY_EOF } check_mysql() { MYSQL_HOST="$1" MYSQL_PORT="$2" MYSQL_USER="$3" MYSQL_PASSWORD="$4" MYSQL_DB="$5" "$PY" - << 'PY_EOF' import os, sys try: import pymysql except ImportError: sys.exit(3) try: conn = pymysql.connect( host=os.environ["MYSQL_HOST"], port=int(os.environ["MYSQL_PORT"]), user=os.environ["MYSQL_USER"], password=os.environ.get("MYSQL_PASSWORD") or "", db=os.environ.get("MYSQL_DB") or None, charset="utf8mb4", connect_timeout=5, autocommit=True, ) with conn.cursor() as cur: cur.execute("SELECT 1") cur.fetchone() cur.execute("SELECT VERSION()") print("MySQL %s" % cur.fetchone()[0]) conn.close() except Exception as exc: # noqa: BLE001 print("%s: %s" % (type(exc).__name__, exc)) sys.exit(2) PY_EOF } check_port() { "$PY" - "$1" "$2" << 'PY_EOF' import socket, sys socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=3).close() PY_EOF } # probe <名称> <检查函数> <主机> <端口> [参数...]:rc=0 通过 / 3 缺库降级 / 其它 直接失败 probe() { local label="$1" checker="$2" host="$3" port="$4" shift 4 local out rc # set -e 下命令替换失败会直接终止脚本,必须用 if 捕获返回码 if out="$("$checker" "$host" "$port" "$@" 2>&1)"; then rc=0; else rc=$?; fi case "$rc" in 0) ok "${label} 连接正常${out:+(${out})}" ;; 3) if check_port "$host" "$port" >/dev/null 2>&1; then warn "${label} 仅端口可连通(虚拟环境缺少客户端库,跳过功能性检查)" else fail "${label} 无法连接(${host}:${port})。请确认服务已启动且地址端口正确。" fi ;; *) fail "${label} 连接失败:${out} 请修正 backend/.env 中的连接配置后重新运行。" ;; esac } env_val() { sed -n "s/^$1=//p" backend/.env | head -1; } DB_HOST_VAL="$(env_val DB_HOST)"; DB_PORT_VAL="$(env_val DB_PORT)"; DB_PORT_VAL="${DB_PORT_VAL:-3306}" DB_USER_VAL="$(env_val DB_USER)"; DB_PASSWORD_VAL="$(env_val DB_PASSWORD)"; DB_NAME_VAL="$(env_val DB_NAME)" REDIS_HOST_VAL="$(env_val REDIS_HOST)"; REDIS_PORT_VAL="$(env_val REDIS_PORT)"; REDIS_PORT_VAL="${REDIS_PORT_VAL:-6379}" REDIS_PASSWORD_VAL="$(env_val REDIS_PASSWORD)"; REDIS_DB_VAL="$(env_val REDIS_DB)" if [ "$START_BACKEND" = "1" ] || [ "$INIT_DB" = "1" ]; then if [ -n "$DB_HOST_VAL" ]; then probe "MySQL" check_mysql "$DB_HOST_VAL" "$DB_PORT_VAL" "$DB_USER_VAL" "$DB_PASSWORD_VAL" "$DB_NAME_VAL" fi if [ -n "$REDIS_HOST_VAL" ]; then probe "Redis" check_redis "$REDIS_HOST_VAL" "$REDIS_PORT_VAL" "$REDIS_PASSWORD_VAL" "$REDIS_DB_VAL" fi fi # ---------------------------------------------------------------- 数据库初始化 if [ "$INIT_DB" = "1" ] || [ ! -f "${RUN_DIR}/db-initialized" ]; then info "初始化/校验数据库结构(幂等)" if ( cd backend && "../${VENV_DIR}/bin/python" scripts/init_db.py ); then touch "${RUN_DIR}/db-initialized" ok "数据库结构已就绪" else warn "数据库初始化失败,请检查 backend/.env 中的数据库配置" fi fi if [ "$INSTALL_ONLY" = "1" ]; then ok "环境准备完成(--install 模式不会启动服务)" exit 0 fi # ---------------------------------------------------------------- 启动服务 cleanup() { echo info "正在停止服务 ..." [ -n "${BACKEND_PID:-}" ] && kill "$BACKEND_PID" 2>/dev/null || true [ -n "${FRONTEND_PID:-}" ] && kill "$FRONTEND_PID" 2>/dev/null || true rm -f "${RUN_DIR}/backend.pid" "${RUN_DIR}/frontend.pid" } trap cleanup EXIT INT TERM if [ -n "${BACKEND_PORT}" ]; then # 环境变量优先级高于 backend/.env,无需修改用户配置文件 export PORT="${BACKEND_PORT}" fi start_backend() { info "启动后端 http://localhost:${BACKEND_PORT:-8000}" ( cd backend && exec "../${VENV_DIR}/bin/python" -m uvicorn main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8000}" --reload ) \ > "${RUN_DIR}/backend.log" 2>&1 & BACKEND_PID=$! echo "$BACKEND_PID" > "${RUN_DIR}/backend.pid" } start_frontend() { info "启动前端 http://localhost:5173" ( cd frontend && exec npm run dev ) > "${RUN_DIR}/frontend.log" 2>&1 & FRONTEND_PID=$! echo "$FRONTEND_PID" > "${RUN_DIR}/frontend.pid" } [ "$START_BACKEND" = "1" ] && start_backend [ "$START_FRONTEND" = "1" ] && start_frontend # 启动探活:健康检查通过才认定启动成功,失败时直接回显日志尾部 BACKEND_HEALTHY=0 if [ "$START_BACKEND" = "1" ]; then for _ in $(seq 1 40); do if curl -fsS "http://localhost:${BACKEND_PORT:-8000}/health" >/dev/null 2>&1; then BACKEND_HEALTHY=1 break fi kill -0 "$BACKEND_PID" 2>/dev/null || break sleep 1 done if [ "$BACKEND_HEALTHY" != "1" ]; then echo -e "${RED}✗ 后端未能正常启动,${RUN_DIR}/backend.log 末尾:${NC}" >&2 tail -n 20 "${RUN_DIR}/backend.log" >&2 || true fail "请按上面的日志修复后重新运行 ./scripts/start.sh" fi ok "后端健康检查通过(/health)" fi echo ok "NEX Docus 已启动" [ "$START_BACKEND" = "1" ] && echo -e " 后端 API ${GREEN}http://localhost:${BACKEND_PORT:-8000}${NC} 文档 ${GREEN}http://localhost:${BACKEND_PORT:-8000}/docs${NC}" [ "$START_FRONTEND" = "1" ] && echo -e " 前端页面 ${GREEN}http://localhost:5173${NC}" echo " 日志目录 ${RUN_DIR}" if [ "$DAEMON" = "1" ]; then echo " 停止服务 ./scripts/stop.sh" # 解除退出 traps:否则 EXIT trap 会把刚启动的服务一起带走 trap - EXIT INT TERM exit 0 fi echo " 停止服务 ./scripts/stop.sh (或直接 Ctrl+C)" echo wait