소스 검색

fix:处理一键式启动前后端交互失败问题

yangxiaolong 1 개월 전
부모
커밋
603153f59c
5개의 변경된 파일410개의 추가작업 그리고 22개의 파일을 삭제
  1. 1 1
      frontend/apps/admin/package.json
  2. 1 1
      frontend/apps/h5/package.json
  3. 1 1
      frontend/package.json
  4. 241 12
      scripts/dev.ps1
  5. 166 7
      scripts/dev.sh

+ 1 - 1
frontend/apps/admin/package.json

@@ -4,7 +4,7 @@
   "version": "0.1.0",
   "type": "module",
   "scripts": {
-    "dev": "vite --host 127.0.0.1 --port 5174",
+    "dev": "vite --host 127.0.0.1 --port 5174 --strictPort",
     "build": "vue-tsc -b && vite build",
     "typecheck": "vue-tsc -b"
   },

+ 1 - 1
frontend/apps/h5/package.json

@@ -4,7 +4,7 @@
   "version": "0.1.0",
   "type": "module",
   "scripts": {
-    "dev": "vite --host 127.0.0.1 --port 5173",
+    "dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
     "build": "vue-tsc -b && vite build",
     "typecheck": "vue-tsc -b"
   },

+ 1 - 1
frontend/package.json

