feat: expose Douyin login QR in admin

This commit is contained in:
2026-09-16 10:50:05 +08:00
parent 9095920a5b
commit 7712b033b0
10 changed files with 379 additions and 1 deletions
+50
View File
@@ -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
+1
View File
@@ -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
+34
View File
@@ -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 {
+36
View File
@@ -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++ }))