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 }