Files
creator-hub/cmd/control-plane/creator.go
T

1431 lines
50 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.ipao.vip/rogee/creator-hub/internal/creator"
"git.ipao.vip/rogee/creator-hub/internal/douyin"
"git.ipao.vip/rogee/creator-hub/internal/hub"
"git.ipao.vip/rogee/creator-hub/internal/phasea"
"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 *phasea.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 *phasea.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()
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 <-c.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.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/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.ListCompetitors(c.Context(), c.Query("platform"))
if err != nil {
return creatorError(c, err)
}
return c.JSON(items)
})
app.Post("/api/creator/competitors", func(c fiber.Ctx) error {
var input creator.CompetitorInput
if err := decodeCreator(c, &input); err != nil {
return creatorError(c, err)
}
item, err := store.CreateCompetitor(c.Context(), input)
if err != nil {
return creatorError(c, err)
}
return c.Status(fiber.StatusCreated).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.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/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/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.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/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 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")}
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()
}
return c.Status(status).JSON(map[string]string{"error": message})
}
type creatorGatewayBrowser struct {
gateway hub.Gateway
environment hub.EnvironmentContext
}
type creatorGatewayActionExecutor struct {
store *creator.Store
phaseAStore *phasea.Store
hubStore *hub.Store
}
func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, request creator.ActionRequest) (creator.ActionResult, 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
}
gateway, err := executor.hubStore.GetGateway(ctx, environment.Gateway)
if err != nil {
return creator.ActionResult{}, err
}
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
uid, identityErr := browser.Identity(ctx, 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(ctx, request.AccountID, uid); verifyErr != nil {
return creator.ActionResult{}, fmt.Errorf("persist verified account identity: %w", verifyErr)
}
payload := gatewayGenerationPayload(environment)
payload["expected_uid"] = uid
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 targetErr != nil {
return creator.ActionResult{}, targetErr
}
payload["target_comment_id"] = comment.CommentKey
if request.TargetWorkID == "" {
request.TargetWorkID = comment.WorkID
}
}
if request.TargetWorkID != "" {
work, targetErr := executor.store.GetWork(ctx, request.TargetWorkID)
if targetErr != nil {
return creator.ActionResult{}, targetErr
}
payload["target_work_id"] = work.WorkKey
}
payload["text"] = request.Text
payload["confirm"] = true
status, body, err := gatewayCall(ctx, 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 verifyCreatorAccount(ctx context.Context, store *creator.Store, phaseAStore *phasea.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 != creator.PlatformDouyin || profile.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)
}
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
uid, err := browser.Identity(ctx, profile.PlatformAccountKey)
if err != nil {
return creator.LoginResult{}, fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, err)
}
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
}
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)
}
func syncCreatorCompetitor(ctx context.Context, store *creator.Store, phaseAStore *phasea.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 *phasea.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 *phasea.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: 小红书采集器尚未完成平台能力验证", creator.ErrUnavailable))
}
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.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))
}
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
return blocked(fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr))
}
collector := douyin.CreatorCollector{Browser: browser, AccountKey: competitor.PlatformAccountKey, SourceType: creator.SourceCompetitor, SourceID: competitor.ID}
canonicalSecUID, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey)
if err != nil {
return blocked(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err))
}
collector.AccountKey = canonicalSecUID
collectionNow := now
if competitor.NextSyncAt != nil && !competitor.NextSyncAt.After(now) {
collectionNow = competitor.NextSyncAt.UTC()
}
report, collectErr := store.CollectSource(ctx, competitor.Platform, creator.SourceCompetitor, competitor.ID, collector, collectionNow)
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 runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *phasea.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()
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 *phasea.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 *phasea.Store, hubStore *hub.Store, work creator.Work, accountID string, settings creator.Settings, now time.Time) error {
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.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)
}
gateway, err := hubStore.GetGateway(ctx, environment.Gateway)
if err != nil {
return fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err)
}
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
return fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr)
}
collector := douyin.CreatorCollector{Browser: browser, AccountKey: account.PlatformAccountKey, SourceType: work.SourceType, SourceID: work.SourceID}
canonical, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey)
if err != nil {
return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err)
}
collector.AccountKey = canonical
cursor := ""
for page := 0; page < 100; page++ {
result, pageErr := collector.ListWorks(ctx, canonical, 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(ctx, 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 *phasea.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.LoginStatus != "logged_in" {
return creator.ErrConflict
}
settings, err := store.GetSettings(ctx)
if err != nil {
return err
}
blockOwned := func(blockErr error) error {
return errors.Join(blockErr, store.MarkCollectionBlocked(ctx, creator.SourceOwned, accountID, blockErr.Error(), now, settings.LookbackDays))
}
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)
}
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
return blockOwned(fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr))
}
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")
}
}()
collector := douyin.CreatorCollector{Browser: browser, AccountKey: account.PlatformAccountKey, SourceType: creator.SourceOwned, SourceID: account.ID}
canonicalSecUID, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey)
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)
}
collector.AccountKey = canonicalSecUID
_, collectionNow, windowErr := store.NextCollectionWindow(ctx, creator.SourceOwned, account.ID, now, time.Duration(settings.NewWorkIntervalSeconds)*time.Second, settings.LookbackDays)
if windowErr != nil {
return windowErr
}
_, err = store.CollectSource(ctx, account.Platform, creator.SourceOwned, account.ID, collector, collectionNow)
return err
}
func creatorCollectionAccount(ctx context.Context, store *creator.Store, phaseAStore *phasea.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
}
if _, err := store.GetAccountProfile(ctx, account.ID); err != nil {
continue
}
if _, err := hubStore.GetEnvironmentContextForAccount(ctx, account.ID); err != nil {
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 *phasea.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:
}
}
}