charging-cabinet/deploy/deploy-prod.ps1
12451 471b61d277 fix(test)+fix(deploy)+docs: 探针状态语义反转修复、退出码补齐、文档校订
1) probe_export.py:任务状态语义**搞反了**(导出成功也会被判失败)
   - download_tasks.status 的权威语义是 0 排队 / 1 处理中 / **2 完成** / 3 失败
     (db/migrate.rs:225 列注释;写入侧 charge_records.rs::mark_task_completed 写的就是 2)。
   - 该脚本此前**等 status == 1(处理中)当成功、把 status == 2(完成)判成"导出失败"**
     —— 典型假红。已按权威语义订正,并与另外两处**本来就正确**的口径对齐:
     web/src/pages/charge-records/DownloadCenter.tsx::statusText、
     test/schema_alignment_e2e.py(等 status in (2,3) 再断言 == 2)。
   - 同批修正退出码分类:账号缺 charge_record:export(403)由 exit 1 改为
     **exit 2「未验证」** —— 本项要验证的是"降权成 pms 后能否往 exports/ 落盘",
     没权限根本走不到那一步,报 1 是**把夹具缺陷说成产品缺陷**(误报与假绿同样有害)。
   - 允许用 PMS_PROBE_PHONE / PMS_PROBE_PASSWORD 换成有权限的账号。
   - **C 项结论(由落盘物证直接判定,不依赖该脚本)**:测试环境
     exports/充电记录__-.xlsx 5559 B、生产同类产物 5457 B,属主均为 pms:pms;
     两环境 exports/uploads 目录均 pms:pms,两 api-server 单元均 User=pms/Group=pms
     ⇒ 降权进程确实能落盘。已在 README 写明并给出取证依据。

2) deploy-prod.ps1 的退出码(此前失败的探针只打印 ❌ 就继续,脚本仍以 0 退出)
   - 消费链路探测失败(exit 1)现在**记入失败清单**并以 exit 1 收尾;
     "未验证"(隧道没起)默认仍不计失败(环境问题会误报),
     但新增 **-StrictVerify** 开关供 CI/无人值守使用,打开即 exit 1。
   - 好处:把这条从"打印了 ❌ 却 exit 0"(仓库第 8 条假绿)里摘出来。

3) 统一退出码 0/1/2 落地到本批脚本,并对每个失败路径实测取证:
   - test/tcp_e2e_smoke.py:13/13 通过(含未登录业务报文被静默丢弃);先复位夹具再跑。
   - test/security-hardening/ 新增 verdict.py 判据容器(未跟踪 → 本次入库);
     4 个探针全部按失败→exit 1 改造,并各实测过失败路径。
   - 实测结果(2026-09-20 发布后回归):smoke_prod 4/4 exit 0、
     probe_prod 5/5 exit 0、probe_upload 6/6 exit 0。

4) 工具与文档
   - tools/db.py 默认库由**生产 pms 改为测试 pms_dev**(可用 PMS_MYSQL_DB 覆盖,
     切生产时打印醒目警告)—— "以为在测测试库、实际打生产"的隐患。
   - tools/db_schema_diff.py:连不上必须是 exit 2,绝不伪装成"无漂移"的 0。
   - 新增 tools/dev-tunnel.ps1(入库,SSH 隧道;口令走 PMS_SSH_PASSWORD 环境变量),
     配套 tools/dev-tunnel-run.ps1 自愈启动器**保持 gitignore**(内含口令)。
   - deploy/nginx/nginx.conf 镜像随线上同步(移除 stream 转发;与线上逐字节一致)。
   - AGENTS.md / README.md:补「踩过的坑」第 7/8 条(掐断上游管道会杀被测进程、
     打印 ❌ 却 exit 0 是假绿)、退出码约定、本地开发改 SSH 隧道、
     在线判据口径订正、order_timeout 守门补齐、小程序 vue-tsc 门禁与其抓到的两个真 BUG。

发布验收(本次统一发布,按 device→api 顺序):
- 测试环境与生产环境四服务 active,端口 9000/9001/3000/3001 正确,ERROR 均为 0;
- 生产迁移按判据 grep '更新列注释' 实测对齐 cabinets.status 列注释;
- 结构漂移 python tools/db_schema_diff.py = 0 处漂移 / exit 0(--vs-ddl 两库同样 0);
- schema_alignment_e2e --env dev 全部通过 exit 0;
- 生产域名 /api/pms/cabinets -> 401、/pms/ -> 200;消费链路探测通过。
2026-09-20 04:31:42 +08:00

