xyc
2 天以前 bd4e71203152cdb31ecb939c6113e9db78d1d767
pack-deploy.ps1
@@ -1,34 +1,88 @@
<#
  一键打包部署包(含"去密自检")—— 2026-09-27 新增
  一键打包部署包(含"去密自检")—— 2026-09-27 新增,2026-10-03 修订
  用法:
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -OutRoot "E:\试验" -SkipFrontend
  做四件事:
    1) mvn clean package 构建后端 jar
    powershell -NoProfile -ExecutionPolicy Bypass -File pack-deploy.ps1 -NoFrontendDist -SkipServiceWrapper
      —— 单端口发布:不生成独立的 frontend\dist 目录(仍构建并同步 backend\web,单端口必需);
         服务器已装服务时配合 -SkipServiceWrapper 去掉约 17 MB 的 WinSW,可显著减小包体积。
  做五件事:
    1) 用 deploy-templates\jar-application.yml(全占位符)临时替换源码 application.yml,
       执行 mvn clean package;无论成败都还原开发机真实配置
    2) 解压 jar 检查内置 application.yml:必须全是占位符(禁止 sk- 密钥 / 明文口令),不合格直接中止
    3) npm run build 构建前端 dist(可 -SkipFrontend)
    4) 组装 deploy 目录结构(backend / frontend / db / README-部署说明.txt),输出到 <OutRoot>\traffic-audit-deploy-<yyyyMMdd>
    3) npm run build 重建前端 dist(可 -SkipFrontend)
    4) 组装完整部署包:backend(jar + application.yml + 启动脚本 + 服务包装器 + docs + web) / frontend/dist(可选,-NoFrontendDist 不打) / db / README
       Windows 服务包装器 WinSW:优先用 deploy-templates\tools\winsw.exe 缓存(校验 SHA-256),
       缺失时自动下载 v2.12.0 x64 到该缓存目录;随包输出为 backend\trafficAudit.exe
    5) 对组装后的整包再扫一遍明文密钥
  修订背景(2026-10-03):2026-10-02 手工产出的包缺 application.yml / docs / db / 启动脚本,
  且没有把 dist 同步到 backend\web,部署机启动直接报
  "java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: NO)"。
  本版把上述文件全部补齐,并按 jar 内置占位符配置重新构建,避免再次出现。
