merge: 生产管理跳板通道

This commit is contained in:
WuYongJie 2026-07-29 11:44:12 +08:00
commit bdc8cdab8c
4 changed files with 206 additions and 14 deletions

View File

@ -185,7 +185,7 @@ git push --force-with-lease origin <你的个人任务分支>
生产发布必须由用户明确授权。
发布代码默认来自 `origin/main`,部署入口以仓库根目录下的 `scripts/deploy-yydd-prod-from-git.ps1` 为准,底层打包脚本是 `scripts/create-yydd-prod-deploy.ps1`。
发布代码默认来自 `origin/main`,部署首选入口以仓库根目录下的 `scripts/deploy-yydd-prod-via-jump.ps1` 为准;它通过新管理跳板链路调用 `scripts/deploy-yydd-prod-from-git.ps1`,底层打包脚本是 `scripts/create-yydd-prod-deploy.ps1`。
部署前至少核对:

View File

@ -169,7 +169,7 @@ SPU、SKU、barcode、批次必须分清:
## 8. 生产和部署协作
生产发布必须由用户明确授权。
发布代码默认以 `origin/main` 为来源,常用入口是仓库根目录下的 `scripts/deploy-yydd-prod-from-git.ps1`,底层打包脚本是 `scripts/create-yydd-prod-deploy.ps1`。
发布代码默认以 `origin/main` 为来源,首选入口是仓库根目录下的 `scripts/deploy-yydd-prod-via-jump.ps1`;它通过新管理跳板链路调用 `scripts/deploy-yydd-prod-from-git.ps1`,底层打包脚本是 `scripts/create-yydd-prod-deploy.ps1`。
部署前至少核对:

View File

