Files
app-cli/dev/dev.ps1
toom1996 e366ee7498 chore: 初始化 app-cli,本地开发环境脚本
- dev/dev.ps1: 一键启动后端(编译后运行)+前端(4321),校验 CORS 与 astro dev 单实例锁
- dev/stop.ps1: 按 PID 登记停止,-Force 清残留
- dev/status.ps1: 端口/PID/健康检查一览
- dev/doctor.ps1: 工具链/路径/依赖/数据库/端口/CORS 体检
- vision/vision.ps1: CLIP 向量服务起停
- lib/common.ps1: 路径探测、端口、进程登记、HTTP 探测
- config/local.env.example: 路径与端口覆盖模板
2026-10-22 10:37:59 +08:00

358 lines
13 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
端口被占时先杀掉占用进程再启动
#>
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 锁(<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 }
# 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
}