package usecase import ( "context" "encoding/json" "errors" "fmt" "time" "gateway/internal/model/notification" notifconfig "gateway/internal/model/notification/config" domentity "gateway/internal/model/notification/domain/entity" "gateway/internal/model/notification/domain/enum" domrepo "gateway/internal/model/notification/domain/repository" domtpl "gateway/internal/model/notification/domain/template" domusecase "gateway/internal/model/notification/domain/usecase" "gateway/internal/model/notification/provider/email" "gateway/internal/model/notification/provider/sms" "gateway/internal/model/notification/template" ) const ( idempotencyCacheTTL = 24 * time.Hour quotaKeyTTL = 25 * time.Hour ) // NotifierUseCaseParam wires dependencies for NotifierUseCase. type NotifierUseCaseParam struct { Repo domrepo.NotificationRepository Idempotency domrepo.IdempotencyCache // optional Quota domrepo.QuotaCounter // optional RetryQueue domrepo.RetryQueue // required for Enqueue Renderer domtpl.Renderer Email *email.Chain SMS *sms.Chain Config notifconfig.Config } type notifierUseCase struct { NotifierUseCaseParam } // MustNotifierUseCase constructs NotifierUseCase. func MustNotifierUseCase(param NotifierUseCaseParam) domusecase.NotifierUseCase { if param.Renderer == nil { param.Renderer = template.NewRenderer(template.DefaultRegistry(), domtpl.LocaleZhTW, domtpl.LocaleEnUS) } return ¬ifierUseCase{param} } func (uc *notifierUseCase) Send(ctx context.Context, req *domusecase.SendRequest) (*domusecase.NotificationDTO, error) { if err := validateSendRequest(req); err != nil { return nil, err } if dto, ok, err := uc.lookupIdempotent(ctx, req); err != nil { return nil, err } else if ok { return dto, nil } if err := uc.checkQuota(ctx, req); err != nil { return nil, err } locale := uc.resolveLocale(req.Locale) doc := uc.newNotificationEntity(req, locale, enum.NotifyStatusPending, 0) if err := uc.Repo.Insert(ctx, doc); err != nil { if errors.Is(err, notification.ErrDuplicateIdempotency) { existing, findErr := uc.Repo.FindByIdempotency(ctx, req.TenantID, req.Kind, req.IdempotencyKey) if findErr != nil { return nil, wrapRepoErr(findErr) } dto := entityToDTO(existing) uc.cacheIdempotent(ctx, req, dto) return dto, nil } return nil, wrapRepoErr(err) } rendered, err := uc.Renderer.Render(req.Kind, locale, req.Data) if err != nil { return nil, uc.markFailed(ctx, doc, err) } provider, providerMsgID, sendErr := uc.delivery().deliver(ctx, req, rendered) attempts := 1 if sendErr != nil { if updateErr := uc.Repo.UpdateDelivery(ctx, req.TenantID, doc.ID.Hex(), &domrepo.NotificationDeliveryUpdate{ Status: enum.NotifyStatusFailed, Provider: provider, LastError: sendErr.Error(), Attempts: attempts, }); updateErr != nil { sendErr = fmt.Errorf("%w; persist failed status: %w", sendErr, updateErr) } doc.Status = enum.NotifyStatusFailed doc.Attempts = attempts doc.LastError = sendErr.Error() doc.Provider = provider dto := entityToDTO(doc) uc.cacheIdempotent(ctx, req, dto) return nil, errb.SvcThirdParty("notification delivery failed").WithCause(sendErr) } now := time.Now().UTC().UnixNano() body := "" if !req.DoNotPersistBody { body = rendered.Body } if err := uc.Repo.UpdateDelivery(ctx, req.TenantID, doc.ID.Hex(), &domrepo.NotificationDeliveryUpdate{ Status: enum.NotifyStatusSent, Provider: provider, ProviderMessageID: providerMsgID, Attempts: attempts, Body: body, DeliveredAt: &now, }); err != nil { return nil, wrapRepoErr(err) } doc.Status = enum.NotifyStatusSent doc.Attempts = attempts doc.Provider = provider doc.ProviderMessageID = providerMsgID doc.DeliveredAt = &now if !req.DoNotPersistBody { doc.Body = body } dto := entityToDTO(doc) uc.cacheIdempotent(ctx, req, dto) return dto, nil } func (uc *notifierUseCase) Enqueue(ctx context.Context, req *domusecase.SendRequest) (*domusecase.NotificationDTO, error) { if err := validateSendRequest(req); err != nil { return nil, err } if dto, ok, err := uc.lookupIdempotent(ctx, req); err != nil { return nil, err } else if ok { return dto, nil } if err := uc.checkQuota(ctx, req); err != nil { return nil, err } locale := uc.resolveLocale(req.Locale) doc := uc.newNotificationEntity(req, locale, enum.NotifyStatusPending, 0) if err := uc.Repo.Insert(ctx, doc); err != nil { if errors.Is(err, notification.ErrDuplicateIdempotency) { existing, findErr := uc.Repo.FindByIdempotency(ctx, req.TenantID, req.Kind, req.IdempotencyKey) if findErr != nil { return nil, wrapRepoErr(findErr) } dto := entityToDTO(existing) uc.cacheIdempotent(ctx, req, dto) return dto, nil } return nil, wrapRepoErr(err) } if uc.RetryQueue == nil { return nil, errb.SysNotImplemented("async notification requires redis retry queue") } job := &domusecase.RetryJob{ NotificationID: doc.ID.Hex(), TenantID: req.TenantID, UID: req.UID, Channel: req.Channel, Kind: req.Kind, Target: req.Target, Locale: locale, Data: req.Data, DoNotPersistBody: req.DoNotPersistBody, } if err := ScheduleImmediate(ctx, uc.RetryQueue, job); err != nil { return nil, wrapStoreErr(err, "failed to schedule notification") } dto := entityToDTO(doc) uc.cacheIdempotent(ctx, req, dto) return dto, nil } func (uc *notifierUseCase) Get(ctx context.Context, tenantID, notificationID string) (*domusecase.NotificationDTO, error) { doc, err := uc.Repo.FindByID(ctx, tenantID, notificationID) if err != nil { return nil, wrapRepoErr(err) } return entityToDTO(doc), nil } func (uc *notifierUseCase) delivery() deliveryDeps { return deliveryDeps{ Renderer: uc.Renderer, Email: uc.Email, SMS: uc.SMS, Config: uc.Config, } } func (uc *notifierUseCase) newNotificationEntity(req *domusecase.SendRequest, locale string, status enum.NotifyStatus, attempts int) *domentity.Notification { severity := req.Severity if severity == "" { severity = enum.SeverityInfo } return &domentity.Notification{ TenantID: req.TenantID, UID: req.UID, Channel: req.Channel, Kind: req.Kind, TargetHash: hashTarget(req.Target), TemplateKey: string(req.Kind), Locale: locale, Status: status, Attempts: attempts, IdempotencyKey: req.IdempotencyKey, Severity: severity, } } func (uc *notifierUseCase) resolveLocale(locale string) string { if locale != "" { return locale } if uc.Config.DefaultLocale != "" { return uc.Config.DefaultLocale } return domtpl.LocaleZhTW } func (uc *notifierUseCase) lookupIdempotent(ctx context.Context, req *domusecase.SendRequest) (*domusecase.NotificationDTO, bool, error) { if uc.Idempotency != nil { key := notification.GetIdempotencyRedisKey(req.TenantID, string(req.Kind), req.IdempotencyKey) raw, err := uc.Idempotency.Get(ctx, key) if err != nil { return nil, false, wrapStoreErr(err, "idempotency cache read failed") } if len(raw) > 0 { var dto domusecase.NotificationDTO if err := json.Unmarshal(raw, &dto); err != nil { return nil, false, errb.DBDataConvert("idempotency cache decode failed").WithCause(err) } return &dto, true, nil } } existing, err := uc.Repo.FindByIdempotency(ctx, req.TenantID, req.Kind, req.IdempotencyKey) if err != nil { if errors.Is(err, notification.ErrNotFound) { return nil, false, nil } return nil, false, wrapRepoErr(err) } dto := entityToDTO(existing) uc.cacheIdempotent(ctx, req, dto) return dto, true, nil } func (uc *notifierUseCase) cacheIdempotent(ctx context.Context, req *domusecase.SendRequest, dto *domusecase.NotificationDTO) { if uc.Idempotency == nil || dto == nil { return } raw, err := json.Marshal(dto) if err != nil { return } key := notification.GetIdempotencyRedisKey(req.TenantID, string(req.Kind), req.IdempotencyKey) if err := uc.Idempotency.Set(ctx, key, raw, idempotencyCacheTTL); err != nil { return } } func (uc *notifierUseCase) checkQuota(ctx context.Context, req *domusecase.SendRequest) error { if uc.Quota == nil { return nil } limit := uc.quotaLimit(req.Channel) if limit <= 0 { return nil } day := time.Now().UTC().Format("20060102") key := notification.GetQuotaRedisKey(req.TenantID, string(req.Channel), day) count, err := uc.Quota.Incr(ctx, key, quotaKeyTTL) if err != nil { return wrapStoreErr(err, "quota check failed") } if count > int64(limit) { return errb.ResInsufficientQuota("notification daily quota exceeded") } return nil } func (uc *notifierUseCase) quotaLimit(ch enum.Channel) int { switch ch { case enum.ChannelEmail: return uc.Config.RatePerTenant.Email case enum.ChannelSMS: return uc.Config.RatePerTenant.SMS default: return 0 } } func (uc *notifierUseCase) markFailed(ctx context.Context, doc *domentity.Notification, cause error) error { if updateErr := uc.Repo.UpdateDelivery(ctx, doc.TenantID, doc.ID.Hex(), &domrepo.NotificationDeliveryUpdate{ Status: enum.NotifyStatusFailed, LastError: cause.Error(), Attempts: 1, }); updateErr != nil { cause = fmt.Errorf("%w; persist failed status: %w", cause, updateErr) } return errb.SvcInternal("notification template render failed").WithCause(cause) } func validateSendRequest(req *domusecase.SendRequest) error { if req == nil { return errb.InputMissingRequired("send request is required") } if req.TenantID == "" { return errb.InputMissingRequired("tenant_id is required") } if req.IdempotencyKey == "" { return errb.InputMissingRequired("idempotency_key is required") } if !req.Channel.IsValid() { return errb.InputInvalidFormat("invalid notification channel") } if req.Target == "" { return errb.InputMissingRequired("target is required") } if req.Kind == "" { return errb.InputMissingRequired("kind is required") } if !req.Kind.IsValid() { return errb.InputInvalidFormat("invalid notification kind") } return nil }