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: 路径与端口覆盖模板
This commit is contained in:
toom1996
2026-10-22 10:37:59 +08:00
commit e366ee7498
10 changed files with 1269 additions and 0 deletions

357
dev/dev.ps1 Normal file
View File

@ -0,0 +1,357 @@
<#
.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
}

190
dev/doctor.ps1 Normal file
View File

@ -0,0 +1,190 @@
# dev/doctor.ps1 — 本地环境体检
#
# 只读检查,不改任何东西。把「启动后才发现」的问题提前暴露:
# 工具链版本 / 仓库路径 / 配置文件 / 依赖安装 / 数据库连通 / 端口占用 / CORS 白名单
#
# 退出码:0 = 可以开工;1 = 有必须先处理的问题。
$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')
$script:Fail = 0
$script:Warn = 0
# Check:$Value 只在通过时显示(版本号、路径等);$Hint 只在未通过时显示(怎么修)。
function Check {
param(
[string]$Name,
[bool]$Ok,
[string]$Value = '',
[string]$Hint = '',
[bool]$Fatal = $true
)
if ($Ok) {
if ($Value -ne '') { Write-Ok "$Name($Value)" } else { Write-Ok $Name }
return
}
if ($Fatal) {
Write-Fail $Name
$script:Fail++
} else {
Write-Warn $Name
$script:Warn++
}
if ($Hint -ne '') { Write-Note " $Hint" }
}
Write-Host ''
Write-Host ' app-cli / 本地环境体检' -ForegroundColor White
Write-Host ' ----------------------------------------' -ForegroundColor DarkGray
# ---------------------------------------------------------------- 1. 工具链
Write-Step '工具链'
$goCmd = Get-Command go -ErrorAction SilentlyContinue
if ($goCmd) {
$goVer = (& go version) -replace 'go version ', '' -replace ' windows.*', ''
Check -Name 'Go' -Ok $true -Value $goVer
} else {
Check -Name 'Go' -Ok $false -Hint '未安装,后端无法编译'
}
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
if ($nodeCmd) {
$nodeVer = (& node -v).Trim()
$major = 0
if ($nodeVer -match 'v(\d+)') { $major = [int]$Matches[1] }
# 前端 package.json engines 要求 >=22.12,只做提示不拦
Check -Name 'Node' -Ok ($major -ge 22) -Value $nodeVer -Hint "当前 $nodeVer,前端要求 >=22.12" -Fatal $false
} else {
Check -Name 'Node' -Ok $false -Hint '未安装,前端无法启动'
}
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
if ($pythonCmd) {
$pyVer = (& python --version 2>&1).ToString().Trim()
Check -Name 'Python' -Ok $true -Value $pyVer
} else {
Check -Name 'Python' -Ok $false -Hint '未安装(只有跑视觉服务才需要)' -Fatal $false
}
# ---------------------------------------------------------------- 2. 仓库路径
Write-Step '仓库路径'
Check -Name "backend $($svc.Backend.Path)" -Ok $svc.Backend.IsValid -Hint "缺标记文件 $($svc.Backend.Marker)"
Check -Name "frontend $($svc.Frontend.Path)" -Ok $svc.Frontend.IsValid -Hint "缺标记文件 $($svc.Frontend.Marker)"
Check -Name "vision $($svc.Vision.Path)" -Ok $svc.Vision.IsValid -Hint "缺标记文件 $($svc.Vision.Marker),仅影响视觉服务" -Fatal $false
# ---------------------------------------------------------------- 3. 配置与依赖
Write-Step '配置与依赖'
if ($svc.Backend.IsValid) {
$cfgYml = Join-Path $svc.Backend.Path 'configs\config.yml'
$cfgLocal = Join-Path $svc.Backend.Path 'configs\config.local.yml'
Check -Name 'backend configs/config.yml' -Ok (Test-Path $cfgYml) -Hint '唯一配置源,缺失则全走默认值'
Check -Name 'backend configs/config.local.yml' -Ok (Test-Path $cfgLocal) -Hint '本机密钥(DB 密码 / JWT / S4),缺失会在启动时交互提示' -Fatal $false
}
if ($svc.Frontend.IsValid) {
$nm = Join-Path $svc.Frontend.Path 'node_modules'
$astro = Join-Path $svc.Frontend.Path 'node_modules\astro\bin\astro.mjs'
Check -Name 'frontend node_modules' -Ok (Test-Path $nm) -Hint '缺失时 dev.ps1 会自动 npm ci' -Fatal $false
Check -Name 'frontend astro CLI' -Ok (Test-Path $astro) -Hint "缺 $astro(依赖损坏,删掉 node_modules 重装)"
# 前端 dev 端口的两处来源必须一致,否则手动 npm run dev 会起在别的端口 → CORS 拦截
$astroCfg = Join-Path $svc.Frontend.Path 'astro.config.mjs'
if (Test-Path $astroCfg) {
$raw = Get-Content $astroCfg -Raw
if ($raw -match 'port:\s*(\d+)') {
$cfgPort = [int]$Matches[1]
Check -Name '前端端口一致性' -Ok ($cfgPort -eq $frontendPort) -Value "$cfgPort" `
-Hint "astro.config.mjs 的 server.port=$cfgPort,而 FRONTEND_PORT=$frontendPort;dev.ps1 会用 --port 覆盖,但你手动 npm run dev 会起在 $cfgPort 并被 CORS 拦截" `
-Fatal $false
}
}
}
if ($svc.Vision.IsValid) {
$uvicorn = Join-Path $svc.Vision.Path '.venv\Scripts\uvicorn.exe'
Check -Name 'vision .venv/uvicorn' -Ok (Test-Path $uvicorn) -Hint '缺失则无法启动 CLIP 服务(python -m venv .venv 后装依赖)' -Fatal $false
}
# ---------------------------------------------------------------- 4. 数据库连通
Write-Step '数据库'
if ($svc.Backend.IsValid) {
$cfgYml = Join-Path $svc.Backend.Path 'configs\config.yml'
$cfgLocal = Join-Path $svc.Backend.Path 'configs\config.local.yml'
$dbHost = Get-YamlScalar -Path $cfgYml -Section 'database' -Key 'host'
$dbPortRaw = Get-YamlScalar -Path $cfgYml -Section 'database' -Key 'port'
$dbName = Get-YamlScalar -Path $cfgYml -Section 'database' -Key 'name'
# 本机覆盖优先
$localHost = Get-YamlScalar -Path $cfgLocal -Section 'database' -Key 'host'
if ($localHost -ne '') { $dbHost = $localHost }
$localPort = Get-YamlScalar -Path $cfgLocal -Section 'database' -Key 'port'
if ($localPort -ne '') { $dbPortRaw = $localPort }
$dbPort = 5432
if ($dbPortRaw -ne '') { $dbPort = [int]$dbPortRaw }
if ($dbHost -eq '') {
Check -Name '数据库配置' -Ok $false -Hint '配置里没读到 database.host' -Fatal $false
} else {
$reachable = Test-TcpConnect -ComputerName $dbHost -Port $dbPort -TimeoutMs 4000
Check -Name "PostgreSQL $dbHost`:$dbPort/$dbName" -Ok $reachable `
-Hint 'TCP 连不上:后端启动会直接退出(查网络 / 密码 / 远端是否放行本机 IP)'
}
}
# ---------------------------------------------------------------- 5. 端口
Write-Step '端口'
$ports = @(
@{ Port = $backendPort; Name = 'backend 公开' },
@{ Port = 8091; Name = 'backend SSG' },
@{ Port = 8092; Name = 'backend 后台' },
@{ Port = $frontendPort; Name = 'frontend dev' },
@{ Port = $visionPort; Name = 'vision' }
)
foreach ($p in $ports) {
if (Test-PortInUse $p.Port) {
$owner = Get-PortOwnerPid $p.Port
Write-Note ($p.Port.ToString() + ' ' + $p.Name + ' 已被 PID ' + $owner + ' 占用(若已在跑属正常)')
} else {
Write-Note ($p.Port.ToString() + ' ' + $p.Name + ' 空闲')
}
}
# ---------------------------------------------------------------- 6. 运行时自检
Write-Step '运行时自检'
if (Test-PortInUse $backendPort) {
$healthUrl = "http://127.0.0.1:$backendPort/api/health"
Check -Name '后端 /api/health' -Ok (Wait-HttpOk -Url $healthUrl -TimeoutSec 3) -Value $healthUrl -Fatal $false
$origin = "http://localhost:$frontendPort"
$acao = Get-CorsOrigin -Url "http://127.0.0.1:$backendPort/api/v1/public/runway-looks?page=1" -Origin $origin
if ($acao -eq $origin) {
Check -Name "CORS 放行 $origin" -Ok $true -Value $acao
} else {
Check -Name "CORS 放行 $origin" -Ok $false -Fatal $false `
-Hint "后端回显 '$acao';不在白名单时浏览器会拦掉接口,页面显示「无数据」。改 backend/configs/config.local.yml → cors.allow_origins"
}
} else {
Write-Note '后端未运行,跳过健康与 CORS 检查'
}
# ---------------------------------------------------------------- 汇总
Write-Host ''
if ($script:Fail -gt 0) {
Write-Fail ("体检未通过:$($script:Fail) 项必处理,$($script:Warn) 项提醒")
exit 1
}
if ($script:Warn -gt 0) {
Write-Warn ("可以开工,但有 $($script:Warn) 项提醒")
exit 0
}
Write-Ok '体检全部通过'

