271 lines
11 KiB
Go
271 lines
11 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"time"
|
||
|
||
"bindbox-game/configs"
|
||
"bindbox-game/internal/pkg/env"
|
||
"bindbox-game/internal/pkg/logger"
|
||
"bindbox-game/internal/repository/mysql"
|
||
"bindbox-game/internal/repository/mysql/model"
|
||
douyin "bindbox-game/internal/service/douyin"
|
||
)
|
||
|
||
// staticSyscfg implements sysconfig.Service with fixed cookie
|
||
type staticSyscfg struct {
|
||
cookie string
|
||
proxy string
|
||
}
|
||
|
||
func (s *staticSyscfg) GetByKey(ctx context.Context, key string) (*model.SystemConfigs, error) {
|
||
switch key {
|
||
case douyin.ConfigKeyDouyinCookie:
|
||
if s.cookie == "" {
|
||
return nil, errors.New("douyin cookie 未设置")
|
||
}
|
||
return &model.SystemConfigs{ConfigKey: key, ConfigValue: s.cookie}, nil
|
||
case douyin.ConfigKeyDouyinInterval:
|
||
return &model.SystemConfigs{ConfigKey: key, ConfigValue: "5"}, nil
|
||
case douyin.ConfigKeyDouyinProxy:
|
||
if s.proxy == "" {
|
||
return nil, errors.New("douyin proxy 未设置")
|
||
}
|
||
return &model.SystemConfigs{ConfigKey: key, ConfigValue: s.proxy}, nil
|
||
default:
|
||
return nil, errors.New("暂不支持的配置 key: " + key)
|
||
}
|
||
}
|
||
|
||
func (s *staticSyscfg) UpsertByKey(ctx context.Context, key string, value string, remark string) (*model.SystemConfigs, error) {
|
||
return nil, errors.New("UpsertByKey 未实现")
|
||
}
|
||
func (s *staticSyscfg) ModifyByID(ctx context.Context, id int64, value *string, remark *string) error {
|
||
return errors.New("ModifyByID 未实现")
|
||
}
|
||
func (s *staticSyscfg) DeleteByID(ctx context.Context, id int64) error {
|
||
return errors.New("DeleteByID 未实现")
|
||
}
|
||
func (s *staticSyscfg) List(ctx context.Context, page int, pageSize int, keyword string) (items []*model.SystemConfigs, total int64, err error) {
|
||
return nil, 0, errors.New("List 未实现")
|
||
}
|
||
|
||
func main() {
|
||
minutes := flag.Int("minutes", 10, "同步最近多少分钟的订单")
|
||
useProxy := flag.Bool("proxy", false, "是否使用服务内置代理")
|
||
printLimit := flag.Int("print", 10, "同步后打印多少条订单 (0 表示不打印)")
|
||
mode := flag.String("mode", "sync-all", "同步模式: sync-all(默认增量)/fetch(按绑定用户)/user(指定抖音 buyer)")
|
||
grantMinesweeper := flag.Bool("grant-minesweeper", false, "同步后执行 GrantMinesweeperQualifications")
|
||
fetchOnlyUnmatched := flag.Bool("fetch-only-unmatched", true, "按用户同步时是否仅同步未匹配订单的用户")
|
||
fetchMaxUsers := flag.Int("fetch-max-users", 200, "按用户同步时最多处理的用户数量 (50-1000)")
|
||
fetchBatchSize := flag.Int("fetch-batch-size", 20, "按用户同步时的单批次用户数量 (5-50)")
|
||
fetchConcurrency := flag.Int("fetch-concurrency", 5, "按用户同步时的并发抓取数 (<=批次大小)")
|
||
fetchDelay := flag.Int("fetch-delay-ms", 200, "批次之间的停顿时间 (毫秒)")
|
||
buyer := flag.String("buyer", "", "指定抖音 ID (douyin_user_id / Buyer ID),仅同步此用户。等价于 -mode user")
|
||
proxyURL := flag.String("proxy-url", "", "覆盖代理地址 (例: http://user:pass@host:port)")
|
||
replayURL := flag.String("replay-url", "", "[救急] 把浏览器抓到的完整 searchlist URL 贴进来,绕过风控直接同步")
|
||
replayCookie := flag.String("replay-cookie", "", "[救急] 与 -replay-url 配套的 cookie 串")
|
||
flag.Parse()
|
||
|
||
env.Active() // 初始化 env flag(依赖已有的全局 -env/ACTIVE_ENV 配置)
|
||
configs.Init()
|
||
|
||
cookie := "passport_csrf_token=133a0751277aa016a5851e4cfc27c30c; passport_csrf_token_default=133a0751277aa016a5851e4cfc27c30c; s_v_web_id=verify_mmwdotm1_QYpHiLoc_99vO_49un_9xFU_0ZKfqsmF8gzh; is_staff_user=false; ttwid=1%7Caa-Nm2neyE97yjVd8lXbX7cMYg2IRxLWDrrcDT-XwQI%7C1778257690%7C8366dc43f20a0c89c5c9b77aaa7a5f554d1e2eed250b654e65ae9828aba5b8be; odin_tt=c4ebef3065193a34066032a3d3e72ecd8584a523548d361b090a303d3af9d410dde8802c1319457684ebca43f05632bf3a190329be76dc16918a45fc0453e4d0; uid_tt=8d9cfddb6881e9a2e5f2985ba1509aef; sid_tt=f5f318456b8263a30e1d4a6a5926ef41; sessionid=f5f318456b8263a30e1d4a6a5926ef41; sessionid_ss=f5f318456b8263a30e1d4a6a5926ef41; PHPSESSID=cf2e0d098639e2c3eed2d3d9eb1e0293; csrf_session_id=436ad6d84082eaf485438e339abd88df; ecom_gray_shop_id=156231010; sid_guard=f5f318456b8263a30e1d4a6a5926ef41%7C1778257842%7C5184000%7CTue%2C+07-Jul-2026+16%3A30%3A42+GMT"
|
||
if cookie == "" {
|
||
fmt.Println("请通过环境变量 DOUYIN_COOKIE 提供抖店 Cookie")
|
||
os.Exit(1)
|
||
}
|
||
|
||
log, err := logger.NewCustomLogger(logger.WithDebugLevel(), logger.WithOutputInConsole())
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
|
||
repo, err := mysql.New()
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
defer repo.DbRClose()
|
||
defer repo.DbWClose()
|
||
|
||
if *proxyURL != "" {
|
||
fmt.Printf("使用代理: %s\n", *proxyURL)
|
||
}
|
||
svc := douyin.New(log, repo, &staticSyscfg{cookie: cookie, proxy: *proxyURL}, nil, nil, nil)
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||
defer cancel()
|
||
|
||
effectiveMode := *mode
|
||
if *replayURL != "" {
|
||
effectiveMode = "replay"
|
||
} else if *buyer != "" {
|
||
effectiveMode = "user"
|
||
}
|
||
|
||
switch effectiveMode {
|
||
case "replay":
|
||
if *replayURL == "" || *replayCookie == "" {
|
||
fmt.Println("replay 模式需要同时提供 -replay-url 和 -replay-cookie")
|
||
os.Exit(1)
|
||
}
|
||
if *buyer == "" {
|
||
fmt.Println("replay 模式需要 -buyer 用于关联本地用户 ID")
|
||
os.Exit(1)
|
||
}
|
||
var u model.Users
|
||
if err := repo.GetDbR().Where("douyin_user_id = ?", *buyer).First(&u).Error; err != nil {
|
||
fmt.Printf("未找到绑定该抖音 ID 的用户 (douyin_user_id=%s): %v\n", *buyer, err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("Replay:local_user_id=%d, nickname=%s, douyin_user_id=%s\n", u.ID, u.Nickname, u.DouyinUserID)
|
||
newOrders, matched, total, err := replayFetchAndSync(ctx, svc, *replayURL, *replayCookie, u.ID)
|
||
if err != nil {
|
||
fmt.Printf("replay 失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("完成:抓取 %d,新订单 %d,匹配 %d。\n", total, newOrders, matched)
|
||
case "user":
|
||
if *buyer == "" {
|
||
fmt.Println("使用 -mode user 时必须通过 -buyer 指定抖音 ID")
|
||
os.Exit(1)
|
||
}
|
||
var u model.Users
|
||
if err := repo.GetDbR().Where("douyin_user_id = ?", *buyer).First(&u).Error; err != nil {
|
||
fmt.Printf("未找到绑定该抖音 ID 的用户 (douyin_user_id=%s): %v\n", *buyer, err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("开始 SyncUserOrders:local_user_id=%d, nickname=%s, douyin_user_id=%s\n",
|
||
u.ID, u.Nickname, u.DouyinUserID)
|
||
result, err := svc.SyncUserOrders(ctx, u.ID)
|
||
if err != nil {
|
||
fmt.Printf("SyncUserOrders 失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("完成:抓取 %d,新订单 %d,匹配 %d,用时 %.2fs。\nDebug: %s\n",
|
||
result.TotalFetched, result.NewOrders, result.MatchedUsers,
|
||
float64(result.ElapsedMS)/1000.0, result.DebugInfo)
|
||
case "fetch":
|
||
fmt.Println("开始 FetchAndSyncOrders(按绑定用户同步)...")
|
||
result, err := svc.FetchAndSyncOrders(ctx, &douyin.FetchOptions{
|
||
OnlyUnmatched: *fetchOnlyUnmatched,
|
||
MaxUsers: *fetchMaxUsers,
|
||
BatchSize: *fetchBatchSize,
|
||
Concurrency: *fetchConcurrency,
|
||
InterBatchDelay: time.Duration(*fetchDelay) * time.Millisecond,
|
||
})
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
fmt.Printf("完成:抓取 %d,新订单 %d,匹配 %d,处理用户 %d/%d,跳过 %d,用时 %.2fs。\n",
|
||
result.TotalFetched, result.NewOrders, result.MatchedUsers,
|
||
result.ProcessedUsers, result.TotalUsers, result.SkippedUsers,
|
||
float64(result.ElapsedMS)/1000.0)
|
||
case "sync-all":
|
||
fallthrough
|
||
default:
|
||
duration := time.Duration(*minutes) * time.Minute
|
||
fmt.Printf("开始 SyncAllOrders,duration=%s proxy=%v ...\n", duration, *useProxy)
|
||
result, err := svc.SyncAllOrders(ctx, duration, *useProxy)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
fmt.Printf("完成:抓取 %d,新订单 %d,匹配 %d。\n", result.TotalFetched, result.NewOrders, result.MatchedUsers)
|
||
}
|
||
|
||
if *grantMinesweeper {
|
||
fmt.Println("执行 GrantMinesweeperQualifications ...")
|
||
if err := svc.GrantMinesweeperQualifications(ctx); err != nil {
|
||
fmt.Printf("GrantMinesweeperQualifications 失败: %v\n", err)
|
||
} else {
|
||
fmt.Println("GrantMinesweeperQualifications 完成。")
|
||
}
|
||
}
|
||
|
||
if *printLimit > 0 {
|
||
var orders []model.DouyinOrders
|
||
if err := repo.GetDbR().Order("id DESC").Limit(*printLimit).Find(&orders).Error; err != nil {
|
||
fmt.Printf("读取订单列表失败: %v\n", err)
|
||
return
|
||
}
|
||
fmt.Println("shop_order_id\torder_status\tdouyin_user_id\tlocal_user_id")
|
||
for _, o := range orders {
|
||
fmt.Printf("%s\t%d\t%s\t%s\n", o.ShopOrderID, o.OrderStatus, o.DouyinUserID, o.LocalUserID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// go run cmd/douyin_sync_debug/main.go -env dev -mode fetch -fetch-only-unmatched=false -fetch-max-users=200 -fetch-batch-size=1 -fetch-concurrency=1 -fetch-delay-ms=0
|
||
|
||
// replayFetchAndSync 把浏览器抓到的完整 URL+cookie 直接打过去,绕开风控,然后把订单写入 DB
|
||
func replayFetchAndSync(ctx context.Context, svc douyin.Service, fullURL, cookie string, localUserID int64) (newOrders, matched, total int, err error) {
|
||
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
||
if err != nil {
|
||
return 0, 0, 0, fmt.Errorf("构造请求失败: %w", err)
|
||
}
|
||
req.Header.Set("Cookie", cookie)
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36")
|
||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
|
||
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
|
||
req.Header.Set("priority", "u=1, i")
|
||
req.Header.Set("sec-ch-ua", `"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"`)
|
||
req.Header.Set("sec-ch-ua-mobile", "?0")
|
||
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
|
||
req.Header.Set("sec-fetch-dest", "empty")
|
||
req.Header.Set("sec-fetch-mode", "cors")
|
||
req.Header.Set("sec-fetch-site", "same-origin")
|
||
req.Close = true
|
||
|
||
client := &http.Client{Timeout: 60 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return 0, 0, 0, fmt.Errorf("请求失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return 0, 0, 0, fmt.Errorf("读取响应失败: %w", err)
|
||
}
|
||
|
||
var wrap struct {
|
||
St int `json:"st"`
|
||
Code int `json:"code"`
|
||
Msg string `json:"msg"`
|
||
Data []douyin.DouyinOrderItem `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||
fmt.Printf("响应原文(前2000字节): %s\n", string(body[:min2k(len(body))]))
|
||
return 0, 0, 0, fmt.Errorf("解析响应失败: %w", err)
|
||
}
|
||
if wrap.St != 0 && wrap.Code != 0 {
|
||
return 0, 0, 0, fmt.Errorf("API 错误: %s (st=%d code=%d) body=%s", wrap.Msg, wrap.St, wrap.Code, string(body[:min2k(len(body))]))
|
||
}
|
||
|
||
total = len(wrap.Data)
|
||
for i := range wrap.Data {
|
||
isNew, isMatched := svc.SyncOrder(ctx, &wrap.Data[i], localUserID, "")
|
||
if isNew {
|
||
newOrders++
|
||
}
|
||
if isMatched {
|
||
matched++
|
||
}
|
||
}
|
||
return newOrders, matched, total, nil
|
||
}
|
||
|
||
func min2k(n int) int {
|
||
if n > 2000 {
|
||
return 2000
|
||
}
|
||
return n
|
||
}
|