From f329f504040f3608478c687a99aa36a3cf5720b7 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期六, 03 十月 2026 23:45:04 +0800
Subject: [PATCH] feat(observation): 新增观测站分日调查数据导入与明细查看

---
 deploy-templates/backend/install-service.ps1 |  330 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 330 insertions(+), 0 deletions(-)

diff --git a/deploy-templates/backend/install-service.ps1 b/deploy-templates/backend/install-service.ps1
new file mode 100644
index 0000000..98373d6
--- /dev/null
+++ b/deploy-templates/backend/install-service.ps1
@@ -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-<version>.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).'
+}

--
Gitblit v1.9.1