feat: 闭环观察台角色、补齐 Web.config 路径覆盖、修 S1 竞态

- loop.ps1 新增 console 角色(:5090),传入 ABP / iXserver 日志目录与库连接
- overrides.psd1 补 WebchatPath、OffLineFormPath、AppConfigManager_AppConfigJsonFile(新增 config/ixserver/config.json)
- loop.psd1 新增 Ports.Console、QuotaName
- S1 等第三方回复记录改为轮询,失败时打印最新的记录
- README:观察台、iXserver 主日志位置

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
fengruixiang 2026-10-21 15:34:07 +08:00
parent ad136a9d53
commit ff0e390238
6 changed files with 43 additions and 7 deletions

View File

@ -10,6 +10,7 @@
| 第三方 chatbot 客户端 | 5056 | `iXserver\Tools\ThirdPartyChatbotTestClient` |
| IG 后端模拟 | 5070 | `iXserver\Tools\IgMockServer` |
| SCRM 客服系统模拟 | 5080 | `iXserver\Tools\ScrmMockServer` |
| 闭环观察台(只读,泳道时序图) | 5090 | `iXserver\Tools\LoopConsole` |
所有端口只绑本机。iXserver 的旧接口和 WebSocket **没有鉴权**,绝不能对外开放 8081 / 33346。
@ -51,12 +52,14 @@
.\scenarios\s8-human-chain.ps1 # SCRM 开工 → 新 IG 用户 → 人工工单推送 → 指派 / 回复 → IG 收到 → 结束 → 下班
```
跑的时候开着观察台 <http://127.0.0.1:5090>,每一跳和状态变化会实时画成泳道时序图;`?ticket=<工单号>` 只看一张。
S8 结束时会把服务号切回机器人、在线人数归零,所以 S1 / S8 先后跑互不影响;要停在人工状态看页面加 `-KeepShift`。
从 Mac 看几个页面用 SSH 本地端口转发:
```bash
ssh -N -L 5056:127.0.0.1:5056 -L 5070:127.0.0.1:5070 -L 5080:127.0.0.1:5080 FG-W11-ICON-DEV
ssh -N -L 5056:127.0.0.1:5056 -L 5070:127.0.0.1:5070 -L 5080:127.0.0.1:5080 -L 5090:127.0.0.1:5090 FG-W11-ICON-DEV
```
## 几个容易踩的点
@ -87,6 +90,8 @@ ssh -N -L 5056:127.0.0.1:5056 -L 5070:127.0.0.1:5070 -L 5080:127.0.0.1:5080 FG-W
`appsettings.Loop.json` 把所有连接串都盖成本机 iCONAbpFX。
- PowerShell 5.1 的两个坑已经在 `loop.ps1` 里绕开:原生命令往 stderr 写一行就抛异常(统一走 `Invoke-Native`);
`Get-Content` 默认按 GBK 读无 BOM 的 UTF-8(拷配置用 `Copy-Item`,脚本文件一律带 BOM)。
- **iXserver 的主日志在 `run\ixserver\Log\{Debug,Information,Warning,Error}\<日期>\`**(注入的 ILogger,内部步骤很全,观察台就读它)。
`run\data\API_Log` 是静态 `Logger` 写的另一份,几乎是空的,别看它下结论。排查先看 `Log\Error`。
- `deployed.json` 记录每次部署时两个源码仓库的 commit 与未提交文件。闭环结果离开「当时部署的是哪个版本」没法解读,
跑场景的记录里引用它。注意 winbox 上 iXserver 仓库的 HEAD 不随 `sync.sh` 前进(只推文件),
`commit` 字段可能停在很久以前,以 `uncommitted` 清单和 Mac 上的提交为准。

View File

@ -0,0 +1 @@
{}

View File

@ -33,6 +33,11 @@
'FileFolder' = '{RunData}\API_FileFolder\'
'DbTimeLogLocation' = '{RunData}\API_Log_DB\'
'AutoChatJsonSetting_JsonStoreDir' = '{RunData}\TicketJsonDir'
# sys_language 启动时把生成的 JS 写进 WebchatPath;写死的 D:\ 不存在会记一条「设备未就绪」
'WebchatPath' = '{RunData}\Webchat'
'OffLineFormPath' = '{RunData}\pages\'
# 仓库里没有 config.json,读不到就记 Error 并退回默认值;指到 LoopEnv 里那份(空对象 = 全用默认值)
'AppConfigManager_AppConfigJsonFile' = '{LoopRoot}\config\ixserver\config.json'
# ---- 自身地址:上传文件的访问地址要能从本机打开
'ApiUrl' = 'http://localhost:{IxserverPort}/'

View File

@ -21,6 +21,7 @@
Chatbot = 5056
IgMock = 5070
ScrmMock = 5080
Console = 5090 # 闭环观察台:只读汇总各角色,画泳道时序图
}
# 闭环专用的假凭据:与 staging / 生产无关,可以进 git
@ -39,6 +40,7 @@
IgPageId = '568932443471849'
CompanyCode = 'SM_HKTB_IG'
AdminCode = 'HKTB'
QuotaName = 'HKTB' # 额度池名,与管理员 code 同名只是碰巧
# 第三方客户端收到投递后自动回复:echo / 任意固定文本;留空则只能在页面上手动回
ChatbotAutoReply = 'echo'

View File

@ -54,7 +54,7 @@ $Dirs = @{
}
foreach ($d in $Dirs.Values) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d | Out-Null } }
$AllRoles = @('abp', 'ixserver', 'chatbot', 'igmock', 'scrm')
$AllRoles = @('abp', 'ixserver', 'chatbot', 'igmock', 'scrm', 'console')
$Roles = if ($Only) { $AllRoles | Where-Object { $Only -contains $_ } } else { $AllRoles }
# 健康检查要连本机 ABP 的 dev 证书;只影响本 PowerShell 进程
@ -70,6 +70,7 @@ function Write-Warn2([string]$text) { Write-Host " $text" -ForegroundColor Ye
function Expand-Template([string]$text) {
$map = @{
'{RunData}' = $Dirs.Data
'{LoopRoot}' = $Root
'{IxserverPort}' = $Cfg.Ports.Ixserver
'{WebSocketPort}' = $Cfg.Ports.WebSocket
'{AbpPort}' = $Cfg.Ports.Abp
@ -230,11 +231,27 @@ function Get-RoleSpec([string]$role) {
}
'scrm' {
$script = "$tools\ScrmMockServer\server.js"
if (-not (Test-Path $script)) { return $null } # P7 之前还没有
if (-not (Test-Path $script)) { return $null } # iXserver 仓库还没同步到带这个工具的版本
$cmd = "`"$(Get-NodeExe)`" `"$script`" --port $($p.ScrmMock) --ixserver http://localhost:$($p.Ixserver)" +
" --ws ws://localhost:$($p.WebSocket) --admin-code $($Cfg.AdminCode) --company-code $($Cfg.CompanyCode) --service-code $($Cfg.ServiceCode)"
" --ws ws://localhost:$($p.WebSocket) --admin-code $($Cfg.AdminCode) --company-code $($Cfg.CompanyCode) --service-code $($Cfg.ServiceCode) --quota-name $($Cfg.QuotaName)"
return @{ Port = $p.ScrmMock; WorkDir = $Root; Command = $cmd; Health = { Test-HttpAlive "http://127.0.0.1:$($Cfg.Ports.ScrmMock)/_api/state" }; Timeout = 30 }
}
'console' {
$script = "$tools\LoopConsole\server.js"
if (-not (Test-Path $script)) { return $null }
$s = $Cfg.Sql
$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')`"" +
" --sql-server `"$($s.Server)`" --sql-user $($s.User) --db $($s.IxserverDb)" +
" --company-code $($Cfg.CompanyCode) --service-code $($Cfg.ServiceCode) --quota-name $($Cfg.QuotaName)"
return @{
Port = $p.Console; WorkDir = $Root; Command = $cmd
Env = @{ LOOP_SQL_PASSWORD = $s.Password }
Health = { Test-HttpAlive "http://127.0.0.1:$($Cfg.Ports.Console)/_api/state" }; Timeout = 30
}
}
}
}
@ -276,7 +293,7 @@ function Set-WebConfigOverrides {
$xml.Save($path)
foreach ($sub in 'API_Log', 'API_TempFolder', 'API_FileFolder', 'API_Log_DB', 'TicketJsonDir', 'aspnet-temp') {
foreach ($sub in 'API_Log', 'API_TempFolder', 'API_FileFolder', 'API_Log_DB', 'TicketJsonDir', 'aspnet-temp', 'Webchat\Scripts', 'pages') {
$d = Join-Path $Dirs.Data $sub
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d | Out-Null }
}

View File

@ -15,7 +15,8 @@ function Fail([string]$why) {
Write-Host '--- IG 模拟记录'
(Invoke-RestMethod "$ig/_api/state").events | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Out-Host
Write-Host '--- chatbot 记录(最近 6 条)'
(Invoke-RestMethod "$cb/_api/state").events | Select-Object -Last 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
exit 1
}
@ -36,7 +37,12 @@ if (-not $hit) { Fail "$TimeoutSec 秒内 IG 用户没有收到「$expected」"
if (@($hit.issues).Count) { Fail "出站请求有问题:$($hit.issues -join ';')" }
if (-not $hit.tokenPresent) { Fail '出站请求没带 access_token' }
# 也要等:iXserver 在 send-txt 应答之前就把消息发到 IG 了,客户端拿到应答才记 reply,会晚上百毫秒
$reply = $null
while (-not $reply -and (Get-Date) -lt $deadline.AddSeconds(5)) {
$reply = @((Invoke-RestMethod "$cb/_api/state").events | Where-Object { $_.kind -eq 'reply' -and $_.message -eq $expected }) | Select-Object -First 1
if (-not $reply) { Start-Sleep -Milliseconds 200 }
}
if (-not $reply) { Fail 'chatbot 侧没有对应的回复记录' }
if ($reply.response.status -ne 201) { Fail "send-txt 返回 $($reply.response.status)" }