553 lines
27 KiB
PowerShell
553 lines
27 KiB
PowerShell
<#
|
||
.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 看某个角色的日志尾巴
|
||
|
||
.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
|
||
#>
|
||
param(
|
||
[Parameter(Position = 0)]
|
||
[ValidateSet('deploy', 'up', 'down', 'restart', 'reset', 'reset-abp', 'status', 'logs')]
|
||
[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'
|
||
}
|
||
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')`"" +
|
||
" --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'
|
||
& robocopy.exe (Join-Path $repo 'API') $Dirs.Ixserver /MIR /XD obj .vs /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
|
||
}
|
||
}
|
||
}
|