iXserverLoopEnv/loop.ps1

566 lines
28 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<#
.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\<role>.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/"
}
}