xyc
4 天以前 67cd940347bfe38ef6aab075a2dbd738bec41ed3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
<#
  一键打包部署包(含"去密自检")—— 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) 用 deploy-templates\jar-application.yml(全占位符)临时替换源码 application.yml,
       执行 mvn clean package;无论成败都还原开发机真实配置
    2) 解压 jar 检查内置 application.yml:必须全是占位符(禁止 sk- 密钥 / 明文口令),不合格直接中止
    3) npm run build 重建前端 dist(可 -SkipFrontend)
    4) 组装完整部署包:backend(jar + application.yml + 启动脚本 + 服务包装器 + docs + web) / frontend/dist / 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 = '',
  [switch]$SkipFrontend,
  [switch]$SkipBackend,     # 复用已存在的 jar(不重编译、不动源码配置),仅用于补全/重组发布包
  [switch]$SkipServiceWrapper   # 不把 WinSW 服务包装器打进包(即不提供“注册成 Windows 服务”的开机自启)
)
 
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
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-1.0.0-SNAPSHOT.jar'
$jar       = Join-Path $root $jarRel
$webDist   = Join-Path $root 'traffic-audit-web\dist'
 
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)
try {
  $entry = $zip.Entries | Where-Object { $_.FullName -eq 'BOOT-INF/classes/application.yml' }
  if (-not $entry) { throw 'jar 内缺少 BOOT-INF/classes/application.yml' }
  $sr = New-Object System.IO.StreamReader($entry.Open())
  $text = $sr.ReadToEnd()
  $sr.Close()
} finally { $zip.Dispose() }
 
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 ';') + "。请检查 $tplJarCfg")
}
if ($text -notmatch 'password:\s*\$\{TRAFFIC_DB_PASSWORD') {
  Write-Host '      警告:jar 内置 password 不是 TRAFFIC_DB_PASSWORD 占位符形式,请确认模板。'
}
Write-Host '      通过:jar 内置配置全部为占位符,无明文密钥/口令。'
 
# ---------- [3/5] 前端构建 ----------
Write-Host '[3/5] 构建前端(npm run build,可 -SkipFrontend 跳过)...'
if (-not $SkipFrontend) {
  Push-Location (Join-Path $root 'traffic-audit-web')
  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)
$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
 
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 $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
}
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 内容一致
Copy-Item $webDist (Join-Path $dest 'backend\web') -Recurse -Force
 
# docs:运行时模板 + 用户文档;排除开发过程文档与缓存/备份(减小体积、避免把内部清单带出去)
$docsDest = Join-Path $dest 'backend\docs'
New-Item -ItemType Directory -Force -Path $docsDest | Out-Null
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'), (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') $docsDest -Force -ErrorAction SilentlyContinue
 
# README:用模板并替换日期占位
$readme = Get-Content (Join-Path $tplDir 'README-部署说明.txt') -Raw -Encoding UTF8
$readme = $readme.Replace('{stamp}', $stamp)
Set-Content -LiteralPath (Join-Path $dest 'README-部署说明.txt') -Value $readme -Encoding UTF8
 
# ---------- [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, $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)再启动。'