zhizhijie
2026-10-06 bee9e18c3af284ff432a4472f1d409f5a6dc09a7
deploy-templates/backend/install-service.ps1
New file
@@ -0,0 +1,330 @@
<#
  Install / update / remove the TrafficAudit backend as a Windows service.
  The service is a WinSW (Windows Service Wrapper) host: trafficAudit.exe reads
  trafficAudit.xml and runs "java -jar traffic-audit-server.jar".
  Because it is a real Windows service it starts at boot with no user logged on,
  is listed in services.msc / Get-Service, and is restarted by the Service
  Control Manager when the java process dies.
  Run from an ELEVATED PowerShell in the backend folder
  (the folder that contains the jar, application.yml, docs, web and trafficAudit.exe):
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Status
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Restart
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -Remove
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -NoStart
    powershell -NoProfile -ExecutionPolicy Bypass -File install-service.ps1 -JavaPath 'D:\jdk8\bin\java.exe'
  What it does:
    1. locates java.exe (JAVA_HOME -> PATH -> JavaSoft registry -> Program Files)
    2. writes that absolute path into trafficAudit.xml (<executable>)
    3. installs or reinstalls the service and starts it (unless -NoStart)
  Notes:
    - Re-running is idempotent: an existing service is stopped and reinstalled.
    - Logs: logs\trafficAudit.out.log / logs\trafficAudit.err.log (10 MB x 10 roll).
    - Do NOT enable the scheduled-task autostart (install-autostart.ps1) at the
      same time: two hosts would race for the same jar and port 8090.
    - Keep this file ASCII-only; Windows PowerShell 5.1 reads a BOM-less .ps1 as ANSI.