#>
param(
  [string]$OutRoot = 'E:\试验',
  [switch]$SkipFrontend
  [string]$OutRoot = '',
  [switch]$SkipFrontend,
  [switch]$NoFrontendDist,  # 单端口发布:不打独立的 frontend\dist 目录,但仍构建/同步 backend\web(单端口必需)
  [switch]$SkipBackend,     # 复用已存在的 jar(不重编译、不动源码配置),仅用于补全/重组发布包
  [switch]$SkipServiceWrapper   # 不把 WinSW 服务包装器打进包(即不提供“注册成 Windows 服务”的开机自启)
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$tplDir = Join-Path $root 'deploy-templates'                  # 打包模板件(已入库,2026-09-27 起)
$jarRel = 'traffic-audit-server\target\traffic-audit-server-1.0.0-SNAPSHOT.jar'
$jar = Join-Path $root $jarRel
$pomPath = Join-Path $root 'traffic-audit-server\pom.xml'
if (-not (Test-Path $pomPath)) { throw "Missing Maven project: $pomPath" }
[xml]$pom = Get-Content -LiteralPath $pomPath -Raw -Encoding UTF8
$productVersion = [string]$pom.project.version
if (-not $productVersion) { throw 'Missing <version> in traffic-audit-server\pom.xml' }
if ($productVersion -match '(?i)-SNAPSHOT$') { throw "Release builds must not use a SNAPSHOT version: $productVersion" }
if (-not $OutRoot) { $OutRoot = Join-Path $root 'deploy' }      # 默认输出到仓库 deploy\(已 gitignore)
$tplDir    = Join-Path $root 'deploy-templates'                  # 打包模板件(已入库)
$tplJarCfg = Join-Path $tplDir 'jar-application.yml'             # 进 jar 的占位符配置
$srcCfg    = Join-Path $root 'traffic-audit-server\src\main\resources\application.yml'
$jarRel    = 'traffic-audit-server\target\traffic-audit-server.jar'
$jar       = Join-Path $root $jarRel
$webDist   = Join-Path $root 'traffic-audit-web\dist'
Write-Host '[1/5] 构建后端(mvn clean package -DskipTests)...'
Push-Location $root
cmd /c "mvn.cmd -q clean package -DskipTests -f traffic-audit-server\pom.xml > pack-build.log 2>&1"
$rc = $LASTEXITCODE
Pop-Location
if ($rc -ne 0) { throw '后端构建失败,详见 pack-build.log' }
if (-not (Test-Path $tplJarCfg)) { throw "缺少打包模板:$tplJarCfg" }
# ---------- [1/5] 用占位符配置构建 jar,构建后还原开发机真实配置 ----------
if ($SkipBackend) {
  Write-Host '[1/5] 已指定 -SkipBackend,跳过后端构建,复用现有 jar'
  if (-not (Test-Path $jar)) { throw "未找到现有 jar:$jar(去掉 -SkipBackend 重新构建)" }
} else {
Write-Host '[1/5] 构建后端(mvn clean package -DskipTests,构建期使用占位符配置)...'
$backupCfg = $null
$hadCfg = Test-Path $srcCfg
try {
  if ($hadCfg) {
    $backupCfg = [System.IO.Path]::GetTempFileName()
    Copy-Item $srcCfg $backupCfg -Force
  }
  Copy-Item $tplJarCfg $srcCfg -Force
  Push-Location $root
  try {
    cmd /c "mvn.cmd -q clean package -DskipTests -f traffic-audit-server\pom.xml > pack-build.log 2>&1"
    $rc = $LASTEXITCODE
  } finally { Pop-Location }
  if ($rc -ne 0) { throw '后端构建失败,详见 pack-build.log' }
} finally {
  if ($backupCfg) {
    Copy-Item $backupCfg $srcCfg -Force
    Remove-Item $backupCfg -Force -ErrorAction SilentlyContinue
    Write-Host '      已还原开发机 src\main\resources\application.yml'
  } elseif (-not $hadCfg) {
    Remove-Item $srcCfg -Force -ErrorAction SilentlyContinue
    Write-Host '      注意:构建前源码目录本来没有 application.yml,已清掉临时占位符文件'
  }
}
if (-not (Test-Path $jar)) { throw "未找到构建产物:$jar" }
}
Write-Host ("      产物:{0:N1} MB" -f ((Get-Item $jar).Length / 1MB))
# ---------- [2/5] 去密自检:jar 内置配置必须全是占位符 ----------
Write-Host '[2/5] 去密自检:解压 jar 检查 BOOT-INF/classes/application.yml ...'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($jar)
@@ -38,45 +92,138 @@
  $sr = New-Object System.IO.StreamReader($entry.Open())
  $text = $sr.ReadToEnd()
  $sr.Close()
  $versionEntry = $zip.Entries | Where-Object { $_.FullName -eq 'BOOT-INF/classes/app-version.properties' }
  if (-not $versionEntry) { throw 'jar 内缺少 BOOT-INF/classes/app-version.properties' }
  $vr = New-Object System.IO.StreamReader($versionEntry.Open())
  $versionText = $vr.ReadToEnd()
  $vr.Close()
  $manifestEntry = $zip.Entries | Where-Object { $_.FullName -eq 'META-INF/MANIFEST.MF' }
  if (-not $manifestEntry) { throw 'jar 内缺少 META-INF/MANIFEST.MF' }
  $mr = New-Object System.IO.StreamReader($manifestEntry.Open())
  $manifestText = $mr.ReadToEnd()
  $mr.Close()
} finally { $zip.Dispose() }
$bad = @()
if ($text -match 'sk-[A-Za-z0-9]{16,}') { $bad += '疑似明文 API key(sk-…)' }
foreach ($line in ($text -split "`n")) {
  if ($line -match '^\s*(password|api-key|secret)\s*:\s*(.+)$') {
    $v = $matches[2].Trim().Trim('"')
    if ($v -and -not $v.StartsWith('${')) { $bad += ("$($matches[1]) 不是占位符") }
function Get-SecretHits([string]$ymlText) {
  $hits = @()
  if ($ymlText -match 'sk-[A-Za-z0-9]{16,}') { $hits += '疑似明文 API key(sk-...)' }
  foreach ($line in ($ymlText -split "`n")) {
    if ($line -match '^\s*(password|api-key|secret)\s*:\s*(.+)$') {
      $v = $matches[2].Trim().Trim('"')
      if ($v -and -not $v.StartsWith('${')) { $hits += ("$($matches[1]) 不是占位符") }
    }
  }
  return $hits
}
$bad = Get-SecretHits $text
if ($bad.Count -gt 0) {
  throw ('去密自检未通过:' + ($bad -join ';') + '。请检查 traffic-audit-server\src\main\resources\application.yml')
  throw ('去密自检未通过:' + ($bad -join ';') + "。请检查 $tplJarCfg")
}
if ($text -notmatch 'password:\s*\$\{TRAFFIC_DB_PASSWORD') {
  Write-Host '      警告:jar 内置 password 不是 TRAFFIC_DB_PASSWORD 占位符形式,请确认模板。'
}
Write-Host '      通过:jar 内置配置全部为占位符,无明文密钥/口令。'
$builtVersion = ''
if ($versionText -match '(?m)^\s*version\s*=\s*(.+?)\s*$') { $builtVersion = $matches[1].Trim() }
if ($builtVersion -ne $productVersion) {
  throw "版本不一致:pom.xml=$productVersion, app-version.properties=$builtVersion"
}
$manifestVersion = ''
if ($manifestText -match '(?m)^Implementation-Version:\s*(.+?)\s*$') { $manifestVersion = $matches[1].Trim() }
if ($manifestVersion -ne $productVersion) {
  throw "版本不一致:pom.xml=$productVersion, MANIFEST.MF=$manifestVersion"
}
Write-Host "      版本自检通过:$productVersion (pom / app-version.properties / MANIFEST.MF)"
# ---------- [3/5] 前端构建 ----------
# 单端口部署必须有 backend\web,所以即使 -NoFrontendDist 也需要 dist;只有 -SkipFrontend 才复用现有 dist。
Write-Host '[3/5] 构建前端(npm run build,可 -SkipFrontend 跳过)...'
if (-not $SkipFrontend) {
  Push-Location (Join-Path $root 'traffic-audit-web')
  cmd /c "npm.cmd run build > ..\pack-front.log 2>&1"
  $rc2 = $LASTEXITCODE
  Pop-Location
  try {
    cmd /c "npm.cmd run build > ..\pack-front.log 2>&1"
    $rc2 = $LASTEXITCODE
  } finally { Pop-Location }
  if ($rc2 -ne 0) { throw '前端构建失败,详见 pack-front.log' }
  Write-Host '      前端 dist 已重建'
} else {
  Write-Host '      已按 -SkipFrontend 跳过,使用现有 dist'
}
if (-not (Test-Path $webDist)) { throw "未找到前端产物:$webDist(去掉 -SkipFrontend 重新构建)" }
# ---------- WinSW(Windows 服务包装器)准备:本地缓存优先,缺失时从官方发布页下载并校验 SHA-256 ----------
# WinSW v2.12.0 官方产物未做 Authenticode 签名,所以以固定 SHA-256 作为完整性校验。
$winswUrl    = 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe'
$winswSha256 = '05B82D46AD331CC16BDC00DE5C6332C1EF818DF8CEEFCD49C726553209B3A0DA'
$winswSrc    = Join-Path $tplDir 'tools\winsw.exe'
function Test-WinswCache {
  param([string]$Path)
  if (-not (Test-Path -LiteralPath $Path)) { return $false }
  $hash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
  if ($hash -ne $winswSha256) {
    Write-Host ('      警告:WinSW 缓存 SHA-256 不匹配,将重新下载(期望 {0}...,实际 {1}...)' -f $winswSha256.Substring(0, 12), $hash.Substring(0, 12))
    return $false
  }
  return $true
}
if ($SkipServiceWrapper) {
  Write-Host '      已按 -SkipServiceWrapper 跳过:包内不含 Windows 服务方式开机自启'
} else {
  if (-not (Test-WinswCache $winswSrc)) {
    New-Item -ItemType Directory -Force -Path (Split-Path -Parent $winswSrc) | Out-Null
    Write-Host '      下载 WinSW v2.12.0 x64 ...'
    try {
      [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
      Invoke-WebRequest -Uri $winswUrl -OutFile $winswSrc -UseBasicParsing
    } catch {
      throw ("下载 WinSW 失败:{0}。`n请手动下载 {1} 存为 {2} 后重试,或用 -SkipServiceWrapper 跳过。" -f $_.Exception.Message, $winswUrl, $winswSrc)
    }
    if (-not (Test-WinswCache $winswSrc)) { throw "WinSW 校验失败,已中止打包:$winswSrc" }
  }
  Write-Host ('      WinSW 就绪(SHA-256 已校验):{0:N1} MB' -f ((Get-Item -LiteralPath $winswSrc).Length / 1MB))
}
# ---------- [4/5] 组装完整部署目录 ----------
Write-Host '[4/5] 组装部署目录...'
$stamp = Get-Date -Format 'yyyyMMdd'
$dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp)
$dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-v" + $productVersion)
$n = 2
while (Test-Path $dest) { $dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-" + $n); $n++ }
New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'frontend'), (Join-Path $dest 'db') | Out-Null
while (Test-Path $dest) { $dest = Join-Path $OutRoot ("traffic-audit-deploy-" + $stamp + "-v" + $productVersion + "-" + $n); $n++ }
if ($NoFrontendDist) {
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'db') | Out-Null
} else {
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'backend'), (Join-Path $dest 'frontend'), (Join-Path $dest 'db') | Out-Null
}
Copy-Item $jar (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\application.yml') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\start-backend.cmd') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\start-backend.sh') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $root 'traffic-audit-web\dist') (Join-Path $dest 'frontend') -Recurse -Force
Copy-Item (Join-Path $tplDir 'frontend\nginx.conf.example') (Join-Path $dest 'frontend') -Force
Copy-Item (Join-Path $tplDir 'backend\start-backend-service.cmd') (Join-Path $dest 'backend') -Force
Copy-Item (Join-Path $tplDir 'backend\install-autostart.ps1') (Join-Path $dest 'backend') -Force
if (-not $SkipServiceWrapper) {
  # WinSW 要求配置文件名与 exe 同名:trafficAudit.exe 读同目录的 trafficAudit.xml
  Copy-Item -LiteralPath $winswSrc (Join-Path $dest 'backend\trafficAudit.exe') -Force
  Copy-Item (Join-Path $tplDir 'backend\trafficAudit.xml') (Join-Path $dest 'backend') -Force
  Copy-Item (Join-Path $tplDir 'backend\install-service.ps1') (Join-Path $dest 'backend') -Force
  Copy-Item (Join-Path $tplDir 'backend\install-service.cmd') (Join-Path $dest 'backend') -Force
}
if (-not $NoFrontendDist) {
  Copy-Item $webDist (Join-Path $dest 'frontend') -Recurse -Force
  Copy-Item (Join-Path $tplDir 'frontend\nginx.conf.example') (Join-Path $dest 'frontend') -Force
}
Copy-Item (Join-Path $tplDir 'db\*.sql') (Join-Path $dest 'db') -Force
# 单端口 8090 模式:后端从 jar 同级 web\ 读前端静态文件,必须和 dist 内容一致。
# 单端口发布(-NoFrontendDist)同样必须同步,否则页面 404。
Copy-Item $webDist (Join-Path $dest 'backend\web') -Recurse -Force
# docs:运行时模板 + 用户文档;排除开发过程文档与缓存/备份(减小体积、避免把内部清单带出去)
$docsDest = Join-Path $dest 'backend\docs'
@@ -84,15 +231,31 @@
cmd /c "robocopy `"$root\docs`" `"$docsDest`" /E /NFL /NDL /NJH /NJS /NP /XD _cache 工作日结 功能测试报告 问题汇总 /XF 生成_*.xlsx _bak_* >nul"
if ($LASTEXITCODE -ge 8) { throw "docs 复制失败(robocopy 返回 $LASTEXITCODE)" }
# db 脚本也放一份到 docs(与旧包一致:docs/init.sql 等)
Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql') $docsDest -Force -ErrorAction SilentlyContinue
Copy-Item (Join-Path $tplDir 'db\init.sql'), (Join-Path $tplDir 'db\sql_city_bus.sql'), (Join-Path $tplDir 'db\sql_city_taxi.sql'), (Join-Path $tplDir 'db\sql_wyc.sql'), (Join-Path $tplDir 'db\sql_holiday.sql'), (Join-Path $tplDir 'db\sql_holiday_decimal_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_comparison_base_migration.sql'), (Join-Path $tplDir 'db\sql_holiday_previous_day_migration.sql'), (Join-Path $tplDir 'db\sql_observation_station.sql') $docsDest -Force -ErrorAction SilentlyContinue
# README:用旧包模板并替换日期占位
# README:用模板并替换日期占位
$readme = Get-Content (Join-Path $tplDir 'README-部署说明.txt') -Raw -Encoding UTF8
$readme = $readme.Replace('{stamp}', $stamp)
$readme = $readme.Replace('{stamp}', $stamp).Replace('{version}', $productVersion)
Set-Content -LiteralPath (Join-Path $dest 'README-部署说明.txt') -Value $readme -Encoding UTF8
Write-Host '[5/5] 汇总...'
# ---------- [5/5] 整包明文密钥扫描 + 汇总 ----------
Write-Host '[5/5] 整包扫描 + 汇总...'
$leak = @()
foreach ($f in (Get-ChildItem (Join-Path $dest 'backend') -File | Where-Object { $_.Extension -in '.yml', '.yaml', '.cmd', '.sh', '.txt' })) {
  $t = Get-Content $f.FullName -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
  if ($t -and $t -match 'sk-[A-Za-z0-9]{16,}') { $leak += $f.Name + '(疑似明文 API key)' }
}
if ($leak.Count -gt 0) { throw ('整包扫描未通过:' + ($leak -join ';')) }
$files = Get-ChildItem $dest -Recurse -File
$mb = (($files | Measure-Object Length -Sum).Sum / 1MB)
Write-Host ("      输出目录:{0}" -f $dest)
Write-Host ("      文件数:{0} 合计:{1:N2} MB" -f $files.Count, (($files | Measure-Object Length -Sum).Sum / 1MB))
Write-Host '      提醒:把包拷到部署机后,只需改 backend\application.yml 里的数据库三处 + 需要时填 deepseek.api-key。'
Write-Host ("      文件数:{0}  合计:{1:N2} MB" -f $files.Count, $mb)
$zipPath = $dest + '.zip'
if (Get-Command Compress-Archive -ErrorAction SilentlyContinue) {
  if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
  Compress-Archive -Path (Join-Path $dest '*') -DestinationPath $zipPath -CompressionLevel Optimal
  Write-Host ("      压缩包:{0}({1:N2} MB)" -f $zipPath, ((Get-Item $zipPath).Length / 1MB))
}
Write-Host '      提醒:拷到部署机后,务必先改 backend\application.yml 的 spring.datasource(url/username/password)再启动。'