Files
creator-hub/internal/controlplane/api/douyin.go
T
rogee 0879d9fd25 refactor(schema): 迁移 043 schema 收敛——42→19 张表,统一迁移 runner
- 迁移 043 一次到位:runtime_instance/environment_binding 并入 browser_env(账号即环境)、
  credential_reference 并入 social_account、work_source/metric_plan 并入 creator_work、
  sync 租约并入 creator_collection_checkpoint(kind='sync')、audit_event 瘦列、
  creator_settings 去转写列、TRUNCATE 账号域(竞品采集数据无损保留)
- 统一迁移 runner:environment.Open 串起 001 账号基座 + hub 链 + 038 creator 版本回填
  (v+1000) + creator 1017-1042 + 043;account/creator Open 不再迁移
- 三域 store SQL 全量适配:CreateBoundEnv 就绪门禁、SetRuntimeCleanupPending fence
  语义(未知代 MissingRuntimeID 允许登记并释放活跃实例)、metric_plan 列并入 work、
  checkpoint sync 租约、deletion 链路重写
- api 适配:envView 去 runtime_instance_id、audit filter 去任务列、browser_env 单表查询
- 测试:迁移 043 形态探针、迁移不可变映射新路径、legacy 迁移重放子测试重写、
  fence/出口生命周期/单来源 upsert 语义覆盖;CREATORHUB_POSTGRES_TEST_URL 全绿,
  environment 70%/creator 67.5% 覆盖率,tsc 通过
2026-09-28 19:48:55 +08:00

118 lines
4.8 KiB
Go

package api
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"time"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
douyin "git.ipao.vip/rogee/creator-hub/internal/platform/douyin"
)
type douyinGatewayBrowser struct {
gateway hub.Gateway
environment hub.EnvironmentContext
}
type douyinGatewayRequest struct {
BindingVersion int64 `json:"binding_version"`
RuntimeID string `json:"runtime_id"`
NetworkID string `json:"network_id"`
NetworkExitID string `json:"network_exit_id"`
URL string `json:"url,omitempty"`
}
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 := gatewayCallWithLimit(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/login-qr", payload, 30*time.Second, largeGatewayResponseLimit)
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 {
return douyin.Response{}, err
}
request.URL = target
status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/get", request, 30*time.Second)
if err != nil || status != http.StatusOK {
return douyin.Response{}, errors.New("restricted browser operation failed")
}
var response struct {
Status int `json:"status"`
Body string `json:"body"`
Challenge douyin.Challenge `json:"challenge"`
}
if json.Unmarshal(body, &response) != nil || response.Status < 100 || response.Status > 599 ||
(response.Challenge != douyin.ChallengeNone && response.Challenge != douyin.ChallengeCaptcha &&
response.Challenge != douyin.ChallengeDevice) {
return douyin.Response{}, errors.New("restricted browser returned an invalid response")
}
return douyin.Response{Status: response.Status, Body: []byte(response.Body), Challenge: response.Challenge}, nil
}
type douyinHistoryMessage struct {
ServerID string `json:"server_id"`
SenderUID string `json:"sender_uid"`
MessageType string `json:"message_type"`
Content json.RawMessage `json:"content"`
CreatedAt string `json:"created_at"`
ServerState *int `json:"server_status"`
}
type douyinMessageHistory struct {
Status string `json:"status"`
HistorySource string `json:"history_source"`
HistoryCursor string `json:"history_cursor"`
HistoryHasMore bool `json:"history_has_more"`
AccountUID string `json:"account_uid"`
Conversation map[string]any `json:"conversation"`
Messages []douyinHistoryMessage `json:"messages"`
}
func (browser douyinGatewayBrowser) request() (douyinGatewayRequest, error) {
environment := browser.environment
if browser.gateway.Endpoint == "" || browser.gateway.Token == "" || !accountRunnable(environment) ||
!gatewayGenerationIDPattern.MatchString(environment.AccountID) || !gatewayGenerationIDPattern.MatchString(environment.Alias) ||
!gatewayGenerationIDPattern.MatchString(environment.Exit.ID) || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending ||
!gatewayGenerationIDPattern.MatchString(environment.RuntimeID) ||
!gatewayGenerationIDPattern.MatchString(environment.RuntimeNetworkID) ||
environment.BindingVersion < 1 {
return douyinGatewayRequest{}, errors.New("restricted browser is not ready")
}
return douyinGatewayRequest{BindingVersion: environment.BindingVersion, RuntimeID: environment.RuntimeID,
NetworkID: environment.RuntimeNetworkID, NetworkExitID: environment.Exit.ID}, nil
}