Skip to main content

概述

kira-notify 是 Kira 的统一通知中心。其他服务不再各自拼邮件、查用户、选渠道,而是只 publish 一个语义化的 notification type(例如 daily_digestaccount_deletion.scheduled)加上 userId。kira-notify 负责剩下的一切:
  • 从 Supabase 解析用户(email / locale / plan / is_open_notification
  • 按偏好与 plan 门槛决定是否发、走哪个渠道
  • 用 i18n 文案 + HTML 模板渲染内容
  • 通过合适的 channel 投递(当前仅 email live)
渠道选择是 notify 的职责,不是调用方的。 业务方只声明”发生了什么”(event type + userId),由 notify 决定”用什么渠道、发不发”。调用方不传 channels 列表、不做 plan/偏好判断。
运行时Bun + Hono
端口8084
暴露形态内网专用(6PN,无公网入口)
内网地址http://kira-notify.internal:8084
部署Fly.io,app kira-notify,region sjc
鉴权X-Internal-Key header(服务间)
邮件 providerResend
数据源共享 Supabase(auth + user_profiles
观测OpenTelemetry → kira-otel-collector → Dash0

架构

分层设计

服务分两层:高层 notifications(语义事件)和低层 channels(投递实现)。
  • src
    • bootstrap.ts 入口:先启 OTel SDK,再 import index
    • index.ts Hono app、CORS、onError、中间件挂载、serve()
    • auth.ts X-Internal-Key 校验中间件
    • lib.ts Supabase client + FROM_ADDRESS
    • i18n.ts t(key, locale) + render(template, vars)
    • routes
      • notify.ts POST /notify — 语义事件
      • emails.ts POST /emails — Resend 兼容原始邮件
    • notifications
      • registry.ts type → handler 映射
      • types.ts NotificationHandler 接口、NotificationSkipped
      • daily-digest.ts daily_digest
      • account-deletion.ts account_deletion.{scheduled,cancelled,reminder,deleted}
    • channels
      • email.ts LIVE — Resend 实现
      • push.ts / sms.ts / inapp.ts stub,抛 “not implemented”
      • types.ts Channel<T> 接口 + payload 类型
    • resolve
      • user.ts resolveUser(userId) → Supabase
    • messages 12 个 locale JSON
    • templates
      • daily-digest/email.html
      • account-deletion/email.html
    • telemetry OTel traces / metrics / logs / errors / user-context

语义事件分发流程

POST /notify 拿到 {type, userId, data?, contact?} 后:
1

查 handler

getHandler(type) 从 registry 取对应 NotificationHandler。未注册的 type → 400 Unknown notification type
2

解析用户

handler 调 resolveUser(userId),并行查 supabase.auth.admin.getUserById + user_profiles.select(language, plan, is_open_notification),返回 { email, locale, plan, isOpenNotification }。查不到 → 抛 NotificationSkipped("user_not_found")
account_deletion.* 支持传 contact: {email?, locale?} 兜底——用户记录已被删除(deleted 阶段)时跳过 Supabase 查询。
3

门槛 / 偏好过滤

handler 内部判定。例如 daily_digest 要求 plan === "free"(否则 plan_gate)且 isOpenNotification(否则 disabled_by_user)。不满足时抛 NotificationSkipped,路由层转成 { skipped: true, reason }——不是错误
4

渲染

subject 与正文 vars 走 t(key, locale),HTML 走 render(template, vars)(替换 {{placeholder}})。
5

投递

emailChannel.send(payload, { template }) 调 Resend,返回 providerId。响应 { delivered: [{ channel, providerId }] }

渠道

渠道状态实现
emailLIVEsrc/channels/email.ts,Resend SDK(resend.emails.send
push占位send()"push channel not implemented"
sms占位send()"sms channel not implemented"
inapp占位send()"inapp channel not implemented"
每次 email 发送记录两个 Dash0 指标:直方图 notify.email.duration(ms,按 template + result=ok|err 切片)和计数器 notify.channel.dispatch(按 channel + result)。
push / sms / inapp 接口已定型(Channel<T> + 各自 payload 类型),但 send() 仅抛异常占位。未实现,不要在生产中路由到这些渠道。

国际化(i18n)

12 个 locale,文案在 src/messages/{locale}.json
zh-CN  zh-TW  ja-JP  ko-KR  de-DE  es-ES
fr-FR  it-IT  ms-MY  pt-PT  ru-RU  en-US
  • t(key, locale, format?) 按点号路径取嵌套键(如 daily.email_title),可选 {var} 占位替换。
  • locale 文件缺失时回退到 en-US;键缺失时直接返回原始 key(不抛错)。
  • render(template, vars) 把 HTML 里的 {{placeholder}} 替换为 vars 值。

鉴权

POST /notifyPOST /emails 都经 internalKeyAuth 中间件:请求需带 header X-Internal-Key,值与服务端 env INTERNAL_KEY 比对。
  • INTERNAL_KEY 未配置 → 500
  • header 不匹配 → 401
  • GET /health 不需要鉴权
kira-notify 无 [http_service]、无公网域名。只能从同一 Fly org 私网(kira-be / kira-queue)或 WireGuard 访问 http://kira-notify.internal:8084X-Internal-Key 是服务间防护层,不是面向公网的 API key。

部署

Fly appkira-notify
primary_regionsjc
VM1 GB / shared 1 CPU
deployrolling
http_service(内网专用,6PN)
容器oven/bun:1.3CMD ["bun", "/app/src/bootstrap.ts"]EXPOSE 8084
服务监听 hostname: "::"PORT(默认 8084)、idleTimeout: 0SIGINT/SIGTERM 时先 flush 并关闭 OTel,再 process.exit(0)

可观测性

三类信号(traces / metrics / logs)全部走 OpenTelemetry → kira-otel-collector.internal:4318 → Dash0,与 kira-be / kira-cdn 设计一致。
  • Traces@hono/otel 入站 SERVER span;手写 fetch wrapper(installFetchTracing)发出站 CLIENT span(Resend / Supabase / collector)。不用 @opentelemetry/instrumentation-fetch——它每个 fetch new PerformanceObserver(),Bun 下漏 native 内存。W3C traceparent + baggage 只注入到 *.kira.art / *.internal,不漏给第三方。
  • Metrics:业务直方图 notify.email.duration、计数器 notify.channel.dispatch,加 @hono/otel 自带的 http.server.request.duration + http.server.active_requests
  • Logs / ErrorscaptureException 写 ERROR-severity log + 在 active span 上 recordException,Dash0 按 exception.type 分组。
  • User attribution:上游 caller(kira-be)通过 W3C Baggage 传 user.idUserAttributePropagator 自动 stamp 到本服务所有 span。
OTel 仅在 NODE_ENV=production OTEL_EXPORTER_OTLP_ENDPOINT 已设时启用,否则 startTelemetry() 静默 no-op。

环境变量

仅列变量名(值不入文档)。
变量用途
RESEND_API_KEYResend API key(复用 kira-be 已验证的 kira.art 域)
NOTIFY_FROM_ADDRESS发件人,默认 Kira Art <support@kira.art>(见 src/lib.ts
SUPABASE_URLSupabase 项目 URL
SUPABASE_KEYSupabase service key(查 auth user + user_profiles
INTERNAL_KEY服务间鉴权,与 X-Internal-Key header 比对
OTEL_EXPORTER_OTLP_ENDPOINThttp://kira-otel-collector.internal:4318(collector 持有 Dash0 凭证)
NODE_ENVproduction 时启用 OTel + 收紧 CORS
PORT默认 8084

相关文档

API 参考

POST /notify、POST /emails、GET /health 的请求/响应与 skip 语义。

Notification Types

daily_digest 与 account_deletion.* 的模板、i18n 键、CTA,以及如何新增 type。