package api import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "sync" "sync/atomic" "time" accountdomain "git.ipao.vip/rogee/creator-hub/internal/account" "git.ipao.vip/rogee/creator-hub/internal/creator" hub "git.ipao.vip/rogee/creator-hub/internal/environment" "github.com/sirupsen/logrus" ) const creatorEventReconcileInterval = 10 * time.Second type creatorGatewayEvent struct { DeliveryID string `json:"delivery_id,omitempty"` Generation string `json:"generation,omitempty"` Kind string `json:"kind"` Reason string `json:"reason,omitempty"` Continuity string `json:"continuity,omitempty"` BoundaryAt string `json:"boundary_at,omitempty"` BoundarySource string `json:"boundary_source,omitempty"` Baseline bool `json:"baseline,omitempty"` Notice *creatorGatewayEventNotice `json:"notice,omitempty"` } type creatorGatewayEventNotice struct { EventKey string `json:"event_key"` EventType string `json:"event_type"` InteractorUID string `json:"interactor_uid"` CommentID string `json:"comment_id"` WorkID string `json:"work_id"` MessageType string `json:"message_type,omitempty"` MessageText string `json:"message_text,omitempty"` PlatformEventAt string `json:"platform_event_at,omitempty"` GatewayReceivedAt string `json:"gateway_received_at,omitempty"` } type creatorEventBinding struct { accountID string uid string env hub.EnvironmentContext gateway hub.Gateway hubStore *hub.Store sessionToken string } type creatorUpdateHub struct { mu sync.Mutex subscribers map[chan struct{}]struct{} } var creatorUpdates = &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})} func (h *creatorUpdateHub) subscribe() (<-chan struct{}, func()) { channel := make(chan struct{}, 1) h.mu.Lock() h.subscribers[channel] = struct{}{} h.mu.Unlock() return channel, func() { h.mu.Lock() if _, ok := h.subscribers[channel]; ok { delete(h.subscribers, channel) close(channel) } h.mu.Unlock() } } func (h *creatorUpdateHub) publish() { h.mu.Lock() defer h.mu.Unlock() for channel := range h.subscribers { select { case channel <- struct{}{}: default: } } } func creatorListenerGeneration(env hub.EnvironmentContext) string { return fmt.Sprintf("%s:%s:%d", env.RuntimeID, env.RuntimeNetworkID, env.BindingVersion) } var creatorListenerSessionNonce atomic.Uint64 func creatorListenerSessionToken(env hub.EnvironmentContext) string { return fmt.Sprintf("%s/%d-%d", creatorListenerGeneration(env), time.Now().UnixNano(), creatorListenerSessionNonce.Add(1)) } func listenerBoundaryPointer(value time.Time) *time.Time { if value.IsZero() { return nil } value = value.UTC() return &value } func persistCreatorListenerState(ctx context.Context, store *creator.Store, binding creatorEventBinding, status, reason string, boundaryAt *time.Time, deliveryID string) { if store == nil { return } if _, err := store.UpsertListenerState(ctx, creator.ListenerState{ AccountID: binding.accountID, Platform: creator.PlatformDouyin, Generation: creatorListenerGeneration(binding.env), SessionToken: binding.sessionToken, Status: status, BoundaryAt: boundaryAt, LastDeliveryID: deliveryID, Reason: reason, }); err != nil { logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator listener state persistence failed") } } func (binding creatorEventBinding) key() string { return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s\x00%s\x00%s\x00%s", binding.gateway.Name, binding.gateway.Endpoint, binding.gateway.Token, binding.env.BindingVersion, binding.env.RuntimeID, binding.env.RuntimeNetworkID, binding.env.Exit.ID, binding.uid) } type creatorEventListenerHandle struct { cancel context.CancelFunc done chan struct{} key string } type creatorEventListenerManager struct { mu sync.Mutex items map[string]creatorEventListenerHandle } func RunCreatorEventListeners(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator) { manager := &creatorEventListenerManager{items: map[string]creatorEventListenerHandle{}} ticker := time.NewTicker(creatorEventReconcileInterval) defer ticker.Stop() defer manager.close() for { if err := manager.reconcile(ctx, store, phaseAStore, hubStore, executor, generator); err != nil && ctx.Err() == nil { logrus.WithField("service", "control-plane").WithError(err).Warn("creator event listener reconciliation failed") } select { case <-ctx.Done(): return case <-ticker.C: } } } func (manager *creatorEventListenerManager) reconcile(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator) error { if store == nil || phaseAStore == nil || hubStore == nil { return creator.ErrUnavailable } if recovered, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()); err != nil { return err } else if recovered > 0 { logrus.WithField("count", recovered).Warn("recovered stale creator operations as uncertain") } accounts, err := phaseAStore.ListAccounts(ctx) if err != nil { return err } desired := make(map[string]creatorEventBinding) for _, account := range accounts { if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" { continue } profile, profileErr := store.GetAccountProfile(ctx, account.ID) if profileErr != nil { logrus.WithError(profileErr).WithField("account_id", account.ID).Warn("creator event listener account profile unavailable") continue } if profile.LoginStatus != "logged_in" { continue } if !creatorEventUID(profile.PlatformAccountKey) { logrus.WithField("account_id", account.ID).Warn("creator event listener account UID is unavailable") continue } environment, environmentErr := hubStore.GetEnvironmentContextForAccount(ctx, account.ID) if environmentErr != nil { logrus.WithError(environmentErr).WithField("account_id", account.ID).Warn("creator event listener environment unavailable") continue } if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { continue } gateway, gatewayErr := hubStore.GetGateway(ctx, environment.Gateway) if gatewayErr != nil { logrus.WithError(gatewayErr).WithField("account_id", account.ID).Warn("creator event listener gateway unavailable") continue } desired[account.ID] = creatorEventBinding{accountID: account.ID, uid: profile.PlatformAccountKey, env: environment, gateway: gateway, hubStore: hubStore} } var stopping []creatorEventListenerHandle manager.mu.Lock() for accountID, current := range manager.items { binding, ok := desired[accountID] if ok && current.key == binding.key() { state, stateErr := store.GetListenerState(ctx, accountID) if stateErr != nil || !state.Invalidated { continue } } current.cancel() delete(manager.items, accountID) stopping = append(stopping, current) } manager.mu.Unlock() for _, current := range stopping { <-current.done } manager.mu.Lock() defer manager.mu.Unlock() for accountID, binding := range desired { if _, ok := manager.items[accountID]; ok { continue } listenerContext, cancel := context.WithCancel(ctx) done := make(chan struct{}) listenerBinding := binding manager.items[accountID] = creatorEventListenerHandle{cancel: cancel, done: done, key: binding.key()} go func() { defer close(done) runCreatorEventListener(listenerContext, store, listenerBinding, executor, generator) }() } return nil } func (manager *creatorEventListenerManager) close() { manager.mu.Lock() handles := make([]creatorEventListenerHandle, 0, len(manager.items)) for accountID, current := range manager.items { current.cancel() handles = append(handles, current) delete(manager.items, accountID) } manager.mu.Unlock() for _, current := range handles { <-current.done } } func runCreatorEventListener(ctx context.Context, store *creator.Store, binding creatorEventBinding, executor creator.ActionExecutor, generator creator.TextGenerator) { binding.sessionToken = creatorListenerSessionToken(binding.env) path := "/v1/browsers/" + url.PathEscape(binding.env.Alias) + "/douyin/events" generation := gatewayGenerationPayload(binding.env) useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, binding.hubStore, binding.env, "listener", "creator-listener-"+binding.accountID) if err != nil { persistCreatorListenerState(context.WithoutCancel(ctx), store, binding, "error", "runtime use unavailable: "+err.Error(), nil, "") return } ctx = useCtx defer func() { if releaseErr := runtimeUse.Close(); releaseErr != nil { logrus.WithError(releaseErr).WithField("account_id", binding.accountID).Error("creator listener runtime use release failed") } }() startPayload := make(map[string]any, len(generation)+1) for key, value := range generation { startPayload[key] = value } startPayload["expected_uid"] = binding.uid persistCreatorListenerState(ctx, store, binding, "starting", "等待平台边界标记", nil, "") defer func() { stopCreatorEventListener(binding.accountID, binding.gateway, path, generation) persistCreatorListenerState(context.WithoutCancel(ctx), store, binding, "stopped", "监听已停止", nil, "") }() backoff := time.Second for ctx.Err() == nil { status, _, err := gatewayCall(ctx, binding.gateway, http.MethodPost, path, startPayload, 30*time.Second) if err != nil || status != http.StatusOK { if err == nil { err = fmt.Errorf("gateway returned HTTP %d", status) } logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener start failed") persistCreatorListenerState(ctx, store, binding, "error", err.Error(), nil, "") if !waitCreatorEventBackoff(ctx, backoff) { return } backoff *= 2 if backoff > 30*time.Second { backoff = 30 * time.Second } continue } backoff = time.Second // Every successful start creates a new boundary. Events observed before // its marker remain historical even when the previous poll loop was ready. ready := false var boundaryAt time.Time for ctx.Err() == nil { status, body, err := gatewayCall(ctx, binding.gateway, http.MethodGet, path+"?limit=100&wait=25", generation, 35*time.Second) if err != nil || status != http.StatusOK { if err == nil { err = fmt.Errorf("gateway returned HTTP %d", status) } logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener poll failed") persistCreatorListenerState(ctx, store, binding, "gap", err.Error(), listenerBoundaryPointer(boundaryAt), "") break } var events []creatorGatewayEvent if err := json.Unmarshal(body, &events); err != nil { logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener response is invalid") persistCreatorListenerState(ctx, store, binding, "gap", "监听响应无法解析: "+err.Error(), listenerBoundaryPointer(boundaryAt), "") break } for _, event := range events { if event.Kind == "baseline" { parsedBoundary, boundaryReady, reason := creatorGatewayBoundary(event) boundaryAt, ready = parsedBoundary, boundaryReady status := "gap" if ready { status, reason = "ready", "" } persistCreatorListenerState(ctx, store, binding, status, reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) } else if event.Kind == "open" || event.Kind == "error" || event.Kind == "close" || event.Kind == "reconnected" { // A transport event never proves continuity. Only the explicit // boundary marker permits automatic writes again. ready = false persistCreatorListenerState(ctx, store, binding, "gap", event.Reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) } if event.Kind == "notice" { if needsBaseline, reason := creatorGatewayEventNeedsBaseline(event, ready); needsBaseline { event.Baseline = true event.Reason = reason } else if creatorEventBeforeBoundary(event, boundaryAt) { event.Baseline = true event.Reason = "平台事件早于监听边界" } status := "gap" reason := event.Reason if ready && !event.Baseline { status, reason = "ready", "" } persistCreatorListenerState(ctx, store, binding, status, reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) } handleCreatorGatewayEvent(ctx, store, binding, event, executor, generator) } } if !waitCreatorEventBackoff(ctx, backoff) { return } backoff *= 2 if backoff > 30*time.Second { backoff = 30 * time.Second } } } func stopCreatorEventListener(accountID string, gateway hub.Gateway, path string, generation map[string]any) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if status, _, err := gatewayCall(ctx, gateway, http.MethodDelete, path, generation, 5*time.Second); err != nil || status != http.StatusNoContent { if err == nil { err = fmt.Errorf("gateway returned HTTP %d", status) } logrus.WithError(err).WithField("account_id", accountID).Warn("creator event listener stop failed") } } func waitCreatorEventBackoff(ctx context.Context, delay time.Duration) bool { timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } func pathForCreatorEvent(environment hub.EnvironmentContext) string { return "/v1/browsers/" + url.PathEscape(environment.Alias) + "/douyin/events" } func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, binding creatorEventBinding, event creatorGatewayEvent, executor creator.ActionExecutor, generator creator.TextGenerator) { ack := func() { if event.DeliveryID == "" { return } // Acknowledgement is deliberately detached from the listener poll loop: // receipt and event classification must not be serialized behind a slow // gateway request. The gateway keeps the delivery until this succeeds. go func(deliveryID string) { ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() ackPath := pathForCreatorEvent(binding.env) status, _, ackErr := gatewayCall(ackCtx, binding.gateway, http.MethodGet, ackPath+"?ack="+url.QueryEscape(deliveryID)+"&limit=1&wait=0", gatewayGenerationPayload(binding.env), 5*time.Second) if ackErr != nil || status != http.StatusOK { if ackErr == nil { ackErr = fmt.Errorf("gateway returned HTTP %d", status) } logrus.WithError(ackErr).WithField("account_id", binding.accountID).Warn("creator event acknowledgement failed") } }(event.DeliveryID) } switch event.Kind { case "error": logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "reason": event.Reason, "continuity": event.Continuity}).Warn("creator event listener reported an error") ack() return case "reconnected": logrus.WithField("account_id", binding.accountID).Warn("creator event listener reconnected") ack() return case "open", "baseline": ack() return case "close": logrus.WithField("account_id", binding.accountID).Warn("creator event listener connection closed") ack() return case "notice": default: logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "kind": event.Kind}).Warn("creator event listener returned an unknown event") ack() return } if event.Notice == nil { logrus.WithField("account_id", binding.accountID).Warn("creator event listener notice is missing") ack() return } input, err := creatorEventFromGatewayNotice(binding.accountID, *event.Notice) if err != nil { logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener notice was rejected") ack() return } input.Baseline = event.Baseline input.BaselineReason = event.Reason input.Generation = strings.TrimSpace(event.Generation) if input.Generation == "" { input.Generation = creatorListenerGeneration(binding.env) } selfEvent := binding.uid != "" && input.InteractorUID == binding.uid if selfEvent { input.Baseline = true input.BaselineReason = "接收账号主动行为" } if input.GatewayReceivedAt == nil { receivedAt := time.Now().UTC() input.GatewayReceivedAt = &receivedAt } if input.PlatformEventAt == nil { input.Baseline = true input.BaselineReason = "缺少平台事件时间" } if store == nil { return } // Receipt is durable before the potentially slow action. This prevents an // executor outage from erasing the platform notification and lets polling // continue while a prior action is still in flight. received, err := store.RecordEvent(ctx, input) if err != nil { logrus.WithError(err).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator event receipt failed") return } creatorUpdates.publish() if input.EventType == "dm" && input.InteractorUID != "" { messageAt := input.PlatformEventAt if messageAt == nil { receivedAt := input.ReceivedAt if receivedAt.IsZero() { receivedAt = time.Now().UTC() } messageAt = &receivedAt } direction, sentState := "inbound", "received" if selfEvent { direction, sentState = "outbound", "succeeded" } savedMessage, _, messageErr := store.SaveMessage(ctx, creator.MessageInput{Platform: input.Platform, AccountID: input.ReceivingAccountID, PeerUID: input.InteractorUID, PlatformMessageKey: input.EventKey, Direction: direction, MessageType: input.MessageType, Text: input.MessageText, SentState: sentState, MessageAt: messageAt}) if messageErr != nil { logrus.WithError(messageErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator direct message persistence failed") return } if selfEvent { if err := store.LinkMessageOperation(ctx, savedMessage.ID, input.EventKey); err != nil { logrus.WithError(err).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator outbound direct message correlation failed") return } } } // The event is now durable. An action may be slow or unavailable, but that // must not hold ingestion or cause the same receipt to be fetched forever. ack() if input.Baseline || received.Event.State != "received" { return } go func() { result, processErr := store.ProcessAutomaticEvent(ctx, input, executor, generator) if processErr != nil { logrus.WithError(processErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator event processing failed") return } logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey, "event_type": input.EventType, "state": result.Event.State}).Info("creator event processed") creatorUpdates.publish() }() } func creatorGatewayBoundary(event creatorGatewayEvent) (time.Time, bool, string) { if event.BoundarySource != "douyin_identity_extra_now" { return time.Time{}, false, "平台边界来源未验证" } if event.BoundaryAt == "" { return time.Time{}, false, "平台边界无效" } parsedBoundary, err := time.Parse(time.RFC3339Nano, event.BoundaryAt) if err != nil { return time.Time{}, false, "平台边界无效" } return parsedBoundary.UTC(), true, "" } func creatorEventBeforeBoundary(event creatorGatewayEvent, boundaryAt time.Time) bool { if boundaryAt.IsZero() || event.Notice == nil { return false } platformAt, err := time.Parse(time.RFC3339Nano, event.Notice.PlatformEventAt) return err != nil || !platformAt.After(boundaryAt) } func creatorGatewayEventNeedsBaseline(event creatorGatewayEvent, ready bool) (bool, string) { if event.Baseline { if event.Reason != "" { return true, event.Reason } return true, "监听基线" } if !ready { return true, "监听边界未确认" } if event.Notice == nil || strings.TrimSpace(event.Notice.PlatformEventAt) == "" { return true, "缺少平台事件时间" } return false, "" } func creatorEventFromGatewayNotice(accountID string, notice creatorGatewayEventNotice) (creator.InteractionEvent, error) { if accountID == "" || !creatorEventID(notice.EventKey) || !creator.ValidEventType(notice.EventType) { return creator.InteractionEvent{}, creator.ErrInvalid } if notice.InteractorUID != "" && !creatorEventUID(notice.InteractorUID) { return creator.InteractionEvent{}, creator.ErrInvalid } if (notice.CommentID != "" && !creatorEventID(notice.CommentID)) || (notice.WorkID != "" && !creatorEventID(notice.WorkID)) { return creator.InteractionEvent{}, creator.ErrInvalid } messageType := strings.TrimSpace(notice.MessageType) if messageType == "" { messageType = creator.MessageTypeText } if !creator.ValidMessageType(messageType) { return creator.InteractionEvent{}, creator.ErrInvalid } result := creator.InteractionEvent{Platform: creator.PlatformDouyin, ReceivingAccountID: accountID, EventKey: notice.EventKey, EventType: notice.EventType, InteractorUID: notice.InteractorUID, CommentID: notice.CommentID, WorkID: notice.WorkID, MessageType: messageType, MessageText: strings.TrimSpace(notice.MessageText)} if strings.TrimSpace(notice.PlatformEventAt) != "" { at, err := time.Parse(time.RFC3339Nano, notice.PlatformEventAt) if err != nil { return creator.InteractionEvent{}, fmt.Errorf("invalid platform event time: %w", err) } at = at.UTC() result.PlatformEventAt = &at } if strings.TrimSpace(notice.GatewayReceivedAt) != "" { receivedAt, err := time.Parse(time.RFC3339Nano, notice.GatewayReceivedAt) if err != nil { return creator.InteractionEvent{}, fmt.Errorf("invalid gateway receipt time: %w", err) } receivedAt = receivedAt.UTC() result.GatewayReceivedAt = &receivedAt } return result, nil } func creatorEventUID(value string) bool { return creatorEventDigits(value, 20) } func creatorEventID(value string) bool { return creatorEventDigits(value, 64) } func creatorEventDigits(value string, max int) bool { if value == "" || len(value) > max || value[0] < '1' || value[0] > '9' { return false } for _, char := range value[1:] { if char < '0' || char > '9' { return false } } return true }