2026-05-09 01:26:15 +08:00

271 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("Replaylocal_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("开始 SyncUserOrderslocal_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("开始 SyncAllOrdersduration=%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
}