#!/usr/bin/env zsh set -e SCRIPT_PATH="${(%):-%N}" SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" cd "$SCRIPT_DIR" RED='\033[38;5;203m' GREEN='\033[38;5;114m' YELLOW='\033[38;5;221m' BLUE='\033[38;5;111m' CYAN='\033[38;5;117m' WHITE='\033[1;37m' DIM='\033[2m' NC='\033[0m' WAIT_SPINNER_FRAMES=( '⠉⠁' '⠈⠁' '⠈⠉' ' ⠙' ' ⠸' ' ⢰' ' ⣠' '⢀⣀' '⣀⡀' '⣄ ' '⡆ ' '⠇ ' '⠋ ' '⠋⠁' ) WAIT_SPINNER_STEP=0 WAIT_SPINNER_TICKS_PER_SECOND=8 WAIT_SPINNER_DETAIL="" WAIT_SPINNER_MESSAGE="" WAIT_SESSION_ACTIVE=0 HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-0.2}" BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}" BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}" DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}" DEPENDENCY_INSTALL_RETRY_INTERVAL="${DEPENDENCY_INSTALL_RETRY_INTERVAL:-5}" AI_PROVIDER_HEALTH_CHECK_ATTEMPTS="${AI_PROVIDER_HEALTH_CHECK_ATTEMPTS:-10}" AI_PROVIDER_HEALTH_CHECK_INTERVAL="${AI_PROVIDER_HEALTH_CHECK_INTERVAL:-2}" AI_PROVIDER_START_MAX_RETRIES="${AI_PROVIDER_START_MAX_RETRIES:-3}" AI_PROVIDER_RETRY_INTERVAL="${AI_PROVIDER_RETRY_INTERVAL:-5}" DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}" DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}" FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}" FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}" FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}" PORT_RELEASE_ATTEMPTS="${PORT_RELEASE_ATTEMPTS:-45}" PORT_RELEASE_INTERVAL="${PORT_RELEASE_INTERVAL:-1}" DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}" DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}" DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}" FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}" FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}" FRONTEND_PID_FILE="/tmp/planet_frontend.pid" AI_PROVIDER_BUILD_STAMP_FILE="/tmp/planet_aiprovider_build.sha256" AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log" AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}" AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}" AI_PROVIDER_RECREATE_REQUIRED=0 prepend_path_once() { local path_entry="$1" [ -n "$path_entry" ] || return 0 [ -d "$path_entry" ] || return 0 case ":$PATH:" in *":$path_entry:"*) ;; *) export PATH="$path_entry:$PATH" ;; esac } ensure_local_runtime_bins_on_path() { prepend_path_once "$HOME/.local/bin" prepend_path_once "$HOME/.bun/bin" } ensure_local_runtime_bins_on_path # Shell / Docker helpers compose_available() { docker compose version >/dev/null 2>&1 } compose_v1_available() { command -v docker-compose >/dev/null 2>&1 } report_missing_compose() { clear_wait_spinner log_error "未检测到可用的 Docker Compose" log_note '优先使用 `docker compose`' log_note "如果当前机器仍只装有旧版 `docker-compose`,也请确认该命令可执行" log_note "推荐安装 Docker Compose 插件:" log_note ' mkdir -p ~/.docker/cli-plugins' log_note ' curl -SL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose' log_note ' chmod +x ~/.docker/cli-plugins/docker-compose' log_note ' docker compose version' return 1 } buildx_version() { docker buildx version 2>/dev/null | awk '{print $2}' | sed 's/^v//' } buildx_meets_minimum() { local current_version="$1" local required_version="$2" [ -n "$current_version" ] || return 1 [ -n "$required_version" ] || return 1 if [ "$(printf '%s\n%s\n' "$required_version" "$current_version" | sort -V | head -n 1)" = "$required_version" ]; then return 0 fi return 1 } log_buildx_diagnostics_if_needed() { local current_buildx_version="" local required_buildx_version="0.17.0" current_buildx_version="$(buildx_version)" if [ -z "$current_buildx_version" ]; then log_note "未检测到 docker buildx,AI Provider 镜像构建依赖 buildx。" log_note "请先执行: docker buildx version" return 0 fi if ! buildx_meets_minimum "$current_buildx_version" "$required_buildx_version"; then log_note "检测到 docker buildx 当前版本为 v${current_buildx_version},但 compose build 需要 v${required_buildx_version} 或更高。" log_note "可先升级 buildx 插件,再重新执行 ./planet.sh start" log_note "请先执行: docker buildx version" fi } compose_up() { local args=("$@") local arg_text="$*" if compose_available; then set_wait_detail "正在用 docker compose 执行: ${arg_text}" if docker compose "${args[@]}"; then return 0 fi log_warn "docker compose 执行失败,回退到 docker-compose v1" fi if compose_v1_available; then set_wait_detail "正在用 docker-compose v1 执行: ${arg_text}" if docker-compose "${args[@]}"; then return 0 fi clear_wait_spinner log_error "docker compose 与 docker-compose v1 均执行失败" log_note "最后尝试的命令: docker-compose ${arg_text}" return 1 fi report_missing_compose } compose_supports_build() { if compose_available; then docker compose build --help >/dev/null 2>&1 && return 0 fi if compose_v1_available; then docker-compose build --help >/dev/null 2>&1 && return 0 fi return 1 } # Terminal rendering helpers render_wait_spinner() { local message="$1" local step="${2:-1}" local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} )) local frame="${WAIT_SPINNER_FRAMES[$frame_index]}" local detail="${WAIT_SPINNER_DETAIL:- }" printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K\033[1A\r" "$frame" "$message" "$detail" >&2 } animate_wait_spinner() { local message="$1" local duration="${2:-1}" local ticks=$(( duration * WAIT_SPINNER_TICKS_PER_SECOND )) local tick=0 if [ "$ticks" -lt 1 ]; then ticks=1 fi while [ "$tick" -lt "$ticks" ]; do WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) render_wait_spinner "$message" "$WAIT_SPINNER_STEP" sleep 0.125 tick=$((tick + 1)) done } clear_wait_spinner() { printf "\r\033[K\n\033[K\033[1A\r" >&2 } start_wait_session() { WAIT_SESSION_ACTIVE=1 WAIT_SPINNER_MESSAGE="$1" WAIT_SPINNER_DETAIL="" WAIT_SPINNER_STEP=1 render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" } resume_wait_session() { if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" fi } stop_wait_session() { if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then clear_wait_spinner fi WAIT_SESSION_ACTIVE=0 reset_wait_spinner_state } close_wait_session_context() { local owns_wait_session="$1" if [ "$owns_wait_session" -eq 1 ]; then stop_wait_session else reset_wait_spinner_state fi } reset_wait_spinner_state() { WAIT_SPINNER_DETAIL="" WAIT_SPINNER_MESSAGE="" } sanitize_wait_detail() { local line="$1" line="$(printf "%s" "$line" | tr '\t' ' ' | tr -d '\r')" line="$(printf "%s" "$line" | sed -E 's/\x1B\[[0-9;]*[[:alpha:]]//g')" line="$(printf "%s" "$line" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')" if [ "${#line}" -gt 140 ]; then line="${line:0:137}..." fi printf "%s" "$line" } set_wait_detail() { WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")" if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" fi } run_command_with_spinner() { local message="$1" shift if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then set_wait_detail "$message" fi "$@" & local command_pid=$! local exit_code=0 while kill -0 "$command_pid" 2>/dev/null; do WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" else render_wait_spinner "$message" "$WAIT_SPINNER_STEP" fi sleep 0.125 done wait "$command_pid" || exit_code=$? return "$exit_code" } resolve_bun_from_shell_configs() { local shell_name="$1" shift local config_file local candidate="" local bun_install="" for config_file in "$@"; do [ -f "$config_file" ] || continue if grep -Eq 'BUN_INSTALL|\.bun/bin|bun ' "$config_file" 2>/dev/null; then bun_install="$(sed -nE 's/^[[:space:]]*(export[[:space:]]+)?BUN_INSTALL=["'\'']?([^"'\'']+)["'\'']?/\2/p' "$config_file" | tail -n 1)" if [ -n "$bun_install" ]; then bun_install="${bun_install/#\$HOME/$HOME}" bun_install="${bun_install/#\~/$HOME}" candidate="$bun_install/bin/bun" if [ -x "$candidate" ]; then FRONTEND_RUNTIME_BIN="$candidate" FRONTEND_RUNTIME_SOURCE="${shell_name}-config" return 0 fi fi candidate="$HOME/.bun/bin/bun" if [ -x "$candidate" ]; then FRONTEND_RUNTIME_BIN="$candidate" FRONTEND_RUNTIME_SOURCE="${shell_name}-config" return 0 fi fi done return 1 } finish_wait_spinner() { log_line "done" "$GREEN" "$1" } # Logging helpers log_line() { local label="$1" local color="$2" local message="$3" clear_wait_spinner printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC}\n" "$label" "$message" resume_wait_session } log_step() { log_line "run" "$BLUE" "$1" } log_warn() { log_line "warn" "$YELLOW" "$1" } log_halt() { log_line "stopped" "$RED" "$1" } log_error() { log_line "fail" "$RED" "$1" } log_note() { printf "${DIM} %s${NC}\n" "$1" } log_success() { log_line "done" "$GREEN" "$1" } print_splash() { clear_wait_spinner printf "%b" "$CYAN" cat <<'EOF' ___ _ _ _ _ _ |_ _|_ __ | |_ ___| | (_) __ _ ___ _ __ | |_ | || '_ \| __/ _ \ | | |/ _` |/ _ \ '_ \| __| | || | | | || __/ | | | (_| | __/ | | | |_ |___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__| |___/ EOF printf "%b" "$BLUE" cat <<'EOF' ____ _ _ | _ \| | __ _ _ __ ___ | |_ | |_) | |/ _` | '_ \ / _ \| __| | __/| | (_| | | | | __/| |_ |_| |_|\__,_|_| |_|\___| \__| EOF printf "%b" "$WHITE" cat <<'EOF' ____ _ | _ \| | __ _ _ __ | |_) | |/ _` | '_ \ | __/| | (_| | | | | |_| |_|\__,_|_| |_| EOF printf "%b\n\n" "$NC" } # Retry / dependency helpers run_with_retry() { local max_retries="$1" local interval="$2" local failure_message="$3" local retry_label="$4" shift 4 local attempt=1 while [ "$attempt" -le "$max_retries" ]; do if run_command_with_spinner "$retry_label" "$@"; then WAIT_SPINNER_DETAIL="" return 0 fi if [ "$attempt" -eq "$max_retries" ]; then log_error "${failure_message}" return 1 fi animate_wait_spinner "${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试" "$interval" attempt=$((attempt + 1)) done return 1 } docker_daemon_available() { docker info >/dev/null 2>&1 } log_docker_daemon_diagnostics() { local docker_service_status="" local docker_socket_status="" local service_hint="sudo systemctl enable --now docker.socket && sudo systemctl restart docker" docker_service_status="$(systemctl is-active docker 2>/dev/null || true)" docker_socket_status="$(systemctl is-active docker.socket 2>/dev/null || true)" if [ -S /var/run/docker.sock ] && [ ! -r /var/run/docker.sock ]; then log_note "检测到 /var/run/docker.sock 存在,但当前用户无法访问。" log_note "请确认当前用户已加入 docker 组,并重新登录会话。" return 0 fi if [ "$docker_service_status" = "active" ]; then log_note "Docker CLI 可执行,但当前会话仍无法连接 daemon。" log_note "请先执行: docker info" return 0 fi if [ "$docker_service_status" = "failed" ] || [ "$docker_socket_status" = "failed" ]; then log_note "检测到 Docker daemon 当前未正常运行。" log_note "可先执行: ${service_hint}" log_note "如仍失败,继续查看: journalctl -u docker.service -n 50 --no-pager" return 0 fi if [ "$docker_socket_status" != "active" ]; then log_note "检测到 Docker socket 未激活,daemon 可能无法通过 systemd socket activation 启动。" log_note "可先执行: ${service_hint}" return 0 fi log_note "当前无法连接 Docker daemon,请先执行: docker info" log_note "若 daemon 未启动,可尝试: ${service_hint}" } print_database_failure_diagnostics() { if ! docker_daemon_available; then log_note "数据库容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。" log_docker_daemon_diagnostics return 0 fi docker logs --tail 20 planet_postgres 2>/dev/null || true docker logs --tail 20 planet_redis 2>/dev/null || true } compute_ai_provider_build_fingerprint() { ( cd "$SCRIPT_DIR" || exit 1 tar -cf - \ aiprovider \ pyproject.toml \ uv.lock \ docker-compose.yml \ docker-compose.simple.yml 2>/dev/null ) | sha256sum | awk '{print $1}' } read_ai_provider_build_stamp() { [ -f "$AI_PROVIDER_BUILD_STAMP_FILE" ] || return 1 tr -d '[:space:]' < "$AI_PROVIDER_BUILD_STAMP_FILE" } write_ai_provider_build_stamp() { local fingerprint="$1" printf "%s\n" "$fingerprint" > "$AI_PROVIDER_BUILD_STAMP_FILE" } ai_provider_image_exists() { docker image inspect "$AI_PROVIDER_IMAGE_NAME" >/dev/null 2>&1 } list_ai_provider_containers() { docker ps -aq --filter "name=planet_aiprovider" 2>/dev/null || true } remove_ai_provider_containers() { local container_ids container_ids="$(list_ai_provider_containers)" [ -n "$container_ids" ] || return 0 echo "$container_ids" | xargs -r docker rm -f >/dev/null 2>&1 || true } run_ai_provider_container_manually() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local env_file_args=() if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env") fi docker run -d \ --name "$AI_PROVIDER_CONTAINER_NAME" \ "${env_file_args[@]}" \ -p "${ai_provider_port}:8010" \ "$AI_PROVIDER_IMAGE_NAME" >/dev/null } recreate_ai_provider_container() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" remove_ai_provider_containers if compose_up up -d aiprovider >/dev/null 2>&1; then return 0 fi run_ai_provider_container_manually "$ai_provider_port" } ensure_ai_provider_image_current() { local current_fingerprint="" local previous_fingerprint="" current_fingerprint="$(compute_ai_provider_build_fingerprint)" previous_fingerprint="$(read_ai_provider_build_stamp || true)" if ai_provider_image_exists && [ -n "$previous_fingerprint" ] && [ "$current_fingerprint" = "$previous_fingerprint" ]; then AI_PROVIDER_RECREATE_REQUIRED=0 return 0 fi set_wait_detail "检测到 AI Provider 代码或配置变化,重建镜像" if ! compose_supports_build; then log_error "当前 docker compose 不支持 build,请检查 Docker / Compose 环境" exit 1 fi : > "$AI_PROVIDER_BUILD_LOG_FILE" if ! build_ai_provider_image_with_fallback; then clear_wait_spinner log_error "AI Provider 镜像构建失败" tail -20 "$AI_PROVIDER_BUILD_LOG_FILE" 2>/dev/null || true log_buildx_diagnostics_if_needed exit 1 fi log_success "AI Provider 镜像已构建" write_ai_provider_build_stamp "$current_fingerprint" AI_PROVIDER_RECREATE_REQUIRED=1 } build_ai_provider_image_with_fallback() { if compose_available; then set_wait_detail "使用 docker compose 构建 AI Provider 镜像" if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then return 0 fi if compose_v1_available; then log_warn "docker compose 构建失败,回退到 docker-compose v1" else log_warn "docker compose 构建失败,当前环境未安装 docker-compose v1,无法继续回退" return 1 fi fi if compose_v1_available; then set_wait_detail "使用 docker-compose v1 构建 AI Provider 镜像" if run_command_with_spinner "构建 AI Provider 镜像" sh -c "docker-compose build aiprovider > \"$AI_PROVIDER_BUILD_LOG_FILE\" 2>&1"; then return 0 fi return 1 fi report_missing_compose } install_uv_if_needed() { if command -v uv >/dev/null 2>&1; then return 0 fi set_wait_detail "未找到 uv,准备自动安装" mkdir -p "$HOME/.local/bin" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "uv 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "安装 uv" \ sh -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'; then exit 1 fi ensure_local_runtime_bins_on_path if ! command -v uv >/dev/null 2>&1; then log_error "uv 安装完成后仍未找到命令,请检查 ~/.local/bin" exit 1 fi } install_bun_if_needed() { if command -v bun >/dev/null 2>&1 || [ -x "$HOME/.bun/bin/bun" ]; then ensure_local_runtime_bins_on_path return 0 fi set_wait_detail "未找到 bun,准备自动安装" mkdir -p "$HOME/.bun" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "bun 安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "安装 bun" \ sh -c 'curl -fsSL https://bun.sh/install | bash'; then exit 1 fi ensure_local_runtime_bins_on_path if [ ! -x "$HOME/.bun/bin/bun" ] && ! command -v bun >/dev/null 2>&1; then log_error "bun 安装完成后仍未找到命令,请检查 ~/.bun/bin" exit 1 fi } ensure_uv_backend_deps() { set_wait_detail "检查后端 uv 环境" if ! command -v uv >/dev/null 2>&1; then install_uv_if_needed fi cd "$SCRIPT_DIR" if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then log_warn "未检测到 .venv,正在执行 uv sync" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "uv 环境初始化失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "uv sync" \ uv sync --group dev; then exit 1 fi fi if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then log_error "uv 环境初始化失败,未找到 .venv/bin/python" exit 1 fi } # Frontend runtime resolution resolve_frontend_runtime() { local shell_bin local candidate if [ -n "$FRONTEND_RUNTIME_BIN" ] && [ -x "$FRONTEND_RUNTIME_BIN" ]; then return 0 fi ensure_local_runtime_bins_on_path if [ -x "$HOME/.bun/bin/bun" ]; then FRONTEND_RUNTIME_BIN="$HOME/.bun/bin/bun" FRONTEND_RUNTIME_SOURCE="local-install" WAIT_SPINNER_DETAIL="已找到 ~/.bun/bin/bun" return 0 fi set_wait_detail "读取 zsh 配置中的 bun" if resolve_bun_from_shell_configs "zsh" "$HOME/.zshrc" "$HOME/.zprofile"; then WAIT_SPINNER_DETAIL="已找到 zsh 配置中的 bun" return 0 fi if command -v zsh >/dev/null 2>&1; then set_wait_detail "检查 zsh 中的 bun" candidate="$(zsh -ic 'command -v bun 2>/dev/null || true' 2>/dev/null | tail -n 1)" if [ -n "$candidate" ] && [ -x "$candidate" ]; then FRONTEND_RUNTIME_BIN="$candidate" FRONTEND_RUNTIME_SOURCE="zsh" WAIT_SPINNER_DETAIL="已找到 zsh 中的 bun" return 0 fi fi for shell_bin in pwsh powershell; do if command -v "$shell_bin" >/dev/null 2>&1; then set_wait_detail "检查 powershell 中的 bun" candidate="$("$shell_bin" -Command "(Get-Command bun -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)" 2>/dev/null | tail -n 1 | tr -d '\r')" if [ -n "$candidate" ] && [ -x "$candidate" ]; then FRONTEND_RUNTIME_BIN="$candidate" FRONTEND_RUNTIME_SOURCE="powershell" WAIT_SPINNER_DETAIL="已找到 powershell 中的 bun" return 0 fi fi done if command -v bun >/dev/null 2>&1; then set_wait_detail "检查当前环境中的 bun" candidate="$(command -v bun)" if [ -n "$candidate" ] && [ -x "$candidate" ]; then FRONTEND_RUNTIME_BIN="$candidate" FRONTEND_RUNTIME_SOURCE="current-shell" WAIT_SPINNER_DETAIL="已找到当前环境中的 bun" return 0 fi fi return 1 } ensure_frontend_deps() { local owns_wait_session=0 if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then start_wait_session "检查前端依赖" owns_wait_session=1 else set_wait_detail "检查前端依赖" fi set_wait_detail "解析前端运行时" if ! resolve_frontend_runtime; then install_bun_if_needed if ! resolve_frontend_runtime; then close_wait_session_context "$owns_wait_session" log_error "未找到 bun,自动安装后仍无法解析运行时" exit 1 fi fi cd "$SCRIPT_DIR/frontend" set_wait_detail "检查 vite 是否已安装" if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then log_warn "前端依赖缺失,正在执行 bun install (${FRONTEND_RUNTIME_SOURCE})" set_wait_detail "执行 ${FRONTEND_RUNTIME_SOURCE} bun install" if ! run_with_retry \ "$DEPENDENCY_INSTALL_MAX_RETRIES" \ "$DEPENDENCY_INSTALL_RETRY_INTERVAL" \ "前端依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \ "bun install" \ "$FRONTEND_RUNTIME_BIN" install; then exit 1 fi fi if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then close_wait_session_context "$owns_wait_session" log_error "前端依赖安装失败,未找到 vite" exit 1 fi close_wait_session_context "$owns_wait_session" if [ "$owns_wait_session" -eq 0 ]; then resume_wait_session fi } # Health / readiness helpers wait_for_http() { local url="$1" local attempts="$2" local interval="$3" local service_name="$4" local attempt=1 local wait_message="检查${service_name}健康检查" local wait_detail_prefix="请求" set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})" while [ "$attempt" -le "$attempts" ]; do if curl -s --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1; then finish_wait_spinner "${service_name}已就绪" return 0 fi set_wait_detail "${wait_detail_prefix} ${url} (${attempt}/${attempts})" animate_wait_spinner "${wait_message}" "$interval" attempt=$((attempt + 1)) done clear_wait_spinner return 1 } get_log_tail_line() { local log_file="$1" local fallback="$2" local last_line="" local filtered_line="" if [ -f "$log_file" ]; then while IFS= read -r last_line; do filtered_line="$(sanitize_wait_detail "$last_line")" if [ -n "$filtered_line" ] && [[ ! "$filtered_line" =~ ^\$[[:space:]] ]] && [[ "$filtered_line" != "nohup: ignoring input" ]] && [[ "$filtered_line" != The\ CJS\ build\ of\ Vite\'s\ Node\ API\ is\ deprecated.* ]]; then printf "%s" "$filtered_line" return 0 fi done < <(tail -n 20 "$log_file" 2>/dev/null | tac) fi if [ -z "$last_line" ]; then printf "%s" "$fallback" return 0 fi sanitize_wait_detail "$last_line" } wait_for_frontend_ready() { local frontend_port="$1" local frontend_pid="$2" local log_file="$3" local attempts="$4" local interval="$5" local owns_wait_session=0 local tick=0 local max_ticks=$(( attempts * interval * WAIT_SPINNER_TICKS_PER_SECOND )) local probe_every_ticks=$(( WAIT_SPINNER_TICKS_PER_SECOND / 2 )) local detail_every_ticks=2 local detail="" if [ "$max_ticks" -lt 1 ]; then max_ticks=1 fi if [ "$probe_every_ticks" -lt 1 ]; then probe_every_ticks=1 fi if [ "$WAIT_SESSION_ACTIVE" -eq 0 ]; then start_wait_session "启动前端服务" owns_wait_session=1 else set_wait_detail "启动 Vite 开发服务器" fi while [ "$tick" -lt "$max_ticks" ]; do WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1)) render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" if ! kill -0 "$frontend_pid" 2>/dev/null; then detail="$(get_log_tail_line "$log_file" "前端进程已退出")" WAIT_SPINNER_DETAIL="$detail" clear_wait_spinner if [ -n "$detail" ]; then log_note "$detail" fi close_wait_session_context "$owns_wait_session" return 1 fi if [ $(( tick % detail_every_ticks )) -eq 0 ]; then detail="$(get_log_tail_line "$log_file" "等待 Vite 输出启动日志")" WAIT_SPINNER_DETAIL="$detail" render_wait_spinner "$WAIT_SPINNER_MESSAGE" "$WAIT_SPINNER_STEP" fi if [ $(( tick % probe_every_ticks )) -eq 0 ] && curl -s --max-time "$HTTP_CHECK_MAX_TIME" "http://localhost:${frontend_port}" > /dev/null 2>&1; then if [ "$owns_wait_session" -eq 1 ]; then stop_wait_session finish_wait_spinner "前端已就绪" else reset_wait_spinner_state fi return 0 fi sleep 0.125 tick=$((tick + 1)) done WAIT_SPINNER_DETAIL="$(get_log_tail_line "$log_file" "等待前端就绪超时")" clear_wait_spinner if [ -n "$WAIT_SPINNER_DETAIL" ]; then log_note "$WAIT_SPINNER_DETAIL" fi close_wait_session_context "$owns_wait_session" return 1 } wait_for_container_health() { local container_name="$1" local attempts="$2" local interval="$3" local service_name="$4" local attempt=1 local health_status local wait_message="检查${service_name}容器健康检查" local wait_detail_prefix="读取" set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (1/${attempts})" while [ "$attempt" -le "$attempts" ]; do health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)" if [ "$health_status" = "healthy" ] || [ "$health_status" = "running" ]; then finish_wait_spinner "${service_name}容器健康检查已通过" return 0 fi set_wait_detail "${wait_detail_prefix} ${container_name} 容器健康状态 (${attempt}/${attempts})" animate_wait_spinner "${wait_message}" "$interval" attempt=$((attempt + 1)) done clear_wait_spinner return 1 } fail_unreleased_port() { local port="$1" if [ -z "$(collect_port_pids "$port" || true)" ]; then log_error "端口 ${port} 当前环境内未发现占用进程,但端口仍不可用,请检查宿主机或外部环境占用" else log_error "端口 ${port} 清理失败,请检查占用进程" if command -v lsof >/dev/null 2>&1; then lsof -iTCP:"${port}" -sTCP:LISTEN -n -P 2>/dev/null || true elif command -v ss >/dev/null 2>&1; then ss -ltnp "( sport = :${port} )" 2>/dev/null || true fi fi exit 1 } # Service startup helpers wait_for_database_health() { wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" && wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis" } wait_for_postgres_health() { wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" } start_database_services() { docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1 } restart_database_services() { docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1 } start_postgres_service() { docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1 } # Backend lifecycle helpers cleanup_backend_processes() { pkill -f "uvicorn" 2>/dev/null || true } start_backend_with_retry() { local backend_port="$1" local retry=1 while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do cleanup_backend_processes cd "$SCRIPT_DIR/backend" PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 & BACKEND_PID=$! if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then return 0 fi kill "$BACKEND_PID" 2>/dev/null || true animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL" retry=$((retry + 1)) done clear_wait_spinner return 1 } start_ai_provider_service() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local retry=1 set_wait_detail "启动 AI Provider" ensure_ai_provider_image_current while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do if [ "$AI_PROVIDER_RECREATE_REQUIRED" -eq 1 ]; then if recreate_ai_provider_container "$ai_provider_port"; then AI_PROVIDER_RECREATE_REQUIRED=0 if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" && wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then return 0 fi fi elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" && wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then return 0 fi fi if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次" docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true exit 1 fi remove_ai_provider_containers AI_PROVIDER_RECREATE_REQUIRED=1 animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL" retry=$((retry + 1)) done log_error "AI Provider 启动失败" docker logs --tail 20 "$AI_PROVIDER_CONTAINER_NAME" 2>/dev/null || true exit 1 } ensure_database_services_healthy() { local retry=1 while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do if start_database_services && wait_for_database_health; then return 0 fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" print_database_failure_diagnostics exit 1 fi docker restart planet_postgres planet_redis >/dev/null 2>&1 || true animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } ensure_postgres_service_healthy() { local retry=1 while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do if start_postgres_service && wait_for_postgres_health; then return 0 fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" if ! docker_daemon_available; then log_note "PostgreSQL 容器尚未真正开始启动,当前失败更像是 Docker 运行时不可用。" log_docker_daemon_diagnostics else docker logs --tail 20 planet_postgres 2>/dev/null || true fi exit 1 fi docker restart planet_postgres >/dev/null 2>&1 || true animate_wait_spinner "PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } restart_database_service() { local retry=1 set_wait_detail "重启数据库" while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do if restart_database_services && wait_for_database_health; then sleep 3 return 0 fi if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次" print_database_failure_diagnostics exit 1 fi animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试" "$DATABASE_RETRY_INTERVAL" retry=$((retry + 1)) done } start_backend_service() { local backend_port="$1" local backend_port_requested="$2" local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}" set_wait_detail "启动数据库" ensure_database_services_healthy log_success "启动数据库已就绪" sleep 3 start_ai_provider_service "$ai_provider_port" if [ "$backend_port_requested" -eq 1 ]; then kill_port_if_requested "$backend_port" "后端" fi set_wait_detail "启动后端" ensure_uv_backend_deps if ! start_backend_with_retry "$backend_port"; then log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次" tail -10 /tmp/planet_backend.log exit 1 fi } # Port / process helpers validate_port() { local port="$1" if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then log_error "非法端口: ${port}" exit 1 fi } collect_port_pids() { local port="$1" local pids="" local pid="" if command -v lsof >/dev/null 2>&1; then pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" if [ -n "$pids" ]; then printf "%s\n" "$pids" | awk '!seen[$0]++' return 0 fi fi if command -v fuser >/dev/null 2>&1; then pids="$(fuser "${port}/tcp" 2>/dev/null || true)" if [ -n "$pids" ]; then for pid in $pids; do printf "%s\n" "$pid" done | awk '!seen[$0]++' return 0 fi fi if command -v ss >/dev/null 2>&1; then pids="$(ss -ltnp "( sport = :${port} )" 2>/dev/null | sed -nE 's/.*pid=([0-9]+).*/\1/p' || true)" if [ -n "$pids" ]; then printf "%s\n" "$pids" | awk '!seen[$0]++' return 0 fi fi return 1 } terminate_process_tree() { local signal="$1" local pid="$2" local child_pid [ -n "$pid" ] || return 0 kill -0 "$pid" 2>/dev/null || return 0 if command -v pgrep >/dev/null 2>&1; then while IFS= read -r child_pid; do [ -n "$child_pid" ] || continue terminate_process_tree "$signal" "$child_pid" done < <(pgrep -P "$pid" 2>/dev/null || true) fi kill "-${signal}" "$pid" 2>/dev/null || true } can_bind_port() { local port="$1" python3 - "$port" <<'PY' >/dev/null 2>&1 import socket import sys port = int(sys.argv[1]) sockets = [] try: for family, host in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")): try: sock = socket.socket(family) sock.bind((host, port)) sockets.append(sock) except OSError: raise finally: for sock in sockets: try: sock.close() except OSError: pass PY } wait_for_port_release() { local port="$1" local attempt=1 while [ "$attempt" -le "$PORT_RELEASE_ATTEMPTS" ]; do if can_bind_port "$port"; then return 0 fi sleep "$PORT_RELEASE_INTERVAL" attempt=$((attempt + 1)) done return 1 } kill_port_if_requested() { local port="$1" local service_name="$2" local pids="" local pid="" log_warn "检测 ${service_name} 端口 ${port} 占用" pids="$(collect_port_pids "$port" || true)" if [ -z "$pids" ] && can_bind_port "$port"; then log_success "端口 ${port} 未被占用" return 0 fi log_step "发现端口 ${port} 占用,正在终止" for pid in $pids; do terminate_process_tree TERM "$pid" done if wait_for_port_release "$port"; then log_success "端口 ${port} 已释放" return 0 fi log_warn "端口 ${port} 仍被占用,正在强制终止" pids="$(collect_port_pids "$port" || true)" for pid in $pids; do terminate_process_tree KILL "$pid" done if wait_for_port_release "$port"; then log_success "端口 ${port} 已释放" return 0 fi fail_unreleased_port "$port" } frontend_log_indicates_port_conflict() { local log_file="$1" [ -f "$log_file" ] || return 1 grep -q "Port .* is already in use" "$log_file" 2>/dev/null } # Frontend lifecycle helpers cleanup_frontend_processes() { local frontend_port="${1:-$DEFAULT_FRONTEND_PORT}" local tracked_pid="" if [ -f "$FRONTEND_PID_FILE" ]; then tracked_pid="$(cat "$FRONTEND_PID_FILE" 2>/dev/null || true)" if [ -n "$tracked_pid" ]; then terminate_process_tree TERM "$tracked_pid" sleep 1 terminate_process_tree KILL "$tracked_pid" fi rm -f "$FRONTEND_PID_FILE" fi pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite --port ${frontend_port} --strictPort" 2>/dev/null || true pkill -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite" 2>/dev/null || true pkill -f "bun run dev --port ${frontend_port}" 2>/dev/null || true pkill -f "bun run dev" 2>/dev/null || true } start_frontend_with_retry() { local frontend_port="$1" local frontend_port_requested="${2:-0}" local retry=1 while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do cleanup_frontend_processes "$frontend_port" if ! wait_for_port_release "$frontend_port"; then fail_unreleased_port "$frontend_port" fi cd "$SCRIPT_DIR/frontend" : > /tmp/planet_frontend.log nohup "$FRONTEND_RUNTIME_BIN" run dev --port "$frontend_port" --strictPort > /tmp/planet_frontend.log 2>&1 & FRONTEND_PID=$! printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE" if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "/tmp/planet_frontend.log" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then return 0 fi cleanup_frontend_processes "$frontend_port" reset_wait_spinner_state if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "/tmp/planet_frontend.log"; then kill_port_if_requested "$frontend_port" "前端" fi animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL" retry=$((retry + 1)) done clear_wait_spinner return 1 } start_frontend_service() { local frontend_port="$1" local frontend_port_requested="$2" if [ "$frontend_port_requested" -eq 1 ]; then kill_port_if_requested "$frontend_port" "前端" fi start_wait_session "检查前端依赖" ensure_frontend_deps stop_wait_session log_success "前端依赖已就绪" start_wait_session "启动前端服务" set_wait_detail "启动 Vite 开发服务器" if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested"; then stop_wait_session log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次" tail -10 /tmp/planet_frontend.log exit 1 fi stop_wait_session log_success "前端已就绪" } # Argument parsing / command helpers parse_service_args() { BACKEND_PORT="$DEFAULT_BACKEND_PORT" FRONTEND_PORT="$DEFAULT_FRONTEND_PORT" AI_PROVIDER_PORT="$DEFAULT_AI_PROVIDER_PORT" BACKEND_PORT_REQUESTED=0 FRONTEND_PORT_REQUESTED=0 AI_PROVIDER_REQUESTED=0 DATABASE_REQUESTED=0 while [ "$#" -gt 0 ]; do case "$1" in -b|--backend-port) BACKEND_PORT_REQUESTED=1 if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then BACKEND_PORT="$2" shift 2 else shift 1 fi ;; -f|--frontend-port) FRONTEND_PORT_REQUESTED=1 if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then FRONTEND_PORT="$2" shift 2 else shift 1 fi ;; -a|--ai-provider-port) AI_PROVIDER_REQUESTED=1 if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then AI_PROVIDER_PORT="$2" shift 2 else shift 1 fi ;; -d|--database) DATABASE_REQUESTED=1 shift 1 ;; *) log_error "未知参数: $1" exit 1 ;; esac done validate_port "$BACKEND_PORT" validate_port "$FRONTEND_PORT" validate_port "$AI_PROVIDER_PORT" } cleanup_exit_containers() { local exit_containers exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)" if [ -n "$exit_containers" ]; then log_step "清理残留 Exit 容器" echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true log_success "残留容器已清理" fi } stop_container_if_running() { local container_name="$1" local service_name="$2" local container_status="" container_status="$(docker inspect --format '{{.State.Status}}' "$container_name" 2>/dev/null || true)" if [ "$container_status" = "running" ]; then docker stop "$container_name" >/dev/null 2>&1 || true log_halt "${service_name}已停止" fi } stop_backend_service() { if pgrep -f "uvicorn" >/dev/null 2>&1; then cleanup_backend_processes log_halt "后端服务已停止" fi } stop_ai_provider_service() { stop_container_if_running "$AI_PROVIDER_CONTAINER_NAME" "AI Provider" } stop_frontend_service() { if pgrep -f "${SCRIPT_DIR}/frontend/node_modules/.bin/vite|bun run dev" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then cleanup_frontend_processes "$FRONTEND_PORT" fi log_halt "前端服务已停止" return 0 fi cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then cleanup_frontend_processes "$FRONTEND_PORT" fi } create_user() { local username local password local password_confirm local email local generated_email local role="viewer" local is_admin ensure_uv_backend_deps log_step "启动数据库" ensure_postgres_service_healthy sleep 2 log_step "创建用户" read -r -p "用户名: " username if [ -z "$username" ]; then log_error "用户名不能为空" exit 1 fi generated_email="${username}@planet.local" read -r -p "邮箱(留空自动使用 ${generated_email}): " email if [ -z "$email" ]; then email="$generated_email" fi read -r -s -p "密码: " password echo "" read -r -s -p "确认密码: " password_confirm echo "" if [ -z "$password" ]; then log_error "密码不能为空" exit 1 fi if [ "${#password}" -lt 8 ]; then log_error "密码长度不能少于 8 位" exit 1 fi if [ "$password" != "$password_confirm" ]; then log_error "两次输入的密码不一致" exit 1 fi read -r -p "是否为管理员? (Y/N): " is_admin if [[ "$is_admin" =~ ^[Yy]$ ]]; then role="super_admin" fi cd "$SCRIPT_DIR/backend" PLANET_CREATEUSER_USERNAME="$username" \ PLANET_CREATEUSER_PASSWORD="$password" \ PLANET_CREATEUSER_EMAIL="$email" \ PLANET_CREATEUSER_ROLE="$role" \ PYTHONPATH="$SCRIPT_DIR/backend" \ "$SCRIPT_DIR/.venv/bin/python" - <<'PY' import asyncio import os from sqlalchemy import text from app.core.security import get_password_hash from app.db.session import async_session_factory from app.models.user import User async def main(): username = os.environ["PLANET_CREATEUSER_USERNAME"].strip() password = os.environ["PLANET_CREATEUSER_PASSWORD"] email = os.environ["PLANET_CREATEUSER_EMAIL"].strip() role = os.environ["PLANET_CREATEUSER_ROLE"].strip() async with async_session_factory() as session: result = await session.execute( text("SELECT id FROM users WHERE username = :username OR email = :email"), {"username": username, "email": email}, ) if result.fetchone(): raise SystemExit("用户名已存在") user = User( username=username, email=email, password_hash=get_password_hash(password), role=role, is_active=True, ) session.add(user) await session.commit() asyncio.run(main()) PY log_success "用户创建成功" log_note "用户名: ${username}" log_note "角色: ${role}" log_note "邮箱: ${email}" } # Top-level commands start() { parse_service_args "$@" cleanup_exit_containers print_splash start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" log_success "启动完成" log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground" log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs" } stop() { log_warn "停止服务" stop_backend_service stop_ai_provider_service stop_frontend_service stop_container_if_running "planet_postgres" "PostgreSQL" stop_container_if_running "planet_redis" "Redis" log_success "已停止" } restart() { parse_service_args "$@" cleanup_exit_containers if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then stop sleep 1 start return 0 fi log_warn "按需重启服务" if [ "$DATABASE_REQUESTED" -eq 1 ]; then restart_database_service fi if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then stop_ai_provider_service sleep 1 start_ai_provider_service "$AI_PROVIDER_PORT" fi if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then stop_backend_service sleep 1 start_backend_service "$BACKEND_PORT" 1 "$AI_PROVIDER_PORT" fi if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then stop_frontend_service sleep 1 start_frontend_service "$FRONTEND_PORT" 1 fi echo "" log_success "重启完成" if [ "$DATABASE_REQUESTED" -eq 1 ]; then log_note "数据库: planet_postgres, planet_redis" fi if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}" fi if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then log_note "后端: http://localhost:${BACKEND_PORT}" fi if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then log_note "前端: http://localhost:${FRONTEND_PORT}" fi } health() { echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}" docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" echo "" echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}" if curl -s http://localhost:8000/health > /dev/null 2>&1; then echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}" else echo -e "${DIM} 后端:${NC} ${RED}offline${NC}" fi if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}" else echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}" fi if curl -s http://localhost:3000 > /dev/null 2>&1; then echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}" else echo -e "${DIM} 前端:${NC} ${RED}offline${NC}" fi } log() { case "$1" in -f|--frontend) log_step "查看前端日志" log_note "按 Ctrl+C 退出" tail -f /tmp/planet_frontend.log ;; -b|--backend) log_step "查看后端日志" log_note "按 Ctrl+C 退出" tail -f /tmp/planet_backend.log ;; -a|--ai-provider) log_step "查看 AI Provider 日志" log_note "按 Ctrl+C 退出" docker logs -f planet_aiprovider ;; *) log_step "查看最近日志" log_note "后端" tail -20 /tmp/planet_backend.log 2>/dev/null || log_note "无日志" log_note "AI Provider" docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志" log_note "前端" tail -20 /tmp/planet_frontend.log 2>/dev/null || log_note "无日志" ;; esac } case "$1" in start) shift start "$@" ;; stop) stop ;; restart) shift restart "$@" ;; createuser) create_user ;; health) health ;; log) log "$2" ;; *) log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}" log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a " log_note "stop 停止服务" log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d" log_note "createuser 交互创建用户" log_note "health 检查健康状态" log_note "log 查看日志" log_note "log -f 查看前端日志" log_note "log -b 查看后端日志" log_note "log -a 查看 AI Provider 日志" ;; esac