281 lines
14 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# =============================================================================
# PMS 正式环境部署脚本(在**本地开发机**执行,不要拿到服务器上跑)
#
# 为什么是 PowerShell 而不是原 deploy-prod.sh:
# 原脚本有 3 个致命问题(2026-09 修复)——
# 1. 它在 `target/release/` 找二进制,而实际构建产物在
# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件
# 2. 它安装并重启 `pms-api-prod` / `pms-device-prod`,而线上真实单元名是
# `pms-api` / `pms-device` → 会**多出一套重复单元**且重启失败
# 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错,
# 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂)
# 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收"
# 的顺序执行,并在收尾打印回滚方法;最后按"只保留最近 5 次回滚点"清理历史 `backup-*`。
#
# 用法:
# pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收)
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署
# pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动
# pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端
# pwsh deploy/deploy-prod.ps1 -StrictVerify # 【CI/无人值守请加】"未验证"也当作失败(退出码 1)
#
# 退出码(遵循仓库统一约定):
# 0 = 部署且全部验收通过
# 1 = 有验收项失败(含 -StrictVerify 下的"未验证")
# 2 = 环境/连接问题(构建产物缺失、SSH/上传失败)
# =============================================================================
param(
[switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build
[switch]$SkipWeb, # 跳过前端
[switch]$DryRun, # 只演练,不执行
# 把"未验证(通常是本地 SSH 隧道没起)"也当成失败:**默认关闭**是因为
# "未验证"确实是环境问题而非部署问题,误报会让人习惯性忽略;但 CI/无人值守
# 必须打开 —— 否则"探测根本没跑"会被当成绿灯(这正是仓库第 7/8 条假绿的形态)。
[switch]$StrictVerify
)
$ErrorActionPreference = 'Stop'
# ---- 环境参数(正式环境)---------------------------------------------------
$Host_ = '118.31.119.206'
$User = 'root'
$Pwd_ = 'Hbhyg731024@' # 内网仓库明文密码(见 AGENTS.md「代码审查豁免」)
$Dst = '/data/pms'
$ApiPort = 9000
$TcpPort = 9001
$Domain = 'app.anzhizhichong.com'
# 回滚点(backup-YYYYMMDD-HHMMSS)保留份数:每次部署新建一份,从不清理会持续涨
# (实测 2026-09-20 已累计 143MB / 4 份,单份最大 99MB)。保留最近 N 次即可回滚。
$KeepBackups = 5
# ---- 路径 -------------------------------------------------------------------
$Repo = Split-Path -Parent $PSScriptRoot
$ApiBin = Join-Path $Repo 'software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api'
$DevBin = Join-Path $Repo 'software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server'
$WebDist = Join-Path $Repo 'software/web/dist'
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
function Say($msg) { Write-Host " $msg" }
function Invoke-Remote([string]$cmd) {
if ($DryRun) { Say "[DryRun] ssh: $cmd"; return }
& plink -batch -pw $Pwd_ "$User@$Host_" $cmd
if ($LASTEXITCODE -ne 0) { throw "远端命令失败(exit $LASTEXITCODE):$cmd" }
}
function Send-File([string]$local, [string]$remote) {
if ($DryRun) { Say "[DryRun] scp: $local -> $remote"; return }
& pscp -batch -pw $Pwd_ $local "$User@$Host_`:$remote"
if ($LASTEXITCODE -ne 0) { throw "上传失败(exit $LASTEXITCODE):$local" }
}
# ---- 1. 构建 ---------------------------------------------------------------
if (-not $SkipBuild) {
Step '1/8 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)'
Say 'api-server ...'
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/api-server/Cargo.toml') }
Say 'device-server ...'
if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/device-server/Cargo.toml') }
if (-not $SkipWeb) {
Say 'web ...'
if (-not $DryRun) { Push-Location (Join-Path $Repo 'software/web'); npm run build; Pop-Location }
}
}
# ---- 2. 校验产物 + 静态检查 ------------------------------------------------
Step '2/8 校验产物与静态检查'
foreach ($f in @($ApiBin, $DevBin)) {
if (-not (Test-Path $f)) { throw "找不到构建产物:$f`n(请先执行 cargo zigbuild,或用 -SkipBuild 前确认已构建)" }
Say ("✅ {0} ({1:N1} MB)" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB))
}
if (-not $SkipWeb) {
if (-not (Test-Path (Join-Path $WebDist 'index.html'))) { throw "找不到前端产物:$WebDist" }
Say ("✅ web/dist ({0} 个文件)" -f (Get-ChildItem $WebDist -Recurse -File).Count)
}
# ---- 3. 服务器备份(可回滚)------------------------------------------------
Step '3/8 服务器备份(保留旧二进制/.env/nginx/单元/前端)'
Invoke-Remote @"
set -e
TS=`$(date +%Y%m%d-%H%M%S)
mkdir -p $Dst/backup-`$TS
cp -a $Dst/api-server/pms-api $Dst/backup-`$TS/ 2>/dev/null || true
cp -a $Dst/device-server/pms-device-server $Dst/backup-`$TS/ 2>/dev/null || true
cp -a $Dst/api-server/.env $Dst/device-server/.env $Dst/backup-`$TS/ 2>/dev/null || true
mkdir -p $Dst/backup-`$TS/web && cp -a $Dst/web/. $Dst/backup-`$TS/web/ 2>/dev/null || true
echo `$TS > /tmp/pms_last_backup
echo " 备份目录: $Dst/backup-`$TS"
"@
# ---- 4. 上传(临时名,避免 ETXTBSY)----------------------------------------
Step '4/8 上传(二进制传 .new,运行中的二进制不可直接覆盖)'
Send-File $ApiBin "$Dst/api-server/pms-api.new"
Send-File $DevBin "$Dst/device-server/pms-device-server.new"
if (-not $SkipWeb) {
$tgz = Join-Path $env:TEMP 'pms-dist.tgz'
if (-not $DryRun) { tar -czf $tgz -C $WebDist . }
Send-File $tgz '/tmp/pms-dist.tgz'
}
# ---- 5. 切换 + 重启 --------------------------------------------------------
Step '5/8 原子替换并重启服务'
Invoke-Remote @"
set -e
chmod +x $Dst/api-server/pms-api.new $Dst/device-server/pms-device-server.new
systemctl stop pms-api pms-device
mv -f $Dst/api-server/pms-api.new $Dst/api-server/pms-api
mv -f $Dst/device-server/pms-device-server.new $Dst/device-server/pms-device-server
$(if (-not $SkipWeb) { "rm -rf $Dst/web/* && tar -xzf /tmp/pms-dist.tgz -C $Dst/web/ && rm -f /tmp/pms-dist.tgz" } else { 'true' })
systemctl daemon-reload
systemctl start pms-device pms-api
sleep 8
nginx -t && systemctl reload nginx
"@
# ---- 6. 验收 ---------------------------------------------------------------
Step '6/8 验收(服务/端口/域名/日志)'
Invoke-Remote @"
echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' '
echo '--- 端口(应为 $ApiPort / $TcpPort)---'; ss -lntp | grep -E ':$ApiPort|:$TcpPort' | awk '{print `" `" `$4}'
echo '--- 域名 ---'
curl -sk -m 8 -o /dev/null -w ' /api/pms/cabinets -> %{http_code}\n' https://$Domain/api/pms/cabinets
curl -sk -m 8 -o /dev/null -w ' /pms/ -> %{http_code}\n' https://$Domain/pms/
echo '--- 启动日志尾部 ---'; journalctl -u pms-api --no-pager -n 4 | tail -4
echo '--- 新进程 ERROR 数(应为 0)---'
MAIN=`$(systemctl show -p MainPID --value pms-api)
journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^/ ERROR 行数: /'
"@
# ---- 7. 消费链路探测(无副作用)-------------------------------------------
# ⚠️ 本步骤的 probe.py 在**本地**执行,Redis 走 SSH 隧道(tools/dev-tunnel.ps1 的
# 127.0.0.1:16379)—— 服务器已不再开放 3306/6379(nginx stream 转发已移除)。
# 连不上时**必须显示为"未验证",绝不伪装成通过**(否则部署会带着未验证的链路报成功)。
$Unverified = @()
$Failures = @()
Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
if (-not $DryRun) {
$probe = @'
import os, json, time
import redis
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
host = os.environ.get("PMS_REDIS_HOST", "127.0.0.1")
port = int(os.environ.get("PMS_REDIS_PORT", "16379"))
try:
r = redis.Redis(host=host, port=port, password="Hbhyg731024@", db=5, socket_timeout=5)
r.ping()
except Exception as e:
# 连不上 ≠ 通过:退出码 3 = "本步骤未验证",与 1 = "消费未工作" 明确区分
print(" ⚠️ 未验证:连不上 Redis %s:%s(%s)" % (host, port, e))
print(" 连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道"
"(或设置 PMS_REDIS_HOST/PMS_REDIS_PORT 指向其它可达地址)")
raise SystemExit(3)
r.lpush("device:reports", json.dumps({"act": "auth_str", "dev_id": "899999999999999", "msg_id": 1}))
time.sleep(3)
left = r.llen("device:reports")
if left == 0:
print(" 队列剩余=0 -> ✅ 消费正常")
raise SystemExit(0)
print(" 队列剩余=%d -> ❌ 消费未工作,请立即回滚" % left)
raise SystemExit(1)
'@
$probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py"
python "$env:TEMP\pms_probe.py"
switch ($LASTEXITCODE) {
0 { }
3 { $Unverified += '上报消费链路探测(Redis 不可达 —— 见上方 ⚠️,通常是本地 SSH 隧道未建立)' }
default {
Say "❌ 上报消费链路探测失败(exit $LASTEXITCODE)—— 请立即回滚(见上方输出)"
# ⚠️ 这里必须**记入失败清单**:此前只打印 ❌ 就继续往下走,脚本最终以 0 退出,
# 于是"消费链路已经断了"会被 CI/人当成"部署成功"(仓库第 7/8 条假绿)。
$Failures += "上报消费链路探测失败(exit $LASTEXITCODE)—— 消费已停摆,需回滚"
}
}
}
# ---- 8. 回滚点保留策略(只保留最近 N 次 backup-YYYYMMDD-HHMMSS)--------------
# 为什么放在最后:本步骤在**验收通过之后**才执行 —— 中途任何一步 throw 都会在这里
# 之前退出,本次及历史的回滚点全部保留(部署失败时最需要回滚点)。
#
# ⚠️ 匹配规则必须**严格**:只认「名字完全等于 backup-8位数字-6位数字」的**目录**。
# /data/pms 下还存在 `backup-20260920-004848-api.env`(文件名带后缀)这类条目,
# 通配 backup-* 会把它一起卷进来 —— 那是**数据/配置备份,不是回滚点,绝不能删**。
# 因此这里用 glob 字符类 + `-d` 判定 + 删除前 `case` 复核,三重限定。
Step "8/8 回滚点保留策略(只保留最近 $KeepBackups 次 backup-YYYYMMDD-HHMMSS)"
Invoke-Remote @"
set -e
shopt -s nullglob
KEEP=$KeepBackups
# glob 字符类只匹配严格的 backup-YYYYMMDD-HHMMSS;nullglob 让无匹配时展开为空数组。
# glob 结果按字典序排列,对该命名格式而言等价于时间戳升序(最早的在前)。
rollbacks=()
for p in $Dst/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "`$p" ]; then
rollbacks+=( "`$p" )
fi
done
total=`${#rollbacks[@]}
echo " 当前回滚点 `$total 个(deploy 会为每次部署新建一份):"
for p in "`${rollbacks[@]}"; do
echo " `$p"
done
if [ "`$total" -gt "`$KEEP" ]; then
drop=`$((total - KEEP))
echo " 将删除最早的 `$drop 个(保留最近 `$KEEP 个):"
i=0
for p in "`${rollbacks[@]}"; do
if [ "`$i" -ge "`$drop" ]; then
break
fi
i=`$((i + 1))
# 复核:名字必须严格符合 backup-YYYYMMDD-HHMMSS,否则拒绝删除(绝不碰其它目录)
case "`$(basename "`$p")" in
backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
echo " 删除 `$p"
rm -rf -- "`$p"
;;
*)
echo " [skip] 名字不符合回滚点模式,拒绝删除: `$p"
;;
esac
done
else
echo " 回滚点数量未超过 `$KEEP,无需清理"
fi
kept=()
for p in $Dst/backup-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]; do
if [ -d "`$p" ]; then
kept+=( "`$p" )
fi
done
echo " 清理后保留的回滚点 `${#kept[@]} 个:"
for p in "`${kept[@]}"; do
echo " `$p"
done
echo " $Dst 占用: `$(du -sh $Dst | cut -f1)"
"@
Write-Host "`n=== 部署完成 ===" -ForegroundColor Green
if ($Unverified.Count -gt 0) {
Write-Host "⚠️ 有步骤【未验证】(不是通过,请勿当作绿灯):" -ForegroundColor Yellow
foreach ($u in $Unverified) { Say " - $u" }
Say ' 处理:先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道,再重跑本步确认。'
if ($StrictVerify) {
Write-Host " ❌ -StrictVerify 已开启:未验证视为失败(退出码 1)" -ForegroundColor Red
}
}
if ($Failures.Count -gt 0) {
Write-Host "❌ 以下验收项**失败**:" -ForegroundColor Red
foreach ($f in $Failures) { Say " - $f" }
}
Say "正式环境:https://$Domain/pms/"
Say "API :$ApiPort TCP :$TcpPort"
Say ''
Say '回滚方法(如需):'
Say " plink -batch -pw <pwd> root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device`""
Say " 备份目录见 $Dst/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 $KeepBackups 次)"
# 退出码:失败优先;-StrictVerify 下"未验证"也算失败。
if ($Failures.Count -gt 0) { exit 1 }
if ($StrictVerify -and $Unverified.Count -gt 0) { exit 1 }
exit 0