FlareStarter 文档
功能与集成

Notifications(增长事件推送)

一个自包含的 src/features/notifications/ 切片:把三类增长事件——已验证的新用户注册、候补名单加入、用户反馈提交——实时推到你的团队群(飞书 / Discord / Telegram / 任意通用 webhook)。它是运营侧的「心跳」,不经手一分钱,也不落库、不接入 i18n、不碰 Stripe/webhook 流程。

1. 为什么没有收入类事件

订阅创建、续费、买断这些「钱到账了」的事件,Stripe 自己的 Dashboard 邮件 + 手机 App 推送已经覆盖得很好——再接一遍是重复劳动。这个切片只覆盖 Stripe 覆盖不到的部分:产品早期最想第一时间知道的「有人来了」「有人反馈了」。NOTIFY_EVENTS(notifications.shared.ts)是一份审慎维护的白名单(写法上参照 audit.shared.ts#AUDIT_ACTIONS):

export const NOTIFY_EVENTS = ['user.signup', 'waitlist.joined', 'feedback.submitted'] as const

加新事件类型永远从这个数组开始,而不是从调用点开始;这也是本文档明确划的红线——审查/扩展这个切片时,收入事件属于范围之外。

2. 四个渠道,各自的环境变量

每个渠道由一组环境变量独立控制,留空即关闭,互不影响;notify() 会并行 fan-out 到所有已配置的渠道:

渠道环境变量备注
通用 WebhookNOTIFY_WEBHOOK_URL原始未转义 JSON,供 Zapier / n8n / 自建服务消费
DiscordDISCORD_WEBHOOK_URLChannel Webhook URL
飞书 / LarkFEISHU_WEBHOOK_URL(+ 可选 FEISHU_WEBHOOK_SECRET)自定义机器人
TelegramTELEGRAM_BOT_TOKEN 和 TELEGRAM_CHAT_ID两个都配才会推送,缺一个整个渠道跳过

六个变量的占位符在 .dev.vars.example 里,注释就近写着一句话说明。三个「人读」渠道(Discord / 飞书 / Telegram)会转义用户输入并按渠道上限截断:Discord content ≤ 2000,Telegram text ≤ 4096;通用 Webhook 走 formatWebhook,发送原始、未转义、未截断的 JSON——它是给程序消费的,转义/截断反而会破坏下游解析。

3. 转义与截断(notifications.shared.ts / format.ts)

escapeText 是等长替换(不用会扩长的实体编码),专门中和几类会被聊天软件解释执行的字符:

export function escapeText(s: string): string {
  return s
    .replace(/@/g, '@')      // 全角 @,防止 @everyone / @here 群体提及
    .replace(/`/g, "'")       // 反引号 → 单引号,防代码块逃逸
    .replace(/[[\]()]/g, ' ') // markdown 链接方括号/圆括号 → 空格
    .replace(/[<>]/g, ' ')    // 尖括号 → 空格(飞书 <at>、HTML)
}

等长是关键约束:format.ts 里 feedback 事件的截断预算是「先转义、再按剩余字符数截断 body」,如果转义会改变长度,这个预算计算就会失准,导致 admin 深链被截断或超出渠道上限。反馈正文是唯一可能超长的字段,renderText 会先留出「标题 + 管理后台链接」的空间,再把剩余预算分给 body,保证深链永远完整可见。

4. 飞书加签(FEISHU_WEBHOOK_SECRET)

如果飞书自定义机器人开启了「签名校验」,把密钥填进 FEISHU_WEBHOOK_SECRET;留空则不加签,走普通 URL 校验。加签逻辑在 notify.server.ts#withFeishuSign:

const timestamp = Math.floor(Date.now() / 1000).toString() // 秒级,不是毫秒
const key = await crypto.subtle.importKey('raw', enc.encode(`${timestamp}\n${secret}`), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
const sig = await crypto.subtle.sign('HMAC', key, new Uint8Array(0))
const sign = btoa(String.fromCharCode(...new Uint8Array(sig)))

飞书要求 timestamp 与服务器当前时间的偏差在 ±1 小时内,否则会拒收——如果收不到消息,先确认 Worker 所在环境的系统时钟没有明显漂移。timestamp 必须是 Unix 秒(约 10 位数字)而不是毫秒(约 13 位数字),这是最容易踩的坑,单测(notify.server.node.test.ts)里专门断言了这一点。

5. 隐私 / GDPR

这个功能会把用户数据发给第三方处理方(飞书 / Discord / Telegram 服务器)和/或你配置的任意通用 URL:

  • 注册事件携带邮箱、姓名(如有)。
  • 反馈事件携带用户提交的自由文本正文和提交者邮箱。

对欧盟等有数据处理协议(DPA)要求的部署,这应当被当作一项处理方合规事项来对待——把飞书/Discord/Telegram/你的 webhook 端点纳入数据处理清单。如果你的产品不希望把这些字段发出去,直接改 format.ts 里对应的 renderText/formatWebhook 分支,去掉不想外发的字段即可;纯函数、无 DB,改动面很小。

6. 面向运营方,不是面向租户

这些 webhook 是运营方全局配置(环境变量),不是每个组织/用户可自行配置的功能——所有组织的事件都推到同一组渠道。如果产品需要「租户自己配置通知渠道」,那是一个完全不同的功能(需要落库、需要一套用户可编辑的 webhook 管理 UI),不在这个切片的范围内。

7. 投递语义:至少一次 + 尽力而为

  • 至少一次,无去重台账:afterEmailVerification 会在任何验证事件(含普通用户换绑邮箱后的重新验证)触发时推送 user.signup,因此存在极小概率的重复推送。这是刻意的取舍——引入去重需要额外状态(一张台账表),为一个几乎不会发生、发生了也无害的重复通知去加状态属于过度设计。
  • 尽力而为,从不抛错:notify()(notify.server.ts)整体包在 try/catch 里,且从不向调用方抛出异常——渠道推送失败绝不能拖垮它所观察的注册/候补/反馈动作本身,这与 recordAudit 的策略一致。
  • 每渠道 1.5 秒超时:post() 用 AbortController 给每个 fetch 设置 TIMEOUT_MS = 1500 的硬超时,配合 Promise.allSettled 并行 fan-out——某个渠道慢或挂掉不会拖慢其他渠道,也不会拖慢触发它的用户请求。
  • 日志卫生:失败时只记录「渠道名 + HTTP 状态码/错误类名」,绝不记录 payload、响应体、邮箱、反馈正文,也不记录 Telegram 的请求 URL(bot token 就在路径里)。

8. 手工冒烟测试(未自动化)

想在本地端到端验证一遍:把 .dev.vars 里的 NOTIFY_WEBHOOK_URL 指向一个 https://webhook.site 生成的临时 URL,pnpm dev 起服务后提交一条反馈或加入候补名单,确认 webhook.site 收到一条 JSON POST。注册事件的验证路径依赖 RESEND_API_KEY:没配置时(没有验证邮件流程)邮箱密码注册会在 databaseHooks.user.create.after 里无条件推送;配置了的话则等邮箱验证通过后才推送,避免对着一个还没验证、可能是垃圾邮箱的地址发通知。

On this page