概述
kira-notify 是 Kira 的统一通知中心。其他服务不再各自拼邮件、查用户、选渠道,而是只 publish 一个语义化的 notification type(例如 daily_digest、account_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(服务间) |
| 邮件 provider | Resend |
| 数据源 | 共享 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 兼容原始邮件
- notify.ts
- 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
- user.ts
- 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?} 后:
查 handler
getHandler(type) 从 registry 取对应 NotificationHandler。未注册的 type → 400 Unknown notification type。解析用户
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 查询。
门槛 / 偏好过滤
handler 内部判定。例如
daily_digest 要求 plan === "free"(否则 plan_gate)且 isOpenNotification(否则 disabled_by_user)。不满足时抛 NotificationSkipped,路由层转成 { skipped: true, reason }——不是错误。渠道
| 渠道 | 状态 | 实现 |
|---|---|---|
email | LIVE | src/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" |
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:
t(key, locale, format?)按点号路径取嵌套键(如daily.email_title),可选{var}占位替换。- locale 文件缺失时回退到
en-US;键缺失时直接返回原始 key(不抛错)。 render(template, vars)把 HTML 里的{{placeholder}}替换为 vars 值。
鉴权
POST /notify 与 POST /emails 都经 internalKeyAuth 中间件:请求需带 header X-Internal-Key,值与服务端 env INTERNAL_KEY 比对。
INTERNAL_KEY未配置 →500- header 不匹配 →
401 GET /health不需要鉴权
部署
| 项 | 值 |
|---|---|
| Fly app | kira-notify |
| primary_region | sjc |
| VM | 1 GB / shared 1 CPU |
| deploy | rolling |
| http_service | 无(内网专用,6PN) |
| 容器 | oven/bun:1.3,CMD ["bun", "/app/src/bootstrap.ts"],EXPOSE 8084 |
hostname: "::"、PORT(默认 8084)、idleTimeout: 0。SIGINT/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——它每个 fetchnew PerformanceObserver(),Bun 下漏 native 内存。W3Ctraceparent+ baggage 只注入到*.kira.art/*.internal,不漏给第三方。 - Metrics:业务直方图
notify.email.duration、计数器notify.channel.dispatch,加@hono/otel自带的http.server.request.duration+http.server.active_requests。 - Logs / Errors:
captureException写 ERROR-severity log + 在 active span 上recordException,Dash0 按exception.type分组。 - User attribution:上游 caller(kira-be)通过 W3C Baggage 传
user.id,UserAttributePropagator自动 stamp 到本服务所有 span。
OTel 仅在
NODE_ENV=production 且 OTEL_EXPORTER_OTLP_ENDPOINT 已设时启用,否则 startTelemetry() 静默 no-op。环境变量
仅列变量名(值不入文档)。| 变量 | 用途 |
|---|---|
RESEND_API_KEY | Resend API key(复用 kira-be 已验证的 kira.art 域) |
NOTIFY_FROM_ADDRESS | 发件人,默认 Kira Art <support@kira.art>(见 src/lib.ts) |
SUPABASE_URL | Supabase 项目 URL |
SUPABASE_KEY | Supabase service key(查 auth user + user_profiles) |
INTERNAL_KEY | 服务间鉴权,与 X-Internal-Key header 比对 |
OTEL_EXPORTER_OTLP_ENDPOINT | http://kira-otel-collector.internal:4318(collector 持有 Dash0 凭证) |
NODE_ENV | production 时启用 OTel + 收紧 CORS |
PORT | 默认 8084 |
相关文档
API 参考
POST /notify、POST /emails、GET /health 的请求/响应与 skip 语义。
Notification Types
daily_digest 与 account_deletion.* 的模板、i18n 键、CTA,以及如何新增 type。