diff --git a/cmd/control-plane/creator.go b/cmd/control-plane/creator.go index a924704..39ffa49 100644 --- a/cmd/control-plane/creator.go +++ b/cmd/control-plane/creator.go @@ -1,14 +1,18 @@ package main import ( + "bufio" "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -21,15 +25,49 @@ import ( "github.com/sirupsen/logrus" ) -func registerCreator(app *fiber.App, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge) { - var executor creator.ActionExecutor - if store != nil && phaseAStore != nil && hubStore != nil { - executor = creatorGatewayActionExecutor{store: store, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials} +func creatorPageQuery(c fiber.Ctx) (page, pageSize int, enabled bool, err error) { + pageValue, pageSizeValue := strings.TrimSpace(c.Query("page")), strings.TrimSpace(c.Query("page_size")) + if pageValue == "" && pageSizeValue == "" { + return 0, 0, false, nil } - registerCreatorWithServices(app, store, phaseAStore, hubStore, credentials, executor, nil, nil) + page, pageSize = 1, 25 + if pageValue != "" { + page, err = strconv.Atoi(pageValue) + if err != nil { + return 0, 0, true, creator.ErrInvalid + } + } + if pageSizeValue != "" { + pageSize, err = strconv.Atoi(pageSizeValue) + if err != nil { + return 0, 0, true, creator.ErrInvalid + } + } + return page, pageSize, true, nil } -func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, executor creator.ActionExecutor, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) { +func registerCreator(app *fiber.App, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store) { + var executor creator.ActionExecutor + if store != nil && phaseAStore != nil && hubStore != nil { + executor = creatorGatewayActionExecutor{store: store, phaseAStore: phaseAStore, hubStore: hubStore} + } + registerCreatorWithServices(app, store, phaseAStore, hubStore, executor, nil, nil) +} + +func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) { + // Platform records enter through the managed collector/listener, not a public + // client-supplied write. The explicit test namespace is kept for isolated + // contract tests and never participates in the production listener. + for _, path := range []string{ + "/api/creator/works", + "/api/creator/comments", + "/api/creator/events", + "/api/creator/events/process", + "/api/creator/messages", + } { + app.Post(path, func(c fiber.Ctx) error { return creatorError(c, creator.ErrConflict) }) + } + app.Get("/api/creator/settings", func(c fiber.Ctx) error { settings, err := store.GetSettings(c.Context()) if err != nil { @@ -48,6 +86,41 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } return c.JSON(settings) }) + app.Get("/api/creator/updates", func(c fiber.Ctx) error { + updates, unsubscribe := creatorUpdates.subscribe() + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + c.Set("Transfer-Encoding", "chunked") + return c.SendStreamWriter(func(w *bufio.Writer) { + defer unsubscribe() + if _, err := fmt.Fprint(w, "retry: 5000\\n\\n"); err != nil { + return + } + if err := w.Flush(); err != nil { + return + } + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + for { + select { + case <-updates: + if _, err := fmt.Fprint(w, "event: creator-update\\ndata: {}\\n\\n"); err != nil { + return + } + case <-heartbeat.C: + if _, err := fmt.Fprint(w, ": keep-alive\\n\\n"); err != nil { + return + } + case <-c.RequestCtx().Done(): + return + } + if err := w.Flush(); err != nil { + return + } + } + }) + }) app.Get("/api/creator/accounts", func(c fiber.Ctx) error { profiles, err := store.ListAccountProfiles(c.Context()) @@ -83,12 +156,22 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } + if input.Status == "logged_in" { + return creatorError(c, creator.ErrConflict) + } result, err := store.RecordLoginResult(c.Context(), c.Params("id"), input.Status, input.Reason, input.ActualKey) if err != nil { return creatorError(c, err) } return c.JSON(result) }) + app.Post("/api/creator/accounts/:id/verify", func(c fiber.Ctx) error { + result, err := verifyCreatorAccount(c.Context(), store, phaseAStore, hubStore, c.Params("id")) + if err != nil { + return creatorError(c, err) + } + return c.JSON(result) + }) app.Post("/api/creator/accounts/:id/big-account", func(c fiber.Ctx) error { var input struct { Enabled bool `json:"enabled"` @@ -206,7 +289,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } - report, err := syncCreatorCompetitor(c.Context(), store, phaseAStore, hubStore, credentials, c.Params("id"), input.AccountID) + report, err := syncCreatorCompetitor(c.Context(), store, phaseAStore, hubStore, c.Params("id"), input.AccountID) if err != nil { return creatorError(c, err) } @@ -218,13 +301,24 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto if err != nil { return creatorError(c, err) } + page, pageSize, paged, err := creatorPageQuery(c) + if err != nil { + return creatorError(c, err) + } + if paged { + items, err := store.ListWorksPage(c.Context(), filter, page, pageSize) + if err != nil { + return creatorError(c, err) + } + return c.JSON(items) + } items, err := store.ListWorks(c.Context(), filter) if err != nil { return creatorError(c, err) } return c.JSON(items) }) - app.Post("/api/creator/works", func(c fiber.Ctx) error { + app.Post("/api/creator/test/works", func(c fiber.Ctx) error { var input creator.WorkInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) @@ -295,7 +389,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto return c.Status(status).JSON(item) }) app.Post("/api/creator/works/:id/material/process", func(c fiber.Ctx) error { - item, err := processCreatorMaterial(c.Context(), store, c.Params("id")) + item, err := processCreatorMaterial(c.Context(), store, phaseAStore, hubStore, c.Params("id")) if err != nil { return creatorError(c, err) } @@ -314,6 +408,13 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } return c.JSON(item) }) + app.Post("/api/creator/works/:id/material/rewrite/generate", func(c fiber.Ctx) error { + item, err := store.GenerateRewrite(c.Context(), c.Params("id"), generator) + if err != nil { + return creatorError(c, err) + } + return c.JSON(item) + }) app.Put("/api/creator/works/:id/material/rewrite", func(c fiber.Ctx) error { var input struct { Title string `json:"title"` @@ -330,13 +431,24 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto }) app.Get("/api/creator/comments", func(c fiber.Ctx) error { + page, pageSize, paged, err := creatorPageQuery(c) + if err != nil { + return creatorError(c, err) + } + if paged { + items, err := store.ListCommentsPage(c.Context(), c.Query("platform"), c.Query("work_id"), page, pageSize) + if err != nil { + return creatorError(c, err) + } + return c.JSON(items) + } items, err := store.ListComments(c.Context(), c.Query("platform"), c.Query("work_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) - app.Post("/api/creator/comments", func(c fiber.Ctx) error { + app.Post("/api/creator/test/comments", func(c fiber.Ctx) error { var input creator.CommentInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) @@ -422,6 +534,26 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } return c.JSON(items) }) + app.Post("/api/creator/comments/analyze", func(c fiber.Ctx) error { + var input struct { + CommentIDs []string `json:"comment_ids"` + RuleID string `json:"rule_id"` + } + if err := decodeCreator(c, &input); err != nil { + return creatorError(c, err) + } + items, err := store.AnalyzeComments(c.Context(), input.CommentIDs, input.RuleID, analyzer) + if err != nil && len(items) == 0 { + return creatorError(c, err) + } + status := fiber.StatusOK + if errors.Is(err, creator.ErrUnavailable) { + status = fiber.StatusServiceUnavailable + } else if err != nil { + status = fiber.StatusMultiStatus + } + return c.Status(status).JSON(map[string]any{"items": items}) + }) app.Post("/api/creator/comments/:id/analyze", func(c fiber.Ctx) error { var input struct { RuleID string `json:"rule_id"` @@ -441,13 +573,31 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto }) app.Get("/api/creator/events", func(c fiber.Ctx) error { + page, pageSize, paged, err := creatorPageQuery(c) + if err != nil { + return creatorError(c, err) + } + if paged { + items, err := store.ListEventsPage(c.Context(), c.Query("account_id"), page, pageSize) + if err != nil { + return creatorError(c, err) + } + return c.JSON(items) + } items, err := store.ListEvents(c.Context(), c.Query("account_id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) - app.Post("/api/creator/events", func(c fiber.Ctx) error { + app.Get("/api/creator/listeners", func(c fiber.Ctx) error { + items, err := store.ListListenerStates(c.Context(), c.Query("account_id")) + if err != nil { + return creatorError(c, err) + } + return c.JSON(items) + }) + app.Post("/api/creator/test/events", func(c fiber.Ctx) error { var input creator.InteractionEvent if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) @@ -456,13 +606,14 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto if err != nil { return creatorError(c, err) } + creatorUpdates.publish() status := fiber.StatusOK if !result.Duplicate { status = fiber.StatusCreated } return c.Status(status).JSON(result) }) - app.Post("/api/creator/events/process", func(c fiber.Ctx) error { + app.Post("/api/creator/test/events/process", func(c fiber.Ctx) error { var input creator.InteractionEvent if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) @@ -475,6 +626,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto if errors.Is(err, creator.ErrUnavailable) { status = fiber.StatusServiceUnavailable } + creatorUpdates.publish() return c.Status(status).JSON(result) }) app.Post("/api/creator/events/:id/display", func(c fiber.Ctx) error { @@ -482,6 +634,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto if err != nil { return creatorError(c, err) } + creatorUpdates.publish() return c.JSON(event) }) app.Get("/api/creator/operations", func(c fiber.Ctx) error { @@ -529,21 +682,67 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto return c.JSON(items) }) app.Get("/api/creator/conversations/:id/messages", func(c fiber.Ctx) error { + page, pageSize, paged, err := creatorPageQuery(c) + if err != nil { + return creatorError(c, err) + } + if paged { + items, err := store.ListMessagesPage(c.Context(), c.Params("id"), page, pageSize) + if err != nil { + return creatorError(c, err) + } + return c.JSON(items) + } items, err := store.ListMessages(c.Context(), c.Params("id")) if err != nil { return creatorError(c, err) } return c.JSON(items) }) - app.Post("/api/creator/messages", func(c fiber.Ctx) error { + app.Post("/api/creator/messages/send", func(c fiber.Ctx) error { var input creator.MessageInput if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } + if strings.TrimSpace(input.PlatformMessageKey) == "" || input.Direction != "" && input.Direction != "outbound" || input.MessageType != "text" || input.SentState != "" || input.MessageAt != nil { + return creatorError(c, creator.ErrInvalid) + } + operation, inserted, err := store.CreateOperation(c.Context(), creator.OperationInput{ + IdempotencyKey: "message:" + input.Platform + ":" + input.AccountID + ":" + input.PeerUID + ":" + input.PlatformMessageKey, + Source: "manual", + Action: creator.ActionDM, + Platform: input.Platform, + AccountID: input.AccountID, + TargetUID: input.PeerUID, + Text: input.Text, + }) + if err != nil { + return creatorError(c, err) + } + operation, err = store.ExecuteManualOperation(c.Context(), operation.ID, executor) + if err != nil { + return creatorError(c, err) + } + creatorUpdates.publish() + status := fiber.StatusOK + if inserted { + status = fiber.StatusCreated + } + return c.Status(status).JSON(map[string]any{"operation": operation}) + }) + app.Post("/api/creator/test/messages", func(c fiber.Ctx) error { + var input creator.MessageInput + if err := decodeCreator(c, &input); err != nil { + return creatorError(c, err) + } + if input.Direction != "inbound" { + return creatorError(c, creator.ErrInvalid) + } item, inserted, err := store.SaveMessage(c.Context(), input) if err != nil { return creatorError(c, err) } + creatorUpdates.publish() status := fiber.StatusOK if inserted { status = fiber.StatusCreated @@ -632,7 +831,6 @@ type creatorGatewayActionExecutor struct { store *creator.Store phaseAStore *phasea.Store hubStore *hub.Store - credentials phasea.CredentialBridge } func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, request creator.ActionRequest) (creator.ActionResult, error) { @@ -661,25 +859,7 @@ func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, reques browser := creatorGatewayBrowser{gateway: gateway, environment: environment} uid, identityErr := browser.Identity(ctx, profile.PlatformAccountKey) if identityErr != nil { - resolver, ok := executor.credentials.(phasea.CredentialResolver) - if !ok { - return creator.ActionResult{}, creator.ErrUnavailable - } - rawCredential, resolveErr := executor.phaseAStore.ResolveAccountCredential(ctx, request.AccountID, resolver) - if resolveErr != nil { - return creator.ActionResult{}, resolveErr - } - cookies, parseErr := douyin.ParseCredential(rawCredential) - if parseErr != nil { - return creator.ActionResult{}, fmt.Errorf("parse account credential: %w", parseErr) - } - if setErr := browser.SetCookies(ctx, cookies); setErr != nil { - return creator.ActionResult{}, setErr - } - uid, identityErr = browser.Identity(ctx, profile.PlatformAccountKey) - if identityErr != nil { - return creator.ActionResult{}, fmt.Errorf("verify account identity after credential injection: %w", identityErr) - } + return creator.ActionResult{}, fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr) } if _, verifyErr := executor.store.RecordVerifiedLoginResult(ctx, request.AccountID, uid); verifyErr != nil { return creator.ActionResult{}, fmt.Errorf("persist verified account identity: %w", verifyErr) @@ -714,6 +894,12 @@ func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, reques return creator.ActionResult{}, err } if status != http.StatusOK { + var gatewayError struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &gatewayError) == nil && strings.EqualFold(strings.TrimSpace(gatewayError.Error), "ACTION_UNAVAILABLE") { + return creator.ActionResult{State: "failed", Reason: "ACTION_UNAVAILABLE", Evidence: map[string]string{"gateway_http_status": strconv.Itoa(status)}}, nil + } return creator.ActionResult{State: "uncertain", Reason: fmt.Sprintf("gateway returned HTTP %d", status)}, nil } var response struct { @@ -782,17 +968,42 @@ func (browser creatorGatewayBrowser) Identity(ctx context.Context, expectedKey s return identity.UID, nil } -func (browser creatorGatewayBrowser) SetCookies(ctx context.Context, cookies []douyin.Cookie) error { - payload := gatewayGenerationPayload(browser.environment) - payload["cookies"] = cookies - status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/cookies", payload, 30*time.Second) +func verifyCreatorAccount(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID string) (creator.LoginResult, error) { + if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { + return creator.LoginResult{}, creator.ErrUnavailable + } + account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { - return err + return creator.LoginResult{}, err } - if status != http.StatusNoContent && status != http.StatusNotModified { - return fmt.Errorf("douyin cookie injection rejected with HTTP %d: %s", status, string(body)) + profile, err := store.GetAccountProfile(ctx, accountID) + if err != nil { + return creator.LoginResult{}, err } - return nil + if account.Platform != creator.PlatformDouyin || profile.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || profile.PlatformAccountKey == "" || account.PlatformAccountKey != profile.PlatformAccountKey { + return creator.LoginResult{}, creator.ErrConflict + } + environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) + if err != nil { + return creator.LoginResult{}, fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) + } + if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { + return creator.LoginResult{}, fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable) + } + gateway, err := hubStore.GetGateway(ctx, environment.Gateway) + if err != nil { + return creator.LoginResult{}, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) + } + browser := creatorGatewayBrowser{gateway: gateway, environment: environment} + uid, err := browser.Identity(ctx, profile.PlatformAccountKey) + if err != nil { + return creator.LoginResult{}, fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, err) + } + result, err := store.RecordVerifiedLoginResult(ctx, accountID, uid) + if err != nil { + return creator.LoginResult{}, fmt.Errorf("persist verified account identity: %w", err) + } + return result, nil } func (browser creatorGatewayBrowser) Get(ctx context.Context, target string) (douyin.Response, error) { @@ -816,15 +1027,82 @@ func (browser creatorGatewayBrowser) Get(ctx context.Context, target string) (do return douyin.Response{Status: response.Status, Body: []byte(response.Body), Challenge: response.Challenge}, nil } -func syncCreatorCompetitor(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, competitorID, accountID string) (creator.CollectionReport, error) { - return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, credentials, competitorID, accountID, true) +const maxCreatorMediaBytes = 64 << 20 + +func writeCreatorMedia(destination string, data []byte) error { + if destination == "" || len(data) == 0 || len(data) > maxCreatorMediaBytes { + return creator.ErrInvalid + } + temporary, err := os.CreateTemp(filepath.Dir(destination), ".creator-media-*") + if err != nil { + return err + } + temporaryName := temporary.Name() + keep := false + defer func() { + if !keep { + _ = os.Remove(temporaryName) + } + }() + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryName, destination); err != nil { + return err + } + keep = true + return nil } -func syncCreatorCompetitorDue(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, competitorID, accountID string) (creator.CollectionReport, error) { - return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, credentials, competitorID, accountID, false) +func (browser creatorGatewayBrowser) Media(ctx context.Context, target, destination string) error { + payload := gatewayGenerationPayload(browser.environment) + payload["url"] = target + status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/media", payload, 90*time.Second) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("douyin media request rejected with HTTP %d: %s", status, string(body)) + } + var response struct { + Status int `json:"status"` + ContentType string `json:"content_type"` + BodyBase64 string `json:"body_base64"` + } + if err := json.Unmarshal(body, &response); err != nil { + return fmt.Errorf("decode douyin media response: %w", err) + } + contentType := strings.ToLower(strings.TrimSpace(response.ContentType)) + if response.Status < 200 || response.Status >= 300 || (contentType != "application/octet-stream" && !strings.HasPrefix(contentType, "video/")) { + return fmt.Errorf("douyin media response is not a video") + } + data, err := base64.StdEncoding.DecodeString(response.BodyBase64) + if err != nil { + return fmt.Errorf("decode douyin media bytes: %w", err) + } + if len(data) > maxCreatorMediaBytes { + return fmt.Errorf("douyin media response is too large") + } + return writeCreatorMedia(destination, data) } -func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, competitorID, accountID string, force bool) (creator.CollectionReport, error) { +func syncCreatorCompetitor(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, competitorID, accountID string) (creator.CollectionReport, error) { + return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, competitorID, accountID, true) +} + +func syncCreatorCompetitorDue(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, competitorID, accountID string) (creator.CollectionReport, error) { + return syncCreatorCompetitorWithClaim(ctx, store, phaseAStore, hubStore, competitorID, accountID, false) +} + +func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, competitorID, accountID string, force bool) (creator.CollectionReport, error) { if store == nil || phaseAStore == nil || hubStore == nil || accountID == "" { return creator.CollectionReport{}, creator.ErrUnavailable } @@ -855,7 +1133,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p if err != nil { return blocked(err) } - if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" { + if account.Platform != competitor.Platform || account.AuthorizationStatus != "authorized" { return blocked(creator.ErrConflict) } profile, err := store.GetAccountProfile(ctx, accountID) @@ -878,24 +1156,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p } browser := creatorGatewayBrowser{gateway: gateway, environment: environment} if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil { - resolver, ok := credentials.(phasea.CredentialResolver) - if !ok { - return blocked(creator.ErrUnavailable) - } - rawCredential, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver) - if resolveErr != nil { - return blocked(fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr)) - } - cookies, parseErr := douyin.ParseCredential(rawCredential) - if parseErr != nil { - return blocked(fmt.Errorf("%w: invalid account credential", creator.ErrConflict)) - } - if setErr := browser.SetCookies(ctx, cookies); setErr != nil { - return blocked(fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr)) - } - if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil { - return blocked(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr)) - } + return blocked(fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr)) } collector := douyin.CreatorCollector{Browser: browser, AccountKey: competitor.PlatformAccountKey, SourceType: creator.SourceCompetitor, SourceID: competitor.ID} canonicalSecUID, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey) @@ -936,7 +1197,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p return report, nil } -func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge) error { +func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store) error { if store == nil { return creator.ErrUnavailable } @@ -965,7 +1226,7 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto logrus.WithError(err).WithField("competitor_id", competitor.ID).Warn("creator competitor sync blocked") continue } - if _, err := syncCreatorCompetitorDue(ctx, store, phaseAStore, hubStore, credentials, competitor.ID, accountID); err != nil { + if _, err := syncCreatorCompetitorDue(ctx, store, phaseAStore, hubStore, competitor.ID, accountID); err != nil { logrus.WithError(err).WithField("competitor_id", competitor.ID).Warn("creator competitor scheduled sync failed") } } @@ -974,17 +1235,17 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto return err } for _, accountID := range ownedAccounts { - if err := syncCreatorOwned(ctx, store, phaseAStore, hubStore, credentials, accountID, now); err != nil { + if err := syncCreatorOwned(ctx, store, phaseAStore, hubStore, accountID, now); err != nil { logrus.WithError(err).WithField("account_id", accountID).Warn("creator owned scheduled sync failed") } } - if err := runCreatorMetricScheduleOnce(ctx, store, phaseAStore, hubStore, credentials, now); err != nil { + if err := runCreatorMetricScheduleOnce(ctx, store, phaseAStore, hubStore, now); err != nil { return err } return nil } -func runCreatorMetricScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, now time.Time) error { +func runCreatorMetricScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, now time.Time) error { settings, err := store.GetSettings(ctx) if err != nil { return err @@ -1002,14 +1263,14 @@ func runCreatorMetricScheduleOnce(ctx context.Context, store *creator.Store, pha continue } } - if err := refreshCreatorMetricWork(ctx, store, phaseAStore, hubStore, credentials, work, accountID, settings, now); err != nil { + if err := refreshCreatorMetricWork(ctx, store, phaseAStore, hubStore, work, accountID, settings, now); err != nil { logrus.WithError(err).WithField("work_id", work.ID).Warn("creator metric refresh failed") } } return nil } -func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, work creator.Work, accountID string, settings creator.Settings, now time.Time) error { +func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, work creator.Work, accountID string, settings creator.Settings, now time.Time) error { account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { return err @@ -1031,24 +1292,7 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS } browser := creatorGatewayBrowser{gateway: gateway, environment: environment} if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil { - resolver, ok := credentials.(phasea.CredentialResolver) - if !ok { - return creator.ErrUnavailable - } - raw, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver) - if resolveErr != nil { - return fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr) - } - cookies, parseErr := douyin.ParseCredential(raw) - if parseErr != nil { - return fmt.Errorf("%w: invalid account credential", creator.ErrConflict) - } - if setErr := browser.SetCookies(ctx, cookies); setErr != nil { - return fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr) - } - if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil { - return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr) - } + return fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr) } collector := douyin.CreatorCollector{Browser: browser, AccountKey: account.PlatformAccountKey, SourceType: work.SourceType, SourceID: work.SourceID} canonical, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey) @@ -1083,7 +1327,7 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS return creator.ErrInvalid } -func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, accountID string, now time.Time) error { +func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID string, now time.Time) error { if store == nil || phaseAStore == nil || hubStore == nil || accountID == "" { return creator.ErrUnavailable } @@ -1121,25 +1365,17 @@ func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *ph } browser := creatorGatewayBrowser{gateway: gateway, environment: environment} if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil { - resolver, ok := credentials.(phasea.CredentialResolver) - if !ok { - return blockOwned(creator.ErrUnavailable) - } - rawCredential, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver) - if resolveErr != nil { - return blockOwned(fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr)) - } - cookies, parseErr := douyin.ParseCredential(rawCredential) - if parseErr != nil { - return blockOwned(fmt.Errorf("%w: invalid account credential", creator.ErrConflict)) - } - if setErr := browser.SetCookies(ctx, cookies); setErr != nil { - return blockOwned(fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr)) - } - if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil { - return blockOwned(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr)) - } + return blockOwned(fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr)) } + syncLease, err := store.ClaimSourceSync(ctx, creator.SourceOwned, account.ID) + if err != nil { + return err + } + defer func() { + if releaseErr := store.ReleaseSourceSync(context.WithoutCancel(ctx), creator.SourceOwned, account.ID, syncLease); releaseErr != nil { + logrus.WithError(releaseErr).WithField("account_id", account.ID).Warn("creator source sync lease release failed") + } + }() collector := douyin.CreatorCollector{Browser: browser, AccountKey: account.PlatformAccountKey, SourceType: creator.SourceOwned, SourceID: account.ID} canonicalSecUID, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey) if err != nil { @@ -1178,11 +1414,11 @@ func creatorCollectionAccount(ctx context.Context, store *creator.Store, phaseAS return "", fmt.Errorf("%w: no authorized creator collection account", creator.ErrUnavailable) } -func runCreatorScheduler(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge) { +func runCreatorScheduler(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { - if err := runCreatorScheduleOnce(ctx, store, phaseAStore, hubStore, credentials); err != nil { + if err := runCreatorScheduleOnce(ctx, store, phaseAStore, hubStore); err != nil { logrus.WithError(err).Error("creator scheduler failed") } select { diff --git a/cmd/control-plane/creator_events.go b/cmd/control-plane/creator_events.go index 664de75..163a57d 100644 --- a/cmd/control-plane/creator_events.go +++ b/cmd/control-plane/creator_events.go @@ -34,6 +34,7 @@ type creatorGatewayEventNotice struct { InteractorUID string `json:"interactor_uid"` CommentID string `json:"comment_id"` WorkID string `json:"work_id"` + MessageType string `json:"message_type,omitempty"` MessageText string `json:"message_text,omitempty"` PlatformEventAt string `json:"platform_event_at,omitempty"` GatewayReceivedAt string `json:"gateway_received_at,omitempty"` @@ -46,6 +47,68 @@ type creatorEventBinding struct { gateway hub.Gateway } +type creatorUpdateHub struct { + mu sync.Mutex + subscribers map[chan struct{}]struct{} +} + +var creatorUpdates = &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})} + +func (h *creatorUpdateHub) subscribe() (<-chan struct{}, func()) { + channel := make(chan struct{}, 1) + h.mu.Lock() + h.subscribers[channel] = struct{}{} + h.mu.Unlock() + return channel, func() { + h.mu.Lock() + if _, ok := h.subscribers[channel]; ok { + delete(h.subscribers, channel) + close(channel) + } + h.mu.Unlock() + } +} + +func (h *creatorUpdateHub) publish() { + h.mu.Lock() + defer h.mu.Unlock() + for channel := range h.subscribers { + select { + case channel <- struct{}{}: + default: + } + } +} + +func creatorListenerGeneration(env hub.EnvironmentContext) string { + return fmt.Sprintf("%s:%s:%d", env.RuntimeID, env.RuntimeNetworkID, env.BindingVersion) +} + +func listenerBoundaryPointer(value time.Time) *time.Time { + if value.IsZero() { + return nil + } + value = value.UTC() + return &value +} + +func persistCreatorListenerState(ctx context.Context, store *creator.Store, binding creatorEventBinding, status, reason string, boundaryAt *time.Time, deliveryID string) { + if store == nil { + return + } + if _, err := store.UpsertListenerState(ctx, creator.ListenerState{ + AccountID: binding.accountID, + Platform: creator.PlatformDouyin, + Generation: creatorListenerGeneration(binding.env), + Status: status, + BoundaryAt: boundaryAt, + LastDeliveryID: deliveryID, + Reason: reason, + }); err != nil { + logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator listener state persistence failed") + } +} + func (binding creatorEventBinding) key() string { return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s\x00%s\x00%s\x00%s", binding.gateway.Name, binding.gateway.Endpoint, binding.gateway.Token, binding.env.BindingVersion, binding.env.RuntimeID, binding.env.RuntimeNetworkID, binding.env.Exit.ID, binding.uid) } @@ -180,7 +243,11 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding startPayload[key] = value } startPayload["expected_uid"] = binding.uid - defer stopCreatorEventListener(binding.accountID, binding.gateway, path, generation) + persistCreatorListenerState(ctx, store, binding, "starting", "等待平台边界标记", nil, "") + defer func() { + stopCreatorEventListener(binding.accountID, binding.gateway, path, generation) + persistCreatorListenerState(context.WithoutCancel(ctx), store, binding, "stopped", "监听已停止", nil, "") + }() backoff := time.Second for ctx.Err() == nil { @@ -190,6 +257,7 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding err = fmt.Errorf("gateway returned HTTP %d", status) } logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener start failed") + persistCreatorListenerState(ctx, store, binding, "error", err.Error(), nil, "") if !waitCreatorEventBackoff(ctx, backoff) { return } @@ -211,11 +279,13 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding err = fmt.Errorf("gateway returned HTTP %d", status) } logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener poll failed") + persistCreatorListenerState(ctx, store, binding, "gap", err.Error(), listenerBoundaryPointer(boundaryAt), "") break } var events []creatorGatewayEvent if err := json.Unmarshal(body, &events); err != nil { logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener response is invalid") + persistCreatorListenerState(ctx, store, binding, "gap", "监听响应无法解析: "+err.Error(), listenerBoundaryPointer(boundaryAt), "") break } for _, event := range events { @@ -236,10 +306,16 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding ready = false boundaryAt = time.Time{} } + status, reason := "gap", "平台边界无效" + if ready { + status, reason = "ready", "" + } + persistCreatorListenerState(ctx, store, binding, status, reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) } else if event.Kind == "open" || event.Kind == "error" || event.Kind == "close" || event.Kind == "reconnected" { // A transport event never proves continuity. Only the explicit // boundary marker permits automatic writes again. ready = false + persistCreatorListenerState(ctx, store, binding, "gap", event.Reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) } if event.Kind == "notice" { if needsBaseline, reason := creatorGatewayEventNeedsBaseline(event, ready); needsBaseline { @@ -249,6 +325,12 @@ func runCreatorEventListener(ctx context.Context, store *creator.Store, binding event.Baseline = true event.Reason = "平台事件早于监听边界" } + status := "gap" + reason := event.Reason + if ready && !event.Baseline { + status, reason = "ready", "" + } + persistCreatorListenerState(ctx, store, binding, status, reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) } handleCreatorGatewayEvent(ctx, store, binding, event, executor, generator) } @@ -360,6 +442,7 @@ func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, bindin logrus.WithError(err).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator event receipt failed") return } + creatorUpdates.publish() if input.EventType == "dm" && input.InteractorUID != "" { messageAt := input.PlatformEventAt if messageAt == nil { @@ -369,7 +452,7 @@ func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, bindin } messageAt = &receivedAt } - if _, _, messageErr := store.SaveMessage(ctx, creator.MessageInput{Platform: input.Platform, AccountID: input.ReceivingAccountID, PeerUID: input.InteractorUID, PlatformMessageKey: "event:" + input.EventKey, Direction: "inbound", MessageType: "text", Text: input.MessageText, SentState: "received", MessageAt: messageAt}); messageErr != nil { + if _, _, messageErr := store.SaveMessage(ctx, creator.MessageInput{Platform: input.Platform, AccountID: input.ReceivingAccountID, PeerUID: input.InteractorUID, PlatformMessageKey: "event:" + input.EventKey, Direction: "inbound", MessageType: input.MessageType, Text: input.MessageText, SentState: "received", MessageAt: messageAt}); messageErr != nil { logrus.WithError(messageErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator direct message persistence failed") return } @@ -387,6 +470,7 @@ func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, bindin return } logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey, "event_type": input.EventType, "state": result.Event.State}).Info("creator event processed") + creatorUpdates.publish() }() } @@ -424,7 +508,14 @@ func creatorEventFromGatewayNotice(accountID string, notice creatorGatewayEventN if (notice.CommentID != "" && !creatorEventID(notice.CommentID)) || (notice.WorkID != "" && !creatorEventID(notice.WorkID)) { return creator.InteractionEvent{}, creator.ErrInvalid } - result := creator.InteractionEvent{Platform: creator.PlatformDouyin, ReceivingAccountID: accountID, EventKey: notice.EventKey, EventType: notice.EventType, InteractorUID: notice.InteractorUID, CommentID: notice.CommentID, WorkID: notice.WorkID, MessageText: strings.TrimSpace(notice.MessageText)} + messageType := strings.TrimSpace(notice.MessageType) + if messageType == "" { + messageType = creator.MessageTypeText + } + if !creator.ValidMessageType(messageType) { + return creator.InteractionEvent{}, creator.ErrInvalid + } + result := creator.InteractionEvent{Platform: creator.PlatformDouyin, ReceivingAccountID: accountID, EventKey: notice.EventKey, EventType: notice.EventType, InteractorUID: notice.InteractorUID, CommentID: notice.CommentID, WorkID: notice.WorkID, MessageType: messageType, MessageText: strings.TrimSpace(notice.MessageText)} if strings.TrimSpace(notice.PlatformEventAt) != "" { at, err := time.Parse(time.RFC3339Nano, notice.PlatformEventAt) if err != nil { @@ -438,7 +529,8 @@ func creatorEventFromGatewayNotice(accountID string, notice creatorGatewayEventN if err != nil { return creator.InteractionEvent{}, fmt.Errorf("invalid gateway receipt time: %w", err) } - result.ReceivedAt = receivedAt.UTC() + receivedAt = receivedAt.UTC() + result.GatewayReceivedAt = &receivedAt } return result, nil } diff --git a/cmd/control-plane/creator_events_test.go b/cmd/control-plane/creator_events_test.go index 9a9116a..d24a078 100644 --- a/cmd/control-plane/creator_events_test.go +++ b/cmd/control-plane/creator_events_test.go @@ -4,9 +4,30 @@ import ( "testing" "time" + "git.ipao.vip/rogee/creator-hub/internal/creator" "git.ipao.vip/rogee/creator-hub/internal/hub" ) +func TestCreatorUpdateHubPublishesAndUnsubscribes(t *testing.T) { + hub := &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})} + updates, unsubscribe := hub.subscribe() + hub.publish() + select { + case <-updates: + default: + t.Fatal("publish did not notify subscriber") + } + unsubscribe() + hub.publish() + select { + case _, ok := <-updates: + if ok { + t.Fatal("unsubscribed channel received an update") + } + default: + } +} + func TestGatewayGenerationPayloadIncludesCurrentProxyExit(t *testing.T) { payload := gatewayGenerationPayload(hub.EnvironmentContext{ BindingVersion: 3, @@ -60,8 +81,8 @@ func TestCreatorEventFromGatewayNotice(t *testing.T) { if event.PlatformEventAt == nil || event.PlatformEventAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:20Z" { t.Fatalf("unexpected event time: %+v", event.PlatformEventAt) } - if event.ReceivedAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:21Z" { - t.Fatalf("unexpected gateway receipt time: %v", event.ReceivedAt) + if event.GatewayReceivedAt == nil || event.GatewayReceivedAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:21Z" { + t.Fatalf("unexpected gateway receipt time: %v", event.GatewayReceivedAt) } } @@ -87,6 +108,18 @@ func TestCreatorEventFromGatewayNoticeRejectsInvalidGatewayReceiptTime(t *testin } } +func TestCreatorEventFromGatewayNoticePreservesMessageType(t *testing.T) { + result, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{ + EventKey: "123", + EventType: "dm", + InteractorUID: "456", + MessageType: creator.MessageTypeImage, + }) + if err != nil || result.MessageType != creator.MessageTypeImage { + t.Fatalf("message type was not preserved: result=%#v err=%v", result, err) + } +} + func TestCreatorEventFromGatewayNoticeRejectsInvalidTime(t *testing.T) { _, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{ EventKey: "1", diff --git a/cmd/control-plane/creator_material.go b/cmd/control-plane/creator_material.go index 9b4df92..e410ea4 100644 --- a/cmd/control-plane/creator_material.go +++ b/cmd/control-plane/creator_material.go @@ -3,20 +3,61 @@ package main import ( "context" "fmt" - "io" - "net/http" "os" "os/exec" "path/filepath" "strings" - "time" "git.ipao.vip/rogee/creator-hub/internal/creator" + "git.ipao.vip/rogee/creator-hub/internal/hub" + "git.ipao.vip/rogee/creator-hub/internal/phasea" ) -const maxCreatorMaterialBytes int64 = 512 << 20 +type creatorMaterialDownloader struct { + store *creator.Store + phaseAStore *phasea.Store + hubStore *hub.Store +} -func processCreatorMaterial(ctx context.Context, store *creator.Store, workID string) (creator.MaterialJob, error) { +func (downloader creatorMaterialDownloader) Download(ctx context.Context, work creator.Work, destination string) error { + if work.Platform != creator.PlatformDouyin || downloader.store == nil || downloader.phaseAStore == nil || downloader.hubStore == nil { + return fmt.Errorf("%w: creator media gateway is unavailable", creator.ErrUnavailable) + } + accountID := work.SourceID + if work.SourceType == creator.SourceCompetitor { + var err error + accountID, err = creatorCollectionAccount(ctx, downloader.store, downloader.phaseAStore, downloader.hubStore, work.Platform) + if err != nil { + return err + } + } + account, err := downloader.phaseAStore.GetAccount(ctx, accountID) + if err != nil { + return err + } + profile, err := downloader.store.GetAccountProfile(ctx, accountID) + if err != nil { + return err + } + if account.Platform != creator.PlatformDouyin || profile.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || profile.LoginStatus != "logged_in" || account.PlatformAccountKey != profile.PlatformAccountKey { + return fmt.Errorf("%w: media account identity is not verified", creator.ErrConflict) + } + environment, err := downloader.hubStore.GetEnvironmentContextForAccount(ctx, accountID) + if err != nil { + return fmt.Errorf("%w: media browser environment unavailable: %v", creator.ErrUnavailable, err) + } + gateway, err := downloader.hubStore.GetGateway(ctx, environment.Gateway) + if err != nil { + return fmt.Errorf("%w: media gateway unavailable: %v", creator.ErrUnavailable, err) + } + browser := creatorGatewayBrowser{gateway: gateway, environment: environment} + if _, err := browser.Identity(ctx, profile.PlatformAccountKey); err != nil { + return fmt.Errorf("%w: media browser identity verification failed: %v", creator.ErrConflict, err) + } + return browser.Media(ctx, work.OriginalURL, destination) +} + +func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, workID string) (creator.MaterialJob, error) { if store == nil || workID == "" || filepath.Base(workID) != workID { return creator.MaterialJob{}, creator.ErrInvalid } @@ -46,7 +87,7 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, workID st if _, err := store.SetMaterialStep(ctx, workID, "download", "running", "", ""); err != nil { return creator.MaterialJob{}, err } - if err := downloadCreatorMaterial(ctx, work.OriginalURL, videoPath); err != nil { + if err := (creatorMaterialDownloader{store: store, phaseAStore: phaseAStore, hubStore: hubStore}).Download(ctx, work, videoPath); err != nil { return setMaterialFailure(ctx, store, workID, "download", err) } job, err = store.SetMaterialStep(ctx, workID, "download", "succeeded", videoReference, "") @@ -84,8 +125,15 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, workID st if job.AudioStatus == "no_audio" { job, err = store.SetMaterialStep(ctx, workID, "transcription", "no_speech", "", "没有可转写的音轨") } else { + settings, settingsErr := store.GetSettings(ctx) + if settingsErr != nil { + return creator.MaterialJob{}, settingsErr + } + if !settings.TranscriptionConfigured || strings.TrimSpace(settings.TranscriptionProvider) == "" || strings.TrimSpace(settings.TranscriptionModel) == "" { + return setMaterialFailure(ctx, store, workID, "transcription", fmt.Errorf("transcription provider is not configured")) + } if _, err = store.SetMaterialStep(ctx, workID, "transcription", "running", "", ""); err == nil { - transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath) + transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath, settings.TranscriptionModel) if transcribeErr != nil { job, err = setMaterialFailure(ctx, store, workID, "transcription", transcribeErr) } else if strings.TrimSpace(transcript) == "" { @@ -107,46 +155,6 @@ func processCreatorMaterial(ctx context.Context, store *creator.Store, workID st return job, nil } -func downloadCreatorMaterial(ctx context.Context, rawURL, destination string) error { - parsed, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil || (parsed.URL.Scheme != "http" && parsed.URL.Scheme != "https") || parsed.URL.Host == "" { - return creator.ErrInvalid - } - client := &http.Client{Timeout: 2 * time.Minute} - response, err := client.Do(parsed) - if err != nil { - return fmt.Errorf("download material: %w", err) - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("download material: HTTP %s", response.Status) - } - if response.ContentLength > maxCreatorMaterialBytes { - return fmt.Errorf("download material exceeds size limit") - } - temporary := destination + ".tmp" - defer os.Remove(temporary) - file, err := os.OpenFile(temporary, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) - if err != nil { - return fmt.Errorf("create material file: %w", err) - } - written, copyErr := io.Copy(file, io.LimitReader(response.Body, maxCreatorMaterialBytes+1)) - closeErr := file.Close() - if copyErr != nil { - return fmt.Errorf("write material: %w", copyErr) - } - if closeErr != nil { - return fmt.Errorf("close material file: %w", closeErr) - } - if written > maxCreatorMaterialBytes { - return fmt.Errorf("download material exceeds size limit") - } - if err := os.Rename(temporary, destination); err != nil { - return fmt.Errorf("publish material: %w", err) - } - return nil -} - func creatorMaterialHasAudio(ctx context.Context, videoPath string) (bool, error) { if _, err := exec.LookPath("ffprobe"); err != nil { return false, fmt.Errorf("ffprobe is unavailable: %w", err) @@ -178,15 +186,36 @@ func extractCreatorAudio(ctx context.Context, videoPath, audioPath string) error return nil } -func transcribeCreatorAudio(ctx context.Context, audioPath string) (string, error) { - binary := os.Getenv("CREATOR_TRANSCRIPTION_BIN") - if binary == "" { - return "", fmt.Errorf("transcription provider is not configured") +func validateTranscriptionBinary(binary string) error { + path, err := exec.LookPath(binary) + if err != nil { + return fmt.Errorf("lookup transcription provider %q: %w", binary, err) } - if _, err := exec.LookPath(binary); err != nil { + if path == "" { + return fmt.Errorf("binary path is empty") + } + return nil +} + +func transcribeCreatorAudio(ctx context.Context, audioPath, model string) (string, error) { + model = strings.TrimSpace(model) + if model == "" { + return "", fmt.Errorf("transcription model is not configured") + } + configured := strings.TrimSpace(os.Getenv("CREATOR_TRANSCRIPTION_BIN")) + var binary string + switch filepath.Base(configured) { + case "whisper": + binary = "whisper" + case "faster-whisper": + binary = "faster-whisper" + default: + return "", fmt.Errorf("transcription provider is not configured or unsupported") + } + if err := validateTranscriptionBinary(binary); err != nil { return "", fmt.Errorf("transcription provider is unavailable: %w", err) } - output, err := exec.CommandContext(ctx, binary, audioPath).Output() + output, err := exec.CommandContext(ctx, binary, audioPath, "--model", model).Output() if err != nil { return "", fmt.Errorf("transcribe audio: %w", err) } diff --git a/cmd/control-plane/creator_material_test.go b/cmd/control-plane/creator_material_test.go index 512a6aa..0956f60 100644 --- a/cmd/control-plane/creator_material_test.go +++ b/cmd/control-plane/creator_material_test.go @@ -2,52 +2,45 @@ package main import ( "context" - "net/http" - "net/http/httptest" "os" "path/filepath" "testing" ) -func TestDownloadCreatorMaterialPublishesOnlyCompleteFiles(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/source" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - _, _ = w.Write([]byte("media")) - })) - defer server.Close() - +func TestWriteCreatorMediaPublishesOnlyCompleteFiles(t *testing.T) { destination := filepath.Join(t.TempDir(), "source") - if err := downloadCreatorMaterial(context.Background(), server.URL+"/source", destination); err != nil { - t.Fatalf("download material: %v", err) + if err := writeCreatorMedia(destination, []byte("media")); err != nil { + t.Fatalf("write media: %v", err) } content, err := os.ReadFile(destination) if err != nil { - t.Fatalf("read material: %v", err) + t.Fatalf("read media: %v", err) } if string(content) != "media" { - t.Fatalf("unexpected material: %q", content) + t.Fatalf("unexpected media: %q", content) } - if _, err := os.Stat(destination + ".tmp"); !os.IsNotExist(err) { - t.Fatalf("temporary file remains: %v", err) + matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".creator-media-*")) + if err != nil { + t.Fatalf("find temporary media: %v", err) + } + if len(matches) != 0 { + t.Fatalf("temporary files remain: %v", matches) } } -func TestDownloadCreatorMaterialRejectsInvalidResponses(t *testing.T) { - server := httptest.NewServer(http.NotFoundHandler()) - defer server.Close() - if err := downloadCreatorMaterial(context.Background(), server.URL, filepath.Join(t.TempDir(), "source")); err == nil { - t.Fatal("expected HTTP failure") +func TestWriteCreatorMediaRejectsEmptyAndOversizedFiles(t *testing.T) { + destination := filepath.Join(t.TempDir(), "source") + if err := writeCreatorMedia(destination, nil); err == nil { + t.Fatal("expected empty media to be rejected") } - if err := downloadCreatorMaterial(context.Background(), "file:///tmp/source", filepath.Join(t.TempDir(), "source")); err == nil { - t.Fatal("expected non-HTTP URL failure") + if err := writeCreatorMedia(destination, make([]byte, maxCreatorMediaBytes+1)); err == nil { + t.Fatal("expected oversized media to be rejected") } } func TestTranscribeCreatorAudioRequiresExplicitProvider(t *testing.T) { t.Setenv("CREATOR_TRANSCRIPTION_BIN", "") - if _, err := transcribeCreatorAudio(context.Background(), "/tmp/audio.wav"); err == nil { + if _, err := transcribeCreatorAudio(context.Background(), "/tmp/audio.wav", "base"); err == nil { t.Fatal("expected missing provider error") } } diff --git a/cmd/control-plane/douyin.go b/cmd/control-plane/douyin.go index 31468ad..8f65c6d 100644 --- a/cmd/control-plane/douyin.go +++ b/cmd/control-plane/douyin.go @@ -18,26 +18,11 @@ type douyinGatewayBrowser struct { } type douyinGatewayRequest struct { - BindingVersion int64 `json:"binding_version"` - RuntimeID string `json:"runtime_id"` - NetworkID string `json:"network_id"` - NetworkExitID string `json:"network_exit_id"` - Cookies []douyin.Cookie `json:"cookies,omitempty"` - URL string `json:"url,omitempty"` -} - -func (browser douyinGatewayBrowser) SetCookies(ctx context.Context, cookies []douyin.Cookie) error { - request, err := browser.request() - if err != nil { - return err - } - request.Cookies = cookies - status, _, err := gatewayCall(ctx, browser.gateway, http.MethodPost, - "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/cookies", request, 30*time.Second) - if err != nil || status != http.StatusNoContent { - return errors.New("restricted browser operation failed") - } - return nil + BindingVersion int64 `json:"binding_version"` + RuntimeID string `json:"runtime_id"` + NetworkID string `json:"network_id"` + NetworkExitID string `json:"network_exit_id"` + URL string `json:"url,omitempty"` } func (browser douyinGatewayBrowser) Get(ctx context.Context, target string) (douyin.Response, error) { diff --git a/cmd/control-plane/douyin_test.go b/cmd/control-plane/douyin_test.go index 91aac2e..3ffd85e 100644 --- a/cmd/control-plane/douyin_test.go +++ b/cmd/control-plane/douyin_test.go @@ -27,12 +27,6 @@ func TestDouyinGatewayBrowserFencesAccountGeneration(t *testing.T) { t.Fatalf("generation fence missing: %#v", body) } switch request.URL.Path { - case "/v1/browsers/account-a/douyin/cookies": - cookies, ok := body["cookies"].([]any) - if !ok || len(cookies) != 1 { - t.Fatalf("cookies missing: %#v", body) - } - response.WriteHeader(http.StatusNoContent) case "/v1/browsers/account-a/douyin/get": if body["url"] != testDouyinIdentityURL { t.Fatalf("unexpected URL: %#v", body) @@ -44,11 +38,8 @@ func TestDouyinGatewayBrowserFencesAccountGeneration(t *testing.T) { })) defer server.Close() browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()} - if err := browser.SetCookies(context.Background(), []douyin.Cookie{{Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/"}}); err != nil { - t.Fatal(err) - } result, err := browser.Get(context.Background(), testDouyinIdentityURL) - if err != nil || result.Status != 412 || result.Challenge != douyin.ChallengeCaptcha || requests != 2 { + if err != nil || result.Status != 412 || result.Challenge != douyin.ChallengeCaptcha || requests != 1 { t.Fatalf("unexpected result: %#v requests=%d err=%v", result, requests, err) } } @@ -72,7 +63,7 @@ func TestDouyinGatewayBrowserDoesNotEchoCredentialOnFailure(t *testing.T) { })) defer server.Close() browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()} - err := browser.SetCookies(context.Background(), []douyin.Cookie{{Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/"}}) + _, err := browser.Get(context.Background(), testDouyinIdentityURL) if err == nil || strings.Contains(err.Error(), "private-session") { t.Fatalf("gateway failure leaked credential: %v", err) } diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index be42ce7..cfc54ee 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -96,11 +96,11 @@ func newCommand() *cobra.Command { creatorScheduleDone := make(chan struct{}) go func() { defer close(creatorScheduleDone) - runCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore, credentials) + runCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore) }() creatorEventContext, stopCreatorEvents := context.WithCancel(command.Context()) creatorEventDone := make(chan struct{}) - creatorEventExecutor := creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials} + creatorEventExecutor := creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore} creatorAI := &creator.ConfiguredBailian{Store: creatorStore, APIKey: cfg.aiAPIKey, BaseURL: cfg.aiBaseURL} go func() { defer close(creatorEventDone) @@ -295,13 +295,13 @@ func newHandlerWithCreatorAndAI(webDirectory, username, password string, phaseAS } if creatorStore != nil { if generator == nil && analyzer == nil { - registerCreator(app, creatorStore, phaseAStore, hubStore, credentials) + registerCreator(app, creatorStore, phaseAStore, hubStore) } else { var executor creator.ActionExecutor if phaseAStore != nil && hubStore != nil { - executor = creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials} + executor = creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore} } - registerCreatorWithServices(app, creatorStore, phaseAStore, hubStore, credentials, executor, generator, analyzer) + registerCreatorWithServices(app, creatorStore, phaseAStore, hubStore, executor, generator, analyzer) } } app.Get("/*", spaHandler(webDirectory)) diff --git a/cmd/docker_gateway/douyin.py b/cmd/docker_gateway/douyin.py index fdbf7e9..2beaef7 100644 --- a/cmd/docker_gateway/douyin.py +++ b/cmd/docker_gateway/douyin.py @@ -2,6 +2,8 @@ from __future__ import annotations +import base64 +import binascii import http.client import json import logging @@ -31,6 +33,7 @@ IDENTITY_URL = ( WORKS_PATH = "/aweme/v1/web/aweme/post/" COMMENTS_PATH = "/aweme/v1/web/comment/list/" RESPONSE_LIMIT = 1 << 20 +MEDIA_RESPONSE_LIMIT = 32 << 20 CONTROL_TIMEOUT = 15.0 UID_RE = re.compile(r"^[1-9][0-9]{0,19}$") ID_RE = re.compile(r"^[1-9][0-9]{0,63}$") @@ -62,6 +65,13 @@ class BrowserResponse: challenge: str = "" +@dataclass(frozen=True) +class BrowserMediaResponse: + status: int + content_type: str + body_base64: str + + class CDPConnection: def __init__(self, socket: websocket.WebSocket) -> None: self.socket = socket @@ -212,10 +222,20 @@ class CDPConnection: class DouyinBrowser: - def __init__(self, endpoint: Callable[[str], str] | None = None) -> None: + def __init__( + self, + endpoint: Callable[[str], str] | None = None, + *, + origin: str = ORIGIN, + url_validator: Callable[[object], bool] | None = None, + media_validator: Callable[[object], bool] | None = None, + ) -> None: self.endpoint = endpoint or ( lambda alias: f"http://creatorhub-browser-{alias}:9222" ) + self.origin = origin + self.url_validator = url_validator or is_douyin_url + self.media_validator = media_validator or is_douyin_media_url @contextmanager def connection(self, alias: str): @@ -272,14 +292,18 @@ class DouyinBrowser: for target in targets if isinstance(target, dict) and target.get("type") == "page" ] - douyin_targets = [ - target for target in page_targets if is_douyin_url(target.get("url", "")) + matching_targets = [ + target + for target in page_targets + if self.url_validator(target.get("url", "")) ] - if len(douyin_targets) > 1 or (not douyin_targets and len(page_targets) > 1): + if len(matching_targets) > 1 or ( + not matching_targets and len(page_targets) > 1 + ): raise DouyinError("browser has more than one page target") target = ( - douyin_targets[0] - if douyin_targets + matching_targets[0] + if matching_targets else page_targets[0] if page_targets else None @@ -319,63 +343,9 @@ class DouyinBrowser: raise DouyinError("browser CDP connection failed") from exc return CDPConnection(socket) - def set_cookies(self, alias: str, cookies: list[dict]) -> None: - with self.connection(alias) as cdp: - cdp.command("Network.enable") - cdp.command("Network.clearBrowserCookies") - cdp.command("Page.enable") - navigation = cdp.command("Page.navigate", {"url": ORIGIN_URL}) - frame_id = navigation.get("frameId") - if not isinstance(frame_id, str) or navigation.get("errorText"): - raise DouyinError("browser navigation failed") - cdp.wait_event( - "Page.frameNavigated", - lambda params: ( - params.get("frame", {}).get("id") == frame_id - and is_douyin_url(params.get("frame", {}).get("url")) - ), - ) - deadline = time.monotonic() + CONTROL_TIMEOUT - last_error: DouyinError | None = None - while time.monotonic() < deadline: - try: - state = cdp.evaluate( - "({origin:location.origin,state:document.readyState})" - ) - if ( - isinstance(state, dict) - and state.get("origin") == ORIGIN - and state.get("state") in {"interactive", "complete"} - ): - break - except DouyinError as exc: - last_error = exc - time.sleep(0.1) - else: - if last_error: - raise DouyinError("browser did not reach Douyin") from last_error - raise DouyinError("browser did not reach Douyin") - values = [] - for cookie in cookies: - value = { - "name": cookie["name"], - "value": cookie["value"], - "url": ORIGIN_URL, - "domain": cookie["domain"], - "path": cookie.get("path") or "/", - "secure": bool(cookie.get("secure")), - "httpOnly": bool(cookie.get("http_only")), - } - if cookie.get("same_site"): - value["sameSite"] = cookie["same_site"] - if cookie.get("expires"): - value["expires"] = cookie["expires"] - values.append(value) - cdp.command("Network.setCookies", {"cookies": values}) - def get(self, alias: str, target: str) -> BrowserResponse: with self.connection(alias) as cdp: - if cdp.evaluate("location.origin") != ORIGIN: + if cdp.evaluate("location.origin") != self.origin: raise DouyinError("restricted browser origin changed") expression = f"""(async()=>{{ const r=await fetch({json.dumps(target)},{{credentials:'include',redirect:'error'}}); @@ -402,6 +372,72 @@ class DouyinBrowser: raise DouyinError("restricted browser fetch returned invalid body") return BrowserResponse(status, body, detect_challenge(status, body)) + def get_media(self, alias: str, target: str) -> BrowserMediaResponse: + if not self.media_validator(target): + raise DouyinError("restricted browser media target is invalid") + with self.connection(alias) as cdp: + navigation = cdp.command("Page.navigate", {"url": target}) + frame_id = navigation.get("frameId") + if not isinstance(frame_id, str) or navigation.get("errorText"): + raise DouyinError("Douyin media page navigation failed") + cdp.wait_event( + "Page.frameNavigated", + lambda params: ( + params.get("frame", {}).get("id") == frame_id + and self.url_validator(params.get("frame", {}).get("url")) + ), + ) + deadline = time.monotonic() + CONTROL_TIMEOUT + while time.monotonic() < deadline: + if cdp.evaluate("document.readyState") in {"interactive", "complete"}: + break + time.sleep(0.1) + else: + raise DouyinError("Douyin media page did not load") + result = cdp.evaluate( + f"""(async()=>{{ + const video=document.querySelector('video'); + const source=video?.currentSrc||video?.src||''; + if(!source)return {{error:'media_source_unavailable'}}; + const r=await fetch(source,{{credentials:'include',redirect:'error'}}); + if(!r.body)return {{status:r.status,content_type:r.headers.get('content-type')||'',body:''}}; + const reader=r.body.getReader(), chunks=[]; let size=0; + for(;;){{const item=await reader.read();if(item.done)break; + if(size+item.value.byteLength>{MEDIA_RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}} + size+=item.value.byteLength;chunks.push(item.value); + }} + const bytes=new Uint8Array(size); let offset=0; + for(const chunk of chunks){{bytes.set(chunk,offset);offset+=chunk.length;}} + let binary=''; + for(let offset=0;offset= 300: + raise DouyinError("Douyin media response was not successful") + try: + decoded_size = len(base64.b64decode(body, validate=True)) + except (ValueError, binascii.Error) as exc: + raise DouyinError("Douyin media response is invalid") from exc + if decoded_size > MEDIA_RESPONSE_LIMIT: + raise DouyinError("Douyin media response is too large") + return BrowserMediaResponse(status, content_type, body) + def identity(self, alias: str, expected_uid: str | None = None) -> dict: response = self.get(alias, IDENTITY_URL) try: @@ -521,7 +557,7 @@ class DouyinBrowser: def _evaluate(self, alias: str, expression: str) -> object: with self.connection(alias) as cdp: - if cdp.evaluate("location.origin") != ORIGIN: + if cdp.evaluate("location.origin") != self.origin: raise DouyinError("restricted browser origin changed") return cdp.evaluate(expression) @@ -1020,6 +1056,43 @@ def _notice_id(value: object) -> str: return "" +def _direct_message_type(detail: dict, message: object) -> str: + raw_type = ( + detail.get("message_type") or detail.get("msg_type") or detail.get("type") + ) + if isinstance(message, dict): + raw_type = raw_type or message.get("type") or message.get("message_type") + if isinstance(raw_type, int) and not isinstance(raw_type, bool): + raw_type = {1: "text", 2: "image", 3: "voice", 4: "video", 5: "sticker"}.get( + raw_type + ) + if isinstance(raw_type, str): + normalized = raw_type.strip().lower() + aliases = { + "text": "text", + "txt": "text", + "image": "image", + "img": "image", + "picture": "image", + "voice": "voice", + "audio": "voice", + "video": "video", + "sticker": "sticker", + "emoji": "sticker", + } + if normalized in aliases: + return aliases[normalized] + if any(detail.get(key) for key in ("voice", "audio", "audio_url")): + return "voice" + if any(detail.get(key) for key in ("image", "picture", "image_url")): + return "image" + if any(detail.get(key) for key in ("video", "video_url")): + return "video" + if isinstance(message, str) and message: + return "text" + return "non_text" + + def normalize_notice(notice: object) -> dict | None: if not isinstance(notice, dict): raise DouyinError("notification detail is invalid") @@ -1062,6 +1135,7 @@ def normalize_notice(notice: object) -> dict | None: or notice.get("text") or "" ) + message_type = _direct_message_type(detail, message) if isinstance(message, dict): message = message.get("text") or message.get("content") or "" if not isinstance(message, str) or len(message) > 100000: @@ -1082,6 +1156,7 @@ def normalize_notice(notice: object) -> dict | None: "interactor_uid": _notice_id(sender.get("uid") or sender.get("user_id")), "comment_id": "", "work_id": "", + "message_type": message_type, "message_text": message, } create_time = notice.get("create_time") @@ -1180,6 +1255,23 @@ def is_douyin_url(value: object) -> bool: return False +def is_douyin_media_url(value: object) -> bool: + if not isinstance(value, str): + return False + try: + parsed = urlsplit(value) + return ( + parsed.scheme == "https" + and parsed.hostname == "www.douyin.com" + and parsed.port is None + and parsed.username is None + and parsed.password is None + and parsed.path.startswith("/video/") + ) + except (TypeError, ValueError): + return False + + def detect_challenge(status: int, body: str) -> str: if status == 429: return "" diff --git a/cmd/docker_gateway/gateway.py b/cmd/docker_gateway/gateway.py index 8a20577..9a8a128 100644 --- a/cmd/docker_gateway/gateway.py +++ b/cmd/docker_gateway/gateway.py @@ -590,19 +590,6 @@ class Gateway: ) raise - def set_douyin_cookies(self, alias: str, input: dict) -> None: - cookies_value = input.get("cookies") - if not valid_douyin_generation(input) or not valid_cookies(cookies_value): - raise RequestError("invalid restricted browser request", 400) - cookies = cast(list[dict], cookies_value) - with self._alias_lock(alias): - self._require_douyin_generation(alias, input) - try: - self.browser.set_cookies(alias, cookies) - self._require_douyin_generation(alias, input) - except DouyinError as exc: - raise RequestError("restricted browser operation failed") from exc - def get_douyin(self, alias: str, input: dict) -> dict: if not valid_douyin_generation(input) or not valid_douyin_url( input.get("url", "") @@ -626,6 +613,28 @@ class Gateway: "challenge": response.challenge, } + def get_douyin_media(self, alias: str, input: dict) -> dict: + target = input.get("url", "") + if not valid_douyin_generation(input) or not isinstance(target, str): + raise RequestError("invalid restricted browser request", 400) + with self._alias_lock(alias): + self._require_douyin_generation(alias, input) + try: + response = self.browser.get_media(alias, target) + self._require_douyin_generation(alias, input) + except DouyinError as exc: + LOG.warning( + "Douyin media download failed alias=%s reason=%s", + alias, + str(exc), + ) + raise RequestError("restricted browser media download failed") from exc + return { + "status": response.status, + "content_type": response.content_type, + "body_base64": response.body_base64, + } + def douyin_identity(self, alias: str, input: dict) -> dict: expected_account_key = input.get("expected_account_key", "") if ( @@ -678,6 +687,8 @@ class Gateway: or action not in ACTIONS ): raise RequestError("invalid Douyin action request", 400) + if action == "repost": + raise RequestError("ACTION_UNAVAILABLE", 409) with self._alias_lock(alias): self._require_douyin_generation(alias, input) self._claim_action(alias) @@ -1120,16 +1131,15 @@ class GatewayHandler(BaseHTTPRequestHandler): gateway.restore_proxy(alias, body) return None match = re.fullmatch( - r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(cookies|get|identity|action|events)", + r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(get|media|identity|action|events)", path, ) if match: alias, action = match.groups() - if action == "cookies" and method == "POST": - gateway.set_douyin_cookies(alias, body) - return None if action == "get" and method == "POST": return gateway.get_douyin(alias, body) + if action == "media" and method == "POST": + return gateway.get_douyin_media(alias, body) if action == "identity" and method == "POST": return gateway.douyin_identity(alias, body) if action == "action" and method == "POST": @@ -1409,57 +1419,6 @@ def valid_douyin_generation(value: dict) -> bool: ) -def valid_cookies(cookies: object) -> bool: - if not isinstance(cookies, list) or not 1 <= len(cookies) <= 64: - return False - for cookie in cookies: - if not isinstance(cookie, dict): - return False - allowed = { - "name", - "value", - "domain", - "path", - "secure", - "http_only", - "same_site", - "expires", - } - if set(cookie) - allowed: - return False - name = cookie.get("name") - value = cookie.get("value") - domain = cookie.get("domain") - path_value = cookie.get("path") - path = "/" if path_value is None else path_value - if ( - not isinstance(name, str) - or not isinstance(value, str) - or not isinstance(domain, str) - or not isinstance(path, str) - ): - return False - if ( - not name - or len(name) > 256 - or len(value) > 4096 - or len(domain) > 256 - or len(path) > 256 - or domain != domain.lower().strip() - or (domain != "douyin.com" and not domain.endswith(".douyin.com")) - or not path.startswith("/") - or has_control(name) - or has_control(value) - or has_control(path) - or (cookie.get("same_site", "") not in {"", "Lax", "Strict", "None"}) - ): - return False - expires = cookie.get("expires", 0) - if type(expires) not in (int, float) or expires < 0: - return False - return True - - def valid_douyin_url(raw: object) -> bool: if not isinstance(raw, str): return False diff --git a/cmd/docker_gateway/test_gateway.py b/cmd/docker_gateway/test_gateway.py index d342cb9..ed3ff94 100644 --- a/cmd/docker_gateway/test_gateway.py +++ b/cmd/docker_gateway/test_gateway.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import io import json import socket @@ -83,7 +84,6 @@ validate_create = gateway_module.validate_create parse_proxy_exit = gateway_module.parse_proxy_exit validate_proxy_exit = gateway_module.validate_proxy_exit validate_proxy_restore = gateway_module.validate_proxy_restore -valid_cookies = gateway_module.valid_cookies valid_douyin_generation = gateway_module.valid_douyin_generation valid_account_key_query = gateway_module.valid_account_key_query numeric_cursor = gateway_module.numeric_cursor @@ -228,7 +228,8 @@ class GatewayValidationTests(unittest.TestCase): handler._route("POST", "/v1/browsers/safe/start", {}, {}) handler._route("POST", "/v1/browsers/safe/stop", {}, {}) handler._route("POST", "/v1/browsers/safe/proxy", {}, {}) - handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {}) + with self.assertRaises(RequestError): + handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {}) handler._route("POST", "/v1/browsers/safe/douyin/get", {}, {}) handler._route("POST", "/v1/browsers/safe/douyin/identity", {}, {}) handler._route("POST", "/v1/browsers/safe/douyin/action", {}, {}) @@ -285,10 +286,6 @@ class GatewayValidationTests(unittest.TestCase): ) with self.assertRaises(RequestError): validate_proxy_exit(ProxyExit("http", "proxy", 0)) - cookie = {"name": "sessionid", "value": "v", "domain": ".douyin.com"} - self.assertTrue(valid_cookies([cookie])) - self.assertFalse(valid_cookies([{**cookie, "domain": "evil.example"}])) - self.assertFalse(valid_cookies([{**cookie, "same_site": "bad"}])) generation = { "binding_version": 1, "runtime_id": "a" * 64, @@ -332,19 +329,6 @@ class GatewayValidationTests(unittest.TestCase): load_config({"GATEWAY_TOKEN": "short"}) self.assertFalse(valid_douyin_url("http://www.douyin.com/video/1")) self.assertFalse(valid_douyin_url("https://www.douyin.com/unknown")) - self.assertFalse(valid_cookies("not-a-list")) - self.assertFalse( - valid_cookies( - [ - { - "name": "x", - "value": "v", - "domain": ".douyin.com", - "path": "relative", - } - ] - ) - ) with self.assertRaises(RequestError): decode_generation( {"binding_version": 1, "runtime_id": "a" * 64}, True, True @@ -651,33 +635,36 @@ class BrowserTests(unittest.TestCase): cast(Any, browser).connection = bound - def test_cookie_navigation_and_fetch(self) -> None: - cdp = BrowserCDP( - [{"origin": "https://www.douyin.com", "state": "complete"}, "{}"] - ) - browser = DouyinBrowser() - self._with_connection(browser, cdp) - browser.set_cookies( - "safe", [{"name": "sessionid", "value": "v", "domain": ".douyin.com"}] - ) - self.assertIn("Page.navigate", [method for method, _ in cdp.commands]) - set_command = next( - params for method, params in cdp.commands if method == "Network.setCookies" - ) - assert set_command is not None - self.assertEqual(set_command["cookies"][0]["domain"], ".douyin.com") - + def test_browser_fetch_uses_manually_logged_session(self) -> None: cdp = BrowserCDP( [ "https://www.douyin.com", {"status": 200, "body": "{}", "too_large": False}, ] ) + browser = DouyinBrowser() self._with_connection(browser, cdp) response = browser.get( "safe", "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383" ) self.assertEqual(response.status, 200) + self.assertNotIn("Network.setCookies", [method for method, _ in cdp.commands]) + + def test_media_download_is_browser_mediated_and_bounded(self) -> None: + payload = base64.b64encode(b"video-bytes").decode("ascii") + cdp = BrowserCDP( + [ + "complete", + {"status": 200, "content_type": "video/mp4", "body": payload}, + ] + ) + browser = DouyinBrowser() + self._with_connection(browser, cdp) + response = browser.get_media("safe", "https://www.douyin.com/video/123") + self.assertEqual(response.status, 200) + self.assertEqual(response.content_type, "video/mp4") + self.assertEqual(base64.b64decode(response.body_base64), b"video-bytes") + self.assertIn("Page.navigate", [method for method, _ in cdp.commands]) def test_connect_identity_and_actions(self) -> None: target = [ @@ -1768,13 +1755,6 @@ class AdditionalGatewayCoverageTests(unittest.TestCase): browser.action("safe", "1", "follow", "2", confirm=True) def test_gateway_and_proxy_validation_edges(self) -> None: - for value in ( - None, - [], - [{"name": "bad"}], - [{"name": "sessionid", "value": "x", "domain": "evil.test"}], - ): - self.assertFalse(valid_cookies(value)) self.assertEqual( parse_proxy_exit({"protocol": "http", "host": "", "port": 80}).host, "" ) @@ -2009,7 +1989,21 @@ class AdditionalGatewayCoverageTests(unittest.TestCase): assert notice is not None self.assertEqual(notice["event_type"], "dm") self.assertEqual(notice["interactor_uid"], "456789") + self.assertEqual(notice["message_type"], "text") self.assertEqual(notice["message_text"], "hello") + non_text = normalize_notice( + { + "dm": { + "message_id": "123457", + "from_user": {"uid": "456789"}, + "message_type": "image", + "image_url": "https://example.invalid/image", + } + } + ) + assert non_text is not None + self.assertEqual(non_text["message_type"], "image") + self.assertEqual(non_text["message_text"], "") script = action_expression({"alias": "safe", "action": "follow", "target": "2"}) self.assertIn("POST_UNCERTAIN", script) self.assertIn("BUSINESS_REJECTED", script) diff --git a/docs/better-douyin-整合计划.md b/docs/better-douyin-整合计划.md index 2e428c9..8dadcd7 100644 --- a/docs/better-douyin-整合计划.md +++ b/docs/better-douyin-整合计划.md @@ -1,262 +1,211 @@ # better-douyin 整合计划 -> 状态:待排期,仅作为 CreatorHub 的独立实现计划,不代表相关功能已经完成。 +> 状态:待排期;已按文档 review 修正,不代表功能实现或真实平台验收通过。 > > 参考项目: > -> 审阅快照:commit `f534c66d61f541a526fa3f5251d32494aad758a8`(2026-08-28) +> 待核实参考快照:commit `f534c66d61f541a526fa3f5251d32494aad758a8`(原登记日期 2026-08-28,提交内容与日期均待核实) > -> 需求基线:[`docs/plan01.md`](./plan01.md) +> 需求与验收基线:[plan01.md](./plan01.md);工程约束:[AGENTS.md](../AGENTS.md)。 -## 1. 目标 +## 1. 目标与文档职责 -从 `better-douyin` 中吸收经过验证的产品组织方式和状态处理思路,补强 CreatorHub 的抖音完整流程: +参考 `better-douyin` 的候选产品组织方式,补强 CreatorHub 已批准的竞品分析、素材处理、评论线索、人工发送、监听与自动响应流程。本计划只吸收**功能思想和交互原则**,不复制源代码、界面资源、提示词、品牌内容或私有协议实现。 -- 真实事件监听、断线恢复和事件去重; -- 竞品创作者监控、作品发现和素材处理; -- 统一的自动响应与人工发送工作台; -- 评论线索、通知和私信的可追溯处理; -- AI 建议、动作策略和任务结果证据。 +本文是 `plan01` 的补充实施计划,不替代其业务范围、页面地图、规则或验收标准。第 4 节沿用 G0、G1.1 至 G1.6、G2、G3 的顺序;未在本文展开的账号、环境、代理等要求仍归属 `plan01` 对应阶段,不视为已经完成或排除。已有实现先核实并复用,不另建一套通用任务、规则或工作流系统。 -本计划只吸收**功能思想和交互原则**,不复制源代码、界面资源、提示词、品牌内容或私有协议实现。 +**本次文档修正完成标准**:review 所列问题均有对应规则或验收入口;阶段与 `plan01` 一致;本地链接与验收编号有效;未核实的上游描述不写成事实。文档检查不能替代代码测试或平台验收。 -## 2. 审阅结论与边界 +## 2. 来源核验与边界 -### 2.1 参考项目的公开边界 +### 2.1 上游描述待核实 -公开仓库主要包含: +本轮 review 请求快照原文时遇到网络保护拦截,未能独立读取来源。原稿中的以下描述保留为**待核实项**,不能作为选型或能力结论: -- React 前端界面; -- Tauri mock bridge; -- Node mock backend; -- 前端契约和模拟数据。 +- React 前端、Tauri mock bridge、Node mock backend、契约与模拟数据的具体构成; +- 真实抖音接口、登录会话、Cookie、签名、下载解析及验收证据是否公开; +- 私信恢复、创作者监控、下载、通知、AI、MCP 等功能与所引文件的对应关系; +- 许可证名称、商业使用限制及授权条件。 -公开内容不包含真实抖音接口、登录会话、Cookie、签名、下载解析、发布密钥或真实平台验收证据。因此,项目中的功能只能作为产品和代码组织参考,不能作为 CreatorHub 的真实平台能力证明。 +核验时应逐项记录指定快照的文件、原文依据,以及仅界面、契约、模拟实现或真实适配的区别。未核实前不称其为“经过验证”的实现,不以来源未读到推断功能不存在,也不作“缺失检查脚本”的结论。无论上游核验结果如何,都不能用其实现替代 CreatorHub 的真实平台证据。 -### 2.2 许可证限制 +### 2.2 许可证与独立实现 -仓库使用 `Better Douyin Non-Commercial License`。CreatorHub 面向商业使用,不能直接复制或改造其代码、资源和衍生实现;如需复用原代码,必须先取得明确的书面许可。本计划默认全部功能由 CreatorHub 独立实现。 +原稿登记的 `Better Douyin Non-Commercial License` 名称及限制尚未核实;不得仅凭名称断言许可范围。复用任何上游代码或资产前,必须核验对应快照的完整许可证及授权范围;不能确认覆盖拟议商业用途时,不得复用,须取得明确授权后另行确认。 -### 2.3 必须保持的 CreatorHub 原则 +本计划的默认边界不变:全部独立实现,不复制代码或资产。此边界不是对上游许可证已核验通过的声明。 -1. 自动动作与人工动作分离:自动动作按策略和 UID 冷却执行,人工发送逐次确认。 -2. 自有账号的点赞、评论、关注、转发和私信优先使用真实事件监听,不以轮询或 Mock 冒充实际能力。 -3. 写操作必须核对账号身份,并记录成功、失败或结果不明;不能把异常静默转换为成功。 -4. 不增加本地 Web UI 或本地 MCP。统一管理继续放在远程 Web 和控制面。 -5. 抖音真实流程完成并验收前,不提前扩展小红书实现。 +### 2.3 CreatorHub 的固定边界 -## 3. 可吸收功能清单 +1. 自动响应仅由大号**收到的评论、点赞、转发、关注**四类互动触发,遵循 A3 的有序单动作策略。推荐流、好友活动、创作者新增作品、`@` 等不新增为自动触发源;扩展须另行确认。 +2. 私信新消息只更新会话,不触发 AI 自动聊天;评论线索识别本身不自动发送。人工回复与私信逐次确认,不受自动冷却限制。 +3. 自有账号互动和私信必须使用后台真实事件监听,不能以固定轮询或 Mock 替代;作品、指标及一级评论按 C2/W1 固定计划采集。历史读取只按 A6 的恢复边界进行,不变成持续轮询入口;账号、环境和代理列表不主动探测实时状态。 +4. 控制面继续使用 Go,Docker/浏览器 gateway 使用 Python,界面复用 React/Refine/shadcn/ui 与现有布局。整合不引入 Tauri 桌面端、本地 MCP 或另一套管理界面,不改变已有部署方式。 +5. 真实写操作必须核对账号、目标和执行条件;明确区分成功、失败、结果不明。已经开始的自动写动作不自动重试,结果不明只核验,不自动补发。 +6. 抖音全部适用验收项通过前不进入小红书实现;能力不足必须提交证据并由用户裁决,不自行删项。 -| 优先级 | 参考亮点 | CreatorHub 整合方式 | 参考位置 | -| --- | --- | --- | --- | -| P0 | 私信实时事件、历史补偿、断线恢复、账号切换保护、静默基线 | 独立重写到真实事件链路,增加我们的结果证据和 `uncertain` 状态 | `frontend/src/components/friends/`、`frontend/src/hooks/use-global-friends-im.ts` | -| P0 | 统一自动化工作台 | 集中展示来源、策略、动作、运行状态、日志和失败原因 | `frontend/src/components/automation/automation-view.tsx` | -| P0 | 关键词、阈值、延迟、单次动作上限、去重 | 映射到账号策略、UID 冷却、人工确认和自动策略 | `automation-settings-dialog.tsx`、`lib/ai-automation.ts` | -| P1 | 创作者监控、基线、新作品发现、周期扫描、批量下载 | 用于竞品创作者采集;按 CreatorHub 的真实分页、采集和限时要求实现 | `lib/contracts.ts`、`automation-view.tsx` | -| P1 | 下载任务状态和历史 | 用于素材下载、提音轨、转写;增加结果不明和证据查看 | `components/downloads/`、`hooks/use-downloads.ts` | -| P1 | 通知分类、作品跳转、直接回复 | 作为评论线索和私信入口;主触发仍来自真实事件 | `components/notices/notices-view.tsx`、`use-global-notice-monitor.ts` | -| P1 | 搜索和链接解析 | 作为竞品 UID、作品和创作者监控的导入入口 | `components/search/`、`components/link/link-view.tsx` | -| P2 | AI 供应商、模型、建议回复配置 | 先用于建议和人工确认,再按策略开放自动动作 | `components/settings/settings-ai.tsx` | -| 不整合 | 本地 MCP 服务 | 与 CreatorHub 的远程 Web/控制面架构冲突;只保留写授权、确认和审计原则 | `components/settings/settings-mcp.tsx` | +## 3. 候选参考与批准范围的映射 -## 4. 实施阶段 +下列上游功能名称与文件位置均为**待核实引用线索**,不是已确认的功能归因;实施顺序以第 4 节为准。 -### 阶段 0:冻结边界和验收口径 +| 候选参考 | CreatorHub 采用范围 | 待核实参考位置 | +| --- | --- | --- | +| 私信恢复、历史基线、账号切换 | A6/M1 的后台监听、只读恢复与账号隔离;不自动聊天 | `frontend/src/components/friends/`、`frontend/src/hooks/use-global-friends-im.ts` | +| 统一自动化工作台 | 复用工作台与任务页,展示来源、执行号、策略、状态、错误和证据,不新增通用自动化编辑器 | `frontend/src/components/automation/automation-view.tsx` | +| 条件、去重与动作配置 | 仅采用 A3 有序单动作和 A5 冷却;关键词归 W2,指标阈值归 C3,不引入额外延迟或可配置多动作上限 | `automation-settings-dialog.tsx`、`lib/ai-automation.ts` | +| 创作者监控与作品发现 | C1/C2 的完整回溯、分页及独立指标计划;新作品只入列表,人工选取才下载 | `lib/contracts.ts`、`automation-view.tsx` | +| 下载状态与历史 | C4 的分步产物、失败重试与两次确认;不因参考功能追加暂停/恢复等未批准交互 | `components/downloads/`、`hooks/use-downloads.ts` | +| 通知分类、作品跳转、回复 | 区分互动事件、评论线索和私信展示;仅批准的四类互动可触发自动响应 | `components/notices/notices-view.tsx`、`use-global-notice-monitor.ts` | +| 搜索与链接解析 | 仅 C1 主页/可解析作品分享链接的解析预览、人工确认和稳定标识去重;不新增通用搜索或 UID 导入入口 | `components/search/`、`components/link/link-view.tsx` | +| AI 配置 | 仅已批准的转写、素材理解与仿写、主题/线索判断及无候选文本时的单次响应 | `components/settings/settings-ai.tsx` | +| 本地 MCP | 不整合 | `components/settings/settings-mcp.tsx` | -**目标**:避免把 mock 功能误当成真实能力,也避免直接引入不适合 CreatorHub 的架构。 +## 4. 实施阶段与完成标准 -**工作内容**: +### G0:证据与输入冻结 -- 建立独立的事件、动作、监控任务和素材任务契约; -- 统一动作结果为 `success`、`failed`、`uncertain`; -- 为每次真实写操作定义最小证据:账号、目标 UID/作品、动作、时间、平台返回或页面证据; -- 将自动策略和人工确认建模为两条不同流程; -- 标注所有“仅离线可验证”的功能,禁止进入真实能力清单。 +**入口与工作**: -**完成标准**: +- 对照 `plan01` 第 8.7 节逐分项登记抖音能力:事件 ID、互动者 UID、目标、时间、基线、恢复范围、各动作及分页/媒体证据。缺可靠事件身份、UID、目标或真实监听证据即阻塞相应自动能力,不能用 Mock 通过。 +- 相关 AI 功能开发前取得服务商、模型/版本、参数、提示要求、费用、脱敏样本及逐条预期和允许误差的批准;转写含清晰语音、背景音乐和无语音样本。不编造准确率或默认选用付费服务。 +- 对照现有事件、动作、任务及产物记录补齐缺口,不另建独立通用契约。区分人工与自动来源,记录实际成功、失败或结果不明,不以状态命名转换改变结果含义。 +- 将 [2026-09-14 测试报告](./e2e-test-report-20260914.md) 与 [证据](./e2e-evidence-20260914.md) 作为待复验基线,而非永远有效的现状声明:凭据解析和浏览器读取阻断归 G1.1;作品/评论分页和指标计划问题归 G1.2;素材处理、线索入口及 AI/转写配置问题归 G1.3;事件读取与恢复缺口归 G1.5。每项保存原用例、复验结果及新证据,修复未经复验不得视为前置能力已具备。 -- 计划、契约和测试都明确区分真实、模拟、失败和结果不明; -- 不存在静默回退、隐式重试或将 Mock 结果标记为成功的路径。 +**完成标准**:`plan01` G0、8.6、8.7 的输入与证据逐项登记;未批准或不支持项明确阻塞对应能力。来源核验未完成时,不使用上游事实作为实施依据;独立功能仍按 CreatorHub 自身证据判断。 -### 阶段 1:真实事件基础和私信恢复 +### G1.1:抖音账号与环境 -**目标**:完成 CreatorHub 待办中的真实平台事件监听、去重和断连恢复。 +**入口**:G0 对该阶段的输入已明确,测试账号及只读/可控写入样本已授权。 -**工作内容**: +**工作**:按 A1/A2/E1/E2 完成账号资料、人工业务状态、同环境人工登录和身份核对、稳定指纹及代理。验证实际浏览器读取可用;账号、登录、环境状态分别展示。凭据或浏览器访问阻断未解决前,不启动依赖它们的真实采集或写入验收。 -- 接收实时事件并核对平台、账号和当前会话代际; -- 使用账号和会话维度的事件身份,避免重复处理; -- 对历史同步建立静默基线,不把旧消息当成新消息; -- 断线后按游标、同步提示或有限历史窗口补偿; -- 对未确认身份、过期时间戳、旧账号事件和重复事件拒绝自动写操作; -- 保留有限长度的待处理队列,并记录溢出和停止原因; -- 将自动化副作用放在事件确认之后。 +**完成标准**:AC-A1、AC-A2、AC-A3、AC-E1、AC-E2、AC-E3、AC-E4、AC-E5,以及 AC-U3 的账号/登录部分通过。账号封禁、注销、禁言的执行限制按 A1,不推测处罚状态或自动换账号、代理。 -**必须覆盖的检查**: +### G1.2:抖音只读采集 -- 实时新消息; -- 重复事件; -- 断线重连; -- 账号切换期间的旧事件; -- 历史消息基线; -- 事件身份不可信; -- 超时、取消和结果不明。 +**入口**:G1.1 已通过相关身份和浏览器读取检查;G0 已记录链接、稳定标识、分页及指标能力。 -**完成标准**: +**工作**: -- 在真实测试账号上完成事件接收、恢复和去重证据; -- 满足 `plan01` 规定的 5 秒/30 秒时限; -- 所有自动动作都能追溯到唯一事件和账号。 +- 链接解析后展示平台、作者及稳定标识,人工确认才创建监测;首次默认回溯最近 30 天,按 C2 固定 UTC 窗口完整分页,支持中断续采,不能仅建立新作品基线而省略历史资料。 +- 新作品默认每 30 分钟检查;既有作品继续独立更新指标,按发布时间后的 1/3/7/15/31/55……小时计划、24 小时间隔上限及满 30 天停止规则执行。暂停、恢复、设置变化、无可靠发布时间均遵循 C2,不补造历史指标。 +- 爆款筛选遵循 C3:所有已填阈值同时满足,缺指标不填 0,允许真实指标下降。 +- 自有与竞品最近回溯范围内作品的全部一级评论按 W1 定时补采、分页与去重,不采楼中楼,不用通知事件列表替代评论采集。 +- 新作品只进入作品列表;未经人工选取,不创建下载、转写或仿写任务。 -### 阶段 2:统一自动化工作台和动作策略 +**完成标准**:AC-C1、AC-C2、AC-C3、AC-C4、AC-C5、AC-C6、AC-W1、AC-U1、AC-B4 通过;用超过一页的真实作品和评论验证范围完整性,采集失败与中断可追溯。 -**目标**:将推荐流、评论、通知、好友和创作者监控的响应集中到一个工作台。 +### G1.3:抖音媒体与 AI -**工作内容**: +**入口**:G1.2 提供可核实作品/评论;G0 的 AI/转写服务、费用和质量样本已批准,实际调用配置及素材处理入口可用。不能将这些前置配置延至监听或私信完成之后。 -- 按事件来源、账号、策略、动作状态筛选; -- 展示候选动作、执行中的动作、成功、失败和结果不明; -- 展示动作前的目标、内容、策略命中原因和冷却信息; -- 人工发送必须先预览,再逐次确认; -- 自动动作必须经过启用状态、关键词/阈值、UID 冷却、单次上限和账号身份校验; -- 记录每个动作的尝试次数、实际结果、错误原因和证据链接。 +**工作**: -**完成标准**: +- 固定流程:作品资料 → **人工选取素材** → 下载视频、提音轨、真实转写 → **人工确认仿写** → 可编辑标题与口播文案及保存。不自动下载、不自动发布。 +- 每一步记录输入、产物、状态、尝试次数和具体错误;视频/音频须真实可读,转写不能用作品描述冒充。失败仅由人工重试失败步骤,复用成功产物;不使用空文件或 Mock 推进下游。 +- 已确认无音轨/无说话内容是明确结果,可如实展示后确认仿写;下载或转写失败必须解决后才能仿写。结果不明先核验,不盲目重复调用;磁盘/配额不足明确失败,不自动删除素材。 +- 线索固定按 W2 的“作品主题 AI → 评论包含/排除关键词 → 评论 AI”判断;原文子串匹配,不改变大小写或 Unicode。多规则只生成一个评论线索,保存当时依据;规则修改不改写历史,重新分析由用户明确选择范围。 +- AI 只用于批准场景,失败、空内容、无法解析均显示真实错误,不伪造结果、不静默切换供应商。配置由控制面管理,不增加认证、租户或模型市场;凭据不进入前端日志、普通响应或证据。 -- 自动和人工动作在界面和服务层均不可混淆; -- 同一 UID 在冷却期间不会重复自动触发; -- 失败和结果不明不会被列表隐藏,也不会无条件重试。 +**完成标准**:AC-C7、AC-C8、AC-C9、AC-C10、AC-W2、AC-W3、AC-U2、AC-U4、AC-U6、AC-B5、AC-B6 通过;质量按 G0 批准样本逐项验收,覆盖无语音、失败重试、重复点击、放弃仿写及文稿保存失败。 -### 阶段 3:创作者监控与竞品采集 +### G1.4:抖音人工动作 -**目标**:把创作者监控变成可审计的竞品采集流程。 +**入口**:G1.1 的执行账号条件可用,G1.2/G1.3 的评论与线索入口可用;G0 已核实相关写动作及结果证据方式。 -**工作内容**: +**工作**: -- 支持通过创作者 UID、主页链接或作品链接创建监控目标; -- 首次运行建立基线,历史作品不触发新作品动作; -- 后续运行只处理新增作品,并保存游标、最后成功时间和错误原因; -- 支持按账号、创作者、作品和采集状态查看结果; -- 新作品进入下载任务,不在采集阶段假设下载成功; -- 采集失败、分页异常、账号失效和权限变化都保留证据。 +- 从评论或线索选择同平台账号、核对目标与实际文本、预览后逐次确认回复或私信。人工发送不受 A5 自动冷却限制,线索出现不自动发送。 +- 提交前取得持久操作标识并固定账号、目标、文本;双击、请求重试、页面重开及进程重启沿用同一标识,服务端保证至多执行一次,不只靠禁用按钮。 +- 同一执行账号的所有人工与自动写操作统一串行协调。真正写入前重新核对业务状态、登录身份、目标及启停条件;等待期间条件变化阻止未开始动作,已开始动作保留真实结果,不将停止冒充撤销。 +- 结果不明只提供查询/人工核验并保存证据,不重用原操作再次发送。确需另发必须重新确认并生成新操作标识,记录与原操作的关系及原因。 -**完成标准**: +**完成标准**:AC-W4、AC-W5、AC-B2 的人工部分通过,覆盖双击、重启、错误身份、禁言、等待期间条件变化和发送后响应丢失。此阶段私信发送通过,不代表完整会话已通过。 -- 真实创作者目标能够完成基线和新增作品识别; -- 重复运行不会重复创建同一作品任务; -- 采集证据与后续素材任务可以关联。 +### G1.5:抖音监听与自动响应 -### 阶段 4:素材下载、提音轨和转写 +**入口**:G1.4 的真实写入及防重复路径可用;G0 已逐类验证四事件、五类动作、可靠事件身份与恢复边界。无候选文本的策略还依赖已批准且可用的 AI 配置及大号回复要求。 -**目标**:完成 CreatorHub 当前待办中的真实素材处理链路。 +**事件与恢复**: -**任务状态**: +- 事件身份采用“平台+接收账号+平台稳定事件 ID”,或经真机证明跨重连/重启稳定且唯一的事件位置。普通翻页游标、昵称、时间/内容哈希不能替代。会话代际只用于拒绝旧会话或错误账号事件,不参与重置永久去重。 +- 首次监听与每次重新启用先持久保存可信历史基线,再接收确定的新边界事件;保存所有事件的最小身份、边界分类和处理结论,包括未匹配、跳过、失败和结果不明。最小去重记录不随冷却到期删除,旧事件不因策略变化或重启再次执行。 +- 断连和进程重启均核对持久基线与恢复位置;补偿默认只恢复记录和展示,不补发自动动作。断连期间或无法证明处于持续启用新边界内的事件列为迟到/待核验,不按“晚了几秒”或任意过期时间判断。恢复事件获准自动触发须先提交 G0 平台证据并取得用户批准;停用期间与启用前事件始终禁发。 +- 无法证明恢复连续性时展示数据缺口,从重新确认的新边界开始;重复只更新原记录,不以重连成功声称无遗漏。队列溢出或解析失败也必须显示缺口/停止原因,不以丢弃事件后正常运行冒充完整。 +- 后台监听不依赖页面打开;平台→后台监听与后台→页面更新分别验收,只选一种适合现有服务的页面推送方式。时间证据见第 6.2 节。 -`pending → running → succeeded / failed / uncertain`,并支持明确的 `cancelled`;只有有证据的失败才允许重试。 +**策略与冷却**: -**工作内容**: +- 按 A3 管理同平台大小号归属、有序策略;四类大号收到的互动逐类验收,每次选第一条匹配且可用的策略,由一个小号执行一个动作。开始前不可用可检查下一条并记录原因;一旦开始不换号、换动作或自动重试。 +- 五类动作是私信、回复评论、点赞评论/作品、关注互动用户、转发作品;点赞评论/作品分别验证。所有自动动作都要求互动者稳定 UID 和对应目标,不猜目标。未批准的事件和私信新消息不得触发自动聊天。 +- 冷却范围固定为“平台+大号+互动用户 UID”,跨该大号所有小号、事件和动作共享;默认 24 小时且必须为正数。从选中小号、开始执行时原子占用,不能被并发穿透。 +- 冷却及原到期时间持久保存,重启、重连、策略启停不能清空;失败、AI 失败、结果不明均保留已占名额,不换小号补发。修改冷却只影响新响应;到期仅允许新事件,人工发送例外。 +- 文本按 A4 从非空候选中随机选择;候选为空才按大号统一要求调用 AI,不额外加人工审批。AI 失败、空内容或超长停止本次响应,不隐藏兜底、不截断强发。 +- 执行前复核账号、关系、策略和目标,并复用 G1.4 的同账号协调。停用大号、策略、调整关系或账号状态变化应阻止未开始动作;恢复正常不自动重启受影响策略。 -- 下载任务支持进度、暂停、恢复、取消、重试和历史查看; -- 每一步保存输入作品、输出文件、尝试次数和实际错误; -- 下载完成后再进入提音轨和转写,不用空文件或 Mock 数据继续下游; -- 供应商响应超时或写入结果无法确认时标记 `uncertain`,交由人工处理; -- 成功证据至少包含输出文件存在性和可读取性,必要时保存校验值。 +**完成标准**:AC-A4、AC-A5、AC-A6、AC-A7、AC-A8、AC-A9、AC-A10、AC-A11、AC-A12、AC-A13、AC-A14、AC-B1、AC-B2、AC-B3 全部通过。包括跨小号/跨动作并发、人工/自动竞争、冷却到期旧事件、重启、停用再启用及不明结果核验;不能以一次私信成功代替全部事件和动作。 -**完成标准**: +### G1.6:抖音私信及完整回归 -- 真实素材可以从作品进入下载、提音轨和转写; -- 失败重试不会重复污染结果; -- 结果不明不会被显示为成功。 +**入口**:G1.1 至 G1.5 相关能力通过;完整私信历史范围和非文本事件能力已核实。 -### 阶段 5:通知、评论线索和私信动作 +**工作**:复用工作台会话列表与消息详情,按所选已登录平台账号隔离消息、草稿与迟返请求;账号/会话切换提示未保存草稿。新消息仅更新展示,历史按平台可提供范围读取;非文本显示类型,断连可见。人工文本发送复用 G1.4,不自动聊天、不增加群聊或附件功能。 -**目标**:将通知和评论转化为可确认、可追踪的业务线索。 +**完成标准**:AC-M1、AC-M2、AC-M3、AC-U5 通过;G1 所有片段及 `plan01` 第 8 节**全部抖音适用验收项**有通过证据后,才称抖音完整完成。局部界面、记录或阶段通过不能代替业务闭环;失败/阻塞项须解决或取得明确范围变更批准。 -**工作内容**: +### G2 / G3:小红书与整体回归 -- 将点赞、评论、关注、@和私信统一成事件列表; -- 每条事件显示账号、来源、目标作品、对方 UID、发生时间和处理状态; -- 支持从事件直接打开作品、查看评论上下文或进入私信; -- 评论回复和私信发送沿用人工确认流程; -- 自动回复只处理经过策略授权的真实新事件,不能处理历史基线事件; -- 发送成功、失败和结果不明均保存平台证据。 +抖音完整验收通过后,小红书先独立重复 G0,再按 G1.1 至 G1.6 同范围实现和验证,不直接假定抖音事件/动作模型已证明小红书能力。差异和删项须用户批准。G3 验证两平台共存、账号隔离、失败恢复及无重复发送;全部适用项有证据,未通过项清零或获明确需求调整后才整体交付。 -**完成标准**: +## 5. 界面与证据展示 -- 从真实通知或私信事件到人工确认发送可以完整追踪; -- 事件去重、UID 冷却和账号切换保护有效; -- 不因通知轮询或页面刷新产生重复动作。 - -### 阶段 6:AI 建议和生产配置 - -**目标**:在真实事件和人工确认稳定后,再引入 AI 辅助。 - -**工作内容**: - -- 支持供应商、模型、提示词和超时配置; -- AI 默认生成建议,不默认代表已发送; -- 对建议保存输入事件、上下文、模型配置和最终人工修改; -- 供应商失败时直接暴露错误,不静默切换或伪造回复; -- 不采用“伪装成人工、否认 AI 身份”等提示词; -- 生产配置由控制面管理,凭据不进入前端日志、事件证据或普通租户数据。 - -**完成标准**: - -- AI 建议和平台写操作可以分别验收; -- AI 失败不会阻断人工处理,也不会被包装成成功; -- 配置、权限和审计边界符合 CreatorHub 的控制面设计。 - -### 阶段 7:小红书扩展 - -抖音真实流程、证据和失败处理完成后,再复用已经验证的事件、动作和任务模型实现小红书。不得因为 `better-douyin` 同时覆盖多个页面,就提前复制未经验证的平台适配层。 - -## 5. CreatorHub 与参考项目的差异化要求 - -| 参考项目做法 | CreatorHub 要求 | -| --- | --- | -| 公开 shell 使用 mock bridge | 真实平台能力必须有独立验收证据 | -| 通知可通过轮询触发自动化 | 真实事件监听为主,轮询只能用于恢复或健康检查 | -| 自动评论、私信、关注统一配置 | 自动策略与人工确认严格分离 | -| 下载任务有基础状态 | 增加结果不明、证据、真实输出和下游处理状态 | -| 本地 MCP 配置 | 使用远程 Web/控制面,不新增本地 MCP | -| AI 可配置自动动作 | 默认先生成建议,写操作需按策略或人工确认 | -| 项目许可证允许非商业使用 | CreatorHub 独立实现,不直接复制代码和资源 | +- 沿用 `plan01` 第 7.4 节页面地图;竞品、作品、素材、账号策略、评论线索、私信和任务各自保留明确职责,不另造统一推荐流或自动化大屏。 +- 按来源、平台、账号、规则/策略、动作状态查询;人工与自动明确标识,目标、实际文本、执行号、冷却、错误和证据可查看。历史通知和私信展示不意味着授权自动发送。 +- 业务结果可跳转任务和原作品/评论/会话;失败及结果不明不被列表隐藏,结果不明不显示重新发送按钮。自动冷却不能禁用合法人工发送。 +- 加载、空、错误、禁用状态可区分;失败保留输入和已有数据。按 AC-U1 至 AC-U6 验证确认/取消、返回筛选分页、草稿切换、键盘与焦点,不以截图替代交互检查。 ## 6. 验收与回归要求 ### 6.1 自动检查 -按照受影响服务执行现有仓库门槛: +按受影响范围执行 [AGENTS.md](../AGENTS.md) 的现有门槛,不以本文缩减检查: -- Go 控制面:`go test ./...`、`go vet ./...`、构建目标程序; -- 有并发、生命周期或共享状态变化时:`go test -race ./...`; -- Python gateway:非交互式单元测试、覆盖率检查、Compose 健康检查; -- 前端:使用 lockfile 安装,执行仓库测试和 `npm --prefix web run build`; -- 单元测试覆盖率保持 65% 以上。 +- 非平凡行为先写在未修正实现时会失败的回归测试;单元测试覆盖率至少 65%。 +- Go:`go test ./...`、`go vet ./...`、`go build ./cmd/control-plane`;并发、生命周期或共享状态变化另执行 `go test -race ./...`。 +- Python gateway:`python3 -m unittest discover -s cmd -p 'test_*.py'`、排除测试代码的同次覆盖率报告、Compose 构建及健康检查。 +- Docker/Compose 变化另执行 `docker compose config --quiet`;不能用旧容器健康代替本次镜像构建和运行结果。 +- 前端:从 lockfile 安装,执行仓库非交互测试和 `npm --prefix web run build`;工作台、动作、布局等变化补相应错误、禁用、确认/取消及重复提交交互检查。 +- 记录实际执行与跳过项;仅修改本计划时检查本地链接、AC 编号、阶段对应及 `git diff --check`,不宣称通过上述代码或平台检查。 -### 6.2 真实平台验收 +### 6.2 真实平台证据与时限 -每个真实写操作至少记录: +每个平台按 `plan01` 第 8.7 节逐分项记录能力与限制,按第 8 节 AC 保存输入、操作、预期、实际及脱敏证据。至少使用一个大号、两个小号及可控互动账号;只读样本与写入样本分开授权,不向无关用户发送测试消息。 -- 测试时间、平台、账号和目标 UID/作品; -- 触发事件及其唯一身份; -- 发送或操作内容; -- 平台页面、响应、状态或其他可复核证据; -- 最终分类:成功、失败或结果不明; -- 是否发生重试、断线恢复或账号切换。 +每次真实写操作至少记录: -离线测试、Mock bridge、截图占位和本地假数据只能证明界面或流程,不得作为真实平台成功证据。 +- 平台、接收账号/执行账号、目标 UID 及作品/评论/会话标识; +- 自动来源的稳定事件身份、边界分类、策略及冷却占用;人工来源的持久操作标识和确认记录,不强造一个平台触发事件; +- 实际内容、开始/结束时间、平台响应或页面证据、成功/失败/结果不明分类; +- 断连恢复、会话代际、账号切换及实际尝试情况;不明结果的核验记录、新确认操作与原操作的关联; +- 不记录密码、身份证、Cookie、代理凭据或无关完整私信。 + +监听与页面时限另记录**平台事件时间(若提供)、系统接收、处理开始、已打开页面展示、动作结束**,以及页面打开/连接状态: + +- 监听正常时,系统接收至处理开始不超过 **5 秒**;相关页面已打开且连接正常时,接收至页面可见不超过 **30 秒**。不把 AI 或动作完成误作 30 秒承诺。 +- 平台时间缺失则来源延迟记“不可测”,不填 0;后台处理快不证明平台投递及时。 +- 关闭页面仍须后台监听;页面断连显示状态,离线/关闭不参与页面准时判定,也不标为准时通过;重开先读取持久结果再接更新。分别覆盖 AC-A13、AC-B3,并与 AC-B1/B2 的恢复、并发证据关联。 + +离线测试、Mock、任务入队、HTTP 200 和截图占位不能作为真实平台成功证据;供应商不可用、能力不支持或样本未批准均不得填写通过。 ## 7. 风险与处理 -- **平台协议变化**:适配层失败必须显式暴露,并保留足够日志;不使用静默降级。 -- **事件重复或延迟**:依靠账号范围、事件身份、时间窗口和基线处理;不靠扩大重试次数掩盖问题。 -- **写操作结果不确定**:进入人工复核队列,不自动重拨或重复发送。 -- **账号身份错误**:写操作前再次核对当前账号、窗口和会话代际。 -- **许可证风险**:只参考公开行为和组织方式,所有实现、文案和资源独立完成。 -- **公开项目可信度不足**:其 mock 实现和缺失的检查脚本不纳入 CreatorHub 的质量证明。 +- **平台能力或协议变化**:显式失败、保留原因和数据缺口;不能用固定轮询、猜测身份或静默降级冒充真实监听。 +- **事件重复与恢复**:依靠跨会话稳定身份、持久基线及永久最小去重;恢复事件默认只展示,不扩大重试次数或任意时间窗口补发。 +- **错误账号或重复发送**:写入前重核、同账号协调、人工持久操作标识及自动共享冷却共同验证,不只检查界面按钮。 +- **媒体/AI 依赖缺失**:相关阶段入口保持阻塞,保留成功产物与具体失败步骤;不跳过人工确认、样本批准或真实输出核验。 +- **来源与许可未核实**:第 8 节仅保留待核实链接;不得复制资产,也不以未经核实的上游优缺点证明本项目质量。 -## 8. 参考文件 +## 8. 待核实参考文件 - [better-douyin README](https://github.com/anYuJia/better-douyin/blob/f534c66d61f541a526fa3f5251d32494aad758a8/README.md) - [better-douyin LICENSE](https://github.com/anYuJia/better-douyin/blob/f534c66d61f541a526fa3f5251d32494aad758a8/LICENSE) diff --git a/docs/e2e-test-plan.md b/docs/e2e-test-plan.md index e58bf91..3427b6d 100644 --- a/docs/e2e-test-plan.md +++ b/docs/e2e-test-plan.md @@ -1387,6 +1387,8 @@ OP-08的UI注入在浏览器Console执行 `Object.defineProperty(crypto, 'random | GET `/api/creator/accounts/{A}/profile` | 无;不是 `/accounts/{A}` | UI、STR-01 | | PUT `/api/creator/accounts/{A}/profile` | E-profile | UI、STR-01/07 | | POST `/api/creator/accounts/{A}/login-result` | `{"status":"manual_required","reason":"RUN需账号所有者人工登录","actual_platform_account_key":""}`;仅negative状态;logged_in用于拒绝验证 | **API-only**、AC-06、STR-07 | +| POST `/api/creator/accounts/{A}/verify` | 无;核对已人工登录的受管浏览器身份 | UI、AC-06 | +| GET `/api/creator/listeners` | `?account_id=A` | UI、EVT-01/02 | | POST `/api/creator/accounts/{A}/big-account` | `{"enabled":true}` / false | UI、STR-03 | | GET `/api/creator/relations` | `?big_account_id=A` | UI、STR-03 | | POST `/api/creator/relations` | `{"big_account_id":"A","small_account_id":"S1","enabled":true}`;false解除 | UI、STR-03/05 | @@ -1430,7 +1432,7 @@ E-strategy: | POST `/api/creator/competitors/{C}/resume` | 无 | UI、COL-02 | | POST `/api/creator/competitors/{C}/sync` | `{"account_id":"A"}` | UI、COL-02/03/09 | | GET `/api/creator/works` | `?platform=douyin&source_type=competitor&source_id=C&published_after=2026-09-01T00%3A00%3A00Z&published_before=2026-09-14T00%3A00%3A00Z&min_likes=0&min_comments=0&min_shares=0`;时间换本轮窗口 | UI仅平台/计数,其余**API-only筛选**,COL-07 | -| POST `/api/creator/works` | F-work;仅API补充数据 | **API-only导入**、API-01 | +| POST `/api/creator/test/works` | F-work;仅隔离测试补充数据 | **API-only导入**、API-01;生产入口拒绝客户端伪造 | | GET `/api/creator/works/{W}` | 无 | **API-only详情**、COL-07 | | GET `/api/creator/works/{W}/metrics` | 无 | **API-only快照**、COL-08、API-02 | | POST `/api/creator/works/{W}/metrics` | `{"collected_at":"2026-09-13T12:00:00Z","likes":0,"comments_count":null,"shares":1}`;当轮时间 | **API-only导入**、API-02 | @@ -1438,9 +1440,10 @@ E-strategy: | POST `/api/creator/works/{W}/material/select` | 无 | UI、MAT-01 | | POST `/api/creator/works/{W}/material/process` | 无;会真实下载/处理文件 | UI、MAT-02~04 | | POST `/api/creator/works/{W}/material/rewrite/confirm` | `{"requirement":"RUN保留事实,使用测试口播风格"}` | UI、MAT-05/06 | +| POST `/api/creator/works/{W}/material/rewrite/generate` | 无;需 AI 设置已批准 | UI、MAT-05/06 | | PUT `/api/creator/works/{W}/material/rewrite` | `{"title":"RUN人工编辑标题","script":"RUN人工编辑口播正文"}` | UI、MAT-05/06 | | GET `/api/creator/comments` | `?platform=douyin&work_id=W` | UI、LEAD-03/07 | -| POST `/api/creator/comments` | F-comment | **API-only导入**、API-03 | +| POST `/api/creator/test/comments` | F-comment | **隔离测试导入**、API-03;生产入口拒绝客户端伪造 | | GET `/api/creator/comments/{K}` | 无 | **API-only详情**、LEAD-07 | | GET `/api/creator/rules` | 无,或 `?enabled_only=true` | UI及**API-only启用过滤**、LEAD-01/07 | | POST `/api/creator/rules` | F-rule | UI、LEAD-01 | @@ -1450,10 +1453,10 @@ E-strategy: | POST `/api/creator/rules/{R}/disable` | 无 | UI、LEAD-02 | | GET `/api/creator/rule-results` | `?comment_id=K&rule_id=R` | **API-only结果详情**、LEAD-04/07 | | GET `/api/creator/leads` | `?platform=douyin` | UI、LEAD-04/06 | -| POST `/api/creator/comments/{K}/analyze` | `{"rule_id":"R"}` | UI、LEAD-03~05 | +| POST `/api/creator/comments/analyze` | `{"comment_ids":["K1","K2"],"rule_id":"R"}` | UI批量分析、LEAD-03~05 | | GET `/api/creator/events` | `?account_id=A` | **API-only**、EVT、API-04 | -| POST `/api/creator/events` | F-event | **API-only导入**、API-04;不证明监听 | -| POST `/api/creator/events/process` | F-event;可触发真实写,API补充仅baseline/无启用策略账号 | **API-only**、API-04 | +| POST `/api/creator/test/events` | F-event | **隔离测试导入**、API-04;不证明监听 | +| POST `/api/creator/test/events/process` | F-event;仅隔离测试,可触发真实写 | **隔离测试**、API-04;不证明监听 | | POST `/api/creator/events/{event_id}/display` | 无 | **API-only**、API-04;不作为实际页面可见证据 | | GET `/api/creator/operations` | `?account_id=S1` | UI、OP-08 | | POST `/api/creator/operations` | F-operation | UI回复/私信;其他动作**API-only**、OP-01~07 | @@ -1461,7 +1464,7 @@ E-strategy: | POST `/api/creator/operations/{operation_id}/execute` | 无;仅在逐次确认/已批准API动作后执行 | UI或API、OP-01~07 | | GET `/api/creator/conversations` | `?account_id=A` | UI、OP-02 | | GET `/api/creator/conversations/{conversation_id}/messages` | 无 | UI、OP-02/03 | -| POST `/api/creator/messages` | F-message;仅存储,不发平台消息 | **API-only导入**、API-05 | +| POST `/api/creator/test/messages` | F-message;仅隔离测试存储,不发平台消息 | **隔离测试导入**、API-05 | F-competitor(主页与标识换为C真实资料;这里不是一键解析真实身份): diff --git a/docs/research/xhs-all-in-one.md b/docs/research/xhs-all-in-one.md new file mode 100644 index 0000000..b263770 --- /dev/null +++ b/docs/research/xhs-all-in-one.md @@ -0,0 +1,388 @@ +# `XHS_ALL_IN_ONE` 小红书能力吸收与落地研究 + +> 研究日期:2026-09-14 +> 上游仓库: +> 研究快照:`e86f82b`(`master`) +> 研究方式:只读检查源码、提交历史与公开 Issues;未向小红书真实账号发起请求,以下不等同于平台能力验收。 +> 当前结论:本文仅形成小红书后续实现的研究约束,尚未接入运行代码;未复制上游代码、签名脚本、Cookie 或指纹数据。 + +## 1. 结论先行 + +这个仓库最值得 CreatorHub 吸收的不是它的逆向接口代码,而是它暴露出来的**平台适配边界、请求状态分类、数据标准化和失败场景**。 + +| 判断 | 结论 | +| --- | --- | +| 能否直接作为 CreatorHub 的小红书 SDK | **不能**。它依赖 Web 私有接口、动态签名、设备/浏览器状态和服务端下发程序,且公开 Issues 持续出现 406、461、登录失败和账号封禁反馈。 | +| 能否吸收工程做法 | **可以**。重点吸收适配器分层、原始响应保留、`xsec_token/xsec_source` 端到端保留、评论父子关系、媒体处理分步状态和登录态健康检查。 | +| 能否直接复制签名/指纹/`curl_cffi` 方案 | **暂不可以**。这属于高维护、强平台耦合和潜在合规风险路径;先取得真实平台能力证据,再决定是否需要平台专用实现。 | +| 能否证明小红书已经支持 CreatorHub 要求的事件监听 | **不能**。上游主要是主动请求式读取和发布,没有解决“自有账号互动/私信事件监听、断连恢复、稳定事件 ID”的问题。 | +| 能否直接复制上游代码 | **当前不能确认**。README 声称 MIT,但 GitHub API 的 `license` 为 `null`,研究快照中也没有可读的 `LICENSE` 文件;在许可证和逆向产物来源确认前不复制代码。 | + +**推荐落地顺序:**先做小红书只读能力和证据链,再做素材处理;写操作和事件监听分别独立验收,不能因为上游有发布接口就宣称 CreatorHub 已具备发布或自动响应能力。 + +## 1.1 当前实现边界 + +- CreatorHub 当前只保留平台枚举和通用 Creator 数据模型;小红书专用 collector、gateway 路由和动作适配器未接入。 +- `internal/creator/collection.go` 的分页、窗口、checkpoint 和 lease 模型可作为后续适配的复用边界,但不能证明小红书平台能力。 + +**未完成的真实能力验收:**私有接口签名是否能由浏览器当前会话完成、真实 UID/作品/评论分页、媒体下载、写操作和事件监听仍需真实小红书环境分别验证。HTTP 200、离线 fixture 和本地单元测试不能替代这些证据。 + +## 2. 上游实际做了什么 + +### 2.1 已能从源码确认的能力 + +| 能力 | 上游实现 | 对 CreatorHub 的价值 | 证据 | +| --- | --- | --- | --- | +| PC 端搜索笔记 | 组装搜索请求、分页参数、排序和筛选,使用 `/api/sns/web/v2/search/notes` | 可作为小红书只读采集字段和分页适配的参考 | `apis/xhs_pc_apis.py`:`search_note`、`search_some_note` | +| 笔记详情 | 从链接提取 note ID、`xsec_token`、`xsec_source`,调用 feed 详情接口 | 提醒我们不能只保存 note ID;分享链接中的访问上下文也可能是请求材料 | `apis/xhs_pc_apis.py`:`get_note_info`;`backend/app/api/platforms/xhs/pc.py`:`_note_url`、`_normalize_detail_payload` | +| 用户作品读取 | `user_posted` 分页读取用户作品 | 可作为竞品账号“按主页读取作品”的候选平台能力 | `apis/xhs_pc_apis.py`:`get_user_note_info`、`get_user_all_notes` | +| 一级/二级评论 | 分别调用 comment page 和 sub-comment page,后端递归扁平化并保留父评论 ID | 可吸收评论层级模型;CreatorHub 当前需求首先要求一级评论,但应保留父子关系扩展位 | `apis/xhs_pc_apis.py`:`get_note_out_comment`、`get_note_inner_comment`;`backend/app/api/platforms/xhs/pc.py`:`_flatten_comments` | +| 图文/视频媒体上传 | 先申请上传许可,再向 `/spectrum/{file_id}` 上传;视频返回 `X-Ros-Video-Id` | 上传许可、token、过期时间和实际上传结果应分步记录 | `apis/xhs_creator_apis.py`:`get_fileIds`、`upload_media` | +| Creator 发布 | 话题/地点查询、媒体上传、图文发布;视频还涉及封面和转码查询 | 说明写操作不是一个 HTTP 请求,需要独立状态和结果证据 | `apis/xhs_creator_apis.py`:`get_topic`、`get_location_info`、`post_note` | +| 登录 | 支持 Cookie、二维码和手机号路径,维护多域 Cookie 和安全初始化 | 可借鉴登录态与业务账号状态分离,以及登录后再次调用 user-info 验收 | `xhs_utils/xhs_pc/auth.py`、`apis/xhs_pc_login_apis.py`、`apis/xhs_creator_login_apis.py` | +| 监控/定时/草稿 | 有关键词、账号、品牌、笔记 URL 监控、快照、定时发布和 AI 草稿 | 可作为产品功能的反例和字段参考;不能当作已验证生产能力 | `backend/app/api/platforms/xhs/monitoring.py`、`backend/app/services/scheduler_service.py` | +| 原始响应与标准字段并存 | 标准化标题、正文、作者、媒体、指标,同时保留 `raw`/`raw_json` | 适应平台字段变化时可重新解析历史数据,不必再次请求 | `backend/app/api/platforms/xhs/pc.py`、`backend/app/api/platforms/xhs/crawl.py` | + +### 2.2 README 宣称但不能直接视为已实现 + +上游 README 的完整功能清单很大,包含数据采集、内容管理、AI 改写、图片处理、发布、定时和多账号健康检查。但代码中存在明显的 demo/placeholder: + +- `backend/app/api/platforms/xhs/pc.py` 的 `search_users()` 返回固定 `demo-user`。 +- 同文件的 `user_notes()` 返回 `sample_notes()`,不是用户作品真实读取。 +- `homefeed_channels()` 和 `homefeed_recommend()` 返回固定示例。 +- 监控、定时、AI 等业务接口存在,但不等于真实平台链路已成功,也不等于数据持续更新正确。 + +因此本研究把“README 功能清单”标为**文档宣称**,把上表源码路径标为**源码存在**;只有真实账号、真实响应和失败恢复证据才能升级为 CreatorHub 的“已支持”。 + +## 3. 上游已经暴露的关键坑 + +### 3.1 请求签名是动态协议,不是一个固定函数 + +源码同时维护: + +- 本地生成的 `a1`、`b1`、`X-s`、`X-t`、`X-S-Common`、trace ID、`xy-direction`、搜索 ID; +- 服务端下发或依赖会话的 `_dsl`、`websectiga`、`sec_poison_id`、`gid`、登录 Cookie; +- PC 与 Creator 分开的 RAP 指纹模板; +- MNS 分档、会话计数、设备状态、Cookie 域和请求头顺序; +- `curl_cffi` Chrome impersonation、HTTP/2 和关闭默认 headers 的传输行为。 + +对应源码: + +- `xhs_utils/xhs_pc/auth.py` +- `xhs_utils/xhs_pc/state.py` +- `xhs_utils/xhs_pc/params.py` +- `xhs_utils/xhs_core/http.py` +- `xhs_utils/xhs_creator/auth.py` +- `xhs_utils/xhs_creator/params.py` + +**可吸收的原则:**把每个请求材料标注为“本地可计算、服务端签发、用户交互、会话状态”四类;服务端签发材料不能当静态配置,登录 Cookie 不能当作永久登录证明。 + +**不应吸收的做法:**把逆向签名、指纹模板或固定 Chrome 版本包装成长期稳定 SDK。它们应被视为平台专用、可替换、需要真实证据的适配层。 + +### 3.2 PC 和 Creator 是两套不同链路 + +上游同时维护 `xhs_pc_*` 与 `xhs_creator_*` 两套认证、请求参数、Cookie 域、RAP 模板和业务 API。Creator 发布请求使用独立指纹模板;PC homefeed 使用的模板不能直接拿去发布,否则会被服务端以参数错误拒绝。 + +**对 CreatorHub 的约束:** + +- `platform=xhs` 不足以决定适配方式,还要区分读取/登录上下文和 Creator 写入上下文。 +- 不复用“PC 读取成功”作为 Creator 发布成功依据。 +- 账号登录状态、业务账号状态和浏览器环境状态必须分别保存。 + +### 3.3 `_dsl`、安全 Cookie 和设备状态有时序要求 + +Creator 登录实现明确分为匿名会话、honeypot、DS 程序、安全 Cookie、`websectiga`、`gid`、CAS 二维码/手机号登录和最终 user-info 验收。部分 `_dsl` 由服务端拉取并短时缓存;代码还记录不同 CDN 节点可能导致时间锚点不同。 + +这说明以下做法不可靠: + +- 用本机时间简单替代服务端时间锚点; +- 只保存 `a1` 或网页 `document.cookie`; +- 只判断二维码接口 HTTP 200 就认为登录完成; +- 把一个匿名设备会话的安全材料复制给另一个账号。 + +### 3.4 406 不是普通网络失败,重试边界取决于会话 + +上游源码区分了两类 406: + +- 某些 customer 域请求被记录为按请求概率拒绝,可以在同一会话内有限重发; +- Creator 登录/发布场景的另一些拒绝会标记整个匿名设备会话,需要重建 `a1/webId`、Cookie store 和连接,而不是重复发送原请求。 + +公开 Issue 也说明该判断会随服务端变化: + +- [Issue #27:发布接口 HTTP 406](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/27):同一会话重新生成参数仍连续失败,作者怀疑 Creator 指纹模板过期; +- [Issue #24:Creator 二维码/验证码 406](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/24):登录入口持续 406。 + +**CreatorHub 做法:**只对已证明是安全的、幂等的读取请求使用有限重试;写操作遇到 406 不得用重试掩盖不确定结果。已发送但缺乏确认时标为 `uncertain`,不自动补发。 + +### 3.5 461 可能来自流程变化,不一定是账号失效 + +[Issue #25](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/25) 记录:PC 二维码登录的 `/api/sns/web/v1/login/activate` 稳定返回 HTTP 461;前置安全接口均为 200。同一机器在旧提交 `2819d74` 中通过 `webprofile` 获取 `gid` 的流程可以继续生成二维码,算法更新后反而失败。 + +**可吸收的排查方式:**保留“最后成功提交/算法版本、请求阶段、HTTP 状态、响应 code、Cookie key、是否已拿到 gid”的结构化诊断,不把 461 直接归类为 Cookie 过期或账号封禁。 + +### 3.6 发布 payload 的微小差异会造成业务错误 + +`apis/xhs_creator_apis.py` 记录了几个具体失败点: + +- 没有地点时必须省略 `common.post_loc`,发送空对象 `{}` 可能返回 `-9059`; +- 话题需要先查到平台对象,不能只把用户输入字符串塞进 payload; +- Creator 发布需要 Creator 专用 `x-rap-param` 指纹; +- 视频上传成功后还涉及封面、异步转码和发布时机。 + +这类坑值得吸收为**契约测试和失败证据**,不值得吸收为“万能兼容字段清洗”。平台响应字段变化时应让适配器明确失败并记录原始响应。 + +### 3.7 视频转码失败不应被当作成功前置条件 + +上游视频发布分支在转码查询不可用时会 warning,然后直接继续发布;这是该仓库的取舍,不适合 CreatorHub 当前的结果模型。CreatorHub 已要求区分成功、失败和结果不明,不能把“转码状态无法确认”转换成“可以继续发布”。 + +### 3.8 上游的分页循环缺少 CreatorHub 所需的完整边界 + +上游的 `get_user_all_notes()`、评论聚合等循环主要依赖 `has_more` 和返回 cursor,源码没有统一的重复 cursor 检测、最大页数和 checkpoint/lease 机制。 + +CreatorHub 已有更严格的 `internal/creator/collection.go`: + +- cursor 缺失、重复、不前进会失败; +- 最大 100 页硬边界; +- 采集窗口固定,不因分页时间变化而滑动; +- 有 checkpoint、lease 和中断后续采基础。 + +**结论:**分页保护直接复用 CreatorHub 现有模型,不复制上游的简单 `while True`。 + +### 3.9 媒体下载实现过于宽松 + +`backend/app/services/asset_downloader.py`: + +- 仅检查 URL 前缀、HTTP 状态和内容长度; +- 使用空 `Referer`; +- 不验证响应 MIME 与实际文件类型; +- 不限制文件大小、不做断点、不保留明确失败状态; +- 异常统一 warning 后返回 `None`,上层仍可能把笔记保存成“成功”。 + +**CreatorHub 不采用:**下载失败静默变成空路径。使用现有 `internal/creator/creator_material.go` 的分步状态、原子文件、大小限制、HTML 拒绝和失败步骤记录。 + +### 3.10 账号风控风险是真实运营风险 + +公开 [Issue #13](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/13) 及评论中,多名用户反馈使用工具发布后出现脚本操作警告、封禁;反馈包括视频发布后封号和“贵重账号慎用”。这些是用户报告,不是平台官方因果证明,但足以作为高风险信号。 + +CreatorHub 不应承诺“稳定”“不封号”或“无法识别”,也不应通过更换指纹、随机重试或隐藏错误来规避风控。真实写操作必须: + +1. 使用者明确授权; +2. 登录环境和账号身份再次核对; +3. 目标、文案、媒体和平台返回证据可追溯; +4. 失败/不确定停止,不自动换账号、换代理或补发。 + +### 3.11 前端 refresh 递归和部署依赖也值得记录 + +- [Issue #23](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/23):前端 401 拦截器把 `/auth/refresh` 自身再次送入 refresh,导致登录页无限 checking。通用经验是 refresh 请求必须有明确标记,失败只清理一次并回到登录页。 +- [Issue #22](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/22):Debian Trixie 移除了 `libgl1-mesa-glx`,Docker 构建失败。不要固定过时系统包名,镜像构建要在当前基础镜像上实际验证。 + +这两项不是 CreatorHub 当前小红书业务核心,但可作为前端会话恢复和 Compose 构建检查的回归用例。 + +## 4. 对 CreatorHub 的吸收清单 + +### 4.1 现在就吸收:不增加平台耦合 + +| 吸收项 | CreatorHub 落点 | 具体要求 | +| --- | --- | --- | +| 平台适配器与业务逻辑分离 | 新增小红书适配时沿用 `internal/douyin` 的边界,业务层不直接拼平台请求 | 读取、登录、媒体、动作、事件各自有清晰接口;不把上游 Python SDK 直接嵌进控制面 | +| 原始响应 + 标准字段 | `internal/creator` 的 Work/Comment/Material 数据 | 保存原始平台 payload、来源链接、采集时间和标准化结果;字段变化先保原文再调整解析 | +| `xsec_token`/`xsec_source` 端到端保留 | 作品来源/平台适配输入模型 | 详情链接、搜索结果和后续详情请求不得丢失访问上下文;没有 token 时不得猜测或拼造 | +| 评论父子关系 | Comment 入库字段扩展时保留 `parent_comment_id` | 当前业务先采一级评论,平台返回子评论时不丢弃层级;不要把昵称当用户 ID | +| 指标三态 | Work 指标和采集报告 | `0` 只表示平台明确返回 0;缺失使用“不可获取/待采集”,不把缺失填成 0 | +| 分页安全 | 直接复用 `internal/creator/collection.go` | 统一做 cursor 缺失、重复、不前进、页数上限和 checkpoint/lease,不复制上游无限循环 | +| 媒体分步处理 | `internal/creator/creator_material.go` | 下载、提音、转写、仿写分别记录状态、产物、失败步骤;不把整条任务压成一个 boolean | +| 登录态健康检查 | 账号登录状态与动作前身份核对 | Cookie 存在不等于登录有效;每次写操作前核对实际 UID、环境和目标 | +| 证据分级 | `ActionResult`、Task、E2E evidence | 区分源码存在、接口返回、业务确认、失败、结果不明;HTTP 200 不自动算成功 | +| 领域错误可观测 | 网关/控制面边界 | 记录平台、账号、环境、请求阶段、外部状态、业务 code、响应摘要和 trace;不在底层静默吞异常 | + +### 4.2 只能带着改造吸收 + +| 上游做法 | 改造后才可用 | 原因 | +| --- | --- | --- | +| `curl_cffi` Chrome impersonation | 仅在真实平台证据证明普通浏览器网关无法满足时评审引入 | 新增依赖和维护面大,不能先把私有签名客户端当默认架构 | +| `_dsl`/`websectiga` 缓存 | 设计为有来源、TTL、版本和失效原因的短生命周期运行时材料 | 服务端程序可能轮换,不能写死或永久缓存 | +| 406 有限重试 | 只用于已证明幂等且不会产生写副作用的阶段 | 写操作重试会造成重复发布或掩盖不确定结果 | +| Creator 上传许可 | 建立上传许可、过期时间、上传结果、媒体校验的状态机 | token 过期和上传成功不是同一状态 | +| 视频封面/转码查询 | 转码未确认时状态为 `pending/uncertain`,禁止自动跳过门禁发布 | 上游“warning 后继续”不符合 CreatorHub 结果约束 | +| 监控/定时服务 | 使用 CreatorHub 既有计划、lease、任务和证据模型 | 上游 scheduler 在应用进程内运行,容易重复执行和丢失任务边界 | + +### 4.3 明确不吸收 + +- 复制或改造上游逆向签名 JavaScript、RAP 指纹模板、设备指纹模板、`websectiga` 执行器。 +- 固定 Chrome/Web build、固定 MNS 环境值,并把它们当作永久配置。 +- 通过重试 406/461 规避平台拒绝。 +- 把 Cookie 导入当作 CreatorHub 的生产登录方案;当前需求是用户在可见环境人工登录,系统核对身份。 +- 用普通翻页 cursor、昵称、头像、时间或内容哈希猜事件唯一 ID。 +- 用上游主动读取接口代替自有账号互动/私信事件监听。 +- 采用空 Referer 下载、内容长度阈值代替媒体真实性校验。 +- 采用上游固定 demo 返回或 README 清单作为功能回退。 +- 直接采用 Ant Design、`lucide-react` 等前端体系;CreatorHub 保持 shadcn/ui、Tailwind 和 RemixIcon。 +- 直接把上游 AI 自动改写、随机选笔记和定时发布流程当作本期业务。 + +## 5. 与当前 CreatorHub 的对照 + +### 5.1 已有基础,应直接复用 + +- `internal/creator/models.go`:已有平台、作品、评论、素材、事件、策略和动作结果模型。 +- `internal/creator/collection.go`:已有分页边界、固定 UTC 窗口、checkpoint、lease 和续采。 +- `internal/creator/content.go`:已有竞品导入、启停、作品 upsert 和来源关联。 +- `internal/creator/actions.go`:已有事件永久去重、策略顺序、同 UID 冷却,以及 AI 失败/结果不明不补发。 +- `cmd/control-plane/creator.go`:已有写操作前身份核对、目标映射和结果证据保存。 +- `cmd/control-plane/creator_material.go`:已有人工选取、原子媒体文件、大小/HTML 校验、ffprobe/ffmpeg 和显式转写失败步骤。 +- `cmd/control-plane/creator_events.go` 与 `cmd/docker_gateway/douyin.py`:已有监听代际、baseline、gap、断连恢复、delivery ACK/retry 的抖音实现;小红书不能直接沿用为已验证能力。 +- `docs/plan01.md`:已明确先抖音后小红书、逐平台真实验收,以及读取/写入/事件的成功、失败、不明边界。 + +### 5.2 当前小红书缺口 + +当前已有小红书只读代码适配器和 gateway 路由,但尚未完成真实账号验收。不能因为 `platform=xhs` 可登记、代码测试通过或页面可打开,就宣称以下能力已经在生产环境可用: + +- 人工登录后的实际 UID 核对; +- 主页/分享链接解析和稳定账号标识; +- 作品和一级评论完整分页; +- 30 天窗口、checkpoint 续采和指标计划; +- 媒体下载、提音、转写; +- 评论、点赞、关注、转发、私信写操作; +- 自有账号互动和私信事件监听; +- 断连恢复、稳定事件 ID、baseline 和缺口证据。 + +### 5.3 上游没有解决、CreatorHub 仍必须独立验收 + +1. **事件监听**:上游的搜索/评论/主页接口是主动读取,不证明平台会推送稳定事件,也没有跨重启事件身份闭环。 +2. **目标身份**:读取到评论不等于能拿到可用于私信/关注的稳定互动者 UID。 +3. **公开数据完整性**:第一页或当前接口返回不等于最近 30 天完整作品/评论。 +4. **媒体地址寿命**:返回视频/图片 URL 不等于可下载、可长期复用或可提取音轨。 +5. **写操作结果**:上传成功、发布请求返回和平台实际可见是不同证据层级。 +6. **账号风险**:开源实现能跑不等于账号可安全长期运营。 + +## 6. 建议的落地阶段 + +### XHS-G0:冻结能力矩阵和证据格式 + +先按平台能力单独建立小红书矩阵,不复制抖音结论。每一项记录: + +- 账号/环境/代理/登录方式; +- 平台稳定 UID、作品 ID、评论 ID、互动者 UID 是否存在; +- 请求阶段、平台时间、系统接收时间、原始响应摘要; +- 成功、失败、结果不明或数据缺失; +- 是否可跨重连/重启恢复; +- 是否允许进入自动响应。 + +小红书事件如果无法证明稳定事件 ID、互动者 UID、目标作品/评论和恢复边界,自动响应保持阻塞。 + +### XHS-G1:只读连接器 + +只实现最小纵向切片: + +1. 人工登录并核对账号 UID; +2. 输入主页链接或作品分享链接,解析并要求用户确认; +3. 读取一页作品和详情; +4. 读取超过一页的作品和一级评论; +5. 保存原始 payload、原始链接、`xsec_token/xsec_source`(若平台提供)和标准字段; +6. 证明固定 30 天窗口、分页不重复、cursor 不前进会失败、重启可续采。 + +这一阶段不做签名引擎复制、不做自动登录、不做事件监听、不做自动动作。 + +### XHS-G2:素材处理 + +在 G1 取得真实可用媒体地址后,接入现有人工选取链路: + +- 下载原始媒体并做大小/MIME/文件真实性校验; +- 无视频、无音轨、无语音、下载失败分别记录; +- 提取音轨、转写、仿写分别产生状态和产物; +- AI 失败和转写失败不得用原文或空文本冒充成功。 + +### XHS-G3:人工写操作 + +仅在每个动作分别取得真实证据后实现平台专用 adapter: + +- 回复评论; +- 点赞评论/作品; +- 关注互动用户; +- 转发作品; +- 人工发送私信。 + +每次写入前再次核对登记账号 UID、当前浏览器身份、目标 UID/评论/作品;平台返回不完整或连接中断时保存 `uncertain`,不自动重试或换小号。 + +### XHS-G4:事件监听 + +上游仓库不能提供现成方案。需要单独研究小红书已登录页面/平台可观察事件: + +- 事件是否能由真实页面监听取得; +- 是否存在稳定事件 ID 或可证明的新旧边界; +- 互动者 UID、评论 ID、作品 ID 是否齐全; +- 断连和进程重启后能否恢复; +- 是否满足 5 秒开始处理、30 秒页面可见的时限。 + +未通过这些证据前,不启用小红书自动响应。 + +### XHS-G5:双平台回归 + +小红书独立通过 G0-G4 后,再做双平台共存回归:账号、Profile、代理、Cookie/登录态、事件、冷却、任务和证据不能串号;不能用上游 README 或离线 fixture 替代真实证据。 + +## 7. 第一批推荐实现项 + +| 优先级 | 项目 | 原因 | 验收门槛 | +| --- | --- | --- | --- | +| P0 | 小红书只读适配器 + 原始响应归档 | 价值最大、风险最低,能验证平台真实读取边界 | 真实账号 UID、详情、分页作品、一级评论、失败证据 | +| P0 | 作品/评论字段三态和 `xsec_*` 上下文保留 | 直接解决字段缺失、链接失效和后续重解析 | 缺字段不变 0;上下文可回放/诊断 | +| P1 | 竞品账号固定窗口采集 | 对应当前 C1/C2/W1 需求 | 最近 30 天、超过一页、重启续采、重复 cursor 失败 | +| P1 | 人工选取后的媒体处理 | 对应当前 C4,且不依赖自动写操作 | 下载/提音/转写逐步成功、失败、无内容证据 | +| P2 | 人工评论/私信写操作 | 先满足工作台最小闭环 | UID/目标/身份核对,成功/失败/不明三态 | +| P2 | 互动/私信事件监听 | 依赖平台真实事件身份和恢复能力 | 稳定事件 ID、baseline、重连/gap、时限证据 | +| P3 | 自动响应策略 | 只有事件、动作和账号隔离都通过后才有意义 | 永久去重、UID 冷却、AI 失败不补发、全量真实证据 | + +## 8. 研究后形成的明确决策 + +1. **把上游当作坑位和协议观察资料,不作为 CreatorHub 运行时依赖。** +2. **先复用 CreatorHub 自有分页、任务、素材和结果证据模型。**上游简单循环、静默下载失败和 warning 后继续发布不符合当前验收标准。 +3. **小红书读取、Creator 发布和事件监听分别建立适配器和证据门。**不把一个能力的成功外推到另一个能力。 +4. **许可证未确认前不复制上游代码或静态逆向产物。**即使后续确认 MIT,也要单独核对签名脚本、指纹模板和其上游来源的版权/授权。 +5. **不建设“反检测/不封号”承诺。**风控拒绝和账号处罚是外部平台结果,必须如实暴露并保留人工处理入口。 +6. **当前不新增 `curl_cffi`、APScheduler 或另一套前端组件体系。**只有真实平台证据证明现有浏览器 gateway/标准库不足时,才提交针对性技术评审。 + +## 9. 来源与证据索引 + +### 上游仓库与提交 + +- 仓库: +- 研究快照: +- 算法大改提交: +- README: +- PC API: +- Creator API: +- Creator 登录: +- PC 登录: +- PC 鉴权/参数: +- Creator 鉴权: +- HTTP 传输: +- PC 后端标准化: +- 媒体下载: + +### 公开 Issues + +- [#5 长期稳定性、签名、补环境和 TLS/HTTP 指纹](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/5) +- [#13 使用脚本操作警告与封禁反馈](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/13) +- [#22 Debian Trixie 中 `libgl1-mesa-glx` 构建失败](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/22) +- [#23 前端 refresh 401 死循环](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/23) +- [#24 Creator 登录二维码/验证码 406](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/24) +- [#25 PC 二维码登录 `/login/activate` 返回 461](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/25) +- [#27 Creator 发布 `/web_api/sns/v2/note` 返回 406](https://github.com/cv-cat/XHS_ALL_IN_ONE/issues/27) + +### CreatorHub 当前依据 + +- [业务需求与验收基线](../plan01.md) +- `internal/creator/models.go` +- `internal/creator/collection.go` +- `internal/creator/content.go` +- `internal/creator/actions.go` +- `cmd/control-plane/creator.go` +- `cmd/control-plane/creator_events.go` +- `cmd/control-plane/creator_material.go` +- [容器与 gateway 架构](../architecture/container-control.md) + +## 10. 研究范围限制 + +- 未使用真实小红书账号验证登录、搜索、详情、评论、下载、发布或事件监听。 +- 上游 README 的功能清单、用户 Issue 的账号封禁反馈和平台风控判断不能当作官方因果证明。 +- `LICENSE` 文本当前未能确认,不能据此进行代码复制或商业化授权判断。 +- 上游实现可能继续随 Web 版本变化;后续若要实现小红书能力,必须重新锁定源码提交、真实环境和证据时间。 diff --git a/internal/creator/actions.go b/internal/creator/actions.go index 820cf93..731e5b6 100644 --- a/internal/creator/actions.go +++ b/internal/creator/actions.go @@ -125,10 +125,11 @@ func (s *Store) CreateStrategy(ctx context.Context, bigAccountID string, input S if err != nil { return Strategy{}, err } - if small.Platform != "" { - if err := s.requireRelation(ctx, bigAccountID, input.ExecutionAccountID); err != nil { - return Strategy{}, err - } + if big.Platform != small.Platform { + return Strategy{}, ErrConflict + } + if err := s.requireRelation(ctx, bigAccountID, input.ExecutionAccountID); err != nil { + return Strategy{}, err } events, texts, err := encodeStrategyLists(input) if err != nil { @@ -191,6 +192,17 @@ func (s *Store) UpdateStrategy(ctx context.Context, id string, input StrategyInp if err := s.validateEnabledStrategy(ctx, strategy.BigAccountID, input); err != nil { return Strategy{}, err } + big, err := s.GetAccountProfile(ctx, strategy.BigAccountID) + if err != nil { + return Strategy{}, err + } + small, err := s.GetAccountProfile(ctx, input.ExecutionAccountID) + if err != nil { + return Strategy{}, err + } + if big.Platform != small.Platform { + return Strategy{}, ErrConflict + } events, texts, err := encodeStrategyLists(input) if err != nil { return Strategy{}, err @@ -225,21 +237,21 @@ func (s *Store) DeleteStrategy(ctx context.Context, id string) error { func scanEvent(scanner interface{ Scan(...any) error }) (InteractionEvent, error) { var result InteractionEvent - var platformAt, receivedAt, startedAt, finishedAt, displayedAt sql.NullTime - var commentID, workID, messageText, strategyID, executionID sql.NullString - if err := scanner.Scan(&result.ID, &result.Platform, &result.ReceivingAccountID, &result.EventKey, &result.EventType, &result.InteractorUID, &commentID, &workID, &messageText, &platformAt, &receivedAt, &startedAt, &finishedAt, &displayedAt, &result.State, &result.Reason, &strategyID, &executionID); err != nil { + var platformAt, gatewayReceivedAt, receivedAt, startedAt, finishedAt, displayedAt sql.NullTime + var commentID, workID, messageType, messageText, strategyID, executionID sql.NullString + if err := scanner.Scan(&result.ID, &result.Platform, &result.ReceivingAccountID, &result.EventKey, &result.EventType, &result.InteractorUID, &commentID, &workID, &messageType, &messageText, &platformAt, &gatewayReceivedAt, &receivedAt, &startedAt, &finishedAt, &displayedAt, &result.State, &result.Reason, &strategyID, &executionID); err != nil { return InteractionEvent{}, err } - result.CommentID, result.WorkID, result.MessageText = commentID.String, workID.String, messageText.String + result.CommentID, result.WorkID, result.MessageType, result.MessageText = commentID.String, workID.String, messageType.String, messageText.String result.StrategyID, result.ExecutionAccountID = strategyID.String, executionID.String - result.PlatformEventAt, result.ProcessingStartedAt, result.ProcessingFinishedAt, result.DisplayedAt = nullableTime(platformAt), nullableTime(startedAt), nullableTime(finishedAt), nullableTime(displayedAt) + result.PlatformEventAt, result.GatewayReceivedAt, result.ProcessingStartedAt, result.ProcessingFinishedAt, result.DisplayedAt = nullableTime(platformAt), nullableTime(gatewayReceivedAt), nullableTime(startedAt), nullableTime(finishedAt), nullableTime(displayedAt) if receivedAt.Valid { result.ReceivedAt = receivedAt.Time.UTC() } return result, nil } -const eventSelect = `SELECT id,platform,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,message_text,platform_event_at,received_at,processing_started_at,processing_finished_at,displayed_at,state,reason,strategy_id,execution_account_id FROM creator_event` +const eventSelect = `SELECT id,platform,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,message_type,message_text,platform_event_at,gateway_received_at,received_at,processing_started_at,processing_finished_at,displayed_at,state,reason,strategy_id,execution_account_id FROM creator_event` func (s *Store) GetEvent(ctx context.Context, id string) (InteractionEvent, error) { result, err := scanEvent(s.db.QueryRowContext(ctx, eventSelect+` WHERE id=$1`, id)) @@ -270,8 +282,11 @@ func (s *Store) ListEvents(ctx context.Context, accountID string) ([]Interaction } func (s *Store) RecordEvent(ctx context.Context, input InteractionEvent) (AutomaticResult, error) { - input.Platform, input.ReceivingAccountID, input.EventKey, input.EventType, input.InteractorUID, input.CommentID, input.WorkID, input.MessageText = strings.TrimSpace(input.Platform), strings.TrimSpace(input.ReceivingAccountID), strings.TrimSpace(input.EventKey), strings.TrimSpace(input.EventType), strings.TrimSpace(input.InteractorUID), strings.TrimSpace(input.CommentID), strings.TrimSpace(input.WorkID), strings.TrimSpace(input.MessageText) - if !ValidatePlatform(input.Platform) || input.ReceivingAccountID == "" || input.EventKey == "" || !ValidEventType(input.EventType) || len(input.MessageText) > 100000 { + input.Platform, input.ReceivingAccountID, input.EventKey, input.EventType, input.InteractorUID, input.CommentID, input.WorkID, input.MessageType, input.MessageText = strings.TrimSpace(input.Platform), strings.TrimSpace(input.ReceivingAccountID), strings.TrimSpace(input.EventKey), strings.TrimSpace(input.EventType), strings.TrimSpace(input.InteractorUID), strings.TrimSpace(input.CommentID), strings.TrimSpace(input.WorkID), strings.TrimSpace(input.MessageType), strings.TrimSpace(input.MessageText) + if input.MessageType == "" { + input.MessageType = MessageTypeText + } + if !ValidatePlatform(input.Platform) || input.ReceivingAccountID == "" || input.EventKey == "" || !ValidEventType(input.EventType) || !ValidMessageType(input.MessageType) || len(input.MessageText) > 100000 { return AutomaticResult{}, ErrInvalid } profile, err := s.GetAccountProfile(ctx, input.ReceivingAccountID) @@ -301,7 +316,7 @@ func (s *Store) RecordEvent(ctx context.Context, input InteractionEvent) (Automa } var returnedID string var inserted bool - err = s.db.QueryRowContext(ctx, `INSERT INTO creator_event (id,platform,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,message_text,platform_event_at,received_at,state,reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (platform,receiving_account_id,event_key) DO NOTHING RETURNING id,(xmax=0)`, id, input.Platform, input.ReceivingAccountID, input.EventKey, input.EventType, input.InteractorUID, input.CommentID, input.WorkID, input.MessageText, input.PlatformEventAt, receivedAt, state, reason).Scan(&returnedID, &inserted) + err = s.db.QueryRowContext(ctx, `INSERT INTO creator_event (id,platform,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,message_type,message_text,platform_event_at,gateway_received_at,received_at,state,reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT (platform,receiving_account_id,event_key) DO NOTHING RETURNING id,(xmax=0)`, id, input.Platform, input.ReceivingAccountID, input.EventKey, input.EventType, input.InteractorUID, input.CommentID, input.WorkID, input.MessageType, input.MessageText, input.PlatformEventAt, input.GatewayReceivedAt, receivedAt, state, reason).Scan(&returnedID, &inserted) if errors.Is(err, sql.ErrNoRows) { existingErr := s.db.QueryRowContext(ctx, `SELECT id FROM creator_event WHERE platform=$1 AND receiving_account_id=$2 AND event_key=$3`, input.Platform, input.ReceivingAccountID, input.EventKey).Scan(&returnedID) if existingErr != nil { @@ -851,6 +866,19 @@ func (s *Store) UpdateOperationResult(ctx context.Context, id string, result Act _, err = s.db.ExecContext(ctx, `UPDATE creator_operation SET state=$2,evidence=$3::jsonb,reason=$4,updated_at=now() WHERE id=$1`, id, result.State, evidence, result.Reason) return databaseError(err) } +func (s *Store) persistDirectMessageResult(ctx context.Context, op Operation, result ActionResult) error { + if op.Action != ActionDM { + return nil + } + messageState := result.State + if messageState == "blocked" { + messageState = "failed" + } + messageAt := time.Now().UTC() + _, _, err := s.SaveMessage(ctx, MessageInput{Platform: op.Platform, AccountID: op.AccountID, PeerUID: op.TargetUID, PlatformMessageKey: "operation:" + op.ID, Direction: "outbound", MessageType: "text", Text: op.Text, SentState: messageState, MessageAt: &messageAt}) + return err +} + func (s *Store) ExecuteManualOperation(ctx context.Context, id string, executor ActionExecutor) (Operation, error) { op, err := s.GetOperation(ctx, id) if err != nil { @@ -872,21 +900,33 @@ func (s *Store) ExecuteManualOperation(ctx context.Context, id string, executor executionLock.Lock() defer executionLock.Unlock() if _, checkErr := s.AccountWriteCheck(ctx, op.AccountID, false, op.Action); checkErr != nil { - if updateErr := s.UpdateOperationResult(ctx, id, actionPreconditionResult(checkErr, "写入前条件已变化")); updateErr != nil { + result := actionPreconditionResult(checkErr, "写入前条件已变化") + if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil { return Operation{}, updateErr } + if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil { + return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr) + } return s.GetOperation(ctx, id) } if checkErr := s.validateOperationTarget(ctx, &OperationInput{Platform: op.Platform, AccountID: op.AccountID, Action: op.Action, TargetUID: op.TargetUID, TargetCommentID: op.TargetCommentID, TargetWorkID: op.TargetWorkID}); checkErr != nil { - if updateErr := s.UpdateOperationResult(ctx, id, actionPreconditionResult(checkErr, "写入目标已变化")); updateErr != nil { + result := actionPreconditionResult(checkErr, "写入目标已变化") + if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil { return Operation{}, updateErr } + if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil { + return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr) + } return s.GetOperation(ctx, id) } if executor == nil { - if updateErr := s.UpdateOperationResult(ctx, id, ActionResult{State: "uncertain", Reason: "平台执行器不可用"}); updateErr != nil { + result := ActionResult{State: "uncertain", Reason: "平台执行器不可用"} + if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil { return Operation{}, updateErr } + if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil { + return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr) + } return s.GetOperation(ctx, id) } result, execErr := executor.Execute(ctx, ActionRequest{OperationID: claimedID, Action: op.Action, Platform: op.Platform, AccountID: op.AccountID, TargetUID: op.TargetUID, TargetCommentID: op.TargetCommentID, TargetWorkID: op.TargetWorkID, Text: op.Text}) @@ -894,15 +934,8 @@ func (s *Store) ExecuteManualOperation(ctx context.Context, id string, executor if err := s.UpdateOperationResult(ctx, id, result); err != nil { return Operation{}, err } - if op.Action == "dm" { - messageState := result.State - if messageState == "blocked" { - messageState = "failed" - } - messageAt := time.Now().UTC() - if _, _, messageErr := s.SaveMessage(ctx, MessageInput{Platform: op.Platform, AccountID: op.AccountID, PeerUID: op.TargetUID, PlatformMessageKey: "operation:" + op.ID, Direction: "outbound", MessageType: "text", Text: op.Text, SentState: messageState, MessageAt: &messageAt}); messageErr != nil { - return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr) - } + if err := s.persistDirectMessageResult(ctx, op, result); err != nil { + return Operation{}, fmt.Errorf("persist direct message result: %w", err) } return s.GetOperation(ctx, id) } @@ -968,7 +1001,7 @@ func (s *Store) SaveMessage(ctx context.Context, input MessageInput) (Message, b input.PlatformMessageKey = strings.TrimSpace(input.PlatformMessageKey) input.Direction = strings.TrimSpace(input.Direction) input.MessageType = strings.TrimSpace(input.MessageType) - if input.PlatformMessageKey == "" || input.Direction != "inbound" && input.Direction != "outbound" || input.MessageType == "" || len(input.Text) > 100000 { + if input.PlatformMessageKey == "" || len(input.PlatformMessageKey) > 500 || input.Direction != "inbound" && input.Direction != "outbound" || input.MessageType == "" || len(input.MessageType) > 100 || len(input.Text) > 100000 { return Message{}, false, ErrInvalid } state := input.SentState @@ -1016,6 +1049,22 @@ func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) { result, err := scanMessage(s.db.QueryRowContext(ctx, messageSelect+` WHERE id=$1`, id)) return result, rowError(err) } +func (s *Store) ListEventsPage(ctx context.Context, accountID string, page, pageSize int) (Page[InteractionEvent], error) { + items, err := s.ListEvents(ctx, accountID) + if err != nil { + return Page[InteractionEvent]{}, err + } + return slicePage(items, page, pageSize) +} + +func (s *Store) ListMessagesPage(ctx context.Context, conversationID string, page, pageSize int) (Page[Message], error) { + items, err := s.ListMessages(ctx, conversationID) + if err != nil { + return Page[Message]{}, err + } + return slicePage(items, page, pageSize) +} + func (s *Store) ListMessages(ctx context.Context, conversationID string) ([]Message, error) { rows, err := s.db.QueryContext(ctx, messageSelect+` WHERE conversation_id=$1 ORDER BY message_at NULLS LAST,created_at,id`, conversationID) if err != nil { diff --git a/internal/creator/collection.go b/internal/creator/collection.go index 3890256..66bebe8 100644 --- a/internal/creator/collection.go +++ b/internal/creator/collection.go @@ -27,6 +27,12 @@ type PlatformCollector interface { ListTopLevelComments(context.Context, string, string) (CommentPage, error) } +// WorkContextCollector lets a platform retain per-work request context across +// the separate works and comments phases, including a resumed collection. +type WorkContextCollector interface { + SetWorkContext(string, string) error +} + type CollectionReport struct { SourceType string `json:"source_type"` SourceID string `json:"source_id"` @@ -103,6 +109,14 @@ func NewCollectionWindow(now time.Time, lookbackDays int) (time.Time, time.Time, return end.Add(-time.Duration(lookbackDays) * 24 * time.Hour), end, nil } +func publishedAtInCollectionWindow(publishedAt *time.Time, start, end time.Time) bool { + if publishedAt == nil { + return true + } + published := publishedAt.UTC() + return !published.Before(start.UTC()) && !published.After(end.UTC()) +} + func checkpointID(sourceType, sourceID, kind string) string { return sourceType + ":" + sourceID + ":" + kind } @@ -330,7 +344,7 @@ func (s *Store) ListDueOwnedAccounts(ctx context.Context, now time.Time, interva ON works_checkpoint.source_type='owned' AND works_checkpoint.source_id=account.id AND works_checkpoint.collection_kind='works' LEFT JOIN creator_collection_checkpoint comments_checkpoint ON comments_checkpoint.source_type='owned' AND comments_checkpoint.source_id=account.id AND comments_checkpoint.collection_kind='comments' - WHERE account.platform='douyin' AND account.authorization_status='authorized' + WHERE account.platform IN ('douyin', 'xiaohongshu') AND account.authorization_status='authorized' AND profile.login_status='logged_in' AND profile.big_account=true AND COALESCE(works_checkpoint.status, '') <> 'blocked' AND COALESCE(comments_checkpoint.status, '') <> 'blocked' @@ -393,7 +407,7 @@ func (s *Store) CollectSource(ctx context.Context, platform, sourceType, sourceI return err } report.WorksSeen++ - if work.PublishedAt != nil && work.PublishedAt.Before(report.WindowStart) { + if !publishedAtInCollectionWindow(work.PublishedAt, report.WindowStart, report.WindowEnd) { continue } work.Platform, work.SourceType, work.SourceID = platform, sourceType, sourceID @@ -460,6 +474,11 @@ func (s *Store) CollectSource(ctx context.Context, platform, sourceType, sourceI } for index := startIndex; index < len(works); index++ { work := works[index] + if contextual, ok := collector.(WorkContextCollector); ok { + if err := contextual.SetWorkContext(work.WorkKey, work.OriginalURL); err != nil { + return report, s.failCheckpoint(ctx, sourceType, sourceID, "comments", commentsLease, err) + } + } cursor := initialCommentCursor initialCommentCursor = "" _, err := collectPagesFromCursor(ctx, cursor, func(ctx context.Context, cursor string) ([]CommentInput, string, bool, error) { @@ -482,7 +501,7 @@ func (s *Store) CollectSource(ctx context.Context, platform, sourceType, sourceI return err } report.CommentsSeen++ - if comment.PublishedAt != nil && !InWindow(*comment.PublishedAt, report.WindowStart, report.WindowEnd) || comment.CommentType == "reply" { + if !publishedAtInCollectionWindow(comment.PublishedAt, report.WindowStart, report.WindowEnd) || comment.CommentType == "reply" { continue } comment.Platform, comment.WorkID = platform, work.ID diff --git a/internal/creator/collection_test.go b/internal/creator/collection_test.go index 2cf1242..bdd9844 100644 --- a/internal/creator/collection_test.go +++ b/internal/creator/collection_test.go @@ -44,3 +44,26 @@ func TestCollectionWindowIsFixedInUTC(t *testing.T) { t.Fatal("window must include both exact boundaries only") } } + +func TestPublishedAtCollectionWindowRejectsFutureItems(t *testing.T) { + start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + before, atStart, atEnd, after := start.Add(-time.Nanosecond), start, end, end.Add(time.Nanosecond) + for _, test := range []struct { + name string + at *time.Time + want bool + }{ + {name: "unknown", at: nil, want: true}, + {name: "before", at: &before, want: false}, + {name: "start", at: &atStart, want: true}, + {name: "end", at: &atEnd, want: true}, + {name: "future", at: &after, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := publishedAtInCollectionWindow(test.at, start, end); got != test.want { + t.Fatalf("publishedAtInCollectionWindow(%v) = %v, want %v", test.at, got, test.want) + } + }) + } +} diff --git a/internal/creator/content.go b/internal/creator/content.go index a9a496e..430c4b1 100644 --- a/internal/creator/content.go +++ b/internal/creator/content.go @@ -3,6 +3,7 @@ package creator import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "net/url" @@ -184,6 +185,26 @@ func (s *Store) ListDueCompetitors(ctx context.Context, now time.Time) ([]Compet return result, rows.Err() } +func (s *Store) validateWorkSource(ctx context.Context, platform, sourceType, sourceID string) error { + var query string + switch sourceType { + case SourceOwned: + query = `SELECT 1 FROM social_account WHERE id=$1 AND platform=$2` + case SourceCompetitor: + query = `SELECT 1 FROM creator_competitor WHERE id=$1 AND platform=$2` + default: + return ErrInvalid + } + var exists int + if err := s.db.QueryRowContext(ctx, query, sourceID, platform).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrInvalid + } + return databaseError(err) + } + return nil +} + func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) (Work, bool, error) { input.Platform = strings.TrimSpace(input.Platform) input.WorkKey = strings.TrimSpace(input.WorkKey) @@ -200,6 +221,9 @@ func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) utf8.RuneCountInString(input.CoverURL) > 2000 { return Work{}, false, ErrInvalid } + if err := s.validateWorkSource(ctx, input.Platform, input.SourceType, input.SourceID); err != nil { + return Work{}, false, err + } if now.IsZero() { now = time.Now().UTC() } @@ -532,6 +556,59 @@ func (s *Store) ConfirmRewrite(ctx context.Context, workID, requirement string) return s.GetMaterial(ctx, workID) } +func parseGeneratedRewrite(content string) (string, string, error) { + var result struct { + Title string `json:"title"` + Script string `json:"script"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(content)), &result); err != nil { + return "", "", fmt.Errorf("decode generated rewrite: %w", err) + } + result.Title, result.Script = strings.TrimSpace(result.Title), strings.TrimSpace(result.Script) + if result.Title == "" || result.Script == "" || utf8.RuneCountInString(result.Title) > 1000 || utf8.RuneCountInString(result.Script) > 100000 { + return "", "", ErrInvalid + } + return result.Title, result.Script, nil +} + +func (s *Store) GenerateRewrite(ctx context.Context, workID string, generator TextGenerator) (MaterialJob, error) { + if generator == nil { + return MaterialJob{}, fmt.Errorf("%w: text generator is not configured", ErrUnavailable) + } + settings, err := s.GetSettings(ctx) + if err != nil { + return MaterialJob{}, err + } + if !settings.AIConfigured || strings.TrimSpace(settings.AIProvider) == "" || strings.TrimSpace(settings.AIModel) == "" { + return MaterialJob{}, fmt.Errorf("%w: AI settings are not approved", ErrUnavailable) + } + job, err := s.GetMaterial(ctx, workID) + if err != nil { + return MaterialJob{}, err + } + if job.RewriteConfirmedAt == nil { + return MaterialJob{}, ErrConflict + } + work, err := s.GetWork(ctx, workID) + if err != nil { + return MaterialJob{}, err + } + content, err := generator.Generate(ctx, + "根据原作品和转写内容生成仿写草稿。只返回 JSON,不要 Markdown 或额外文字,格式必须是 {\"title\":\"标题\",\"script\":\"脚本\"}。不得编造原文没有的事实。", + fmt.Sprintf("改写要求:%s\n原标题:%s\n原文案:%s\n转写:%s", job.RewriteRequirement, work.Title, work.Body, job.Transcript)) + if err != nil { + return MaterialJob{}, err + } + title, script, err := parseGeneratedRewrite(content) + if err != nil { + return MaterialJob{}, err + } + if _, err := s.db.ExecContext(ctx, `UPDATE creator_material_job SET generated_title = $2, generated_script = $3, updated_at = now() WHERE work_id = $1`, workID, title, script); err != nil { + return MaterialJob{}, databaseError(err) + } + return s.GetMaterial(ctx, workID) +} + func (s *Store) SaveRewrite(ctx context.Context, workID, title, script string) (MaterialJob, error) { title, script = strings.TrimSpace(title), strings.TrimSpace(script) if title == "" || script == "" || utf8.RuneCountInString(title) > 1000 || utf8.RuneCountInString(script) > 100000 { @@ -591,6 +668,48 @@ func (s *Store) GetComment(ctx context.Context, id string) (Comment, error) { return result, rowError(err) } +func pageBounds(page, pageSize int) (int, int, error) { + if page < 1 || pageSize < 1 || pageSize > 100 { + return 0, 0, ErrInvalid + } + if page > int(maxDurationSeconds) || pageSize > int(maxDurationSeconds)/page { + return 0, 0, ErrInvalid + } + return (page - 1) * pageSize, pageSize, nil +} + +func slicePage[T any](items []T, page, pageSize int) (Page[T], error) { + offset, limit, err := pageBounds(page, pageSize) + if err != nil { + return Page[T]{}, err + } + if offset > len(items) { + offset = len(items) + } + end := offset + limit + if end > len(items) { + end = len(items) + } + data := append([]T(nil), items[offset:end]...) + return Page[T]{Data: data, Page: page, PageSize: pageSize, Total: len(items), HasNext: end < len(items)}, nil +} + +func (s *Store) ListWorksPage(ctx context.Context, filter WorkFilter, page, pageSize int) (Page[Work], error) { + items, err := s.ListWorks(ctx, filter) + if err != nil { + return Page[Work]{}, err + } + return slicePage(items, page, pageSize) +} + +func (s *Store) ListCommentsPage(ctx context.Context, platform, workID string, page, pageSize int) (Page[Comment], error) { + items, err := s.ListComments(ctx, platform, workID) + if err != nil { + return Page[Comment]{}, err + } + return slicePage(items, page, pageSize) +} + func (s *Store) ListComments(ctx context.Context, platform, workID string) ([]Comment, error) { query, args := commentSelect, []any{} where := []string{} diff --git a/internal/creator/content_test.go b/internal/creator/content_test.go new file mode 100644 index 0000000..57bf467 --- /dev/null +++ b/internal/creator/content_test.go @@ -0,0 +1,20 @@ +package creator + +import "testing" + +func TestParseGeneratedRewriteRequiresBoundedJSON(t *testing.T) { + title, script, err := parseGeneratedRewrite(`{"title":"新标题","script":"新脚本"}`) + if err != nil || title != "新标题" || script != "新脚本" { + t.Fatalf("parseGeneratedRewrite() = %q, %q, %v", title, script, err) + } + for _, content := range []string{ + "", + "不是 JSON", + `{"title":"有标题"}`, + `{"title":"标题","script":""}`, + } { + if _, _, err := parseGeneratedRewrite(content); err == nil { + t.Fatalf("parseGeneratedRewrite(%q) accepted invalid output", content) + } + } +} diff --git a/internal/creator/listener.go b/internal/creator/listener.go new file mode 100644 index 0000000..7a81b92 --- /dev/null +++ b/internal/creator/listener.go @@ -0,0 +1,85 @@ +package creator + +import ( + "context" + "database/sql" + "strings" +) + +const listenerStateSelect = `SELECT account_id,platform,generation,status,boundary_at,last_delivery_id,reason,updated_at FROM creator_listener_state` + +func validListenerState(input ListenerState) bool { + if input.AccountID == "" || !ValidatePlatform(input.Platform) || strings.TrimSpace(input.Generation) == "" { + return false + } + switch input.Status { + case "starting", "gap", "stopped", "error": + return len(input.LastDeliveryID) <= 500 && len(input.Reason) <= 2000 + case "ready": + return input.BoundaryAt != nil && !input.BoundaryAt.IsZero() && len(input.LastDeliveryID) <= 500 && len(input.Reason) <= 2000 + default: + return false + } +} + +func scanListenerState(scanner interface{ Scan(...any) error }) (ListenerState, error) { + var result ListenerState + var boundaryAt sql.NullTime + if err := scanner.Scan(&result.AccountID, &result.Platform, &result.Generation, &result.Status, &boundaryAt, &result.LastDeliveryID, &result.Reason, &result.UpdatedAt); err != nil { + return ListenerState{}, err + } + result.BoundaryAt = nullableTime(boundaryAt) + result.UpdatedAt = result.UpdatedAt.UTC() + return result, nil +} + +func (s *Store) UpsertListenerState(ctx context.Context, input ListenerState) (ListenerState, error) { + input.Generation = strings.TrimSpace(input.Generation) + input.LastDeliveryID = strings.TrimSpace(input.LastDeliveryID) + input.Reason = strings.TrimSpace(input.Reason) + if !validListenerState(input) { + return ListenerState{}, ErrInvalid + } + if _, err := s.db.ExecContext(ctx, ` + INSERT INTO creator_listener_state (account_id,platform,generation,status,boundary_at,last_delivery_id,reason,updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,now()) + ON CONFLICT (account_id) DO UPDATE SET platform=EXCLUDED.platform,generation=EXCLUDED.generation,status=EXCLUDED.status, + boundary_at=COALESCE(EXCLUDED.boundary_at, creator_listener_state.boundary_at), + last_delivery_id=CASE WHEN EXCLUDED.last_delivery_id = '' THEN creator_listener_state.last_delivery_id ELSE EXCLUDED.last_delivery_id END, + reason=EXCLUDED.reason,updated_at=now()`, + input.AccountID, input.Platform, input.Generation, input.Status, input.BoundaryAt, input.LastDeliveryID, input.Reason); err != nil { + return ListenerState{}, databaseError(err) + } + return s.GetListenerState(ctx, input.AccountID) +} + +func (s *Store) GetListenerState(ctx context.Context, accountID string) (ListenerState, error) { + if strings.TrimSpace(accountID) == "" { + return ListenerState{}, ErrInvalid + } + result, err := scanListenerState(s.db.QueryRowContext(ctx, listenerStateSelect+` WHERE account_id=$1`, accountID)) + return result, rowError(err) +} + +func (s *Store) ListListenerStates(ctx context.Context, accountID string) ([]ListenerState, error) { + query, args := listenerStateSelect, []any{} + if strings.TrimSpace(accountID) != "" { + query += ` WHERE account_id=$1` + args = append(args, accountID) + } + query += ` ORDER BY account_id` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, databaseError(err) + } + defer rows.Close() + result := make([]ListenerState, 0) + for rows.Next() { + item, err := scanListenerState(rows) + if err != nil { + return nil, err + } + result = append(result, item) + } + return result, rows.Err() +} diff --git a/internal/creator/listener_test.go b/internal/creator/listener_test.go new file mode 100644 index 0000000..fbf27bd --- /dev/null +++ b/internal/creator/listener_test.go @@ -0,0 +1,29 @@ +package creator + +import "testing" + +func TestValidListenerStateRequiresVerifiedGeneration(t *testing.T) { + base := ListenerState{AccountID: "account", Platform: PlatformDouyin, Generation: "runtime:network:1", Status: "starting"} + if !validListenerState(base) { + t.Fatal("expected a complete listener state to be valid") + } + for _, mutate := range []func(*ListenerState){ + func(state *ListenerState) { state.Generation = "" }, + func(state *ListenerState) { state.Platform = "unknown" }, + func(state *ListenerState) { state.Status = "ready"; state.BoundaryAt = nil }, + func(state *ListenerState) { state.Status = "lost" }, + } { + state := base + mutate(&state) + if state.Status == "ready" && state.BoundaryAt == nil { + // A ready state without a boundary is never a valid continuity claim. + if validListenerState(state) { + t.Fatalf("accepted ready state without boundary: %+v", state) + } + continue + } + if validListenerState(state) { + t.Fatalf("accepted invalid listener state: %+v", state) + } + } +} diff --git a/internal/creator/logic.go b/internal/creator/logic.go index 80e71cc..34956b8 100644 --- a/internal/creator/logic.go +++ b/internal/creator/logic.go @@ -166,6 +166,15 @@ func ValidEventType(eventType string) bool { } } +func ValidMessageType(messageType string) bool { + switch messageType { + case MessageTypeText, MessageTypeImage, MessageTypeVoice, MessageTypeVideo, MessageTypeSticker, MessageTypeUnknown: + return true + default: + return false + } +} + func ValidAction(action string) bool { switch action { case ActionDM, ActionReplyComment, ActionLikeComment, ActionLikeWork, ActionFollow, ActionRepost: diff --git a/internal/creator/logic_test.go b/internal/creator/logic_test.go index fcd0892..edcac73 100644 --- a/internal/creator/logic_test.go +++ b/internal/creator/logic_test.go @@ -78,3 +78,14 @@ func TestActionTargetAndBusinessStatusGates(t *testing.T) { t.Fatalf("normal logged-in account should be writable: %v", err) } } + +func TestValidMessageTypeKeepsNonTextMessagesExplicit(t *testing.T) { + for _, messageType := range []string{MessageTypeText, MessageTypeImage, MessageTypeVoice, MessageTypeVideo, MessageTypeSticker, MessageTypeUnknown} { + if !ValidMessageType(messageType) { + t.Fatalf("message type %q should be accepted", messageType) + } + } + if ValidMessageType("text-like") { + t.Fatal("unknown message type must not be accepted") + } +} diff --git a/internal/creator/migrations/027_creator_listener_state.sql b/internal/creator/migrations/027_creator_listener_state.sql new file mode 100644 index 0000000..033b015 --- /dev/null +++ b/internal/creator/migrations/027_creator_listener_state.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS creator_listener_state ( + account_id text PRIMARY KEY REFERENCES social_account ( + id + ) ON DELETE CASCADE, + platform text NOT NULL CHECK (platform IN ('douyin', 'xiaohongshu')), + generation text NOT NULL DEFAULT '', + status text NOT NULL CHECK ( + status IN ('starting', 'ready', 'gap', 'stopped', 'error') + ), + boundary_at timestamptz, + last_delivery_id text NOT NULL DEFAULT '', + reason text NOT NULL DEFAULT '', + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/internal/creator/migrations/028_creator_event_message_type.sql b/internal/creator/migrations/028_creator_event_message_type.sql new file mode 100644 index 0000000..9f03b6d --- /dev/null +++ b/internal/creator/migrations/028_creator_event_message_type.sql @@ -0,0 +1,9 @@ +ALTER TABLE creator_event + ADD COLUMN IF NOT EXISTS message_type text NOT NULL DEFAULT 'text'; + +ALTER TABLE creator_event + DROP CONSTRAINT IF EXISTS creator_event_message_type_check; + +ALTER TABLE creator_event + ADD CONSTRAINT creator_event_message_type_check + CHECK (message_type IN ('text', 'image', 'voice', 'video', 'sticker', 'non_text')); diff --git a/internal/creator/migrations/029_creator_source_sync_lease.sql b/internal/creator/migrations/029_creator_source_sync_lease.sql new file mode 100644 index 0000000..28aa6ff --- /dev/null +++ b/internal/creator/migrations/029_creator_source_sync_lease.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS creator_source_sync_lease ( + source_type text NOT NULL CHECK (source_type IN ('owned', 'competitor')), + source_id text NOT NULL, + lease_token text NOT NULL, + lease_until timestamptz NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (source_type, source_id) +); diff --git a/internal/creator/migrations/030_creator_event_gateway_time.sql b/internal/creator/migrations/030_creator_event_gateway_time.sql new file mode 100644 index 0000000..4fb605b --- /dev/null +++ b/internal/creator/migrations/030_creator_event_gateway_time.sql @@ -0,0 +1,2 @@ +ALTER TABLE creator_event + ADD COLUMN IF NOT EXISTS gateway_received_at timestamptz; diff --git a/internal/creator/models.go b/internal/creator/models.go index b6b45d2..7421f5b 100644 --- a/internal/creator/models.go +++ b/internal/creator/models.go @@ -18,6 +18,13 @@ const ( ActionLikeWork = "like_work" ActionFollow = "follow" ActionRepost = "repost" + + MessageTypeText = "text" + MessageTypeImage = "image" + MessageTypeVoice = "voice" + MessageTypeVideo = "video" + MessageTypeSticker = "sticker" + MessageTypeUnknown = "non_text" ) type Settings struct { @@ -271,6 +278,12 @@ type RuleResult struct { AnalysedAt *time.Time `json:"analysed_at,omitempty"` } +type RuleAnalysisItem struct { + CommentID string `json:"comment_id"` + Result *RuleResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + type Lead struct { Comment Comment `json:"comment"` RuleIDs []string `json:"rule_ids"` @@ -306,6 +319,25 @@ type Relation struct { SmallAccountID string `json:"small_account_id"` } +type ListenerState struct { + AccountID string `json:"account_id"` + Platform string `json:"platform"` + Generation string `json:"generation"` + Status string `json:"status"` + BoundaryAt *time.Time `json:"boundary_at,omitempty"` + LastDeliveryID string `json:"last_delivery_id,omitempty"` + Reason string `json:"reason,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Page[T any] struct { + Data []T `json:"data"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` + HasNext bool `json:"has_next"` +} + type InteractionEvent struct { ID string `json:"id"` Platform string `json:"platform"` @@ -317,8 +349,10 @@ type InteractionEvent struct { InteractorUID string `json:"interactor_uid"` CommentID string `json:"comment_id"` WorkID string `json:"work_id"` + MessageType string `json:"message_type"` MessageText string `json:"message_text,omitempty"` PlatformEventAt *time.Time `json:"platform_event_at,omitempty"` + GatewayReceivedAt *time.Time `json:"gateway_received_at,omitempty"` ReceivedAt time.Time `json:"received_at"` ProcessingStartedAt *time.Time `json:"processing_started_at,omitempty"` ProcessingFinishedAt *time.Time `json:"processing_finished_at,omitempty"` diff --git a/internal/creator/rules.go b/internal/creator/rules.go index 1f26b77..73b6053 100644 --- a/internal/creator/rules.go +++ b/internal/creator/rules.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "strings" "time" @@ -123,6 +124,34 @@ func (s *Store) upsertRuleResult(ctx context.Context, commentID string, rule Lea return RuleResult{CommentID: commentID, RuleID: rule.ID, Status: status, Reason: reason, MatchedKeywords: matched, RuleSnapshot: rule, AnalysedAt: analysedAt}, nil } +func (s *Store) AnalyzeComments(ctx context.Context, commentIDs []string, ruleID string, analyzer ThemeAnalyzer) ([]RuleAnalysisItem, error) { + if len(commentIDs) == 0 || len(commentIDs) > 100 || strings.TrimSpace(ruleID) == "" { + return nil, ErrInvalid + } + seen := make(map[string]struct{}, len(commentIDs)) + items := make([]RuleAnalysisItem, 0, len(commentIDs)) + var batchErr error + for _, commentID := range commentIDs { + commentID = strings.TrimSpace(commentID) + if commentID == "" { + return nil, ErrInvalid + } + if _, ok := seen[commentID]; ok { + continue + } + seen[commentID] = struct{}{} + result, err := s.AnalyzeComment(ctx, commentID, ruleID, analyzer) + item := RuleAnalysisItem{CommentID: commentID, Result: &result} + if err != nil { + item.Result = nil + item.Error = err.Error() + batchErr = errors.Join(batchErr, err) + } + items = append(items, item) + } + return items, batchErr +} + func (s *Store) AnalyzeComment(ctx context.Context, commentID, ruleID string, analyzer ThemeAnalyzer) (RuleResult, error) { comment, err := s.GetComment(ctx, commentID) if err != nil { diff --git a/internal/creator/source_lease.go b/internal/creator/source_lease.go new file mode 100644 index 0000000..d0d9ff1 --- /dev/null +++ b/internal/creator/source_lease.go @@ -0,0 +1,44 @@ +package creator + +import ( + "context" + "database/sql" +) + +func (s *Store) ClaimSourceSync(ctx context.Context, sourceType, sourceID string) (string, error) { + if sourceType != SourceOwned && sourceType != SourceCompetitor || sourceID == "" { + return "", ErrInvalid + } + token := newID("source-lease") + var claimed string + err := s.db.QueryRowContext(ctx, ` + INSERT INTO creator_source_sync_lease (source_type,source_id,lease_token,lease_until,updated_at) + VALUES ($1,$2,$3,now() + interval '10 minutes',now()) + ON CONFLICT (source_type,source_id) DO UPDATE SET lease_token=EXCLUDED.lease_token, + lease_until=EXCLUDED.lease_until,updated_at=now() + WHERE creator_source_sync_lease.lease_until <= now() + RETURNING lease_token`, sourceType, sourceID, token).Scan(&claimed) + if err == sql.ErrNoRows { + return "", ErrConflict + } + if err != nil { + return "", databaseError(err) + } + return claimed, nil +} + +func (s *Store) ReleaseSourceSync(ctx context.Context, sourceType, sourceID, token string) error { + if sourceType != SourceOwned && sourceType != SourceCompetitor || sourceID == "" || token == "" { + return ErrInvalid + } + result, err := s.db.ExecContext(ctx, `DELETE FROM creator_source_sync_lease WHERE source_type=$1 AND source_id=$2 AND lease_token=$3`, sourceType, sourceID, token) + if err != nil { + return databaseError(err) + } + if rows, err := result.RowsAffected(); err != nil { + return err + } else if rows != 1 { + return ErrConflict + } + return nil +} diff --git a/internal/creator/store.go b/internal/creator/store.go index 6fa559e..947a639 100644 --- a/internal/creator/store.go +++ b/internal/creator/store.go @@ -46,6 +46,18 @@ var migration025 string //go:embed migrations/026_event_message_text.sql var migration026 string +//go:embed migrations/027_creator_listener_state.sql +var migration027 string + +//go:embed migrations/028_creator_event_message_type.sql +var migration028 string + +//go:embed migrations/029_creator_source_sync_lease.sql +var migration029 string + +//go:embed migrations/030_creator_event_gateway_time.sql +var migration030 string + type SecretReference struct { ID string Provider string @@ -119,6 +131,10 @@ func (s *Store) migrate(ctx context.Context) error { {version: 24, sql: migration024}, {version: 25, sql: migration025}, {version: 26, sql: migration026}, + {version: 27, sql: migration027}, + {version: 28, sql: migration028}, + {version: 29, sql: migration029}, + {version: 30, sql: migration030}, } for _, migration := range migrations { var applied bool diff --git a/internal/douyin/connector.go b/internal/douyin/connector.go index f16a348..50fbd9f 100644 --- a/internal/douyin/connector.go +++ b/internal/douyin/connector.go @@ -32,10 +32,7 @@ const ( worksEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/post/" ) -var ( - keyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`) - credentialKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._/-]{0,126}$`) -) +var keyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`) var ErrInvalid = errors.New("invalid douyin connector input") @@ -65,9 +62,9 @@ type Response struct { } // Browser is the deliberately narrow contract the restricted browser control -// plane must implement. It does not permit arbitrary CDP commands. +// plane must implement. Login happens in the managed browser session; the +// connector never injects stored credentials or cookies. type Browser interface { - SetCookies(context.Context, []Cookie) error Get(context.Context, string) (Response, error) } @@ -127,9 +124,7 @@ type Request struct { func (connector Connector) Sync(ctx context.Context, request Request) (Result, error) { if connector.Browser == nil || connector.Store == nil || !keyPattern.MatchString(request.AccountID) || - !keyPattern.MatchString(request.PlatformAccountKey) || - (request.Credential.Provider != "os_keyring" && request.Credential.Provider != "secret_manager") || - !credentialKeyPattern.MatchString(request.Credential.Key) { + !keyPattern.MatchString(request.PlatformAccountKey) { return Result{}, ErrInvalid } if err := ctx.Err(); err != nil { @@ -140,24 +135,7 @@ func (connector Connector) Sync(ctx context.Context, request Request) (Result, e if ctx.Err() != nil { return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"}) } - if connector.Secrets == nil { - return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"}) - } - credential, credentialErr := connector.Secrets.Resolve(ctx, request.Credential) - if credentialErr != nil { - return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"}) - } - cookies, credentialErr := ParseCredential(credential) - if credentialErr != nil { - return connector.stop(ctx, request.AccountID, StatePolicyHold, ReasonAuthInvalid, Evidence{Phase: "login"}) - } - if credentialErr = connector.Browser.SetCookies(ctx, cookies); credentialErr != nil { - return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "login"}) - } - identityResponse, err = connector.Browser.Get(ctx, identityEndpoint) - if err != nil { - return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "identity"}) - } + return connector.stop(ctx, request.AccountID, StateNeedsConfirmation, ReasonUnknown, Evidence{Phase: "login"}) } if state, reason := classify(identityResponse); state != "" { return connector.stop(ctx, request.AccountID, state, reason, Evidence{Phase: "identity", HTTPStatus: identityResponse.Status}) diff --git a/internal/douyin/connector_test.go b/internal/douyin/connector_test.go index 4561d65..4175b3b 100644 --- a/internal/douyin/connector_test.go +++ b/internal/douyin/connector_test.go @@ -83,7 +83,7 @@ func TestSyncLogsInVerifiesIdentityAndReadsOwnWorks(t *testing.T) { store := &fakeStore{} resolveCalls := 0 result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(credential), resolveCalls: &resolveCalls}, Store: store}).Sync(context.Background(), Request{ - AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference, + AccountID: "account-a", PlatformAccountKey: "sec-a", }) if err != nil || result.State != StateSucceeded || !result.Evidence.IdentityVerified || result.Evidence.WorksSeen != 1 || !result.Evidence.HasMore { t.Fatalf("unexpected result: %#v err=%v", result, err) @@ -193,7 +193,7 @@ func TestSyncRejectsInvalidCredentialWithoutLeakingIt(t *testing.T) { result, err := (Connector{Browser: browser, Secrets: fakeSecrets{value: []byte(`{"cookies":[{"name":"sessionid","value":"secret","domain":"evil.example"}]}`)}, Store: store}).Sync(context.Background(), Request{ AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference, }) - if err != nil || result.State != StatePolicyHold || result.ReasonCode != ReasonAuthInvalid || len(browser.urls) != 1 || len(browser.cookies) != 0 || len(store.holds) != 1 { + if err != nil || result.State != StateNeedsConfirmation || result.ReasonCode != ReasonUnknown || len(browser.urls) != 1 || len(browser.cookies) != 0 || len(store.holds) != 1 { t.Fatalf("unexpected invalid credential result: %#v browser=%#v holds=%#v err=%v", result, browser, store.holds, err) } encoded, _ := json.Marshal(result) @@ -208,8 +208,8 @@ func TestSyncStopsWhenSecretReferenceCannotResolve(t *testing.T) { result, err := (Connector{Browser: browser, Secrets: fakeSecrets{err: errors.New("secret unavailable")}, Store: store}).Sync(context.Background(), Request{ AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: secretReference, }) - if err != nil || result.State != StatePolicyHold || result.ReasonCode != ReasonAuthInvalid || len(browser.urls) != 1 || len(store.holds) != 1 { - t.Fatalf("unavailable secret did not fail closed: result=%#v browser=%#v holds=%#v err=%v", result, browser, store.holds, err) + if err != nil || result.State != StateNeedsConfirmation || result.ReasonCode != ReasonUnknown || len(browser.urls) != 1 || len(store.holds) != 1 { + t.Fatalf("browser session did not fail closed without credential injection: result=%#v browser=%#v holds=%#v err=%v", result, browser, store.holds, err) } } @@ -269,11 +269,15 @@ func TestSyncHoldsWithCancelledRequestContext(t *testing.T) { } } -func TestSyncRejectsNonSecretCredentialReference(t *testing.T) { - _, err := (Connector{Browser: &fakeBrowser{}, Secrets: fakeSecrets{}, Store: &fakeStore{}}).Sync(context.Background(), Request{ +func TestSyncIgnoresLegacyCredentialReference(t *testing.T) { + browser := &fakeBrowser{responses: []Response{ + {Status: 200, Body: identityBody("uid-a", "sec-a", "handle-a")}, + {Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"aweme_list":[]}`)}, + }} + result, err := (Connector{Browser: browser, Store: &fakeStore{}}).Sync(context.Background(), Request{ AccountID: "account-a", PlatformAccountKey: "sec-a", Credential: SecretReference{Provider: "plain_text", Key: "raw-secret"}, }) - if !errors.Is(err, ErrInvalid) { - t.Fatalf("accepted non-secret credential reference: %v", err) + if errors.Is(err, ErrInvalid) || result.State != StateSucceeded { + t.Fatalf("legacy credential reference affected browser-session sync: result=%#v err=%v", result, err) } } diff --git a/web/src/AccountsPage.jsx b/web/src/AccountsPage.jsx index 1d65729..a880ea1 100644 --- a/web/src/AccountsPage.jsx +++ b/web/src/AccountsPage.jsx @@ -350,6 +350,15 @@ export function AccountList() { render: (account) => account.runtime_status === "active" ? "启用" : "暂停", }, + { + header: "操作", + width: "16%", + render: (account) => ( + + ), + }, ]} rows={accounts} rowKey={(account) => account.id} diff --git a/web/src/CreatorAccountsPage.jsx b/web/src/CreatorAccountsPage.jsx index 70e3f3d..f108ae2 100644 --- a/web/src/CreatorAccountsPage.jsx +++ b/web/src/CreatorAccountsPage.jsx @@ -73,10 +73,13 @@ export function CreatorAccountsPage() { const [editingStrategyID, setEditingStrategyID] = useState(""); const [relations, setRelations] = useState([]); const [relationError, setRelationError] = useState(null); + const [listener, setListener] = useState(null); + const [listenerError, setListenerError] = useState(null); const loadSequence = useRef(0); const selectionVersion = useRef(0); const strategySequence = useRef(0); const relationSequence = useRef(0); + const listenerSequence = useRef(0); const [strategyForm, setStrategyForm] = useState({ execution_account_id: "", position: 1, @@ -130,6 +133,23 @@ export function CreatorAccountsPage() { if (sequence === strategySequence.current) setStrategyError(loadError); }); }, [selectedID]); + useEffect(() => { + if (!selectedID) return; + const sequence = ++listenerSequence.current; + setListenerError(null); + setListener(null); + dataProvider + .creatorGet( + `/creator/listeners?account_id=${encodeURIComponent(selectedID)}`, + ) + .then((result) => { + if (sequence === listenerSequence.current) + setListener(result[0] || null); + }) + .catch((loadError) => { + if (sequence === listenerSequence.current) setListenerError(loadError); + }); + }, [selectedID]); useEffect(() => { if (!selectedID) return; const sequence = ++relationSequence.current; @@ -208,6 +228,40 @@ export function CreatorAccountsPage() { } } }; + const verifyLogin = async () => { + if (!selected) return; + setBusy(true); + setNotice(null); + try { + const result = await dataProvider.creatorAction( + `/creator/accounts/${encodeURIComponent(selected.id)}/verify`, + ); + setProfiles((items) => + items.map((item) => + item.id === selected.id + ? { + ...item, + login_status: result.status, + login_reason: result.reason, + login_checked_at: result.checked_at, + } + : item, + ), + ); + setNotice({ variant: "success", text: "浏览器身份核验成功。" }); + await load(); + } catch (verifyError) { + setNotice({ + variant: "destructive", + text: conflictMessage( + verifyError, + "浏览器身份核验失败;请先在指定环境人工登录", + ), + }); + } finally { + setBusy(false); + } + }; const toggleBig = async () => { if (!selected) return; const accountID = selected.id; @@ -458,14 +512,37 @@ export function CreatorAccountsPage() { 密码凭据: {selected.password_configured ? "已配置" : "未配置"}

+

+ 监听:{listener ? listener.status : "尚未启动"} + {listener?.boundary_at + ? ` · 边界 ${dateTime(listener.boundary_at)}` + : " · 等待平台边界"} + {listener?.reason ? ` · ${listener.reason}` : ""} +

+ {listenerError ? ( +

+ 监听状态读取失败: + {conflictMessage(listenerError, "请重试")} +

+ ) : null} + +
+ +
- {notice ? ( diff --git a/web/src/CreatorCompetitorsPage.jsx b/web/src/CreatorCompetitorsPage.jsx index ccf3ea3..1e24e27 100644 --- a/web/src/CreatorCompetitorsPage.jsx +++ b/web/src/CreatorCompetitorsPage.jsx @@ -25,6 +25,11 @@ export function CreatorCompetitorsPage() { const dataProvider = useDataProvider()("default"); const [competitors, setCompetitors] = useState([]); const [works, setWorks] = useState([]); + const [workPage, setWorkPage] = useState(1); + const [workPageInfo, setWorkPageInfo] = useState({ + total: 0, + hasNext: false, + }); const [accounts, setAccounts] = useState([]); const [platform, setPlatform] = useState(""); const [form, setForm] = useState({ @@ -38,6 +43,8 @@ export function CreatorCompetitorsPage() { const [minShares, setMinShares] = useState(""); const [pending, setPending] = useState(true); const [busy, setBusy] = useState(false); + const [preview, setPreview] = useState(null); + const [previewConfirmed, setPreviewConfirmed] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [accountError, setAccountError] = useState(null); @@ -64,10 +71,15 @@ export function CreatorCompetitorsPage() { resource: "creator-competitors", filters: platform ? [{ field: "platform", value: platform }] : [], }), - dataProvider.getList({ resource: "creator-works", filters }), + dataProvider.getList({ + resource: "creator-works", + filters, + pagination: { currentPage: workPage, pageSize: 25 }, + }), ]); setCompetitors(competitorResult.data); setWorks(workResult.data); + setWorkPageInfo({ total: workResult.total, hasNext: workResult.hasNext }); } catch (loadError) { setError(loadError); } finally { @@ -76,6 +88,9 @@ export function CreatorCompetitorsPage() { }; useEffect(() => { load(); + }, [platform, minLikes, minComments, minShares, workPage]); + useEffect(() => { + setWorkPage(1); }, [platform, minLikes, minComments, minShares]); useEffect(() => { dataProvider @@ -87,8 +102,11 @@ export function CreatorCompetitorsPage() { .catch((loadError) => setAccountError(loadError)); }, [dataProvider]); - const change = (field) => (event) => + const change = (field) => (event) => { setForm((value) => ({ ...value, [field]: event.target.value })); + setPreview(null); + setPreviewConfirmed(false); + }; const parseHomepage = () => { try { const parsed = new URL(form.homepage_url); @@ -108,10 +126,13 @@ export function CreatorCompetitorsPage() { "链接不是受支持的平台主页格式,请人工填写平台返回的稳定标识", ); } - setForm((value) => ({ ...value, platform_account_key: candidate })); + const next = { ...form, platform_account_key: candidate }; + setForm(next); + setPreview(next); + setPreviewConfirmed(false); setNotice({ variant: "info", - text: `已解析候选标识 ${candidate},请核对平台返回值后再提交。`, + text: `已解析候选标识 ${candidate},请核对平台返回值后确认。`, }); } catch (parseError) { setNotice({ @@ -122,6 +143,10 @@ export function CreatorCompetitorsPage() { }; const create = async (event) => { event.preventDefault(); + if (!preview || !previewConfirmed) { + setNotice({ variant: "warning", text: "请先解析并确认导入预览。" }); + return; + } setBusy(true); setNotice(null); try { @@ -136,6 +161,8 @@ export function CreatorCompetitorsPage() { nickname: "", homepage_url: "", })); + setPreview(null); + setPreviewConfirmed(false); setNotice({ variant: "success", text: "竞品账号已加入监测。" }); } catch (createError) { setNotice({ @@ -281,6 +308,30 @@ export function CreatorCompetitorsPage() { setMaterialPending(false); } }; + const generateRewrite = async () => { + if (!material) return; + setMaterialPending(true); + setNotice(null); + try { + const result = await dataProvider.creatorAction( + `/creator/works/${encodeURIComponent(material.work_id)}/material/rewrite/generate`, + ); + setMaterial(result); + setRewriteTitle(result.generated_title || ""); + setRewriteScript(result.generated_script || ""); + setNotice({ + variant: "success", + text: "仿写草稿已生成,请人工复核后保存。", + }); + } catch (actionError) { + setNotice({ + variant: "destructive", + text: conflictMessage(actionError, "仿写草稿生成失败"), + }); + } finally { + setMaterialPending(false); + } + }; const saveRewrite = async () => { if (!material) return; setMaterialPending(true); @@ -365,10 +416,33 @@ export function CreatorCompetitorsPage() { - + {preview ? ( +
+

导入预览

+

+ {preview.platform === "douyin" ? "抖音" : "小红书"} ·{" "} + {preview.platform_account_key} · {preview.homepage_url} +

+ +
+ ) : null}

主页链接仅用于人工核对;请把平台返回的稳定标识填入上方,不根据昵称自动猜测。

@@ -546,6 +620,29 @@ export function CreatorCompetitorsPage() { +
+ + 当前页 {works.length} 条 · 共 {workPageInfo.total} 条 + +
+ + +
+
{material ? (
@@ -617,25 +714,20 @@ export function CreatorCompetitorsPage() { } /> - {!material.rewrite_confirmed_at ? ( - - ) : ( + {material.rewrite_confirmed_at ? (
+
+ + + 生成结果不会自动发布,保存前必须人工复核。 + +
+ ) : ( + )}
) : null} diff --git a/web/src/CreatorPages.test.jsx b/web/src/CreatorPages.test.jsx index 5d4e0a3..67ecb42 100644 --- a/web/src/CreatorPages.test.jsx +++ b/web/src/CreatorPages.test.jsx @@ -180,6 +180,29 @@ describe("creator pages", () => { expect(await screen.findByText("暂无符合条件的作品。")).toBeTruthy(); }); + it("requires preview confirmation before importing a competitor", async () => { + const dataProvider = provider(); + renderPage(, dataProvider); + fireEvent.change(screen.getByLabelText("主页 URL", { exact: false }), { + target: { value: "https://www.douyin.com/user/sec-b" }, + }); + fireEvent.click(screen.getByRole("button", { name: "解析链接预览" })); + expect(await screen.findByText("导入预览")).toBeTruthy(); + expect(dataProvider.create).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "确认预览内容" })); + fireEvent.click(screen.getByRole("button", { name: "加入监测" })); + await waitFor(() => expect(dataProvider.create).toHaveBeenCalled()); + expect(dataProvider.create).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "creator-competitors", + variables: expect.objectContaining({ + platform: "douyin", + platform_account_key: "sec-b", + }), + }), + ); + }); + it("keeps account password out of the returned profile and exposes big-account action", async () => { const dataProvider = provider(); renderPage(, dataProvider); @@ -230,6 +253,40 @@ describe("creator pages", () => { expect(screen.getByRole("option", { name: "小红书账号" })).toBeTruthy(); }); + it("shows the private-message business update connection state", async () => { + const creatorSubscribe = vi.fn(() => new Promise(() => {})); + const conversation = { + id: "conversation-a", + account_id: profile.id, + platform: profile.platform, + peer_uid: "peer-a", + peer_name: "客户", + }; + const dataProvider = provider({ + creatorSubscribe, + getList: vi.fn(({ resource }) => + Promise.resolve({ + data: + resource === "creator-accounts" + ? [profile] + : resource === "creator-conversations" + ? [conversation] + : [], + total: resource === "creator-conversations" ? 1 : 0, + }), + ), + }); + renderPage(, dataProvider); + fireEvent.click(await screen.findByRole("tab", { name: "私信" })); + expect(await screen.findByText("后台更新连接中")).toBeTruthy(); + expect(creatorSubscribe).toHaveBeenCalledWith( + "/creator/updates", + expect.any(Function), + expect.any(AbortSignal), + expect.any(Function), + ); + }); + it("isolates private messages by account and confirms one durable send", async () => { const conversation = { id: "conversation-a", diff --git a/web/src/CreatorWorkbenchPage.jsx b/web/src/CreatorWorkbenchPage.jsx index 5fa43ba..ba8c1d9 100644 --- a/web/src/CreatorWorkbenchPage.jsx +++ b/web/src/CreatorWorkbenchPage.jsx @@ -21,6 +21,7 @@ const tabs = [ ["comments", "评论"], ["leads", "线索"], ["rules", "规则"], + ["events", "事件监听"], ["dms", "私信"], ["operations", "操作记录"], ]; @@ -71,19 +72,58 @@ function operationStatus(state, reason) { return `${labels[state] || state || "未知状态"}${reason ? `:${reason}` : ""}`; } +function PageNav({ page, total, count, hasNext, onPageChange }) { + return ( +
+ + 当前页 {count} 条 · 共 {total} 条 + +
+ + +
+
+ ); +} + export function CreatorWorkbenchPage() { const dataProvider = useDataProvider()("default"); const [tab, setTab] = useState("comments"); const [data, setData] = useState([]); const [dataTab, setDataTab] = useState(""); + const [listPage, setListPage] = useState(1); + const [listPageInfo, setListPageInfo] = useState({ + total: 0, + hasNext: false, + }); const [rules, setRules] = useState([]); const [accounts, setAccounts] = useState([]); const [conversations, setConversations] = useState([]); const [conversationAccountID, setConversationAccountID] = useState(""); const [messages, setMessages] = useState([]); + const [messagePage, setMessagePage] = useState(1); + const [messagePageInfo, setMessagePageInfo] = useState({ + total: 0, + hasNext: false, + }); const [messagePending, setMessagePending] = useState(false); const [messageError, setMessageError] = useState(null); const [messageRetry, setMessageRetry] = useState(0); + const [updateStatus, setUpdateStatus] = useState("disconnected"); const [conversationID, setConversationID] = useState(""); const [dmAccountID, setDmAccountID] = useState(""); const [dmText, setDmText] = useState(""); @@ -92,6 +132,7 @@ export function CreatorWorkbenchPage() { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [analyzing, setAnalyzing] = useState(""); + const [selectedCommentIDs, setSelectedCommentIDs] = useState([]); const [ruleForm, setRuleForm] = useState({ name: "", source_type: "all", @@ -125,11 +166,15 @@ export function CreatorWorkbenchPage() { try { if (tab === "comments") { const [comments, ruleList] = await Promise.all([ - dataProvider.getList({ resource: "creator-comments" }), + dataProvider.getList({ + resource: "creator-comments", + pagination: { currentPage: listPage, pageSize: 25 }, + }), dataProvider.getList({ resource: "creator-rules" }), ]); if (sequence !== loadSequence.current) return; setData(comments.data); + setListPageInfo({ total: comments.total, hasNext: comments.hasNext }); setDataTab("comments"); setRules(ruleList.data); } else if (tab === "leads") { @@ -139,6 +184,15 @@ export function CreatorWorkbenchPage() { if (sequence !== loadSequence.current) return; setData(result.data); setDataTab("leads"); + } else if (tab === "events") { + const result = await dataProvider.getList({ + resource: "creator-events", + pagination: { currentPage: listPage, pageSize: 25 }, + }); + if (sequence !== loadSequence.current) return; + setData(result.data); + setListPageInfo({ total: result.total, hasNext: result.hasNext }); + setDataTab("events"); } else if (tab === "rules") { const result = await dataProvider.getList({ resource: "creator-rules", @@ -199,12 +253,14 @@ export function CreatorWorkbenchPage() { }, [dataProvider]); useEffect(() => { if (tab !== "dms" || dmAccountID) load(); - }, [tab, dmAccountID]); + }, [tab, dmAccountID, listPage]); const switchTab = (nextTab) => { if (nextTab === tab) return; loadSequence.current += 1; setData([]); setDataTab(""); + setListPage(1); + setListPageInfo({ total: 0, hasNext: false }); setError(null); setTab(nextTab); }; @@ -221,10 +277,18 @@ export function CreatorWorkbenchPage() { setMessageError(null); dataProvider .creatorGet( - `/creator/conversations/${encodeURIComponent(conversationID)}/messages`, + `/creator/conversations/${encodeURIComponent(conversationID)}/messages?page=${messagePage}&page_size=25`, ) .then((result) => { - if (sequence === messageSequence.current) setMessages(result); + if (sequence !== messageSequence.current) return; + const items = Array.isArray(result) ? result : result.data || []; + setMessages(items); + setMessagePageInfo({ + total: Array.isArray(result) + ? items.length + : (result.total ?? items.length), + hasNext: Array.isArray(result) ? false : Boolean(result.has_next), + }); }) .catch((loadError) => { if (sequence === messageSequence.current) setMessageError(loadError); @@ -235,19 +299,51 @@ export function CreatorWorkbenchPage() { return () => { if (sequence === messageSequence.current) setMessagePending(false); }; - }, [conversationID, tab, dataProvider, messageRetry]); + }, [conversationID, tab, dataProvider, messageRetry, messagePage]); useEffect(() => { if (reply.target_comment_id) { writeDraft(replyDraftKey(reply.target_comment_id), reply); } }, [reply]); useEffect(() => { - if (tab !== "dms" || !conversationID) return undefined; - const timer = window.setInterval(() => { - setMessageRetry((value) => value + 1); - }, 10000); - return () => window.clearInterval(timer); - }, [tab, conversationID]); + if (tab !== "dms" || typeof dataProvider.creatorSubscribe !== "function") { + setUpdateStatus("disconnected"); + return undefined; + } + const controller = new AbortController(); + let stopped = false; + let retryTimer; + const connect = () => { + if (stopped) return; + setUpdateStatus("connecting"); + dataProvider + .creatorSubscribe( + "/creator/updates", + () => { + load(); + setMessageRetry((value) => value + 1); + }, + controller.signal, + (status) => setUpdateStatus(status), + ) + .then(() => { + if (!stopped) retryTimer = window.setTimeout(connect, 5000); + }) + .catch((streamError) => { + if (stopped || streamError.name === "AbortError") return; + setUpdateStatus("disconnected"); + setMessageError(streamError); + retryTimer = window.setTimeout(connect, 5000); + }); + }; + connect(); + return () => { + stopped = true; + controller.abort(); + window.clearTimeout(retryTimer); + setUpdateStatus("disconnected"); + }; + }, [tab, dmAccountID, dataProvider]); useEffect(() => { if (!dmAccountID || !conversationID) return; const draft = readDraft(dmDraftKey(dmAccountID, conversationID)); @@ -263,6 +359,37 @@ export function CreatorWorkbenchPage() { } }, [dmAccountID, conversationID, dmOperationKey, dmText]); + const analyzeSelected = async (ruleID) => { + if (!ruleID || !selectedCommentIDs.length) return; + setAnalyzing(`batch:${ruleID}`); + setNotice(null); + try { + const response = await dataProvider.creatorCreate( + "/creator/comments/analyze", + { + comment_ids: selectedCommentIDs, + rule_id: ruleID, + }, + ); + const failed = (response.items || []).filter((item) => item.error).length; + setNotice({ + variant: failed ? "warning" : "success", + text: `已分析 ${selectedCommentIDs.length - failed} 条评论${failed ? `,${failed} 条失败` : ""}。`, + }); + setSelectedCommentIDs([]); + } catch (actionError) { + setNotice({ + variant: "warning", + text: conflictMessage( + actionError, + "批量 AI 分析不可用,未生成虚假结果", + ), + }); + } finally { + setAnalyzing(""); + } + }; + const analyze = async (commentID, ruleID) => { setAnalyzing(`${commentID}:${ruleID}`); setNotice(null); @@ -418,12 +545,16 @@ export function CreatorWorkbenchPage() { setConversationAccountID(""); setConversations([]); setConversationID(""); + setMessagePage(1); + setMessagePageInfo({ total: 0, hasNext: false }); setDmText(""); setDmOperationKey(""); }; const switchConversation = (nextID) => { if (nextID === conversationID || !canSwitchDM()) return; setConversationID(nextID); + setMessagePage(1); + setMessagePageInfo({ total: 0, hasNext: false }); setDmText(""); setDmOperationKey(""); }; @@ -553,12 +684,44 @@ export function CreatorWorkbenchPage() { ) : null} +
+ + setSelectedCommentIDs((current) => + event.target.checked + ? [...new Set([...current, comment.id])] + : current.filter((id) => id !== comment.id), + ) + } + /> + 加入批量分析 +

{comment.author_name || "未知用户"}{" "} @@ -601,6 +764,13 @@ export function CreatorWorkbenchPage() { ))}

+ ) : tab === "leads" ? ( + ) : tab === "events" ? ( + +
+ {visibleData.map((event) => ( + + +
+
+

+ {event.event_type} ·{" "} + {event.interactor_uid || "UID 不可用"} +

+

+ 作品:{event.work_id || "—"} · 事件键:{event.event_key} +

+ {event.message_text ? ( +

+ {event.message_text} +

+ ) : null} +
+ +
+

+ 平台时间: + {event.platform_event_at + ? dateTime(event.platform_event_at) + : "不可用"}{" "} + · 网关接收: + {event.gateway_received_at + ? dateTime(event.gateway_received_at) + : "不可用"}{" "} + · 控制面接收:{dateTime(event.received_at)} +

+
+
+ ))} +
+ +
) : tab === "rules" ? (
@@ -878,8 +1115,26 @@ export function CreatorWorkbenchPage() { -
-

消息记录

+
+
+

消息记录

+ +
+
diff --git a/web/src/LoginPage.jsx b/web/src/LoginPage.jsx index 95e6c7f..fe8da53 100644 --- a/web/src/LoginPage.jsx +++ b/web/src/LoginPage.jsx @@ -1,29 +1,40 @@ -import { useState } from 'react' -import { useNavigate } from 'react-router' -import { Alert, Button, Field, Input } from './lib/ui.jsx' +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { Alert, Button, Field, Input } from "./lib/ui.jsx"; // 开发阶段:本地保存 Basic 凭证,仅在请求层自动附带。开发代理(vite)不注入 Authorization。 export function LoginPage() { - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [busy, setBusy] = useState(false) - const navigate = useNavigate() + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const navigate = useNavigate(); async function submit(event) { - event.preventDefault() - if (!username.trim() || !password) return - setBusy(true); setError('') + event.preventDefault(); + if (!username.trim() || !password) return; + setBusy(true); + setError(""); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15000); try { - const response = await fetch('/api/gateways', { headers: { Authorization: `Basic ${btoa(`${username}:${password}`)}` } }) - if (response.status === 401) throw new Error('用户名或密码不正确') - if (!response.ok) throw new Error(`认证服务不可用(${response.status})`) - localStorage.setItem('creatorhub.auth', `${username}:${password}`) - navigate('/accounts') + const response = await fetch("/api/gateways", { + headers: { Authorization: `Basic ${btoa(`${username}:${password}`)}` }, + signal: controller.signal, + }); + if (response.status === 401) throw new Error("用户名或密码不正确"); + if (!response.ok) throw new Error(`认证服务不可用(${response.status})`); + localStorage.setItem("creatorhub.auth", `${username}:${password}`); + navigate("/accounts"); } catch (reason) { - setError(reason.message) + setError( + reason.name === "AbortError" + ? "认证请求超时,请检查控制面连接" + : reason.message || "登录失败", + ); } finally { - setBusy(false) + window.clearTimeout(timeout); + setBusy(false); } } @@ -31,20 +42,49 @@ export function LoginPage() {
-