2026-08-01

飞书下线旧 webhook 机器人后,本文已同步更新至 open-api 方案。

由于近期网站速度不稳定,只好再把博客解析改到服务器上。博客源代码托管在 GitHub 上,每次更新的流程如下:

  1. 提交源码到私有仓库
  2. 私有仓库执行 Action
    2.1. 执行 hexo g 生成静态文件
    2.2. 部署到 xaoxuu.github.io 公开仓库
    2.3. 触发服务器同步拉取 xaoxuu.github.io 更新 本文内容
  3. Vercel/Netlify/Cloudflare等平台同步部署(基于 xaoxuu.github.io 静态内容)

2.3 曾经是 rsync 同步到服务器、也曾是同步到 oss,但前者配置复杂,这次我已经忘了怎么操作了;后者用了几天感觉同步速度极慢。
rsync 方案: 服务器问题记录

整理思路

全自动化部署,当然要避免手动 pull,所以我希望实现:

  1. 每次推送后,GitHub Action 自动触发 Webhook
  2. Webhook 服务运行在服务器上,收到请求后拉取仓库最新代码
  3. 同时,通过飞书发送部署成功、失败的通知

技术栈选型

模块工具
Web 服务Node.js + Express
后台守护pm2
通知方式飞书开放平台自建应用(机器人服务)

飞书开放平台这套 open-api 我也比较熟,用来发部署通知足够稳定,而且可控性更强。

网站根目录下关联 GitHub 仓库

以下是我的配置,可以根据实际情况修改为你自己的:

  • 我的网站目录是:/opt/1panel/www/sites/xaoxuu.com/index
  • 要同步的 GitHub 静态文件仓库是:https://github.com/xaoxuu/xaoxuu.github.io

执行前确保没有重要数据

cd /opt/1panel/www/sites/xaoxuu.com
rm -rf index
git clone https://github.com/xaoxuu/xaoxuu.github.io.git index

忽略 .git

我用的 1panel,在网站的【配置文件】中的 server 块中增加以下设置:

location ~ /\.git {
deny all;
}

效果类似于:

server {
listen 80 ;
listen 443 ssl ;
...
root /www/sites/xaoxuu.com/index;
location ~ /\.git {
deny all;
}
...
}

创建两个 js 文件

我放在了网站项目目录下,放在别处也可以,但注意不要放到网站根目录下,也就是 git clone 的地方,否则会被覆盖掉。

/opt/1panel/www/sites/xaoxuu.com
index/ <- 网站根目录,也就是 git 仓库
log/
proxy/
ssl/
webhook.js <- 创建的
ecosystem.config.js <- 创建的

ecosystem.config.js 内容

module.exports = {
apps: [
{
name: "webhook",
script: "./webhook.js",
env: {
PORT: 2333,
SYNC_TOKEN: "xxx",
FEISHU_APP_ID: "cli_xxx",
FEISHU_APP_SECRET: "xxx",
FEISHU_USER_OPEN_ID: "ou_xxx"
}
}
]
};

SYNC_TOKEN 就是随便创建一串字符串,粘贴到 GitHub Secrets 中,简单防刷保护,不需要的话,下文的 SYNC_TOKEN 部分删掉即可。

这里的 FEISHU_APP_SECRET / SYNC_TOKEN 都属于敏感信息,不要提交到仓库,也不要在日志里明文输出。

webhook.js 内容

监听本地 2333 端口的 Webhook 服务如下:

const express = require('express');
const { exec } = require('child_process');
const https = require('https');
const app = express();

const PORT = process.env.PORT;
const TOKEN = process.env.SYNC_TOKEN || 'your_secret_token';
const SITE_DIR = '/opt/1panel/www/sites/xaoxuu.com/index';

const FEISHU_APP_ID = process.env.FEISHU_APP_ID;
const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET;
const FEISHU_USER_OPEN_ID = process.env.FEISHU_USER_OPEN_ID;

let cachedToken = null;
let tokenExpiresAt = 0;

