package activity import ( "bindbox-game/configs" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/notify" paypkg "bindbox-game/internal/pkg/pay" "bindbox-game/internal/pkg/wechat" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/model" strat "bindbox-game/internal/service/activity/strategy" usersvc "bindbox-game/internal/service/user" "context" "database/sql" "fmt" "strings" "time" ) type scheduledConfig struct { PlayType string `json:"play_type"` DrawMode string `json:"draw_mode"` MinParticipants int64 `json:"min_participants"` ScheduledTime string `json:"scheduled_time"` ScheduledDelayMinutes int64 `json:"scheduled_delay_minutes"` IntervalMinutes int64 `json:"interval_minutes"` LastSettledAt string `json:"last_settled_at"` RefundCouponType string `json:"refund_coupon_type"` RefundCouponAmount float64 `json:"refund_coupon_amount"` RefundCouponID int64 `json:"refund_coupon_id"` } func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) { r := dao.Use(repo.GetDbR()) w := dao.Use(repo.GetDbW()) us := usersvc.New(l, repo) go func() { t := time.NewTicker(30 * time.Second) defer t.Stop() for range t.C { ctx := context.Background() now := time.Now() fmt.Printf("[定时开奖] ====== 开始检查 时间=%s ======\n", now.Format("2006-01-02 15:04:05")) var acts []struct { ID int64 PlayType string DrawMode string MinParticipants int64 IntervalMinutes int64 ScheduledTime *time.Time RefundCouponID int64 LastSettledAt *time.Time } _ = repo.GetDbR().Raw("SELECT id, play_type, draw_mode, min_participants, interval_minutes, scheduled_time, refund_coupon_id, last_settled_at FROM activities WHERE draw_mode='scheduled' AND (scheduled_time IS NOT NULL OR interval_minutes > 0)").Scan(&acts) fmt.Printf("[定时开奖] 查询到定时开奖活动数量=%d\n", len(acts)) for _, a := range acts { fmt.Printf("[定时开奖] 检查活动 ID=%d PlayType=%s IntervalMinutes=%d ScheduledTime=%v LastSettledAt=%v\n", a.ID, a.PlayType, a.IntervalMinutes, a.ScheduledTime, a.LastSettledAt) // 计算开奖时间 st := time.Time{} if a.ScheduledTime != nil { st = *a.ScheduledTime } // 【修复】如果 scheduled_time 是过去的时间且 interval_minutes > 0, // 跳过所有过期窗口,推进到最近的一个开奖时间 if !st.IsZero() && now.After(st) && a.IntervalMinutes > 0 { // 计算需要跳过多少个周期 elapsed := now.Sub(st) periods := int64(elapsed.Minutes()) / a.IntervalMinutes if periods > 0 { // 跳过所有完整的过期周期,st 变成最近的开奖时间点 st = st.Add(time.Duration(periods*a.IntervalMinutes) * time.Minute) fmt.Printf("[定时开奖] 活动ID=%d 跳过过期周期数=%d 新开奖时间=%s\n", a.ID, periods, st.Format("2006-01-02 15:04:05")) } } if st.IsZero() && a.IntervalMinutes > 0 { if a.LastSettledAt != nil && !a.LastSettledAt.IsZero() { st = a.LastSettledAt.Add(time.Duration(a.IntervalMinutes) * time.Minute) } else { st = now } } fmt.Printf("[定时开奖] 活动ID=%d 计算开奖时间st=%s 当前时间now=%s 是否跳过=%t\n", a.ID, st.Format("2006-01-02 15:04:05"), now.Format("2006-01-02 15:04:05"), st.IsZero() || now.Before(st)) if st.IsZero() || now.Before(st) { continue } aid := a.ID // 【修复】使用 last_settled_at 或很早的时间作为查询起点 last := time.Time{} if a.LastSettledAt != nil && !a.LastSettledAt.IsZero() { last = *a.LastSettledAt } // 【修复】查询从 last 到 now 的所有订单(而非到 st),确保能找到最新订单 fmt.Printf("[定时开奖] 活动ID=%d 查询订单范围: last=%s now=%s\n", aid, last.Format("2006-01-02 15:04:05"), now.Format("2006-01-02 15:04:05")) orders, _ := r.Orders.WithContext(ctx).ReadDB().Where( r.Orders.Status.Eq(2), r.Orders.SourceType.Eq(2), r.Orders.Remark.Like(fmt.Sprintf("lottery:activity:%d|%%", aid)), r.Orders.CreatedAt.Gte(last), ).Find() count := int64(len(orders)) fmt.Printf("[定时开奖] 活动ID=%d 查询到订单数=%d 最低参与人数=%d 是否满足=%t\n", aid, count, a.MinParticipants, count >= a.MinParticipants) if count < a.MinParticipants { fmt.Printf("[定时开奖] 活动ID=%d ❌ 人数不足,进行退款处理\n", aid) wc, err := paypkg.NewWechatPayClient(ctx) if err == nil { for _, o := range orders { // 先处理积分退款(如有) if o.PointsAmount > 0 { refundPts := o.PointsAmount / 100 _, _ = us.RefundPoints(ctx, o.UserID, refundPts, o.OrderNo, "scheduled_not_enough") } // 微信支付部分退款(如有) if o.ActualAmount > 0 { refundNo := fmt.Sprintf("R%s-%d", o.OrderNo, time.Now().Unix()) refundID, status, err := wc.RefundOrder(ctx, o.OrderNo, refundNo, o.ActualAmount, o.ActualAmount, "scheduled_not_enough") if err == nil { _ = w.PaymentRefunds.WithContext(ctx).Create(&model.PaymentRefunds{OrderID: o.ID, OrderNo: o.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: o.ActualAmount, Reason: "scheduled_not_enough"}) _ = w.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{UserID: o.UserID, Action: "refund_amount", Points: o.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID}) } } // 标记订单退款 _, _ = w.Orders.WithContext(ctx).Where(w.Orders.ID.Eq(o.ID)).Updates(map[string]any{w.Orders.Status.ColumnName().String(): 4}) iss := extractIssueID(o.Remark) _ = repo.GetDbW().Exec("INSERT INTO lottery_refund_logs(issue_id, order_id, user_id, amount, coupon_type, coupon_amount, reason, status) VALUES(?,?,?,?,?,?,?,?)", iss, o.ID, o.UserID, o.ActualAmount, "", 0, "scheduled_not_enough", "done").Error if a.RefundCouponID > 0 { _ = us.AddCoupon(ctx, o.UserID, a.RefundCouponID) } } } } else { fmt.Printf("[定时开奖] 活动ID=%d ✅ 人数满足,开始开奖处理\n", aid) if a.PlayType == "ichiban" { fmt.Printf("[定时开奖] 活动ID=%d 一番赏模式开奖,订单数=%d\n", aid, len(orders)) // 一番赏定时开奖逻辑 ichibanSel := strat.NewIchiban(r, w) for _, o := range orders { uid := o.UserID iss := extractIssueID(o.Remark) fmt.Printf("[定时开奖-一番赏] 处理订单 OrderID=%d UserID=%d IssueID=%d\n", o.ID, uid, iss) // 检查是否已经处理过 logs, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(o.ID)).Find() if len(logs) > 0 { fmt.Printf("[定时开奖-一番赏] 订单ID=%d 已处理过,跳过\n", o.ID) continue } // 查找该订单锁定的所有格位 claims, _ := r.IssuePositionClaims.WithContext(ctx).ReadDB().Where( r.IssuePositionClaims.IssueID.Eq(iss), r.IssuePositionClaims.OrderID.Eq(o.ID), ).Find() fmt.Printf("[定时开奖-一番赏] 订单ID=%d 找到格位占用数=%d\n", o.ID, len(claims)) for claimIdx, claim := range claims { fmt.Printf("[定时开奖-一番赏] 处理格位 SlotIndex=%d (索引=%d)\n", claim.SlotIndex, claimIdx) // 【幂等检查】检查该订单的该格位是否已经发过奖 existingLogCnt, _ := r.ActivityDrawLogs.WithContext(ctx).Where( r.ActivityDrawLogs.OrderID.Eq(o.ID), r.ActivityDrawLogs.IssueID.Eq(iss), ).Count() if existingLogCnt > int64(claimIdx) { fmt.Printf("[定时开奖-一番赏] ⚠️ 格位 SlotIndex=%d 已处理过(日志数=%d,当前索引=%d),跳过\n", claim.SlotIndex, existingLogCnt, claimIdx) continue } // 使用 claim 中的 slot_index 直接获取奖品 rid, proof, err := ichibanSel.SelectItemBySlot(ctx, aid, iss, claim.SlotIndex) if err != nil || rid <= 0 { fmt.Printf("[定时开奖-一番赏] ❌ SelectItemBySlot失败 err=%v rid=%d\n", err, rid) continue } rw, err := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.ID.Eq(rid)).First() if err != nil || rw == nil { fmt.Printf("[定时开奖-一番赏] ❌ 奖品设置不存在 rid=%d\n", rid) continue } // 【先记录日志,再发奖】确保日志创建成功后再发奖,防止重复 drawLog := &model.ActivityDrawLogs{ UserID: uid, IssueID: iss, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1, } if err := w.ActivityDrawLogs.WithContext(ctx).Create(drawLog); err != nil { fmt.Printf("[定时开奖-一番赏] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", err) continue } // 发放奖励(在原订单上添加中奖商品,不创建新订单) fmt.Printf("[定时开奖-一番赏] 发放奖励到原订单 OrderID=%d UserID=%d RewardID=%d 奖品名=%s\n", o.ID, uid, rid, rw.Name) _, err = us.GrantRewardToOrder(ctx, uid, usersvc.GrantRewardToOrderRequest{ OrderID: o.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &rid, Remark: rw.Name, }) if err != nil { fmt.Printf("[定时开奖-一番赏] ⚠️ 发放奖励失败 err=%v\n", err) } fmt.Printf("[定时开奖-一番赏] ✅ 开奖成功 UserID=%d OrderID=%d RewardID=%d\n", uid, o.ID, rid) // 保存可验证凭据 if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil { fmt.Printf("[定时开奖-一番赏] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof) } else { fmt.Printf("[定时开奖-一番赏] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss) } } // 【开奖后虚拟发货】定时一番赏开奖后上传虚拟发货 uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string { act, _ := r.Activities.WithContext(ctx).Where(r.Activities.ID.Eq(aid)).First() if act != nil { return act.Name } return "活动" }()) } } else { // 默认玩法逻辑 sel := strat.NewDefault(r, w) for _, o := range orders { uid := o.UserID iss := extractIssueID(o.Remark) dc := extractCount(o.Remark) if dc <= 0 { dc = 1 } logs, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(o.ID)).Find() done := int64(len(logs)) for i := done; i < dc; i++ { rid, proof, err := sel.SelectItem(ctx, aid, iss, uid) if err != nil || rid <= 0 { break } rw, err := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.ID.Eq(rid)).First() if err != nil || rw == nil { break } // 【先记录日志,再发奖】确保日志创建成功后再发奖,防止重复 drawLog := &model.ActivityDrawLogs{UserID: uid, IssueID: iss, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} if err := w.ActivityDrawLogs.WithContext(ctx).Create(drawLog); err != nil { fmt.Printf("[定时开奖-默认] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", err) break } // 发放奖励(在原订单上添加中奖商品,不创建新订单) _, _ = us.GrantRewardToOrder(ctx, uid, usersvc.GrantRewardToOrderRequest{OrderID: o.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &rid, Remark: rw.Name}) // 保存可验证凭据 if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil { fmt.Printf("[定时开奖-默认] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof) } else { fmt.Printf("[定时开奖-默认] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss) } } // 【开奖后虚拟发货】定时开奖后上传虚拟发货 uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string { act, _ := r.Activities.WithContext(ctx).Where(r.Activities.ID.Eq(aid)).First() if act != nil { return act.Name } return "活动" }()) } } } // 【修复】无论成功开奖还是退款,都更新活动的结算时间与下次计划 var next *time.Time if a.IntervalMinutes > 0 { t := now.Add(time.Duration(a.IntervalMinutes) * time.Minute) next = &t } var nextVal sql.NullTime if next != nil { nextVal = sql.NullTime{Time: next.UTC(), Valid: true} } fmt.Printf("[定时开奖] 活动ID=%d 更新时间: last_settled_at=%s scheduled_time=%s\n", aid, now.Format("2006-01-02 15:04:05"), next) _ = repo.GetDbW().WithContext(ctx).Exec("UPDATE activities SET last_settled_at=?, scheduled_time=? WHERE id= ?", now.UTC(), nextVal, aid).Error } // 即时开奖:处理所有已支付且未记录抽奖日志的订单 var instantActs []struct{ ID int64 } _ = repo.GetDbR().WithContext(ctx).Raw("SELECT id FROM activities WHERE draw_mode='instant'").Scan(&instantActs) if len(instantActs) > 0 { sel2 := strat.NewDefault(r, w) for _, ia := range instantActs { orders2, _ := r.Orders.WithContext(ctx).ReadDB().Where( r.Orders.Status.Eq(2), r.Orders.SourceType.Eq(2), r.Orders.Remark.Like(fmt.Sprintf("lottery:activity:%d|%%", ia.ID)), ).Find() for _, o2 := range orders2 { uid := o2.UserID iss := extractIssueID(o2.Remark) dc := extractCount(o2.Remark) if dc <= 0 { dc = 1 } logs2, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(o2.ID)).Find() done2 := int64(len(logs2)) for i := done2; i < dc; i++ { rid, proof, err := sel2.SelectItem(ctx, ia.ID, iss, uid) if err != nil || rid <= 0 { break } rw, err := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.ID.Eq(rid)).First() if err != nil || rw == nil { break } // 【先记录日志,再发奖】确保日志创建成功后再发奖,防止重复 drawLog := &model.ActivityDrawLogs{UserID: uid, IssueID: iss, OrderID: o2.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} if err := w.ActivityDrawLogs.WithContext(ctx).Create(drawLog); err != nil { fmt.Printf("[即时开奖补偿] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", err) break } _, _ = us.GrantRewardToOrder(ctx, uid, usersvc.GrantRewardToOrderRequest{OrderID: o2.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &ia.ID, RewardID: &rid, Remark: rw.Name}) // 保存可验证凭据 if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil { fmt.Printf("[即时开奖补偿] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof) } else { fmt.Printf("[即时开奖补偿] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss) } } } } } } }() } func parseTime(s string) time.Time { if s == "" { return time.Time{} } if t, err := time.Parse(time.RFC3339, s); err == nil { return t } if t2, err2 := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err2 == nil { return t2 } return time.Time{} } func parseInt64(s string) int64 { var n int64 for i := 0; i < len(s); i++ { c := s[i] if c < '0' || c > '9' { break } n = n*10 + int64(c-'0') } return n } func extractIssueID(remark string) int64 { if remark == "" { return 0 } parts := strings.Split(remark, "|") for _, p := range parts { if strings.HasPrefix(p, "issue:") { return parseInt64(p[6:]) } } return 0 } func extractCount(remark string) int64 { if remark == "" { return 1 } parts := strings.Split(remark, "|") for _, p := range parts { if strings.HasPrefix(p, "count:") { return parseInt64(p[6:]) } } return 1 } // uploadVirtualShippingForScheduledDraw 定时开奖后上传虚拟发货 // 收集中奖产品名称并调用微信虚拟发货API func uploadVirtualShippingForScheduledDraw(ctx context.Context, r *dao.Query, orderID int64, orderNo string, userID int64, actName string) { // 获取开奖记录 drawLogs, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(orderID)).Find() if len(drawLogs) == 0 { fmt.Printf("[定时开奖-虚拟发货] 没有开奖记录,跳过 order_id=%d\n", orderID) return } // 收集赏品名称 var rewardNames []string for _, lg := range drawLogs { if rw, _ := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.ID.Eq(lg.RewardID)).First(); rw != nil { rewardNames = append(rewardNames, rw.Name) } } itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ") if len(itemsDesc) > 120 { itemsDesc = itemsDesc[:120] } // 获取支付交易信息 tx, _ := r.PaymentTransactions.WithContext(ctx).Where(r.PaymentTransactions.OrderNo.Eq(orderNo)).First() if tx == nil || tx.TransactionID == "" { fmt.Printf("[定时开奖-虚拟发货] 没有支付交易记录,跳过 order_no=%s\n", orderNo) return } // 获取用户openid u, _ := r.Users.WithContext(ctx).Where(r.Users.ID.Eq(userID)).First() payerOpenid := "" if u != nil { payerOpenid = u.Openid } // 获取微信配置 c := configs.Get() cfg := &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret} fmt.Printf("[定时开奖-虚拟发货] 上传 order_no=%s transaction_id=%s items_desc=%s\n", orderNo, tx.TransactionID, itemsDesc) if err := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil { fmt.Printf("[定时开奖-虚拟发货] 上传失败: %v\n", err) } // 【定时开奖后推送通知】 notifyCfg := ¬ify.WechatNotifyConfig{ AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret, LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID, } _ = notify.SendLotteryResultNotification(ctx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now()) }