diff --git a/AGENTS.md b/AGENTS.md index 7208dd0..38dad1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,6 @@ ## 宪法 +- 你是我的合伙人,不是只会干活的助手,我的需求你需要结合当前系统实际定位给出合理性建议,而不是一味地遵守(除非我很强硬地需要这么做),如果我说的不对,你要第一时间指出,并给出合理结论。 - 任何涉及文件的调研或修改,如果当前是 git 仓库,需要先同步远程提交到本地,避免调研过时问题。 - 基于 TDD 进行功能的开发与业务变更,单元测试覆盖率要保证 65% 以上 - 禁止自动搞 E2E 测试:不主动编写、不主动运行任何 E2E 测试(Playwright/Cypress 等),仅在使用者明确要求时进行;单元测试要求不变 @@ -39,12 +40,14 @@ 产品方向:平台当前仅支持抖音(douyin);小红书等其他平台的业务与代码已全部移除,不得重新引入。 -环境先行:创建时只创建抖音浏览器环境,不填写昵称、UID 或 Cookie,不创建占位账号。未登录环境在账号列表独立显示。首次浏览器身份核验成功后,同一事务创建/关联账号并同步真实 UID、昵称、头像、抖音号与 secUID;同一 UID 只能绑定一个账号,已有绑定禁止换绑或自动覆盖。后续同 UID 核验更新平台资料,不覆盖本地备注和业务设置。浏览器 profile_id 与指纹 seed 在环境创建时确定,账号绑定不得改变它们;旧环境保持原 profile_id 和 seed,不重建浏览器或清空 Cookie。指纹 seed 由环境独立序列分配,不再依赖账号 ID。 +环境先行:创建时只创建抖音浏览器环境,不填写昵称、UID 或 Cookie,不创建占位账号。未登录环境与已登录账号在“我的账号”同一列表展示,不设独立区域;缺失资料使用前端默认值,不创建占位账号,不写回数据库。待登录行仅保留“登录并同步账号”,不提供账号详情或其他账号操作。统一列表按共用的浏览器环境数据库 ID 倒序排列,新建项在前;登录绑定保留环境 ID,不改变行的相对位置。首次浏览器身份核验成功后,同一事务创建/关联账号并同步真实 UID、昵称、头像、抖音号与 secUID;同一 UID 只能绑定一个账号,已有绑定禁止换绑或自动覆盖。后续同 UID 核验更新平台资料,不覆盖本地备注和业务设置。浏览器 profile_id 与指纹 seed 在环境创建时确定,账号绑定不得改变它们;旧环境保持原 profile_id 和 seed,不重建浏览器或清空 Cookie。指纹 seed 由环境独立序列分配,不再依赖账号 ID。 作品封面:自有账号与监测账号采集后都下载封面,图片保存为静态文件 `<账号 UID>/<作品 ID>.<实际图片扩展名>`,不把图片字节存入数据库。目录由 `CREATOR_COVER_DIR` 配置,开发默认 `.data/covers`,容器使用持久卷;作品封面接口只读取本地文件,不使用远程图片兜底。下载与文件错误必须可追踪并反映到同步结果;同一来源每次同步最多回填 40 张历史缺失封面。 账号创建交互:在“我的账号”列表通过 Modal 创建,网关必选,浏览器指纹为默认折叠的可选配置;创建成功关闭弹窗并刷新列表,不保留独立新增页面。未登录环境及空昵称仅在前端显示“待登录”,不将此文案写入数据库。 +自有账号作品:作品总数使用抖音最新非空 `aweme_count`,已保存 `work_count` 仅表示采集进度,不得互相替代;未知总数与零作品必须区分。自有账号作品库保存平台分页返回的全部历史作品,不受采集回看天数限制;回看天数仍限制监控账号作品和评论采集。详情分页浏览已采集作品,并明确展示采集进度与未完整状态,不能将采集成功等同于已采齐。 + 前端框架:Umi Max 4.7 + React 19; 组件库:antd 6.6.5 + @ant-design/pro-components 3.x(beta 线)+ @ant-design/icons;仅使用 antd/pro 默认组件原样实现,禁止自定义封装与样式魔改;组件不满足业务时改交互逻辑适配组件; diff --git a/browser_gateway/server/http.py b/browser_gateway/server/http.py index 78a2609..c1e0129 100644 --- a/browser_gateway/server/http.py +++ b/browser_gateway/server/http.py @@ -744,7 +744,7 @@ def valid_douyin_url(raw: object) -> bool: if parsed.path == DOUYIN_PROFILE_OTHER_PATH: return valid_douyin_profile_query(query) if parsed.path == DOUYIN_WORKS_PATH: - return valid_douyin_api_query(query, "sec_user_id", "max_cursor") + return valid_douyin_works_query(query) if parsed.path == DOUYIN_WORK_DETAIL_PATH: return valid_douyin_work_detail_query(query) if parsed.path == DOUYIN_COMMENTS_PATH: @@ -773,6 +773,37 @@ def valid_douyin_profile_query(query: dict[str, list[str]]) -> bool: ) +def valid_douyin_works_query(query: dict[str, list[str]]) -> bool: + # Keep the works endpoint pinned to the parameters used by Douyin's web + # profile page; do not let the pagination fix widen the restricted URL surface. + rest = query + if "a_bogus" in query: + if len(query["a_bogus"]) != 1 or not DOUYIN_SIGNATURE_RE.fullmatch( + query["a_bogus"][0] + ): + return False + rest = {key: value for key, value in query.items() if key != "a_bogus"} + required = { + "aid": ["6383"], + "device_platform": ["webapp"], + "channel": ["channel_pc_web"], + "count": ["18"], + "locate_query": ["false"], + "show_live_replay_strategy": ["1"], + "need_time_list": ["1"], + "time_list_query": ["0"], + "cut_version": ["1"], + "publish_video_strategy_type": ["2"], + "from_user_page": ["0"], + } + return ( + set(rest) == set(required) | {"sec_user_id", "max_cursor"} + and all(rest.get(key) == value for key, value in required.items()) + and valid_account_key_query(rest, "sec_user_id") + and numeric_cursor(rest.get("max_cursor")) + ) + + def valid_douyin_api_query( query: dict[str, list[str]], account_field: str, cursor_field: str ) -> bool: diff --git a/browser_gateway/test_gateway.py b/browser_gateway/test_gateway.py index 71498f1..36944b7 100644 --- a/browser_gateway/test_gateway.py +++ b/browser_gateway/test_gateway.py @@ -198,6 +198,19 @@ class GatewayValidationTests(unittest.TestCase): "https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=1&count=20" ) ) + works_url = ( + "https://www.douyin.com/aweme/v1/web/aweme/post/?" + "aid=6383&device_platform=webapp&channel=channel_pc_web&" + "sec_user_id=MS4wLjABAAAA9f_a7k0bzVizLYXlpC7R61EIaqJ8Ordug7yp7AB8fGKuuF8Fzqk5_DM-eutXnPIK&" + "count=18&max_cursor=1746702477000&locate_query=false&" + "show_live_replay_strategy=1&need_time_list=1&time_list_query=0&" + "cut_version=1&publish_video_strategy_type=2&from_user_page=0" + ) + self.assertTrue(valid_douyin_url(works_url)) + self.assertTrue(valid_douyin_url(works_url + "&a_bogus=abcdefghijklmnop")) + self.assertFalse(valid_douyin_url(works_url + "&unexpected=1")) + self.assertFalse(valid_douyin_url(works_url.replace("count=18", "count=20"))) + self.assertFalse(valid_douyin_url(works_url.replace("max_cursor=1746702477000", "max_cursor=-1"))) self.assertFalse(valid_douyin_url("https://www.douyin.com.evil/")) self.assertTrue(is_douyin_url("https://www.douyin.com/video/123")) self.assertFalse(is_douyin_url("https://www.douyin.com.evil/video/123")) diff --git a/internal/controlplane/api/account_environment_order_test.go b/internal/controlplane/api/account_environment_order_test.go new file mode 100644 index 0000000..0a86ee2 --- /dev/null +++ b/internal/controlplane/api/account_environment_order_test.go @@ -0,0 +1,14 @@ +package api + +import ( + "testing" + + hub "git.ipao.vip/rogee/creator-hub/internal/environment" +) + +func TestAccountEnvironmentViewPreservesDatabaseOrderingID(t *testing.T) { + view := accountEnvironmentView(hub.EnvironmentContext{Env: hub.Env{ID: 42, Alias: "env-order"}}) + if view["id"] != int64(42) { + t.Fatalf("account environment is missing its shared ordering ID: %#v", view) + } +} diff --git a/internal/controlplane/api/accounts_operations.go b/internal/controlplane/api/accounts_operations.go index 0571054..0a36d62 100644 --- a/internal/controlplane/api/accounts_operations.go +++ b/internal/controlplane/api/accounts_operations.go @@ -243,7 +243,7 @@ func PhaseAError(c fiber.Ctx, err error) error { func accountEnvironmentView(environment hub.EnvironmentContext) map[string]any { scheduleStatus, scheduleBlockReason := environmentScheduleReadiness(environment) return map[string]any{ - "alias": environment.Alias, "name": environment.Name, "gateway": environment.Gateway, + "id": environment.ID, "alias": environment.Alias, "name": environment.Name, "gateway": environment.Gateway, "binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID, "runtime_cleanup_pending": environment.RuntimeCleanupPending, "network_exit_id": environment.Exit.ID, "network_exit_health": environment.Exit.HealthStatus, diff --git a/internal/controlplane/api/environment_login_test.go b/internal/controlplane/api/environment_login_test.go index f01452e..7dc30b0 100644 --- a/internal/controlplane/api/environment_login_test.go +++ b/internal/controlplane/api/environment_login_test.go @@ -95,8 +95,8 @@ func TestPendingEnvironmentLoginRoutesKeepBrowserIdentity(t *testing.T) { } } pending, err := hs.ListPendingEnvironments(ctx) - if err != nil || len(pending) != 1 { - t.Fatalf("pending browser created account: %v %v", pending, err) + if err != nil || len(pending) != 1 || pending[0].ID != env.ID || env.ID < 1 { + t.Fatalf("pending browser created account or lost its ordering ID: %v %v", pending, err) } logged = true response := do(app, http.MethodPost, route+"/verify", `{}`) @@ -108,7 +108,7 @@ func TestPendingEnvironmentLoginRoutesKeepBrowserIdentity(t *testing.T) { t.Fatalf("login response: %#v %v", result, err) } bound, err := hs.GetEnvironmentContext(ctx, alias) - if err != nil || bound.AccountID != result.AccountID || bound.RuntimeID != "login-runtime" || bound.ProfileID != alias || bound.Fingerprint.Seed != env.Fingerprint.Seed { + if err != nil || bound.AccountID != result.AccountID || bound.RuntimeID != "login-runtime" || bound.ProfileID != alias || bound.Fingerprint.Seed != env.Fingerprint.Seed || bound.ID != env.ID { t.Fatalf("binding changed browser: %#v %v", bound, err) } response = do(app, http.MethodPost, route+"/verify", `{}`) diff --git a/internal/controlplane/app/app_test.go b/internal/controlplane/app/app_test.go index 80a5c7a..a7f958a 100644 --- a/internal/controlplane/app/app_test.go +++ b/internal/controlplane/app/app_test.go @@ -283,6 +283,7 @@ func TestControlPlaneRegisteredRouteMatrix(t *testing.T) { want int }{ {http.MethodPost, "/api/not-registered", http.StatusNotFound}, + {http.MethodPost, "/api/phase-a/accounts", http.StatusNotFound}, {http.MethodDelete, "/api/not-registered", http.StatusNotFound}, } { t.Run("unregistered "+route.method, func(t *testing.T) { @@ -359,7 +360,6 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase { {http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound}, {http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound}, - {http.MethodPost, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusBadRequest}, {http.MethodGet, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusOK}, {http.MethodGet, "/api/phase-a/accounts/:id", "/api/phase-a/accounts/missing", "", http.StatusNotFound}, {http.MethodPost, "/api/phase-a/accounts/:id/pause", "/api/phase-a/accounts/missing/pause", "", http.StatusNotFound}, diff --git a/internal/creator/accounts.go b/internal/creator/accounts.go index c65c611..a66c152 100644 --- a/internal/creator/accounts.go +++ b/internal/creator/accounts.go @@ -234,8 +234,10 @@ func (s *Store) ownedWorkStats(ctx context.Context) (map[string]ownedWorkStat, e // AccountCollectionStatus 自有账号采集状态(checkpoint 形态,对齐竞品的 sync_status 展示语义)。 type AccountCollectionStatus struct { - Works AccountCheckpointStatus `json:"works"` - Comments AccountCheckpointStatus `json:"comments"` + WorkCount int64 `json:"work_count"` + AwemeCount *int64 `json:"aweme_count"` + Works AccountCheckpointStatus `json:"works"` + Comments AccountCheckpointStatus `json:"comments"` } type AccountCheckpointStatus struct { @@ -254,6 +256,20 @@ func (s *Store) GetAccountCollectionStatus(ctx context.Context, accountID string return AccountCollectionStatus{}, ErrInvalid } status := AccountCollectionStatus{} + var total sql.NullInt64 + err := s.db.QueryRowContext(ctx, ` + SELECT + (SELECT COUNT(*) FROM creator_work WHERE source_type = $1 AND source_id = $2), + (SELECT metric.aweme_count FROM creator_account_metric metric + JOIN social_account account ON account.id = metric.account_id + WHERE account.account_id = $2 AND metric.aweme_count IS NOT NULL + ORDER BY metric.collected_at DESC LIMIT 1)`, SourceOwned, accountID).Scan(&status.WorkCount, &total) + if err != nil { + return AccountCollectionStatus{}, databaseError(err) + } + if total.Valid { + status.AwemeCount = &total.Int64 + } for _, kind := range []struct { name string pointer *AccountCheckpointStatus diff --git a/internal/creator/collection.go b/internal/creator/collection.go index 0062e37..8bcd72c 100644 --- a/internal/creator/collection.go +++ b/internal/creator/collection.go @@ -403,7 +403,9 @@ func (s *Store) CollectSource(ctx context.Context, platform, sourceType, sourceI return err } report.WorksSeen++ - if work.PublishedAt != nil && work.PublishedAt.Before(report.WindowStart) { + // Owned accounts keep their entire work library; the lookback window + // still bounds competitor works and the later comments phase. + if sourceType == SourceCompetitor && work.PublishedAt != nil && work.PublishedAt.Before(report.WindowStart) { continue } work.Platform, work.SourceType, work.SourceID = platform, sourceType, sourceID diff --git a/internal/creator/owned_collection_progress_test.go b/internal/creator/owned_collection_progress_test.go new file mode 100644 index 0000000..899abf7 --- /dev/null +++ b/internal/creator/owned_collection_progress_test.go @@ -0,0 +1,145 @@ +package creator + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" +) + +type ownedHistoryCollector struct { + pages map[string]WorkPage + workCursors []string + commentWorks []string +} + +func (c *ownedHistoryCollector) ListWorks(_ context.Context, _, cursor string) (WorkPage, error) { + c.workCursors = append(c.workCursors, cursor) + page, ok := c.pages[cursor] + if !ok { + return WorkPage{}, fmt.Errorf("unexpected cursor %q", cursor) + } + return page, nil +} + +func (c *ownedHistoryCollector) ListTopLevelComments(_ context.Context, workKey, _ string) (CommentPage, error) { + c.commentWorks = append(c.commentWorks, workKey) + return CommentPage{}, nil +} + +func TestCreatorPostgresOwnedCollectionIncludesHistoryAndProgress(t *testing.T) { + store, accounts, ctx := openCreatorIntegrationStore(t) + accountID := createIntegrationAccount(t, ctx, accounts, fmt.Sprint(time.Now().UnixNano())) + now := time.Now().UTC().Truncate(time.Second) + settings, err := store.GetSettings(ctx) + if err != nil { + t.Fatal(err) + } + old := now.Add(-time.Duration(settings.LookbackDays+365) * 24 * time.Hour) + recent := now.Add(-time.Hour) + pages := map[string]WorkPage{"": {NextCursor: "history", HasMore: true}} + for i := 0; i < 28; i++ { + published := old + if i == 0 { + published = recent + } + work := WorkInput{WorkKey: fmt.Sprintf("owned-history-%02d", i), Title: fmt.Sprintf("作品 %d", i), PublishedAt: &published, PublishedAtStatus: "verified"} + cursor := "history" + if i < 14 { + cursor = "" + } + page := pages[cursor] + page.Items = append(page.Items, work) + pages[cursor] = page + } + collector := &ownedHistoryCollector{pages: pages} + total := int64(30) + if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: now.Add(-time.Minute), AwemeCount: &total}); err != nil { + t.Fatal(err) + } + // A newer snapshot lacking aweme_count must not erase the known platform total. + followers := int64(100) + if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: now, FollowerCount: &followers}); err != nil { + t.Fatal(err) + } + report, err := store.CollectSource(ctx, PlatformDouyin, SourceOwned, accountID, collector, now) + if err != nil || !report.PaginationComplete || report.WorksSeen != 28 || report.WorksSaved != 28 { + t.Fatalf("full history collection: report=%+v err=%v", report, err) + } + if len(collector.workCursors) != 2 || collector.workCursors[1] != "history" { + t.Fatalf("history pagination: %v", collector.workCursors) + } + // History is listed, but comments remain limited to the configured window. + if len(collector.commentWorks) != 1 || collector.commentWorks[0] != "owned-history-00" { + t.Fatalf("historical comments should not be scheduled: %v", collector.commentWorks) + } + first, err := store.ListWorksPage(ctx, WorkFilter{SourceType: SourceOwned, SourceID: accountID}, 1, 25) + if err != nil || len(first.Data) != 25 || first.Total != 28 || !first.HasNext { + t.Fatalf("first history page: page=%+v err=%v", first, err) + } + second, err := store.ListWorksPage(ctx, WorkFilter{SourceType: SourceOwned, SourceID: accountID}, 2, 25) + if err != nil || len(second.Data) != 3 || second.Total != 28 || second.HasNext { + t.Fatalf("last history page: page=%+v err=%v", second, err) + } + status, err := store.GetAccountCollectionStatus(ctx, accountID) + if err != nil { + t.Fatal(err) + } + // Read JSON fields to keep the regression test compilable before implementation. + assertCollectionCounts(t, status, 28, &total) + views, err := store.ListAccountMonitorViews(ctx) + if err != nil || len(views) != 1 || views[0].WorkCount != 28 || views[0].AwemeCount == nil || *views[0].AwemeCount != total { + t.Fatalf("list/detail count consistency: views=%+v err=%v", views, err) + } + // A full subsequent run must not count duplicate works twice. + if _, err := store.CollectSource(ctx, PlatformDouyin, SourceOwned, accountID, collector, now.Add(time.Second)); err != nil { + t.Fatal(err) + } + status, err = store.GetAccountCollectionStatus(ctx, accountID) + if err != nil { + t.Fatal(err) + } + assertCollectionCounts(t, status, 28, &total) +} + +func assertCollectionCounts(t *testing.T, status AccountCollectionStatus, collected int64, total *int64) { + t.Helper() + data, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if string(fields["work_count"]) != fmt.Sprint(collected) { + t.Fatalf("collected count: status=%s want=%d", data, collected) + } + want := "null" + if total != nil { + want = fmt.Sprint(*total) + } + if string(fields["aweme_count"]) != want { + t.Fatalf("platform total: status=%s want=%s", data, want) + } +} + +func TestCreatorPostgresCollectionProgressDistinguishesUnknownAndZero(t *testing.T) { + store, accounts, ctx := openCreatorIntegrationStore(t) + accountID := createIntegrationAccount(t, ctx, accounts, fmt.Sprint(time.Now().UnixNano())) + status, err := store.GetAccountCollectionStatus(ctx, accountID) + if err != nil || status.Works.Status != "pending" { + t.Fatalf("pending status: %+v err=%v", status, err) + } + assertCollectionCounts(t, status, 0, nil) + zero := int64(0) + if err := store.RecordAccountMetric(ctx, AccountMetricInput{AccountID: accountID, CollectedAt: time.Now().UTC(), AwemeCount: &zero}); err != nil { + t.Fatal(err) + } + status, err = store.GetAccountCollectionStatus(ctx, accountID) + if err != nil { + t.Fatal(err) + } + assertCollectionCounts(t, status, 0, &zero) +} diff --git a/internal/environment/environment.go b/internal/environment/environment.go index ec836dc..3167a6f 100644 --- a/internal/environment/environment.go +++ b/internal/environment/environment.go @@ -462,7 +462,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro var runtimeID, runtimeNetworkID, runtimeNodeID, cleanupRuntimeID, cleanupNetworkID sql.NullString var cleanupBindingVersion sql.NullInt64 err = tx.QueryRowContext(ctx, ` - SELECT environment.alias, environment.name, gateway.name, + SELECT environment.id, environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at, COALESCE(account.account_id, ''), COALESCE(account.status, ''), environment.profile_id, environment.version, environment.runtime_cleanup_pending, environment.runtime_cleanup_binding_version, @@ -479,7 +479,7 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro JOIN gateway ON gateway.id = environment.gateway_id LEFT JOIN network_exit network ON network.id = environment.exit_id WHERE environment.alias = $1`, alias). - Scan(&result.Alias, &result.Name, &result.Gateway, &encoded, &result.CreatedAt, + Scan(&result.ID, &result.Alias, &result.Name, &result.Gateway, &encoded, &result.CreatedAt, &result.AccountID, &result.AccountStatus, &result.ProfileID, &result.BindingVersion, &result.RuntimeCleanupPending, &cleanupBindingVersion, &cleanupRuntimeID, &cleanupNetworkID, diff --git a/internal/environment/standalone.go b/internal/environment/standalone.go index 8e80dad..bc59dff 100644 --- a/internal/environment/standalone.go +++ b/internal/environment/standalone.go @@ -24,7 +24,7 @@ func (s *Store) CreateStandaloneEnv(ctx context.Context, gateway string, fingerp } func (s *Store) ListPendingEnvironments(ctx context.Context) ([]EnvironmentContext, error) { - rows, err := s.db.QueryContext(ctx, `SELECT alias FROM browser_env WHERE account_id IS NULL ORDER BY created_at,id`) + rows, err := s.db.QueryContext(ctx, `SELECT alias FROM browser_env WHERE account_id IS NULL ORDER BY id DESC`) if err != nil { return nil, fmt.Errorf("list pending browser environments: %w", err) } diff --git a/internal/environment/standalone_order_test.go b/internal/environment/standalone_order_test.go new file mode 100644 index 0000000..7f5eec8 --- /dev/null +++ b/internal/environment/standalone_order_test.go @@ -0,0 +1,46 @@ +package environment + +import ( + "context" + "encoding/json" + "os" + "testing" +) + +func TestPendingEnvironmentDatabaseIDOrder(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") + } + ctx := context.Background() + store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL)) + t.Cleanup(func() { _ = store.Close() }) + if _, err := store.CreateGateway(ctx, "order-gateway", "http://127.0.0.1:8090", "order-gateway-test-token"); err != nil { + t.Fatal(err) + } + first, err := store.CreateStandaloneEnv(ctx, "order-gateway", Fingerprint{}) + if err != nil { + t.Fatal(err) + } + second, err := store.CreateStandaloneEnv(ctx, "order-gateway", Fingerprint{}) + if err != nil { + t.Fatal(err) + } + if first.ID < 1 || second.ID <= first.ID { + t.Fatalf("environment IDs must increase: first=%d second=%d", first.ID, second.ID) + } + pending, err := store.ListPendingEnvironments(ctx) + if err != nil || len(pending) != 2 || pending[0].ID != second.ID || pending[1].ID != first.ID { + t.Fatalf("pending environments must be newest first: %#v err=%v", pending, err) + } + data, err := json.Marshal(second) + if err != nil { + t.Fatal(err) + } + var response struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(data, &response); err != nil || response.ID != second.ID { + t.Fatalf("database ID missing from environment JSON: %s err=%v", data, err) + } +} diff --git a/internal/environment/store.go b/internal/environment/store.go index dba7e2b..195351f 100644 --- a/internal/environment/store.go +++ b/internal/environment/store.go @@ -223,6 +223,7 @@ type Gateway struct { // Env 是一个浏览器环境;浏览器安装和默认运行时由 gateway 宿主机配置。 type Env struct { + ID int64 `json:"id"` Alias string `json:"alias"` Name string `json:"name"` Gateway string `json:"gateway"` @@ -452,7 +453,7 @@ func (s *Store) DeleteGateway(ctx context.Context, name string) error { func (s *Store) ListEnvs(ctx context.Context) ([]Env, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at + SELECT environment.id, environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at FROM browser_env environment JOIN gateway ON gateway.id = environment.gateway_id ORDER BY environment.created_at, environment.alias`) @@ -476,7 +477,7 @@ func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) { return Env{}, ErrInvalid } rows, err := s.db.QueryContext(ctx, ` - SELECT environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at + SELECT environment.id, environment.alias, environment.name, gateway.name, environment.fingerprint, environment.created_at FROM browser_env environment JOIN gateway ON gateway.id = environment.gateway_id WHERE environment.alias = $1`, alias) @@ -536,7 +537,7 @@ func (s *Store) DeleteAccountEnvironment(ctx context.Context, accountID string) func scanEnv(rows *sql.Rows) (Env, error) { var env Env var encoded []byte - if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &encoded, &env.CreatedAt); err != nil { + if err := rows.Scan(&env.ID, &env.Alias, &env.Name, &env.Gateway, &encoded, &env.CreatedAt); err != nil { return Env{}, errors.New("decode browser env") } if len(encoded) > 0 { diff --git a/internal/platform/douyin/creator_collector.go b/internal/platform/douyin/creator_collector.go index 210e5e0..ff50503 100644 --- a/internal/platform/douyin/creator_collector.go +++ b/internal/platform/douyin/creator_collector.go @@ -237,9 +237,17 @@ func (c CreatorCollector) ListWorks(ctx context.Context, accountKey, cursor stri maxCursor = cursor } query := douyinAPIQuery() + query.Set("channel", "channel_pc_web") query.Set("sec_user_id", accountKey) - query.Set("count", "20") + query.Set("count", "18") query.Set("max_cursor", maxCursor) + query.Set("locate_query", "false") + query.Set("show_live_replay_strategy", "1") + query.Set("need_time_list", "1") + query.Set("time_list_query", "0") + query.Set("cut_version", "1") + query.Set("publish_video_strategy_type", "2") + query.Set("from_user_page", "0") response, err := c.Browser.Get(ctx, worksEndpoint+"?"+query.Encode()) if err != nil { return creator.WorkPage{}, err diff --git a/internal/platform/douyin/creator_collector_test.go b/internal/platform/douyin/creator_collector_test.go index 45a2ee0..e5e31d6 100644 --- a/internal/platform/douyin/creator_collector_test.go +++ b/internal/platform/douyin/creator_collector_test.go @@ -101,6 +101,40 @@ func TestCreatorCollectorGuardsAndCollection(t *testing.T) { } } +func TestListWorksUsesCurrentDouyinWebPaginationParameters(t *testing.T) { + browser := &collectorBrowser{response: Response{Status: 200, Body: []byte(`{"status_code":0,"has_more":false,"max_cursor":0,"aweme_list":[]}`)}} + collector := CreatorCollector{Browser: browser, AccountKey: "MS4wLjABAAAAkey"} + if _, err := collector.ListWorks(context.Background(), "ignored", "1746702477000"); err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(browser.url) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + want := map[string]string{ + "channel": "channel_pc_web", + "sec_user_id": "MS4wLjABAAAAkey", + "count": "18", + "max_cursor": "1746702477000", + "locate_query": "false", + "show_live_replay_strategy": "1", + "need_time_list": "1", + "time_list_query": "0", + "cut_version": "1", + "publish_video_strategy_type": "2", + "from_user_page": "0", + } + if parsed.Path != "/aweme/v1/web/aweme/post/" { + t.Fatalf("unexpected endpoint: %s", parsed.Path) + } + for key, value := range want { + if query.Get(key) != value { + t.Errorf("query %s = %q, want %q (full query: %s)", key, query.Get(key), value, query.Encode()) + } + } +} + func TestParseCreatorCommentsPageAllowsEmptyComments(t *testing.T) { page, err := parseCreatorCommentsPage([]byte(`{"status_code":0,"has_more":false,"cursor":20,"comments":null}`)) if err != nil { diff --git a/web/src/components/accounts/AccountManagementList.tsx b/web/src/components/accounts/AccountManagementList.tsx index 274f64a..4b180a6 100644 --- a/web/src/components/accounts/AccountManagementList.tsx +++ b/web/src/components/accounts/AccountManagementList.tsx @@ -10,12 +10,13 @@ import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { remove, creatorUpdate, creatorGet, creatorAction } from '@/services/api'; import { accountReadiness, conflictMessage, dateTime } from '@/utils/helpers'; -import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination, useOverflowGrid } from '@/utils/table'; import AccountLoginModal from '@/components/accounts/AccountLoginModal'; import AccountCreateModal from '@/components/accounts/AccountCreateModal'; // 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。 interface EnvironmentView { + id?: number; gateway?: string; name?: string; alias?: string; @@ -25,15 +26,21 @@ interface EnvironmentView { network_exit_health?: string; } +interface PendingEnvironment extends EnvironmentView { + alias: string; + network_exit?: { id: string; health_status: string }; +} + interface Row { id: string; + pending_environment?: string; name: string; platform_account_key: string; avatar_url?: string; douyin_number?: string; tags?: string[]; runtime_status?: string; - business_status: 'normal' | 'muted' | 'banned' | 'deleted'; + business_status?: 'normal' | 'muted' | 'banned' | 'deleted'; environment?: EnvironmentView; work_count?: number; latest_published_at?: string | null; @@ -54,7 +61,6 @@ function formatCount(value?: number | null): string { export default function AccountManagementList() { const [rows, setRows] = useState([]); const [createOpen, setCreateOpen] = useState(false); - const [environments, setEnvironments] = useState<{ alias: string; gateway: string; runtime_id?: string }[]>([]); const [environmentTarget, setEnvironmentTarget] = useState(null); const [pending, setPending] = useState(true); const [error, setError] = useState(null); @@ -64,7 +70,7 @@ export default function AccountManagementList() { const [actionBusy, setActionBusy] = useState(''); const [loginTarget, setLoginTarget] = useState(null); const { message: messageApi } = App.useApp(); - const { vertical, horizontal } = useOverflowGrid(rows.length); + const { vertical } = useOverflowGrid(rows.length); const load = useCallback(async () => { setPending(true); @@ -73,13 +79,28 @@ export default function AccountManagementList() { const [rows, environments] = await Promise.all([ creatorGet('/creator/accounts/monitor-views'), creatorGet('/creator/environments'), ]); - setEnvironments(environments); - setRows( - (rows ?? []).map((account: any) => ({ - ...account, - name: account.name, - tags: account.tags ?? [] })), - ); + setRows([ + ...(rows ?? []).map((account: Row) => ({ ...account, tags: account.tags ?? [] })), + ...environments.map((environment: PendingEnvironment): Row => ({ + id: `environment:${environment.alias}`, + pending_environment: environment.alias, + name: '', + platform_account_key: '', + tags: [], + follower_count: 0, + following_count: 0, + total_favorited: 0, + friend_count: 0, + comment_total: 0, + aweme_count: 0, + work_count: 0, + environment: { + ...environment, + network_exit_id: environment.network_exit?.id, + network_exit_health: environment.network_exit?.health_status, + }, + })), + ].sort((a, b) => (b.environment?.id ?? 0) - (a.environment?.id ?? 0))); } catch (loadError) { setError(loadError); } finally { @@ -143,14 +164,18 @@ export default function AccountManagementList() { { title: '账号', dataIndex: 'name', - ...fixedLeft({}), + ...fixedLeft({ width: 240 }), render: (_, account) => ( -
- history.push(`/accounts/${account.id}`)}>{account.name || '待登录'} - - {account.platform_account_key} - -
+ + {(account.name || '账').slice(0, 1).toUpperCase()} + {account.pending_environment ? ( + 待登录 + ) : ( + history.push(`/accounts/${account.id}`)}> + {account.name || '待登录'} + + )} + ) }, { title: '粉丝', @@ -175,9 +200,14 @@ export default function AccountManagementList() { { title: '作品数据', dataIndex: 'work_count', - render: (_, account) => ( + render: (_, account) => account.pending_environment ? ( + 登录后显示作品数据 + ) : ( - {formatCount(account.work_count ?? account.aweme_count)} 个作品 + {account.aweme_count == null ? '作品总数未知' : `${account.aweme_count} 个作品`} + + 已采集 {account.work_count ?? '未知'} / 总计 {account.aweme_count ?? '未知'} + 最近发布 {account.latest_published_at ? dateTime(account.latest_published_at) : '—'} @@ -215,6 +245,7 @@ export default function AccountManagementList() { title: '状态', dataIndex: 'status', render: (_, account) => { + if (account.pending_environment) return 待登录; // 与详情页同款三层判定(绑定/出口/账号),环境未绑定时退化为账号启停状态。 const readiness = accountReadiness(account, account.environment ?? null); return ( @@ -227,6 +258,7 @@ export default function AccountManagementList() { title: '业务状态', dataIndex: 'business_status', render: (status: Row['business_status']) => { + if (!status) return —; const statuses = { normal: { label: '正常', color: 'success' }, muted: { label: '禁言', color: 'warning' }, @@ -241,35 +273,46 @@ export default function AccountManagementList() { title: '操作', key: 'actions', align: 'right', - ...fixedRight({}), - render: (_, account) => ( - - - - - deleteAccount(account)} - disabled={actionBusy === account.id} - > - - - - ) }, + ); + } + return ( + + + + + deleteAccount(account)} + disabled={actionBusy === account.id} + > + + + + ); + }, + }, ]; return ( - 共 {rows.length} 个自有账号 + 共 {rows.length} 项