nex_docus/scripts/start.sh

320 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/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