package activity import ( "bindbox-game/internal/pkg/logger" paypkg "bindbox-game/internal/pkg/pay" "bindbox-game/internal/pkg/util/remark" "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" "go.uber.org/zap" ) 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) go func() { t := time.NewTicker(30 * time.Second) defer t.Stop() for range t.C { ctx := context.Background() now := time.Now() l.Debug("定时开奖: 开始检查", zap.Time("now", now)) // 【独立检查】一番赏格位重置:每30秒检查所有售罄的一番赏期号 checkAndResetIchibanSlots(ctx, l, 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) l.Debug("定时开奖: 查询到活动", zap.Int("count", len(acts))) for _, a := range acts { l.Debug("定时开奖: 检查活动", zap.Int64("id", a.ID), zap.String("play_type", a.PlayType), zap.Int64("interval", a.IntervalMinutes), zap.Reflect("scheduled_time", a.ScheduledTime), zap.Reflect("last_settled", 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) l.Info("定时开奖: 跳过过期周期", zap.Int64("id", a.ID), zap.Int64("periods", periods), zap.Time("new_st", st)) } } 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 } } l.Debug("定时开奖: 计算开奖时间", zap.Int64("id", a.ID), zap.Time("st", st), zap.Time("now", now), zap.Bool("skip", 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),确保能找到最新订单 l.Debug("定时开奖: 查询订单范围", zap.Int64("id", aid), zap.Time("last", last), zap.Time("now", now)) 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)) l.Debug("定时开奖: 查询到订单", zap.Int64("id", aid), zap.Int64("count", count), zap.Int64("min", 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 := remark.Parse(o.Remark).IssueID 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() l.Debug("定时开奖-一番赏: 检查售罄", zap.Int64("issue_id", iss), zap.Int64("sold", soldSlots), zap.Int64("total", totalSlots)) if soldSlots < totalSlots { l.Info("定时开奖-一番赏: 未售罄,执行全额退款", zap.Int64("issue_id", 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 { // Check if the order has already been drawn (has logs) logCount, _ := r.ActivityDrawLogs.WithContext(ctx).Where( r.ActivityDrawLogs.OrderID.Eq(o.ID), r.ActivityDrawLogs.IssueID.Eq(iss), ).Count() if logCount > 0 { l.Info("定时开奖-一番赏: 订单已开奖,跳过退款", zap.Int64("order_id", o.ID), zap.Int64("issue_id", iss)) continue } refundOrder(ctx, l, o, "ichiban_not_sold_out", wc, r, w, us, a.RefundCouponID) } } else { // 【Fix】已售罄:处理所有未开奖的订单(包括旧时间窗口的订单) l.Info("定时开奖-一番赏: 已售罄,检查并处理所有未开奖订单", zap.Int64("issue_id", 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 { l.Error("定时开奖-一番赏: 查询未处理订单ID失败", zap.Int64("issue_id", iss), zap.Error(errClaims)) } l.Debug("定时开奖-一番赏: 查询到未处理订单", zap.Int64("issue_id", iss), zap.Int("count", len(claimOrderIDs)), zap.Reflect("order_ids", 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 { l.Error("定时开奖-一番赏: 获取订单详情失败", zap.Error(errOrders)) } l.Info("定时开奖-一番赏: 开始补录未处理订单", zap.Int64("issue_id", iss), zap.Int("count", len(unprocessedOrders))) for _, o := range unprocessedOrders { if err := activitySvc.ProcessOrderLottery(ctx, o.ID); err != nil { l.Error("定时开奖-一番赏-补录: ProcessOrderLottery 失败", zap.Int64("order_id", o.ID), zap.Error(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) l.Debug("定时开奖-一番赏: 剩余未处理订单记录", zap.Int64("issue_id", iss), zap.Int64("count", remainingUnprocessed)) if remainingUnprocessed == 0 { // 所有订单都已处理,执行重置 if err := repo.GetDbW().Exec("DELETE FROM issue_position_claims WHERE issue_id = ?", iss).Error; err != nil { l.Error("定时开奖-一番赏: 重置格位失败", zap.Int64("issue_id", iss), zap.Error(err)) } else { l.Info("定时开奖-一番赏: 格位已重置,新一轮可以开始", zap.Int64("issue_id", iss)) } } } } shouldRefund := false if a.PlayType != "ichiban" { if count < a.MinParticipants { shouldRefund = true } } if shouldRefund { l.Info("定时开奖: 人数不足,执行退款完毕", zap.Int64("id", aid)) for _, o := range orders { refundOrder(ctx, l, o, "scheduled_not_enough", wc, r, w, us, a.RefundCouponID) } } else { l.Info("定时开奖: 人数满足,开始开奖处理", zap.Int64("id", aid)) for _, o := range orders { iss := remark.Parse(o.Remark).IssueID if a.PlayType == "ichiban" && refundedIssues[iss] { l.Debug("定时开奖-一番赏: 订单已退款,跳过开奖", zap.Int64("order_id", o.ID), zap.Int64("issue_id", iss)) continue } if err := activitySvc.ProcessOrderLottery(ctx, o.ID); err != nil { l.Error("定时开奖: ProcessOrderLottery 失败", zap.Int64("order_id", o.ID), zap.Error(err)) } } } // 【修复】无论成功开奖还是退款,都更新活动的结算时间与下次计划 var nextVal sql.NullTime if a.IntervalMinutes > 0 { // 更新开奖时间戳 next := now.Add(time.Duration(a.IntervalMinutes) * time.Minute) nextVal = sql.NullTime{Time: next.UTC(), Valid: true} l.Info("定时开奖: 更新活动下次结算时间", zap.Int64("id", aid), zap.Time("last", now), zap.Time("next", next)) } else { // 如果没有间隔,则不设置下次计划时间 nextVal = sql.NullTime{Valid: false} l.Info("定时开奖: 活动无间隔,不设置下次计划时间", zap.Int64("id", aid), zap.Time("last", now)) } _ = 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 { l.Error("即时开奖补偿: ProcessOrderLottery 失败", zap.Int64("order_id", o2.ID), zap.Error(err)) } } } } } }() } // checkAndResetIchibanSlots 检查并重置所有售罄且已完成的一番赏期号 func checkAndResetIchibanSlots(ctx context.Context, l logger.CustomLogger, 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 { l.Error("一番赏重置检查: 查询失败", zap.Error(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 { l.Error("一番赏重置检查: 重置失败", zap.Int64("issue_id", iss.IssueID), zap.Error(err)) } else { l.Info("一番赏重置检查: 重置通过", zap.Int64("issue_id", iss.IssueID)) } } else { l.Debug("一番赏重置检查: 仍有未处理订单", zap.Int64("issue_id", iss.IssueID), zap.Int64("unprocessed", 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, l logger.CustomLogger, 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, }) } if err != nil { l.Error("Refund: WeChat refund failed", zap.String("order_no", o.OrderNo), zap.Error(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 { l.Error("Refund: Failed to restore coupon", zap.Int64("ucID", oc.UserCouponID), zap.String("order_no", o.OrderNo), zap.Error(err)) } else { l.Info("Refund: Restored coupon", zap.Int64("ucID", oc.UserCouponID), zap.String("order_no", o.OrderNo)) } } // 3.5. 一番赏退款:删除 issue_position_claims 记录,恢复格位 iss := remark.Parse(o.Remark).IssueID if iss > 0 { result, err := w.IssuePositionClaims.WithContext(ctx).Where( w.IssuePositionClaims.OrderID.Eq(o.ID), ).Delete() if err != nil { l.Error("Refund: Failed to delete position claims", zap.Int64("order_id", o.ID), zap.Error(err)) } else { l.Info("Refund: Restored slot positions", zap.Int64("order_id", o.ID), zap.Int64("rows", result.RowsAffected)) } } // 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 l.Info("Refund: Restored item card", zap.Int64("icID", icID), zap.String("order_no", 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 = remark.Parse(o.Remark).IssueID _ = 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) } }