fix(activity): 修复对对碰按配置发放复数奖品

修复对对碰开奖固定发放单个奖品的问题,改为按奖励配置数量发放,并统一手动结算与自动开奖逻辑。
This commit is contained in:
Zuncle 2026-05-02 03:04:19 +08:00
parent 79f2c2236f
commit cb5061f1da
2 changed files with 209 additions and 228 deletions

View File

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

View File

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