2286 lines
85 KiB
Go
2286 lines
85 KiB
Go
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"
|
|
xiaohongshu "git.ipao.vip/rogee/creator-hub/internal/platform/xiaohongshu"
|
|
"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.ListCompetitors(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.ListCompetitorShareJobs(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.Post("/api/creator/xiaohongshu/search", func(c fiber.Ctx) error {
|
|
var input struct {
|
|
AccountID string `json:"account_id"`
|
|
Query string `json:"query"`
|
|
Page int `json:"page"`
|
|
}
|
|
if err := decodeCreator(c, &input); err != nil {
|
|
return creatorError(c, err)
|
|
}
|
|
if input.Page == 0 {
|
|
input.Page = 1
|
|
}
|
|
environment, err := hubStore.GetEnvironmentContextForAccount(c.Context(), input.AccountID)
|
|
if err != nil {
|
|
return creatorError(c, err)
|
|
}
|
|
useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(c.Context(), hubStore, environment, "task", "creator-xhs-read-"+input.AccountID)
|
|
if err != nil {
|
|
return creatorError(c, fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err))
|
|
}
|
|
collector, err := newXiaohongshuReadCollector(useCtx, store, phaseAStore, hubStore, input.AccountID, creator.SourceOwned, input.AccountID)
|
|
if err != nil {
|
|
return creatorError(c, errors.Join(err, runtimeUse.Close()))
|
|
}
|
|
page, callErr := collector.SearchNotes(useCtx, input.Query, input.Page)
|
|
if closeErr := runtimeUse.Close(); callErr != nil || closeErr != nil {
|
|
return creatorError(c, errors.Join(callErr, closeErr))
|
|
}
|
|
return c.JSON(page)
|
|
})
|
|
app.Post("/api/creator/xiaohongshu/detail", func(c fiber.Ctx) error {
|
|
var input struct {
|
|
AccountID string `json:"account_id"`
|
|
URL string `json:"url"`
|
|
}
|
|
if err := decodeCreator(c, &input); err != nil {
|
|
return creatorError(c, err)
|
|
}
|
|
environment, err := hubStore.GetEnvironmentContextForAccount(c.Context(), input.AccountID)
|
|
if err != nil {
|
|
return creatorError(c, err)
|
|
}
|
|
useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(c.Context(), hubStore, environment, "task", "creator-xhs-detail-"+input.AccountID)
|
|
if err != nil {
|
|
return creatorError(c, fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err))
|
|
}
|
|
collector, err := newXiaohongshuReadCollector(useCtx, store, phaseAStore, hubStore, input.AccountID, creator.SourceOwned, input.AccountID)
|
|
if err != nil {
|
|
return creatorError(c, errors.Join(err, runtimeUse.Close()))
|
|
}
|
|
item, callErr := collector.GetNoteDetail(useCtx, input.URL)
|
|
if closeErr := runtimeUse.Close(); callErr != nil || closeErr != nil {
|
|
return creatorError(c, errors.Join(callErr, closeErr))
|
|
}
|
|
return c.JSON(item)
|
|
})
|
|
|
|
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.Platform != creator.PlatformXiaohongshu) || 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
|
|
case "www.xiaohongshu.com", "xhslink.com", "www.xhslink.com":
|
|
return creator.PlatformXiaohongshu, 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)
|
|
switch platform {
|
|
case creator.PlatformDouyin:
|
|
return previewDouyinCompetitorShare(ctx, store, phaseAStore, hubStore, accountID, shareURL)
|
|
case creator.PlatformXiaohongshu:
|
|
return previewXiaohongshuCompetitorShare(ctx, store, phaseAStore, hubStore, accountID, shareURL)
|
|
default:
|
|
return competitorSharePreview{}, creator.ErrUnavailable
|
|
}
|
|
}
|
|
|
|
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 previewXiaohongshuCompetitorShare(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())
|
|
}()
|
|
collector := &xiaohongshu.Collector{
|
|
Browser: xiaohongshuGatewayBrowser{gateway: lease.gateway, environment: lease.environment},
|
|
SourceType: creator.SourceCompetitor,
|
|
}
|
|
work, err := collector.GetNoteDetail(ctx, shareURL)
|
|
if err != nil {
|
|
return competitorSharePreview{}, err
|
|
}
|
|
if work.AuthorKey == "" {
|
|
return competitorSharePreview{}, fmt.Errorf("%w: xiaohongshu note author identity is missing", creator.ErrInvalid)
|
|
}
|
|
return competitorSharePreview{
|
|
Platform: creator.PlatformXiaohongshu,
|
|
PlatformAccountKey: work.AuthorKey,
|
|
Nickname: work.AuthorName,
|
|
HomepageURL: "https://www.xiaohongshu.com/user/profile/" + work.AuthorKey,
|
|
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 && competitor.Platform != creator.PlatformXiaohongshu {
|
|
return blocked(fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, competitor.Platform))
|
|
}
|
|
if competitor.Platform == creator.PlatformXiaohongshu {
|
|
if err := validateXiaohongshuSource(competitor.HomepageURL, competitor.PlatformAccountKey); err != nil {
|
|
return blocked(err)
|
|
}
|
|
}
|
|
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 = validateXiaohongshuCompetitor(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.Platform != creator.PlatformXiaohongshu) || 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.Platform != creator.PlatformXiaohongshu || 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}
|
|
}
|