856 lines
32 KiB
Go
856 lines
32 KiB
Go
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 ""
|
||
}
|