From 7712b033b092e45076b604d27c63f0cedbe26f72 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 16 Sep 2026 10:50:05 +0800 Subject: [PATCH] feat: expose Douyin login QR in admin --- cmd/control-plane/creator.go | 50 ++++++++++++++ cmd/control-plane/creator_helper_test.go | 1 + cmd/control-plane/douyin.go | 34 +++++++++ cmd/control-plane/douyin_test.go | 36 ++++++++++ cmd/docker_gateway/douyin.py | 88 ++++++++++++++++++++++++ cmd/docker_gateway/gateway.py | 25 ++++++- cmd/docker_gateway/test_gateway.py | 34 +++++++++ docs/deployment.md | 6 ++ web/src/CreatorAccountsPage.jsx | 75 ++++++++++++++++++++ web/src/CreatorPages.test.jsx | 31 +++++++++ 10 files changed, 379 insertions(+), 1 deletion(-) diff --git a/cmd/control-plane/creator.go b/cmd/control-plane/creator.go index 58227b4..b8bf433 100644 --- a/cmd/control-plane/creator.go +++ b/cmd/control-plane/creator.go @@ -172,6 +172,13 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } return c.JSON(result) }) + app.Post("/api/creator/accounts/:id/login-qr", func(c fiber.Ctx) error { + result, err := creatorLoginQRCode(c.Context(), store, phaseAStore, hubStore, c.Params("id")) + if err != nil { + return creatorError(c, err) + } + return c.JSON(result) + }) app.Post("/api/creator/accounts/:id/big-account", func(c fiber.Ctx) error { var input struct { Enabled bool `json:"enabled"` @@ -1240,6 +1247,49 @@ func (browser creatorGatewayBrowser) MessageHistory(ctx context.Context, expecte return response, nil } +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) { + if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { + return nil, creator.ErrUnavailable + } + account, err := phaseAStore.GetAccount(ctx, accountID) + if err != nil { + return nil, err + } + profile, err := store.GetAccountProfile(ctx, accountID) + if err != nil { + return nil, err + } + if account.Platform != creator.PlatformDouyin || profile.Platform != creator.PlatformDouyin || + account.AuthorizationStatus != "authorized" || profile.PlatformAccountKey == "" || + account.PlatformAccountKey != profile.PlatformAccountKey { + return nil, creator.ErrConflict + } + environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err) + } + if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { + return nil, fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable) + } + gateway, err := hubStore.GetGateway(ctx, environment.Gateway) + if err != nil { + return nil, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) + } + response, err := (douyinGatewayBrowser{gateway: gateway, environment: environment}).LoginQR(ctx) + if err != nil { + return nil, fmt.Errorf("%w: capture the Douyin login screen: %v", creator.ErrUnavailable, err) + } + return map[string]any{ + "status": "manual_login", + "content_type": response.ContentType, + "image_base64": response.BodyBase64, + "qr_detected": response.QRDetected, + "expires_at": time.Now().UTC().Add(creatorLoginQRLifetime).Format(time.RFC3339), + }, nil +} + func verifyCreatorAccount(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID string) (creator.LoginResult, error) { if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { return creator.LoginResult{}, creator.ErrUnavailable diff --git a/cmd/control-plane/creator_helper_test.go b/cmd/control-plane/creator_helper_test.go index 6391825..852e36d 100644 --- a/cmd/control-plane/creator_helper_test.go +++ b/cmd/control-plane/creator_helper_test.go @@ -145,6 +145,7 @@ func TestCreatorSchedulerAndPreviewGuards(t *testing.T) { call func() error }{ {"verify account", func() error { _, err := verifyCreatorAccount(ctx, nil, nil, nil, "account"); return err }}, + {"login QR", func() error { _, err := creatorLoginQRCode(ctx, nil, nil, nil, "account"); return err }}, {"preview competitor", func() error { _, err := previewDouyinCompetitor(ctx, nil, nil, nil, "account", creator.CompetitorInput{}) return err diff --git a/cmd/control-plane/douyin.go b/cmd/control-plane/douyin.go index acf5012..299b20d 100644 --- a/cmd/control-plane/douyin.go +++ b/cmd/control-plane/douyin.go @@ -2,8 +2,10 @@ package main import ( "context" + "encoding/base64" "encoding/json" "errors" + "fmt" "net/http" "net/url" "time" @@ -25,6 +27,38 @@ type douyinGatewayRequest struct { URL string `json:"url,omitempty"` } +type douyinLoginQRResponse struct { + ContentType string `json:"content_type"` + BodyBase64 string `json:"body_base64"` + QRDetected bool `json:"qr_detected"` +} + +const maxCreatorLoginQRBytes = 8 << 20 + +func (browser douyinGatewayBrowser) LoginQR(ctx context.Context) (douyinLoginQRResponse, error) { + payload := gatewayGenerationPayload(browser.environment) + status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, + "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/login-qr", payload, 30*time.Second) + if err != nil { + return douyinLoginQRResponse{}, err + } + if status != http.StatusOK { + return douyinLoginQRResponse{}, fmt.Errorf("douyin login screen request rejected with HTTP %d: %s", status, string(body)) + } + var response douyinLoginQRResponse + if err := json.Unmarshal(body, &response); err != nil { + return douyinLoginQRResponse{}, fmt.Errorf("decode douyin login screen response: %w", err) + } + if response.ContentType != "image/png" || response.BodyBase64 == "" { + return douyinLoginQRResponse{}, errors.New("douyin login screen response is invalid") + } + data, err := base64.StdEncoding.DecodeString(response.BodyBase64) + if err != nil || len(data) == 0 || len(data) > maxCreatorLoginQRBytes { + return douyinLoginQRResponse{}, errors.New("douyin login screen response is invalid") + } + return response, nil +} + func (browser douyinGatewayBrowser) Get(ctx context.Context, target string) (douyin.Response, error) { request, err := browser.request() if err != nil { diff --git a/cmd/control-plane/douyin_test.go b/cmd/control-plane/douyin_test.go index 3ffd85e..f8af67e 100644 --- a/cmd/control-plane/douyin_test.go +++ b/cmd/control-plane/douyin_test.go @@ -44,6 +44,42 @@ func TestDouyinGatewayBrowserFencesAccountGeneration(t *testing.T) { } } +func TestDouyinGatewayBrowserCapturesLoginScreen(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/browsers/account-a/douyin/login-qr" { + t.Fatalf("unexpected path: %s", request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer gateway-token-1" { + t.Fatalf("missing gateway authorization") + } + _ = json.NewEncoder(response).Encode(map[string]any{ + "content_type": "image/png", + "body_base64": "cG5n", + "qr_detected": true, + }) + })) + defer server.Close() + browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()} + result, err := browser.LoginQR(context.Background()) + if err != nil || result.ContentType != "image/png" || result.BodyBase64 != "cG5n" || !result.QRDetected { + t.Fatalf("unexpected login screen: %#v err=%v", result, err) + } +} + +func TestDouyinGatewayBrowserRejectsInvalidLoginScreen(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(response).Encode(map[string]any{ + "content_type": "image/png", + "body_base64": "not-base64", + }) + })) + defer server.Close() + browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()} + if _, err := browser.LoginQR(context.Background()); err == nil { + t.Fatal("invalid login screen was accepted") + } +} + func TestDouyinGatewayBrowserFailsClosedWithoutReadyBinding(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) diff --git a/cmd/docker_gateway/douyin.py b/cmd/docker_gateway/douyin.py index d9a7cff..9b029b8 100644 --- a/cmd/docker_gateway/douyin.py +++ b/cmd/docker_gateway/douyin.py @@ -27,6 +27,10 @@ from .docker_client import RUNTIME_ID_RE LOG = logging.getLogger("creatorhub.douyin") ORIGIN = "https://www.douyin.com" ORIGIN_URL = ORIGIN + "/" +LOGIN_ORIGINS = frozenset( + {ORIGIN, "https://sso.douyin.com", "https://verify.snssdk.com", "https://verify.bytedance.com"} +) +LOGIN_SCREENSHOT_LIMIT = 8 << 20 IDENTITY_URL = ( ORIGIN + "/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp" ) @@ -74,6 +78,13 @@ class BrowserMediaResponse: body_base64: str +@dataclass(frozen=True) +class BrowserLoginQRResponse: + content_type: str + body_base64: str + qr_detected: bool + + class CDPConnection: def __init__(self, socket: websocket.WebSocket) -> None: self.socket = socket @@ -385,6 +396,83 @@ class DouyinBrowser: raise DouyinError("restricted browser fetch returned invalid body") return BrowserResponse(status, body, detect_challenge(status, body)) + def login_qr(self, alias: str) -> BrowserLoginQRResponse: + with self.connection(alias) as cdp: + navigation = cdp.command("Page.navigate", {"url": ORIGIN_URL}) + if ( + not isinstance(navigation, dict) + or not isinstance(navigation.get("frameId"), str) + or navigation.get("errorText") + ): + raise DouyinError("Douyin login page navigation failed") + time.sleep(0.5) + opened = cdp.evaluate( + """(() => { + const text = value => String(value || '').replace(/\\s+/g, ''); + const candidate = [...document.querySelectorAll('button,a,[role="button"]')] + .find(element => /登录|扫码登录/.test(text(element.innerText || element.getAttribute('aria-label')))); + if (!candidate) return false; + candidate.click(); + return true; + })()""" + ) + if isinstance(opened, bool) and opened: + time.sleep(0.5) + page = cdp.evaluate( + """(() => { + const visible = element => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return rect.width >= 120 && rect.height >= 120 && + style.visibility !== 'hidden' && style.display !== 'none'; + }; + const qrElement = [...document.querySelectorAll('img,canvas')].find(element => { + if (!visible(element)) return false; + const rect = element.getBoundingClientRect(); + const label = `${element.alt || ''} ${element.title || ''} ${element.getAttribute('aria-label') || ''}`; + return /二维码|qr.?code/i.test(label) || + (element.tagName === 'CANVAS' && Math.abs(rect.width - rect.height) < 24); + }); + if (!qrElement) return {origin: location.origin, qr_detected: false, clip: null}; + const rect = qrElement.getBoundingClientRect(); + const padding = 16; + const x = Math.max(0, Math.min(rect.x - padding, innerWidth - 1)); + const y = Math.max(0, Math.min(rect.y - padding, innerHeight - 1)); + return { + origin: location.origin, + qr_detected: true, + clip: { + x, y, + width: Math.min(innerWidth - x, rect.width + padding * 2), + height: Math.min(innerHeight - y, rect.height + padding * 2), + scale: 1, + }, + }; + })()""" + ) + if ( + not isinstance(page, dict) + or page.get("origin") not in LOGIN_ORIGINS + or not isinstance(page.get("qr_detected"), bool) + ): + raise DouyinError("Douyin login page origin is not allowed") + screenshot_params = {"format": "png", "fromSurface": True} + if isinstance(page.get("clip"), dict): + screenshot_params["clip"] = page["clip"] + screenshot = cdp.command("Page.captureScreenshot", screenshot_params) + if not isinstance(screenshot, dict): + raise DouyinError("Douyin login screenshot is invalid") + body = screenshot.get("data") + if not isinstance(body, str) or not body: + raise DouyinError("Douyin login screenshot is invalid") + try: + decoded_size = len(base64.b64decode(body, validate=True)) + except (ValueError, binascii.Error) as exc: + raise DouyinError("Douyin login screenshot is invalid") from exc + if decoded_size > LOGIN_SCREENSHOT_LIMIT: + raise DouyinError("Douyin login screenshot is too large") + return BrowserLoginQRResponse("image/png", body, bool(page["qr_detected"])) + def get_media(self, alias: str, target: str) -> BrowserMediaResponse: if not self.media_validator(target): raise DouyinError("restricted browser media target is invalid") diff --git a/cmd/docker_gateway/gateway.py b/cmd/docker_gateway/gateway.py index 4c01957..b64b750 100644 --- a/cmd/docker_gateway/gateway.py +++ b/cmd/docker_gateway/gateway.py @@ -700,6 +700,27 @@ class Gateway: ) return identity + def douyin_login_qr(self, alias: str, input: dict) -> dict: + if not valid_douyin_generation(input): + raise RequestError("invalid Douyin login QR request", 400) + with self._alias_lock(alias): + self._require_douyin_generation(alias, input) + try: + screen = self.browser.login_qr(alias) + self._require_douyin_generation(alias, input) + except DouyinError as exc: + LOG.warning( + "Douyin login screen capture failed alias=%s reason=%s", + alias, + str(exc), + ) + raise RequestError("Douyin login screen could not be captured") from exc + return { + "content_type": screen.content_type, + "body_base64": screen.body_base64, + "qr_detected": screen.qr_detected, + } + def get_xiaohongshu(self, alias: str, input: dict) -> dict: target = input.get("url", "") if not valid_xiaohongshu_generation(input) or not valid_xiaohongshu_url(target): @@ -1402,7 +1423,7 @@ 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|action|messages|events)", + r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(get|media|identity|login-qr|action|messages|events)", path, ) if match: @@ -1413,6 +1434,8 @@ class GatewayHandler(BaseHTTPRequestHandler): return gateway.get_douyin_media(alias, body) if action == "identity" and method == "POST": return gateway.douyin_identity(alias, body) + if action == "login-qr" and method == "POST": + return gateway.douyin_login_qr(alias, body) if action == "action" and method == "POST": return gateway.douyin_action(alias, body) if action == "messages" and method == "POST": diff --git a/cmd/docker_gateway/test_gateway.py b/cmd/docker_gateway/test_gateway.py index 54e1eda..cf229cd 100644 --- a/cmd/docker_gateway/test_gateway.py +++ b/cmd/docker_gateway/test_gateway.py @@ -267,6 +267,11 @@ class GatewayValidationTests(unittest.TestCase): gateway = Mock() gateway.list_browsers.return_value = [] gateway.douyin_identity.return_value = {"uid": "123"} + gateway.douyin_login_qr.return_value = { + "content_type": "image/png", + "body_base64": "cG5n", + "qr_detected": True, + } gateway.douyin_action.return_value = {"status": "succeeded"} gateway.douyin_message_history.return_value = {"status": "succeeded"} gateway.poll_douyin_events.return_value = [] @@ -295,6 +300,11 @@ class GatewayValidationTests(unittest.TestCase): handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {}) handler._route("POST", "/v1/browsers/safe/douyin/get", {}, {}) handler._route("POST", "/v1/browsers/safe/douyin/identity", {}, {}) + self.assertEqual( + handler._route("POST", "/v1/browsers/safe/douyin/login-qr", {}, {}), + gateway.douyin_login_qr.return_value, + ) + gateway.douyin_login_qr.assert_called_once_with("safe", {}) handler._route("POST", "/v1/browsers/safe/douyin/action", {}, {}) self.assertEqual( handler._route("POST", "/v1/browsers/safe/douyin/messages", {}, {}), @@ -719,6 +729,30 @@ class BrowserTests(unittest.TestCase): self.assertEqual(response.status, 200) self.assertNotIn("Network.setCookies", [method for method, _ in cdp.commands]) + def test_login_qr_captures_a_browser_screen_without_credentials(self) -> None: + screenshot = base64.b64encode(b"png-bytes").decode("ascii") + cdp = Mock() + cdp.evaluate.side_effect = [ + True, + {"origin": "https://www.douyin.com", "qr_detected": True}, + ] + cdp.command.side_effect = [ + {"frameId": "frame-1"}, + {"data": screenshot}, + ] + browser = DouyinBrowser() + self._with_connection(browser, cast(BrowserCDP, cdp)) + with patch.object(douyin_module.time, "sleep"): + response = browser.login_qr("safe") + self.assertEqual(response.content_type, "image/png") + self.assertEqual(response.body_base64, screenshot) + self.assertTrue(response.qr_detected) + self.assertEqual( + [call.args[0] for call in cdp.command.call_args_list], + ["Page.navigate", "Page.captureScreenshot"], + ) + self.assertFalse(any("cookie" in expression.lower() for expression in cdp.evaluate.call_args.args)) + def test_connect_selects_configured_target_from_large_mixed_list(self) -> None: targets = [ {"type": "service", "url": "http://127.0.0.1:9222/json"} for _ in range(40) diff --git a/docs/deployment.md b/docs/deployment.md index 7896494..d319f36 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -68,6 +68,12 @@ docker compose up --detach --build 3. 「社媒账号」页先创建账号(默认暂停),再在「运行环境」选该账号、网关、镜像,填写中文名、小写别名及指纹参数。代理可选;指定代理须先手动检测为健康,留空是明确直连,不是失败回退。 4. 创建得到停止态容器;在账号页恢复账号后回环境页显式启动。容器名 `creatorhub-browser-<别名>`,Profile 卷 `creatorhub-profile-<别名>`。回收只删除容器、保留环境/binding/Profile;完整契约见[架构说明](architecture/container-control.md)。当前直连 create/start 不代表 upgrade/rebind 已支持空出口。 +### 人工登录二维码 + +抖音账号页的「显示登录二维码」由 CreatorHub 通过已绑定 gateway 请求登录画面,并直接展示在后台;它不会注入密码、Cookie 或自动完成登录。登录画面只在内存中返回,前端显示两分钟有效期;完成扫码或验证码后,点击「核验浏览器身份」确认 UID 与账号绑定一致。gateway 无法取得二维码时,后台展示实际登录/验证码画面并要求人工处理,不把不确定结果标记为成功。 + +对应接口为 `POST /api/creator/accounts//login-qr`,仅接受已授权、已运行的抖音账号;响应中的 `image_base64` 只用于当前页面展示,不应写入日志、数据库或备份。 + ## 部署验证 ```bash diff --git a/web/src/CreatorAccountsPage.jsx b/web/src/CreatorAccountsPage.jsx index 920b9d7..8642276 100644 --- a/web/src/CreatorAccountsPage.jsx +++ b/web/src/CreatorAccountsPage.jsx @@ -68,6 +68,7 @@ export function CreatorAccountsPage() { const [pending, setPending] = useState(true); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); + const [loginQR, setLoginQR] = useState(null); const [busy, setBusy] = useState(false); const [strategies, setStrategies] = useState([]); const [strategyError, setStrategyError] = useState(null); @@ -202,6 +203,7 @@ export function CreatorAccountsPage() { setEditingStrategyID(""); setBusy(false); setNotice(null); + setLoginQR(null); }; const change = (field) => (event) => setForm((value) => ({ ...value, [field]: event.target.value })); @@ -240,6 +242,38 @@ export function CreatorAccountsPage() { } } }; + const requestLoginQR = async () => { + if (!selected || selected.platform !== "douyin") return; + const accountID = selected.id; + const version = selectionVersion.current; + setBusy(true); + setNotice(null); + try { + const result = await dataProvider.creatorAction( + `/creator/accounts/${encodeURIComponent(accountID)}/login-qr`, + ); + if (version !== selectionVersion.current || accountID !== selectedID) + return; + setLoginQR(result); + setNotice({ + variant: "success", + text: result.qr_detected + ? "登录二维码已生成,请使用抖音 App 扫码。" + : "登录画面已生成;当前未检测到二维码,请按页面提示人工完成验证。", + }); + } catch (qrError) { + if (version === selectionVersion.current && accountID === selectedID) { + setNotice({ + variant: "destructive", + text: conflictMessage(qrError, "登录二维码获取失败;请确认浏览器环境正在运行"), + }); + } + } finally { + if (version === selectionVersion.current && accountID === selectedID) { + setBusy(false); + } + } + }; const verifyLogin = async () => { if (!selected) return; setBusy(true); @@ -260,6 +294,7 @@ export function CreatorAccountsPage() { : item, ), ); + setLoginQR(null); setNotice({ variant: "success", text: "浏览器身份核验成功。" }); await load(); } catch (verifyError) { @@ -539,6 +574,14 @@ export function CreatorAccountsPage() { ) : null}
+ +
+ 抖音登录二维码或人工验证画面 +

+ 当前画面有效至 {dateTime(loginQR.expires_at)};完成扫码后请点击“核验浏览器身份”。 +

+ + ) : null}
{ expect(await screen.findByText("已开启大号模式。")).toBeTruthy(); }); + it("displays the CreatorHub login screen returned by the gateway", async () => { + const dataProvider = provider({ + creatorAction: vi.fn((path) => + path.endsWith("/login-qr") + ? Promise.resolve({ + status: "manual_login", + content_type: "image/png", + image_base64: "cG5n", + qr_detected: true, + expires_at: "2026-01-01T00:02:00Z", + }) + : Promise.resolve({ ...profile, login_status: "logged_in" }), + ), + }); + renderPage(, dataProvider); + fireEvent.click( + await screen.findByRole("button", { name: "显示登录二维码" }), + ); + await waitFor(() => + expect(dataProvider.creatorAction).toHaveBeenCalledWith( + "/creator/accounts/account-a/login-qr", + ), + ); + expect( + screen + .getByRole("img", { name: "抖音登录二维码或人工验证画面" }) + .getAttribute("src"), + ).toBe("data:image/png;base64,cG5n"); + expect(await screen.findByText("请使用抖音 App 扫描下方二维码。")).toBeTruthy(); + }); + it("shows retryable account and strategy loading failures", async () => { const dataProvider = provider({ getList: vi.fn().mockRejectedValue(new Error("accounts offline")),