# 只读:拉服务器新系统 8910 的业务/错误日志,并落到本机 logs/(UTF-8 BOM,记事本可直接打开)。 # 用法:双击 Watch-Agent8910.bat / Watch-Agent8910-Once.bat # 本机:logs/yyyyMMdd_info.log 、 logs/yyyyMMdd_error.log # 服务器旧日志可能是 GBK、新日志是 UTF-8;远程按「逐行 UTF-8,失败则 GBK」解码,避免乱码。 [CmdletBinding()] param( [switch]$Once, [switch]$All, [switch]$Access, [switch]$Gbk, [switch]$NoLocalLog, [int]$Tail = 120 ) $ErrorActionPreference = 'Stop' $SshKey = if ($env:YTD_SSH_KEY) { $env:YTD_SSH_KEY } else { Join-Path $env:USERPROFILE '.ssh\id_ed25519_ytd' } $SshHost = if ($env:YTD_SSH_HOST) { $env:YTD_SSH_HOST } else { 'ytd_extdev_0903@129.211.181.80' } $LogOut = 'E:\wwwroot\logs\agent-http.out.log' $LogErr = 'E:\wwwroot\logs\agent-http.err.log' # once / follow 用不同远程脚本名,避免互相覆盖导致「持续监听」其实跑成 Once $WatchTag = if ($Once) { 'once' } else { 'follow' } $IncomingRemotePath = "C:/Users/ytd_extdev_0903/incoming/_watch_agent8910_$WatchTag.ps1" $IncomingLocalRun = "C:\Users\ytd_extdev_0903\incoming\_watch_agent8910_$WatchTag.ps1" $Pattern = '回调|inject-text|inject:|outbox|inbox|errcode|errmsg|60020|ERROR|WARNING|Traceback|Exception|ModuleNotFoundError|sender=|touser=|crypto|ChannelRuntime|dispatch intent|企微发送|验签|解密|reply=' if (-not (Test-Path $SshKey)) { throw "缺少 SSH 密钥: $SshKey" } try { chcp 65001 | Out-Null [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false) [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) $OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { Write-Host 'warn: console UTF-8 setup failed' } $RepoRoot = Split-Path -Parent $PSScriptRoot # 在仓库 deploy/ 下:写到仓库根 logs/;单独解压给同事:写到本工具目录 logs/ if ((Split-Path -Leaf $PSScriptRoot) -eq 'deploy') { $LocalLogDir = Join-Path $RepoRoot 'logs' } else { $LocalLogDir = Join-Path $PSScriptRoot 'logs' } $Day = Get-Date -Format 'yyyyMMdd' $LocalInfoLog = Join-Path $LocalLogDir ($Day + '_info.log') $LocalErrorLog = Join-Path $LocalLogDir ($Day + '_error.log') $Utf8NoBom = New-Object System.Text.UTF8Encoding $false $Utf8Bom = New-Object System.Text.UTF8Encoding $true function Ensure-Utf8BomFile([string]$path) { if (-not (Test-Path $path) -or ((Get-Item $path).Length -eq 0)) { [System.IO.File]::WriteAllText($path, '', $Utf8Bom) return } $fs = [System.IO.File]::OpenRead($path) try { $b0 = $fs.ReadByte(); $b1 = $fs.ReadByte(); $b2 = $fs.ReadByte() } finally { $fs.Close() } if ($b0 -eq 0xEF -and $b1 -eq 0xBB -and $b2 -eq 0xBF) { return } $raw = [System.IO.File]::ReadAllBytes($path) $bom = [byte[]](0xEF, 0xBB, 0xBF) $merged = New-Object byte[] ($bom.Length + $raw.Length) [Array]::Copy($bom, 0, $merged, 0, 3) [Array]::Copy($raw, 0, $merged, 3, $raw.Length) [System.IO.File]::WriteAllBytes($path, $merged) } if (-not $NoLocalLog) { New-Item -ItemType Directory -Force -Path $LocalLogDir | Out-Null Ensure-Utf8BomFile $LocalInfoLog Ensure-Utf8BomFile $LocalErrorLog $sessionHdr = '===== watch start ' + (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + ' host=' + $SshHost + ' once=' + $Once.IsPresent + ' =====' [System.IO.File]::AppendAllText($LocalInfoLog, $sessionHdr + [Environment]::NewLine, $Utf8NoBom) [System.IO.File]::AppendAllText($LocalErrorLog, $sessionHdr + [Environment]::NewLine, $Utf8NoBom) } function Write-LocalLine([string]$line) { Write-Host $line if ($NoLocalLog) { return } if ($line -match '^(目标:|主日志|访问日志|日志编码|过滤:|本机落盘:|说明:|---+|=====)') { $isErr = $false } else { $isErr = ($line -cmatch '\] WARNING |\] ERROR |Traceback|Exception|ModuleNotFoundError') -or ($line -match 'errcode=\d+|errmsg=.+|health_fail|MISSING |解密失败|验签/解密失败|relation "agent_inbox"|not allow to access from your ip') } $target = if ($isErr) { $LocalErrorLog } else { $LocalInfoLog } try { [System.IO.File]::AppendAllText($target, $line + [Environment]::NewLine, $Utf8NoBom) } catch { Write-Host ('warn: write local log failed: ' + $_.Exception.Message) } } Write-LocalLine ("目标: $SshHost") Write-LocalLine ("主日志(业务/错误): $LogErr") if ($Access -or $All) { Write-LocalLine ("访问日志: $LogOut") } Write-LocalLine ("日志编码: smart UTF-8/GBK" + $(if ($Gbk) { ' (force GBK)' } else { '' })) if ($All) { Write-LocalLine '过滤: 关闭' } else { Write-LocalLine ("过滤: $Pattern") } if (-not $NoLocalLog) { Write-LocalLine ("本机落盘: $LocalInfoLog") Write-LocalLine ("本机落盘: $LocalErrorLog") } Write-LocalLine '说明: 本地日志为 UTF-8 BOM;请重新跑 Once 覆盖旧乱码内容(可先删当天 log 再跑)' Write-LocalLine '---' # follow=1 / once=0,避免 $true/$false 在远程被覆盖或解析歧义 $waitFlag = if ($Once) { '0' } else { '1' } $allLiteral = if ($All) { '$true' } else { '$false' } $accessLiteral = if ($Access) { '$true' } else { '$false' } $forceGbkLiteral = if ($Gbk) { '$true' } else { '$false' } # 远程:逐行按 UTF-8 严格解码,失败再用 GBK(936);输出强制 Flush,避免持续监听被缓冲卡住 $remoteScript = @" `$ErrorActionPreference = 'Continue' try { chcp 65001 | Out-Null [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(`$false) `$OutputEncoding = [System.Text.UTF8Encoding]::new(`$false) } catch {} `$out = '$LogOut' `$err = '$LogErr' `$tail = $Tail `$waitFlag = '$waitFlag' `$wait = (`$waitFlag -eq '1') `$all = $allLiteral `$wantAccess = $accessLiteral `$pat = '$Pattern' `$forceGbk = $forceGbkLiteral `$utf8Strict = New-Object System.Text.UTF8Encoding `$false, `$true `$gbk = [System.Text.Encoding]::GetEncoding(936) function Emit([string]`$s) { [Console]::Out.WriteLine(`$s) [Console]::Out.Flush() } function Decode-LogBytes([byte[]]`$chunk) { if (`$null -eq `$chunk -or `$chunk.Length -eq 0) { return '' } if (`$forceGbk) { return `$gbk.GetString(`$chunk) } try { return `$utf8Strict.GetString(`$chunk) } catch { return `$gbk.GetString(`$chunk) } } function Get-SmartTailLines([string]`$path, [int]`$n) { if (-not (Test-Path `$path)) { return @() } `$fs = [System.IO.File]::Open(`$path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) try { `$ms = New-Object System.IO.MemoryStream `$fs.CopyTo(`$ms) `$bytes = `$ms.ToArray() } finally { `$fs.Close() } `$lines = New-Object System.Collections.Generic.List[string] `$start = 0 for (`$i = 0; `$i -lt `$bytes.Length; `$i++) { if (`$bytes[`$i] -ne 10) { continue } `$end = `$i if (`$end -gt `$start -and `$bytes[`$end - 1] -eq 13) { `$end = `$end - 1 } `$len = `$end - `$start if (`$len -lt 0) { `$len = 0 } `$chunk = New-Object byte[] `$len if (`$len -gt 0) { [Array]::Copy(`$bytes, `$start, `$chunk, 0, `$len) } `$lines.Add((Decode-LogBytes `$chunk)) `$start = `$i + 1 } if (`$start -lt `$bytes.Length) { `$len = `$bytes.Length - `$start `$chunk = New-Object byte[] `$len [Array]::Copy(`$bytes, `$start, `$chunk, 0, `$len) `$lines.Add((Decode-LogBytes `$chunk)) } if (`$lines.Count -le `$n) { return `$lines.ToArray() } return `$lines.GetRange(`$lines.Count - `$n, `$n).ToArray() } Emit ('mode=' + `$waitFlag + ' follow=' + `$wait) Emit '=== remote health 8910 ===' try { `$h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 5 Emit `$h.Content } catch { Emit ('health_fail: ' + `$_.Exception.Message) } Emit '=== business/error log (err) ===' function Show-Err([string]`$line) { if (`$all) { Emit ('[err] ' + `$line); return } if (`$line -match `$pat) { Emit ('[err] ' + `$line) } } function Show-Out([string]`$line) { if (-not `$wantAccess -and -not `$all) { return } if (`$all) { Emit ('[out] ' + `$line); return } if (`$line -match '/wecom/callback|inject-text') { Emit ('[out] ' + `$line) } } if (-not (Test-Path `$err)) { Emit ('MISSING ' + `$err) } if (-not `$wait) { if (Test-Path `$err) { Get-SmartTailLines `$err `$tail | ForEach-Object { Show-Err `$_ } } if (`$wantAccess -or `$all) { Emit '=== access log (out) ===' if (Test-Path `$out) { Get-SmartTailLines `$out `$tail | ForEach-Object { Show-Out `$_ } } } exit 0 } Emit 'following err.log ... (Ctrl+C to stop)' `$lastLen = @{} `$carry = @{} foreach (`$f in @(`$err, `$out)) { if (Test-Path `$f) { `$lastLen[`$f] = (Get-Item `$f).Length } else { `$lastLen[`$f] = 0 } `$carry[`$f] = '' } if (Test-Path `$err) { Get-SmartTailLines `$err `$tail | ForEach-Object { Show-Err `$_ } } # 尾部打完后,从当前文件末尾继续跟,避免重复刷旧行 foreach (`$f in @(`$err, `$out)) { if (Test-Path `$f) { `$lastLen[`$f] = (Get-Item `$f).Length } } `$heartbeatAt = [DateTime]::UtcNow while (`$true) { Start-Sleep -Milliseconds 500 if (([DateTime]::UtcNow - `$heartbeatAt).TotalSeconds -ge 30) { Emit ('... still watching ' + (Get-Date).ToString('HH:mm:ss')) `$heartbeatAt = [DateTime]::UtcNow } `$targets = @() if (Test-Path `$err) { `$targets += `$err } if ((`$wantAccess -or `$all) -and (Test-Path `$out)) { `$targets += `$out } foreach (`$f in `$targets) { if (-not `$lastLen.ContainsKey(`$f)) { `$lastLen[`$f] = 0; `$carry[`$f] = '' } `$len = (Get-Item `$f).Length if (`$len -lt `$lastLen[`$f]) { `$lastLen[`$f] = 0; `$carry[`$f] = '' } if (`$len -le `$lastLen[`$f]) { continue } `$fs = [System.IO.File]::Open(`$f, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) try { [void]`$fs.Seek(`$lastLen[`$f], [System.IO.SeekOrigin]::Begin) `$buf = New-Object byte[] (`$len - `$lastLen[`$f]) `$read = `$fs.Read(`$buf, 0, `$buf.Length) } finally { `$fs.Close() } `$lastLen[`$f] = `$len if (`$read -le 0) { continue } `$chunk = New-Object byte[] `$read [Array]::Copy(`$buf, 0, `$chunk, 0, `$read) `$text = `$carry[`$f] + (Decode-LogBytes `$chunk) `$parts = `$text -split "`n" if (-not (`$text.EndsWith("`n") -or `$text.EndsWith("`r"))) { if (`$parts.Count -ge 1) { `$carry[`$f] = `$parts[`$parts.Count - 1].TrimEnd("`r") `$limit = `$parts.Count - 1 } else { `$carry[`$f] = `$text `$limit = 0 } } else { `$carry[`$f] = '' `$limit = `$parts.Count } for (`$pi = 0; `$pi -lt `$limit; `$pi++) { `$line = `$parts[`$pi].TrimEnd("`r") if (-not `$line) { continue } if (`$f -eq `$out) { Show-Out `$line } else { Show-Err `$line } } } } "@ $localTemp = Join-Path $env:TEMP 'ytd-watch-agent8910-remote.ps1' [System.IO.File]::WriteAllText($localTemp, $remoteScript, $Utf8NoBom) & scp.exe -o BatchMode=yes -o ConnectTimeout=12 -i $SshKey $localTemp "${SshHost}:${IncomingRemotePath}" if ($LASTEXITCODE -ne 0) { throw "scp failed: $LASTEXITCODE" } $sshArgs = @( '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', '-i', $SshKey, $SshHost, "powershell -NoProfile -ExecutionPolicy Bypass -File `"$IncomingLocalRun`"" ) $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = 'ssh.exe' $psi.Arguments = ($sshArgs | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join ' ' $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 $proc = New-Object System.Diagnostics.Process $proc.StartInfo = $psi [void]$proc.Start() while (-not $proc.StandardOutput.EndOfStream) { $line = $proc.StandardOutput.ReadLine() if ($null -ne $line) { Write-LocalLine $line } } $errTail = $proc.StandardError.ReadToEnd() if ($errTail) { foreach ($eline in ($errTail -split "`r?`n")) { if ($eline) { Write-LocalLine $eline } } } $proc.WaitForExit() exit $proc.ExitCode