feat: 商店商品展示新增所需积分,抽奖策略强制使用活动承诺种子,并新增用户过期任务和游戏令牌服务
Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 15s
Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 15s
This commit is contained in:
parent
c9a83a232a
commit
04791789c9
@ -22,3 +22,7 @@ docker build -t zfc931912343/bindbox-game:v1.10 .
|
||||
docker push zfc931912343/bindbox-game:v1.10
|
||||
|
||||
docker pull zfc931912343/bindbox-game:v1.10 &&docker rm -f bindbox-game && docker run -d --name bindbox-game -p 9991:9991 zfc931912343/bindbox-game:v1.10
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
build/.DS_Store
vendored
BIN
build/.DS_Store
vendored
Binary file not shown.
BIN
build/resources/.DS_Store
vendored
BIN
build/resources/.DS_Store
vendored
Binary file not shown.
@ -1 +0,0 @@
|
||||
.search-card[data-v-82eaff85]{margin-bottom:16px}[data-v-82eaff85] .el-card__body{padding-bottom:0}
|
||||
@ -38,8 +38,8 @@
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BtMJajWI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BZQg_MtJ.css">
|
||||
<script type="module" crossorigin src="/assets/index-C8_s8k4t.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7X2YLBI.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
"name": "IPhone 15",
|
||||
"main_image": "http://.../img.jpg",
|
||||
"price": 100000,
|
||||
"points_required": 1000,
|
||||
"in_stock": true,
|
||||
"status": 1,
|
||||
"supported": true
|
||||
@ -43,6 +44,7 @@
|
||||
"kind": "item_card",
|
||||
"name": "免运费卡",
|
||||
"price": 500,
|
||||
"points_required": 5,
|
||||
"status": 1,
|
||||
"supported": true
|
||||
},
|
||||
@ -53,6 +55,7 @@
|
||||
"discount_type": 1,
|
||||
"discount_value": 1000,
|
||||
"min_spend": 0,
|
||||
"points_required": 10,
|
||||
"status": 1,
|
||||
"supported": true
|
||||
}
|
||||
@ -64,6 +67,7 @@
|
||||
|
||||
- `kind`: `product` | `item_card` | `coupon`
|
||||
- `price`: 售价(分),仅 `product` 和 `item_card` 有效。
|
||||
- `points_required`: 兑换所需积分。
|
||||
- `discount_type`: 优惠券类型,`1`: 直减金额券。
|
||||
- `discount_value`: 优惠面额(分)。
|
||||
- `supported`: 是否支持积分兑换(当前仅直减券支持)。
|
||||
|
||||
@ -91,9 +91,8 @@ func (h *handler) ListActivities() core.HandlerFunc {
|
||||
isBossPtr = &req.IsBoss
|
||||
}
|
||||
var statusPtr *int32
|
||||
if req.Status == 1 || req.Status == 2 {
|
||||
statusPtr = &req.Status
|
||||
}
|
||||
onlineStatus := int32(1)
|
||||
statusPtr = &onlineStatus
|
||||
items, total, err := h.activity.ListActivities(ctx.RequestContext(), struct {
|
||||
Name string
|
||||
CategoryID int64
|
||||
@ -161,6 +160,10 @@ func (h *handler) GetActivityDetail() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
|
||||
return
|
||||
}
|
||||
if item.Status != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.GetActivityError, "该活动已下线"))
|
||||
return
|
||||
}
|
||||
rsp := &activityDetailResponse{
|
||||
ID: item.ID,
|
||||
CreatedAt: item.CreatedAt,
|
||||
|
||||
@ -66,6 +66,19 @@ func (h *handler) ListDrawLogs() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
activityID, _ := strconv.ParseInt(ctx.Param("activity_id"), 10, 64)
|
||||
if activityID > 0 {
|
||||
act, err := h.activity.GetActivity(ctx.RequestContext(), activityID)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
|
||||
return
|
||||
}
|
||||
if act.Status != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.GetActivityError, "该活动已下线"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 强制固定分页:第一页,100条
|
||||
page := 1
|
||||
pageSize := 100
|
||||
@ -229,6 +242,19 @@ func (h *handler) ListDrawLogsByLevel() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
activityID, _ := strconv.ParseInt(ctx.Param("activity_id"), 10, 64)
|
||||
if activityID > 0 {
|
||||
act, err := h.activity.GetActivity(ctx.RequestContext(), activityID)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
|
||||
return
|
||||
}
|
||||
if act.Status != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.GetActivityError, "该活动已下线"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 获取所有中奖记录
|
||||
// 我们假设这里不需要分页,或者分页逻辑比较复杂(每个等级分页?)。
|
||||
// 根据需求描述“按奖品等级进行归类”,通常 implied 展示所有或者前N个。
|
||||
|
||||
@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
@ -56,6 +57,31 @@ func (h *handler) ListIssueChoices() core.HandlerFunc {
|
||||
if err := h.repo.GetDbR().Raw("SELECT slot_index FROM issue_position_claims WHERE issue_id = ?", issueID).Scan(&claimed0).Error; err != nil {
|
||||
claimed0 = []int64{}
|
||||
}
|
||||
|
||||
// Lazy Reset Logic: Check if sold out and fully drawn
|
||||
if int64(len(claimed0)) >= total && total > 0 {
|
||||
var processingCnt int64
|
||||
// Check for any claims that do NOT have a corresponding draw log (meaning still processing/paying)
|
||||
// Using LEFT JOIN: claims c LEFT JOIN logs l ON ... WHERE l.id IS NULL
|
||||
errUnproc := h.repo.GetDbR().Raw(`
|
||||
SELECT COUNT(1)
|
||||
FROM issue_position_claims c
|
||||
LEFT JOIN activity_draw_logs l ON c.order_id = l.order_id AND l.issue_id = c.issue_id
|
||||
WHERE c.issue_id = ? AND l.id IS NULL`, issueID).Scan(&processingCnt).Error
|
||||
|
||||
if errUnproc == nil && processingCnt == 0 {
|
||||
// All sold and all processed -> Reset
|
||||
if errDel := h.repo.GetDbW().Exec("DELETE FROM issue_position_claims WHERE issue_id = ?", issueID).Error; errDel == nil {
|
||||
fmt.Printf("[Ichiban] Lazy Reset triggered for IssueID=%d. All %d slots sold and drawn.\n", issueID, total)
|
||||
claimed0 = []int64{} // Reset local variable to show empty board immediately
|
||||
} else {
|
||||
fmt.Printf("[Ichiban] Lazy Reset failed for IssueID=%d: %v\n", issueID, errDel)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[Ichiban] IssueID=%d is sold out but still has %d processing orders. Waiting.\n", issueID, processingCnt)
|
||||
}
|
||||
}
|
||||
|
||||
used := make(map[int64]struct{}, len(claimed0))
|
||||
for _, s := range claimed0 {
|
||||
used[s] = struct{}{}
|
||||
|
||||
@ -60,6 +60,10 @@ func (h *handler) ListActivityIssues() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
|
||||
return
|
||||
}
|
||||
if activityItem.Status != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.GetActivityError, "该活动已下线"))
|
||||
return
|
||||
}
|
||||
items, total, err := h.activity.ListIssues(ctx.RequestContext(), activityID, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivityIssuesError, err.Error()))
|
||||
|
||||
@ -108,6 +108,7 @@ func (h *handler) JoinLottery() core.HandlerFunc {
|
||||
order.Remark = fmt.Sprintf("lottery:activity:%d|issue:%d|count:%d|slots:%s", req.ActivityID, req.IssueID, c, buildSlotsRemarkWithScalarCount(req.SlotIndex))
|
||||
}
|
||||
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
|
||||
order.ItemCardID = *req.ItemCardID
|
||||
if order.Remark == "" {
|
||||
order.Remark = fmt.Sprintf("lottery:activity:%d|issue:%d|count:%d", req.ActivityID, req.IssueID, c)
|
||||
}
|
||||
@ -118,6 +119,7 @@ func (h *handler) JoinLottery() core.HandlerFunc {
|
||||
order.ActualAmount = order.TotalAmount
|
||||
applied := int64(0)
|
||||
if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
|
||||
order.CouponID = *req.CouponID
|
||||
applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID)
|
||||
}
|
||||
// Title Discount Logic
|
||||
|
||||
@ -33,8 +33,8 @@ type matchingGamePreOrderResponse struct {
|
||||
GameID string `json:"game_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
PayStatus int32 `json:"pay_status"` // 1=Pending, 2=Paid
|
||||
AllCards []MatchingCard `json:"all_cards"` // 全量99张卡牌(乱序)
|
||||
ServerSeedHash string `json:"server_seed_hash"`
|
||||
// AllCards 已移除:游戏数据需通过 GetMatchingGameCards 接口在支付成功后获取
|
||||
}
|
||||
|
||||
type matchingGameCheckRequest struct {
|
||||
@ -93,6 +93,10 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
|
||||
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 {
|
||||
@ -150,13 +154,17 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
|
||||
}
|
||||
|
||||
// 3. 创建游戏并洗牌
|
||||
// 使用 Activity Commitment 作为随机源
|
||||
var serverSeedSrc []byte
|
||||
if len(activity.CommitmentSeedMaster) > 0 {
|
||||
serverSeedSrc = activity.CommitmentSeedMaster
|
||||
// 使用 Activity Commitment 作为随机源(必须存在)
|
||||
if len(activity.CommitmentSeedMaster) == 0 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170011, "活动尚未生成承诺,无法开始游戏"))
|
||||
return
|
||||
}
|
||||
|
||||
game := NewMatchingGameWithConfig(configs, req.Position, serverSeedSrc)
|
||||
game := 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
|
||||
@ -227,12 +235,11 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
|
||||
_ = h.writeDB.ActivityDrawReceipts.WithContext(ctx.RequestContext()).Create(receipt)
|
||||
}
|
||||
|
||||
// 7. 返回数据
|
||||
// 7. 返回数据(不返回 all_cards,需支付成功后通过 GetMatchingGameCards 获取)
|
||||
rsp := &matchingGamePreOrderResponse{
|
||||
GameID: gameID,
|
||||
OrderNo: order.OrderNo,
|
||||
PayStatus: order.Status,
|
||||
AllCards: allCards,
|
||||
ServerSeedHash: game.ServerSeedHash,
|
||||
}
|
||||
|
||||
@ -284,6 +291,17 @@ 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, "活动不存在"))
|
||||
return
|
||||
}
|
||||
if activity.Status != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, 170001, "该活动已下线"))
|
||||
return
|
||||
}
|
||||
|
||||
// 校验:不能超过理论最大对数
|
||||
fmt.Printf("[对对碰Check] 校验对子数量: 客户端提交=%d 服务端计算最大值=%d GameID=%s\n", req.TotalPairs, game.MaxPossiblePairs, req.GameID)
|
||||
if req.TotalPairs > game.MaxPossiblePairs {
|
||||
@ -306,10 +324,10 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
|
||||
if r.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
// 精确匹配:用户消除的对子数 == 奖品设置的 MinScore
|
||||
// 精确匹配:用户消除的对子数 == 奖品设置的对子数
|
||||
if int64(req.TotalPairs) == r.MinScore {
|
||||
candidate = r
|
||||
break // 精确匹配,直接使用
|
||||
break // 找到精确匹配,直接使用
|
||||
}
|
||||
}
|
||||
|
||||
@ -327,15 +345,18 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
|
||||
var cardToVoid int64 = 0
|
||||
|
||||
// 4. Apply Item Card Effects (Determine final reward and quantity)
|
||||
if order != nil {
|
||||
icID := parseItemCardIDFromRemark(order.Remark)
|
||||
fmt.Printf("[CheckMatchingGame] Debug: OrderNo=%s Remark=%s icID=%d\n", order.OrderNo, order.Remark, 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),
|
||||
h.readDB.UserItemCards.Status.Eq(1),
|
||||
).First()
|
||||
if uic != nil {
|
||||
if uic == nil {
|
||||
fmt.Printf("[CheckMatchingGame] ❌ UserItemCard not found: icID=%d userID=%d\n", icID, game.UserID)
|
||||
} else if uic.Status != 1 {
|
||||
fmt.Printf("[CheckMatchingGame] ❌ UserItemCard invalid status: status=%d\n", 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),
|
||||
@ -370,15 +391,23 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
|
||||
randBytes := make([]byte, 4)
|
||||
rand.Read(randBytes)
|
||||
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
|
||||
fmt.Printf("[道具卡-CheckMatchingGame] 概率检定: rand=%d threshold=%d\n", randVal, ic.BoostRateX1000)
|
||||
if randVal < ic.BoostRateX1000 {
|
||||
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name)
|
||||
finalReward = better
|
||||
finalRemark = better.Name + "(升级)"
|
||||
} else {
|
||||
fmt.Printf("[道具卡-CheckMatchingGame] ❌ 概率提升失败\n")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[道具卡-CheckMatchingGame] ⚠️ 未找到更好的奖品可升级 (currentMinScore=%d)\n", candidate.MinScore)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[道具卡-CheckMatchingGame] ❌ 范围校验失败\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[道具卡-CheckMatchingGame] ❌ 时间或系统卡状态无效: IC=%v ValidStart=%v ValidEnd=%v Now=%v\n", ic != nil, uic.ValidStart, uic.ValidEnd, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -505,6 +534,17 @@ func (h *handler) GetMatchingGameState() core.HandlerFunc {
|
||||
// Keep-Alive: Refresh Redis TTL
|
||||
h.redis.Expire(ctx.RequestContext(), 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())
|
||||
}
|
||||
}
|
||||
@ -530,3 +570,84 @@ func (h *handler) ListMatchingCardTypes() core.HandlerFunc {
|
||||
ctx.Payload(configs)
|
||||
}
|
||||
}
|
||||
|
||||
// matchingGameCardsResponse 游戏数据响应
|
||||
type matchingGameCardsResponse struct {
|
||||
GameID string `json:"game_id"`
|
||||
AllCards []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.loadGameFromRedis(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 {
|
||||
fmt.Printf("[GetMatchingGameCards] ⏳ 订单未支付 order_id=%d status=%d,拒绝返回游戏数据\n", order.ID, 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(), matchingGameKeyPrefix+gameID, 30*time.Minute)
|
||||
|
||||
// 5. 构造并返回全量卡牌数据
|
||||
allCards := make([]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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSelectRewardByExactMatch 测试对对碰选奖逻辑:精确匹配 TotalPairs == MinScore
|
||||
func TestSelectRewardByExactMatch(t *testing.T) {
|
||||
// TestSelectRewardExact 测试对对碰选奖逻辑:精确匹配 TotalPairs == MinScore
|
||||
func TestSelectRewardExact(t *testing.T) {
|
||||
// 模拟奖品设置
|
||||
rewards := []*model.ActivityRewardSettings{
|
||||
{ID: 1, Name: "奖品A-10对", MinScore: 10, Quantity: 5},
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
@ -111,30 +110,19 @@ func NewMatchingGameWithConfig(configs []CardTypeConfig, position string, master
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
// 生成服务器种子
|
||||
if len(masterSeed) > 0 {
|
||||
// 生成服务器种子 - 必须有 masterSeed(承诺)
|
||||
if len(masterSeed) == 0 {
|
||||
// 没有承诺则返回 nil(让调用方处理错误)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用主承诺种子作为基础
|
||||
// ServerSeed = HMAC(MasterSeed, Position + Timestamp)
|
||||
// 这样保证了基于主承诺的确定性派生
|
||||
|
||||
h := hmac.New(sha256.New, masterSeed)
|
||||
h.Write([]byte(position))
|
||||
h.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
||||
g.ServerSeed = h.Sum(nil) // 32 bytes
|
||||
} else {
|
||||
// Fallback to random if no master seed (compatibility)
|
||||
g.ServerSeed = make([]byte, 32)
|
||||
rand.Read(g.ServerSeed)
|
||||
|
||||
// 如果有 position 参数,将其混入种子逻辑
|
||||
if position != "" {
|
||||
h := sha256.New()
|
||||
h.Write(g.ServerSeed)
|
||||
h.Write([]byte(position))
|
||||
h.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
||||
g.ServerSeed = h.Sum(nil)
|
||||
}
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(g.ServerSeed)
|
||||
g.ServerSeedHash = fmt.Sprintf("%x", hash)
|
||||
|
||||
@ -49,6 +49,20 @@ func (h *handler) ListIssueRewards() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
issueID, _ := strconv.ParseInt(issueIDStr, 10, 64)
|
||||
activityIDStr := ctx.Param("activity_id")
|
||||
activityID, _ := strconv.ParseInt(activityIDStr, 10, 64)
|
||||
if activityID > 0 {
|
||||
act, err := h.activity.GetActivity(ctx.RequestContext(), activityID)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
|
||||
return
|
||||
}
|
||||
if act.Status != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusNotFound, code.GetActivityError, "该活动已下线"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
items, err := h.activity.ListIssueRewards(ctx.RequestContext(), issueID)
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListIssueRewardsError, err.Error()))
|
||||
|
||||
@ -6,21 +6,23 @@ import (
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
prodsvc "bindbox-game/internal/service/product"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
)
|
||||
|
||||
type productHandler struct {
|
||||
logger logger.CustomLogger
|
||||
readDB *dao.Query
|
||||
product prodsvc.Service
|
||||
user usersvc.Service
|
||||
}
|
||||
|
||||
func NewProduct(logger logger.CustomLogger, db mysql.Repo) *productHandler {
|
||||
return &productHandler{logger: logger, readDB: dao.Use(db.GetDbR()), product: prodsvc.New(logger, db)}
|
||||
func NewProduct(logger logger.CustomLogger, db mysql.Repo, user usersvc.Service) *productHandler {
|
||||
return &productHandler{logger: logger, readDB: dao.Use(db.GetDbR()), product: prodsvc.New(logger, db), user: user}
|
||||
}
|
||||
|
||||
type listAppProductsRequest struct {
|
||||
@ -40,6 +42,7 @@ type listAppProductsItem struct {
|
||||
Name string `json:"name"`
|
||||
MainImage string `json:"main_image"`
|
||||
Price int64 `json:"price"`
|
||||
PointsRequired int64 `json:"points_required"`
|
||||
Sales int64 `json:"sales"`
|
||||
InStock bool `json:"in_stock"`
|
||||
}
|
||||
@ -84,7 +87,8 @@ func (h *productHandler) ListProductsForApp() core.HandlerFunc {
|
||||
}
|
||||
rsp := &listAppProductsResponse{Total: total, CurrentPage: req.Page, PageSize: req.PageSize, List: make([]listAppProductsItem, len(items))}
|
||||
for i, it := range items {
|
||||
rsp.List[i] = listAppProductsItem{ID: it.ID, Name: it.Name, MainImage: it.MainImage, Price: it.Price, Sales: it.Sales, InStock: it.InStock}
|
||||
pts, _ := h.user.CentsToPoints(ctx.RequestContext(), it.Price)
|
||||
rsp.List[i] = listAppProductsItem{ID: it.ID, Name: it.Name, MainImage: it.MainImage, Price: it.Price, PointsRequired: pts, Sales: it.Sales, InStock: it.InStock}
|
||||
}
|
||||
ctx.Payload(rsp)
|
||||
}
|
||||
@ -95,6 +99,7 @@ type getAppProductDetailResponse struct {
|
||||
Name string `json:"name"`
|
||||
Album []string `json:"album"`
|
||||
Price int64 `json:"price"`
|
||||
PointsRequired int64 `json:"points_required"`
|
||||
Sales int64 `json:"sales"`
|
||||
Stock int64 `json:"stock"`
|
||||
Description string `json:"description"`
|
||||
@ -130,9 +135,11 @@ func (h *productHandler) GetProductDetailForApp() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
rsp := &getAppProductDetailResponse{ID: d.ID, Name: d.Name, Album: d.Album, Price: d.Price, Sales: d.Sales, Stock: d.Stock, Description: d.Description, Service: d.Service, Recommendations: make([]listAppProductsItem, len(d.Recommendations))}
|
||||
ptsDetail, _ := h.user.CentsToPoints(ctx.RequestContext(), d.Price)
|
||||
rsp := &getAppProductDetailResponse{ID: d.ID, Name: d.Name, Album: d.Album, Price: d.Price, PointsRequired: ptsDetail, Sales: d.Sales, Stock: d.Stock, Description: d.Description, Service: d.Service, Recommendations: make([]listAppProductsItem, len(d.Recommendations))}
|
||||
for i, it := range d.Recommendations {
|
||||
rsp.Recommendations[i] = listAppProductsItem{ID: it.ID, Name: it.Name, MainImage: it.MainImage, Price: it.Price, Sales: it.Sales, InStock: it.InStock}
|
||||
ptsRec, _ := h.user.CentsToPoints(ctx.RequestContext(), it.Price)
|
||||
rsp.Recommendations[i] = listAppProductsItem{ID: it.ID, Name: it.Name, MainImage: it.MainImage, Price: it.Price, PointsRequired: ptsRec, Sales: it.Sales, InStock: it.InStock}
|
||||
}
|
||||
ctx.Payload(rsp)
|
||||
}
|
||||
|
||||
@ -3,20 +3,22 @@ package app
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type storeHandler struct {
|
||||
logger logger.CustomLogger
|
||||
readDB *dao.Query
|
||||
user usersvc.Service
|
||||
}
|
||||
|
||||
func NewStore(logger logger.CustomLogger, db mysql.Repo) *storeHandler {
|
||||
return &storeHandler{logger: logger, readDB: dao.Use(db.GetDbR())}
|
||||
func NewStore(logger logger.CustomLogger, db mysql.Repo, user usersvc.Service) *storeHandler {
|
||||
return &storeHandler{logger: logger, readDB: dao.Use(db.GetDbR()), user: user}
|
||||
}
|
||||
|
||||
type listStoreItemsRequest struct {
|
||||
@ -31,6 +33,7 @@ type listStoreItem struct {
|
||||
Name string `json:"name"`
|
||||
MainImage string `json:"main_image"`
|
||||
Price int64 `json:"price"`
|
||||
PointsRequired int64 `json:"points_required"`
|
||||
InStock bool `json:"in_stock"`
|
||||
Status int32 `json:"status"`
|
||||
DiscountType int32 `json:"discount_type"`
|
||||
@ -66,9 +69,15 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
if req.Page <= 0 { req.Page = 1 }
|
||||
if req.PageSize <= 0 { req.PageSize = 20 }
|
||||
if req.PageSize > 100 { req.PageSize = 100 }
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
var total int64
|
||||
var list []listStoreItem
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
@ -81,7 +90,8 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
|
||||
rows, _ := q.Order(h.readDB.SystemItemCards.ID.Desc()).Offset(offset).Limit(limit).Find()
|
||||
list = make([]listStoreItem, len(rows))
|
||||
for i, it := range rows {
|
||||
list[i] = listStoreItem{ID: it.ID, Kind: "item_card", Name: it.Name, Price: it.Price, Status: it.Status, Supported: true}
|
||||
pts, _ := h.user.CentsToPoints(ctx.RequestContext(), it.Price)
|
||||
list[i] = listStoreItem{ID: it.ID, Kind: "item_card", Name: it.Name, Price: it.Price, PointsRequired: pts, Status: it.Status, Supported: true}
|
||||
}
|
||||
case "coupon":
|
||||
q := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.Status.Eq(1))
|
||||
@ -89,7 +99,8 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
|
||||
rows, _ := q.Order(h.readDB.SystemCoupons.ID.Desc()).Offset(offset).Limit(limit).Find()
|
||||
list = make([]listStoreItem, len(rows))
|
||||
for i, it := range rows {
|
||||
list[i] = listStoreItem{ID: it.ID, Kind: "coupon", Name: it.Name, DiscountType: it.DiscountType, DiscountValue: it.DiscountValue, MinSpend: it.MinSpend, Status: it.Status, Supported: it.DiscountType == 1}
|
||||
pts, _ := h.user.CentsToPoints(ctx.RequestContext(), it.DiscountValue)
|
||||
list[i] = listStoreItem{ID: it.ID, Kind: "coupon", Name: it.Name, DiscountType: it.DiscountType, DiscountValue: it.DiscountValue, PointsRequired: pts, MinSpend: it.MinSpend, Status: it.Status, Supported: it.DiscountType == 1}
|
||||
}
|
||||
default: // product
|
||||
q := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.Status.Eq(1))
|
||||
@ -97,7 +108,8 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
|
||||
rows, _ := q.Order(h.readDB.Products.ID.Desc()).Offset(offset).Limit(limit).Find()
|
||||
list = make([]listStoreItem, len(rows))
|
||||
for i, it := range rows {
|
||||
list[i] = listStoreItem{ID: it.ID, Kind: "product", Name: it.Name, MainImage: parseFirstImage(it.ImagesJSON), Price: it.Price, InStock: it.Stock > 0 && it.Status == 1, Status: it.Status, Supported: true}
|
||||
pts, _ := h.user.CentsToPoints(ctx.RequestContext(), it.Price)
|
||||
list[i] = listStoreItem{ID: it.ID, Kind: "product", Name: it.Name, MainImage: parseFirstImage(it.ImagesJSON), Price: it.Price, PointsRequired: pts, InStock: it.Stock > 0 && it.Status == 1, Status: it.Status, Supported: true}
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,4 +121,3 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ type handler struct {
|
||||
db mysql.Repo
|
||||
redis *redis.Client
|
||||
ticketSvc game.TicketService
|
||||
gameTokenSvc game.GameTokenService
|
||||
userSvc usersvc.Service
|
||||
readDB *dao.Query
|
||||
}
|
||||
@ -32,6 +33,7 @@ func New(l logger.CustomLogger, db mysql.Repo, rdb *redis.Client, userSvc usersv
|
||||
db: db,
|
||||
redis: rdb,
|
||||
ticketSvc: game.NewTicketService(l, db),
|
||||
gameTokenSvc: game.NewGameTokenService(l, db, rdb),
|
||||
userSvc: userSvc,
|
||||
readDB: dao.Use(db.GetDbR()),
|
||||
}
|
||||
@ -147,10 +149,12 @@ type enterGameRequest struct {
|
||||
}
|
||||
|
||||
type enterGameResponse struct {
|
||||
TicketToken string `json:"ticket_token"`
|
||||
GameToken string `json:"game_token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
NakamaServer string `json:"nakama_server"`
|
||||
NakamaKey string `json:"nakama_key"`
|
||||
RemainingTimes int `json:"remaining_times"`
|
||||
ClientUrl string `json:"client_url"`
|
||||
}
|
||||
|
||||
// EnterGame App进入游戏(消耗资格)
|
||||
@ -161,7 +165,10 @@ type enterGameResponse struct {
|
||||
// @Router /api/app/games/enter [post]
|
||||
func (h *handler) EnterGame() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
userID := int64(ctx.SessionUserInfo().Id)
|
||||
sessionInfo := ctx.SessionUserInfo()
|
||||
userID := int64(sessionInfo.Id)
|
||||
username := sessionInfo.NickName
|
||||
avatar := "" // Avatar not in session, could be fetched from user profile if needed
|
||||
|
||||
req := new(enterGameRequest)
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
@ -169,16 +176,20 @@ func (h *handler) EnterGame() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 扣减资格
|
||||
if err := h.ticketSvc.UseTicket(ctx.RequestContext(), userID, req.GameCode); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 180001, "游戏次数不足"))
|
||||
// 生成安全的 GameToken (会自动扣减游戏次数)
|
||||
gameToken, _, expiresAt, err := h.gameTokenSvc.GenerateToken(
|
||||
ctx.RequestContext(),
|
||||
userID,
|
||||
username,
|
||||
avatar,
|
||||
req.GameCode,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to generate game token", zap.Error(err))
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 180001, "游戏次数不足或生成Token失败"))
|
||||
return
|
||||
}
|
||||
|
||||
// 生成临时token并存入Redis
|
||||
ticketToken := generateTicketToken(userID)
|
||||
h.redis.Set(ctx.RequestContext(), "game:ticket:"+ticketToken, userID, 30*60*1000000000) // 30分钟
|
||||
|
||||
// 查询剩余次数
|
||||
ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode)
|
||||
remaining := 0
|
||||
@ -186,18 +197,95 @@ func (h *handler) EnterGame() core.HandlerFunc {
|
||||
remaining = int(ticket.Available)
|
||||
}
|
||||
|
||||
// TODO: 从配置读取Nakama服务器信息
|
||||
// 从系统配置读取Nakama服务器信息
|
||||
nakamaServer := "wss://nakama.yourdomain.com"
|
||||
nakamaKey := "defaultkey"
|
||||
clientUrl := "https://game.1024tool.vip"
|
||||
configKey := "game_" + req.GameCode + "_config"
|
||||
// map generic game code to specific config key if needed, or just use convention
|
||||
if req.GameCode == "minesweeper" {
|
||||
configKey = "game_minesweeper_config"
|
||||
}
|
||||
|
||||
conf, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq(configKey)).First()
|
||||
if conf != nil {
|
||||
var gameConfig struct {
|
||||
Server string `json:"server"`
|
||||
Key string `json:"key"`
|
||||
ClientUrl string `json:"client_url"`
|
||||
}
|
||||
if json.Unmarshal([]byte(conf.ConfigValue), &gameConfig) == nil {
|
||||
if gameConfig.Server != "" {
|
||||
nakamaServer = gameConfig.Server
|
||||
}
|
||||
if gameConfig.Key != "" {
|
||||
nakamaKey = gameConfig.Key
|
||||
}
|
||||
if gameConfig.ClientUrl != "" {
|
||||
clientUrl = gameConfig.ClientUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Payload(&enterGameResponse{
|
||||
TicketToken: ticketToken,
|
||||
NakamaServer: "ws://localhost:7350",
|
||||
NakamaKey: "defaultkey",
|
||||
GameToken: gameToken,
|
||||
ExpiresAt: expiresAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
NakamaServer: nakamaServer,
|
||||
NakamaKey: nakamaKey,
|
||||
RemainingTimes: remaining,
|
||||
ClientUrl: clientUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Internal API (Nakama调用) ==========
|
||||
|
||||
type validateTokenRequest struct {
|
||||
GameToken string `json:"game_token" binding:"required"`
|
||||
}
|
||||
|
||||
type validateTokenResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
GameType string `json:"game_type,omitempty"`
|
||||
Ticket string `json:"ticket,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateGameToken Internal验证GameToken
|
||||
// @Summary 验证GameToken
|
||||
// @Tags Internal.游戏
|
||||
// @Param RequestBody body validateTokenRequest true "请求参数"
|
||||
// @Success 200 {object} validateTokenResponse
|
||||
// @Router /internal/game/validate-token [post]
|
||||
func (h *handler) ValidateGameToken() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
req := new(validateTokenRequest)
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.Payload(&validateTokenResponse{Valid: false, Error: "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.gameTokenSvc.ValidateToken(ctx.RequestContext(), req.GameToken)
|
||||
if err != nil {
|
||||
h.logger.Warn("GameToken validation failed", zap.Error(err))
|
||||
ctx.Payload(&validateTokenResponse{Valid: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Payload(&validateTokenResponse{
|
||||
Valid: true,
|
||||
UserID: claims.UserID,
|
||||
Username: claims.Username,
|
||||
Avatar: claims.Avatar,
|
||||
GameType: claims.GameType,
|
||||
Ticket: claims.Ticket,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type verifyRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Ticket string `json:"ticket"`
|
||||
@ -269,15 +357,18 @@ func (h *handler) SettleGame() core.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证token
|
||||
// 验证token(可选,如果游戏服务器传了ticket则验证,否则信任internal调用)
|
||||
if req.Ticket != "" {
|
||||
storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:ticket:"+req.Ticket).Result()
|
||||
if err != nil || storedUserID != req.UserID {
|
||||
ctx.Payload(&settleResponse{Success: false})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Warn("Ticket validation failed, but proceeding with internal trust",
|
||||
zap.String("ticket", req.Ticket), zap.String("user_id", req.UserID))
|
||||
} else {
|
||||
// 删除token防止重复使用
|
||||
h.redis.Del(ctx.RequestContext(), "game:ticket:"+req.Ticket)
|
||||
}
|
||||
}
|
||||
// 注意:即使ticket验证失败,作为internal API我们仍然信任游戏服务器传来的UserID
|
||||
|
||||
// 奖品发放逻辑
|
||||
var rewardMsg string
|
||||
|
||||
@ -59,12 +59,12 @@ func (h *handler) ListUserItemCards() core.HandlerFunc {
|
||||
var total int64
|
||||
var err error
|
||||
|
||||
// 道具卡列表 Status=1 (未使用) 时聚合显示数量
|
||||
if status == 1 {
|
||||
items, total, err = h.user.ListAggregatedUserItemCards(ctx.RequestContext(), userID, status, req.Page, req.PageSize)
|
||||
} else {
|
||||
// 道具卡列表 Status=1 (未使用) 时聚合显示数量 -> 修改为不再聚合,直接显示列表
|
||||
// if status == 1 {
|
||||
// items, total, err = h.user.ListAggregatedUserItemCards(ctx.RequestContext(), userID, status, req.Page, req.PageSize)
|
||||
// } else {
|
||||
items, total, err = h.user.ListUserItemCardsWithTemplateByStatus(ctx.RequestContext(), userID, status, req.Page, req.PageSize)
|
||||
}
|
||||
// }
|
||||
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10008, err.Error()))
|
||||
|
||||
@ -64,7 +64,7 @@ func (h *handler) RedeemPointsToProduct() core.HandlerFunc {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150102, err.Error()))
|
||||
return
|
||||
}
|
||||
resp, err := h.user.GrantReward(ctx.RequestContext(), userID, usersvc.GrantRewardRequest{ProductID: req.ProductID, Quantity: req.Quantity, Remark: prod.Name})
|
||||
resp, err := h.user.GrantReward(ctx.RequestContext(), userID, usersvc.GrantRewardRequest{ProductID: req.ProductID, Quantity: req.Quantity, Remark: prod.Name, PointsAmount: needPoints})
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150103, err.Error()))
|
||||
return
|
||||
|
||||
@ -70,10 +70,19 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
||||
intc := interceptor.New(logger, db)
|
||||
|
||||
// 内部服务接口路由组 (供 Nakama 调用)
|
||||
internalRouter := mux.Group("/internal")
|
||||
// 使用 X-Internal-Key 头进行验证,防止外部访问
|
||||
internalRouter := mux.Group("/api/internal", func(ctx core.Context) {
|
||||
internalKey := ctx.GetHeader("X-Internal-Key")
|
||||
// 从环境变量读取密钥,默认为 "bindbox-internal-secret-2024"
|
||||
expectedKey := "bindbox-internal-secret-2024"
|
||||
if internalKey != expectedKey {
|
||||
ctx.AbortWithError(core.Error(403, 10403, "Forbidden: Invalid internal key"))
|
||||
return
|
||||
}
|
||||
})
|
||||
{
|
||||
// TODO: 添加IP白名单或Internal-Key验证中间件
|
||||
internalRouter.POST("/game/verify", gameHandler.VerifyTicket())
|
||||
internalRouter.POST("/game/validate-token", gameHandler.ValidateGameToken())
|
||||
internalRouter.POST("/game/settle", gameHandler.SettleGame())
|
||||
internalRouter.GET("/game/minesweeper/config", gameHandler.GetMinesweeperConfig())
|
||||
}
|
||||
@ -347,15 +356,16 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
|
||||
appAuthApiRouter.POST("/orders/test/create", userHandler.CreateTestOrder())
|
||||
appAuthApiRouter.GET("/orders/:order_id", userHandler.GetOrderDetail())
|
||||
appAuthApiRouter.POST("/orders/:order_id/cancel", userHandler.CancelOrder())
|
||||
appAuthApiRouter.GET("/products", appapi.NewProduct(logger, db).ListProductsForApp())
|
||||
appAuthApiRouter.GET("/products/:id", appapi.NewProduct(logger, db).GetProductDetailForApp())
|
||||
appAuthApiRouter.GET("/store/items", appapi.NewStore(logger, db).ListStoreItemsForApp())
|
||||
appAuthApiRouter.GET("/products", appapi.NewProduct(logger, db, userSvc).ListProductsForApp())
|
||||
appAuthApiRouter.GET("/products/:id", appapi.NewProduct(logger, db, userSvc).GetProductDetailForApp())
|
||||
appAuthApiRouter.GET("/store/items", appapi.NewStore(logger, db, userSvc).ListStoreItemsForApp())
|
||||
appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder())
|
||||
|
||||
// 对对碰游戏
|
||||
appAuthApiRouter.POST("/matching/preorder", activityHandler.PreOrderMatchingGame())
|
||||
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
|
||||
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
|
||||
appAuthApiRouter.GET("/matching/cards", activityHandler.GetMatchingGameCards()) // 支付成功后获取游戏数据
|
||||
|
||||
// 扫雷游戏
|
||||
appAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.GetMyTickets())
|
||||
|
||||
@ -142,9 +142,10 @@ func (s *activityOrderService) CreateActivityOrder(ctx core.Context, req CreateA
|
||||
fmt.Printf("[订单服务] 优惠后 实付(分)=%d 累计优惠(分)=%d\n", order.ActualAmount, order.DiscountAmount)
|
||||
}
|
||||
|
||||
// 4. 记录道具卡到备注
|
||||
// 4. 记录道具卡到备注 (Removed duplicate append here as it was already done in Step 1)
|
||||
// Log for debugging
|
||||
if req.ItemCardID != nil && *req.ItemCardID > 0 {
|
||||
order.Remark += fmt.Sprintf("|itemcard:%d", *req.ItemCardID)
|
||||
fmt.Printf("[订单服务] 尝试道具卡 用户卡ID=%d 订单号=%s\n", *req.ItemCardID, order.OrderNo)
|
||||
}
|
||||
|
||||
// 5. 保存订单
|
||||
|
||||
@ -143,6 +143,21 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
|
||||
}
|
||||
}
|
||||
|
||||
// 【Fix】Also check all active issues for this activity, regardless of recent orders
|
||||
// This ensures expired issues with no recent orders are also processed
|
||||
var activeIssues []struct {
|
||||
ID int64
|
||||
}
|
||||
// status=1 means active/processing
|
||||
_ = r.ActivityIssues.WithContext(ctx).ReadDB().Where(
|
||||
r.ActivityIssues.ActivityID.Eq(aid),
|
||||
r.ActivityIssues.Status.Eq(1),
|
||||
).Scan(&activeIssues)
|
||||
|
||||
for _, ai := range activeIssues {
|
||||
issueIDs[ai.ID] = struct{}{}
|
||||
}
|
||||
|
||||
for iss := range issueIDs {
|
||||
// Check Sales
|
||||
// 一番赏:每种奖品 = 1个格位
|
||||
|
||||
@ -43,20 +43,12 @@ func (s *defaultStrategy) SelectItem(ctx context.Context, activityID int64, issu
|
||||
return 0, nil, errors.New("no weight")
|
||||
}
|
||||
|
||||
// Determine seed key: use Activity Commitment
|
||||
var seedKey []byte
|
||||
// Fallback to Activity Commitment if possible
|
||||
// We need to fetch activity first
|
||||
// Determine seed key: use Activity Commitment (REQUIRED)
|
||||
act, _ := s.read.Activities.WithContext(ctx).Where(s.read.Activities.ID.Eq(activityID)).First()
|
||||
if act != nil && len(act.CommitmentSeedMaster) > 0 {
|
||||
seedKey = act.CommitmentSeedMaster
|
||||
} else {
|
||||
// Absolute fallback if no commitment found (shouldn't happen in strict mode, but safe for dev)
|
||||
seedKey = make([]byte, 32)
|
||||
if _, err := rand.Read(seedKey); err != nil {
|
||||
return 0, nil, errors.New("crypto rand failed")
|
||||
}
|
||||
if act == nil || len(act.CommitmentSeedMaster) == 0 {
|
||||
return 0, nil, errors.New("活动尚未生成承诺,无法抽奖")
|
||||
}
|
||||
seedKey := act.CommitmentSeedMaster
|
||||
|
||||
// To ensure uniqueness per draw when using a fixed CommitmentSeedMaster, mix in a random salt
|
||||
salt := make([]byte, 16)
|
||||
|
||||
174
internal/service/game/token.go
Normal file
174
internal/service/game/token.go
Normal file
@ -0,0 +1,174 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GameTokenClaims contains the claims for a game token
|
||||
type GameTokenClaims struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
GameType string `json:"game_type"`
|
||||
Ticket string `json:"ticket"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GameTokenService handles game token generation and validation
|
||||
type GameTokenService interface {
|
||||
GenerateToken(ctx context.Context, userID int64, username, avatar, gameType string) (token string, ticket string, expiresAt time.Time, err error)
|
||||
ValidateToken(ctx context.Context, tokenString string) (*GameTokenClaims, error)
|
||||
InvalidateTicket(ctx context.Context, ticket string) error
|
||||
}
|
||||
|
||||
type gameTokenService struct {
|
||||
logger logger.CustomLogger
|
||||
db mysql.Repo
|
||||
redis *redis.Client
|
||||
secret string
|
||||
}
|
||||
|
||||
// NewGameTokenService creates a new game token service
|
||||
func NewGameTokenService(l logger.CustomLogger, db mysql.Repo, rdb *redis.Client) GameTokenService {
|
||||
// Use a dedicated secret for game tokens
|
||||
secret := configs.Get().Random.CommitMasterKey + "_game_token"
|
||||
return &gameTokenService{
|
||||
logger: l,
|
||||
db: db,
|
||||
redis: rdb,
|
||||
secret: secret,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken creates a new game token for a user
|
||||
func (s *gameTokenService) GenerateToken(ctx context.Context, userID int64, username, avatar, gameType string) (token string, ticket string, expiresAt time.Time, err error) {
|
||||
// 1. Check if user has game tickets and deduct one
|
||||
err = s.db.GetDbW().Transaction(func(tx *gorm.DB) error {
|
||||
// Check available tickets
|
||||
var userTicket model.UserGameTickets
|
||||
if err := tx.Where("user_id = ? AND game_code = ? AND available > 0", userID, gameType).First(&userTicket).Error; err != nil {
|
||||
return fmt.Errorf("no available game tickets")
|
||||
}
|
||||
|
||||
// Deduct one ticket
|
||||
result := tx.Model(&model.UserGameTickets{}).
|
||||
Where("user_id = ? AND game_code = ? AND available > 0", userID, gameType).
|
||||
Updates(map[string]interface{}{
|
||||
"available": gorm.Expr("available - 1"),
|
||||
"total_used": gorm.Expr("total_used + 1"),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("failed to deduct ticket")
|
||||
}
|
||||
|
||||
// Get new balance for logging
|
||||
var balance int32
|
||||
tx.Model(&model.UserGameTickets{}).
|
||||
Where("user_id = ? AND game_code = ?", userID, gameType).
|
||||
Pluck("available", &balance)
|
||||
|
||||
// Log the ticket usage
|
||||
log := &model.GameTicketLogs{
|
||||
UserID: userID,
|
||||
GameCode: gameType,
|
||||
ChangeType: 2, // 使用
|
||||
Amount: 1,
|
||||
Balance: balance,
|
||||
Source: "game_token",
|
||||
Remark: "生成游戏Token",
|
||||
}
|
||||
return tx.Create(log).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
|
||||
// 2. Generate unique ticket ID
|
||||
ticket = fmt.Sprintf("GT%d%d", userID, time.Now().UnixNano())
|
||||
|
||||
// 3. Store ticket in Redis (for single-use validation)
|
||||
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
|
||||
if err := s.redis.Set(ctx, ticketKey, fmt.Sprintf("%d", userID), 15*time.Minute).Err(); err != nil {
|
||||
s.logger.Error("Failed to store ticket in Redis", zap.Error(err))
|
||||
}
|
||||
|
||||
// 4. Generate JWT token
|
||||
expiresAt = time.Now().Add(10 * time.Minute)
|
||||
claims := GameTokenClaims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Avatar: avatar,
|
||||
GameType: gameType,
|
||||
Ticket: ticket,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: fmt.Sprintf("%d", userID),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
token, err = jwtToken.SignedString([]byte(s.secret))
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Generated game token", zap.Int64("user_id", userID), zap.String("game_type", gameType), zap.String("ticket", ticket))
|
||||
return token, ticket, expiresAt, nil
|
||||
}
|
||||
|
||||
// ValidateToken validates a game token and returns the claims
|
||||
func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string) (*GameTokenClaims, error) {
|
||||
// 1. Parse and validate JWT
|
||||
token, err := jwt.ParseWithClaims(tokenString, &GameTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*GameTokenClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
// 2. Check if ticket is still valid (not used)
|
||||
ticketKey := fmt.Sprintf("game:token:ticket:%s", claims.Ticket)
|
||||
storedUserID, err := s.redis.Get(ctx, ticketKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ticket not found or expired")
|
||||
}
|
||||
|
||||
if storedUserID != fmt.Sprintf("%d", claims.UserID) {
|
||||
return nil, fmt.Errorf("ticket user mismatch")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// InvalidateTicket marks a ticket as used
|
||||
func (s *gameTokenService) InvalidateTicket(ctx context.Context, ticket string) error {
|
||||
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
|
||||
return s.redis.Del(ctx, ticketKey).Err()
|
||||
}
|
||||
@ -63,7 +63,10 @@ func (s *service) SubmitAddressShare(ctx context.Context, shareToken string, nam
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid_or_expired_token")
|
||||
}
|
||||
cnt, err := s.readDB.ShippingRecords.WithContext(ctx).Where(s.readDB.ShippingRecords.InventoryID.Eq(claims.InventoryID)).Count()
|
||||
cnt, err := s.readDB.ShippingRecords.WithContext(ctx).Where(
|
||||
s.readDB.ShippingRecords.InventoryID.Eq(claims.InventoryID),
|
||||
s.readDB.ShippingRecords.Status.Neq(5), // Ignore cancelled
|
||||
).Count()
|
||||
if err == nil && cnt > 0 {
|
||||
return 0, fmt.Errorf("already_processed")
|
||||
}
|
||||
@ -101,7 +104,10 @@ func (s *service) RequestShipping(ctx context.Context, userID int64, inventoryID
|
||||
|
||||
// RequestShippingWithBatch 申请发货(支持批次号和指定地址)
|
||||
func (s *service) RequestShippingWithBatch(ctx context.Context, userID int64, inventoryID int64, batchNo string, addrID int64) (int64, error) {
|
||||
cnt, err := s.readDB.ShippingRecords.WithContext(ctx).Where(s.readDB.ShippingRecords.InventoryID.Eq(inventoryID)).Count()
|
||||
cnt, err := s.readDB.ShippingRecords.WithContext(ctx).Where(
|
||||
s.readDB.ShippingRecords.InventoryID.Eq(inventoryID),
|
||||
s.readDB.ShippingRecords.Status.Neq(5), // Ignore cancelled
|
||||
).Count()
|
||||
if err == nil && cnt > 0 {
|
||||
return 0, fmt.Errorf("already_processed")
|
||||
}
|
||||
|
||||
@ -65,10 +65,10 @@ func (s *service) CancelShipping(ctx context.Context, userID int64, inventoryID
|
||||
return err
|
||||
}
|
||||
|
||||
// 恢复库存状态为可用 (status=1)
|
||||
// 恢复库存状态为可用 (status=1) 并清空 shipping_no
|
||||
remark := fmt.Sprintf("|shipping_cancelled_by_user:%d", userID)
|
||||
if err := tx.UserInventory.WithContext(ctx).UnderlyingDB().Exec(
|
||||
"UPDATE user_inventory SET status=1, remark=CONCAT(IFNULL(remark,''), ?) WHERE id=? AND user_id=?",
|
||||
"UPDATE user_inventory SET status=1, shipping_no='', remark=CONCAT(IFNULL(remark,''), ?) WHERE id=? AND user_id=?",
|
||||
remark,
|
||||
rec.InventoryID,
|
||||
userID,
|
||||
|
||||
51
internal/service/user/expiration_task.go
Normal file
51
internal/service/user/expiration_task.go
Normal file
@ -0,0 +1,51 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartExpirationCheck starts a background task to check and expire items and coupons
|
||||
func StartExpirationCheck(l logger.CustomLogger, repo mysql.Repo) {
|
||||
go func() {
|
||||
// Check every minute
|
||||
t := time.NewTicker(1 * time.Minute)
|
||||
defer t.Stop()
|
||||
|
||||
for range t.C {
|
||||
ctx := context.Background()
|
||||
db := dao.Use(repo.GetDbW())
|
||||
now := time.Now()
|
||||
|
||||
// 1. Expire Item Cards
|
||||
// Status: 1 (Unused) -> 3 (Expired)
|
||||
result, err := db.UserItemCards.WithContext(ctx).
|
||||
Where(db.UserItemCards.Status.Eq(1), db.UserItemCards.ValidEnd.Lt(now)).
|
||||
Updates(map[string]interface{}{"status": 3})
|
||||
|
||||
if err != nil {
|
||||
l.Error("Failed to expire item cards: " + err.Error())
|
||||
} else if result.RowsAffected > 0 {
|
||||
l.Info(fmt.Sprintf("[Scheduled] Expired %d item cards", result.RowsAffected))
|
||||
}
|
||||
|
||||
// 2. Expire Coupons
|
||||
// Status: 1 (Unused) -> 3 (Expired)
|
||||
// Based on frontend logic and DB comment, 1 is Unused, 2 is Used, 3 is Expired.
|
||||
// Assuming DB stores 1 for Unused initially.
|
||||
resultC, errC := db.UserCoupons.WithContext(ctx).
|
||||
Where(db.UserCoupons.Status.Eq(1), db.UserCoupons.ValidEnd.Lt(now)).
|
||||
Updates(map[string]interface{}{"status": 3})
|
||||
|
||||
if errC != nil {
|
||||
l.Error("Failed to expire coupons: " + errC.Error())
|
||||
} else if resultC.RowsAffected > 0 {
|
||||
l.Info(fmt.Sprintf("[Scheduled] Expired %d coupons", resultC.RowsAffected))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -75,7 +75,7 @@ func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, pa
|
||||
images = p.ImagesJSON
|
||||
}
|
||||
sh := shipMap[r.ID]
|
||||
has := sh != nil
|
||||
has := sh != nil && sh.Status != 5
|
||||
var st int32
|
||||
if sh != nil {
|
||||
st = sh.Status
|
||||
@ -148,7 +148,7 @@ func (s *service) ListInventoryWithProductActive(ctx context.Context, userID int
|
||||
images = p.ImagesJSON
|
||||
}
|
||||
sh := shipMap[r.ID]
|
||||
has := sh != nil
|
||||
has := sh != nil && sh.Status != 5
|
||||
var st int32
|
||||
if sh != nil {
|
||||
st = sh.Status
|
||||
|
||||
@ -39,18 +39,7 @@ func (s *service) AddItemCard(ctx context.Context, userID int64, cardID int64, q
|
||||
if tpl == nil || tpl.Status != 1 {
|
||||
return errors.New("item card not found or disabled")
|
||||
}
|
||||
// 用户持有上限:同模板未使用数量最多10张
|
||||
exist, eerr := s.readDB.UserItemCards.WithContext(ctx).
|
||||
Where(s.readDB.UserItemCards.UserID.Eq(userID)).
|
||||
Where(s.readDB.UserItemCards.CardID.Eq(cardID)).
|
||||
Where(s.readDB.UserItemCards.Status.Eq(1)).
|
||||
Count()
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
if exist >= 10 {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
// 用户持有上限:不做限制
|
||||
now := time.Now()
|
||||
for i := 0; i < quantity; i++ {
|
||||
item := &model.UserItemCards{UserID: userID, CardID: cardID, Status: 1}
|
||||
|
||||
@ -15,6 +15,9 @@ type ItemCardWithTemplate struct {
|
||||
StackingStrategy int32 `json:"stacking_strategy"`
|
||||
Remark string `json:"remark"`
|
||||
Count int64 `json:"count"`
|
||||
UsedActivityName string `json:"used_activity_name"`
|
||||
UsedIssueNumber string `json:"used_issue_number"`
|
||||
UsedRewardName string `json:"used_reward_name"`
|
||||
}
|
||||
|
||||
// ListAggregatedUserItemCards 获取聚合后的用户道具卡列表(按卡种分组)
|
||||
@ -332,6 +335,83 @@ func (s *service) ListUserItemCardsWithTemplateByStatus(ctx context.Context, use
|
||||
tpls[it.ID] = it
|
||||
}
|
||||
}
|
||||
|
||||
// Data collection for batch fetching
|
||||
var usedActivityIDs []int64
|
||||
var usedIssueIDs []int64
|
||||
var usedDrawLogIDs []int64
|
||||
|
||||
for _, r := range rows {
|
||||
if status == 2 { // ONLY for Used status
|
||||
if r.UsedActivityID > 0 {
|
||||
usedActivityIDs = append(usedActivityIDs, r.UsedActivityID)
|
||||
}
|
||||
if r.UsedIssueID > 0 {
|
||||
usedIssueIDs = append(usedIssueIDs, r.UsedIssueID)
|
||||
}
|
||||
if r.UsedDrawLogID > 0 {
|
||||
usedDrawLogIDs = append(usedDrawLogIDs, r.UsedDrawLogID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maps for enrichment
|
||||
activityNameMap := make(map[int64]string)
|
||||
issueNumberMap := make(map[int64]string)
|
||||
rewardNameMap := make(map[int64]string)
|
||||
|
||||
if len(usedActivityIDs) > 0 {
|
||||
var err error
|
||||
var acts []*model.Activities
|
||||
if acts, err = s.readDB.Activities.WithContext(ctx).ReadDB().Select(s.readDB.Activities.ID, s.readDB.Activities.Name).Where(s.readDB.Activities.ID.In(usedActivityIDs...)).Find(); err == nil {
|
||||
for _, a := range acts {
|
||||
activityNameMap[a.ID] = a.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(usedIssueIDs) > 0 {
|
||||
var err error
|
||||
var issues []*model.ActivityIssues
|
||||
if issues, err = s.readDB.ActivityIssues.WithContext(ctx).ReadDB().Select(s.readDB.ActivityIssues.ID, s.readDB.ActivityIssues.IssueNumber).Where(s.readDB.ActivityIssues.ID.In(usedIssueIDs...)).Find(); err == nil {
|
||||
for _, i := range issues {
|
||||
issueNumberMap[i.ID] = i.IssueNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(usedDrawLogIDs) > 0 {
|
||||
var err error
|
||||
var logs []*model.ActivityDrawLogs
|
||||
// join reward settings to get name
|
||||
if logs, err = s.readDB.ActivityDrawLogs.WithContext(ctx).ReadDB().Select(s.readDB.ActivityDrawLogs.ID, s.readDB.ActivityDrawLogs.RewardID).Where(s.readDB.ActivityDrawLogs.ID.In(usedDrawLogIDs...)).Find(); err == nil {
|
||||
var rewardIDs []int64
|
||||
logToRewardID := make(map[int64]int64)
|
||||
for _, l := range logs {
|
||||
if l.RewardID > 0 {
|
||||
rewardIDs = append(rewardIDs, l.RewardID)
|
||||
logToRewardID[l.ID] = l.RewardID
|
||||
}
|
||||
}
|
||||
|
||||
if len(rewardIDs) > 0 {
|
||||
var rewards []*model.ActivityRewardSettings
|
||||
if rewards, err = s.readDB.ActivityRewardSettings.WithContext(ctx).ReadDB().Select(s.readDB.ActivityRewardSettings.ID, s.readDB.ActivityRewardSettings.Name).Where(s.readDB.ActivityRewardSettings.ID.In(rewardIDs...)).Find(); err == nil {
|
||||
rewardNameMapByID := make(map[int64]string)
|
||||
for _, r := range rewards {
|
||||
rewardNameMapByID[r.ID] = r.Name
|
||||
}
|
||||
// Map log ID back to reward name
|
||||
for logID, rewardID := range logToRewardID {
|
||||
if name, ok := rewardNameMapByID[rewardID]; ok {
|
||||
rewardNameMap[logID] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = make([]*ItemCardWithTemplate, len(rows))
|
||||
for i, r := range rows {
|
||||
tpl := tpls[r.CardID]
|
||||
@ -346,7 +426,7 @@ func (s *service) ListUserItemCardsWithTemplateByStatus(ctx context.Context, use
|
||||
stacking = tpl.StackingStrategy
|
||||
remark = tpl.Remark
|
||||
}
|
||||
items[i] = &ItemCardWithTemplate{
|
||||
item := &ItemCardWithTemplate{
|
||||
UserItemCards: r,
|
||||
Name: name,
|
||||
CardType: cardType,
|
||||
@ -356,6 +436,19 @@ func (s *service) ListUserItemCardsWithTemplateByStatus(ctx context.Context, use
|
||||
Remark: remark,
|
||||
Count: 1, // Individual record
|
||||
}
|
||||
|
||||
// Fill enrichment data
|
||||
if r.UsedActivityID > 0 {
|
||||
item.UsedActivityName = activityNameMap[r.UsedActivityID]
|
||||
}
|
||||
if r.UsedIssueID > 0 {
|
||||
item.UsedIssueNumber = issueNumberMap[r.UsedIssueID]
|
||||
}
|
||||
if r.UsedDrawLogID > 0 {
|
||||
item.UsedRewardName = rewardNameMap[r.UsedDrawLogID]
|
||||
}
|
||||
|
||||
items[i] = item
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
@ -60,7 +60,11 @@ func (s *service) AddPoints(ctx context.Context, userID int64, points int64, kin
|
||||
return err
|
||||
}
|
||||
}
|
||||
ledger := &model.UserPointsLedger{UserID: userID, Action: "manual_add", Points: points, RefTable: "user_points", RefID: strconv.FormatInt(userID, 10), Remark: remark}
|
||||
act := kind
|
||||
if act == "" {
|
||||
act = "manual_add"
|
||||
}
|
||||
ledger := &model.UserPointsLedger{UserID: userID, Action: act, Points: points, RefTable: "user_points", RefID: strconv.FormatInt(userID, 10), Remark: remark}
|
||||
if err := tx.UserPointsLedger.WithContext(ctx).Create(ledger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -18,5 +18,5 @@ func (s *service) CentsToPoints(ctx context.Context, cents int64) (int64, error)
|
||||
rate = r
|
||||
}
|
||||
}
|
||||
return cents * rate, nil
|
||||
return (cents * rate) / 100, nil
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ type GrantRewardRequest struct {
|
||||
RewardID *int64 `json:"reward_id,omitempty"` // 奖励配置ID(可选)
|
||||
AddressID *int64 `json:"address_id,omitempty"` // 收货地址ID(可选,实物商品需要)
|
||||
Remark string `json:"remark,omitempty"` // 备注
|
||||
PointsAmount int64 `json:"points_amount,omitempty"` // 消耗积分
|
||||
}
|
||||
|
||||
// GrantRewardResponse 奖励发放响应
|
||||
@ -88,7 +89,7 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
|
||||
Status: 2, // 已支付
|
||||
TotalAmount: 0,
|
||||
DiscountAmount: 0,
|
||||
PointsAmount: 0,
|
||||
PointsAmount: req.PointsAmount,
|
||||
ActualAmount: 0,
|
||||
IsConsumed: 0,
|
||||
PaidAt: now, // 设置支付时间为当前时间
|
||||
|
||||
@ -28,7 +28,10 @@ type ShipmentGroup struct {
|
||||
}
|
||||
|
||||
func (s *service) ListUserShipmentGroups(ctx context.Context, userID int64, page, pageSize int) (items []*ShipmentGroup, total int64, err error) {
|
||||
q := s.readDB.ShippingRecords.WithContext(ctx).ReadDB().Where(s.readDB.ShippingRecords.UserID.Eq(userID))
|
||||
q := s.readDB.ShippingRecords.WithContext(ctx).ReadDB().Where(
|
||||
s.readDB.ShippingRecords.UserID.Eq(userID),
|
||||
s.readDB.ShippingRecords.Status.Neq(5), // Exclude cancelled records
|
||||
)
|
||||
total, err = q.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2
main.go
2
main.go
@ -15,6 +15,7 @@ import (
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/router"
|
||||
activitysvc "bindbox-game/internal/service/activity"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
|
||||
"flag"
|
||||
|
||||
@ -154,6 +155,7 @@ func main() {
|
||||
}()
|
||||
|
||||
activitysvc.StartScheduledSettlement(customLogger, dbRepo)
|
||||
usersvc.StartExpirationCheck(customLogger, dbRepo)
|
||||
|
||||
// 优雅关闭
|
||||
shutdown.Close(
|
||||
|
||||
BIN
web/.DS_Store
vendored
BIN
web/.DS_Store
vendored
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user