From d3c6dd07a91df1778b630cbbd5e452f335a65b32 Mon Sep 17 00:00:00 2001 From: fengruixiang <> Date: Wed, 21 Oct 2026 16:29:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9C=BA=E6=99=AF=E8=B7=91=E5=AE=8C?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=8A=A5=E5=91=8A=EF=BC=8Cloop.ps1=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scenarios/_lib.ps1:Start-LoopRun / Add-LoopStep / Set-LoopTicket / Save-LoopReport,请观察台把这次运行存成 run\reports\ 下的单文件 HTML - S1、S8 每个断言都记成一步,通过和失败都出报告;-NoReport 只断言不出报告 - S1 增加「Graph 应答 200」断言:之前 Graph 返回 400 时 IG 侧也记了出站,要到 send-txt 502 才判失败,原因不直观 - loop.ps1:观察台传 --reports-dir;新增 reports 命令列出最近的报告 Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++- loop.ps1 | 16 +++++++++-- scenarios/_lib.ps1 | 54 +++++++++++++++++++++++++++++++++++ scenarios/s1-ig-text-echo.ps1 | 24 ++++++++++++---- scenarios/s8-human-chain.ps1 | 39 ++++++++++++++++--------- 5 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 scenarios/_lib.ps1 diff --git a/README.md b/README.md index 0f5127c..9edef4a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,11 @@ ## 场景 -`scenarios\` 下每个脚本独立可跑,通过退出 0、失败退出 1 并打印两侧记录。前提是 `up` 后五个角色都健康。 +`scenarios\` 下每个脚本独立可跑,通过退出 0、失败退出 1 并打印两侧记录。前提是 `up` 后各角色都健康。 +观察台在跑时,每次跑完(通过或失败)都会生成一份**场景报告**:这次的时序图、iXserver 内部步骤、逐步断言、部署版本, +存成 `run\reports\<开始时间>-<场景>-.html`(单文件,离线可开,不进 git)。 +`.\loop.ps1 reports` 列出最近的,观察台 里也能点开。加 `-NoReport` 只断言不出报告(省几秒)。 +新写场景照 S1 / S8 点源 `scenarios\_lib.ps1`:`Start-LoopRun` → 每个断言 `Add-LoopStep` → `Save-LoopReport`。 ```powershell .\scenarios\s1-ig-text-echo.ps1 # IG 文本 → iXserver → 第三方 chatbot echo → IG 收到 diff --git a/loop.ps1 b/loop.ps1 index c97f5eb..80120a2 100644 --- a/loop.ps1 +++ b/loop.ps1 @@ -12,6 +12,7 @@ reset-abp iCONAbpFX 还原到留底备份;-Init 首次打补丁并留底 status 各角色进程、端口、健康 logs 看某个角色的日志尾巴 + reports 列出最近的场景报告(run\reports\,观察台 /reports/ 里也能看) .EXAMPLE .\loop.ps1 deploy @@ -21,10 +22,11 @@ .\loop.ps1 up .\loop.ps1 status .\loop.ps1 logs -Only ixserver + .\loop.ps1 reports #> param( [Parameter(Position = 0)] - [ValidateSet('deploy', 'up', 'down', 'restart', 'reset', 'reset-abp', 'status', 'logs')] + [ValidateSet('deploy', 'up', 'down', 'restart', 'reset', 'reset-abp', 'status', 'logs', 'reports')] [string]$Command = 'status', # 只操作这些角色:abp, ixserver, chatbot, igmock, scrm @@ -51,6 +53,7 @@ $Dirs = @{ Logs = Join-Path $Run 'logs' Pids = Join-Path $Run 'pids' Tools = Join-Path $Run 'tools' + Reports = Join-Path $Run 'reports' } foreach ($d in $Dirs.Values) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d | Out-Null } } @@ -243,7 +246,7 @@ function Get-RoleSpec([string]$role) { $cmd = "`"$(Get-NodeExe)`" `"$script`" --port $($p.Console)" + " --ig http://127.0.0.1:$($p.IgMock) --chatbot http://127.0.0.1:$($p.Chatbot) --scrm http://127.0.0.1:$($p.ScrmMock)" + " --ixserver http://localhost:$($p.Ixserver) --abp https://localhost:$($p.Abp)" + - " --abp-log-dir `"$(Join-Path $Dirs.Abp 'Logs\Information')`" --ix-log-dir `"$(Join-Path $Dirs.Ixserver 'Log')`" --deployed `"$(Join-Path $Root 'deployed.json')`"" + + " --abp-log-dir `"$(Join-Path $Dirs.Abp 'Logs\Information')`" --ix-log-dir `"$(Join-Path $Dirs.Ixserver 'Log')`" --deployed `"$(Join-Path $Root 'deployed.json')`" --reports-dir `"$($Dirs.Reports)`"" + " --sql-server `"$($s.Server)`" --sql-user $($s.User) --db $($s.IxserverDb)" + " --company-code $($Cfg.CompanyCode) --service-code $($Cfg.ServiceCode) --quota-name $($Cfg.QuotaName)" return @{ @@ -550,4 +553,13 @@ switch ($Command) { Get-Content (Join-Path $Dirs.Logs "$role.log") -Tail $Tail -ErrorAction SilentlyContinue | Out-Host } } + 'reports' { + $files = @(Get-ChildItem $Dirs.Reports -Filter '*.html' -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First $Tail) + if (-not $files.Count) { Write-Ok '还没有报告:跑一个 scenarios\ 下的脚本(观察台要在跑)'; return } + $files | ForEach-Object { + $result = if ($_.Name -like '*-PASS.html') { '通过' } else { '失败' } + [pscustomobject]@{ Time = $_.LastWriteTime.ToString('MM-dd HH:mm:ss'); Result = $result; File = $_.Name } + } | Format-Table -AutoSize | Out-Host + Write-Ok "观察台里打开:http://127.0.0.1:$($Cfg.Ports.Console)/reports/" + } } diff --git a/scenarios/_lib.ps1 b/scenarios/_lib.ps1 new file mode 100644 index 0000000..c179a79 --- /dev/null +++ b/scenarios/_lib.ps1 @@ -0,0 +1,54 @@ +<# + 场景脚本公用:计时、记下每一步断言,结束时(通过或失败)请闭环观察台生成场景报告。 + 报告是观察台截取这段时间窗里的每一跳、状态变化、iXserver 日志,连同断言与部署版本存成的单文件 HTML, + 放在 run\reports\(不进 git),列表在 http://127.0.0.1:/reports/。 + + 用法(在场景脚本里点源): + . (Join-Path $PSScriptRoot '_lib.ps1') + Start-LoopRun 'S1' 'IG 文本 → 第三方 echo → IG' + Add-LoopStep 'iXserver 收 webhook' $true 'HTTP 200' + Set-LoopTicket 1973133303 + Save-LoopReport -Passed $true # 或 Save-LoopReport -Passed $false -Reason '…' + 脚本带 -NoReport 时设 $LoopNoReport = $true 就只断言不出报告(省掉等日志落盘的几秒)。 +#> + +$LoopCfg = Import-PowerShellDataFile (Join-Path $PSScriptRoot '..\config\loop.psd1') +$script:LoopRun = $null + +function Start-LoopRun([string]$Scenario, [string]$Title = '') { + $script:LoopRun = [ordered]@{ + scenario = $Scenario + title = $Title + startedAt = (Get-Date).ToUniversalTime().ToString('o') + steps = New-Object System.Collections.ArrayList + ticketId = '' + } +} + +function Add-LoopStep([string]$Name, [bool]$Ok, [string]$Detail = '') { + if (-not $script:LoopRun) { return } + [void]$script:LoopRun.steps.Add([ordered]@{ name = $Name; ok = $Ok; detail = $Detail }) +} + +function Set-LoopTicket($TicketId) { + if ($script:LoopRun -and $TicketId) { $script:LoopRun.ticketId = [string]$TicketId } +} + +function Save-LoopReport([bool]$Passed, [string]$Reason = '') { + $run = $script:LoopRun + if (-not $run -or $LoopNoReport) { return } + $run.endedAt = (Get-Date).ToUniversalTime().ToString('o') + $run.passed = $Passed + $run.reason = $Reason + $console = "http://127.0.0.1:$($LoopCfg.Ports.Console)" + try { + $body = [Text.Encoding]::UTF8.GetBytes(($run | ConvertTo-Json -Depth 6 -Compress)) + # 观察台要等 iXserver 日志落盘、库轮询跟上才截取,这一步会停几秒 + $r = Invoke-RestMethod "$console/_api/report" -Method Post -ContentType 'application/json; charset=utf-8' -Body $body -TimeoutSec 60 + Write-Host "报告:$($r.file)($($r.hops) 跳 / $($r.logs) 条日志;观察台里打开 $console$($r.url))" + } + catch { + # 报告是附带的:观察台没在跑不影响场景本身的结果 + Write-Host "没有生成报告(观察台没在跑?.\loop.ps1 up -Only console):$($_.Exception.Message)" -ForegroundColor Yellow + } +} diff --git a/scenarios/s1-ig-text-echo.ps1 b/scenarios/s1-ig-text-echo.ps1 index 06d37a8..78b6f3d 100644 --- a/scenarios/s1-ig-text-echo.ps1 +++ b/scenarios/s1-ig-text-echo.ps1 @@ -1,12 +1,15 @@ <# S1:IG 用户发一条文本 → iXserver → 第三方 chatbot(echo 自动回复)→ iXserver → IG 用户收到 echo。 - 前提:.\loop.ps1 up 四个角色都健康,chatbot 以 --auto-reply echo 运行(config\loop.psd1 的 ChatbotAutoReply)。 - 通过退出 0,失败退出 1 并打印两侧的记录。用法:.\scenarios\s1-ig-text-echo.ps1 [-TimeoutSec 60] + 前提:.\loop.ps1 up 各角色都健康,chatbot 以 --auto-reply echo 运行(config\loop.psd1 的 ChatbotAutoReply)。 + 通过退出 0,失败退出 1 并打印两侧的记录;两种情况都在 run\reports\ 生成场景报告(观察台在跑时)。 + 用法:.\scenarios\s1-ig-text-echo.ps1 [-TimeoutSec 60] [-NoReport] #> -param([int]$TimeoutSec = 60) +param([int]$TimeoutSec = 60, [switch]$NoReport) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' -$Cfg = Import-PowerShellDataFile (Join-Path $PSScriptRoot '..\config\loop.psd1') +$LoopNoReport = [bool]$NoReport +. (Join-Path $PSScriptRoot '_lib.ps1') +$Cfg = $LoopCfg $ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)" $cb = "http://127.0.0.1:$($Cfg.Ports.Chatbot)" @@ -17,13 +20,17 @@ function Fail([string]$why) { Write-Host '--- chatbot 记录(最近 6 条)' # 记录是新的在前 (Invoke-RestMethod "$cb/_api/state").events | Select-Object -First 6 | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Out-Host + Add-LoopStep $why $false + Save-LoopReport -Passed $false -Reason $why exit 1 } +Start-LoopRun 'S1' 'IG 文本 → 第三方 echo → IG' Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null $text = 'loop-s1 ' + [guid]::NewGuid().ToString('N').Substring(0, 8) $sent = Invoke-RestMethod "$ig/_api/send" -Method Post -ContentType 'application/json; charset=utf-8' -Body (@{ kind = 'text'; text = $text } | ConvertTo-Json) if ($sent.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($sent.event.response.status)" } +Add-LoopStep 'iXserver 收下 webhook' $true "「$text」HTTP 200" # 出站是异步的:轮询 IG 模拟的 outbound,带上限 $expected = "echo: $text" @@ -33,9 +40,13 @@ while (-not $hit -and (Get-Date) -lt $deadline) { $hit = @((Invoke-RestMethod "$ig/_api/state").events | Where-Object { $_.kind -eq 'outbound' -and $_.summary -eq $expected }) | Select-Object -First 1 if (-not $hit) { Start-Sleep -Milliseconds 500 } } -if (-not $hit) { Fail "$TimeoutSec 秒内 IG 用户没有收到「$expected」" } +if (-not $hit) { Fail "$TimeoutSec 秒内 iXserver 没有向 IG 用户发出「$expected」" } +Add-LoopStep 'iXserver 向 IG 用户发出 echo' $true $expected if (@($hit.issues).Count) { Fail "出站请求有问题:$($hit.issues -join ';')" } if (-not $hit.tokenPresent) { Fail '出站请求没带 access_token' } +Add-LoopStep '出站请求带 token、报文无问题' $true +if ($hit.response.status -ne 200) { Fail "Graph 应答 $($hit.response.status)(IG 模拟档位 $($hit.response.mode)),用户实际没收到" } +Add-LoopStep 'Graph 应答 200,用户收到' $true $hit.response.messageId # 也要等:iXserver 在 send-txt 应答之前就把消息发到 IG 了,客户端拿到应答才记 reply,会晚上百毫秒 $reply = $null @@ -44,7 +55,10 @@ while (-not $reply -and (Get-Date) -lt $deadline.AddSeconds(5)) { if (-not $reply) { Start-Sleep -Milliseconds 200 } } if (-not $reply) { Fail 'chatbot 侧没有对应的回复记录' } +Set-LoopTicket $reply.ticketId if ($reply.response.status -ne 201) { Fail "send-txt 返回 $($reply.response.status)" } +Add-LoopStep '第三方 send-txt 201' $true "ticket $($reply.ticketId)" Write-Host "S1 通过:「$text」→ ticket $($reply.ticketId) → IG 收到「$expected」" -ForegroundColor Green +Save-LoopReport -Passed $true exit 0 diff --git a/scenarios/s8-human-chain.ps1 b/scenarios/s8-human-chain.ps1 index 28d0806..4d26b0b 100644 --- a/scenarios/s8-human-chain.ps1 +++ b/scenarios/s8-human-chain.ps1 @@ -3,13 +3,16 @@ SCRM 开工(SetSetting IsEnable=false + UpdateCSAvailable 3)→ 客服上线(UserReg + 额度池) → 新 IG 用户发消息 → 人工工单,SCRM 在 WebSocket 上收到推送 → 指派 → 打开(验证信息 + 已读) → 以客服身份回复 → IG 用户收到 → 结束工单 → SCRM 下班(切回机器人、在线 0),不影响 S1。 - 前提:.\loop.ps1 up 五个角色都健康。每次用新的 IGSID,保证建出新工单。 - 通过退出 0,失败退出 1 并打印记录。用法:.\scenarios\s8-human-chain.ps1 [-TimeoutSec 60] [-KeepShift] + 前提:.\loop.ps1 up 各角色都健康。每次用新的 IGSID,保证建出新工单。 + 通过退出 0,失败退出 1 并打印记录;两种情况都在 run\reports\ 生成场景报告(观察台在跑时)。 + 用法:.\scenarios\s8-human-chain.ps1 [-TimeoutSec 60] [-KeepShift] [-NoReport] #> -param([int]$TimeoutSec = 60, [switch]$KeepShift) +param([int]$TimeoutSec = 60, [switch]$KeepShift, [switch]$NoReport) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' -$Cfg = Import-PowerShellDataFile (Join-Path $PSScriptRoot '..\config\loop.psd1') +$LoopNoReport = [bool]$NoReport +. (Join-Path $PSScriptRoot '_lib.ps1') +$Cfg = $LoopCfg $ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)" $sc = "http://127.0.0.1:$($Cfg.Ports.ScrmMock)" @@ -31,13 +34,16 @@ function Fail([string]$why) { (Invoke-RestMethod "$sc/_api/state").events | Select-Object -First 15 | ForEach-Object { ($_ | ConvertTo-Json -Depth 8 -Compress) } | Out-Host Write-Host '--- IG 模拟记录' (Invoke-RestMethod "$ig/_api/state").events | ForEach-Object { "{0} {1} {2}" -f $_.kind, $_.igsid, $_.summary } | Out-Host + Add-LoopStep $why $false if (-not $KeepShift) { Post "$sc/_api/shift" @{ on = $false } | Out-Null } + Save-LoopReport -Passed $false -Reason $why exit 1 } -function Step([string]$action, $body) { +function Step([string]$name, [string]$action, $body) { $r = Post "$sc/_api/$action" $body - if (-not $r.ok) { Fail "$action 未成功:$($r.error) $((@($r.calls) | ForEach-Object { $_.api + ' → ' + ($_.response.body.msg) }) -join ';')" } + if (-not $r.ok) { Fail "$name($action)未成功:$($r.error) $((@($r.calls) | ForEach-Object { $_.api + ' → ' + ($_.response.body.msg) }) -join ';')" } + Add-LoopStep $name $true ((@($r.calls) | ForEach-Object { $_.api }) -join ' + ') return $r } @@ -51,37 +57,44 @@ function Wait-For([string]$what, [scriptblock]$probe) { Fail "$TimeoutSec 秒内没等到:$what" } +Start-LoopRun 'S8' 'SCRM 人工链路' $ws = (Invoke-RestMethod "$sc/_api/state").state.ws if ($ws.status -ne 'logged-in') { Fail "SCRM 模拟的 WebSocket 没登录($($ws.status) $($ws.lastError))" } Invoke-RestMethod "$sc/_api/clear" -Method Post | Out-Null Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null -Step 'shift' @{ on = $true } | Out-Null -Step 'agent-online' @{} | Out-Null +Step 'SCRM 开工(切人工、在线 3)' 'shift' @{ on = $true } | Out-Null +Step '客服上线' 'agent-online' @{} | Out-Null $igsid = 'loop-s8-' + [guid]::NewGuid().ToString('N').Substring(0, 8) $sent = Post "$ig/_api/send" @{ kind = 'text'; igsid = $igsid; text = 'S8 想找人工' } if ($sent.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($sent.event.response.status)" } +Add-LoopStep '新 IG 用户发消息' $true "$igsid HTTP 200" # 推送报文没有统一类型字段,按 enduser_openid = IGSID 认出这张新工单 $ticketId = Wait-For 'SCRM 收到这位 IG 用户的工单推送' { $push = @((Invoke-RestMethod "$sc/_api/state").events | Where-Object { $_.kind -eq 'push' -and (($_.parsed | ConvertTo-Json -Depth 8 -Compress) -match [regex]::Escape($igsid)) }) | Select-Object -First 1 if ($push) { $push.ticketIds | Select-Object -First 1 } } +Set-LoopTicket $ticketId +Add-LoopStep 'SCRM 收到工单推送' $true "ticket $ticketId" -Step 'assign' @{ ticket_id = $ticketId } | Out-Null -Step 'open' @{ ticket_id = $ticketId } | Out-Null +Step '指派给客服' 'assign' @{ ticket_id = $ticketId } | Out-Null +Step '打开工单(验证信息 + 已读)' 'open' @{ ticket_id = $ticketId } | Out-Null $replyText = 'S8 客服回复 ' + [guid]::NewGuid().ToString('N').Substring(0, 6) -Step 'reply' @{ ticket_id = $ticketId; text = $replyText } | Out-Null +Step '客服回复' 'reply' @{ ticket_id = $ticketId; text = $replyText } | Out-Null $out = Wait-For "IG 用户收到「$replyText」" { @((Invoke-RestMethod "$ig/_api/state").events | Where-Object { $_.kind -eq 'outbound' -and $_.igsid -eq $igsid -and $_.summary -eq $replyText }) | Select-Object -First 1 } if (@($out.issues).Count) { Fail "出站请求有问题:$($out.issues -join ';')" } +if ($out.response.status -ne 200) { Fail "Graph 应答 $($out.response.status)(IG 模拟档位 $($out.response.mode)),用户实际没收到" } +Add-LoopStep 'IG 用户收到客服回复' $true $replyText -Step 'complete' @{ ticket_id = $ticketId } | Out-Null -if (-not $KeepShift) { Step 'shift' @{ on = $false } | Out-Null } +Step '结束工单' 'complete' @{ ticket_id = $ticketId } | Out-Null +if (-not $KeepShift) { Step 'SCRM 下班(切回机器人、在线 0)' 'shift' @{ on = $false } | Out-Null } Write-Host "S8 通过:$igsid → 人工工单 $ticketId → SCRM 收到推送、指派、回复 → IG 收到「$replyText」→ 已结束" -ForegroundColor Green +Save-LoopReport -Passed $true exit 0