package main import ( "context" "encoding/json" "time" "github.com/segmentio/kafka-go" "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) type kafkaWorkbenchPayload struct { Module string `json:"module"` Action string `json:"action"` ResourceID string `json:"resource_id"` ActorUserID string `json:"actor_user_id"` TargetUserIDs []string `json:"target_user_ids,omitempty"` Title string `json:"title,omitempty"` Summary string `json:"summary,omitempty"` Data map[string]any `json:"data,omitempty"` OccurredAt time.Time `json:"occurred_at"` } type kafkaAuditPayload struct { ActorType string `json:"actor_type"` ActorID string `json:"actor_id"` Action string `json:"action"` Resource string `json:"resource"` ResourceID string `json:"resource_id"` Result string `json:"result"` Reason string `json:"reason,omitempty"` Data map[string]any `json:"data,omitempty"` OccurredAt time.Time `json:"occurred_at"` } func startKafkaWorkbenchEventConsumer() { startKafkaConsumer("workbench_event", handleKafkaWorkbenchEvent) } func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, targets []types.Uid, title, summary string, data map[string]any) error { if !kafkaEnabled() { return nil } targetUserIDs := make([]string, 0, len(targets)) for _, target := range targets { if !target.IsZero() { targetUserIDs = append(targetUserIDs, target.UserId()) } } payload := kafkaWorkbenchPayload{ Module: module, Action: action, ResourceID: resourceID, ActorUserID: actor.UserId(), TargetUserIDs: targetUserIDs, Title: title, Summary: summary, Data: data, OccurredAt: time.Now().UTC().Round(time.Millisecond), } return enqueueKafkaTypedEvent(ctx, tenantID, "workbench_event", "workbench."+action, "workbench", resourceID, actor, payload) } func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorType string, actor types.Uid, action, resource, resourceID, result, reason string, data map[string]any) error { if !kafkaEnabled() { return nil } actorID := "" if !actor.IsZero() { actorID = actor.UserId() } payload := kafkaAuditPayload{ ActorType: actorType, ActorID: actorID, Action: action, Resource: resource, ResourceID: resourceID, Result: result, Reason: reason, Data: data, OccurredAt: time.Now().UTC().Round(time.Millisecond), } return enqueueKafkaTypedEvent(ctx, tenantID, "audit_event", "audit."+action, "audit", resource+":"+resourceID, actor, payload) } func enqueueKafkaTypedEvent(_ context.Context, tenantID types.TenantID, topicName, eventType, bizType, bizID string, actor types.Uid, payload any) error { tenantCode := "" if store.Tenants != nil { if tenant, err := store.Tenants.GetByID(tenantID); err == nil && tenant != nil { tenantCode = tenant.Code } else { logs.Warn.Printf("kafka event: failed to resolve tenant code for tenant %d: %v", tenantID, err) } } encoded, err := json.Marshal(payload) if err != nil { return err } eventID := stableIMEventID(topicName, tenantID, eventType, bizID, encoded) envelope := kafkaEnvelope{ EventID: eventID, TenantCode: tenantCode, SourceService: "im-server", TargetService: "im-server", EventType: eventType, BizType: bizType, BizID: bizID, OccurredAt: time.Now().UTC(), Payload: encoded, } if !actor.IsZero() { envelope.Actor = &kafkaActor{Type: "user", ID: actor.UserId()} } return enqueueKafkaOutbox(tenantID, topicName, bizID, envelope) } func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error { tenantID, err := kafkaTenantID(envelope.TenantCode) if err != nil { return err } consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "workbench_event") if err != nil || !claimed { return err } var payload kafkaWorkbenchPayload if err = json.Unmarshal(envelope.Payload, &payload); err != nil { return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error()) } if err = executeKafkaWorkbenchEvent(tenantID, envelope, payload); err != nil { failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond)) if markErr != nil { return markErr } if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount { return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error()) } sleepKafkaRetryBackoff(failed.RetryCount) return err } return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)) } func executeKafkaWorkbenchEvent(tenantID types.TenantID, envelope kafkaEnvelope, payload kafkaWorkbenchPayload) error { actor := types.ParseUserId(payload.ActorUserID) if actor.IsZero() { actor = types.ParseUid(payload.ActorUserID) } for _, raw := range payload.TargetUserIDs { target := types.ParseUserId(raw) if target.IsZero() { continue } eventID := stableIMEventID("workbench", envelope.EventID, target.UserId()) if target != actor && payload.Title != "" { if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), payload.Action, payload.Title, payload.Summary, types.KVMap{"module": payload.Module, "resource_id": payload.ResourceID}); err != nil { return err } } if err := enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, payload.Action, payload.Data); err != nil { return err } } return nil }