feat: 场景跑完生成报告,loop.ps1 reports

- 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 <noreply@anthropic.com>
main
fengruixiang 2026-10-21 16:29:39 +08:00
parent e0bfe6ed3a
commit d3c6dd07a9
5 changed files with 118 additions and 21 deletions

View File

@ -45,7 +45,11 @@
## 场景 ## 场景
`scenarios\` 下每个脚本独立可跑,通过退出 0、失败退出 1 并打印两侧记录。前提是 `up` 后五个角色都健康。 `scenarios\` 下每个脚本独立可跑,通过退出 0、失败退出 1 并打印两侧记录。前提是 `up` 后各角色都健康。
观察台在跑时,每次跑完(通过或失败)都会生成一份**场景报告**:这次的时序图、iXserver 内部步骤、逐步断言、部署版本,
存成 `run\reports\<开始时间>-<场景>-<PASS|FAIL>.html`(单文件,离线可开,不进 git)。
`.\loop.ps1 reports` 列出最近的,观察台 <http://127.0.0.1:5090/reports/> 里也能点开。加 `-NoReport` 只断言不出报告(省几秒)。
新写场景照 S1 / S8 点源 `scenarios\_lib.ps1`:`Start-LoopRun` → 每个断言 `Add-LoopStep` → `Save-LoopReport`。
```powershell ```powershell
.\scenarios\s1-ig-text-echo.ps1 # IG 文本 → iXserver → 第三方 chatbot echo → IG 收到 .\scenarios\s1-ig-text-echo.ps1 # IG 文本 → iXserver → 第三方 chatbot echo → IG 收到

View File

@ -12,6 +12,7 @@
reset-abp iCONAbpFX 还原到留底备份;-Init 首次打补丁并留底 reset-abp iCONAbpFX 还原到留底备份;-Init 首次打补丁并留底
status 各角色进程、端口、健康 status 各角色进程、端口、健康
logs 看某个角色的日志尾巴 logs 看某个角色的日志尾巴
reports 列出最近的场景报告(run\reports\,观察台 /reports/ 里也能看)
.EXAMPLE .EXAMPLE
.\loop.ps1 deploy .\loop.ps1 deploy
@ -21,10 +22,11 @@
.\loop.ps1 up .\loop.ps1 up
.\loop.ps1 status .\loop.ps1 status
.\loop.ps1 logs -Only ixserver .\loop.ps1 logs -Only ixserver
.\loop.ps1 reports
#> #>
param( param(
[Parameter(Position = 0)] [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', [string]$Command = 'status',
# 只操作这些角色:abp, ixserver, chatbot, igmock, scrm # 只操作这些角色:abp, ixserver, chatbot, igmock, scrm
@ -51,6 +53,7 @@ $Dirs = @{
Logs = Join-Path $Run 'logs' Logs = Join-Path $Run 'logs'
Pids = Join-Path $Run 'pids' Pids = Join-Path $Run 'pids'
Tools = Join-Path $Run 'tools' 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 } } 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)" + $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)" + " --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)" + " --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)" + " --sql-server `"$($s.Server)`" --sql-user $($s.User) --db $($s.IxserverDb)" +
" --company-code $($Cfg.CompanyCode) --service-code $($Cfg.ServiceCode) --quota-name $($Cfg.QuotaName)" " --company-code $($Cfg.CompanyCode) --service-code $($Cfg.ServiceCode) --quota-name $($Cfg.QuotaName)"
return @{ return @{
@ -550,4 +553,13 @@ switch ($Command) {
Get-Content (Join-Path $Dirs.Logs "$role.log") -Tail $Tail -ErrorAction SilentlyContinue | Out-Host 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/"
}
} }

54
scenarios/_lib.ps1 100644
View File

@ -0,0 +1,54 @@
<#
场景脚本公用:计时、记下每一步断言,结束时(通过或失败)请闭环观察台生成场景报告。
报告是观察台截取这段时间窗里的每一跳、状态变化、iXserver 日志,连同断言与部署版本存成的单文件 HTML,
放在 run\reports\(不进 git),列表在 http://127.0.0.1:<Ports.Console>/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
}
}

