feat: 商店商品展示新增所需积分,抽奖策略强制使用活动承诺种子,并新增用户过期任务和游戏令牌服务
Some checks failed
Build docker and publish / linux (1.24.5) (push) Failing after 15s

This commit is contained in:
邹方成 2025-12-26 12:22:32 +08:00
parent c9a83a232a
commit 04791789c9
40 changed files with 2537 additions and 598 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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.zip

Binary file not shown.

BIN
build/.DS_Store vendored

Binary file not shown.

Binary file not shown.

View File

@ -1 +0,0 @@
.search-card[data-v-82eaff85]{margin-bottom:16px}[data-v-82eaff85] .el-card__body{padding-bottom:0}

View File

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

View File

@ -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`: 是否支持积分兑换(当前仅直减券支持)。

View File

@ -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,

View File

@ -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个。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. 保存订单

View File

@ -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个格位

View File

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

View 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()
}

View File

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

View File

@ -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,

View 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))
}
}
}()
}

View File

@ -29,7 +29,7 @@ func (s *service) ListInventoryWithProduct(ctx context.Context, userID int64, pa
if pageSize > 100 {
pageSize = 100
}
rows, err := q.Order(s.readDB.UserInventory.ID.Desc()).Offset((page-1)*pageSize).Limit(pageSize).Find()
rows, err := q.Order(s.readDB.UserInventory.ID.Desc()).Offset((page - 1) * pageSize).Limit(pageSize).Find()
if err != nil {
return nil, 0, err
}
@ -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
@ -103,7 +103,7 @@ func (s *service) ListInventoryWithProductActive(ctx context.Context, userID int
if pageSize > 100 {
pageSize = 100
}
rows, err := q.Order(s.readDB.UserInventory.ID.Desc()).Offset((page-1)*pageSize).Limit(pageSize).Find()
rows, err := q.Order(s.readDB.UserInventory.ID.Desc()).Offset((page - 1) * pageSize).Limit(pageSize).Find()
if err != nil {
return nil, 0, err
}
@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, // 设置支付时间为当前时间

View File

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

View File

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

Binary file not shown.