1054 lines
36 KiB
Go
Executable File
1054 lines
36 KiB
Go
Executable File
package douyin
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
"unicode"
|
||
|
||
"bindbox-game/internal/pkg/logger"
|
||
"bindbox-game/internal/repository/mysql"
|
||
"bindbox-game/internal/repository/mysql/dao"
|
||
"bindbox-game/internal/repository/mysql/model"
|
||
"bindbox-game/internal/service/game"
|
||
"bindbox-game/internal/service/sysconfig"
|
||
"bindbox-game/internal/service/user"
|
||
|
||
"go.uber.org/zap"
|
||
"golang.org/x/sync/singleflight"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 系统配置键
|
||
const (
|
||
ConfigKeyDouyinCookie = "douyin_cookie"
|
||
ConfigKeyDouyinInterval = "douyin_sync_interval_minutes"
|
||
ConfigKeyDouyinProxy = "douyin_proxy"
|
||
)
|
||
|
||
type Service interface {
|
||
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (按绑定用户同步)
|
||
FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*SyncResult, error)
|
||
// SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态)
|
||
// useProxy: 是否使用代理服务器访问抖音API
|
||
SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*SyncResult, error)
|
||
// ListOrders 获取本地抖店订单列表
|
||
ListOrders(ctx context.Context, page, pageSize int, filter *ListOrdersFilter) ([]*model.DouyinOrders, int64, error)
|
||
// GetConfig 获取抖店配置
|
||
GetConfig(ctx context.Context) (*DouyinConfig, error)
|
||
// SaveConfig 保存抖店配置
|
||
SaveConfig(ctx context.Context, cookie, proxy string, intervalMinutes int) error
|
||
// SyncOrder 同步单个订单到本地,可传入建议关联的用户ID和商品ID
|
||
SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (isNew bool, isMatched bool)
|
||
// GrantMinesweeperQualifications 自动补发扫雷资格
|
||
GrantMinesweeperQualifications(ctx context.Context) error
|
||
// GrantLivestreamPrizes 自动发放直播间奖品
|
||
GrantLivestreamPrizes(ctx context.Context) error
|
||
// SyncRefundStatus 同步退款状态
|
||
SyncRefundStatus(ctx context.Context) error
|
||
// GrantOrderReward 手动触发单个订单的奖励发放
|
||
GrantOrderReward(ctx context.Context, shopOrderID string) (*GrantOrderRewardResult, error)
|
||
}
|
||
|
||
type DouyinConfig struct {
|
||
Cookie string `json:"cookie"`
|
||
IntervalMinutes int `json:"interval_minutes"`
|
||
Proxy string `json:"proxy"`
|
||
}
|
||
|
||
type FetchOptions struct {
|
||
OnlyUnmatched bool
|
||
MaxUsers int
|
||
BatchSize int
|
||
Concurrency int
|
||
InterBatchDelay time.Duration
|
||
}
|
||
|
||
const (
|
||
defaultFetchMaxUsers = 200
|
||
minFetchMaxUsers = 50
|
||
maxFetchMaxUsers = 1000
|
||
defaultFetchBatchSize = 20
|
||
minFetchBatchSize = 5
|
||
maxFetchBatchSize = 50
|
||
defaultFetchConcurrency = 5
|
||
minFetchConcurrency = 1
|
||
defaultFetchInterBatchDelay = 200 * time.Millisecond
|
||
maxFetchInterBatchDelay = 2 * time.Second
|
||
)
|
||
|
||
func normalizeFetchOptions(opts *FetchOptions) *FetchOptions {
|
||
n := FetchOptions{
|
||
OnlyUnmatched: false,
|
||
MaxUsers: defaultFetchMaxUsers,
|
||
BatchSize: defaultFetchBatchSize,
|
||
Concurrency: defaultFetchConcurrency,
|
||
InterBatchDelay: defaultFetchInterBatchDelay,
|
||
}
|
||
if opts != nil {
|
||
n.OnlyUnmatched = opts.OnlyUnmatched
|
||
if opts.MaxUsers > 0 {
|
||
n.MaxUsers = opts.MaxUsers
|
||
}
|
||
if opts.BatchSize > 0 {
|
||
n.BatchSize = opts.BatchSize
|
||
}
|
||
if opts.Concurrency > 0 {
|
||
n.Concurrency = opts.Concurrency
|
||
}
|
||
if opts.InterBatchDelay > 0 {
|
||
n.InterBatchDelay = opts.InterBatchDelay
|
||
} else if opts.InterBatchDelay == 0 {
|
||
n.InterBatchDelay = 0
|
||
}
|
||
}
|
||
|
||
if n.MaxUsers < minFetchMaxUsers {
|
||
n.MaxUsers = minFetchMaxUsers
|
||
}
|
||
if n.MaxUsers > maxFetchMaxUsers {
|
||
n.MaxUsers = maxFetchMaxUsers
|
||
}
|
||
|
||
if n.BatchSize < minFetchBatchSize {
|
||
n.BatchSize = minFetchBatchSize
|
||
}
|
||
if n.BatchSize > maxFetchBatchSize {
|
||
n.BatchSize = maxFetchBatchSize
|
||
}
|
||
if n.BatchSize > n.MaxUsers {
|
||
n.BatchSize = n.MaxUsers
|
||
}
|
||
|
||
if n.Concurrency < minFetchConcurrency {
|
||
n.Concurrency = minFetchConcurrency
|
||
}
|
||
if n.Concurrency > n.BatchSize {
|
||
n.Concurrency = n.BatchSize
|
||
}
|
||
|
||
if n.InterBatchDelay < 0 {
|
||
n.InterBatchDelay = 0
|
||
}
|
||
if n.InterBatchDelay > maxFetchInterBatchDelay {
|
||
n.InterBatchDelay = maxFetchInterBatchDelay
|
||
}
|
||
|
||
return &n
|
||
}
|
||
|
||
type SyncResult struct {
|
||
TotalFetched int `json:"total_fetched"`
|
||
NewOrders int `json:"new_orders"`
|
||
MatchedUsers int `json:"matched_users"`
|
||
Orders []*model.DouyinOrders `json:"orders"` // 新增:返回详情以供后续处理
|
||
DebugInfo string `json:"debug_info"`
|
||
TotalUsers int `json:"total_users"`
|
||
ProcessedUsers int `json:"processed_users"`
|
||
SkippedUsers int `json:"skipped_users"`
|
||
ElapsedMS int64 `json:"elapsed_ms"`
|
||
}
|
||
|
||
type GrantOrderRewardResult struct {
|
||
ShopOrderID string `json:"shop_order_id"`
|
||
Message string `json:"message"`
|
||
Granted bool `json:"granted"`
|
||
RewardGranted int32 `json:"reward_granted"`
|
||
ProductCount int32 `json:"product_count"`
|
||
OrderStatus int32 `json:"order_status"`
|
||
LocalUserID string `json:"local_user_id"`
|
||
}
|
||
|
||
type ListOrdersFilter struct {
|
||
Status *int
|
||
MatchStatus *string
|
||
ShopOrderID string
|
||
DouyinUserID string
|
||
}
|
||
|
||
func (s *service) logRewardResult(ctx context.Context, shopOrderID string, douyinUserID string, localUserID int64, douyinProductID string, prizeID int64, source string, status string, message string) {
|
||
logEntry := &model.DouyinRewardLogs{
|
||
ShopOrderID: shopOrderID,
|
||
DouyinUserID: douyinUserID,
|
||
LocalUserID: localUserID,
|
||
DouyinProductID: douyinProductID,
|
||
PrizeID: prizeID,
|
||
Source: source,
|
||
Status: status,
|
||
Message: message,
|
||
Extra: "{}",
|
||
}
|
||
if err := s.repo.GetDbW().WithContext(ctx).Create(logEntry).Error; err != nil {
|
||
s.logger.Warn("[发奖日志] 写入失败", zap.String("order", shopOrderID), zap.Error(err))
|
||
}
|
||
}
|
||
|
||
type service struct {
|
||
logger logger.CustomLogger
|
||
repo mysql.Repo
|
||
readDB *dao.Query
|
||
writeDB *dao.Query
|
||
syscfg sysconfig.Service
|
||
ticketSvc game.TicketService
|
||
userSvc user.Service
|
||
rewardDispatcher *RewardDispatcher
|
||
|
||
sfGroup singleflight.Group
|
||
lastSyncTime time.Time
|
||
syncLock sync.Mutex
|
||
}
|
||
|
||
func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService, userSvc user.Service, titleSvc TitleAssigner) Service {
|
||
// 创建奖励发放器
|
||
var dispatcher *RewardDispatcher
|
||
if titleSvc != nil {
|
||
dispatcher = NewRewardDispatcher(ticketSvc, userSvc, titleSvc)
|
||
}
|
||
return &service{
|
||
logger: l,
|
||
repo: repo,
|
||
readDB: dao.Use(repo.GetDbR()),
|
||
writeDB: dao.Use(repo.GetDbW()),
|
||
syscfg: syscfg,
|
||
ticketSvc: ticketSvc,
|
||
userSvc: userSvc,
|
||
rewardDispatcher: dispatcher,
|
||
}
|
||
}
|
||
|
||
// GetConfig 获取抖店配置
|
||
func (s *service) GetConfig(ctx context.Context) (*DouyinConfig, error) {
|
||
cfg := &DouyinConfig{IntervalMinutes: 5}
|
||
|
||
if c, err := s.syscfg.GetByKey(ctx, ConfigKeyDouyinCookie); err == nil && c != nil {
|
||
cfg.Cookie = c.ConfigValue
|
||
}
|
||
if c, err := s.syscfg.GetByKey(ctx, ConfigKeyDouyinInterval); err == nil && c != nil {
|
||
if v, e := strconv.Atoi(c.ConfigValue); e == nil && v > 0 {
|
||
cfg.IntervalMinutes = v
|
||
}
|
||
}
|
||
if c, err := s.syscfg.GetByKey(ctx, ConfigKeyDouyinProxy); err == nil && c != nil {
|
||
cfg.Proxy = c.ConfigValue
|
||
}
|
||
return cfg, nil
|
||
}
|
||
|
||
// SaveConfig 保存抖店配置
|
||
func (s *service) SaveConfig(ctx context.Context, cookie, proxy string, intervalMinutes int) error {
|
||
if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinCookie, cookie, "抖店Cookie"); err != nil {
|
||
return err
|
||
}
|
||
if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinProxy, proxy, "抖店代理配置"); err != nil {
|
||
return err
|
||
}
|
||
if intervalMinutes < 1 {
|
||
intervalMinutes = 5
|
||
}
|
||
if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinInterval, strconv.Itoa(intervalMinutes), "抖店订单同步间隔(分钟)"); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListOrders 获取本地抖店订单列表
|
||
func (s *service) ListOrders(ctx context.Context, page, pageSize int, filter *ListOrdersFilter) ([]*model.DouyinOrders, int64, error) {
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 20
|
||
}
|
||
|
||
db := s.repo.GetDbR().WithContext(ctx).Model(&model.DouyinOrders{})
|
||
if filter == nil {
|
||
filter = &ListOrdersFilter{}
|
||
}
|
||
if filter != nil {
|
||
if filter.Status != nil {
|
||
db = db.Where("order_status = ?", *filter.Status)
|
||
}
|
||
if filter.MatchStatus != nil {
|
||
switch strings.ToLower(strings.TrimSpace(*filter.MatchStatus)) {
|
||
case "matched":
|
||
db = db.Where("local_user_id IS NOT NULL AND local_user_id != '' AND local_user_id != '0'")
|
||
case "unmatched":
|
||
db = db.Where("(local_user_id IS NULL OR local_user_id = '' OR local_user_id = '0')")
|
||
}
|
||
}
|
||
if filter.ShopOrderID != "" {
|
||
db = db.Where("shop_order_id = ?", filter.ShopOrderID)
|
||
}
|
||
if filter.DouyinUserID != "" {
|
||
db = db.Where("local_user_id IN (SELECT id FROM users WHERE douyin_user_id = ?)", filter.DouyinUserID)
|
||
}
|
||
}
|
||
|
||
var total int64
|
||
if err := db.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
var orders []*model.DouyinOrders
|
||
if err := db.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&orders).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
return orders, total, nil
|
||
}
|
||
|
||
// FetchAndSyncOrders 遍历所有已绑定抖音号的用户并同步其订单
|
||
func (s *service) FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*SyncResult, error) {
|
||
options := normalizeFetchOptions(opts)
|
||
|
||
cfg, err := s.GetConfig(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取配置失败: %w", err)
|
||
}
|
||
if cfg.Cookie == "" {
|
||
return nil, fmt.Errorf("抖店 Cookie 未配置")
|
||
}
|
||
|
||
// 1. 获取所有绑定了抖音号的用户
|
||
userQuery := s.repo.GetDbR().WithContext(ctx).
|
||
Model(&model.Users{}).
|
||
Where("douyin_user_id IS NOT NULL AND douyin_user_id != ''")
|
||
|
||
if options.OnlyUnmatched {
|
||
const collateExpr = "utf8mb4_unicode_ci"
|
||
subQuery := s.repo.GetDbR().WithContext(ctx).
|
||
Model(&model.DouyinOrders{}).
|
||
Select("1").
|
||
Where(fmt.Sprintf("douyin_orders.douyin_user_id COLLATE %s = users.douyin_user_id COLLATE %s", collateExpr, collateExpr)).
|
||
Where("(douyin_orders.local_user_id IS NULL OR douyin_orders.local_user_id = '' OR douyin_orders.local_user_id = '0')")
|
||
userQuery = userQuery.Where("EXISTS (?)", subQuery)
|
||
}
|
||
|
||
userQuery = userQuery.Order("updated_at DESC").Limit(options.MaxUsers)
|
||
|
||
var users []model.Users
|
||
if err := userQuery.Find(&users).Error; err != nil {
|
||
return nil, fmt.Errorf("获取绑定用户失败: %w", err)
|
||
}
|
||
|
||
result := &SyncResult{
|
||
TotalUsers: len(users),
|
||
}
|
||
startAt := time.Now()
|
||
s.logger.Info("[抖店同步] 按用户同步开始",
|
||
zap.Int("bound_users", len(users)),
|
||
zap.Bool("only_unmatched", options.OnlyUnmatched),
|
||
zap.Int("max_users", options.MaxUsers),
|
||
zap.Int("batch_size", options.BatchSize),
|
||
zap.Int("concurrency", options.Concurrency))
|
||
|
||
if len(users) == 0 {
|
||
result.ElapsedMS = time.Since(startAt).Milliseconds()
|
||
result.DebugInfo = "未找到符合条件的用户"
|
||
return result, nil
|
||
}
|
||
|
||
var mu sync.Mutex
|
||
|
||
syncUser := func(u model.Users) {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
default:
|
||
}
|
||
|
||
s.logger.Info("[抖店同步] 开始同步用户订单",
|
||
zap.Int64("user_id", u.ID),
|
||
zap.String("nickname", u.Nickname),
|
||
zap.String("douyin_user_id", u.DouyinUserID))
|
||
|
||
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID, cfg.Proxy)
|
||
if err != nil {
|
||
s.logger.Warn("[抖店同步] 抓取用户订单失败",
|
||
zap.String("douyin_user_id", u.DouyinUserID),
|
||
zap.Error(err))
|
||
mu.Lock()
|
||
result.SkippedUsers++
|
||
mu.Unlock()
|
||
return
|
||
}
|
||
|
||
perUserNew := 0
|
||
perUserMatched := 0
|
||
for _, order := range orders {
|
||
isNew, matched := s.SyncOrder(ctx, &order, u.ID, "")
|
||
if isNew {
|
||
perUserNew++
|
||
}
|
||
if matched {
|
||
perUserMatched++
|
||
}
|
||
}
|
||
|
||
mu.Lock()
|
||
result.ProcessedUsers++
|
||
result.TotalFetched += len(orders)
|
||
result.NewOrders += perUserNew
|
||
result.MatchedUsers += perUserMatched
|
||
mu.Unlock()
|
||
|
||
s.logger.Info("[抖店同步] 用户订单同步完成",
|
||
zap.Int64("user_id", u.ID),
|
||
zap.Int("fetched", len(orders)),
|
||
zap.Int("new_orders", perUserNew),
|
||
zap.Int("matched_orders", perUserMatched))
|
||
}
|
||
|
||
for start := 0; start < len(users); start += options.BatchSize {
|
||
end := start + options.BatchSize
|
||
if end > len(users) {
|
||
end = len(users)
|
||
}
|
||
batch := users[start:end]
|
||
|
||
if err := ctx.Err(); err != nil {
|
||
break
|
||
}
|
||
|
||
s.logger.Info("[抖店同步] Batch start",
|
||
zap.Int("batch_index", start/options.BatchSize+1),
|
||
zap.Int("batch_size", len(batch)),
|
||
zap.Int64("first_user_id", batch[0].ID),
|
||
zap.Int64("last_user_id", batch[len(batch)-1].ID))
|
||
|
||
var wg sync.WaitGroup
|
||
sem := make(chan struct{}, options.Concurrency)
|
||
|
||
stop := false
|
||
for _, user := range batch {
|
||
user := user
|
||
if err := ctx.Err(); err != nil {
|
||
stop = true
|
||
break
|
||
}
|
||
|
||
sem <- struct{}{}
|
||
wg.Add(1)
|
||
go func(u model.Users) {
|
||
defer wg.Done()
|
||
defer func() { <-sem }()
|
||
syncUser(u)
|
||
}(user)
|
||
}
|
||
|
||
wg.Wait()
|
||
if stop {
|
||
break
|
||
}
|
||
|
||
if options.InterBatchDelay > 0 && end < len(users) {
|
||
select {
|
||
case <-time.After(options.InterBatchDelay):
|
||
case <-ctx.Done():
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
result.ElapsedMS = time.Since(startAt).Milliseconds()
|
||
result.DebugInfo = fmt.Sprintf("按用户同步完成: 处理 %d/%d, 跳过 %d, 抓取 %d, 新订单 %d, 匹配 %d, 耗时 %.2fs",
|
||
result.ProcessedUsers, result.TotalUsers, result.SkippedUsers,
|
||
result.TotalFetched, result.NewOrders, result.MatchedUsers,
|
||
float64(result.ElapsedMS)/1000.0)
|
||
|
||
s.logger.Info("[抖店同步] 按用户同步完成",
|
||
zap.Int("total_fetched", result.TotalFetched),
|
||
zap.Int("new_orders", result.NewOrders),
|
||
zap.Int("matched_users", result.MatchedUsers),
|
||
zap.Int("processed_users", result.ProcessedUsers),
|
||
zap.Int("skipped_users", result.SkippedUsers),
|
||
zap.Int64("elapsed_ms", result.ElapsedMS))
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// removed SyncShopOrders
|
||
|
||
// 抖店 API 响应结构
|
||
type douyinOrderResponse struct {
|
||
Code int `json:"code"`
|
||
St int `json:"st"` // 抖店实际返回的是 st 而非 code
|
||
Msg string `json:"msg"`
|
||
Data []DouyinOrderItem `json:"data"` // data 直接是数组
|
||
}
|
||
|
||
type DouyinOrderItem struct {
|
||
ShopOrderID string `json:"shop_order_id"`
|
||
OrderStatus int `json:"order_status"`
|
||
UserID string `json:"user_id"`
|
||
ActualReceiveAmount any `json:"actual_receive_amount"`
|
||
ActualPayAmount any `json:"actual_pay_amount"`
|
||
PayTypeDesc string `json:"pay_type_desc"`
|
||
Remark string `json:"remark"`
|
||
UserNickname string `json:"user_nickname"`
|
||
ProductCount int64 `json:"product_count"` // 抖店返回的商品数量
|
||
ProductItemList []DouyinProductItem `json:"product_item"` // 商品详情列表
|
||
SkuOrderList []SkuOrderItem `json:"sku_order_list"`
|
||
}
|
||
|
||
type DouyinProductItem struct {
|
||
ProductID string `json:"product_id"`
|
||
ProductName string `json:"product_name"`
|
||
ComboNum int64 `json:"combo_num"`
|
||
TotalProductCount int64 `json:"total_product_count"`
|
||
}
|
||
|
||
type SkuOrderItem struct {
|
||
ProductID string `json:"product_id"`
|
||
ProductName string `json:"product_name"`
|
||
SkuID string `json:"sku_id"`
|
||
}
|
||
|
||
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单 (保持向后兼容)
|
||
func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string, proxy string) ([]DouyinOrderItem, error) {
|
||
params := url.Values{}
|
||
params.Set("page", "0")
|
||
params.Set("pageSize", "100")
|
||
params.Set("buyer", buyer)
|
||
params.Set("order_by", "create_time")
|
||
params.Set("order", "desc")
|
||
params.Set("tab", "all")
|
||
params.Set("appid", "1")
|
||
params.Set("_bid", "ffa_order")
|
||
params.Set("aid", "4272")
|
||
|
||
return s.fetchDouyinOrders(cookie, params, proxy)
|
||
}
|
||
|
||
// fetchDouyinOrders 通用的抖店订单抓取方法
|
||
func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr string) ([]DouyinOrderItem, error) {
|
||
baseUrl := "https://fxg.jinritemai.com/api/order/searchlist"
|
||
fullUrl := baseUrl + "?" + params.Encode()
|
||
|
||
// 配置代理服务器:巨量代理IP (可选)
|
||
var proxyURL *url.URL
|
||
if strings.TrimSpace(proxyAddr) != "" {
|
||
if parsed, err := url.Parse(proxyAddr); err != nil {
|
||
s.logger.Warn("[抖店API] 代理地址解析失败", zap.String("proxy", proxyAddr), zap.Error(err))
|
||
} else {
|
||
proxyURL = parsed
|
||
}
|
||
}
|
||
|
||
var lastErr error
|
||
// 重试 3 次
|
||
for i := 0; i < 3; i++ {
|
||
req, err := http.NewRequest("GET", fullUrl, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 设置请求头
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||
req.Header.Set("Cookie", cookie)
|
||
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
|
||
// 禁用连接复用,防止代理断开导致 EOF
|
||
req.Close = true
|
||
|
||
// 根据 proxyURL 是否存在决定是否使用代理
|
||
var transport *http.Transport
|
||
if proxyURL != nil {
|
||
transport = &http.Transport{
|
||
Proxy: http.ProxyURL(proxyURL),
|
||
DisableKeepAlives: true, // 禁用 Keep-Alive
|
||
}
|
||
} else {
|
||
transport = &http.Transport{
|
||
DisableKeepAlives: true, // 禁用 Keep-Alive
|
||
}
|
||
}
|
||
|
||
client := &http.Client{
|
||
Timeout: 60 * time.Second,
|
||
Transport: transport,
|
||
}
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
lastErr = err
|
||
s.logger.Warn("[抖店API] 请求失败,准备重试", zap.Int("retry", i+1), zap.Bool("use_proxy", proxyURL != nil), zap.Error(err))
|
||
time.Sleep(1 * time.Second)
|
||
continue
|
||
}
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
if err != nil {
|
||
lastErr = err
|
||
s.logger.Warn("[抖店API] 读取响应失败,准备重试", zap.Int("retry", i+1), zap.Error(err))
|
||
time.Sleep(1 * time.Second)
|
||
continue
|
||
}
|
||
|
||
var respData douyinOrderResponse
|
||
if err := json.Unmarshal(body, &respData); err != nil {
|
||
s.logger.Error("[抖店API] 解析响应失败", zap.String("response", string(body[:min(len(body), 5000)])))
|
||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||
}
|
||
|
||
// 临时调试日志:打印第一笔订单的金额字段
|
||
if len(respData.Data) > 0 {
|
||
fmt.Printf("[DEBUG] 抖店订单 0 金额测试: RawBody(500)=%s\n", string(body[:min(len(body), 500)]))
|
||
}
|
||
|
||
if respData.St != 0 && respData.Code != 0 {
|
||
return nil, fmt.Errorf("API 返回错误: %s (ST:%d CODE:%d)", respData.Msg, respData.St, respData.Code)
|
||
}
|
||
|
||
return respData.Data, nil
|
||
}
|
||
|
||
return nil, fmt.Errorf("请求失败(重试3次): %w", lastErr)
|
||
}
|
||
|
||
// SyncOrder 同步单个订单到本地
|
||
func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (isNew bool, isMatched bool) {
|
||
db := s.repo.GetDbW().WithContext(ctx)
|
||
|
||
// 解析金额工具函数
|
||
parseMoney := func(val any) int64 {
|
||
if val == nil {
|
||
return 0
|
||
}
|
||
// JSON 数字会被解析为 float64
|
||
if f, ok := val.(float64); ok {
|
||
// 如果是数值类型,但带有小数部分(如 138.4),通常是元单位
|
||
if f != math.Trunc(f) {
|
||
return int64(f*100 + 0.5)
|
||
}
|
||
// 如果是整数,保持原样(分)
|
||
return int64(f)
|
||
}
|
||
|
||
s := fmt.Sprintf("%v", val)
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return 0
|
||
}
|
||
// 只保留数字和点号 (处理 "¥158.40" 这种情况)
|
||
var sb strings.Builder
|
||
for _, r := range s {
|
||
if unicode.IsDigit(r) || r == '.' {
|
||
sb.WriteRune(r)
|
||
}
|
||
}
|
||
cleanStr := sb.String()
|
||
if f, err := strconv.ParseFloat(cleanStr, 64); err == nil {
|
||
// 字符串一律按元转分处理 (兼容旧逻辑)
|
||
return int64(f*100 + 0.5)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
var order model.DouyinOrders
|
||
err := db.Where("shop_order_id = ?", item.ShopOrderID).First(&order).Error
|
||
|
||
if err == nil {
|
||
// 订单已存在
|
||
isNew = false
|
||
// 只有当订单还没关联用户,且提供了建议用户时,才做关联
|
||
if (order.LocalUserID == "" || order.LocalUserID == "0") && suggestUserID > 0 {
|
||
order.LocalUserID = strconv.FormatInt(suggestUserID, 10)
|
||
db.Model(&order).Update("local_user_id", order.LocalUserID)
|
||
fmt.Printf("[DEBUG] 抖店辅助关联成功: %s -> User %d\n", item.ShopOrderID, suggestUserID)
|
||
}
|
||
|
||
// 更新状态与金额 (确保之前因解析失败导致的 0 金额被修复)
|
||
db.Model(&order).Updates(map[string]any{
|
||
"order_status": item.OrderStatus,
|
||
"remark": item.Remark,
|
||
"actual_receive_amount": parseMoney(item.ActualReceiveAmount),
|
||
"actual_pay_amount": parseMoney(item.ActualPayAmount),
|
||
})
|
||
// 重要:同步内存状态
|
||
order.OrderStatus = int32(item.OrderStatus)
|
||
order.Remark = item.Remark
|
||
} else {
|
||
// 订单不存在,创建新记录
|
||
isNew = true
|
||
localUserIDStr := "0"
|
||
if suggestUserID > 0 {
|
||
localUserIDStr = strconv.FormatInt(suggestUserID, 10)
|
||
}
|
||
|
||
fmt.Printf("[DEBUG] 抖店新订单: %s, UserID: %s, Recommend: %s\n", item.ShopOrderID, item.UserID, localUserIDStr)
|
||
|
||
amount := parseMoney(item.ActualReceiveAmount)
|
||
payAmount := parseMoney(item.ActualPayAmount)
|
||
|
||
// 计算商品数量:如果指定了 productID,则只统计该商品的数量;否则使用总数量
|
||
pCount := item.ProductCount
|
||
if productID != "" && len(item.ProductItemList) > 0 {
|
||
var matchedCount int64
|
||
for _, pi := range item.ProductItemList {
|
||
if pi.ProductID == productID {
|
||
// 有些情况下 TotalProductCount 准确,有些 ComboNum 准确
|
||
// 用户反馈的 JSON 中 ComboNum=2, TotalProductCount=2
|
||
// 优先使用 ComboNum
|
||
if pi.ComboNum > 0 {
|
||
matchedCount += pi.ComboNum
|
||
} else {
|
||
matchedCount += pi.TotalProductCount
|
||
}
|
||
}
|
||
}
|
||
if matchedCount > 0 {
|
||
pCount = matchedCount
|
||
}
|
||
}
|
||
// 如果没指定 productID,尝试自动填补
|
||
if productID == "" && len(item.ProductItemList) > 0 {
|
||
if len(item.ProductItemList) == 1 {
|
||
// 只有一个商品时,自动使用该商品ID
|
||
productID = item.ProductItemList[0].ProductID
|
||
} else {
|
||
// 多个商品时,使用第一个商品ID(记录主商品)
|
||
productID = item.ProductItemList[0].ProductID
|
||
|
||
// 记录日志,方便后续分析多商品订单
|
||
fmt.Printf("[WARN] 订单 %s 包含多个商品(%d个),使用第一个商品ID: %s\n",
|
||
item.ShopOrderID, len(item.ProductItemList), productID)
|
||
}
|
||
}
|
||
|
||
rawData, _ := json.Marshal(item)
|
||
|
||
order = model.DouyinOrders{
|
||
ShopOrderID: item.ShopOrderID,
|
||
DouyinProductID: productID, // 写入商品ID
|
||
ProductCount: int32(pCount), // 写入计算后的商品数量
|
||
OrderStatus: int32(item.OrderStatus),
|
||
DouyinUserID: item.UserID,
|
||
ActualReceiveAmount: amount,
|
||
ActualPayAmount: payAmount,
|
||
PayTypeDesc: item.PayTypeDesc,
|
||
Remark: item.Remark,
|
||
UserNickname: item.UserNickname,
|
||
RawData: string(rawData),
|
||
RewardGranted: 0,
|
||
LocalUserID: localUserIDStr,
|
||
}
|
||
|
||
if err := db.Create(&order).Error; err != nil {
|
||
return false, false
|
||
}
|
||
}
|
||
|
||
// 如果还没关联用户(比如之前全量抓取的),尝试用抖店的 UID (long string) 匹配
|
||
if (order.LocalUserID == "" || order.LocalUserID == "0") && item.UserID != "" {
|
||
var user model.Users
|
||
if err := s.repo.GetDbR().Where("douyin_user_id = ?", item.UserID).First(&user).Error; err == nil {
|
||
order.LocalUserID = strconv.FormatInt(user.ID, 10)
|
||
db.Model(&order).Update("local_user_id", order.LocalUserID)
|
||
fmt.Printf("[DEBUG] 通过抖店 UID 匹配成功: User %d\n", user.ID)
|
||
}
|
||
}
|
||
|
||
// ---- 统一处理:发放奖励 ----
|
||
isMatched = order.LocalUserID != "" && order.LocalUserID != "0"
|
||
|
||
// 订单完成且未发放奖励时,根据产品ID查询奖励规则并发放
|
||
if isMatched && order.RewardGranted == 0 && order.OrderStatus == 2 && order.DouyinProductID != "" {
|
||
// 检查黑名单
|
||
var blacklistCount int64
|
||
if err := db.Table("douyin_blacklist").Where("douyin_user_id = ?", item.UserID).Count(&blacklistCount).Error; err == nil && blacklistCount > 0 {
|
||
fmt.Printf("[DEBUG] 用户 %s 在黑名单中,跳过发奖\n", item.UserID)
|
||
return isNew, isMatched
|
||
}
|
||
|
||
localUserID, _ := strconv.ParseInt(order.LocalUserID, 10, 64)
|
||
if localUserID <= 0 {
|
||
return isNew, isMatched
|
||
}
|
||
|
||
// 查询该商品的所有奖励规则 (status=1 表示启用)
|
||
var rewards []model.DouyinProductRewards
|
||
if err := db.Where("product_id = ? AND status = 1", order.DouyinProductID).Find(&rewards).Error; err != nil {
|
||
fmt.Printf("[DEBUG] 查询奖励规则失败: %v\n", err)
|
||
return isNew, isMatched
|
||
}
|
||
|
||
if len(rewards) == 0 {
|
||
fmt.Printf("[DEBUG] 订单 %s 未找到奖励规则,跳过发奖\n", item.ShopOrderID)
|
||
return isNew, isMatched
|
||
}
|
||
|
||
// 遍历所有规则发放奖励
|
||
allSuccess := true
|
||
hasFlipCard := false
|
||
grantedCount := 0
|
||
for _, reward := range rewards {
|
||
// 翻牌游戏不自动发放,等待用户手动翻牌
|
||
if s.rewardDispatcher != nil && s.rewardDispatcher.IsFlipCardReward(reward) {
|
||
fmt.Printf("[DEBUG] 订单 %s 配置为翻牌游戏,跳过自动发放 (规则ID: %d)\n", item.ShopOrderID, reward.ID)
|
||
hasFlipCard = true
|
||
continue
|
||
}
|
||
|
||
if s.rewardDispatcher == nil {
|
||
fmt.Printf("[DEBUG] 订单 %s 奖励发放器未初始化,跳过 (规则ID: %d)\n", item.ShopOrderID, reward.ID)
|
||
continue
|
||
}
|
||
|
||
fmt.Printf("[DEBUG] 准备发放奖励: User: %d, ProductID: %s, Type: %s, Quantity: %d, RuleID: %d\n",
|
||
localUserID, order.DouyinProductID, reward.RewardType, reward.Quantity, reward.ID)
|
||
|
||
err := s.rewardDispatcher.GrantReward(ctx, localUserID, reward, int(order.ProductCount), "douyin_order", order.ID, order.ShopOrderID)
|
||
if err != nil {
|
||
fmt.Printf("[DEBUG] 订单 %s 发放奖励失败 (规则ID: %d): %v\n", item.ShopOrderID, reward.ID, err)
|
||
allSuccess = false
|
||
} else {
|
||
grantedCount++
|
||
fmt.Printf("[DEBUG] 订单 %s 发放奖励成功: %s × %d (规则ID: %d)\n",
|
||
item.ShopOrderID, reward.RewardType, int(reward.Quantity)*int(order.ProductCount), reward.ID)
|
||
}
|
||
}
|
||
|
||
// 标记奖励已发放
|
||
// - 如果只有翻牌游戏,不标记(让翻牌页面可以查询)
|
||
// - 如果有其他奖励成功发放,标记为已发放(防止重复发放)
|
||
if allSuccess && grantedCount > 0 {
|
||
db.Model(&order).Update("reward_granted", order.ProductCount)
|
||
order.RewardGranted = order.ProductCount
|
||
} else if hasFlipCard && grantedCount == 0 {
|
||
// 纯翻牌游戏,不标记
|
||
fmt.Printf("[DEBUG] 订单 %s 仅配置翻牌游戏,等待手动翻牌\n", item.ShopOrderID)
|
||
}
|
||
}
|
||
|
||
return isNew, isMatched
|
||
}
|
||
|
||
// GrantOrderReward 手动触发单个订单的奖励发放
|
||
func (s *service) GrantOrderReward(ctx context.Context, shopOrderID string) (*GrantOrderRewardResult, error) {
|
||
if strings.TrimSpace(shopOrderID) == "" {
|
||
return nil, fmt.Errorf("shop_order_id 不能为空")
|
||
}
|
||
|
||
order, err := s.readDB.DouyinOrders.WithContext(ctx).
|
||
Where(s.readDB.DouyinOrders.ShopOrderID.Eq(shopOrderID)).
|
||
First()
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
s.logRewardResult(ctx, shopOrderID, "", 0, "", 0, "manual", "failed", "订单不存在")
|
||
return nil, fmt.Errorf("订单不存在: %s", shopOrderID)
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
if order.OrderStatus != 2 {
|
||
localUID, _ := strconv.ParseInt(order.LocalUserID, 10, 64)
|
||
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, localUID, order.DouyinProductID, 0, "manual", "skipped", "订单状态非待发货")
|
||
return &GrantOrderRewardResult{
|
||
ShopOrderID: shopOrderID,
|
||
Message: "订单状态非待发货,无法发放",
|
||
Granted: false,
|
||
OrderStatus: order.OrderStatus,
|
||
ProductCount: order.ProductCount,
|
||
LocalUserID: order.LocalUserID,
|
||
}, nil
|
||
}
|
||
|
||
if order.DouyinProductID == "" {
|
||
localUID, _ := strconv.ParseInt(order.LocalUserID, 10, 64)
|
||
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, localUID, "", 0, "manual", "failed", "缺少商品ID")
|
||
return nil, fmt.Errorf("订单缺少 Douyin 商品ID,无法匹配奖励")
|
||
}
|
||
|
||
if order.LocalUserID == "" || order.LocalUserID == "0" {
|
||
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, 0, order.DouyinProductID, 0, "manual", "failed", "订单未绑定本地用户")
|
||
return nil, fmt.Errorf("订单未绑定本地用户,无法发放奖励")
|
||
}
|
||
|
||
localUserID, _ := strconv.ParseInt(order.LocalUserID, 10, 64)
|
||
if localUserID <= 0 {
|
||
return nil, fmt.Errorf("订单本地用户ID无效")
|
||
}
|
||
|
||
var rewards []model.DouyinProductRewards
|
||
if err := s.repo.GetDbR().WithContext(ctx).
|
||
Where("product_id = ? AND status = 1", order.DouyinProductID).
|
||
Find(&rewards).Error; err != nil {
|
||
return nil, fmt.Errorf("查询奖励规则失败: %w", err)
|
||
}
|
||
if len(rewards) == 0 {
|
||
return nil, fmt.Errorf("该商品未配置奖励规则")
|
||
}
|
||
|
||
if s.rewardDispatcher == nil {
|
||
return nil, fmt.Errorf("奖励发放器未初始化")
|
||
}
|
||
|
||
totalGranted := int32(0)
|
||
for _, reward := range rewards {
|
||
if s.rewardDispatcher.IsFlipCardReward(reward) {
|
||
continue
|
||
}
|
||
if err := s.rewardDispatcher.GrantReward(ctx, localUserID, reward, int(order.ProductCount), "douyin_order_manual", order.ID, order.ShopOrderID); err != nil {
|
||
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, localUserID, order.DouyinProductID, reward.ID, "manual", "failed", err.Error())
|
||
return nil, fmt.Errorf("发放奖励失败 (规则 %d): %w", reward.ID, err)
|
||
}
|
||
totalGranted += order.ProductCount
|
||
}
|
||
|
||
if totalGranted > 0 {
|
||
if err := s.repo.GetDbW().WithContext(ctx).
|
||
Model(&model.DouyinOrders{}).
|
||
Where("id = ?", order.ID).
|
||
Update("reward_granted", totalGranted).Error; err != nil {
|
||
return nil, fmt.Errorf("更新发奖状态失败: %w", err)
|
||
}
|
||
s.logRewardResult(ctx, order.ShopOrderID, order.DouyinUserID, localUserID, order.DouyinProductID, 0, "manual", "success", "手动发奖成功")
|
||
}
|
||
|
||
return &GrantOrderRewardResult{
|
||
ShopOrderID: shopOrderID,
|
||
Message: "奖励发放成功",
|
||
Granted: totalGranted > 0,
|
||
RewardGranted: totalGranted,
|
||
ProductCount: order.ProductCount,
|
||
OrderStatus: order.OrderStatus,
|
||
LocalUserID: order.LocalUserID,
|
||
}, nil
|
||
}
|
||
|
||
// min 返回两个整数的最小值
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态)
|
||
func (s *service) SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*SyncResult, error) {
|
||
// 使用 singleflight 合并并发请求
|
||
v, err, _ := s.sfGroup.Do("SyncAllOrders", func() (interface{}, error) {
|
||
// 1. 检查限流 (5秒内不重复同步)
|
||
s.syncLock.Lock()
|
||
if time.Since(s.lastSyncTime) < 5*time.Second {
|
||
s.syncLock.Unlock()
|
||
// 触发限流,直接返回空结果(调用方会使用数据库旧数据)
|
||
return &SyncResult{
|
||
DebugInfo: "Sync throttled (within 5s)",
|
||
}, nil
|
||
}
|
||
s.syncLock.Unlock()
|
||
|
||
// 2. 执行真正的同步逻辑
|
||
start := time.Now()
|
||
cfg, err := s.GetConfig(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取配置失败: %w", err)
|
||
}
|
||
if cfg.Cookie == "" {
|
||
return nil, fmt.Errorf("抖店 Cookie 未配置")
|
||
}
|
||
|
||
// 临时:强制使用用户提供的最新 Cookie
|
||
if len(cfg.Cookie) < 100 {
|
||
cfg.Cookie = "passport_csrf_token=afcc4debfeacce6454979bb9465999dc; passport_csrf_token_default=afcc4debfeacce6454979bb9465999dc; is_staff_user=false; zsgw_business_data=%7B%22uuid%22%3A%22fa769974-ba17-4daf-94cb-3162ba299c40%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; s_v_web_id=verify_mjqlw6yx_mNQjOEnB_oXBo_4Etb_AVQ9_7tQGH9WORNRy; SHOP_ID=47668214; PIGEON_CID=3501298428676440; x-web-secsdk-uid=663d5a20-e75c-4789-bc98-839744bf70bc; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1766891015,1766979339,1767628404,1768381245; HMACCOUNT=95F3EBE1C47ED196; ttcid=7962a054674f4dd7bf895af73ae3f34142; passport_mfa_token=CjfZetGovLzEQb6MwoEpMQnvCSomMC9o0P776kEFy77vhrRCAdFvvrnTSpTXY2aib8hCdU5w3tQvGkoKPAAAAAAAAAAAAABP88E%2FGYNOqYg7lJ6fcoAzlVHbNi0bqTR%2Fru8noACGHR%2BtNjtq%2FnW9rBK32mcHCC5TzRDW8YYOGPax0WwgAiIBA3WMQyg%3D; source=seo.fxg.jinritemai.com; gfkadpd=4272,23756; csrf_session_id=b7b4150c5eeefaede4ef5e71473e9dc1; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1768381314; ttwid=1%7CAwu3-vdDBhOP12XdEzmCJlbyX3Qt_5RcioPVgjBIDps%7C1768381315%7Ca763fd05ed6fa274ed997007385cc0090896c597cfac0b812c962faf34f04897; tt_scid=f4YqIWnO3OdWrfVz0YVnJmYahx-qu9o9j.VZC2op7nwrQRodgrSh1ka0Ow3g5nyKd42a; odin_tt=bcf942ae72bd6b4b8f357955b71cc21199b6aec5e9acee4ce64f80704f08ea1cbaaa6e70f444f6a09712806aa424f4d0cce236e77b0bfa2991aa8a23dab27e1e; passport_auth_status=b3b3a865e0bd3857e6a28ea5a6854830%2C228cf6630632c26472c096506639ed6e; passport_auth_status_ss=b3b3a865e0bd3857e6a28ea5a6854830%2C228cf6630632c26472c096506639ed6e; uid_tt=4dfa662033e2e4eefe629ad8815f076f; uid_tt_ss=4dfa662033e2e4eefe629ad8815f076f; sid_tt=4cc6aa2f1a6e338ec72d663a0b611d3c; sessionid=4cc6aa2f1a6e338ec72d663a0b611d3c; sessionid_ss=4cc6aa2f1a6e338ec72d663a0b611d3c; PHPSESSID=a1b2fd062c1346e5c6f94bac3073cd7d; PHPSESSID_SS=a1b2fd062c1346e5c6f94bac3073cd7d; ucas_c0=CkEKBTEuMC4wEJOIgezc9NazaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cpt53LBkip69nNBlC_vL6Ekt3t1GdYbhIU2LuS6yHmC8_SKu9Jok5ToGxfQIg; ucas_c0_ss=CkEKBTEuMC4wEJOIgezc9NazaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cpt53LBkip69nNBlC_vL6Ekt3t1GdYbhIU2LuS6yHmC8_SKu9Jok5ToGxfQIg; ecom_gray_shop_id=156231010; sid_guard=4cc6aa2f1a6e338ec72d663a0b611d3c%7C1768381360%7C5184000%7CSun%2C+15-Mar-2026+09%3A02%3A40+GMT; session_tlb_tag=sttt%7C4%7CTMaqLxpuM47HLWY6C2EdPP________-x3_oZvMYjz8-Uw3dAm6JiPFDhS1ih9XTV79AgAO_5cvo%3D; sid_ucp_v1=1.0.0-KGRmNzNkZjM2YjUwZDk2M2M0MjQ5MGE2NzNkNGZkZjNhZWFhYmJkMmIKGQib1oDYuM3aBxCwt53LBhiwISAMOAZA9AcaAmxmIiA0Y2M2YWEyZjFhNmUzMzhlYzcyZDY2M2EwYjYxMWQzYw; ssid_ucp_v1=1.0.0-KGRmNzNkZjM2YjUwZDk2M2M0MjQ5MGE2NzNkNGZkZjNhZWFhYmJkMmIKGQib1oDYuM3aBxCwt53LBhiwISAMOAZA9AcaAmxmIiA0Y2M2YWEyZjFhNmUzMzhlYzcyZDY2M2EwYjYxMWQzYw; COMPASS_LUOPAN_DT=session_7595137429020049706; BUYIN_SASID=SID2_7595138116287152420"
|
||
}
|
||
|
||
startTime := time.Now().Add(-duration)
|
||
|
||
queryParams := url.Values{
|
||
"page": {"0"},
|
||
"pageSize": {"50"},
|
||
"order_by": {"update_time"},
|
||
"order": {"desc"},
|
||
"appid": {"1"},
|
||
"_bid": {"ffa_order"},
|
||
"aid": {"4272"},
|
||
"tab": {"all"}, // 全量状态
|
||
"update_time_start": {strconv.FormatInt(startTime.Unix(), 10)},
|
||
}
|
||
|
||
fetchStart := time.Now()
|
||
proxyAddr := ""
|
||
if useProxy {
|
||
proxyAddr = cfg.Proxy
|
||
}
|
||
|
||
orders, err := s.fetchDouyinOrders(cfg.Cookie, queryParams, proxyAddr)
|
||
fetchDuration := time.Since(fetchStart)
|
||
|
||
if err != nil {
|
||
fmt.Printf("[SyncAll] 抓取失败,耗时: %v, Err: %v\n", fetchDuration, err)
|
||
return nil, fmt.Errorf("抓取增量订单失败: %w", err)
|
||
}
|
||
fmt.Printf("[SyncAll] 抓取成功,耗时: %v, 订单数: %d\n", fetchDuration, len(orders))
|
||
|
||
result := &SyncResult{
|
||
TotalFetched: len(orders),
|
||
DebugInfo: fmt.Sprintf("UpdateSince: %s, Fetched: %d", startTime.Format("15:04:05"), len(orders)),
|
||
}
|
||
|
||
processStart := time.Now()
|
||
|
||
// 并发处理订单
|
||
var wg sync.WaitGroup
|
||
// 限制并发数为 10,防止数据库连接耗尽
|
||
sem := make(chan struct{}, 10)
|
||
|
||
var newOrdersCount int64
|
||
var matchedUsersCount int64
|
||
|
||
for _, order := range orders {
|
||
wg.Add(1)
|
||
go func(o DouyinOrderItem) {
|
||
defer wg.Done()
|
||
|
||
sem <- struct{}{} // 获取信号量
|
||
defer func() { <-sem }()
|
||
|
||
isNew, matched := s.SyncOrder(ctx, &o, 0, "")
|
||
|
||
if isNew {
|
||
atomic.AddInt64(&newOrdersCount, 1)
|
||
}
|
||
if matched {
|
||
atomic.AddInt64(&matchedUsersCount, 1)
|
||
}
|
||
}(order)
|
||
}
|
||
wg.Wait()
|
||
|
||
result.NewOrders = int(newOrdersCount)
|
||
result.MatchedUsers = int(matchedUsersCount)
|
||
|
||
processDuration := time.Since(processStart)
|
||
totalDuration := time.Since(start)
|
||
|
||
fmt.Printf("[SyncAll] 处理完成,DB耗时: %v, 总耗时: %v\n", processDuration, totalDuration)
|
||
|
||
// 3. 更新同步时间
|
||
s.syncLock.Lock()
|
||
s.lastSyncTime = time.Now()
|
||
s.syncLock.Unlock()
|
||
|
||
return result, nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return v.(*SyncResult), nil
|
||
}
|