function getFeishuToken() {
return new Promise((resolve, reject) => {
if (cachedToken && Date.now() < tokenExpiresAt) {
return resolve(cachedToken);
}

if (!FEISHU_APP_ID || !FEISHU_APP_SECRET) {
return reject(new Error('FEISHU_APP_ID/FEISHU_APP_SECRET 未配置'));
}

const body = JSON.stringify({ app_id: FEISHU_APP_ID, app_secret: FEISHU_APP_SECRET });
const req = https.request({
hostname: 'open.feishu.cn',
path: '/open-apis/auth/v3/tenant_access_token/internal',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
}, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
let json;
try {
json = JSON.parse(data);
} catch (e) {
return reject(new Error(`获取 token 失败: 响应非 JSON (status=${res.statusCode}) body=${String(data).slice(0, 300)}`));
}

if (json.code === 0 && json.tenant_access_token) {
cachedToken = json.tenant_access_token;
tokenExpiresAt = Date.now() + (json.expire - 60) * 1000;
return resolve(cachedToken);
}

return reject(new Error(`获取 token 失败: ${JSON.stringify(json)}`));
});
});

req.on('error', reject);
req.write(body);
req.end();
});
}

async function notifyFeishu(payload) {
if (!FEISHU_USER_OPEN_ID) {
console.error('❌ 飞书通知失败: FEISHU_USER_OPEN_ID 未配置');
return;
}

try {
const token = await getFeishuToken();
const body = JSON.stringify({
receive_id: FEISHU_USER_OPEN_ID,
msg_type: 'interactive',
content: JSON.stringify({
config: { wide_screen_mode: true },
header: {
template: 'blue',
title: {
tag: 'plain_text',
content: String(payload.title || ''),
},
},
elements: [
{
tag: 'markdown',
content: String(payload.message || ''),
},
{
tag: 'action',
actions: [
{
tag: 'button',
text: {
tag: 'plain_text',
content: '查看详情',
},
type: 'primary',
url: 'https://xaoxuu.com',
},
],
},
],
})
});

const req = https.request({
hostname: 'open.feishu.cn',
path: '/open-apis/im/v1/messages?receive_id_type=open_id',
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
}, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => console.log('✅ 飞书返回:', data));
});

req.on('error', (e) => console.error('❌ 飞书通知失败:', e));
req.write(body);
req.end();
} catch (e) {
console.error('❌ 飞书通知失败:', e);
}
}

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post('/sync', (req, res) => {
const token = req.query.token || req.headers['x-sync-token'];

console.log('📬 headers:\n', JSON.stringify(req.headers, null, 2));
console.log('📥 body:\n', JSON.stringify(req.body, null, 2));

if (token !== TOKEN) {
return res.status(403).send('Forbidden: Invalid Token');
}

const cmd = `cd ${SITE_DIR} && git -c "safe.directory=${SITE_DIR}" fetch origin gh-pages && git -c "safe.directory=${SITE_DIR}" reset --hard origin/gh-pages`;
exec(cmd, (err, stdout, stderr) => {
if (err) {
console.error(`❌ 拉取失败: ${stderr}`);
try {
notifyFeishu({
title: `❌ 部署失败!`,
message: `错误信息:\n${stderr}`,
});
} catch (e) {
console.error('🚨 飞书失败通知异常:', e);
}
return res.status(500).send('Sync failed');
}

console.log(`✅ 同步成功:\n${stdout}`);

try {
notifyFeishu({
title: `✅ 网站部署成功啦!🎉`,
message: `时间:${new Date().toLocaleString()}`,
});
} catch (e) {
console.error('🚨 飞书通知异常:', e);
}

res.send('Sync success');
});
});

app.post('/feishu/events', (req, res) => {
let event = req.body;
if (typeof event === 'string') {
try {
event = JSON.parse(event);
} catch (_) {}
}

console.log('📩 收到飞书事件回调:');
console.log('📬 headers:\n', JSON.stringify(req.headers, null, 2));
console.log(JSON.stringify(event, null, 2));

if (event.type === 'url_verification') {
return res.send({ challenge: event.challenge });
}

if (event.header && event.header.event_type) {
const type = event.header.event_type;

switch (type) {
case 'im.message.receive_v1':
console.log('event_id:', event.header && event.header.event_id ? event.header.event_id : null);
console.log('event_time:', event.header && event.header.create_time ? event.header.create_time : null);
console.log('open_id:', event.event && event.event.sender && event.event.sender.sender_id ? event.event.sender.sender_id.open_id : null);
console.log('chat_id:', event.event && event.event.message ? event.event.message.chat_id : null);
console.log('msg_type:', event.event && event.event.message ? event.event.message.message_type : null);
console.log('content:', event.event && event.event.message ? event.event.message.content : null);
break;

default:
console.log(`📦 未处理的事件类型:${type}`);
}
}

res.status(200).send('Event received');
});

