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:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# 本机配置(路径/端口覆盖,含私有路径)
|
||||||
|
config/local.env
|
||||||
|
|
||||||
|
# 运行时产物:日志、PID 登记、编译出的二进制
|
||||||
|
.logs/
|
||||||
|
.run/
|
||||||
|
.bin/
|
||||||
|
|
||||||
|
# 部署密钥
|
||||||
|
deploy/.env
|
||||||
|
|
||||||
|
# 编辑器
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
140
README.md
Normal file
140
README.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# app-cli
|
||||||
|
|
||||||
|
运维脚本仓库。**不放业务代码**,只放「怎么跑起来、怎么检查、怎么上线」的操作入口。
|
||||||
|
|
||||||
|
三个业务仓各自独立,本仓库通过路径引用它们:
|
||||||
|
|
||||||
|
```
|
||||||
|
<project>/
|
||||||
|
├─ app-cli/ ← 本仓库:脚本与部署编排
|
||||||
|
├─ backend/ ← Go 后端(8090 公开 / 8091 SSG / 8092 后台)
|
||||||
|
├─ test/test/ ← Astro 前端
|
||||||
|
└─ fashion-siglip/ ← CLIP 向量服务(可选,仅后台「疑似同款」用)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 为什么单独一个仓库
|
||||||
|
|
||||||
|
| 理由 | 具体好处 |
|
||||||
|
|---|---|
|
||||||
|
| 脚本不跟业务版本绑在一起 | 改部署流程不用动业务仓,回滚业务代码不会把脚本一起回滚掉 |
|
||||||
|
| 一处放全部「怎么运行」的知识 | 换机器只 clone 这个仓库 + 配一份 `config/local.env` |
|
||||||
|
| 服务器只需拉这一个仓库 | 蓝绿部署时槽位切换、备份、健康检查都在这里 |
|
||||||
|
|
||||||
|
代价是**目录必须同级**(脚本要能按 `../backend` 找到业务仓)。路径不一致时在 `config/local.env` 里显式写死。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构(当前进度)
|
||||||
|
|
||||||
|
```
|
||||||
|
app-cli/
|
||||||
|
├─ dev.cmd # 双击启动(转发给 dev/dev.ps1)
|
||||||
|
├─ config/
|
||||||
|
│ ├─ local.env.example # 路径/端口覆盖模板(复制成 local.env)
|
||||||
|
│ └─ local.env # 本机实际值,不入库
|
||||||
|
├─ lib/
|
||||||
|
│ └─ common.ps1 # 公共函数:路径探测/端口/进程/HTTP(已在用)
|
||||||
|
├─ dev/ # ★ 本地环境(第一版已完成)
|
||||||
|
│ ├─ dev.ps1 # 一键启动:后端 + 前端(可选 CLIP)
|
||||||
|
│ ├─ stop.ps1 # 按 PID 登记停止,支持 -Force 清残留
|
||||||
|
│ ├─ status.ps1 # 端口 / PID / 健康检查一览
|
||||||
|
│ └─ doctor.ps1 # 体检:版本/路径/依赖/数据库/端口/CORS
|
||||||
|
└─ vision/
|
||||||
|
└─ vision.ps1 # CLIP 服务 start/stop/restart/status/logs
|
||||||
|
|
||||||
|
# 下面是规划中的目录,本轮还没建:
|
||||||
|
# ├─ check/ # 提交前检查:go build+vet+test、astro check
|
||||||
|
# ├─ deploy/ # 生产部署:docker-compose、nginx、deploy.sh、init-db.sh
|
||||||
|
# └─ docs/ # 排障手册
|
||||||
|
```
|
||||||
|
|
||||||
|
## 脚本分四类
|
||||||
|
|
||||||
|
| 类别 | 目录 | 运行在哪 | 现在 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 本地环境 | `dev/` | 开发机 Windows | ✅ 已完成 |
|
||||||
|
| 视觉服务 | `vision/` | 开发机 / 服务器 | ✅ 已完成 |
|
||||||
|
| 提交前检查 | `check/` | 开发机 | 待建 |
|
||||||
|
| 生产部署 | `deploy/` | Linux 服务器 | 待迁入(现在散在前端仓) |
|
||||||
|
|
||||||
|
分界的意义:`dev/` 里的东西**永远不在生产跑**,`deploy/` 里的东西**不在开发机跑**。混在一起就会出现「为了本地方便给生产脚本加了个开关」这类事故。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本地开发怎么用
|
||||||
|
|
||||||
|
首次:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd <project>/app-cli
|
||||||
|
copy config\local.env.example config\local.env # 可选,不填也能跑(自动探测同级目录)
|
||||||
|
.\dev\doctor.ps1 # 先体检
|
||||||
|
```
|
||||||
|
|
||||||
|
日常:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dev\dev.ps1 # 后端 + 前端
|
||||||
|
.\dev\dev.ps1 -Vision -Open # 连 CLIP 一起起,并打开浏览器
|
||||||
|
.\dev\dev.ps1 frontend # 只起前端(后端已在跑)
|
||||||
|
.\dev\dev.ps1 -Force # 端口被占时先杀掉占用者
|
||||||
|
.\dev\status.ps1 # 看状态
|
||||||
|
.\dev\stop.ps1 # 停掉
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以双击 `dev.cmd`(自动带 `-ExecutionPolicy Bypass`,绕开脚本执行策略限制)。
|
||||||
|
|
||||||
|
### 端口约定
|
||||||
|
|
||||||
|
| 端口 | 服务 | 谁能访问 |
|
||||||
|
|---|---|---|
|
||||||
|
| 8090 | 后端公开 API | 前端浏览器直连(dev 模式下 `baseApi` 写死这个地址) |
|
||||||
|
| 8091 | 后端 SSG 内部接口 | 仅构建期取数用 |
|
||||||
|
| 8092 | 管理后台 `/admin` | 本机 |
|
||||||
|
| 4321 | Astro dev server | 浏览器 |
|
||||||
|
| 8900 | CLIP 向量服务 | 仅 127.0.0.1 |
|
||||||
|
|
||||||
|
**4321 不是随便选的**:后端 `cors.allow_origins` 里放行的就是 `http://localhost:4321`。
|
||||||
|
而前端 `astro.config.mjs` 的 `server.port` 是 **3000**(那是给容器内 node 常驻用的),
|
||||||
|
所以直接 `npm run dev` 会起在 3000 —— 浏览器请求会被 CORS 拦掉,页面表现为「没数据」。
|
||||||
|
`dev.ps1` 用 `--port 4321` 显式覆盖,并在启动后**自检 CORS 回显**,不一致直接告警。
|
||||||
|
|
||||||
|
### 为什么后端是「先编译再运行」而不是 `go run`
|
||||||
|
|
||||||
|
`go run` 会派生一个 `main.exe` 子进程,PID 杀掉 `go run` 父进程后子进程可能还在,端口继续被占——正是「明明关了服务 8090 还能通」的成因。
|
||||||
|
`dev.ps1` 用 `go build -o .bin/fashionapi-dev.exe` 再直接运行,进程树干净,`stop.ps1` 能准确回收。
|
||||||
|
|
||||||
|
### 日志与状态
|
||||||
|
|
||||||
|
```
|
||||||
|
.logs/backend.log .logs/backend.err.log
|
||||||
|
.logs/frontend.log .logs/frontend.err.log
|
||||||
|
.logs/vision.log .logs/vision.err.log
|
||||||
|
.run/backend.pid .run/frontend.pid .run/vision.pid
|
||||||
|
```
|
||||||
|
|
||||||
|
`stop.ps1` 优先按 PID 停;端口上还有本脚本管不到的进程(比如手动 `go run` 留下的)时只报告不硬杀,加 `-Force` 才清。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计取舍
|
||||||
|
|
||||||
|
**1. 不用 pm2 / 不用 supervisor(本地)**
|
||||||
|
进程管理交给脚本 + PID 登记。引入 pm2 只是多一层要维护的状态,而失败场景(谁占着端口、为什么没起来)反而变难查。
|
||||||
|
|
||||||
|
**2. 只做 PowerShell,不做 .sh 双份**
|
||||||
|
开发机是 Windows。生产是 Linux,那边用 `deploy/` 的 bash 脚本,本来就该分开写——同一套逻辑写两遍,最后一定是两份都只测过一份。
|
||||||
|
|
||||||
|
**3. 路径自动探测 + 显式覆盖**
|
||||||
|
默认按同级目录找,用标记文件(`cmd\server\main.go` / `astro.config.mjs` / `app.py`)校验。写错路径会在启动前报错,不会静默启动到空目录。
|
||||||
|
|
||||||
|
**4. 体检和启动分离**
|
||||||
|
`doctor.ps1` 只读不写,可以随时跑;`dev.ps1` 只管启动。合并成一个会出现「想查问题时它顺手改了环境」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
1. `check/check.ps1`:`go build` + `go vet` + `go test ./internal/...` + `astro check` 一条命令
|
||||||
|
2. `deploy/`:把现在散落在前端仓的 `docker-compose.yml`、`nginx/`、`deploy.sh`、`init-db.sh` 迁进来,改成 `build: context: ../backend` / `../test/test`
|
||||||
|
3. `docs/`:把「上线后 5 处点检」和常见故障对照表写成文档
|
||||||
24
config/local.env.example
Normal file
24
config/local.env.example
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# config/local.env — 本机路径与端口覆盖(复制为 local.env 后按需修改)
|
||||||
|
#
|
||||||
|
# 不填任何一项也能跑:默认按「与 app-cli 同级的目录」自动探测
|
||||||
|
# BACKEND_DIR = <project>/backend
|
||||||
|
# FRONTEND_DIR = <project>/test/test
|
||||||
|
# VISION_DIR = <project>/fashion-siglip
|
||||||
|
# 路径写错会在启动前被标记文件校验拦下,不会静默启动到空目录。
|
||||||
|
|
||||||
|
# ---- 仓库路径 ----
|
||||||
|
# BACKEND_DIR=D:/project/backend
|
||||||
|
# FRONTEND_DIR=D:/project/test/test
|
||||||
|
# VISION_DIR=D:/project/fashion-siglip
|
||||||
|
|
||||||
|
# ---- 端口 ----
|
||||||
|
# FRONTEND_PORT 必须与后端 cors.allow_origins 里放行的来源一致,
|
||||||
|
# 否则浏览器会拦截接口(页面显示「无数据」)。改了这里记得同步改
|
||||||
|
# backend/configs/config.local.yml 的 cors.allow_origins。
|
||||||
|
BACKEND_PORT=8090
|
||||||
|
FRONTEND_PORT=4321
|
||||||
|
VISION_PORT=8900
|
||||||
|
|
||||||
|
# ---- 行为 ----
|
||||||
|
# 启动完成后自动打开浏览器:1 开 / 0 关
|
||||||
|
OPEN_BROWSER=0
|
||||||
5
dev.cmd
Normal file
5
dev.cmd
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
rem dev.cmd — 双击即可启动本地开发环境(等同于 .\dev\dev.ps1)
|
||||||
|
rem 参数原样透传,例如:dev.cmd -Vision -Open
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0dev\dev.ps1" %*
|
||||||
|
if errorlevel 1 pause
|
||||||
357
dev/dev.ps1
Normal file
357
dev/dev.ps1
Normal 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
190
dev/doctor.ps1
Normal 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
62
dev/status.ps1
Normal 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
67
dev/stop.ps1
Normal 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'
|
||||||
|
}
|
||||||
288
lib/common.ps1
Normal file
288
lib/common.ps1
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
# lib/common.ps1 — app-cli 公共函数
|
||||||
|
#
|
||||||
|
# 约定:调用方脚本先设置 $CliRoot(app-cli 根目录),再点源引入本文件:
|
||||||
|
# $CliRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
# . (Join-Path $CliRoot 'lib\common.ps1')
|
||||||
|
#
|
||||||
|
# 本文件不启动任何服务,只提供:路径解析 / 配置读取 / 端口探测 / 进程登记 / HTTP 探测。
|
||||||
|
# 兼容 Windows PowerShell 5.1 与 PowerShell 7。
|
||||||
|
|
||||||
|
if (-not $CliRoot) { throw 'common.ps1:调用方必须先设置 $CliRoot' }
|
||||||
|
|
||||||
|
$script:AppCliRoot = $CliRoot
|
||||||
|
$LogDir = Join-Path $CliRoot '.logs'
|
||||||
|
$RunDir = Join-Path $CliRoot '.run'
|
||||||
|
$BinDir = Join-Path $CliRoot '.bin'
|
||||||
|
foreach ($d in @($LogDir, $RunDir, $BinDir)) {
|
||||||
|
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 输出
|
||||||
|
|
||||||
|
function Write-Step { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan }
|
||||||
|
function Write-Ok { param([string]$Msg) Write-Host " [OK] $Msg" -ForegroundColor Green }
|
||||||
|
function Write-Note { param([string]$Msg) Write-Host " [--] $Msg" -ForegroundColor Gray }
|
||||||
|
function Write-Warn { param([string]$Msg) Write-Host " [!!] $Msg" -ForegroundColor Yellow }
|
||||||
|
function Write-Fail { param([string]$Msg) Write-Host " [XX] $Msg" -ForegroundColor Red }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 配置
|
||||||
|
|
||||||
|
# Import-LocalEnv:读取 KEY=VALUE 形式的本地配置(config/local.env)。
|
||||||
|
# 支持 # 注释、空行、值两侧引号;文件不存在时返回空表(全部走默认值)。
|
||||||
|
function Import-LocalEnv {
|
||||||
|
param([string]$Path)
|
||||||
|
$map = @{}
|
||||||
|
if (-not (Test-Path $Path)) { return $map }
|
||||||
|
foreach ($line in (Get-Content -Path $Path -Encoding UTF8)) {
|
||||||
|
$t = $line.Trim()
|
||||||
|
if ($t -eq '' -or $t.StartsWith('#')) { continue }
|
||||||
|
$i = $t.IndexOf('=')
|
||||||
|
if ($i -lt 1) { continue }
|
||||||
|
$k = $t.Substring(0, $i).Trim()
|
||||||
|
$v = $t.Substring($i + 1).Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v.StartsWith('"') -and $v.EndsWith('"')) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2)
|
||||||
|
}
|
||||||
|
$map[$k] = $v
|
||||||
|
}
|
||||||
|
return $map
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get-Setting:取本地配置项,缺省或空串时回落 $Default。
|
||||||
|
function Get-Setting {
|
||||||
|
param($EnvMap, [string]$Key, [string]$Default)
|
||||||
|
if ($EnvMap -and $EnvMap.ContainsKey($Key)) {
|
||||||
|
$v = $EnvMap[$Key]
|
||||||
|
if ($v -ne '') { return $v }
|
||||||
|
}
|
||||||
|
return $Default
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get-YamlScalar:从 yml 里取「某个顶层 section 下某个键」的标量值(只处理本项目用到的简单结构)。
|
||||||
|
function Get-YamlScalar {
|
||||||
|
param([string]$Path, [string]$Section, [string]$Key)
|
||||||
|
if (-not (Test-Path $Path)) { return '' }
|
||||||
|
$inSection = $false
|
||||||
|
foreach ($line in (Get-Content -Path $Path -Encoding UTF8)) {
|
||||||
|
if ($line -match '^([A-Za-z_][A-Za-z0-9_]*)\s*:') {
|
||||||
|
$inSection = ($Matches[1] -eq $Section)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (-not $inSection) { continue }
|
||||||
|
if ($line -match '^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$') {
|
||||||
|
if ($Matches[1] -ne $Key) { continue }
|
||||||
|
$v = $Matches[2]
|
||||||
|
$h = $v.IndexOf('#')
|
||||||
|
if ($h -ge 0) { $v = $v.Substring(0, $h) }
|
||||||
|
return $v.Trim().Trim('"').Trim("'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 仓库定位
|
||||||
|
|
||||||
|
# Resolve-Services:确定 backend / frontend / vision 三个仓库的实际路径。
|
||||||
|
# 优先级:config/local.env 显式配置 > 与 app-cli 同级的约定目录。
|
||||||
|
# 用「标记文件」校验,避免路径写错后启动到一个空目录。
|
||||||
|
function Resolve-Services {
|
||||||
|
param($EnvMap, [string]$Root)
|
||||||
|
$parent = Split-Path -Parent $Root
|
||||||
|
|
||||||
|
$defs = @(
|
||||||
|
@{ Name = 'backend'; Key = 'BACKEND_DIR'; Default = (Join-Path $parent 'backend'); Marker = 'cmd\server\main.go' },
|
||||||
|
@{ Name = 'frontend'; Key = 'FRONTEND_DIR'; Default = (Join-Path $parent 'test\test'); Marker = 'astro.config.mjs' },
|
||||||
|
@{ Name = 'vision'; Key = 'VISION_DIR'; Default = (Join-Path $parent 'fashion-siglip'); Marker = 'app.py' }
|
||||||
|
)
|
||||||
|
|
||||||
|
$result = @{}
|
||||||
|
foreach ($d in $defs) {
|
||||||
|
$path = Get-Setting $EnvMap $d.Key $d.Default
|
||||||
|
$path = $path.TrimEnd('\', '/')
|
||||||
|
$marker = Join-Path $path $d.Marker
|
||||||
|
$result[$d.Name] = [PSCustomObject]@{
|
||||||
|
Name = $d.Name
|
||||||
|
Path = $path
|
||||||
|
Marker = $marker
|
||||||
|
IsValid = (Test-Path $marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Backend = $result['backend']
|
||||||
|
Frontend = $result['frontend']
|
||||||
|
Vision = $result['vision']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 端口与进程
|
||||||
|
|
||||||
|
function Test-PortInUse {
|
||||||
|
param([int]$Port)
|
||||||
|
$c = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||||
|
return [bool]$c
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PortOwnerPid {
|
||||||
|
param([int]$Port)
|
||||||
|
$c = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if (-not $c) { return 0 }
|
||||||
|
return [int]$c.OwningProcess
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PidFile {
|
||||||
|
param([string]$Name)
|
||||||
|
return (Join-Path $RunDir ($Name + '.pid'))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save-ServicePid:登记本脚本启动的进程,供 stop.ps1 / status.ps1 使用。
|
||||||
|
function Save-ServicePid {
|
||||||
|
param([string]$Name, [int]$ProcessId)
|
||||||
|
Set-Content -Path (Get-PidFile $Name) -Value $ProcessId -Encoding ASCII
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get-ServicePid:读取登记的 PID;进程已不存在时清掉登记并返回 0。
|
||||||
|
function Get-ServicePid {
|
||||||
|
param([string]$Name)
|
||||||
|
$f = Get-PidFile $Name
|
||||||
|
if (-not (Test-Path $f)) { return 0 }
|
||||||
|
$raw = (Get-Content -Path $f -ErrorAction SilentlyContinue | Select-Object -First 1)
|
||||||
|
$id = 0
|
||||||
|
if ($raw -and [int]::TryParse($raw.Trim(), [ref]$id)) {
|
||||||
|
$p = Get-Process -Id $id -ErrorAction SilentlyContinue
|
||||||
|
if ($p) { return $id }
|
||||||
|
}
|
||||||
|
Remove-Item $f -Force -ErrorAction SilentlyContinue
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop-PidTree:杀掉进程及其子进程(node/go 会派生,必须连树一起杀)。
|
||||||
|
function Stop-PidTree {
|
||||||
|
param([int]$ProcessId)
|
||||||
|
if ($ProcessId -le 0) { return $false }
|
||||||
|
$p = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
|
||||||
|
if (-not $p) { return $false }
|
||||||
|
& taskkill.exe /PID $ProcessId /T /F 2>&1 | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 300
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop-NamedService:按登记名停服务,顺带清掉 PID 文件。
|
||||||
|
function Stop-NamedService {
|
||||||
|
param([string]$Name)
|
||||||
|
$id = Get-ServicePid $Name
|
||||||
|
if ($id -le 0) { return $false }
|
||||||
|
$killed = Stop-PidTree $id
|
||||||
|
Remove-Item (Get-PidFile $Name) -Force -ErrorAction SilentlyContinue
|
||||||
|
return $killed
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start-LoggedProcess:后台启动并重定向日志,成功后登记 PID。
|
||||||
|
function Start-LoggedProcess {
|
||||||
|
param(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$ArgumentList = @(),
|
||||||
|
[string]$WorkingDirectory
|
||||||
|
)
|
||||||
|
$out = Join-Path $LogDir ($Name + '.log')
|
||||||
|
$err = Join-Path $LogDir ($Name + '.err.log')
|
||||||
|
foreach ($f in @($out, $err)) {
|
||||||
|
if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue }
|
||||||
|
}
|
||||||
|
# 注意:Start-Process 的 -ArgumentList 传空数组会抛参数校验异常("该参数为 Null、为空"),
|
||||||
|
# 所以无参进程(例如后端的编译产物)必须整个不传这个参数。
|
||||||
|
$params = @{
|
||||||
|
FilePath = $FilePath
|
||||||
|
WorkingDirectory = $WorkingDirectory
|
||||||
|
RedirectStandardOutput = $out
|
||||||
|
RedirectStandardError = $err
|
||||||
|
WindowStyle = 'Hidden'
|
||||||
|
PassThru = $true
|
||||||
|
}
|
||||||
|
if ($null -ne $ArgumentList -and $ArgumentList.Count -gt 0) {
|
||||||
|
$params['ArgumentList'] = $ArgumentList
|
||||||
|
}
|
||||||
|
$p = Start-Process @params
|
||||||
|
Save-ServicePid -Name $Name -ProcessId $p.Id
|
||||||
|
return $p
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- HTTP
|
||||||
|
|
||||||
|
# Test-TcpConnect:TCP 连通性探测(数据库这类非 HTTP 服务用)。
|
||||||
|
function Test-TcpConnect {
|
||||||
|
param([string]$ComputerName, [int]$Port, [int]$TimeoutMs = 3000)
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
try {
|
||||||
|
$iar = $client.BeginConnect($ComputerName, $Port, $null, $null)
|
||||||
|
if (-not $iar.AsyncWaitHandle.WaitOne($TimeoutMs)) { return $false }
|
||||||
|
$client.EndConnect($iar)
|
||||||
|
return $true
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
} finally {
|
||||||
|
$client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait-HttpOk:轮询直到返回 2xx/3xx,超时返回 $false。
|
||||||
|
function Wait-HttpOk {
|
||||||
|
param([string]$Url, [int]$TimeoutSec = 90)
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
try {
|
||||||
|
$r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||||
|
if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) { return $true }
|
||||||
|
} catch { }
|
||||||
|
Start-Sleep -Milliseconds 700
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get-CorsOrigin:带 Origin 头请求一次,返回后端回显的 Access-Control-Allow-Origin。
|
||||||
|
# 返回空串表示后端没放行该来源(浏览器会整条拦掉,页面表现为「无数据」)。
|
||||||
|
function Get-CorsOrigin {
|
||||||
|
param([string]$Url, [string]$Origin)
|
||||||
|
try {
|
||||||
|
$r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 8 -Headers @{ Origin = $Origin } -ErrorAction Stop
|
||||||
|
$acao = $r.Headers['Access-Control-Allow-Origin']
|
||||||
|
if ($acao -is [array]) { $acao = $acao[0] }
|
||||||
|
if ($null -eq $acao) { return '' }
|
||||||
|
return [string]$acao
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 日志
|
||||||
|
|
||||||
|
function Show-LogTail {
|
||||||
|
param([string]$Name, [int]$Lines = 15)
|
||||||
|
foreach ($suffix in @('.log', '.err.log')) {
|
||||||
|
$f = Join-Path $LogDir ($Name + $suffix)
|
||||||
|
if (-not (Test-Path $f)) { continue }
|
||||||
|
$content = Get-Content -Path $f -Tail $Lines -ErrorAction SilentlyContinue
|
||||||
|
if (-not $content) { continue }
|
||||||
|
Write-Host (" --- " + $Name + $suffix + " ---") -ForegroundColor DarkGray
|
||||||
|
foreach ($l in $content) { Write-Host (" " + $l) -ForegroundColor DarkGray }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get-ServiceStatus:给 status.ps1 用的单服务状态。
|
||||||
|
function Get-ServiceStatus {
|
||||||
|
param([string]$Name, [int]$Port, [string]$HealthUrl)
|
||||||
|
$id = Get-ServicePid $Name
|
||||||
|
$listening = Test-PortInUse $Port
|
||||||
|
$owner = 0
|
||||||
|
if ($listening) { $owner = Get-PortOwnerPid $Port }
|
||||||
|
$healthy = $false
|
||||||
|
if ($listening -and $HealthUrl -ne '') { $healthy = Wait-HttpOk -Url $HealthUrl -TimeoutSec 3 }
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Name = $Name
|
||||||
|
Port = $Port
|
||||||
|
Pid = $id
|
||||||
|
Listening = $listening
|
||||||
|
OwnerPid = $owner
|
||||||
|
Healthy = $healthy
|
||||||
|
}
|
||||||
|
}
|
||||||
121
vision/vision.ps1
Normal file
121
vision/vision.ps1
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
CLIP 向量服务(fashion-siglip)的独立起停脚本。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
该服务把图片编码成归一化向量,供后台「疑似同款」提示使用。
|
||||||
|
- 只监听 127.0.0.1,不对外暴露
|
||||||
|
- --workers 必须为 1(模型常驻进程内,多 worker 会重复加载模型)
|
||||||
|
- 后端需要 VISION_ENABLED=true 才会调用它;服务不可用时后端自动降级,不影响入库
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\vision\vision.ps1 start
|
||||||
|
.\vision\vision.ps1 status
|
||||||
|
.\vision\vision.ps1 logs
|
||||||
|
.\vision\vision.ps1 stop
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Position = 0)]
|
||||||
|
[ValidateSet('start', 'stop', 'restart', 'status', 'logs')]
|
||||||
|
[string]$Action = 'start',
|
||||||
|
[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
|
||||||
|
$port = [int](Get-Setting $envMap 'VISION_PORT' '8900')
|
||||||
|
$healthUrl = "http://127.0.0.1:$port/health"
|
||||||
|
|
||||||
|
function Start-CLIP {
|
||||||
|
if (Test-PortInUse $port) {
|
||||||
|
$owner = Get-PortOwnerPid $port
|
||||||
|
$saved = Get-ServicePid 'vision'
|
||||||
|
if ($saved -gt 0 -and $saved -eq $owner) {
|
||||||
|
Write-Note "视觉服务已在运行(PID $owner)"
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if (-not $Force) {
|
||||||
|
Write-Fail "端口 $port 被 PID $owner 占用"
|
||||||
|
Write-Note '加 -Force 强制释放:.\vision\vision.ps1 start -Force'
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
Stop-PidTree $owner | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $svc.Vision.IsValid) {
|
||||||
|
Write-Fail "视觉服务目录无效:$($svc.Vision.Path)"
|
||||||
|
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) 建虚拟环境:python -m venv .venv && .venv\Scripts\pip install -r requirements.txt"
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "启动视觉服务(端口 $port,首次加载模型较慢)"
|
||||||
|
Start-LoggedProcess -Name 'vision' -FilePath $uvicorn `
|
||||||
|
-ArgumentList @('app:app', '--host', '127.0.0.1', '--port', "$port", '--workers', '1') `
|
||||||
|
-WorkingDirectory $svc.Vision.Path | Out-Null
|
||||||
|
|
||||||
|
if (Wait-HttpOk -Url $healthUrl -TimeoutSec 180) {
|
||||||
|
Write-Ok '视觉服务就绪'
|
||||||
|
Show-Health
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
Write-Fail '视觉服务 180 秒内未就绪'
|
||||||
|
Show-LogTail -Name 'vision' -Lines 25
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Health {
|
||||||
|
try {
|
||||||
|
$r = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||||
|
Write-Note ("health: " + $r.Content.Trim())
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' app-cli / CLIP 视觉服务' -ForegroundColor White
|
||||||
|
Write-Host ' ----------------------------------------' -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
switch ($Action) {
|
||||||
|
'start' {
|
||||||
|
if (-not (Start-CLIP)) { exit 1 }
|
||||||
|
}
|
||||||
|
'stop' {
|
||||||
|
if (Stop-NamedService 'vision') { Write-Ok '视觉服务已停止' }
|
||||||
|
else { Write-Note '视觉服务未在本脚本登记(可能没启动过)' }
|
||||||
|
if (Test-PortInUse $port) {
|
||||||
|
$owner = Get-PortOwnerPid $port
|
||||||
|
Write-Warn "端口 $port 仍被 PID $owner 占用"
|
||||||
|
if ($Force) { Stop-PidTree $owner | Out-Null; Write-Ok "已终止 PID $owner" }
|
||||||
|
else { Write-Note '加 -Force 强制终止:.\vision\vision.ps1 stop -Force' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'restart' {
|
||||||
|
Stop-NamedService 'vision' | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
if (-not (Start-CLIP)) { exit 1 }
|
||||||
|
}
|
||||||
|
'status' {
|
||||||
|
$st = Get-ServiceStatus -Name 'vision' -Port $port -HealthUrl $healthUrl
|
||||||
|
if ($st.Healthy) {
|
||||||
|
Write-Ok "运行中(PID $($st.OwnerPid),端口 $port)"
|
||||||
|
Show-Health
|
||||||
|
} elseif ($st.Listening) {
|
||||||
|
Write-Warn "端口 $port 已监听但 /health 未通过(模型可能仍在加载)"
|
||||||
|
Show-LogTail -Name 'vision' -Lines 10
|
||||||
|
} else {
|
||||||
|
Write-Note '未运行'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'logs' {
|
||||||
|
Show-LogTail -Name 'vision' -Lines 40
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user