fix: complete Douyin release remediation
This commit is contained in:
+161
-23
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user