charging-cabinet/deploy/deploy-prod.ps1
12451 ee92102f3c feat(scope)+fix(db)+feat(security): 数据范围(项目级)生效、库列类型对齐、安全加固、状态位分层展示、能耗实时聚合
- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
  总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
  前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
  GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
  DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
  恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
  新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
  服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
  仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
  每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
2026-09-20 02:44:51 +08:00

222 lines
11 KiB
PowerShell
Raw 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 # 只发后端,不更新前端
# =============================================================================
param(
[switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build
[switch]$SkipWeb, # 跳过前端
[switch]$DryRun # 只演练,不执行
)
$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. 消费链路探测(无副作用)-------------------------------------------
Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
if (-not $DryRun) {
$probe = @'
import redis, json, time
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5)
r.lpush('device:reports', json.dumps({"act":"auth_str","dev_id":"899999999999999","msg_id":1}))
time.sleep(3)
left = r.llen('device:reports')
print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}")
'@
$probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py"
python "$env:TEMP\pms_probe.py"
}
# ---- 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
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 次)"