diff --git a/internal/api/activity/matching_game_app.go b/internal/api/activity/matching_game_app.go index dfda6a5..5f4f4b1 100755 --- a/internal/api/activity/matching_game_app.go +++ b/internal/api/activity/matching_game_app.go @@ -2,8 +2,6 @@ package app import ( "context" - "crypto/rand" - "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -372,7 +370,6 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { return } - // 1. Concurrency Lock: Prevent multiple check requests for the same game lockKey := fmt.Sprintf("lock:matching_game:check:%s", req.GameID) locked, err := h.redis.SetNX(ctx.RequestContext(), lockKey, "1", 10*time.Second).Result() if err != nil { @@ -396,9 +393,6 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { return } - // 校验:不能超过理论最大对数 - // 【关键校验】检查订单是否已支付 - // 对对碰游戏必须先支付才能结算和发奖 order, err := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First() if err != nil || order == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170003, "订单不存在")) @@ -412,7 +406,6 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { return } - // 检查活动状态 activity, err := h.activity.GetActivity(ctx.RequestContext(), game.ActivityID) if err != nil || activity == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "活动不存在")) @@ -423,7 +416,6 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { return } - // 【核心安全校验】使用服务端模拟计算实际对数,不信任客户端提交的值 serverSimulatedPairs := game.SimulateMaxPairs() h.logger.Debug("对对碰Check: 服务端模拟验证", zap.Int64("client_pairs", req.TotalPairs), @@ -432,11 +424,7 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { zap.String("position", game.Position), zap.String("game_id", req.GameID)) - // 使用服务端模拟的对数,而非客户端提交的值 - // 这样即使客户端伪造数据也无法作弊 actualPairs := serverSimulatedPairs - - // 如果客户端提交的值与服务端模拟不一致,记录警告日志(可能是作弊尝试) if req.TotalPairs != serverSimulatedPairs { h.logger.Warn("对对碰Check: 客户端提交数值与服务端模拟不一致", zap.Int64("client_pairs", req.TotalPairs), @@ -444,220 +432,38 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { zap.String("game_id", req.GameID)) } - game.TotalPairs = actualPairs // 使用服务端验证后的值 + game.TotalPairs = actualPairs var rewardInfo *MatchingRewardInfo - // 【幂等性检查】在发奖前检查该订单是否已经获得过奖励 - existingLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where( - h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID), - h.readDB.ActivityDrawLogs.IsWinner.Eq(1), - ).First() - if existingLog != nil { - h.logger.Warn("对对碰Check: 订单已获得过奖励,拒绝重复发放", - zap.Int64("order_id", game.OrderID), - zap.Int64("existing_log_id", existingLog.ID)) - // 返回已有的奖励信息而不是重复发放 - if existingLog.RewardID > 0 { - rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where( - h.readDB.ActivityRewardSettings.ID.Eq(existingLog.RewardID)).First() - if rw != nil { - prodName := "" - prodImage := "" - if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(rw.ProductID)).First(); p != nil { - prodName = p.Name - prodImage = getFirstImage(p.ImagesJSON) - } - rewardInfo = &MatchingRewardInfo{ - RewardID: rw.ID, - Name: prodName, - ProductName: prodName, - ProductImage: prodImage, - Level: rw.Level, - } - } - } - ctx.Payload(&matchingGameCheckResponse{ - GameID: req.GameID, - TotalPairs: req.TotalPairs, - Finished: true, - Reward: rewardInfo, - }) - return - } - - // 1. Fetch Rewards rewards, err := h.activity.ListIssueRewards(ctx.RequestContext(), game.IssueID) - if err == nil && len(rewards) > 0 { - // 2. Filter & Sort var candidate *model.ActivityRewardSettings for _, r := range rewards { if r.Quantity <= 0 { continue } - // 精确匹配:服务端验证的对子数 == 奖品设置的对子数 if actualPairs == r.MinScore { candidate = r - break // 找到精确匹配,直接使用 + break } } if candidate != nil { - // 3. Prepare Grant Params - // Fetch real product name for remark - productName := "" - if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil { - productName = p.Name - } - - finalReward := candidate - finalQuantity := 1 - finalRemark := fmt.Sprintf("%s %s", order.OrderNo, productName) - var cardToVoid int64 = 0 - - // 4. Apply Item Card Effects (Determine final reward and quantity) - icID := parseItemCardIDFromRemark(order.Remark) - h.logger.Debug("CheckMatchingGame: 道具卡检查", - zap.String("order_no", order.OrderNo), - zap.String("remark", order.Remark), - zap.Int64("icID", icID)) - - if icID > 0 { - uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where( - h.readDB.UserItemCards.ID.Eq(icID), - h.readDB.UserItemCards.UserID.Eq(game.UserID), - ).First() - if uic == nil { - h.logger.Warn("CheckMatchingGame: 用户道具卡未找到", zap.Int64("icID", icID), zap.Int64("user_id", game.UserID)) - } else if uic.Status != 1 { - h.logger.Warn("CheckMatchingGame: 用户道具卡状态无效", zap.Int32("status", uic.Status)) - } else { // Status == 1 - ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where( - h.readDB.SystemItemCards.ID.Eq(uic.CardID), - h.readDB.SystemItemCards.Status.Eq(1), - ).First() - now := time.Now() - if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) { - scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID) - h.logger.Debug("道具卡-CheckMatchingGame: 范围检查", - zap.Int32("scope_type", ic.ScopeType), - zap.Int64("activity_id", game.ActivityID), - zap.Int64("issue_id", game.IssueID), - zap.Bool("is_ok", scopeOK)) - - if scopeOK { - // Fix: Don't set cardToVoid immediately. Only set it if an effect is actually applied. - - // Double reward - if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { - cardToVoid = icID // Mark for consumption - h.logger.Info("道具卡-CheckMatchingGame: 应用双倍奖励", zap.Int32("multiplier", ic.RewardMultiplierX1000)) - finalQuantity = 2 - finalRemark += "(倍数)" - } else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { - // Probability boost - cardToVoid = icID // Mark for consumption (even if RNG fails, the card is "used") - - h.logger.Debug("道具卡-CheckMatchingGame: 应用概率提升", zap.Int32("boost_rate", ic.BoostRateX1000)) - allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where( - h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID), - ).Find() - var better *model.ActivityRewardSettings - for _, r := range allRewards { - if r.MinScore > candidate.MinScore && r.Quantity > 0 { - if better == nil || r.MinScore < better.MinScore { - better = r - } - } - } - if better != nil { - // Use crypto/rand for secure random - randBytes := make([]byte, 4) - rand.Read(randBytes) - randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000) - h.logger.Debug("道具卡-CheckMatchingGame: 概率检定", - zap.Int32("rand", randVal), - zap.Int32("threshold", ic.BoostRateX1000)) - - if randVal < ic.BoostRateX1000 { - // 获取升级后的商品名称 - betterProdName := "" - if better.ProductID > 0 { - if bp, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(better.ProductID)).First(); bp != nil { - betterProdName = bp.Name - } - } - h.logger.Info("道具卡-CheckMatchingGame: 概率提升成功", - zap.Int64("new_reward_id", better.ID), - zap.String("product_name", betterProdName)) - finalReward = better - finalRemark = betterProdName + "(升级)" - } else { - h.logger.Debug("道具卡-CheckMatchingGame: 概率提升失败") - } - } else { - h.logger.Debug("道具卡-CheckMatchingGame: 未找到更好的奖品可升级", zap.Int64("current_score", candidate.MinScore)) - } - } else { - // Effect not recognized or params too low - h.logger.Warn("道具卡-CheckMatchingGame: 效果类型未知或参数无效,不消耗卡片", - zap.Int32("effect_type", ic.EffectType), - zap.Int32("multiplier", ic.RewardMultiplierX1000)) - } - } else { - h.logger.Debug("道具卡-CheckMatchingGame: 范围校验失败") - } - } else { - h.logger.Debug("道具卡-CheckMatchingGame: 时间或系统卡状态无效", - zap.Bool("has_ic", ic != nil), - zap.Time("start", uic.ValidStart), - zap.Time("end", uic.ValidEnd), - zap.Time("now", now)) - } - } - } - - // 5. Grant Reward - if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, finalReward, finalQuantity, finalRemark); err != nil { + plan, err := h.settleMatchingReward(ctx.RequestContext(), game, order, candidate, false) + if err != nil { h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err)) - } else { + } else if plan != nil { prodImage := "" - if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil { - productName = p.Name + if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(plan.reward.ProductID)).First(); p != nil { prodImage = getFirstImage(p.ImagesJSON) } rewardInfo = &MatchingRewardInfo{ - RewardID: finalReward.ID, - Name: productName, - ProductName: productName, + RewardID: plan.reward.ID, + Name: plan.rewardName, + ProductName: plan.productName, ProductImage: prodImage, - Level: finalReward.Level, - } - - // 6. Void Item Card (if used) - if cardToVoid > 0 { - h.logger.Info("道具卡-CheckMatchingGame: 核销道具卡", zap.Int64("uic_id", cardToVoid)) - now := time.Now() - // Get DrawLog ID for the order - drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where( - h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID), - ).First() - var drawLogID int64 - if drawLog != nil { - drawLogID = drawLog.ID - } - _, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where( - h.writeDB.UserItemCards.ID.Eq(cardToVoid), - h.writeDB.UserItemCards.UserID.Eq(game.UserID), - h.writeDB.UserItemCards.Status.Eq(1), - ).Updates(map[string]any{ - h.writeDB.UserItemCards.Status.ColumnName().String(): 2, - h.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): drawLogID, - h.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): game.ActivityID, - h.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): game.IssueID, - h.writeDB.UserItemCards.UsedAt.ColumnName().String(): now, - }) + Level: plan.reward.Level, } } } @@ -670,8 +476,6 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { Reward: rewardInfo, } - // 7. Virtual Shipping (Async) - // Upload shipping info to WeChat (similar to Ichiban Kuji) so user can see "Shipped" status and reward info. rewardName := "无奖励" if rewardInfo != nil { rewardName = rewardInfo.Name @@ -679,14 +483,12 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { go func(orderID int64, orderNo string, userID int64, rName string) { bgCtx := context.Background() - // 1. Get Payment Transaction tx, _ := h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First() if tx == nil || tx.TransactionID == "" { h.logger.Warn("CheckMatchingGame: No payment transaction found for shipping", zap.String("order_no", orderNo)) return } - // 2. Get User OpenID (Prioritize PayerOpenid from transaction) payerOpenid := tx.PayerOpenid if payerOpenid == "" { u, _ := h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First() @@ -695,13 +497,11 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { } } - // 3. Construct Item Desc itemsDesc := fmt.Sprintf("对对碰 %s 赏品: %s", orderNo, rName) if len(itemsDesc) > 120 { itemsDesc = itemsDesc[:120] } - // 4. Upload c := configs.Get() if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil { h.logger.Error("CheckMatchingGame: Failed to upload virtual shipping", zap.Error(err)) @@ -710,9 +510,7 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc { } }(game.OrderID, order.OrderNo, game.UserID, rewardName) - // 结算完成,清理会话 (Delete from Redis) _ = h.redis.Del(ctx.RequestContext(), activitysvc.MatchingGameKeyPrefix+req.GameID) - ctx.Payload(rsp) } } diff --git a/internal/api/activity/matching_game_helper.go b/internal/api/activity/matching_game_helper.go index 2c01375..652ad22 100755 --- a/internal/api/activity/matching_game_helper.go +++ b/internal/api/activity/matching_game_helper.go @@ -2,6 +2,8 @@ package app import ( "context" + "crypto/rand" + "encoding/binary" "fmt" "strings" "sync" @@ -16,6 +18,36 @@ import ( "go.uber.org/zap" ) +func matchingRewardQuantity(reward *model.ActivityRewardSettings, isDoubled bool) int { + if reward == nil { + return 0 + } + quantity := int(reward.DropQuantity) + if quantity < 1 { + quantity = 1 + } + if isDoubled { + quantity *= 2 + } + return quantity +} + +func matchingRewardDisplayName(name string, isDoubled bool) string { + if !isDoubled { + return name + } + return name + "(翻倍)" +} + +type matchingSettlementPlan struct { + reward *model.ActivityRewardSettings + quantity int + productName string + rewardName string + cardToVoid int64 + drawLogID int64 +} + // grantRewardHelper 发放奖励辅助函数 func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64, r *model.ActivityRewardSettings, quantity int, remark string) error { // 1. Grant to Order (Delegating stock check to user service) @@ -51,6 +83,162 @@ func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64, return err } +func (h *handler) buildMatchingSettlementPlan(ctx context.Context, game *activitysvc.MatchingGame, order *model.Orders, candidate *model.ActivityRewardSettings) *matchingSettlementPlan { + if candidate == nil { + return nil + } + + productName := "" + if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil { + productName = p.Name + } + + plan := &matchingSettlementPlan{ + reward: candidate, + quantity: matchingRewardQuantity(candidate, false), + productName: productName, + rewardName: matchingRewardDisplayName(productName, false), + } + + icID := parseItemCardIDFromRemark(order.Remark) + h.logger.Debug("MatchingSettlement: 道具卡检查", + zap.String("order_no", order.OrderNo), + zap.String("remark", order.Remark), + zap.Int64("icID", icID)) + + if icID <= 0 { + return plan + } + + uic, _ := h.readDB.UserItemCards.WithContext(ctx).Where( + h.readDB.UserItemCards.ID.Eq(icID), + h.readDB.UserItemCards.UserID.Eq(game.UserID), + ).First() + if uic == nil || uic.Status != 1 { + return plan + } + + ic, _ := h.readDB.SystemItemCards.WithContext(ctx).Where( + h.readDB.SystemItemCards.ID.Eq(uic.CardID), + h.readDB.SystemItemCards.Status.Eq(1), + ).First() + now := time.Now() + if ic == nil || uic.ValidStart.After(now) || uic.ValidEnd.Before(now) { + return plan + } + + scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID) + if !scopeOK { + return plan + } + + if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { + plan.cardToVoid = icID + plan.quantity = matchingRewardQuantity(plan.reward, true) + plan.rewardName = matchingRewardDisplayName(plan.productName, true) + return plan + } + + if ic.EffectType != 2 || ic.BoostRateX1000 <= 0 { + return plan + } + + plan.cardToVoid = icID + allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx).Where( + h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID), + ).Find() + var better *model.ActivityRewardSettings + for _, r := range allRewards { + if r.MinScore > candidate.MinScore && r.Quantity > 0 { + if better == nil || r.MinScore < better.MinScore { + better = r + } + } + } + if better == nil { + return plan + } + + randBytes := make([]byte, 4) + rand.Read(randBytes) + randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000) + if randVal >= ic.BoostRateX1000 { + return plan + } + + betterProdName := "" + if better.ProductID > 0 { + if bp, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(better.ProductID)).First(); bp != nil { + betterProdName = bp.Name + } + } + plan.reward = better + plan.productName = betterProdName + plan.rewardName = matchingRewardDisplayName(betterProdName, false) + "(升级)" + plan.quantity = matchingRewardQuantity(better, false) + return plan +} + +func (h *handler) settleMatchingReward(ctx context.Context, game *activitysvc.MatchingGame, order *model.Orders, candidate *model.ActivityRewardSettings, auto bool) (*matchingSettlementPlan, error) { + plan := h.buildMatchingSettlementPlan(ctx, game, order, candidate) + if plan == nil || plan.reward == nil || plan.quantity <= 0 { + return nil, nil + } + + drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx).Where( + h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID), + ).First() + if drawLog != nil { + plan.drawLogID = drawLog.ID + } + + var existingCount int64 + query := h.readDB.UserInventory.WithContext(ctx).Where( + h.readDB.UserInventory.UserID.Eq(game.UserID), + h.readDB.UserInventory.OrderID.Eq(game.OrderID), + h.readDB.UserInventory.RewardID.Eq(plan.reward.ID), + ) + if auto { + query = query.Where(h.readDB.UserInventory.Remark.Like(order.OrderNo + "%")) + } + existingCount, _ = query.Count() + missingCount := int64(plan.quantity) - existingCount + if missingCount < 0 { + missingCount = 0 + } + + if missingCount > 0 { + if err := h.grantRewardHelper(ctx, game.UserID, game.OrderID, plan.reward, int(missingCount), fmt.Sprintf("%s %s", order.OrderNo, plan.rewardName)); err != nil { + return nil, err + } + } else if plan.drawLogID > 0 { + _, _ = h.writeDB.ActivityDrawLogs.WithContext(ctx).Where( + h.writeDB.ActivityDrawLogs.ID.Eq(plan.drawLogID), + ).Updates(&model.ActivityDrawLogs{ + IsWinner: 1, + RewardID: plan.reward.ID, + Level: plan.reward.Level, + }) + } + + if plan.cardToVoid > 0 && plan.drawLogID > 0 { + now := time.Now() + _, _ = h.writeDB.UserItemCards.WithContext(ctx).Where( + h.writeDB.UserItemCards.ID.Eq(plan.cardToVoid), + h.writeDB.UserItemCards.UserID.Eq(game.UserID), + h.writeDB.UserItemCards.Status.Eq(1), + ).Updates(map[string]any{ + h.writeDB.UserItemCards.Status.ColumnName().String(): 2, + h.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): plan.drawLogID, + h.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): game.ActivityID, + h.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): game.IssueID, + h.writeDB.UserItemCards.UsedAt.ColumnName().String(): now, + }) + } + + return plan, nil +} + var matchingCleanupOnce sync.Once func (h *handler) startMatchingGameCleanup() { @@ -240,33 +428,28 @@ func (h *handler) doAutoCheck(ctx context.Context, gameID string, game *activity } if candidate != nil { - productName := "" - if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil { - productName = p.Name - } - - finalRemark := fmt.Sprintf("%s %s (自动开奖)", order.OrderNo, productName) - - if err := h.grantRewardHelper(ctx, game.UserID, game.OrderID, candidate, 1, finalRemark); err != nil { + plan, err := h.settleMatchingReward(ctx, game, order, candidate, true) + if err != nil { h.logger.Error("对对碰自动开奖: 发放奖励失败", zap.Int64("order_id", game.OrderID), zap.Error(err)) - } else { + } else if plan != nil { prodImage := "" - if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil { + if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(plan.reward.ProductID)).First(); p != nil { prodImage = getFirstImage(p.ImagesJSON) } rewardInfo = &MatchingRewardInfo{ - RewardID: candidate.ID, - Name: productName, - ProductName: productName, + RewardID: plan.reward.ID, + Name: plan.rewardName, + ProductName: plan.productName, ProductImage: prodImage, - Level: candidate.Level, + Level: plan.reward.Level, } h.logger.Info("对对碰自动开奖: 奖励发放成功", zap.Int64("order_id", game.OrderID), - zap.String("product_name", productName), - zap.Int32("level", candidate.Level)) + zap.String("product_name", plan.productName), + zap.Int32("level", plan.reward.Level), + zap.Int("quantity", plan.quantity)) } } }