Files
dobotai-platform/internal/coverage/notification.go
T

151 lines
4.7 KiB
Go

package coverage
import (
"context"
"errors"
"fmt"
"reflect"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const (
SupportTriggerCoverageUncovered = "coverage_uncovered"
SupportTriggerFeedbackDown = "feedback_down"
)
// SupportNotification is a safe, minimal view of the data needed to send a
// notification. It must never contain tokens, webhooks, raw API responses,
// raw error text, reference bodies, or group IDs.
type SupportNotification struct {
CaseID string
TriggerType string
Question string
SenderName string
Channel string
ConversationType core.ConversationType
AskedAt time.Time
PreviousQuestions []string
FAQRecordID string
}
// PendingSupportCase holds the data needed to send a notification for a
// support case that is pending notification.
type PendingSupportCase struct {
SupportNotification
Attempts int
}
// NotificationRepository manages the notification lifecycle of support_cases.
type NotificationRepository interface {
// Claim atomically claims up to limit pending/retryable/expired-notifying
// support cases. Each case must have attempts < maxAttempts. Cases stuck
// in notifying with last_notification_at older than staleBefore are also
// eligible. Returns the claimed cases with their notification data.
Claim(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]PendingSupportCase, error)
// MarkNotified transitions a case from notifying to notified.
// RowsAffected must be exactly 1.
MarkNotified(ctx context.Context, caseID string) error
// MarkFailed transitions a case from notifying to notification_failed
// with a stable failure code. Raw errors must never be written.
// RowsAffected must be exactly 1.
MarkFailed(ctx context.Context, caseID string, failureCode string) error
}
// Notifier sends a notification. Matches the signature of
// dingtalk.SupportGroupSender.Send.
type Notifier interface {
Send(ctx context.Context, notif SupportNotification) error
}
// DispatcherStats holds sanitized dispatch statistics.
type DispatcherStats struct {
Claimed int
Notified int
Failed int
}
// DispatcherOption configures the Dispatcher.
type DispatcherOption func(*Dispatcher)
// WithBatchSize sets the batch size (1..100). Default 10.
func WithBatchSize(n int) DispatcherOption {
return func(d *Dispatcher) { d.BatchSize = n }
}
// WithMaxAttempts sets the max notification attempts (1..10). Default 3.
func WithMaxAttempts(n int) DispatcherOption {
return func(d *Dispatcher) { d.MaxAttempts = n }
}
// WithClaimLease sets the stale-notifying lease duration. Default 5 minutes.
func WithClaimLease(dur time.Duration) DispatcherOption {
return func(d *Dispatcher) { d.ClaimLease = dur }
}
// WithSendTimeout sets the per-item send timeout. Default 30 seconds.
func WithSendTimeout(dur time.Duration) DispatcherOption {
return func(d *Dispatcher) { d.SendTimeout = dur }
}
// Dispatcher claims pending support cases and sends them via the Notifier.
type Dispatcher struct {
repo NotificationRepository
notifier Notifier
BatchSize int
MaxAttempts int
ClaimLease time.Duration
SendTimeout time.Duration
}
// NewDispatcher creates a validated Dispatcher with sensible defaults.
// Defaults: BatchSize=10, MaxAttempts=3, ClaimLease=5m, SendTimeout=30s.
func NewDispatcher(repo NotificationRepository, notifier Notifier, opts ...DispatcherOption) (*Dispatcher, error) {
if isNilDependency(repo) {
return nil, errors.New("dispatcher: NotificationRepository is required")
}
if isNilDependency(notifier) {
return nil, errors.New("dispatcher: Notifier is required")
}
d := &Dispatcher{
repo: repo,
notifier: notifier,
BatchSize: 10,
MaxAttempts: 3,
ClaimLease: 5 * time.Minute,
SendTimeout: 30 * time.Second,
}
for _, o := range opts {
o(d)
}
if d.BatchSize < 1 || d.BatchSize > 100 {
return nil, fmt.Errorf("dispatcher: BatchSize must be 1..100, got %d", d.BatchSize)
}
if d.MaxAttempts < 1 || d.MaxAttempts > 10 {
return nil, fmt.Errorf("dispatcher: MaxAttempts must be 1..10, got %d", d.MaxAttempts)
}
if d.ClaimLease <= 0 || d.ClaimLease > 24*time.Hour {
return nil, fmt.Errorf("dispatcher: ClaimLease must be >0 and <=24h, got %v", d.ClaimLease)
}
if d.SendTimeout <= 0 || d.SendTimeout > 24*time.Hour {
return nil, fmt.Errorf("dispatcher: SendTimeout must be >0 and <=24h, got %v", d.SendTimeout)
}
return d, nil
}
func isNilDependency(value any) bool {
if value == nil {
return true
}
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return v.IsNil()
default:
return false
}
}