Files
lzf_0626 404f8f6aa5 固化接口体检脚本,并交付双击即用的启动 bat
## tools/portal_api_check.py(新)

把 2026-09-13 那轮"照着接口内容把前端测一遍"的验证固化成可重复跑的脚本。
当时靠这套验证查出 6 个真缺陷,但它们只存在于一次会话里,下次改动没人会重跑:

- `K002`/`K003` 是裸信封,前端按 `payload.data` 取 -> 知识库页显示"已入库 0 块 / 为空"
- 委托/成交详情页照着建表字段写,而接口返回视图没带那些列 -> 5 行永远显示 `--`
- 配置项/路由规则的 `PUT` 要 `If-Match`,却没有端点能返回该 digest -> 首次编辑必然 409
- 回复模板 `scene` 只校验长度不校验枚举 -> 非法值撞数据库 CHECK、冒成 500
- 路由规则表单固定 `max_attempts=2` 且 `fallbacks` 为空 -> 后端必然 422
- 模型端点手填 ID -> 未激活的 ID 直接 422

与 `e2e_smoke_test.py` 分工互补:后者测**业务链路通不通**,本脚本测**接口契约对不对**
(状态码、信封形状、字段名与前端期望是否一致)。

三档,默认只跑第一档:
- 默认只读 41 项(不动数据)
- `--write` 加测写操作:配置项 ETag 链路、`max_attempts` 越界、知识库上传+失效、
  回复模板非法枚举、敏感词、推广物料全链路
- `--dangerous` 再加测会改生效配置的操作(激活配置版本会整版本替换,默认不跑,
  脚本内注明了恢复办法)

实测:只读 39/39、写入档 54/54 全绿。

## 桌面一键启动(双击即用)

桌面 `启动金融Agent平台.bat` + 仓库 `启动平台.bat`,由
`tools/make_launcher_bat.py` 生成,只负责"双击"这一层,启动逻辑复用 `start.ps1`
(不重复实现,避免两边漂移)。参数可透传:`-Port 8100` / `-NoBrowser` / `-SkipPriceSync`。

不要手写这个 bat:必须同时满足 **GBK 编码 + CRLF 换行 + 无 BOM**。生成器自己
读回来校验这三条。踩过的坑:用 `write_bytes` 直接落盘时没转 CRLF,cmd 对 LF-only
批处理会行边界错乱,把 `echo` 的说明文字当命令执行(实测报
`AT 命令已弃用`、`']' 不是内部或外部命令`)。

## start.ps1 的三处修复

1. **解释器探测选错环境(真机双击失败的主因)**
   原先只验证 `--version` 成功就选中,结果挑到一个 Python 3.10 环境:
   本项目用了 `datetime.UTC`(3.11+)且依赖 `asyncmy`,启动当场 ImportError ——
   而报错发生在**行情刷新**那一步,看起来像"行情源坏了"。
   现在实测两项:**版本 >= 3.11** + `import fastapi, sqlalchemy, asyncmy, pydantic` 通过,
   并在跳过时打印具体原因。

2. **`Select-Object -First 1` 掐断 native 管道**
   `& $exe -c ... 2>&1 | Select-Object -First 1` 拿到首个对象后停掉上游管道,
   等于把进程掐了、`$LASTEXITCODE` 变脏,于是**每个候选都被误判成"无法执行"**
   (连装好的 jr_py313 也被跳过)。改为先整体接住输出、再在结果上取行。

3. **幂等 + 就绪探测 + Docker 自动拉起**
   重复双击不再起第二个 API/Worker(API 按端口、Worker 按进程判断);
   起完等 `/internal/health/ready` 真正应答才报成功;Milvus 不可达时尝试拉起
   Docker Desktop 并最多等 60 秒(它不常驻,是"知识检索静默降级"最常见的原因)。

## 实测

- `启动平台.bat` 重复执行:解释器正确选中 jr_py313、依赖检查全 OK、行情已刷新、
  两个服务识别为已在跑并跳过、就绪探测 1 秒
- `-Port 8199` 冷启动:新起 API 窗口,8199 的 `/internal/health/ready` 与 `/portal/` 均 200,
  测试后已清理
- `ruff check app tests tools alembic hq.py` -> All checks passed
- `start.ps1` 语法解析通过(BOM 已保留)、bat 三项编码约束校验通过