@@ -6,7 +6,7 @@
     "node": ">=22 <23"
   },
   "scripts": {
-    "dev": "pnpm --parallel --filter './apps/*' dev",
+    "dev": "pnpm --parallel --filter \"./apps/*\" dev",
     "dev:h5": "pnpm --filter @zbt/h5 dev",
     "dev:admin": "pnpm --filter @zbt/admin dev",
     "build": "pnpm --workspace-concurrency=1 --recursive build",

+ 241 - 12
scripts/dev.ps1

@@ -1,21 +1,250 @@
-$ErrorActionPreference = "Stop"
+[CmdletBinding()]
+param(
+    # 仅用于自动化验证;正常启动时保持 0,由 Ctrl+C 结束。
+    [ValidateRange(0, 3600)]
+    [int]$RunForSeconds = 0
+)
+
+$ErrorActionPreference = "Stop"
+
 $ProjectRoot = Split-Path -Parent $PSScriptRoot
 $FrontendPath = Join-Path $ProjectRoot "frontend"
-$FrontendJob = Start-Job -ScriptBlock {
-    param($WorkingDirectory)
-    Set-Location $WorkingDirectory
-    pnpm dev
-} -ArgumentList $FrontendPath
+$BackendPath = Join-Path $ProjectRoot "backend"
+$ManagedProcessIds = [System.Collections.Generic.HashSet[int]]::new()
+$StartedProcesses = @()
+
+function Get-DevPortListeners {
+    param([int]$Port)
+
+    return @(
+        Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
+    )
+}
+
+function Test-DevPortListening {
+    param([int]$Port)
+
+    $Endpoints = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
+    return $null -ne ($Endpoints | Where-Object { $_.Port -eq $Port } | Select-Object -First 1)
+}
+
+function Assert-DevPortAvailable {
+    param(
+        [int]$Port,
+        [string]$ApplicationName
+    )
+
+    if (-not (Test-DevPortListening -Port $Port)) {
+        return
+    }
+
+    # 只有端口确实被占用时才调用较慢的 Windows 连接查询,以便展示 PID。
+    $Listeners = Get-DevPortListeners -Port $Port
+
+    $Owners = foreach ($Listener in $Listeners) {
+        $Process = Get-CimInstance Win32_Process `
+            -Filter "ProcessId=$($Listener.OwningProcess)" `
+            -ErrorAction SilentlyContinue
+        if ($null -ne $Process) {
+            "PID $($Process.ProcessId):$($Process.CommandLine)"
+        }
+        else {
+            "PID $($Listener.OwningProcess)"
+        }
+    }
+    throw "$ApplicationName 端口 $Port 已被占用。请先结束以下进程后重试:`n$($Owners -join "`n")"
+}
+
+function Update-ManagedProcessTree {
+    # Uvicorn reload、pnpm 和 Vite 都会派生子进程。持续记录完整进程树,
+    # 即使某个中间父进程先退出,最终清理时仍能找到它已经创建的子进程。
+    # Win32_Process 单次查询在部分 Windows 机器上约需半秒,逐个父 PID
+    # 查询会明显拖慢启动。因此每轮只取一次快照,在内存中展开进程树。
+    $AllProcesses = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)
+    $AddedProcess = $true
+    while ($AddedProcess) {
+        $AddedProcess = $false
+        foreach ($Child in $AllProcesses) {
+            if (-not $ManagedProcessIds.Contains([int]$Child.ParentProcessId)) {
+                continue
+            }
+
+            $ChildId = [int]$Child.ProcessId
+            if ($ManagedProcessIds.Add($ChildId)) {
+                $AddedProcess = $true
+            }
+        }
+    }
+}
+
+function Test-ProcessRunning {
+    param([System.Diagnostics.Process]$Process)
+
+    try {
+        $Process.Refresh()
+        return -not $Process.HasExited
+    }
+    catch {
+        return $false
+    }
+}
+
+function Test-HttpEndpoint {
+    param([string]$Uri)
+
+    try {
+        $Response = Invoke-WebRequest `
+            -Uri $Uri `
+            -UseBasicParsing `
+            -TimeoutSec 2 `
+            -ErrorAction Stop
+        return $Response.StatusCode -ge 200 -and $Response.StatusCode -lt 500
+    }
+    catch {
+        return $false
+    }
+}
+
+function Wait-ForDevelopmentServices {
+    param(
+        [System.Diagnostics.Process]$FrontendProcess,
+        [System.Diagnostics.Process]$BackendProcess,
+        [int]$TimeoutSeconds = 45
+    )
+
+    $Deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+    while ((Get-Date) -lt $Deadline) {
+        Update-ManagedProcessTree
+
+        if (-not (Test-ProcessRunning -Process $FrontendProcess)) {
+            throw "前端开发服务器启动失败,请检查上方 pnpm 输出。"
+        }
+        if (-not (Test-ProcessRunning -Process $BackendProcess)) {
+            throw "后端开发服务器启动失败,请检查上方 Uvicorn 输出。"
+        }
+
+        $H5Ready = Test-HttpEndpoint -Uri "http://127.0.0.1:5173/"
+        $AdminReady = Test-HttpEndpoint -Uri "http://127.0.0.1:5174/"
+        $BackendReady = Test-HttpEndpoint -Uri "http://127.0.0.1:8000/docs"
+        if ($H5Ready -and $AdminReady -and $BackendReady) {
+            return
+        }
+
+        Start-Sleep -Milliseconds 300
+    }
+
+    throw "开发服务在 $TimeoutSeconds 秒内未全部就绪,请检查上方启动日志。"
+}
+
+function Stop-ManagedProcesses {
+    Write-Host "`n正在停止智保通开发服务..." -ForegroundColor Yellow
+
+    # 多刷新几次,覆盖清理开始前恰好生成的 reload/Vite 子进程。
+    1..3 | ForEach-Object {
+        Update-ManagedProcessTree
+        Start-Sleep -Milliseconds 100
+    }
+
+    $Processes = @(
+        Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
+            Where-Object {
+                $_.ProcessId -ne $PID -and
+                $ManagedProcessIds.Contains([int]$_.ProcessId)
+            }
+    )
+
+    # 子进程通常晚于父进程创建;倒序停止可避免父进程退出后留下孤儿。
+    $Processes |
+        Sort-Object CreationDate -Descending |
+        ForEach-Object {
+            Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
+        }
+
+    # 确认本次脚本启动的三个监听端口已经释放。
+    $Deadline = (Get-Date).AddSeconds(5)
+    while ((Get-Date) -lt $Deadline) {
+        $AnyPortListening = $false
+        foreach ($Port in 5173, 5174, 8000) {
+            if (Test-DevPortListening -Port $Port) {
+                $AnyPortListening = $true
+                break
+            }
+        }
+        if (-not $AnyPortListening) {
+            break
+        }
+        Start-Sleep -Milliseconds 200
+    }
+
+    Write-Host "智保通开发服务已停止。" -ForegroundColor Green
+}
+
+foreach ($Service in @(
+    @{ Port = 5173; Name = "H5" },
+    @{ Port = 5174; Name = "管理后台" },
+    @{ Port = 8000; Name = "后端 API" }
+)) {
+    Assert-DevPortAvailable -Port $Service.Port -ApplicationName $Service.Name
+}
+
+$PnpmPath = (Get-Command pnpm.cmd -ErrorAction Stop).Source
+$UvPath = (Get-Command uv.exe -ErrorAction Stop).Source
 
-Push-Location (Join-Path $ProjectRoot "backend")
 try {
+    Write-Host "正在启动 H5、管理后台和后端 API..." -ForegroundColor Cyan
+
+    $FrontendProcess = Start-Process `
+        -FilePath $PnpmPath `
+        -ArgumentList "dev" `
+        -WorkingDirectory $FrontendPath `
+        -NoNewWindow `
+        -PassThru
+    $StartedProcesses += $FrontendProcess
+    [void]$ManagedProcessIds.Add($FrontendProcess.Id)
+
+    $BackendProcess = Start-Process `
+        -FilePath $UvPath `
+        -ArgumentList @(
+            "run", "uvicorn", "zbt.main:app",
+            "--host", "127.0.0.1",
+            "--port", "8000",
+            "--reload"
+        ) `
+        -WorkingDirectory $BackendPath `
+        -NoNewWindow `
+        -PassThru
+    $StartedProcesses += $BackendProcess
+    [void]$ManagedProcessIds.Add($BackendProcess.Id)
+
+    Wait-ForDevelopmentServices `
+        -FrontendProcess $FrontendProcess `
+        -BackendProcess $BackendProcess
+
+    Write-Host ""
+    Write-Host "智保通开发服务已全部启动:" -ForegroundColor Green
     Write-Host "H5:http://127.0.0.1:5173"
     Write-Host "管理后台:http://127.0.0.1:5174"
-    Write-Host "后端API:http://127.0.0.1:8000/docs"
-    uv run uvicorn zbt.main:app --host 127.0.0.1 --port 8000 --reload
+    Write-Host "后端 API:http://127.0.0.1:8000/docs"
+    Write-Host "按 Ctrl+C 可一次性停止全部服务。" -ForegroundColor DarkGray
+
+    $ReadyAt = Get-Date
+    while ($true) {
+        Update-ManagedProcessTree
+
+        foreach ($StartedProcess in $StartedProcesses) {
+            if (-not (Test-ProcessRunning -Process $StartedProcess)) {
+                throw "开发服务进程 PID $($StartedProcess.Id) 意外退出,请检查上方日志。"
+            }
+        }
+
+        if ($RunForSeconds -gt 0 -and
+            ((Get-Date) - $ReadyAt).TotalSeconds -ge $RunForSeconds) {
+            break
+        }
+
+        Start-Sleep -Milliseconds 500
+    }
 }
 finally {
-    Pop-Location
-    Stop-Job -Job $FrontendJob -ErrorAction SilentlyContinue
-    Remove-Job -Job $FrontendJob -Force -ErrorAction SilentlyContinue
+    Stop-ManagedProcesses
 }

+ 166 - 7
scripts/dev.sh

@@ -3,20 +3,179 @@ set -euo pipefail
 
 project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
 frontend_pid=""
+backend_pid=""
+cleanup_done=0
+run_for_seconds="${ZBT_DEV_RUN_SECONDS:-0}"
+
+die() {
+    echo "错误:$*" >&2
+    exit 1
+}
+
+require_command() {
+    local command_name="$1"
+    command -v "$command_name" >/dev/null 2>&1 || \
+        die "未找到 $command_name,请先安装并确保它位于 PATH 中。"
+}
+
+assert_port_available() {
+    local port="$1"
+    local application_name="$2"
+    local listeners
+
+    listeners="$(lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)"
+    if [[ -n "$listeners" ]]; then
+        echo "$listeners" >&2
+        die "$application_name 端口 $port 已被占用,请先结束上方进程后重试。"
+    fi
+}
+
+process_group_is_alive() {
+    local group_id="$1"
+    [[ -n "$group_id" ]] && kill -0 -- "-$group_id" 2>/dev/null
+}
+
+stop_process_group() {
+    local group_id="$1"
+    if process_group_is_alive "$group_id"; then
+        kill -TERM -- "-$group_id" 2>/dev/null || true
+    fi
+}
 
 cleanup() {
-    if [[ -n "$frontend_pid" ]] && kill -0 "$frontend_pid" 2>/dev/null; then
-        kill "$frontend_pid"
+    local exit_status=$?
+    local deadline
+
+    if (( cleanup_done )); then
+        return "$exit_status"
+    fi
+    cleanup_done=1
+    trap - INT TERM HUP
+
+    if [[ -n "$frontend_pid" || -n "$backend_pid" ]]; then
+        echo
+        echo "正在停止智保通开发服务..."
+    fi
+
+    stop_process_group "$frontend_pid"
+    stop_process_group "$backend_pid"
+
+    deadline=$((SECONDS + 5))
+    while (( SECONDS < deadline )); do
+        if ! process_group_is_alive "$frontend_pid" && \
+            ! process_group_is_alive "$backend_pid"; then
+            break
+        fi
+        sleep 0.2
+    done
+
+    if process_group_is_alive "$frontend_pid"; then
+        kill -KILL -- "-$frontend_pid" 2>/dev/null || true
+    fi
+    if process_group_is_alive "$backend_pid"; then
+        kill -KILL -- "-$backend_pid" 2>/dev/null || true
+    fi
+
+    if [[ -n "$frontend_pid" ]]; then
         wait "$frontend_pid" 2>/dev/null || true
     fi
+    if [[ -n "$backend_pid" ]]; then
+        wait "$backend_pid" 2>/dev/null || true
+    fi
+
+    if [[ -n "$frontend_pid" || -n "$backend_pid" ]]; then
+        echo "智保通开发服务已停止。"
+    fi
+
+    return "$exit_status"
+}
+
+http_ready() {
+    local url="$1"
+    curl --fail --silent --show-error --max-time 2 "$url" >/dev/null 2>&1
+}
+
+wait_for_services() {
+    local deadline=$((SECONDS + 45))
+
+    while (( SECONDS < deadline )); do
+        kill -0 "$frontend_pid" 2>/dev/null || \
+            die "前端开发服务器启动失败,请检查上方 pnpm 输出。"
+        kill -0 "$backend_pid" 2>/dev/null || \
+            die "后端开发服务器启动失败,请检查上方 Uvicorn 输出。"
+
+        if http_ready "http://127.0.0.1:5173/" && \
+            http_ready "http://127.0.0.1:5174/" && \
+            http_ready "http://127.0.0.1:8000/docs"; then
+            return 0
+        fi
+
+        sleep 0.3
+    done
+
+    die "开发服务在 45 秒内未全部就绪,请检查上方启动日志。"
 }
-trap cleanup EXIT INT TERM
 
-(cd "$project_root/frontend" && exec pnpm dev) &
+case "$run_for_seconds" in
+    ''|*[!0-9]*)
+        die "ZBT_DEV_RUN_SECONDS 必须是非负整数。"
+        ;;
+esac
+
+trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
+trap 'exit 129' HUP
+
+require_command pnpm
+require_command uv
+require_command curl
+require_command lsof
+
+assert_port_available 5173 "H5"
+assert_port_available 5174 "管理后台"
+assert_port_available 8000 "后端 API"
+
+echo "正在启动 H5、管理后台和后端 API..."
+
+# Bash 3.2(macOS 系统自带版本)没有 setsid。临时启用 job control,
+# 可让两个后台任务各自成为独立进程组,清理时连同 Vite/Uvicorn 子进程一起结束。
+set -m
+(
+    cd "$project_root/frontend"
+    exec pnpm dev
+) &
 frontend_pid=$!
 
+(
+    cd "$project_root/backend"
+    exec uv run uvicorn zbt.main:app \
+        --host 127.0.0.1 \
+        --port 8000 \
+        --reload
+) &
+backend_pid=$!
+set +m
+
+wait_for_services
+
+echo
+echo "智保通开发服务已全部启动:"
 echo "H5:http://127.0.0.1:5173"
 echo "管理后台:http://127.0.0.1:5174"
-echo "后端API:http://127.0.0.1:8000/docs"
-cd "$project_root/backend"
-uv run uvicorn zbt.main:app --host 127.0.0.1 --port 8000 --reload
+echo "后端 API:http://127.0.0.1:8000/docs"
+echo "按 Ctrl+C 可一次性停止全部服务。"
+
+ready_at=$SECONDS
+while true; do
+    kill -0 "$frontend_pid" 2>/dev/null || \
+        die "前端开发服务器意外退出,请检查上方日志。"
+    kill -0 "$backend_pid" 2>/dev/null || \
+        die "后端开发服务器意外退出,请检查上方日志。"
+
+    if (( run_for_seconds > 0 && SECONDS - ready_at >= run_for_seconds )); then
+        break
+    fi
+
+    sleep 0.5
+done