151 lines
4.7 KiB
Go
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
|
|
}
|
|
}
|