@ -6,19 +6,31 @@
## 权威生产目标
- SSH 入口:`root@ltai5tdb2ogyj59fquyedh.yydd.ai`
- SSH 端口:`60022`
- 管理跳板机:`root@218.93.9.38:22`
- 生产 SSH:通过跳板机本地回环 `127.0.0.1:26022` 转发,不直接暴露公网
- 生产部署目录:`/opt/furniture-3d-system-prod`
- PM2 进程名:`furniture-3d-system-prod`
- 应用本机端口:`3001`
- 默认部署来源:`origin/main`
旧 `ltai5tdb2ogyj59fquyedh.yydd.ai:60022` 入口已于 2026-07-29 停用。它曾因公网扫描占满 SSH `MaxStartups`,导致部署连接在认证前被拒绝。不要重新启用旧代理或用提高连接上限掩盖该问题。
## 本仓库部署脚本
- 首选入口:仓库根目录 `scripts/deploy-yydd-prod-from-git.ps1`
- 首选入口:仓库根目录 `scripts/deploy-yydd-prod-via-jump.ps1`
- 跳板下层入口:仓库根目录 `scripts/deploy-yydd-prod-from-git.ps1`
- 底层打包脚本:仓库根目录 `scripts/create-yydd-prod-deploy.ps1`
执行部署或生产核查前,优先读取上面两个脚本的当前默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。部署入口默认从 `origin/main` 取代码,发布前必须核对远端 `refs/heads/main` 和本地 `origin/main` 是否一致。
首选脚本负责建立临时本地转发,调用原 Git 增量部署脚本,并在结束后关闭本地转发。两个服务器密码都必须从本地安全材料传入参数,不得写进脚本、Git 或文档。
```powershell
.\scripts\deploy-yydd-prod-via-jump.ps1 `
-Ref origin/main `
-JumpPassword $jumpPassword `
-ServerPassword $productionPassword
```
执行部署或生产核查前,优先读取上述脚本的当前默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。部署入口默认从 `origin/main` 取代码,发布前必须核对远端 `refs/heads/main` 和本地 `origin/main` 是否一致。
底层发布流程会执行依赖安装、`prisma generate`、迁移、构建、PM2 重启和健康检查。因此生产发布前必须先确认迁移目录、迁移历史和主数据 strict 巡检口径没有分叉。
@ -45,14 +57,15 @@
生产相关动作开始前,至少确认:
1. 目标主机是 `ltai5tdb2ogyj59fquyedh.yydd.ai:60022`。
2. 远端存在 `/opt/furniture-3d-system-prod`。
3. PM2 中存在 `furniture-3d-system-prod`。
4. 应用监听端口是 `3001`。
5. 部署来源是 `origin/main`,且远端 `main` 和本地 `origin/main` 一致。
6. 生产只读地基核查优先使用 `npm run db:foundation:audit:production-readonly`。
7. 生产只读 manifest 回传后,使用 `npm run db:foundation:verify-production-readonly -- --manifest <manifest.json>` 验收,不能人工挑几个字段就宣称生产通过。
8. 不把密码、密钥或连接串写入 Git、文档或长期记忆。
1. 跳板机是 `218.93.9.38:22`,生产管理代理只允许跳板机本机访问 `127.0.0.1:26022`。
2. 跳板机的 `frps-furniture-prod.service` 和 `furniture-mgmt-port-guard.service` 均为 `active`。
3. 生产机的 `frpc-furniture-mgmt.service` 为 `active`。
4. 远端存在 `/opt/furniture-3d-system-prod`。
5. PM2 中存在 `furniture-3d-system-prod`,应用监听端口是 `3001`。
6. 部署来源是 `origin/main`,且远端 `main` 和本地 `origin/main` 一致。
7. 生产只读地基核查优先使用 `npm run db:foundation:audit:production-readonly`。
8. 生产只读 manifest 回传后,使用 `npm run db:foundation:verify-production-readonly -- --manifest <manifest.json>` 验收,不能人工挑几个字段就宣称生产通过。
9. 不把密码、密钥或连接串写入 Git、文档或长期记忆。
## 部署后核查

View File

@ -0,0 +1,179 @@
param(
[string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot),
[string]$ProjectSubdir = "furniture-3d-system",
[string]$Ref = "origin/main",
[string]$RemoteBase = "/opt/furniture-3d-system-prod",
[string]$RemoteRepoDir = "",
[string]$RemoteGitUrl = "",
[string]$JumpHost = "218.93.9.38",
[int]$JumpPort = 22,
[string]$JumpUser = "root",
[string]$JumpPassword = "",
[string]$JumpHostKeyFingerprint = "SHA256:UwFngDHnyUFNAszlwbtVzBc4NdZMwCCfOz+8yoJvgKs",
[int]$RemoteManagementPort = 26022,
[int]$LocalForwardPort = 0,
[string]$ServerUser = "root",
[string]$ServerPassword = "",
[string]$HostKeyFingerprint = "SHA256:JPHcomdjuUZOTM7sNdBYg0/xRkQSXzbXe7FEfNtIFAU",
[int]$AppPort = 3001,
[string]$Pm2Name = "furniture-3d-system-prod",
[int]$KeepReleases = 5,
[switch]$ForceArchiveFallback,
[switch]$KeepArtifacts
)
$ErrorActionPreference = "Stop"
$deployScript = Join-Path $RepoRoot "scripts\deploy-yydd-prod-from-git.ps1"
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
if (-not (Test-Path -LiteralPath $deployScript)) {
throw "Missing deployment script: $deployScript"
}
if (-not (Test-Path -LiteralPath $plinkPath)) {
throw "Missing plink.exe: $plinkPath"
}
if ([string]::IsNullOrWhiteSpace($JumpPassword)) {
throw "JumpPassword is required. Read it from local secure material; do not store it in Git."
}
if ([string]::IsNullOrWhiteSpace($ServerPassword)) {
throw "ServerPassword is required. Read it from local secure material; do not store it in Git."
}
function Get-FreeLoopbackPort {
$listener = [System.Net.Sockets.TcpListener]::new(
[System.Net.IPAddress]::Loopback,
0
)
try {
$listener.Start()
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
}
finally {
$listener.Stop()
}
}
function Wait-LoopbackPort {
param(
[int]$Port,
[System.Diagnostics.Process]$Process,
[int]$TimeoutSeconds = 12
)
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
if ($Process.HasExited) {
$errorOutput = $Process.StandardError.ReadToEnd().Trim()
if ([string]::IsNullOrWhiteSpace($errorOutput)) {
$errorOutput = "exit code $($Process.ExitCode)"
}
throw "Management tunnel exited before it became ready: $errorOutput"
}
$client = [System.Net.Sockets.TcpClient]::new()
try {
$pending = $client.BeginConnect("127.0.0.1", $Port, $null, $null)
if ($pending.AsyncWaitHandle.WaitOne(500, $false)) {
$client.EndConnect($pending)
return
}
}
catch {
# The listener is not ready yet.
}
finally {
$client.Dispose()
}
Start-Sleep -Milliseconds 250
}
throw "Timed out waiting for the local management tunnel on port $Port."
}
if ($LocalForwardPort -le 0) {
$LocalForwardPort = Get-FreeLoopbackPort
}
$existingListener = Get-NetTCPConnection `
-State Listen `
-LocalPort $LocalForwardPort `
-ErrorAction SilentlyContinue
if ($existingListener) {
throw "Local forwarding port $LocalForwardPort is already in use."
}
$processInfo = [System.Diagnostics.ProcessStartInfo]::new()
$processInfo.FileName = $plinkPath
$processInfo.UseShellExecute = $false
$processInfo.CreateNoWindow = $true
$processInfo.RedirectStandardError = $true
$processInfo.RedirectStandardOutput = $true
$forwardArguments = @(
"-batch",
"-N",
"-ssh",
"-P",
[string]$JumpPort,
"-l",
$JumpUser,
"-pw",
$JumpPassword,
"-hostkey",
$JumpHostKeyFingerprint,
"-L",
("127.0.0.1:{0}:127.0.0.1:{1}" -f $LocalForwardPort, $RemoteManagementPort),
$JumpHost
)
foreach ($argument in $forwardArguments) {
[void]$processInfo.ArgumentList.Add($argument)
}
$tunnelProcess = $null
try {
$tunnelProcess = [System.Diagnostics.Process]::Start($processInfo)
Wait-LoopbackPort -Port $LocalForwardPort -Process $tunnelProcess
$deployArguments = @{
RepoRoot = $RepoRoot
ProjectSubdir = $ProjectSubdir
Ref = $Ref
RemoteBase = $RemoteBase
ServerHost = "127.0.0.1"
SshPort = $LocalForwardPort
ServerUser = $ServerUser
ServerPassword = $ServerPassword
HostKeyFingerprint = $HostKeyFingerprint
AppPort = $AppPort
Pm2Name = $Pm2Name
KeepReleases = $KeepReleases
}
if (-not [string]::IsNullOrWhiteSpace($RemoteRepoDir)) {
$deployArguments.RemoteRepoDir = $RemoteRepoDir
}
if (-not [string]::IsNullOrWhiteSpace($RemoteGitUrl)) {
$deployArguments.RemoteGitUrl = $RemoteGitUrl
}
if ($ForceArchiveFallback) {
$deployArguments.ForceArchiveFallback = $true
}
if ($KeepArtifacts) {
$deployArguments.KeepArtifacts = $true
}
& $deployScript @deployArguments
}
finally {
if ($null -ne $tunnelProcess) {
if (-not $tunnelProcess.HasExited) {
$tunnelProcess.Kill()
$tunnelProcess.WaitForExit()
}
$tunnelProcess.Dispose()
}
}