#>
param(
  [switch]$Remove,
  [switch]$Status,
  [switch]$Restart,
  [switch]$NoStart,
  [string]$JavaPath
)
$ErrorActionPreference = 'Stop'
$root      = Split-Path -Parent $MyInvocation.MyCommand.Path
$wrapper   = Join-Path $root 'trafficAudit.exe'
$config    = Join-Path $root 'trafficAudit.xml'
$appConfig = Join-Path $root 'application.yml'
$serviceId = 'TrafficAuditBackend'
# Any terminating error below lands here instead of dumping a raw stack trace
# at the operator. The line number is kept so a copy of this window is enough
# to diagnose a report.
trap {
  Write-Host ''
  Write-Host '=============================================================='
  Write-Host ' ERROR: the installer stopped before finishing.'
  Write-Host ('        ' + $_.Exception.Message)
  Write-Host ('        at line ' + $_.InvocationInfo.ScriptLineNumber + ' of install-service.ps1')
  Write-Host ' The service may not be installed. Send this window text to the'
  Write-Host ' developer if the reason is not obvious.'
  Write-Host '=============================================================='
  exit 1
}
function Assert-Admin {
  $identity  = [Security.Principal.WindowsIdentity]::GetCurrent()
  $principal = New-Object Security.Principal.WindowsPrincipal($identity)
  if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw 'This script must run from an elevated (Run as Administrator) PowerShell.'
  }
}
function Resolve-JavaPath {
  param([string]$Explicit)
  if ($Explicit) {
    if (-not (Test-Path $Explicit)) { throw "JavaPath does not exist: $Explicit" }
    return (Resolve-Path $Explicit).Path
  }
  if ($env:JAVA_HOME) {
    $candidate = Join-Path $env:JAVA_HOME 'bin\java.exe'
    if (Test-Path $candidate) { return (Resolve-Path $candidate).Path }
  }
  $onPath = Get-Command java.exe -ErrorAction SilentlyContinue
  if ($onPath -and $onPath.Source) { return $onPath.Source }
  # A SYSTEM service inherits the machine PATH, which often lacks the JDK.
  # Fall back to the install locations used by Oracle / OpenJDK installers.
  foreach ($reg in @('HKLM:\SOFTWARE\JavaSoft\JDK', 'HKLM:\SOFTWARE\JavaSoft\Java Development Kit')) {
    if (-not (Test-Path $reg)) { continue }
    foreach ($ver in (Get-ChildItem $reg -ErrorAction SilentlyContinue | Sort-Object Name -Descending)) {
      $home = (Get-ItemProperty -Path $ver.PSPath -Name JavaHome -ErrorAction SilentlyContinue).JavaHome
      if ($home) {
        $candidate = Join-Path $home 'bin\java.exe'
        if (Test-Path $candidate) { return (Resolve-Path $candidate).Path }
      }
    }
  }
  $roots = @($env:ProgramFiles)
  if (${env:ProgramFiles(x86)}) { $roots += ${env:ProgramFiles(x86)} }
  foreach ($base in $roots) {
    foreach ($vendor in @('Java', 'Eclipse Adoptium', 'AdoptOpenJDK', 'Microsoft')) {
      $hit = Get-ChildItem -Path (Join-Path $base ($vendor + '\*\bin\java.exe')) -ErrorAction SilentlyContinue |
             Sort-Object FullName -Descending | Select-Object -First 1
      if ($hit) { return $hit.FullName }
    }
  }
  throw 'java.exe not found. Install a JDK 8 and re-run, or pass -JavaPath "D:\path\to\bin\java.exe".'
}
function Set-WrapperJavaPath {
  param([string]$JavaExe)
  if (-not (Test-Path $config)) { throw "Missing config file: $config" }
  # Edit through the DOM: a regex would also hit the executable mention inside
  # the header comment and would mangle the whole document.
  $doc = New-Object System.Xml.XmlDocument
  $doc.PreserveWhitespace = $true
  $doc.Load($config)
  $node = $doc.SelectSingleNode('/service/executable')
  if (-not $node) { throw "No /service/executable element found in $config" }
  if ($node.InnerText -eq $JavaExe) {
    Write-Host "Config already points at $JavaExe"
    return
  }
  $node.InnerText = $JavaExe
  $settings = New-Object System.Xml.XmlWriterSettings
  $settings.Encoding = New-Object System.Text.UTF8Encoding $false
  $settings.Indent = $true
  $settings.IndentChars = '  '
  $writer = [System.Xml.XmlWriter]::Create($config, $settings)
  try { $doc.Save($writer) } finally { $writer.Close() }
  Write-Host "Updated $config -> $JavaExe"
}
function Get-ServiceState {
  Get-Service -Name $serviceId -ErrorAction SilentlyContinue
}
function Test-LocalPort {
  param([int]$Port, [string]$TargetHost = '127.0.0.1')
  # Probe TCP instead of HTTP: Invoke-WebRequest does not exist on Windows
  # PowerShell 2 (Windows 7), and this works on every PowerShell version.
  $client = New-Object System.Net.Sockets.TcpClient
  try {
    $async = $client.BeginConnect($TargetHost, $Port, $null, $null)
    if ($async.AsyncWaitHandle.WaitOne(1000, $false) -and $client.Connected) { return $true }
    return $false
  } catch {
    return $false
  } finally {
    $client.Close()
  }
}
function Test-AppConfigPassword {
  param([string]$Path)
  # Returns $true when the datasource password looks usable. Prints a WARN and
  # returns $false for the states that break startup with
  # "Access denied ... (using password: NO)": missing file / empty value / placeholder.
  if (-not (Test-Path -LiteralPath $Path)) {
    Write-Host 'WARN: application.yml is missing next to the jar.'
    Write-Host '      The built-in password placeholder resolves to an empty string and startup will fail with'
    Write-Host "      Java SQLException: Access denied for user 'root'@'localhost' (using password: NO)."
    return $false
  }
  $line = Select-String -LiteralPath $Path -Pattern '^\s*password\s*:' | Select-Object -First 1
  if (-not $line) {
    Write-Host "WARN: no 'password:' entry found in application.yml; check the datasource block."
    return $false
  }
  # Trim() takes a single char, so strip whitespace, then one quote kind at a
  # time. Passing a 2-char string here throws
  # "cannot convert value ... to type System.Char" and aborts the script.
  $value = $line.Line -replace '^\s*password\s*:\s*', ''
  $value = $value.Trim()
  $value = $value.Trim("'")
  $value = $value.Trim('"')
  if ($value -eq '') {
    Write-Host "WARN: spring.datasource.password is empty in application.yml; startup may fail with '(using password: NO)'."
    return $false
  }
  if ($value -match '[^\x00-\x7F]') {
    Write-Host 'WARN: the datasource password contains non-ASCII characters.'
    Write-Host '      If it is still the Chinese placeholder text, replace it with the real MySQL password first.'
    return $false
  }
  return $true
}
# ---------- read-only status: no elevation needed ----------
if ($Status) {
  $service = Get-ServiceState
  if (-not $service) {
    Write-Host "Service '$serviceId' is NOT installed."
    exit 1
  }
  $service | Select-Object Name, DisplayName, Status, StartType | Format-List
  # Get-CimInstance needs PowerShell 3+; fall back to WMI for Windows 7 / PowerShell 2.
  $detail = $null
  try {
    $detail = Get-CimInstance Win32_Service -Filter "Name='$serviceId'" -ErrorAction Stop
  } catch {
    $detail = Get-WmiObject Win32_Service -Filter "Name='$serviceId'" -ErrorAction SilentlyContinue
  }
  if ($detail) { $detail | Select-Object ProcessId, StartMode, DelayedAutoStart, PathName | Format-List }
  if (Test-Path $wrapper) { & $wrapper status }
  exit 0
}
if ($Restart) {
  Assert-Admin
  if (-not (Get-ServiceState)) { throw "Service '$serviceId' is not installed; run this script without -Restart first." }
  Write-Host "Restarting service '$serviceId'..."
  Restart-Service -Name $serviceId -Force
  Start-Sleep -Seconds 3
  Get-ServiceState | Select-Object Name, Status | Format-List
  exit 0
}
if ($Remove) {
  Assert-Admin
  if (-not (Test-Path $wrapper)) { throw "Missing wrapper executable: $wrapper" }
  if (Get-ServiceState) {
    Write-Host "Stopping and removing service '$serviceId'..."
    & $wrapper stop | Out-Null
    Start-Sleep -Seconds 3
    & $wrapper uninstall | Out-Null
    Write-Host "Removed service '$serviceId'. Files in $root were left untouched."
  } else {
    Write-Host "Service '$serviceId' is not installed; nothing to remove."
  }
  exit 0
}
# ---------- install / reinstall ----------
Assert-Admin
if (-not (Test-Path $wrapper)) {
  throw "Missing wrapper executable: $wrapper`nRebuild the deploy package with pack-deploy.ps1 (it provides WinSW), or copy WinSW-x64.exe here renamed to trafficAudit.exe."
}
$passwordOk = Test-AppConfigPassword -Path $appConfig
$java = Resolve-JavaPath -Explicit $JavaPath
Set-WrapperJavaPath -JavaExe $java
$existing = Get-ServiceState
if ($existing) {
  Write-Host "Service '$serviceId' already exists (status: $($existing.Status)); reinstalling..."
  if ($existing.Status -ne 'Stopped') {
    & $wrapper stop | Out-Null
    Start-Sleep -Seconds 3
  }
  & $wrapper uninstall | Out-Null
  Start-Sleep -Seconds 2
}
Write-Host "Installing service '$serviceId'..."
& $wrapper install
if ($LASTEXITCODE -ne 0) { throw "WinSW install failed with exit code $LASTEXITCODE" }
if (-not $NoStart) {
  Write-Host "Starting service '$serviceId' (auto-start at boot is enabled)..."
  & $wrapper start
  Start-Sleep -Seconds 5
  $service = Get-ServiceState
  $service | Select-Object Name, DisplayName, Status, StartType | Format-List
  # Read the port from application.yml so the check matches the real config.
  $port = 8090
  if (Test-Path $appConfig) {
    $portLine = Select-String -LiteralPath $appConfig -Pattern '^\s*port\s*:' | Select-Object -First 1
    if ($portLine -and $portLine.Line -match '(\d{2,5})') { $port = [int]$matches[1] }
  }
  $ready = $false
  for ($i = 1; $i -le 30; $i++) {
    if (Test-LocalPort -Port $port) { $ready = $true; break }
    Start-Sleep -Seconds 2
    $state = Get-ServiceState
    if ($state -and $state.Status -ne 'Running') { break }
  }
  $logDir = Join-Path $root 'logs'
  Write-Host ''
  if ($ready) {
    Write-Host "OK  : the backend is listening on port $port."
    Write-Host "      On this server   : http://127.0.0.1:$port"
    Write-Host "      For other people : http://<server-ip>:$port"
  } else {
    Write-Host "WARN: nothing is listening on port $port yet - startup is still running, or it failed."
    Write-Host "      Fix application.yml / MySQL, then run: .\install-service.ps1 -Restart"
    foreach ($name in @('trafficAudit.err.log', 'trafficAudit.out.log')) {
      $logFile = Join-Path $logDir $name
      if (Test-Path -LiteralPath $logFile) {
        Write-Host ''
        Write-Host "---- last 15 lines of $name ----"
        Get-Content -LiteralPath $logFile -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host "  $_" }
      }
    }
  }
  if (-not $passwordOk) {
    Write-Host ''
    Write-Host '=============================================================='
    Write-Host ' IMPORTANT: backend\application.yml still looks unset.'
    Write-Host ' Put the real MySQL url / username / password in it, then run'
    Write-Host '   .\install-service.ps1 -Restart'
    Write-Host '=============================================================='
  }
  Write-Host ''
  Write-Host "Logs  : $logDir\trafficAudit.out.log / trafficAudit.err.log"
  Write-Host "Manage: .\install-service.ps1 -Status | -Restart | -Remove"
  Write-Host "        Stop-Service $serviceId | Start-Service $serviceId"
  Write-Host ''
  Write-Host 'This service starts automatically on every boot (Automatic + delayed start).'
}