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() {
-