Notification¶
Umbra 시스템에서 사용자에게 알림을 전달하는 도메인. 결제 이벤트, 복구 결과, AntiNuke 감지, 플랜 변경 등 여러 도메인이 발행한 이벤트를 구독하여 Discord DM 또는 감사 채널로 전달한다.
Bounded context¶
- Type — Supporting
- Sibling contexts — Billing, Licensing, Recovery 의 이벤트 구독자
- Location in codebase —
engine/notification/
Why this domain exists¶
여러 도메인이 각자 "알림 발송" 을 구현하면 다음 문제가 발생한다.
- 동일한 알림 로직(rate limit, 실패 재시도, 템플릿)의 중복 구현
- 사용자 알림 선호도 관리의 분산
- Discord rate limit 을 도메인마다 개별 대응
Notification 도메인은 모든 사용자 알림의 단일 진입점 이 된다. 발신 채널(Discord DM, 길드 감사 채널, 대시보드 toast)을 추상화하고, 이벤트 → 메시지 렌더링을 한 곳에서 관리한다.
Domain model¶
NotificationRequest¶
발송 예정 또는 완료된 알림.
NotificationRequest
├─ id UUID v7 PK
├─ recipient_type TEXT 'user_dm' | 'guild_audit_channel' | 'dashboard_toast'
├─ recipient_id TEXT user_dm 은 Discord user ID, guild_audit 는 channel ID
├─ guild_id UUID → guild.guilds.id (관련 길드, nullable)
├─ template_key TEXT 'payment.succeeded', 'restore.completed' 등
├─ payload JSONB 템플릿 렌더링용 변수
├─ state TEXT 'pending' | 'sent' | 'failed' | 'suppressed'
├─ attempt_count INTEGER
├─ last_error TEXT
├─ scheduled_at TIMESTAMPTZ
├─ sent_at TIMESTAMPTZ
└─ created_at TIMESTAMPTZ
NotificationPreference¶
사용자별 알림 선호도 (User 에 귀속).
NotificationPreference
├─ user_id UUID PK, → identity.users.id
├─ dm_enabled BOOLEAN DM 전체 on/off
├─ payment_notifications BOOLEAN
├─ recovery_notifications BOOLEAN
├─ antinuke_notifications BOOLEAN
├─ marketing_notifications BOOLEAN
└─ updated_at TIMESTAMPTZ
기본값은 모두 true. 사용자가 대시보드에서 카테고리별 off 가능. AntiNuke 긴급 알림은 사용자 선호도를 무시할 수 있음 (중요도 override).
Aggregates¶
- NotificationRequest — 독립 aggregate
- NotificationPreference — User 가 root aggregate, 여기서 수정
Invariants¶
- recipient_type 과 recipient_id 일치 —
user_dm은 Discord user ID,guild_audit_channel은 channel ID - template_key 존재 — 코드베이스에 정의된 템플릿 키만 허용 (CI 로 검증 가능)
- max attempt_count — 3회 실패 시
failed로 전환 - suppressed 상태 — 사용자 preference 로 off 된 경우, 이력은 남기되 발송 안 함
State machine¶
stateDiagram-v2
[*] --> Pending : Event received
Pending --> Sent : Discord API success
Pending --> Failed : Max retries exceeded
Pending --> Suppressed : User preference off
Sent --> [*]
Failed --> [*]
Suppressed --> [*]
Domain events¶
Published¶
Notification 은 기본적으로 이벤트를 구독하는 쪽 이라 새로 발행할 일은 적다. 단 Audit 을 위해 다음은 기록:
| Event | Trigger | Payload | Subscribers |
|---|---|---|---|
NotificationSent | DM 또는 채널 메시지 발송 성공 | request_id, template_key, recipient_type | Audit |
NotificationFailed | 3회 재시도 실패 | request_id, last_error | Audit |
Consumed (주요 이벤트 → 알림 매핑)¶
| Source Event | Template | Recipient | Notes |
|---|---|---|---|
SubscriptionStarted | subscription.started | User DM + Guild audit channel | |
PaymentSucceeded | payment.succeeded | User DM (payment_notifications 존중) | |
PaymentFailed | payment.failed | User DM (override 가능) | |
PaymentFailedFinal | subscription.auto_canceled | User DM + Guild audit | |
SubscriptionCanceled | subscription.canceled | User DM | |
PlanUpgraded | plan.upgraded | User DM + Guild audit | |
PlanDowngraded | plan.downgraded | User DM + Guild audit | |
LicenseSuspended | license.suspended | Guild owner DM | |
RestoreStarted | restore.started | Guild audit | |
RestoreCompleted | restore.completed | Guild owner DM + Guild audit | |
RestoreFailed | restore.failed | Guild owner DM (override) | |
AntiNukeTriggered | antinuke.triggered | Guild owner DM (override) | Preference 무시, 긴급 알림 |
AntiNukeActioned | antinuke.actioned | Guild owner DM + Guild audit |
Ports¶
Inbound¶
type Service interface {
Enqueue(ctx, request NotificationRequest) error
GetPreference(ctx, userID) (*NotificationPreference, error)
UpdatePreference(ctx, userID, pref) error
}
Outbound¶
- DiscordDMClient — 사용자 DM 발송
- DiscordChannelClient — 감사 채널 메시지 발송
- NotificationRepository — sqlc 기반
- PreferenceRepository — sqlc 기반
Consumer¶
Notification 의 핵심 부분은 event consumer 등록 이다. Outbox poller 가 이벤트를 dispatch 하면 Notification 이 수신.
// engine/notification/consumer.go
func (c *Consumer) OnPaymentSucceeded(ctx, event) error {
return c.service.Enqueue(ctx, NotificationRequest{
RecipientType: "user_dm",
RecipientID: event.PayerDiscordID,
TemplateKey: "payment.succeeded",
Payload: event.ToPayload(),
})
}
Adapters¶
- Persistence —
engine/notification/adapter/persistence/ - Discord DM —
engine/notification/adapter/discord_dm/ - Discord Channel —
engine/notification/adapter/discord_channel/
Templates¶
템플릿은 코드베이스에 내장 (engine/notification/templates/).
- 각
template_key마다 ko / en 버전 (i18n) - Discord Components V2 포맷 활용 (Container, Section)
- 렌더링은 Go text/template + payload JSONB
예시 template_key:
payment.succeeded
payment.failed
subscription.started
subscription.canceled
subscription.auto_canceled
plan.upgraded
plan.downgraded
license.suspended
restore.started
restore.completed
restore.failed
antinuke.triggered
antinuke.actioned
Permission model¶
- Preference 조회/수정 — User 본인만
- Notification 내역 조회 — User 본인 (대시보드)
- Admin 조회 — 내부 운영 도구만 (별도 인증)
Retry and rate limit¶
Discord rate limit 고려:
- User DM 은 사용자당 rate limit 존재 → 연속 알림은 큐 처리
- Global rate limit 대응을 위해 asynq priority queue 사용
- 3회 실패 시
failed로 확정, 운영자 알림
Failure modes¶
- Discord user 가 봇 DM 거부 — 실패 응답, 해당 사용자에 대해 "DM 불가" 캐시 (24h) → 이후 알림은 guild_audit_channel 로 fallback
- 감사 채널 삭제됨 — 알림 실패, GuildConfig 재설정 요구 DM
- Template 렌더링 오류 — 즉시
failed, 운영 알림 - 대량 이벤트 폭주 — asynq queue 가 완충, 지연은 발생하나 손실 없음
See also¶
data/notification-schema.md— DB 스키마architecture/event-flow.md— 이벤트 카탈로그 (Subscribers 컬럼)domain/billing.md,domain/licensing.md,domain/recovery/overview.md— 구독 대상 이벤트 발행자adr/0016-outbox-pattern.md— 이벤트 구독 메커니즘