feat: integrate creator hub douyin workflows
This commit is contained in:
+347
-111
@@ -1,14 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,15 +25,49 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func registerCreator(app *fiber.App, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge) {
|
||||
var executor creator.ActionExecutor
|
||||
if store != nil && phaseAStore != nil && hubStore != nil {
|
||||
executor = creatorGatewayActionExecutor{store: store, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials}
|
||||
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
|
||||
}
|
||||
registerCreatorWithServices(app, store, phaseAStore, hubStore, credentials, executor, nil, 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 registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, executor creator.ActionExecutor, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) {
|
||||
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 {
|
||||
@@ -48,6 +86,41 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
}
|
||||
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())
|
||||
@@ -83,12 +156,22 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
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"`
|
||||
@@ -206,7 +289,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
if err := decodeCreator(c, &input); err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
report, err := syncCreatorCompetitor(c.Context(), store, phaseAStore, hubStore, credentials, c.Params("id"), input.AccountID)
|
||||
report, err := syncCreatorCompetitor(c.Context(), store, phaseAStore, hubStore, c.Params("id"), input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
@@ -218,13 +301,24 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
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/works", func(c fiber.Ctx) error {
|
||||
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)
|
||||
@@ -295,7 +389,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
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, c.Params("id"))
|
||||
item, err := processCreatorMaterial(c.Context(), store, phaseAStore, hubStore, c.Params("id"))
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
@@ -314,6 +408,13 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
}
|
||||
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"`
|
||||
@@ -330,13 +431,24 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
})
|
||||
|
||||
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/comments", func(c fiber.Ctx) error {
|
||||
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)
|
||||
@@ -422,6 +534,26 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
}
|
||||
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"`
|
||||
@@ -441,13 +573,31 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
})
|
||||
|
||||
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.Post("/api/creator/events", func(c fiber.Ctx) error {
|
||||
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)
|
||||
@@ -456,13 +606,14 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
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/events/process", func(c fiber.Ctx) error {
|
||||
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)
|
||||
@@ -475,6 +626,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
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 {
|
||||
@@ -482,6 +634,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
creatorUpdates.publish()
|
||||
return c.JSON(event)
|
||||
})
|
||||
app.Get("/api/creator/operations", func(c fiber.Ctx) error {
|
||||
@@ -529,21 +682,67 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
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", func(c fiber.Ctx) error {
|
||||
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
|
||||
@@ -632,7 +831,6 @@ type creatorGatewayActionExecutor struct {
|
||||
store *creator.Store
|
||||
phaseAStore *phasea.Store
|
||||
hubStore *hub.Store
|
||||
credentials phasea.CredentialBridge
|
||||
}
|
||||
|
||||
func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, request creator.ActionRequest) (creator.ActionResult, error) {
|
||||
@@ -661,25 +859,7 @@ func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, reques
|
||||
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
|
||||
uid, identityErr := browser.Identity(ctx, profile.PlatformAccountKey)
|
||||
if identityErr != nil {
|
||||
resolver, ok := executor.credentials.(phasea.CredentialResolver)
|
||||
if !ok {
|
||||
return creator.ActionResult{}, creator.ErrUnavailable
|
||||
}
|
||||
rawCredential, resolveErr := executor.phaseAStore.ResolveAccountCredential(ctx, request.AccountID, resolver)
|
||||
if resolveErr != nil {
|
||||
return creator.ActionResult{}, resolveErr
|
||||
}
|
||||
cookies, parseErr := douyin.ParseCredential(rawCredential)
|
||||
if parseErr != nil {
|
||||
return creator.ActionResult{}, fmt.Errorf("parse account credential: %w", parseErr)
|
||||
}
|
||||
if setErr := browser.SetCookies(ctx, cookies); setErr != nil {
|
||||
return creator.ActionResult{}, setErr
|
||||
}
|
||||
uid, identityErr = browser.Identity(ctx, profile.PlatformAccountKey)
|
||||
if identityErr != nil {
|
||||
return creator.ActionResult{}, fmt.Errorf("verify account identity after credential injection: %w", identityErr)
|
||||
}
|
||||
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)
|
||||
@@ -714,6 +894,12 @@ func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, reques
|
||||
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 {
|
||||
@@ -782,17 +968,42 @@ func (browser creatorGatewayBrowser) Identity(ctx context.Context, expectedKey s
|
||||
return identity.UID, nil
|
||||
}
|
||||
|
||||
func (browser creatorGatewayBrowser) SetCookies(ctx context.Context, cookies []douyin.Cookie) error {
|
||||
payload := gatewayGenerationPayload(browser.environment)
|
||||
payload["cookies"] = cookies
|
||||
status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/cookies", payload, 30*time.Second)
|
||||
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 err
|
||||
return creator.LoginResult{}, err
|
||||
}
|
||||
if status != http.StatusNoContent && status != http.StatusNotModified {
|
||||
return fmt.Errorf("douyin cookie injection rejected with HTTP %d: %s", status, string(body))
|
||||
profile, err := store.GetAccountProfile(ctx, accountID)
|
||||
if err != nil {
|
||||
return creator.LoginResult{}, err
|
||||
}
|
||||
return nil
|
||||
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) {
|
||||
@@ -816,15 +1027,82 @@ func (browser creatorGatewayBrowser) Get(ctx context.Context, target string) (do
|
||||
return douyin.Response{Status: response.Status, Body: []byte(response.Body), Challenge: response.Challenge}, nil
|
||||
}
|
||||
|
||||
func syncCreatorCompetitor(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, competitorID, accountID string) (creator.CollectionReport, error) {
|
||||
return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, credentials, competitorID, accountID, true)
|
||||
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 syncCreatorCompetitorDue(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, competitorID, accountID string) (creator.CollectionReport, error) {
|
||||
return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, credentials, competitorID, accountID, false)
|
||||
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 syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, competitorID, accountID string, force bool) (creator.CollectionReport, error) {
|
||||
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
|
||||
}
|
||||
@@ -855,7 +1133,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
|
||||
if err != nil {
|
||||
return blocked(err)
|
||||
}
|
||||
if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" {
|
||||
if account.Platform != competitor.Platform || account.AuthorizationStatus != "authorized" {
|
||||
return blocked(creator.ErrConflict)
|
||||
}
|
||||
profile, err := store.GetAccountProfile(ctx, accountID)
|
||||
@@ -878,24 +1156,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
|
||||
}
|
||||
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
|
||||
if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
|
||||
resolver, ok := credentials.(phasea.CredentialResolver)
|
||||
if !ok {
|
||||
return blocked(creator.ErrUnavailable)
|
||||
}
|
||||
rawCredential, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver)
|
||||
if resolveErr != nil {
|
||||
return blocked(fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr))
|
||||
}
|
||||
cookies, parseErr := douyin.ParseCredential(rawCredential)
|
||||
if parseErr != nil {
|
||||
return blocked(fmt.Errorf("%w: invalid account credential", creator.ErrConflict))
|
||||
}
|
||||
if setErr := browser.SetCookies(ctx, cookies); setErr != nil {
|
||||
return blocked(fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr))
|
||||
}
|
||||
if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil {
|
||||
return blocked(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr))
|
||||
}
|
||||
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)
|
||||
@@ -936,7 +1197,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge) error {
|
||||
func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store) error {
|
||||
if store == nil {
|
||||
return creator.ErrUnavailable
|
||||
}
|
||||
@@ -965,7 +1226,7 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto
|
||||
logrus.WithError(err).WithField("competitor_id", competitor.ID).Warn("creator competitor sync blocked")
|
||||
continue
|
||||
}
|
||||
if _, err := syncCreatorCompetitorDue(ctx, store, phaseAStore, hubStore, credentials, competitor.ID, accountID); err != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -974,17 +1235,17 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto
|
||||
return err
|
||||
}
|
||||
for _, accountID := range ownedAccounts {
|
||||
if err := syncCreatorOwned(ctx, store, phaseAStore, hubStore, credentials, accountID, now); err != nil {
|
||||
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, credentials, now); err != nil {
|
||||
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, credentials phasea.CredentialBridge, now time.Time) error {
|
||||
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
|
||||
@@ -1002,14 +1263,14 @@ func runCreatorMetricScheduleOnce(ctx context.Context, store *creator.Store, pha
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := refreshCreatorMetricWork(ctx, store, phaseAStore, hubStore, credentials, work, accountID, settings, now); err != nil {
|
||||
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, credentials phasea.CredentialBridge, work creator.Work, accountID string, settings creator.Settings, now time.Time) error {
|
||||
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
|
||||
@@ -1031,24 +1292,7 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS
|
||||
}
|
||||
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
|
||||
if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
|
||||
resolver, ok := credentials.(phasea.CredentialResolver)
|
||||
if !ok {
|
||||
return creator.ErrUnavailable
|
||||
}
|
||||
raw, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver)
|
||||
if resolveErr != nil {
|
||||
return fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr)
|
||||
}
|
||||
cookies, parseErr := douyin.ParseCredential(raw)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("%w: invalid account credential", creator.ErrConflict)
|
||||
}
|
||||
if setErr := browser.SetCookies(ctx, cookies); setErr != nil {
|
||||
return fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr)
|
||||
}
|
||||
if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil {
|
||||
return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr)
|
||||
}
|
||||
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)
|
||||
@@ -1083,7 +1327,7 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS
|
||||
return creator.ErrInvalid
|
||||
}
|
||||
|
||||
func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, accountID string, now time.Time) error {
|
||||
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
|
||||
}
|
||||
@@ -1121,25 +1365,17 @@ func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *ph
|
||||
}
|
||||
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
|
||||
if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
|
||||
resolver, ok := credentials.(phasea.CredentialResolver)
|
||||
if !ok {
|
||||
return blockOwned(creator.ErrUnavailable)
|
||||
}
|
||||
rawCredential, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver)
|
||||
if resolveErr != nil {
|
||||
return blockOwned(fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr))
|
||||
}
|
||||
cookies, parseErr := douyin.ParseCredential(rawCredential)
|
||||
if parseErr != nil {
|
||||
return blockOwned(fmt.Errorf("%w: invalid account credential", creator.ErrConflict))
|
||||
}
|
||||
if setErr := browser.SetCookies(ctx, cookies); setErr != nil {
|
||||
return blockOwned(fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr))
|
||||
}
|
||||
if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil {
|
||||
return blockOwned(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr))
|
||||
}
|
||||
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 {
|
||||
@@ -1178,11 +1414,11 @@ func creatorCollectionAccount(ctx context.Context, store *creator.Store, phaseAS
|
||||
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, credentials phasea.CredentialBridge) {
|
||||
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, credentials); err != nil {
|
||||
if err := runCreatorScheduleOnce(ctx, store, phaseAStore, hubStore); err != nil {
|
||||
logrus.WithError(err).Error("creator scheduler failed")
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -34,6 +34,7 @@ type creatorGatewayEventNotice struct {
|
||||
InteractorUID string `json:"interactor_uid"`
|
||||
CommentID string `json:"comment_id"`
|
||||
WorkID string `json:"work_id"`
|
||||
MessageType string `json:"message_type,omitempty"`
|
||||
MessageText string `json:"message_text,omitempty"`
|
||||
PlatformEventAt string `json:"platform_event_at,omitempty"`
|
||||
GatewayReceivedAt string `json:"gateway_received_at,omitempty"`
|
||||
@@ -46,6 +47,68 @@ type creatorEventBinding struct {
|
||||
gateway hub.Gateway
|
||||
}
|
||||
|
||||
type creatorUpdateHub struct {
|
||||
mu sync.Mutex
|
||||
subscribers map[chan struct{}]struct{}
|
||||
}
|
||||
|
||||
var creatorUpdates = &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})}
|
||||
|
||||
func (h *creatorUpdateHub) subscribe() (<-chan struct{}, func()) {
|
||||
channel := make(chan struct{}, 1)
|
||||
h.mu.Lock()
|
||||
h.subscribers[channel] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return channel, func() {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.subscribers[channel]; ok {
|
||||
delete(h.subscribers, channel)
|
||||
close(channel)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *creatorUpdateHub) publish() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for channel := range h.subscribers {
|
||||
select {
|
||||
case channel <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func creatorListenerGeneration(env hub.EnvironmentContext) string {
|
||||
return fmt.Sprintf("%s:%s:%d", env.RuntimeID, env.RuntimeNetworkID, env.BindingVersion)
|
||||
}
|
||||
|
||||
func listenerBoundaryPointer(value time.Time) *time.Time {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
value = value.UTC()
|
||||
return &value
|
||||
}
|
||||
|
||||
func persistCreatorListenerState(ctx context.Context, store *creator.Store, binding creatorEventBinding, status, reason string, boundaryAt *time.Time, deliveryID string) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
if _, err := store.UpsertListenerState(ctx, creator.ListenerState{
|
||||
AccountID: binding.accountID,
|
||||
Platform: creator.PlatformDouyin,
|
||||
Generation: creatorListenerGeneration(binding.env),
|
||||
Status: status,
|
||||
BoundaryAt: boundaryAt,
|
||||
LastDeliveryID: deliveryID,
|
||||
Reason: reason,
|
||||
}); err != nil {
|
||||
logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator listener state persistence failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (binding creatorEventBinding) key() string {
|
||||
return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s\x00%s\x00%s\x00%s", binding.gateway.Name, binding.gateway.Endpoint, binding.gateway.Token, binding.env.BindingVersion, binding.env.RuntimeID, binding.env.RuntimeNetworkID, binding.env.Exit.ID, binding.uid)
|
||||
}
|
||||
@@ -180,7 +243,11 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding
|
||||
startPayload[key] = value
|
||||
}
|
||||
startPayload["expected_uid"] = binding.uid
|
||||
defer stopCreatorEventListener(binding.accountID, binding.gateway, path, generation)
|
||||
persistCreatorListenerState(ctx, store, binding, "starting", "等待平台边界标记", nil, "")
|
||||
defer func() {
|
||||
stopCreatorEventListener(binding.accountID, binding.gateway, path, generation)
|
||||
persistCreatorListenerState(context.WithoutCancel(ctx), store, binding, "stopped", "监听已停止", nil, "")
|
||||
}()
|
||||
|
||||
backoff := time.Second
|
||||
for ctx.Err() == nil {
|
||||
@@ -190,6 +257,7 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding
|
||||
err = fmt.Errorf("gateway returned HTTP %d", status)
|
||||
}
|
||||
logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener start failed")
|
||||
persistCreatorListenerState(ctx, store, binding, "error", err.Error(), nil, "")
|
||||
if !waitCreatorEventBackoff(ctx, backoff) {
|
||||
return
|
||||
}
|
||||
@@ -211,11 +279,13 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding
|
||||
err = fmt.Errorf("gateway returned HTTP %d", status)
|
||||
}
|
||||
logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener poll failed")
|
||||
persistCreatorListenerState(ctx, store, binding, "gap", err.Error(), listenerBoundaryPointer(boundaryAt), "")
|
||||
break
|
||||
}
|
||||
var events []creatorGatewayEvent
|
||||
if err := json.Unmarshal(body, &events); err != nil {
|
||||
logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener response is invalid")
|
||||
persistCreatorListenerState(ctx, store, binding, "gap", "监听响应无法解析: "+err.Error(), listenerBoundaryPointer(boundaryAt), "")
|
||||
break
|
||||
}
|
||||
for _, event := range events {
|
||||
@@ -236,10 +306,16 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding
|
||||
ready = false
|
||||
boundaryAt = time.Time{}
|
||||
}
|
||||
status, reason := "gap", "平台边界无效"
|
||||
if ready {
|
||||
status, reason = "ready", ""
|
||||
}
|
||||
persistCreatorListenerState(ctx, store, binding, status, reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID)
|
||||
} else if event.Kind == "open" || event.Kind == "error" || event.Kind == "close" || event.Kind == "reconnected" {
|
||||
// A transport event never proves continuity. Only the explicit
|
||||
// boundary marker permits automatic writes again.
|
||||
ready = false
|
||||
persistCreatorListenerState(ctx, store, binding, "gap", event.Reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID)
|
||||
}
|
||||
if event.Kind == "notice" {
|
||||
if needsBaseline, reason := creatorGatewayEventNeedsBaseline(event, ready); needsBaseline {
|
||||
@@ -249,6 +325,12 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding
|
||||
event.Baseline = true
|
||||
event.Reason = "平台事件早于监听边界"
|
||||
}
|
||||
status := "gap"
|
||||
reason := event.Reason
|
||||
if ready && !event.Baseline {
|
||||
status, reason = "ready", ""
|
||||
}
|
||||
persistCreatorListenerState(ctx, store, binding, status, reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID)
|
||||
}
|
||||
handleCreatorGatewayEvent(ctx, store, binding, event, executor, generator)
|
||||
}
|
||||
@@ -360,6 +442,7 @@ func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, bindin
|
||||
logrus.WithError(err).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator event receipt failed")
|
||||
return
|
||||
}
|
||||
creatorUpdates.publish()
|
||||
if input.EventType == "dm" && input.InteractorUID != "" {
|
||||
messageAt := input.PlatformEventAt
|
||||
if messageAt == nil {
|
||||
@@ -369,7 +452,7 @@ func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, bindin
|
||||
}
|
||||
messageAt = &receivedAt
|
||||
}
|
||||
if _, _, messageErr := store.SaveMessage(ctx, creator.MessageInput{Platform: input.Platform, AccountID: input.ReceivingAccountID, PeerUID: input.InteractorUID, PlatformMessageKey: "event:" + input.EventKey, Direction: "inbound", MessageType: "text", Text: input.MessageText, SentState: "received", MessageAt: messageAt}); messageErr != nil {
|
||||
if _, _, messageErr := store.SaveMessage(ctx, creator.MessageInput{Platform: input.Platform, AccountID: input.ReceivingAccountID, PeerUID: input.InteractorUID, PlatformMessageKey: "event:" + input.EventKey, Direction: "inbound", MessageType: input.MessageType, Text: input.MessageText, SentState: "received", MessageAt: messageAt}); messageErr != nil {
|
||||
logrus.WithError(messageErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator direct message persistence failed")
|
||||
return
|
||||
}
|
||||
@@ -387,6 +470,7 @@ func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, bindin
|
||||
return
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey, "event_type": input.EventType, "state": result.Event.State}).Info("creator event processed")
|
||||
creatorUpdates.publish()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -424,7 +508,14 @@ func creatorEventFromGatewayNotice(accountID string, notice creatorGatewayEventN
|
||||
if (notice.CommentID != "" && !creatorEventID(notice.CommentID)) || (notice.WorkID != "" && !creatorEventID(notice.WorkID)) {
|
||||
return creator.InteractionEvent{}, creator.ErrInvalid
|
||||
}
|
||||
result := creator.InteractionEvent{Platform: creator.PlatformDouyin, ReceivingAccountID: accountID, EventKey: notice.EventKey, EventType: notice.EventType, InteractorUID: notice.InteractorUID, CommentID: notice.CommentID, WorkID: notice.WorkID, MessageText: strings.TrimSpace(notice.MessageText)}
|
||||
messageType := strings.TrimSpace(notice.MessageType)
|
||||
if messageType == "" {
|
||||
messageType = creator.MessageTypeText
|
||||
}
|
||||
if !creator.ValidMessageType(messageType) {
|
||||
return creator.InteractionEvent{}, creator.ErrInvalid
|
||||
}
|
||||
result := creator.InteractionEvent{Platform: creator.PlatformDouyin, ReceivingAccountID: accountID, EventKey: notice.EventKey, EventType: notice.EventType, InteractorUID: notice.InteractorUID, CommentID: notice.CommentID, WorkID: notice.WorkID, MessageType: messageType, MessageText: strings.TrimSpace(notice.MessageText)}
|
||||
if strings.TrimSpace(notice.PlatformEventAt) != "" {
|
||||
at, err := time.Parse(time.RFC3339Nano, notice.PlatformEventAt)
|
||||
if err != nil {
|
||||
@@ -438,7 +529,8 @@ func creatorEventFromGatewayNotice(accountID string, notice creatorGatewayEventN
|
||||
if err != nil {
|
||||
return creator.InteractionEvent{}, fmt.Errorf("invalid gateway receipt time: %w", err)
|
||||
}
|
||||
result.ReceivedAt = receivedAt.UTC()
|
||||
receivedAt = receivedAt.UTC()
|
||||
result.GatewayReceivedAt = &receivedAt
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -4,9 +4,30 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
)
|
||||
|
||||
func TestCreatorUpdateHubPublishesAndUnsubscribes(t *testing.T) {
|
||||
hub := &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})}
|
||||
updates, unsubscribe := hub.subscribe()
|
||||
hub.publish()
|
||||
select {
|
||||
case <-updates:
|
||||
default:
|
||||
t.Fatal("publish did not notify subscriber")
|
||||
}
|
||||
unsubscribe()
|
||||
hub.publish()
|
||||
select {
|
||||
case _, ok := <-updates:
|
||||
if ok {
|
||||
t.Fatal("unsubscribed channel received an update")
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayGenerationPayloadIncludesCurrentProxyExit(t *testing.T) {
|
||||
payload := gatewayGenerationPayload(hub.EnvironmentContext{
|
||||
BindingVersion: 3,
|
||||
@@ -60,8 +81,8 @@ func TestCreatorEventFromGatewayNotice(t *testing.T) {
|
||||
if event.PlatformEventAt == nil || event.PlatformEventAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:20Z" {
|
||||
t.Fatalf("unexpected event time: %+v", event.PlatformEventAt)
|
||||
}
|
||||
if event.ReceivedAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:21Z" {
|
||||
t.Fatalf("unexpected gateway receipt time: %v", event.ReceivedAt)
|
||||
if event.GatewayReceivedAt == nil || event.GatewayReceivedAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:21Z" {
|
||||
t.Fatalf("unexpected gateway receipt time: %v", event.GatewayReceivedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +108,18 @@ func TestCreatorEventFromGatewayNoticeRejectsInvalidGatewayReceiptTime(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorEventFromGatewayNoticePreservesMessageType(t *testing.T) {
|
||||
result, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{
|
||||
EventKey: "123",
|
||||
EventType: "dm",
|
||||
InteractorUID: "456",
|
||||
MessageType: creator.MessageTypeImage,
|
||||
})
|
||||
if err != nil || result.MessageType != creator.MessageTypeImage {
|
||||
t.Fatalf("message type was not preserved: result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorEventFromGatewayNoticeRejectsInvalidTime(t *testing.T) {
|
||||
_, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{
|
||||
EventKey: "1",
|
||||
|
||||
@@ -3,20 +3,61 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/phasea"
|
||||
)
|
||||
|
||||
const maxCreatorMaterialBytes int64 = 512 << 20
|
||||
type creatorMaterialDownloader struct {
|
||||
store *creator.Store
|
||||
phaseAStore *phasea.Store
|
||||
hubStore *hub.Store
|
||||
}
|
||||
|
||||
func processCreatorMaterial(ctx context.Context, store *creator.Store, workID string) (creator.MaterialJob, error) {
|
||||
func (downloader creatorMaterialDownloader) Download(ctx context.Context, work creator.Work, destination string) error {
|
||||
if work.Platform != creator.PlatformDouyin || downloader.store == nil || downloader.phaseAStore == nil || downloader.hubStore == nil {
|
||||
return fmt.Errorf("%w: creator media gateway is unavailable", creator.ErrUnavailable)
|
||||
}
|
||||
accountID := work.SourceID
|
||||
if work.SourceType == creator.SourceCompetitor {
|
||||
var err error
|
||||
accountID, err = creatorCollectionAccount(ctx, downloader.store, downloader.phaseAStore, downloader.hubStore, work.Platform)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
account, err := downloader.phaseAStore.GetAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profile, err := downloader.store.GetAccountProfile(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if account.Platform != creator.PlatformDouyin || profile.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || profile.LoginStatus != "logged_in" || account.PlatformAccountKey != profile.PlatformAccountKey {
|
||||
return fmt.Errorf("%w: media account identity is not verified", creator.ErrConflict)
|
||||
}
|
||||
environment, err := downloader.hubStore.GetEnvironmentContextForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: media browser environment unavailable: %v", creator.ErrUnavailable, err)
|
||||
}
|
||||
gateway, err := downloader.hubStore.GetGateway(ctx, environment.Gateway)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: media gateway unavailable: %v", creator.ErrUnavailable, err)
|
||||
}
|
||||
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
|
||||
if _, err := browser.Identity(ctx, profile.PlatformAccountKey); err != nil {
|
||||
return fmt.Errorf("%w: media browser identity verification failed: %v", creator.ErrConflict, err)
|
||||
}
|
||||
return browser.Media(ctx, work.OriginalURL, destination)
|
||||
}
|
||||
|
||||
func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, workID string) (creator.MaterialJob, error) {
|
||||
if store == nil || workID == "" || filepath.Base(workID) != workID {
|
||||
return creator.MaterialJob{}, creator.ErrInvalid
|
||||
}
|
||||
@@ -46,7 +87,7 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, workID st
|
||||
if _, err := store.SetMaterialStep(ctx, workID, "download", "running", "", ""); err != nil {
|
||||
return creator.MaterialJob{}, err
|
||||
}
|
||||
if err := downloadCreatorMaterial(ctx, work.OriginalURL, videoPath); err != nil {
|
||||
if err := (creatorMaterialDownloader{store: store, phaseAStore: phaseAStore, hubStore: hubStore}).Download(ctx, work, videoPath); err != nil {
|
||||
return setMaterialFailure(ctx, store, workID, "download", err)
|
||||
}
|
||||
job, err = store.SetMaterialStep(ctx, workID, "download", "succeeded", videoReference, "")
|
||||
@@ -84,8 +125,15 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, workID st
|
||||
if job.AudioStatus == "no_audio" {
|
||||
job, err = store.SetMaterialStep(ctx, workID, "transcription", "no_speech", "", "没有可转写的音轨")
|
||||
} else {
|
||||
settings, settingsErr := store.GetSettings(ctx)
|
||||
if settingsErr != nil {
|
||||
return creator.MaterialJob{}, settingsErr
|
||||
}
|
||||
if !settings.TranscriptionConfigured || strings.TrimSpace(settings.TranscriptionProvider) == "" || strings.TrimSpace(settings.TranscriptionModel) == "" {
|
||||
return setMaterialFailure(ctx, store, workID, "transcription", fmt.Errorf("transcription provider is not configured"))
|
||||
}
|
||||
if _, err = store.SetMaterialStep(ctx, workID, "transcription", "running", "", ""); err == nil {
|
||||
transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath)
|
||||
transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath, settings.TranscriptionModel)
|
||||
if transcribeErr != nil {
|
||||
job, err = setMaterialFailure(ctx, store, workID, "transcription", transcribeErr)
|
||||
} else if strings.TrimSpace(transcript) == "" {
|
||||
@@ -107,46 +155,6 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, workID st
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func downloadCreatorMaterial(ctx context.Context, rawURL, destination string) error {
|
||||
parsed, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil || (parsed.URL.Scheme != "http" && parsed.URL.Scheme != "https") || parsed.URL.Host == "" {
|
||||
return creator.ErrInvalid
|
||||
}
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
response, err := client.Do(parsed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download material: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("download material: HTTP %s", response.Status)
|
||||
}
|
||||
if response.ContentLength > maxCreatorMaterialBytes {
|
||||
return fmt.Errorf("download material exceeds size limit")
|
||||
}
|
||||
temporary := destination + ".tmp"
|
||||
defer os.Remove(temporary)
|
||||
file, err := os.OpenFile(temporary, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create material file: %w", err)
|
||||
}
|
||||
written, copyErr := io.Copy(file, io.LimitReader(response.Body, maxCreatorMaterialBytes+1))
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("write material: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close material file: %w", closeErr)
|
||||
}
|
||||
if written > maxCreatorMaterialBytes {
|
||||
return fmt.Errorf("download material exceeds size limit")
|
||||
}
|
||||
if err := os.Rename(temporary, destination); err != nil {
|
||||
return fmt.Errorf("publish material: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func creatorMaterialHasAudio(ctx context.Context, videoPath string) (bool, error) {
|
||||
if _, err := exec.LookPath("ffprobe"); err != nil {
|
||||
return false, fmt.Errorf("ffprobe is unavailable: %w", err)
|
||||
@@ -178,15 +186,36 @@ func extractCreatorAudio(ctx context.Context, videoPath, audioPath string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func transcribeCreatorAudio(ctx context.Context, audioPath string) (string, error) {
|
||||
binary := os.Getenv("CREATOR_TRANSCRIPTION_BIN")
|
||||
if binary == "" {
|
||||
return "", fmt.Errorf("transcription provider is not configured")
|
||||
func validateTranscriptionBinary(binary string) error {
|
||||
path, err := exec.LookPath(binary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup transcription provider %q: %w", binary, err)
|
||||
}
|
||||
if _, err := exec.LookPath(binary); err != nil {
|
||||
if path == "" {
|
||||
return fmt.Errorf("binary path is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func transcribeCreatorAudio(ctx context.Context, audioPath, model string) (string, error) {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return "", fmt.Errorf("transcription model is not configured")
|
||||
}
|
||||
configured := strings.TrimSpace(os.Getenv("CREATOR_TRANSCRIPTION_BIN"))
|
||||
var binary string
|
||||
switch filepath.Base(configured) {
|
||||
case "whisper":
|
||||
binary = "whisper"
|
||||
case "faster-whisper":
|
||||
binary = "faster-whisper"
|
||||
default:
|
||||
return "", fmt.Errorf("transcription provider is not configured or unsupported")
|
||||
}
|
||||
if err := validateTranscriptionBinary(binary); err != nil {
|
||||
return "", fmt.Errorf("transcription provider is unavailable: %w", err)
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, binary, audioPath).Output()
|
||||
output, err := exec.CommandContext(ctx, binary, audioPath, "--model", model).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("transcribe audio: %w", err)
|
||||
}
|
||||
|
||||
@@ -2,52 +2,45 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDownloadCreatorMaterialPublishesOnlyCompleteFiles(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/source" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte("media"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
func TestWriteCreatorMediaPublishesOnlyCompleteFiles(t *testing.T) {
|
||||
destination := filepath.Join(t.TempDir(), "source")
|
||||
if err := downloadCreatorMaterial(context.Background(), server.URL+"/source", destination); err != nil {
|
||||
t.Fatalf("download material: %v", err)
|
||||
if err := writeCreatorMedia(destination, []byte("media")); err != nil {
|
||||
t.Fatalf("write media: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(destination)
|
||||
if err != nil {
|
||||
t.Fatalf("read material: %v", err)
|
||||
t.Fatalf("read media: %v", err)
|
||||
}
|
||||
if string(content) != "media" {
|
||||
t.Fatalf("unexpected material: %q", content)
|
||||
t.Fatalf("unexpected media: %q", content)
|
||||
}
|
||||
if _, err := os.Stat(destination + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("temporary file remains: %v", err)
|
||||
matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".creator-media-*"))
|
||||
if err != nil {
|
||||
t.Fatalf("find temporary media: %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary files remain: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadCreatorMaterialRejectsInvalidResponses(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
if err := downloadCreatorMaterial(context.Background(), server.URL, filepath.Join(t.TempDir(), "source")); err == nil {
|
||||
t.Fatal("expected HTTP failure")
|
||||
func TestWriteCreatorMediaRejectsEmptyAndOversizedFiles(t *testing.T) {
|
||||
destination := filepath.Join(t.TempDir(), "source")
|
||||
if err := writeCreatorMedia(destination, nil); err == nil {
|
||||
t.Fatal("expected empty media to be rejected")
|
||||
}
|
||||
if err := downloadCreatorMaterial(context.Background(), "file:///tmp/source", filepath.Join(t.TempDir(), "source")); err == nil {
|
||||
t.Fatal("expected non-HTTP URL failure")
|
||||
if err := writeCreatorMedia(destination, make([]byte, maxCreatorMediaBytes+1)); err == nil {
|
||||
t.Fatal("expected oversized media to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeCreatorAudioRequiresExplicitProvider(t *testing.T) {
|
||||
t.Setenv("CREATOR_TRANSCRIPTION_BIN", "")
|
||||
if _, err := transcribeCreatorAudio(context.Background(), "/tmp/audio.wav"); err == nil {
|
||||
if _, err := transcribeCreatorAudio(context.Background(), "/tmp/audio.wav", "base"); err == nil {
|
||||
t.Fatal("expected missing provider error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,26 +18,11 @@ type douyinGatewayBrowser struct {
|
||||
}
|
||||
|
||||
type douyinGatewayRequest struct {
|
||||
BindingVersion int64 `json:"binding_version"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
NetworkID string `json:"network_id"`
|
||||
NetworkExitID string `json:"network_exit_id"`
|
||||
Cookies []douyin.Cookie `json:"cookies,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (browser douyinGatewayBrowser) SetCookies(ctx context.Context, cookies []douyin.Cookie) error {
|
||||
request, err := browser.request()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Cookies = cookies
|
||||
status, _, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
|
||||
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/cookies", request, 30*time.Second)
|
||||
if err != nil || status != http.StatusNoContent {
|
||||
return errors.New("restricted browser operation failed")
|
||||
}
|
||||
return nil
|
||||
BindingVersion int64 `json:"binding_version"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
NetworkID string `json:"network_id"`
|
||||
NetworkExitID string `json:"network_exit_id"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (browser douyinGatewayBrowser) Get(ctx context.Context, target string) (douyin.Response, error) {
|
||||
|
||||
@@ -27,12 +27,6 @@ func TestDouyinGatewayBrowserFencesAccountGeneration(t *testing.T) {
|
||||
t.Fatalf("generation fence missing: %#v", body)
|
||||
}
|
||||
switch request.URL.Path {
|
||||
case "/v1/browsers/account-a/douyin/cookies":
|
||||
cookies, ok := body["cookies"].([]any)
|
||||
if !ok || len(cookies) != 1 {
|
||||
t.Fatalf("cookies missing: %#v", body)
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
case "/v1/browsers/account-a/douyin/get":
|
||||
if body["url"] != testDouyinIdentityURL {
|
||||
t.Fatalf("unexpected URL: %#v", body)
|
||||
@@ -44,11 +38,8 @@ func TestDouyinGatewayBrowserFencesAccountGeneration(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
|
||||
if err := browser.SetCookies(context.Background(), []douyin.Cookie{{Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := browser.Get(context.Background(), testDouyinIdentityURL)
|
||||
if err != nil || result.Status != 412 || result.Challenge != douyin.ChallengeCaptcha || requests != 2 {
|
||||
if err != nil || result.Status != 412 || result.Challenge != douyin.ChallengeCaptcha || requests != 1 {
|
||||
t.Fatalf("unexpected result: %#v requests=%d err=%v", result, requests, err)
|
||||
}
|
||||
}
|
||||
@@ -72,7 +63,7 @@ func TestDouyinGatewayBrowserDoesNotEchoCredentialOnFailure(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
|
||||
err := browser.SetCookies(context.Background(), []douyin.Cookie{{Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/"}})
|
||||
_, err := browser.Get(context.Background(), testDouyinIdentityURL)
|
||||
if err == nil || strings.Contains(err.Error(), "private-session") {
|
||||
t.Fatalf("gateway failure leaked credential: %v", err)
|
||||
}
|
||||
|
||||
@@ -96,11 +96,11 @@ func newCommand() *cobra.Command {
|
||||
creatorScheduleDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(creatorScheduleDone)
|
||||
runCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore, credentials)
|
||||
runCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore)
|
||||
}()
|
||||
creatorEventContext, stopCreatorEvents := context.WithCancel(command.Context())
|
||||
creatorEventDone := make(chan struct{})
|
||||
creatorEventExecutor := creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials}
|
||||
creatorEventExecutor := creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore}
|
||||
creatorAI := &creator.ConfiguredBailian{Store: creatorStore, APIKey: cfg.aiAPIKey, BaseURL: cfg.aiBaseURL}
|
||||
go func() {
|
||||
defer close(creatorEventDone)
|
||||
@@ -295,13 +295,13 @@ func newHandlerWithCreatorAndAI(webDirectory, username, password string, phaseAS
|
||||
}
|
||||
if creatorStore != nil {
|
||||
if generator == nil && analyzer == nil {
|
||||
registerCreator(app, creatorStore, phaseAStore, hubStore, credentials)
|
||||
registerCreator(app, creatorStore, phaseAStore, hubStore)
|
||||
} else {
|
||||
var executor creator.ActionExecutor
|
||||
if phaseAStore != nil && hubStore != nil {
|
||||
executor = creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials}
|
||||
executor = creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore}
|
||||
}
|
||||
registerCreatorWithServices(app, creatorStore, phaseAStore, hubStore, credentials, executor, generator, analyzer)
|
||||
registerCreatorWithServices(app, creatorStore, phaseAStore, hubStore, executor, generator, analyzer)
|
||||
}
|
||||
}
|
||||
app.Get("/*", spaHandler(webDirectory))
|
||||
|
||||
Reference in New Issue
Block a user