320 lines
11 KiB
Bash
Executable File
320 lines
11 KiB
Bash
Executable File
#!/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
|