488 lines
19 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 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"
"bindbox-game/internal/service/user"
)
// 系统配置键
const (
ConfigKeyDouyinCookie = "douyin_cookie"
ConfigKeyDouyinInterval = "douyin_sync_interval_minutes"
)
type Service interface {
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (原有按用户同步逻辑)
FetchAndSyncOrders(ctx context.Context) (*SyncResult, error)
// SyncShopOrders 同步店铺全量订单 (专供直播间等全扫描场景)
SyncShopOrders(ctx context.Context, activityID int64) (*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和商品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
}
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"`
Orders []*model.DouyinOrders `json:"orders"` // 新增:返回详情以供后续处理
DebugInfo string `json:"debug_info"`
}
type service struct {
logger logger.CustomLogger
repo mysql.Repo
readDB *dao.Query
writeDB *dao.Query
syscfg sysconfig.Service
ticketSvc game.TicketService
userSvc user.Service
}
func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService, userSvc user.Service) Service {
return &service{
logger: l,
repo: repo,
readDB: dao.Use(repo.GetDbR()),
writeDB: dao.Use(repo.GetDbW()),
syscfg: syscfg,
ticketSvc: ticketSvc,
userSvc: userSvc,
}
}
// 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++
}
}
}
result.DebugInfo += fmt.Sprintf("\n同步完成: 总抓取 %d, 新订单 %d, 匹配用户 %d", result.TotalFetched, result.NewOrders, result.MatchedUsers)
return result, nil
}
// SyncShopOrders 同步店铺全量订单 (专供直播间等全扫描场景)
func (s *service) SyncShopOrders(ctx context.Context, activityID int64) (*SyncResult, error) {
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("获取配置失败: %w", err)
}
// 临时:强制使用用户提供的最新 Cookie (调试模式)
// if cfg.Cookie == "" || 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"
// }
// 1. 获取活动信息以拿到 ProductID
var activity model.LivestreamActivities
if err := s.repo.GetDbR().Where("id = ?", activityID).First(&activity).Error; err != nil {
return nil, fmt.Errorf("查询活动失败: %w", err)
}
fmt.Printf("[DEBUG] 直播间全量同步开始: ActivityID=%d, ProductID=%s\n", activityID, activity.DouyinProductID)
// 构建请求参数
queryParams := url.Values{
"page": {"0"},
"pageSize": {"20"}, // 增大每页数量以确保覆盖
"order_by": {"create_time"},
"order": {"desc"},
"appid": {"1"},
"_bid": {"ffa_order"},
"aid": {"4272"},
// 新增过滤参数
"order_status": {"stock_up"}, // 仅同步待发货/备货中
"tab": {"stock_up"},
"compact_time[select]": {"create_time_start,create_time_end"},
}
// 如果活动绑定了某些商品,则过滤这些商品
if activity.DouyinProductID != "" {
queryParams.Set("product", activity.DouyinProductID)
}
// 2. 抓取订单
orders, err := s.fetchDouyinOrders(cfg.Cookie, queryParams)
if err != nil {
return nil, fmt.Errorf("抓取全店订单失败: %w", err)
}
result := &SyncResult{
TotalFetched: len(orders),
DebugInfo: fmt.Sprintf("Activity: %d, ProductID: %s, Fetched: %d", activityID, activity.DouyinProductID, len(orders)),
}
// 3. 遍历并同步
for _, order := range orders {
// SyncOrder 内部会根据 status 更新或创建,传入 productID
isNew, matched := s.SyncOrder(ctx, &order, 0, activity.DouyinProductID)
if isNew {
result.NewOrders++
}
if matched {
result.MatchedUsers++
}
// 查出同步后的订单记录
var dbOrder model.DouyinOrders
if err := s.repo.GetDbR().Where("shop_order_id = ?", order.ShopOrderID).First(&dbOrder).Error; err == nil {
result.Orders = append(result.Orders, &dbOrder)
}
// 【新增】自动将订单与当前活动绑定 (如果尚未绑定)
// 这一步确保即使订单之前存在,也能关联到当前的新活动 ID如果业务需要一对多这里可能需要额外表但目前模型看来是一对一或多对一
// 假设通过 livestream_draw_logs 关联,或者仅仅是同步下来即可。
// 目前 SyncOrder 只存 douyin_orders。真正的绑定在 Draw 阶段,或者这里可以做一些预处理。
// 暂时保持 SyncOrder 原样,因为 SyncResult 返回给前端后,前端会展示 Pending Orders。
}
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"`
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) ([]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)
}
// fetchDouyinOrders 通用的抖店订单抓取方法
func (s *service) fetchDouyinOrders(cookie string, params url.Values) ([]DouyinOrderItem, error) {
baseUrl := "https://fxg.jinritemai.com/api/order/searchlist"
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 (ST:%d CODE:%d)", respData.Msg, respData.St, respData.Code)
}
return respData.Data, nil
}
// SyncOrder 同步单个订单到本地
func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64, productID string) (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)
}
}
// 计算商品数量:如果指定了 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但 iterate 发现只有一个商品,也可以尝试自动填补 productID (可选优化)
if productID == "" && len(item.ProductItemList) == 1 {
productID = item.ProductItemList[0].ProductID
}
rawData, _ := json.Marshal(item)
order = model.DouyinOrders{
ShopOrderID: item.ShopOrderID,
DouyinProductID: productID, // 写入商品ID
ProductCount: pCount, // 写入计算后的商品数量
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 {
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
}