View File

@ -1,12 +1,15 @@
<# <#
S1:IG 用户发一条文本 → iXserver → 第三方 chatbot(echo 自动回复)→ iXserver → IG 用户收到 echo。 S1:IG 用户发一条文本 → iXserver → 第三方 chatbot(echo 自动回复)→ iXserver → IG 用户收到 echo。
前提:.\loop.ps1 up 四个角色都健康,chatbot 以 --auto-reply echo 运行(config\loop.psd1 的 ChatbotAutoReply)。 前提:.\loop.ps1 up 各角色都健康,chatbot 以 --auto-reply echo 运行(config\loop.psd1 的 ChatbotAutoReply)。
通过退出 0,失败退出 1 并打印两侧的记录。用法:.\scenarios\s1-ig-text-echo.ps1 [-TimeoutSec 60] 通过退出 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' $ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' $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)" $ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)"
$cb = "http://127.0.0.1:$($Cfg.Ports.Chatbot)" $cb = "http://127.0.0.1:$($Cfg.Ports.Chatbot)"
@ -17,13 +20,17 @@ function Fail([string]$why) {
Write-Host '--- chatbot 记录(最近 6 条)' Write-Host '--- chatbot 记录(最近 6 条)'
# 记录是新的在前 # 记录是新的在前
(Invoke-RestMethod "$cb/_api/state").events | Select-Object -First 6 | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Out-Host (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 exit 1
} }
Start-LoopRun 'S1' 'IG 文本 → 第三方 echo → IG'
Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null
$text = 'loop-s1 ' + [guid]::NewGuid().ToString('N').Substring(0, 8) $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) $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)" } if ($sent.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($sent.event.response.status)" }
Add-LoopStep 'iXserver 收下 webhook' $true "「$text」HTTP 200"
# 出站是异步的:轮询 IG 模拟的 outbound,带上限 # 出站是异步的:轮询 IG 模拟的 outbound,带上限
$expected = "echo: $text" $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 $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) { 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 (@($hit.issues).Count) { Fail "出站请求有问题:$($hit.issues -join ';')" }
if (-not $hit.tokenPresent) { Fail '出站请求没带 access_token' } 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,会晚上百毫秒 # 也要等:iXserver 在 send-txt 应答之前就把消息发到 IG 了,客户端拿到应答才记 reply,会晚上百毫秒
$reply = $null $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) { Start-Sleep -Milliseconds 200 }
} }
if (-not $reply) { Fail 'chatbot 侧没有对应的回复记录' } if (-not $reply) { Fail 'chatbot 侧没有对应的回复记录' }
Set-LoopTicket $reply.ticketId
if ($reply.response.status -ne 201) { Fail "send-txt 返回 $($reply.response.status)" } 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 Write-Host "S1 通过:「$text」→ ticket $($reply.ticketId) → IG 收到「$expected」" -ForegroundColor Green
Save-LoopReport -Passed $true
exit 0 exit 0

View File