app.listen(PORT, () => {
console.log(`🚀 Webhook 启动成功:http://localhost:${PORT}/sync`);
});

反代到监听服务

  1. 增加一个反代配置,反代路径随便设置,例如:/your-sync-path
  2. 后端代理地址填写:(http)127.0.0.1:2333/sync
  3. (可选)如果你想用本文的事件回调来获取 open_id 或调试飞书事件,再加一个反代,例如:/your-feishu-events-path → (http)127.0.0.1:2333/feishu/events

创建并执行 pm2 任务

如果没有安装依赖,需要先安装一下:

安装 Node.js

curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt install -y nodejs

安装 pm2(全局)

npm install -g pm2

启动 webhook 服务并设置开机自启

pm2 start ecosystem.config.js
pm2 startup
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u root --hp /root
pm2 save

检查一下:

pm2 ls

配置飞书通知

本文的通知方式是「飞书开放平台自建应用 + 机器人」,通过 open-api 给指定用户发送“卡片消息”(私聊)。

创建自建应用并启用机器人

  1. 打开飞书开放平台,创建一个企业自建应用。
  2. 在应用能力里启用「机器人」。
  3. 发布版本,并确保应用对你自己可用(通常需要在企业内安装/开通可用范围,否则机器人无法给你发消息)。

配置权限

在应用的权限管理中,申请并开通“发送消息”等 IM 相关权限(以控制台展示为准)。如果权限不全,im/v1/messages 会返回无权限之类的错误。

配置环境变量

把下列信息填入 ecosystem.config.jsenv(示例见上文):

  1. FEISHU_APP_ID:应用的 App ID
  2. FEISHU_APP_SECRET:应用的 App Secret
  3. FEISHU_USER_OPEN_ID:接收通知的用户 open_id

如果你修改了 ecosystem.config.js,记得这样重载并更新环境变量:

$

获取接收人的 open_id(推荐做法:事件回调)

最省事的方法是启用事件订阅,让目标账号给机器人发一条消息,然后从日志里取 open_id。

  1. 在飞书开放平台的「事件订阅」里配置请求地址:https://yourdomain.com/your-feishu-events-path
  2. 订阅消息事件(例如收到消息一类的事件);本文代码示例里会处理并打印 im.message.receive_v1 的 sender open_id
  3. 让接收通知的那个人给机器人发一条任意消息
  4. 查看日志:
$

然后把日志里打印出的 open_id 填回 FEISHU_USER_OPEN_ID,再重载 pm2。

注意:本文示例代码只做了最基础的 url_verification 校验,并没有实现回调签名校验/消息体加解密。如果你在开放平台里开启了“加密策略”,这份示例需要额外适配才能解析事件内容。

安全与隐私提示

  1. 不要在日志里输出 SYNC_TOKEN(无论放在 query 还是 header);本文代码为了调试会打印 headers/body,生产使用建议去掉或做脱敏。
  2. 反代路径尽量用不容易猜的随机字符串,并在网关层加上限流或 IP 白名单,避免 /sync 被扫到。
  3. FEISHU_APP_SECRET 属于高敏感信息,任何时候都不要写进仓库,也不要粘贴到公开平台。

GitHub Action 调用 Webhook

name: auto-deploy

on:
push:
branches:
- main

jobs:
notify:
runs-on: ubuntu-latest
steps:
# 前面是其它流程,确保静态文件仓库部署完成后,再调这个步骤:
- name: Call Webhook
run: |
curl -X POST "https://yourdomain.com/your-sync-path?token=${{ secrets.SYNC_TOKEN }}"

调试与验证

修改了 webhook.js 之后重启一下:

$

如果也有修改 ecosystem.config.js 文件,则这样重启:

$

查看日志:

$

在服务器上测试效果:

$

在本地电脑上测试效果,这个能通就代表全流程通了:

$