62
dev/status.ps1 Normal file
View File

@ -0,0 +1,62 @@
# dev/status.ps1 — 查看本地开发环境状态(端口 / PID / 健康检查)
$ErrorActionPreference = 'Stop'
$CliRoot = Split-Path -Parent $PSScriptRoot
. (Join-Path $CliRoot 'lib\common.ps1')
$envMap = Import-LocalEnv (Join-Path $CliRoot 'config\local.env')
$backendPort = [int](Get-Setting $envMap 'BACKEND_PORT' '8090')
$frontendPort = [int](Get-Setting $envMap 'FRONTEND_PORT' '4321')
$visionPort = [int](Get-Setting $envMap 'VISION_PORT' '8900')
# 注意:不要写成 @( cmd1, cmd2 ) —— PowerShell 会把逗号当成 cmd1 的参数分隔符
$stBackend = Get-ServiceStatus -Name 'backend' -Port $backendPort -HealthUrl "http://127.0.0.1:$backendPort/api/health"
$stFrontend = Get-ServiceStatus -Name 'frontend' -Port $frontendPort -HealthUrl "http://127.0.0.1:$frontendPort/cn/runway-looks/"
$stVision = Get-ServiceStatus -Name 'vision' -Port $visionPort -HealthUrl "http://127.0.0.1:$visionPort/health"
$rows = @($stBackend, $stFrontend, $stVision)
# 后端另外两个内部端口只做占用展示
$internal = @(
@{ Name = 'ssg'; Port = 8091 },
@{ Name = 'backstage'; Port = 8092 }
)
Write-Host ''
Write-Host ' 本地开发环境状态' -ForegroundColor White
Write-Host ' ----------------------------------------------' -ForegroundColor DarkGray
Write-Host (' {0,-10} {1,-6} {2,-8} {3,-10} {4}' -f '服务', '端口', 'PID', '监听', '健康') -ForegroundColor DarkGray
foreach ($r in $rows) {
$listen = '否'
if ($r.Listening) { $listen = '是' }
$health = '否'
if ($r.Healthy) { $health = '是' }
$color = 'Gray'
if ($r.Healthy) { $color = 'Green' }
elseif ($r.Listening) { $color = 'Yellow' }
$pidText = '-'
if ($r.Pid -gt 0) { $pidText = [string]$r.Pid }
elseif ($r.OwnerPid -gt 0) { $pidText = [string]$r.OwnerPid + '*' }
Write-Host (' {0,-10} {1,-6} {2,-8} {3,-10} {4}' -f $r.Name, $r.Port, $pidText, $listen, $health) -ForegroundColor $color
}
# 8091/8092 属于后端同一进程,占用者等于登记的 backend PID 时不算「野进程」
$backendPid = Get-ServicePid 'backend'
foreach ($i in $internal) {
$listen = '否'
$pidText = '-'
if (Test-PortInUse $i.Port) {
$listen = '是'
$owner = Get-PortOwnerPid $i.Port
if ($backendPid -gt 0 -and $owner -eq $backendPid) {
$pidText = [string]$owner
} else {
$pidText = [string]$owner + '*'
}
}
Write-Host (' {0,-10} {1,-6} {2,-8} {3,-10} {4}' -f $i.Name, $i.Port, $pidText, $listen, '-') -ForegroundColor DarkGray
}
Write-Host ''
Write-Note '* = 端口被占用,但不是 dev.ps1 登记的进程(可能是手动启动的)'
Write-Note '启动:.\dev\dev.ps1 停止:.\dev\stop.ps1 体检:.\dev\doctor.ps1'

