跳到主要内容

飞书机器人热点推送实现说明

本文说明本项目的用户级飞书热点推送如何设计、为什么需要数据库表,以及本地、Vercel、Cloudflare 三种运行模式如何复用同一套业务逻辑。

1. 整体目标​

每位已登录用户可以保存自己的飞书群机器人 Webhook,选择热点来源、周期、数量、每日推送时间和启停状态。 系统不是把所有消息发到一个管理员机器人,而是按用户配置分别发送。未来增加 QQ 邮箱、Telegram 时,只需要新增渠道提供者,不需要改变订阅、调度和投递记录的主流程。

2. 全链路流程​

  1. 用户在首页右上角头像菜单打开“推送通知”弹窗。
  2. 前端调用通知设置接口,读取当前用户的掩码信息和当前平台能力。
  3. 用户保存 Webhook、签名密钥与订阅偏好。
  4. 服务端校验飞书地址、加密凭证,并写入 MySQL。
  5. 定时平台调用内部 dispatch 接口:Vercel 每日一次、Cloudflare 每五分钟、本地手动调用。
  6. 分发器查找当前时间到期的订阅,先创建当天的投递记录以抢占发送权。
  7. 分发器抓取用户选中的热点,拼装消息,解密 Webhook 并发送给飞书。
  8. 投递记录更新为 sent 或 failed,供后续排查和去重。
mermaid
flowchart LR
A[用户弹窗保存配置] --> B[设置 API]
B --> C[校验并 AES-GCM 加密]
C --> D[(MySQL 通道与订阅)]
E[Vercel / Cloudflare / local] --> F[内部调度接口]
F --> G[分发器]
G --> H[抢占当天投递记录]
H --> I[抓取热点]
I --> J[飞书机器人 Webhook]
J --> K[(投递结果)]

3. 为什么需要通知推送数据库表​

环境变量只适合保存一份全局、管理员级配置,不能支持多用户、不同来源、不同时间、不同机器人,也无法记录是否已经发过。

NotificationChannel:用户的渠道凭证​

字段重点:

  • userId:归属用户,所有查询都以它隔离数据。
  • type:渠道类型。当前是 feishu,未来可扩展 email、telegram。
  • encryptedConfig、encryptionIv、encryptionTag:加密后的 Webhook 和签名密钥,不保存明文。
  • lastTestedAt、lastTestStatus、lastTestMessage:记录最近一次“发送测试”的结果。

唯一约束 userId + type 表示:第一版每个用户只配置一个飞书渠道,保存时可直接 upsert。

NotificationSubscription:用户想收到什么、何时收到​

一个渠道对应一个订阅,保存:

  • sources:热点来源数组,如 ai、github、weibo。
  • period、limit:热点时间范围和每个来源的条数。
  • scheduledMinute:一天中的第几分钟,五分钟粒度。例如 540 等于 09:00。
  • isEnabled:临时关闭推送无需删除机器人配置。

这样配置是数据,不是环境变量;用户修改后无需重新部署应用。

NotificationDelivery:每日投递账本与幂等锁​

定时任务可能重试、延迟或被多个实例重复触发。如果先发送消息再记录结果,用户可能收到重复通知。 本表通过唯一约束 subscriptionId + deliveryDate 确保一个订阅在一个中国日期最多抢占一次发送权。创建记录成功的任务才能发送;重复任务会触发唯一键冲突并直接跳过。

4. 核心代码说明​

4.1 AES-GCM 加密保存凭证​

文件:src/modules/notifications/crypto.ts

ts
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(JSON.stringify(value)),
)
const tag = encrypted.slice(-16)

说明:

  • 服务端只从 NOTIFICATION_ENCRYPTION_KEY_BASE64 读取 32 字节密钥。
  • 每次保存生成新的随机 IV,避免相同 Webhook 得到相同密文。
  • AES-GCM 自带篡改检测。认证标签和密文任一被修改,解密会失败。
  • 设置接口仅返回掩码地址,例如 open.feishu.cn/.../abcd****,永不回传密钥。

4.2 用户配置保存与 Vercel 时间限制​

文件:src/modules/notifications/service.ts

ts
const subscription = {
...parsedSubscription,
scheduledMinute: capabilities.fixedDeliveryMinute
?? parsedSubscription.scheduledMinute,
}

说明:

  • Vercel 免费日程每天只运行一次,因此服务端强制采用 NOTIFICATION_FIXED_DELIVERY_TIME。
  • Cloudflare 和 local 模式保留用户选择的五分钟时间。
  • 保存使用 Prisma upsert:首次创建通道与订阅,之后更新同一条记录。
  • 密钥输入框留空时保留已有加密密钥,不会被空字符串覆盖。

4.3 调度来源验证​

文件:src/server/routes/internal-notifications.ts

ts
if (provider !== 'vercel' && provider !== 'cloudflare' && provider !== 'local') {
return c.json({ success: false, message: 'Invalid scheduler' }, 400)
}
const data = await dispatchDueNotifications(provider)

说明:

  • 调度入口需要 Bearer Token。
  • dispatch/local 只可在 local 模式执行,dispatch/vercel 只可在 Vercel 模式执行。
  • 分发器再次比对请求来源和环境变量,防止两个云平台同时向同一数据库发送。

