飞书机器人热点推送实现说明
本文说明本项目的用户级飞书热点推送如何设计、为什么需要数据库表,以及本地、Vercel、Cloudflare 三种运行模式如何复用同一套业务逻辑。
1. 整体目标
每位已登录用户可以保存自己的飞书群机器人 Webhook,选择热点来源、周期、数量、每日推送时间和启停状态。 系统不是把所有消息发到一个管理员机器人,而是按用户配置分别发送。未来增加 QQ 邮箱、Telegram 时,只需要新增渠道提供者,不需要改变订阅、调度和投递记录的主流程。
2. 全链路流程
- 用户在首页右上角头像菜单打开“推送通知”弹窗。
- 前端调用通知设置接口,读取当前用户的掩码信息和当前平台能力。
- 用户保存 Webhook、签名密钥与订阅偏好。
- 服务端校验飞书地址、加密凭证,并写入 MySQL。
- 定时平台调用内部 dispatch 接口:Vercel 每日一次、Cloudflare 每五分钟、本地手动调用。
- 分发器查找当前时间到期的订阅,先创建当天的投递记录以抢占发送权。
- 分发器抓取用户选中的热点,拼装消息,解密 Webhook 并发送给飞书。
- 投递记录更新为 sent 或 failed,供后续排查和去重。
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
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
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
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
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
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 手动触发。
本地最小环境变量:
NOTIFICATION_ENCRYPTION_KEY_BASE64=32字节Base64密钥
NOTIFICATION_DISPATCH_TOKEN=local-test-token
NOTIFICATION_SCHEDULER_PROVIDER=local
vercel:线上 Vercel
vercel.json 每天调用一次 dispatch/vercel。Vercel 免费计划只能每日一次,因此所有用户使用固定中国时间。
NOTIFICATION_SCHEDULER_PROVIDER=vercel
NOTIFICATION_FIXED_DELIVERY_TIME=09:00
CRON_SECRET=长随机令牌
cloudflare:线上 Cloudflare Workers
wrangler.jsonc 中的 Cron 每五分钟触发 Worker;Worker 再调用 dispatch/cloudflare。用户可自行选取五分钟时段。
NOTIFICATION_SCHEDULER_PROVIDER=cloudflare
NOTIFICATION_DISPATCH_TOKEN=长随机令牌
同一生产数据库只能启用一个线上调度平台。
6. 本地测试步骤
- 在 .env 设置加密密钥和 local 模式,重启 pnpm dev。
- 登录,打开头像菜单中的“推送通知”。
- 填入飞书群机器人 Webhook,点击保存。
- 点击测试;飞书群立即收到消息,即可验证 Webhook。
- 如需验证完整调度链路,选择即将到达的五分钟时段,然后执行:
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
- 执行一次正式推送任务”的内部入口
- 只用于本地开发和手动模拟定时任务
- 保护这个接口 api/internal/notifications/dispatch/local
| 变量 | 用途 | 本地是否需要 |
|---|---|---|
NOTIFICATION_ENCRYPTION_KEY_BASE64 | 加密数据库中保存的 Webhook 和飞书签名密钥 | 必填 |
NOTIFICATION_SCHEDULER_PROVIDER | 当前调度模式:local、vercel、cloudflare | 本地可省略,默认 local |
NOTIFICATION_FIXED_DELIVERY_TIME | Vercel 免费版固定每日推送中国时间 | 本地和 Cloudflare 忽略 |
NOTIFICATION_DISPATCH_TOKEN | 保护内部定时分发接口 | 本地完整链路、Cloudflare 需要 |
CRON_SECRET | Vercel Cron 自动携带的调度令牌 | 仅 Vercel 建议配置 |