Files
creator-hub/internal/controlplane/api/creator_events.go
T

608 lines
22 KiB
Go

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
}