From 1bf905599cb843959d6b1d4d64918771a0f87aa7 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 17 Sep 2026 15:18:59 +0800 Subject: [PATCH] feat: unify owned and monitored accounts --- AGENTS.md | 17 +- README.md | 9 +- cmd/control-plane/creator.go | 274 +++++-- cmd/control-plane/creator_login_test.go | 46 ++ cmd/control-plane/creator_share_test.go | 62 ++ cmd/control-plane/hub.go | 82 +- cmd/docker_gateway/douyin.py | 67 ++ cmd/docker_gateway/gateway.py | 43 +- cmd/docker_gateway/test_gateway.py | 13 + docs/native-browser-change-review.md | 180 +++++ docs/native-browser-implementation-plan.md | 175 +++++ docs/native-browser-verification.md | 252 +++++++ docs/plan01.md | 10 + internal/creator/accounts.go | 36 +- internal/creator/actions.go | 6 + internal/creator/content.go | 61 +- internal/creator/content_test.go | 39 +- internal/creator/integration_test.go | 22 +- .../migrations/033_competitor_tags.sql | 7 + internal/creator/models.go | 14 +- internal/creator/store.go | 4 + internal/douyin/connector.go | 5 +- internal/douyin/creator_collector.go | 59 ++ internal/douyin/creator_collector_test.go | 23 + internal/xiaohongshu/collector.go | 5 + internal/xiaohongshu/detail_test.go | 2 +- ...orAccountsPage.jsx => AccountEditPage.jsx} | 124 +-- web/src/AccountsPage.jsx | 706 +++++++++++++----- web/src/AccountsPage.test.jsx | 96 +-- web/src/CreatorCompetitorsPage.jsx | 492 +++++------- web/src/CreatorPages.test.jsx | 354 +++++++-- web/src/Layout.jsx | 3 +- web/src/Layout.test.jsx | 42 ++ web/src/lib/ui.jsx | 3 +- web/src/main.jsx | 13 +- 35 files changed, 2522 insertions(+), 824 deletions(-) create mode 100644 cmd/control-plane/creator_login_test.go create mode 100644 cmd/control-plane/creator_share_test.go create mode 100644 docs/native-browser-change-review.md create mode 100644 docs/native-browser-implementation-plan.md create mode 100644 docs/native-browser-verification.md create mode 100644 internal/creator/migrations/033_competitor_tags.sql rename web/src/{CreatorAccountsPage.jsx => AccountEditPage.jsx} (89%) create mode 100644 web/src/Layout.test.jsx diff --git a/AGENTS.md b/AGENTS.md index 3e6b2ab..923267e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,23 +65,25 @@ - 当前业务范围与验收以 [docs/plan01.md](docs/plan01.md) 为准:竞品分析、账号与大小号响应、环境与代理、评论线索和私信;先完成抖音完整流程,再完成小红书。 - 与旧探索规划或现有功能冲突时,以上述需求及使用者最新确认为准。自动响应按策略和 UID 冷却执行;人工发送逐次确认,两者不得混淆。自有账号互动与私信采用事件监听,不用轮询或 Mock 冒充实际能力。 -- 上述内容是目标范围,不代表已经实现。平台能力缺口、新的业务歧义必须向使用者确认;禁止自行删减需求、隐藏失败或以未要求的通用框架扩大实现范围。控制面继续使用 Go;经使用者确认,Docker/浏览器 gateway 改用 Python。 +- 上述内容是目标范围,不代表已经实现。平台能力缺口、新的业务歧义必须向使用者确认;禁止自行删减需求、隐藏失败或以未要求的通用框架扩大实现范围。控制面继续使用 Go,浏览器 gateway 使用 Python。 +- 浏览器环境目标改为各机器 gateway 管理宿主机浏览器进程与 Xvfb,不再通过 Docker 创建浏览器环境;复用现有多机控制。采集结束(含失败、取消、超时)回收任务创建的临时资源,保留账号 Profile、正式结果和合法长期监听。评审、实施与验收见 [变更评审](docs/native-browser-change-review.md)、[实施计划](docs/native-browser-implementation-plan.md)、[验证文档](docs/native-browser-verification.md)。本轮仅交付文档,现有 Docker 实现尚未替换,具体契约与部署方案须批准后实施。 ## 已批准的技术栈 ### Go 控制面 - 使用 Go 1.26、Fiber v3(HTTP 路由与服务生命周期)、Viper(配置)、Logrus(应用日志)、Cobra(可执行入口)。依赖版本由 `go.mod` 和 `go.sum` 精确锁定。 -- 保留成熟的标准库集成,如反向代理和 Docker HTTP 客户端,不重复造轮子。`net/http` handler 跨越 Fiber 边界时,使用 Fiber 官方适配器。 +- 保留成熟的标准库集成,如反向代理,不重复造轮子;去 Docker 改造完成后移除浏览器链路的 Docker HTTP 客户端。`net/http` handler 跨越 Fiber 边界时,使用 Fiber 官方适配器。 - 创建局部 `viper.New()` 实例,只绑定支持的输入,显式应用默认值,并在产生网络、文件系统或 Docker 副作用前完成全部配置校验。未经评审的需求批准,不使用 Viper 全局单例、远程 provider 或热加载。 - 通过 Logrus 输出结构化 JSON 日志,保持 `service` 等稳定字段。可复用代码只返回错误,并在服务边界记录一次。 - 每个服务只保留一个最小化的 Cobra 根命令。仅当存在真实的运维工作流需求时,才添加子命令、持久化 flag、代码生成器或补全。 - 未经评审的需求批准,不添加 ORM、Redis、任务框架或另一套 HTTP/配置/日志/CLI 技术栈。 -### Python Docker/浏览器 gateway +### Python 浏览器 gateway -- Docker/浏览器 gateway 使用 Python 3.12+;优先使用标准库 HTTP、Docker Engine Unix socket、socket/ssl/asyncio 与显式输入校验。只有真实浏览器会话需要时才使用已锁定的 Patchright/WebSocket 依赖。 -- gateway 继续是唯一挂载 Docker socket 的服务,只提供领域路由;Docker 生命周期、网络代际、代理、浏览器 CDP 和抖音页面内动作由 Python 实现,账号身份必须在每次写操作前核对。 +- 浏览器 gateway 使用 Python 3.12+;优先使用标准库 HTTP、进程管理、socket/ssl/asyncio 与显式输入校验,复用已有 CDP/WebSocket 能力。仅在实际需求明确且批准后新增锁定的浏览器依赖。 +- 目标为每台 gateway 仅管理本机浏览器/Xvfb 生命周期、运行代次、Profile、临时资源、代理及页面动作,复用现有多机路由;账号身份必须在每次写操作前核对。当前代码仍依赖 Docker socket;实施批准后删除浏览器容器、卷、网络及镜像代码路径,不保留 Docker 回退。 +- 任务清理必须核对节点、运行代次和资源归属,不能误删账号登录资料、正式素材或他人会话;控制面与 gateway 各自处理本机创建的临时文件,清理失败必须可见。 - Python 依赖必须写入锁定文件;不允许自动登录、任意 CDP、Cookie/验证码/密码回显或把不确定写结果转换为成功。 ### React 前端 @@ -97,8 +99,11 @@ ## 验证与交付 +- 功能完成后的功能验收不使用浏览器或其他自动化操作;只启动可联调的测试环境,并提供清晰的手工验证步骤,由使用者完成实际功能验证。 +- 测试阶段默认不构建 Docker 镜像或容器;优先直接裸启动本地 Go control-plane 与 Vite 前端,保证本地服务可运行、可联调。 +- 测试环境必须支持局域网手工验证:前端与本地 control-plane 监听 `0.0.0.0`,不得只绑定 `127.0.0.1`,并提供局域网访问地址。 - 开始任务前先定义完成标准。交付前依此验证,发现问题就修好再测,不把未完成的工作交回给使用者。只有确认完成,或遇到真正需要使用者介入的障碍时,才回报。 - 每个非平凡行为变更附带最小的回归测试,且该测试在无此变更时会失败。在信任与集成边界覆盖成功、校验、失败和兼容路径;单元测试覆盖率保证 65% 以上。 -- 控制面变更必须通过 `go test ./...`、`go vet ./...`,并构建 `./cmd/control-plane`;涉及并发、生命周期或共享状态的变更须运行 `go test -race ./...`。Python gateway 必须通过其非交互式单元测试与覆盖率检查,并完成 Compose 构建/健康检查。Docker 或 Compose 变更还须通过 `docker compose config --quiet`。 +- 控制面变更必须通过 `go test ./...`、`go vet ./...`,并构建 `./cmd/control-plane`;涉及并发、生命周期或共享状态的变更须运行 `go test -race ./...`。Python gateway 必须通过其非交互式单元测试与覆盖率检查;只有明确涉及 gateway/Docker/Compose 变更且获得使用者同意时,才执行 Compose 构建/健康检查。Docker 或 Compose 变更还须通过 `docker compose config --quiet`。 - 前端变更必须从 lockfile 安装、通过仓库的非交互式测试命令,并通过 `npm --prefix web run build`。主题、Layout、导航、资源动作或 data provider 的变更需要聚焦的交互覆盖,包括适用的错误与禁用状态。 - 除非 issue 明确批准契约变更,保持既有 API 与 Docker 生命周期行为不变。在 PR 中文档化任何状态码、载荷、配置、迁移、安全或重试方面的影响。 diff --git a/README.md b/README.md index d6b3afe..6dd7023 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ ## 当前阶段 -项目处于阶段 A 技术验证。当前可运行切片提供浏览器容器控制,以及单用户、Mock 连接器的离线业务闭环,功能参考公开上游项目独立实现,不直接复制其代码。 +业务范围与验收以 [docs/plan01.md](docs/plan01.md) 为准;规划范围不代表已经实现,离线检查不能替代真实平台验收。 -详细的五领域优先级、系统边界、合规知识库与分阶段验证方案见 -[《合规产品规划与上游调研》](docs/product/compliance-product-plan.md)。 +浏览器环境目标改为 **各机器 gateway 管理原生浏览器 + Xvfb**,并在采集结束后清理任务临时资源、保留登录状态和正式结果。本轮仅完成方案文档,当前代码及下方运行命令仍使用 Docker: + +- [变更评审](docs/native-browser-change-review.md):现状、风险、资源保留与清理边界。 +- [实施计划](docs/native-browser-implementation-plan.md):改动范围、阶段顺序与批准条件。 +- [验证文档](docs/native-browser-verification.md):多机、异常清理、磁盘与性能的手工验收。 ## 本地运行 diff --git a/cmd/control-plane/creator.go b/cmd/control-plane/creator.go index b8bf433..e2c3f66 100644 --- a/cmd/control-plane/creator.go +++ b/cmd/control-plane/creator.go @@ -147,6 +147,19 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } return c.JSON(profile) }) + app.Put("/api/creator/accounts/:id/tags", func(c fiber.Ctx) error { + var input struct { + Tags []string `json:"tags"` + } + if err := decodeCreator(c, &input); err != nil { + return creatorError(c, err) + } + tags, err := store.UpdateAccountTags(c.Context(), c.Params("id"), input.Tags) + if err != nil { + return creatorError(c, err) + } + return c.JSON(fiber.Map{"id": c.Params("id"), "tags": tags}) + }) app.Post("/api/creator/accounts/:id/login-result", func(c fiber.Ctx) error { var input struct { Status string `json:"status"` @@ -258,45 +271,27 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto return c.JSON(items) }) app.Post("/api/creator/competitors/preview", func(c fiber.Ctx) error { - var input struct { - AccountID string `json:"account_id"` - creator.CompetitorInput - } + var input competitorShareRequest if err := decodeCreator(c, &input); err != nil { return creatorError(c, err) } - if input.Platform != creator.PlatformDouyin { - return creatorError(c, creator.ErrUnavailable) - } - profile, err := previewDouyinCompetitor(c.Context(), store, phaseAStore, hubStore, input.AccountID, input.CompetitorInput) + preview, err := previewCompetitorShare(c.Context(), store, phaseAStore, hubStore, input.AccountID, input.Platform, input.ShareURL) if err != nil { return creatorError(c, err) } - return c.JSON(profile) + return c.JSON(preview) }) app.Post("/api/creator/competitors", func(c fiber.Ctx) error { - var request struct { - AccountID string `json:"account_id"` - creator.CompetitorInput - } + var request competitorShareRequest if err := decodeCreator(c, &request); err != nil { return creatorError(c, err) } - input := request.CompetitorInput - if input.Platform == creator.PlatformDouyin { - preview, err := previewDouyinCompetitor(c.Context(), store, phaseAStore, hubStore, request.AccountID, input) - if err != nil { - return creatorError(c, err) - } - canonical, ok := preview["platform_account_key"].(string) - if !ok || canonical == "" { - return creatorError(c, creator.ErrConflict) - } - input.PlatformAccountKey = canonical - if homepage, ok := preview["homepage_url"].(string); ok { - input.HomepageURL = homepage - } + preview, err := previewCompetitorShare(c.Context(), store, phaseAStore, hubStore, request.AccountID, request.Platform, request.ShareURL) + if err != nil { + return creatorError(c, err) } + input := preview.input() + input.Tags = request.Tags if err := validateXiaohongshuCompetitor(input); err != nil { return creatorError(c, err) } @@ -313,6 +308,19 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } return c.JSON(item) }) + app.Put("/api/creator/competitors/:id", func(c fiber.Ctx) error { + var input struct { + Tags []string `json:"tags"` + } + if err := decodeCreator(c, &input); err != nil { + return creatorError(c, err) + } + item, err := store.UpdateCompetitorTags(c.Context(), c.Params("id"), input.Tags) + if err != nil { + return creatorError(c, err) + } + return c.JSON(item) + }) app.Post("/api/creator/competitors/:id/pause", func(c fiber.Ctx) error { item, err := store.SetCompetitorEnabled(c.Context(), c.Params("id"), false) if err != nil { @@ -1063,7 +1071,11 @@ func creatorError(c fiber.Ctx, err error) error { case errors.Is(err, creator.ErrUncertain): status, message = fiber.StatusConflict, creator.ErrUncertain.Error() } - return c.Status(status).JSON(map[string]string{"error": message}) + response := map[string]string{"error": message} + if errors.Is(err, creator.ErrUnavailable) && err.Error() != creator.ErrUnavailable.Error() { + response["reason"] = err.Error() + } + return c.Status(status).JSON(response) } type creatorGatewayBrowser struct { @@ -1249,7 +1261,23 @@ func (browser creatorGatewayBrowser) MessageHistory(ctx context.Context, expecte const creatorLoginQRLifetime = 2 * time.Minute -func creatorLoginQRCode(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID string) (map[string]any, error) { +func startCreatorEnvironment(ctx context.Context, store hubStore, environment hub.EnvironmentContext) error { + if store == nil { + return creator.ErrUnavailable + } + action := actionForEnvironment("start", environment) + if err := store.AppendEnvironmentAction(ctx, "environment_action_requested", action); err != nil { + return err + } + finish := func(outcome, reason string, current hub.EnvironmentContext) error { + action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID + action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID + return store.AppendEnvironmentAction(ctx, "environment_action_finished", action) + } + return startBrowserRuntime(ctx, store, defaultNetworkExitProbe(), environment, finish) +} + +func creatorLoginQRCode(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore hubStore, accountID string) (map[string]any, error) { if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { return nil, creator.ErrUnavailable } @@ -1266,12 +1294,21 @@ func creatorLoginQRCode(ctx context.Context, store *creator.Store, phaseAStore * account.PlatformAccountKey != profile.PlatformAccountKey { return nil, creator.ErrConflict } + unlock, lockErr := lockAccountResources(ctx, hubStore, accountID) + if lockErr != nil { + return nil, fmt.Errorf("%w: lock account environment: %v", creator.ErrUnavailable, lockErr) + } + defer unlock() environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { - return nil, fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) + return nil, fmt.Errorf("%w: reload account environment before start: %v", creator.ErrUnavailable, err) } - if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { - return nil, fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable) + if err := startCreatorEnvironment(ctx, hubStore, environment); err != nil { + return nil, fmt.Errorf("%w: start account environment: %v", creator.ErrUnavailable, err) + } + environment, err = hubStore.GetEnvironmentContextForAccount(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("%w: reload account environment after start: %v", creator.ErrUnavailable, err) } gateway, err := hubStore.GetGateway(ctx, environment.Gateway) if err != nil { @@ -1348,6 +1385,25 @@ 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 (browser creatorGatewayBrowser) Resolve(ctx context.Context, target string) (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/resolve", payload, 30*time.Second) + if err != nil { + return "", err + } + if status != http.StatusOK { + return "", fmt.Errorf("douyin share URL resolve rejected with HTTP %d: %s", status, string(body)) + } + var response struct { + URL string `json:"url"` + } + if err := json.Unmarshal(body, &response); err != nil || response.URL == "" { + return "", errors.New("douyin share URL resolve response is invalid") + } + return response.URL, nil +} + const maxCreatorMediaBytes = 64 << 20 func writeCreatorMedia(destination string, data []byte) error { @@ -1415,35 +1471,169 @@ func (browser creatorGatewayBrowser) Media(ctx context.Context, target, destinat return writeCreatorMedia(destination, data) } -func previewDouyinCompetitor(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID string, input creator.CompetitorInput) (map[string]any, error) { - if store == nil || phaseAStore == nil || hubStore == nil || input.Platform != creator.PlatformDouyin || strings.TrimSpace(accountID) == "" { - return nil, creator.ErrInvalid +type competitorShareRequest struct { + AccountID string `json:"account_id"` + Platform string `json:"platform"` + ShareURL string `json:"share_url"` + Tags []string `json:"tags"` +} + +type competitorSharePreview struct { + AccountID string `json:"account_id"` + Platform string `json:"platform"` + PlatformAccountKey string `json:"platform_account_key"` + Nickname string `json:"nickname"` + AvatarURL string `json:"avatar_url,omitempty"` + HomepageURL string `json:"homepage_url"` + ShareURL string `json:"share_url"` +} + +func (preview competitorSharePreview) input() creator.CompetitorInput { + return creator.CompetitorInput{ + Platform: preview.Platform, + PlatformAccountKey: preview.PlatformAccountKey, + Nickname: preview.Nickname, + AvatarURL: preview.AvatarURL, + HomepageURL: preview.HomepageURL, + } +} + +func competitorSharePlatform(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() != "" || parsed.Fragment != "" { + return "", creator.ErrInvalid + } + switch strings.ToLower(parsed.Hostname()) { + case "www.douyin.com", "v.douyin.com": + return creator.PlatformDouyin, nil + case "www.xiaohongshu.com", "xhslink.com", "www.xhslink.com": + return creator.PlatformXiaohongshu, nil + default: + return "", creator.ErrInvalid + } +} + +func previewCompetitorShare(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID, requestedPlatform, rawShareURL string) (competitorSharePreview, error) { + platform, err := competitorSharePlatform(rawShareURL) + if err != nil { + return competitorSharePreview{}, err + } + if requestedPlatform != "" && requestedPlatform != platform { + return competitorSharePreview{}, creator.ErrInvalid + } + shareURL := strings.TrimSpace(rawShareURL) + switch platform { + case creator.PlatformDouyin: + return previewDouyinCompetitorShare(ctx, store, phaseAStore, hubStore, accountID, shareURL) + case creator.PlatformXiaohongshu: + return previewXiaohongshuCompetitorShare(ctx, store, phaseAStore, hubStore, accountID, shareURL) + default: + return competitorSharePreview{}, creator.ErrUnavailable + } +} + +func previewDouyinCompetitorShare(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID, shareURL string) (competitorSharePreview, error) { + browser, err := newDouyinAccountBrowser(ctx, store, phaseAStore, hubStore, accountID) + if err != nil { + return competitorSharePreview{}, err + } + canonicalURL, err := browser.Resolve(ctx, shareURL) + if err != nil { + return competitorSharePreview{}, fmt.Errorf("%w: share URL resolution failed: %v", creator.ErrUnavailable, err) + } + workKey, err := douyinWorkKeyFromURL(canonicalURL) + if err != nil { + return competitorSharePreview{}, err + } + target, err := (douyin.CreatorCollector{Browser: browser}).ResolveWork(ctx, workKey) + if err != nil { + return competitorSharePreview{}, fmt.Errorf("%w: target identity verification failed: %v", creator.ErrConflict, err) + } + return competitorSharePreview{ + AccountID: accountID, + Platform: creator.PlatformDouyin, + PlatformAccountKey: target.SecUID, + Nickname: target.Nickname, + AvatarURL: target.AvatarURL, + HomepageURL: "https://www.douyin.com/user/" + target.SecUID, + ShareURL: shareURL, + }, nil +} + +func previewXiaohongshuCompetitorShare(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID, shareURL string) (competitorSharePreview, error) { + collector, err := newXiaohongshuReadCollector(ctx, store, phaseAStore, hubStore, accountID, creator.SourceCompetitor, accountID) + if err != nil { + return competitorSharePreview{}, err + } + work, err := collector.GetNoteDetail(ctx, shareURL) + if err != nil { + return competitorSharePreview{}, err + } + if work.AuthorKey == "" { + return competitorSharePreview{}, fmt.Errorf("%w: xiaohongshu note author identity is missing", creator.ErrInvalid) + } + return competitorSharePreview{ + AccountID: accountID, + Platform: creator.PlatformXiaohongshu, + PlatformAccountKey: work.AuthorKey, + Nickname: work.AuthorName, + HomepageURL: "https://www.xiaohongshu.com/user/profile/" + work.AuthorKey, + ShareURL: shareURL, + }, nil +} + +func douyinWorkKeyFromURL(raw string) (string, error) { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() != "www.douyin.com" || parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" { + return "", creator.ErrInvalid + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) != 2 || parts[0] != "video" || parts[1] == "" || parts[1][0] == '0' || strings.Trim(parts[1], "0123456789") != "" { + return "", creator.ErrInvalid + } + return parts[1], nil +} + +func newDouyinAccountBrowser(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID string) (creatorGatewayBrowser, error) { + if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { + return creatorGatewayBrowser{}, creator.ErrInvalid } account, err := phaseAStore.GetAccount(ctx, accountID) if err != nil { - return nil, err + return creatorGatewayBrowser{}, err } if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" { - return nil, creator.ErrConflict + return creatorGatewayBrowser{}, creator.ErrConflict } profile, err := store.GetAccountProfile(ctx, accountID) if err != nil { - return nil, err + return creatorGatewayBrowser{}, err } if profile.Platform != creator.PlatformDouyin || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" || profile.PlatformAccountKey == "" { - return nil, creator.ErrConflict + return creatorGatewayBrowser{}, creator.ErrConflict } environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) if err != nil { - return nil, fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) + return creatorGatewayBrowser{}, fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) } gateway, err := hubStore.GetGateway(ctx, environment.Gateway) if err != nil { - return nil, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) + return creatorGatewayBrowser{}, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } browser := creatorGatewayBrowser{gateway: gateway, environment: environment} if _, err := browser.Identity(ctx, profile.PlatformAccountKey); err != nil { - return nil, fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err) + return creatorGatewayBrowser{}, fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err) + } + return browser, nil +} + +func previewDouyinCompetitor(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID string, input creator.CompetitorInput) (map[string]any, error) { + if input.Platform != creator.PlatformDouyin { + return nil, creator.ErrInvalid + } + browser, err := newDouyinAccountBrowser(ctx, store, phaseAStore, hubStore, accountID) + if err != nil { + return nil, err } target, err := (douyin.CreatorCollector{Browser: browser}).ResolveTarget(ctx, input.PlatformAccountKey) if err != nil { diff --git a/cmd/control-plane/creator_login_test.go b/cmd/control-plane/creator_login_test.go new file mode 100644 index 0000000..44b16df --- /dev/null +++ b/cmd/control-plane/creator_login_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "net/http/httptest" + "testing" + + "git.ipao.vip/rogee/creator-hub/internal/hub" +) + +func TestStartCreatorEnvironmentStartsBoundRuntime(t *testing.T) { + store := newMemoryStore() + gateway := &fakeGateway{token: "unit-test-gateway-token"} + server := httptest.NewServer(gateway.handler(t)) + defer server.Close() + store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} + _ = store.CreateImage(context.Background(), hub.Image{ + Version: "148.0.7778.215", ImageRef: "registry.example/browser:148", Enabled: true, + }) + environment := hub.EnvironmentContext{ + Env: hub.Env{ + Alias: "account-a", + Name: "账号一", + Gateway: "gw-1", + ImageVersion: "148.0.7778.215", + }, + AccountID: "account-a", + AccountStatus: "active", + AuthorizationStatus: "authorized", + BindingID: "binding-a", + BindingVersion: 1, + } + store.envs[environment.Alias] = environment.Env + store.bindings[environment.Alias] = environment + + if err := startCreatorEnvironment(context.Background(), store, environment); err != nil { + t.Fatal(err) + } + bound := store.bindings[environment.Alias] + if bound.RuntimeID == "" || bound.RuntimeNetworkID == "" { + t.Fatalf("creator login did not start the bound runtime: %#v", bound) + } + if len(store.actions) != 2 || store.actions[0].Action != "start" || store.actions[1].Outcome != "succeeded" { + t.Fatalf("creator login start did not record lifecycle actions: %#v", store.actions) + } +} diff --git a/cmd/control-plane/creator_share_test.go b/cmd/control-plane/creator_share_test.go new file mode 100644 index 0000000..b71658c --- /dev/null +++ b/cmd/control-plane/creator_share_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "testing" + + "git.ipao.vip/rogee/creator-hub/internal/creator" +) + +func TestCompetitorSharePlatform(t *testing.T) { + for _, test := range []struct { + name string + url string + want string + }{ + {name: "douyin short link", url: "https://v.douyin.com/abc123/", want: creator.PlatformDouyin}, + {name: "douyin content", url: "https://www.douyin.com/video/123", want: creator.PlatformDouyin}, + {name: "xiaohongshu note", url: "https://www.xiaohongshu.com/explore/note-1", want: creator.PlatformXiaohongshu}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := competitorSharePlatform(test.url) + if err != nil || got != test.want { + t.Fatalf("competitorSharePlatform(%q) = %q, %v; want %q", test.url, got, err, test.want) + } + }) + } + + for _, url := range []string{ + "http://v.douyin.com/abc123/", + "https://www.douyin.com.evil/video/123", + "https://v.douyin.com/abc123/#fragment", + } { + t.Run(url, func(t *testing.T) { + if _, err := competitorSharePlatform(url); err == nil { + t.Fatalf("competitorSharePlatform(%q) unexpectedly succeeded", url) + } + }) + } +} + +func TestDouyinWorkKeyFromURL(t *testing.T) { + for _, test := range []struct { + url string + want string + }{ + {url: "https://www.douyin.com/video/123", want: "123"}, + {url: "https://www.douyin.com/video/123?previous_page=app_code_link", want: "123"}, + } { + got, err := douyinWorkKeyFromURL(test.url) + if err != nil || got != test.want { + t.Fatalf("douyinWorkKeyFromURL(%q) = %q, %v; want %q", test.url, got, err, test.want) + } + } + for _, url := range []string{ + "https://www.douyin.com/video/0", + "https://www.douyin.com/user/123", + "https://v.douyin.com/abc123", + } { + if _, err := douyinWorkKeyFromURL(url); err == nil { + t.Fatalf("douyinWorkKeyFromURL(%q) unexpectedly succeeded", url) + } + } +} diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go index 7463779..5055e18 100644 --- a/cmd/control-plane/hub.go +++ b/cmd/control-plane/hub.go @@ -1494,109 +1494,117 @@ func stopEnvironmentRuntime(ctx context.Context, store runtimeStopStore, environ } func startBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExitAccess) (string, error), c fiber.Ctx, + environment hub.EnvironmentContext, finish func(string, string, hub.EnvironmentContext) error) error { + if err := startBrowserRuntime(c.Context(), store, probe, environment, finish); err != nil { + return hubError(c, err) + } + return c.SendStatus(fiber.StatusNoContent) +} + +func startBrowserRuntime(ctx context.Context, store hubStore, probe networkExitProbe, environment hub.EnvironmentContext, finish func(string, string, hub.EnvironmentContext) error) error { if !accountRunnable(environment) { - return hubError(c, hub.ErrConflict) + return hub.ErrConflict } var access hub.NetworkExitAccess var err error if environment.Exit.ID != "" { var reason string - access, reason, err = verifyNetworkExit(c.Context(), store, probe, environment.Exit.ID) + access, reason, err = verifyNetworkExit(ctx, store, probe, environment.Exit.ID) if err != nil { - if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil { + if cleanupErr := discardRuntime(ctx, store, environment); cleanupErr != nil { _ = finish("unknown", "cleanup_result_unknown", environment) - return hubError(c, cleanupErr) + return cleanupErr } _ = finish("failed", reason, environment) - return hubError(c, err) + return err } } - gateway, err := store.GetGateway(c.Context(), environment.Gateway) + gateway, err := store.GetGateway(ctx, environment.Gateway) if err != nil { _ = finish("failed", "gateway_unavailable", environment) - return hubError(c, err) + return err } if environment.RuntimeCleanupPending { - if _, cleanupErr := removeGatewayRuntime(c.Context(), store, gateway, environment); cleanupErr != nil { + if _, cleanupErr := removeGatewayRuntime(ctx, store, gateway, environment); cleanupErr != nil { _ = finish("unknown", "cleanup_result_unknown", environment) - return hubError(c, cleanupErr) + return cleanupErr } - environment, err = store.GetEnvironmentContext(c.Context(), environment.Alias) + environment, err = store.GetEnvironmentContext(ctx, environment.Alias) if err != nil { - return hubError(c, err) + return err } if !accountRunnable(environment) { - return hubError(c, hub.ErrConflict) + return hub.ErrConflict } } - imageRef, err := store.ImageRef(c.Context(), environment.ImageVersion) + imageRef, err := store.ImageRef(ctx, environment.ImageVersion) if err != nil { - if cleanupErr := discardRuntime(c.Context(), store, environment); cleanupErr != nil { + if cleanupErr := discardRuntime(ctx, store, environment); cleanupErr != nil { _ = finish("unknown", "cleanup_result_unknown", environment) - return hubError(c, cleanupErr) + return cleanupErr } _ = finish("failed", "image_unavailable", environment) - return hubError(c, err) + return err } networkExit := gatewayNetworkExit{} if environment.Exit.ID != "" { networkExit = gatewayNetworkExitFor(access) } - container, found, err := reconcileGatewayContainer(c.Context(), gateway, environment.Alias) + container, found, err := reconcileGatewayContainer(ctx, gateway, environment.Alias) if err != nil { _ = finish("unknown", "gateway_result_unknown", environment) - return hubError(c, gatewayUnreachable(err)) + return gatewayUnreachable(err) } if found && container.State == "running" && container.ProxyReady && containerMatchesBinding(container, environment) { - environment, err = activateGatewayRuntime(c.Context(), store, gateway, environment, container.ID, container.NetworkID) + environment, err = activateGatewayRuntime(ctx, store, gateway, environment, container.ID, container.NetworkID) if err != nil { _ = finish("failed", "runtime_persistence_failed", environment) - return hubError(c, err) + return err } if err := finish("succeeded", "gateway_reconciled", environment); err != nil { - return hubError(c, err) + return err } - return c.SendStatus(fiber.StatusNoContent) + return nil } if found { - if _, removeErr := removeGatewayRuntime(c.Context(), store, gateway, environment); removeErr != nil { + if _, removeErr := removeGatewayRuntime(ctx, store, gateway, environment); removeErr != nil { _ = finish("unknown", "gateway_result_unknown", environment) - return hubError(c, removeErr) + return removeErr } - } else if err := releaseRuntime(c.Context(), store, environment); err != nil { + } else if err := releaseRuntime(ctx, store, environment); err != nil { _ = finish("failed", "runtime_release_failed", environment) - return hubError(c, err) + return err } - status, body, callErr := gatewayCall(c.Context(), gateway, http.MethodPost, "/v1/browsers", + status, body, callErr := gatewayCall(ctx, gateway, http.MethodPost, "/v1/browsers", gatewayCreatePayload(environment, imageRef, networkExit), gatewayLongTimeout) if callErr != nil || status != http.StatusCreated { - reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body) + reconcileErr := reconcileGatewayCreate(ctx, store, gateway, environment, status, callErr, body) if reconcileErr != nil { _ = finish("unknown", "gateway_result_unknown", environment) - return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"}, reconcileErr)) + return errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"}, reconcileErr) } _ = finish("failed", "gateway_create_failed", environment) if callErr != nil { - return hubError(c, gatewayUnreachable(callErr)) + return gatewayUnreachable(callErr) } - return hubError(c, gatewayRejected(status, body)) + return gatewayRejected(status, body) } var created containerStatus if json.Unmarshal(body, &created) != nil || !validCreatedRuntime(created, environment, true) { - reconcileErr := reconcileGatewayCreate(c.Context(), store, gateway, environment, status, callErr, body) + reconcileErr := reconcileGatewayCreate(ctx, store, gateway, environment, status, callErr, body) _ = finish("unknown", "gateway_result_unknown", environment) - return hubError(c, errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"}, reconcileErr)) + return errors.Join(gatewayFailure{status: http.StatusBadGateway, message: "gateway start result unknown; retry to reconcile"}, reconcileErr) } - environment, err = activateGatewayRuntime(c.Context(), store, gateway, environment, created.ID, created.NetworkID) + environment, err = activateGatewayRuntime(ctx, store, gateway, environment, created.ID, created.NetworkID) if err != nil { _ = finish("failed", "runtime_persistence_failed", environment) - return hubError(c, err) + return err } if err := finish("succeeded", "environment_started", environment); err != nil { - return hubError(c, err) + return err } - return c.SendStatus(fiber.StatusNoContent) + return nil } func upgradeBrowser(store hubStore, probe networkExitProbe, _ func(hub.NetworkExitAccess) (string, error), c fiber.Ctx) error { diff --git a/cmd/docker_gateway/douyin.py b/cmd/docker_gateway/douyin.py index 7cae881..5f9ac00 100644 --- a/cmd/docker_gateway/douyin.py +++ b/cmd/docker_gateway/douyin.py @@ -398,6 +398,32 @@ class DouyinBrowser: raise DouyinError("restricted browser fetch returned invalid body") return BrowserResponse(status, body, detect_challenge(status, body)) + def resolve(self, alias: str, target: str) -> str: + if not is_douyin_share_url(target): + raise DouyinError("restricted Douyin share URL is invalid") + if is_douyin_content_url(target): + return target + with self.connection(alias) as cdp: + if cdp.evaluate("location.origin") != self.origin: + raise DouyinError("restricted browser origin changed") + navigation = cdp.command("Page.navigate", {"url": target}) + if ( + not isinstance(navigation, dict) + or not isinstance(navigation.get("frameId"), str) + or navigation.get("errorText") + ): + raise DouyinError("Douyin share URL navigation failed") + event = cdp.wait_event( + "Page.frameNavigated", + lambda params: is_douyin_content_url( + params.get("frame", {}).get("url", "") + ), + ) + final_url = event.get("frame", {}).get("url") + if not isinstance(final_url, str) or not is_douyin_content_url(final_url): + raise DouyinError("Douyin share URL did not resolve to a supported page") + return final_url + def _wait_for_login_render(self, cdp: CDPConnection) -> None: deadline = time.monotonic() + LOGIN_RENDER_TIMEOUT while True: @@ -1497,6 +1523,47 @@ def is_douyin_url(value: object) -> bool: return False +def is_douyin_content_url(value: object) -> bool: + if not isinstance(value, str): + return False + try: + parsed = urlsplit(value) + parts = parsed.path.strip("/").split("/") + 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 not parsed.fragment + and len(parts) == 2 + and parts[0] == "video" + and bool(ID_RE.fullmatch(parts[1])) + ) + except (TypeError, ValueError): + return False + + +def is_douyin_share_url(value: object) -> bool: + if not isinstance(value, str): + return False + try: + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or parsed.port is not None + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + return False + if parsed.hostname == "v.douyin.com": + return bool(parsed.path.strip("/")) and len(value) <= 2048 + return parsed.hostname == "www.douyin.com" and is_douyin_content_url(value) + except (TypeError, ValueError): + return False + + def is_douyin_media_url(value: object) -> bool: if not isinstance(value, str): return False diff --git a/cmd/docker_gateway/gateway.py b/cmd/docker_gateway/gateway.py index b64b750..50ab55b 100644 --- a/cmd/docker_gateway/gateway.py +++ b/cmd/docker_gateway/gateway.py @@ -47,6 +47,8 @@ from .douyin import ( DouyinError, SubscriptionManager, XiaohongshuBrowser, + is_douyin_content_url, + is_douyin_share_url, is_xiaohongshu_share_url, ) from .proxy import ProxyExit, ProxyRegistry @@ -66,6 +68,7 @@ DOUYIN_IDENTITY_PATH = "/aweme/v1/web/user/profile/self/" DOUYIN_PROFILE_OTHER_PATH = "/aweme/v1/web/user/profile/other/" DOUYIN_IDENTITY_URL = IDENTITY_URL DOUYIN_WORKS_PATH = WORKS_PATH +DOUYIN_WORK_DETAIL_PATH = "/aweme/v1/web/aweme/detail/" DOUYIN_COMMENTS_PATH = COMMENTS_PATH XHS_ACCOUNT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$") XHS_IDENTITY_PATH = "/api/sns/web/v2/user/me" @@ -647,6 +650,26 @@ class Gateway: "challenge": response.challenge, } + def resolve_douyin(self, alias: str, input: dict) -> dict: + target = input.get("url", "") + if not valid_douyin_generation(input) or not valid_douyin_source_url(target): + raise RequestError("invalid restricted Douyin source URL", 400) + if is_douyin_content_url(target): + return {"url": target} + with self._alias_lock(alias): + self._require_douyin_generation(alias, input) + try: + resolved = self.browser.resolve(alias, target) + self._require_douyin_generation(alias, input) + except DouyinError as exc: + LOG.warning( + "Douyin share resolution failed alias=%s reason=%s", alias, str(exc) + ) + raise RequestError("restricted Douyin share resolution failed") from exc + if not is_douyin_content_url(resolved): + raise RequestError("Douyin share resolved to an unsupported URL", 502) + return {"url": resolved} + 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): @@ -1423,13 +1446,15 @@ class GatewayHandler(BaseHTTPRequestHandler): if action == "identity" and method == "POST": return gateway.xiaohongshu_identity(alias, body) match = re.fullmatch( - r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(get|media|identity|login-qr|action|messages|events)", + r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(get|media|identity|resolve|login-qr|action|messages|events)", path, ) if match: alias, action = match.groups() if action == "get" and method == "POST": return gateway.get_douyin(alias, body) + if action == "resolve" and method == "POST": + return gateway.resolve_douyin(alias, body) if action == "media" and method == "POST": return gateway.get_douyin_media(alias, body) if action == "identity" and method == "POST": @@ -1873,6 +1898,10 @@ def valid_xiaohongshu_media_url(raw: object) -> bool: ) +def valid_douyin_source_url(raw: object) -> bool: + return is_douyin_share_url(raw) + + def valid_douyin_url(raw: object) -> bool: if not isinstance(raw, str): return False @@ -1894,11 +1923,23 @@ def valid_douyin_url(raw: object) -> bool: return valid_douyin_profile_query(query) if parsed.path == DOUYIN_WORKS_PATH: return valid_douyin_api_query(query, "sec_user_id", "max_cursor") + if parsed.path == DOUYIN_WORK_DETAIL_PATH: + return valid_douyin_work_detail_query(query) if parsed.path == DOUYIN_COMMENTS_PATH: return valid_douyin_api_query(query, "aweme_id", "cursor") return False +def valid_douyin_work_detail_query(query: dict[str, list[str]]) -> bool: + return ( + len(query) == 3 + and query.get("aid") == ["6383"] + and query.get("device_platform") == ["webapp"] + and len(query.get("aweme_id", [])) == 1 + and bool(re.fullmatch(r"[1-9][0-9]{0,63}", query["aweme_id"][0])) + ) + + def valid_douyin_profile_query(query: dict[str, list[str]]) -> bool: account_fields = [field for field in ("user_id", "sec_user_id") if field in query] return ( diff --git a/cmd/docker_gateway/test_gateway.py b/cmd/docker_gateway/test_gateway.py index 485bd7f..419edeb 100644 --- a/cmd/docker_gateway/test_gateway.py +++ b/cmd/docker_gateway/test_gateway.py @@ -48,6 +48,8 @@ from .douyin import ( detect_challenge, im_expression, install_expression, + is_douyin_content_url, + is_douyin_share_url, is_douyin_url, normalize_notice, notice_ids, @@ -80,6 +82,7 @@ json_bytes = gateway_module.json_bytes load_config = gateway_module.load_config split_listen_address = gateway_module.split_listen_address valid_douyin_url = gateway_module.valid_douyin_url +valid_douyin_source_url = gateway_module.valid_douyin_source_url validate_create = gateway_module.validate_create parse_proxy_exit = gateway_module.parse_proxy_exit validate_proxy_exit = gateway_module.validate_proxy_exit @@ -233,6 +236,16 @@ class GatewayValidationTests(unittest.TestCase): 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")) + self.assertTrue(is_douyin_content_url("https://www.douyin.com/video/123")) + self.assertTrue(is_douyin_share_url("https://v.douyin.com/abc123/")) + self.assertTrue(valid_douyin_source_url("https://v.douyin.com/abc123/")) + self.assertFalse(is_douyin_share_url("https://v.douyin.com/abc123/#bad")) + self.assertFalse(is_douyin_share_url("https://www.douyin.com/user/123")) + self.assertTrue( + valid_douyin_url( + "https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=6383&device_platform=webapp&aweme_id=123" + ) + ) def test_external_cdp_is_virtual_and_generation_bound(self) -> None: external = { diff --git a/docs/native-browser-change-review.md b/docs/native-browser-change-review.md new file mode 100644 index 0000000..1c90458 --- /dev/null +++ b/docs/native-browser-change-review.md @@ -0,0 +1,180 @@ +# 浏览器环境去 Docker:变更评审 + +## 1. 状态与结论 + +- 日期:2026-09-17。 +- 评审基线:`main` / `1f63b5b`,以及评审时工作区的现有未提交改动;不能将本文当作该提交单独具备的能力证明。 +- 用户已确认:本轮只交付评审、实施计划与验证文档,不修改业务代码。 +- **方向成立,但不是把 Docker 启动命令换成 Xvfb 命令即可。**必须同时替换进程生命周期、运行代次、Profile 路径、端口分配、代理接入及版本管理。 +- 下文“目标/推荐”是待实施方案,不表示已实现或已通过真机验证。详细执行顺序见[实施计划](native-browser-implementation-plan.md),验收见[验证文档](native-browser-verification.md)。 + +### 已确认的需求 + +1. 不再用 Docker 创建、启动浏览器环境;每台机器由已有 Python gateway 管理本机浏览器,Xvfb 提供虚拟显示。 +2. 保留已有多 gateway 控制方式,账号/环境仍绑定指定机器。 +3. 采集浏览器按任务按需启动,任务完成后清理本次创建的临时资源;失败、取消、超时及异常退出也必须处理。 +4. 不删除需要保留的账号登录状态、稳定指纹、采集结果与正式素材;重启与普通浏览器升级后仍使用原 Profile、指纹及账号身份,不因采集结束关闭无关账号的长期监听。 +5. 用实测比较启动耗时、内存及磁盘占用;不预先承诺节省比例。 + +### 本次边界 + +- 移除的是**浏览器运行环境对 Docker 的依赖**。PostgreSQL 或控制面的部署方式不是本次强制重写对象;联调仍优先裸启动 Go、Python、Vite。 +- 不增加自动登录、浏览器池、远程桌面新协议、Redis、任务框架、自动跨机迁移或通用资源调度平台。 +- 不借此次变更增加认证、网络隔离等安全策略;保留现有身份校验、凭据处理与平台写操作约束。 +- 先抖音,再小红书;不能以运行环境改造为由删减 `plan01.md` 的业务范围。 + +## 2. 现有实现及其影响 + +以下是代码定位,不是对真实平台能力的背书。实施前须针对最新工作区复核。 + +| 现有事实/入口 | 对本次变更的影响 | +| --- | --- | +| `internal/hub/store.go` 的 `Gateway` 保存名称、Endpoint;`Env` 保存 `Gateway`、`ImageVersion` | 多机器注册与路由已存在,应复用,不新增第二套节点管理 | +| `cmd/control-plane/hub.go` 的 `startBrowserRuntime`、`createGatewayRuntime`、`removeGatewayRuntime` 管理启停与清理 | 必须整体改造生命周期,不能仅替换 Python 中一个 Docker 调用 | +| `gatewayCreatePayload` 下发 `creatorhub-profile-{alias}` 卷名、指纹、代理和绑定版本 | 卷改成 gateway 管理的持久目录;控制面不能下发任意宿主机路径 | +| `runtimeCleanupStore` 及启动前清理逻辑已有 cleanup-pending 概念 | 保留“未清理完成不能冒充已停止”的语义,改用本机资源身份,不再依赖容器/网络 ID | +| `cmd/docker_gateway/gateway.py` 初始化 `DockerClient`;`load_config` 读取 `DOCKER_SOCKET`、`BROWSER_NETWORK` | 当前正常入口仍依赖 Docker。外部 CDP 配置并不等于完整的原生浏览器生命周期 | +| `docker/browser-wrapper/docker-entrypoint.sh` 包含 Xvfb、x11vnc、socat、Chromium,固定 `:99` 和容器内端口,Profile 为 `/data` | 已有容器内 Xvfb 脚本,但 gateway 强制入口与 wrapper 入口不一致,不能证明实际镜像执行了它。宿主机并发不能复用固定 display/端口 | +| 同一脚本传入 `--no-sandbox` | **当前脚本并未启用 Chromium sandbox。**不能以“浏览器本身就是沙盒”解释现有安全边界或直接照搬启动参数 | +| `cmd/control-plane/creator.go` 的采集读取已有运行账号,没有按任务自动创建 runtime;`creator_events.go` 独立运行监听 | 需要新增任务使用权;现有 source lease 不是浏览器使用权,直接在采集末尾调用 stop 会误停共享会话 | +| `creator.go` 的临时下载、`creator_material.go` 的 `audio.wav.tmp` 依赖正常退出删除;素材先写固定路径再提交数据库 token | 控制面本机也需要崩溃清理;旧执行可能覆盖新产物,必须先隔离执行目录与产物发布,不能只增加目录扫描 | +| `internal/creator/source_lease.go` / `content.go` 的来源 lease 为固定十分钟;部分失败收尾仍使用已取消的 context | 长任务可能重复领取,取消后仍显示 running;资源使用权必须覆盖真实执行期并有独立收尾上下文 | +| `internal/hub/store.go` 的 gateway endpoint 可更新,环境按 gateway 名称重新解析地址 | 不能把名称当稳定机器身份;原 owner 和待清理目标必须保留,禁止把仍有绑定资源的 gateway 改指另一机器 | +| `internal/hub/store.go` 的 `Image`、前端镜像页、指纹/版本字段依赖 Docker 镜像语义 | 浏览器版本来源必须替换;只改界面名称会留下无效配置 | +| `scripts/dev-backend.mjs` / `compose*.yaml` 启动 Docker gateway | 联调脚本和部署说明必须同步;不能让“原生模式”后台仍偷偷启动旧 gateway | +| `requirements-gateway.lock` 当前锁定 `websocket-client` | 优先保留已有 CDP/WebSocket 与平台适配器,不因取消 Docker 就新增 Playwright/Patchright | + +## 3. 方案选择 + +### 3.1 显示与浏览器 + +| 方案 | 优点 | 缺点 | 结论 | +| --- | --- | --- | --- | +| 每个运行实例独立 Xvfb + 浏览器 | 所有权明确;远程桌面不串窗口;单任务易于关闭和清理 | 比共享 Xvfb 多一个轻量进程,需要分配 display | **推荐首版**,满足任务结束释放资源;实际开销纳入对比 | +| 每个 gateway 共用一个 Xvfb | 减少 X server 数量 | 窗口、远程桌面及故障相互影响;不能随单任务清理 | 无实测瓶颈前不采用 | +| 改成全 headless 或复用浏览器池 | 可能进一步减少开销 | 与本次 Xvfb 要求、人工登录和监听生命周期不一致 | 本次不做 | + +Xvfb 只负责显示;gateway 负责启动、就绪确认、终止、回收与恢复。浏览器进程存在不等于能执行采集:至少须完成 CDP 就绪、目标页面可用、代理与实际账号身份确认。 + +### 3.2 进程归属与异常退出 + +推荐首版运行在 Linux + systemd:每个 runtime 使用独立的 systemd transient service 管理其 Xvfb、浏览器、可选 x11vnc 及子进程,使用该 runtime 的 cgroup 作为终止与观察边界。Python 通过标准库执行固定的管理命令,不实现通用进程管理平台,也不为每个进程引入第三方库。 + +- **推荐每个 runtime unit 独立存活,gateway 重启后按持久记录和 unit 身份重新核对。**接近当前容器可独立存活的行为,不默认把 gateway 升级变成所有账号强制断开。 +- 短任务使用权必须续租,且有明确最长执行期限;gateway 未及时恢复时,runtime 自身的有界寿命也能终止短任务进程。长期监听不按短任务期限回收,但 gateway/代理中断必须可见,恢复后重新确认事件边界,不能宣称无遗漏。 +- gateway、控制面重启及监听重连不得清除自动响应的事件去重记录、基线或已占用 UID 冷却;冷却到期仅允许新事件,旧事件永不重放,迟到事件默认只记录、不自动补发。按 `plan01.md` 的 AC-A9、AC-B1 验证,不能用人工逐次确认的发送测试代替自动策略验收。 +- 正常停止先请求浏览器退出,给出有限等待时间,再停止整个 runtime unit;无法停止就报清理失败,不能删除仍在使用的 Profile。 +- 磁盘中的运行清单记录 gateway、environment、runtime generation、任务/用途、unit 名及临时目录归属;先登记意图,再创建资源,逐步记录结果。 +- gateway 重启先核对自己的 unit、运行记录及代理监听;仅重新识别完整身份匹配的受管实例,不接管任意现存 CDP。恢复失败时明确停止该 runtime 并标记待处理,不能另开同 Profile 浏览器或静默直连。 +- 不能只按 PID、进程名或目录年龄清理。PID 会复用,另一台机器可能有同名环境,新一代 runtime 也可能复用端口。 +- 更简单的“gateway 服务停止即杀掉全部子进程”会改变现有会话体验,因此不默认采用;若用户明确接受每次 gateway 重启中断全部会话,再缩减为该方案。 +- 单纯 `subprocess` + `finally` 无法处理 gateway 被强制终止或宿主机重启;仅靠进程组也必须证明目标浏览器不会留下脱离进程组的子进程。首版使用 OS 原生进程归属,避免依赖此假设。 + +这是部署前置条件,不承诺本轮支持 Windows/macOS、无 systemd Linux,也不增加多个进程管理后端。实施前需确认目标机器符合这一条件。 + +### 3.3 多机及浏览器版本 + +- 控制面继续选中账号绑定的 gateway;gateway 只管理本机进程、目录、端口和浏览器安装。控制面自行管理它创建的素材临时文件,不能要求 gateway 清理另一机器的路径。 +- gateway 需持久稳定的节点身份;显示名称、Endpoint 与节点身份分开。仍有绑定环境、运行实例或待清理资源时,不允许把 Endpoint 改指另一机器。 +- runtime 身份至少包含 `(gateway, environment, generation)`;创建、查询、停止、清理及事件都核对同一身份。 +- Profile 留在账号绑定机器。gateway 不可达时显示不可达/结果不明,绝不换到另一机器以空 Profile 重建。 +- 首版不做跨机复制 Profile、不做共享文件系统、不做自动故障转移。人工重新绑定机器必须说明要重新登录或另行批准搬迁。 +- 浏览器二进制按版本在每台 gateway 预安装一次,启动前核实版本可用;任务不得拉镜像、下载或复制整套浏览器。 +- 用“浏览器版本”取代“镜像版本”,控制面保存版本标识,gateway 映射到预安装的绝对路径。路径不是用户可自由下发的执行命令。 +- **必须取得现有指纹 Chromium 的原生 Linux 发行物、来源、许可证及参数支持证据。**不能偷偷换成普通 Chromium。若只有 Docker 镜像可用,应先解决发行物来源问题,不把启动时解包镜像作为新方案。 + +## 4. 生命周期与清理契约 + +### 4.1 两种资源寿命,不新增两套环境系统 + +| 对象 | 何时创建 | 何时回收 | 必须保留的内容 | +| --- | --- | --- | --- | +| 账号环境定义、稳定指纹、绑定 gateway | 创建账号/环境 | 用户明确删除相关业务对象 | 不因一次任务结束删除 | +| 账号 Profile | 首次人工登录/启动需要时 | 用户明确删除账号数据;普通停止不删除 | Cookie、Local Storage 等登录与身份必需状态 | +| 任务独占浏览器及 Xvfb | 采集真正开始时 | 本次采集成功、失败、取消、超时后 | 不保留活进程 | +| 匿名采集临时 Profile | 仅明确无需登录的任务需要时 | 随该任务清理 | 无需保留 | +| cache、临时下载、CDP 文件、运行目录、临时截图 | 对应 runtime/任务需要时 | runtime 停止并完成结果提交后 | 正式结果另存,不依赖这些目录 | +| 人工登录会话、持续监听浏览器 | 明确登录/开启监听 | 显式停止、登录期限结束或服务故障 | 长期监听不能被另一个短任务关闭 | +| 作品、评论、线索、审计及用户确认保留的素材 | 业务提交成功时 | 遵循业务删除操作 | 不属于运行时垃圾 | +| 浏览器安装包/共享二进制 | 运维安装版本 | 确认无环境引用后显式卸载 | 不随任务删除,也不每任务复制 | + +**一个稳定账号 Profile 同时最多由一个浏览器实例使用。**采集账号与自有账号监听职责分开:优先使用已有指定采集账号,不复制活跃 Profile,不为同一账号另开浏览器。冲突明确排队或拒绝,不悄悄关闭监听。若必须使用已有长期会话,需明确标识为借用:只关闭本任务创建的页面、下载与引用,绝不销毁借入 runtime;不能将该路径伪称为任务独占浏览器已回收。 + +任务所属的临时资源必须全部归还,不要求把用户早已打开的登录/监听会话一并关闭。排队等待期间不能提前占用 Xvfb、浏览器或临时大文件。 + +### 4.2 正常与异常终态 + +1. 获取账号/Profile 使用权与运行代次;落盘创建意图。 +2. 创建 runtime 临时目录,分配显示/端口,启动 Xvfb/浏览器,核对代理及身份。 +3. 执行业务,先将结果提交到持久存储。大文件写入本次执行独有、不可覆盖的产物路径,再由数据库核对执行 token 并发布引用;旧 token 不能覆盖新文件。数据库提交结果不明时先查引用,不能猜成未发布后删除。 +4. 无论业务结果如何,都使用**独立且有时限的清理上下文**释放任务资源,不能沿用已取消的请求上下文。 +5. 回收顺序:停止任务及订阅/页面 → 退出浏览器及子进程 → 关闭 Xvfb/可选桌面 → 释放本次代理引用 → 删除已确认属于本代次的临时目录 → 释放锁并记账。 +6. 清理失败时保存待清理清单、最后错误及重试信息;禁止只写日志然后报“已回收”。同环境新启动必须先解决旧代次冲突。 +7. 重复取消、重复清理、迟到的旧代次请求必须幂等;旧请求不得结束新 runtime。 + +业务结果与清理结果分别记录:例如“采集成功,资源待清理”。不能因为删除临时目录失败就重复采集,更不能自动重复执行写操作;也不能因为业务失败就跳过清理。 + +### 4.3 磁盘增长不能只靠删除临时目录 + +- 尽可能把浏览器磁盘缓存、下载与临时文件指向 runtime 私有目录。`--disk-cache-dir` 不代表浏览器的全部写入都已迁移。 +- 持久 Profile 只保留登录/身份相关状态;浏览器版本已验证可重建的缓存,必须等 Profile 解锁后按固定白名单清理。不凭名称猜测并删除未知目录,不删除 Cookie/Local Storage。 +- 浏览器日志、截图与诊断材料必须有明确大小/数量/保留期限;不能把垃圾从 runtime 目录转移到日志目录。 +- 每台 gateway 启动前检查可用磁盘和运行槽位;不足时明确拒绝/等待,不通过删除登录资料腾空间。 +- 未知目录不自动 `rm -rf`。目录归属、路径边界和符号链接校验属于防止误删的必要条件,不是新增安全产品。 +- 验收分别统计临时目录、Profile、诊断日志和正式素材;正式采集结果的合理增长不能算泄漏,登录 Profile 的持续无界增长也不能被忽略。 + +## 5. 不能漏掉的能力替代 + +| 风险 | 处理要求 | 放行条件 | +| --- | --- | --- | +| 容器内固定 display/5900/9222 在宿主机冲突 | 由 runtime 独占分配,优先使用操作系统/工具提供的空闲分配能力;并发就绪确认,不用“先探测再假定可用” | 同机器两个以上 runtime 不串端口/窗口 | +| 容器 ID / network ID 被业务当运行身份 | 统一改为 runtime generation 与代理绑定版本;不复用 PID 冒充稳定 ID | 迟到请求、重启、并发启动不误删新实例 | +| 代理以前依赖 Docker 网段/容器地址 | 复用 Python 代理转发;浏览器显式连接本机对应代理端点;代次核对继续保留 | A09/B03/D05 按 HTTP/HTTPS/SOCKS4/SOCKS5 及协议支持的认证配置分项记录;实际浏览器出口、认证失败、网络失败均有证据,失败不直连(AC-E3) | +| 原生版本切换重置账号资料 | 停止后执行普通升级,保留原 Profile、稳定指纹与绑定账号,不通过重建环境升级 | A02 核对实际版本已切换、原 Profile/指纹/登录身份保持(AC-E1);不含首次 Docker 切换的数据处置 | +| 把 Docker 网络隔离等价替换成浏览器 sandbox | 明确移除旧隔离描述;sandbox 不能提供原容器的网络/文件系统边界 | 不作隔离等价承诺;如仍要求系统级禁直连,需要另行确认,而不是暗加防火墙 | +| `--no-sandbox` 直接继承 | 非 root 运行,确认内核/浏览器支持正常 sandbox;不能启动就报前置条件不满足,不静默禁用 | 使用实际指纹浏览器证明 sandbox 配置有效 | +| wrapper 有 RFB 服务,但源码未证明网页远程桌面已接通 | 保留已实现的二维码/CDP 页面动作;如要求完整网页远程桌面,单独确认后使用成熟链路,不冒称已有 | 登录、二维码保持;桌面仅在批准的范围内验收 | +| gateway 消失后浏览器仍在运行 | OS 管理完整运行组;短任务有最长寿命;恢复时识别受管实例并清理失去所有者的资源 | 无永久失管进程;存活监听恢复或明确报告中断 | +| 所有采集结束都通用关闭浏览器 | 任务独占与借入长期会话明确区分;共享代理引用计数不误关 | 常驻监听、其他任务、另一机器不受清理影响 | +| 出口探测在控制面执行、gateway 代理 ready 仅证明监听 | 从实际执行 gateway 的浏览器路径验证代理和身份;控制面探测不能代表远端机器可用 | A/B 机器各自出网检查有证据,不能只复用中央检测结果 | +| 素材临时文件和旧执行产物相互覆盖 | 每个执行独有目录/不可变产物引用,发布受 token 约束;清理也校验归属 | 旧执行恢复、清理、超时不破坏新产物 | +| 多机快照串行等待拖慢续租 | 保留既有心跳,按节点隔离失败与时限;只有验证证明需要时增加有界并发,不新建调度系统 | A 不可达时 B 续租、采集和监听不受拖累 | +| UI 显示完成但后台仍占磁盘 | P2 即交付最小接口适配、启停/采集入口及业务与清理双状态,失败有明确错误;P4 完善完整管理界面 | P2 的单节点闭环可从界面操作并查看停止、待清理和失败,不依赖 P4 才能验收;清理失败不会显示为完全成功 | + +## 6. 改造评审结论与放行项 + +**建议采用:每台 gateway 管理本机独立 Xvfb/runtime,保留账号 Profile,采集结束回收任务资源,复用现有多机路由及业务层。**先形成一个可手工验收的抖音端到端切片,再完成多机故障与全业务回归。 + +代码实施前须确认: + +- [ ] 目标机器为满足 systemd、Xvfb、sandbox 条件的 Linux,原生指纹浏览器可合法部署且能力相同。 +- [ ] 接受删除 Docker 浏览器代码路径、镜像字段/接口及相关部署配置;开发数据可以受控重建,但删除用户数据仍需逐次授权。 +- [ ] 确认 Profile 不随采集删除、机器不自动切换、任务不得抢占人工登录/长期监听;gateway 重启按独立 runtime 核对恢复,机器重启不自动恢复已过期短任务。 +- [ ] 确认本次不把完整网页远程桌面当作已有能力;若要求补齐,应另列明确验收范围。 +- [ ] 批准[验证文档](native-browser-verification.md)中的资源预算与性能放行标准;没有旧基线时不能宣称已降低开销。 + +以上是实施放行清单,不是本轮要求用户补齐运行环境后才能拿到文档。 + +## 7. 专项评审处理记录 + +本轮进行了两项独立只读代码评审:gateway/多机生命周期,以及采集/素材资源清理。它们审查的是现有实现与改造风险,不是新实现的测试通过证明;本方案由主审汇总,仍待用户批准实施。 + +| 发现 | 处理 | +| --- | --- | +| gateway 和 Docker runtime 寿命不同,直接绑定父子进程会改变重启体验 | 采用独立 runtime unit,核对恢复;短任务有有界寿命 | +| Xvfb wrapper 与实际强制入口不同,远程桌面链路无完整证据 | 不冒称现有能力;原生启动链单独验证,完整桌面另行确认 | +| source lease 不能代表浏览器所有权,短任务会误停长期会话 | 明确任务自建/借入与使用权,P3 验证并发和监听 | +| 控制面也有临时文件,旧素材执行可能覆盖新产物 | 先做执行目录与不可变产物发布,再做残留清理 | +| gateway 名称/Endpoint 可变,旧清理可能指向新机器 | 固定节点身份与资源 owner,阻止有资源时更换机器 | +| 中央出口检查不能证明远端浏览器出网正确 | 从 A/B 实际执行路径分别验证,不复用中央结果冒充 | + +尚待 P0 补齐的不是代码小修,而是原生浏览器发行物、目标机器条件、契约批准和性能基线。未执行真实平台或故障测试的项目全部保留为未验证。 + +## 8. 成熟方案参考与证据边界 + +- [Xvfb/xvfb-run 手册](https://manpages.debian.org/bookworm/xvfb/xvfb-run.1.en.html):虚拟显示、自动 display 分配与 X server 生命周期;不将它当浏览器业务管理器。 +- [systemd.kill](https://www.freedesktop.org/software/systemd/man/latest/systemd.kill.html)、[systemd-run](https://www.freedesktop.org/software/systemd/man/latest/systemd-run.html):使用系统已有的进程归属和终止机制。 +- [Playwright persistent context 文档](https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-persistent-context):持久 user-data-dir 独占与上下文寿命的成熟约定;引用约定不代表引入该依赖。 +- [Chromium Linux sandbox](https://chromium.googlesource.com/chromium/src/+/HEAD/docs/linux/sandboxing.md):sandbox 是浏览器防护机制,不等于 Docker 环境隔离。 + +本轮取得检索结果,但直接抓取上述页面被当前工具的代理地址校验拦截;不将未取得的全文或发行版行为当作已验证证据。实施 P0 须用目标机器的手册、浏览器文档与小规模验证补齐。这里只引用文档,不复用上游代码;若后续复制脚本/发行物,必须记录具体来源、版本和许可证。 diff --git a/docs/native-browser-implementation-plan.md b/docs/native-browser-implementation-plan.md new file mode 100644 index 0000000..95cd6dd --- /dev/null +++ b/docs/native-browser-implementation-plan.md @@ -0,0 +1,175 @@ +# 原生浏览器环境:评审后的实施计划 + +> 状态:待批准实施。仅文档,本轮未修改业务代码、数据库或运行环境。 +> 依据:[变更评审](native-browser-change-review.md)、[业务基线](plan01.md)。验收:[验证文档](native-browser-verification.md)。 + +## 1. 完成定义 + +满足以下条件才算完成改造,而不是“可以启动 Chromium”就结束: + +- 各 gateway 不访问 Docker socket,不创建浏览器容器、镜像层、卷或网络;每个 runtime 在本机运行 Xvfb 与指定版本浏览器。 +- 通过现有控制面管理至少两台机器;账号、运行代次、代理、Profile 与实际节点一致,不自动跨机重建。 +- 采集任务可按需启停;任务自建进程和临时文件在所有终态回收。清理失败真实可见并可重试。 +- 持久登录、Profile、稳定指纹及账号身份在重启和普通浏览器升级后保持;二维码登录、采集结果及逐次确认的人工发送不退化(A02、A10;AC-E1)。 +- gateway、控制面重启及监听重连不清除自动响应的事件去重、基线或 UID 冷却;旧事件不重放,迟到事件默认只记录、不自动补发(C02、D04;AC-A9、AC-B1)。 +- HTTP、HTTPS、SOCKS4、SOCKS5 及协议支持的认证配置逐项验证实际浏览器出口、认证失败和网络失败,失败不得直连(A09、B03、D05;AC-E3)。 +- 控制面本机的素材临时文件同样受执行归属和清理约束;旧任务不能覆盖新产物。 +- 旧 Docker 浏览器路径彻底删除;构建/测试达标,局域网环境可供用户手工验收,资源对比有实际记录。 + +## 2. 保持简单的目标结构 + +```text +React → Go control-plane(账号/任务/绑定/业务结果/素材发布) + ├─ gateway A(已有节点登记与路由) + │ ├─ 预安装浏览器版本(所有任务共用只读二进制) + │ ├─ 账号 A 的持久 Profile + │ └─ runtime generation:Xvfb + 浏览器 + 临时目录 + └─ gateway B(同样结构,独立本地资源) +``` + +- 继续使用 Go 控制面、Python gateway、PostgreSQL 与现有 CDP/WebSocket。 +- 用 Linux/systemd transient unit 管理每个运行实例;gateway 使用 Python 标准库调用明确的进程管理操作。固定的 runner 仅启动 Xvfb、浏览器和批准的桌面组件,不是可执行任意命令的接口。 +- 一 runtime 一 Xvfb,按需启动,不设浏览器池、不预热空闲实例。 +- 不为 Docker/原生模式保留可切换后端。实施分支可以暂时有未发布的中间步骤,但交付只保留原生路径。 +- 先完成“单机账号登录 → 抖音采集 → 资源释放”最小完整流程,再扩展为多机与全部业务回归。 + +## 3. 必要数据和接口变化 + +以下为**契约变更提案**,不是已存在的新字段/API。P0 批准后才能实施。 + +| 概念 | 变更 | 约束 | +| --- | --- | --- | +| gateway | 保留节点登记、显示名称与 Endpoint;新增持久稳定节点身份/能力 | 仍有关联资源时不得把旧节点改指新机器;无自动迁移 | +| browser version | 删除镜像拉取/构建语义,改为预安装浏览器版本及可用状态 | 每台 gateway 核对实际版本、指纹参数与路径;缺失时明确报错 | +| environment | 保留稳定账号环境、指纹与绑定;版本字段去除 image 语义 | 环境定义不等于正在运行的浏览器,元数据创建不应提前占用进程 | +| runtime | 用节点身份 + environment + generation 代替 container/network ID | 状态、清理、事件和代理绑定均核对代次;不能只用 PID | +| profile | 原 Docker volume 改为 gateway 依据内部 ID 生成的持久目录 | 单 Profile 独占打开;任何任务请求都不能指定任意删除路径 | +| task use/lease | 复用现有任务与 lease 表/模式,增加 runtime 使用目的、所有权和期限 | source lease 不等于 runtime lease;长任务须续租,暂停等待不提前分配浏览器 | +| cleanup | 业务状态与 cleanup 状态分别记录,并保存资源 owner 与最后错误 | 成功且待清理不等于业务失败;不得借清理重试重复采集/发送 | +| material artifact | 每次执行独有临时目录与不可变产物路径,DB 按执行 token 发布引用 | 发布结果未知时查询事实后再删;清理不能删除已被正式引用的文件 | +| gateway create/delete | 保留显式创建、启动、停止、回收动作,替换 Docker 载荷 | 重复创建使用同一 generation;响应丢失时查询同代次,不能直接换 ID 重开 | + +### 状态与错误语义 + +- 区分浏览器就绪与账号可执行:CDP/页面就绪后即可进入人工登录;未登录时显示待登录,而不是永久“正在启动”。只有代理和实际账号身份确认后才允许采集/写操作;身份不一致立即拒绝写操作。 +- gateway 不可达、响应丢失、写入结果不确定时,记录未知/待核对,不自动重试平台写操作。 +- 明确区分校验失败、Profile 占用/代次冲突、节点/依赖不可用与服务内部错误。P0 列出当前状态码及预期变化并批准;不在实现中悄悄改为 `200`。 +- 删除已经不存在的同代次资源应可重复执行;针对旧代次的删除不得作用于新实例。 +- 去除 `container_id`、Docker 网络及 image 字段会影响 API、UI 和测试。采用受控的破坏性开发切换,不增加兼容字段、双写或迁移回填。 +- 数据重建不等于可以删除用户 Profile/素材。实际删除与重新登录名单须在执行前取得授权。 + +## 4. 阶段与放行条件 + +### P0:前置条件和变更契约(不改业务行为) + +交付: + +1. 确认当前未提交工作由谁负责,将基线固定到可复现提交;在干净、明确基线之上新建实施分支,不擅自 stash/reset 用户改动。 +2. 核实两台 Linux 机器的 systemd、用户服务、Xvfb、字体、浏览器依赖、sandbox、可用磁盘;取得合法原生指纹浏览器并固定版本,指定用于 A02 普通升级验证的源版本与目标版本。 +3. 记录旧方式性能/磁盘基线。默认不运行 Docker;如无历史可信数据,需要用户另行批准旧方式对照运行。 +4. 列出受影响状态码、载荷、字段、配置和移除项,批准第三节契约。明确 gateway 重启后受管 runtime 的恢复行为。 +5. 固定验证参数:任务最长时间、清理时间预算、任务使用权续租/过期时间、磁盘下限、并发数、日志保留及资源对比指标。 + +放行:没有原生浏览器、必要指纹参数不被支持、sandbox 无法启用、没有批准破坏性契约时,停在此阶段并给出明确缺口;不得偷偷切到普通 Chromium、`--no-sandbox` 或旧 Docker。 + +### P1:gateway 原生生命周期(与 P2 组成第一个端到端切片) + +先写失败测试,再实现: + +- 提取并保留现有与 Docker 无关的代理、CDP、页面动作;将 gateway 包和测试从 Docker 命名改为浏览器领域命名,建议目标路径 `cmd/browser_gateway/`。 +- 原生启动输入校验在创建目录/进程之前完成:节点、环境、代次、浏览器版本、Profile、代理绑定及必要资源容量。 +- 创建前落盘运行意图;用 runtime 专属 unit/临时目录启动 Xvfb 和浏览器,分配不冲突的 display 与端口。Profile 独占不能只靠控制面内存锁。 +- 默认不继承 `--no-sandbox`;浏览器与 gateway 以非 root 身份运行。具体 sandbox 模式由目标二进制/发行版证明。 +- 创建失败逆序回收已经创建的资源;停止为有界的优雅退出、OS 终止与目录清理。 +- 短任务有自我终止上限,runtime 独立于 gateway 进程存活但不永久失管;长期会话在 gateway 重启后核对 unit/目录/代理与代次,不随意接管别的 CDP。 +- gateway snapshot/health 返回实际 runtime 与待清理信息;“进程在”不能冒充浏览器可用。 + +最小检查:创建/就绪/停止、部分启动失败、并发分配、Profile 冲突、重复清理、旧代次删除、gateway 重启识别、未知资源拒绝删除;四类代理及支持的认证配置分别覆盖连接成功、认证失败和网络失败,不直连。实际浏览器证据按 A09/B03/D05 另行手工验收。 + +放行:单节点不需要 Docker daemon/socket 可完成真实人工登录和停止再开,账号仍保持登录;gateway 单元覆盖率至少 65%。 + +### P2:控制面连接原生 runtime(首个可用闭环) + +先测试控制面与 gateway 的成功、校验、失败、冲突、响应丢失路径,再改: + +- `hub.go` 的创建、启动、停止、状态核对和 cleanup-pending 改用 generation/owner;删除容器和网络代次依赖。 +- `internal/hub/store.go`、相关查询与 schema 改为原生版本/运行模型;开发数据库受控重建,不回填旧容器字段。 +- 继续复用既有多 gateway 登记、路由和心跳;核对稳定机器身份。节点 Endpoint 的更换不能改写既存 cleanup 的目的地。 +- 代理服务不再依赖 Docker IP/网络,仍保留代理绑定代次和失败可见性;从目标 gateway 的实际浏览器路径验证出口。 +- 登录二维码、账号身份快照、恢复事件订阅全部使用新 runtime 身份;不能随运行代次变化清空业务事件去重、基线和已占用 UID 冷却。先用确定性测试覆盖重启/重连、冷却内新事件、冷却到期旧事件及迟到事件不补发;真实平台按 C02/D04 验收。 +- 接入停止后的普通浏览器版本升级,复用原 Profile 与稳定指纹;启动后核对实际版本及账号身份,不通过新建环境或重置账号资料实现升级。 +- 同阶段交付首个闭环所需的 Refine data provider 适配、已安装版本选择/普通升级、登录、显式启停与采集入口;环境页区分定义与运行状态,任务页分别展示业务结果、清理状态和明确错误,不只写日志。覆盖节点不可达、版本缺失、Profile 占用、待清理及禁用状态的交互测试,不推迟至 P4。 +- 本阶段即接入最小任务归属:仅对空闲专用采集账号申请独占使用权,自动创建、执行并用独立有界上下文回收;创建前登记资源 owner,正常/失败都释放。借入长期会话、多任务续租和素材崩溃恢复在 P3 完成,不以无归属的临时启动代码作为中间方案。 +- 一台 gateway 的不可达不能阻塞其他节点的续租/状态更新;在现有机制上做有界隔离,不增加第二套 scheduler。 + +放行:一台 gateway 上从界面完成“手工登录 → 按需采集一个指定账号 → 正确存储结果 → 释放本任务 runtime/临时文件 → 再次启动无需重新登录”。停止、清理及失败状态从界面可见;按 A02 验证普通升级后实际版本切换且 Profile、指纹、登录身份保持。新运行身份下自动响应去重/冷却的确定性回归检查通过;C02/D04 的真实平台恢复与多机证据在 P4 最终放行前补齐,不能用代码检查标记手工用例通过。 + +### P3:采集任务和素材的完整资源归属 + +这是本次磁盘问题的根本修复,不只做 `finally`: + +1. 任务分配在实际执行前申请使用权,标明任务自建 runtime 或借入已有会话。优先使用专用采集账号;Profile 被监听/人工登录占用时明确等待或冲突,不抢占。 +2. 对 source/素材执行 lease 续租,执行 token 覆盖写结果、发布产物和清理。旧 lease 到期的执行不得提交/覆盖新执行结果。 +3. 成功、失败、取消、超时均走独立有界清理上下文;取消后的业务收尾同样不能用已取消 context,避免永久 running。 +4. 控制面与每台 gateway 各清理自己创建的文件。任务临时资源登记不使用跨机任意路径删除接口。 +5. 素材写本次执行独有的目录;结果不可变发布与 DB token 检查构成同一业务提交边界。先防止旧执行覆盖新文件,再增加孤儿文件清理。 +6. 业务完成和 cleanup 完成分别保存。失败记录有错误、次数和下次重试;周期核对只处理本节点受管残留,不按文件年龄扫整个目录。 +7. 缓存尽可能落到 runtime 目录;持久 Profile 离线清理经过版本验证的缓存白名单。诊断日志有明确上限;正式素材不属于临时清理。 +8. 活跃监听/人工会话与任务页面分别持有使用权。最后一个短任务退出可以结束其自建 runtime,但不能结束仍有合法长期持有者的 runtime;不引入无限空闲复用池。 + +最小检查:所有业务终态、采集超十分钟仍有效续租、旧 token 迟到、数据库提交结果未知、控制面强杀、gateway 强杀、磁盘满、删除失败、两任务同 Profile、监听与采集并行、另一机器同名环境。 + +放行:每种终态均有资源前后清单;正式素材/登录信息不受影响;残留可解释且有界,失败不隐瞒。 + +### P4:界面、部署与清理旧路径 + +- 在 P2 已可用的版本选择/普通升级、启停及任务状态界面上,完成浏览器版本管理与各节点可用情况展示;移除无效的镜像拉取、构建、容器/网络操作。 +- 扩展 P2 的 Refine data provider 和交互测试,覆盖 P3 完整资源归属及多节点场景;保留业务结果、清理状态、错误和禁用原因分别可见,不把首个闭环必需的界面工作留到本阶段。 +- 增加可重复部署的 gateway/原生 runner 用户服务配置和本机浏览器安装说明。跨重启保留节点 ID、Profile、版本配置和运行清单。 +- 若 runtime 允许跨 gateway 重启存活,其临时目录必须由 runtime unit 归属,不能放在 gateway 重启就被 systemd 删除的 `RuntimeDirectory` 中。大型缓存/下载明确使用配置的数据磁盘,不因“临时”二字默认放进 `/run` 的 tmpfs 而转为大量内存占用。 +- 修改 `scripts/dev-backend.mjs`、开发脚本、Dockerfile/Compose 的旧 gateway 相关配置;默认裸启动 Go/Python/Vite,不悄悄 `compose up docker-gateway`。 +- 删除 Docker browser wrapper、拉镜像/构建/网络/卷管理及相关测试、配置;PostgreSQL 等仍使用 Docker 的独立部署资产可保留,逐项说明,不做无关清理。 +- `AGENTS.md`、README、部署说明、架构说明、E2E 文档全部对齐。删除旧入口,不保留“失败就回退 Docker”。 + +放行:验证文档全部必测项有证据;静态检查中浏览器链路没有 Docker 依赖;两节点局域网手工验收通过。 + +## 5. 改动定位与顺序约束 + +| 范围 | 主要定位 | 必须一起变化的内容 | +| --- | --- | --- | +| gateway 执行 | `cmd/docker_gateway/gateway.py`、`proxy.py`、CDP/平台模块及其测试 | 原生 runner、版本/端口/Profile、unit 生命周期、snapshot、代理恢复 | +| 控制面 lifecycle | `cmd/control-plane/hub.go`、`main.go`、hub tests | generation、owner、清理/心跳、多 gateway 错误隔离 | +| 采集与监听 | `creator.go`、`creator_events.go`、`internal/creator/source_lease.go`、`content.go` | 使用权、续租、独立收尾、事件边界与身份核对 | +| 素材 | `creator_material.go`、`internal/creator/material.go`、素材测试 | 临时目录、不可变发布、token、崩溃清理 | +| 数据模型 | `internal/hub/`、`internal/creator/` | 删除 Docker 字段,明确开发重建;业务结果与清理状态分离 | +| 前端 | 现有 gateway/环境/镜像/任务相关组件及 data provider | 对应契约、失败/禁用交互;不重做导航和视觉体系 | +| 部署/文档 | `scripts/`、`compose*.yaml`、Dockerfile、`docker/browser-wrapper/`、README、`docs/` | 原生安装和联调;旧路径移除;手工验收地址 | + +顺序限制:产物执行隔离先于孤儿文件清理;本地单任务闭环先于多机压测;所有权和代次先于通用自动清理;不能先删 Docker 实现再留下无法登录的中间交付。 + +## 6. 验证及交付方式 + +- 每个非平凡行为改动先有会在旧实现下失败的测试;单元覆盖率至少 65%。Go 全量测试、vet、build,生命周期/并发相关变更跑 race;Python 非交互测试与覆盖率;前端从 lockfile 安装并测试、构建。 +- 更改 Docker/Compose 文件执行 `docker compose config --quiet`;不默认构建/运行任何 Docker 镜像。旧基线测试需另行得到用户同意。 +- 功能验收不使用浏览器自动化或批量 API 代替用户操作。提供 `0.0.0.0` 监听的本地环境和两台 gateway 地址,由用户按验证文档手工确认。 +- 资源观测、日志读取和单元测试可以自动执行,但不能因此将真实登录、采集、发送或多机故障验收标为通过。 +- 本轮不会启动服务、重建数据库或删除遗留资源。实施完成后才启动可联调环境,并报告实测局域网地址。 + +## 7. 切换与失败恢复 + +1. 经用户确认冻结旧运行、记录账号/节点/版本与需要保留的资料。旧 Docker 卷、用户 Profile、数据库及素材都不能未经授权删除。 +2. 原生环境使用全新开发数据可重新登录;不在应用里实现旧 Docker Profile 迁移或双读。若要求保留既有登录,单独确认一次性受控操作及浏览器版本一致性。 +3. 停止旧浏览器执行后才能启用新路径,不能让同账号新旧环境同时工作。 +4. 如新环境验收失败,停止新任务、保留证据、修复再验。紧急恢复旧版只能是人工恢复到明确版本和匹配数据,须取得授权;不是运行时隐式回退。 +5. 验收后,经授权回收旧浏览器容器/卷/网络与镜像。只删除确认属于旧 CreatorHub 浏览器的资源,禁止全机 `docker system prune` 或按相似名称批量删除。 + +## 8. 实施前批准清单 + +- [ ] Linux/systemd 与原生指纹浏览器部署条件满足。 +- [ ] 独立 runtime 跨 gateway 重启核对恢复、短任务有界存活策略获准。 +- [ ] 账号 Profile 保留、采集独占/借用边界、无自动跨机迁移获准。 +- [ ] API/schema/配置的破坏性变更及数据重建范围获准。 +- [ ] 明确完整网页远程桌面是否另立需求,不能把 wrapper 内有 x11vnc 当已具备该功能。 +- [ ] 验证参数、旧方式对照是否允许运行 Docker、实测性能放行标准获准。 + +在上述批准之前,这份文档只作为实施依据,不构成已经完成的改造。 diff --git a/docs/native-browser-verification.md b/docs/native-browser-verification.md new file mode 100644 index 0000000..e12d012 --- /dev/null +++ b/docs/native-browser-verification.md @@ -0,0 +1,252 @@ +# 原生浏览器环境:验证与手工验收 + +> **当前状态:仅验证计划,下面所有功能、故障、性能用例均未执行。** +> 本轮未构建镜像、启动服务、操作真实账号、终止进程或删除数据。 +> 方案:[变更评审](native-browser-change-review.md);阶段:[实施计划](native-browser-implementation-plan.md)。 + +## 1. 验证边界 + +- 开发者负责单元/契约检查、构建和启动可联调环境;真实功能由用户手工验证,不使用浏览器自动化或脚本代点网页。 +- 读取日志、目录占用、进程/端口和系统指标可以使用命令。自动测试中的替身只证明代码行为,不能证明平台登录、监听、发送或真实代理能力。 +- 默认不构建/运行 Docker。旧方式对照、旧资源删除、故障注入必须单独取得用户同意。 +- 测试专用账号、Profile、gateway、数据目录及出口;不能强杀用户正在使用的长期账号或清空全机 Docker 资源。 +- 任何结果使用“未执行 / 通过 / 失败 / 阻塞”,不得把文档中的期望填成实际结果。 + +## 2. 前置条件和记录表 + +实施 P0 完成后填写,缺项不得开始破坏性操作: + +| 项目 | 必填记录 | +| --- | --- | +| 软件基线 | Git 提交、未提交改动清单、Go/Python/Node/systemd/Xvfb/浏览器版本 | +| 机器 | 控制面 C、gateway A、gateway B 的稳定节点 ID、IP、CPU、内存、磁盘、发行版 | +| 账号 | 专用采集账号、自有监听账号;预期平台 UID,勿记录密码/Cookie | +| 浏览器 | 原生发行物来源、许可证、校验值、支持的指纹参数和 sandbox 证据;A02 普通升级的已安装源版本/目标版本 | +| 路径 | 各机器 Profile 根目录、runtime 根目录、运行清单、日志、控制面素材目录 | +| 网络 | A/B 各节点的 HTTP/HTTPS/SOCKS4/SOCKS5 测试出口、协议支持的认证配置、预期出口 IP、CDP 可用情况;只记录配置标识,不记录密码 | +| 操作许可 | 是否允许旧 Docker 对照、普通浏览器升级、gateway/控制面重启、任务取消、磁盘故障和测试数据删除;C02/D04 自动响应的测试策略、可控互动/接收账号及授权范围,A10 人工发送另行逐次确认 | +| 时限 | task 最大执行时间、续租间隔/过期时间、清理预算 `T_cleanup`、恢复预算 `T_recover` | +| 资源预算 | 并发数、最低可用磁盘、日志上限;启动 p50/p95 和总内存目标,Profile 缓存预算 | +| 证据位置 | 独立于 runtime 清理目录的本地证据目录,不提交账号敏感数据 | + +建议首轮使用并发 1 和 2、短任务正常清理预算 30 秒、服务恢复预算 60 秒;这些是待批准的测试参数,不是已实现默认配置。复杂平台采集的最大时长单独确定,不能因为清理预算而强行截断正常业务。 + +## 3. 开发者检查命令 + +以下是**实施完成后的命令**,不是本轮执行记录。gateway 重命名为 `cmd/browser_gateway/` 后才使用该路径;最终脚本需与实际交付一致,不允许测试目录为空仍算通过。 + +### Go + +```bash +go test ./... +go vet ./... +go build -o /tmp/creatorhub-control-plane-check ./cmd/control-plane +go test -race ./... +go test -coverprofile=/tmp/creatorhub-go.cover ./... +go tool cover -func=/tmp/creatorhub-go.cover +``` + +要求:测试、vet、build、race 全部成功;单元测试覆盖率至少 65%,同时核对本次生命周期/资源归属模块,不能用高覆盖无关模块掩盖改动遗漏。自动响应须用确定性测试覆盖重启/重连后事件永久去重、原 UID 冷却保持、冷却内新事件不执行、到期只允许新事件及迟到事件不补发(AC-A9、AC-B1);P2 接入新运行身份时即通过这些检查。真实平台结果另外验收。 + +### Python gateway + +```bash +python3 -m venv .venv-gateway +.venv-gateway/bin/python -m pip install -r requirements-gateway-dev.lock +.venv-gateway/bin/python -m unittest discover -s cmd/browser_gateway -p 'test_*.py' +.venv-gateway/bin/python -m coverage erase +.venv-gateway/bin/python -m coverage run --source=cmd/browser_gateway \ + -m unittest discover -s cmd/browser_gateway -p 'test_*.py' +.venv-gateway/bin/python -m coverage report --omit='*/test_*.py' --fail-under=65 +``` + +核对实际发现的测试数量;禁止跳过未安装的原生管理依赖后将结果当成全通过。用模拟子进程测试错误分支不等于真机 systemd/Xvfb 验收完成。 + +### 前端 + +```bash +npm --prefix web ci +npm --prefix web run test +npm --prefix web run test:coverage +npm --prefix web run build +``` + +覆盖 gateway 不可达、版本缺失、Profile 占用、开始/取消/停止、待清理错误和禁用状态。不要运行浏览器自动化命令代替本节手工验收。 + +### 配置与旧路径 + +```bash +docker compose config --quiet +``` + +仅在改动 Docker/Compose 时做配置校验,不执行 build/up;有保留的独立开发 Compose 文件时也逐份验证。另行检查浏览器 runtime 路径已无 DockerClient/socket、容器/镜像/卷/网络创建、容器 wrapper 入口、旧 image 字段和“原生失败回退 Docker”;PostgreSQL 的独立部署配置不算漏删。 + +## 4. 联调启动与访问 + +### 当前可确认的设置 + +- Go 使用 `LISTEN_ADDR`;联调须设置 `0.0.0.0:8082`。 +- Vite 当前 `/api` 代理到 `127.0.0.1:8082`,端口 5173。Vite 必须加 `--host 0.0.0.0`,不能只给 localhost 地址。 +- Python gateway 使用 `LISTEN_ADDR`,多机控制测试可采用各节点 `0.0.0.0:8081`;现有凭据约束保持不变。 +- 当前 `scripts/dev-backend.mjs` 会启动 Compose 依赖,因此在 P4 替换之前,**不能把现有 `pnpm dev` 当作已支持原生 gateway 的命令。** + +### 实施完成后 + +1. 按最终交付的原生安装说明,在 A/B 安装同一已确认浏览器、Xvfb 和 gateway 用户服务。服务名建议固定为 `creatorhub-gateway.service`;它目前尚未交付。 +2. 使用已有、经验证的 PostgreSQL 与凭据配置。不要重新生成现有主密钥;不要把秘密值粘贴到验收报告。 +3. 按交付脚本启动 A/B 的原生 gateway;在控制面登记真实 A/B 地址,并核对返回的节点身份。 +4. 启动控制面及前端: + +```bash +# 其余数据库/凭据等必填配置由现有受控环境提供,不在命令行回显秘密。 +LISTEN_ADDR=0.0.0.0:8082 go run ./cmd/control-plane +# 另一个终端 +npm --prefix web run dev -- --host 0.0.0.0 +``` + +命令启动后,用 `ip -brief -4 addr` 记录当次实际局域网 IP。用户在另一台设备访问 `http://:5173`;开发者可查看 `http://:8082/healthz`。报告列出 A/B 的实际 Endpoint。 + +本轮宿主机曾观察到 `10.1.1.104`,只可作为候选地址,**没有服务已启动的含义**;实施当天重新核实,不预先宣称该地址可用。 + +## 5. 手工功能与清理矩阵 + +每条记录:操作者、时间、节点、账号/任务/代次、操作、期望、实际、状态、日志/截图/资源清单位置。保留 33 项用例编号;同一用例的重启/升级、协议/认证、节点及恢复场景分别记录子项。必测子项未执行或阻塞时,不能把整个用例标为通过。 + +### A. 正常生命周期 + +| ID | 手工步骤 | 通过条件 | +| --- | --- | --- | +| A01 | 在无 Docker daemon/socket 可用的测试节点创建账号环境,选择原生版本,启动 | 正确 Xvfb/浏览器就绪;未登录时可进入人工登录,不永久停在“正在启动”;无镜像下载、容器、卷、Docker 网络操作 | +| A02 | 人工完成二维码登录,记录账号、Profile 标识和指纹;先关闭再启动,再停止并从界面普通升级至另一已安装且获准的版本后启动 | 两个子项均保持原 Profile、指纹和登录身份,无意外重新登录;升级后实际浏览器版本等于所选目标版本,不通过重建环境/重新登录冒充保持(AC-E1) | +| A03 | 浏览器停止时,从界面启动一次竞品账号采集,等待结束 | 自动创建任务 runtime,结果保存;`T_cleanup` 内进程、display、端口和临时文件释放 | +| A04 | 连续采集作品/评论/线索,再打开结果查看 | 数据完整且仍可读;不能因 runtime 清理丢失业务结果或已发布素材 | +| A05 | 同节点两个不同账号同时采集 | display、Profile、CDP/代理端口互不冲突;结束一个不影响另一个 | +| A06 | 同账号同时发起两个任务;另测人工登录占用该 Profile 时发起采集 | 明确排队或冲突;不出现两个浏览器打开同 Profile,不抢占登录会话 | +| A07 | 自有账号保持事件监听,另用采集账号执行并结束任务 | 监听不中断、不串 UID;采集自建资源完全释放 | +| A08 | 若批准借入长期 runtime 路径,用对应场景执行任务 | 只删除本任务页面/临时文件/引用;长期 holder 和 runtime 保留,界面如实说明借用 | +| A09 | 按下方代理矩阵分别配置四类代理及其支持的认证配置,改变代理或账号绑定后启动采集 | 每个配置下实际浏览器出口、代理版本、指纹时区/语言等正确;身份不符时拒绝动作;不能用单一无认证代理的成功代替其他配置(AC-E3) | +| A10 | 仅在用户准备真实接收账号并逐次确认时验证人工回复/私信 | 保持原有确认与身份核对;重复结果不触发重复发送,不确定状态不冒充成功 | + +A02 验证的是原生环境内的普通版本升级,不是首次 Docker 切换或 Profile 迁移;缺少获准版本时标为阻塞。 + +代理矩阵适用于 A09/B03/D05:A/B 每台节点分别列出 HTTP、HTTPS、SOCKS4、SOCKS5,按协议允许的配置验证无认证(如支持)及支持的认证方式;A09/D05 记录成功与实际浏览器出口,B03 记录认证失败(适用时)与网络失败,均不得静默直连或轮换出口。协议本身不支持的认证项说明依据,不作为通过项;缺少测试代理、凭据或尚未实现的承诺能力标为阻塞,不能免测。证据不得包含密码/Cookie。 + +A10 不能由脚本自动发送,也不能因为只修改 runtime 就免除必要回归;人工逐次确认不能代替 C02/D04 的自动策略验收。 + +### B. 全部终态与资源异常 + +| ID | 手工步骤/经授权的故障注入 | 通过条件 | +| --- | --- | --- | +| B01 | 任务运行中点击取消 | 业务不永久 running;独立清理继续,`T_cleanup` 内完成或明确待清理 | +| B02 | 让测试任务超过已批准最大时长 | 超时可见;lease 释放,任务进程/目录回收,不能继续后台写入 | +| B03 | 按代理矩阵逐节点、逐协议分别制造认证失败(协议支持时)和网络失败;另测无效浏览器依赖启动 | 每个适用子项明确失败,无静默直连或出口轮换;已创建的 Xvfb/端口/临时目录逆序回收(AC-E3) | +| B04 | 经授权终止该测试 runtime 的浏览器,另测 Xvfb 退出 | 任务失败可见;其余子进程终止;其他 runtime 不受影响 | +| B05 | 对测试专用目录制造无法删除条件后结束任务 | 显示“业务结果 + 清理失败”,保留可追溯记录;恢复条件后重试只清资源,不重跑业务 | +| B06 | 测试磁盘低于启动阈值;用隔离测试盘验证写满 | 启动前拒绝或运行中明确失败;不删除登录资料/正式素材腾空间;收尾状态可靠 | +| B07 | 重复点击取消/停止/清理;再对已结束任务重复操作 | 幂等,不返回虚假的活实例;不会清掉后来启动的新代次 | +| B08 | 经授权在 gateway 内已启动、控制面尚未收到回复时中断连接 | 查询同 generation 收敛;没有重复进程、Profile 或资源分配 | +| B09 | 长任务超过十分钟,观察续租;让旧执行失去租约后恢复响应 | 当前 holder 继续有效;旧执行不得发布产物、覆盖结果或清理新执行目录 | +| B10 | 人为让任务结果保存失败或响应丢失 | 区分确定失败与结果未知;先核对数据库引用,不能误删已发布素材或盲目重试 | + +### C. 重启与孤儿恢复 + +| ID | 步骤 | 通过条件 | +| --- | --- | --- | +| C01 | 在测试采集运行时,经授权强杀并重启 gateway | 依据 unit+节点+代次核对受管实例;过期任务回收,未过期任务恢复或明确失败;不重复启动 | +| C02 | 在已产生自动响应及冷却记录的长期监听账号上,分别重启 gateway、控制面并恢复订阅;按下方恢复子项核对 | 浏览器 Profile 不被删;代理/订阅恢复有证据,失联时间和可能遗漏可见;基线、事件去重记录与原冷却到期时间保留,旧事件不重放,迟到事件不自动补发(AC-A9、AC-B1) | +| C03 | 强杀 gateway 后暂不恢复,超过短任务的 unit 最长寿命 | 短任务整组进程退出;临时文件在 runtime 退出或后续核对时回收,不无限占用 | +| C04 | 控制面在临时素材下载、音轨提取期间被强杀,再启动 | 控制面处理自己遗留的临时文件;不会要求 gateway 删除控制面路径 | +| C05 | 旧素材执行恢复时新执行已完成,触发旧执行发布/清理 | 新正式文件与 DB 引用不变;旧执行只能清理自身未被引用的产物 | +| C06 | 经授权重启 gateway 所在测试机器 | 节点身份稳定,旧运行代次不冒充存活;过期短任务不自动重做,登录资料保留;监听恢复或明确提示 | +| C07 | 在清理目录旁放置无关文件/符号链接和另一 runtime 记录 | 无关资源不删除;未知归属有明确错误,不能按目录年龄一并清空 | + +C02/D04 必测恢复子项: + +- 恢复前记录已处理事件、自动动作结果和 UID 冷却原到期时间;恢复后,同一大号下该 UID 在冷却内产生新互动也不得再次触发自动动作,不因更换小号而绕过。 +- 已处理旧事件即使在冷却到期后再次出现,也不能重放;到期后的新事件才可按策略响应,迟到/恢复历史事件默认只记录、不自动补发。无法确认连续性时显示缺口并重新确认边界,不能只以“监听已连接”判通过。 +- 保存动作记录及可控接收端证据,确认未发生额外自动动作。真实平台由用户在已授权策略和可控互动/接收账号范围内手工制造互动,观察产品自身自动响应;不向无关用户发送,不用脚本或 Mock 冒充真实平台,也不把自动动作改成人工逐次确认。 +- 旧事件重放、到期及迟到等精确边界先由确定性测试证明;真实平台无法复现的子项记为未执行,缺账号/权限/平台能力则记为阻塞,不得用离线检查替代真实证据或将整项标为通过。 + +C05/B09 的精确竞态先用确定性单元测试证明;真机无法可重复制造时标记该子项未执行,不能用“人工未复现”替代回归测试。测试不得添加面向生产的任意执行接口。 + +### D. 多机控制 + +| ID | 步骤 | 通过条件 | +| --- | --- | --- | +| D01 | A/B 各创建合法唯一环境,分别启动;停止/清理一侧并观察另一侧 | 按稳定节点和代次执行,不误清另一机器;节点间资源 ID 碰撞另用契约测试覆盖,不为测试放松名称唯一性 | +| D02 | A 失联,B 正常采集/续租/监听 | B 不被 A 的超时拖住;A 显示不可达,不把 A 的账号迁到 B | +| D03 | A 仍有 Profile、运行实例或待清理资源时,尝试把登记地址改成 B | 明确阻止改变机器归属;旧 cleanup 目标仍是 A | +| D04 | A 恢复,旧停止响应/事件迟到,期间已有新代次;按 C02 恢复子项核对既有自动响应与冷却记录 | 旧响应不能关闭/污染新实例;事件和身份边界重新确认;原 UID 冷却保留,重复旧事件及迟到事件不触发额外动作,B 的记录与正常监听不受影响(AC-A9、AC-B1) | +| D05 | A/B 配置不同出口,按代理矩阵逐节点检查四类代理及支持的认证配置下目标浏览器的实际出网 | 各适用配置均有对应 gateway 的浏览器出口证据,不能只显示控制面测得 IP;失败子项与 B03 对照,无直连或轮换(AC-E3) | +| D06 | B 未安装账号指定浏览器版本 | 明确显示不可用,不自动换普通 Chromium、其他版本或另一节点 | + +抖音先完成全部适用用例;小红书按相同生命周期复验。尚未实现的平台能力记为阻塞,不用 Mock、轮询或另一平台成功代替。 + +## 6. 磁盘与性能验证 + +### 6.1 测量方法 + +1. 固定机器、浏览器版本、指纹、账号、代理、任务内容、并发和依赖状态;除生命周期方式外尽量保持一致。不要拿不同浏览器或不同网络的结果相减。 +2. 冷启动指本次机器/服务启动后的第一次浏览器启动,和后续热启动分开记录;不擅自全机清理 page cache。安装、镜像拉取或下载浏览器耗时另列,不混入日常任务启动。 +3. 每种方式在并发 1 下至少 20 次有效启动,并在并发 2 下至少 10 组。按相同节奏执行,遵守平台限制。遇验证码/限流单独记录,不删去异常后只展示好看的数值。 +4. 时间从用户启动请求被接收到 CDP+代理+账号就绪;另外记录完整任务、终止到资源清理完成耗时。不能只测进程创建或 Xvfb 启动时间。 +5. 内存统计整个 runtime 的浏览器子进程、Xvfb、可选桌面,以及 gateway 增量;旧方案同样统计完整进程树及 Docker/代理相关增量。优先使用 cgroup 峰值或一致口径的 PSS;RSS 合计会重复计算共享页,必须注明。 +6. 记录每轮之前、业务结束、清理完成三个时刻:活跃进程/unit、端口、runtime/Profile/日志/素材字节数。使用 `ps`、`ss`、`systemctl show`、`du -sb` 等只读工具;共享内存和 X socket 也须核对。 +7. 原生路径连续完成至少 30 个短任务,覆盖取消和失败;再观察一轮长期监听与采集并行。平台受限时可用明确标识的本地测试页面单独测生命周期,但不得拿它替代真实平台功能验收。 + +建议记录 CSV: + +```text +mode,node_id,browser_version,concurrency,round,task_id,generation,start_ms,task_ms,cleanup_ms,peak_memory_bytes,tmp_bytes,profile_bytes,log_bytes,material_bytes,result,cleanup_result +``` + +p50 取中位数;p95 使用排序后第 `ceil(0.95*N)` 个样本并标出 N。至少做两轮实验评估噪声,既报告绝对值也报告比例。 + +### 6.2 放行标准 + +| 项目 | 通过条件 | +| --- | --- | +| 短任务进程 | 清理完成后,该代次浏览器、Xvfb、桌面及子进程数为 0;合法长期会话单独列出 | +| 临时磁盘 | 清理完成后该任务临时目录不存在或为空;重复任务不留下逐轮增长的 runtime/下载/cache/提取临时文件 | +| 显示/端口 | 本代次 display/socket/端口释放;不能误删其他合法 X server 的共享目录 | +| Profile | 账号身份/登录状态保留;经批准的缓存预算不超标。不宣称持久 Profile 必须字节不变 | +| 日志 | 达到已批准保留上限后有界,清理失败诊断可追溯;日志中无 Cookie/密码/验证码 | +| 素材 | 正式结果可访问;增长与新增业务结果匹配,不被当垃圾删除 | +| 启动与内存 | 达到 P0 批准的具体预算,相比可信旧基线的改善超过重复实验噪声;若没有改善,报告未达到性能目标,不以“取消了 Docker”判通过 | +| 故障恢复 | 在批准时间内完成清理或给出明确待处理原因;无永久失管短任务,无跨机误删 | + +Docker 本身通常不是全部浏览器内存开销的来源;即使启动开销降低,Chromium 的页面和渲染进程仍可能占主要内存。实测不达标就继续定位,不承诺移除 Docker 必然显著省内存。 + +没有获准运行旧方式、也没有可信旧记录时:原生功能和绝对资源预算可独立验收,**性能改善仍标记“缺基线,未验证”**,不能填写节省百分比。 + +## 7. 日志与证据 + +关键节点应可关联:任务接受、使用权取得/续租、创建意图、unit 启动、显示/CDP/代理/身份就绪、业务提交、停止、删除、待清理、重试和恢复。 + +每条必要字段:`node_id`、环境/账号内部 ID、`runtime_generation`、任务/执行 token、阶段、耗时、结果、清理 owner 和错误;只记录内部标识,避免平台敏感数据泄漏。 + +每条失败证据包括: + +- 节点与任务界面状态,操作时间范围。 +- 控制面和对应 gateway 的关联日志。 +- runtime unit/进程/端口和目录占用前后对比。 +- 业务结果是否提交、清理是否完成、是否需用户介入。 +- 重试后能否收敛,以及其他节点/长期账号是否保持正常。 + +## 8. 验收记录模板 + +```text +提交/日期: +执行者/机器: +用例 ID / 子项(重启或升级、协议/认证/节点、恢复场景): +前置条件及授权: +操作步骤: +预期: +实际: +状态:未执行 / 通过 / 失败 / 阻塞 +证据位置: +资源差异: +遗留问题/复测结果: +``` + +最终签收分别列出:代码检查、单机生命周期、短任务清理、持久登录、长期监听、多机故障、素材归属、磁盘稳定性、性能对比、各平台真实能力。只有记录充分的项目才勾选通过;失败修复后复测,真正缺少用户授权/账号/机器/发行物时明确标为阻塞。 diff --git a/docs/plan01.md b/docs/plan01.md index ee959db..2af9adb 100644 --- a/docs/plan01.md +++ b/docs/plan01.md @@ -213,6 +213,16 @@ - 被环境引用的代理不能直接删除,先解除或更换。停用后不能用于新启动;被运行环境使用时提示先停环境,避免管理状态与实际连接不一致。 - 代理失败显示原因,不静默直连、不自动换代理,不增加批量导入、供应商采购、自动轮换或代理池调度。 +### E3. 原生浏览器、多机与任务清理(新增目标,尚未实施) + +- 浏览器环境不再由 Docker 创建;复用现有多 gateway 控制,每台 gateway 直接管理本机浏览器进程,由 Xvfb 提供虚拟显示。控制面、数据库的部署方式不因此强制改变。 +- 一账号一稳定环境与 Profile,不等于浏览器永久运行。采集任务实际执行时按需启动;成功、失败、取消和超时后均回收任务自建进程、缓存、临时下载等资源,并处理异常退出遗留。 +- 账号登录状态、稳定指纹、正式采集结果及素材必须保留;人工登录和自有账号持续事件监听不得被无关任务清理中断。借用既有会话时,只释放本任务创建的资源和使用权。 +- Profile 同时只供一个浏览器实例使用;冲突明确等待或拒绝,不复制活跃 Profile、不抢占监听。节点不可达时如实报告,不自动跨机重建账号环境。 +- 控制面与 gateway 分别清理本机创建的临时文件;清理校验机器、运行代次与执行归属,旧任务不得覆盖新结果、删除新资源。业务完成与清理完成分别显示,失败不得隐瞒。 +- 原生浏览器继续满足 E1 的指纹与 E2 的代理要求;启动成本、内存和磁盘改善必须实测,不以移除 Docker 代替验收。 +- 具体方案和待批准项见[变更评审](native-browser-change-review.md)、[实施计划](native-browser-implementation-plan.md)与[验证文档](native-browser-verification.md)。本轮仅文档,不代表已具备原生运行能力。 + ## 6. 工作台 ### W1. 评论来源与采集 diff --git a/internal/creator/accounts.go b/internal/creator/accounts.go index cc84800..4fb4140 100644 --- a/internal/creator/accounts.go +++ b/internal/creator/accounts.go @@ -23,7 +23,7 @@ func (s *Store) EnsureAccountProfile(ctx context.Context, accountID string) erro func accountProfileQuery() string { return ` SELECT a.id, a.name, a.platform, a.platform_account_key, - a.authorization_status, a.status, + a.authorization_kind, a.authorization_status, a.status, p.login_username, p.password_configured, p.real_name_status, p.real_name, p.identity_number, p.note, p.business_status, p.big_account, p.reply_requirements, p.login_status, p.login_reason, @@ -38,7 +38,7 @@ func scanAccountProfile(scanner interface{ Scan(...any) error }) (AccountProfile var checkedAt sql.NullTime if err := scanner.Scan( &result.ID, &result.Name, &result.Platform, &result.PlatformAccountKey, - &result.AuthorizationStatus, &result.RuntimeStatus, + &result.AuthorizationKind, &result.AuthorizationStatus, &result.RuntimeStatus, &result.LoginUsername, &result.PasswordConfigured, &result.RealNameStatus, &result.RealName, &result.IdentityNumber, &result.Note, &result.BusinessStatus, &result.BigAccount, &result.ReplyRequirements, &result.LoginStatus, &result.LoginReason, @@ -58,6 +58,29 @@ func (s *Store) GetAccountProfile(ctx context.Context, accountID string) (Accoun return result, rowError(err) } +func (s *Store) UpdateAccountTags(ctx context.Context, accountID string, tags []string) ([]string, error) { + accountID = strings.TrimSpace(accountID) + if accountID == "" || validateCreatorTags(tags) != nil { + return nil, ErrInvalid + } + if tags == nil { + tags = []string{} + } + result, err := s.db.ExecContext(ctx, ` + UPDATE social_account SET tags = $2, updated_at = now() WHERE id = $1`, accountID, tags) + if err != nil { + return nil, databaseError(err) + } + affected, err := result.RowsAffected() + if err != nil { + return nil, databaseError(err) + } + if affected != 1 { + return nil, ErrNotFound + } + return tags, nil +} + func (s *Store) ListAccountProfiles(ctx context.Context) ([]AccountProfile, error) { if _, err := s.db.ExecContext(ctx, ` INSERT INTO creator_account_profile (account_id) @@ -342,16 +365,19 @@ func (s *Store) SetRelation(ctx context.Context, bigAccountID, smallAccountID st if err := rows.Close(); err != nil { return err } - var bigPlatform, smallPlatform string - if err := tx.QueryRowContext(ctx, `SELECT platform FROM social_account WHERE id = $1`, bigAccountID).Scan(&bigPlatform); err != nil { + var bigPlatform, smallPlatform, bigAuthorizationKind, smallAuthorizationKind string + if err := tx.QueryRowContext(ctx, `SELECT platform, authorization_kind FROM social_account WHERE id = $1`, bigAccountID).Scan(&bigPlatform, &bigAuthorizationKind); err != nil { return rowError(err) } - if err := tx.QueryRowContext(ctx, `SELECT platform FROM social_account WHERE id = $1`, smallAccountID).Scan(&smallPlatform); err != nil { + if err := tx.QueryRowContext(ctx, `SELECT platform, authorization_kind FROM social_account WHERE id = $1`, smallAccountID).Scan(&smallPlatform, &smallAuthorizationKind); err != nil { return rowError(err) } if !ValidatePlatform(bigPlatform) || bigPlatform != smallPlatform { return ErrInvalid } + if enabled && (bigAuthorizationKind != SourceOwned || smallAuthorizationKind != SourceOwned) { + return ErrInvalid + } var bigEnabled bool if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT big_account FROM creator_account_profile WHERE account_id=$1), false)`, bigAccountID).Scan(&bigEnabled); err != nil { return databaseError(err) diff --git a/internal/creator/actions.go b/internal/creator/actions.go index 1adaec7..6cf1dfa 100644 --- a/internal/creator/actions.go +++ b/internal/creator/actions.go @@ -128,6 +128,9 @@ func (s *Store) CreateStrategy(ctx context.Context, bigAccountID string, input S if big.Platform != small.Platform { return Strategy{}, ErrConflict } + if big.AuthorizationKind != SourceOwned || small.AuthorizationKind != SourceOwned { + return Strategy{}, ErrInvalid + } if err := s.requireRelation(ctx, bigAccountID, input.ExecutionAccountID); err != nil { return Strategy{}, err } @@ -229,6 +232,9 @@ func (s *Store) UpdateStrategy(ctx context.Context, id string, input StrategyInp if big.Platform != small.Platform { return Strategy{}, ErrConflict } + if big.AuthorizationKind != SourceOwned || small.AuthorizationKind != SourceOwned { + return Strategy{}, ErrInvalid + } events, texts, err := encodeStrategyLists(input) if err != nil { return Strategy{}, err diff --git a/internal/creator/content.go b/internal/creator/content.go index bf120dc..a400a86 100644 --- a/internal/creator/content.go +++ b/internal/creator/content.go @@ -10,6 +10,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/jackc/pgx/v5/pgtype" ) const maxRawPayloadBytes = 4 << 20 @@ -22,22 +24,38 @@ func validateHomepage(value string) error { return nil } +func validateCreatorTags(tags []string) error { + if len(tags) > 20 { + return ErrInvalid + } + for _, tag := range tags { + if tag == "" || strings.TrimSpace(tag) != tag || !utf8.ValidString(tag) || utf8.RuneCountInString(tag) > 32 { + return ErrInvalid + } + } + return nil +} + func (s *Store) CreateCompetitor(ctx context.Context, input CompetitorInput) (Competitor, error) { input.Platform = strings.TrimSpace(input.Platform) input.PlatformAccountKey = strings.TrimSpace(input.PlatformAccountKey) input.Nickname = strings.TrimSpace(input.Nickname) input.AvatarURL = strings.TrimSpace(input.AvatarURL) input.HomepageURL = strings.TrimSpace(input.HomepageURL) + if input.Tags == nil { + input.Tags = []string{} + } if !ValidatePlatform(input.Platform) || input.PlatformAccountKey == "" || input.HomepageURL == "" || utf8.RuneCountInString(input.PlatformAccountKey) > 255 || utf8.RuneCountInString(input.Nickname) > 255 || - utf8.RuneCountInString(input.AvatarURL) > 1000 || validateHomepage(input.HomepageURL) != nil { + utf8.RuneCountInString(input.AvatarURL) > 1000 || validateHomepage(input.HomepageURL) != nil || + validateCreatorTags(input.Tags) != nil { return Competitor{}, ErrInvalid } id := newID("competitor") if _, err := s.db.ExecContext(ctx, ` - INSERT INTO creator_competitor (id, platform, platform_account_key, nickname, avatar_url, homepage_url, next_sync_at) - VALUES ($1, $2, $3, $4, $5, $6, now())`, - id, input.Platform, input.PlatformAccountKey, input.Nickname, input.AvatarURL, input.HomepageURL); err != nil { + INSERT INTO creator_competitor (id, platform, platform_account_key, nickname, avatar_url, homepage_url, tags, next_sync_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now())`, + id, input.Platform, input.PlatformAccountKey, input.Nickname, input.AvatarURL, input.HomepageURL, input.Tags); err != nil { return Competitor{}, databaseError(err) } return s.GetCompetitor(ctx, id) @@ -45,12 +63,14 @@ func (s *Store) CreateCompetitor(ctx context.Context, input CompetitorInput) (Co func scanCompetitor(scanner interface{ Scan(...any) error }) (Competitor, error) { var result Competitor + var tags pgtype.FlatArray[string] var leaseUntil, lastSync, nextSync sql.NullTime if err := scanner.Scan(&result.ID, &result.Platform, &result.PlatformAccountKey, &result.Nickname, - &result.AvatarURL, &result.HomepageURL, &result.Enabled, &result.SyncStatus, &result.SyncCursor, + &result.AvatarURL, &result.HomepageURL, pgtype.NewMap().SQLScanner(&tags), &result.Enabled, &result.SyncStatus, &result.SyncCursor, &result.SyncError, &leaseUntil, &lastSync, &nextSync, &result.CreatedAt, &result.UpdatedAt); err != nil { return Competitor{}, err } + result.Tags = []string(tags) result.SyncLeaseUntil = nullableTime(leaseUntil) result.LastSyncAt = nullableTime(lastSync) result.NextSyncAt = nullableTime(nextSync) @@ -59,14 +79,14 @@ func scanCompetitor(scanner interface{ Scan(...any) error }) (Competitor, error) func (s *Store) GetCompetitor(ctx context.Context, id string) (Competitor, error) { result, err := scanCompetitor(s.db.QueryRowContext(ctx, ` - SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, + SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, tags, enabled, sync_status, sync_cursor, sync_error, sync_lease_until, last_sync_at, next_sync_at, created_at, updated_at FROM creator_competitor WHERE id = $1`, id)) return result, rowError(err) } func (s *Store) ListCompetitors(ctx context.Context, platform string) ([]Competitor, error) { - query := `SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, + query := `SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, tags, enabled, sync_status, sync_cursor, sync_error, sync_lease_until, last_sync_at, next_sync_at, created_at, updated_at FROM creator_competitor` args := []any{} @@ -94,6 +114,29 @@ func (s *Store) ListCompetitors(ctx context.Context, platform string) ([]Competi return result, rows.Err() } +func (s *Store) UpdateCompetitorTags(ctx context.Context, id string, tags []string) (Competitor, error) { + id = strings.TrimSpace(id) + if id == "" || validateCreatorTags(tags) != nil { + return Competitor{}, ErrInvalid + } + if tags == nil { + tags = []string{} + } + result, err := s.db.ExecContext(ctx, ` + UPDATE creator_competitor SET tags = $2, updated_at = now() WHERE id = $1`, id, tags) + if err != nil { + return Competitor{}, databaseError(err) + } + affected, err := result.RowsAffected() + if err != nil { + return Competitor{}, databaseError(err) + } + if affected != 1 { + return Competitor{}, ErrNotFound + } + return s.GetCompetitor(ctx, id) +} + func (s *Store) SetCompetitorEnabled(ctx context.Context, id string, enabled bool) (Competitor, error) { status := "paused" if enabled { @@ -166,7 +209,7 @@ func (s *Store) ListDueCompetitors(ctx context.Context, now time.Time) ([]Compet if now.IsZero() { return nil, ErrInvalid } - rows, err := s.db.QueryContext(ctx, `SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, + rows, err := s.db.QueryContext(ctx, `SELECT id, platform, platform_account_key, nickname, avatar_url, homepage_url, tags, enabled, sync_status, sync_cursor, sync_error, sync_lease_until, last_sync_at, next_sync_at, created_at, updated_at FROM creator_competitor WHERE enabled AND next_sync_at IS NOT NULL AND next_sync_at <= $1 @@ -849,7 +892,7 @@ func slicePage[T any](items []T, page, pageSize int) (Page[T], error) { if end > len(items) { end = len(items) } - data := append([]T(nil), items[offset:end]...) + data := append([]T{}, items[offset:end]...) return Page[T]{Data: data, Page: page, PageSize: pageSize, Total: len(items), HasNext: end < len(items)}, nil } diff --git a/internal/creator/content_test.go b/internal/creator/content_test.go index 57bf467..1fbc7f8 100644 --- a/internal/creator/content_test.go +++ b/internal/creator/content_test.go @@ -1,6 +1,23 @@ package creator -import "testing" +import ( + "encoding/json" + "testing" +) + +func TestSlicePageUsesEmptyDataArray(t *testing.T) { + page, err := slicePage[string](nil, 1, 25) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + if string(encoded) != `{"data":[],"page":1,"page_size":25,"total":0,"has_next":false}` { + t.Fatalf("empty page JSON = %s", encoded) + } +} func TestParseGeneratedRewriteRequiresBoundedJSON(t *testing.T) { title, script, err := parseGeneratedRewrite(`{"title":"新标题","script":"新脚本"}`) @@ -18,3 +35,23 @@ func TestParseGeneratedRewriteRequiresBoundedJSON(t *testing.T) { } } } + +func TestValidateCreatorTags(t *testing.T) { + valid := make([]string, 20) + for i := range valid { + valid[i] = "标签" + } + if err := validateCreatorTags(valid); err != nil { + t.Fatalf("validateCreatorTags(valid) = %v", err) + } + for name, tags := range map[string][]string{ + "too many": append(valid, "额外"), + "empty": {""}, + "spaced": {" 标签"}, + "too long": {string(make([]rune, 33))}, + } { + if err := validateCreatorTags(tags); err == nil { + t.Fatalf("validateCreatorTags(%s) accepted invalid tags", name) + } + } +} diff --git a/internal/creator/integration_test.go b/internal/creator/integration_test.go index 13fb529..90dd62d 100644 --- a/internal/creator/integration_test.go +++ b/internal/creator/integration_test.go @@ -146,6 +146,10 @@ func TestCreatorPostgresContentAndWorkflow(t *testing.T) { if err != nil || len(profiles) != 2 { t.Fatalf("list account profiles: profiles=%+v err=%v", profiles, err) } + updatedTags, err := store.UpdateAccountTags(ctx, bigID, []string{"主账号"}) + if err != nil || len(updatedTags) != 1 || updatedTags[0] != "主账号" { + t.Fatalf("update account tags: tags=%v err=%v", updatedTags, err) + } if _, err := store.UpdateAccountProfile(ctx, bigID, AccountProfileUpdate{RealNameStatus: "unknown", BusinessStatus: "normal", BigAccount: true, ReplyRequirements: "保持准确", CooldownSeconds: 86400}); err != nil { t.Fatal(err) } @@ -168,6 +172,13 @@ func TestCreatorPostgresContentAndWorkflow(t *testing.T) { if _, err := store.SetBigAccount(ctx, smallID, true); !errors.Is(err, ErrConflict) { t.Fatalf("expected small-account promotion to be rejected, got %v", err) } + authorizedID := createIntegrationAccount(t, ctx, phaseAStore, "authorized"+stamp) + if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET authorization_kind = 'authorized' WHERE id = $1`, authorizedID); err != nil { + t.Fatal(err) + } + if err := store.SetRelation(ctx, bigID, authorizedID, true); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected authorized account relation to be rejected, got %v", err) + } now := time.Now().UTC().Truncate(time.Microsecond) ownedDue, err := store.ListDueOwnedAccounts(ctx, now, 1800) @@ -181,12 +192,19 @@ func TestCreatorPostgresContentAndWorkflow(t *testing.T) { if err != nil || !inserted { t.Fatalf("insert work: work=%+v inserted=%v err=%v", work, inserted, err) } - competitor, err := store.CreateCompetitor(ctx, CompetitorInput{Platform: PlatformDouyin, PlatformAccountKey: "sec_uid_competitor_" + stamp, Nickname: "Competitor", HomepageURL: "https://www.douyin.com/user/sec_uid_competitor_" + stamp}) + competitor, err := store.CreateCompetitor(ctx, CompetitorInput{Platform: PlatformDouyin, PlatformAccountKey: "sec_uid_competitor_" + stamp, Nickname: "Competitor", HomepageURL: "https://www.douyin.com/user/sec_uid_competitor_" + stamp, Tags: []string{"重点监测"}}) if err != nil { t.Fatal(err) } + if len(competitor.Tags) != 1 || competitor.Tags[0] != "重点监测" { + t.Fatalf("create competitor tags: %+v", competitor.Tags) + } + updatedCompetitor, err := store.UpdateCompetitorTags(ctx, competitor.ID, []string{"已分类"}) + if err != nil || len(updatedCompetitor.Tags) != 1 || updatedCompetitor.Tags[0] != "已分类" { + t.Fatalf("update competitor tags: competitor=%+v err=%v", updatedCompetitor, err) + } competitors, err := store.ListCompetitors(ctx, PlatformDouyin) - if err != nil || len(competitors) != 1 { + if err != nil || len(competitors) != 1 || len(competitors[0].Tags) != 1 || competitors[0].Tags[0] != "已分类" { t.Fatalf("list competitors: competitors=%+v err=%v", competitors, err) } if _, err := store.SetCompetitorEnabled(ctx, competitor.ID, false); err != nil { diff --git a/internal/creator/migrations/033_competitor_tags.sql b/internal/creator/migrations/033_competitor_tags.sql new file mode 100644 index 0000000..37da55e --- /dev/null +++ b/internal/creator/migrations/033_competitor_tags.sql @@ -0,0 +1,7 @@ +ALTER TABLE creator_competitor + ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT ARRAY[]::text[]; + +ALTER TABLE creator_competitor + DROP CONSTRAINT IF EXISTS creator_competitor_tags_check, + ADD CONSTRAINT creator_competitor_tags_check + CHECK (cardinality(tags) <= 20); diff --git a/internal/creator/models.go b/internal/creator/models.go index c74dd46..0c914f1 100644 --- a/internal/creator/models.go +++ b/internal/creator/models.go @@ -63,6 +63,7 @@ type AccountProfile struct { Name string `json:"name"` Platform string `json:"platform"` PlatformAccountKey string `json:"platform_account_key"` + AuthorizationKind string `json:"authorization_kind"` AuthorizationStatus string `json:"authorization_status"` RuntimeStatus string `json:"runtime_status"` LoginUsername string `json:"login_username"` @@ -109,6 +110,7 @@ type Competitor struct { Nickname string `json:"nickname"` AvatarURL string `json:"avatar_url"` HomepageURL string `json:"homepage_url"` + Tags []string `json:"tags"` Enabled bool `json:"enabled"` SyncStatus string `json:"sync_status"` SyncCursor string `json:"sync_cursor,omitempty"` @@ -121,11 +123,12 @@ type Competitor struct { } type CompetitorInput struct { - Platform string `json:"platform"` - PlatformAccountKey string `json:"platform_account_key"` - Nickname string `json:"nickname"` - AvatarURL string `json:"avatar_url"` - HomepageURL string `json:"homepage_url"` + Platform string `json:"platform"` + PlatformAccountKey string `json:"platform_account_key"` + Nickname string `json:"nickname"` + AvatarURL string `json:"avatar_url"` + HomepageURL string `json:"homepage_url"` + Tags []string `json:"tags"` } type WorkSource struct { @@ -164,6 +167,7 @@ type WorkInput struct { WorkKey string `json:"work_key"` SourceType string `json:"source_type"` SourceID string `json:"source_id"` + AuthorKey string `json:"author_key,omitempty"` AuthorName string `json:"author_name"` Title string `json:"title"` Body string `json:"body"` diff --git a/internal/creator/store.go b/internal/creator/store.go index a882f13..0471297 100644 --- a/internal/creator/store.go +++ b/internal/creator/store.go @@ -64,6 +64,9 @@ var migration031 string //go:embed migrations/032_douyin_release_remediation.sql var migration032 string +//go:embed migrations/033_competitor_tags.sql +var migration033 string + type SecretReference struct { ID string Provider string @@ -157,6 +160,7 @@ func (s *Store) migrate(ctx context.Context) error { {version: 30, sql: migration030}, {version: 31, sql: migration031}, {version: 32, sql: migration032}, + {version: 33, sql: migration033}, } for _, migration := range migrations { var applied bool diff --git a/internal/douyin/connector.go b/internal/douyin/connector.go index 7b5c534..0fd1365 100644 --- a/internal/douyin/connector.go +++ b/internal/douyin/connector.go @@ -28,8 +28,9 @@ const ( ReasonIdentityMatch = "douyin_identity_mismatch" ReasonSucceeded = "douyin_sync_succeeded" - identityEndpoint = "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp" - worksEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/post/" + identityEndpoint = "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp" + worksEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/post/" + workDetailEndpoint = "https://www.douyin.com/aweme/v1/web/aweme/detail/" ) func douyinAPIQuery() url.Values { diff --git a/internal/douyin/creator_collector.go b/internal/douyin/creator_collector.go index adb744a..f773704 100644 --- a/internal/douyin/creator_collector.go +++ b/internal/douyin/creator_collector.go @@ -17,6 +17,8 @@ const ( profileOtherEndpoint = "https://www.douyin.com/aweme/v1/web/user/profile/other/" ) +var workKeyPattern = regexp.MustCompile(`^[1-9][0-9]{0,63}$`) + type CreatorCollector struct { Browser Browser AccountKey string @@ -32,6 +34,43 @@ type TargetProfile struct { AvatarURL string } +type workDetailEnvelope struct { + StatusCode *int `json:"status_code"` + AwemeDetail *struct { + AwemeID string `json:"aweme_id"` + Author *struct { + UID string `json:"uid"` + SecUID string `json:"sec_uid"` + UniqueID string `json:"unique_id"` + Nickname string `json:"nickname"` + AvatarThumb *struct { + URLList []string `json:"url_list"` + } `json:"avatar_thumb"` + } `json:"author"` + } `json:"aweme_detail"` +} + +func parseWorkTarget(body []byte, expectedWorkKey string) (TargetProfile, bool) { + var envelope workDetailEnvelope + if len(body) > 2<<20 || json.Unmarshal(body, &envelope) != nil || envelope.StatusCode == nil || *envelope.StatusCode != 0 || + envelope.AwemeDetail == nil || envelope.AwemeDetail.AwemeID != expectedWorkKey || envelope.AwemeDetail.Author == nil || + !keyPattern.MatchString(envelope.AwemeDetail.Author.UID) || !keyPattern.MatchString(envelope.AwemeDetail.Author.SecUID) || + (envelope.AwemeDetail.Author.UniqueID != "" && !keyPattern.MatchString(envelope.AwemeDetail.Author.UniqueID)) { + return TargetProfile{}, false + } + avatarURL := "" + if envelope.AwemeDetail.Author.AvatarThumb != nil && len(envelope.AwemeDetail.Author.AvatarThumb.URLList) > 0 { + avatarURL = envelope.AwemeDetail.Author.AvatarThumb.URLList[0] + } + return TargetProfile{ + UID: envelope.AwemeDetail.Author.UID, + SecUID: envelope.AwemeDetail.Author.SecUID, + UniqueID: envelope.AwemeDetail.Author.UniqueID, + Nickname: envelope.AwemeDetail.Author.Nickname, + AvatarURL: avatarURL, + }, true +} + func (c CreatorCollector) CanonicalSecUID(ctx context.Context, expectedKey string) (string, error) { if c.Browser == nil || !keyPattern.MatchString(expectedKey) { return "", fmt.Errorf("%w: invalid identity verification request", ErrInvalid) @@ -82,6 +121,26 @@ func (c CreatorCollector) ResolveTarget(ctx context.Context, expectedKey string) return TargetProfile{UID: identity.User.UID, SecUID: identity.User.SecUID, UniqueID: identity.User.UniqueID, Nickname: identity.User.Nickname, AvatarURL: avatarURL}, nil } +func (c CreatorCollector) ResolveWork(ctx context.Context, workKey string) (TargetProfile, error) { + if c.Browser == nil || !workKeyPattern.MatchString(workKey) { + return TargetProfile{}, fmt.Errorf("%w: invalid work request", ErrInvalid) + } + query := douyinAPIQuery() + query.Set("aweme_id", workKey) + response, err := c.Browser.Get(ctx, workDetailEndpoint+"?"+query.Encode()) + if err != nil { + return TargetProfile{}, err + } + if err := creatorResponseError(response, "work detail"); err != nil { + return TargetProfile{}, err + } + profile, ok := parseWorkTarget(response.Body, workKey) + if !ok { + return TargetProfile{}, fmt.Errorf("%w: invalid douyin work detail response", ErrInvalid) + } + return profile, nil +} + func (c CreatorCollector) VerifyIdentity(ctx context.Context, expectedKey string) error { if c.Browser == nil || !keyPattern.MatchString(expectedKey) { return fmt.Errorf("%w: invalid identity verification request", ErrInvalid) diff --git a/internal/douyin/creator_collector_test.go b/internal/douyin/creator_collector_test.go index 8a3adfe..0b2d532 100644 --- a/internal/douyin/creator_collector_test.go +++ b/internal/douyin/creator_collector_test.go @@ -109,3 +109,26 @@ func TestParseCreatorWorksPageRejectsMissingWorkIdentity(t *testing.T) { t.Fatal("expected missing work identity to be rejected") } } + +func TestParseWorkTarget(t *testing.T) { + body := []byte(`{"status_code":0,"aweme_detail":{"aweme_id":"123","author":{"uid":"456","sec_uid":"MS4wLjABAAAAkey","unique_id":"handle","nickname":"作者","avatar_thumb":{"url_list":["https://example.com/avatar.jpg"]}}}}`) + profile, ok := parseWorkTarget(body, "123") + if !ok || profile.UID != "456" || profile.SecUID != "MS4wLjABAAAAkey" || profile.Nickname != "作者" || profile.AvatarURL == "" { + t.Fatalf("unexpected work target: ok=%v profile=%+v", ok, profile) + } + if _, ok := parseWorkTarget(body, "999"); ok { + t.Fatal("accepted a detail response for another work") + } +} + +func TestResolveWorkUsesWorkDetailEndpoint(t *testing.T) { + browser := &collectorBrowser{response: Response{Status: 200, Body: []byte(`{"status_code":0,"aweme_detail":{"aweme_id":"123","author":{"uid":"456","sec_uid":"MS4wLjABAAAAkey"}}}`)}} + profile, err := (CreatorCollector{Browser: browser}).ResolveWork(context.Background(), "123") + if err != nil || profile.SecUID != "MS4wLjABAAAAkey" { + t.Fatalf("work profile: %+v err=%v", profile, err) + } + parsed, err := url.Parse(browser.url) + if err != nil || parsed.Path != "/aweme/v1/web/aweme/detail/" || parsed.Query().Get("aweme_id") != "123" { + t.Fatalf("unexpected work detail URL: %s", browser.url) + } +} diff --git a/internal/xiaohongshu/collector.go b/internal/xiaohongshu/collector.go index 0dffa42..dea1f6c 100644 --- a/internal/xiaohongshu/collector.go +++ b/internal/xiaohongshu/collector.go @@ -480,7 +480,11 @@ func parseWork(raw json.RawMessage, fallback accessContext) (creator.WorkInput, return creator.WorkInput{}, accessContext{}, false } user := firstObject(object, "user", "user_info", "author") + authorKey := firstString(user, "user_id", "userid", "uid", "id") authorName := firstString(user, "nickname", "name") + if authorKey != "" && !keyPattern.MatchString(authorKey) { + return creator.WorkInput{}, accessContext{}, false + } published, publishedOK := optionalTimestamp(object, "time", "create_time", "last_update_time") if !publishedOK { return creator.WorkInput{}, accessContext{}, false @@ -541,6 +545,7 @@ func parseWork(raw json.RawMessage, fallback accessContext) (creator.WorkInput, Platform: creator.PlatformXiaohongshu, RawPayload: string(raw), WorkKey: id, + AuthorKey: authorKey, AuthorName: authorName, Title: title, Body: body, diff --git a/internal/xiaohongshu/detail_test.go b/internal/xiaohongshu/detail_test.go index b040d16..af1287b 100644 --- a/internal/xiaohongshu/detail_test.go +++ b/internal/xiaohongshu/detail_test.go @@ -12,7 +12,7 @@ func TestGetNoteDetailPreservesShareContext(t *testing.T) { browser := &fakeBrowser{postResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"items":[{"note_id":"n-1","title":"详情","desc":"正文","time":1710000000,"user":{"user_id":"u-1","nickname":"作者"},"interact_info":{"liked_count":1,"comment_count":2,"shared_count":3}}]}}`)}} collector := &Collector{Browser: browser, SourceType: creator.SourceCompetitor, SourceID: "source-1"} item, err := collector.GetNoteDetail(context.Background(), "https://www.xiaohongshu.com/explore/n-1?xsec_token=tok-1&xsec_source=pc_search") - if err != nil || item.WorkKey != "n-1" || item.OriginalURL == "" { + if err != nil || item.WorkKey != "n-1" || item.AuthorKey != "u-1" || item.OriginalURL == "" { t.Fatalf("item = %#v, err = %v", item, err) } if err := collector.SetWorkContext("n-1", item.OriginalURL); err != nil { diff --git a/web/src/CreatorAccountsPage.jsx b/web/src/AccountEditPage.jsx similarity index 89% rename from web/src/CreatorAccountsPage.jsx rename to web/src/AccountEditPage.jsx index 8642276..fbe4d13 100644 --- a/web/src/CreatorAccountsPage.jsx +++ b/web/src/AccountEditPage.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useParams } from "react-router"; import { useDataProvider } from "@refinedev/core"; import { Alert, @@ -32,12 +33,6 @@ const emptyProfile = { }; const platformLabel = { douyin: "抖音", xiaohongshu: "小红书" }; -const statusLabel = { - normal: "正常", - muted: "禁言", - banned: "封禁", - deleted: "注销", -}; const eventTypeOptions = [ ["comment", "评论"], ["like", "点赞"], @@ -60,10 +55,10 @@ function profileForm(profile) { }; } -export function CreatorAccountsPage() { +export function AccountEditPage() { + const { id: selectedID } = useParams(); const dataProvider = useDataProvider()("default"); const [profiles, setProfiles] = useState([]); - const [selectedID, setSelectedID] = useState(""); const [form, setForm] = useState(emptyProfile); const [pending, setPending] = useState(true); const [error, setError] = useState(null); @@ -102,12 +97,11 @@ export function CreatorAccountsPage() { }); if (sequence !== loadSequence.current) return; setProfiles(result.data); - if (selectedID) { - const selected = result.data.find((item) => item.id === selectedID); - if (selected) setForm(profileForm(selected)); - } else if (result.data[0]) { - setSelectedID(result.data[0].id); - setForm(profileForm(result.data[0])); + const selected = result.data.find((item) => item.id === selectedID); + if (selected) { + setForm(profileForm(selected)); + } else { + setError(new Error("账号不存在")); } } catch (loadError) { if (sequence === loadSequence.current) setError(loadError); @@ -118,7 +112,7 @@ export function CreatorAccountsPage() { useEffect(() => { load(); - }, []); + }, [selectedID]); useEffect(() => { if (!selectedID) return; const sequence = ++strategySequence.current; @@ -184,27 +178,6 @@ export function CreatorAccountsPage() { editingStrategyID, ), ); - const choose = (profile) => { - const dirty = - selected && - JSON.stringify(form) !== JSON.stringify(profileForm(selected)); - if ( - dirty && - !window.confirm("当前账号资料尚未保存,确定放弃修改并切换吗?") - ) { - return; - } - selectionVersion.current += 1; - loadSequence.current += 1; - setSelectedID(profile.id); - setForm(profileForm(profile)); - setStrategies([]); - setRelations([]); - setEditingStrategyID(""); - setBusy(false); - setNotice(null); - setLoginQR(null); - }; const change = (field) => (event) => setForm((value) => ({ ...value, [field]: event.target.value })); const save = async (event) => { @@ -265,7 +238,10 @@ export function CreatorAccountsPage() { if (version === selectionVersion.current && accountID === selectedID) { setNotice({ variant: "destructive", - text: conflictMessage(qrError, "登录二维码获取失败;请确认浏览器环境正在运行"), + text: conflictMessage( + qrError, + "登录二维码获取失败;请检查运行环境、网关和镜像配置", + ), }); } } finally { @@ -468,72 +444,23 @@ export function CreatorAccountsPage() { <> + title="编辑社媒账号" + description="维护账号资料、登录核验与自动响应策略。" + > + + -
+ {selected ? ( - -
    - {profiles.map((profile) => ( -
  • - -
  • - ))} -
-
-
- {selected ? ( - - +

@@ -764,6 +691,7 @@ export function CreatorAccountsPage() { id="strategy-execution-account" label="执行小号" required + helper="这里只显示自有账号;监测账号不能作为小号。" > - update("name", event.target.value)} - placeholder="如:店铺一号" - /> - - update("name", event.target.value)} + placeholder="如:店铺一号" + /> + + + update("platform", event.target.value)} - options={platforms} - placeholder="选择平台" - /> - - update("platform", event.target.value)} + options={platforms} + placeholder="选择平台" + /> + + + - - update("platform_account_key", event.target.value) - } - /> - - + update("platform_account_key", event.target.value) + } + /> + + + - update("tags", tags)} - placeholder="如:主账号" - /> - - update("tags", tags)} + placeholder="如:主账号" + /> + + +