文档同步:`AGENTS.md`(启动方式、bat 生成器、解释器探测两个坑)、
`docs/44-演示流程.md` §0.3/§0.4(双击启动、两条自检线的分工)。
2026-09-14 01:59:46 +08:00

331 lines
16 KiB
PowerShell
Raw Permalink 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
启动金融 Agent 平台:依赖检查 → API → Agent Worker。
.NOTES
⚠️ 本文件必须保存为 **UTF-8 with BOM**。
Windows PowerShell 5.1 在缺少 BOM 时按系统 ANSI 解析脚本(简体中文下是 GBK),
中文注释与输出会变成乱码并直接抛语法错误(实测报 `Unexpected token '[璀﹀憡]'`)。
用编辑器改完本文件后,务必另存为「UTF-8 带 BOM」。
.DESCRIPTION
平台需要**两个进程**才能完整工作,本脚本把它们一起起在独立窗口里:
· API —— 所有 HTTP 接口与前端页面(/portal/)
· Worker —— 消费队列:Agent 对话、知识向量同步、记忆抽取、风控扫描
**没有 Worker 的后果**(这也是最容易踩的坑):
· 客服对话会一直停在 queued,前端显示"客服繁忙/超时";
· 新灌的知识写不进 Milvus,客服照旧答不上,且**没有任何报错**。
.PARAMETER Port
API 监听端口,默认 8000。
.PARAMETER SkipChecks
跳过依赖端口检查(MySQL / Redis / Milvus)。
.PARAMETER ApiOnly
只起 API,不起 Worker(仅在明确不需要 Agent 能力时使用)。
.PARAMETER SkipPriceSync
跳过启动时的行情刷新。仅在外部数据源不可用、或不想联网时使用。
.PARAMETER NoBrowser
启动后不自动打开浏览器。
.EXAMPLE
powershell -ExecutionPolicy Bypass -File start.ps1
powershell -ExecutionPolicy Bypass -File start.ps1 -Port 8100
.NOTES
本脚本是**幂等**的:重复执行不会起出第二个 API/Worker
(端口被占用、或已存在 app.worker 进程时会跳过),可以放心反复双击桌面的启动 .bat。
#>
param(
[int]$Port = 8000,
[switch]$SkipChecks,
[switch]$ApiOnly,
[switch]$SkipPriceSync,
[switch]$NoBrowser
)
$ErrorActionPreference = "Stop"
$Root = $PSScriptRoot
Set-Location $Root
Write-Host "=== 金融 Agent 平台 · 启动 ===" -ForegroundColor Cyan
Write-Host "工作目录:$Root"
# ---------------------------------------------------------------- 小工具
# 用 .NET 的 TcpClient 探端口:比 Test-NetConnection 快一个量级
# (后者每个端口要 1~5 秒,双击启动时全花在等待上),且不依赖 WinRM 相关服务。
function Test-PortOpen {
param([string]$TargetHost, [int]$TargetPort, [int]$TimeoutMs = 1500)
$client = New-Object System.Net.Sockets.TcpClient
try {
$async = $client.BeginConnect($TargetHost, $TargetPort, $null, $null)
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { return $false }
$client.EndConnect($async)
return $true
} catch {
return $false
} finally {
$client.Close()
}
}
# Worker 没有监听端口,只能按命令行匹配进程。
function Test-WorkerRunning {
try {
$procs = Get-CimInstance Win32_Process -Filter "Name like '%python%'" -ErrorAction SilentlyContinue
return @($procs | Where-Object { $_.CommandLine -like '*app.worker*' }).Count -gt 0
} catch {
return $false
}
}
# Milvus 跑在 Docker 里,而 **Docker Desktop 不会常驻**:它没起来时 Milvus 必然不可达,
# 后果是知识检索**静默降级**(客服答不上知识题、新灌的知识进不了向量库,且没有任何报错)。
# 所以这里尝试把 Docker Desktop 拉起来,等 Milvus 端口出现为止。
function Start-DockerIfPossible {
$candidates = @(
(Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe"),
(Join-Path ${env:ProgramFiles(x86)} "Docker\Docker\Docker Desktop.exe"),
(Join-Path $env:LOCALAPPDATA "Docker\Docker Desktop.exe")
) | Where-Object { $_ -and (Test-Path $_) }
if (-not $candidates) {
Write-Host " [提示] 没找到 Docker Desktop:Milvus 无法自动拉起" -ForegroundColor DarkGray
return $false
}
Write-Host " [启动] Docker Desktop(Milvus 依赖它)…" -ForegroundColor Yellow
Start-Process ($candidates | Select-Object -First 1) | Out-Null
# 冷启动通常 30~60 秒。最多等 60 秒,每 5 秒报告一次,避免看起来像卡死。
for ($i = 1; $i -le 12; $i++) {
Start-Sleep -Seconds 5
if (Test-PortOpen "127.0.0.1" 19530) {
Write-Host (" [OK] Milvus 已就绪(等待 {0} 秒)" -f ($i * 5)) -ForegroundColor Green
return $true
}
Write-Host (" 等待 Milvus… 已等 {0} 秒" -f ($i * 5)) -ForegroundColor DarkGray
}
Write-Host " [警告] 等了 60 秒 Milvus 仍未就绪:本次启动继续,知识检索会降级" -ForegroundColor Yellow
return $false
}
# ---------------------------------------------------------------- 解释器
# 逐个尝试:项目虚拟环境 → 常见 conda 环境 → PATH 上的 python。
# 不写死任何一个,因为各人机器的环境不同(.venv 被 gitignore,不进仓库)。
#
# ⚠️ 判定标准不是「这个 exe 能跑」,而是「这个环境能跑起本项目」。
# 这里踩过坑:原先只验证 `--version` 成功就选中,结果 PATH 上先撞到一个
# Python 3.10 环境 —— 本项目用了 `datetime.UTC`(3.11+)且依赖 `asyncmy`,
# 启动当场 ImportError,而且报错发生在**行情刷新**那一步,看上去像
# 「行情源坏了」,很难联想到是解释器选错。
# 所以这里实测两项:**版本 ≥ 3.11** + **关键依赖能导入**。
$candidates = @(
(Join-Path $Root ".venv\Scripts\python.exe"),
"D:\conda\envs\jr_py313\python.exe",
"$env:USERPROFILE\miniconda3\envs\jr_py313\python.exe",
"$env:USERPROFILE\anaconda3\envs\jr_py313\python.exe",
"python"
)
$Python = $null
foreach ($candidate in $candidates) {
if ($candidate -ne "python" -and -not (Test-Path $candidate)) { continue }
# ⚠️ 这里**不能**用 `... 2>&1 | Select-Object -First 1`。
# `Select-Object -First 1` 拿到第一个对象后会停掉上游管道,而上游是 native 命令 ——
# 等于把进程掐了,`$LASTEXITCODE` 随即变成非 0,于是**每个候选都被误判成"无法执行"**
# (实测:连装得好好的 jr_py313 也被跳过,最后报"找不到 Python 环境")。
# 先整体接住输出、再在结果上取行,才安全。
$versionOutput = ""
try {
$versionOutput = & $candidate -c "import sys;print('.'.join(map(str,sys.version_info[:3])))" 2>$null
} catch {
Write-Host " [跳过] $candidate —— 无法执行" -ForegroundColor DarkGray
continue
}
if ($LASTEXITCODE -ne 0) {
Write-Host " [跳过] $candidate —— 无法执行" -ForegroundColor DarkGray
continue
}
$version = "$versionOutput".Trim()
# 版本门槛:`app/` 里用了 datetime.UTC(3.11 起才有),3.10 必然导入失败。
$parts = $version.Split(".")
$major = [int]$parts[0]
$minor = [int]$parts[1]
if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 11)) {
Write-Host " [跳过] $candidate —— Python $version,本项目要求 3.11+" -ForegroundColor DarkYellow
continue
}
# 依赖门槛:只看版本号不够,环境可能没装 asyncmy / fastapi。
# `$ErrorActionPreference = "Stop"` 下 native 命令写 stderr 会变成终止性错误,
# 所以 try 住,把报错当"这个候选不合适",而不是让整个脚本崩掉。
$probeOutput = ""
try {
$probeOutput = & $candidate -c "import fastapi, sqlalchemy, asyncmy, pydantic" 2>&1
} catch {
$probeOutput = "$_"
}
if ($LASTEXITCODE -ne 0) {
$reason = ("$probeOutput" -split "`n" |
Where-Object { $_ -match "Error" } | Select-Object -First 1)
Write-Host " [跳过] $candidate —— Python $version 但依赖不全:$reason" -ForegroundColor DarkYellow
continue
}
$Python = $candidate
Write-Host "解释器:$candidate(Python $version,依赖完整)" -ForegroundColor Green
break
}
if (-not $Python) {
Write-Host "`n[失败] 找不到能运行本项目的 Python 环境。" -ForegroundColor Red
Write-Host " 需要 3.11+ 且装齐 fastapi / sqlalchemy / asyncmy / pydantic。" -ForegroundColor Red
Write-Host " 可以先建虚拟环境:" -ForegroundColor Yellow
Write-Host " python -m venv .venv" -ForegroundColor Yellow
Write-Host " .venv\Scripts\pip install -e ." -ForegroundColor Yellow
exit 1
}
# ---------------------------------------------------------------- 依赖检查
if (-not $SkipChecks) {
Write-Host "`n--- 依赖服务检查 ---" -ForegroundColor Cyan
# 先处理 Milvus:它跑在 Docker 里,而 Docker Desktop 不会常驻,
# 是最常见的"平台看着正常、知识检索却悄悄降级"的原因。
if (-not (Test-PortOpen "127.0.0.1" 19530)) {
Start-DockerIfPossible | Out-Null
}
$services = @(
@{ Name = "MySQL"; HostName = "127.0.0.1"; Port = 3306; Required = $true },
@{ Name = "Redis"; HostName = "127.0.0.1"; Port = 6379; Required = $false },
@{ Name = "Milvus"; HostName = "127.0.0.1"; Port = 19530; Required = $false }
)
foreach ($service in $services) {
$reachable = Test-PortOpen $service.HostName $service.Port
if ($reachable) {
Write-Host (" [OK] {0,-7} {1}:{2}" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Green
} elseif ($service.Required) {
Write-Host (" [缺失] {0,-7} {1}:{2} —— 平台起不来,请先启动它" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Red
exit 1
} else {
Write-Host (" [警告] {0,-7} {1}:{2} 不可达:相关知识检索会降级、Docker 未运行时常见" -f $service.Name, $service.HostName, $service.Port) -ForegroundColor Yellow
}
}
}
# ---------------------------------------------------------------- 刷新行情
# ⚠️ 为什么放在启动流程里:下单要求行情快照落在 **15 分钟**有效期内
# (`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`),超时后**所有委托直接 503
# 「行情已过期」**,而系统**没有任何自动刷新机制** —— 演示时讲到一半下单就会失败。
# 所以默认在启动时刷一次:15 分钟窗口从此刻重新计时。
# 同步是按产品 upsert 的幂等操作;失败只警告不阻断(仍可手动重跑)。
if (-not $SkipPriceSync) {
Write-Host "`n--- 刷新行情(下单前置,有效期 15 分钟)---" -ForegroundColor Cyan
$syncOutput = & $Python "tools\sync_market_prices.py" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] 行情已刷新,15 分钟内可正常下单" -ForegroundColor Green
} else {
Write-Host " [警告] 行情刷新失败(退出码 $LASTEXITCODE):下单可能返回 503" -ForegroundColor Yellow
Write-Host " 可手动重跑:$Python tools\sync_market_prices.py" -ForegroundColor Yellow
$syncOutput | Select-Object -Last 8 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
}
# ---------------------------------------------------------------- 启动进程
# 用独立窗口起,方便分别看两边的日志(Worker 的日志是排查问题的第一现场)。
function Start-PlatformWindow {
param([string]$Title, [string[]]$Arguments)
$shell = if (Get-Command pwsh -ErrorAction SilentlyContinue) { "pwsh" } else { "powershell" }
$command = "`$host.UI.RawUI.WindowTitle = '$Title'; & '$Python' " + ($Arguments -join " ")
# 显式给 WorkingDirectory:`app.main:app` 与 `app.worker` 都是相对当前目录导入的,
# 继承错目录会直接 ModuleNotFoundError。
Start-Process -FilePath $shell -ArgumentList "-NoExit", "-Command", $command `
-WorkingDirectory $Root | Out-Null
}
Write-Host "`n--- 启动进程 ---" -ForegroundColor Cyan
# 幂等:重复双击启动脚本不应该起出第二个 API / Worker。
# API 用端口判断;Worker 没有端口,用进程命令行判断。
if (Test-PortOpen "127.0.0.1" $Port 500) {
Write-Host " [跳过] 端口 $Port 已有服务在跑(不重复启动 API)。若那不是本平台,请换端口:-Port 8100" -ForegroundColor Yellow
} else {
Start-PlatformWindow -Title "平台 API :$Port" -Arguments @(
"-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$Port"
)
Write-Host " [已启动] API 窗口(uvicorn,端口 $Port)" -ForegroundColor Green
}
if ($ApiOnly) {
Write-Host " [跳过] Worker(-ApiOnly):客服对话会一直 queued、新知识不会进 Milvus" -ForegroundColor Yellow
} elseif (Test-WorkerRunning) {
Write-Host " [跳过] 已有 Worker 在跑(不重复启动)" -ForegroundColor Yellow
} else {
Start-PlatformWindow -Title "平台 Agent Worker" -Arguments @("-m", "app.worker")
Write-Host " [已启动] Worker 窗口(Agent 对话 / 知识向量 / 记忆抽取)" -ForegroundColor Green
}
# ---------------------------------------------------------------- 就绪探测
# 双击启动时最怕"窗口起了但其实没起来"。这里等 API 真正应答再报成功。
Write-Host "`n--- 等待 API 就绪 ---" -ForegroundColor Cyan
$ready = $false
for ($i = 1; $i -le 30; $i++) {
try {
$response = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/internal/health/ready" `
-TimeoutSec 2 -UseBasicParsing -ErrorAction Stop
if ($response.StatusCode -eq 200) { $ready = $true; break }
} catch { }
Start-Sleep -Seconds 1
}
if ($ready) {
Write-Host " [OK] API 已就绪(约 $i 秒)" -ForegroundColor Green
} else {
Write-Host " [警告] 等了 30 秒 API 仍未就绪:切到 API 窗口看报错(常见原因是数据库连不上)" -ForegroundColor Yellow
}
# ---------------------------------------------------------------- 访问入口
Write-Host "`n=== 访问入口 ===" -ForegroundColor Cyan
Write-Host " 门户首页 http://127.0.0.1:$Port/portal/"
Write-Host " 访客页 http://127.0.0.1:$Port/portal/guest/home/"
Write-Host " 客户登录 http://127.0.0.1:$Port/portal/customer/login/ (cust_t / 123456)"
Write-Host " 员工登录 http://127.0.0.1:$Port/portal/employee-console/login/(见下)"
Write-Host " 接口文档 http://127.0.0.1:$Port/docs"
Write-Host "`n=== 演示账号 ===" -ForegroundColor Cyan
Write-Host " 客户 cust_t / 123456"
Write-Host " 风控专员 risk_t / 666666"
Write-Host " 管理员 admin_t / 88888888"
Write-Host " 投顾 advisor_t / abc12345"
Write-Host " 运营 offsite_t / offsite123"
Write-Host "`n提示:" -ForegroundColor Yellow
Write-Host " · 首次使用先准备数据:python tools\seed_demo_data.py"
Write-Host " · 行情有效期只有 15 分钟:演示中途下单若报 503,在新窗口重跑"
Write-Host " python tools\sync_market_prices.py (立即生效,无需重启服务)"
Write-Host " · 体检:python tools\e2e_smoke_test.py (业务链路,40 项)"
Write-Host " python tools\portal_api_check.py (接口契约,41 项)"
Write-Host " · 演示流程见 docs/44-演示流程.md"
# ---------------------------------------------------------------- 打开浏览器
# 服务已经探测就绪,此时打开才不会看到"无法访问"。用户可以从 bat 传 -NoBrowser 关掉。
if (-not $NoBrowser -and $ready) {
try {
Start-Process "http://127.0.0.1:$Port/portal/" | Out-Null
Write-Host "`n已打开浏览器:http://127.0.0.1:$Port/portal/" -ForegroundColor Green
} catch {
Write-Host "`n(自动打开浏览器失败,请手动访问 http://127.0.0.1:$Port/portal/)" -ForegroundColor DarkGray
}
}
Write-Host "`n=== 启动完成。服务在独立窗口里运行,关掉本窗口不影响它们。===" -ForegroundColor Cyan