大修改

This commit is contained in:
Yeomai committed 2026-09-30 03:38:10 +08:00
1 parent a1cbc9404a
commit e81481623c
47 files changed
+1453 -285

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
# 一键部署脚本
在本机运行一条命令,自动完成「打包源码 → 上传服务器 → 构建 → 重启进程」。
服务器上**不需要安装任何东西**,它复用的就是你已经能用的 SSH。
---
## 一次性配置
### 1. 填配置文件
编辑 `deploy/deploy.config.ps1`,需要改这几项:
| 配置项 | 填什么 | 怎么查 |
|---|---|---|
| `Host` | 服务器 IP 或域名 | — |
| `Port` | SSH 端口 | 宝塔 → 安全 → SSH 端口 |
| `User` | SSH 登录用户 | 一般是 `root` |
| `NodeBin` | Node 的 bin 目录 | 宝塔 → 软件商店 → Node 版本管理器 |
| `Api.RemotePath` | 服务端在服务器上的绝对路径 | 你部署时放的位置 |
| `Admin.RemotePath` | 管理端在服务器上的绝对路径 | 同上 |
| `Api.RestartCmd` | 重启服务端的命令 | 服务器上执行 `pm2 list` 看进程名 |
| `Admin.RestartCmd` | 重启管理端的命令 | 同上 |
| `Api.StartCmd` / `Admin.StartCmd` | 杀掉旧进程后把服务拉起来的命令 | 照抄模板;项目路径或端口变了才需要改 |
| `Api.HealthUrl` / `Admin.HealthUrl` | 启动后的探活地址 | 照抄模板;端口变了才需要改 |
| `NginxCacheDir` | Nginx 反向代理缓存目录 | 宝塔 → 软件商店 → Nginx → 配置修改,搜 `proxy_cache_path` |
| `NginxBin` | Nginx 可执行文件路径 | 一般是 `/www/server/nginx/sbin/nginx` |
### 2. 配置免密登录(强烈建议)
不配的话,每次部署要输两次密码。配好之后全程无提示。
```powershell
# 本机生成密钥(已生成过可跳过,直接第二次命令)
ssh-keygen -t ed25519
# 把公钥传到服务器
type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh root@你的服务器 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
```
### 3. 确认服务器上的 .env 已就位
脚本**不会上传** `.env`(这是故意的,避免覆盖服务器上的密钥配置)。
- 服务端:`yelaymaths_api/.env` 必须已在服务器上配好
- 管理端:`yelaymaths_admin/.env.local` 必须已在服务器上配好
- 注意 `NEXT_PUBLIC_*` 是**构建时**写死的,所以第一次构建前它就得在
---
## 日常使用
```powershell
.\deploy\deploy.ps1 api # 只部署服务端
.\deploy\deploy.ps1 admin # 只部署管理端
.\deploy\deploy.ps1 # 两个都部署
```
输出长这样:
```
=== yelaymaths_api (server API) ===
[1/5] Building locally: pnpm prisma:generate && pnpm build
[2/5] Packing yelaymaths_api
100.8 KB (src, prisma, tools, package.json, ...)
[3/5] Uploading to /www/wwwroot/yelaymaths/yelaymaths_api/api
[4/5] Running on server
__DEPLOY_DONE__
[5/5] Restarted
Started, health check passed: http://127.0.0.1:3002/healthz
Nginx proxy cache cleared and reloaded
All done in 12.0s
```
### 其它参数
| 参数 | 作用 |
|---|---|
| `-DryRun` | 只打包、只显示会上传哪些文件,**不真的传**。第一次用建议先跑这个 |
| `-WithInstall` | 额外安装依赖。**只在改了 `package.json` 时才需要** |
---
## 为什么默认不装依赖
服务器上的全局 pnpm 是 **8.x**,而本机是 10.x,两代锁文件格式不兼容——服务器直接读不了我们的 `pnpm-lock.yaml`。
而服务器上**不能升级 pnpm**(其他服务也在用)。
好在只有「装依赖」这一步需要版本匹配,**「构建」不需要**——`pnpm build` 只是执行 `package.json` 里的脚本,pnpm 8 也能跑。
所以日常部署走「上传源码 + 构建」,用服务器上现成的 `node_modules`,既不碰全局 pnpm,也快得多。
只有 `package.json` 变了才需要装依赖,这时用 `-WithInstall`,它会通过 `npx` 临时拉一个 10.27.0 的 pnpm 来跑,同样不影响全局。
---
## 上传哪些文件
脚本用的是**白名单**,只有列在 `DeployPaths` 里的路径会被打包。所以这些东西**结构上不可能**被传上去:
- `.env` / `.env.local`(服务器上的配置不会被覆盖)
- `node_modules/`(依赖在服务器上装)
- `dist/`、`.next/`(构建产物在服务器上生成)
服务端实际约 100 KB,管理端约 25 KB。
---
## 已知限制
**删除的文件不会同步删除。** 脚本是「覆盖式上传」,如果你在本地删掉某个源码文件,服务器上那份还在。
一般不影响运行(多余的文件不会被执行),但如果你发现改了名字的旧文件仍在作怪,手动上服务器删掉即可。
需要「完全同步」的场景(比如删文件、想要版本回滚),改用 git 推送部署更合适——那是另一个方案,需要时再说。
---
## 部署结束后会清一次 Nginx 缓存
宝塔在 `/www/server/nginx/conf/proxy.conf` 里对**所有**反向代理站点开了 `proxy_cache cache_one`,而 Next.js 给页面发的响应头是 `Cache-Control: s-maxage=31536000`——翻译过来就是「这一页允许缓存一年」。
两边一叠加,后果很隐蔽:新构建传上去、进程也重启了,用户访问到的还是缓存里的旧 HTML;而旧 HTML 引用的分包早被新构建删掉,请求直接 404,页面渲染到一半就断,表现成「某个区块整块消失」,看起来像代码写错了,实际跟代码毫无关系。
所以脚本在每次部署的最后会多做一步:
```bash
find /www/server/nginx/proxy_cache_dir -mindepth 1 -delete # 清空缓存文件
/www/server/nginx/sbin/nginx -s reload # 重载 Nginx
```
服务端和管理端两条部署线都带这一步。缓存只影响响应速度,清空后第一次访问会回源到新构建,不会丢数据。
把 `NginxCacheDir` 留空(`''`)就会跳过这一步。
---
## 部署结束会把服务自动拉起来
杀掉旧进程不等于新进程会跑起来。之前这里是「只杀不拉」,所以每次部署完都要去宝塔面板点一下「启动」,忘了点线上就是 502。
现在每个应用多两项配置:
- `RestartCmd`:杀掉旧进程
- `StartCmd`:把新进程拉起来(`setsid` + `nohup` + 重定向,SSH 会话断了也不受影响)
- `HealthUrl`:拉起后探活,30 秒内没响应就让部署直接判失败
所以正常输出结尾会是:
```
[5/5] Restarted
Started, health check passed: http://127.0.0.1:3001/
Nginx proxy cache cleared and reloaded
```
进程日志写在服务器上:`/www/wwwlogs/yelaymaths-api.out.log`、`/www/wwwlogs/yelaymaths-admin.out.log`。
有一点要知道:进程是脚本拉起来的,不是宝塔面板拉起来的,所以**面板里那个项目的状态可能显示成「未启动」**,但服务本身是正常在跑的(脚本每次都会探活确认)。如果想让面板重新接管,在面板点一次「重启」即可,之后照常用面板管。
只有 `RestartCmd` 不想自己启动(比如服务由 PM2 或 systemd 托管),把 `StartCmd` 留空(`''`)就会退回「只杀不拉」的老行为。
---
## 出问题怎么排查
| 现象 | 原因 | 处理 |
|---|---|---|
| `deploy.config.ps1 not found` | 没建配置文件 | 复制 `deploy.config.example.ps1` |
| 一直提示输密码 | 没配免密登录 | 见上面第 2 步 |
| `pnpm: command not found` | `NodeBin` 填错 | 在服务器上执行 `which pnpm` 看真实路径 |
| `pm2: command not found` | PM2 不在 Node 的 bin 目录 | 服务器上执行 `which pm2`,把目录也加到 `NodeBin` |
| 构建成功但页面没变 | 进程没重启,或 Nginx 缓存没清 | 检查 `RestartCmd`、`NginxCacheDir` 填对没有 |
| 页面某个区块整块消失 | 缓存里的旧 HTML 引用了已 404 的旧分包 | 手动清一次 `NginxCacheDir` 并 reload |
| 部署报 `service did not come up` | 新进程没起来 | 看 `/www/wwwlogs/yelaymaths-*.out.log` 的报错,多半是端口被占或依赖没装 |
| `Permission denied` | 目标目录权限不对 | 服务器上 `chown -R root:root 目标目录` |
+72
View File
@@ -0,0 +1,72 @@
# Copy this file to deploy.config.ps1 and fill in your own values.
# deploy.config.ps1 is gitignored, so your server address stays out of the repo.
$Config = @{
# ---- SSH ----
Host = '1.2.3.4' # server IP or domain
Port = 22
User = 'root'
# Node bin directory on the server (prepended to PATH during the remote build).
# Find it in BT Panel -> Node version manager, e.g. /www/server/nodejs/v22.14.0/bin
NodeBin = '/www/server/nodejs/v22/bin'
# ---- Nginx proxy cache ----
# BT Panel enables `proxy_cache cache_one` globally in
# /www/server/nginx/conf/proxy.conf, while Next.js answers with
# `Cache-Control: s-maxage=31536000` — i.e. "this page may be cached for a year".
# Nginx obeys, so a fresh build is never served; the cached HTML references chunks
# that the new build already removed (404), and the page breaks halfway through.
# deploy.ps1 clears this directory and reloads Nginx after every deploy.
# Leave NginxCacheDir empty ('') to skip that step.
NginxCacheDir = '/www/server/nginx/proxy_cache_dir'
NginxBin = '/www/server/nginx/sbin/nginx'
# ---- yelaymaths_api ----
Api = @{
Key = 'api'
Label = 'yelaymaths_api (server API)'
LocalPath = 'yelaymaths_api'
RemotePath = '/www/wwwroot/yelaymaths/yelaymaths_api'
DeployPaths = @(
'src', 'prisma', 'tools',
'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
'tsconfig.json', 'tsconfig.build.json', 'nest-cli.json',
'.env.example'
)
InstallCmd = 'pnpm install --frozen-lockfile'
BuildCmd = 'pnpm build'
# Restart command. Run `pm2 list` on the server to find the process name.
# Leave empty ('') if you restart the process some other way.
RestartCmd = 'pm2 restart yelaymaths-api'
# Start command. Used when killing the old process is not enough
# (BT Panel does not bring it back automatically). setsid + nohup keeps
# the process alive after the SSH session ends. Leave empty to skip.
# `node` resolves through the NodeBin PATH exported by the remote script.
StartCmd = 'setsid nohup node dist/main.js >> /www/wwwlogs/yelaymaths-api.out.log 2>&1 < /dev/null &'
# Probe after start. The deploy fails if this URL does not answer in 30s.
HealthUrl = 'http://127.0.0.1:3002/healthz'
}
# ---- yelaymaths_admin ----
# NOTE: NEXT_PUBLIC_* values are inlined at build time, so .env.local must already
# exist on the server before the first deploy. It is never uploaded (it is gitignored
# and not in the whitelist).
Admin = @{
Key = 'admin'
Label = 'yelaymaths_admin (admin console)'
LocalPath = 'yelaymaths_admin'
RemotePath = '/www/wwwroot/yelaymaths/yelaymaths_admin'
DeployPaths = @(
'src',
'package.json', 'pnpm-lock.yaml',
'tsconfig.json', 'next.config.ts', 'postcss.config.mjs'
)
InstallCmd = 'pnpm install --frozen-lockfile'
BuildCmd = 'pnpm build'
RestartCmd = 'pm2 restart yelaymaths-admin'
StartCmd = 'setsid nohup node node_modules/next/dist/bin/next start -p 3001 >> /www/wwwlogs/yelaymaths-admin.out.log 2>&1 < /dev/null &'
HealthUrl = 'http://127.0.0.1:3001/'
}
}
+209
View File
@@ -0,0 +1,209 @@
#Requires -Version 5.1
<#
One-command deploy for yelaymaths_api / yelaymaths_admin.
Packs source files locally, uploads over SSH, builds on the server and restarts the process.
No service is installed on the server; it reuses the SSH access you already have.
Usage:
.\deploy\deploy.ps1 api
.\deploy\deploy.ps1 admin
.\deploy\deploy.ps1 all
.\deploy\deploy.ps1 api -DryRun # pack only, show what would be uploaded
.\deploy\deploy.ps1 api -WithInstall # also install dependencies (only after package.json changes)
Files are packed from an explicit whitelist, so .env / node_modules / dist / .next
are never uploaded and your server-side .env stays untouched.
#>
[CmdletBinding()]
param(
[ValidateSet('api', 'admin', 'all')]
[string]$Target = 'all',
[switch]$DryRun,
[switch]$WithInstall
)
$ErrorActionPreference = 'Stop'
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$configPath = Join-Path $scriptDir 'deploy.config.ps1'
if (-not (Test-Path $configPath)) {
Write-Host 'ERROR: deploy.config.ps1 not found.' -ForegroundColor Red
Write-Host 'Copy deploy.config.example.ps1 to deploy.config.ps1 and fill in your server details.'
exit 1
}
. $configPath
foreach ($exe in @('ssh', 'scp', 'tar')) {
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) {
throw "Missing required command: $exe (Windows 10+ ships OpenSSH and tar)"
}
}
$sshTarget = '{0}@{1}' -f $Config.User, $Config.Host
function Invoke-AppDeploy {
param([hashtable]$App)
$local = Join-Path $repoRoot $App.LocalPath
if (-not (Test-Path $local)) { throw "Local path not found: $local" }
# Whitelist: only include paths that exist. Everything else (secrets, build output)
# is structurally impossible to upload.
$items = @()
foreach ($p in $App.DeployPaths) {
if (Test-Path (Join-Path $local $p)) { $items += $p }
}
if ($items.Count -eq 0) { throw "Nothing to deploy for $($App.Key)" }
$excludes = @()
foreach ($p in ($App.ExcludePaths ?? @())) { $excludes += "--exclude=$p" }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$tarName = 'yelay-{0}-{1}.tar.gz' -f $App.Key, $stamp
$tarFile = Join-Path $env:TEMP $tarName
$remoteTar = '/tmp/' + $tarName
Write-Host ''
Write-Host ('=== {0} ===' -f $App.Label) -ForegroundColor Cyan
# ---- 本地构建:把耗时的编译放在本机,服务器只接收产物 ----
if ($App.LocalBuildCmd) {
Write-Host ('[1/5] Building locally: {0}' -f $App.LocalBuildCmd)
# 国内拉取 Prisma 引擎很慢,指向镜像
$env:PRISMA_ENGINES_MIRROR = 'https://registry.npmmirror.com/-/binary/prisma'
Push-Location $local
try {
Invoke-Expression $App.LocalBuildCmd
if ($LASTEXITCODE -ne 0) { throw "Local build failed: $($App.LocalBuildCmd)" }
} finally {
Pop-Location
}
} else {
Write-Host '[1/5] Skipping local build'
}
Write-Host ('[2/5] Packing {0}' -f $App.LocalPath)
Push-Location $local
try {
& tar -czf $tarFile @excludes @items
if ($LASTEXITCODE -ne 0) { throw 'tar failed' }
} finally {
Pop-Location
}
$sizeKb = [math]::Round((Get-Item $tarFile).Length / 1KB, 1)
Write-Host (' {0} KB ({1})' -f $sizeKb, ($items -join ', '))
if ($DryRun) {
Write-Host ' DryRun: nothing uploaded.'
Remove-Item $tarFile -Force
return
}
Write-Host ('[3/5] Uploading to {0}' -f $App.RemotePath)
& scp -P $Config.Port -q $tarFile ('{0}:{1}' -f $sshTarget, $remoteTar)
if ($LASTEXITCODE -ne 0) { throw 'scp failed' }
$install = ''
if ($WithInstall) { $install = $App.InstallCmd }
# 部署前先在服务器上删掉指定的旧目录。
#
# 为什么必须要这一步:Next.js 会把预渲染好的页面缓存在 .next/cache 里,
# 命中时直接返回缓存(响应头 x-nextjs-cache: HIT),不再读磁盘上的新文件。
# 而 .next/cache 体积很大(约 73MB),上传时会排除掉,于是它永远是旧的 ——
# 表现就是「代码传上去了、也重启了,页面却一直没变」。这里先删掉,让它重建。
$cleanCmd = ''
foreach ($p in ($App.CleanPaths ?? @())) {
$cleanCmd += "rm -rf '$($App.RemotePath)/$p'`n"
}
# 部署后清空 Nginx 反代缓存。
#
# 为什么必须清:宝塔在 /www/server/nginx/conf/proxy.conf 里全局开了
# proxy_cache cache_one,而 Next.js 给静态页面发的响应头是
# Cache-Control: s-maxage=31536000,等于告诉 Nginx「这一页可以存一年」。
# 于是新构建传上去、进程也重启了,访问到的还是缓存的旧 HTML;旧 HTML
# 引用的分包早被新构建删掉,一请求就是 404,页面渲染到一半就断,
# 表现就是「怎么改都不生效」,甚至某个区块整块消失。
# 清掉缓存目录再 reload,让它回源读新构建。
$nginxCacheCmd = ''
if ($Config.NginxCacheDir) {
$nginxCacheCmd += "find '$($Config.NginxCacheDir)' -mindepth 1 -delete 2>/dev/null || true`n"
if ($Config.NginxBin) {
$nginxCacheCmd += "'$($Config.NginxBin)' -s reload 2>/dev/null || true`n"
}
}
# 启动命令。
#
# RestartCmd 只负责杀掉旧进程,宝塔不会自动把新进程拉起来,
# 所以这里自己拉。先 sleep 2 是为了让刚被杀掉的进程彻底放开端口,
# 否则新进程会 EADDRINUSE 起不来。
$startCmd = ''
if ($App.StartCmd) {
$startCmd = "sleep 2`n$($App.StartCmd)`n"
}
# 启动后探活:确认端口真的在响应,而不是「命令敲下去就当成功」。
# 30 秒还没起来就让部署直接失败,免得又出现「显示部署成功、线上却是 502」。
$healthCmd = ''
if ($App.HealthUrl) {
$healthCmd = "for i in {1..30}; do curl -sf -o /dev/null '$($App.HealthUrl)' && break; sleep 1; done`n"
$healthCmd += "curl -sf -o /dev/null '$($App.HealthUrl)' || { echo 'ERROR: service did not come up: $($App.HealthUrl)'; exit 1; }`n"
}
# Remote script is intentionally ASCII-only to avoid encoding issues over SSH.
$remoteScript = @"
set -e
export PATH=$($Config.NodeBin):`$PATH
mkdir -p '$($App.RemotePath)'
cd '$($App.RemotePath)'
$cleanCmd
tar -xzf '$remoteTar'
rm -f '$remoteTar'
$install
$($App.BuildCmd)
$($App.RestartCmd)
$startCmd
$healthCmd
$nginxCacheCmd
echo '__DEPLOY_DONE__'
"@
Write-Host '[4/5] Running on server'
$remoteScript | & ssh -p $Config.Port $sshTarget 'bash -s'
if ($LASTEXITCODE -ne 0) { throw 'remote build or restart failed' }
Remove-Item $tarFile -Force -ErrorAction SilentlyContinue
if ($App.RestartCmd) {
Write-Host '[5/5] Restarted' -ForegroundColor Green
} else {
Write-Host '[5/5] Done' -ForegroundColor Green
Write-Host (' NOTE: restart "{0}" from the BT panel now.' -f $App.Label) -ForegroundColor Yellow
}
if ($App.StartCmd) {
Write-Host (' Started, health check passed: {0}' -f $App.HealthUrl) -ForegroundColor Green
}
if ($nginxCacheCmd) {
Write-Host ' Nginx proxy cache cleared and reloaded' -ForegroundColor Green
}
}
$apps = switch ($Target) {
'api' { @($Config.Api) }
'admin' { @($Config.Admin) }
'all' { @($Config.Api, $Config.Admin) }
}
$started = Get-Date
foreach ($app in $apps) { Invoke-AppDeploy -App $app }
if (-not $DryRun) {
Write-Host ''
Write-Host ('All done in {0:N1}s' -f ((Get-Date) - $started).TotalSeconds) -ForegroundColor Green
}