499 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 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)
}
}