<# .SYNOPSIS 本地一键启动开发环境:Go 后端 + Astro 前端(可选 CLIP 视觉服务)。 .DESCRIPTION - 后端:编译后直接运行二进制(不走 go run,进程可精确管理),监听 8090/8091/8092 - 前端:node 直接拉起 astro dev,端口固定 4321(与后端 CORS 白名单一致) - 可选:-Vision 同时拉起 fashion-siglip 向量服务,并给后端注入 VISION_ENABLED=true - 日志写到 .logs\,PID 登记到 .run\,随时可用 dev\stop.ps1 一键关停 - 启动完成后自检 CORS:前端端口不在白名单里会直接告警(这是「页面无数据」的头号原因) .EXAMPLE .\dev\dev.ps1 起后端 + 前端 .EXAMPLE .\dev\dev.ps1 -Vision -Open 连视觉服务一起起,并自动打开浏览器 .EXAMPLE .\dev\dev.ps1 frontend 只起前端(后端已经单独在跑) .EXAMPLE .\dev\dev.ps1 -Force 端口被占时先杀掉占用进程再启动 #> param( [Parameter(Position = 0)] [ValidateSet('all', 'backend', 'frontend', 'vision')] [string]$Target = 'all', [switch]$Vision, [switch]$SkipBuild, [switch]$Open, [switch]$Force ) $ErrorActionPreference = 'Stop' $CliRoot = Split-Path -Parent $PSScriptRoot . (Join-Path $CliRoot 'lib\common.ps1') $envMap = Import-LocalEnv (Join-Path $CliRoot 'config\local.env') $svc = Resolve-Services -EnvMap $envMap -Root $CliRoot $backendPort = [int](Get-Setting $envMap 'BACKEND_PORT' '8090') $frontendPort = [int](Get-Setting $envMap 'FRONTEND_PORT' '4321') $visionPort = [int](Get-Setting $envMap 'VISION_PORT' '8900') if ($Open.IsPresent -eq $false -and (Get-Setting $envMap 'OPEN_BROWSER' '0') -eq '1') { $Open = $true } # ---------------------------------------------------------------- 通用小工具 # Assert-CanStart:'start' 该起;'skip' 已在跑;'abort' 有冲突且未授权强杀。 function Assert-CanStart { param([int]$Port, [string]$Name) if (-not (Test-PortInUse $Port)) { return 'start' } $owner = Get-PortOwnerPid $Port $saved = Get-ServicePid $Name if ($saved -gt 0 -and $saved -eq $owner) { Write-Note "$Name 已在运行(PID $owner,端口 $Port),跳过" return 'skip' } if ($Force) { Write-Warn "端口 $Port 被 PID $owner 占用,按 -Force 终止" Stop-PidTree $owner | Out-Null if (Test-PortInUse $Port) { Write-Fail "端口 $Port 仍被占用,可能是系统服务或无权限" return 'abort' } return 'start' } Write-Fail "端口 $Port 已被 PID $owner 占用(不是本脚本启动的)" Write-Note "释放后再启动:.\dev\dev.ps1 -Force 或 手动结束该进程" return 'abort' } function Test-ToolVersion { param([string]$Name, [string]$Hint) $cmd = Get-Command $Name -ErrorAction SilentlyContinue if (-not $cmd) { Write-Fail "找不到 $Name —— $Hint" return $false } return $true } # ---------------------------------------------------------------- 后端 function Start-Backend { $action = Assert-CanStart -Port $backendPort -Name 'backend' if ($action -eq 'skip') { return $true } if ($action -eq 'abort') { return $false } $exe = Join-Path $BinDir 'fashionapi-dev.exe' if (-not $SkipBuild) { Write-Step '编译后端(go build ./cmd/server)' Push-Location $svc.Backend.Path try { & go build -o $exe ./cmd/server $code = $LASTEXITCODE } finally { Pop-Location } if ($code -ne 0) { Write-Fail 'Go 编译失败,见上方错误' return $false } Write-Ok "编译完成:$exe" } elseif (-not (Test-Path $exe)) { Write-Fail "指定了 -SkipBuild 但没有编译产物:$exe" return $false } # 视觉服务开关通过环境变量传给后端(config.yml 里默认 false) if ($Vision) { $env:VISION_ENABLED = 'true' $env:VISION_ENDPOINT = "http://127.0.0.1:$visionPort" } else { Remove-Item Env:\VISION_ENABLED -ErrorAction SilentlyContinue } Write-Step "启动后端(公开 $backendPort / SSG 8091 / 后台 8092)" Start-LoggedProcess -Name 'backend' -FilePath $exe -ArgumentList @() -WorkingDirectory $svc.Backend.Path | Out-Null Remove-Item Env:\VISION_ENABLED -ErrorAction SilentlyContinue $health = "http://127.0.0.1:$backendPort/api/health" if (Wait-HttpOk -Url $health -TimeoutSec 60) { Write-Ok '后端就绪' return $true } Write-Fail '后端 60 秒内未就绪(常见原因:数据库连不上、config.local.yml 缺密码)' Show-LogTail -Name 'backend' -Lines 20 return $false } # ---------------------------------------------------------------- 前端 # Get-AstroDevLock:读 astro 自己的 dev 锁(/.astro/dev.json)。 # astro 7 起 dev server 有单实例锁:已有实例在跑时直接报错退出, # 即使端口没冲突也会起不来——所以必须显式处理,不能只查端口。 function Get-AstroDevLock { param([string]$FrontendPath) $f = Join-Path $FrontendPath '.astro\dev.json' if (-not (Test-Path $f)) { return $null } try { $j = Get-Content -Path $f -Raw -ErrorAction Stop | ConvertFrom-Json $lockPid = 0 if ($null -eq $j.pid) { return $null } $lockPid = [int]$j.pid $alive = [bool](Get-Process -Id $lockPid -ErrorAction SilentlyContinue) $lockPort = 0 if ($null -ne $j.port) { $lockPort = [int]$j.port } $lockUrl = '' if ($null -ne $j.url) { $lockUrl = [string]$j.url } return [PSCustomObject]@{ Pid = $lockPid; Port = $lockPort; Url = $lockUrl; Alive = $alive } } catch { return $null } } function Remove-AstroDevLock { param([string]$FrontendPath) $f = Join-Path $FrontendPath '.astro\dev.json' Remove-Item $f -Force -ErrorAction SilentlyContinue } function Start-Frontend { $action = Assert-CanStart -Port $frontendPort -Name 'frontend' if ($action -eq 'skip') { return $true } if ($action -eq 'abort') { return $false } # astro 单实例锁:端口没冲突也可能起不来 $lock = Get-AstroDevLock $svc.Frontend.Path if ($lock -and $lock.Alive) { if ($lock.Port -eq $frontendPort) { Write-Note "前端已在运行(PID $($lock.Pid),端口 $($lock.Port)),跳过" return $true } if (-not $Force) { Write-Fail "已有一个 astro dev 在端口 $($lock.Port) 运行(PID $($lock.Pid),$($lock.Url))" Write-Note "它不是本脚本启动的,且端口不在 CORS 白名单里 → 浏览器调接口会被拦。" Write-Note "接管并重起在 $frontendPort:.\dev\dev.ps1 frontend -Force" return $false } Write-Warn "接管:终止旧的 astro dev(PID $($lock.Pid),端口 $($lock.Port))" Stop-PidTree $lock.Pid | Out-Null Start-Sleep -Milliseconds 500 Remove-AstroDevLock $svc.Frontend.Path } $nodeModules = Join-Path $svc.Frontend.Path 'node_modules' if (-not (Test-Path $nodeModules)) { Write-Step '安装前端依赖(npm ci)' Push-Location $svc.Frontend.Path try { & npm.cmd ci $code = $LASTEXITCODE } finally { Pop-Location } if ($code -ne 0) { Write-Fail 'npm ci 失败' return $false } } $astro = Join-Path $svc.Frontend.Path 'node_modules\astro\bin\astro.mjs' if (-not (Test-Path $astro)) { Write-Fail "找不到 astro CLI:$astro(依赖可能损坏,删掉 node_modules 重装)" return $false } Write-Step "启动前端(astro dev,端口 $frontendPort)" Start-LoggedProcess -Name 'frontend' -FilePath 'node.exe' ` -ArgumentList @($astro, 'dev', '--port', "$frontendPort") ` -WorkingDirectory $svc.Frontend.Path | Out-Null $probe = "http://127.0.0.1:$frontendPort/cn/runway-looks/" if (Wait-HttpOk -Url $probe -TimeoutSec 120) { Write-Ok '前端就绪' return $true } Write-Fail '前端 120 秒内未就绪' Show-LogTail -Name 'frontend' -Lines 20 return $false } # ---------------------------------------------------------------- 视觉服务 function Start-Vision { $action = Assert-CanStart -Port $visionPort -Name 'vision' if ($action -eq 'skip') { return $true } if ($action -eq 'abort') { return $false } $uvicorn = Join-Path $svc.Vision.Path '.venv\Scripts\uvicorn.exe' if (-not (Test-Path $uvicorn)) { Write-Fail "找不到 $uvicorn" Write-Note "先在 $($svc.Vision.Path) 建虚拟环境并装依赖(torch / open_clip_torch / fastapi / uvicorn)" return $false } Write-Step "启动视觉服务(CLIP,端口 $visionPort,首次加载模型较慢)" Start-LoggedProcess -Name 'vision' -FilePath $uvicorn ` -ArgumentList @('app:app', '--host', '127.0.0.1', '--port', "$visionPort", '--workers', '1') ` -WorkingDirectory $svc.Vision.Path | Out-Null $health = "http://127.0.0.1:$visionPort/health" if (Wait-HttpOk -Url $health -TimeoutSec 180) { Write-Ok '视觉服务就绪' return $true } Write-Fail '视觉服务 180 秒内未就绪' Show-LogTail -Name 'vision' -Lines 20 return $false } # ---------------------------------------------------------------- 主流程 Write-Host '' Write-Host ' app-cli / 本地开发环境' -ForegroundColor White Write-Host ' ----------------------------------------' -ForegroundColor DarkGray Write-Step '检查依赖' $ready = $true if ($Target -eq 'all' -or $Target -eq 'backend') { if (-not (Test-ToolVersion 'go' '安装 Go 1.22+')) { $ready = $false } } if ($Target -eq 'all' -or $Target -eq 'frontend') { if (-not (Test-ToolVersion 'node' '安装 Node 22.12+')) { $ready = $false } if (-not (Test-ToolVersion 'npm' '随 Node 一起安装')) { $ready = $false } } if (-not $ready) { exit 1 } Write-Step '检查仓库路径' foreach ($item in @( @{ S = $svc.Backend; Need = ($Target -eq 'all' -or $Target -eq 'backend') }, @{ S = $svc.Frontend; Need = ($Target -eq 'all' -or $Target -eq 'frontend') }, @{ S = $svc.Vision; Need = ($Vision -or $Target -eq 'vision') } )) { if (-not $item.Need) { continue } if ($item.S.IsValid) { Write-Ok "$($item.S.Name): $($item.S.Path)" } else { Write-Fail "$($item.S.Name) 路径无效:$($item.S.Path)(缺标记文件 $($item.S.Marker))" $ready = $false } } if (-not $ready) { Write-Note '在 config/local.env 里显式写 BACKEND_DIR / FRONTEND_DIR / VISION_DIR 纠正路径' exit 1 } $failed = @() if ($Target -eq 'all' -or $Target -eq 'backend') { if (-not (Start-Backend)) { $failed += 'backend' } } if ($Target -eq 'all' -or $Target -eq 'frontend') { if (-not (Start-Frontend)) { $failed += 'frontend' } } if ($Vision -or $Target -eq 'vision') { if (-not (Start-Vision)) { $failed += 'vision' } } # ---------------------------------------------------------------- 结果汇总 Write-Host '' if ($failed.Count -gt 0) { Write-Fail ("启动失败:" + ($failed -join ', ')) Write-Note '日志在 .logs\ 目录;排查完用 .\dev\dev.ps1 -Force 重来' exit 1 } Write-Host ' 已就绪' -ForegroundColor White Write-Host ' ----------------------------------------' -ForegroundColor DarkGray if ($Target -eq 'all' -or $Target -eq 'backend') { Write-Host (" 前台页面 http://localhost:" + $frontendPort + "/cn/runway-looks/") Write-Host (" 公开接口 http://localhost:" + $backendPort + "/api/v1/public/runway-looks?page=1") Write-Host (" 管理后台 http://localhost:8092/admin") Write-Host (" 健康检查 http://localhost:" + $backendPort + "/api/health") } if ($Vision -or $Target -eq 'vision') { Write-Host (" 视觉服务 http://127.0.0.1:" + $visionPort + "/health") } Write-Host '' # CORS 自检:前端端口没被后端放行时,浏览器会整条拦掉接口响应 if (Test-PortInUse $backendPort) { $origin = "http://localhost:$frontendPort" $url = "http://127.0.0.1:$backendPort/api/v1/public/runway-looks?page=1" $acao = Get-CorsOrigin -Url $url -Origin $origin if ($acao -eq $origin) { Write-Ok "CORS 已放行 $origin" } else { Write-Warn "CORS 未放行 $origin(后端回显:'$acao')" Write-Note "浏览器会拦截接口 → 页面显示「无数据」。" Write-Note "修:在 backend/configs/config.local.yml 的 cors.allow_origins 里加一行 $origin" } } Write-Host '' Write-Note '日志:.logs\backend.log / .logs\frontend.log / .logs\vision.log' Write-Note '停止:.\dev\stop.ps1 状态:.\dev\status.ps1' if ($Open) { $target = "http://localhost:$frontendPort/cn/runway-looks/" Write-Note "打开浏览器:$target" Start-Process $target | Out-Null }