2026-02-01 00:27:38 +08:00

377 lines
14 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/model"
"bindbox-game/internal/service/game"
"bindbox-game/internal/service/sysconfig"
"context"
"fmt"
"strconv"
"time"
"go.uber.org/zap"
"bindbox-game/internal/service/user"
)
// StartDouyinOrderSync 启动抖店订单定时同步任务
func StartDouyinOrderSync(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService, userSvc user.Service, titleSvc TitleAssigner) {
svc := New(l, repo, syscfg, ticketSvc, userSvc, titleSvc)
go func() {
// 初始等待30秒让服务完全启动
time.Sleep(30 * time.Second)
firstRun := true
for {
ctx := context.Background()
// 获取同步间隔配置
intervalMinutes := 5 // 默认5分钟
if c, err := syscfg.GetByKey(ctx, ConfigKeyDouyinInterval); err == nil && c != nil {
if v, e := strconv.Atoi(c.ConfigValue); e == nil && v > 0 {
intervalMinutes = v
}
}
// 检查是否配置了 Cookie
cookieCfg, err := syscfg.GetByKey(ctx, ConfigKeyDouyinCookie)
if err != nil || cookieCfg == nil || cookieCfg.ConfigValue == "" {
l.Debug("[抖店定时同步] Cookie 未配置,跳过本次同步")
time.Sleep(time.Duration(intervalMinutes) * time.Minute)
continue
}
l.Info("[抖店定时同步] 开始同步", zap.Int("interval_minutes", intervalMinutes))
// ========== 优先:按用户同步 (Only valid users) ==========
// “优先遍历:代码先查 users 表中所有已绑定抖音的用户。 然后根据抖音id 去请求抖音的订单接口拿数据”
result, err := svc.FetchAndSyncOrders(ctx)
if err != nil {
l.Error("[抖店定时同步] 用户订单同步失败", zap.Error(err))
} else {
l.Info("[抖店定时同步] 用户订单同步成功",
zap.Int("total_fetched", result.TotalFetched),
zap.Int("new_orders", result.NewOrders),
zap.Int("matched_users", result.MatchedUsers),
)
}
// ========== 自动补发扫雷游戏资格 (针对刚才同步到的订单) ==========
// [修复] 禁用自动补发逻辑,防止占用直播间抽奖配额
// if err := svc.GrantMinesweeperQualifications(ctx); err != nil {
// l.Error("[定时补发] 补发扫雷资格失败", zap.Error(err))
// }
// ========== 自动发放直播间奖品 ==========
if err := svc.GrantLivestreamPrizes(ctx); err != nil {
l.Error("[定时发放] 发放直播奖品失败", zap.Error(err))
}
// ========== 核心:批量同步最近所有订单变更 (基于更新时间,不分状态) ==========
// 首次运行同步最近 48 小时以修复潜在的历史遗漏,之后同步最近 1 小时
syncDuration := 1 * time.Hour
if firstRun {
syncDuration = 48 * time.Hour
}
if res, err := svc.SyncAllOrders(ctx, syncDuration); err != nil {
l.Error("[定时同步] 全量同步失败", zap.Error(err))
} else {
l.Info("[定时同步] 全量同步完成", zap.String("info", res.DebugInfo))
}
firstRun = false
// ========== 新增:同步退款状态 ==========
if err := svc.SyncRefundStatus(ctx); err != nil {
l.Error("[定时同步] 同步退款状态失败", zap.Error(err))
}
// 等待下次同步
time.Sleep(time.Duration(intervalMinutes) * time.Minute)
}
}()
l.Info("[抖店定时同步] 定时任务已启动")
}
// GrantMinesweeperQualifications 自动补发扫雷资格
// 逻辑:遍历已绑定抖音的用户 -> 查找其未归属的订单 -> 关联订单 -> 补发资格
func (s *service) GrantMinesweeperQualifications(ctx context.Context) error {
db := s.repo.GetDbW().WithContext(ctx)
// 1. 查找所有已绑定抖音的用户
var users []model.Users
if err := s.repo.GetDbR().Where("douyin_user_id != '' AND douyin_user_id IS NOT NULL").Find(&users).Error; err != nil {
return err
}
for _, u := range users {
// 1.1 检查是否在黑名单中
var blacklistCount int64
if err := s.repo.GetDbR().Table("douyin_blacklist").Where("douyin_user_id = ?", u.DouyinUserID).Count(&blacklistCount).Error; err == nil && blacklistCount > 0 {
continue
}
// 2. 查找该抖音ID下未关联(local_user_id=0 or empty)的订单
var orders []model.DouyinOrders
if err := db.Where("douyin_user_id = ? AND (local_user_id = '' OR local_user_id = '0')", u.DouyinUserID).Find(&orders).Error; err != nil {
continue
}
for _, order := range orders {
// 3. 关联订单到用户
if err := db.Model(&order).Update("local_user_id", strconv.FormatInt(u.ID, 10)).Error; err != nil {
s.logger.Error("[自动补发] 关联订单失败", zap.String("order_id", order.ShopOrderID), zap.Error(err))
continue
}
// 4. 如果是已支付待发货的订单(2),且未发奖,则补发
if order.OrderStatus == 2 && order.RewardGranted == 0 {
orderID := order.ID
s.logger.Info("[自动补发] 开始补发扫雷资格", zap.Int64("user_id", u.ID), zap.String("shop_order_id", order.ShopOrderID))
// 调用发奖服务
count := int64(1)
if order.ProductCount > 0 {
count = int64(order.ProductCount)
}
s.logger.Info("[自动补发] 发放数量", zap.Int64("count", count))
if err := s.ticketSvc.GrantTicket(ctx, u.ID, "minesweeper", int(count), "douyin_order", orderID, "定时任务补发"); err == nil {
db.Model(&order).Update("reward_granted", int32(count))
s.logger.Info("[自动补发] 补发成功", zap.String("shop_order_id", order.ShopOrderID))
} else {
s.logger.Error("[自动补发] 补发失败", zap.Error(err))
}
}
}
}
return nil
}
// GrantLivestreamPrizes 自动发放直播间奖品
// 逻辑:扫描 livestream_draw_logs 中 is_granted=0 的记录 -> 找到对应 ProductID -> 发放商品
func (s *service) GrantLivestreamPrizes(ctx context.Context) error {
db := s.repo.GetDbW().WithContext(ctx)
// 1. 查找未发放的记录
var logs []model.LivestreamDrawLogs
if err := db.Where("is_granted = 0").Find(&logs).Error; err != nil {
return err
}
for _, log := range logs {
// 必须要有对应的本地用户ID
if log.LocalUserID == 0 {
// 尝试从 douyin_orders 补全 user_id
var order model.DouyinOrders
if err := s.repo.GetDbR().Where("shop_order_id = ?", log.ShopOrderID).First(&order).Error; err == nil {
if uid, _ := strconv.ParseInt(order.LocalUserID, 10, 64); uid > 0 {
log.LocalUserID = uid
db.Model(&log).Update("local_user_id", uid)
}
}
}
if log.LocalUserID == 0 {
continue // 还没关联到用户,跳过
}
// 2. 查奖品关联的 ProductID
var prize model.LivestreamPrizes
if err := s.repo.GetDbR().Where("id = ?", log.PrizeID).First(&prize).Error; err != nil {
s.logger.Error("[自动发放] 奖品不存在", zap.Int64("prize_id", log.PrizeID))
continue
}
if prize.ProductID == 0 {
s.logger.Warn("[自动发放] 奖品未关联商品ID跳过", zap.Int64("prize_id", log.PrizeID), zap.String("name", prize.Name))
continue
}
// 3. 发放商品 (使用 GrantReward 创建新订单发放)
sourceType := int32(5) // 5 代表直播间
req := user.GrantRewardRequest{
ProductID: prize.ProductID,
Quantity: 1,
SourceType: &sourceType,
Remark: fmt.Sprintf("直播间抽奖: %s (关联抖店订单: %s)", log.PrizeName, log.ShopOrderID),
}
s.logger.Info("[自动发放] 开始发放直播商品",
zap.Int64("user_id", log.LocalUserID),
zap.Int64("product_id", prize.ProductID),
zap.String("prize", log.PrizeName),
)
res, err := s.userSvc.GrantReward(ctx, log.LocalUserID, req)
if err != nil {
s.logger.Error("[自动发放] 发放失败", zap.Error(err))
// 如果发放失败是库存原因等,可能需要告警。暂时不重试,等下个周期。
} else {
// 4. 更新发放状态
db.Model(&log).Update("is_granted", 1)
s.logger.Info("[自动发放] 发放成功", zap.Int64("log_id", log.ID))
// 5. 自动虚拟发货 (本地状态更新)
// 直播间奖品通常为虚拟发货,直接标记为已消费/已发货
if res != nil && res.OrderID > 0 {
updates := map[string]interface{}{
"is_consumed": 1,
"updated_at": time.Now(),
}
if err := s.repo.GetDbW().WithContext(ctx).Model(&model.Orders{}).Where("id = ?", res.OrderID).Updates(updates).Error; err != nil {
s.logger.Error("[自动发放] 更新订单状态失败", zap.Int64("order_id", res.OrderID), zap.Error(err))
}
// 更新发货记录
shippingUpdates := map[string]interface{}{
"status": 2, // 已发货
"shipped_at": time.Now(),
"updated_at": time.Now(),
}
if err := s.repo.GetDbW().WithContext(ctx).Model(&model.ShippingRecords{}).Where("order_id = ?", res.OrderID).Updates(shippingUpdates).Error; err != nil {
s.logger.Error("[自动发放] 更新发货记录失败", zap.Int64("order_id", res.OrderID), zap.Error(err))
} else {
s.logger.Info("[自动发放] 虚拟发货完成(本地)", zap.Int64("order_id", res.OrderID))
}
}
}
}
return nil
}
// SyncRefundStatus 同步退款状态
// 逻辑:检查 douyin_orders 的状态变更,如果订单已退款,则标记对应的 livestream_draw_logs
func (s *service) SyncRefundStatus(ctx context.Context) error {
db := s.repo.GetDbW().WithContext(ctx)
// 1. 查找所有关联直播抽奖但尚未标记退款的记录
var logs []model.LivestreamDrawLogs
if err := db.Where("is_refunded = 0 AND shop_order_id != ''").Find(&logs).Error; err != nil {
return err
}
refundedCount := 0
for _, log := range logs {
// 2. 查找对应的抖店订单
var order model.DouyinOrders
if err := s.repo.GetDbR().Where("shop_order_id = ?", log.ShopOrderID).First(&order).Error; err != nil {
continue // 找不到订单,跳过
}
// 3. 检查订单状态:抖店状态 4=已关闭 (包含退款/取消等关闭情况)
// 状态说明: 3=已发货, 4=已关闭, 5=已完成
if order.OrderStatus == 4 {
db.Model(&log).Update("is_refunded", 1)
refundedCount++
s.logger.Info("[退款同步] 标记退款记录",
zap.Int64("draw_log_id", log.ID),
zap.String("shop_order_id", log.ShopOrderID),
zap.Int32("order_status", order.OrderStatus),
)
// 4. 如果用户已关联,回收资产
if log.LocalUserID > 0 {
s.reclaimLivestreamAssets(ctx, &log)
}
}
}
if refundedCount > 0 {
s.logger.Info("[退款同步] 本次同步完成", zap.Int("refunded_count", refundedCount))
}
return nil
}
// reclaimLivestreamAssets 回收直播间发放的资产
// 逻辑:查找该用户通过此抽奖获得的 user_inventory作废或扣除积分
func (s *service) reclaimLivestreamAssets(ctx context.Context, log *model.LivestreamDrawLogs) {
db := s.repo.GetDbW().WithContext(ctx)
// 1. 查找关联的 user_inventory 记录
// 直播间奖品是通过 GrantReward 发放的,会创建一个新的本地订单
// 我们需要通过 remark 或其他方式找到关联的 inventory
// 由于 GrantReward 会在 remark 中记录 shop_order_id我们通过这个来查找
var inventories []model.UserInventory
// 查找用户持有的、来自直播间的资产(通过 remark 包含 shop_order_id 来关联)
searchPattern := "%" + log.ShopOrderID + "%"
if err := db.Where("user_id = ? AND status IN (1, 3) AND remark LIKE ?", log.LocalUserID, searchPattern).Find(&inventories).Error; err != nil {
s.logger.Error("[资产回收] 查询资产失败", zap.Error(err), zap.Int64("user_id", log.LocalUserID))
return
}
if len(inventories) == 0 {
// 尝试通过 prize_id 和 product_id 关联查找
var prize model.LivestreamPrizes
if err := s.repo.GetDbR().Where("id = ?", log.PrizeID).First(&prize).Error; err == nil && prize.ProductID > 0 {
// 查找该用户最近获得的该商品的资产(时间相近)
db.Where("user_id = ? AND product_id = ? AND status IN (1, 3) AND created_at >= ?",
log.LocalUserID, prize.ProductID, log.CreatedAt.Add(-time.Hour)).
Order("created_at DESC").Limit(1).Find(&inventories)
}
}
if len(inventories) == 0 {
s.logger.Warn("[资产回收] 未找到可回收资产",
zap.Int64("user_id", log.LocalUserID),
zap.String("shop_order_id", log.ShopOrderID),
)
return
}
// 2. 回收资产
for _, inv := range inventories {
if inv.Status == 1 {
// 状态1持有作废
db.Model(&inv).Updates(map[string]any{
"status": 2,
"remark": inv.Remark + "|refund_reclaimed",
})
s.logger.Info("[资产回收] 作废持有资产",
zap.Int64("inventory_id", inv.ID),
zap.Int64("user_id", inv.UserID),
)
} else if inv.Status == 3 {
// 状态3已兑换/发货):扣除积分
// 查找商品价格作为积分扣除依据
var product model.Products
if err := s.repo.GetDbR().Where("id = ?", inv.ProductID).First(&product).Error; err == nil {
pointsToDeduct := product.Price / 100 // 分转换为积分(假设 1积分=1分钱
if pointsToDeduct > 0 {
_, consumed, err := s.userSvc.ConsumePointsForRefund(ctx, inv.UserID, pointsToDeduct, "user_inventory", fmt.Sprintf("%d", inv.ID), "直播退款回收已兑换资产")
if err != nil {
s.logger.Error("[资产回收] 扣除积分失败", zap.Error(err), zap.Int64("user_id", inv.UserID))
}
if consumed < pointsToDeduct {
// 积分不足,标记用户
s.logger.Warn("[资产回收] 用户积分不足",
zap.Int64("user_id", inv.UserID),
zap.Int64("needed", pointsToDeduct),
zap.Int64("consumed", consumed),
)
// 可选:加入黑名单
// db.Exec("UPDATE users SET status = 3 WHERE id = ?", inv.UserID)
}
}
}
// 作废记录
db.Model(&inv).Updates(map[string]any{
"status": 2,
"remark": inv.Remark + "|refund_reclaimed_points_deducted",
})
s.logger.Info("[资产回收] 扣除积分并作废",
zap.Int64("inventory_id", inv.ID),
zap.Int64("user_id", inv.UserID),
)
}
}
// 3. 恢复奖品库存
db.Exec("UPDATE livestream_prizes SET remaining = remaining + 1 WHERE id = ? AND remaining >= 0", log.PrizeID)
s.logger.Info("[资产回收] 恢复奖品库存", zap.Int64("prize_id", log.PrizeID))
}