package activity import ( "bindbox-game/internal/pkg/logger" paypkg "bindbox-game/internal/pkg/pay" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/model" usersvc "bindbox-game/internal/service/user" "context" "database/sql" "fmt" "time" "github.com/redis/go-redis/v9" ) 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, rdb *redis.Client) { r := dao.Use(repo.GetDbR()) w := dao.Use(repo.GetDbW()) us := usersvc.New(l, repo) activitySvc := New(l, repo, us, rdb) // Ensure lottery_refund_logs table exists _ = repo.GetDbW().Exec(`CREATE TABLE IF NOT EXISTS lottery_refund_logs ( id bigint unsigned AUTO_INCREMENT PRIMARY KEY, issue_id bigint NOT NULL DEFAULT 0, order_id bigint NOT NULL DEFAULT 0, user_id bigint NOT NULL DEFAULT 0, amount bigint NOT NULL DEFAULT 0, coupon_type varchar(64) DEFAULT '', coupon_amount bigint DEFAULT 0, reason varchar(255) DEFAULT '', status varchar(32) DEFAULT '', created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_issue (issue_id), INDEX idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`).Error 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")) // 【独立检查】一番赏格位重置:每30秒检查所有售罄的一番赏期号 checkAndResetIchibanSlots(ctx, repo, r) 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.IsConsumed.Eq(0), // 仅处理未履约的订单 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\n", aid, count, a.MinParticipants) // Initialize Wechat Client if needed wc, _ := paypkg.NewWechatPayClient(ctx) refundedIssues := make(map[int64]bool) // 【优化】一番赏定时退款:检查是否售罄 if a.PlayType == "ichiban" { issueIDs := make(map[int64]struct{}) for _, o := range orders { iss := extractIssueID(o.Remark) if iss > 0 { issueIDs[iss] = struct{}{} } } // 【Fix】Also check all active issues for this activity, regardless of recent orders // This ensures expired issues with no recent orders are also processed var activeIssues []struct { ID int64 } // status=1 means active/processing _ = r.ActivityIssues.WithContext(ctx).ReadDB().Where( r.ActivityIssues.ActivityID.Eq(aid), r.ActivityIssues.Status.Eq(1), ).Scan(&activeIssues) for _, ai := range activeIssues { issueIDs[ai.ID] = struct{}{} } for iss := range issueIDs { // Check Sales // 一番赏:每种奖品 = 1个格位 totalSlots, _ := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.IssueID.Eq(iss)).Count() soldSlots, _ := r.IssuePositionClaims.WithContext(ctx).Where(r.IssuePositionClaims.IssueID.Eq(iss)).Count() fmt.Printf("[定时开奖-一番赏] 检查售罄 IssueID=%d Sold=%d Total=%d\n", iss, soldSlots, totalSlots) if soldSlots < totalSlots { fmt.Printf("[定时开奖-一番赏] ❌ IssueID=%d 未售罄,执行全额退款\n", iss) refundedIssues[iss] = true // Find ALL valid orders for this issue issueOrders, _ := r.Orders.WithContext(ctx).Where( r.Orders.Status.Eq(2), r.Orders.SourceType.Eq(2), r.Orders.Remark.Like(fmt.Sprintf("%%issue:%d|%%", iss)), ).Find() for _, o := range issueOrders { refundOrder(ctx, o, "ichiban_not_sold_out", wc, r, w, us, a.RefundCouponID) } } else { // 【Fix】已售罄:处理所有未开奖的订单(包括旧时间窗口的订单) fmt.Printf("[定时开奖-一番赏] ✅ IssueID=%d 已售罄,检查并处理所有未开奖订单\n", iss) // Step 1: Get all order IDs from claims for this issue var claimOrderIDs []int64 errClaims := repo.GetDbR().Raw(` SELECT DISTINCT c.order_id FROM issue_position_claims c LEFT JOIN activity_draw_logs l ON l.order_id = c.order_id AND l.issue_id = c.issue_id WHERE c.issue_id = ? AND l.id IS NULL `, iss).Scan(&claimOrderIDs).Error if errClaims != nil { fmt.Printf("[定时开奖-一番赏] ❌ 查询未处理订单ID失败: %v\n", errClaims) } fmt.Printf("[定时开奖-一番赏] IssueID=%d 查询到 %d 个未处理订单ID: %v\n", iss, len(claimOrderIDs), claimOrderIDs) if len(claimOrderIDs) > 0 { // Step 2: Fetch the actual order records unprocessedOrders, errOrders := r.Orders.WithContext(ctx).Where( r.Orders.ID.In(claimOrderIDs...), r.Orders.Status.Eq(2), ).Find() if errOrders != nil { fmt.Printf("[定时开奖-一番赏] ❌ 获取订单详情失败: %v\n", errOrders) } fmt.Printf("[定时开奖-一番赏] IssueID=%d 发现 %d 个未处理订单,开始补录\n", iss, len(unprocessedOrders)) for _, o := range unprocessedOrders { if err := activitySvc.ProcessOrderLottery(ctx, o.ID); err != nil { fmt.Printf("[定时开奖-一番赏-补录] ❌ ProcessOrderLottery 失败: %v\n", err) } } } } // 【直接重置】无论是否有待处理订单,检查该期号是否可以重置 var remainingUnprocessed int64 _ = repo.GetDbR().Raw(` SELECT COUNT(1) FROM issue_position_claims c LEFT JOIN activity_draw_logs l ON l.order_id = c.order_id AND l.issue_id = c.issue_id WHERE c.issue_id = ? AND l.id IS NULL `, iss).Scan(&remainingUnprocessed) fmt.Printf("[定时开奖-一番赏] IssueID=%d 剩余未处理订单数: %d\n", iss, remainingUnprocessed) if remainingUnprocessed == 0 { // 所有订单都已处理,执行重置 if err := repo.GetDbW().Exec("DELETE FROM issue_position_claims WHERE issue_id = ?", iss).Error; err != nil { fmt.Printf("[定时开奖-一番赏] ❌ 重置格位失败: %v\n", err) } else { fmt.Printf("[定时开奖-一番赏] ✅ IssueID=%d 所有订单已处理,格位已重置,新一轮可以开始\n", iss) } } } } shouldRefund := false if a.PlayType != "ichiban" { if count < a.MinParticipants { shouldRefund = true } } if shouldRefund { fmt.Printf("[定时开奖] 活动ID=%d ❌ 人数不足,进行退款处理\n", aid) for _, o := range orders { refundOrder(ctx, o, "scheduled_not_enough", wc, r, w, us, a.RefundCouponID) } } else { fmt.Printf("[定时开奖] 活动ID=%d ✅ 人数满足(或一番赏模式),开始开奖处理\n", aid) for _, o := range orders { iss := extractIssueID(o.Remark) if a.PlayType == "ichiban" && refundedIssues[iss] { fmt.Printf("[定时开奖-一番赏] OrderID=%d IssueID=%d 已退款,跳过开奖\n", o.ID, iss) continue } if err := activitySvc.ProcessOrderLottery(ctx, o.ID); err != nil { fmt.Printf("[定时开奖] ❌ ProcessOrderLottery 失败: %v\n", err) } } } // 【修复】无论成功开奖还是退款,都更新活动的结算时间与下次计划 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 { for _, ia := range instantActs { orders2, _ := r.Orders.WithContext(ctx).ReadDB().Where( r.Orders.Status.Eq(2), r.Orders.SourceType.Eq(2), r.Orders.IsConsumed.Eq(0), // 仅处理未履约的订单 r.Orders.Remark.Like(fmt.Sprintf("lottery:activity:%d|%%", ia.ID)), r.Orders.CreatedAt.Lt(now.Add(-5*time.Minute)), r.Orders.CreatedAt.Gt(now.Add(-24*time.Hour)), // 限制时间窗口为24小时,避免全表扫描 ).Find() for _, o2 := range orders2 { if err := activitySvc.ProcessOrderLottery(ctx, o2.ID); err != nil { fmt.Printf("[即时开奖补偿] ❌ ProcessOrderLottery 失败: %v\n", err) } } } } } }() } // checkAndResetIchibanSlots 检查并重置所有售罄且已完成的一番赏期号 func checkAndResetIchibanSlots(ctx context.Context, repo mysql.Repo, r *dao.Query) { // 查找所有一番赏活动下的活跃期号 var issuesWithClaims []struct { IssueID int64 TotalSlots int64 SoldSlots int64 } err := repo.GetDbR().Raw(` SELECT ai.id as issue_id, (SELECT COUNT(*) FROM activity_reward_settings WHERE issue_id = ai.id) as total_slots, (SELECT COUNT(*) FROM issue_position_claims WHERE issue_id = ai.id) as sold_slots FROM activity_issues ai INNER JOIN activities a ON a.id = ai.activity_id WHERE a.play_type = 'ichiban' AND a.status = 1 AND ai.status = 1 HAVING sold_slots > 0 AND sold_slots >= total_slots `).Scan(&issuesWithClaims).Error if err != nil { fmt.Printf("[一番赏重置检查] 查询失败: %v\n", err) return } for _, iss := range issuesWithClaims { // 检查是否所有订单都已处理(有draw logs) var unprocessedCnt int64 _ = repo.GetDbR().Raw(` SELECT COUNT(1) FROM issue_position_claims c LEFT JOIN activity_draw_logs l ON l.order_id = c.order_id AND l.issue_id = c.issue_id WHERE c.issue_id = ? AND l.id IS NULL `, iss.IssueID).Scan(&unprocessedCnt) if unprocessedCnt == 0 { // 所有订单都已处理,执行重置 if err := repo.GetDbW().Exec("DELETE FROM issue_position_claims WHERE issue_id = ?", iss.IssueID).Error; err != nil { fmt.Printf("[一番赏重置检查] ❌ IssueID=%d 重置失败: %v\n", iss.IssueID, err) } else { fmt.Printf("[一番赏重置检查] ✅ IssueID=%d 已售罄且全部处理完成,格位已重置\n", iss.IssueID) } } else { fmt.Printf("[一番赏重置检查] IssueID=%d 已售罄但仍有 %d 个未处理订单\n", iss.IssueID, unprocessedCnt) } } } 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{} } // uploadVirtualShippingForScheduledDraw 定时开奖后上传虚拟发货 // 收集中奖产品名称并调用微信虚拟发货API // playType: 活动玩法类型,只有 ichiban 时才发送开奖结果通知 func refundOrder(ctx context.Context, o *model.Orders, reason string, wc *paypkg.WechatPayClient, r *dao.Query, w *dao.Query, us usersvc.Service, refundCouponID int64) { // 1. Refund Points if o.PointsAmount > 0 { refundPts := o.PointsAmount / 100 _, _ = us.RefundPoints(ctx, o.UserID, refundPts, o.OrderNo, reason) } // 2. Refund WeChat if o.ActualAmount > 0 && wc != nil { refundNo := fmt.Sprintf("R%s-%d", o.OrderNo, time.Now().Unix()) refundID, status, err := wc.RefundOrder(ctx, o.OrderNo, refundNo, o.ActualAmount, o.ActualAmount, reason) if err == nil { // 修复:raw 字段需要有效的 JSON 值,不能为空 rawJSON := fmt.Sprintf(`{"refund_id":"%s","status":"%s"}`, refundID, status) _ = w.PaymentRefunds.WithContext(ctx).Create(&model.PaymentRefunds{ OrderID: o.ID, OrderNo: o.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: o.ActualAmount, Reason: reason, SuccessTime: time.Now(), Raw: rawJSON, }) _ = w.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{UserID: o.UserID, Action: "refund_amount", Points: o.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID}) } else { fmt.Printf("[Refund] WeChat refund failed for order %s: %v\n", o.OrderNo, err) } } // 3. Refund Used Coupons ocs, _ := r.OrderCoupons.WithContext(ctx).Where(r.OrderCoupons.OrderID.Eq(o.ID)).Find() for _, oc := range ocs { // Restore user coupon status to 1 (Unused) and clear usage info _, err := w.UserCoupons.WithContext(ctx).Where(w.UserCoupons.ID.Eq(oc.UserCouponID)).Updates(map[string]interface{}{ "status": 1, "used_order_id": 0, "used_at": nil, }) if err != nil { fmt.Printf("[Refund] Failed to restore coupon %d for order %s: %v\n", oc.UserCouponID, o.OrderNo, err) } else { fmt.Printf("[Refund] Restored coupon %d for order %s\n", oc.UserCouponID, o.OrderNo) } } // 3.5. 一番赏退款:删除 issue_position_claims 记录,恢复格位 iss := extractIssueID(o.Remark) if iss > 0 { result, err := w.IssuePositionClaims.WithContext(ctx).Where( w.IssuePositionClaims.OrderID.Eq(o.ID), ).Delete() if err != nil { fmt.Printf("[Refund] Failed to delete position claims for order %d: %v\n", o.ID, err) } else if result.RowsAffected > 0 { fmt.Printf("[Refund] ✅ Restored %d slot position(s) for order %d issue %d\n", result.RowsAffected, o.ID, iss) } } // 3.6. 退还道具卡(支持两种记录方式) // 方式1:从 activity_draw_effects 表查询(无限赏等游戏类型) var itemCardIDs []int64 _ = r.Orders.WithContext(ctx).UnderlyingDB().Raw("SELECT user_item_card_id FROM activity_draw_effects WHERE draw_log_id IN (SELECT id FROM activity_draw_logs WHERE order_id=?)", o.ID).Scan(&itemCardIDs).Error // 方式2:从 user_item_cards 表的 used_draw_log_id 直接查询(对对碰等游戏类型) var itemCardIDsFromItemCards []int64 _ = r.Orders.WithContext(ctx).UnderlyingDB().Raw("SELECT id FROM user_item_cards WHERE used_draw_log_id IN (SELECT id FROM activity_draw_logs WHERE order_id=?) AND status=2", o.ID).Scan(&itemCardIDsFromItemCards).Error // 合并去重 idSet := make(map[int64]struct{}) for _, icID := range itemCardIDs { if icID > 0 { idSet[icID] = struct{}{} } } for _, icID := range itemCardIDsFromItemCards { if icID > 0 { idSet[icID] = struct{}{} } } // 执行退还 for icID := range idSet { _ = w.Orders.WithContext(ctx).UnderlyingDB().Exec("UPDATE user_item_cards SET status=1, used_at=NULL, used_draw_log_id=0, used_activity_id=0, used_issue_id=0, updated_at=NOW(3) WHERE id=?", icID).Error fmt.Printf("[Refund] ✅ Restored item card %d for order %s\n", icID, o.OrderNo) } // 4. Update Order Status _, _ = w.Orders.WithContext(ctx).Where(w.Orders.ID.Eq(o.ID)).Updates(map[string]any{w.Orders.Status.ColumnName().String(): 4}) // 5. Log Refund iss = extractIssueID(o.Remark) _ = w.Orders.WithContext(ctx).UnderlyingDB().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, reason, "done").Error // 6. Compensation if refundCouponID > 0 { _ = us.AddCoupon(ctx, o.UserID, refundCouponID) } }