@ -3,13 +3,16 @@
SCRM 开工(SetSetting IsEnable=false + UpdateCSAvailable 3)→ 客服上线(UserReg + 额度池) SCRM 开工(SetSetting IsEnable=false + UpdateCSAvailable 3)→ 客服上线(UserReg + 额度池)
→ 新 IG 用户发消息 → 人工工单,SCRM 在 WebSocket 上收到推送 → 指派 → 打开(验证信息 + 已读) → 新 IG 用户发消息 → 人工工单,SCRM 在 WebSocket 上收到推送 → 指派 → 打开(验证信息 + 已读)
→ 以客服身份回复 → IG 用户收到 → 结束工单 → SCRM 下班(切回机器人、在线 0),不影响 S1。 → 以客服身份回复 → IG 用户收到 → 结束工单 → SCRM 下班(切回机器人、在线 0),不影响 S1。
前提:.\loop.ps1 up 五个角色都健康。每次用新的 IGSID,保证建出新工单。 前提:.\loop.ps1 up 各角色都健康。每次用新的 IGSID,保证建出新工单。
通过退出 0,失败退出 1 并打印记录。用法:.\scenarios\s8-human-chain.ps1 [-TimeoutSec 60] [-KeepShift] 通过退出 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' $ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' $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)" $ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)"
$sc = "http://127.0.0.1:$($Cfg.Ports.ScrmMock)" $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 (Invoke-RestMethod "$sc/_api/state").events | Select-Object -First 15 | ForEach-Object { ($_ | ConvertTo-Json -Depth 8 -Compress) } | Out-Host
Write-Host '--- IG 模拟记录' Write-Host '--- IG 模拟记录'
(Invoke-RestMethod "$ig/_api/state").events | ForEach-Object { "{0} {1} {2}" -f $_.kind, $_.igsid, $_.summary } | Out-Host (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 } if (-not $KeepShift) { Post "$sc/_api/shift" @{ on = $false } | Out-Null }
Save-LoopReport -Passed $false -Reason $why
exit 1 exit 1
} }
function Step([string]$action, $body) { function Step([string]$name, [string]$action, $body) {
$r = Post "$sc/_api/$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 return $r
} }
@ -51,37 +57,44 @@ function Wait-For([string]$what, [scriptblock]$probe) {
Fail "$TimeoutSec 秒内没等到:$what" Fail "$TimeoutSec 秒内没等到:$what"
} }
Start-LoopRun 'S8' 'SCRM 人工链路'
$ws = (Invoke-RestMethod "$sc/_api/state").state.ws $ws = (Invoke-RestMethod "$sc/_api/state").state.ws
if ($ws.status -ne 'logged-in') { Fail "SCRM 模拟的 WebSocket 没登录($($ws.status) $($ws.lastError))" } if ($ws.status -ne 'logged-in') { Fail "SCRM 模拟的 WebSocket 没登录($($ws.status) $($ws.lastError))" }
Invoke-RestMethod "$sc/_api/clear" -Method Post | Out-Null Invoke-RestMethod "$sc/_api/clear" -Method Post | Out-Null
Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null
Step 'shift' @{ on = $true } | Out-Null Step 'SCRM 开工(切人工、在线 3)' 'shift' @{ on = $true } | Out-Null
Step 'agent-online' @{} | Out-Null Step '客服上线' 'agent-online' @{} | Out-Null
$igsid = 'loop-s8-' + [guid]::NewGuid().ToString('N').Substring(0, 8) $igsid = 'loop-s8-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$sent = Post "$ig/_api/send" @{ kind = 'text'; igsid = $igsid; text = 'S8 想找人工' } $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)" } if ($sent.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($sent.event.response.status)" }
Add-LoopStep '新 IG 用户发消息' $true "$igsid HTTP 200"
# 推送报文没有统一类型字段,按 enduser_openid = IGSID 认出这张新工单 # 推送报文没有统一类型字段,按 enduser_openid = IGSID 认出这张新工单
$ticketId = Wait-For 'SCRM 收到这位 IG 用户的工单推送' { $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 $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 } 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 '指派给客服' 'assign' @{ ticket_id = $ticketId } | Out-Null
Step 'open' @{ ticket_id = $ticketId } | Out-Null Step '打开工单(验证信息 + 已读)' 'open' @{ ticket_id = $ticketId } | Out-Null
$replyText = 'S8 客服回复 ' + [guid]::NewGuid().ToString('N').Substring(0, 6) $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」" { $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 @((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.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 Step '结束工单' 'complete' @{ ticket_id = $ticketId } | Out-Null
if (-not $KeepShift) { Step 'shift' @{ on = $false } | Out-Null } if (-not $KeepShift) { Step 'SCRM 下班(切回机器人、在线 0)' 'shift' @{ on = $false } | Out-Null }
Write-Host "S8 通过:$igsid → 人工工单 $ticketId → SCRM 收到推送、指派、回复 → IG 收到「$replyText」→ 已结束" -ForegroundColor Green Write-Host "S8 通过:$igsid → 人工工单 $ticketId → SCRM 收到推送、指派、回复 → IG 收到「$replyText」→ 已结束" -ForegroundColor Green
Save-LoopReport -Passed $true
exit 0 exit 0