Skip to content

站点发布到主机

构建好的 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] 清理临时  → SUCCESS

1. 启动

  • 验证目标主机 nginx_status === INSTALLED_ACTIVE,否则直接抛 RC_DEPLOY_NGINX_MISSING 提示用户先初始化
  • 创建 host_deploy_history 一条记录(status=PENDING,version_no=max+1,site_slug=slugify(site.name))
  • ReentrantLockhostUnCode: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 槽位):

bash
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 解码(平台端 TarArchiveOutputStreamnew TarArchiveOutputStream(out, "UTF-8") 构造 + setAddPaxHeadersForNonAsciiNames(true) 写 pax header,任何版本 GNU tar 都能正确读);unzip -O UTF-8 是 fallback 兜底(老 .zip 产物可能没用 UTF-8 flag)。

6. reload nginx

bash
nginx -t && systemctl reload nginx
  • nginx -t 配置语法检查,失败立即报 RC_DEPLOY_REMOTE_FAILED
  • systemctl reload nginx 平滑重载(不中断服务)

7. 清理临时

rm -f /tmp/<slug>-v<n>.tar.gz 删除主机上的临时文件;history.setRemoteVersionDir(webRoot) + setStatus(SUCCESS) + updateById(),前端轮询看到终态。


日志累积机制

前端「发布历史」抽屉的日志框不是实时 SSE 推的(用 2s 轮询 getDeployHistory),所以后端把关键节点累积到 hist.log 字段:

java
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) 排序:

sql
ORDER BY version_no DESC, id DESC

version_noMAX(version_no) + 1 连续递增,语义就是「发布版本号」,倒序天然「最新→最旧」。

id DESC 兜底同 versionNo(MAX+1 在并发下可能撞,id 仍稳定)。

每行显示:状态 Tag(SUCCESS / FAILED / ROLLED_BACK) + v<n> + 开始时间。


回滚

点击历史行的「回滚到此版本」按钮 → 平台 用历史的 buildTaskUnCode 重新触发一次 deploy(startDeployfinalStatus="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 输出,可能包含中文文件名(如 快速开始.mdguide/入门/index.md)。平台在 Windows 上跑,默认 GBK 编码,Java ZipOutputStream 写入 zip 时如果 entry name 是 GBK char 序列,会被双重编码(GBK char → UTF-8 byte),Linux 主机 unzip 拿到错的字节,显示乱码。

修法: 改用 Apache Commons Compresstar.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,.zipunzip -O UTF-8 -q -o,两种都加 LANG=C.UTF-8 LC_ALL=C.UTF-8 兜底 locale。


常见失败

现象原因修法
nginx: [emerg] "location" directive is not allowed herethink-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 use80 端口被其他进程占用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 端口会冲突。常见做法:

  1. 不同主机:每个 site 用独立主机(最简单,推荐)
  2. 不同端口:主机 nginx.confserver { listen 8080; server_name _; location / { alias /var/www/site1/html/; } },site 部署到对应子目录,IP:8080 访问
  3. 同主机同一 webRoot:多个 site 部署到 webRoot/<slug>/,在主机 nginx 加 location /<slug>/ { alias webRoot/<slug>/; },访问 http://host/<slug>/

当前平台部署模型是「直接覆盖 webRoot」,适合「一主机一 site」场景。 多 site 场景需要管理员手动在主机加 location 转发,平台不自动管理 nginx 配置(简化运维,避免改主机配置导致 reload 失败)。

基于 VitePress 构建