站点发布到主机
构建好的 VitePress / VuePress 站点产物,可以一键发布到「主机管理」里任何 nginx_status=INSTALLED_ACTIVE 的目标主机。后端负责 build → SCP → 解压 → reload nginx,全异步,前端 SSE 实时进度 + 日志轮询。
入口
- 「站点管理」列表 → 选站点行 → 点击「发布」按钮 → 选主机 → 启动
- 启动后弹「发布历史」抽屉,左侧历史列表(versionNo 倒序,最新在最上),右侧详情(状态 + 远端目录 + 实时日志框,2s 轮询
getDeployHistory) - 也可在「主机管理」行直接「发布到此主机」
部署流程(7 步)
[1] 启动 → [2] SSH 预检 → [3] 复用/构建 → [4] SCP 上传
→ [5] 解压 webRoot → [6] reload nginx → [7] 清理临时 → SUCCESS1. 启动
- 验证目标主机
nginx_status === INSTALLED_ACTIVE,否则直接抛RC_DEPLOY_NGINX_MISSING提示用户先初始化 - 创建
host_deploy_history一条记录(status=PENDING,version_no=max+1,site_slug=slugify(site.name)) - 用
ReentrantLock按hostUnCode:siteUnCode串行化同一 host+site 的并发部署 new Thread异步跑部署线程,当前线程的SecurityContext.authentication复制到子线程(子线程默认拿不到 SecurityContext,会导致UserDetailUtil.getUserDetailInfo()抛 NPE)
2. SSH 预检
HostService.requireWithSecret(hostUnCode) 取密文 → AES-GCM 解密 → SshConnection.connect() 验证 SSH 通,失败直接报 RC_DEPLOY_REMOTE_FAILED。
3. 复用 / 构建
- 优先复用: 调用方传了
presetBuildTaskUnCode(从「站点管理 → 已构建产物」列表选),直接用这个 build task 的 zip - 否则构建: 调用
SiteBuildService.build(site)重新跑,前端用 SSE 看 build 进度 - 拿产物 zip 的
getUnCode,确认status=SUCCESS,否则RC_DEPLOY_BUILD_FAILED
4. SCP 上传
File archiveFile = resolveArchiveFile(task),产物优先找 .tar.gz,fallback 旧 .zip。
scpClient.upload(archiveFile, /tmp/<slug>-v<n>.tar.gz) 通过 MINA SSHD 的 SCP 协议传到主机 /tmp。
5. 解压 webRoot(简化部署模型)
关键设计: 不维护多版本目录 + current 软链 + think-sites.conf。直接解压到主机 web root(如 /var/www/html)覆盖原内容,nginx 默认 server 的 root 就是这个目录,reload 即可生效。
合并命令(单条 SSH channel 节省 MaxSessions=10 槽位):
test -s '/tmp/<slug>-v<n>.tar.gz' && {
LANG=C.UTF-8 LC_ALL=C.UTF-8
find '/var/www/html' -mindepth 1 -maxdepth 1 -not -name '.*' -exec rm -rf {} +
case '/tmp/<slug>-v<n>.tar.gz' in
*.tar.gz) LANG=C.UTF-8 LC_ALL=C.UTF-8
tar -xzf '/tmp/<slug>-v<n>.tar.gz' -C '/var/www/html'
&& echo DEPLOY_UNZIP_OK ;;
*.zip) LANG=C.UTF-8 LC_ALL=C.UTF-8
unzip -O UTF-8 -q -o '/tmp/<slug>-v<n>.zip' -d '/var/www/html'
&& echo DEPLOY_UNZIP_OK ;;
esac
} || {
echo 'DEPLOY_UNZIP_FAILED: archive missing or empty' >&2; exit 1
}保留隐藏文件: find ... -not -name '.*' 不删 .well-known/acme-challenge 等,Let's Encrypt challenge 不被破坏。
中文文件名: tar -xzf 默认按 UTF-8 解码(平台端 TarArchiveOutputStream 用 new TarArchiveOutputStream(out, "UTF-8") 构造 + setAddPaxHeadersForNonAsciiNames(true) 写 pax header,任何版本 GNU tar 都能正确读);unzip -O UTF-8 是 fallback 兜底(老 .zip 产物可能没用 UTF-8 flag)。
6. reload nginx
nginx -t && systemctl reload nginxnginx -t配置语法检查,失败立即报RC_DEPLOY_REMOTE_FAILEDsystemctl reload nginx平滑重载(不中断服务)
7. 清理临时
rm -f /tmp/<slug>-v<n>.tar.gz 删除主机上的临时文件;history.setRemoteVersionDir(webRoot) + setStatus(SUCCESS) + updateById(),前端轮询看到终态。
日志累积机制
前端「发布历史」抽屉的日志框不是实时 SSE 推的(用 2s 轮询 getDeployHistory),所以后端把关键节点累积到 hist.log 字段:
private void appendLog(
HostDeployHistory hist,
String deployUnCode,
int progress,
String line) {
// 1) SSE 推一条 progress 事件(给前端实时进度)
progressHub.emitProgress(deployUnCode, progress, line);
// 2) 累积到 hist.log(每行带 [HH:mm:ss] 时间戳)
String ts = new SimpleDateFormat("HH:mm:ss").format(new Date());
String old = hist.getLog() == null ? "" : hist.getLog();
hist.setLog(old + "\n[" + ts + "] " + line);
// 3) 写 db(失败非致命)
historyMapper.updateById(hist);
}触发点: 30%(复用 build task) / 70%(开始上传) / 80%(解压+覆盖) / 100%(成功摘要)。
循环里频繁刷新的(构建进度轮询每 1-2s 一次)只走 SSE,不写 db,避免 N+1 updateById。
前端看到的日志框示例:
[14:32:01] 上传构建产物...
[14:32:08] 已上传,正在远端解压 + 切换 current 软链 + 写 think-sites.conf
[14:32:09] ✓ 发布成功,v3 已上线,访问 http://<host>/(直接覆盖 web root /var/www/html)历史版本列表
HostDeployHistoryMapper.selectByFilter(hostUnCode, siteUnCode) 排序:
ORDER BY version_no DESC, id DESCversion_no 是 MAX(version_no) + 1 连续递增,语义就是「发布版本号」,倒序天然「最新→最旧」。
id DESC 兜底同 versionNo(MAX+1 在并发下可能撞,id 仍稳定)。
每行显示:状态 Tag(SUCCESS / FAILED / ROLLED_BACK) + v<n> + 开始时间。
回滚
点击历史行的「回滚到此版本」按钮 → 平台 用历史的 buildTaskUnCode 重新触发一次 deploy(startDeploy 传 finalStatus="ROLLED_BACK")。
- 新 history
version_no = max + 1,status = ROLLED_BACK(让前端能识别这是回滚操作) - 旧 history
log字段追加一行[yyyy-MM-dd HH:mm:ss] 由 <operator> 发起回滚(重新部署 v<n> 的 buildTask),方便审计
回滚 = 重新部署,简单稳定。 不维护 v<n> 目录 + current 软链,所以「回滚到 v3」=「用 v3 的 buildTask 重新构建产物 + 覆盖到 webRoot」。
跨平台中文文件名
构建产物是 VitePress 输出,可能包含中文文件名(如 快速开始.md、guide/入门/index.md)。平台在 Windows 上跑,默认 GBK 编码,Java ZipOutputStream 写入 zip 时如果 entry name 是 GBK char 序列,会被双重编码(GBK char → UTF-8 byte),Linux 主机 unzip 拿到错的字节,显示乱码。
修法: 改用 Apache Commons Compress 打 tar.gz:
new TarArchiveOutputStream(gzipOut, "UTF-8")构造器第四参数显式 UTF-8 编码setAddPaxHeadersForNonAsciiNames(true)对非 ASCII entry 加 pax extended header(POSIX.1-2001 标准),内含path = UTF-8 完整路径- 任何版本 GNU tar 都能正确解码(旧版读 pax,新版直接读 UTF-8 字节)
降级兼容: 如果用户的旧 .zip 产物(没用 UTF-8)还在,deploy 端 shell case 识别后缀,.tar.gz 走 tar,.zip 走 unzip -O UTF-8 -q -o,两种都加 LANG=C.UTF-8 LC_ALL=C.UTF-8 兜底 locale。
常见失败
| 现象 | 原因 | 修法 |
|---|---|---|
nginx: [emerg] "location" directive is not allowed here | think-sites.conf 把 location 直接写在 http 块 | 当前版本已改:不再写 think-sites.conf,直接覆盖 webRoot |
nginx -t 失败 | 主机上有旧 think-sites.conf 残留 | rm /etc/nginx/conf.d/think-sites.conf && systemctl reload nginx |
nginx: [emerg] socket() ... Address already in use | 80 端口被其他进程占用 | ss -tlnp | grep :80 看占用方 |
Authentication failed | 主机密码 / 私钥错误 | 「探测」按钮验证 SSH 通,确认 host 记录的凭据 |
Permission denied (publickey) | sshd_config 禁用密码 / 公钥 | 编辑 /etc/ssh/sshd_config 启用 PasswordAuthentication yes 或配 authorized_keys |
tar: command not found | 主机是极简镜像没装 tar | 改用 apt install tar / yum install tar |
进阶:多 site 部署
单主机多 site 场景下,如果每个 site 单独占用 80 端口会冲突。常见做法:
- 不同主机:每个 site 用独立主机(最简单,推荐)
- 不同端口:主机
nginx.conf加server { listen 8080; server_name _; location / { alias /var/www/site1/html/; } },site 部署到对应子目录,IP:8080 访问 - 同主机同一 webRoot:多个 site 部署到
webRoot/<slug>/,在主机 nginx 加location /<slug>/ { alias webRoot/<slug>/; },访问http://host/<slug>/
当前平台部署模型是「直接覆盖 webRoot」,适合「一主机一 site」场景。 多 site 场景需要管理员手动在主机加 location 转发,平台不自动管理 nginx 配置(简化运维,避免改主机配置导致 reload 失败)。