From 404f8f6aa5e2b13cad713a0b7bd5d8a5bc1ba614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Mon, 14 Sep 2026 01:59:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E5=8C=96=E6=8E=A5=E5=8F=A3=E4=BD=93?= =?UTF-8?q?=E6=A3=80=E8=84=9A=E6=9C=AC=EF=BC=8C=E5=B9=B6=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E5=8D=B3=E7=94=A8=E7=9A=84=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=20bat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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(双击启动、两条自检线的分工)。 --- AGENTS.md | 23 +- docs/44-演示流程.md | 33 +- start.ps1 | 223 +++++++++++-- tools/make_launcher_bat.py | 163 ++++++++++ tools/portal_api_check.py | 625 +++++++++++++++++++++++++++++++++++++ 启动平台.bat | 62 ++++ 6 files changed, 1090 insertions(+), 39 deletions(-) create mode 100644 tools/make_launcher_bat.py create mode 100644 tools/portal_api_check.py create mode 100644 启动平台.bat diff --git a/AGENTS.md b/AGENTS.md index a223c53..43c75e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,16 +31,31 @@ > 启动:`python -m uvicorn app.main:app --port 8000`(注意模块级变量是 **`app`**,不是 `application`)。 > `tools/portal.py`(8101)**只是跨角色联调工具,不是产品前端**,不要再往它加功能。 > -> **🚀 一键启动与演示(2026-09-13 起)**:根目录 **`start.ps1`** -> (`powershell -ExecutionPolicy Bypass -File start.ps1`)按序做四件事: -> 找解释器 → 检查 MySQL/Redis/Milvus → **刷新行情** → 起 **API 与 Agent Worker 两个窗口**。 +> **🚀 一键启动与演示(2026-09-13 起)**:**不想敲命令就直接双击桌面的 +> `启动金融Agent平台.bat`**(仓库里也有一份 `启动平台.bat`)。它按序做六件事: +> 找解释器 → 检查 MySQL/Redis/Milvus → **刷新行情** → 起 **API 与 Agent Worker 两个窗口** +> → **等 API 真正应答** → 自动开浏览器。**重复双击是安全的**(API 按端口、Worker 按进程判断, +> 不会起第二份);参数可透传,如 `启动平台.bat -Port 8100`、`-NoBrowser`、`-SkipPriceSync`。 +> bat 由 **`python tools/make_launcher_bat.py`** 生成(改完 `start.ps1` 或想换路径就重跑它, +> 桌面与仓库两份一起更新)—— 不要手写那个 bat,它必须同时满足 **GBK 编码 + CRLF 换行 + 无 BOM**, +> 缺任何一条 cmd 都会解析错乱(LF 换行会把 `echo` 的说明文字当命令执行, +> 实测报 `AT 命令已弃用`、`']' 不是内部或外部命令`)。 +> 也可以直接用脚本:`powershell -ExecutionPolicy Bypass -File start.ps1`。 > 演示数据一键准备:`python tools/seed_demo_data.py`(10 步,顺序有依赖,见脚本内表格); > **演示流程(8 个场景照读版 + 排障表 + 账号速查)见 `docs/44-演示流程.md`**; -> 交付自检:`python tools/e2e_smoke_test.py`(6 条线 40 项,`--read-only` 不动数据)。 +> 交付自检(**两条线互补,都跑一遍**): +> `python tools/e2e_smoke_test.py`(**业务链路**冒烟:登录→下单→成交、风控扫描→处置闭环、客服问答,6 条线 40 项,`--read-only` 不动数据); +> `python tools/portal_api_check.py`(**接口契约**体检:按前端的方式调每个端点,核对状态码、信封形状与字段是否与前端期望一致,41 项;`--write` 加测写操作、`--dangerous` 再加测会改生效配置的操作)。 > ⚠️ **`start.ps1` 必须保存为 UTF-8 with BOM**:Windows PowerShell 5.1 在缺 BOM 时按系统 > ANSI(简中为 GBK)解析,中文注释直接抛 `Unexpected token '[璀﹀憡]'` 这类语法错误。 > 用 `edit`/`write` 类工具改完**务必补回 BOM**(只加字节、别重写换行: > `d=open(p,'rb').read(); open(p,'wb').write(b'\xef\xbb\xbf'+d)`)。 +> ⚠️ **解释器探测必须实测「能 import 依赖」,不能只看 `--version` 成功**: +> 曾经因此选中一个 Python 3.10 环境(本项目用 `datetime.UTC`,3.11+ 才有,且依赖 `asyncmy`), +> 报错却发生在**行情刷新**那一步,看起来像"行情源坏了"。现在的门槛是 +> **版本 ≥ 3.11 + `import fastapi, sqlalchemy, asyncmy, pydantic` 通过**。 +> 另注:`... 2>&1 | Select-Object -First 1` 会掐断上游 native 进程、把 `$LASTEXITCODE` 弄脏, +> 在探测循环里用它会**把每个候选都判成"无法执行"** —— 先接住输出再取行。 > ⚠️ **行情有效期只有 15 分钟**(`app/service/trade_service.py` 的 `MAX_QUOTE_AGE`), > 超时后**所有委托一律 503「行情已过期」**且无自动刷新 —— 这是演示最容易翻的一环。 > 补刷用 `python tools/sync_market_prices.py`,**立即生效、无需重启服务**。 diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md index 39356f7..56c6885 100644 --- a/docs/44-演示流程.md +++ b/docs/44-演示流程.md @@ -40,25 +40,48 @@ python tools/sync_market_prices.py ### 0.3 启动平台 +**最省事的办法:双击桌面上的 `启动金融Agent平台.bat`。** 不用开 PowerShell、不用敲命令。 +(仓库根目录也有一份 `启动平台.bat`,是同一个东西。) + +它按顺序做六件事:**找解释器 → 检查 MySQL/Redis/Milvus → 刷新行情 → 起两个窗口 +→ 等 API 真正应答 → 自动开浏览器**,然后打印访问入口与账号。 + +> **重复双击是安全的**:API 按端口判断、Worker 按进程判断,已经在跑的不会起第二份。 +> 演示前如果服务已经开着,直接双击它就等于"刷新行情 + 打开页面"。 + +想传参数就用命令行(bat 会把参数原样转给 `start.ps1`): + ```powershell -powershell -ExecutionPolicy Bypass -File start.ps1 +powershell -ExecutionPolicy Bypass -File start.ps1 # 等价于双击 +powershell -ExecutionPolicy Bypass -File start.ps1 -NoBrowser # 不自动开浏览器 +powershell -ExecutionPolicy Bypass -File start.ps1 -Port 8100 # 换端口(8000 被别的程序占了) ``` -它按顺序做四件事:**找解释器 → 检查 MySQL/Redis/Milvus → 刷新行情 → 起两个窗口**,然后打印访问入口与账号。 -它会**开两个窗口**: +它**开两个窗口**: | 窗口 | 作用 | 少了它会怎样 | |---|---|---| | API | 所有接口与页面 | 什么都没有 | | **Worker** | Agent 对话、知识向量同步、记忆抽取、风控扫描 | 客服对话一直"超时";新知识不进 Milvus 且**无任何报错** | +> ⚠️ 两个窗口都别关。启动脚本自己的窗口跑完就可以关(服务在独立窗口里)。 + ### 0.4 开场前自检(务必做) +两条线互补,都跑一遍: + ```powershell -python tools/e2e_smoke_test.py --read-only +python tools/e2e_smoke_test.py --read-only # 业务链路:登录→下单→成交、风控闭环、客服问答 +python tools/portal_api_check.py # 接口契约:按前端的方式核对每个端点的状态码与字段 ``` -看最后一行:**40/40 通过**才开始演示。有 FAIL 就按 §4 排查。 +看最后一行:**40/40** 与 **41 项全通过**才开始演示。有 FAIL 就按 §4 排查。 + +> 两者的分工:`e2e_smoke_test.py` 回答"**这条业务走得通吗**", +> `portal_api_check.py` 回答"**接口返回的东西前端能不能正确显示**"。 +> 后者是 2026-09-13 那轮"照着接口把前端测一遍"的固化 —— 当时靠它查出 6 个真缺陷 +> (知识库页永远显示 0 块、详情页 5 行全是 `--`、配置项首次编辑必然 409、非法场景值冒成 500 等)。 +> 加 `--write` 会再测一遍写操作(会产生数据);`--dangerous` 会动生效配置,**演示前不要加**。 ### 0.5 打开第一个页面 diff --git a/start.ps1 b/start.ps1 index 0f6d981..d1c9884 100644 --- a/start.ps1 +++ b/start.ps1 @@ -30,15 +30,23 @@ .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]$SkipPriceSync, + [switch]$NoBrowser ) $ErrorActionPreference = "Stop" @@ -48,49 +56,161 @@ 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 { - if ($candidate -eq "python") { - $version = & python --version 2>&1 - } else { - if (-not (Test-Path $candidate)) { continue } - $version = & $candidate --version 2>&1 - } - if ($LASTEXITCODE -eq 0) { - $Python = $candidate - Write-Host "解释器:$candidate($version)" -ForegroundColor Green - break - } - } catch { continue } + $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 "[失败] 找不到可用的 Python。请先建虚拟环境,或把解释器路径加进本脚本的候选列表。" -ForegroundColor Red + 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 = $false - try { - $reachable = (Test-NetConnection -ComputerName $service.HostName -Port $service.Port ` - -InformationLevel Quiet -WarningAction SilentlyContinue) - } catch { $reachable = $false } + $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 @@ -127,24 +247,53 @@ 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 " ") - Start-Process -FilePath $shell -ArgumentList "-NoExit", "-Command", $command | Out-Null + # 显式给 WorkingDirectory:`app.main:app` 与 `app.worker` 都是相对当前目录导入的, + # 继承错目录会直接 ModuleNotFoundError。 + Start-Process -FilePath $shell -ArgumentList "-NoExit", "-Command", $command ` + -WorkingDirectory $Root | Out-Null } Write-Host "`n--- 启动进程 ---" -ForegroundColor Cyan -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 (-not $ApiOnly) { +# 幂等:重复双击启动脚本不应该起出第二个 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 " [跳过] Worker(-ApiOnly):客服对话会一直 queued、新知识不会进 Milvus" -ForegroundColor Yellow + Write-Host " [警告] 等了 30 秒 API 仍未就绪:切到 API 窗口看报错(常见原因是数据库连不上)" -ForegroundColor Yellow } # ---------------------------------------------------------------- 访问入口 -Start-Sleep -Seconds 3 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/" @@ -160,8 +309,22 @@ 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 " · 首次使用先准备数据: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" +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 diff --git a/tools/make_launcher_bat.py b/tools/make_launcher_bat.py new file mode 100644 index 0000000..eb1c423 --- /dev/null +++ b/tools/make_launcher_bat.py @@ -0,0 +1,163 @@ +"""生成"双击即用"的启动 bat。 + +## 为什么用脚本生成而不是直接写 .bat 文件 + +两个编码陷阱,靠普通文本工具写必然踩: + +1. **cmd 按系统 ANSI 解析批处理**(简体中文下是 GBK)。同样一段中文, + 存成 UTF-8 后在 cmd 里就是乱码 —— 所以 bat 必须写成 **GBK**。 +2. **`start.ps1` 必须带 BOM**(见该文件自己的 .NOTES)。本脚本顺带校验, + 少了就补,避免"双击后满屏语法错误"。 + +## 为什么 bat 里只调 start.ps1 + +启动逻辑(解释器发现、依赖检查、行情刷新、幂等起窗口、就绪探测)都在 +`start.ps1` 里,且已经能单独跑。bat 只负责"双击"这一层: +定位项目 → 切代码页 → 交给 PowerShell → 留住窗口让人看到账号和报错。 +逻辑只有一份,不会两边漂移。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +BAT_TEMPLATE = r"""@echo off +chcp 936 >nul +title 金融 Agent 平台 · 一键启动 + +rem ============================================================ +rem 双击本文件即可启动平台: +rem 依赖检查(MySQL/Redis/Milvus) -> 刷新基金行情 -> API(含前端) -> Agent Worker +rem 真正的启动逻辑在项目里的 start.ps1,这里只做入口。 +rem 如果项目换了位置,改下面这一行 PROJ 即可。 +rem ============================================================ + +set "PROJ={project}" + +echo ============================================================ +echo 金融 Agent 平台 · 一键启动 +echo ============================================================ +echo. + +if not exist "%PROJ%\start.ps1" ( + echo [错误] 找不到启动脚本: + echo %PROJ%\start.ps1 + echo. + echo 项目可能被移动或改名了。用记事本打开本文件, + echo 把 set "PROJ=..." 那一行改成实际路径。 + echo. + pause + exit /b 1 +) + +cd /d "%PROJ%" + +echo 项目目录:%PROJ% +echo 即将启动:API(前端+接口) 、Agent Worker 、并刷新基金行情 +echo 就绪后会自动打开浏览器,请稍候... +echo. + +rem %%* 让参数能透传给 start.ps1,例如: +rem 启动平台.bat -NoBrowser 不自动开浏览器 +rem 启动平台.bat -Port 8100 换端口 +rem 启动平台.bat -SkipPriceSync 不联网刷新行情 +powershell -NoProfile -ExecutionPolicy Bypass -File "%PROJ%\start.ps1" %* +set "RC=%ERRORLEVEL%" + +echo. +echo ============================================================ +if "%RC%"=="0" ( + echo 启动完成。服务在**独立窗口**里运行,关掉本窗口不影响它们。 +) else ( + echo 启动过程返回了错误码 %RC%,请往上翻看红色输出。 +) +echo. +echo 服务窗口说明: +echo - 平台 API :接口 + 前端页面,地址 http://127.0.0.1:8000/portal/ +echo - Agent Worker:客服对话、知识向量同步、风控扫描,必须一起开着 +echo. +echo 常用命令(在本目录新开一个终端执行): +echo python tools\seed_demo_data.py 首次使用,准备演示数据 +echo python tools\sync_market_prices.py 下单报"行情已过期"时补刷 +echo python tools\e2e_smoke_test.py 交付自检 +echo ============================================================ +echo. +pause +""" + + +def build(project_root: Path) -> str: + return BAT_TEMPLATE.format(project=project_root) + + +def write_bat(path: Path, content: str) -> None: + """按 GBK + CRLF 落盘:这是 cmd 能正确解析并显示中文的唯一组合。 + + ⚠️ **换行必须是 CRLF**,这一点踩过坑:`cmd` 对 LF-only 的批处理会行边界错乱, + 把本该 `echo` 出去的说明文字当成命令执行(实测出现 `AT 命令已弃用`、 + `']' 不是内部或外部命令` 这种莫名其妙的报错,还会真的去跑脚本)。 + """ + try: + encoded = content.encode("gbk") + except UnicodeEncodeError as exc: # pragma: no cover - 防御性 + raise SystemExit( + f"[失败] 内容含 GBK 无法表示的字符({exc.object[exc.start:exc.end]!r})。" + "bat 里只能用 GBK 能表示的字符。" + ) from exc + + # 先把 CRLF 归一成 LF,再统一展开 —— 否则模板里已经是 CRLF 的行会变成 CRCRLF。 + encoded = encoded.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") + path.write_bytes(encoded) + + # 校验(不是"我觉得写对了",是真的读回来验):无 BOM、纯 CRLF、GBK 可逆。 + raw = path.read_bytes() + assert not raw.startswith(b"\xef\xbb\xbf"), "bat 不能带 BOM:cmd 会把 BOM 当命令的一部分" + assert b"\n" not in raw.replace(b"\r\n", b""), "存在裸 LF:cmd 会错乱解析" + # 比编码是否可逆时先把两边都归一成 LF,否则比的是"换行风格"而不是"内容"。 + assert raw.decode("gbk").replace("\r\n", "\n") == content.replace("\r\n", "\n"), \ + "GBK 往返不一致" + print(f"[OK] 已写入 {path}({len(raw)} 字节,GBK/CRLF,无 BOM)") + + +def ensure_ps1_bom(ps1_path: Path) -> None: + """`start.ps1` 缺 BOM 会让 Windows PowerShell 5.1 按 GBK 解析、直接抛语法错。""" + raw = ps1_path.read_bytes() + if raw.startswith(b"\xef\xbb\xbf"): + print(f"[OK] {ps1_path.name} 已带 BOM") + return + ps1_path.write_bytes(b"\xef\xbb\xbf" + raw) + print(f"[修好] {ps1_path.name} 缺 BOM,已补上(否则 PowerShell 5.1 解析中文会报语法错)") + + +def main() -> int: + target_dir = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.home() / "Desktop" + if not target_dir.is_dir(): + print(f"[失败] 目标目录不存在:{target_dir}") + return 1 + + ps1_path = PROJECT_ROOT / "start.ps1" + if not ps1_path.is_file(): + print(f"[失败] 找不到 {ps1_path}") + return 1 + ensure_ps1_bom(ps1_path) + + content = build(PROJECT_ROOT) + + # 桌面:用户双击的那个 + write_bat(target_dir / "启动金融Agent平台.bat", content) + + # 项目根:随仓库走,别人 clone 下来也能双击(路径指向自己所在目录) + write_bat(PROJECT_ROOT / "启动平台.bat", content) + + print() + print("完成。双击桌面的「启动金融Agent平台.bat」即可:") + print(" 依赖检查 → 刷新基金行情 → API(前端) → Agent Worker → 自动开浏览器") + print("重复双击是安全的:API 按端口判断、Worker 按进程判断,都不会起第二份。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/portal_api_check.py b/tools/portal_api_check.py new file mode 100644 index 0000000..e7a492a --- /dev/null +++ b/tools/portal_api_check.py @@ -0,0 +1,625 @@ +"""门户接口体检:按前端的方式调用每个端点,核对状态、信封与字段。 + +## 为什么需要它 + +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` 的分工 + +| 脚本 | 回答的问题 | +|---|---| +| `e2e_smoke_test.py` | **业务链路通不通**:登录→下单→成交、风控扫描→处置闭环、客服问答 | +| `portal_api_check.py`(本脚本) | **接口契约对不对**:字段名是否与前端一致、`data` 是 list 还是 dict、错误码是 422 还是 500、参数边界 | + +两者互补,都跑一遍最稳。**前置与冒烟相同**(见 `e2e_smoke_test.py` 的"前置"一节, +其中 `sync_market_prices.py` 与常驻 Worker 尤其关键)。 + +## 三档(默认只跑第一档) + + python tools/portal_api_check.py # 只读:GET 与只读分析,不动数据 + python tools/portal_api_check.py --write # 加测写操作(会产生数据) + python tools/portal_api_check.py --dangerous # 再加**会改生效配置**的操作 + +⚠️ `--dangerous` 那一档包括**激活配置版本**。2026-09-13 有一次就是激活了空版本, +`config_release` 是整版本替换语义 ⇒ 所有配置项(客服/风控的工具白名单)失效 ⇒ +**Agent 全线失败关闭**。跑之前请确认你知道怎么恢复(用 `tools/publish_*.py` 重新发布, +或把上一个版本的配置项复制进新版本 —— 注意 `rollbacks` **不复制配置项**,救不回来)。 + +## 退出码 + +0 = 全部通过;1 = 有 FAIL(`NOTE` 是已知问题,不影响退出码)。 +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import urllib.error +import urllib.request +import uuid +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr] + +BASE = "http://127.0.0.1:8000" +DEMO_ACCOUNTS = { + "customer": ("cust_t", "123456"), + "risk": ("risk_t", "666666"), + "advisor": ("advisor_t", "abc12345"), + "operator": ("offsite_t", "offsite123"), + "admin": ("admin_t", "88888888"), +} + +RESULTS: list[tuple[str, str, str]] = [] +TOKENS: dict[str, str] = {} +CONTEXT: dict[str, Any] = {} +DO_WRITE = False +DO_DANGEROUS = False + + +# ---------------------------------------------------------------- 基础设施 + +def call( + method: str, + path: str, + token: str | None = None, + body: Any = None, + *, + headers: dict[str, str] | None = None, + expect_json: bool = True, + timeout: int = 60, +) -> tuple[int, Any, dict[str, str]]: + """发一个与前端行为一致的请求(写操作自动带 Idempotency-Key)。""" + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(BASE + path, data=data, method=method) + request.add_header("Accept", "application/json") + if data is not None: + request.add_header("Content-Type", "application/json") + if token: + request.add_header("Authorization", f"Bearer {token}") + for key, value in (headers or {}).items(): + request.add_header(key, value) + if method != "GET": + request.add_header("Idempotency-Key", uuid.uuid4().hex) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8", errors="replace") + payload = json.loads(raw) if (expect_json and raw.strip()) else raw + return response.status, payload, dict(response.headers) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + return exc.code, json.loads(raw) if raw.strip() else {}, dict(exc.headers) + except Exception: + return exc.code, {"_raw": raw[:200]}, dict(exc.headers) + except Exception as exc: # noqa: BLE001 + return 0, {"error": {"code": type(exc).__name__, "message": str(exc)}}, {} + + +def login(username: str, password: str) -> str | None: + status, payload, _ = call( + "POST", "/api/v1/auth/tokens", body={"username": username, "password": password} + ) + return (payload.get("data") or {}).get("access_token") if status == 200 else None + + +def record(name: str, ok: bool, detail: str) -> None: + RESULTS.append(("PASS" if ok else "FAIL", name, detail)) + + +def note(name: str, detail: str) -> None: + """已知问题或设计如此:既不算通过也不算失败。""" + RESULTS.append(("NOTE", name, detail)) + + +def skip(name: str, detail: str) -> None: + RESULTS.append(("SKIP", name, detail)) + + +# ---------------------------------------------------------------- data 形状 + +def unwrap(payload: Any) -> Any: + """前端 `request()` 取的是 `payload.data`(端点标了 `raw` 的除外)。""" + return payload["data"] if isinstance(payload, dict) and "data" in payload else payload + + +def resolve_row( + payload: Any, *, nested: str | None = None, list_key: str | None = None +) -> dict[str, Any]: + """定位"被渲染的那一行"。 + + `data` 在本平台有三种形状,前端三种都要处理:**裸 list**、**dict[list_key]**、 + **dict(单对象)**。把它们区分开是本脚本的第一价值 —— 曾经就是这里判断错, + 把三个正常接口报成了"缺字段"。 + """ + data = unwrap(payload) + if nested: + return (data or {}).get(nested) or {} + if isinstance(data, list): + return data[0] if data else {} + if isinstance(data, dict): + if list_key: + rows = data.get(list_key) + if isinstance(rows, list): + return rows[0] if rows else {} + return data + if list_key and isinstance(payload, dict): + rows = payload.get(list_key) + if isinstance(rows, list): + return rows[0] if rows else {} + return {} + + +def check( + name: str, + method: str, + path: str, + role: str | None = None, + *, + expect: int | tuple[int, ...] = 200, + fields: tuple[str, ...] = (), + nested: str | None = None, + list_key: str | None = None, +) -> Any: + """调用并核对:状态码 +(可选)前端 render 真正读取的字段。""" + token = TOKENS.get(role) if role else TOKENS.get("guest") + status, payload, _ = call(method, path, token) + expected = (expect,) if isinstance(expect, int) else expect + ok = status in expected + detail = f"HTTP={status}" + if not ok: + code = (payload.get("error") or {}).get("code") if isinstance(payload, dict) else None + detail += f" {code or ''} 期望 {expected}" + elif fields: + row = resolve_row(payload, nested=nested, list_key=list_key) + missing = [f for f in fields if f not in (row or {})] + if missing: + ok = False + detail += f" 缺字段={missing}" + else: + detail += f" 字段齐全({len(fields)})" + data = unwrap(payload) + if isinstance(data, list): + detail += f" 共{len(data)}条" + elif list_key and isinstance(data, dict) and isinstance(data.get(list_key), list): + detail += f" 共{len(data[list_key])}条" + record(name, ok, detail) + return payload + + +# ---------------------------------------------------------------- 只读组 + +def check_guest_readonly() -> None: + check("访客:公开产品 P001", "GET", "/api/v1/products", list_key="products", + fields=("product_code", "product_name", "exchange_code", "product_category", + "risk_level", "current_nav", "change_pct")) + # 列表接口的 data 是 list(前端 `state.products = data`),不是 {products: [...]} + listing = unwrap(call("GET", "/api/v1/products?limit=20")[1]) + rows = listing.get("products") if isinstance(listing, dict) else listing + product_code = (rows or [{}])[0].get("product_code") or "510300" + check("访客:净值走势 P002", "GET", f"/api/v1/products/{product_code}/nav-history?days=120", + list_key="points", fields=("nav_date", "nav")) + + +def check_customer_readonly() -> None: + check("客户:资产看板 T001·account", "GET", "/api/v1/users/me/account/dashboard", "customer", + nested="account", fields=("available_cash",)) + check("客户:资产看板 T001·summary", "GET", "/api/v1/users/me/account/dashboard", "customer", + nested="summary", fields=("total_asset", "total_market_value")) + check("客户:持仓 T006", "GET", "/api/v1/users/me/holdings", "customer", list_key="holdings", + fields=("product_code", "product_name", "total_quantity", "average_cost", "market_value")) + check("客户:委托列表 T003", "GET", "/api/v1/users/me/orders?limit=20", "customer", + fields=("order_no", "product_name", "product_code", "order_side", "quantity", + "average_executed_price", "quote_price", "status", "submitted_at")) + check("客户:成交列表 T007", "GET", "/api/v1/users/me/transactions?limit=20", "customer", + list_key="transactions", + fields=("transaction_no", "order_no", "product_name", "product_code", "order_side", + "executed_quantity", "executed_price", "gross_amount", "fee_amount", + "net_amount", "executed_at")) + check("客户:资金流水 T009", "GET", "/api/v1/users/me/cash-ledger?limit=20", "customer", + list_key="entries") + check("客户:风险测评 ONB001", "GET", "/api/v1/onboarding/risk-questionnaire", "customer") + + # 详情类:需要真实单据号,从列表里取 + orders = resolve_row( + call("GET", "/api/v1/users/me/orders?limit=1", TOKENS.get("customer"))[1] + ) + order_no = orders.get("order_no") + if order_no: + check("客户:委托详情 T004", "GET", f"/api/v1/users/me/orders/{order_no}", "customer", + fields=("order_no", "product_name", "product_code", "order_side", "price_type", + "quantity", "filled_quantity", "quote_price", "average_executed_price", + "status", "submitted_at", "quote_at")) + else: + skip("客户:委托详情 T004", "库里没有委托,先跑一次下单或 e2e_smoke_test") + + txns = unwrap(call("GET", "/api/v1/users/me/transactions?limit=1", TOKENS.get("customer"))[1]) + txn_no = (txns.get("transactions") or [{}])[0].get("transaction_no") if isinstance(txns, dict) else None + if txn_no: + check("客户:成交详情 T008", "GET", f"/api/v1/users/me/transactions/{txn_no}", "customer", + fields=("transaction_no", "order_no", "product_name", "product_code", "order_side", + "executed_price", "executed_quantity", "gross_amount", "fee_amount", + "net_amount", "quote_at", "executed_at")) + else: + skip("客户:成交详情 T008", "库里没有成交记录") + + +def check_risk_readonly() -> None: + check("风控:概览 RK001", "GET", "/api/v1/risk/overview", "risk", + fields=("total", "pending", "levels")) + # ⚠️ limit 上限是 5(RiskAlertPageQuery),传大了会 422 —— 前端传的正是 5 + check("风控:预警列表 RK002(limit=5)", "GET", "/api/v1/risk/alerts?limit=5", "risk", + fields=("alert_no", "risk_level", "customer_no", "product_name", "alert_type", "status")) + alerts = call("GET", "/api/v1/risk/alerts?limit=1", TOKENS.get("risk"))[1] + rows = unwrap(alerts) if isinstance(unwrap(alerts), list) else [] + alert_no = rows[0].get("alert_no") if rows else None + if alert_no: + check("风控:预警详情 RK003", "GET", f"/api/v1/risk/alerts/{alert_no}", "risk") + # ⚠️ source 是**枚举**:customers/products/transactions/capital_flows/holdings/ + # login_records/alerts/notifications —— 传别的会 422 + check("风控:证据 RK004(source=customers)", "GET", + f"/api/v1/risk/evidence/customers?alert_no={alert_no}&limit=10", "risk") + else: + skip("风控:预警详情 RK003 / 证据 RK004", "库里没有预警") + check("风控:通知 RK005(limit=10)", "GET", "/api/v1/risk/notifications?limit=10", "risk") + + +def check_advisor_readonly() -> None: + check("投顾:已发布方案 AD011", "GET", "/api/v1/advisor/recommendations/published", "advisor", + fields=("content_id", "customer_id", "content_type", "plan", "published_at")) + goals = call("GET", "/api/v1/advisor/customers/9001/investment-goals/current", + TOKENS.get("advisor"))[1] + goal = unwrap(goals) or {} + if goal.get("goal_no"): + check("投顾:客户目标 AD003", "GET", + "/api/v1/advisor/customers/9001/investment-goals/current", "advisor", + fields=("goal_no", "customer_id", "status", "goal_book", "confirmed_at")) + check("投顾:目标方案书 AD005", "GET", + f"/api/v1/advisor/investment-goals/{goal['goal_no']}/goal-book", "advisor", + fields=("goal_no", "goal_status", "review_status", "content")) + else: + skip("投顾:客户目标 AD003 / 方案书 AD005", "客户 9001 还没有投资目标") + + +def check_admin_readonly() -> None: + check("管理:配置发布 A002", "GET", "/api/v1/admin/config-releases?limit=20", "admin", + fields=("id", "release_no", "title", "status", "created_by", "updated_at")) + check("管理:模型端点 A012", "GET", "/api/v1/admin/model-endpoints?limit=20", "admin") + check("管理:审计 A033", "GET", "/api/v1/admin/audit-records?limit=20", "admin") + check("管理:角色列表 A035", "GET", "/api/v1/admin/roles", "admin") + check("管理:角色权限 A037", "GET", "/api/v1/admin/roles/customer/permissions", "admin") + check("管理:用户身份 A038", "GET", "/api/v1/admin/users/9001/roles", "admin") + check("管理:画像候选 A039", "GET", "/api/v1/admin/customer-profile-candidates?limit=20", "admin") + check("管理:转人工工单", "GET", + "/api/v1/admin/customer-service/handover-tickets?limit=20", "admin") + check("管理:投顾待审 A047", "GET", "/api/v1/admin/advisor/pending-contents", "admin", + fields=("content_id", "customer_id", "content_type", "review_status", "goal_no")) + # ⚠️ K003 是**裸信封**({items, count}),这是前端最容易搞错的一处 + status, payload, _ = call("GET", "/api/v1/knowledge/list?limit=50", TOKENS.get("admin")) + bare = isinstance(payload, dict) and "items" in payload and "data" not in payload + record("管理:知识库 K003(裸信封)", status == 200 and bare, + f"HTTP={status} {'顶层 items:符合裸信封' if bare else '信封形状变了,前端按裸体取会拿不到数据'}") + + # 配置项/路由规则列表:顺带取一个 release_id 给写入档用 + releases = unwrap(call("GET", "/api/v1/admin/config-releases?limit=20", TOKENS.get("admin"))[1]) + if isinstance(releases, list) and releases: + CONTEXT["release_id"] = releases[0].get("id") + check("管理:配置项列表 A009", "GET", + f"/api/v1/admin/config-releases/{CONTEXT['release_id']}/platform-config-items", "admin") + check("管理:路由规则列表 A019", "GET", + f"/api/v1/admin/config-releases/{CONTEXT['release_id']}/model-routing-rules", "admin") + + +def check_operator_readonly() -> None: + check("运营:场外邮件列表", "GET", "/api/v1/offsite-fund/mails?limit=20", "operator") + check("运营:邮箱状态", "GET", "/api/v1/offsite-fund/mailbox-status", "operator") + + +# ---------------------------------------------------------------- 写入档 + +def check_write() -> None: + admin = TOKENS.get("admin") + + # ---- 配置版本 + 配置项(含 If-Match 链路)---- + status, payload, _ = call("POST", "/api/v1/admin/config-releases", admin, { + "release_no": f"CHECK-{uuid.uuid4().hex[:8]}", "title": "接口体检版本", + "change_summary": "由 portal_api_check.py --write 创建,用于验证配置项与路由规则的增改链路。"}) + release_id = (payload.get("data") or {}).get("id") + record("写入:新建配置版本 A001", status == 201 and bool(release_id), + f"HTTP={status} id={release_id}") + if not release_id: + return + CONTEXT["check_release_id"] = release_id + + # 配置项的 key 必须是「已注册 agent_type:声明的意图」,而且白名单**不能超出 + # 该 Agent 的代码上限** —— `admin_service` 会拿 `AgentFactory.definition()` 校验 + # `allowed_tools`,手编一个工具名必然 422「配置超出 Agent 工具上限」。 + # (我第一版就写死了 `search_knowledge`,结果选中的是风控 Agent,直接被打回。) + # 正确做法:从接口声明的意图配置里取工具,保证落在上限内 —— 这同时也验证了 + # 「意图配置的 allowed_tools 能被配置项接口接受」这条跨接口一致性。 + raw_intents = unwrap(call("GET", "/api/v1/admin/agent-intent-configs?limit=100", admin)[1]) or [] + if isinstance(raw_intents, dict): + raw_intents = (raw_intents.get("items") or raw_intents.get("intent_configs") + or raw_intents.get("records") or []) + usable = [ + row for row in raw_intents + if isinstance(row, dict) and row.get("agent_type") and row.get("intent_code") + and row.get("allowed_tools") + ] + if usable: + seed = usable[0] + agent = str(seed["agent_type"]) + intent = str(seed["intent_code"]) + tools = [str(t) for t in seed["allowed_tools"]][:1] + else: + agent, intent, tools = "customer_service", "faq", [] + item_key = f"{agent}:{intent}" + + if not tools: + skip("写入:新增配置项 A008", "没有声明了工具的白名单意图,无法构造合法配置项") + else: + status, payload, _ = call( + "POST", f"/api/v1/admin/config-releases/{release_id}/platform-config-items", admin, + {"namespace": "agent_tools", "item_key": item_key, + "value_json": {"allowed_tools": tools}, "schema_version": "1"}) + item_id = (payload.get("data") or {}).get("id") + error = (payload.get("error") or {}) + record("写入:新增配置项 A008", status == 201 and bool(item_id), + f"HTTP={status} id={item_id} key={item_key} tools={tools} " + f"{error.get('code') or ''} {error.get('message') or ''}") + + if item_id: + # 更新必须带 If-Match;etag 只能从**详情端点**取(列表的 meta 没有它) + detail = call("GET", + f"/api/v1/admin/config-releases/{release_id}/platform-config-items/{item_id}", + admin)[1] + etag = ((detail.get("meta") or {}).get("etag")) if isinstance(detail, dict) else None + record("写入:配置项详情取 ETag A048", bool(etag), + f"etag={'有' if etag else '没有'}(没有就说明 PUT 链路是死的)") + if etag: + status, payload, _ = call( + "PUT", + f"/api/v1/admin/config-releases/{release_id}/platform-config-items/{item_id}", + admin, + {"namespace": "agent_tools", "item_key": item_key, + "value_json": {"allowed_tools": tools}, + "schema_version": "1"}, + ) + # 注意:这里**故意不带 If-Match** —— 期望 409,证明乐观并发没被削弱 + record("写入:不带 If-Match 应 409", status == 409, f"HTTP={status}") + + # ---- 路由规则:max_attempts 不能超过端点数量 ---- + endpoints = unwrap(call("GET", "/api/v1/admin/model-endpoints?limit=20", admin)[1]) or [] + active = [e for e in endpoints if e.get("status") == "active"] + if active: + endpoint_id = active[0]["id"] + status, payload, _ = call( + "POST", f"/api/v1/admin/config-releases/{release_id}/model-routing-rules", admin, + {"rule_code": f"check-{uuid.uuid4().hex[:8]}", "agent_type": agent, + "task_type": "intent_classify", "model_policy": "primary_only", + "primary_endpoint_id": endpoint_id, "fallbacks": [], "max_attempts": 1, + "latency_budget_ms": 15000, "priority": 100}) + record("写入:新增路由规则 A018", status == 201, + f"HTTP={status} endpoint={endpoint_id} {(payload.get('error') or {}).get('message') or ''}") + status, payload, _ = call( + "POST", f"/api/v1/admin/config-releases/{release_id}/model-routing-rules", admin, + {"rule_code": f"check-bad-{uuid.uuid4().hex[:8]}", "agent_type": agent, + "task_type": "intent_classify", "model_policy": "primary_only", + "primary_endpoint_id": endpoint_id, "fallbacks": [], "max_attempts": 2, + "latency_budget_ms": 15000, "priority": 100}) + # fallbacks 为空时 max_attempts=2 必须被拒 —— 前端曾固定写 2,导致表单必然失败 + record("写入:max_attempts 超端点数量应 422", status == 422, f"HTTP={status}") + else: + skip("写入:路由规则 A018", "没有已激活的模型端点") + + # ---- 知识库(裸信封)---- + text = "# 接口体检文档\n\n## 一、目的\n\n" + ("这用于确认上传能被切分并写入知识表。" * 12) + "\n" + status, payload, _ = call("POST", "/api/v1/knowledge/upload", admin, { + "filename": f"check-{uuid.uuid4().hex[:8]}.md", + "content_base64": base64.b64encode(text.encode()).decode(), + "knowledge_type": "faq"}) + ids = (payload.get("knowledge_ids") or []) if isinstance(payload, dict) else [] + record("写入:知识库上传 K002(裸信封)", status == 201 and bool(ids), + f"HTTP={status} 切分 {len(ids)} 块") + for knowledge_id in ids: + status, _, _ = call("DELETE", f"/api/v1/knowledge/{knowledge_id}", admin) + record(f"写入:知识库失效 K004(id={knowledge_id})", status == 200, f"HTTP={status}") + + # ---- 回复模板:scene 是枚举,非法值必须是 422 而不是 500 ---- + status, payload, _ = call("POST", "/api/v1/admin/reply-templates", admin, { + "template_code": f"ZZCHECK-{uuid.uuid4().hex[:6]}", "scene": "not_a_scene", + "title": "体检用", "content_text": "不应被创建"}) + record("写入:回复模板非法 scene 应 422(非 500)", status == 422, + f"HTTP={status} ← 500 表示 schema 漏了枚举校验") + + # ---- 敏感词(用绝不会被说出的词)---- + status, payload, _ = call("POST", "/api/v1/admin/negative-word-rules", admin, { + "rule_code": f"ZZCHECK-{uuid.uuid4().hex[:6]}", + "word_pattern": f"ZZCHECKTOKEN{uuid.uuid4().hex[:6]}", + "match_type": "contains", "category": "体检", "severity": "warn"}) + record("写入:创建敏感词规则", status == 201, + f"HTTP={status} {(payload.get('error') or {}).get('message') or ''}") + + # ---- 推广物料:完整链路(合规会拦不合规输入,这里用合规的最小输入)---- + status, payload, _ = call("POST", "/api/v1/fund-promotion-materials", admin, { + "product_name": "红利低波50ETF南方", "product_code": "515450", + "material_title": f"接口体检材料 {uuid.uuid4().hex[:6]}", + "style_code": "steady_professional", "output_formats": ["pptx"]}) + task_no = (payload.get("data") or {}).get("task_no") + record("写入:创建推广任务", bool(task_no), f"HTTP={status} task_no={task_no}") + if task_no: + na = "不适用" + status, payload, _ = call( + "PUT", f"/api/v1/fund-promotion-materials/{task_no}/inputs", admin, { + "product_info": {"fund_type": "股票型-指数", "operation_mode": "契约型开放式", + "product_status": "new_product", + "investment_objective": "紧密跟踪标的指数。", + "benchmark": "红利低波50指数", "risk_level": "R3"}, + "manager_info": {"manager_name": "张三", + "management_company": "南方基金管理股份有限公司", + "registration_code": "F0000000000000"}, + "team_info": {"team_description": "指数投资团队。"}, + "strategy_info": {"investment_scope": "标的指数成份股。", + "strategy": "完全复制法。", "restrictions": "遵守合同约定。"}, + "fee_structure": {"subscription_fee": na, "purchase_fee": na, "redemption_fee": na, + "sales_service_fee": na, "management_fee": "0.50%/年", + "custody_fee": "0.10%/年", "client_maintenance_fee": na}, + "performance_info": {"show_product_performance": False, + "show_manager_performance": False}, + "risk_disclosure": {"special_risks": ["指数跟踪偏离风险"]}, + "source_notes": {}}) + record("写入:更新材料输入", status == 200, + f"HTTP={status} {(payload.get('error') or {}).get('message') or ''}") + status, payload, _ = call( + "POST", f"/api/v1/fund-promotion-materials/{task_no}/generations", admin, + {"output_formats": ["pptx"]}) + version_id = (payload.get("data") or {}).get("material_version_id") or \ + (payload.get("data") or {}).get("id") + record("写入:生成推介材料", status in (200, 201) and bool(version_id), + f"HTTP={status} version_id={version_id}") + status, payload, _ = call( + "GET", f"/api/v1/fund-promotion-materials/{task_no}/compliance-checks", admin) + findings = ((unwrap(payload) or {}).get("findings") or []) if isinstance(unwrap(payload), dict) else [] + record("写入:合规检查", status == 200, + f"HTTP={status} findings={len(findings)}" + + (f"({findings[0].get('rule_code')})" if findings else "")) + + +# ---------------------------------------------------------------- 危险档 + +def check_dangerous() -> None: + """会改**生效配置**的操作。默认不跑。""" + admin = TOKENS.get("admin") + release_id = CONTEXT.get("check_release_id") + if not release_id: + skip("危险:配置发布四态", "没有可用的测试版本(先跑 --write)") + return + + base = f"/api/v1/admin/config-releases/{release_id}" + + def etag_headers() -> dict[str, str]: + _, _, headers = call("GET", base, admin) + value = headers.get("ETag") or headers.get("etag") or "" + return {"If-Match": value} if value else {} + + # ⚠️ 这一档会把该版本**激活**。若该版本内容不全,生效配置会被替换掉, + # 客服/风控的工具白名单可能随之失效(config_release 是整版本替换语义)。 + status, payload, _ = call("POST", f"{base}/validations", admin, {}) + record("危险:提交校验 A004", status in (200, 422), + f"HTTP={status} {(payload.get('error') or {}).get('message') or ''}") + if status != 200: + note("危险:后续审核/激活", "校验未通过,按设计不再继续") + return + status, payload, _ = call("POST", f"{base}/reviews", admin, + {"decision": "approved", "comment": "接口体检"}, headers=etag_headers()) + record("危险:审核 A005", status == 200, + f"HTTP={status} {(payload.get('error') or {}).get('message') or ''}") + if status != 200: + return + note("危险:激活 A006", "**故意不执行** —— 激活会替换生效配置。" + "需要时手工调,或先确认该版本已继承全部既有配置项。") + + +# ---------------------------------------------------------------- 主流程 + +def main() -> int: + global DO_WRITE, DO_DANGEROUS + + parser = argparse.ArgumentParser(description="门户接口体检") + parser.add_argument("--write", action="store_true", help="加测写操作(会产生数据)") + parser.add_argument("--dangerous", action="store_true", help="再加测会改生效配置的操作") + args = parser.parse_args() + DO_WRITE = args.write or args.dangerous + DO_DANGEROUS = args.dangerous + + print("=" * 100) + print("门户接口体检:按前端的方式调用端点,核对状态、信封与字段") + print(f"档位:{'危险(含写)' if DO_DANGEROUS else '写入(含只读)' if DO_WRITE else '只读'}") + print("=" * 100) + + # 前置:健康检查 + 访客令牌 + 五个角色登录 + status, _, _ = call("GET", "/internal/health/ready") + if status != 200: + print(f"\n[失败] API 未就绪(GET /internal/health/ready -> {status})。请先起服务。") + return 1 + + status, payload, _ = call("POST", "/api/v1/visitor-tokens") + # 访客令牌端点是 `raw` 形状:令牌在顶层,没有 data 信封 + TOKENS["guest"] = (payload.get("data") or payload).get("access_token") if status == 201 else None + record("前置:访客令牌 V001", bool(TOKENS["guest"]), f"HTTP={status}") + + for role, (username, password) in DEMO_ACCOUNTS.items(): + token = login(username, password) + TOKENS[role] = token + record(f"前置:{role} 登录({username})", bool(token), "" if token else "登录失败,该角色全部用例会红") + + print() + print("--- 只读:访客 / 客户 ---") + check_guest_readonly() + check_customer_readonly() + print("--- 只读:风控 / 投顾 ---") + check_risk_readonly() + check_advisor_readonly() + print("--- 只读:管理员 / 运营 ---") + check_admin_readonly() + check_operator_readonly() + + if DO_WRITE: + print("--- 写入档(会产生数据)---") + check_write() + else: + skip("写入档", "未加 --write,跳过") + + if DO_DANGEROUS: + print("--- 危险档(会改生效配置)---") + check_dangerous() + else: + skip("危险档", "未加 --dangerous,跳过") + + # 汇总 + print() + print("=" * 100) + for verdict, name, detail in RESULTS: + print(f"{verdict:<6}{name:<40}{detail}") + print("=" * 100) + failed = [r for r in RESULTS if r[0] == "FAIL"] + passed = [r for r in RESULTS if r[0] == "PASS"] + noted = [r for r in RESULTS if r[0] == "NOTE"] + skipped = [r for r in RESULTS if r[0] == "SKIP"] + print(f"合计 {len(RESULTS)} 项:通过 {len(passed)},失败 {len(failed)}," + f"已知问题 {len(noted)},跳过 {len(skipped)}") + + if failed: + print("\n失败项:") + for _, name, detail in failed: + print(f" · {name} {detail}") + print("\n提示:字段缺失通常是「前端按建表字段写、接口返回视图没带」;") + print(" 500 通常是「表上有 CHECK 而 schema 只做长度校验」;") + print(" 422 通常是「前端传的参数超出接口约束(如 limit 上限)」。") + return 1 + + print("\n全部通过。") + if DO_WRITE: + print("\n注意:写入档会留下测试数据(配置版本、敏感词等**没有删除端点**)。" + "\n 命名前缀 CHECK-/ZZCHECK- 便于识别;知识库条目已在脚本内自动失效。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/启动平台.bat b/启动平台.bat new file mode 100644 index 0000000..a760ff4 --- /dev/null +++ b/启动平台.bat @@ -0,0 +1,62 @@ +@echo off +chcp 936 >nul +title ½ðÈÚ Agent ƽ̨ ¡¤ һ¼üÆô¶¯ + +rem ============================================================ +rem ˫»÷±¾Îļþ¼´¿ÉÆô¶¯ƽ̨£º +rem ÒÀÀµ¼ì²é(MySQL/Redis/Milvus) -> ˢлù½ðÐÐÇé -> API(º¬ǰ¶Ë) -> Agent Worker +rem ÕæÕýµÄÆô¶¯Â߼­ÔÚÏîĿÀïµÄ start.ps1£¬ÕâÀïֻ×öÈë¿ڡ£ +rem Èç¹ûÏîĿ»»ÁËλÖ㬸ÄÏÂÃæÕâһÐÐ PROJ ¼´¿ɡ£ +rem ============================================================ + +set "PROJ=C:\Users\Windows\Desktop\ÏîĿ´úÂë" + +echo ============================================================ +echo ½ðÈÚ Agent ƽ̨ ¡¤ һ¼üÆô¶¯ +echo ============================================================ +echo. + +if not exist "%PROJ%\start.ps1" ( + echo [´íÎó] ÕҲ»µ½Æô¶¯½ű¾£º + echo %PROJ%\start.ps1 + echo. + echo ÏîĿ¿ÉÄܱ»Òƶ¯»ò¸ÄÃûÁˡ£ÓüÇʱ¾´򿪱¾Îļþ£¬ + echo °Ñ set "PROJ=..." ÄÇһÐиijÉʵ¼Ê·¾¶¡£ + echo. + pause + exit /b 1 +) + +cd /d "%PROJ%" + +echo ÏîĿĿ¼£º%PROJ% +echo ¼´½«Æô¶¯£ºAPI(ǰ¶Ë+½ӿÚ) ¡¢Agent Worker ¡¢²¢ˢлù½ðÐÐÇé +echo ¾ÍÐ÷ºó»á×Զ¯´ò¿ªä¯ÀÀÆ÷£¬ÇëÉԺò... +echo. + +rem %%* ÈòÎÊýÄÜ͸´«¸ø start.ps1£¬ÀýÈ磺 +rem Æô¶¯ƽ̨.bat -NoBrowser ²»×Զ¯¿ªä¯ÀÀÆ÷ +rem Æô¶¯ƽ̨.bat -Port 8100 »»¶˿Ú +rem Æô¶¯ƽ̨.bat -SkipPriceSync ²»ÁªÍøˢÐÂÐÐÇé +powershell -NoProfile -ExecutionPolicy Bypass -File "%PROJ%\start.ps1" %* +set "RC=%ERRORLEVEL%" + +echo. +echo ============================================================ +if "%RC%"=="0" ( + echo Æô¶¯Íê³ɡ£·þÎñÔÚ**¶ÀÁ¢´°¿Ú**ÀïÔËÐУ¬¹صô±¾´°¿ڲ»ӰÏìËüÃǡ£ +) else ( + echo Æô¶¯¹ý³̷µ»ØÁ˴íÎóÂë %RC%£¬ÇëÍùÉϷ­¿´ºìɫÊä³ö¡£ +) +echo. +echo ·þÎ񴰿Ú˵Ã÷£º +echo - ƽ̨ API £º½ӿÚ + ǰ¶ËҳÃ棬µØַ http://127.0.0.1:8000/portal/ +echo - Agent Worker£º¿ͷþ¶Ի°¡¢֪ʶÏòÁ¿ͬ²½¡¢·ç¿ØɨÃ裬±ØÐëһÆð¿ª×Å +echo. +echo ³£ÓÃÃüÁÔڱ¾Ŀ¼Ð¿ªһ¸öÖնËִÐУ©£º +echo python tools\seed_demo_data.py Ê״ÎʹÓã¬׼±¸ÑÝʾÊý¾Ý +echo python tools\sync_market_prices.py ϵ¥±¨"ÐÐÇéÒѹýÆÚ"ʱ²¹ˢ +echo python tools\e2e_smoke_test.py ½»¸¶×Լì +echo ============================================================ +echo. +pause