Files
app-cli/dev/dev.ps1
toom1996 b993b4d61e update
2026-10-22 19:58:53 +08:00

493 lines
19 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<#
.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
端口被占时先杀掉占用进程再启动
.EXAMPLE
.\dev\dev.ps1 backend -Foreground
前台运行后端:日志(含 gin 路由、8092 管理后台请求)直接打在终端,Ctrl+C 结束
.EXAMPLE
.\dev\dev.ps1 frontend -Foreground
前台运行前端:astro 的请求日志、编译告警直接打在终端,Ctrl+C 结束
.EXAMPLE
.\dev\dev.ps1 -Foreground
推荐日常调试:后端 + 前端都前台跑,两边输出一起打在当前终端
#>
param(
[Parameter(Position = 0)]
[ValidateSet('all', 'backend', 'frontend', 'vision')]
[string]$Target = 'all',
[switch]$Vision,
[switch]$SkipBuild,
[switch]$Open,
[switch]$Force,
# 前台模式:不重定向、不隐藏窗口,服务输出直接打在当前终端(Ctrl+C 结束)。
# 不带服务名(all)时,前后端输出会一起打在当前终端,适合日常调试。
[switch]$Foreground
)
$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
if ($Foreground -and $Target -eq 'vision') {
Write-Fail '视觉服务不支持 -Foreground(用 .\vision\vision.ps1 logs 查看输出)'
exit 1
}
# 前台模式下正在运行的进程(all 模式会有多个,输出共用当前控制台)
$script:fgProcs = @()
$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) {
# 前台模式的语义就是「把输出占在我的终端」,已在后台跑的那份必须让位。
# 否则会出现「全部跳过 → 前台循环没进程 → 脚本直接退出」的假象。
if ($Foreground) {
Write-Note "$Name 已在后台运行(PID $owner),前台接管:先停止再前台启动"
Stop-NamedService $Name | Out-Null
Start-Sleep -Milliseconds 600
if (Test-PortInUse $Port) {
Write-Fail "端口 $Port 仍未释放,无法前台接管"
return 'abort'
}
return 'start'
}
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
}
if ($Foreground) {
Write-Step "前台运行后端(公开 $backendPort / SSG 8091 / 后台 8092)"
# 只起后端:直接阻塞在这个进程上,Ctrl+C 就是结束进程,最干净。
if ($Target -eq 'backend') {
Write-Note '输出直接打在下面;Ctrl+C 结束'
Push-Location $svc.Backend.Path
try {
& $exe
} finally {
Pop-Location
Remove-Item Env:\VISION_ENABLED -ErrorAction SilentlyContinue
}
exit 0
}
# all 模式:-NoNewWindow 让子进程继承当前控制台,输出实时可见(不重定向)
$p = Start-Process -FilePath $exe -WorkingDirectory $svc.Backend.Path -NoNewWindow -PassThru
$script:fgProcs += [PSCustomObject]@{ Name = 'backend'; Proc = $p; Reported = $false }
Remove-Item Env:\VISION_ENABLED -ErrorAction SilentlyContinue
Write-Ok "后端已在前台启动(PID $($p.Id))"
return $true
}
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 锁(<frontend>/.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 }
# 前台接管时旧实例刚被停掉,锁文件可能残留(PID 被复用会误判「已在运行」)
if ($Foreground) { Remove-AstroDevLock $svc.Frontend.Path }
# 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
}
if ($Foreground) {
Write-Step "前台运行前端(astro dev,端口 $frontendPort)"
if ($Target -eq 'frontend') {
Write-Note '输出直接打在下面;Ctrl+C 结束'
Push-Location $svc.Frontend.Path
try {
& node.exe $astro dev --port "$frontendPort"
} finally {
Pop-Location
}
exit 0
}
$p = Start-Process -FilePath 'node.exe' `
-ArgumentList @($astro, 'dev', '--port', "$frontendPort") `
-WorkingDirectory $svc.Frontend.Path -NoNewWindow -PassThru
$script:fgProcs += [PSCustomObject]@{ Name = 'frontend'; Proc = $p; Reported = $false }
Write-Ok "前端已在前台启动(PID $($p.Id))"
return $true
}
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' }
}
# ---------------------------------------------------------------- 前台聚合输出
# 前台模式下半套启动没有意义(端口冲突等):直接报错退出,不留孤儿进程。
if ($Foreground -and $failed.Count -gt 0) {
Write-Fail ("前台启动失败:" + ($failed -join ', '))
Write-Note '常见原因:8090/4323 已被之前的进程占用,先 .\dev\stop.ps1 再重试'
exit 1
}
# 兜底:前台模式必须有进程在跑,否则静默退出会让人误以为脚本自己崩了。
if ($Foreground -and $script:fgProcs.Count -eq 0) {
Write-Fail '前台模式没有启动任何进程,请查看上面的输出'
exit 1
}
# all + -Foreground:子进程共用当前控制台,输出由它们直接写终端(无重定向、无延迟)。
# 这里只负责等待与收尾:任一进程退出就报告,Ctrl+C 时一起停掉。
if ($Foreground -and $script:fgProcs.Count -gt 0) {
$names = ($script:fgProcs | ForEach-Object { $_.Name }) -join ' + '
Write-Host ''
Write-Host ' ----------------------------------------' -ForegroundColor DarkGray
Write-Host " $names 的输出直接打在下面,Ctrl+C 一起停止" -ForegroundColor Yellow
Write-Host ' ----------------------------------------' -ForegroundColor DarkGray
Write-Host ''
try {
while ($true) {
Start-Sleep -Milliseconds 400
$alive = @($script:fgProcs | Where-Object { -not $_.Proc.HasExited })
if ($alive.Count -eq 0) { break }
foreach ($e in $script:fgProcs) {
if (-not $e.Reported -and $e.Proc.HasExited) {
Write-Fail "$($e.Name) 已退出(退出码 $($e.Proc.ExitCode)),另一个仍在运行"
$e.Reported = $true
}
}
}
} finally {
foreach ($e in $script:fgProcs) {
if (-not $e.Proc.HasExited) {
Write-Note "停止 $($e.Name)(PID $($e.Proc.Id))"
Stop-PidTree $e.Proc.Id | Out-Null
}
}
}
Write-Host ''
Write-Note '前台模式已退出(这些进程不在 .run\ 登记,stop.ps1 不管它们)'
exit 0
}
# ---------------------------------------------------------------- 结果汇总
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\ 下,不会打到终端(这是后台模式的正常表现)'
Write-Note '要实时看输出:.\dev\logs.ps1 backend -Follow 或 .\dev\logs.ps1 frontend -Follow'
Write-Note '要实时看全部输出:.\dev\dev.ps1 -Foreground(前后端一起前台跑,Ctrl+C 停)'
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
}