bindbox-game/internal/api/activity/matching_game_app.go

856 lines
32 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 app
import (
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/model"
activitysvc "bindbox-game/internal/service/activity"
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sort"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// ========== API Handlers ==========
type matchingGamePreOrderRequest struct {
IssueID int64 `json:"issue_id"`
Position string `json:"position"`
CouponID *int64 `json:"coupon_id"`
ItemCardID *int64 `json:"item_card_id"`
UseGamePass bool `json:"use_game_pass"` // 新增:是否使用次数卡
}
type matchingGamePreOrderResponse struct {
GameID string `json:"game_id"`
OrderNo string `json:"order_no"`
PayStatus int32 `json:"pay_status"` // 1=Pending, 2=Paid
ServerSeedHash string `json:"server_seed_hash"`
// AllCards 已移除:游戏数据需通过 GetMatchingGameCards 接口在支付成功后获取
}
type matchingGameCheckRequest struct {
GameID string `json:"game_id" binding:"required"`
TotalPairs int64 `json:"total_pairs"` // 客户端上报的消除总对数
}
type MatchingRewardInfo struct {
RewardID int64 `json:"reward_id"`
Name string `json:"name"`
ProductName string `json:"product_name"` // 商品原始名称
ProductImage string `json:"product_image"` // 商品图片
Level int32 `json:"level"`
}
type matchingGameCheckResponse struct {
GameID string `json:"game_id"`
TotalPairs int64 `json:"total_pairs"`
Finished bool `json:"finished"`
Reward *MatchingRewardInfo `json:"reward,omitempty"`
}
// PreOrderMatchingGame 下单并预生成对对碰游戏数据
// @Summary 下单并获取对对碰全量数据
// @Description 用户下单服务器扣费并返回全量99张乱序卡牌前端自行负责游戏流程
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body matchingGamePreOrderRequest true "请求参数"
// @Success 200 {object} matchingGamePreOrderResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/preorder [post]
func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
// 启动清理协程(Lazy Init)
h.startMatchingGameCleanup()
return func(ctx core.Context) {
userID := int64(ctx.SessionUserInfo().Id)
req := new(matchingGamePreOrderRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 1. Get Activity/Issue Info (Mocking price for now or fetching if available)
// Assuming price is fixed or fetched. Let's fetch basic activity info if possible, or assume config.
// Since Request has IssueID, let's fetch Issue to get ActivityID and Price.
// Note: The current handler doesn't have easy access to Issue struct helper without exporting or duplicating.
// We will assume `req.IssueID` is valid and fetch price via `h.activity.GetActivity` if we had ActivityID.
// But req only has IssueID. Let's look up Issue first.
issue, err := h.writeDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.writeDB.ActivityIssues.ID.Eq(req.IssueID)).First()
if err != nil || issue == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "issue not found"))
return
}
activity, err := h.activity.GetActivity(ctx.RequestContext(), issue.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "activity not found"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
return
}
// Validation
if !activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170009, "本活动不支持优惠券"))
return
}
if !activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170010, "本活动不支持道具卡"))
return
}
var order *model.Orders
// ⭐ 次数卡支付分支
if req.UseGamePass {
// 查询用户可用的次数卡(全局或该活动的)
now := time.Now()
gamePasses, err := h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
Where(h.writeDB.UserGamePasses.UserID.Eq(userID)).
Where(h.writeDB.UserGamePasses.Remaining.Gt(0)).
Where(h.writeDB.UserGamePasses.ActivityID.In(0, issue.ActivityID)).
Order(h.writeDB.UserGamePasses.ActivityID.Desc()). // 优先使用活动限定的
Find()
if err != nil || len(gamePasses) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "无可用的次数卡"))
return
}
// 找到第一个未过期的次数卡
var validPass *model.UserGamePasses
for _, p := range gamePasses {
if p.ExpiredAt.IsZero() || p.ExpiredAt.After(now) {
validPass = p
break
}
}
if validPass == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "次数卡已过期"))
return
}
// 扣减次数
result, err := h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
Where(h.writeDB.UserGamePasses.ID.Eq(validPass.ID)).
Where(h.writeDB.UserGamePasses.Remaining.Gt(0)).
Updates(map[string]any{
"remaining": validPass.Remaining - 1,
"total_used": validPass.TotalUsed + 1,
})
if err != nil || result.RowsAffected == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "次数卡扣减失败"))
return
}
// 直接创建“已支付”订单
orderNo := now.Format("20060102150405") + fmt.Sprintf("%04d", now.UnixNano()%10000)
newOrder := &model.Orders{
UserID: userID,
OrderNo: "GP" + orderNo,
SourceType: 3, // 对对碰
TotalAmount: activity.PriceDraw,
ActualAmount: 0, // 次数卡抵扣实付0元
DiscountAmount: activity.PriceDraw,
Status: 2, // 已支付
Remark: fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID),
CreatedAt: now,
UpdatedAt: now,
PaidAt: now,
}
if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).
Omit(h.writeDB.Orders.CancelledAt).
Create(newOrder); err != nil {
// 回滚次数卡
h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
Where(h.writeDB.UserGamePasses.ID.Eq(validPass.ID)).
Updates(map[string]any{
"remaining": validPass.Remaining,
"total_used": validPass.TotalUsed,
})
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
return
}
order = newOrder
// 次数卡 0 元订单手动触发任务中心
go func() {
_ = h.task.OnOrderPaid(context.Background(), userID, order.ID)
}()
} else {
// 原有支付流程
var couponID *int64
if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
couponID = req.CouponID
}
var itemCardID *int64
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
itemCardID = req.ItemCardID
}
orderResult, err := h.activityOrder.CreateActivityOrder(ctx, activitysvc.CreateActivityOrderRequest{
UserID: userID,
ActivityID: issue.ActivityID,
IssueID: req.IssueID,
Count: 1,
UnitPrice: activity.PriceDraw,
SourceType: 3, // 对对碰
CouponID: couponID,
ItemCardID: itemCardID,
ExtraRemark: fmt.Sprintf("matching_game:issue:%d", req.IssueID),
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
return
}
order = orderResult.Order
}
// 2. 加载配置
configs, err := h.activity.ListMatchingCardTypes(ctx.RequestContext())
if err != nil || len(configs) == 0 {
configs = []activitysvc.CardTypeConfig{
{Code: "A", Name: "类型A", Quantity: 9},
{Code: "B", Name: "类型B", Quantity: 9},
{Code: "C", Name: "类型C", Quantity: 9},
{Code: "D", Name: "类型D", Quantity: 9},
{Code: "E", Name: "类型E", Quantity: 9},
{Code: "F", Name: "类型F", Quantity: 9},
{Code: "G", Name: "类型G", Quantity: 9},
{Code: "H", Name: "类型H", Quantity: 9},
{Code: "I", Name: "类型I", Quantity: 9},
{Code: "J", Name: "类型J", Quantity: 9},
{Code: "K", Name: "类型K", Quantity: 9},
}
}
// 3. 创建游戏并洗牌
// 使用 Activity Commitment 作为随机源(必须存在)
if len(activity.CommitmentSeedMaster) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170011, "活动尚未生成承诺,无法开始游戏"))
return
}
// 🔍 【关键修复】对配置进行强制排序,保证洗牌前的初始数组顺序绝对固定
sort.Slice(configs, func(i, j int) bool {
return configs[i].Code < configs[j].Code
})
game := activitysvc.NewMatchingGameWithConfig(configs, req.Position, activity.CommitmentSeedMaster)
if game == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170011, "活动尚未生成承诺,无法开始游戏"))
return
}
game.ActivityID = issue.ActivityID
game.IssueID = req.IssueID
game.OrderID = order.ID
game.UserID = userID
game.Position = req.Position // 保存用户选择的类型,用于服务端验证
game.CreatedAt = time.Now() // 设置游戏创建时间,用于自动开奖超时判断
// 4. 构造 AllCards (仅需返回 Flat List)
// game.deck 包含了所有的牌(已洗好,且包含了 board[0..8] 因为 NewMatchingGameWithConfig 中我们是从 deck 发到 board 的)
// 但 NewMatchingGameWithConfig 目前的逻辑是:生成 -> 洗牌 -> 发前9张到 board -> deck只剩剩下的。
// 所以我们需要把 board 和 deck 拼起来。
allCards := make([]activitysvc.MatchingCard, 0, 99)
for _, c := range game.Board {
if c != nil {
allCards = append(allCards, *c)
}
}
for _, c := range game.Deck {
allCards = append(allCards, *c)
}
// 5. 生成GameID并存储 (主要用于 Check 时校验存在性,或者验签)
gameID := fmt.Sprintf("MG%d%d", userID, time.Now().UnixNano())
// Save to Redis
if err := h.activity.SaveMatchingGameToRedis(ctx.RequestContext(), gameID, game); err != nil {
h.logger.Error("Failed to save matching game session", zap.Error(err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "failed to create game session"))
return
}
// 6. Save Verification Data (ActivityDrawLogs + ActivityDrawReceipts)
// This is required for the "Verification" feature in App/Admin to work.
// A "Matching Game" session is treated as one "Draw".
// 6.1 Create DrawLog
drawLog := &model.ActivityDrawLogs{
UserID: userID,
IssueID: req.IssueID,
OrderID: order.ID,
CreatedAt: time.Now(),
IsWinner: 0, // Will be updated if they win prizes at `Check`? Or just 0 for participation.
Level: 0,
}
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog)
// 6.2 Create DrawReceipt
if drawLog.ID > 0 {
receipt := &model.ActivityDrawReceipts{
CreatedAt: time.Now(),
DrawLogID: drawLog.ID,
AlgoVersion: "HMAC-SHA256-v1",
RoundID: req.IssueID,
DrawID: time.Now().UnixNano(), // Use timestamp to ensure uniqueness as we don't have real DrawID
ClientID: userID,
Timestamp: time.Now().UnixMilli(),
ServerSeedHash: game.ServerSeedHash,
ServerSubSeed: "", // Matching game generic seed
ClientSeed: req.Position, // Use Position as ClientSeed
Nonce: 0,
ItemsRoot: "", // Could enable if we hashed the deck
WeightsTotal: 0,
SelectedIndex: 0,
RandProof: "",
Signature: "",
}
// Hex encode server seed
receipt.ServerSubSeed = hex.EncodeToString(game.ServerSeed)
_ = h.writeDB.ActivityDrawReceipts.WithContext(ctx.RequestContext()).Create(receipt)
}
// 7. 返回数据(不返回 all_cards需支付成功后通过 GetMatchingGameCards 获取)
rsp := &matchingGamePreOrderResponse{
GameID: gameID,
OrderNo: order.OrderNo,
PayStatus: order.Status,
ServerSeedHash: game.ServerSeedHash,
}
ctx.Payload(rsp)
}
}
// CheckMatchingGame 游戏结束结算校验
// @Summary 游戏结束结算校验
// @Description 前端游戏结束后上报结果,服务器发放奖励
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body matchingGameCheckRequest true "请求参数"
// @Success 200 {object} matchingGameCheckResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/check [post]
func (h *handler) CheckMatchingGame() core.HandlerFunc {
return func(ctx core.Context) {
req := new(matchingGameCheckRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
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 {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "redis error"))
return
}
if !locked {
ctx.AbortWithError(core.Error(http.StatusConflict, 170005, "结算处理中,请勿重复提交"))
return
}
defer h.redis.Del(ctx.RequestContext(), lockKey)
game, err := h.activity.GetMatchingGameFromRedis(ctx.RequestContext(), req.GameID)
if err != nil {
if err == redis.Nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found or expired"))
} else {
h.logger.Error("Failed to load matching game session", zap.Error(err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "internal server error"))
}
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, "订单不存在"))
return
}
if order.Status != 2 {
h.logger.Debug("对对碰Check: 订单支付确认中",
zap.Int64("order_id", order.ID),
zap.Int32("status", order.Status))
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170004, "支付确认中,请稍后重试"))
return
}
// 检查活动状态
activity, err := h.activity.GetActivity(ctx.RequestContext(), game.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "活动不存在"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
return
}
// 【核心安全校验】使用服务端模拟计算实际对数,不信任客户端提交的值
serverSimulatedPairs := game.SimulateMaxPairs()
h.logger.Debug("对对碰Check: 服务端模拟验证",
zap.Int64("client_pairs", req.TotalPairs),
zap.Int64("server_simulated", serverSimulatedPairs),
zap.Int64("max_possible", game.MaxPossiblePairs),
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),
zap.Int64("server_simulated", serverSimulatedPairs),
zap.String("game_id", req.GameID))
}
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 // 找到精确匹配,直接使用
}
}
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 {
cardToVoid = icID
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// Double reward
h.logger.Info("道具卡-CheckMatchingGame: 应用双倍奖励", zap.Int32("multiplier", ic.RewardMultiplierX1000))
finalQuantity = 2
finalRemark += "(倍数)"
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// Probability boost - try to upgrade to better reward
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 {
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 {
h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err))
} else {
prodImage := ""
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
productName = p.Name
prodImage = getFirstImage(p.ImagesJSON)
}
rewardInfo = &MatchingRewardInfo{
RewardID: finalReward.ID,
Name: productName,
ProductName: 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,
})
}
}
}
}
rsp := &matchingGameCheckResponse{
GameID: req.GameID,
TotalPairs: req.TotalPairs,
Finished: true,
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
}
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
u, _ := h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First()
payerOpenid := ""
if u != nil {
payerOpenid = u.Openid
}
// 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))
} else {
h.logger.Info("CheckMatchingGame: Virtual shipping uploaded", zap.String("order_no", orderNo), zap.String("items", itemsDesc))
}
}(game.OrderID, order.OrderNo, game.UserID, rewardName)
// 结算完成,清理会话 (Delete from Redis)
_ = h.redis.Del(ctx.RequestContext(), activitysvc.MatchingGameKeyPrefix+req.GameID)
ctx.Payload(rsp)
}
}
// GetMatchingGameState 获取对对碰游戏状态
// @Summary 获取对对碰游戏状态
// @Description 获取当前游戏的完整状态
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param game_id query string true "游戏ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/state [get]
func (h *handler) GetMatchingGameState() core.HandlerFunc {
return func(ctx core.Context) {
gameID := ctx.RequestInputParams().Get("game_id")
if gameID == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game_id required"))
return
}
game, err := h.activity.GetMatchingGameFromRedis(ctx.RequestContext(), gameID)
if err != nil {
if err == redis.Nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found"))
} else {
h.logger.Error("Failed to load matching game", zap.Error(err))
}
return
}
// Keep-Alive: Refresh Redis TTL
h.redis.Expire(ctx.RequestContext(), activitysvc.MatchingGameKeyPrefix+gameID, 30*time.Minute)
// 检查活动状态
activity, err := h.activity.GetActivity(ctx.RequestContext(), game.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "活动不存在"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
return
}
ctx.Payload(game.GetGameState())
}
}
// ListMatchingCardTypes 列出对对碰卡牌类型App端枚举
// @Summary 列出对对碰卡牌类型
// @Description 获取所有启用的卡牌类型配置用于App端预览或动画展示
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Success 200 {array} activitysvc.CardTypeConfig
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/card_types [get]
func (h *handler) ListMatchingCardTypes() core.HandlerFunc {
return func(ctx core.Context) {
configs, err := h.activity.ListMatchingCardTypes(ctx.RequestContext())
if err != nil {
// Try to serve default configs if DB fails? Or just error safely.
// Let's rely on DB being available.
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ParamBindError, err.Error()))
return
}
ctx.Payload(configs)
}
}
// matchingGameCardsResponse 游戏数据响应
type matchingGameCardsResponse struct {
GameID string `json:"game_id"`
AllCards []activitysvc.MatchingCard `json:"all_cards"`
}
// GetMatchingGameCards 支付成功后获取游戏数据
// @Summary 获取对对碰游戏数据
// @Description 只有支付成功后才能获取游戏牌组数据,防止未支付用户获取牌组信息
// @Tags APP端.活动
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param game_id query string true "游戏ID"
// @Success 200 {object} matchingGameCardsResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/matching/cards [get]
func (h *handler) GetMatchingGameCards() core.HandlerFunc {
return func(ctx core.Context) {
gameID := ctx.RequestInputParams().Get("game_id")
if gameID == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game_id required"))
return
}
// 1. 从 Redis 加载游戏数据
game, err := h.activity.GetMatchingGameFromRedis(ctx.RequestContext(), gameID)
if err != nil {
if err == redis.Nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found or expired"))
} else {
h.logger.Error("Failed to load matching game", zap.Error(err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "internal server error"))
}
return
}
// 2. 【关键校验】检查订单是否已支付
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, "订单不存在"))
return
}
if order.Status != 2 {
h.logger.Warn("GetMatchingGameCards: 订单未支付", zap.Int64("order_id", order.ID), zap.Int32("status", order.Status))
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170004, "请先完成支付"))
return
}
// 3. 检查活动状态
activity, err := h.activity.GetActivity(ctx.RequestContext(), game.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "活动不存在"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
return
}
// 4. Keep-Alive: Refresh Redis TTL
h.redis.Expire(ctx.RequestContext(), activitysvc.MatchingGameKeyPrefix+gameID, 30*time.Minute)
// 5. 构造并返回全量卡牌数据
allCards := make([]activitysvc.MatchingCard, 0, 99)
for _, c := range game.Board {
if c != nil {
allCards = append(allCards, *c)
}
}
for _, c := range game.Deck {
allCards = append(allCards, *c)
}
ctx.Payload(&matchingGameCardsResponse{
GameID: gameID,
AllCards: allCards,
})
}
}
func getFirstImage(imagesJSON string) string {
if imagesJSON == "" || imagesJSON == "[]" {
return ""
}
// 简单解析,假设是 ["url1", "url2"] 格式
var images []string
if err := json.Unmarshal([]byte(imagesJSON), &images); err == nil && len(images) > 0 {
return images[0]
}
return ""
}