fix: complete Douyin release remediation

This commit is contained in:
2026-09-16 01:51:14 +08:00
parent 977e541fef
commit 9095920a5b
56 changed files with 4530 additions and 518 deletions
+161 -23
View File
@@ -94,7 +94,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
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 {
if _, err := fmt.Fprint(w, "retry: 5000\n\n"); err != nil {
return
}
if err := w.Flush(); err != nil {
@@ -105,11 +105,11 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
for {
select {
case <-updates:
if _, err := fmt.Fprint(w, "event: creator-update\\ndata: {}\\n\\n"); err != nil {
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 {
if _, err := fmt.Fprint(w, ": keep-alive\n\n"); err != nil {
return
}
case <-c.RequestCtx().Done():
@@ -250,11 +250,46 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
}
return c.JSON(items)
})
app.Post("/api/creator/competitors", func(c fiber.Ctx) error {
var input creator.CompetitorInput
app.Post("/api/creator/competitors/preview", func(c fiber.Ctx) error {
var input struct {
AccountID string `json:"account_id"`
creator.CompetitorInput
}
if err := decodeCreator(c, &input); err != nil {
return creatorError(c, err)
}
if input.Platform != creator.PlatformDouyin {
return creatorError(c, creator.ErrUnavailable)
}
profile, err := previewDouyinCompetitor(c.Context(), store, phaseAStore, hubStore, input.AccountID, input.CompetitorInput)
if err != nil {
return creatorError(c, err)
}
return c.JSON(profile)
})
app.Post("/api/creator/competitors", func(c fiber.Ctx) error {
var request struct {
AccountID string `json:"account_id"`
creator.CompetitorInput
}
if err := decodeCreator(c, &request); err != nil {
return creatorError(c, err)
}
input := request.CompetitorInput
if input.Platform == creator.PlatformDouyin {
preview, err := previewDouyinCompetitor(c.Context(), store, phaseAStore, hubStore, request.AccountID, input)
if err != nil {
return creatorError(c, err)
}
canonical, ok := preview["platform_account_key"].(string)
if !ok || canonical == "" {
return creatorError(c, creator.ErrConflict)
}
input.PlatformAccountKey = canonical
if homepage, ok := preview["homepage_url"].(string); ok {
input.HomepageURL = homepage
}
}
if err := validateXiaohongshuCompetitor(input); err != nil {
return creatorError(c, err)
}
@@ -616,6 +651,13 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
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 {
@@ -681,6 +723,13 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
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 {
@@ -710,6 +759,20 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
}
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 {
@@ -767,7 +830,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
return creatorError(c, err)
}
if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" ||
profile.Platform != creator.PlatformDouyin || profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" ||
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)
}
@@ -784,7 +847,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
if err != nil {
return creatorError(c, fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err))
}
history, err := browser.MessageHistory(c.Context(), accountUID, conversation.PeerUID, limit)
history, err := browser.MessageHistory(c.Context(), accountUID, conversation.PeerUID, conversation.HistoryCursor, limit)
if err != nil {
return creatorError(c, err)
}
@@ -795,11 +858,15 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
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,
})
})
@@ -896,7 +963,7 @@ func persistDouyinMessageHistory(ctx context.Context, store *creator.Store, conv
if item.SenderUID == accountUID {
direction, state = "outbound", "succeeded"
}
_, wasInserted, err := store.SaveMessage(ctx, creator.MessageInput{
savedMessage, wasInserted, err := store.SaveMessage(ctx, creator.MessageInput{
Platform: creator.PlatformDouyin,
AccountID: conversation.AccountID,
PeerUID: conversation.PeerUID,
@@ -911,6 +978,9 @@ func persistDouyinMessageHistory(ctx context.Context, store *creator.Store, conv
if err != nil {
return inserted, err
}
if err := store.LinkMessageOperation(ctx, savedMessage.ID, item.ServerID); err != nil {
return inserted, err
}
if wasInserted {
inserted++
}
@@ -927,7 +997,7 @@ func setStrategyEnabled(c fiber.Ctx, store *creator.Store, enabled bool) error {
}
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")}
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
@@ -1033,6 +1103,7 @@ func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, reques
}
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
@@ -1042,23 +1113,31 @@ func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, reques
if errors.Is(targetErr, creator.ErrNotFound) {
comment, targetErr = executor.store.GetCommentByKey(ctx, request.Platform, request.TargetCommentID)
}
if targetErr != nil {
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
}
payload["target_comment_id"] = comment.CommentKey
if request.TargetWorkID == "" {
request.TargetWorkID = comment.WorkID
}
}
if request.TargetWorkID != "" {
work, targetErr := executor.store.GetWork(ctx, request.TargetWorkID)
if errors.Is(targetErr, creator.ErrNotFound) {
work, targetErr = executor.store.GetWorkByKey(ctx, request.Platform, request.TargetWorkID)
}
if targetErr != nil {
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["target_work_id"] = work.WorkKey
}
payload["text"] = request.Text
payload["confirm"] = true
@@ -1141,13 +1220,14 @@ func (browser creatorGatewayBrowser) Identity(ctx context.Context, expectedKey s
return identity.UID, nil
}
func (browser creatorGatewayBrowser) MessageHistory(ctx context.Context, expectedUID, targetUID string, limit int) (douyinMessageHistory, error) {
if strings.TrimSpace(expectedUID) == "" || strings.TrimSpace(targetUID) == "" || limit < 1 || limit > 200 {
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 {
@@ -1285,6 +1365,50 @@ func (browser creatorGatewayBrowser) Media(ctx context.Context, target, destinat
return writeCreatorMedia(destination, data)
}
func previewDouyinCompetitor(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID string, input creator.CompetitorInput) (map[string]any, error) {
if store == nil || phaseAStore == nil || hubStore == nil || input.Platform != creator.PlatformDouyin || strings.TrimSpace(accountID) == "" {
return nil, creator.ErrInvalid
}
account, err := phaseAStore.GetAccount(ctx, accountID)
if err != nil {
return nil, err
}
if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" {
return nil, creator.ErrConflict
}
profile, err := store.GetAccountProfile(ctx, accountID)
if err != nil {
return nil, err
}
if profile.Platform != creator.PlatformDouyin || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" || profile.PlatformAccountKey == "" {
return nil, creator.ErrConflict
}
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)
if err != nil {
return nil, fmt.Errorf("%w: account environment unavailable: %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)
}
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
if _, err := browser.Identity(ctx, profile.PlatformAccountKey); err != nil {
return nil, fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, 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,
"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 *phasea.Store, hubStore *hub.Store, competitorID, accountID string) (creator.CollectionReport, error) {
return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, competitorID, accountID, true)
}
@@ -1336,7 +1460,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
if err != nil {
return blocked(err)
}
if profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" {
if (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" {
return blocked(creator.ErrConflict)
}
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)
@@ -1461,6 +1585,18 @@ func runCreatorMetricScheduleOnce(ctx context.Context, store *creator.Store, pha
}
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 {
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
@@ -1469,7 +1605,7 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS
if err != nil {
return err
}
if (account.Platform != creator.PlatformDouyin && account.Platform != creator.PlatformXiaohongshu) || account.AuthorizationStatus != "authorized" || profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" {
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)
@@ -1537,7 +1673,7 @@ func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *ph
if err != nil {
return err
}
if profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" {
if (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" {
return creator.ErrConflict
}
settings, err := store.GetSettings(ctx)
@@ -1589,10 +1725,12 @@ func creatorCollectionAccount(ctx context.Context, store *creator.Store, phaseAS
if account.Platform != platform || account.AuthorizationStatus != "authorized" {
continue
}
if _, err := store.GetAccountProfile(ctx, account.ID); err != nil {
profile, err := store.GetAccountProfile(ctx, account.ID)
if err != nil || profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted" || profile.LoginStatus != "logged_in" {
continue
}
if _, err := hubStore.GetEnvironmentContextForAccount(ctx, account.ID); err != nil {
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, account.ID)
if err != nil || environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 {
continue
}
return account.ID, nil
+44 -6
View File
@@ -8,6 +8,7 @@ import (
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"git.ipao.vip/rogee/creator-hub/internal/creator"
@@ -20,6 +21,7 @@ const creatorEventReconcileInterval = 10 * time.Second
type creatorGatewayEvent struct {
DeliveryID string `json:"delivery_id,omitempty"`
Generation string `json:"generation,omitempty"`
Kind string `json:"kind"`
Reason string `json:"reason,omitempty"`
Continuity string `json:"continuity,omitempty"`
@@ -42,10 +44,11 @@ type creatorGatewayEventNotice struct {
}
type creatorEventBinding struct {
accountID string
uid string
env hub.EnvironmentContext
gateway hub.Gateway
accountID string
uid string
env hub.EnvironmentContext
gateway hub.Gateway
sessionToken string
}
type creatorUpdateHub struct {
@@ -85,6 +88,12 @@ func creatorListenerGeneration(env hub.EnvironmentContext) string {
return fmt.Sprintf("%s:%s:%d", env.RuntimeID, env.RuntimeNetworkID, env.BindingVersion)
}
var creatorListenerSessionNonce atomic.Uint64
func creatorListenerSessionToken(env hub.EnvironmentContext) string {
return fmt.Sprintf("%s/%d-%d", creatorListenerGeneration(env), time.Now().UnixNano(), creatorListenerSessionNonce.Add(1))
}
func listenerBoundaryPointer(value time.Time) *time.Time {
if value.IsZero() {
return nil
@@ -101,6 +110,7 @@ func persistCreatorListenerState(ctx context.Context, store *creator.Store, bind
AccountID: binding.accountID,
Platform: creator.PlatformDouyin,
Generation: creatorListenerGeneration(binding.env),
SessionToken: binding.sessionToken,
Status: status,
BoundaryAt: boundaryAt,
LastDeliveryID: deliveryID,
@@ -193,7 +203,10 @@ func (manager *creatorEventListenerManager) reconcile(ctx context.Context, store
for accountID, current := range manager.items {
binding, ok := desired[accountID]
if ok && current.key == binding.key() {
continue
state, stateErr := store.GetListenerState(ctx, accountID)
if stateErr != nil || !state.Invalidated {
continue
}
}
current.cancel()
delete(manager.items, accountID)
@@ -237,6 +250,7 @@ func (manager *creatorEventListenerManager) close() {
}
func runCreatorEventListener(ctx context.Context, store *creator.Store, binding creatorEventBinding, executor creator.ActionExecutor, generator creator.TextGenerator) {
binding.sessionToken = creatorListenerSessionToken(binding.env)
path := "/v1/browsers/" + url.PathEscape(binding.env.Alias) + "/douyin/events"
generation := gatewayGenerationPayload(binding.env)
startPayload := make(map[string]any, len(generation)+1)
@@ -414,6 +428,19 @@ func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, bindin
}
input.Baseline = event.Baseline
input.BaselineReason = event.Reason
input.Generation = strings.TrimSpace(event.Generation)
if input.Generation == "" {
input.Generation = creatorListenerGeneration(binding.env)
}
selfEvent := binding.uid != "" && input.InteractorUID == binding.uid
if selfEvent {
input.Baseline = true
input.BaselineReason = "接收账号主动行为"
}
if input.GatewayReceivedAt == nil {
receivedAt := time.Now().UTC()
input.GatewayReceivedAt = &receivedAt
}
if input.PlatformEventAt == nil {
input.Baseline = true
input.BaselineReason = "缺少平台事件时间"
@@ -439,10 +466,21 @@ 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: input.MessageType, Text: input.MessageText, SentState: "received", MessageAt: messageAt}); messageErr != nil {
direction, sentState := "inbound", "received"
if selfEvent {
direction, sentState = "outbound", "succeeded"
}
savedMessage, _, messageErr := store.SaveMessage(ctx, creator.MessageInput{Platform: input.Platform, AccountID: input.ReceivingAccountID, PeerUID: input.InteractorUID, PlatformMessageKey: input.EventKey, Direction: direction, MessageType: input.MessageType, Text: input.MessageText, SentState: sentState, MessageAt: messageAt})
if messageErr != nil {
logrus.WithError(messageErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator direct message persistence failed")
return
}
if selfEvent {
if err := store.LinkMessageOperation(ctx, savedMessage.ID, input.EventKey); err != nil {
logrus.WithError(err).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator outbound direct message correlation failed")
return
}
}
}
// The event is now durable. An action may be slow or unavailable, but that
// must not hold ingestion or cause the same receipt to be fetched forever.
+45
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"testing"
"time"
@@ -8,6 +9,50 @@ import (
"git.ipao.vip/rogee/creator-hub/internal/hub"
)
func TestCreatorListenerPrimitives(t *testing.T) {
env := hub.EnvironmentContext{Env: hub.Env{Alias: "browser/a"}, BindingVersion: 3, RuntimeID: "runtime", RuntimeNetworkID: "network", Exit: hub.NetworkExit{ID: "exit"}}
generation := creatorListenerGeneration(env)
if generation != "runtime:network:3" {
t.Fatalf("unexpected generation: %q", generation)
}
tokenA, tokenB := creatorListenerSessionToken(env), creatorListenerSessionToken(env)
if tokenA == tokenB {
t.Fatal("listener session tokens must be unique")
}
if listenerBoundaryPointer(time.Time{}) != nil {
t.Fatal("zero boundary must remain nil")
}
boundary := listenerBoundaryPointer(time.Date(2024, 1, 1, 1, 0, 0, 0, time.FixedZone("test", 3600)))
if boundary == nil || !boundary.Equal(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("boundary was not normalized: %v", boundary)
}
binding := creatorEventBinding{accountID: "account", uid: "uid", env: env, gateway: hub.Gateway{Name: "gateway", Endpoint: "http://gateway", Token: "token"}, sessionToken: "session"}
if binding.key() == "" || pathForCreatorEvent(env) != "/v1/browsers/browser%2Fa/douyin/events" {
t.Fatalf("unexpected listener identity: key=%q path=%q", binding.key(), pathForCreatorEvent(env))
}
}
func TestCreatorEventListenerShutdownAndEventKinds(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if waitCreatorEventBackoff(ctx, time.Hour) {
t.Fatal("cancelled listener backoff must stop")
}
manager := &creatorEventListenerManager{items: map[string]creatorEventListenerHandle{
"account": {cancel: func() {}, done: func() chan struct{} { ch := make(chan struct{}); close(ch); return ch }()},
}}
manager.close()
if len(manager.items) != 0 {
t.Fatal("listener manager did not clear handles")
}
for _, kind := range []string{"error", "reconnected", "open", "baseline", "close", "unknown"} {
handleCreatorGatewayEvent(context.Background(), nil, creatorEventBinding{}, creatorGatewayEvent{Kind: kind}, nil, nil)
}
if err := manager.reconcile(context.Background(), nil, nil, nil, nil, nil); err != creator.ErrUnavailable {
t.Fatalf("nil listener dependencies: %v", err)
}
}
func TestCreatorUpdateHubPublishesAndUnsubscribes(t *testing.T) {
hub := &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})}
updates, unsubscribe := hub.subscribe()
+234
View File
@@ -0,0 +1,234 @@
package main
import (
"context"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.ipao.vip/rogee/creator-hub/internal/creator"
"git.ipao.vip/rogee/creator-hub/internal/hub"
"github.com/gofiber/fiber/v3"
)
func TestCreatorHelperBranches(t *testing.T) {
if got := flattenActionEvidence(map[string]string{}, "evidence", "text"); got != 1 {
t.Fatalf("flatten string count = %d", got)
}
values := map[string]string{}
if got := flattenActionEvidence(values, "evidence", map[string]any{"nested": "value", "empty": "", "number": 1}); got != 1 || values["evidence.nested"] != "value" {
t.Fatalf("flatten map = %d, %#v", got, values)
}
if got := flattenActionEvidence(values, "evidence", []any{"ignored"}); got != 0 {
t.Fatalf("flatten unsupported count = %d", got)
}
if token, err := materialClaimToken(); err != nil || len(token) != 32 {
t.Fatalf("material claim token = %q, %v", token, err)
}
if _, err := creatorMaterialHasAudio(context.Background(), "/does/not/exist"); err == nil {
t.Fatal("missing media must not report audio")
}
mediaPath := filepath.Join(t.TempDir(), "media.bin")
if err := writeCreatorMedia(mediaPath, []byte("media")); err != nil {
t.Fatal(err)
}
if data, err := os.ReadFile(mediaPath); err != nil || string(data) != "media" || !fileExists(mediaPath) {
t.Fatalf("media write: %q %v", data, err)
}
if err := os.WriteFile(filepath.Join(filepath.Dir(mediaPath), "empty"), nil, 0o600); err != nil {
t.Fatal(err)
}
if fileExists(filepath.Join(filepath.Dir(mediaPath), "empty")) || fileExists("/does/not/exist") {
t.Fatal("empty or missing media reported as existing")
}
for _, input := range [][2]string{{"", ""}, {"unsupported", "model"}, {"whisper", "model"}} {
if _, err := transcribeCreatorAudio(context.Background(), "/does/not/exist", input[0], input[1]); err == nil {
t.Fatalf("invalid transcription config accepted: %v", input)
}
}
if err := validateTranscriptionBinary("/does/not/exist"); err == nil {
t.Fatal("missing transcription binary accepted")
}
if _, err := processCreatorMaterial(context.Background(), nil, nil, nil, "../escape"); !errors.Is(err, creator.ErrInvalid) {
t.Fatalf("invalid material path = %v", err)
}
if _, err := verifyCreatorPlatformIdentity(context.Background(), "unsupported", hub.Gateway{}, hub.EnvironmentContext{}, "key"); !errors.Is(err, creator.ErrUnavailable) {
t.Fatalf("unsupported identity platform = %v", err)
}
if _, _, err := newCreatorCollector(context.Background(), creator.PlatformDouyin, hub.Gateway{}, hub.EnvironmentContext{}, "", "target", "", creator.SourceCompetitor, "id"); !errors.Is(err, creator.ErrInvalid) {
t.Fatalf("missing collector key = %v", err)
}
if _, err := decodeXiaohongshuResponse([]byte("not-json")); err == nil {
t.Fatal("malformed Xiaohongshu response must fail")
}
if response, err := decodeXiaohongshuResponse([]byte(`{"status":200,"body":"ok","challenge":""}`)); err != nil || response.Status != 200 || string(response.Body) != "ok" {
t.Fatalf("decode Xiaohongshu response = %+v, %v", response, err)
}
if _, err := decodeBase64("not-base64"); err == nil {
t.Fatal("invalid base64 must fail")
}
encoded := base64.StdEncoding.EncodeToString([]byte("media"))
if data, err := decodeBase64(encoded); err != nil || string(data) != "media" {
t.Fatalf("decode media = %q, %v", data, err)
}
if err := validateXiaohongshuSource("", ""); err == nil {
t.Fatal("empty Xiaohongshu source must fail")
}
if err := validateXiaohongshuCompetitor(creator.CompetitorInput{Platform: creator.PlatformDouyin}); err != nil {
t.Fatal(err)
}
}
func TestCreatorPageQueryValidation(t *testing.T) {
app := fiber.New()
app.Get("/", func(c fiber.Ctx) error {
page, pageSize, enabled, err := creatorPageQuery(c)
if err != nil {
return creatorError(c, err)
}
return c.JSON(map[string]any{"page": page, "page_size": pageSize, "enabled": enabled})
})
for _, query := range []string{"", "?page=2&page_size=10&enabled=false"} {
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/"+query, nil))
if err != nil || response.StatusCode != http.StatusOK {
t.Fatalf("valid query %s: %d %v", query, response.StatusCode, err)
}
}
for _, query := range []string{"?page=bad"} {
response, err := app.Test(httptest.NewRequest(http.MethodGet, "/"+query, nil))
if err != nil || response.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid query %s: %d %v", query, response.StatusCode, err)
}
}
}
func TestCreatorControlPlaneGuards(t *testing.T) {
ctx := context.Background()
if _, err := persistDouyinMessageHistory(ctx, nil, creator.Conversation{Platform: creator.PlatformXiaohongshu}, "uid", nil); !errors.Is(err, creator.ErrInvalid) {
t.Fatalf("invalid history store/platform = %v", err)
}
conversation := creator.Conversation{Platform: creator.PlatformDouyin, AccountID: "account", PeerUID: "peer"}
cases := []douyinHistoryMessage{
{},
{ServerID: "id", SenderUID: "sender", Content: []byte("not-json")},
{ServerID: "id", SenderUID: "sender", CreatedAt: "not-a-time"},
}
for _, item := range cases {
if _, err := persistDouyinMessageHistory(ctx, nil, conversation, "account", []douyinHistoryMessage{item}); !errors.Is(err, creator.ErrInvalid) {
t.Fatalf("invalid history item %v = %v", item, err)
}
}
if _, err := (creatorGatewayActionExecutor{}).Execute(ctx, creator.ActionRequest{}); !errors.Is(err, creator.ErrUnavailable) {
t.Fatalf("empty action executor = %v", err)
}
if err := (creatorMaterialDownloader{}).Download(ctx, creator.Work{Platform: "unsupported"}, "/tmp/media"); !errors.Is(err, creator.ErrUnavailable) {
t.Fatalf("unsupported material platform = %v", err)
}
if _, err := newXiaohongshuReadCollector(ctx, nil, nil, nil, "", creator.SourceOwned, "id"); !errors.Is(err, creator.ErrUnavailable) {
t.Fatalf("empty Xiaohongshu collector = %v", err)
}
if listenerBoundaryPointer(time.Time{}) != nil {
t.Fatal("zero listener boundary should be nil")
}
}
func TestCreatorSchedulerAndPreviewGuards(t *testing.T) {
ctx := context.Background()
checks := []struct {
name string
call func() error
}{
{"verify account", func() error { _, err := verifyCreatorAccount(ctx, nil, nil, nil, "account"); return err }},
{"preview competitor", func() error {
_, err := previewDouyinCompetitor(ctx, nil, nil, nil, "account", creator.CompetitorInput{})
return err
}},
{"sync competitor", func() error { _, err := syncCreatorCompetitor(ctx, nil, nil, nil, "competitor", "account"); return err }},
{"sync due competitor", func() error {
_, err := syncCreatorCompetitorDue(ctx, nil, nil, nil, "competitor", "account")
return err
}},
{"sync owned", func() error { return syncCreatorOwned(ctx, nil, nil, nil, "account", time.Now()) }},
{"select collection account", func() error {
_, err := creatorCollectionAccount(ctx, nil, nil, nil, creator.PlatformDouyin)
return err
}},
}
for _, check := range checks {
if err := check.call(); err == nil {
t.Fatalf("%s unexpectedly succeeded", check.name)
}
}
}
func TestCreatorGatewayBrowserHistoryAndMedia(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/douyin/messages"):
_, _ = w.Write([]byte(`{"status":"succeeded","account_uid":"account","history_source":"douyin","messages":[]}`))
case strings.HasSuffix(r.URL.Path, "/douyin/media"):
_, _ = w.Write([]byte(`{"status":200,"content_type":"video/mp4","body_base64":"` + base64.StdEncoding.EncodeToString([]byte("video")) + `"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
browser := creatorGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "token"}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, RuntimeID: "runtime", RuntimeNetworkID: "network", BindingVersion: 1}}
history, err := browser.MessageHistory(context.Background(), "account", "peer", "", 10)
if err != nil || history.Status != "succeeded" || history.HistorySource != "douyin" {
t.Fatalf("history = %+v, %v", history, err)
}
if _, err := browser.MessageHistory(context.Background(), "", "peer", "", 10); err == nil {
t.Fatal("empty history identity must fail")
}
path := filepath.Join(t.TempDir(), "media.mp4")
if err := browser.Media(context.Background(), "https://video.example/media", path); err != nil {
t.Fatal(err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "video" {
t.Fatalf("media output = %q, %v", data, err)
}
}
func TestXiaohongshuGatewayMedia(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if !strings.HasSuffix(r.URL.Path, "/xiaohongshu/media") {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(`{"status":200,"content_type":"image/jpeg","body_base64":"` + base64.StdEncoding.EncodeToString([]byte("image")) + `"}`))
}))
defer server.Close()
browser := xiaohongshuGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "token"}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, AccountID: "account", AccountStatus: "active", AuthorizationStatus: "authorized", BindingID: "binding", RuntimeInstanceID: "instance", RuntimeID: "runtime", RuntimeNetworkID: "network", BindingVersion: 1, Exit: hub.NetworkExit{ID: "exit", HealthStatus: "healthy"}}}
data, contentType, err := browser.Media(context.Background(), "https://www.xiaohongshu.com/explore/abc")
if err != nil || string(data) != "image" || contentType != "image/jpeg" {
t.Fatalf("media = %q, %q, %v", data, contentType, err)
}
}
func TestCreatorGatewayBrowserIdentity(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/douyin/identity") {
t.Fatalf("unexpected identity request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"uid":"verified-uid"}`))
}))
defer server.Close()
browser := creatorGatewayBrowser{
gateway: hub.Gateway{Endpoint: server.URL, Token: "token"},
environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, RuntimeID: "runtime", RuntimeNetworkID: "network", BindingVersion: 1},
}
uid, err := browser.Identity(context.Background(), "expected-key")
if err != nil || uid != "verified-uid" {
t.Fatalf("identity = %q, %v", uid, err)
}
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"context"
"encoding/json"
"os"
"strconv"
"testing"
"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"
)
func TestPersistDouyinMessageHistory(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run history coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
phaseAStore, err := phasea.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
store, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
credentials := &testCredentialBridge{values: make(map[string]string)}
if err := phaseAStore.CreateAccount(ctx, phasea.Account{
ID: "history-account", Name: "History Account", Platform: creator.PlatformDouyin,
PlatformAccountKey: "history-platform", Tags: []string{}, Cookies: "",
CredentialReference: phasea.CredentialReference{ID: "history-credential", Provider: "os_keyring"},
CredentialKey: "creatorhub/history-account/cookies",
}, credentials); err != nil {
t.Fatal(err)
}
if err := store.EnsureAccountProfile(ctx, "history-account"); err != nil {
t.Fatal(err)
}
conversation := creator.Conversation{Platform: creator.PlatformDouyin, AccountID: "history-account", PeerUID: "peer", PeerName: "Peer"}
stamp := time.Now().UTC().UnixMilli()
messages := []douyinHistoryMessage{
{ServerID: "history-inbound", SenderUID: "peer", Content: json.RawMessage(`{"text":"inbound"}`), CreatedAt: strconv.FormatInt(stamp, 10)},
{ServerID: "history-outbound", SenderUID: "history-account", Content: json.RawMessage(`{"text":"outbound"}`), CreatedAt: strconv.FormatInt(stamp+1, 10)},
}
inserted, err := persistDouyinMessageHistory(ctx, store, conversation, "history-account", messages)
if err != nil || inserted != 2 {
t.Fatalf("persist history: inserted=%d err=%v", inserted, err)
}
inserted, err = persistDouyinMessageHistory(ctx, store, conversation, "history-account", messages)
if err != nil || inserted != 0 {
t.Fatalf("deduplicate history: inserted=%d err=%v", inserted, err)
}
badContent := []douyinHistoryMessage{{ServerID: "bad", SenderUID: "peer", Content: json.RawMessage(`{"text":`), CreatedAt: strconv.FormatInt(stamp, 10)}}
if _, err := persistDouyinMessageHistory(ctx, store, conversation, "history-account", badContent); err != creator.ErrInvalid {
t.Fatalf("bad history content: %v", err)
}
}
+89 -49
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"os/exec"
@@ -68,7 +70,7 @@ func (downloader creatorMaterialDownloader) Download(ctx context.Context, work c
}
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 {
if store == nil || workID == "" || workID == "." || workID == ".." || strings.ContainsAny(workID, `/\\`) || filepath.Base(workID) != workID {
return creator.MaterialJob{}, creator.ErrInvalid
}
work, err := store.GetWork(ctx, workID)
@@ -94,13 +96,26 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseASto
videoPath := filepath.Join(dir, "source")
videoReference := filepath.Join(workID, "source")
if job.DownloadStatus != "succeeded" || !fileExists(videoPath) {
if _, err := store.SetMaterialStep(ctx, workID, "download", "running", "", ""); err != nil {
if job.DownloadStatus == "succeeded" {
if _, err := store.SetMaterialStep(ctx, workID, "download", "failed", "", "下载产物不存在"); err != nil {
return creator.MaterialJob{}, err
}
}
token, tokenErr := materialClaimToken()
if tokenErr != nil {
return creator.MaterialJob{}, tokenErr
}
job, claimed, err := store.ClaimMaterialStep(ctx, workID, "download", token)
if err != nil {
return creator.MaterialJob{}, err
}
if err := (creatorMaterialDownloader{store: store, phaseAStore: phaseAStore, hubStore: hubStore}).Download(ctx, work, videoPath); err != nil {
return setMaterialFailure(ctx, store, workID, "download", err)
if !claimed {
return job, fmt.Errorf("%w: download step is already in progress", creator.ErrConflict)
}
job, err = store.SetMaterialStep(ctx, workID, "download", "succeeded", videoReference, "")
if err := (creatorMaterialDownloader{store: store, phaseAStore: phaseAStore, hubStore: hubStore}).Download(ctx, work, videoPath); err != nil {
return setMaterialFailure(ctx, store, workID, "download", token, err)
}
job, err = store.CompleteMaterialStep(ctx, workID, "download", token, "succeeded", videoReference, "")
if err != nil {
return creator.MaterialJob{}, err
}
@@ -108,53 +123,72 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseASto
audioPath := filepath.Join(dir, "audio.wav")
audioReference := filepath.Join(workID, "audio.wav")
if job.AudioStatus != "succeeded" && job.AudioStatus != "no_audio" {
if _, err := store.SetMaterialStep(ctx, workID, "audio", "running", "", ""); err != nil {
if job.AudioStatus != "succeeded" && job.AudioStatus != "no_audio" || job.AudioStatus == "succeeded" && !fileExists(audioPath) {
if job.AudioStatus == "succeeded" {
if _, err := store.SetMaterialStep(ctx, workID, "audio", "failed", "", "音频产物不存在"); err != nil {
return creator.MaterialJob{}, err
}
}
token, tokenErr := materialClaimToken()
if tokenErr != nil {
return creator.MaterialJob{}, tokenErr
}
job, claimed, err := store.ClaimMaterialStep(ctx, workID, "audio", token)
if err != nil {
return creator.MaterialJob{}, err
}
if !claimed {
return job, fmt.Errorf("%w: audio step is already in progress", creator.ErrConflict)
}
hasAudio, err := creatorMaterialHasAudio(ctx, videoPath)
if err != nil {
return setMaterialFailure(ctx, store, workID, "audio", err)
return setMaterialFailure(ctx, store, workID, "audio", token, err)
}
if !hasAudio {
job, err = store.SetMaterialStep(ctx, workID, "audio", "no_audio", "", "视频没有音轨")
if err != nil {
return creator.MaterialJob{}, err
}
job, err = store.CompleteMaterialStep(ctx, workID, "audio", token, "no_audio", "", "视频没有音轨")
} else if err := extractCreatorAudio(ctx, videoPath, audioPath); err != nil {
return setMaterialFailure(ctx, store, workID, "audio", err)
return setMaterialFailure(ctx, store, workID, "audio", token, err)
} else {
job, err = store.SetMaterialStep(ctx, workID, "audio", "succeeded", audioReference, "")
if err != nil {
return creator.MaterialJob{}, err
}
job, err = store.CompleteMaterialStep(ctx, workID, "audio", token, "succeeded", audioReference, "")
}
if err != nil {
return creator.MaterialJob{}, err
}
}
if job.TranscriptionStatus != "succeeded" && job.TranscriptionStatus != "no_speech" {
token, tokenErr := materialClaimToken()
if tokenErr != nil {
return creator.MaterialJob{}, tokenErr
}
job, claimed, claimErr := store.ClaimMaterialStep(ctx, workID, "transcription", token)
if claimErr != nil {
return creator.MaterialJob{}, claimErr
}
if !claimed {
return job, fmt.Errorf("%w: transcription step is already in progress", creator.ErrConflict)
}
if job.AudioStatus == "no_audio" {
job, err = store.SetMaterialStep(ctx, workID, "transcription", "no_speech", "", "没有可转写的音轨")
job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "no_speech", "", "没有可转写的音轨")
} else {
settings, settingsErr := store.GetSettings(ctx)
if settingsErr != nil {
return creator.MaterialJob{}, settingsErr
return setMaterialFailure(ctx, store, workID, "transcription", token, 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"))
return setMaterialFailure(ctx, store, workID, "transcription", token, fmt.Errorf("transcription provider is not configured"))
}
if _, err = store.SetMaterialStep(ctx, workID, "transcription", "running", "", ""); err == nil {
transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath, settings.TranscriptionModel)
if transcribeErr != nil {
job, err = setMaterialFailure(ctx, store, workID, "transcription", transcribeErr)
} else if strings.TrimSpace(transcript) == "" {
job, err = store.SetMaterialStep(ctx, workID, "transcription", "no_speech", "", "转写未检测到语音")
transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath, settings.TranscriptionProvider, settings.TranscriptionModel)
if transcribeErr != nil {
job, err = setMaterialFailure(ctx, store, workID, "transcription", token, transcribeErr)
} else if strings.TrimSpace(transcript) == "" {
job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "no_speech", "", "转写未检测到语音")
} else {
transcriptPath := filepath.Join(dir, "transcript.txt")
if writeErr := os.WriteFile(transcriptPath, []byte(transcript), 0o600); writeErr != nil {
job, err = setMaterialFailure(ctx, store, workID, "transcription", token, writeErr)
} else {
transcriptPath := filepath.Join(dir, "transcript.txt")
if writeErr := os.WriteFile(transcriptPath, []byte(transcript), 0o600); writeErr != nil {
job, err = setMaterialFailure(ctx, store, workID, "transcription", writeErr)
} else {
job, err = store.SetMaterialStep(ctx, workID, "transcription", "succeeded", filepath.Join(workID, "transcript.txt"), "")
}
job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "succeeded", transcript, "")
}
}
}
@@ -165,6 +199,14 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseASto
return job, nil
}
func materialClaimToken() (string, error) {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
return "", fmt.Errorf("create material claim token: %w", err)
}
return hex.EncodeToString(data[:]), 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)
@@ -183,7 +225,7 @@ func extractCreatorAudio(ctx context.Context, videoPath, audioPath string) error
}
temporary := audioPath + ".tmp"
defer os.Remove(temporary)
command := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-v", "error", "-y", "-i", videoPath, "-vn", "-ac", "1", "-ar", "16000", temporary)
command := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-v", "error", "-y", "-i", videoPath, "-vn", "-ac", "1", "-ar", "16000", "-f", "wav", temporary)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("extract audio: %w: %s", err, strings.TrimSpace(string(output)))
}
@@ -207,25 +249,23 @@ func validateTranscriptionBinary(binary string) error {
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")
func transcribeCreatorAudio(ctx context.Context, audioPath, provider, model string) (string, error) {
provider, model = strings.TrimSpace(provider), strings.TrimSpace(model)
if provider == "" || model == "" {
return "", fmt.Errorf("transcription provider and model are not configured")
}
if provider != "whisper" && provider != "faster-whisper" {
return "", fmt.Errorf("unsupported transcription provider %q", provider)
}
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 configured == "" || filepath.Base(configured) != provider {
return "", fmt.Errorf("transcription binary does not match configured provider %q", provider)
}
if err := validateTranscriptionBinary(binary); err != nil {
if err := validateTranscriptionBinary(configured); err != nil {
return "", fmt.Errorf("transcription provider is unavailable: %w", err)
}
output, err := exec.CommandContext(ctx, binary, audioPath, "--model", model).Output()
command := exec.CommandContext(ctx, configured, audioPath, "--model", model)
output, err := command.Output()
if err != nil {
return "", fmt.Errorf("transcribe audio: %w", err)
}
@@ -235,8 +275,8 @@ func transcribeCreatorAudio(ctx context.Context, audioPath, model string) (strin
return string(output), nil
}
func setMaterialFailure(ctx context.Context, store *creator.Store, workID, step string, cause error) (creator.MaterialJob, error) {
job, err := store.SetMaterialStep(ctx, workID, step, "failed", "", cause.Error())
func setMaterialFailure(ctx context.Context, store *creator.Store, workID, step, token string, cause error) (creator.MaterialJob, error) {
job, err := store.CompleteMaterialStep(ctx, workID, step, token, "failed", "", cause.Error())
if err != nil {
return creator.MaterialJob{}, fmt.Errorf("record %s failure: %w", step, err)
}
+47 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -38,9 +39,54 @@ func TestWriteCreatorMediaRejectsEmptyAndOversizedFiles(t *testing.T) {
}
}
func TestExtractCreatorAudioPublishesWavAtomically(t *testing.T) {
dir := t.TempDir()
fakeFFmpeg := filepath.Join(dir, "ffmpeg")
argsFile := filepath.Join(dir, "args")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGS_FILE\"\nfor arg in \"$@\"; do output=\"$arg\"; done\nprintf 'RIFFfake' > \"$output\"\n"
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o700); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("ARGS_FILE", argsFile)
videoPath := filepath.Join(dir, "source")
audioPath := filepath.Join(dir, "audio.wav")
if err := os.WriteFile(videoPath, []byte("video"), 0o600); err != nil {
t.Fatalf("write source: %v", err)
}
if err := extractCreatorAudio(context.Background(), videoPath, audioPath); err != nil {
t.Fatalf("extract audio: %v", err)
}
if !fileExists(audioPath) {
t.Fatal("expected published audio")
}
if _, err := os.Stat(audioPath + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("temporary audio remains: %v", err)
}
args, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read ffmpeg args: %v", err)
}
if !strings.Contains(string(args), "-f\n") || !strings.Contains(string(args), "\nwav\n") {
t.Fatalf("ffmpeg did not request WAV output: %s", args)
}
}
func TestTranscribeCreatorAudioRequiresExplicitProvider(t *testing.T) {
t.Setenv("CREATOR_TRANSCRIPTION_BIN", "")
if _, err := transcribeCreatorAudio(context.Background(), "/tmp/audio.wav", "base"); err == nil {
if _, err := transcribeCreatorAudio(context.Background(), "/tmp/audio.wav", "whisper", "base"); err == nil {
t.Fatal("expected missing provider error")
}
}
func TestTranscribeCreatorAudioUsesConfiguredBinaryPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "whisper")
if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf configured-transcript\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("CREATOR_TRANSCRIPTION_BIN", path)
transcript, err := transcribeCreatorAudio(context.Background(), "/tmp/audio.wav", "whisper", "base")
if err != nil || transcript != "configured-transcript" {
t.Fatalf("configured transcription: transcript=%q err=%v", transcript, err)
}
}
+28
View File
@@ -58,6 +58,34 @@ func TestPersistentCredentialBridgeStoreFailureLeavesNoFile(t *testing.T) {
}
}
func TestPersistentCredentialBridgeResolvesAndRejectsCorruption(t *testing.T) {
bridge, err := newPersistentCredentialBridge(t.TempDir(), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
key := "creatorhub/account-a/cookies"
if err := bridge.Store(context.Background(), testCredentialReference, key, testCredentialValue); err != nil {
t.Fatal(err)
}
value, err := bridge.Resolve(context.Background(), testCredentialReference, key)
if err != nil || string(value) != testCredentialValue {
t.Fatalf("resolve credential: %q %v", value, err)
}
path := bridge.path(testCredentialReference.Provider, key)
if err := os.WriteFile(path, []byte("corrupted"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := bridge.Resolve(context.Background(), testCredentialReference, key); err == nil {
t.Fatal("corrupted credential resolved")
}
if err := bridge.Delete(context.Background(), testCredentialReference, key); err != nil {
t.Fatal(err)
}
if err := bridge.Delete(context.Background(), testCredentialReference, key); err != nil {
t.Fatal(err)
}
}
func TestPersistentCredentialBridgeStoresEncryptedCredentialAcrossProcesses(t *testing.T) {
if action := os.Getenv("CREATORHUB_CREDENTIAL_HELPER_ACTION"); action != "" {
bridge, err := newPersistentCredentialBridge(os.Getenv("CREATORHUB_CREDENTIAL_HELPER_DIR"), []byte("0123456789abcdef0123456789abcdef"))
+1
View File
@@ -61,6 +61,7 @@ type douyinHistoryMessage struct {
type douyinMessageHistory struct {
Status string `json:"status"`
HistorySource string `json:"history_source"`
HistoryCursor string `json:"history_cursor"`
HistoryHasMore bool `json:"history_has_more"`
AccountUID string `json:"account_uid"`
Conversation map[string]any `json:"conversation"`
+42
View File
@@ -51,6 +51,12 @@ type hubStore interface {
AppendEnvironmentAction(ctx context.Context, eventType string, action hub.EnvironmentAction) error
}
type networkExitAdminStore interface {
UpdateNetworkExit(context.Context, string, hub.NetworkExit) (hub.NetworkExit, error)
EnableNetworkExit(context.Context, string) (hub.NetworkExit, error)
DeleteNetworkExit(context.Context, string) error
}
type runtimeStopStore interface {
LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error)
GetEnvironmentContextForAccount(ctx context.Context, accountID string) (hub.EnvironmentContext, error)
@@ -432,6 +438,42 @@ func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitPro
app.Post("/api/network-exits", createNetworkExit(store))
app.Post("/api/network-exits/:id/check", checkNetworkExit(store, probe))
app.Post("/api/network-exits/:id/disable", disableNetworkExit(store, probe, resolve))
app.Put("/api/network-exits/:id", func(c fiber.Ctx) error {
admin, ok := store.(networkExitAdminStore)
if !ok {
return hubError(c, hub.ErrConflict)
}
input := hub.NetworkExit{}
if err := decodeHubJSON(c, &input); err != nil {
return hubError(c, err)
}
exit, err := admin.UpdateNetworkExit(c.Context(), c.Params("id"), input)
if err != nil {
return hubError(c, err)
}
return c.JSON(exit)
})
app.Post("/api/network-exits/:id/enable", func(c fiber.Ctx) error {
admin, ok := store.(networkExitAdminStore)
if !ok {
return hubError(c, hub.ErrConflict)
}
exit, err := admin.EnableNetworkExit(c.Context(), c.Params("id"))
if err != nil {
return hubError(c, err)
}
return c.JSON(exit)
})
app.Delete("/api/network-exits/:id", func(c fiber.Ctx) error {
admin, ok := store.(networkExitAdminStore)
if !ok {
return hubError(c, hub.ErrConflict)
}
if err := admin.DeleteNetworkExit(c.Context(), c.Params("id")); err != nil {
return hubError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
})
app.Get("/api/browser-images", func(c fiber.Ctx) error {
images, err := store.ListImages(c.Context(), false)
+396
View File
@@ -18,6 +18,7 @@ import (
"testing"
"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"
"git.ipao.vip/rogee/creator-hub/internal/taskstate"
@@ -115,6 +116,398 @@ func TestLoadConfigRequiresCredentialMasterKey(t *testing.T) {
}
}
func TestCreatorReadRoutesAgainstPostgres(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator route coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
phaseAStore, err := phasea.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore)
for _, path := range []string{
"/api/creator/settings", "/api/creator/accounts", "/api/creator/competitors", "/api/creator/relations",
"/api/creator/accounts/route-account/strategies", "/api/creator/rules", "/api/creator/rule-results", "/api/creator/leads",
"/api/creator/works", "/api/creator/works?page=1&page_size=10", "/api/creator/comments", "/api/creator/comments?page=1&page_size=10",
"/api/creator/listener-boundaries", "/api/creator/events", "/api/creator/events?page=1&page_size=10",
"/api/creator/listeners", "/api/creator/operations", "/api/creator/conversations",
} {
response := do(app, http.MethodGet, path, "", "operator", "unit-test-password")
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
}
}
func TestCreatorRouteValidationCoverage(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator route coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
phaseAStore, err := phasea.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
credentials := &testCredentialBridge{values: make(map[string]string)}
if err := phaseAStore.CreateAccount(ctx, phasea.Account{
ID: "route-account", Name: "Route Account", Platform: creator.PlatformDouyin,
PlatformAccountKey: "route-platform", Tags: []string{}, Cookies: "",
CredentialReference: phasea.CredentialReference{ID: "route-account-credential", Provider: "os_keyring"},
CredentialKey: "creatorhub/route-account/cookies",
}, credentials); err != nil {
t.Fatal(err)
}
app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore)
for _, path := range []string{
"/api/creator/accounts/route-account/profile", "/api/creator/accounts/route-account/strategies",
} {
response := do(app, http.MethodGet, path, "", "operator", "unit-test-password")
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
}
for _, path := range []string{
"/api/creator/works/missing/metrics", "/api/creator/rule-results?comment_id=missing",
"/api/creator/events/missing/strategy-trace", "/api/creator/conversations/missing/messages",
} {
response := do(app, http.MethodGet, path, "", "operator", "unit-test-password")
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
}
for _, path := range []string{
"/api/creator/competitors/missing", "/api/creator/works/missing",
"/api/creator/comments/missing", "/api/creator/rules/missing",
"/api/creator/operations/missing", "/api/creator/operations/missing/verification",
} {
response := do(app, http.MethodGet, path, "", "operator", "unit-test-password")
if response.Code != http.StatusNotFound {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
}
response := do(app, http.MethodGet, "/api/creator/works/missing/material", "", "operator", "unit-test-password")
if response.Code != http.StatusBadRequest {
t.Fatalf("GET material for missing work returned %d: %s", response.Code, response.Body.String())
}
for _, route := range []struct {
method string
path string
}{
{http.MethodPut, "/api/creator/settings"},
{http.MethodPut, "/api/creator/accounts/missing/profile"},
{http.MethodPost, "/api/creator/accounts/missing/login-result"},
{http.MethodPost, "/api/creator/accounts/missing/big-account"},
{http.MethodPost, "/api/creator/relations"},
{http.MethodPost, "/api/creator/accounts/missing/strategies"},
{http.MethodPost, "/api/creator/strategies/missing/enable"},
{http.MethodPost, "/api/creator/strategies/missing/disable"},
{http.MethodDelete, "/api/creator/strategies/missing"},
{http.MethodPut, "/api/creator/strategies/missing"},
{http.MethodPost, "/api/creator/competitors/preview"},
{http.MethodPost, "/api/creator/competitors"},
{http.MethodPost, "/api/creator/competitors/missing/pause"},
{http.MethodPost, "/api/creator/competitors/missing/resume"},
{http.MethodPost, "/api/creator/competitors/missing/sync"},
{http.MethodPost, "/api/creator/xiaohongshu/search"},
{http.MethodPost, "/api/creator/xiaohongshu/detail"},
{http.MethodPost, "/api/creator/works/missing/metrics"},
{http.MethodPost, "/api/creator/works/missing/material/select"},
{http.MethodPost, "/api/creator/works/missing/material/process"},
{http.MethodPost, "/api/creator/works/missing/material/rewrite/confirm"},
{http.MethodPost, "/api/creator/works/missing/material/rewrite/generate"},
{http.MethodPut, "/api/creator/works/missing/material/rewrite"},
{http.MethodPost, "/api/creator/rules"},
{http.MethodPut, "/api/creator/rules/missing"},
{http.MethodPost, "/api/creator/rules/missing/enable"},
{http.MethodPost, "/api/creator/rules/missing/disable"},
{http.MethodPost, "/api/creator/comments/analyze"},
{http.MethodPost, "/api/creator/comments/missing/analyze"},
{http.MethodPost, "/api/creator/events/missing/display"},
{http.MethodPost, "/api/creator/operations"},
{http.MethodPost, "/api/creator/operations/missing/execute"},
{http.MethodPost, "/api/creator/conversations/missing/sync"},
{http.MethodPost, "/api/creator/messages/send"},
} {
response := do(app, route.method, route.path, "{", "operator", "unit-test-password")
if response.Code != http.StatusBadRequest && response.Code != http.StatusNotFound && response.Code != http.StatusNoContent && response.Code != http.StatusServiceUnavailable {
t.Fatalf("%s %s returned %d for invalid JSON: %s", route.method, route.path, response.Code, response.Body.String())
}
}
for _, path := range []string{
"/api/creator/works", "/api/creator/comments", "/api/creator/events", "/api/creator/events/process", "/api/creator/messages",
} {
response := do(app, http.MethodPost, path, `{}`, "operator", "unit-test-password")
if response.Code != http.StatusConflict {
t.Fatalf("POST %s accepted public platform input: %d", path, response.Code)
}
}
}
func TestCreatorFixtureRoutesPostgres(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator fixture coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
phaseAStore, err := phasea.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
credentials := &testCredentialBridge{values: make(map[string]string)}
if err := phaseAStore.CreateAccount(ctx, phasea.Account{
ID: "fixture-account", Name: "Fixture Account", Platform: creator.PlatformDouyin,
PlatformAccountKey: "fixture-platform", Tags: []string{}, Cookies: "",
CredentialReference: phasea.CredentialReference{ID: "fixture-credential", Provider: "os_keyring"},
CredentialKey: "creatorhub/fixture-account/cookies",
}, credentials); err != nil {
t.Fatal(err)
}
if err := phaseAStore.CreateAccount(ctx, phasea.Account{
ID: "fixture-small", Name: "Fixture Small", Platform: creator.PlatformDouyin,
PlatformAccountKey: "fixture-small-platform", Tags: []string{}, Cookies: "",
CredentialReference: phasea.CredentialReference{ID: "fixture-small-credential", Provider: "os_keyring"},
CredentialKey: "creatorhub/fixture-small/cookies",
}, credentials); err != nil {
t.Fatal(err)
}
if err := creatorStore.EnsureAccountProfile(ctx, "fixture-small"); err != nil {
t.Fatal(err)
}
app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore)
idFrom := func(response *httptest.ResponseRecorder) string {
var value struct {
ID string `json:"id"`
}
if err := json.Unmarshal(response.Body.Bytes(), &value); err != nil || value.ID == "" {
t.Fatalf("response has no ID: %s (%v)", response.Body.String(), err)
}
return value.ID
}
workBody := `{"platform":"douyin","work_key":"fixture-work","source_type":"owned","source_id":"fixture-account","author_name":"author","title":"title","body":"body","published_at":"2024-01-01T00:00:00Z","published_at_status":"verified"}`
workResponse := do(app, http.MethodPost, "/api/creator/test/works", workBody, "operator", "unit-test-password")
if workResponse.Code != http.StatusCreated {
t.Fatalf("create fixture work: %d %s", workResponse.Code, workResponse.Body.String())
}
workID := idFrom(workResponse)
if response := do(app, http.MethodPost, "/api/creator/test/works", workBody, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("deduplicate fixture work: %d %s", response.Code, response.Body.String())
}
for _, path := range []string{"/api/creator/works/" + workID, "/api/creator/works/" + workID + "/metrics", "/api/creator/works/" + workID + "/material"} {
if response := do(app, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET %s: %d %s", path, response.Code, response.Body.String())
}
}
metricBody := `{"likes":2,"comments_count":1,"shares":1}`
if response := do(app, http.MethodPost, "/api/creator/works/"+workID+"/metrics", metricBody, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("record fixture metric: %d %s", response.Code, response.Body.String())
}
materialResponse := do(app, http.MethodPost, "/api/creator/works/"+workID+"/material/select", `{}`, "operator", "unit-test-password")
if materialResponse.Code != http.StatusCreated && materialResponse.Code != http.StatusOK {
t.Fatalf("select fixture material: %d %s", materialResponse.Code, materialResponse.Body.String())
}
if _, claimed, err := creatorStore.ClaimMaterialStep(ctx, workID, "download", "fixture-material-token"); err != nil || !claimed {
t.Fatalf("claim fixture material download: claimed=%v err=%v", claimed, err)
}
if _, err := setMaterialFailure(ctx, creatorStore, workID, "download", "fixture-material-token", errors.New("fixture download failed")); err != nil {
t.Fatalf("record fixture material failure: %v", err)
}
commentBody := `{"platform":"douyin","comment_key":"fixture-comment","work_id":"` + workID + `","author_uid":"peer","author_name":"Peer","content":"hello","comment_type":"top_level","published_at":"2024-01-01T00:00:00Z"}`
commentResponse := do(app, http.MethodPost, "/api/creator/test/comments", commentBody, "operator", "unit-test-password")
if commentResponse.Code != http.StatusCreated {
t.Fatalf("create fixture comment: %d %s", commentResponse.Code, commentResponse.Body.String())
}
commentID := idFrom(commentResponse)
if response := do(app, http.MethodPost, "/api/creator/test/comments", commentBody, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("deduplicate fixture comment: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodGet, "/api/creator/comments/"+commentID, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET fixture comment: %d %s", response.Code, response.Body.String())
}
profileBody := `{"real_name_status":"unknown","business_status":"normal","cooldown_seconds":60}`
if response := do(app, http.MethodPut, "/api/creator/accounts/fixture-account/profile", profileBody, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("update fixture profile: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/creator/accounts/fixture-account/big-account", `{"enabled":true}`, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("enable fixture big account: %d %s", response.Code, response.Body.String())
}
for _, accountID := range []string{"fixture-account", "fixture-small"} {
if _, err := creatorStore.UpdateAccountProfile(ctx, accountID, creator.AccountProfileUpdate{RealNameStatus: "unknown", BusinessStatus: "normal", BigAccount: accountID == "fixture-account", CooldownSeconds: 60}); err != nil {
t.Fatal(err)
}
}
if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-account", "fixture-platform"); err != nil {
t.Fatal(err)
}
if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-small", "fixture-small-platform"); err != nil {
t.Fatal(err)
}
if err := creatorStore.SetRelation(ctx, "fixture-account", "fixture-small", true); err != nil {
t.Fatal(err)
}
strategyBody := `{"execution_account_id":"fixture-small","position":1,"enabled":true,"event_types":["comment"],"action":"reply_comment","target_type":"comment","candidate_texts":["已收到"]}`
strategyResponse := do(app, http.MethodPost, "/api/creator/accounts/fixture-account/strategies", strategyBody, "operator", "unit-test-password")
if strategyResponse.Code != http.StatusCreated {
t.Fatalf("create fixture strategy: %d %s", strategyResponse.Code, strategyResponse.Body.String())
}
strategyID := idFrom(strategyResponse)
for _, route := range []struct {
method string
path string
body string
}{
{http.MethodPut, "/api/creator/strategies/" + strategyID, strategyBody},
{http.MethodPost, "/api/creator/strategies/" + strategyID + "/enable", `{}`},
{http.MethodPost, "/api/creator/strategies/" + strategyID + "/disable", `{}`},
} {
if response := do(app, route.method, route.path, route.body, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("%s %s: %d %s", route.method, route.path, response.Code, response.Body.String())
}
}
if response := do(app, http.MethodDelete, "/api/creator/strategies/"+strategyID, "", "operator", "unit-test-password"); response.Code != http.StatusNoContent {
t.Fatalf("delete fixture strategy: %d %s", response.Code, response.Body.String())
}
ruleBody := `{"name":"fixture-rule","enabled":true,"source_type":"owned","topic":"title","include_keywords":["hello"],"ai_requirement":"lead"}`
ruleResponse := do(app, http.MethodPost, "/api/creator/rules", ruleBody, "operator", "unit-test-password")
if ruleResponse.Code != http.StatusCreated {
t.Fatalf("create fixture rule: %d %s", ruleResponse.Code, ruleResponse.Body.String())
}
ruleID := idFrom(ruleResponse)
if response := do(app, http.MethodGet, "/api/creator/rules/"+ruleID, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET fixture rule: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/creator/comments/"+commentID+"/analyze", `{"rule_id":"`+ruleID+`"}`, "operator", "unit-test-password"); response.Code != http.StatusServiceUnavailable {
t.Fatalf("analyze fixture comment without AI: %d %s", response.Code, response.Body.String())
}
competitor, err := creatorStore.CreateCompetitor(ctx, creator.CompetitorInput{Platform: creator.PlatformDouyin, PlatformAccountKey: "competitor-key", Nickname: "Competitor", HomepageURL: "https://www.douyin.com/user/competitor-key"})
if err != nil {
t.Fatal(err)
}
competitorID := competitor.ID
if response := do(app, http.MethodGet, "/api/creator/competitors/"+competitorID, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET fixture competitor: %d %s", response.Code, response.Body.String())
}
for _, action := range []string{"pause", "resume", "sync"} {
if response := do(app, http.MethodPost, "/api/creator/competitors/"+competitorID+"/"+action, `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK && response.Code != http.StatusServiceUnavailable {
t.Fatalf("competitor %s: %d %s", action, response.Code, response.Body.String())
}
}
if _, err := syncCreatorCompetitorWithClaim(ctx, creatorStore, phaseAStore, hubStore, competitorID, "fixture-account", true); err == nil {
t.Fatal("competitor sync without browser unexpectedly succeeded")
}
if _, err := previewDouyinCompetitor(ctx, creatorStore, phaseAStore, hubStore, "fixture-account", creator.CompetitorInput{Platform: creator.PlatformDouyin, PlatformAccountKey: "preview-key", Nickname: "Preview", HomepageURL: "https://www.douyin.com/user/preview-key"}); err == nil {
t.Fatal("competitor preview without browser unexpectedly succeeded")
}
eventBody := `{"platform":"douyin","receiving_account_id":"fixture-account","event_key":"fixture-event","event_type":"comment","interactor_uid":"peer","work_id":"` + workID + `","comment_id":"` + commentID + `"}`
eventResponse := do(app, http.MethodPost, "/api/creator/test/events", eventBody, "operator", "unit-test-password")
if eventResponse.Code != http.StatusCreated {
t.Fatalf("create fixture event: %d %s", eventResponse.Code, eventResponse.Body.String())
}
var eventEnvelope struct {
Event struct {
ID string `json:"id"`
} `json:"event"`
}
if err := json.Unmarshal(eventResponse.Body.Bytes(), &eventEnvelope); err != nil || eventEnvelope.Event.ID == "" {
t.Fatalf("event has no ID: %s (%v)", eventResponse.Body.String(), err)
}
eventID := eventEnvelope.Event.ID
if response := do(app, http.MethodPost, "/api/creator/events/"+eventID+"/display", `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("display fixture event: %d %s", response.Code, response.Body.String())
}
operationBody := `{"idempotency_key":"fixture-operation","source":"manual","action":"reply_comment","platform":"douyin","account_id":"fixture-account","target_uid":"peer","target_comment_id":"` + commentID + `","target_work_id":"` + workID + `","text":"reply"}`
operationResponse := do(app, http.MethodPost, "/api/creator/operations", operationBody, "operator", "unit-test-password")
if operationResponse.Code != http.StatusCreated {
t.Fatalf("create fixture operation: %d %s", operationResponse.Code, operationResponse.Body.String())
}
operationID := idFrom(operationResponse)
for _, path := range []string{"/api/creator/operations/" + operationID, "/api/creator/operations/" + operationID + "/verification"} {
if response := do(app, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET %s: %d %s", path, response.Code, response.Body.String())
}
}
if response := do(app, http.MethodPost, "/api/creator/operations/"+operationID+"/execute", `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("execute fixture operation without executor: %d %s", response.Code, response.Body.String())
}
if response := do(app, http.MethodPost, "/api/creator/test/events/process", eventBody, "operator", "unit-test-password"); response.Code != http.StatusOK && response.Code != http.StatusServiceUnavailable {
t.Fatalf("process fixture event without executor: %d %s", response.Code, response.Body.String())
}
messageBody := `{"platform":"douyin","account_id":"fixture-account","peer_uid":"peer","peer_name":"Peer","platform_message_key":"fixture-message","direction":"inbound","message_type":"text","text":"hello"}`
messageResponse := do(app, http.MethodPost, "/api/creator/test/messages", messageBody, "operator", "unit-test-password")
if messageResponse.Code != http.StatusCreated {
t.Fatalf("create fixture message: %d %s", messageResponse.Code, messageResponse.Body.String())
}
var message struct {
ConversationID string `json:"conversation_id"`
}
if err := json.Unmarshal(messageResponse.Body.Bytes(), &message); err != nil || message.ConversationID == "" {
t.Fatalf("message has no conversation: %s (%v)", messageResponse.Body.String(), err)
}
for _, path := range []string{"/api/creator/conversations/" + message.ConversationID + "/messages", "/api/creator/conversations/" + message.ConversationID + "/messages?page=1&page_size=10"} {
if response := do(app, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
t.Fatalf("GET %s: %d %s", path, response.Code, response.Body.String())
}
}
if err := runCreatorScheduleOnce(ctx, creatorStore, phaseAStore, hubStore); err != nil && !errors.Is(err, creator.ErrUnavailable) {
t.Fatalf("creator schedule fixture: %v", err)
}
if err := runCreatorMetricScheduleOnce(ctx, creatorStore, phaseAStore, hubStore, time.Now().UTC()); err != nil && !errors.Is(err, creator.ErrUnavailable) {
t.Fatalf("creator metric schedule fixture: %v", err)
}
storedWork, err := creatorStore.GetWork(ctx, workID)
if err != nil {
t.Fatal(err)
}
settings, err := creatorStore.GetSettings(ctx)
if err != nil {
t.Fatal(err)
}
if err := refreshCreatorMetricWork(ctx, creatorStore, phaseAStore, hubStore, storedWork, "fixture-account", settings, time.Now().UTC()); err == nil {
t.Fatal("metric refresh without browser unexpectedly succeeded")
}
if _, err := verifyCreatorAccount(ctx, creatorStore, phaseAStore, hubStore, "fixture-account"); err == nil {
t.Fatal("account verification without browser unexpectedly succeeded")
}
}
func TestControlPlaneAuthentication(t *testing.T) {
logger := logrus.StandardLogger()
previousOutput := logger.Out
@@ -347,6 +740,9 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase {
{http.MethodPost, "/api/network-exits", "/api/network-exits", "", http.StatusBadRequest},
{http.MethodPost, "/api/network-exits/:id/check", "/api/network-exits/missing/check", "", http.StatusNotFound},
{http.MethodPost, "/api/network-exits/:id/disable", "/api/network-exits/missing/disable", "", http.StatusNotFound},
{http.MethodPut, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusBadRequest},
{http.MethodPost, "/api/network-exits/:id/enable", "/api/network-exits/missing/enable", "", http.StatusNotFound},
{http.MethodDelete, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusNotFound},
{http.MethodGet, "/api/browser-images", "/api/browser-images", "", http.StatusOK},
{http.MethodPost, "/api/browser-images", "/api/browser-images", "", http.StatusBadRequest},
+1 -1
View File
@@ -168,7 +168,7 @@ func newXiaohongshuReadCollector(ctx context.Context, store *creator.Store, phas
if err != nil {
return nil, err
}
if profile.Platform != creator.PlatformXiaohongshu || profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" {
if profile.Platform != creator.PlatformXiaohongshu || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" {
return nil, creator.ErrConflict
}
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)