67
dev/stop.ps1 Normal file
View File

@ -0,0 +1,67 @@
# dev/stop.ps1 — 停掉本地开发环境
#
# 先按 .run\ 里登记的 PID 停(连子进程一起杀),
# 再检查端口上是否还残留「本脚本管不到」的进程,报告但不强杀(除非 -Force)。
param(
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$CliRoot = Split-Path -Parent $PSScriptRoot
. (Join-Path $CliRoot 'lib\common.ps1')
$envMap = Import-LocalEnv (Join-Path $CliRoot 'config\local.env')
$backendPort = [int](Get-Setting $envMap 'BACKEND_PORT' '8090')
$frontendPort = [int](Get-Setting $envMap 'FRONTEND_PORT' '4321')
$visionPort = [int](Get-Setting $envMap 'VISION_PORT' '8900')
Write-Host ''
Write-Host ' 停止本地开发环境' -ForegroundColor White
Write-Host ' ----------------------------------------' -ForegroundColor DarkGray
$services = @(
@{ Name = 'frontend'; Port = $frontendPort },
@{ Name = 'backend'; Port = $backendPort },
@{ Name = 'vision'; Port = $visionPort }
)
foreach ($s in $services) {
if (Stop-NamedService $s.Name) {
Write-Ok "$($s.Name) 已停止"
} else {
Write-Note "$($s.Name) 未在本脚本登记(可能没启动过)"
}
}
# 后端占了三个端口,只停 8090 后 8091/8092 会随进程一起消失,这里只做残留检查
Write-Host ''
$leftover = @()
foreach ($port in @($backendPort, 8091, 8092, $frontendPort, $visionPort)) {
if (-not (Test-PortInUse $port)) { continue }
$owner = Get-PortOwnerPid $port
$leftover += [PSCustomObject]@{ Port = $port; Pid = $owner }
}
if ($leftover.Count -eq 0) {
Write-Ok '相关端口已全部释放'
exit 0
}
foreach ($l in $leftover) {
Write-Warn ("端口 " + $l.Port + " 仍被 PID " + $l.Pid + " 占用")
}
if ($Force) {
foreach ($id in ($leftover | Select-Object -ExpandProperty Pid | Sort-Object -Unique)) {
Stop-PidTree $id | Out-Null
Write-Ok "已终止 PID $id"
}
Write-Host ''
foreach ($port in @($backendPort, 8091, 8092, $frontendPort, $visionPort)) {
if (Test-PortInUse $port) { Write-Warn "端口 $port 仍被占用" }
}
} else {
Write-Note '这些进程不是本脚本启动的(例如手动 go run / npm run dev 留下的)。'
Write-Note '需要一起停掉就加 -Force:.\dev\stop.ps1 -Force'
}