package api import ( "bufio" "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math/rand" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "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" douyin "git.ipao.vip/rogee/creator-hub/internal/platform/douyin" "github.com/gofiber/fiber/v3" "github.com/sirupsen/logrus" ) func creatorPageQuery(c fiber.Ctx) (page, pageSize int, enabled bool, err error) { pageValue, pageSizeValue := strings.TrimSpace(c.Query("page")), strings.TrimSpace(c.Query("page_size")) if pageValue == "" && pageSizeValue == "" { return 0, 0, false, nil } page, pageSize = 1, 25 if pageValue != "" { page, err = strconv.Atoi(pageValue) if err != nil { return 0, 0, true, creator.ErrInvalid } } if pageSizeValue != "" { pageSize, err = strconv.Atoi(pageSizeValue) if err != nil { return 0, 0, true, creator.ErrInvalid } } return page, pageSize, true, nil } func registerCreator(app *fiber.App, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store) { var executor creator.ActionExecutor if store != nil && phaseAStore != nil && hubStore != nil { executor = creatorGatewayActionExecutor{store: store, phaseAStore: phaseAStore, hubStore: hubStore} } registerCreatorWithServices(app, store, phaseAStore, hubStore, executor, nil, nil) } func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) { // Platform records enter through the managed collector/listener, not a public // client-supplied write. The explicit test namespace is kept for isolated // contract tests and never participates in the production listener. for _, path := range []string{ "/api/creator/works", "/api/creator/comments", "/api/creator/events", "/api/creator/events/process", "/api/creator/messages", } { app.Post(path, func(c fiber.Ctx) error { return creatorError(c, creator.ErrConflict) }) } app.Get("/api/creator/settings", func(c fiber.Ctx) error { settings, err := store.GetSettings(c.Context()) if err != nil { return creatorError(c, err) } return c.JSON(settings) }) app.Put("/api/creator/settings", func(c fiber.Ctx) error { var input creator.SettingsUpdate if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } settings, err := store.UpdateSettings(c.Context(), input) if err != nil { return creatorError(c, err) } return c.JSON(settings) }) app.Get("/api/creator/updates", func(c fiber.Ctx) error { updates, unsubscribe := creatorUpdates.subscribe() requestCtx := c.RequestCtx() c.Set("Content-Type", "text/event-stream") c.Set("Cache-Control", "no-cache") c.Set("Connection", "keep-alive") c.Set("Transfer-Encoding", "chunked") return c.SendStreamWriter(func(w *bufio.Writer) { defer unsubscribe() if _, err := fmt.Fprint(w, "retry: 5000\n\n"); err != nil { return } if err := w.Flush(); err != nil { return } heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() for { select { case <-updates: if _, err := fmt.Fprint(w, "event: creator-update\ndata: {}\n\n"); err != nil { return } case <-heartbeat.C: if _, err := fmt.Fprint(w, ": keep-alive\n\n"); err != nil { return } case <-requestCtx.Done(): return } if err := w.Flush(); err != nil { return } } }) }) app.Get("/api/creator/accounts", func(c fiber.Ctx) error { profiles, err := store.ListAccountProfiles(c.Context()) if err != nil { return creatorError(c, err) } return c.JSON(profiles) }) app.Get("/api/creator/accounts/:id/profile", func(c fiber.Ctx) error { profile, err := store.GetAccountProfile(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(profile) }) app.Put("/api/creator/accounts/:id/profile", func(c fiber.Ctx) error { var input creator.AccountProfileUpdate if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } profile, err := store.UpdateAccountProfile(c.Context(), c.Params("id"), input) if err != nil { return creatorError(c, err) } return c.JSON(profile) }) app.Put("/api/creator/accounts/:id/tags", func(c fiber.Ctx) error { var input struct { Tags []string `json:"tags"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } tags, err := store.UpdateAccountTags(c.Context(), c.Params("id"), input.Tags) if err != nil { return creatorError(c, err) } return c.JSON(fiber.Map{"id": c.Params("id"), "tags": tags}) }) app.Post("/api/creator/accounts/:id/login-result", func(c fiber.Ctx) error { var input struct { Status string `json:"status"` Reason string `json:"reason"` ActualKey string `json:"actual_platform_account_key"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } if input.Status == "logged_in" { return creatorError(c, creator.ErrConflict) } result, err := store.RecordLoginResult(c.Context(), c.Params("id"), input.Status, input.Reason, input.ActualKey) if err != nil { return creatorError(c, err) } return c.JSON(result) }) app.Post("/api/creator/accounts/:id/verify", func(c fiber.Ctx) error { result, err := verifyCreatorAccount(c.Context(), store, phaseAStore, hubStore, c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(result) }) app.Post("/api/creator/accounts/:id/login-qr", func(c fiber.Ctx) error { result, err := creatorLoginQRCode(c.Context(), store, phaseAStore, hubStore, c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(result) }) app.Post("/api/creator/accounts/:id/big-account", func(c fiber.Ctx) error { var input struct { Enabled bool `json:"enabled"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } profile, err := store.SetBigAccount(c.Context(), c.Params("id"), input.Enabled) if err != nil { return creatorError(c, err) } return c.JSON(profile) }) app.Get("/api/creator/relations", func(c fiber.Ctx) error { relations, err := store.ListRelations(c.Context(), c.Query("big_account_id")) if err != nil { return creatorError(c, err) } return c.JSON(relations) }) app.Post("/api/creator/relations", func(c fiber.Ctx) error { var input struct { creator.Relation Enabled bool `json:"enabled"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } if err := store.SetRelation(c.Context(), input.BigAccountID, input.SmallAccountID, input.Enabled); err != nil { return creatorError(c, err) } return c.SendStatus(fiber.StatusNoContent) }) app.Get("/api/creator/accounts/:id/strategies", func(c fiber.Ctx) error { strategies, err := store.ListStrategies(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(strategies) }) app.Post("/api/creator/accounts/:id/strategies", func(c fiber.Ctx) error { var input creator.StrategyInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } strategy, err := store.CreateStrategy(c.Context(), c.Params("id"), input) if err != nil { return creatorError(c, err) } return c.Status(fiber.StatusCreated).JSON(strategy) }) app.Put("/api/creator/strategies/:id", func(c fiber.Ctx) error { var input creator.StrategyInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } strategy, err := store.UpdateStrategy(c.Context(), c.Params("id"), input) if err != nil { return creatorError(c, err) } return c.JSON(strategy) }) app.Post("/api/creator/strategies/:id/enable", func(c fiber.Ctx) error { return setStrategyEnabled(c, store, true) }) app.Post("/api/creator/strategies/:id/disable", func(c fiber.Ctx) error { return setStrategyEnabled(c, store, false) }) app.Delete("/api/creator/strategies/:id", func(c fiber.Ctx) error { if err := store.DeleteStrategy(c.Context(), c.Params("id")); err != nil { return creatorError(c, err) } return c.SendStatus(fiber.StatusNoContent) }) app.Get("/api/creator/competitors", func(c fiber.Ctx) error { items, err := store.ListCompetitorsWithProfile(c.Context(), c.Query("platform")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Get("/api/creator/competitor-share-jobs", func(c fiber.Ctx) error { items, err := store.ListCompetitorShareJobsWithAuthor(c.Context(), c.Query("platform"), c.Query("status")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Get("/api/creator/competitor-share-jobs/:id", func(c fiber.Ctx) error { item, err := store.GetCompetitorShareJob(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/competitor-share-jobs", func(c fiber.Ctx) error { var request creator.CompetitorShareJobInput if err := decodeCreator(c, &request); err != nil { return creatorError(c, err) } platform, err := competitorSharePlatform(request.ShareURL) if err != nil { return creatorError(c, err) } if request.Platform != "" && request.Platform != platform { return creatorError(c, creator.ErrInvalid) } request.Platform = platform item, err := store.CreateCompetitorShareJob(c.Context(), request) if err != nil { return creatorError(c, err) } return c.Status(fiber.StatusAccepted).JSON(item) }) app.Post("/api/creator/competitor-share-jobs/:id/retry", func(c fiber.Ctx) error { item, err := store.RetryCompetitorShareJob(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.Status(fiber.StatusAccepted).JSON(item) }) app.Get("/api/creator/competitors/:id", func(c fiber.Ctx) error { item, err := store.GetCompetitor(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Put("/api/creator/competitors/:id", func(c fiber.Ctx) error { var input struct { Tags []string `json:"tags"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } item, err := store.UpdateCompetitorTags(c.Context(), c.Params("id"), input.Tags) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/competitors/:id/pause", func(c fiber.Ctx) error { item, err := store.SetCompetitorEnabled(c.Context(), c.Params("id"), false) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/competitors/:id/resume", func(c fiber.Ctx) error { item, err := store.SetCompetitorEnabled(c.Context(), c.Params("id"), true) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/competitors/:id/sync", func(c fiber.Ctx) error { var input struct { AccountID string `json:"account_id"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } report, err := syncCreatorCompetitor(c.Context(), store, phaseAStore, hubStore, c.Params("id"), input.AccountID) if err != nil { return creatorError(c, err) } return c.Status(fiber.StatusAccepted).JSON(report) }) app.Get("/api/creator/works", func(c fiber.Ctx) error { filter, err := workFilter(c) if err != nil { return creatorError(c, err) } page, pageSize, paged, err := creatorPageQuery(c) if err != nil { return creatorError(c, err) } if paged { items, err := store.ListWorksPage(c.Context(), filter, page, pageSize) if err != nil { return creatorError(c, err) } return c.JSON(items) } items, err := store.ListWorks(c.Context(), filter) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Post("/api/creator/test/works", func(c fiber.Ctx) error { var input creator.WorkInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } item, inserted, err := store.UpsertWork(c.Context(), input, time.Now().UTC()) if err != nil { return creatorError(c, err) } status := fiber.StatusOK if inserted { status = fiber.StatusCreated } return c.Status(status).JSON(item) }) app.Get("/api/creator/works/:id", func(c fiber.Ctx) error { item, err := store.GetWork(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Get("/api/creator/works/:id/metrics", func(c fiber.Ctx) error { items, err := store.ListMetrics(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Post("/api/creator/works/:id/metrics", func(c fiber.Ctx) error { var input struct { CollectedAt time.Time `json:"collected_at"` Likes *int64 `json:"likes"` CommentsCount *int64 `json:"comments_count"` Shares *int64 `json:"shares"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } if input.CollectedAt.IsZero() { input.CollectedAt = time.Now().UTC() } settings, err := store.GetSettings(c.Context()) if err != nil { return creatorError(c, err) } point, err := store.RecordMetric(c.Context(), creator.MetricInput{WorkID: c.Params("id"), CollectedAt: input.CollectedAt, Likes: input.Likes, CommentsCount: input.CommentsCount, Shares: input.Shares}, settings, time.Now().UTC()) if err != nil { return creatorError(c, err) } return c.JSON(point) }) app.Get("/api/creator/works/:id/material", func(c fiber.Ctx) error { item, err := store.GetMaterial(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/works/:id/material/select", func(c fiber.Ctx) error { item, inserted, err := store.SelectMaterial(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } status := fiber.StatusOK if inserted { status = fiber.StatusCreated } return c.Status(status).JSON(item) }) app.Post("/api/creator/works/:id/material/process", func(c fiber.Ctx) error { item, err := processCreatorMaterial(c.Context(), store, phaseAStore, hubStore, c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/works/:id/material/rewrite/confirm", func(c fiber.Ctx) error { var input struct { Requirement string `json:"requirement"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } item, err := store.ConfirmRewrite(c.Context(), c.Params("id"), input.Requirement) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/works/:id/material/rewrite/generate", func(c fiber.Ctx) error { item, err := store.GenerateRewrite(c.Context(), c.Params("id"), generator) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Put("/api/creator/works/:id/material/rewrite", func(c fiber.Ctx) error { var input struct { Title string `json:"title"` Script string `json:"script"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } item, err := store.SaveRewrite(c.Context(), c.Params("id"), input.Title, input.Script) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Get("/api/creator/comments", func(c fiber.Ctx) error { page, pageSize, paged, err := creatorPageQuery(c) if err != nil { return creatorError(c, err) } if paged { items, err := store.ListCommentsPage(c.Context(), c.Query("platform"), c.Query("work_id"), page, pageSize) if err != nil { return creatorError(c, err) } return c.JSON(items) } items, err := store.ListComments(c.Context(), c.Query("platform"), c.Query("work_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Post("/api/creator/test/comments", func(c fiber.Ctx) error { var input creator.CommentInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } item, inserted, err := store.SaveComment(c.Context(), input) if err != nil { return creatorError(c, err) } status := fiber.StatusOK if inserted { status = fiber.StatusCreated } return c.Status(status).JSON(item) }) app.Get("/api/creator/comments/:id", func(c fiber.Ctx) error { item, err := store.GetComment(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Get("/api/creator/rules", func(c fiber.Ctx) error { items, err := store.ListRules(c.Context(), c.Query("enabled_only") == "true") if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Post("/api/creator/rules", func(c fiber.Ctx) error { var input creator.LeadRuleInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } item, err := store.CreateRule(c.Context(), input) if err != nil { return creatorError(c, err) } return c.Status(fiber.StatusCreated).JSON(item) }) app.Get("/api/creator/rules/:id", func(c fiber.Ctx) error { item, err := store.GetRule(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Put("/api/creator/rules/:id", func(c fiber.Ctx) error { var input creator.LeadRuleInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } item, err := store.UpdateRule(c.Context(), c.Params("id"), input) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/rules/:id/enable", func(c fiber.Ctx) error { item, err := store.SetRuleEnabled(c.Context(), c.Params("id"), true) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Post("/api/creator/rules/:id/disable", func(c fiber.Ctx) error { item, err := store.SetRuleEnabled(c.Context(), c.Params("id"), false) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Get("/api/creator/rule-results", func(c fiber.Ctx) error { items, err := store.ListRuleResults(c.Context(), c.Query("comment_id"), c.Query("rule_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Get("/api/creator/leads", func(c fiber.Ctx) error { items, err := store.ListLeads(c.Context(), c.Query("platform")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Post("/api/creator/comments/analyze", func(c fiber.Ctx) error { var input struct { CommentIDs []string `json:"comment_ids"` RuleID string `json:"rule_id"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } items, err := store.AnalyzeComments(c.Context(), input.CommentIDs, input.RuleID, analyzer) if err != nil && len(items) == 0 { return creatorError(c, err) } status := fiber.StatusOK if errors.Is(err, creator.ErrUnavailable) { status = fiber.StatusServiceUnavailable } else if err != nil { status = fiber.StatusMultiStatus } return c.Status(status).JSON(map[string]any{"items": items}) }) app.Post("/api/creator/comments/:id/analyze", func(c fiber.Ctx) error { var input struct { RuleID string `json:"rule_id"` } if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } result, err := store.AnalyzeComment(c.Context(), c.Params("id"), input.RuleID, analyzer) if err != nil && !errors.Is(err, creator.ErrUnavailable) { return creatorError(c, err) } status := fiber.StatusOK if errors.Is(err, creator.ErrUnavailable) { status = fiber.StatusServiceUnavailable } return c.Status(status).JSON(result) }) app.Get("/api/creator/listener-boundaries", func(c fiber.Ctx) error { items, err := store.ListListenerBoundaries(c.Context(), c.Query("account_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Get("/api/creator/events", func(c fiber.Ctx) error { page, pageSize, paged, err := creatorPageQuery(c) if err != nil { return creatorError(c, err) } if paged { items, err := store.ListEventsPage(c.Context(), c.Query("account_id"), page, pageSize) if err != nil { return creatorError(c, err) } return c.JSON(items) } items, err := store.ListEvents(c.Context(), c.Query("account_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Get("/api/creator/listeners", func(c fiber.Ctx) error { items, err := store.ListListenerStates(c.Context(), c.Query("account_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Post("/api/creator/test/events", func(c fiber.Ctx) error { var input creator.InteractionEvent if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } result, err := store.RecordEvent(c.Context(), input) if err != nil { return creatorError(c, err) } creatorUpdates.publish() status := fiber.StatusOK if !result.Duplicate { status = fiber.StatusCreated } return c.Status(status).JSON(result) }) app.Post("/api/creator/test/events/process", func(c fiber.Ctx) error { var input creator.InteractionEvent if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } result, err := store.ProcessAutomaticEvent(c.Context(), input, executor, generator) if err != nil && !errors.Is(err, creator.ErrUnavailable) { return creatorError(c, err) } status := fiber.StatusOK if errors.Is(err, creator.ErrUnavailable) { status = fiber.StatusServiceUnavailable } creatorUpdates.publish() return c.Status(status).JSON(result) }) app.Post("/api/creator/events/:id/display", func(c fiber.Ctx) error { event, err := store.SetEventDisplayed(c.Context(), c.Params("id"), time.Now().UTC()) if err != nil { return creatorError(c, err) } creatorUpdates.publish() return c.JSON(event) }) app.Get("/api/creator/events/:id/strategy-trace", func(c fiber.Ctx) error { items, err := store.ListStrategyTraces(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Get("/api/creator/operations", func(c fiber.Ctx) error { items, err := store.ListOperations(c.Context(), c.Query("account_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Post("/api/creator/operations", func(c fiber.Ctx) error { var input creator.OperationInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } item, inserted, err := store.CreateOperation(c.Context(), input) if err != nil { return creatorError(c, err) } status := fiber.StatusOK if inserted { status = fiber.StatusCreated } return c.Status(status).JSON(item) }) app.Get("/api/creator/operations/:id", func(c fiber.Ctx) error { item, err := store.GetOperation(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Get("/api/creator/operations/:id/verification", func(c fiber.Ctx) error { item, err := store.GetOperation(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(map[string]any{ "operation_id": item.ID, "state": item.State, "verification_state": item.VerificationState, "evidence": item.VerificationProof, "reason": item.Reason, "verified_at": item.VerifiedAt, }) }) app.Post("/api/creator/operations/:id/execute", func(c fiber.Ctx) error { item, err := store.ExecuteManualOperation(c.Context(), c.Params("id"), executor) if err != nil { return creatorError(c, err) } return c.JSON(item) }) app.Get("/api/creator/conversations", func(c fiber.Ctx) error { items, err := store.ListConversations(c.Context(), c.Query("account_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Get("/api/creator/conversations/:id/messages", func(c fiber.Ctx) error { page, pageSize, paged, err := creatorPageQuery(c) if err != nil { return creatorError(c, err) } if paged { items, err := store.ListMessagesPage(c.Context(), c.Params("id"), page, pageSize) if err != nil { return creatorError(c, err) } return c.JSON(items) } items, err := store.ListMessages(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) app.Post("/api/creator/conversations/:id/sync", func(c fiber.Ctx) error { conversation, err := store.GetConversation(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } if conversation.Platform != creator.PlatformDouyin { return creatorError(c, creator.ErrUnavailable) } limit := 100 if value := c.Query("limit"); value != "" { limit, err = strconv.Atoi(value) if err != nil || limit < 1 || limit > 200 { return creatorError(c, creator.ErrInvalid) } } profile, err := store.GetAccountProfile(c.Context(), conversation.AccountID) if err != nil { return creatorError(c, err) } account, err := phaseAStore.GetAccount(c.Context(), conversation.AccountID) if err != nil { return creatorError(c, err) } if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || profile.Platform != creator.PlatformDouyin || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" || profile.PlatformAccountKey == "" || account.PlatformAccountKey != profile.PlatformAccountKey { return creatorError(c, creator.ErrConflict) } environment, err := hubStore.GetEnvironmentContextForAccount(c.Context(), conversation.AccountID) if err != nil { return creatorError(c, err) } gateway, err := hubStore.GetGateway(c.Context(), environment.Gateway) if err != nil { return creatorError(c, err) } useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(c.Context(), hubStore, environment, "task", "creator-conversation-"+conversation.ID) if err != nil { return creatorError(c, fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err)) } browser := creatorGatewayBrowser{gateway: gateway, environment: environment} accountUID, err := browser.Identity(useCtx, profile.PlatformAccountKey) if err != nil { _ = runtimeUse.Close() return creatorError(c, fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err)) } history, err := browser.MessageHistory(useCtx, accountUID, conversation.PeerUID, conversation.HistoryCursor, limit) if err != nil { _ = runtimeUse.Close() return creatorError(c, err) } if history.AccountUID != accountUID { _ = runtimeUse.Close() return creatorError(c, creator.ErrConflict) } if err := runtimeUse.Close(); err != nil { return creatorError(c, err) } inserted, err := persistDouyinMessageHistory(c.Context(), store, conversation, accountUID, history.Messages) if err != nil { return creatorError(c, err) } if err := store.UpdateConversationHistoryCursor(c.Context(), conversation.ID, history.HistoryCursor, history.HistoryHasMore); err != nil { return creatorError(c, err) } creatorUpdates.publish() return c.JSON(map[string]any{ "conversation_id": conversation.ID, "messages": inserted, "history_source": history.HistorySource, "history_cursor": history.HistoryCursor, "history_has_more": history.HistoryHasMore, }) }) app.Post("/api/creator/messages/send", func(c fiber.Ctx) error { var input creator.MessageInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } if strings.TrimSpace(input.PlatformMessageKey) == "" || input.Direction != "" && input.Direction != "outbound" || input.MessageType != "text" || input.SentState != "" || input.MessageAt != nil { return creatorError(c, creator.ErrInvalid) } operation, inserted, err := store.CreateOperation(c.Context(), creator.OperationInput{ IdempotencyKey: "message:" + input.Platform + ":" + input.AccountID + ":" + input.PeerUID + ":" + input.PlatformMessageKey, Source: "manual", Action: creator.ActionDM, Platform: input.Platform, AccountID: input.AccountID, TargetUID: input.PeerUID, Text: input.Text, }) if err != nil { return creatorError(c, err) } operation, err = store.ExecuteManualOperation(c.Context(), operation.ID, executor) if err != nil { return creatorError(c, err) } creatorUpdates.publish() status := fiber.StatusOK if inserted { status = fiber.StatusCreated } return c.Status(status).JSON(map[string]any{"operation": operation}) }) app.Post("/api/creator/test/messages", func(c fiber.Ctx) error { var input creator.MessageInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } if input.Direction != "inbound" { return creatorError(c, creator.ErrInvalid) } item, inserted, err := store.SaveMessage(c.Context(), input) if err != nil { return creatorError(c, err) } creatorUpdates.publish() status := fiber.StatusOK if inserted { status = fiber.StatusCreated } return c.Status(status).JSON(item) }) } func persistDouyinMessageHistory(ctx context.Context, store *creator.Store, conversation creator.Conversation, accountUID string, messages []douyinHistoryMessage) (int, error) { if store == nil || conversation.Platform != creator.PlatformDouyin || strings.TrimSpace(accountUID) == "" || strings.TrimSpace(conversation.PeerUID) == "" { return 0, creator.ErrInvalid } inserted := 0 for _, item := range messages { if strings.TrimSpace(item.ServerID) == "" || strings.TrimSpace(item.SenderUID) == "" { return inserted, creator.ErrInvalid } messageType := creator.MessageTypeUnknown text := "" if len(item.Content) > 0 && string(item.Content) != "null" { var payload struct { Text string `json:"text"` } var encoded string if err := json.Unmarshal(item.Content, &encoded); err == nil { if err := json.Unmarshal([]byte(encoded), &payload); err != nil { return inserted, creator.ErrInvalid } } else if err := json.Unmarshal(item.Content, &payload); err != nil { return inserted, creator.ErrInvalid } text = payload.Text if text != "" { messageType = creator.MessageTypeText } } var messageAt *time.Time if item.CreatedAt != "" { milliseconds, err := strconv.ParseInt(item.CreatedAt, 10, 64) if err != nil || milliseconds <= 0 { return inserted, creator.ErrInvalid } value := time.UnixMilli(milliseconds).UTC() messageAt = &value } direction, state := "inbound", "received" if item.SenderUID == accountUID { direction, state = "outbound", "succeeded" } savedMessage, wasInserted, err := store.SaveMessage(ctx, creator.MessageInput{ Platform: creator.PlatformDouyin, AccountID: conversation.AccountID, PeerUID: conversation.PeerUID, PeerName: conversation.PeerName, PlatformMessageKey: item.ServerID, Direction: direction, MessageType: messageType, Text: text, SentState: state, MessageAt: messageAt, }) if err != nil { return inserted, err } if err := store.LinkMessageOperation(ctx, savedMessage.ID, item.ServerID); err != nil { return inserted, err } if wasInserted { inserted++ } } return inserted, nil } func setStrategyEnabled(c fiber.Ctx, store *creator.Store, enabled bool) error { item, err := store.SetStrategyEnabled(c.Context(), c.Params("id"), enabled) if err != nil { return creatorError(c, err) } return c.JSON(item) } func workFilter(c fiber.Ctx) (creator.WorkFilter, error) { filter := creator.WorkFilter{Platform: c.Query("platform"), SourceID: c.Query("source_id"), SourceType: c.Query("source_type"), PublishedAtStatus: c.Query("published_at_status")} for _, field := range []struct { name string target **int64 }{{"min_likes", &filter.MinLikes}, {"min_comments", &filter.MinComments}, {"min_shares", &filter.MinShares}} { value := c.Query(field.name) if value == "" { continue } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil { return creator.WorkFilter{}, creator.ErrInvalid } *field.target = &parsed } for _, field := range []struct { name string target **time.Time }{{"published_after", &filter.PublishedAfter}, {"published_before", &filter.PublishedBefore}} { value := c.Query(field.name) if value == "" { continue } parsed, err := time.Parse(time.RFC3339, value) if err != nil { return creator.WorkFilter{}, creator.ErrInvalid } *field.target = &parsed } return filter, nil } func decodeCreator(c fiber.Ctx, destination any) error { decoder := json.NewDecoder(bytes.NewReader(c.Body())) decoder.DisallowUnknownFields() if err := decoder.Decode(destination); err != nil { return creator.ErrInvalid } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return creator.ErrInvalid } return nil } func creatorError(c fiber.Ctx, err error) error { logrus.WithError(err).WithField("service", "control-plane").Error("creator operation failed") status, message := fiber.StatusInternalServerError, "creator operation failed" switch { case errors.Is(err, creator.ErrInvalid): status, message = fiber.StatusBadRequest, creator.ErrInvalid.Error() case errors.Is(err, creator.ErrConflict): status, message = fiber.StatusConflict, creator.ErrConflict.Error() case errors.Is(err, creator.ErrNotFound): status, message = fiber.StatusNotFound, creator.ErrNotFound.Error() case errors.Is(err, creator.ErrUnavailable): status, message = fiber.StatusServiceUnavailable, creator.ErrUnavailable.Error() case errors.Is(err, creator.ErrUncertain): status, message = fiber.StatusConflict, creator.ErrUncertain.Error() } response := map[string]string{"error": message} if errors.Is(err, creator.ErrUnavailable) && err.Error() != creator.ErrUnavailable.Error() { response["reason"] = err.Error() } return c.Status(status).JSON(response) } type creatorGatewayBrowser struct { gateway hub.Gateway environment hub.EnvironmentContext } type creatorGatewayActionExecutor struct { store *creator.Store phaseAStore *accountdomain.Store hubStore *hub.Store } func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, request creator.ActionRequest) (result creator.ActionResult, resultErr error) { if request.Platform != creator.PlatformDouyin || executor.store == nil || executor.phaseAStore == nil || executor.hubStore == nil { return creator.ActionResult{}, creator.ErrUnavailable } profile, err := executor.store.GetAccountProfile(ctx, request.AccountID) if err != nil { return creator.ActionResult{}, err } account, err := executor.phaseAStore.GetAccount(ctx, request.AccountID) if err != nil { return creator.ActionResult{}, err } if profile.Platform != creator.PlatformDouyin || account.Platform != creator.PlatformDouyin || profile.PlatformAccountKey == "" || profile.PlatformAccountKey != account.PlatformAccountKey { return creator.ActionResult{}, creator.ErrConflict } environment, err := executor.hubStore.GetEnvironmentContextForAccount(ctx, request.AccountID) if err != nil { return creator.ActionResult{}, err } if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { return creator.ActionResult{}, fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable) } gateway, err := executor.hubStore.GetGateway(ctx, environment.Gateway) if err != nil { return creator.ActionResult{}, err } useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, executor.hubStore, environment, "task", request.OperationID) if err != nil { return creator.ActionResult{}, fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err) } defer func() { resultErr = errors.Join(resultErr, runtimeUse.Close()) }() browser := creatorGatewayBrowser{gateway: gateway, environment: environment} uid, identityErr := browser.Identity(useCtx, profile.PlatformAccountKey) if identityErr != nil { return creator.ActionResult{}, fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr) } if _, verifyErr := executor.store.RecordVerifiedLoginResult(useCtx, request.AccountID, uid); verifyErr != nil { return creator.ActionResult{}, fmt.Errorf("persist verified account identity: %w", verifyErr) } payload := gatewayGenerationPayload(environment) payload["expected_uid"] = uid payload["operation_id"] = request.OperationID payload["action"] = request.Action payload["target_uid"] = request.TargetUID // UI and persistence use opaque internal IDs; the platform gateway receives only // the verified platform keys and the comment's owning work key. if request.TargetCommentID != "" { comment, targetErr := executor.store.GetComment(ctx, request.TargetCommentID) if errors.Is(targetErr, creator.ErrNotFound) { comment, targetErr = executor.store.GetCommentByKey(ctx, request.Platform, request.TargetCommentID) } if targetErr == nil { payload["target_comment_id"] = comment.CommentKey if request.TargetWorkID == "" { request.TargetWorkID = comment.WorkID } } else if errors.Is(targetErr, creator.ErrNotFound) { // The event may arrive before collection. Keep the opaque platform key; // the gateway must verify ownership against the logged-in account. payload["target_comment_id"] = request.TargetCommentID } else { return creator.ActionResult{}, targetErr } } if request.TargetWorkID != "" { work, targetErr := executor.store.GetWork(ctx, request.TargetWorkID) if errors.Is(targetErr, creator.ErrNotFound) { work, targetErr = executor.store.GetWorkByKey(ctx, request.Platform, request.TargetWorkID) } if targetErr == nil { payload["target_work_id"] = work.WorkKey } else if errors.Is(targetErr, creator.ErrNotFound) { payload["target_work_id"] = request.TargetWorkID } else { return creator.ActionResult{}, targetErr } } payload["text"] = request.Text payload["confirm"] = true status, body, err := gatewayCall(useCtx, gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(environment.Alias)+"/douyin/action", payload, 30*time.Second) if err != nil { return creator.ActionResult{}, err } if status != http.StatusOK { var gatewayError struct { Error string `json:"error"` } if json.Unmarshal(body, &gatewayError) == nil && strings.EqualFold(strings.TrimSpace(gatewayError.Error), "ACTION_UNAVAILABLE") { return creator.ActionResult{State: "failed", Reason: "ACTION_UNAVAILABLE", Evidence: map[string]string{"gateway_http_status": strconv.Itoa(status)}}, nil } return creator.ActionResult{State: "uncertain", Reason: fmt.Sprintf("gateway returned HTTP %d", status)}, nil } var response struct { Status string `json:"status"` Code string `json:"code"` Action string `json:"action"` Evidence any `json:"evidence"` } if err := json.Unmarshal(body, &response); err != nil || (response.Status != "succeeded" && response.Status != "failed" && response.Status != "unknown") { return creator.ActionResult{}, errors.New("gateway returned an invalid action result") } evidence := map[string]string{"gateway_status": response.Status} if response.Action != "" { evidence["action"] = response.Action } if response.Code != "" { evidence["code"] = response.Code } evidenceCount := flattenActionEvidence(evidence, "evidence", response.Evidence) state := response.Status reason := response.Code if state == "unknown" || state == "succeeded" && evidenceCount == 0 || state == "failed" && strings.TrimSpace(response.Code) == "" { state = "uncertain" if reason == "" { reason = "写后确认证据不足" } } return creator.ActionResult{State: state, Evidence: evidence, Reason: reason}, nil } func flattenActionEvidence(destination map[string]string, prefix string, value any) int { switch typed := value.(type) { case string: if typed == "" { return 0 } destination[prefix] = typed return 1 case map[string]any: count := 0 for key, item := range typed { count += flattenActionEvidence(destination, prefix+"."+key, item) } return count default: return 0 } } func (browser creatorGatewayBrowser) Identity(ctx context.Context, expectedKey string) (string, error) { payload := gatewayGenerationPayload(browser.environment) payload["expected_account_key"] = expectedKey status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/identity", payload, 30*time.Second) if err != nil { return "", err } if status != http.StatusOK { return "", fmt.Errorf("douyin identity verification rejected with HTTP %d: %s", status, string(body)) } var identity struct { UID string `json:"uid"` } if err := json.Unmarshal(body, &identity); err != nil || identity.UID == "" { return "", errors.New("douyin identity response omitted uid") } return identity.UID, nil } func (browser creatorGatewayBrowser) MessageHistory(ctx context.Context, expectedUID, targetUID, cursor string, limit int) (douyinMessageHistory, error) { if strings.TrimSpace(expectedUID) == "" || strings.TrimSpace(targetUID) == "" || len(cursor) > 500 || limit < 1 || limit > 200 { return douyinMessageHistory{}, errors.New("invalid message history request") } payload := gatewayGenerationPayload(browser.environment) payload["expected_uid"] = expectedUID payload["target_uid"] = targetUID payload["cursor"] = cursor payload["limit"] = limit status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/messages", payload, 30*time.Second) if err != nil || status != http.StatusOK { return douyinMessageHistory{}, errors.New("restricted message history operation failed") } var response douyinMessageHistory if json.Unmarshal(body, &response) != nil || response.Status != "succeeded" || response.HistorySource == "" { return douyinMessageHistory{}, errors.New("restricted message history response is invalid") } return response, nil } const creatorLoginQRLifetime = 2 * time.Minute func startCreatorEnvironment(ctx context.Context, store HubStore, environment hub.EnvironmentContext) error { if store == nil { return creator.ErrUnavailable } action := actionForEnvironment("start", environment) if err := store.AppendEnvironmentAction(ctx, "environment_action_requested", action); err != nil { return err } finish := func(outcome, reason string, current hub.EnvironmentContext) error { action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID return store.AppendEnvironmentAction(ctx, "environment_action_finished", action) } return startBrowserRuntime(ctx, store, defaultNetworkExitProbe(), environment, finish) } func creatorLoginQRCode(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore HubStore, accountID string) (map[string]any, error) { if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { return nil, creator.ErrUnavailable } account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { return nil, err } profile, err := store.GetAccountProfile(ctx, accountID) if err != nil { return nil, err } if account.Platform != creator.PlatformDouyin || profile.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || profile.PlatformAccountKey == "" || account.PlatformAccountKey != profile.PlatformAccountKey { return nil, creator.ErrConflict } unlock, lockErr := lockAccountResources(ctx, hubStore, accountID) if lockErr != nil { return nil, fmt.Errorf("%w: lock account environment: %v", creator.ErrUnavailable, lockErr) } defer unlock() environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { return nil, fmt.Errorf("%w: reload account environment before start: %v", creator.ErrUnavailable, err) } if err := startCreatorEnvironment(ctx, hubStore, environment); err != nil { return nil, fmt.Errorf("%w: start account environment: %v", creator.ErrUnavailable, err) } environment, err = hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { return nil, fmt.Errorf("%w: reload account environment after start: %v", creator.ErrUnavailable, err) } gateway, err := hubStore.GetGateway(ctx, environment.Gateway) if err != nil { return nil, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-login-qr-"+accountID) if err != nil { return nil, fmt.Errorf("%w: login runtime use unavailable: %v", creator.ErrUnavailable, err) } response, callErr := (douyinGatewayBrowser{gateway: gateway, environment: environment}).LoginQR(useCtx) closeErr := runtimeUse.Close() if callErr != nil { return nil, fmt.Errorf("%w: capture the Douyin login screen: %v", creator.ErrUnavailable, callErr) } if closeErr != nil { return nil, fmt.Errorf("%w: release login runtime use: %v", creator.ErrUnavailable, closeErr) } return map[string]any{ "status": "manual_login", "content_type": response.ContentType, "image_base64": response.BodyBase64, "qr_detected": response.QRDetected, "expires_at": time.Now().UTC().Add(creatorLoginQRLifetime).Format(time.RFC3339), }, nil } func verifyCreatorAccount(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, accountID string) (creator.LoginResult, error) { if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { return creator.LoginResult{}, creator.ErrUnavailable } account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { return creator.LoginResult{}, err } profile, err := store.GetAccountProfile(ctx, accountID) if err != nil { return creator.LoginResult{}, err } if account.Platform != profile.Platform || account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || profile.PlatformAccountKey == "" || account.PlatformAccountKey != profile.PlatformAccountKey { return creator.LoginResult{}, creator.ErrConflict } environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { return creator.LoginResult{}, fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) } if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { return creator.LoginResult{}, fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable) } gateway, err := hubStore.GetGateway(ctx, environment.Gateway) if err != nil { return creator.LoginResult{}, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-verify-"+accountID) if err != nil { return creator.LoginResult{}, fmt.Errorf("%w: verification runtime use unavailable: %v", creator.ErrUnavailable, err) } uid, identityErr := verifyCreatorPlatformIdentity(useCtx, account.Platform, gateway, environment, profile.PlatformAccountKey) closeErr := runtimeUse.Close() if identityErr != nil { return creator.LoginResult{}, fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr) } if closeErr != nil { return creator.LoginResult{}, fmt.Errorf("%w: release verification runtime use: %v", creator.ErrUnavailable, closeErr) } result, err := store.RecordVerifiedLoginResult(ctx, accountID, uid) if err != nil { return creator.LoginResult{}, fmt.Errorf("persist verified account identity: %w", err) } return result, nil } func (browser creatorGatewayBrowser) Get(ctx context.Context, target string) (douyin.Response, error) { payload := gatewayGenerationPayload(browser.environment) payload["url"] = target status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/get", payload, 30*time.Second) if err != nil { return douyin.Response{}, err } if status != http.StatusOK { return douyin.Response{}, fmt.Errorf("douyin browser request rejected with HTTP %d: %s", status, string(body)) } var response struct { Status int `json:"status"` Body string `json:"body"` Challenge douyin.Challenge `json:"challenge"` } if err := json.Unmarshal(body, &response); err != nil { return douyin.Response{}, fmt.Errorf("decode douyin browser response: %w", err) } return douyin.Response{Status: response.Status, Body: []byte(response.Body), Challenge: response.Challenge}, nil } func (browser creatorGatewayBrowser) Resolve(ctx context.Context, target string) (string, error) { payload := gatewayGenerationPayload(browser.environment) payload["url"] = target status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/resolve", payload, gatewayBrowserOperationTimeout) if err != nil { return "", err } if status != http.StatusOK { return "", fmt.Errorf("douyin share URL resolve rejected with HTTP %d: %s", status, string(body)) } var response struct { URL string `json:"url"` } if err := json.Unmarshal(body, &response); err != nil || response.URL == "" { return "", errors.New("douyin share URL resolve response is invalid") } return response.URL, nil } const maxCreatorMediaBytes = 64 << 20 func writeCreatorMedia(destination string, data []byte) error { if destination == "" || len(data) == 0 || len(data) > maxCreatorMediaBytes { return creator.ErrInvalid } temporary, err := os.CreateTemp(filepath.Dir(destination), ".creator-media-*") if err != nil { return err } temporaryName := temporary.Name() keep := false defer func() { if !keep { _ = os.Remove(temporaryName) } }() if _, err := temporary.Write(data); err != nil { _ = temporary.Close() return err } if err := temporary.Sync(); err != nil { _ = temporary.Close() return err } if err := temporary.Close(); err != nil { return err } if err := os.Rename(temporaryName, destination); err != nil { return err } keep = true return nil } func (browser creatorGatewayBrowser) Media(ctx context.Context, target, destination string) error { payload := gatewayGenerationPayload(browser.environment) payload["url"] = target status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/media", payload, 90*time.Second) if err != nil { return err } if status != http.StatusOK { return fmt.Errorf("douyin media request rejected with HTTP %d: %s", status, string(body)) } var response struct { Status int `json:"status"` ContentType string `json:"content_type"` BodyBase64 string `json:"body_base64"` } if err := json.Unmarshal(body, &response); err != nil { return fmt.Errorf("decode douyin media response: %w", err) } contentType := strings.ToLower(strings.TrimSpace(response.ContentType)) if response.Status < 200 || response.Status >= 300 || (contentType != "application/octet-stream" && !strings.HasPrefix(contentType, "video/")) { return fmt.Errorf("douyin media response is not a video") } data, err := base64.StdEncoding.DecodeString(response.BodyBase64) if err != nil { return fmt.Errorf("decode douyin media bytes: %w", err) } if len(data) > maxCreatorMediaBytes { return fmt.Errorf("douyin media response is too large") } return writeCreatorMedia(destination, data) } type competitorSharePreview struct { AccountID string `json:"account_id"` Platform string `json:"platform"` PlatformAccountKey string `json:"platform_account_key"` UniqueID string `json:"unique_id,omitempty"` Nickname string `json:"nickname"` AvatarURL string `json:"avatar_url,omitempty"` HomepageURL string `json:"homepage_url"` ShareURL string `json:"share_url"` } func (preview competitorSharePreview) input() creator.CompetitorInput { return creator.CompetitorInput{ Platform: preview.Platform, PlatformAccountKey: preview.PlatformAccountKey, UniqueID: preview.UniqueID, Nickname: preview.Nickname, AvatarURL: preview.AvatarURL, HomepageURL: preview.HomepageURL, } } func competitorSharePlatform(raw string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() != "" || parsed.Fragment != "" { return "", creator.ErrInvalid } switch strings.ToLower(parsed.Hostname()) { case "www.douyin.com", "v.douyin.com": return creator.PlatformDouyin, nil default: return "", creator.ErrInvalid } } func previewCompetitorShare(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, accountID, requestedPlatform, rawShareURL string) (competitorSharePreview, error) { platform, err := competitorSharePlatform(rawShareURL) if err != nil { return competitorSharePreview{}, err } if requestedPlatform != "" && requestedPlatform != platform { return competitorSharePreview{}, creator.ErrInvalid } shareURL := strings.TrimSpace(rawShareURL) if platform != creator.PlatformDouyin { return competitorSharePreview{}, creator.ErrUnavailable } return previewDouyinCompetitorShare(ctx, store, phaseAStore, hubStore, accountID, shareURL) } type anonymousBrowserLease struct { gateway hub.Gateway environment hub.EnvironmentContext } func (lease anonymousBrowserLease) close() error { cleanupContext, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() payload := gatewayCleanupGenerationPayload(lease.environment) payload["purge_profile"] = true var lastErr error for attempt := 0; attempt < 30; attempt++ { status, body, callErr := gatewayCall(cleanupContext, lease.gateway, http.MethodDelete, "/v1/browsers/"+url.PathEscape(lease.environment.Alias), payload, 30*time.Second) if callErr == nil && (status == http.StatusNoContent || status == http.StatusNotFound) { return nil } if callErr != nil { lastErr = gatewayUnreachable(callErr) } else { lastErr = gatewayRejected(status, body) } if callErr == nil && status != http.StatusAccepted && status >= http.StatusBadRequest && status < http.StatusInternalServerError { return lastErr } if cleanupContext.Err() != nil { return errors.Join(lastErr, cleanupContext.Err()) } if err := waitForAnonymousCleanup(cleanupContext, 250*time.Millisecond); err != nil { return errors.Join(lastErr, err) } } return lastErr } func waitForAnonymousCleanup(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() select { case <-timer.C: return nil case <-ctx.Done(): return ctx.Err() } } func randomGateway(gateways []hub.Gateway) hub.Gateway { return gateways[rand.Intn(len(gateways))] } func newAnonymousBrowser(ctx context.Context, store *hub.Store) (anonymousBrowserLease, error) { if store == nil { return anonymousBrowserLease{}, creator.ErrUnavailable } gateways, err := store.ListGateways(ctx) if err != nil { return anonymousBrowserLease{}, err } envs, err := store.ListEnvs(ctx) if err != nil { return anonymousBrowserLease{}, err } if len(gateways) == 0 { return anonymousBrowserLease{}, fmt.Errorf("%w: anonymous browser gateway is not configured", creator.ErrUnavailable) } gateway := randomGateway(gateways) var template hub.Env for _, candidate := range envs { if candidate.Gateway == gateway.Name { template = candidate break } } if template.Gateway == "" { template = hub.Env{Fingerprint: hub.Fingerprint{Seed: 1}} } operationID := hub.NewOperationID() template.Alias = "anon-" + strings.TrimPrefix(operationID, "operation-") template.Name = "匿名竞品解析" template.Gateway = gateway.Name template.Fingerprint.ProxyServer = "" template.Fingerprint.DisableNonProxiedUDP = false if template.Fingerprint.Seed < 1 { template.Fingerprint.Seed = time.Now().UnixNano()%2147483646 + 1 } environment := hub.EnvironmentContext{Env: template, BindingVersion: 1} payload := gatewayCreatePayload(environment, "", gatewayNetworkExit{}) status, body, callErr := gatewayCall(ctx, gateway, http.MethodPost, "/v1/browsers", payload, gatewayLongTimeout) if callErr == nil && status == http.StatusCreated { var created runtimeStatus if json.Unmarshal(body, &created) == nil && created.Alias == environment.Alias && created.BindingVersion == environment.BindingVersion && created.State == "running" && validCreatedRuntime(created, environment, true) { environment.RuntimeID, environment.RuntimeNetworkID = created.ID, created.NetworkID return anonymousBrowserLease{gateway: gateway, environment: environment}, nil } callErr = errors.New("gateway returned an invalid anonymous runtime generation") } else if callErr == nil { callErr = gatewayRejected(status, body) } else { callErr = gatewayUnreachable(callErr) } cleanupErr := cleanupAnonymousRuntimeAfterCreateFailure(ctx, gateway, environment) return anonymousBrowserLease{}, errors.Join(callErr, cleanupErr) } func cleanupAnonymousRuntimeAfterCreateFailure(ctx context.Context, gateway hub.Gateway, environment hub.EnvironmentContext) error { created, found, err := reconcileGatewayRuntime(ctx, gateway, environment.Alias) if err != nil || !found { return err } environment.RuntimeID, environment.RuntimeNetworkID = created.ID, created.NetworkID return (anonymousBrowserLease{gateway: gateway, environment: environment}).close() } func previewDouyinCompetitorShare(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, accountID, shareURL string) (preview competitorSharePreview, err error) { _ = store _ = phaseAStore _ = accountID lease, err := newAnonymousBrowser(ctx, hubStore) if err != nil { return competitorSharePreview{}, fmt.Errorf("%w: temporary anonymous browser unavailable: %v", creator.ErrUnavailable, err) } defer func() { err = errors.Join(err, lease.close()) }() browser := creatorGatewayBrowser{gateway: lease.gateway, environment: lease.environment} canonicalURL, err := browser.Resolve(ctx, shareURL) if err != nil { return competitorSharePreview{}, fmt.Errorf("%w: share URL resolution failed: %v", creator.ErrUnavailable, err) } workKey, err := douyinWorkKeyFromURL(canonicalURL) if err != nil { return competitorSharePreview{}, err } var target douyin.TargetProfile page, pageErr := browser.Get(ctx, canonicalURL) if pageErr == nil && page.Status >= http.StatusOK && page.Status < http.StatusMultipleChoices { target, _ = douyin.ParseShareTargetHTML(page.Body, workKey) } if target.SecUID == "" { target, err = (douyin.CreatorCollector{Browser: browser}).ResolveWork(ctx, workKey) if err != nil { return competitorSharePreview{}, fmt.Errorf("%w: target identity verification failed: %v", creator.ErrConflict, err) } } return competitorSharePreview{ Platform: creator.PlatformDouyin, PlatformAccountKey: target.SecUID, UniqueID: target.UniqueID, Nickname: target.Nickname, AvatarURL: target.AvatarURL, HomepageURL: "https://www.douyin.com/user/" + target.SecUID, ShareURL: shareURL, }, nil } func douyinWorkKeyFromURL(raw string) (string, error) { parsed, err := url.Parse(raw) if err != nil || parsed.Scheme != "https" || parsed.Hostname() != "www.douyin.com" || parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" { return "", creator.ErrInvalid } parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") if len(parts) != 2 || parts[0] != "video" || parts[1] == "" || parts[1][0] == '0' || strings.Trim(parts[1], "0123456789") != "" { return "", creator.ErrInvalid } return parts[1], nil } func newDouyinAccountBrowser(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, accountID string) (creatorGatewayBrowser, error) { if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { return creatorGatewayBrowser{}, creator.ErrInvalid } account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { return creatorGatewayBrowser{}, err } if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" { return creatorGatewayBrowser{}, creator.ErrConflict } profile, err := store.GetAccountProfile(ctx, accountID) if err != nil { return creatorGatewayBrowser{}, err } if profile.Platform != creator.PlatformDouyin || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" || profile.PlatformAccountKey == "" { return creatorGatewayBrowser{}, creator.ErrConflict } environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { return creatorGatewayBrowser{}, fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) } gateway, err := hubStore.GetGateway(ctx, environment.Gateway) if err != nil { return creatorGatewayBrowser{}, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } browser := creatorGatewayBrowser{gateway: gateway, environment: environment} if _, err := browser.Identity(ctx, profile.PlatformAccountKey); err != nil { return creatorGatewayBrowser{}, fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err) } return browser, nil } func previewDouyinCompetitor(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, accountID string, input creator.CompetitorInput) (map[string]any, error) { if input.Platform != creator.PlatformDouyin { return nil, creator.ErrInvalid } browser, err := newDouyinAccountBrowser(ctx, store, phaseAStore, hubStore, accountID) if err != nil { return nil, err } target, err := (douyin.CreatorCollector{Browser: browser}).ResolveTarget(ctx, input.PlatformAccountKey) if err != nil { return nil, fmt.Errorf("%w: target identity verification failed: %v", creator.ErrConflict, err) } return map[string]any{ "account_id": accountID, "platform": creator.PlatformDouyin, "platform_account_key": target.SecUID, "unique_id": target.UniqueID, "nickname": target.Nickname, "avatar_url": target.AvatarURL, "homepage_url": "https://www.douyin.com/user/" + target.SecUID, }, nil } func syncCreatorCompetitor(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, competitorID, accountID string) (creator.CollectionReport, error) { return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, competitorID, accountID, true) } func syncCreatorCompetitorDue(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, competitorID, accountID string) (creator.CollectionReport, error) { return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, competitorID, accountID, false) } func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, competitorID, accountID string, force bool) (creator.CollectionReport, error) { if store == nil || phaseAStore == nil || hubStore == nil || accountID == "" { return creator.CollectionReport{}, creator.ErrUnavailable } competitor, err := store.GetCompetitor(ctx, competitorID) if err != nil { return creator.CollectionReport{}, err } settings, err := store.GetSettings(ctx) if err != nil { return creator.CollectionReport{}, err } now := time.Now().UTC() leaseToken, claimed, err := store.ClaimCompetitorSync(ctx, competitorID, force, now) if err != nil { return creator.CollectionReport{}, err } if !claimed { return creator.CollectionReport{}, creator.ErrConflict } blocked := func(blockErr error) (creator.CollectionReport, error) { markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "blocked", "", blockErr.Error(), nil) return creator.CollectionReport{}, errors.Join(blockErr, markErr) } if competitor.Platform != creator.PlatformDouyin { return blocked(fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, competitor.Platform)) } account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { return blocked(err) } if account.Platform != competitor.Platform || account.AuthorizationStatus != "authorized" { return blocked(creator.ErrConflict) } profile, err := store.GetAccountProfile(ctx, accountID) if err != nil { return blocked(err) } if (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" { return blocked(creator.ErrConflict) } environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { return blocked(fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err)) } if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { return blocked(fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable)) } gateway, err := hubStore.GetGateway(ctx, environment.Gateway) if err != nil { return blocked(fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err)) } useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-sync-"+competitor.ID) if err != nil { return blocked(fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err)) } collector, _, err := newCreatorCollector(useCtx, competitor.Platform, gateway, environment, account.PlatformAccountKey, competitor.PlatformAccountKey, competitor.HomepageURL, creator.SourceCompetitor, competitor.ID) if err != nil { return blocked(errors.Join(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err), runtimeUse.Close())) } collectionNow := now if competitor.NextSyncAt != nil && !competitor.NextSyncAt.After(now) { collectionNow = competitor.NextSyncAt.UTC() } report, collectErr := store.CollectSource(useCtx, competitor.Platform, creator.SourceCompetitor, competitor.ID, collector, collectionNow) if releaseErr := runtimeUse.Close(); releaseErr != nil { collectErr = errors.Join(collectErr, releaseErr) } if collectErr != nil { nextBase := now if competitor.NextSyncAt != nil { nextBase = competitor.NextSyncAt.UTC() } next := creator.NextFixedRun(nextBase, time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second) status := "failed" if errors.Is(collectErr, creator.ErrUnavailable) || errors.Is(collectErr, creator.ErrConflict) { status, next = "blocked", time.Time{} } var nextAt *time.Time if !next.IsZero() { nextAt = &next } markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, status, "", collectErr.Error(), nextAt) return report, errors.Join(collectErr, markErr) } nextBase := now if competitor.NextSyncAt != nil { nextBase = competitor.NextSyncAt.UTC() } next := creator.NextFixedRun(nextBase, time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second) if err := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "idle", "", "", &next); err != nil { return report, err } return report, nil } func processCompetitorShareJob(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, jobID string) error { for { job, leaseToken, claimed, err := store.ClaimCompetitorShareJob(ctx, jobID, time.Now().UTC()) if err != nil { return err } if !claimed { return nil } preview, processErr := previewCompetitorShare(ctx, store, phaseAStore, hubStore, "", job.Platform, job.ShareURL) competitorID := "" if processErr == nil { input := preview.input() input.Tags = job.Tags if processErr = validateDouyinCompetitor(input); processErr == nil { var competitor creator.Competitor competitor, processErr = store.UpsertCompetitor(ctx, input) competitorID = competitor.ID } } if processErr == nil { if err := store.MarkCompetitorShareJob(ctx, job.ID, leaseToken, creator.CompetitorShareJobSucceeded, competitorID, ""); err != nil { return err } logrus.WithFields(logrus.Fields{"job_id": job.ID, "competitor_id": competitorID, "attempts": job.Attempts}).Info("creator competitor share job completed") return nil } status := creator.CompetitorShareJobQueued if job.Attempts >= creator.MaxCompetitorShareJobAttempts { status = creator.CompetitorShareJobFailed } if err := store.MarkCompetitorShareJob(ctx, job.ID, leaseToken, status, "", processErr.Error()); err != nil { return errors.Join(processErr, err) } logrus.WithFields(logrus.Fields{"job_id": job.ID, "attempts": job.Attempts, "status": status}).WithError(processErr).Warn("creator competitor share job attempt failed") if status == creator.CompetitorShareJobFailed || ctx.Err() != nil { return nil } } } func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store) error { if store == nil { return creator.ErrUnavailable } settings, err := store.GetSettings(ctx) if err != nil { return err } now := time.Now().UTC() shareJobs, err := store.ListDueCompetitorShareJobs(ctx, now) if err != nil { return err } for _, job := range shareJobs { if err := processCompetitorShareJob(ctx, store, phaseAStore, hubStore, job.ID); err != nil { logrus.WithError(err).WithField("job_id", job.ID).Warn("creator competitor share job failed") } } competitors, err := store.ListDueCompetitors(ctx, now) if err != nil { return err } for _, competitor := range competitors { accountID, err := creatorCollectionAccount(ctx, store, phaseAStore, hubStore, competitor.Platform) if err != nil { leaseToken, claimed, claimErr := store.ClaimCompetitorSync(ctx, competitor.ID, false, now) if claimErr != nil { logrus.WithError(claimErr).WithField("competitor_id", competitor.ID).Warn("creator competitor sync claim failed") continue } if claimed { if markErr := store.MarkCompetitorSync(ctx, competitor.ID, leaseToken, "blocked", "", err.Error(), nil); markErr != nil { logrus.WithError(markErr).WithField("competitor_id", competitor.ID).Warn("creator competitor sync block update failed") } } logrus.WithError(err).WithField("competitor_id", competitor.ID).Warn("creator competitor sync blocked") continue } if _, err := syncCreatorCompetitorDue(ctx, store, phaseAStore, hubStore, competitor.ID, accountID); err != nil { logrus.WithError(err).WithField("competitor_id", competitor.ID).Warn("creator competitor scheduled sync failed") } } ownedAccounts, err := store.ListDueOwnedAccounts(ctx, now, settings.NewWorkIntervalSeconds) if err != nil { return err } for _, accountID := range ownedAccounts { if err := syncCreatorOwned(ctx, store, phaseAStore, hubStore, accountID, now); err != nil { logrus.WithError(err).WithField("account_id", accountID).Warn("creator owned scheduled sync failed") } } if err := runCreatorMetricScheduleOnce(ctx, store, phaseAStore, hubStore, now); err != nil { return err } return nil } func runCreatorMetricScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, now time.Time) error { settings, err := store.GetSettings(ctx) if err != nil { return err } works, err := store.ListDueMetricWorks(ctx, now) if err != nil { return err } for _, work := range works { accountID := work.SourceID if work.SourceType == creator.SourceCompetitor { accountID, err = creatorCollectionAccount(ctx, store, phaseAStore, hubStore, work.Platform) if err != nil { logrus.WithError(err).WithField("work_id", work.ID).Warn("creator metric refresh account unavailable") continue } } if err := refreshCreatorMetricWork(ctx, store, phaseAStore, hubStore, work, accountID, settings, now); err != nil { logrus.WithError(err).WithField("work_id", work.ID).Warn("creator metric refresh failed") } } return nil } func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, work creator.Work, accountID string, settings creator.Settings, now time.Time) (resultErr error) { if work.SourceType == creator.SourceCompetitor { competitor, err := store.GetCompetitor(ctx, work.SourceID) if err != nil { if errors.Is(err, creator.ErrNotFound) { return store.StopMetricPlan(ctx, work.ID, "source_deleted") } return err } if !competitor.Enabled { return store.StopMetricPlan(ctx, work.ID, "source_disabled") } } account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { return err } profile, err := store.GetAccountProfile(ctx, accountID) if err != nil { return err } if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" { return creator.ErrConflict } environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { return fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) } if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { return fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable) } gateway, err := hubStore.GetGateway(ctx, environment.Gateway) if err != nil { return fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-metric-"+work.ID) if err != nil { return fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err) } defer func() { resultErr = errors.Join(resultErr, runtimeUse.Close()) }() targetAccountKey, homepageURL := account.PlatformAccountKey, "" if work.SourceType == creator.SourceCompetitor { competitor, competitorErr := store.GetCompetitor(ctx, work.SourceID) if competitorErr != nil { return competitorErr } if competitor.Platform != work.Platform { return creator.ErrConflict } targetAccountKey, homepageURL = competitor.PlatformAccountKey, competitor.HomepageURL } collector, collectionKey, err := newCreatorCollector(useCtx, work.Platform, gateway, environment, account.PlatformAccountKey, targetAccountKey, homepageURL, work.SourceType, work.SourceID) if err != nil { return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err) } cursor := "" for page := 0; page < 100; page++ { result, pageErr := collector.ListWorks(useCtx, collectionKey, cursor) if pageErr != nil { return pageErr } for _, item := range result.Items { if item.WorkKey != work.WorkKey { continue } if item.Likes == nil && item.CommentsCount == nil && item.Shares == nil { return creator.ErrUnavailable } _, metricErr := store.RecordMetric(useCtx, creator.MetricInput{WorkID: work.ID, CollectedAt: now, Likes: item.Likes, CommentsCount: item.CommentsCount, Shares: item.Shares}, settings, now) return metricErr } if !result.HasMore { return creator.ErrNotFound } if result.NextCursor == "" || result.NextCursor == cursor { return creator.ErrInvalid } cursor = result.NextCursor } return creator.ErrInvalid } func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, accountID string, now time.Time) error { if store == nil || phaseAStore == nil || hubStore == nil || accountID == "" { return creator.ErrUnavailable } account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { return err } if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" { return creator.ErrConflict } profile, err := store.GetAccountProfile(ctx, accountID) if err != nil { return err } if (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" { return creator.ErrConflict } settings, err := store.GetSettings(ctx) if err != nil { return err } environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { return fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) } if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { return fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable) } gateway, err := hubStore.GetGateway(ctx, environment.Gateway) if err != nil { return fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } syncLease, err := store.ClaimSourceSync(ctx, creator.SourceOwned, account.ID) if err != nil { return err } defer func() { if releaseErr := store.ReleaseSourceSync(context.WithoutCancel(ctx), creator.SourceOwned, account.ID, syncLease); releaseErr != nil { logrus.WithError(releaseErr).WithField("account_id", account.ID).Warn("creator source sync lease release failed") } }() useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-owned-"+account.ID) if err != nil { return fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err) } collector, _, err := newCreatorCollector(useCtx, account.Platform, gateway, environment, account.PlatformAccountKey, account.PlatformAccountKey, "", creator.SourceOwned, account.ID) if err != nil { blockErr := store.MarkCollectionBlocked(ctx, creator.SourceOwned, account.ID, err.Error(), now, settings.LookbackDays) return errors.Join(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err), blockErr, runtimeUse.Close()) } _, collectionNow, windowErr := store.NextCollectionWindow(ctx, creator.SourceOwned, account.ID, now, time.Duration(settings.NewWorkIntervalSeconds)*time.Second, settings.LookbackDays) if windowErr != nil { return errors.Join(windowErr, runtimeUse.Close()) } _, err = store.CollectSource(useCtx, account.Platform, creator.SourceOwned, account.ID, collector, collectionNow) return errors.Join(err, runtimeUse.Close()) } func creatorCollectionAccount(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, platform string) (string, error) { if store == nil || phaseAStore == nil || hubStore == nil || platform == "" { return "", creator.ErrUnavailable } accounts, err := phaseAStore.ListAccounts(ctx) if err != nil { return "", err } for _, account := range accounts { if account.Platform != platform || account.AuthorizationStatus != "authorized" { continue } profile, err := store.GetAccountProfile(ctx, account.ID) if err != nil || profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted" || profile.LoginStatus != "logged_in" { continue } environment, err := hubStore.GetEnvironmentContextForAccount(ctx, account.ID) if err != nil || environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { continue } return account.ID, nil } return "", fmt.Errorf("%w: no authorized creator collection account", creator.ErrUnavailable) } func runCreatorScheduler(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { if err := runCreatorScheduleOnce(ctx, store, phaseAStore, hubStore); err != nil { logrus.WithError(err).Error("creator scheduler failed") } select { case <-ctx.Done(): return case <-ticker.C: } } } // RegisterCreator exposes creator routes to the grouped API package. func RegisterCreator(app *fiber.App, store *creator.Store, accountStore *accountdomain.Store, environmentStore *hub.Store) { registerCreator(app, store, accountStore, environmentStore) } // RegisterCreatorWithAI exposes creator routes with the configured service implementations. func RegisterCreatorWithAI(app *fiber.App, store *creator.Store, accountStore *accountdomain.Store, environmentStore *hub.Store, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) { var executor creator.ActionExecutor if store != nil && accountStore != nil && environmentStore != nil { executor = creatorGatewayActionExecutor{store: store, phaseAStore: accountStore, hubStore: environmentStore} } registerCreatorWithServices(app, store, accountStore, environmentStore, executor, generator, analyzer) } // RunCreatorScheduler runs the creator scheduler until its context is canceled. func RunCreatorScheduler(ctx context.Context, store *creator.Store, accountStore *accountdomain.Store, environmentStore *hub.Store) { runCreatorScheduler(ctx, store, accountStore, environmentStore) } // NewCreatorGatewayActionExecutor builds the native gateway-backed action executor. func NewCreatorGatewayActionExecutor(store *creator.Store, accountStore *accountdomain.Store, environmentStore *hub.Store) creator.ActionExecutor { return creatorGatewayActionExecutor{store: store, phaseAStore: accountStore, hubStore: environmentStore} }