<# .SYNOPSIS 模拟闭环测试环境的编排:部署、起停、重置、状态。方案见 iXserver 仓库 docs\构建模拟闭环测试环境.html。 .DESCRIPTION 五个角色(依赖顺序):abp → ixserver → chatbot → igmock → scrm deploy 从源码仓库编译,产物拷进 run\,写 deployed.json up 按依赖顺序起,逐个等健康检查 down 倒序停 restart down + up reset 重建 iXserverLoop(EDMX 建表 → FeatureTest 种子 → loop_patch)并备份;-FromBackup 直接还原 reset-abp iCONAbpFX 还原到留底备份;-Init 首次打补丁并留底 status 各角色进程、端口、健康 logs 看某个角色的日志尾巴 reports 列出最近的场景报告(run\reports\,观察台 /reports/ 里也能看) .EXAMPLE .\loop.ps1 deploy .\loop.ps1 deploy -Only ixserver -ConfigOnly # 只把 overrides.psd1 重新盖到 Web.config .\loop.ps1 reset-abp -Init # 拷完 iCONAbpFX 后执行一次 .\loop.ps1 reset .\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', 'reports')] [string]$Command = 'status', # 只操作这些角色:abp, ixserver, chatbot, igmock, scrm [string[]]$Only, [switch]$SkipBuild, # deploy:不编译,直接拷现有产物 [switch]$ConfigOnly, # deploy:只重写配置(ixserver 的 Web.config / abp 的 appsettings.Loop.json) [switch]$FromBackup, # reset:从上次留底的备份还原,不重建 [switch]$Init, # reset-abp:首次打补丁并留底 [int]$Tail = 60 # logs:显示多少行 ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2 $Root = $PSScriptRoot $Cfg = Import-PowerShellDataFile (Join-Path $Root 'config\loop.psd1') $Run = Join-Path $Root 'run' $Dirs = @{ Ixserver = Join-Path $Run 'ixserver' Abp = Join-Path $Run 'abp' AbpKey = Join-Path $Run 'abp-key' Data = Join-Path $Run 'data' 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 } } $AllRoles = @('abp', 'ixserver', 'chatbot', 'igmock', 'scrm', 'console') $Roles = if ($Only) { $AllRoles | Where-Object { $Only -contains $_ } } else { $AllRoles } # 健康检查要连本机 ABP 的 dev 证书;只影响本 PowerShell 进程 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } # ============================================================================ 小工具 function Write-Step([string]$text) { Write-Host "==> $text" -ForegroundColor Cyan } function Write-Ok([string]$text) { Write-Host " $text" -ForegroundColor Green } function Write-Warn2([string]$text) { Write-Host " $text" -ForegroundColor Yellow } function Expand-Template([string]$text) { $map = @{ '{RunData}' = $Dirs.Data '{LoopRoot}' = $Root '{IxserverPort}' = $Cfg.Ports.Ixserver '{WebSocketPort}' = $Cfg.Ports.WebSocket '{AbpPort}' = $Cfg.Ports.Abp '{IgMockPort}' = $Cfg.Ports.IgMock '{AbpAdminUser}' = $Cfg.Secrets.AbpAdminUser '{AbpAdminPassword}' = $Cfg.Secrets.AbpAdminPassword '{FxAppClientSecret}' = $Cfg.Secrets.FxAppClientSecret } foreach ($k in $map.Keys) { $text = $text.Replace($k, [string]$map[$k]) } return $text } <# PS 5.1 在 ErrorActionPreference=Stop 下,原生命令往 stderr 写一行(2>&1、2>$null 都一样)就抛异常, 后面的 $LASTEXITCODE 判断和输出收集都轮不到。原生命令一律经这里调:临时放宽,stderr 并进输出转成字符串, 退出码照常留在 $LASTEXITCODE 里由调用方判断。 #> function Invoke-Native([scriptblock]$block) { $ErrorActionPreference = 'Continue' & $block 2>&1 | ForEach-Object { "$_" } } function Find-MSBuild { $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' if (Test-Path $vswhere) { $found = & $vswhere -latest -requires Microsoft.Component.MSBuild -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1 if ($found) { return $found } } throw '找不到 MSBuild(需要 VS 2022 / Build Tools)' } function Invoke-Sql { param([string]$Database = 'master', [string]$Query, [string]$File, [switch]$Utf16) $sqlArgs = @('-S', $Cfg.Sql.Server, '-U', $Cfg.Sql.User, '-P', $Cfg.Sql.Password, '-d', $Database, '-b', '-I', '-W', '-h', '-1') if ($File) { # UTF-16 带 BOM 的文件(FeatureTest 种子)sqlcmd 自己认;其余按 UTF-8 读 if (-not $Utf16) { $sqlArgs += @('-f', '65001') } $sqlArgs += @('-i', $File) } else { $sqlArgs += @('-Q', $Query) } $out = Invoke-Native { & sqlcmd @sqlArgs } if ($LASTEXITCODE -ne 0) { throw "sqlcmd 失败($Database):`n$($out -join "`n")" } return $out } function Get-BackupPath([string]$name) { # 写到实例的默认备份目录:SQL 服务账号对 C:\Projects 下不一定有写权限,不为此去改 ACL $dir = (Invoke-Sql -Query "SET NOCOUNT ON; SELECT CONVERT(NVARCHAR(400), SERVERPROPERTY('InstanceDefaultBackupPath'))" | Select-Object -First 1).Trim() return (Join-Path $dir "$name.loop.bak") } function Test-Port([int]$port) { return [bool](Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) } function Wait-Until([string]$what, [scriptblock]$check, [int]$timeoutSec = 120) { $deadline = (Get-Date).AddSeconds($timeoutSec) while ((Get-Date) -lt $deadline) { try { if (& $check) { return } } catch { } Start-Sleep -Milliseconds 800 } throw "$what 在 $timeoutSec 秒内没有就绪" } # 任何 HTTP 应答(含 4xx/5xx)都算「起来了」;连不上才算没起 function Test-HttpAlive([string]$url) { try { $req = [Net.HttpWebRequest]::Create($url) $req.Timeout = 5000 $resp = $req.GetResponse(); $resp.Close(); return $true } catch [Net.WebException] { if ($_.Exception.Response) { $_.Exception.Response.Close(); return $true } return $false } } function Get-RolePid([string]$role) { $file = Join-Path $Dirs.Pids "$role.pid" if (-not (Test-Path $file)) { return $null } $id = [int](Get-Content $file -Raw) if (Get-Process -Id $id -ErrorAction SilentlyContinue) { return $id } Remove-Item $file -Force return $null } <# 经 SSH 起的进程会随会话的 job object 一起被杀;用 WMI 的 Win32_Process.Create 起, 进程挂在 WmiPrvSE 下面,SSH 断开也不受影响。输出经 cmd 重定向进 run\logs\.log。 #> function Start-Role([string]$role, [string]$workDir, [string]$commandLine, [hashtable]$envVars) { $log = Join-Path $Dirs.Logs "$role.log" if (Test-Path $log) { Move-Item $log "$log.prev" -Force } # WMI 起的进程只有系统环境变量,TEMP 是 C:\Windows\TEMP(当前用户不一定可写),统一指到 run\data\temp $temp = Join-Path $Dirs.Data 'temp' if (-not (Test-Path $temp)) { New-Item -ItemType Directory -Path $temp | Out-Null } $prefix = "set `"TEMP=$temp`" && set `"TMP=$temp`" && " if ($envVars) { foreach ($k in $envVars.Keys) { $prefix += "set `"$k=$($envVars[$k])`" && " } } $full = "cmd.exe /c `"$prefix$commandLine > `"$log`" 2>&1`"" $result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $full; CurrentDirectory = $workDir } if ($result.ReturnValue -ne 0) { throw "$role 启动失败:Win32_Process.Create 返回 $($result.ReturnValue)" } Set-Content -Path (Join-Path $Dirs.Pids "$role.pid") -Value $result.ProcessId return $result.ProcessId } function Stop-Role([string]$role) { $id = Get-RolePid $role if (-not $id) { Write-Ok "$role 没在跑"; return } Invoke-Native { & taskkill.exe /PID $id /T /F } | Out-Null $global:LASTEXITCODE = 0 Remove-Item (Join-Path $Dirs.Pids "$role.pid") -Force -ErrorAction SilentlyContinue Write-Ok "$role 已停(pid $id)" } function Get-NodeExe { $node = Get-Command node -ErrorAction SilentlyContinue if (-not $node) { throw '找不到 node(需要 Node ≥ 18)' } return $node.Source } # ============================================================================ 各角色定义 function Get-RoleSpec([string]$role) { $p = $Cfg.Ports $tools = Join-Path $Cfg.IxserverRepo 'Tools' switch ($role) { 'abp' { return @{ Port = $p.Abp; WorkDir = $Dirs.Abp Command = "dotnet iCON.Abp.FX.HttpApi.Host.dll" Env = @{ ASPNETCORE_ENVIRONMENT = 'Loop' } Health = { Test-HttpAlive "https://localhost:$($Cfg.Ports.Abp)/.well-known/openid-configuration" } Timeout = 180 } } 'ixserver' { $iis = Join-Path $env:ProgramFiles 'IIS Express\iisexpress.exe' return @{ Port = $p.Ixserver; WorkDir = $Dirs.Ixserver Command = "`"$iis`" /path:`"$($Dirs.Ixserver)`" /port:$($p.Ixserver) /clr:v4.0 /systray:false" # 首个请求才触发 Application_Start,内嵌的 WebSocket 也是那时起的,所以两样都等 Health = { (Test-HttpAlive "http://localhost:$($Cfg.Ports.Ixserver)/") -and (Test-Port $Cfg.Ports.WebSocket) } Timeout = 240 } } 'chatbot' { $s = $Cfg.Secrets $cmd = "`"$(Get-NodeExe)`" `"$tools\ThirdPartyChatbotTestClient\server.js`" --no-tunnel --port $($p.Chatbot)" + " --api-base http://localhost:$($p.Ixserver) --service-code $($Cfg.ServiceCode)" + " --token-url http://localhost:$($p.Ixserver)/api/oauth/token --client-id $($s.SetsailClientId) --client-secret $($s.SetsailClientSecret)" if ($Cfg.ChatbotAutoReply) { $cmd += " --auto-reply $($Cfg.ChatbotAutoReply)" } return @{ Port = $p.Chatbot; WorkDir = $Root; Command = $cmd; Health = { Test-HttpAlive "http://127.0.0.1:$($Cfg.Ports.Chatbot)/_api/state" }; Timeout = 30 } } 'igmock' { $cmd = "`"$(Get-NodeExe)`" `"$tools\IgMockServer\server.js`" --port $($p.IgMock) --ixserver http://localhost:$($p.Ixserver)" + " --ig-account $($Cfg.IgAccount) --page-id $($Cfg.IgPageId) --expect-token $($Cfg.Secrets.FakeChannelToken)" return @{ Port = $p.IgMock; WorkDir = $Root; Command = $cmd; Health = { Test-HttpAlive "http://127.0.0.1:$($Cfg.Ports.IgMock)/_api/state" }; Timeout = 30 } } 'scrm' { $script = "$tools\ScrmMockServer\server.js" 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) --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')`" --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 @{ 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 } } } } # ============================================================================ deploy function Set-WebConfigOverrides { $overrides = Import-PowerShellDataFile (Join-Path $Root 'config\ixserver\overrides.psd1') $path = Join-Path $Dirs.Ixserver 'Web.config' $xml = New-Object Xml.XmlDocument $xml.PreserveWhitespace = $true $xml.Load($path) $cs = $overrides.ConnectionString $node = $xml.SelectSingleNode("/configuration/connectionStrings/add[@name='$($cs.Name)']") if (-not $node) { throw "Web.config 里没有连接串 $($cs.Name)" } $value = $node.GetAttribute('connectionString') $value = $value -replace '(?i)data source=[^;"&]*', "data source=$($cs.DataSource)" $value = $value -replace '(?i)initial catalog=[^;"&]*', "initial catalog=$($cs.InitialCatalog)" $value = $value -replace '(?i)user id=[^;"&]*', "user id=$($cs.UserId)" $value = $value -replace '(?i)password=[^;"&]*', "password=$($cs.Password)" $node.SetAttribute('connectionString', $value) $appSettings = $xml.SelectSingleNode('/configuration/appSettings') foreach ($key in $overrides.AppSettings.Keys) { $v = Expand-Template $overrides.AppSettings[$key] $add = $appSettings.SelectSingleNode("add[@key='$key']") if (-not $add) { $add = $xml.CreateElement('add') $add.SetAttribute('key', $key) [void]$appSettings.AppendChild($add) } $add.SetAttribute('value', $v) } # WMI 起的进程拿不到用户自己的 %TEMP%,ASP.NET 退回系统的 Temporary ASP.NET Files 又没写权限, # 首个请求就 500。编译临时目录显式放进 run\data,不去改系统目录的 ACL $compilation = $xml.SelectSingleNode('/configuration/system.web/compilation') if (-not $compilation) { throw 'Web.config 里没有 system.web/compilation' } $compilation.SetAttribute('tempDirectory', (Join-Path $Dirs.Data 'aspnet-temp')) $xml.Save($path) 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 } } Write-Ok "Web.config 已按 overrides.psd1 覆盖($($overrides.AppSettings.Count) 个键 + 连接串 → $($cs.InitialCatalog))" } function Deploy-Ixserver { $repo = $Cfg.IxserverRepo if (-not $ConfigOnly) { if (-not $SkipBuild) { Write-Step "编译 iXserver 整个解决方案(模块的 OutputPath 指向 API\bin,只编 API 会缺模块)" $msbuild = Find-MSBuild $buildLog = Join-Path $Dirs.Logs 'build-ixserver.log' & $msbuild (Join-Path $repo 'iXsocial.sln') /t:Build /p:Configuration=Debug /m /nologo /v:minimal "/flp:LogFile=$buildLog;Encoding=UTF-8;Verbosity=normal" | Out-Host if ($LASTEXITCODE -ne 0) { throw "iXserver 编译失败,见 $buildLog" } } Write-Step '拷贝 API → run\ixserver' # Log / Logs 是运行时生成的(源码里没有),不排除的话 /MIR 每次部署都会把日志当多余文件删掉 & robocopy.exe (Join-Path $repo 'API') $Dirs.Ixserver /MIR /XD obj .vs Log Logs /NFL /NDL /NJH /NJS /NP | Out-Null if ($LASTEXITCODE -ge 8) { throw "robocopy 失败(退出码 $LASTEXITCODE)" } $global:LASTEXITCODE = 0 foreach ($dll in 'iCON.iXserver.Module.ThirdPartyChatbot.BLL.dll', 'iCON.iXserver.Module.ThirdPartyChatbot.API.dll', 'iCON.iXserver.Module.AI.dll', 'InstagramChatChannel.dll') { if (-not (Test-Path (Join-Path $Dirs.Ixserver "bin\$dll"))) { throw "run\ixserver\bin 里缺 $dll(CLAUDE.md 第三节:模块要在宿主 bin 里)" } } } Set-WebConfigOverrides } function Deploy-Abp { $hostDir = Join-Path $Cfg.AbpRepo 'src\iCON.Abp.FX.HttpApi.Host' if (-not $ConfigOnly) { Write-Step '发布 ABP(net5.0)→ run\abp' $buildLog = Join-Path $Dirs.Logs 'build-abp.log' & dotnet publish (Join-Path $hostDir 'iCON.Abp.FX.HttpApi.Host.csproj') -c Release -o $Dirs.Abp --nologo "-flp:LogFile=$buildLog;Encoding=UTF-8;Verbosity=normal" | Out-Host if ($LASTEXITCODE -ne 0) { throw "ABP 发布失败,见 $buildLog" } # 仓库里那把开发密钥不用:删掉,让本机生成(或沿用上次生成的)一把只在这台机器上的 Remove-Item (Join-Path $Dirs.Abp 'tempkey.*') -Force -ErrorAction SilentlyContinue if (Test-Path (Join-Path $Dirs.AbpKey 'tempkey.jwk')) { Copy-Item (Join-Path $Dirs.AbpKey 'tempkey.*') $Dirs.Abp -Force } } # 原样拷贝:PS 5.1 的 Get-Content 默认按 GBK 读无 BOM 的 UTF-8,中文注释会乱码并吞掉换行 Copy-Item (Join-Path $Root 'config\abp\appsettings.Loop.json') (Join-Path $Dirs.Abp 'appsettings.Loop.json') -Force Write-Ok 'appsettings.Loop.json 已就位(ASPNETCORE_ENVIRONMENT=Loop)' } # git 在还没有提交的仓库上 rev-parse HEAD 会失败,记成空即可 function Invoke-Git([string]$repo) { $gitArgs = $args # scriptblock 里的 $args 是它自己的(空的),先存下来 $out = Invoke-Native { & git -C $repo @gitArgs } if ($LASTEXITCODE -ne 0) { $out = $null } $global:LASTEXITCODE = 0 return $out } function Get-RepoState([string]$repo) { if (-not (Test-Path (Join-Path $repo '.git'))) { return @{ repo = $repo; note = '不是 git 仓库' } } return [ordered]@{ repo = $repo branch = (Invoke-Git $repo rev-parse --abbrev-ref HEAD) commit = (Invoke-Git $repo rev-parse HEAD) # winbox 的工作区是 sync.sh 推过来的,有未提交改动在所难免,如实记下(方案 §2.1) uncommitted = @(Invoke-Git $repo status --porcelain) } } function Write-Deployed([string[]]$deployedRoles) { $file = Join-Path $Root 'deployed.json' $state = [ordered]@{} if (Test-Path $file) { $old = Get-Content $file -Raw | ConvertFrom-Json foreach ($p in $old.PSObject.Properties) { $state[$p.Name] = $p.Value } } $now = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz') if ($deployedRoles -contains 'ixserver') { $state['ixserver'] = [ordered]@{ deployedAt = $now; configOnly = [bool]$ConfigOnly; source = (Get-RepoState $Cfg.IxserverRepo) } } if ($deployedRoles -contains 'abp') { $state['abp'] = [ordered]@{ deployedAt = $now; configOnly = [bool]$ConfigOnly; source = (Get-RepoState $Cfg.AbpRepo) } } $state['loopEnv'] = Get-RepoState $Root $state | ConvertTo-Json -Depth 6 | Set-Content -Path $file -Encoding UTF8 Write-Ok "部署来源已记入 deployed.json" } function Invoke-Deploy { $targets = @($Roles | Where-Object { $_ -in 'ixserver', 'abp' }) if (-not $targets) { Write-Ok 'Node 工具直接从 iXserver\Tools 运行,不需要部署'; return } $restart = @($targets | Where-Object { Get-RolePid $_ }) foreach ($r in $restart) { Stop-Role $r } # 跑着的进程锁着 dll if ($targets -contains 'abp') { Deploy-Abp } if ($targets -contains 'ixserver') { Deploy-Ixserver } Write-Deployed $targets if ($restart) { Write-Step "重新拉起部署前在跑的:$($restart -join ', ')"; Invoke-Up $restart } } # ============================================================================ up / down / status function Invoke-Up([string[]]$which) { $sql = Get-Service 'MSSQL$SQLEXPRESS' -ErrorAction SilentlyContinue if (-not $sql -or $sql.Status -ne 'Running') { throw 'SQL Server Express(MSSQL$SQLEXPRESS)没在运行' } foreach ($role in $which) { $spec = Get-RoleSpec $role if (-not $spec) { Write-Warn2 "$role 还没有实现,跳过"; continue } if (Get-RolePid $role) { Write-Ok "$role 已在跑"; continue } if (Test-Port $spec.Port) { $owner = (Get-NetTCPConnection -State Listen -LocalPort $spec.Port | Select-Object -First 1).OwningProcess throw "$role 的端口 $($spec.Port) 被别的进程占着(pid $owner $((Get-Process -Id $owner).ProcessName)),先处理再起" } Write-Step "启动 $role(:$($spec.Port))" $id = Start-Role $role $spec.WorkDir $spec.Command $(if ($spec.ContainsKey('Env')) { $spec.Env }) try { Wait-Until $role $spec.Health $spec.Timeout } catch { Write-Warn2 "日志尾巴(run\logs\$role.log):" Get-Content (Join-Path $Dirs.Logs "$role.log") -Tail 30 -ErrorAction SilentlyContinue | Out-Host throw } Write-Ok "$role 就绪(pid $id)" if ($role -eq 'abp' -and -not (Test-Path (Join-Path $Dirs.AbpKey 'tempkey.jwk')) -and (Test-Path (Join-Path $Dirs.Abp 'tempkey.jwk'))) { Copy-Item (Join-Path $Dirs.Abp 'tempkey.*') $Dirs.AbpKey -Force Write-Ok '本机签名密钥已留存到 run\abp-key(之后重新部署沿用同一把)' } } } function Invoke-Down([string[]]$which) { [array]::Reverse($which) foreach ($role in $which) { Stop-Role $role } } function Invoke-Status { $rows = foreach ($role in $AllRoles) { $spec = Get-RoleSpec $role if (-not $spec) { [pscustomobject]@{ Role = $role; Pid = ''; Port = ''; Listening = ''; Healthy = '未实现' }; continue } $id = Get-RolePid $role $healthy = $false try { $healthy = [bool](& $spec.Health) } catch { } [pscustomobject]@{ Role = $role; Pid = $id; Port = $spec.Port; Listening = (Test-Port $spec.Port); Healthy = $healthy } } $rows | Format-Table -AutoSize | Out-Host $deployed = Join-Path $Root 'deployed.json' if (Test-Path $deployed) { $d = Get-Content $deployed -Raw | ConvertFrom-Json foreach ($name in 'ixserver', 'abp') { if ($d.PSObject.Properties.Name -contains $name) { $s = $d.$name.source Write-Host (" {0,-9} 部署于 {1} {2}@{3} 未提交 {4} 个" -f $name, $d.$name.deployedAt, $s.branch, ([string]$s.commit).Substring(0, [Math]::Min(10, ([string]$s.commit).Length)), @($s.uncommitted).Count) } } } } # ============================================================================ reset function Remove-Database([string]$db) { Invoke-Sql -Query "IF DB_ID(N'$db') IS NOT NULL BEGIN ALTER DATABASE [$db] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [$db]; END" | Out-Null } function Restore-Database([string]$db, [string]$bak) { if (-not (Test-Path $bak)) { throw "没有留底备份 $bak" } $sw = [Diagnostics.Stopwatch]::StartNew() Invoke-Sql -Query "IF DB_ID(N'$db') IS NOT NULL ALTER DATABASE [$db] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; RESTORE DATABASE [$db] FROM DISK = N'$bak' WITH REPLACE; ALTER DATABASE [$db] SET MULTI_USER;" | Out-Null Write-Ok "$db 已从 $bak 还原($([int]$sw.Elapsed.TotalMilliseconds) ms)" } function Backup-Database([string]$db, [string]$bak) { Invoke-Sql -Query "BACKUP DATABASE [$db] TO DISK = N'$bak' WITH INIT, FORMAT" | Out-Null Write-Ok "$db 已留底 → $bak" } <# 现编一个小 exe,用部署出来的 Model.dll 按 EDMX 建库(tools\CreateLoopDb.cs) #> function New-IxserverSchema { $bin = Join-Path $Dirs.Ixserver 'bin' if (-not (Test-Path (Join-Path $bin 'Model.dll'))) { throw '还没有部署 iXserver(run\ixserver\bin\Model.dll 不存在),先 deploy' } $toolDir = Join-Path $Dirs.Tools 'CreateLoopDb' if (Test-Path $toolDir) { Remove-Item $toolDir -Recurse -Force } New-Item -ItemType Directory -Path $toolDir | Out-Null Copy-Item (Join-Path $bin 'Model.dll'), (Join-Path $bin 'EntityFramework*.dll') $toolDir $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' $exe = Join-Path $toolDir 'CreateLoopDb.exe' $out = Invoke-Native { & $csc /nologo /target:exe "/out:$exe" "/reference:$toolDir\Model.dll" "/reference:$toolDir\EntityFramework.dll" /reference:System.Data.Entity.dll (Join-Path $Root 'tools\CreateLoopDb.cs') } if ($LASTEXITCODE -ne 0) { throw "编译 CreateLoopDb 失败:`n$($out -join "`n")" } # exe.config 取自部署后的 Web.config:连接串(已指向 iXserverLoop)、EF 配置节、程序集重定向 $web = New-Object Xml.XmlDocument; $web.Load((Join-Path $Dirs.Ixserver 'Web.config')) $cfg = New-Object Xml.XmlDocument $root = $cfg.AppendChild($cfg.CreateElement('configuration')) foreach ($name in 'configSections', 'connectionStrings', 'entityFramework', 'runtime') { $n = $web.SelectSingleNode("/configuration/$name") if ($n) { [void]$root.AppendChild($cfg.ImportNode($n, $true)) } } # configSections 里别的节(log4net 等)这里用不到,只留 entityFramework,免得缺程序集报错 $sections = $cfg.SelectSingleNode('/configuration/configSections') if ($sections) { foreach ($s in @($sections.ChildNodes)) { if ($s.NodeType -eq 'Element' -and $s.GetAttribute('name') -ne 'entityFramework') { [void]$sections.RemoveChild($s) } } } $cfg.Save("$exe.config") $out = Invoke-Native { & $exe $bin } if ($LASTEXITCODE -ne 0) { throw "按 EDMX 建库失败:`n$($out -join "`n")" } } function Invoke-Reset { $db = $Cfg.Sql.IxserverDb $bak = Get-BackupPath $db $wasRunning = [bool](Get-RolePid 'ixserver') # EF 连接池连着库会让删库 / 还原失败,先停 API if ($wasRunning) { Stop-Role 'ixserver' } if ($FromBackup) { Restore-Database $db $bak } else { $sw = [Diagnostics.Stopwatch]::StartNew() Write-Step "重建 $db" Remove-Database $db New-IxserverSchema Write-Ok '表结构已按 EDMX 建好' $seed = Join-Path $Cfg.IxserverRepo 'FeatureTest\AppData\test_db_init_script.sql' Invoke-Sql -Database $db -File $seed -Utf16 | Out-Null Write-Ok '已灌 FeatureTest 种子' # 补丁与建库绑在一起:种子里有真 token、真外部 webhook,不补不许起(方案 §8 #6) Invoke-Sql -Database $db -File (Join-Path $Root 'db\ixserver\loop_patch.sql') | Out-Host Write-Ok "loop_patch 已执行(共 $([int]$sw.Elapsed.TotalSeconds) 秒)" Backup-Database $db $bak } if ($wasRunning) { Invoke-Up @('ixserver') } } function Invoke-ResetAbp { $db = $Cfg.Sql.AbpDb $bak = Get-BackupPath $db $wasRunning = [bool](Get-RolePid 'abp') if ($wasRunning) { Stop-Role 'abp' } if ($Init) { Write-Step "给 $db 打闭环补丁(换 setsail secret 与 admin 密码)" Invoke-Sql -Database $db -File (Join-Path $Root 'db\abp\loop_patch.sql') | Out-Host Backup-Database $db $bak } else { Restore-Database $db $bak } if ($wasRunning) { Invoke-Up @('abp') } } # ============================================================================ 入口 switch ($Command) { 'deploy' { Invoke-Deploy } 'up' { Invoke-Up $Roles; Invoke-Status } 'down' { Invoke-Down @($Roles) } 'restart' { Invoke-Down @($Roles); Invoke-Up $Roles; Invoke-Status } 'reset' { Invoke-Reset } 'reset-abp' { Invoke-ResetAbp } 'status' { Invoke-Status } 'logs' { foreach ($role in $Roles) { Write-Step "run\logs\$role.log" 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/" } }