package douyin import ( "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" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "go.uber.org/zap" ) // 系统配置键 const ( ConfigKeyDouyinCookie = "douyin_cookie" ConfigKeyDouyinInterval = "douyin_sync_interval_minutes" ) type Service interface { // FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) // ListOrders 获取本地抖店订单列表 ListOrders(ctx context.Context, page, pageSize int, status *int) ([]*model.DouyinOrders, int64, error) // GetConfig 获取抖店配置 GetConfig(ctx context.Context) (*DouyinConfig, error) // SaveConfig 保存抖店配置 SaveConfig(ctx context.Context, cookie string, intervalMinutes int) error // SyncOrder 同步单个订单到本地,可传入建议关联的用户ID SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64) (isNew bool, isMatched bool) } type DouyinConfig struct { Cookie string `json:"cookie"` IntervalMinutes int `json:"interval_minutes"` } type SyncResult struct { TotalFetched int `json:"total_fetched"` NewOrders int `json:"new_orders"` MatchedUsers int `json:"matched_users"` } type service struct { logger logger.CustomLogger repo mysql.Repo readDB *dao.Query writeDB *dao.Query syscfg sysconfig.Service ticketSvc game.TicketService } func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService) Service { return &service{ logger: l, repo: repo, readDB: dao.Use(repo.GetDbR()), writeDB: dao.Use(repo.GetDbW()), syscfg: syscfg, ticketSvc: ticketSvc, } } // 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 } } return cfg, nil } // SaveConfig 保存抖店配置 func (s *service) SaveConfig(ctx context.Context, cookie string, intervalMinutes int) error { if _, err := s.syscfg.UpsertByKey(ctx, ConfigKeyDouyinCookie, cookie, "抖店Cookie"); 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, status *int) ([]*model.DouyinOrders, int64, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } db := s.repo.GetDbR().WithContext(ctx).Model(&model.DouyinOrders{}) if status != nil { db = db.Where("order_status = ?", *status) } 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) (*SyncResult, error) { cfg, err := s.GetConfig(ctx) if err != nil { return nil, fmt.Errorf("获取配置失败: %w", err) } if cfg.Cookie == "" { return nil, fmt.Errorf("抖店 Cookie 未配置") } // 1. 获取所有绑定了抖音号的用户 var users []model.Users if err := s.repo.GetDbR().WithContext(ctx).Where("douyin_user_id != ''").Find(&users).Error; err != nil { return nil, fmt.Errorf("获取绑定用户失败: %w", err) } result := &SyncResult{} fmt.Printf("[DEBUG] 开始全量同步,共 %d 个绑定用户\n", len(users)) // 2. 遍历用户,按 buyer 抓取订单 for _, u := range users { fmt.Printf("[DEBUG] 正在同步用户 ID: %d (昵称: %s, 抖音号: %s) 的订单...\n", u.ID, u.Nickname, u.DouyinUserID) orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID) if err != nil { fmt.Printf("[DEBUG] 抓取用户 %s 订单失败: %v\n", u.DouyinUserID, err) continue } result.TotalFetched += len(orders) // 3. 同步 for _, order := range orders { // 同步订单(传入建议关联的用户 ID) isNew, matched := s.SyncOrder(ctx, &order, u.ID) if isNew { result.NewOrders++ } if matched { result.MatchedUsers++ } } } s.logger.Info("[抖店同步] 全量同步完成", zap.Int("users_count", len(users)), zap.Int("total_fetched", result.TotalFetched), zap.Int("new_orders", result.NewOrders), zap.Int("matched_users", result.MatchedUsers), ) return result, nil } // 抖店 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 string `json:"actual_receive_amount"` PayTypeDesc string `json:"pay_type_desc"` Remark string `json:"remark"` UserNickname string `json:"user_nickname"` } // fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单 func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string) ([]DouyinOrderItem, error) { // 拼接带有业务标识的搜索 URL baseUrl := "https://fxg.jinritemai.com/api/order/searchlist" 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") fullUrl := baseUrl + "?" + params.Encode() 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") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var respData douyinOrderResponse if err := json.Unmarshal(body, &respData); err != nil { s.logger.Error("[抖店API] 解析响应失败", zap.String("response", string(body[:min(len(body), 500)]))) return nil, fmt.Errorf("解析响应失败: %w", err) } if respData.St != 0 && respData.Code != 0 { return nil, fmt.Errorf("API 返回错误: %s", respData.Msg) } return respData.Data, nil } // SyncOrder 同步单个订单到本地 func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64) (isNew bool, isMatched bool) { db := s.repo.GetDbW().WithContext(ctx) 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) } // 更新状态 db.Model(&order).Updates(map[string]any{ "order_status": item.OrderStatus, "remark": item.Remark, }) // 重要:同步内存状态,防止后续判断逻辑失效 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) // 解析金额 var amount int64 if item.ActualReceiveAmount != "" { if f, err := strconv.ParseFloat(strings.TrimSpace(item.ActualReceiveAmount), 64); err == nil { amount = int64(f * 100) } } rawData, _ := json.Marshal(item) order = model.DouyinOrders{ ShopOrderID: item.ShopOrderID, OrderStatus: int32(item.OrderStatus), DouyinUserID: item.UserID, ActualReceiveAmount: amount, PayTypeDesc: item.PayTypeDesc, Remark: item.Remark, UserNickname: item.UserNickname, RawData: string(rawData), RewardGranted: 0, LocalUserID: localUserIDStr, } if err := db.Create(&order).Error; err != nil { s.logger.Error("[抖店同步] 创建订单失败", zap.String("shop_order_id", item.ShopOrderID), zap.Error(err)) 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" if isMatched && order.RewardGranted == 0 && order.OrderStatus == 5 { localUserID, _ := strconv.ParseInt(order.LocalUserID, 10, 64) fmt.Printf("[DEBUG] 准备发放奖励: User: %d, ShopOrder: %s\n", localUserID, item.ShopOrderID) if localUserID > 0 && s.ticketSvc != nil { err := s.ticketSvc.GrantTicket(ctx, localUserID, "minesweeper", 1, "douyin_order", order.ID, "抖店订单奖励") if err == nil { db.Model(&order).Update("reward_granted", 1) order.RewardGranted = 1 fmt.Printf("[DEBUG] 订单 %s 发放奖励成功\n", item.ShopOrderID) } else { fmt.Printf("[DEBUG] 订单 %s 发放奖励失败: %v\n", item.ShopOrderID, err) } } } return isNew, isMatched } // min 返回两个整数的最小值 func min(a, b int) int { if a < b { return a } return b }