4.4 先抢占、再发送,防止重复推送​

文件:src/modules/notifications/dispatcher.ts

ts
await prisma.notificationDelivery.create({
data: {
subscriptionId: subscription.id,
deliveryDate: chinaDate,
scheduledMinute: subscription.scheduledMinute,
status: 'processing',
},
})
await sendFeishuText(channelConfig, message)

说明:

  • create 使用数据库唯一约束抢占当天发送权。
  • 如果另一个任务已创建同一订阅同一天的记录,当前任务直接跳过。
  • 外部飞书调用成功后更新 sent;失败后更新 failed 并保存不含密钥的错误信息。
  • 单个用户或热点源失败不会影响其他用户继续处理。

4.5 飞书地址与签名校验​

文件:src/modules/notifications/providers/feishu.ts

ts
if (
url.protocol !== 'https:'
|| url.hostname !== 'open.feishu.cn'
|| !url.pathname.startsWith('/open-apis/bot/v2/hook/')
) {
throw new Error('Invalid Feishu webhook URL')
}

说明:

  • 只允许飞书官方 HTTPS 机器人地址,避免 SSRF 风险。
  • 用户开启飞书签名校验时,系统按 timestamp 加密密钥计算 HMAC-SHA256 签名。
  • 测试按钮与正式分发均使用同一个发送函数,因此测试结果可信。

5. 三种调度模式​

local:本地开发​

开发环境未设置 NOTIFICATION_SCHEDULER_PROVIDER 时默认 local。

  • 不依赖 Vercel 或 Cloudflare。
  • 弹窗可选择任意五分钟时段。
  • 点击“测试”会立即发送真实飞书消息。
  • 完整链路通过 POST /api/internal/notifications/dispatch/local 手动触发。

本地最小环境变量:

env
NOTIFICATION_ENCRYPTION_KEY_BASE64=32字节Base64密钥
NOTIFICATION_DISPATCH_TOKEN=local-test-token
NOTIFICATION_SCHEDULER_PROVIDER=local

vercel:线上 Vercel​

vercel.json 每天调用一次 dispatch/vercel。Vercel 免费计划只能每日一次,因此所有用户使用固定中国时间。

env
NOTIFICATION_SCHEDULER_PROVIDER=vercel
NOTIFICATION_FIXED_DELIVERY_TIME=09:00
CRON_SECRET=长随机令牌

cloudflare:线上 Cloudflare Workers​

wrangler.jsonc 中的 Cron 每五分钟触发 Worker;Worker 再调用 dispatch/cloudflare。用户可自行选取五分钟时段。

env
NOTIFICATION_SCHEDULER_PROVIDER=cloudflare
NOTIFICATION_DISPATCH_TOKEN=长随机令牌

同一生产数据库只能启用一个线上调度平台。

6. 本地测试步骤​

  1. 在 .env 设置加密密钥和 local 模式,重启 pnpm dev。
  2. 登录,打开头像菜单中的“推送通知”。
  3. 填入飞书群机器人 Webhook,点击保存。
  4. 点击测试;飞书群立即收到消息,即可验证 Webhook。
  5. 如需验证完整调度链路,选择即将到达的五分钟时段,然后执行:
powershell
Invoke-RestMethod -Method POST -Uri "http://localhost:3000/api/internal/notifications/dispatch/local" -Headers @{ Authorization = "Bearer local-test-token" }

接口会返回 sent、failed、date。若 sent 为 0,通常是未到设定时间、已关闭推送,或该日期已经成功投递过。

7. 安全与运维要点​

  • 不提交 .env、Webhook、签名密钥、调度令牌。
  • 加密密钥不可随意更换;更换后旧凭证无法解密。
  • 飞书机器人地址仅允许官方域名。
  • 内部调度接口必须携带令牌。
  • 使用中国日期作为去重维度,避免 UTC 跨日导致重复或漏发。
  • 生产环境 Vercel 和 Cloudflare 只能选择一个调度器。

8、环境变量​

  • NOTIFICATION_ENCRYPTION_KEY_BASE64
    • 用于加密保存用户填写的飞书 Webhook、签名密钥
    • 用户填写的飞书 Webhook 很敏感,不能明文放到数据库。
  • NOTIFICATION_DISPATCH_TOKEN
    • 保护这个接口 api/internal/notifications/dispatch/local
      • 执行一次正式推送任务”的内部入口
    • 只用于本地开发和手动模拟定时任务
变量用途本地是否需要
NOTIFICATION_ENCRYPTION_KEY_BASE64加密数据库中保存的 Webhook 和飞书签名密钥必填
NOTIFICATION_SCHEDULER_PROVIDER当前调度模式:local、vercel、cloudflare本地可省略,默认 local
NOTIFICATION_FIXED_DELIVERY_TIMEVercel 免费版固定每日推送中国时间本地和 Cloudflare 忽略
NOTIFICATION_DISPATCH_TOKEN保护内部定时分发接口本地完整链路、Cloudflare 需要
CRON_SECRETVercel Cron 自动携带的调度令牌仅 Vercel 建议配置