feat: 新增取消发货功能并优化任务中心

fix: 修复微信通知字段截断导致的编码错误
feat: 添加有效邀请相关字段和任务中心常量
refactor: 重构一番赏奖品格位逻辑
perf: 优化道具卡列表聚合显示
docs: 更新项目说明文档和API文档
test: 添加字符串截断工具测试
This commit is contained in:
邹方成 2025-12-23 22:26:07 +08:00
parent 16e2ede037
commit a7a0f639e1
65 changed files with 3111 additions and 2288 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -18,7 +18,7 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -tags timetzda
export DOCKER_DEFAULT_PLATFORM=linux/amd64 export DOCKER_DEFAULT_PLATFORM=linux/amd64
docker build -t zfc931912343/bindbox-game:v1.8 . docker build -t zfc931912343/bindbox-game:v1.9 .
docker push zfc931912343/bindbox-game:v1.8 docker push zfc931912343/bindbox-game:v1.9
docker pull zfc931912343/bindbox-game:v1.8 &&docker rm -f bindbox-game && docker run -d --name bindbox-game -p 9991:9991 zfc931912343/bindbox-game:v1.8 docker pull zfc931912343/bindbox-game:v1.9 &&docker rm -f bindbox-game && docker run -d --name bindbox-game -p 9991:9991 zfc931912343/bindbox-game:v1.9

BIN
build.zip Normal file

Binary file not shown.

View File

@ -1 +0,0 @@
.page[data-v-5a52cd3e]{padding:12px}.mb-3[data-v-5a52cd3e]{margin-bottom:12px}

View File

@ -1 +0,0 @@
.page-container[data-v-c90a46c6]{padding:16px}.quick-actions[data-v-c90a46c6]{margin-bottom:16px}.ellipsis[data-v-c90a46c6]{display:inline-block;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compact-actions[data-v-c90a46c6]{display:flex;align-items:center;gap:6px;white-space:nowrap}

View File

@ -1 +0,0 @@
.page[data-v-15ff8e59]{padding:12px}.mb-3[data-v-15ff8e59]{margin-bottom:12px}

View File

@ -38,8 +38,8 @@
} }
})() })()
</script> </script>
<script type="module" crossorigin src="/assets/index-iR6j7F-E.js"></script> <script type="module" crossorigin src="/assets/index-D9UEFhei.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-q7XdNN2Z.css"> <link rel="stylesheet" crossorigin href="/assets/index-BZQg_MtJ.css">
</head> </head>
<body> <body>

View File

@ -5057,6 +5057,58 @@ const docTemplate = `{
} }
} }
}, },
"/api/app/users/{user_id}/inventory/cancel-shipping": {
"post": {
"security": [
{
"LoginVerifyToken": []
}
],
"description": "取消已提交但未发货的申请;恢复库存状态",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"APP端.用户"
],
"summary": "取消发货申请",
"parameters": [
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"description": "请求参数资产ID",
"name": "RequestBody",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/app.cancelShippingRequest"
}
}
],
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/app.cancelShippingResponse"
}
},
"400": {
"description": "参数错误/记录不存在/已处理",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
}
}
},
"/api/app/users/{user_id}/inventory/redeem": { "/api/app/users/{user_id}/inventory/redeem": {
"post": { "post": {
"security": [ "security": [
@ -8286,6 +8338,17 @@ const docTemplate = `{
} }
} }
}, },
"app.cancelShippingRequest": {
"type": "object",
"properties": {
"inventory_id": {
"type": "integer"
}
}
},
"app.cancelShippingResponse": {
"type": "object"
},
"app.couponDetail": { "app.couponDetail": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -5049,6 +5049,58 @@
} }
} }
}, },
"/api/app/users/{user_id}/inventory/cancel-shipping": {
"post": {
"security": [
{
"LoginVerifyToken": []
}
],
"description": "取消已提交但未发货的申请;恢复库存状态",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"APP端.用户"
],
"summary": "取消发货申请",
"parameters": [
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"description": "请求参数资产ID",
"name": "RequestBody",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/app.cancelShippingRequest"
}
}
],
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/app.cancelShippingResponse"
}
},
"400": {
"description": "参数错误/记录不存在/已处理",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
}
}
},
"/api/app/users/{user_id}/inventory/redeem": { "/api/app/users/{user_id}/inventory/redeem": {
"post": { "post": {
"security": [ "security": [
@ -8278,6 +8330,17 @@
} }
} }
}, },
"app.cancelShippingRequest": {
"type": "object",
"properties": {
"inventory_id": {
"type": "integer"
}
}
},
"app.cancelShippingResponse": {
"type": "object"
},
"app.couponDetail": { "app.couponDetail": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -1393,6 +1393,13 @@ definitions:
status: status:
type: integer type: integer
type: object type: object
app.cancelShippingRequest:
properties:
inventory_id:
type: integer
type: object
app.cancelShippingResponse:
type: object
app.couponDetail: app.couponDetail:
properties: properties:
amount: amount:
@ -6113,6 +6120,39 @@ paths:
summary: 撤销共享地址链接 summary: 撤销共享地址链接
tags: tags:
- APP端.用户 - APP端.用户
/api/app/users/{user_id}/inventory/cancel-shipping:
post:
consumes:
- application/json
description: 取消已提交但未发货的申请;恢复库存状态
parameters:
- description: 用户ID
in: path
name: user_id
required: true
type: integer
- description: 请求参数资产ID
in: body
name: RequestBody
required: true
schema:
$ref: '#/definitions/app.cancelShippingRequest'
produces:
- application/json
responses:
"200":
description: 成功
schema:
$ref: '#/definitions/app.cancelShippingResponse'
"400":
description: 参数错误/记录不存在/已处理
schema:
$ref: '#/definitions/code.Failure'
security:
- LoginVerifyToken: []
summary: 取消发货申请
tags:
- APP端.用户
/api/app/users/{user_id}/inventory/redeem: /api/app/users/{user_id}/inventory/redeem:
post: post:
consumes: consumes:

View File

@ -5,7 +5,6 @@ import (
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
activitysvc "bindbox-game/internal/service/activity" activitysvc "bindbox-game/internal/service/activity"
syscfgsvc "bindbox-game/internal/service/sysconfig"
titlesvc "bindbox-game/internal/service/title" titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
@ -17,13 +16,11 @@ type handler struct {
writeDB *dao.Query writeDB *dao.Query
readDB *dao.Query readDB *dao.Query
activity activitysvc.Service activity activitysvc.Service
syscfg syscfgsvc.Service
title titlesvc.Service title titlesvc.Service
repo mysql.Repo repo mysql.Repo
user usersvc.Service user usersvc.Service
redis *redis.Client redis *redis.Client
activityOrder activitysvc.ActivityOrderService // 活动订单服务 activityOrder activitysvc.ActivityOrderService // 活动订单服务
rewardEffects activitysvc.RewardEffectsService // 奖励效果服务
} }
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler { func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
@ -32,12 +29,10 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
writeDB: dao.Use(db.GetDbW()), writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()), readDB: dao.Use(db.GetDbR()),
activity: activitysvc.New(logger, db), activity: activitysvc.New(logger, db),
syscfg: syscfgsvc.New(logger, db),
title: titlesvc.New(logger, db), title: titlesvc.New(logger, db),
repo: db, repo: db,
user: usersvc.New(logger, db), user: usersvc.New(logger, db),
redis: rdb, redis: rdb,
activityOrder: activitysvc.NewActivityOrderService(logger, db), activityOrder: activitysvc.NewActivityOrderService(logger, db),
rewardEffects: activitysvc.NewRewardEffectsService(logger, db),
} }
} }

View File

@ -50,12 +50,8 @@ func (h *handler) ListIssueChoices() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListIssueRewardsError, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListIssueRewardsError, err.Error()))
return return
} }
var total int64 // 一番赏:每种奖品 = 1个格位
for _, r := range rewards { total := int64(len(rewards))
if r.OriginalQty > 0 {
total += r.OriginalQty
}
}
var claimed0 []int64 var claimed0 []int64
if err := h.repo.GetDbR().Raw("SELECT slot_index FROM issue_position_claims WHERE issue_id = ?", issueID).Scan(&claimed0).Error; err != nil { if err := h.repo.GetDbR().Raw("SELECT slot_index FROM issue_position_claims WHERE issue_id = ?", issueID).Scan(&claimed0).Error; err != nil {
claimed0 = []int64{} claimed0 = []int64{}

View File

@ -73,15 +73,14 @@ func (h *handler) ListActivityIssues() core.HandlerFunc {
it := issueItem{ID: v.ID, IssueNumber: v.IssueNumber, Status: v.Status, Sort: v.Sort} it := issueItem{ID: v.ID, IssueNumber: v.IssueNumber, Status: v.Status, Sort: v.Sort}
if activityItem.ActivityCategoryID == 1 { if activityItem.ActivityCategoryID == 1 {
rewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.Eq(v.ID)).Find() rewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.Eq(v.ID)).Find()
var totalQty int64 // 一番赏:每种奖品 = 1个格位
var remainQty int64 totalQty := int64(len(rewards))
for _, r := range rewards { // 查询已占用格位数
if r.OriginalQty >= 0 { var claimedCnt int64
totalQty += r.OriginalQty _ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id = ?", v.ID).Scan(&claimedCnt).Error
} remainQty := totalQty - claimedCnt
if r.Quantity >= 0 { if remainQty < 0 {
remainQty += r.Quantity remainQty = 0
}
} }
it.TotalPrizeQuantity = totalQty it.TotalPrizeQuantity = totalQty
it.RemainingPrizeQuantity = remainQty it.RemainingPrizeQuantity = remainQty

View File

@ -81,8 +81,6 @@ func (h *handler) JoinLottery() core.HandlerFunc {
if activity.DrawMode != "" { if activity.DrawMode != "" {
cfgMode = activity.DrawMode cfgMode = activity.DrawMode
} }
fmt.Printf("[抽奖下单] 用户=%d 活动ID=%d 期ID=%d 次数=%d 渠道=%s 优惠券ID=%v 道具卡ID=%v\n", userID, req.ActivityID, req.IssueID, req.Count, req.Channel, req.CouponID, req.ItemCardID)
fmt.Printf("[抽奖下单] 活动票价(分)=%d 允许优惠券=%t 允许道具卡=%t 抽奖模式=%s 玩法=%s\n", activity.PriceDraw, activity.AllowCoupons, activity.AllowItemCards, cfgMode, activity.PlayType)
// 定时一番赏开奖前20秒禁止下单防止订单抖动 // 定时一番赏开奖前20秒禁止下单防止订单抖动
if activity.PlayType == "ichiban" && cfgMode == "scheduled" && !activity.ScheduledTime.IsZero() { if activity.PlayType == "ichiban" && cfgMode == "scheduled" && !activity.ScheduledTime.IsZero() {
now := time.Now() now := time.Now()
@ -118,12 +116,9 @@ func (h *handler) JoinLottery() core.HandlerFunc {
order.PointsAmount = 0 order.PointsAmount = 0
order.PointsLedgerID = 0 order.PointsLedgerID = 0
order.ActualAmount = order.TotalAmount order.ActualAmount = order.TotalAmount
fmt.Printf("[抽奖下单] 订单总额(分)=%d 初始实付(分)=%d 备注=%s\n", order.TotalAmount, order.ActualAmount, order.Remark)
applied := int64(0) applied := int64(0)
if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 { if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
fmt.Printf("[抽奖下单] 尝试优惠券 用户券ID=%d 应用前实付(分)=%d 累计优惠(分)=%d\n", *req.CouponID, order.ActualAmount, order.DiscountAmount)
applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID) applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID)
fmt.Printf("[抽奖下单] 优惠后 实付(分)=%d 累计优惠(分)=%d 备注=%s\n", order.ActualAmount, order.DiscountAmount, order.Remark)
} }
// Title Discount Logic // Title Discount Logic
// 1. Fetch active effects for this user, scoped to this activity/issue/category // 1. Fetch active effects for this user, scoped to this activity/issue/category
@ -168,7 +163,6 @@ func (h *handler) JoinLottery() core.HandlerFunc {
if discount > 0 { if discount > 0 {
order.ActualAmount -= discount order.ActualAmount -= discount
fmt.Printf("[抽奖下单] Title Discount Applied: -%d (EffectID: %d)\n", discount, ef.ID)
// Append to remark or separate logging? // Append to remark or separate logging?
if order.Remark == "" { if order.Remark == "" {
order.Remark = fmt.Sprintf("title_discount:%d:%d", ef.ID, discount) order.Remark = fmt.Sprintf("title_discount:%d:%d", ef.ID, discount)
@ -220,7 +214,6 @@ func (h *handler) JoinLottery() core.HandlerFunc {
rsp.JoinID = joinID rsp.JoinID = joinID
rsp.OrderNo = orderNo rsp.OrderNo = orderNo
rsp.DrawMode = cfgMode rsp.DrawMode = cfgMode
fmt.Printf("[抽奖下单] 汇总 订单号=%s 总额(分)=%d 优惠(分)=%d 实付(分)=%d 队列=true 模式=%s\n", orderNo, order.TotalAmount, order.DiscountAmount, order.ActualAmount, cfgMode)
if order.ActualAmount == 0 { if order.ActualAmount == 0 {
now := time.Now() now := time.Now()
_, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 2, h.writeDB.Orders.PaidAt.ColumnName().String(): now}) _, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 2, h.writeDB.Orders.PaidAt.ColumnName().String(): now})
@ -289,210 +282,6 @@ func (h *handler) JoinLottery() core.HandlerFunc {
} }
} }
} }
if cfgMode == "instant" {
ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(orderNo)).First()
if ord != nil {
// 解析次数
slotsIdx, slotsCnt := parseSlotsCountsFromRemark(ord.Remark)
dc := func() int64 {
if len(slotsIdx) > 0 && len(slotsIdx) == len(slotsCnt) {
var s int64
for i := range slotsCnt {
if slotsCnt[i] > 0 {
s += slotsCnt[i]
}
}
if s > 0 {
return s
}
}
remark := ord.Remark
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 6 && seg[:6] == "count:" {
var n int64
for j := 6; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
if n <= 0 {
return 1
}
return n
}
p = i + 1
}
}
return 1
}()
sel := strat.NewDefault(h.readDB, h.writeDB)
logs, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(ord.ID)).Find()
done := int64(len(logs))
rem := make([]int64, len(slotsCnt))
copy(rem, slotsCnt)
cur := 0
for i := done; i < dc; i++ {
rid := int64(0)
var e2 error
if activity.PlayType == "ichiban" {
slot := func() int64 {
if len(slotsIdx) > 0 && len(slotsIdx) == len(rem) {
for cur < len(rem) && rem[cur] == 0 {
cur++
}
if cur >= len(rem) {
return -1
}
rem[cur]--
return slotsIdx[cur] - 1
}
return parseSlotFromRemark(ord.Remark)
}()
if slot >= 0 {
var cnt int64
_ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id=? AND slot_index=?", req.IssueID, slot).Scan(&cnt).Error
if cnt > 0 {
break
}
e := h.repo.GetDbW().Exec("INSERT INTO issue_position_claims (issue_id, slot_index, user_id, order_id, created_at) VALUES (?,?,?,?,NOW(3))", req.IssueID, slot, userID, ord.ID).Error
if e != nil {
break
}
}
}
var proof map[string]any
var rw *model.ActivityRewardSettings
var log *model.ActivityDrawLogs
if activity.PlayType == "ichiban" {
slot := parseSlotFromRemark(ord.Remark)
rid, proof, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), req.ActivityID, req.IssueID, slot)
if e2 == nil && rid > 0 {
rw, _ = h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First()
if rw != nil {
_ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid)
// 创建抽奖日志
log = &model.ActivityDrawLogs{UserID: userID, IssueID: req.IssueID, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1}
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log)
// 保存凭证
_ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, req.IssueID, userID, proof)
// ... 道具卡逻辑
}
}
} else {
rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), req.ActivityID, req.IssueID, userID)
if e2 == nil && rid > 0 {
rw, _ = h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First()
if rw != nil {
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &req.ActivityID, RewardID: &rid, Remark: rw.Name})
// 创建抽奖日志
log = &model.ActivityDrawLogs{UserID: userID, IssueID: req.IssueID, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1}
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log)
// 保存凭证
_ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, req.IssueID, userID, proof)
// ... 道具卡逻辑
}
}
}
if log == nil {
break
}
// 道具卡效果(奖励倍数/概率提升的简单实现:奖励倍数=额外发同奖品;概率提升=尝试升级到更高等级)
fmt.Printf("[道具卡-JoinLottery] 开始检查 活动允许道具卡=%t 请求道具卡ID=%v\n", activity.AllowItemCards, req.ItemCardID)
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
fmt.Printf("[道具卡-JoinLottery] 查询用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, *req.ItemCardID)
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(*req.ItemCardID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).First()
if uic != nil {
fmt.Printf("[道具卡-JoinLottery] 找到用户道具卡 ID=%d CardID=%d Status=%d ValidStart=%s ValidEnd=%s\n", uic.ID, uic.CardID, uic.Status, uic.ValidStart.Format(time.RFC3339), uic.ValidEnd.Format(time.RFC3339))
ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.SystemItemCards.ID.Eq(uic.CardID), h.readDB.SystemItemCards.Status.Eq(1)).First()
now := time.Now()
if ic != nil {
fmt.Printf("[道具卡-JoinLottery] 找到系统道具卡 ID=%d Name=%s EffectType=%d RewardMultiplierX1000=%d ScopeType=%d ActivityID=%d IssueID=%d Status=%d\n", ic.ID, ic.Name, ic.EffectType, ic.RewardMultiplierX1000, ic.ScopeType, ic.ActivityID, ic.IssueID, ic.Status)
fmt.Printf("[道具卡-JoinLottery] 时间检查 当前时间=%s ValidStart.After(now)=%t ValidEnd.Before(now)=%t\n", now.Format(time.RFC3339), uic.ValidStart.After(now), uic.ValidEnd.Before(now))
if !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == req.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == req.IssueID)
fmt.Printf("[道具卡-JoinLottery] 范围检查 ScopeType=%d 请求ActivityID=%d 请求IssueID=%d scopeOK=%t\n", ic.ScopeType, req.ActivityID, req.IssueID, scopeOK)
if scopeOK {
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { // ×2及以上额外发一次相同奖品
fmt.Printf("[道具卡-JoinLottery] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s PlayType=%s\n", ic.RewardMultiplierX1000, rid, rw.Name, activity.PlayType)
if activity.PlayType == "ichiban" {
_ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid)
} else {
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &req.ActivityID, RewardID: &rid, Remark: rw.Name + "(倍数)"})
}
fmt.Printf("[道具卡-JoinLottery] ✅ 双倍奖励发放完成\n")
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { // 概率提升:尝试升级到更高等级的可用奖品
fmt.Printf("[道具卡-JoinLottery] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
uprw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.IssueID.Eq(req.IssueID)).Find()
var better *model.ActivityRewardSettings
for _, r := range uprw {
if r.Level < rw.Level && r.Quantity != 0 {
if better == nil || r.Level < better.Level {
better = r
}
}
}
if better != nil {
// 以boost率决定升级
if rand.Int31n(1000) < ic.BoostRateX1000 {
rid2 := better.ID
fmt.Printf("[道具卡-JoinLottery] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", rid2, better.Name)
if activity.PlayType == "ichiban" {
_ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid2)
} else {
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: better.ProductID, Quantity: 1, ActivityID: &req.ActivityID, RewardID: &rid2, Remark: better.Name + "(升级)"})
}
// 创建抽奖日志并保存凭据
drawLog := &model.ActivityDrawLogs{UserID: userID, IssueID: req.IssueID, OrderID: ord.ID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1}
if err := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog); err != nil {
fmt.Printf("[道具卡-JoinLottery] ❌ 创建抽奖日志失败 err=%v\n", err)
} else {
if err := strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog.ID, req.IssueID, userID, proof); err != nil {
fmt.Printf("[道具卡-JoinLottery] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v\n", drawLog.ID, req.IssueID, userID, err)
} else {
fmt.Printf("[道具卡-JoinLottery] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, req.IssueID)
}
}
} else {
fmt.Printf("[道具卡-JoinLottery] 概率提升未触发\n")
}
} else {
fmt.Printf("[道具卡-JoinLottery] 没有找到更好的奖品可升级\n")
}
} else {
fmt.Printf("[道具卡-JoinLottery] ⚠️ 道具卡效果类型不匹配或倍数不足 EffectType=%d RewardMultiplierX1000=%d\n", ic.EffectType, ic.RewardMultiplierX1000)
}
// 核销道具卡
fmt.Printf("[道具卡-JoinLottery] 核销道具卡 用户道具卡ID=%d\n", *req.ItemCardID)
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(*req.ItemCardID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).Updates(map[string]any{
h.readDB.UserItemCards.Status.ColumnName().String(): 2,
h.readDB.UserItemCards.UsedDrawLogID.ColumnName().String(): log.ID,
h.readDB.UserItemCards.UsedActivityID.ColumnName().String(): req.ActivityID,
h.readDB.UserItemCards.UsedIssueID.ColumnName().String(): req.IssueID,
h.readDB.UserItemCards.UsedAt.ColumnName().String(): time.Now(),
})
} else {
fmt.Printf("[道具卡-JoinLottery] ❌ 范围检查失败\n")
}
} else {
fmt.Printf("[道具卡-JoinLottery] ❌ 时间检查失败\n")
}
} else {
fmt.Printf("[道具卡-JoinLottery] ❌ 未找到系统道具卡 CardID=%d\n", uic.CardID)
}
} else {
fmt.Printf("[道具卡-JoinLottery] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, *req.ItemCardID)
}
} else {
fmt.Printf("[道具卡-JoinLottery] 跳过道具卡检查\n")
}
}
}
}
rsp.Queued = true rsp.Queued = true
} else { } else {
rsp.Queued = true rsp.Queued = true
@ -600,6 +389,8 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170006, "order not paid")) ctx.AbortWithError(core.Error(http.StatusBadRequest, 170006, "order not paid"))
return return
} }
// Daily Seed logic removed to ensure strict adherence to CommitmentSeedMaster
if actCommit.PlayType == "ichiban" { if actCommit.PlayType == "ichiban" {
slot := parseSlotFromRemark(ord.Remark) slot := parseSlotFromRemark(ord.Remark)
if slot >= 0 { if slot >= 0 {
@ -634,20 +425,14 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log) _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log)
_ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, issueID, userID, proof) _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, issueID, userID, proof)
icID := parseItemCardIDFromRemark(ord.Remark) icID := parseItemCardIDFromRemark(ord.Remark)
fmt.Printf("[道具卡-GetLotteryResult] 从订单备注解析道具卡ID icID=%d 订单备注=%s\n", icID, ord.Remark)
if icID > 0 { if icID > 0 {
fmt.Printf("[道具卡-GetLotteryResult] 查询用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, icID)
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).First() uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).First()
if uic != nil { if uic != nil {
fmt.Printf("[道具卡-GetLotteryResult] 找到用户道具卡 ID=%d CardID=%d Status=%d ValidStart=%s ValidEnd=%s\n", uic.ID, uic.CardID, uic.Status, uic.ValidStart.Format(time.RFC3339), uic.ValidEnd.Format(time.RFC3339))
ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.SystemItemCards.ID.Eq(uic.CardID), h.readDB.SystemItemCards.Status.Eq(1)).First() ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.SystemItemCards.ID.Eq(uic.CardID), h.readDB.SystemItemCards.Status.Eq(1)).First()
now := time.Now() now := time.Now()
if ic != nil { if ic != nil {
fmt.Printf("[道具卡-GetLotteryResult] 找到系统道具卡 ID=%d Name=%s EffectType=%d RewardMultiplierX1000=%d ScopeType=%d ActivityID=%d IssueID=%d Status=%d\n", ic.ID, ic.Name, ic.EffectType, ic.RewardMultiplierX1000, ic.ScopeType, ic.ActivityID, ic.IssueID, ic.Status)
fmt.Printf("[道具卡-GetLotteryResult] 时间检查 当前时间=%s ValidStart.After(now)=%t ValidEnd.Before(now)=%t\n", now.Format(time.RFC3339), uic.ValidStart.After(now), uic.ValidEnd.Before(now))
if !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) { if !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == activityID) || (ic.ScopeType == 4 && ic.IssueID == issueID) scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == activityID) || (ic.ScopeType == 4 && ic.IssueID == issueID)
fmt.Printf("[道具卡-GetLotteryResult] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, activityID, issueID, scopeOK)
if scopeOK { if scopeOK {
eff := &model.ActivityDrawEffects{ eff := &model.ActivityDrawEffects{
DrawLogID: log.ID, DrawLogID: log.ID,
@ -665,13 +450,9 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
IssueID: issueID, IssueID: issueID,
} }
_ = h.writeDB.ActivityDrawEffects.WithContext(ctx.RequestContext()).Create(eff) _ = h.writeDB.ActivityDrawEffects.WithContext(ctx.RequestContext()).Create(eff)
fmt.Printf("[道具卡-GetLotteryResult] 创建道具卡效果记录 EffectID=%d\n", eff.ID)
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
fmt.Printf("[道具卡-GetLotteryResult] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s\n", ic.RewardMultiplierX1000, rid, rw.Name)
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: orderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name + "(倍数)"}) _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: orderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name + "(倍数)"})
fmt.Printf("[道具卡-GetLotteryResult] ✅ 双倍奖励发放完成\n")
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { } else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
fmt.Printf("[道具卡-GetLotteryResult] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
uprw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.IssueID.Eq(issueID)).Find() uprw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.IssueID.Eq(issueID)).Find()
var better *model.ActivityRewardSettings var better *model.ActivityRewardSettings
for _, r := range uprw { for _, r := range uprw {
@ -683,41 +464,29 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
} }
if better != nil && rand.Int31n(1000) < ic.BoostRateX1000 { if better != nil && rand.Int31n(1000) < ic.BoostRateX1000 {
rid2 := better.ID rid2 := better.ID
fmt.Printf("[道具卡-GetLotteryResult] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", rid2, better.Name)
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: orderID, ProductID: better.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid2, Remark: better.Name + "(升级)"}) _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: orderID, ProductID: better.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid2, Remark: better.Name + "(升级)"})
// 创建升级后的抽奖日志并保存凭据 // 创建升级后的抽奖日志并保存凭据
drawLog2 := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: orderID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1} drawLog2 := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: orderID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1}
if err := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog2); err != nil { if err := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog2); err != nil {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 创建升级抽奖日志失败 err=%v\n", err)
} else { } else {
if err := strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog2.ID, issueID, userID, proof); err != nil { if err := strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog2.ID, issueID, userID, proof); err != nil {
fmt.Printf("[道具卡-GetLotteryResult] ⚠️ 保存升级凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v\n", drawLog2.ID, issueID, userID, err)
} else { } else {
fmt.Printf("[道具卡-GetLotteryResult] ✅ 保存升级凭据成功 DrawLogID=%d IssueID=%d\n", drawLog2.ID, issueID)
} }
} }
} else { } else {
fmt.Printf("[道具卡-GetLotteryResult] 概率提升未触发\n")
} }
} else { } else {
fmt.Printf("[道具卡-GetLotteryResult] ⚠️ 道具卡效果类型不匹配或倍数不足 EffectType=%d RewardMultiplierX1000=%d\n", ic.EffectType, ic.RewardMultiplierX1000)
} }
fmt.Printf("[道具卡-GetLotteryResult] 核销道具卡 用户道具卡ID=%d\n", icID)
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).Updates(map[string]any{h.readDB.UserItemCards.Status.ColumnName().String(): 2, h.readDB.UserItemCards.UsedDrawLogID.ColumnName().String(): log.ID, h.readDB.UserItemCards.UsedActivityID.ColumnName().String(): activityID, h.readDB.UserItemCards.UsedIssueID.ColumnName().String(): issueID, h.readDB.UserItemCards.UsedAt.ColumnName().String(): time.Now()}) _, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).Updates(map[string]any{h.readDB.UserItemCards.Status.ColumnName().String(): 2, h.readDB.UserItemCards.UsedDrawLogID.ColumnName().String(): log.ID, h.readDB.UserItemCards.UsedActivityID.ColumnName().String(): activityID, h.readDB.UserItemCards.UsedIssueID.ColumnName().String(): issueID, h.readDB.UserItemCards.UsedAt.ColumnName().String(): time.Now()})
} else { } else {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 范围检查失败\n")
} }
} else { } else {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 时间检查失败\n")
} }
} else { } else {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 未找到系统道具卡 CardID=%d\n", uic.CardID)
} }
} else { } else {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, icID)
} }
} else { } else {
fmt.Printf("[道具卡-GetLotteryResult] 订单备注中没有道具卡ID\n")
} }
rsp.Result = map[string]any{"reward_id": rid, "reward_name": func() string { rsp.Result = map[string]any{"reward_id": rid, "reward_name": func() string {
if rw != nil { if rw != nil {
@ -749,143 +518,6 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
} }
} }
func (h *handler) randomID(prefix string) string {
now := time.Now()
return prefix + now.Format("20060102150405")
}
func (h *handler) orderModel(userID int64, orderNo string, amount int64, activityID int64, issueID int64, count int64) *model.Orders {
return &model.Orders{UserID: userID, OrderNo: orderNo, SourceType: 2, TotalAmount: amount, DiscountAmount: 0, PointsAmount: 0, ActualAmount: amount, Status: 1, IsConsumed: 0, Remark: fmt.Sprintf("lottery:activity:%d|issue:%d|count:%d", activityID, issueID, count)}
}
func parseSlotFromRemark(remark string) int64 {
if remark == "" {
return -1
}
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 5 && seg[:5] == "slot:" {
var n int64
for j := 5; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
p = i + 1
}
}
if p < len(remark) {
seg := remark[p:]
if len(seg) > 5 && seg[:5] == "slot:" {
var n int64
for j := 5; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
}
return -1
}
func parseItemCardIDFromRemark(remark string) int64 {
// remark segments separated by '|', find segment starting with "itemcard:"
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 9 && seg[:9] == "itemcard:" {
var n int64
for j := 9; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
if n > 0 {
return n
}
}
p = i + 1
}
}
return 0
}
func (h *handler) joinSigPayload(userID int64, issueID int64, ts int64, nonce int64) string {
return fmt.Sprintf("%d|%d|%d|%d", userID, issueID, ts, nonce)
}
func buildSlotsRemarkWithScalarCount(slots []int64) string {
s := ""
for i := range slots {
if i > 0 {
s += ","
}
s += fmt.Sprintf("%d:%d", slots[i]-1, 1)
}
return s
}
func parseSlotsCountsFromRemark(remark string) ([]int64, []int64) {
if remark == "" {
return nil, nil
}
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 6 && seg[:6] == "slots:" {
pairs := seg[6:]
idxs := make([]int64, 0)
cnts := make([]int64, 0)
start := 0
for start <= len(pairs) {
end := start
for end < len(pairs) && pairs[end] != ',' {
end++
}
if end > start {
a := pairs[start:end]
// a format: num:num
x, y := int64(0), int64(0)
j := 0
for j < len(a) && a[j] >= '0' && a[j] <= '9' {
x = x*10 + int64(a[j]-'0')
j++
}
if j < len(a) && a[j] == ':' {
j++
for j < len(a) && a[j] >= '0' && a[j] <= '9' {
y = y*10 + int64(a[j]-'0')
j++
}
}
if y > 0 {
idxs = append(idxs, x+1)
cnts = append(cnts, y)
}
}
start = end + 1
}
return idxs, cnts
}
p = i + 1
}
}
return nil, nil
}
// validateIchibanSlots 一番赏格位校验 // validateIchibanSlots 一番赏格位校验
// 功能:校验请求中的格位选择是否有效(数量匹配、范围合法、未被占用) // 功能:校验请求中的格位选择是否有效(数量匹配、范围合法、未被占用)
// 参数: // 参数:
@ -922,379 +554,3 @@ func (h *handler) validateIchibanSlots(ctx core.Context, req *joinLotteryRequest
} }
return nil return nil
} }
// applyCouponWithCap 优惠券抵扣含50%封顶与金额券部分使用)
// 功能在订单上应用一张用户券实施总价50%封顶;金额券支持“部分使用”,在 remark 记录明细
// 参数:
// - ctx请求上下文
// - userID用户ID
// - order待更新的订单对象入参引用被本函数更新 discount_amount/actual_amount/remark
// - activityID活动ID用于范围校验
// - userCouponID用户持券ID
//
// 返回本次实际应用的抵扣金额若不适用或受封顶为0则返回0
func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *model.Orders, activityID int64, userCouponID int64) int64 {
uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(userCouponID), h.readDB.UserCoupons.UserID.Eq(userID), h.readDB.UserCoupons.Status.Eq(1)).First()
if uc == nil {
return 0
}
fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string {
if uc.ValidEnd.IsZero() {
return "无截止"
}
return uc.ValidEnd.Format(time.RFC3339)
}())
sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID), h.readDB.SystemCoupons.Status.Eq(1)).First()
now := time.Now()
if sc == nil {
fmt.Printf("[优惠券] 模板不存在 用户券ID=%d\n", userCouponID)
return 0
}
if uc.ValidStart.After(now) {
fmt.Printf("[优惠券] 未到开始时间 用户券ID=%d 当前=%s\n", userCouponID, now.Format(time.RFC3339))
return 0
}
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
fmt.Printf("[优惠券] 已过期 用户券ID=%d 当前=%s\n", userCouponID, now.Format(time.RFC3339))
return 0
}
scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID)
if !scopeOK {
fmt.Printf("[优惠券] 范围不匹配 用户券ID=%d scope_type=%d 模板活动ID=%d 当前活动ID=%d\n", userCouponID, sc.ScopeType, sc.ActivityID, activityID)
return 0
}
if order.TotalAmount < sc.MinSpend {
fmt.Printf("[优惠券] 未达使用门槛 用户券ID=%d min_spend(分)=%d 订单总额(分)=%d\n", userCouponID, sc.MinSpend, order.TotalAmount)
return 0
}
cap := order.TotalAmount / 2
remainingCap := cap - order.DiscountAmount
if remainingCap <= 0 {
fmt.Printf("[优惠券] 已达封顶 封顶(分)=%d 剩余封顶(分)=0\n", cap)
return 0
}
fmt.Printf("[优惠券] 计算前 类型=%d 面值/折扣=%d 封顶(分)=%d 剩余封顶(分)=%d 当前实付(分)=%d 累计优惠(分)=%d\n", sc.DiscountType, sc.DiscountValue, cap, remainingCap, order.ActualAmount, order.DiscountAmount)
applied := int64(0)
switch sc.DiscountType {
case 1:
var bal int64
_ = h.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", userCouponID).Scan(&bal).Error
if bal <= 0 {
bal = sc.DiscountValue
}
fmt.Printf("[优惠券] 金额券余额(分)=%d 模板面值(分)=%d\n", bal, sc.DiscountValue)
if bal > 0 {
if bal > remainingCap {
applied = remainingCap
} else {
applied = bal
}
}
case 2:
applied = sc.DiscountValue
if applied > remainingCap {
applied = remainingCap
}
fmt.Printf("[优惠券] 满减券 应用金额(分)=%d\n", applied)
case 3:
rate := sc.DiscountValue
if rate < 0 {
rate = 0
}
if rate > 1000 {
rate = 1000
}
newAmt := order.ActualAmount * rate / 1000
d := order.ActualAmount - newAmt
if d > remainingCap {
applied = remainingCap
} else {
applied = d
}
fmt.Printf("[优惠券] 折扣券 折扣千分比=%d 抵扣(分)=%d\n", rate, applied)
}
if applied > order.ActualAmount {
applied = order.ActualAmount
}
fmt.Printf("[优惠券] 本次抵扣(分)=%d\n", applied)
if applied <= 0 {
return 0
}
order.DiscountAmount += applied
order.ActualAmount -= applied
order.Remark = order.Remark + fmt.Sprintf("|c:%d:%d", userCouponID, applied)
fmt.Printf("[优惠券] 应用后 累计优惠(分)=%d 订单实付(分)=%d\n", order.DiscountAmount, order.ActualAmount)
return applied
}
// updateUserCouponAfterApply 应用后更新用户券(扣减余额或核销)
// 功能:根据订单 remark 中记录的 applied_amount
//
// 对直金额券扣减余额并在余额为0时核销满减/折扣券一次性核销
//
// 参数:
// - ctx请求上下文
// - userID用户ID
// - order订单用于读取 remark 和写入 used_order_id
// - userCouponID用户持券ID
//
// 返回:无
func (h *handler) updateUserCouponAfterApply(ctx core.Context, userID int64, order *model.Orders, userCouponID int64) {
uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(userCouponID), h.readDB.UserCoupons.UserID.Eq(userID), h.readDB.UserCoupons.Status.Eq(1)).First()
if uc == nil {
return
}
sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID), h.readDB.SystemCoupons.Status.Eq(1)).First()
if sc == nil {
return
}
applied := int64(0)
remark := order.Remark
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 2 && seg[:2] == "c:" {
j := 2
var id int64
for j < len(seg) && seg[j] >= '0' && seg[j] <= '9' {
id = id*10 + int64(seg[j]-'0')
j++
}
if j < len(seg) && seg[j] == ':' {
j++
var amt int64
for j < len(seg) && seg[j] >= '0' && seg[j] <= '9' {
amt = amt*10 + int64(seg[j]-'0')
j++
}
if id == userCouponID {
applied = amt
}
}
}
p = i + 1
}
}
if sc.DiscountType == 1 {
var bal int64
_ = h.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", userCouponID).Scan(&bal).Error
newBal := bal - applied
if newBal < 0 {
newBal = 0
}
if newBal == 0 {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{"balance_amount": newBal, h.writeDB.UserCoupons.Status.ColumnName().String(): 2, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
} else {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{"balance_amount": newBal, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
}
} else {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{h.writeDB.UserCoupons.Status.ColumnName().String(): 2, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
}
}
// markOrderPaid 将订单标记为已支付
// 功能用于0元订单直接置为已支付并写入支付时间
// 参数:
// - ctx请求上下文
// - orderNo订单号
//
// 返回:无
func (h *handler) markOrderPaid(ctx core.Context, orderNo string) {
now := time.Now()
_, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 2, h.writeDB.Orders.PaidAt.ColumnName().String(): now})
}
// processInstantDraw 即时抽奖流程
// 功能:在订单已支付情况下,执行抽奖与发奖;支持一番赏固定格位与普通模式,处理道具卡效果
// 参数:
// - ctx请求上下文
// - userID用户ID
// - activity活动实体用于判断玩法
// - activityID活动ID
// - issueID期ID
// - orderNo订单号
// - itemCardID道具卡ID可选
//
// 返回:无
func (h *handler) processInstantDraw(ctx core.Context, userID int64, activity *model.Activities, activityID int64, issueID int64, orderNo string, itemCardID *int64) {
ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(orderNo)).First()
if ord == nil {
return
}
slotsIdx, slotsCnt := parseSlotsCountsFromRemark(ord.Remark)
dc := func() int64 {
if len(slotsIdx) > 0 && len(slotsIdx) == len(slotsCnt) {
var s int64
for i := range slotsCnt {
if slotsCnt[i] > 0 {
s += slotsCnt[i]
}
}
if s > 0 {
return s
}
}
remark := ord.Remark
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 6 && seg[:6] == "count:" {
var n int64
for j := 6; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
if n <= 0 {
return 1
}
return n
}
p = i + 1
}
}
return 1
}()
sel := strat.NewDefault(h.readDB, h.writeDB)
logs, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(ord.ID)).Find()
done := int64(len(logs))
rem := make([]int64, len(slotsCnt))
copy(rem, slotsCnt)
cur := 0
for i := done; i < dc; i++ {
rid := int64(0)
var e2 error
if activity.PlayType == "ichiban" {
// ... (inside loop)
var proof map[string]any
if activity.PlayType == "ichiban" {
slot := func() int64 {
// ... (existing slot parsing logic)
if len(slotsIdx) > 0 && len(slotsIdx) == len(rem) {
for cur < len(rem) && rem[cur] == 0 {
cur++
}
if cur >= len(rem) {
return -1
}
rem[cur]--
return slotsIdx[cur] - 1
}
return parseSlotFromRemark(ord.Remark)
}()
if slot >= 0 {
var cnt int64
_ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id=? AND slot_index=?", issueID, slot).Scan(&cnt).Error
if cnt > 0 {
break
}
e := h.repo.GetDbW().Exec("INSERT INTO issue_position_claims (issue_id, slot_index, user_id, order_id, created_at) VALUES (?,?,?,?,NOW(3))", issueID, slot, userID, ord.ID).Error
if e != nil {
break
}
rid, proof, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), activityID, issueID, slot)
} else {
rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID)
}
} else {
rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID)
}
if e2 != nil || rid <= 0 {
break
}
rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First()
if rw == nil {
break
}
if activity.PlayType == "ichiban" {
_ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid)
} else {
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name})
}
log := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1}
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log)
_ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, issueID, userID, proof)
fmt.Printf("[道具卡-processInstantDraw] 开始检查 活动允许道具卡=%t itemCardID=%v\n", activity.AllowItemCards, itemCardID)
if activity.AllowItemCards && itemCardID != nil && *itemCardID > 0 {
fmt.Printf("[道具卡-processInstantDraw] 查询用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, *itemCardID)
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(*itemCardID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).First()
if uic != nil {
fmt.Printf("[道具卡-processInstantDraw] 找到用户道具卡 ID=%d CardID=%d Status=%d ValidStart=%s ValidEnd=%s\n", uic.ID, uic.CardID, uic.Status, uic.ValidStart.Format(time.RFC3339), uic.ValidEnd.Format(time.RFC3339))
ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.SystemItemCards.ID.Eq(uic.CardID), h.readDB.SystemItemCards.Status.Eq(1)).First()
now := time.Now()
if ic != nil {
fmt.Printf("[道具卡-processInstantDraw] 找到系统道具卡 ID=%d Name=%s EffectType=%d RewardMultiplierX1000=%d ScopeType=%d ActivityID=%d IssueID=%d Status=%d\n", ic.ID, ic.Name, ic.EffectType, ic.RewardMultiplierX1000, ic.ScopeType, ic.ActivityID, ic.IssueID, ic.Status)
fmt.Printf("[道具卡-processInstantDraw] 时间检查 当前时间=%s ValidStart.After(now)=%t ValidEnd.Before(now)=%t\n", now.Format(time.RFC3339), uic.ValidStart.After(now), uic.ValidEnd.Before(now))
if !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == activityID) || (ic.ScopeType == 4 && ic.IssueID == issueID)
fmt.Printf("[道具卡-processInstantDraw] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, activityID, issueID, scopeOK)
if scopeOK {
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
fmt.Printf("[道具卡-processInstantDraw] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s PlayType=%s\n", ic.RewardMultiplierX1000, rid, rw.Name, activity.PlayType)
if activity.PlayType == "ichiban" {
_ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid)
} else {
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name + "(倍数)"})
}
fmt.Printf("[道具卡-processInstantDraw] ✅ 双倍奖励发放完成\n")
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
fmt.Printf("[道具卡-processInstantDraw] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
uprw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.IssueID.Eq(issueID)).Find()
var better *model.ActivityRewardSettings
for _, r := range uprw {
if r.Level < rw.Level && r.Quantity != 0 {
if better == nil || r.Level < better.Level {
better = r
}
}
}
if better != nil && rand.Int31n(1000) < ic.BoostRateX1000 {
rid2 := better.ID
fmt.Printf("[道具卡-processInstantDraw] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", rid2, better.Name)
if activity.PlayType == "ichiban" {
_ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid2)
} else {
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: better.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid2, Remark: better.Name + "(升级)"})
}
// 创建升级后的抽奖日志并保存凭据
drawLog2 := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: ord.ID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1}
if err := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog2); err != nil {
fmt.Printf("[道具卡-processInstantDraw] ❌ 创建升级抽奖日志失败 err=%v\n", err)
} else {
if err := strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog2.ID, issueID, userID, proof); err != nil {
fmt.Printf("[道具卡-processInstantDraw] ⚠️ 保存升级凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v\n", drawLog2.ID, issueID, userID, err)
} else {
fmt.Printf("[道具卡-processInstantDraw] ✅ 保存升级凭据成功 DrawLogID=%d IssueID=%d\n", drawLog2.ID, issueID)
}
}
} else {
fmt.Printf("[道具卡-processInstantDraw] 概率提升未触发\n")
}
} else {
fmt.Printf("[道具卡-processInstantDraw] ⚠️ 道具卡效果类型不匹配或倍数不足 EffectType=%d RewardMultiplierX1000=%d\n", ic.EffectType, ic.RewardMultiplierX1000)
}
fmt.Printf("[道具卡-processInstantDraw] 核销道具卡 用户道具卡ID=%d\n", *itemCardID)
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(*itemCardID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).Updates(map[string]any{h.readDB.UserItemCards.Status.ColumnName().String(): 2, h.readDB.UserItemCards.UsedDrawLogID.ColumnName().String(): log.ID, h.readDB.UserItemCards.UsedActivityID.ColumnName().String(): activityID, h.readDB.UserItemCards.UsedIssueID.ColumnName().String(): issueID, h.readDB.UserItemCards.UsedAt.ColumnName().String(): time.Now()})
} else {
fmt.Printf("[道具卡-processInstantDraw] ❌ 范围检查失败\n")
}
} else {
fmt.Printf("[道具卡-processInstantDraw] ❌ 时间检查失败\n")
}
} else {
fmt.Printf("[道具卡-processInstantDraw] ❌ 未找到系统道具卡 CardID=%d\n", uic.CardID)
}
} else {
fmt.Printf("[道具卡-processInstantDraw] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, *itemCardID)
}
} else {
fmt.Printf("[道具卡-processInstantDraw] 跳过道具卡检查\n")
}
}
}
}

View File

@ -0,0 +1,387 @@
package app
import (
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/repository/mysql/model"
"fmt"
"time"
)
// applyCouponWithCap 优惠券抵扣含50%封顶与金额券部分使用)
// 功能在订单上应用一张用户券实施总价50%封顶;金额券支持“部分使用”,在 remark 记录明细
// 参数:
// - ctx请求上下文
// - userID用户ID
// - order待更新的订单对象入参引用被本函数更新 discount_amount/actual_amount/remark
// - activityID活动ID用于范围校验
// - userCouponID用户持券ID
//
// 返回本次实际应用的抵扣金额若不适用或受封顶为0则返回0
func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *model.Orders, activityID int64, userCouponID int64) int64 {
uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(userCouponID), h.readDB.UserCoupons.UserID.Eq(userID), h.readDB.UserCoupons.Status.Eq(1)).First()
if uc == nil {
return 0
}
sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID), h.readDB.SystemCoupons.Status.Eq(1)).First()
now := time.Now()
if sc == nil {
return 0
}
if uc.ValidStart.After(now) {
return 0
}
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
return 0
}
scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID)
if !scopeOK {
return 0
}
if order.TotalAmount < sc.MinSpend {
return 0
}
cap := order.TotalAmount / 2
remainingCap := cap - order.DiscountAmount
if remainingCap <= 0 {
return 0
}
applied := int64(0)
switch sc.DiscountType {
case 1:
var bal int64
_ = h.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", userCouponID).Scan(&bal).Error
if bal <= 0 {
bal = sc.DiscountValue
}
if bal > 0 {
if bal > remainingCap {
applied = remainingCap
} else {
applied = bal
}
}
case 2:
applied = sc.DiscountValue
if applied > remainingCap {
applied = remainingCap
}
case 3:
rate := sc.DiscountValue
if rate < 0 {
rate = 0
}
if rate > 1000 {
rate = 1000
}
newAmt := order.ActualAmount * rate / 1000
d := order.ActualAmount - newAmt
if d > remainingCap {
applied = remainingCap
} else {
applied = d
}
}
if applied > order.ActualAmount {
applied = order.ActualAmount
}
if applied <= 0 {
return 0
}
order.DiscountAmount += applied
order.ActualAmount -= applied
order.Remark = order.Remark + fmt.Sprintf("|c:%d:%d", userCouponID, applied)
return applied
}
// updateUserCouponAfterApply 应用后更新用户券(扣减余额或核销)
// 功能:根据订单 remark 中记录的 applied_amount
//
// 对直金额券扣减余额并在余额为0时核销满减/折扣券一次性核销
//
// 参数:
// - ctx请求上下文
// - userID用户ID
// - order订单用于读取 remark 和写入 used_order_id
// - userCouponID用户持券ID
//
// 返回:无
func (h *handler) updateUserCouponAfterApply(ctx core.Context, userID int64, order *model.Orders, userCouponID int64) {
uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(userCouponID), h.readDB.UserCoupons.UserID.Eq(userID), h.readDB.UserCoupons.Status.Eq(1)).First()
if uc == nil {
return
}
sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID), h.readDB.SystemCoupons.Status.Eq(1)).First()
if sc == nil {
return
}
applied := int64(0)
remark := order.Remark
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 2 && seg[:2] == "c:" {
j := 2
var id int64
for j < len(seg) && seg[j] >= '0' && seg[j] <= '9' {
id = id*10 + int64(seg[j]-'0')
j++
}
if j < len(seg) && seg[j] == ':' {
j++
var amt int64
for j < len(seg) && seg[j] >= '0' && seg[j] <= '9' {
amt = amt*10 + int64(seg[j]-'0')
j++
}
if id == userCouponID {
applied = amt
}
}
}
p = i + 1
}
}
if sc.DiscountType == 1 {
var bal int64
_ = h.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", userCouponID).Scan(&bal).Error
newBal := bal - applied
if newBal < 0 {
newBal = 0
}
if newBal == 0 {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{"balance_amount": newBal, h.writeDB.UserCoupons.Status.ColumnName().String(): 2, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
} else {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{"balance_amount": newBal, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
}
} else {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.UserCoupons.ID.Eq(userCouponID), h.writeDB.UserCoupons.UserID.Eq(userID)).Updates(map[string]any{h.writeDB.UserCoupons.Status.ColumnName().String(): 2, h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, h.writeDB.UserCoupons.UsedAt.ColumnName().String(): time.Now()})
}
}
// markOrderPaid 将订单标记为已支付
// 功能用于0元订单直接置为已支付并写入支付时间
// 参数:
// - ctx请求上下文
// - orderNo订单号
//
// 返回:无
func (h *handler) markOrderPaid(ctx core.Context, orderNo string) {
now := time.Now()
_, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 2, h.writeDB.Orders.PaidAt.ColumnName().String(): now})
}
func (h *handler) randomID(prefix string) string {
now := time.Now()
return prefix + now.Format("20060102150405")
}
func (h *handler) orderModel(userID int64, orderNo string, amount int64, activityID int64, issueID int64, count int64) *model.Orders {
return &model.Orders{UserID: userID, OrderNo: orderNo, SourceType: 2, TotalAmount: amount, DiscountAmount: 0, PointsAmount: 0, ActualAmount: amount, Status: 1, IsConsumed: 0, Remark: fmt.Sprintf("lottery:activity:%d|issue:%d|count:%d", activityID, issueID, count)}
}
func parseSlotFromRemark(remark string) int64 {
if remark == "" {
return -1
}
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 5 && seg[:5] == "slot:" {
var n int64
for j := 5; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
p = i + 1
}
}
if p < len(remark) {
seg := remark[p:]
if len(seg) > 5 && seg[:5] == "slot:" {
var n int64
for j := 5; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
}
return -1
}
func parseItemCardIDFromRemark(remark string) int64 {
// remark segments separated by '|', find segment starting with "itemcard:"
p := 0
n := len(remark)
for i := 0; i <= n; i++ {
if i == n || remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 9 && seg[:9] == "itemcard:" {
var val int64
valid := true
for j := 9; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
valid = false
break
}
val = val*10 + int64(c-'0')
}
if valid && val > 0 {
return val
}
}
p = i + 1
}
}
return 0
}
func (h *handler) joinSigPayload(userID int64, issueID int64, ts int64, nonce int64) string {
return fmt.Sprintf("%d|%d|%d|%d", userID, issueID, ts, nonce)
}
func buildSlotsRemarkWithScalarCount(slots []int64) string {
s := ""
for i := range slots {
if i > 0 {
s += ","
}
s += fmt.Sprintf("%d:%d", slots[i]-1, 1)
}
return s
}
func parseSlotsCountsFromRemark(remark string) ([]int64, []int64) {
if remark == "" {
return nil, nil
}
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 6 && seg[:6] == "slots:" {
pairs := seg[6:]
idxs := make([]int64, 0)
cnts := make([]int64, 0)
start := 0
for start <= len(pairs) {
end := start
for end < len(pairs) && pairs[end] != ',' {
end++
}
if end > start {
a := pairs[start:end]
// a format: num:num
x, y := int64(0), int64(0)
j := 0
for j < len(a) && a[j] >= '0' && a[j] <= '9' {
x = x*10 + int64(a[j]-'0')
j++
}
if j < len(a) && a[j] == ':' {
j++
for j < len(a) && a[j] >= '0' && a[j] <= '9' {
y = y*10 + int64(a[j]-'0')
j++
}
}
if y > 0 {
idxs = append(idxs, x+1)
cnts = append(cnts, y)
}
}
start = end + 1
}
return idxs, cnts
}
p = i + 1
}
}
return nil, nil
}
func parseIssueIDFromRemark(remark string) int64 {
if remark == "" {
return 0
}
// Try "issue:" or "matching_game:issue:"
// Split by |
segs := make([]string, 0)
last := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
segs = append(segs, remark[last:i])
last = i + 1
}
}
if last < len(remark) {
segs = append(segs, remark[last:])
}
for _, seg := range segs {
// handle 'issue:123'
if len(seg) > 6 && seg[:6] == "issue:" {
var n int64
for j := 6; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
// handle 'matching_game:issue:123'
if len(seg) > 20 && seg[:20] == "matching_game:issue:" {
var n int64
for j := 20; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
}
return 0
}
func parseCountFromRemark(remark string) int64 {
if remark == "" {
return 1
}
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 6 && seg[:6] == "count:" {
var n int64
for j := 6; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
if n <= 0 {
return 1
}
return n
}
p = i + 1
}
}
return 1
}

View File

@ -119,8 +119,6 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc {
}())).First() }())).First()
if act != nil { if act != nil {
// 一次补抽 // 一次补抽
rid := int64(0)
var e2 error
if act.PlayType == "ichiban" { if act.PlayType == "ichiban" {
slot := parseSlotFromRemark(ord.Remark) slot := parseSlotFromRemark(ord.Remark)
if slot >= 0 { if slot >= 0 {
@ -129,8 +127,8 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc {
if cnt > 0 { if cnt > 0 {
st = "slot_unavailable" st = "slot_unavailable"
} else if err := h.repo.GetDbW().Exec("INSERT INTO issue_position_claims (issue_id, slot_index, user_id, order_id, created_at) VALUES (?,?,?,?,NOW(3))", iss, slot, userID, ord.ID).Error; err == nil { } else if err := h.repo.GetDbW().Exec("INSERT INTO issue_position_claims (issue_id, slot_index, user_id, order_id, created_at) VALUES (?,?,?,?,NOW(3))", iss, slot, userID, ord.ID).Error; err == nil {
var proof map[string]any // Daily Seed removed to enforce CommitmentSeedMaster
rid, proof, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), act.ID, iss, slot) rid, proof, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), act.ID, iss, slot)
if e2 == nil && rid > 0 { if e2 == nil && rid > 0 {
rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First()
if rw != nil { if rw != nil {
@ -146,8 +144,8 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc {
} }
} else { } else {
sel := strat.NewDefault(h.readDB, h.writeDB) sel := strat.NewDefault(h.readDB, h.writeDB)
var proof map[string]any // Daily Seed removed
rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), act.ID, iss, userID) rid, proof, e2 := sel.SelectItem(ctx.RequestContext(), act.ID, iss, userID)
if e2 == nil && rid > 0 { if e2 == nil && rid > 0 {
rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First()
if rw != nil { if rw != nil {
@ -257,78 +255,3 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc {
ctx.Payload(rsp) ctx.Payload(rsp)
} }
} }
func parseIssueIDFromRemark(remark string) int64 {
if remark == "" {
return 0
}
// Try "issue:" or "matching_game:issue:"
// Split by |
segs := make([]string, 0)
last := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
segs = append(segs, remark[last:i])
last = i + 1
}
}
if last < len(remark) {
segs = append(segs, remark[last:])
}
for _, seg := range segs {
// handle 'issue:123'
if len(seg) > 6 && seg[:6] == "issue:" {
var n int64
for j := 6; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
// handle 'matching_game:issue:123'
if len(seg) > 20 && seg[:20] == "matching_game:issue:" {
var n int64
for j := 20; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
}
return 0
}
func parseCountFromRemark(remark string) int64 {
if remark == "" {
return 1
}
p := 0
for i := 0; i < len(remark); i++ {
if remark[i] == '|' {
seg := remark[p:i]
if len(seg) > 6 && seg[:6] == "count:" {
var n int64
for j := 6; j < len(seg); j++ {
c := seg[j]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
if n <= 0 {
return 1
}
return n
}
p = i + 1
}
}
return 1
}

View File

@ -1,291 +1,25 @@
package app package app
import ( import (
"bindbox-game/configs"
"bindbox-game/internal/code" "bindbox-game/internal/code"
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
activitysvc "bindbox-game/internal/service/activity"
"context" "context"
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"sync"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
activitysvc "bindbox-game/internal/service/activity"
usersvc "bindbox-game/internal/service/user"
) )
// CardType 卡牌类型
type CardType string
// CardTypeConfig 卡牌类型配置(从数据库加载)
type CardTypeConfig struct {
Code CardType `json:"code"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
Quantity int32 `json:"quantity"`
}
// MatchingCard 游戏中的卡牌实例
type MatchingCard struct {
ID string `json:"id"`
Type CardType `json:"type"`
}
// MatchingGame 对对碰游戏结构
type MatchingGame struct {
Mu sync.Mutex `json:"-"` // 互斥锁保护并发访问
ServerSeed []byte `json:"server_seed"`
ServerSeedHash string `json:"server_seed_hash"`
Nonce int64 `json:"nonce"`
CardConfigs []CardTypeConfig `json:"card_configs"`
Deck []*MatchingCard `json:"deck"` // 牌堆 (预生成的卡牌对象)
Board [9]*MatchingCard `json:"board"` // 固定9格棋盘
CardIDCounter int64 `json:"card_id_counter"` // 用于生成唯一ID
TotalPairs int64 `json:"total_pairs"`
MaxPossiblePairs int64 `json:"max_possible_pairs"` // 最大可能消除对数 (安全校验)
Round int64 `json:"round"`
RoundHistory []MatchingRoundResult `json:"round_history"`
LastActivity time.Time `json:"last_activity"`
// Context info for reward granting
ActivityID int64 `json:"activity_id"`
IssueID int64 `json:"issue_id"`
OrderID int64 `json:"order_id"`
UserID int64 `json:"user_id"`
}
type MatchingRoundResult struct {
Round int64 `json:"round"`
Board [9]*MatchingCard `json:"board"`
Pairs []MatchingPair `json:"pairs"`
PairsCount int64 `json:"pairs_count"`
DrawnCards []DrawnCardInfo `json:"drawn_cards"` // 优化:包含位置信息
Reshuffled bool `json:"reshuffled"`
CanContinue bool `json:"can_continue"`
}
type DrawnCardInfo struct {
SlotIndex int `json:"slot_index"`
Card MatchingCard `json:"card"`
}
type MatchingPair struct {
CardType CardType `json:"card_type"`
Count int64 `json:"count"`
CardIDs []string `json:"card_ids"`
SlotIndices []int `json:"slot_indices"` // 新增:消除的格子索引
}
// loadCardTypesFromDB 从数据库加载启用的卡牌类型配置
func loadCardTypesFromDB(ctx context.Context, readDB *dao.Query) ([]CardTypeConfig, error) {
items, err := readDB.MatchingCardTypes.WithContext(ctx).Where(readDB.MatchingCardTypes.Status.Eq(1)).Order(readDB.MatchingCardTypes.Sort.Asc()).Find()
if err != nil {
return nil, err
}
configs := make([]CardTypeConfig, len(items))
for i, item := range items {
configs[i] = CardTypeConfig{
Code: CardType(item.Code),
Name: item.Name,
ImageURL: item.ImageURL,
Quantity: item.Quantity,
}
}
return configs, nil
}
// NewMatchingGameWithConfig 使用数据库配置创建游戏
// NewMatchingGameWithConfig 使用数据库配置创建游戏
// position: 用户选择的位置(可选),用于增加随机熵值
func NewMatchingGameWithConfig(configs []CardTypeConfig, position string) *MatchingGame {
g := &MatchingGame{
CardConfigs: configs,
RoundHistory: []MatchingRoundResult{},
Board: [9]*MatchingCard{},
LastActivity: time.Now(),
}
// 生成服务器种子
g.ServerSeed = make([]byte, 32)
rand.Read(g.ServerSeed)
// 如果有 position 参数,将其混入种子逻辑
if position != "" {
// 使用 SHA256 (seed + position + timestamp) 生成新的混合种子
h := sha256.New()
h.Write(g.ServerSeed)
h.Write([]byte(position))
// 还可以加个时间戳确保不仅仅依赖 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)
// 根据配置生成所有卡牌 (99张)
totalCards := 0
for _, cfg := range configs {
totalCards += int(cfg.Quantity)
}
// 创建所有卡牌对象
g.CardIDCounter = 0
allCards := make([]*MatchingCard, 0, totalCards)
for _, cfg := range configs {
for i := int32(0); i < cfg.Quantity; i++ {
// 创建卡牌对象
g.CardIDCounter++
id := fmt.Sprintf("c%d", g.CardIDCounter)
mc := &MatchingCard{
ID: id,
Type: cfg.Code,
}
allCards = append(allCards, mc)
}
}
g.Deck = allCards
// 安全洗牌
g.secureShuffle()
// 初始填充棋盘
for i := 0; i < 9; i++ {
if len(g.Deck) > 0 {
// 从牌堆顶取一张
card := g.Deck[0]
g.Deck = g.Deck[1:]
g.Board[i] = card
} else {
g.Board[i] = nil
}
}
// 计算理论最大对数 (Sanity Check)
// 遍历所有生成的卡牌配置
var theoreticalMax int64
for _, cfg := range configs {
theoreticalMax += int64(cfg.Quantity / 2) // 向下取整每2张算1对
}
g.MaxPossiblePairs = theoreticalMax
return g
}
// createMatchingCard (已废弃,改为预生成) - 但为了兼容 PlayRound 里可能的动态生成(如有),保留作为 helper?
// 不PlayRound 现在应该直接从 deck 取对象。
// 只需要保留 getCardConfig 即可。
// getCardConfig 获取指定卡牌类型的配置
func (g *MatchingGame) getCardConfig(cardType CardType) *CardTypeConfig {
for i := range g.CardConfigs {
if g.CardConfigs[i].Code == cardType {
return &g.CardConfigs[i]
}
}
return nil
}
// secureShuffle 使用 HMAC-SHA256 的 Fisher-Yates 洗牌
func (g *MatchingGame) secureShuffle() {
n := len(g.Deck)
for i := n - 1; i > 0; i-- {
j := g.secureRandInt(i+1, fmt.Sprintf("shuffle:%d", i))
g.Deck[i], g.Deck[j] = g.Deck[j], g.Deck[i]
}
}
// secureRandInt 使用 HMAC-SHA256 生成安全随机数
func (g *MatchingGame) secureRandInt(max int, context string) int {
g.Nonce++
message := fmt.Sprintf("%s|nonce:%d", context, g.Nonce)
mac := hmac.New(sha256.New, g.ServerSeed)
mac.Write([]byte(message))
sum := mac.Sum(nil)
val := binary.BigEndian.Uint64(sum[:8])
return int(val % uint64(max))
}
// reshuffleBoard 重洗棋盘和牌堆
func (g *MatchingGame) reshuffleBoard() {
// 1. 回收所有卡牌(板上 + 牌堆)
tempDeck := make([]*MatchingCard, 0, len(g.Deck)+9)
tempDeck = append(tempDeck, g.Deck...)
for i := 0; i < 9; i++ {
if g.Board[i] != nil {
tempDeck = append(tempDeck, g.Board[i])
g.Board[i] = nil
}
}
// 2. 循环尝试洗牌,直到开局有解(或者尝试一定次数)
// 尝试最多 10 次,寻找一个起手就有解的局面
bestDeck := make([]*MatchingCard, len(tempDeck))
copy(bestDeck, tempDeck)
for retry := 0; retry < 10; retry++ {
// 复制一份进行尝试
currentDeck := make([]*MatchingCard, len(tempDeck))
copy(currentDeck, tempDeck)
g.Deck = currentDeck
g.secureShuffle()
// 检查前9张或更少是否有对子
checkCount := 9
if len(g.Deck) < 9 {
checkCount = len(g.Deck)
}
counts := make(map[CardType]int)
hasPair := false
for k := 0; k < checkCount; k++ {
t := g.Deck[k].Type
counts[t]++
if counts[t] >= 2 {
hasPair = true
break
}
}
if hasPair {
// 找到有解的洗牌结果,采用之
// g.deck 已经是洗好的状态
break
}
}
// 3. 重新填满棋盘
for i := 0; i < 9; i++ {
if len(g.Deck) > 0 {
card := g.Deck[0]
g.Deck = g.Deck[1:]
g.Board[i] = card
}
}
}
// GetGameState 获取游戏状态
func (g *MatchingGame) GetGameState() map[string]any {
return map[string]any{
"board": g.Board,
"deck_count": len(g.Deck),
"total_pairs": g.TotalPairs,
"round": g.Round,
"server_seed_hash": g.ServerSeedHash,
}
}
// ========== API Handlers ========== // ========== API Handlers ==========
type matchingGamePreOrderRequest struct { type matchingGamePreOrderRequest struct {
@ -321,178 +55,6 @@ type matchingGameCheckResponse struct {
Reward *MatchingRewardInfo `json:"reward,omitempty"` Reward *MatchingRewardInfo `json:"reward,omitempty"`
} }
// Redis Key Prefix
const matchingGameKeyPrefix = "bindbox:matching_game:"
// saveGameToRedis 保存游戏状态到 Redis
func (h *handler) saveGameToRedis(ctx context.Context, gameID string, game *MatchingGame) error {
data, err := json.Marshal(game)
if err != nil {
return err
}
// TTL: 30 minutes
return h.redis.Set(ctx, matchingGameKeyPrefix+gameID, data, 30*time.Minute).Err()
}
// loadGameFromRedis 从 Redis 加载游戏状态
// 如果 Redis 中没有找到,则尝试从数据库恢复
func (h *handler) loadGameFromRedis(ctx context.Context, gameID string) (*MatchingGame, error) {
data, err := h.redis.Get(ctx, matchingGameKeyPrefix+gameID).Bytes()
if err == nil {
var game MatchingGame
if err := json.Unmarshal(data, &game); err != nil {
return nil, err
}
return &game, nil
}
// Redis miss - try to recover from DB
if err == redis.Nil {
game, recoverErr := h.recoverGameFromDB(ctx, gameID)
if recoverErr != nil {
return nil, redis.Nil // Return original error to indicate session not found
}
// Cache the recovered game back to Redis
_ = h.saveGameToRedis(ctx, gameID, game)
return game, nil
}
return nil, err
}
// recoverGameFromDB 从数据库恢复游戏状态
// 通过 game_id 解析 user_id然后查找对应的 activity_draw_receipts 记录
// 使用 ServerSubSeed 重建游戏状态
func (h *handler) recoverGameFromDB(ctx context.Context, gameID string) (*MatchingGame, error) {
// Parse user_id from game_id (format: MG{userID}{timestamp})
// Example: MG121766299471192637903
if len(gameID) < 3 || gameID[:2] != "MG" {
return nil, fmt.Errorf("invalid game_id format")
}
// Extract user_id: find the first digit sequence after "MG"
// The user_id is typically short (1-5 digits), timestamp is long (19 digits)
numPart := gameID[2:]
var userID int64
if len(numPart) > 19 {
// User ID is everything before the last 19 chars (nanosecond timestamp)
userIDStr := numPart[:len(numPart)-19]
userID = parseInt64(userIDStr)
} else {
return nil, fmt.Errorf("cannot parse user_id from game_id")
}
if userID <= 0 {
return nil, fmt.Errorf("invalid user_id in game_id")
}
// Find the most recent matching game receipt for this user
receipt, err := h.readDB.ActivityDrawReceipts.WithContext(ctx).
Where(h.readDB.ActivityDrawReceipts.ClientID.Eq(userID)).
Where(h.readDB.ActivityDrawReceipts.AlgoVersion.Eq("HMAC-SHA256-v1")).
Order(h.readDB.ActivityDrawReceipts.ID.Desc()).
First()
if err != nil || receipt == nil {
return nil, fmt.Errorf("no matching game receipt found for user %d", userID)
}
// Decode ServerSubSeed (hex -> bytes)
serverSeed, err := hex.DecodeString(receipt.ServerSubSeed)
if err != nil || len(serverSeed) == 0 {
return nil, fmt.Errorf("invalid server seed in receipt")
}
// Get DrawLog to find IssueID and OrderID
drawLog, err := h.readDB.ActivityDrawLogs.WithContext(ctx).
Where(h.readDB.ActivityDrawLogs.ID.Eq(receipt.DrawLogID)).
First()
if err != nil || drawLog == nil {
return nil, fmt.Errorf("draw log not found")
}
// Load card configs
configs, err := loadCardTypesFromDB(ctx, h.readDB)
if err != nil || len(configs) == 0 {
// Fallback to default configs
configs = []CardTypeConfig{
{Code: "A", Name: "类型A", Quantity: 9},
{Code: "B", Name: "类型B", Quantity: 9},
{Code: "C", Name: "类型C", Quantity: 9},
{Code: "D", Name: "类型D", Quantity: 9},
{Code: "E", Name: "类型E", Quantity: 9},
{Code: "F", Name: "类型F", Quantity: 9},
{Code: "G", Name: "类型G", Quantity: 9},
{Code: "H", Name: "类型H", Quantity: 9},
{Code: "I", Name: "类型I", Quantity: 9},
{Code: "J", Name: "类型J", Quantity: 9},
{Code: "K", Name: "类型K", Quantity: 9},
}
}
// Reconstruct game with the same seed
game := &MatchingGame{
CardConfigs: configs,
RoundHistory: []MatchingRoundResult{},
Board: [9]*MatchingCard{},
LastActivity: time.Now(),
ServerSeed: serverSeed,
ServerSeedHash: receipt.ServerSeedHash,
Nonce: 0, // Reset nonce for reconstruction
ActivityID: drawLog.IssueID, // Note: IssueID is stored in DrawLog
IssueID: drawLog.IssueID,
OrderID: drawLog.OrderID,
UserID: userID,
}
// Get ActivityID from Issue
if issue, _ := h.readDB.ActivityIssues.WithContext(ctx).Where(h.readDB.ActivityIssues.ID.Eq(drawLog.IssueID)).First(); issue != nil {
game.ActivityID = issue.ActivityID
}
// Generate all cards
totalCards := 0
for _, cfg := range configs {
totalCards += int(cfg.Quantity)
}
game.CardIDCounter = 0
allCards := make([]*MatchingCard, 0, totalCards)
for _, cfg := range configs {
for i := int32(0); i < cfg.Quantity; i++ {
game.CardIDCounter++
id := fmt.Sprintf("c%d", game.CardIDCounter)
mc := &MatchingCard{
ID: id,
Type: cfg.Code,
}
allCards = append(allCards, mc)
}
}
game.Deck = allCards
// Shuffle with the same seed (deterministic)
game.secureShuffle()
// Fill board
for i := 0; i < 9; i++ {
if len(game.Deck) > 0 {
card := game.Deck[0]
game.Deck = game.Deck[1:]
game.Board[i] = card
} else {
game.Board[i] = nil
}
}
// Calculate max possible pairs
var theoreticalMax int64
for _, cfg := range configs {
theoreticalMax += int64(cfg.Quantity / 2)
}
game.MaxPossiblePairs = theoreticalMax
fmt.Printf("[会话恢复] 成功从数据库恢复游戏 game_id=%s user_id=%d issue_id=%d\n", gameID, userID, drawLog.IssueID)
return game, nil
}
// PreOrderMatchingGame 下单并预生成对对碰游戏数据 // PreOrderMatchingGame 下单并预生成对对碰游戏数据
// @Summary 下单并获取对对碰全量数据 // @Summary 下单并获取对对碰全量数据
// @Description 用户下单服务器扣费并返回全量99张乱序卡牌前端自行负责游戏流程 // @Description 用户下单服务器扣费并返回全量99张乱序卡牌前端自行负责游戏流程
@ -588,7 +150,13 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
} }
// 3. 创建游戏并洗牌 // 3. 创建游戏并洗牌
game := NewMatchingGameWithConfig(configs, req.Position) // 使用 Activity Commitment 作为随机源
var serverSeedSrc []byte
if len(activity.CommitmentSeedMaster) > 0 {
serverSeedSrc = activity.CommitmentSeedMaster
}
game := NewMatchingGameWithConfig(configs, req.Position, serverSeedSrc)
game.ActivityID = issue.ActivityID game.ActivityID = issue.ActivityID
game.IssueID = req.IssueID game.IssueID = req.IssueID
game.OrderID = order.ID game.OrderID = order.ID
@ -717,10 +285,13 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
} }
// 校验:不能超过理论最大对数 // 校验:不能超过理论最大对数
fmt.Printf("[对对碰Check] 校验对子数量: 客户端提交=%d 服务端计算最大值=%d GameID=%s\n", req.TotalPairs, game.MaxPossiblePairs, req.GameID)
if req.TotalPairs > game.MaxPossiblePairs { if req.TotalPairs > game.MaxPossiblePairs {
fmt.Printf("[对对碰Check] ❌ 校验失败: 提交数量超过理论最大值\n")
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, fmt.Sprintf("invalid total pairs: %d > %d", req.TotalPairs, game.MaxPossiblePairs))) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, fmt.Sprintf("invalid total pairs: %d > %d", req.TotalPairs, game.MaxPossiblePairs)))
return return
} }
fmt.Printf("[对对碰Check] ✅ 校验通过\n")
game.TotalPairs = req.TotalPairs // 记录一下 game.TotalPairs = req.TotalPairs // 记录一下
var rewardInfo *MatchingRewardInfo var rewardInfo *MatchingRewardInfo
@ -742,22 +313,22 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
} }
} }
// 3. Grant Reward if found
if candidate != nil { if candidate != nil {
if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, candidate); err != nil { // 3. Prepare Grant Params
h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err)) // Fetch real product name for remark
} else { productName := candidate.Name
rewardInfo = &MatchingRewardInfo{ if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
RewardID: candidate.ID, productName = p.Name
Name: candidate.Name,
Level: candidate.Level,
} }
// 4. Apply Item Card Effects (if any) finalReward := candidate
ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First() finalQuantity := 1
if ord != nil { finalRemark := fmt.Sprintf("%s %s", order.OrderNo, productName)
icID := parseItemCardIDFromRemark(ord.Remark) var cardToVoid int64 = 0
fmt.Printf("[道具卡-CheckMatchingGame] 从订单备注解析道具卡ID icID=%d 订单备注=%s\n", icID, ord.Remark)
// 4. Apply Item Card Effects (Determine final reward and quantity)
if order != nil {
icID := parseItemCardIDFromRemark(order.Remark)
if icID > 0 { if icID > 0 {
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where( uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.UserItemCards.ID.Eq(icID), h.readDB.UserItemCards.ID.Eq(icID),
@ -774,19 +345,12 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID) scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID)
fmt.Printf("[道具卡-CheckMatchingGame] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, game.ActivityID, game.IssueID, scopeOK) fmt.Printf("[道具卡-CheckMatchingGame] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, game.ActivityID, game.IssueID, scopeOK)
if scopeOK { if scopeOK {
// Apply effect based on type cardToVoid = icID
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// Double reward // Double reward
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s\n", ic.RewardMultiplierX1000, candidate.ID, candidate.Name) fmt.Printf("[道具卡-CheckMatchingGame] ✅ 应用双倍奖励 倍数=%d\n", ic.RewardMultiplierX1000)
rid := candidate.ID finalQuantity = 2
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), game.UserID, usersvc.GrantRewardToOrderRequest{ finalRemark += "(倍数)"
OrderID: game.OrderID,
ProductID: candidate.ProductID,
Quantity: 1,
ActivityID: &game.ActivityID,
RewardID: &rid,
Remark: candidate.Name + "(倍数)",
})
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { } else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// Probability boost - try to upgrade to better reward // Probability boost - try to upgrade to better reward
fmt.Printf("[道具卡-CheckMatchingGame] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000) fmt.Printf("[道具卡-CheckMatchingGame] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
@ -808,21 +372,31 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000) randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
if randVal < ic.BoostRateX1000 { if randVal < ic.BoostRateX1000 {
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name) fmt.Printf("[道具卡-CheckMatchingGame] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name)
rid := better.ID finalReward = better
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), game.UserID, usersvc.GrantRewardToOrderRequest{ finalRemark = better.Name + "(升级)"
OrderID: game.OrderID, }
ProductID: better.ProductID, }
Quantity: 1, }
ActivityID: &game.ActivityID, }
RewardID: &rid, }
Remark: better.Name + "(升级)",
})
} }
} }
} }
// Void the item card // 5. Grant Reward
fmt.Printf("[道具卡-CheckMatchingGame] 核销道具卡 用户道具卡ID=%d\n", icID) if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, finalReward, finalQuantity, finalRemark); err != nil {
h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err))
} else {
rewardInfo = &MatchingRewardInfo{
RewardID: finalReward.ID,
Name: productName,
Level: finalReward.Level,
}
// 6. Void Item Card (if used)
if cardToVoid > 0 {
fmt.Printf("[道具卡-CheckMatchingGame] 核销道具卡 用户道具卡ID=%d\n", cardToVoid)
now := time.Now()
// Get DrawLog ID for the order // Get DrawLog ID for the order
drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where( drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID), h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID),
@ -832,7 +406,7 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
drawLogID = drawLog.ID drawLogID = drawLog.ID
} }
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where( _, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.writeDB.UserItemCards.ID.Eq(icID), h.writeDB.UserItemCards.ID.Eq(cardToVoid),
h.writeDB.UserItemCards.UserID.Eq(game.UserID), h.writeDB.UserItemCards.UserID.Eq(game.UserID),
h.writeDB.UserItemCards.Status.Eq(1), h.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{ ).Updates(map[string]any{
@ -846,10 +420,6 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
} }
} }
} }
}
}
}
}
rsp := &matchingGameCheckResponse{ rsp := &matchingGameCheckResponse{
GameID: req.GameID, GameID: req.GameID,
@ -858,6 +428,44 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
Reward: rewardInfo, Reward: rewardInfo,
} }
// 7. Virtual Shipping (Async)
// Upload shipping info to WeChat (similar to Ichiban Kuji) so user can see "Shipped" status and reward info.
rewardName := "无奖励"
if rewardInfo != nil {
rewardName = rewardInfo.Name
}
go func(orderID int64, orderNo string, userID int64, rName string) {
bgCtx := context.Background()
// 1. Get Payment Transaction
tx, _ := h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
if tx == nil || tx.TransactionID == "" {
h.logger.Warn("CheckMatchingGame: No payment transaction found for shipping", zap.String("order_no", orderNo))
return
}
// 2. Get User OpenID
u, _ := h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First()
payerOpenid := ""
if u != nil {
payerOpenid = u.Openid
}
// 3. Construct Item Desc
itemsDesc := fmt.Sprintf("对对碰 %s 赏品: %s", orderNo, rName)
if len(itemsDesc) > 120 {
itemsDesc = itemsDesc[:120]
}
// 4. Upload
c := configs.Get()
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil {
h.logger.Error("CheckMatchingGame: Failed to upload virtual shipping", zap.Error(err))
} else {
h.logger.Info("CheckMatchingGame: Virtual shipping uploaded", zap.String("order_no", orderNo), zap.String("items", itemsDesc))
}
}(game.OrderID, order.OrderNo, game.UserID, rewardName)
// 结算完成,清理会话 (Delete from Redis) // 结算完成,清理会话 (Delete from Redis)
_ = h.redis.Del(ctx.RequestContext(), matchingGameKeyPrefix+req.GameID) _ = h.redis.Del(ctx.RequestContext(), matchingGameKeyPrefix+req.GameID)
@ -922,81 +530,3 @@ func (h *handler) ListMatchingCardTypes() core.HandlerFunc {
ctx.Payload(configs) ctx.Payload(configs)
} }
} }
// startMatchingGameCleanup ... (Deprecated since we use Redis TTL)
func (h *handler) startMatchingGameCleanup() {
// No-op
}
// cleanupExpiredMatchingGames ... (Deprecated)
func cleanupExpiredMatchingGames(logger logger.CustomLogger) {
// No-op
}
// grantRewardHelper 发放奖励辅助函数
func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64, r *model.ActivityRewardSettings) error {
// 1. 扣减库存
res, err := h.writeDB.ActivityRewardSettings.WithContext(ctx).Where(
h.writeDB.ActivityRewardSettings.ID.Eq(r.ID),
h.writeDB.ActivityRewardSettings.Quantity.Gt(0),
).UpdateSimple(h.writeDB.ActivityRewardSettings.Quantity.Add(-1))
if err != nil {
return err
}
if res.RowsAffected == 0 {
return fmt.Errorf("reward out of stock")
}
// 2. Grant to Order
issue, _ := h.readDB.ActivityIssues.WithContext(ctx).Where(h.readDB.ActivityIssues.ID.Eq(r.IssueID)).First()
var actID int64
if issue != nil {
actID = issue.ActivityID
}
rid := r.ID
_, err = h.user.GrantRewardToOrder(ctx, userID, usersvc.GrantRewardToOrderRequest{
OrderID: orderID,
ProductID: r.ProductID,
Quantity: 1, // 1 prize
ActivityID: &actID,
RewardID: &rid,
Remark: "Matching Game Reward",
})
if err != nil {
// Use h.logger.Error if available, else fmt.Printf or zap.L().Error
// h.logger is likely type definition interface.
// Let's use generic logger if h.logger doesn't support structured.
// But usually it does.
// h.logger.Error(msg, fields...)
h.logger.Error("Failed to grant reward to order", zap.Int64("order_id", orderID), zap.Error(err))
return err
}
// 3. Update Draw Log (IsWinner = 1)
_, err = h.writeDB.ActivityDrawLogs.WithContext(ctx).Where(
h.writeDB.ActivityDrawLogs.OrderID.Eq(orderID),
).Updates(&model.ActivityDrawLogs{
IsWinner: 1,
RewardID: r.ID,
Level: r.Level,
// RewardName: r.Name, // Removed
// ProductPrice: 0, // Removed
// UpdatedAt: time.Now(), // Removed
})
return err
}
// parseInt64 将字符串转换为int64
func parseInt64(s string) int64 {
var n int64
for i := 0; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}

View File

@ -0,0 +1,521 @@
package app
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// CardType 卡牌类型
type CardType string
// CardTypeConfig 卡牌类型配置(从数据库加载)
type CardTypeConfig struct {
Code CardType `json:"code"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
Quantity int32 `json:"quantity"`
}
// MatchingCard 游戏中的卡牌实例
type MatchingCard struct {
ID string `json:"id"`
Type CardType `json:"type"`
}
// MatchingGame 对对碰游戏结构
type MatchingGame struct {
Mu sync.Mutex `json:"-"` // 互斥锁保护并发访问
ServerSeed []byte `json:"server_seed"`
ServerSeedHash string `json:"server_seed_hash"`
Nonce int64 `json:"nonce"`
CardConfigs []CardTypeConfig `json:"card_configs"`
Deck []*MatchingCard `json:"deck"` // 牌堆 (预生成的卡牌对象)
Board [9]*MatchingCard `json:"board"` // 固定9格棋盘
CardIDCounter int64 `json:"card_id_counter"` // 用于生成唯一ID
TotalPairs int64 `json:"total_pairs"`
MaxPossiblePairs int64 `json:"max_possible_pairs"` // 最大可能消除对数 (安全校验)
Round int64 `json:"round"`
RoundHistory []MatchingRoundResult `json:"round_history"`
LastActivity time.Time `json:"last_activity"`
// Context info for reward granting
ActivityID int64 `json:"activity_id"`
IssueID int64 `json:"issue_id"`
OrderID int64 `json:"order_id"`
UserID int64 `json:"user_id"`
}
type MatchingRoundResult struct {
Round int64 `json:"round"`
Board [9]*MatchingCard `json:"board"`
Pairs []MatchingPair `json:"pairs"`
PairsCount int64 `json:"pairs_count"`
DrawnCards []DrawnCardInfo `json:"drawn_cards"` // 优化:包含位置信息
Reshuffled bool `json:"reshuffled"`
CanContinue bool `json:"can_continue"`
}
type DrawnCardInfo struct {
SlotIndex int `json:"slot_index"`
Card MatchingCard `json:"card"`
}
type MatchingPair struct {
CardType CardType `json:"card_type"`
Count int64 `json:"count"`
CardIDs []string `json:"card_ids"`
SlotIndices []int `json:"slot_indices"` // 新增:消除的格子索引
}
// loadCardTypesFromDB 从数据库加载启用的卡牌类型配置
func loadCardTypesFromDB(ctx context.Context, readDB *dao.Query) ([]CardTypeConfig, error) {
items, err := readDB.MatchingCardTypes.WithContext(ctx).Where(readDB.MatchingCardTypes.Status.Eq(1)).Order(readDB.MatchingCardTypes.Sort.Asc()).Find()
if err != nil {
return nil, err
}
configs := make([]CardTypeConfig, len(items))
for i, item := range items {
configs[i] = CardTypeConfig{
Code: CardType(item.Code),
Name: item.Name,
ImageURL: item.ImageURL,
Quantity: item.Quantity,
}
}
return configs, nil
}
// NewMatchingGameWithConfig 使用数据库配置创建游戏
// position: 用户选择的位置(可选),用于增加随机熵值
// masterSeed: 活动的主承诺种子(可选),如果提供则用作随机源的基础
func NewMatchingGameWithConfig(configs []CardTypeConfig, position string, masterSeed []byte) *MatchingGame {
g := &MatchingGame{
CardConfigs: configs,
RoundHistory: []MatchingRoundResult{},
Board: [9]*MatchingCard{},
LastActivity: time.Now(),
}
// 生成服务器种子
if len(masterSeed) > 0 {
// 使用主承诺种子作为基础
// 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)
// 根据配置生成所有卡牌 (99张)
totalCards := 0
for _, cfg := range configs {
totalCards += int(cfg.Quantity)
}
// 创建所有卡牌对象
g.CardIDCounter = 0
allCards := make([]*MatchingCard, 0, totalCards)
for _, cfg := range configs {
for i := int32(0); i < cfg.Quantity; i++ {
// 创建卡牌对象
g.CardIDCounter++
id := fmt.Sprintf("c%d", g.CardIDCounter)
mc := &MatchingCard{
ID: id,
Type: cfg.Code,
}
allCards = append(allCards, mc)
}
}
g.Deck = allCards
// 安全洗牌
g.secureShuffle()
// 初始填充棋盘
for i := 0; i < 9; i++ {
if len(g.Deck) > 0 {
// 从牌堆顶取一张
card := g.Deck[0]
g.Deck = g.Deck[1:]
g.Board[i] = card
} else {
g.Board[i] = nil
}
}
// 计算理论最大对数 (Sanity Check)
// 遍历所有生成的卡牌配置
var theoreticalMax int64
for _, cfg := range configs {
pairs := int64(cfg.Quantity / 2)
theoreticalMax += pairs // 向下取整每2张算1对
fmt.Printf("[对对碰生成] 卡牌类型:%s(%s) 数量:%d 可组成对数:%d\n", cfg.Code, cfg.Name, cfg.Quantity, pairs)
}
g.MaxPossiblePairs = theoreticalMax
fmt.Printf("[对对碰生成] 总卡牌数:%d 理论最大对数:%d\n", totalCards, theoreticalMax)
return g
}
// getCardConfig 获取指定卡牌类型的配置
func (g *MatchingGame) getCardConfig(cardType CardType) *CardTypeConfig {
for i := range g.CardConfigs {
if g.CardConfigs[i].Code == cardType {
return &g.CardConfigs[i]
}
}
return nil
}
// secureShuffle 使用 HMAC-SHA256 的 Fisher-Yates 洗牌
func (g *MatchingGame) secureShuffle() {
n := len(g.Deck)
for i := n - 1; i > 0; i-- {
j := g.secureRandInt(i+1, fmt.Sprintf("shuffle:%d", i))
g.Deck[i], g.Deck[j] = g.Deck[j], g.Deck[i]
}
}
// secureRandInt 使用 HMAC-SHA256 生成安全随机数
func (g *MatchingGame) secureRandInt(max int, context string) int {
g.Nonce++
message := fmt.Sprintf("%s|nonce:%d", context, g.Nonce)
mac := hmac.New(sha256.New, g.ServerSeed)
mac.Write([]byte(message))
sum := mac.Sum(nil)
val := binary.BigEndian.Uint64(sum[:8])
return int(val % uint64(max))
}
// reshuffleBoard 重洗棋盘和牌堆
func (g *MatchingGame) reshuffleBoard() {
// 1. 回收所有卡牌(板上 + 牌堆)
tempDeck := make([]*MatchingCard, 0, len(g.Deck)+9)
tempDeck = append(tempDeck, g.Deck...)
for i := 0; i < 9; i++ {
if g.Board[i] != nil {
tempDeck = append(tempDeck, g.Board[i])
g.Board[i] = nil
}
}
// 2. 循环尝试洗牌,直到开局有解(或者尝试一定次数)
// 尝试最多 10 次,寻找一个起手就有解的局面
bestDeck := make([]*MatchingCard, len(tempDeck))
copy(bestDeck, tempDeck)
for retry := 0; retry < 10; retry++ {
// 复制一份进行尝试
currentDeck := make([]*MatchingCard, len(tempDeck))
copy(currentDeck, tempDeck)
g.Deck = currentDeck
g.secureShuffle()
// 检查前9张或更少是否有对子
checkCount := 9
if len(g.Deck) < 9 {
checkCount = len(g.Deck)
}
counts := make(map[CardType]int)
hasPair := false
for k := 0; k < checkCount; k++ {
t := g.Deck[k].Type
counts[t]++
if counts[t] >= 2 {
hasPair = true
break
}
}
if hasPair {
// 找到有解的洗牌结果,采用之
// g.deck 已经是洗好的状态
break
}
}
// 3. 重新填满棋盘
for i := 0; i < 9; i++ {
if len(g.Deck) > 0 {
card := g.Deck[0]
g.Deck = g.Deck[1:]
g.Board[i] = card
} else {
g.Board[i] = nil
}
}
}
// GetGameState 获取游戏状态
func (g *MatchingGame) GetGameState() map[string]any {
return map[string]any{
"board": g.Board,
"deck_count": len(g.Deck),
"total_pairs": g.TotalPairs,
"round": g.Round,
"server_seed_hash": g.ServerSeedHash,
}
}
// Redis Key Prefix
const matchingGameKeyPrefix = "bindbox:matching_game:"
// saveGameToRedis 保存游戏状态到 Redis
func (h *handler) saveGameToRedis(ctx context.Context, gameID string, game *MatchingGame) error {
data, err := json.Marshal(game)
if err != nil {
return err
}
// TTL: 30 minutes
return h.redis.Set(ctx, matchingGameKeyPrefix+gameID, data, 30*time.Minute).Err()
}
// loadGameFromRedis 从 Redis 加载游戏状态
// 如果 Redis 中没有找到,则尝试从数据库恢复
func (h *handler) loadGameFromRedis(ctx context.Context, gameID string) (*MatchingGame, error) {
data, err := h.redis.Get(ctx, matchingGameKeyPrefix+gameID).Bytes()
if err == nil {
var game MatchingGame
if err := json.Unmarshal(data, &game); err != nil {
return nil, err
}
return &game, nil
}
// Redis miss - try to recover from DB
if err == redis.Nil {
game, recoverErr := h.recoverGameFromDB(ctx, gameID)
if recoverErr != nil {
return nil, redis.Nil // Return original error to indicate session not found
}
// Cache the recovered game back to Redis
_ = h.saveGameToRedis(ctx, gameID, game)
return game, nil
}
return nil, err
}
// recoverGameFromDB 从数据库恢复游戏状态
// 通过 game_id 解析 user_id然后查找对应的 activity_draw_receipts 记录
// 使用 ServerSubSeed 重建游戏状态
func (h *handler) recoverGameFromDB(ctx context.Context, gameID string) (*MatchingGame, error) {
// Parse user_id from game_id (format: MG{userID}{timestamp})
// Example: MG121766299471192637903
if len(gameID) < 3 || gameID[:2] != "MG" {
return nil, fmt.Errorf("invalid game_id format")
}
// Extract user_id: find the first digit sequence after "MG"
// The user_id is typically short (1-5 digits), timestamp is long (19 digits)
numPart := gameID[2:]
var userID int64
if len(numPart) > 19 {
// User ID is everything before the last 19 chars (nanosecond timestamp)
userIDStr := numPart[:len(numPart)-19]
userID = parseInt64(userIDStr)
} else {
return nil, fmt.Errorf("cannot parse user_id from game_id")
}
if userID <= 0 {
return nil, fmt.Errorf("invalid user_id in game_id")
}
// Find the most recent matching game receipt for this user
receipt, err := h.readDB.ActivityDrawReceipts.WithContext(ctx).
Where(h.readDB.ActivityDrawReceipts.ClientID.Eq(userID)).
Where(h.readDB.ActivityDrawReceipts.AlgoVersion.Eq("HMAC-SHA256-v1")).
Order(h.readDB.ActivityDrawReceipts.ID.Desc()).
First()
if err != nil || receipt == nil {
return nil, fmt.Errorf("no matching game receipt found for user %d", userID)
}
// Decode ServerSubSeed (hex -> bytes)
serverSeed, err := hex.DecodeString(receipt.ServerSubSeed)
if err != nil || len(serverSeed) == 0 {
return nil, fmt.Errorf("invalid server seed in receipt")
}
// Get DrawLog to find IssueID and OrderID
drawLog, err := h.readDB.ActivityDrawLogs.WithContext(ctx).
Where(h.readDB.ActivityDrawLogs.ID.Eq(receipt.DrawLogID)).
First()
if err != nil || drawLog == nil {
return nil, fmt.Errorf("draw log not found")
}
// Load card configs
configs, err := loadCardTypesFromDB(ctx, h.readDB)
if err != nil || len(configs) == 0 {
// Fallback to default configs
configs = []CardTypeConfig{
{Code: "A", Name: "类型A", Quantity: 9},
{Code: "B", Name: "类型B", Quantity: 9},
{Code: "C", Name: "类型C", Quantity: 9},
{Code: "D", Name: "类型D", Quantity: 9},
{Code: "E", Name: "类型E", Quantity: 9},
{Code: "F", Name: "类型F", Quantity: 9},
{Code: "G", Name: "类型G", Quantity: 9},
{Code: "H", Name: "类型H", Quantity: 9},
{Code: "I", Name: "类型I", Quantity: 9},
{Code: "J", Name: "类型J", Quantity: 9},
{Code: "K", Name: "类型K", Quantity: 9},
}
}
// Reconstruct game with the same seed
game := &MatchingGame{
CardConfigs: configs,
RoundHistory: []MatchingRoundResult{},
Board: [9]*MatchingCard{},
LastActivity: time.Now(),
ServerSeed: serverSeed,
ServerSeedHash: receipt.ServerSeedHash,
Nonce: 0, // Reset nonce for reconstruction
IssueID: drawLog.IssueID,
OrderID: drawLog.OrderID,
UserID: userID,
}
// Get ActivityID from Issue
if issue, _ := h.readDB.ActivityIssues.WithContext(ctx).Where(h.readDB.ActivityIssues.ID.Eq(drawLog.IssueID)).First(); issue != nil {
game.ActivityID = issue.ActivityID
}
// Generate all cards
totalCards := 0
for _, cfg := range configs {
totalCards += int(cfg.Quantity)
}
game.CardIDCounter = 0
allCards := make([]*MatchingCard, 0, totalCards)
for _, cfg := range configs {
for i := int32(0); i < cfg.Quantity; i++ {
game.CardIDCounter++
id := fmt.Sprintf("c%d", game.CardIDCounter)
mc := &MatchingCard{
ID: id,
Type: cfg.Code,
}
allCards = append(allCards, mc)
}
}
game.Deck = allCards
// Shuffle with the same seed (deterministic)
game.secureShuffle()
// Fill board
for i := 0; i < 9; i++ {
if len(game.Deck) > 0 {
card := game.Deck[0]
game.Deck = game.Deck[1:]
game.Board[i] = card
} else {
game.Board[i] = nil
}
}
// Calculate max possible pairs
var theoreticalMax int64
for _, cfg := range configs {
theoreticalMax += int64(cfg.Quantity / 2)
}
game.MaxPossiblePairs = theoreticalMax
fmt.Printf("[会话恢复] 成功从数据库恢复游戏 game_id=%s user_id=%d issue_id=%d\n", gameID, userID, drawLog.IssueID)
return game, nil
}
// startMatchingGameCleanup ... (Deprecated since we use Redis TTL)
func (h *handler) startMatchingGameCleanup() {
// No-op
}
// cleanupExpiredMatchingGames ... (Deprecated)
func cleanupExpiredMatchingGames(logger logger.CustomLogger) {
// No-op
}
// grantRewardHelper 发放奖励辅助函数
func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64, r *model.ActivityRewardSettings, quantity int, remark string) error {
// 1. Grant to Order (Delegating stock check to user service)
issue, _ := h.readDB.ActivityIssues.WithContext(ctx).Where(h.readDB.ActivityIssues.ID.Eq(r.IssueID)).First()
var actID int64
if issue != nil {
actID = issue.ActivityID
}
rid := r.ID
_, err := h.user.GrantRewardToOrder(ctx, userID, usersvc.GrantRewardToOrderRequest{
OrderID: orderID,
ProductID: r.ProductID,
Quantity: quantity,
ActivityID: &actID,
RewardID: &rid,
Remark: remark,
})
if err != nil {
h.logger.Error("Failed to grant reward to order", zap.Int64("order_id", orderID), zap.Error(err))
return err
}
// 2. Update Draw Log (IsWinner = 1)
_, err = h.writeDB.ActivityDrawLogs.WithContext(ctx).Where(
h.writeDB.ActivityDrawLogs.OrderID.Eq(orderID),
).Updates(&model.ActivityDrawLogs{
IsWinner: 1,
RewardID: r.ID,
Level: r.Level,
})
return err
}
// parseInt64 将字符串转换为int64
func parseInt64(s string) int64 {
var n int64
for i := 0; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}

View File

@ -1,137 +0,0 @@
package app
import (
"fmt"
"testing"
)
// SimulateClientPlay mimics the frontend logic: Match -> Eliminate -> Fill -> Reshuffle
func SimulateClientPlay(initialCards []*MatchingCard) int {
// Deep copy to avoid modifying original test data
deck := make([]*MatchingCard, len(initialCards))
copy(deck, initialCards)
board := make([]*MatchingCard, 9)
// Initial fill
for i := 0; i < 9; i++ {
board[i] = deck[0]
deck = deck[1:]
}
pairsFound := 0
for {
// 1. Check for matches on board
counts := make(map[string][]int) // type -> list of indices
for i, c := range board {
if c != nil {
// Cast CardType to string for map key
counts[string(c.Type)] = append(counts[string(c.Type)], i)
}
}
matchedType := ""
for t, indices := range counts {
if len(indices) >= 2 {
matchedType = t
break
}
}
if matchedType != "" {
// Elimination
pairsFound++
indices := counts[matchedType]
// Remove first 2
idx1, idx2 := indices[0], indices[1]
board[idx1] = nil
board[idx2] = nil
// Filling: Fill empty slots from deck
for i := 0; i < 9; i++ {
if board[i] == nil && len(deck) > 0 {
board[i] = deck[0]
deck = deck[1:]
}
}
} else {
// Deadlock (No matches on board)
// User requirement: "Stop when no pairs can be generated" (i.e., No Reshuffle)
// If we are stuck, we stop.
break
}
}
return pairsFound
}
// TestVerification_DataIntegrity simulates the PreOrder logic 10000 times
func TestVerification_DataIntegrity(t *testing.T) {
fmt.Println("=== Starting Full Game Simulation (10000 Runs) ===")
// Using 10k runs to keep test time reasonable
configs := []CardTypeConfig{
{Code: "A", Name: "TypeA", Quantity: 9},
{Code: "B", Name: "TypeB", Quantity: 9},
{Code: "C", Name: "TypeC", Quantity: 9},
{Code: "D", Name: "TypeD", Quantity: 9},
{Code: "E", Name: "TypeE", Quantity: 9},
{Code: "F", Name: "TypeF", Quantity: 9},
{Code: "G", Name: "TypeG", Quantity: 9},
{Code: "H", Name: "TypeH", Quantity: 9},
{Code: "I", Name: "TypeI", Quantity: 9},
{Code: "J", Name: "TypeJ", Quantity: 9},
{Code: "K", Name: "TypeK", Quantity: 9},
}
scoreDist := make(map[int]int)
for i := 0; i < 10000; i++ {
// 1. Simulate PreOrder generation
game := NewMatchingGameWithConfig(configs, fmt.Sprintf("pos_%d", i))
// 2. Reconstruct "all_cards"
allCards := make([]*MatchingCard, 0, 99)
for _, c := range game.Board {
if c != nil {
allCards = append(allCards, c)
}
}
allCards = append(allCards, game.Deck...)
// 3. Play the game!
score := SimulateClientPlay(allCards)
scoreDist[score]++
// Note: Without reshuffle, score < 44 is expected.
}
// Calculate Stats
totalScore := 0
var allScores []int
for s := 0; s <= 44; s++ {
count := scoreDist[s]
for c := 0; c < count; c++ {
allScores = append(allScores, s)
totalScore += s
}
}
mean := float64(totalScore) / float64(len(allScores))
median := allScores[len(allScores)/2]
fmt.Println("\n=== No-Reshuffle Statistical Analysis (10000 Runs) ===")
fmt.Printf("Mean Score: %.2f / 44\n", mean)
fmt.Printf("Median Score: %d / 44\n", median)
fmt.Printf("Pass Rate: %.2f%%\n", float64(scoreDist[44])/100.0)
fmt.Println("------------------------------------------------")
// Output Distribution Segments
fmt.Println("Detailed Distribution:")
cumulative := 0
for s := 0; s <= 44; s++ {
count := scoreDist[s]
if count > 0 {
cumulative += count
fmt.Printf("Score %d: %d times (%.2f%%) [Cum: %.2f%%]\n", s, count, float64(count)/100.0, float64(cumulative)/100.0)
}
}
}

View File

@ -21,8 +21,6 @@ type slotItem struct {
RewardName string `json:"reward_name"` RewardName string `json:"reward_name"`
Level int32 `json:"level"` Level int32 `json:"level"`
ProductImage string `json:"product_image"` ProductImage string `json:"product_image"`
OriginalQty int64 `json:"original_qty"`
RemainingQty int64 `json:"remaining_qty"`
Claimed bool `json:"claimed"` Claimed bool `json:"claimed"`
} }
@ -72,7 +70,7 @@ func (h *handler) ListIchibanSlots() core.HandlerFunc {
} }
out := make([]slotItem, len(items)) out := make([]slotItem, len(items))
for i, it := range items { for i, it := range items {
out[i] = slotItem{SlotIndex: it.SlotIndex, RewardID: it.RewardID, RewardName: it.RewardName, Level: it.Level, ProductImage: it.ProductImage, OriginalQty: it.OriginalQty, RemainingQty: it.RemainingQty, Claimed: it.Claimed} out[i] = slotItem{SlotIndex: it.SlotIndex, RewardID: it.RewardID, RewardName: it.RewardName, Level: it.Level, ProductImage: it.ProductImage, Claimed: it.Claimed}
} }
ctx.Payload(&listSlotsResponse{TotalSlots: total, List: out}) ctx.Payload(&listSlotsResponse{TotalSlots: total, List: out})
} }
@ -105,8 +103,6 @@ func (h *handler) GetIchibanSlotDetail() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170203, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, 170203, err.Error()))
return return
} }
ctx.Payload(&slotDetailResponse{Item: slotItem{SlotIndex: it.SlotIndex, RewardID: it.RewardID, RewardName: it.RewardName, Level: it.Level, ProductImage: it.ProductImage, OriginalQty: it.OriginalQty, RemainingQty: it.RemainingQty, Claimed: it.Claimed}}) ctx.Payload(&slotDetailResponse{Item: slotItem{SlotIndex: it.SlotIndex, RewardID: it.RewardID, RewardName: it.RewardName, Level: it.Level, ProductImage: it.ProductImage, Claimed: it.Claimed}})
} }
} }

View File

@ -127,6 +127,9 @@ func (h *handler) SettleIssue() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170201, "issue not found")) ctx.AbortWithError(core.Error(http.StatusBadRequest, 170201, "issue not found"))
return return
} }
// Fetch Activity for Seed
// act, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).Where(h.readDB.Activities.ID.Eq(iss.ActivityID)).First()
cfg := map[string]any{} cfg := map[string]any{}
drawMode := "scheduled" drawMode := "scheduled"
minN := int64(0) minN := int64(0)

View File

@ -122,6 +122,93 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
} }
} }
// 批量查询优惠券和道具卡信息
userCouponIDs := make([]int64, 0)
userItemCardIDs := make([]int64, 0)
// 收集订单ID用于查询 OrderCoupons
orderIDs := make([]int64, 0, len(rows))
for _, o := range rows {
orderIDs = append(orderIDs, o.ID)
if o.CouponID > 0 {
userCouponIDs = append(userCouponIDs, o.CouponID)
}
if o.ItemCardID > 0 {
userItemCardIDs = append(userItemCardIDs, o.ItemCardID)
}
}
couponMap := make(map[int64]map[string]any)
// orderID -> userCouponID -> appliedAmount
appliedAmountMap := make(map[int64]map[int64]int64)
if len(userCouponIDs) > 0 {
userCoupons, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserCoupons.ID.In(userCouponIDs...)).Find()
var sysCouponIDs []int64
ucMap := make(map[int64]*model.UserCoupons)
for _, uc := range userCoupons {
sysCouponIDs = append(sysCouponIDs, uc.CouponID)
ucMap[uc.ID] = uc
}
if len(sysCouponIDs) > 0 {
sysCoupons, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.ID.In(sysCouponIDs...)).Find()
scMap := make(map[int64]*model.SystemCoupons)
for _, sc := range sysCoupons {
scMap[sc.ID] = sc
}
for id, uc := range ucMap {
if sc, ok := scMap[uc.CouponID]; ok {
couponMap[id] = map[string]any{
"user_coupon_id": uc.ID,
"name": sc.Name,
"type": sc.DiscountType,
"value": sc.DiscountValue,
}
}
}
}
// 查询 OrderCoupons 获取实际抵扣金额
if len(orderIDs) > 0 {
ocs, _ := h.readDB.OrderCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(
h.readDB.OrderCoupons.OrderID.In(orderIDs...),
h.readDB.OrderCoupons.UserCouponID.In(userCouponIDs...),
).Find()
for _, oc := range ocs {
if _, ok := appliedAmountMap[oc.OrderID]; !ok {
appliedAmountMap[oc.OrderID] = make(map[int64]int64)
}
appliedAmountMap[oc.OrderID][oc.UserCouponID] = oc.AppliedAmount
}
}
}
itemCardMap := make(map[int64]map[string]any)
if len(userItemCardIDs) > 0 {
userCards, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserItemCards.ID.In(userItemCardIDs...)).Find()
var sysCardIDs []int64
ucMap := make(map[int64]*model.UserItemCards)
for _, uc := range userCards {
sysCardIDs = append(sysCardIDs, uc.CardID)
ucMap[uc.ID] = uc
}
if len(sysCardIDs) > 0 {
sysCards, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemItemCards.ID.In(sysCardIDs...)).Find()
scMap := make(map[int64]*model.SystemItemCards)
for _, sc := range sysCards {
scMap[sc.ID] = sc
}
for id, uc := range ucMap {
if sc, ok := scMap[uc.CardID]; ok {
itemCardMap[id] = map[string]any{
"user_card_id": uc.ID,
"name": sc.Name,
"effect_type": sc.EffectType,
}
}
}
}
}
out := make([]map[string]any, 0, len(rows)) out := make([]map[string]any, 0, len(rows))
for _, o := range rows { for _, o := range rows {
ledgers, err := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(o.OrderNo)).Find() ledgers, err := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(o.OrderNo)).Find()
@ -144,6 +231,20 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
} else if pa > 0 { } else if pa > 0 {
pu = pa * pointsRate pu = pa * pointsRate
} }
var cInfo map[string]any
if baseInfo, ok := couponMap[o.CouponID]; ok {
cInfo = make(map[string]any)
for k, v := range baseInfo {
cInfo[k] = v
}
// 尝试使用实际抵扣金额
if amMap, ok := appliedAmountMap[o.ID]; ok {
if amount, ok2 := amMap[o.CouponID]; ok2 {
cInfo["value"] = amount
}
}
}
item := map[string]any{ item := map[string]any{
"id": o.ID, "id": o.ID,
"order_no": o.OrderNo, "order_no": o.OrderNo,
@ -163,6 +264,8 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
"points_amount": pa, "points_amount": pa,
"points_used": pu, "points_used": pu,
"total_amount": o.TotalAmount, "total_amount": o.TotalAmount,
"coupon_info": cInfo,
"item_card_info": itemCardMap[o.ItemCardID],
} }
out = append(out, item) out = append(out, item)
} }
@ -238,6 +341,8 @@ type getPayOrderResponse struct {
} `json:"reward_items"` } `json:"reward_items"`
RewardShipments []*model.ShippingRecords `json:"reward_shipments"` RewardShipments []*model.ShippingRecords `json:"reward_shipments"`
DrawReceipts []*usersvc.DrawReceiptInfo `json:"draw_receipts"` DrawReceipts []*usersvc.DrawReceiptInfo `json:"draw_receipts"`
CouponInfo map[string]any `json:"coupon_info"`
ItemCardInfo map[string]any `json:"item_card_info"`
} }
func (h *handler) GetPayOrderDetail() core.HandlerFunc { func (h *handler) GetPayOrderDetail() core.HandlerFunc {
@ -658,6 +763,41 @@ func (h *handler) GetPayOrderDetail() core.HandlerFunc {
} }
} }
// 补充优惠券和道具卡信息
if order.CouponID > 0 {
if uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserCoupons.ID.Eq(order.CouponID)).First(); uc != nil {
if sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID)).First(); sc != nil {
val := sc.DiscountValue
// 尝试查找实际抵扣金额
// 上面已经查询了 ocRows直接遍历查找
for _, r := range ocRows {
if r.UserCouponID == order.CouponID {
val = r.AppliedAmount
break
}
}
rsp.CouponInfo = map[string]any{
"user_coupon_id": uc.ID,
"name": sc.Name,
"type": sc.DiscountType,
"value": val,
}
}
}
}
if order.ItemCardID > 0 {
if uc, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserItemCards.ID.Eq(order.ItemCardID)).First(); uc != nil {
if sc, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemItemCards.ID.Eq(uc.CardID)).First(); sc != nil {
rsp.ItemCardInfo = map[string]any{
"user_card_id": uc.ID,
"name": sc.Name,
"effect_type": sc.EffectType,
}
}
}
}
ctx.Payload(rsp) ctx.Payload(rsp)
} }
} }

View File

@ -4,6 +4,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"strconv"
"strings"
"time" "time"
"bindbox-game/internal/code" "bindbox-game/internal/code"
@ -11,6 +14,7 @@ import (
paypkg "bindbox-game/internal/pkg/pay" paypkg "bindbox-game/internal/pkg/pay"
"bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
) )
type createRefundRequest struct { type createRefundRequest struct {
@ -42,6 +46,35 @@ func (h *handler) CreateRefund() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160001, "order not found")) ctx.AbortWithError(core.Error(http.StatusBadRequest, 160001, "order not found"))
return return
} }
// 预检查:检查是否有已兑换积分的资产,并验证用户积分余额是否足够扣除
allInvs, _ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).Where(h.readDB.UserInventory.OrderID.Eq(order.ID)).Where(h.readDB.UserInventory.Status.In(1, 3)).Find()
var pointsToReclaim int64
rePoints := regexp.MustCompile(`\|redeemed_points=(\d+)`)
for _, inv := range allInvs {
if inv.Status == 3 && strings.Contains(inv.Remark, "redeemed_points=") {
matches := rePoints.FindStringSubmatch(inv.Remark)
if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64)
pointsToReclaim += p
}
}
}
if pointsToReclaim > 0 {
svc := usersvc.New(h.logger, h.repo)
balance, err := svc.GetPointsBalance(ctx.RequestContext(), order.UserID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160010, "check points balance failed"))
return
}
if balance < pointsToReclaim {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160011, fmt.Sprintf("用户积分不足以抵扣已兑换积分(需%d, 余额%d),无法退款", pointsToReclaim, balance)))
return
}
}
// 计算已退款与可退余额(分) // 计算已退款与可退余额(分)
ledgers, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo)).Find() ledgers, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(order.OrderNo)).Find()
var refundedSumCents int64 var refundedSumCents int64
@ -137,20 +170,36 @@ func (h *handler) CreateRefund() core.HandlerFunc {
} }
} }
// 全额退款:回收中奖资产与奖品库存 // 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
type invRow struct { svc := usersvc.New(h.logger, h.repo)
ID int64 for _, inv := range allInvs {
RewardID int64 if inv.Status == 1 {
} // 状态1持有更新状态为2 (作废)
var invs []invRow
_ = h.repo.GetDbR().Raw("SELECT id, reward_id FROM user_inventory WHERE order_id=? AND status=1", order.ID).Scan(&invs).Error
for _, inv := range invs {
// 更新状态为2 (作废)
_ = h.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_void') WHERE id=?", inv.ID).Error _ = h.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_void') WHERE id=?", inv.ID).Error
// 恢复奖品库存 (ActivityRewardSettings) // 恢复奖品库存
if inv.RewardID > 0 { if inv.RewardID > 0 {
_ = h.repo.GetDbW().Exec("UPDATE activity_reward_settings SET quantity = quantity + 1 WHERE id=?", inv.RewardID).Error _ = h.repo.GetDbW().Exec("UPDATE activity_reward_settings SET quantity = quantity + 1 WHERE id=?", inv.RewardID).Error
} }
} else if inv.Status == 3 {
// 状态3已兑换扣除积分并作废
matches := rePoints.FindStringSubmatch(inv.Remark)
if len(matches) > 1 {
p, _ := strconv.ParseInt(matches[1], 10, 64)
if p > 0 {
// 扣除积分(记录流水)
_, err := svc.ConsumePointsFor(ctx.RequestContext(), order.UserID, p, "user_inventory", strconv.FormatInt(inv.ID, 10), "订单退款回收兑换积分", "refund_reclaim")
if err != nil {
h.logger.Error(fmt.Sprintf("refund reclaim points failed: order=%s user=%d points=%d err=%v", order.OrderNo, order.UserID, p, err))
}
}
}
// 更新状态为2 (作废)
_ = h.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_reclaimed') WHERE id=?", inv.ID).Error
// 恢复奖品库存
if inv.RewardID > 0 {
_ = h.repo.GetDbW().Exec("UPDATE activity_reward_settings SET quantity = quantity + 1 WHERE id=?", inv.RewardID).Error
}
}
} }
// 全额退款:取消待发货记录 // 全额退款:取消待发货记录

View File

@ -14,7 +14,7 @@ type rewardItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ProductID int64 `json:"product_id"` ProductID int64 `json:"product_id"`
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Weight int32 `json:"weight" binding:"required"` Weight float64 `json:"weight" binding:"required"`
Quantity int64 `json:"quantity" binding:"required"` Quantity int64 `json:"quantity" binding:"required"`
OriginalQty int64 `json:"original_qty" binding:"required"` OriginalQty int64 `json:"original_qty" binding:"required"`
Level int32 `json:"level" binding:"required"` Level int32 `json:"level" binding:"required"`
@ -70,7 +70,7 @@ func (h *handler) CreateIssueRewards() core.HandlerFunc {
rewards = append(rewards, activitysvc.CreateRewardInput{ rewards = append(rewards, activitysvc.CreateRewardInput{
ProductID: r.ProductID, ProductID: r.ProductID,
Name: r.Name, Name: r.Name,
Weight: r.Weight, Weight: int32(r.Weight),
Quantity: r.Quantity, Quantity: r.Quantity,
OriginalQty: r.OriginalQty, OriginalQty: r.OriginalQty,
Level: r.Level, Level: r.Level,
@ -120,7 +120,7 @@ func (h *handler) ListIssueRewards() core.HandlerFunc {
ID: v.ID, ID: v.ID,
ProductID: v.ProductID, ProductID: v.ProductID,
Name: v.Name, Name: v.Name,
Weight: v.Weight, Weight: float64(v.Weight),
Quantity: v.Quantity, Quantity: v.Quantity,
OriginalQty: v.OriginalQty, OriginalQty: v.OriginalQty,
Level: v.Level, Level: v.Level,

View File

@ -4,6 +4,7 @@ import (
"bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
tasksvc "bindbox-game/internal/service/task_center"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
) )
@ -12,9 +13,11 @@ type handler struct {
writeDB *dao.Query writeDB *dao.Query
readDB *dao.Query readDB *dao.Query
user usersvc.Service user usersvc.Service
task tasksvc.Service
repo mysql.Repo repo mysql.Repo
} }
func New(logger logger.CustomLogger, db mysql.Repo) *handler { func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
return &handler{logger: logger, writeDB: dao.Use(db.GetDbW()), readDB: dao.Use(db.GetDbR()), user: usersvc.New(logger, db), repo: db} userSvc := usersvc.New(logger, db)
return &handler{logger: logger, writeDB: dao.Use(db.GetDbW()), readDB: dao.Use(db.GetDbR()), user: userSvc, task: taskSvc, repo: db}
} }

View File

@ -13,11 +13,14 @@ import (
"bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/core"
lotterynotify "bindbox-game/internal/pkg/notify" lotterynotify "bindbox-game/internal/pkg/notify"
pay "bindbox-game/internal/pkg/pay" pay "bindbox-game/internal/pkg/pay"
pkgutils "bindbox-game/internal/pkg/utils"
"bindbox-game/internal/pkg/wechat" "bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/repository/mysql/model"
strat "bindbox-game/internal/service/activity/strategy" strat "bindbox-game/internal/service/activity/strategy"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
"go.uber.org/zap"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader" "github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify" "github.com/wechatpay-apiv3/wechatpay-go/core/notify"
@ -150,6 +153,12 @@ func (h *handler) WechatNotify() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150005, err.Error())) ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150005, err.Error()))
return return
} }
// 触发任务中心逻辑 (如有效邀请检测)
if err := h.task.OnOrderPaid(ctx.RequestContext(), order.UserID, order.ID); err != nil {
h.logger.Error("TaskCenter OnOrderPaid failed", zap.Error(err))
}
ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(*transaction.OutTradeNo)).First() ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(*transaction.OutTradeNo)).First()
// 支付成功后扣减优惠券余额(优先使用结构化明细表),如无明细再降级解析备注 // 支付成功后扣减优惠券余额(优先使用结构化明细表),如无明细再降级解析备注
if ord != nil { if ord != nil {
@ -345,6 +354,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
break break
} }
// 位置已在上面占用,这里直接选择奖品 // 位置已在上面占用,这里直接选择奖品
// Use Commitment Seed (via SelectItemBySlot internal logic)
rid, proof, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), aid, iss, slot) rid, proof, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), aid, iss, slot)
fmt.Printf("[支付回调-抽奖] SelectItemBySlot 结果 rid=%d err=%v\n", rid, e2) fmt.Printf("[支付回调-抽奖] SelectItemBySlot 结果 rid=%d err=%v\n", rid, e2)
if e2 != nil || rid <= 0 { if e2 != nil || rid <= 0 {
@ -471,7 +481,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
} }
} }
// 【开奖后虚拟发货】即时开奖完成后上传虚拟发货 // 【开奖后虚拟发货】即时开奖完成后上传虚拟发货
go func(orderID int64, orderNo string, userID int64, actName string) { go func(orderID int64, orderNo string, userID int64, actName string, playType string) {
bgCtx := context.Background() bgCtx := context.Background()
drawLogs, _ := h.readDB.ActivityDrawLogs.WithContext(bgCtx).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find() drawLogs, _ := h.readDB.ActivityDrawLogs.WithContext(bgCtx).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
if len(drawLogs) == 0 { if len(drawLogs) == 0 {
@ -486,9 +496,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
} }
} }
itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ") itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ")
if len(itemsDesc) > 120 { itemsDesc = pkgutils.TruncateBytes(itemsDesc, 120)
itemsDesc = itemsDesc[:120]
}
// 获取支付交易信息 // 获取支付交易信息
var tx *model.PaymentTransactions var tx *model.PaymentTransactions
tx, _ = h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First() tx, _ = h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
@ -507,14 +515,16 @@ func (h *handler) WechatNotify() core.HandlerFunc {
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil { if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil {
fmt.Printf("[即时开奖-虚拟发货] 上传失败: %v\n", err) fmt.Printf("[即时开奖-虚拟发货] 上传失败: %v\n", err)
} }
// 【开奖后推送通知】 // 【开奖后推送通知】只有一番赏才发送
if playType == "ichiban" {
notifyCfg := &lotterynotify.WechatNotifyConfig{ notifyCfg := &lotterynotify.WechatNotifyConfig{
AppID: c.Wechat.AppID, AppID: c.Wechat.AppID,
AppSecret: c.Wechat.AppSecret, AppSecret: c.Wechat.AppSecret,
LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID, LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID,
} }
_ = lotterynotify.SendLotteryResultNotification(bgCtx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now()) _ = lotterynotify.SendLotteryResultNotification(bgCtx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now())
}(ord.ID, ord.OrderNo, ord.UserID, act.Name) }
}(ord.ID, ord.OrderNo, ord.UserID, act.Name, act.PlayType)
} }
} }
if ord != nil { if ord != nil {
@ -525,10 +535,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
parts = append(parts, it.Title+"*"+func(q int64) string { return fmt.Sprintf("%d", q) }(it.Quantity)) parts = append(parts, it.Title+"*"+func(q int64) string { return fmt.Sprintf("%d", q) }(it.Quantity))
} }
s := strings.Join(parts, ", ") s := strings.Join(parts, ", ")
if len(s) > 120 { itemsDesc = pkgutils.TruncateRunes(s, 120)
s = s[:120]
}
itemsDesc = s
} else { } else {
itemsDesc = "订单" + ord.OrderNo itemsDesc = "订单" + ord.OrderNo
} }
@ -536,8 +543,8 @@ func (h *handler) WechatNotify() core.HandlerFunc {
if transaction.Payer != nil && transaction.Payer.Openid != nil { if transaction.Payer != nil && transaction.Payer.Openid != nil {
payerOpenid = *transaction.Payer.Openid payerOpenid = *transaction.Payer.Openid
} }
// 抽奖订单在开奖后发货,非抽奖订单在支付后立即发货 // 抽奖订单(2)和对对碰订单(3)在开奖/结算后发货,非此类订单在支付后立即发货
if ord.SourceType != 2 { if ord.SourceType != 2 && ord.SourceType != 3 {
if transaction.TransactionId != nil && *transaction.TransactionId != "" { if transaction.TransactionId != nil && *transaction.TransactionId != "" {
fmt.Printf("[支付回调] 虚拟发货 尝试上传 order_id=%d order_no=%s user_id=%d transaction_id=%s items_desc=%s payer_openid=%s\n", ord.ID, ord.OrderNo, ord.UserID, *transaction.TransactionId, itemsDesc, payerOpenid) fmt.Printf("[支付回调] 虚拟发货 尝试上传 order_id=%d order_no=%s user_id=%d transaction_id=%s items_desc=%s payer_openid=%s\n", ord.ID, ord.OrderNo, ord.UserID, *transaction.TransactionId, itemsDesc, payerOpenid)
if err := wechat.UploadVirtualShippingWithFallback(ctx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, *transaction.TransactionId, ord.OrderNo, payerOpenid, itemsDesc, time.Now()); err != nil { if err := wechat.UploadVirtualShippingWithFallback(ctx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, *transaction.TransactionId, ord.OrderNo, payerOpenid, itemsDesc, time.Now()); err != nil {
@ -545,7 +552,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
} }
} }
} else { } else {
fmt.Printf("[支付回调] 抽奖订单跳过虚拟发货,将在开奖后发货 order_id=%d order_no=%s\n", ord.ID, ord.OrderNo) fmt.Printf("[支付回调] 抽奖/对对碰订单跳过虚拟发货,将在开奖/结算后发货 order_id=%d order_no=%s source_type=%d\n", ord.ID, ord.OrderNo, ord.SourceType)
} }
} }
// 标记事件处理完成 // 标记事件处理完成

View File

@ -6,6 +6,7 @@ import (
tasksvc "bindbox-game/internal/service/task_center" tasksvc "bindbox-game/internal/service/task_center"
"net/http" "net/http"
"strconv" "strconv"
"time"
"gorm.io/datatypes" "gorm.io/datatypes"
) )
@ -30,7 +31,14 @@ func (h *handler) ListTasksForAdmin() core.HandlerFunc {
} }
out := &rsp{Total: total, List: make([]map[string]any, len(items))} out := &rsp{Total: total, List: make([]map[string]any, len(items))}
for i, v := range items { for i, v := range items {
out.List[i] = map[string]any{"id": v.ID, "name": v.Name, "description": v.Description, "status": v.Status, "start_time": v.StartTime, "end_time": v.EndTime} var stStr, etStr string
if v.StartTime > 0 {
stStr = time.Unix(v.StartTime, 0).Format("2006-01-02 15:04:05")
}
if v.EndTime > 0 {
etStr = time.Unix(v.EndTime, 0).Format("2006-01-02 15:04:05")
}
out.List[i] = map[string]any{"id": v.ID, "name": v.Name, "description": v.Description, "status": v.Status, "start_time": stStr, "end_time": etStr}
} }
ctx.Payload(out) ctx.Payload(out)
} }
@ -41,6 +49,8 @@ type createTaskRequest struct {
Description string `json:"description"` Description string `json:"description"`
Status int32 `json:"status"` Status int32 `json:"status"`
Visibility int32 `json:"visibility"` Visibility int32 `json:"visibility"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
} }
// @Summary 创建任务(Admin) // @Summary 创建任务(Admin)
@ -58,7 +68,18 @@ func (h *handler) CreateTaskForAdmin() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return return
} }
id, err := h.task.CreateTask(ctx.RequestContext(), tasksvc.CreateTaskInput{Name: req.Name, Description: req.Description, Status: req.Status, Visibility: req.Visibility}) var st, et *time.Time
if req.StartTime != "" {
if t, err := time.Parse("2006-01-02 15:04:05", req.StartTime); err == nil {
st = &t
}
}
if req.EndTime != "" {
if t, err := time.Parse("2006-01-02 15:04:05", req.EndTime); err == nil {
et = &t
}
}
id, err := h.task.CreateTask(ctx.RequestContext(), tasksvc.CreateTaskInput{Name: req.Name, Description: req.Description, Status: req.Status, Visibility: req.Visibility, StartTime: st, EndTime: et})
if err != nil { if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return return
@ -72,6 +93,8 @@ type modifyTaskRequest struct {
Description string `json:"description"` Description string `json:"description"`
Status int32 `json:"status"` Status int32 `json:"status"`
Visibility int32 `json:"visibility"` Visibility int32 `json:"visibility"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
} }
// @Summary 修改任务(Admin) // @Summary 修改任务(Admin)
@ -95,7 +118,18 @@ func (h *handler) ModifyTaskForAdmin() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return return
} }
if err := h.task.ModifyTask(ctx.RequestContext(), id, tasksvc.ModifyTaskInput{Name: req.Name, Description: req.Description, Status: req.Status, Visibility: req.Visibility}); err != nil { var st, et *time.Time
if req.StartTime != "" {
if t, err := time.Parse("2006-01-02 15:04:05", req.StartTime); err == nil {
st = &t
}
}
if req.EndTime != "" {
if t, err := time.Parse("2006-01-02 15:04:05", req.EndTime); err == nil {
et = &t
}
}
if err := h.task.ModifyTask(ctx.RequestContext(), id, tasksvc.ModifyTaskInput{Name: req.Name, Description: req.Description, Status: req.Status, Visibility: req.Visibility, StartTime: st, EndTime: et}); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return return
} }
@ -134,6 +168,7 @@ type upsertTiersRequest struct {
Window string `json:"window"` Window string `json:"window"`
Repeatable int32 `json:"repeatable"` Repeatable int32 `json:"repeatable"`
Priority int32 `json:"priority"` Priority int32 `json:"priority"`
ExtraParams datatypes.JSON `json:"extra_params"`
} `json:"tiers"` } `json:"tiers"`
} }
@ -160,7 +195,7 @@ func (h *handler) UpsertTaskTiersForAdmin() core.HandlerFunc {
} }
in := make([]tasksvc.TaskTierInput, len(req.Tiers)) in := make([]tasksvc.TaskTierInput, len(req.Tiers))
for i, t := range req.Tiers { for i, t := range req.Tiers {
in[i] = tasksvc.TaskTierInput{Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority} in[i] = tasksvc.TaskTierInput{Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority, ExtraParams: t.ExtraParams}
} }
if err := h.task.UpsertTaskTiers(ctx.RequestContext(), id, in); err != nil { if err := h.task.UpsertTaskTiers(ctx.RequestContext(), id, in); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))

View File

@ -15,13 +15,16 @@ type handler struct {
task tasksvc.Service task tasksvc.Service
} }
func New(l logger.CustomLogger, db mysql.Repo) *handler { func New(l logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
return &handler{ return &handler{
logger: l, logger: l,
writeDB: dao.Use(db.GetDbW()), writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()), readDB: dao.Use(db.GetDbR()),
repo: db, repo: db,
task: tasksvc.New(l, db), task: taskSvc,
} }
} }
func (h *handler) GetTaskService() tasksvc.Service {
return h.task
}

View File

@ -0,0 +1,43 @@
package app
import (
"net/http"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
)
type cancelShippingRequest struct {
InventoryID int64 `json:"inventory_id"`
}
type cancelShippingResponse struct{}
// CancelShipping 取消发货申请
// @Summary 取消发货申请
// @Description 取消已提交但未发货的申请;恢复库存状态
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param user_id path integer true "用户ID"
// @Param RequestBody body cancelShippingRequest true "请求参数资产ID"
// @Success 200 {object} cancelShippingResponse "成功"
// @Failure 400 {object} code.Failure "参数错误/记录不存在/已处理"
// @Router /api/app/users/{user_id}/inventory/cancel-shipping [post]
func (h *handler) CancelShipping() core.HandlerFunc {
return func(ctx core.Context) {
req := new(cancelShippingRequest)
if err := ctx.ShouldBindJSON(req); err != nil || req.InventoryID == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "参数错误"))
return
}
userID := int64(ctx.SessionUserInfo().Id)
err := h.user.CancelShipping(ctx.RequestContext(), userID, req.InventoryID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10024, err.Error()))
return
}
ctx.Payload(nil)
}
}

View File

@ -55,7 +55,17 @@ func (h *handler) ListUserItemCards() core.HandlerFunc {
status = *req.Status status = *req.Status
} }
items, total, err := h.user.ListUserItemCardsWithTemplateByStatus(ctx.RequestContext(), userID, status, req.Page, req.PageSize) var items []*usersvc.ItemCardWithTemplate
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 {
items, total, err = h.user.ListUserItemCardsWithTemplateByStatus(ctx.RequestContext(), userID, status, req.Page, req.PageSize)
}
if err != nil { if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10008, err.Error())) ctx.AbortWithError(core.Error(http.StatusBadRequest, 10008, err.Error()))
return return

View File

@ -0,0 +1,93 @@
package async
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
const (
TaskEventQueueKey = "task_center:events:queue"
)
type EventType string
const (
EventTypeOrderPaid EventType = "order_paid"
EventTypeInviteSuccess EventType = "invite_success"
)
type TaskEvent struct {
Type EventType `json:"type"`
Payload string `json:"payload"`
CreatedAt int64 `json:"created_at"`
}
type OrderPaidPayload struct {
UserID int64 `json:"user_id"`
OrderID int64 `json:"order_id"`
}
type InviteSuccessPayload struct {
InviterID int64 `json:"inviter_id"`
InviteeID int64 `json:"invitee_id"`
}
type TaskQueue interface {
PublishOrderPaid(ctx context.Context, userID, orderID int64) error
PublishInviteSuccess(ctx context.Context, inviterID, inviteeID int64) error
Consume(ctx context.Context) (*TaskEvent, error)
}
type redisTaskQueue struct {
client *redis.Client
}
func NewRedisTaskQueue(client *redis.Client) TaskQueue {
return &redisTaskQueue{client: client}
}
func (q *redisTaskQueue) PublishOrderPaid(ctx context.Context, userID, orderID int64) error {
payload, _ := json.Marshal(OrderPaidPayload{UserID: userID, OrderID: orderID})
event := TaskEvent{
Type: EventTypeOrderPaid,
Payload: string(payload),
CreatedAt: time.Now().Unix(),
}
bytes, _ := json.Marshal(event)
return q.client.LPush(ctx, TaskEventQueueKey, bytes).Err()
}
func (q *redisTaskQueue) PublishInviteSuccess(ctx context.Context, inviterID, inviteeID int64) error {
payload, _ := json.Marshal(InviteSuccessPayload{InviterID: inviterID, InviteeID: inviteeID})
event := TaskEvent{
Type: EventTypeInviteSuccess,
Payload: string(payload),
CreatedAt: time.Now().Unix(),
}
bytes, _ := json.Marshal(event)
return q.client.LPush(ctx, TaskEventQueueKey, bytes).Err()
}
func (q *redisTaskQueue) Consume(ctx context.Context) (*TaskEvent, error) {
// Block for 2 seconds
result, err := q.client.BRPop(ctx, 2*time.Second, TaskEventQueueKey).Result()
if err != nil {
if err == redis.Nil {
return nil, nil // Timeout, no message
}
return nil, err
}
if len(result) < 2 {
return nil, fmt.Errorf("invalid redis result")
}
var event TaskEvent
if err := json.Unmarshal([]byte(result[1]), &event); err != nil {
return nil, err
}
return &event, nil
}

View File

@ -490,17 +490,17 @@ func New(logger logger.CustomLogger, options ...Option) (Mux, error) {
t.Success = !ctx.IsAborted() && (ctx.Writer.Status() == http.StatusOK) t.Success = !ctx.IsAborted() && (ctx.Writer.Status() == http.StatusOK)
t.CostSeconds = time.Since(ts).Seconds() t.CostSeconds = time.Since(ts).Seconds()
logger.Info("trace-log", // logger.Info("trace-log",
zap.Any("method", ctx.Request.Method), // zap.Any("method", ctx.Request.Method),
zap.Any("path", decodedURL), // zap.Any("path", decodedURL),
zap.Any("http_code", ctx.Writer.Status()), // zap.Any("http_code", ctx.Writer.Status()),
zap.Any("business_code", businessCode), // zap.Any("business_code", businessCode),
zap.Any("success", t.Success), // zap.Any("success", t.Success),
zap.Any("cost_seconds", t.CostSeconds), // zap.Any("cost_seconds", t.CostSeconds),
zap.Any("trace_id", t.Identifier), // zap.Any("trace_id", t.Identifier),
zap.Any("trace_info", t), // zap.Any("trace_info", t),
zap.Error(abortErr), // zap.Error(abortErr),
) // )
traceInfo := "" traceInfo := ""
if traceJsonData, err := json.Marshal(t); err == nil { if traceJsonData, err := json.Marshal(t); err == nil {

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"bindbox-game/internal/pkg/httpclient" "bindbox-game/internal/pkg/httpclient"
pkgutils "bindbox-game/internal/pkg/utils"
) )
// WechatNotifyConfig 微信通知配置 // WechatNotifyConfig 微信通知配置
@ -111,19 +112,16 @@ func SendLotteryResultNotification(ctx context.Context, cfg *WechatNotifyConfig,
return err return err
} }
// 活动名称限制长度thing类型不超过20个字符 // 活动名称限制长度thing类型不超过20个字符
if len(activityName) > 20 { activityName = pkgutils.TruncateRunes(activityName, 20)
activityName = activityName[:17] + "..."
}
// 构建中奖结果描述phrase类型限制5个汉字以内 // 构建中奖结果描述phrase类型限制5个汉字以内
// 将中奖奖品写入到 resultPhrase // 由于奖品名称通常较长phrase3 放不下,改为固定文案 "恭喜中奖"
resultPhrase := strings.Join(rewardNames, ",") // 将奖品名称放入 Thing4 (温馨提示),限制 20 字符
if len(resultPhrase) > 5 { resultPhrase := "恭喜中奖"
resultPhrase = resultPhrase[:2] + "..."
} rewardsStr := strings.Join(rewardNames, ",")
warmTips := pkgutils.TruncateRunes(rewardsStr, 20)
// 温馨提示:固定短语
warmTips := "已发送到您的货柜"
req := &LotteryResultNotificationRequest{ req := &LotteryResultNotificationRequest{
Touser: openid, Touser: openid,
TemplateID: cfg.LotteryResultTemplateID, TemplateID: cfg.LotteryResultTemplateID,

View File

@ -0,0 +1,58 @@
package utils
import (
"testing"
)
func TestTruncateRunes(t *testing.T) {
tests := []struct {
name string
input string
limit int
want string
}{
{"Short English", "hello", 10, "hello"},
{"Long English", "hello world", 5, "hello"},
{"Short Chinese", "你好世界", 10, "你好世界"},
{"Long Chinese", "你好世界", 2, "你好"},
{"Mixed", "hi你好", 3, "hi你"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateRunes(tt.input, tt.limit); got != tt.want {
t.Errorf("TruncateRunes() = %v, want %v", got, tt.want)
}
})
}
}
func TestTruncateBytes(t *testing.T) {
tests := []struct {
name string
input string
limit int
want string
}{
{"Short English", "hello", 10, "hello"},
{"Long English", "hello world", 5, "hello"},
{"Chinese Exact", "你好", 6, "你好"}, // 3+3=6
{"Chinese Cut", "你好", 5, "你"}, // 3+3=6 > 5, cut to 3
{"Chinese Cut 2", "你好", 4, "你"}, // 3+3=6 > 4, cut to 3
{"Chinese Cut 3", "你好", 2, ""}, // 3 > 2, cut to 0
{"Mixed", "hi你好", 4, "hi"}, // 1+1+3=5 > 4, cut to 2
{"Mixed 2", "hi你好", 5, "hi你"}, // 1+1+3=5 <= 5
{"Log Case", "名创优品 O20251223131406 盲盒赏品: MINISO名创优品U型枕飞机云朵护颈枕记忆棉", 40, "名创优品 O20251223131406 盲盒赏"}, // Correctly truncates before "品"
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := TruncateBytes(tt.input, tt.limit)
if got != tt.want {
t.Errorf("TruncateBytes(%q, %d) = %q, want %q", tt.input, tt.limit, got, tt.want)
}
// Verify valid utf8
if len(got) > tt.limit {
t.Errorf("Length %d > limit %d", len(got), tt.limit)
}
})
}
}

View File

@ -229,3 +229,28 @@ func XorDecrypt(encrypted, key string) (string, error) {
} }
return string(result), nil return string(result), nil
} }
// TruncateRunes truncates a string to the specified rune limit
func TruncateRunes(s string, limit int) string {
runes := []rune(s)
if len(runes) > limit {
return string(runes[:limit])
}
return s
}
// TruncateBytes truncates a string to the specified byte limit while ensuring valid UTF-8
func TruncateBytes(s string, limit int) string {
if len(s) <= limit {
return s
}
var b int
for i, r := range s {
rl := len(string(r))
if b+rl > limit {
return s[:i]
}
b += rl
}
return s
}

View File

@ -39,6 +39,10 @@ type Activities struct {
CommitmentAlgo string `gorm:"column:commitment_algo;default:commit-v1" json:"commitment_algo"` CommitmentAlgo string `gorm:"column:commitment_algo;default:commit-v1" json:"commitment_algo"`
CommitmentSeedMaster []byte `gorm:"column:commitment_seed_master" json:"commitment_seed_master"` CommitmentSeedMaster []byte `gorm:"column:commitment_seed_master" json:"commitment_seed_master"`
CommitmentSeedHash []byte `gorm:"column:commitment_seed_hash" json:"commitment_seed_hash"` CommitmentSeedHash []byte `gorm:"column:commitment_seed_hash" json:"commitment_seed_hash"`
DailySeed string `gorm:"column:daily_seed" json:"daily_seed"`
DailySeedDate string `gorm:"column:daily_seed_date" json:"daily_seed_date"`
LastDailySeed string `gorm:"column:last_daily_seed" json:"last_daily_seed"`
LastDailySeedDate string `gorm:"column:last_daily_seed_date" json:"last_daily_seed_date"`
CommitmentStateVersion int32 `gorm:"column:commitment_state_version" json:"commitment_state_version"` CommitmentStateVersion int32 `gorm:"column:commitment_state_version" json:"commitment_state_version"`
CommitmentItemsRoot []byte `gorm:"column:commitment_items_root" json:"commitment_items_root"` CommitmentItemsRoot []byte `gorm:"column:commitment_items_root" json:"commitment_items_root"`
GameplayIntro string `gorm:"column:gameplay_intro;comment:玩法介绍" json:"gameplay_intro"` // 玩法介绍 GameplayIntro string `gorm:"column:gameplay_intro;comment:玩法介绍" json:"gameplay_intro"` // 玩法介绍

View File

@ -29,6 +29,8 @@ type Orders struct {
UserAddressID int64 `gorm:"column:user_address_id;comment:收货地址IDuser_addresses.id" json:"user_address_id"` // 收货地址IDuser_addresses.id UserAddressID int64 `gorm:"column:user_address_id;comment:收货地址IDuser_addresses.id" json:"user_address_id"` // 收货地址IDuser_addresses.id
IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产) IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产)
PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水IDuser_points_ledger.id" json:"points_ledger_id"` // 积分扣减流水IDuser_points_ledger.id PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水IDuser_points_ledger.id" json:"points_ledger_id"` // 积分扣减流水IDuser_points_ledger.id
CouponID int64 `gorm:"column:coupon_id;comment:使用的优惠券ID" json:"coupon_id"` // 使用的优惠券ID
ItemCardID int64 `gorm:"column:item_card_id;comment:使用的道具卡ID" json:"item_card_id"` // 使用的道具卡ID
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注 Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
} }

View File

@ -19,6 +19,8 @@ type UserInvites struct {
InviteeID int64 `gorm:"column:invitee_id;not null;comment:被邀请用户ID" json:"invitee_id"` // 被邀请用户ID InviteeID int64 `gorm:"column:invitee_id;not null;comment:被邀请用户ID" json:"invitee_id"` // 被邀请用户ID
InviteCode string `gorm:"column:invite_code;not null;comment:邀请时使用的邀请码" json:"invite_code"` // 邀请时使用的邀请码 InviteCode string `gorm:"column:invite_code;not null;comment:邀请时使用的邀请码" json:"invite_code"` // 邀请时使用的邀请码
RewardPoints int64 `gorm:"column:reward_points;not null;comment:发放的积分数量(用于审计)" json:"reward_points"` // 发放的积分数量(用于审计) RewardPoints int64 `gorm:"column:reward_points;not null;comment:发放的积分数量(用于审计)" json:"reward_points"` // 发放的积分数量(用于审计)
IsEffective int32 `gorm:"column:is_effective;not null;default:0;comment:是否为有效邀请" json:"is_effective"` // 是否为有效邀请
AccumulatedAmount int64 `gorm:"column:accumulated_amount;not null;default:0;comment:被邀请人累计消费金额(分)" json:"accumulated_amount"` // 被邀请人累计消费金额(分)
RewardedAt time.Time `gorm:"column:rewarded_at;comment:奖励发放时间" json:"rewarded_at"` // 奖励发放时间 RewardedAt time.Time `gorm:"column:rewarded_at;comment:奖励发放时间" json:"rewarded_at"` // 奖励发放时间
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间 UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间

View File

@ -2,6 +2,7 @@ package task_center
import ( import (
"time" "time"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -10,11 +11,13 @@ type Task struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:64;not null"` Name string `gorm:"size:64;not null"`
Description string `gorm:"type:text"` Description string `gorm:"type:text"`
Status int32 `gorm:"not null"` Status int32 `gorm:"not null;index"` // 增加索引,常用于过滤活跃任务
StartTime *time.Time `gorm:"index"` StartTime *time.Time `gorm:"index"`
EndTime *time.Time `gorm:"index"` EndTime *time.Time `gorm:"index"`
Visibility int32 `gorm:"not null"` Visibility int32 `gorm:"not null"`
ConditionsSchema datatypes.JSON `gorm:"type:json"` ConditionsSchema datatypes.JSON `gorm:"type:json"`
Tiers []TaskTier `gorm:"foreignKey:TaskID"`
Rewards []TaskReward `gorm:"foreignKey:TaskID"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@ -30,6 +33,7 @@ type TaskTier struct {
Window string `gorm:"size:32;not null"` Window string `gorm:"size:32;not null"`
Repeatable int32 `gorm:"not null"` Repeatable int32 `gorm:"not null"`
Priority int32 `gorm:"not null"` Priority int32 `gorm:"not null"`
ExtraParams datatypes.JSON `gorm:"type:json"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@ -51,10 +55,11 @@ func (TaskReward) TableName() string { return "task_center_task_rewards" }
type UserTaskProgress struct { type UserTaskProgress struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"index;not null"` UserID int64 `gorm:"uniqueIndex:uk_user_task;not null"` // 联合唯一索引,防止并发重复创建
TaskID int64 `gorm:"index;not null"` TaskID int64 `gorm:"uniqueIndex:uk_user_task;not null"` // 联合唯一索引
OrderCount int64 `gorm:"not null"` OrderCount int64 `gorm:"not null"`
InviteCount int64 `gorm:"not null"` InviteCount int64 `gorm:"not null"`
EffectiveInviteCount int64 `gorm:"not null;default:0"`
FirstOrder int32 `gorm:"not null"` FirstOrder int32 `gorm:"not null"`
ClaimedTiers datatypes.JSON `gorm:"type:json"` ClaimedTiers datatypes.JSON `gorm:"type:json"`
CreatedAt time.Time CreatedAt time.Time
@ -82,4 +87,3 @@ func (TaskEventLog) TableName() string { return "task_center_event_logs" }
func AutoMigrate(db *gorm.DB) error { func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(&Task{}, &TaskTier{}, &TaskReward{}, &UserTaskProgress{}, &TaskEventLog{}) return db.AutoMigrate(&Task{}, &TaskTier{}, &TaskReward{}, &UserTaskProgress{}, &TaskEventLog{})
} }

View File

@ -15,18 +15,21 @@ import (
"bindbox-game/internal/pkg/redis" "bindbox-game/internal/pkg/redis"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/router/interceptor" "bindbox-game/internal/router/interceptor"
tasksvc "bindbox-game/internal/service/task_center"
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
"context" "context"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), error) {
if logger == nil { if logger == nil {
return nil, errors.New("logger required") return nil, nil, errors.New("logger required")
} }
if db == nil { if db == nil {
return nil, errors.New("db required") return nil, nil, errors.New("db required")
} }
mux, err := core.New(logger, mux, err := core.New(logger,
@ -41,19 +44,27 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) {
panic(err) panic(err)
} }
// Init Redis // Redis is initialized in main.go
if err := redis.Init(context.Background(), logger); err != nil {
panic(err)
}
rdb := redis.GetClient() rdb := redis.GetClient()
// Instantiate Services
userSvc := usersvc.New(logger, db)
titleSvc := titlesvc.New(logger, db)
taskSvc := tasksvc.New(logger, db, rdb, userSvc, titleSvc)
// Context for Worker
ctx, cancel := context.WithCancel(context.Background())
// Start task center worker
go taskSvc.StartWorker(ctx)
// 实例化拦截器 // 实例化拦截器
adminHandler := admin.New(logger, db) adminHandler := admin.New(logger, db)
activityHandler := activityapi.New(logger, db, rdb) activityHandler := activityapi.New(logger, db, rdb)
taskCenterHandler := taskcenterapi.New(logger, db) taskCenterHandler := taskcenterapi.New(logger, db, taskSvc)
userHandler := userapi.New(logger, db) userHandler := userapi.New(logger, db)
commonHandler := commonapi.New(logger, db) commonHandler := commonapi.New(logger, db)
payHandler := payapi.New(logger, db) payHandler := payapi.New(logger, db, taskSvc)
// minesweeperHandler := minesweeperapi.New(logger, db) // minesweeperHandler := minesweeperapi.New(logger, db)
intc := interceptor.New(logger, db) intc := interceptor.New(logger, db)
@ -343,6 +354,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) {
appAuthApiRouter.POST("/users/:user_id/inventory/address-share/create", userHandler.CreateAddressShare()) appAuthApiRouter.POST("/users/:user_id/inventory/address-share/create", userHandler.CreateAddressShare())
appAuthApiRouter.POST("/users/:user_id/inventory/address-share/revoke", userHandler.RevokeAddressShare()) appAuthApiRouter.POST("/users/:user_id/inventory/address-share/revoke", userHandler.RevokeAddressShare())
appAuthApiRouter.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch()) appAuthApiRouter.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch())
appAuthApiRouter.POST("/users/:user_id/inventory/cancel-shipping", userHandler.CancelShipping())
appAuthApiRouter.POST("/users/:user_id/inventory/redeem", userHandler.RedeemInventoryToPoints()) appAuthApiRouter.POST("/users/:user_id/inventory/redeem", userHandler.RedeemInventoryToPoints())
appAuthApiRouter.POST("/users/:user_id/points/redeem-coupon", userHandler.RedeemPointsToCoupon()) appAuthApiRouter.POST("/users/:user_id/points/redeem-coupon", userHandler.RedeemPointsToCoupon())
appAuthApiRouter.POST("/users/:user_id/points/redeem-product", userHandler.RedeemPointsToProduct()) appAuthApiRouter.POST("/users/:user_id/points/redeem-product", userHandler.RedeemPointsToProduct())
@ -350,5 +362,5 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) {
} }
// 微信支付平台回调(无需鉴权) // 微信支付平台回调(无需鉴权)
mux.Group("/api/pay").POST("/wechat/notify", payHandler.WechatNotify()) mux.Group("/api/pay").POST("/wechat/notify", payHandler.WechatNotify())
return mux, nil return mux, cancel, nil
} }

View File

@ -91,6 +91,16 @@ func (s *activityOrderService) CreateActivityOrder(ctx core.Context, req CreateA
order.Remark = fmt.Sprintf("activity:%d|issue:%d|count:%d", req.ActivityID, req.IssueID, count) order.Remark = fmt.Sprintf("activity:%d|issue:%d|count:%d", req.ActivityID, req.IssueID, count)
} }
// 记录优惠券和道具卡信息(显式字段 + 备注追加)
if req.CouponID != nil && *req.CouponID > 0 {
order.CouponID = *req.CouponID
order.Remark += fmt.Sprintf("|coupon:%d", *req.CouponID)
}
if req.ItemCardID != nil && *req.ItemCardID > 0 {
order.ItemCardID = *req.ItemCardID
order.Remark += fmt.Sprintf("|itemcard:%d", *req.ItemCardID)
}
// 2. 应用称号折扣 (Title Discount) // 2. 应用称号折扣 (Title Discount)
titleEffects, _ := s.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{ titleEffects, _ := s.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{
ActivityID: &req.ActivityID, ActivityID: &req.ActivityID,

View File

@ -36,20 +36,14 @@ func (s *IchibanSlotsService) buildMapping(ctx context.Context, activityID int64
if err != nil || len(rewards) == 0 { if err != nil || len(rewards) == 0 {
return nil, errors.New("no rewards") return nil, errors.New("no rewards")
} }
var total int64 // 一番赏:每种奖品 = 1个格位
for _, r := range rewards { total := int64(len(rewards))
if r.OriginalQty > 0 {
total += r.OriginalQty
}
}
if total <= 0 { if total <= 0 {
return nil, errors.New("no slots") return nil, errors.New("no slots")
} }
slots := make([]int64, 0, total) slots := make([]int64, total)
for _, r := range rewards { for i, r := range rewards {
for i := int64(0); i < r.OriginalQty; i++ { slots[i] = r.ID
slots = append(slots, r.ID)
}
} }
mac := hmac.New(sha256.New, seed) mac := hmac.New(sha256.New, seed)
for i := int(total - 1); i > 0; i-- { for i := int(total - 1); i > 0; i-- {
@ -68,8 +62,6 @@ type SlotItem struct {
RewardName string RewardName string
Level int32 Level int32
ProductImage string ProductImage string
OriginalQty int64
RemainingQty int64
Claimed bool Claimed bool
} }
@ -131,7 +123,7 @@ func (s *IchibanSlotsService) Page(ctx context.Context, activityID int64, issueI
continue continue
} }
} }
items = append(items, SlotItem{SlotIndex: i + 1, RewardID: rid, RewardName: rw.Name, Level: rw.Level, ProductImage: img, OriginalQty: rw.OriginalQty, RemainingQty: rw.Quantity, Claimed: c}) items = append(items, SlotItem{SlotIndex: i + 1, RewardID: rid, RewardName: rw.Name, Level: rw.Level, ProductImage: img, Claimed: c})
} }
return total, items, nil return total, items, nil
} }
@ -163,5 +155,5 @@ func (s *IchibanSlotsService) SlotDetail(ctx context.Context, activityID int64,
} }
var claimed int64 var claimed int64
_ = s.repo.GetDbR().Raw("SELECT COUNT(1) FROM issue_position_claims WHERE issue_id=? AND slot_index=?", issueID, idx0).Scan(&claimed) _ = s.repo.GetDbR().Raw("SELECT COUNT(1) FROM issue_position_claims WHERE issue_id=? AND slot_index=?", issueID, idx0).Scan(&claimed)
return SlotItem{SlotIndex: slotIndex, RewardID: rid, RewardName: rw.Name, Level: rw.Level, ProductImage: img, OriginalQty: rw.OriginalQty, RemainingQty: rw.Quantity, Claimed: claimed > 0}, nil return SlotItem{SlotIndex: slotIndex, RewardID: rid, RewardName: rw.Name, Level: rw.Level, ProductImage: img, Claimed: claimed > 0}, nil
} }

View File

@ -1,265 +0,0 @@
package activity
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"time"
)
// RewardEffectsService 奖励效果服务
// 统一处理奖励发放和道具卡效果应用
type RewardEffectsService interface {
// GrantRewardWithEffects 发放奖励并应用道具卡效果
GrantRewardWithEffects(ctx context.Context, req GrantRewardRequest) (*GrantRewardResult, error)
}
// GrantRewardRequest 奖励发放请求
type GrantRewardRequest struct {
UserID int64 // 用户ID
OrderID int64 // 订单ID
ActivityID int64 // 活动ID
IssueID int64 // 期ID
Reward *model.ActivityRewardSettings // 要发放的奖励
AllRewards []*model.ActivityRewardSettings // 所有可用奖励(用于概率提升升级)
}
// GrantRewardResult 奖励发放结果
type GrantRewardResult struct {
RewardID int64 // 发放的奖励ID
RewardName string // 奖励名称
ItemCardApplied bool // 是否应用了道具卡效果
UpgradedReward *model.ActivityRewardSettings // 如果概率提升成功,升级后的奖励
DrawLogID int64 // 创建的抽奖日志ID
}
type rewardEffectsService struct {
logger logger.CustomLogger
readDB *dao.Query
writeDB *dao.Query
repo mysql.Repo
user usersvc.Service
}
// NewRewardEffectsService 创建奖励效果服务
func NewRewardEffectsService(l logger.CustomLogger, db mysql.Repo) RewardEffectsService {
return &rewardEffectsService{
logger: l,
readDB: dao.Use(db.GetDbR()),
writeDB: dao.Use(db.GetDbW()),
repo: db,
user: usersvc.New(l, db),
}
}
// GrantRewardWithEffects 发放奖励并应用道具卡效果
func (s *rewardEffectsService) GrantRewardWithEffects(ctx context.Context, req GrantRewardRequest) (*GrantRewardResult, error) {
if req.Reward == nil {
return nil, fmt.Errorf("reward is nil")
}
result := &GrantRewardResult{
RewardID: req.Reward.ID,
RewardName: req.Reward.Name,
}
// 1. 扣减库存
res, err := s.writeDB.ActivityRewardSettings.WithContext(ctx).Where(
s.writeDB.ActivityRewardSettings.ID.Eq(req.Reward.ID),
s.writeDB.ActivityRewardSettings.Quantity.Gt(0),
).UpdateSimple(s.writeDB.ActivityRewardSettings.Quantity.Add(-1))
if err != nil {
return nil, err
}
if res.RowsAffected == 0 {
return nil, fmt.Errorf("reward out of stock")
}
// 2. 发放奖励到订单
rid := req.Reward.ID
_, err = s.user.GrantRewardToOrder(ctx, req.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: req.OrderID,
ProductID: req.Reward.ProductID,
Quantity: 1,
ActivityID: &req.ActivityID,
RewardID: &rid,
Remark: req.Reward.Name,
})
if err != nil {
return nil, err
}
// 3. 创建抽奖日志
drawLog := &model.ActivityDrawLogs{
UserID: req.UserID,
IssueID: req.IssueID,
OrderID: req.OrderID,
RewardID: req.Reward.ID,
IsWinner: 1,
Level: req.Reward.Level,
CurrentLevel: 1,
CreatedAt: time.Now(),
}
if err := s.writeDB.ActivityDrawLogs.WithContext(ctx).Create(drawLog); err != nil {
return nil, err
}
result.DrawLogID = drawLog.ID
// 4. 从订单备注解析道具卡ID并应用效果
ord, _ := s.readDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(req.OrderID)).First()
if ord != nil {
icID := parseItemCardIDFromRemark(ord.Remark)
if icID > 0 {
applied, upgradedReward := s.applyItemCardEffects(ctx, req, icID, drawLog.ID)
result.ItemCardApplied = applied
result.UpgradedReward = upgradedReward
}
}
return result, nil
}
// applyItemCardEffects 应用道具卡效果
func (s *rewardEffectsService) applyItemCardEffects(ctx context.Context, req GrantRewardRequest, icID int64, drawLogID int64) (bool, *model.ActivityRewardSettings) {
fmt.Printf("[道具卡-RewardEffects] 从订单备注解析道具卡ID icID=%d\n", icID)
uic, _ := s.readDB.UserItemCards.WithContext(ctx).Where(
s.readDB.UserItemCards.ID.Eq(icID),
s.readDB.UserItemCards.UserID.Eq(req.UserID),
s.readDB.UserItemCards.Status.Eq(1),
).First()
if uic == nil {
fmt.Printf("[道具卡-RewardEffects] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", req.UserID, icID)
return false, nil
}
ic, _ := s.readDB.SystemItemCards.WithContext(ctx).Where(
s.readDB.SystemItemCards.ID.Eq(uic.CardID),
s.readDB.SystemItemCards.Status.Eq(1),
).First()
if ic == nil {
fmt.Printf("[道具卡-RewardEffects] ❌ 未找到系统道具卡 CardID=%d\n", uic.CardID)
return false, nil
}
now := time.Now()
if uic.ValidStart.After(now) || uic.ValidEnd.Before(now) {
fmt.Printf("[道具卡-RewardEffects] ❌ 道具卡不在有效期\n")
return false, nil
}
// 范围检查
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == req.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == req.IssueID)
if !scopeOK {
fmt.Printf("[道具卡-RewardEffects] ❌ 范围检查失败 ScopeType=%d\n", ic.ScopeType)
return false, nil
}
var upgradedReward *model.ActivityRewardSettings
// 应用效果
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// 双倍奖励
fmt.Printf("[道具卡-RewardEffects] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s\n", ic.RewardMultiplierX1000, req.Reward.ID, req.Reward.Name)
rid := req.Reward.ID
_, _ = s.user.GrantRewardToOrder(ctx, req.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: req.OrderID,
ProductID: req.Reward.ProductID,
Quantity: 1,
ActivityID: &req.ActivityID,
RewardID: &rid,
Remark: req.Reward.Name + "(倍数)",
})
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// 概率提升 - 尝试升级到更好的奖励
fmt.Printf("[道具卡-RewardEffects] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
var better *model.ActivityRewardSettings
for _, r := range req.AllRewards {
if r.MinScore > req.Reward.MinScore && r.Quantity > 0 {
if better == nil || r.MinScore < better.MinScore {
better = r
}
}
}
if better != nil {
randBytes := make([]byte, 4)
rand.Read(randBytes)
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
if randVal < ic.BoostRateX1000 {
fmt.Printf("[道具卡-RewardEffects] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name)
rid := better.ID
_, _ = s.user.GrantRewardToOrder(ctx, req.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: req.OrderID,
ProductID: better.ProductID,
Quantity: 1,
ActivityID: &req.ActivityID,
RewardID: &rid,
Remark: better.Name + "(升级)",
})
upgradedReward = better
}
}
}
// 核销道具卡
fmt.Printf("[道具卡-RewardEffects] 核销道具卡 用户道具卡ID=%d\n", icID)
_, _ = s.writeDB.UserItemCards.WithContext(ctx).Where(
s.writeDB.UserItemCards.ID.Eq(icID),
s.writeDB.UserItemCards.UserID.Eq(req.UserID),
s.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{
s.writeDB.UserItemCards.Status.ColumnName().String(): 2,
s.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): drawLogID,
s.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): req.ActivityID,
s.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): req.IssueID,
s.writeDB.UserItemCards.UsedAt.ColumnName().String(): now,
})
return true, upgradedReward
}
// parseItemCardIDFromRemark 从订单备注解析道具卡ID
func parseItemCardIDFromRemark(remark string) int64 {
if remark == "" {
return 0
}
// 查找 |itemcard:xxx 模式
prefix := "|itemcard:"
idx := -1
for i := 0; i <= len(remark)-len(prefix); i++ {
if remark[i:i+len(prefix)] == prefix {
idx = i + len(prefix)
break
}
}
if idx < 0 {
// 也检查开头没有 | 的情况
prefix = "itemcard:"
for i := 0; i <= len(remark)-len(prefix); i++ {
if remark[i:i+len(prefix)] == prefix {
idx = i + len(prefix)
break
}
}
}
if idx < 0 {
return 0
}
var n int64
for idx < len(remark) {
c := remark[idx]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
idx++
}
return n
}

View File

@ -35,6 +35,24 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
r := dao.Use(repo.GetDbR()) r := dao.Use(repo.GetDbR())
w := dao.Use(repo.GetDbW()) w := dao.Use(repo.GetDbW())
us := usersvc.New(l, repo) us := usersvc.New(l, repo)
// Ensure lottery_refund_logs table exists
_ = repo.GetDbW().Exec(`CREATE TABLE IF NOT EXISTS lottery_refund_logs (
id bigint unsigned AUTO_INCREMENT PRIMARY KEY,
issue_id bigint NOT NULL DEFAULT 0,
order_id bigint NOT NULL DEFAULT 0,
user_id bigint NOT NULL DEFAULT 0,
amount bigint NOT NULL DEFAULT 0,
coupon_type varchar(64) DEFAULT '',
coupon_amount bigint DEFAULT 0,
reason varchar(255) DEFAULT '',
status varchar(32) DEFAULT '',
created_at datetime DEFAULT CURRENT_TIMESTAMP,
updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_issue (issue_id),
INDEX idx_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`).Error
go func() { go func() {
t := time.NewTicker(30 * time.Second) t := time.NewTicker(30 * time.Second)
defer t.Stop() defer t.Stop()
@ -108,45 +126,77 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
r.Orders.CreatedAt.Gte(last), r.Orders.CreatedAt.Gte(last),
).Find() ).Find()
count := int64(len(orders)) count := int64(len(orders))
fmt.Printf("[定时开奖] 活动ID=%d 查询到订单数=%d 最低参与人数=%d 是否满足=%t\n", aid, count, a.MinParticipants, count >= a.MinParticipants) fmt.Printf("[定时开奖] 活动ID=%d 查询到订单数=%d 最低参与人数=%d\n", aid, count, a.MinParticipants)
if count < a.MinParticipants { // Initialize Wechat Client if needed
fmt.Printf("[定时开奖] 活动ID=%d ❌ 人数不足,进行退款处理\n", aid) wc, _ := paypkg.NewWechatPayClient(ctx)
wc, err := paypkg.NewWechatPayClient(ctx)
if err == nil { refundedIssues := make(map[int64]bool)
// 【优化】一番赏定时退款:检查是否售罄
if a.PlayType == "ichiban" {
issueIDs := make(map[int64]struct{})
for _, o := range orders { for _, o := range orders {
// 先处理积分退款(如有)
if o.PointsAmount > 0 {
refundPts := o.PointsAmount / 100
_, _ = us.RefundPoints(ctx, o.UserID, refundPts, o.OrderNo, "scheduled_not_enough")
}
// 微信支付部分退款(如有)
if o.ActualAmount > 0 {
refundNo := fmt.Sprintf("R%s-%d", o.OrderNo, time.Now().Unix())
refundID, status, err := wc.RefundOrder(ctx, o.OrderNo, refundNo, o.ActualAmount, o.ActualAmount, "scheduled_not_enough")
if err == nil {
_ = w.PaymentRefunds.WithContext(ctx).Create(&model.PaymentRefunds{OrderID: o.ID, OrderNo: o.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: o.ActualAmount, Reason: "scheduled_not_enough"})
_ = w.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{UserID: o.UserID, Action: "refund_amount", Points: o.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID})
}
}
// 标记订单退款
_, _ = w.Orders.WithContext(ctx).Where(w.Orders.ID.Eq(o.ID)).Updates(map[string]any{w.Orders.Status.ColumnName().String(): 4})
iss := extractIssueID(o.Remark) iss := extractIssueID(o.Remark)
_ = repo.GetDbW().Exec("INSERT INTO lottery_refund_logs(issue_id, order_id, user_id, amount, coupon_type, coupon_amount, reason, status) VALUES(?,?,?,?,?,?,?,?)", iss, o.ID, o.UserID, o.ActualAmount, "", 0, "scheduled_not_enough", "done").Error if iss > 0 {
if a.RefundCouponID > 0 { issueIDs[iss] = struct{}{}
_ = us.AddCoupon(ctx, o.UserID, a.RefundCouponID)
} }
} }
for iss := range issueIDs {
// Check Sales
// 一番赏:每种奖品 = 1个格位
totalSlots, _ := r.ActivityRewardSettings.WithContext(ctx).Where(r.ActivityRewardSettings.IssueID.Eq(iss)).Count()
soldSlots, _ := r.IssuePositionClaims.WithContext(ctx).Where(r.IssuePositionClaims.IssueID.Eq(iss)).Count()
fmt.Printf("[定时开奖-一番赏] 检查售罄 IssueID=%d Sold=%d Total=%d\n", iss, soldSlots, totalSlots)
if soldSlots < totalSlots {
fmt.Printf("[定时开奖-一番赏] ❌ IssueID=%d 未售罄,执行全额退款\n", iss)
refundedIssues[iss] = true
// Find ALL valid orders for this issue
issueOrders, _ := r.Orders.WithContext(ctx).Where(
r.Orders.Status.Eq(2),
r.Orders.SourceType.Eq(2),
r.Orders.Remark.Like(fmt.Sprintf("%%issue:%d|%%", iss)),
).Find()
for _, o := range issueOrders {
refundOrder(ctx, o, "ichiban_not_sold_out", wc, r, w, us, a.RefundCouponID)
}
}
}
}
shouldRefund := false
if a.PlayType != "ichiban" {
if count < a.MinParticipants {
shouldRefund = true
}
}
if shouldRefund {
fmt.Printf("[定时开奖] 活动ID=%d ❌ 人数不足,进行退款处理\n", aid)
for _, o := range orders {
refundOrder(ctx, o, "scheduled_not_enough", wc, r, w, us, a.RefundCouponID)
} }
} else { } else {
fmt.Printf("[定时开奖] 活动ID=%d ✅ 人数满足,开始开奖处理\n", aid) fmt.Printf("[定时开奖] 活动ID=%d ✅ 人数满足(或一番赏模式),开始开奖处理\n", aid)
if a.PlayType == "ichiban" { if a.PlayType == "ichiban" {
fmt.Printf("[定时开奖] 活动ID=%d 一番赏模式开奖,订单数=%d\n", aid, len(orders)) fmt.Printf("[定时开奖] 活动ID=%d 一番赏模式开奖,订单数=%d\n", aid, len(orders))
// 一番赏定时开奖逻辑 // 一番赏定时开奖逻辑
ichibanSel := strat.NewIchiban(r, w) ichibanSel := strat.NewIchiban(r, w)
for _, o := range orders { for _, o := range orders {
uid := o.UserID
iss := extractIssueID(o.Remark) iss := extractIssueID(o.Remark)
if refundedIssues[iss] {
fmt.Printf("[定时开奖-一番赏] OrderID=%d IssueID=%d 已退款,跳过开奖\n", o.ID, iss)
continue
}
uid := o.UserID
fmt.Printf("[定时开奖-一番赏] 处理订单 OrderID=%d UserID=%d IssueID=%d\n", o.ID, uid, iss) fmt.Printf("[定时开奖-一番赏] 处理订单 OrderID=%d UserID=%d IssueID=%d\n", o.ID, uid, iss)
// 检查是否已经处理过 // 检查是否已经处理过
@ -177,6 +227,7 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
} }
// 使用 claim 中的 slot_index 直接获取奖品 // 使用 claim 中的 slot_index 直接获取奖品
// Use Commitment (via SelectItemBySlot internal logic)
rid, proof, err := ichibanSel.SelectItemBySlot(ctx, aid, iss, claim.SlotIndex) rid, proof, err := ichibanSel.SelectItemBySlot(ctx, aid, iss, claim.SlotIndex)
if err != nil || rid <= 0 { if err != nil || rid <= 0 {
fmt.Printf("[定时开奖-一番赏] ❌ SelectItemBySlot失败 err=%v rid=%d\n", err, rid) fmt.Printf("[定时开奖-一番赏] ❌ SelectItemBySlot失败 err=%v rid=%d\n", err, rid)
@ -234,11 +285,12 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
return act.Name return act.Name
} }
return "活动" return "活动"
}()) }(), "ichiban")
} }
} else { } else {
// 默认玩法逻辑 // 默认玩法逻辑
sel := strat.NewDefault(r, w) sel := strat.NewDefault(r, w)
// Daily Seed removed
for _, o := range orders { for _, o := range orders {
uid := o.UserID uid := o.UserID
iss := extractIssueID(o.Remark) iss := extractIssueID(o.Remark)
@ -272,14 +324,14 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
fmt.Printf("[定时开奖-默认] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss) fmt.Printf("[定时开奖-默认] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss)
} }
} }
// 【开奖后虚拟发货】定时开奖后上传虚拟发货 // 【开奖后虚拟发货】定时开奖后上传虚拟发货(非一番赏不发通知)
uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string { uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string {
act, _ := r.Activities.WithContext(ctx).Where(r.Activities.ID.Eq(aid)).First() act, _ := r.Activities.WithContext(ctx).Where(r.Activities.ID.Eq(aid)).First()
if act != nil { if act != nil {
return act.Name return act.Name
} }
return "活动" return "活动"
}()) }(), "default")
} }
} }
} }
@ -299,7 +351,9 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
} }
// 即时开奖:处理所有已支付且未记录抽奖日志的订单 // 即时开奖:处理所有已支付且未记录抽奖日志的订单
var instantActs []struct{ ID int64 } var instantActs []struct {
ID int64
}
_ = repo.GetDbR().WithContext(ctx).Raw("SELECT id FROM activities WHERE draw_mode='instant'").Scan(&instantActs) _ = repo.GetDbR().WithContext(ctx).Raw("SELECT id FROM activities WHERE draw_mode='instant'").Scan(&instantActs)
if len(instantActs) > 0 { if len(instantActs) > 0 {
sel2 := strat.NewDefault(r, w) sel2 := strat.NewDefault(r, w)
@ -309,6 +363,7 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
r.Orders.SourceType.Eq(2), r.Orders.SourceType.Eq(2),
r.Orders.Remark.Like(fmt.Sprintf("lottery:activity:%d|%%", ia.ID)), r.Orders.Remark.Like(fmt.Sprintf("lottery:activity:%d|%%", ia.ID)),
).Find() ).Find()
// Daily Seed removed
for _, o2 := range orders2 { for _, o2 := range orders2 {
uid := o2.UserID uid := o2.UserID
iss := extractIssueID(o2.Remark) iss := extractIssueID(o2.Remark)
@ -401,7 +456,8 @@ func extractCount(remark string) int64 {
// uploadVirtualShippingForScheduledDraw 定时开奖后上传虚拟发货 // uploadVirtualShippingForScheduledDraw 定时开奖后上传虚拟发货
// 收集中奖产品名称并调用微信虚拟发货API // 收集中奖产品名称并调用微信虚拟发货API
func uploadVirtualShippingForScheduledDraw(ctx context.Context, r *dao.Query, orderID int64, orderNo string, userID int64, actName string) { // playType: 活动玩法类型,只有 ichiban 时才发送开奖结果通知
func uploadVirtualShippingForScheduledDraw(ctx context.Context, r *dao.Query, orderID int64, orderNo string, userID int64, actName string, playType string) {
// 获取开奖记录 // 获取开奖记录
drawLogs, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(orderID)).Find() drawLogs, _ := r.ActivityDrawLogs.WithContext(ctx).Where(r.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
if len(drawLogs) == 0 { if len(drawLogs) == 0 {
@ -438,11 +494,83 @@ func uploadVirtualShippingForScheduledDraw(ctx context.Context, r *dao.Query, or
if err := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil { if err := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil {
fmt.Printf("[定时开奖-虚拟发货] 上传失败: %v\n", err) fmt.Printf("[定时开奖-虚拟发货] 上传失败: %v\n", err)
} }
// 【定时开奖后推送通知】 // 【定时开奖后推送通知】只有一番赏才发送
if playType == "ichiban" {
notifyCfg := &notify.WechatNotifyConfig{ notifyCfg := &notify.WechatNotifyConfig{
AppID: c.Wechat.AppID, AppID: c.Wechat.AppID,
AppSecret: c.Wechat.AppSecret, AppSecret: c.Wechat.AppSecret,
LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID, LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID,
} }
_ = notify.SendLotteryResultNotification(ctx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now()) _ = notify.SendLotteryResultNotification(ctx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now())
}
}
func refundOrder(ctx context.Context, o *model.Orders, reason string, wc *paypkg.WechatPayClient, r *dao.Query, w *dao.Query, us usersvc.Service, refundCouponID int64) {
// 1. Refund Points
if o.PointsAmount > 0 {
refundPts := o.PointsAmount / 100
_, _ = us.RefundPoints(ctx, o.UserID, refundPts, o.OrderNo, reason)
}
// 2. Refund WeChat
if o.ActualAmount > 0 && wc != nil {
refundNo := fmt.Sprintf("R%s-%d", o.OrderNo, time.Now().Unix())
refundID, status, err := wc.RefundOrder(ctx, o.OrderNo, refundNo, o.ActualAmount, o.ActualAmount, reason)
if err == nil {
_ = w.PaymentRefunds.WithContext(ctx).Create(&model.PaymentRefunds{
OrderID: o.ID,
OrderNo: o.OrderNo,
RefundNo: refundNo,
Channel: "wechat_jsapi",
Status: status,
AmountRefund: o.ActualAmount,
Reason: reason,
SuccessTime: time.Now(),
})
_ = w.UserPointsLedger.WithContext(ctx).Create(&model.UserPointsLedger{UserID: o.UserID, Action: "refund_amount", Points: o.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID})
} else {
fmt.Printf("[Refund] WeChat refund failed for order %s: %v\n", o.OrderNo, err)
}
}
// 3. Refund Used Coupons
ocs, _ := r.OrderCoupons.WithContext(ctx).Where(r.OrderCoupons.OrderID.Eq(o.ID)).Find()
for _, oc := range ocs {
// Restore user coupon status to 1 (Unused) and clear usage info
_, err := w.UserCoupons.WithContext(ctx).Where(w.UserCoupons.ID.Eq(oc.UserCouponID)).Updates(map[string]interface{}{
"status": 1,
"used_order_id": 0,
"used_at": nil,
})
if err != nil {
fmt.Printf("[Refund] Failed to restore coupon %d for order %s: %v\n", oc.UserCouponID, o.OrderNo, err)
} else {
fmt.Printf("[Refund] Restored coupon %d for order %s\n", oc.UserCouponID, o.OrderNo)
}
}
// 3.5. 一番赏退款:删除 issue_position_claims 记录,恢复格位
iss := extractIssueID(o.Remark)
if iss > 0 {
result, err := w.IssuePositionClaims.WithContext(ctx).Where(
w.IssuePositionClaims.OrderID.Eq(o.ID),
).Delete()
if err != nil {
fmt.Printf("[Refund] Failed to delete position claims for order %d: %v\n", o.ID, err)
} else if result.RowsAffected > 0 {
fmt.Printf("[Refund] ✅ Restored %d slot position(s) for order %d issue %d\n", result.RowsAffected, o.ID, iss)
}
}
// 4. Update Order Status
_, _ = w.Orders.WithContext(ctx).Where(w.Orders.ID.Eq(o.ID)).Updates(map[string]any{w.Orders.Status.ColumnName().String(): 4})
// 5. Log Refund
iss = extractIssueID(o.Remark)
_ = w.Orders.WithContext(ctx).UnderlyingDB().Exec("INSERT INTO lottery_refund_logs(issue_id, order_id, user_id, amount, coupon_type, coupon_amount, reason, status) VALUES(?,?,?,?,?,?,?,?)", iss, o.ID, o.UserID, o.ActualAmount, "", 0, reason, "done").Error
// 6. Compensation
if refundCouponID > 0 {
_ = us.AddCoupon(ctx, o.UserID, refundCouponID)
}
} }

View File

@ -43,15 +43,30 @@ func (s *defaultStrategy) SelectItem(ctx context.Context, activityID int64, issu
return 0, nil, errors.New("no weight") return 0, nil, errors.New("no weight")
} }
// 使用 crypto/rand 生成加密安全的随机种子 // Determine seed key: use Activity Commitment
seed := make([]byte, 32) var seedKey []byte
if _, err := rand.Read(seed); err != nil { // Fallback to Activity Commitment if possible
// We need to fetch activity first
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") return 0, nil, errors.New("crypto rand failed")
} }
}
// 使用 HMAC-SHA256 生成加密安全的随机数 // To ensure uniqueness per draw when using a fixed CommitmentSeedMaster, mix in a random salt
mac := hmac.New(sha256.New, seed) salt := make([]byte, 16)
mac.Write([]byte(fmt.Sprintf("draw:issue:%d|user:%d", issueID, userID))) if _, err := rand.Read(salt); err != nil {
return 0, nil, errors.New("crypto rand salt failed")
}
// Use HMAC-SHA256 to generate random number derived from seed + context + salt
mac := hmac.New(sha256.New, seedKey)
mac.Write([]byte(fmt.Sprintf("draw:issue:%d|user:%d|salt:%x", issueID, userID, salt)))
sum := mac.Sum(nil) sum := mac.Sum(nil)
rnd := int64(binary.BigEndian.Uint64(sum[:8]) % uint64(total)) rnd := int64(binary.BigEndian.Uint64(sum[:8]) % uint64(total))
@ -70,7 +85,14 @@ func (s *defaultStrategy) SelectItem(ctx context.Context, activityID int64, issu
if picked == 0 { if picked == 0 {
return 0, nil, errors.New("pick failed") return 0, nil, errors.New("pick failed")
} }
proof := map[string]any{"weights_total": total, "rand": rnd, "seed_hash": fmt.Sprintf("%x", sha256.Sum256(seed))}
proof := map[string]any{
"weights_total": total,
"rand": rnd,
"seed_hash": fmt.Sprintf("%x", sha256.Sum256(seedKey)),
"salt": fmt.Sprintf("%x", salt),
"seed_type": "commitment",
}
return picked, proof, nil return picked, proof, nil
} }

View File

@ -32,27 +32,23 @@ func (s *ichibanStrategy) SelectItemBySlot(ctx context.Context, activityID int64
if err != nil || len(rewards) == 0 { if err != nil || len(rewards) == 0 {
return 0, nil, errors.New("no rewards") return 0, nil, errors.New("no rewards")
} }
var totalSlots int64 // 一番赏:每种奖品 = 1个格位无数量概念
for _, r := range rewards { totalSlots := int64(len(rewards))
if r.OriginalQty > 0 {
totalSlots += r.OriginalQty
}
}
if totalSlots <= 0 { if totalSlots <= 0 {
return 0, nil, errors.New("no slots") return 0, nil, errors.New("no slots")
} }
if slotIndex < 0 || slotIndex >= totalSlots { if slotIndex < 0 || slotIndex >= totalSlots {
return 0, nil, errors.New("slot out of range") return 0, nil, errors.New("slot out of range")
} }
// build list // build list: 每个reward直接对应一个slot
slots := make([]int64, 0, totalSlots) slots := make([]int64, totalSlots)
for _, r := range rewards { for i, r := range rewards {
for i := int64(0); i < r.OriginalQty; i++ { slots[i] = r.ID
slots = append(slots, r.ID)
} }
} // deterministic shuffle by CommitmentSeedMaster
// deterministic shuffle by server seed seedKey := act.CommitmentSeedMaster
mac := hmac.New(sha256.New, act.CommitmentSeedMaster)
mac := hmac.New(sha256.New, seedKey)
for i := int(totalSlots - 1); i > 0; i-- { for i := int(totalSlots - 1); i > 0; i-- {
mac.Reset() mac.Reset()
mac.Write([]byte(fmt.Sprintf("shuffle:%d|issue:%d", i, issueID))) mac.Write([]byte(fmt.Sprintf("shuffle:%d|issue:%d", i, issueID)))
@ -63,27 +59,21 @@ func (s *ichibanStrategy) SelectItemBySlot(ctx context.Context, activityID int64
picked := slots[slotIndex] picked := slots[slotIndex]
// Calculate seed hash for proof // Calculate seed hash for proof
sha := sha256.Sum256(act.CommitmentSeedMaster) sha := sha256.Sum256(seedKey)
seedHash := fmt.Sprintf("%x", sha) seedHash := fmt.Sprintf("%x", sha)
proof := map[string]any{ proof := map[string]any{
"total_slots": totalSlots, "total_slots": totalSlots,
"slot_index": slotIndex, "slot_index": slotIndex,
"seed_hash": seedHash, "seed_hash": seedHash,
"seed_type": "commitment",
} }
return picked, proof, nil return picked, proof, nil
} }
func (s *ichibanStrategy) GrantReward(ctx context.Context, userID int64, rewardID int64) error { func (s *ichibanStrategy) GrantReward(ctx context.Context, userID int64, rewardID int64) error {
result, err := s.write.ActivityRewardSettings.WithContext(ctx).Where( // 一番赏模式下不再需要扣减数量,因为每个奖品对应唯一格位
s.write.ActivityRewardSettings.ID.Eq(rewardID), // 格位占用通过 issue_position_claims 表来追踪,而非 quantity 字段
s.write.ActivityRewardSettings.Quantity.Gt(0), // 这里保留接口兼容性,实际的占用检查在调用方完成
).UpdateSimple(s.write.ActivityRewardSettings.Quantity.Add(-1))
if err != nil {
return err
}
if result.RowsAffected == 0 {
return errors.New("sold out or reward not found")
}
return nil return nil
} }

View File

@ -2,6 +2,8 @@ package strategy
import ( import (
"context" "context"
"crypto/sha256"
"fmt"
"testing" "testing"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
@ -85,3 +87,56 @@ func TestIchibanGrantReward_Decrement(t *testing.T) {
got, _ := q.ActivityRewardSettings.Where(q.ActivityRewardSettings.ID.Eq(r.ID)).First() got, _ := q.ActivityRewardSettings.Where(q.ActivityRewardSettings.ID.Eq(r.ID)).First()
if got.Quantity != 1 { t.Fatalf("quantity not decremented: %d", got.Quantity) } if got.Quantity != 1 { t.Fatalf("quantity not decremented: %d", got.Quantity) }
} }
func TestIchibanProofHasSeedHash(t *testing.T) {
// 1. Setup In-Memory DB
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("db open err: %v", err)
}
// 2. Migrate Tables
db.Exec(`CREATE TABLE activities (id INTEGER PRIMARY KEY AUTOINCREMENT, commitment_seed_master BLOB, commitment_state_version INTEGER, deleted_at DATETIME);`)
db.Exec(`CREATE TABLE activity_reward_settings (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, issue_id INTEGER NOT NULL, product_id INTEGER, name TEXT NOT NULL, weight INTEGER NOT NULL, quantity INTEGER NOT NULL, original_qty INTEGER NOT NULL, level INTEGER NOT NULL, sort INTEGER NOT NULL, is_boss INTEGER NOT NULL, deleted_at DATETIME);`)
q := dao.Use(db)
// 3. Insert Test Data
seedBytes := []byte("testseedvalue")
expectedHash := fmt.Sprintf("%x", sha256.Sum256(seedBytes))
db.Exec("INSERT INTO activities (id, commitment_seed_master, commitment_state_version) VALUES (?, ?, ?)", 100, seedBytes, 1)
r1 := &model.ActivityRewardSettings{IssueID: 10, Name: "A", Weight: 10, Quantity: 10, OriginalQty: 5, Level: 1, Sort: 1}
q.ActivityRewardSettings.Create(r1)
// 4. Test SelectItemBySlot
s := NewIchiban(q, q)
ctx := context.Background()
_, proof, err := s.SelectItemBySlot(ctx, 100, 10, 0)
if err != nil {
t.Fatalf("SelectItemBySlot failed: %v", err)
}
// 5. Verify seed_hash is in proof
if proof == nil {
t.Fatal("proof is nil")
}
val, ok := proof["seed_hash"]
if !ok {
t.Fatal("seed_hash missing from proof")
}
seedHash, ok := val.(string)
if !ok {
t.Fatalf("seed_hash is not a string, got %T", val)
}
if seedHash != expectedHash {
t.Fatalf("seed_hash mismatch. got %s, want %s", seedHash, expectedHash)
}
t.Logf("Success: seed_hash found in proof: %s", seedHash)
}

View File

@ -1,9 +0,0 @@
package strategy
// Mock objects for testing (simplified)
// Since we can't easily mock the full DAO chain without a comprehensive mock library or interface,
// we will rely on checking the logic I just added: manual inspection of the code change was logically sound.
// However, to be thorough, I'll write a test that mocks the DB interactions if possible, or use the existing test file `ichiban_test.go` as a base.
// Let's assume `ichiban_test.go` exists (I saw it in the file list earlier).
// I will read it first to see if I can add a case there.

View File

@ -1,66 +0,0 @@
package strategy
import (
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"context"
"crypto/sha256"
"fmt"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestIchibanProofHasSeedHash(t *testing.T) {
// 1. Setup In-Memory DB
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("db open err: %v", err)
}
// 2. Migrate Tables
db.Exec(`CREATE TABLE activities (id INTEGER PRIMARY KEY AUTOINCREMENT, commitment_seed_master BLOB, commitment_state_version INTEGER, deleted_at DATETIME);`)
db.Exec(`CREATE TABLE activity_reward_settings (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, issue_id INTEGER NOT NULL, product_id INTEGER, name TEXT NOT NULL, weight INTEGER NOT NULL, quantity INTEGER NOT NULL, original_qty INTEGER NOT NULL, level INTEGER NOT NULL, sort INTEGER NOT NULL, is_boss INTEGER NOT NULL, deleted_at DATETIME);`)
q := dao.Use(db)
// 3. Insert Test Data
seedBytes := []byte("testseedvalue")
expectedHash := fmt.Sprintf("%x", sha256.Sum256(seedBytes))
db.Exec("INSERT INTO activities (id, commitment_seed_master, commitment_state_version) VALUES (?, ?, ?)", 100, seedBytes, 1)
r1 := &model.ActivityRewardSettings{IssueID: 10, Name: "A", Weight: 10, Quantity: 10, OriginalQty: 5, Level: 1, Sort: 1}
q.ActivityRewardSettings.Create(r1)
// 4. Test SelectItemBySlot
s := NewIchiban(q, q)
ctx := context.Background()
_, proof, err := s.SelectItemBySlot(ctx, 100, 10, 0)
if err != nil {
t.Fatalf("SelectItemBySlot failed: %v", err)
}
// 5. Verify seed_hash is in proof
if proof == nil {
t.Fatal("proof is nil")
}
val, ok := proof["seed_hash"]
if !ok {
t.Fatal("seed_hash missing from proof")
}
seedHash, ok := val.(string)
if !ok {
t.Fatalf("seed_hash is not a string, got %T", val)
}
if seedHash != expectedHash {
t.Fatalf("seed_hash mismatch. got %s, want %s", seedHash, expectedHash)
}
t.Logf("Success: seed_hash found in proof: %s", seedHash)
}

View File

@ -0,0 +1,48 @@
package taskcenter
import (
tcmodel "bindbox-game/internal/repository/mysql/task_center"
"context"
"encoding/json"
"time"
"gorm.io/gorm"
)
const activeTasksCacheKey = "task_center:active_tasks"
func (s *service) invalidateCache(ctx context.Context) error {
if s.redis == nil {
return nil
}
return s.redis.Del(ctx, activeTasksCacheKey).Err()
}
func (s *service) getActiveTasks(ctx context.Context) ([]tcmodel.Task, error) {
// 1. Try Redis
if s.redis != nil {
val, err := s.redis.Get(ctx, activeTasksCacheKey).Result()
if err == nil {
var tasks []tcmodel.Task
if err := json.Unmarshal([]byte(val), &tasks); err == nil {
return tasks, nil
}
}
}
// 2. Fallback to DB
var tasks []tcmodel.Task
if err := s.repo.GetDbR().Preload("Tiers", func(db *gorm.DB) *gorm.DB {
return db.Order("priority asc, id asc")
}).Preload("Rewards").Where("status=1").Find(&tasks).Error; err != nil {
return nil, err
}
// 3. Write back to Redis
if s.redis != nil && len(tasks) > 0 {
b, _ := json.Marshal(tasks)
_ = s.redis.Set(ctx, activeTasksCacheKey, string(b), 1*time.Hour).Err()
}
return tasks, nil
}

View File

@ -0,0 +1,33 @@
package taskcenter
const (
// Task Windows
WindowDaily = "daily"
WindowWeekly = "weekly"
WindowInfinite = "infinite"
// Task Metrics
MetricFirstOrder = "first_order"
MetricOrderCount = "order_count"
MetricInviteCount = "invite_count"
MetricEffectiveInviteCount = "effective_invite_count"
// Operators
OperatorGTE = ">="
OperatorEQ = "="
// Reward Types
RewardTypePoints = "points"
RewardTypeCoupon = "coupon"
RewardTypeItemCard = "item_card"
RewardTypeTitle = "title"
// Event Sources
SourceTypeOrder = "order"
SourceTypeInvite = "invite"
SourceTypeEffectiveInvite = "effective_invite"
SourceTypeTaskCenter = "task_center"
// Event Log Status
EventStatusGranted = "granted"
)

View File

@ -1,6 +1,7 @@
package taskcenter package taskcenter
import ( import (
"bindbox-game/internal/pkg/async"
"bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao" "bindbox-game/internal/repository/mysql/dao"
@ -14,6 +15,8 @@ import (
titlesvc "bindbox-game/internal/service/title" titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user" usersvc "bindbox-game/internal/service/user"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@ -32,6 +35,7 @@ type Service interface {
ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error
OnOrderPaid(ctx context.Context, userID int64, orderID int64) error OnOrderPaid(ctx context.Context, userID int64, orderID int64) error
OnInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error OnInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error
StartWorker(ctx context.Context)
} }
type service struct { type service struct {
@ -39,18 +43,26 @@ type service struct {
readDB *dao.Query readDB *dao.Query
writeDB *dao.Query writeDB *dao.Query
repo mysql.Repo repo mysql.Repo
redis *redis.Client
queue async.TaskQueue
userSvc usersvc.Service userSvc usersvc.Service
titleSvc titlesvc.Service titleSvc titlesvc.Service
} }
func New(l logger.CustomLogger, db mysql.Repo) Service { func New(l logger.CustomLogger, db mysql.Repo, rdb *redis.Client, userSvc usersvc.Service, titleSvc titlesvc.Service) Service {
var q async.TaskQueue
if rdb != nil {
q = async.NewRedisTaskQueue(rdb)
}
return &service{ return &service{
logger: l, logger: l,
readDB: dao.Use(db.GetDbR()), readDB: dao.Use(db.GetDbR()),
writeDB: dao.Use(db.GetDbW()), writeDB: dao.Use(db.GetDbW()),
repo: db, repo: db,
userSvc: usersvc.New(l, db), redis: rdb,
titleSvc: titlesvc.New(l, db), queue: q,
userSvc: userSvc,
titleSvc: titleSvc,
} }
} }
@ -105,6 +117,7 @@ type TaskTierInput struct {
Window string Window string
Repeatable int32 Repeatable int32
Priority int32 Priority int32
ExtraParams datatypes.JSON
} }
type TaskTierItem struct { type TaskTierItem struct {
@ -115,6 +128,7 @@ type TaskTierItem struct {
Window string `json:"window"` Window string `json:"window"`
Repeatable int32 `json:"repeatable"` Repeatable int32 `json:"repeatable"`
Priority int32 `json:"priority"` Priority int32 `json:"priority"`
ExtraParams datatypes.JSON `json:"extra_params"`
} }
type TaskRewardInput struct { type TaskRewardInput struct {
@ -145,10 +159,14 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas
if err = q.Count(&total).Error; err != nil { if err = q.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
if err = q.Offset((in.Page - 1) * in.PageSize).Limit(in.PageSize).Order("id desc").Find(&rows).Error; err != nil { if err = q.Preload("Tiers", func(db *gorm.DB) *gorm.DB {
return db.Order("priority asc, id asc")
}).Preload("Rewards", func(db *gorm.DB) *gorm.DB {
return db.Order("id asc")
}).Offset((in.Page - 1) * in.PageSize).Limit(in.PageSize).Order("id desc").Find(&rows).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
items = make([]TaskItem, len(rows)) out := make([]TaskItem, len(rows))
for i, v := range rows { for i, v := range rows {
var st, et int64 var st, et int64
if v.StartTime != nil { if v.StartTime != nil {
@ -157,17 +175,19 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas
if v.EndTime != nil { if v.EndTime != nil {
et = v.EndTime.Unix() et = v.EndTime.Unix()
} }
items[i] = TaskItem{ID: v.ID, Name: v.Name, Description: v.Description, Status: v.Status, StartTime: st, EndTime: et, Visibility: v.Visibility} out[i] = TaskItem{ID: v.ID, Name: v.Name, Description: v.Description, Status: v.Status, StartTime: st, EndTime: et, Visibility: v.Visibility}
// 填充 Tiers // 填充 Tiers
if tiers, err := s.ListTaskTiers(ctx, v.ID); err == nil { out[i].Tiers = make([]TaskTierItem, len(v.Tiers))
items[i].Tiers = tiers for j, t := range v.Tiers {
out[i].Tiers[j] = TaskTierItem{ID: t.ID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority, ExtraParams: t.ExtraParams}
} }
// 填充 Rewards // 填充 Rewards
if rewards, err := s.ListTaskRewards(ctx, v.ID); err == nil { out[i].Rewards = make([]TaskRewardItem, len(v.Rewards))
items[i].Rewards = rewards for j, r := range v.Rewards {
out[i].Rewards[j] = TaskRewardItem{ID: r.ID, TierID: r.TierID, RewardType: r.RewardType, RewardPayload: r.RewardPayload, Quantity: r.Quantity}
} }
} }
return items, total, nil return out, total, nil
} }
func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) { func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) {
@ -211,17 +231,23 @@ func (s *service) CreateTask(ctx context.Context, in CreateTaskInput) (int64, er
if err := db.Create(row).Error; err != nil { if err := db.Create(row).Error; err != nil {
return 0, err return 0, err
} }
return row.ID, nil return row.ID, s.invalidateCache(ctx)
} }
func (s *service) ModifyTask(ctx context.Context, id int64, in ModifyTaskInput) error { func (s *service) ModifyTask(ctx context.Context, id int64, in ModifyTaskInput) error {
db := s.repo.GetDbW() db := s.repo.GetDbW()
return db.Model(&tcmodel.Task{}).Where("id=?", id).Updates(map[string]any{"name": in.Name, "description": in.Description, "status": in.Status, "start_time": in.StartTime, "end_time": in.EndTime, "visibility": in.Visibility}).Error if err := db.Model(&tcmodel.Task{}).Where("id=?", id).Updates(map[string]any{"name": in.Name, "description": in.Description, "status": in.Status, "start_time": in.StartTime, "end_time": in.EndTime, "visibility": in.Visibility}).Error; err != nil {
return err
}
return s.invalidateCache(ctx)
} }
func (s *service) DeleteTask(ctx context.Context, id int64) error { func (s *service) DeleteTask(ctx context.Context, id int64) error {
db := s.repo.GetDbW() db := s.repo.GetDbW()
return db.Where("id=?", id).Delete(&tcmodel.Task{}).Error if err := db.Where("id=?", id).Delete(&tcmodel.Task{}).Error; err != nil {
return err
}
return s.invalidateCache(ctx)
} }
func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error) { func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error) {
@ -232,7 +258,7 @@ func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierIt
} }
out := make([]TaskTierItem, len(rows)) out := make([]TaskTierItem, len(rows))
for i, v := range rows { for i, v := range rows {
out[i] = TaskTierItem{ID: v.ID, Metric: v.Metric, Operator: v.Operator, Threshold: v.Threshold, Window: v.Window, Repeatable: v.Repeatable, Priority: v.Priority} out[i] = TaskTierItem{ID: v.ID, Metric: v.Metric, Operator: v.Operator, Threshold: v.Threshold, Window: v.Window, Repeatable: v.Repeatable, Priority: v.Priority, ExtraParams: v.ExtraParams}
} }
return out, nil return out, nil
} }
@ -243,12 +269,12 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
return err return err
} }
for _, t := range tiers { for _, t := range tiers {
row := &tcmodel.TaskTier{TaskID: taskID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority} row := &tcmodel.TaskTier{TaskID: taskID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority, ExtraParams: t.ExtraParams}
if err := db.Create(row).Error; err != nil { if err := db.Create(row).Error; err != nil {
return err return err
} }
} }
return nil return s.invalidateCache(ctx)
} }
func (s *service) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewardItem, error) { func (s *service) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewardItem, error) {
@ -275,15 +301,70 @@ func (s *service) UpsertTaskRewards(ctx context.Context, taskID int64, rewards [
return err return err
} }
} }
return nil return s.invalidateCache(ctx)
} }
func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64) error { func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64) error {
var tasks []tcmodel.Task if s.queue != nil {
if err := s.repo.GetDbR().Where("status=1").Find(&tasks).Error; err != nil { return s.queue.PublishOrderPaid(ctx, userID, orderID)
}
return s.processOrderPaid(ctx, userID, orderID)
}
func (s *service) processOrderPaid(ctx context.Context, userID int64, orderID int64) error {
// 1. 获取订单金额
ord, err := s.readDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(orderID)).First()
if err != nil {
return err
}
amount := ord.ActualAmount
// 2. 更新邀请人累计金额并检查是否触发有效邀请
var inviterID int64
var oldAmount int64
var newAmount int64
// 使用事务更新 UserInvites
err = s.writeDB.Transaction(func(tx *dao.Query) error {
uInv, err := tx.UserInvites.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where(tx.UserInvites.InviteeID.Eq(userID)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
inviterID = uInv.InviterID
oldAmount = uInv.AccumulatedAmount
newAmount = oldAmount + amount
updates := map[string]any{
"accumulated_amount": newAmount,
}
_, err = tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.ID.Eq(uInv.ID)).Updates(updates)
return err
})
if err != nil {
return err
}
// 3. 处理普通任务
tasks, err := s.getActiveTasks(ctx)
if err != nil {
return err return err
} }
for _, t := range tasks { for _, t := range tasks {
// Filter tasks: Only process if it has order related metrics
hasOrderMetric := false
for _, tier := range t.Tiers {
if tier.Metric == MetricFirstOrder || tier.Metric == MetricOrderCount {
hasOrderMetric = true
break
}
}
if !hasOrderMetric {
continue
}
var p tcmodel.UserTaskProgress var p tcmodel.UserTaskProgress
err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error { err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", userID, t.ID).First(&p).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", userID, t.ID).First(&p).Error; err != nil {
@ -293,7 +374,7 @@ func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64)
} }
return err return err
} }
if err := s.checkAndResetDailyProgress(ctx, tx, t.ID, &p); err != nil { if err := s.checkAndResetDailyProgress(ctx, tx, &t, &p); err != nil {
return err return err
} }
p.OrderCount++ p.OrderCount++
@ -303,21 +384,95 @@ func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64)
return tx.Save(&p).Error return tx.Save(&p).Error
}) })
if err != nil { if err != nil {
return err s.logger.Error("failed to update progress", zap.Error(err))
continue
} }
if err := s.matchAndGrant(ctx, &t, &p, "order", orderID, fmt.Sprintf("ord:%d", orderID)); err != nil { if err := s.matchAndGrant(ctx, &t, &p, "order", orderID, fmt.Sprintf("ord:%d", orderID)); err != nil {
s.logger.Error("failed to grant reward", zap.Error(err))
}
}
// 4. 处理邀请人任务 (有效邀请)
if inviterID > 0 {
for _, t := range tasks {
tiers := t.Tiers
// 检查该任务是否有 effective_invite_count 类型的 Tier且是否刚好跨越阈值
triggered := false
for _, tier := range tiers {
if tier.Metric == MetricEffectiveInviteCount {
var extra struct {
AmountThreshold int64 `json:"amount_threshold"`
}
if len(tier.ExtraParams) > 0 {
_ = json.Unmarshal([]byte(tier.ExtraParams), &extra)
}
threshold := extra.AmountThreshold
if threshold <= 0 {
threshold = 1 // 默认任意金额
}
// 如果之前的累计金额未达到阈值,而现在的累计金额达到了阈值,则触发
if oldAmount < threshold && newAmount >= threshold {
triggered = true
break // 该任务触发一次即可
}
}
}
if triggered {
var pInv tcmodel.UserTaskProgress
err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", inviterID, t.ID).First(&pInv).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
pInv = tcmodel.UserTaskProgress{UserID: inviterID, TaskID: t.ID, EffectiveInviteCount: 1}
return tx.Create(&pInv).Error
}
return err return err
} }
// 有效邀请通常不重置(除非是每日任务?一般是长期任务)
if err := s.checkAndResetDailyProgress(ctx, tx, &t, &pInv); err != nil {
return err
}
pInv.EffectiveInviteCount++
return tx.Save(&pInv).Error
})
if err == nil {
// 尝试发放奖励
// sourceID 使用 userID (被邀请人ID)eventID 使用特殊前缀防止混淆
_ = s.matchAndGrant(ctx, &t, &pInv, SourceTypeEffectiveInvite, userID, fmt.Sprintf("eff_inv:%d:%d", userID, t.ID))
} else {
s.logger.Error("failed to update inviter progress", zap.Error(err))
}
}
}
} }
return nil return nil
} }
func (s *service) OnInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error { func (s *service) OnInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error {
var tasks []tcmodel.Task if s.queue != nil {
if err := s.repo.GetDbR().Where("status=1").Find(&tasks).Error; err != nil { return s.queue.PublishInviteSuccess(ctx, inviterID, inviteeID)
}
return s.processInviteSuccess(ctx, inviterID, inviteeID)
}
func (s *service) processInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error {
tasks, err := s.getActiveTasks(ctx)
if err != nil {
return err return err
} }
for _, t := range tasks { for _, t := range tasks {
hasInviteMetric := false
for _, tier := range t.Tiers {
if tier.Metric == MetricInviteCount {
hasInviteMetric = true
break
}
}
if !hasInviteMetric {
continue
}
var p tcmodel.UserTaskProgress var p tcmodel.UserTaskProgress
err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error { err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", inviterID, t.ID).First(&p).Error; err != nil { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", inviterID, t.ID).First(&p).Error; err != nil {
@ -327,7 +482,7 @@ func (s *service) OnInviteSuccess(ctx context.Context, inviterID int64, inviteeI
} }
return err return err
} }
if err := s.checkAndResetDailyProgress(ctx, tx, t.ID, &p); err != nil { if err := s.checkAndResetDailyProgress(ctx, tx, &t, &p); err != nil {
return err return err
} }
p.InviteCount++ p.InviteCount++
@ -336,19 +491,31 @@ func (s *service) OnInviteSuccess(ctx context.Context, inviterID int64, inviteeI
if err != nil { if err != nil {
return err return err
} }
if err := s.matchAndGrant(ctx, &t, &p, "invite", inviteeID, fmt.Sprintf("inv:%d", inviteeID)); err != nil { if err := s.matchAndGrant(ctx, &t, &p, SourceTypeInvite, inviteeID, fmt.Sprintf("inv:%d", inviteeID)); err != nil {
return err return err
} }
} }
return nil return nil
} }
func (s *service) checkAndResetDailyProgress(ctx context.Context, tx *gorm.DB, taskID int64, p *tcmodel.UserTaskProgress) error { func (s *service) checkAndResetDailyProgress(ctx context.Context, tx *gorm.DB, t *tcmodel.Task, p *tcmodel.UserTaskProgress) error {
isDaily := false
if len(t.Tiers) > 0 {
for _, tier := range t.Tiers {
if tier.Window == WindowDaily {
isDaily = true
break
}
}
} else {
var count int64 var count int64
if err := tx.Model(&tcmodel.TaskTier{}).Where("task_id = ? AND window = ?", taskID, "daily").Count(&count).Error; err != nil { if err := tx.Model(&tcmodel.TaskTier{}).Where("task_id = ? AND window = ?", t.ID, WindowDaily).Count(&count).Error; err != nil {
return err return err
} }
if count == 0 { isDaily = count > 0
}
if !isDaily {
return nil return nil
} }
now := time.Now() now := time.Now()
@ -368,10 +535,12 @@ func (s *service) checkAndResetDailyProgress(ctx context.Context, tx *gorm.DB, t
} }
func (s *service) matchAndGrant(ctx context.Context, t *tcmodel.Task, p *tcmodel.UserTaskProgress, sourceType string, sourceID int64, eventID string) error { func (s *service) matchAndGrant(ctx context.Context, t *tcmodel.Task, p *tcmodel.UserTaskProgress, sourceType string, sourceID int64, eventID string) error {
var tiers []tcmodel.TaskTier tiers := t.Tiers
if len(tiers) == 0 {
if err := s.repo.GetDbR().Where("task_id=?", t.ID).Order("priority asc").Find(&tiers).Error; err != nil { if err := s.repo.GetDbR().Where("task_id=?", t.ID).Order("priority asc").Find(&tiers).Error; err != nil {
return err return err
} }
}
var claimed []int64 var claimed []int64
if len(p.ClaimedTiers) > 0 { if len(p.ClaimedTiers) > 0 {
_ = json.Unmarshal([]byte(p.ClaimedTiers), &claimed) _ = json.Unmarshal([]byte(p.ClaimedTiers), &claimed)
@ -386,20 +555,26 @@ func (s *service) matchAndGrant(ctx context.Context, t *tcmodel.Task, p *tcmodel
} }
hit := false hit := false
switch tier.Metric { switch tier.Metric {
case "first_order": case MetricFirstOrder:
hit = p.FirstOrder == 1 hit = p.FirstOrder == 1
case "order_count": case MetricOrderCount:
if tier.Operator == ">=" { if tier.Operator == OperatorGTE {
hit = p.OrderCount >= tier.Threshold hit = p.OrderCount >= tier.Threshold
} else { } else {
hit = p.OrderCount == tier.Threshold hit = p.OrderCount == tier.Threshold
} }
case "invite_count": case MetricInviteCount:
if tier.Operator == ">=" { if tier.Operator == OperatorGTE {
hit = p.InviteCount >= tier.Threshold hit = p.InviteCount >= tier.Threshold
} else { } else {
hit = p.InviteCount == tier.Threshold hit = p.InviteCount == tier.Threshold
} }
case MetricEffectiveInviteCount:
if tier.Operator == OperatorGTE {
hit = p.EffectiveInviteCount >= tier.Threshold
} else {
hit = p.EffectiveInviteCount == tier.Threshold
}
} }
if !hit { if !hit {
continue continue

View File

@ -0,0 +1,80 @@
package taskcenter
import (
"bindbox-game/internal/pkg/async"
"context"
"encoding/json"
"time"
"go.uber.org/zap"
)
func (s *service) StartWorker(ctx context.Context) {
if s.queue == nil {
s.logger.Info("Async queue not configured, worker not started")
return
}
s.logger.Info("Task center worker started")
// Start multiple workers for concurrency
workerCount := 5
for i := 0; i < workerCount; i++ {
go s.runWorkerLoop(ctx, i)
}
}
func (s *service) runWorkerLoop(ctx context.Context, workerID int) {
defer func() {
if r := recover(); r != nil {
s.logger.Error("Task center worker panicked", zap.Any("recover", r), zap.Int("worker_id", workerID))
// Restart worker after a short delay to prevent tight loops
time.Sleep(3 * time.Second)
go s.runWorkerLoop(ctx, workerID)
}
}()
s.logger.Info("Worker routine started", zap.Int("worker_id", workerID))
for {
select {
case <-ctx.Done():
s.logger.Info("Task center worker stopping", zap.Int("worker_id", workerID))
return
default:
event, err := s.queue.Consume(ctx)
if err != nil {
s.logger.Error("Failed to consume event", zap.Error(err), zap.Int("worker_id", workerID))
time.Sleep(1 * time.Second)
continue
}
if event == nil {
continue
}
s.logger.Info("Processing event", zap.String("type", string(event.Type)), zap.Int("worker_id", workerID))
switch event.Type {
case async.EventTypeOrderPaid:
var pl async.OrderPaidPayload
if err := json.Unmarshal([]byte(event.Payload), &pl); err != nil {
s.logger.Error("Failed to unmarshal order paid payload", zap.Error(err))
continue
}
if err := s.processOrderPaid(ctx, pl.UserID, pl.OrderID); err != nil {
s.logger.Error("Failed to process order paid", zap.Error(err))
}
case async.EventTypeInviteSuccess:
var pl async.InviteSuccessPayload
if err := json.Unmarshal([]byte(event.Payload), &pl); err != nil {
s.logger.Error("Failed to unmarshal invite success payload", zap.Error(err))
continue
}
if err := s.processInviteSuccess(ctx, pl.InviterID, pl.InviteeID); err != nil {
s.logger.Error("Failed to process invite success", zap.Error(err))
}
default:
s.logger.Warn("Unknown event type", zap.String("type", string(event.Type)))
}
}
}
}

View File

@ -0,0 +1,46 @@
package user
import (
"bindbox-game/internal/repository/mysql/dao"
"context"
"fmt"
)
// CancelShipping 取消发货申请
func (s *service) CancelShipping(ctx context.Context, userID int64, inventoryID int64) error {
// 1. 开启事务
return s.writeDB.Transaction(func(tx *dao.Query) error {
// 2. 查询发货记录(必须是待发货状态 status=1
sr, err := tx.ShippingRecords.WithContext(ctx).
Where(tx.ShippingRecords.InventoryID.Eq(inventoryID)).
Where(tx.ShippingRecords.UserID.Eq(userID)).
Where(tx.ShippingRecords.Status.Eq(1)).
First()
if err != nil {
return fmt.Errorf("shipping record not found or already processed")
}
// 3. 更新发货记录状态为已取消 (status=5)
if _, err := tx.ShippingRecords.WithContext(ctx).
Where(tx.ShippingRecords.ID.Eq(sr.ID)).
Update(tx.ShippingRecords.Status, 5); err != nil {
return err
}
// 4. 恢复库存状态为可用 (status=1)
// 并追加备注
// 使用原生SQL以确保CONCAT行为一致
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=?",
remark,
inventoryID,
userID,
).Error; err != nil {
return err
}
return nil
})
}

View File

@ -14,6 +14,104 @@ type ItemCardWithTemplate struct {
EffectType int32 `json:"effect_type"` EffectType int32 `json:"effect_type"`
StackingStrategy int32 `json:"stacking_strategy"` StackingStrategy int32 `json:"stacking_strategy"`
Remark string `json:"remark"` Remark string `json:"remark"`
Count int64 `json:"count"`
}
// ListAggregatedUserItemCards 获取聚合后的用户道具卡列表(按卡种分组)
func (s *service) ListAggregatedUserItemCards(ctx context.Context, userID int64, status int32, page, pageSize int) (items []*ItemCardWithTemplate, total int64, err error) {
// 1. 计算分组总数 (Using UnderlyingDB for raw SQL flexibility)
var countResult []struct {
CardID int64
Total int64
}
tx := s.readDB.UserItemCards.WithContext(ctx).ReadDB().UnderlyingDB().
Model(&model.UserItemCards{}).
Where("user_id = ? AND status = ?", userID, status)
// Count Distinct CardID
err = tx.Distinct("card_id").Count(&total).Error
if err != nil {
return nil, 0, err
}
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
// 2. 分页查询分组数据
err = tx.Select("card_id, count(*) as total").
Group("card_id").
Order("card_id desc").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&countResult).Error
if err != nil {
return nil, 0, err
}
if len(countResult) == 0 {
return []*ItemCardWithTemplate{}, 0, nil
}
// 3. 获取每组的一个实例(用于获取有效期等信息)和模板信息
cardIDs := make([]int64, 0, len(countResult))
for _, r := range countResult {
cardIDs = append(cardIDs, r.CardID)
}
// 获取模板信息
tpls := map[int64]*model.SystemItemCards{}
// Using GEN helpers for simple queries
tplList, err := s.readDB.SystemItemCards.WithContext(ctx).ReadDB().Where(s.readDB.SystemItemCards.ID.In(cardIDs...)).Find()
if err != nil {
return nil, 0, err
}
for _, t := range tplList {
tpls[t.ID] = t
}
items = make([]*ItemCardWithTemplate, 0, len(countResult))
for _, r := range countResult {
// Find latest instance using GEN helpers
instance, _ := s.readDB.UserItemCards.WithContext(ctx).ReadDB().
Where(s.readDB.UserItemCards.UserID.Eq(userID), s.readDB.UserItemCards.Status.Eq(status), s.readDB.UserItemCards.CardID.Eq(r.CardID)).
Order(s.readDB.UserItemCards.ID.Desc()).
First()
if instance == nil {
instance = &model.UserItemCards{UserID: userID, CardID: r.CardID, Status: status}
}
tpl := tpls[r.CardID]
var name, remark string
var cardType, scopeType, effectType, stacking int32
if tpl != nil {
name = tpl.Name
cardType = tpl.CardType
scopeType = tpl.ScopeType
effectType = tpl.EffectType
stacking = tpl.StackingStrategy
remark = tpl.Remark
}
items = append(items, &ItemCardWithTemplate{
UserItemCards: instance,
Name: name,
CardType: cardType,
ScopeType: scopeType,
EffectType: effectType,
StackingStrategy: stacking,
Remark: remark,
Count: r.Total,
})
}
return items, total, nil
} }
// ListUserItemCards 获取用户道具卡列表 // ListUserItemCards 获取用户道具卡列表
@ -115,6 +213,7 @@ func (s *service) ListUserItemCardsWithTemplate(ctx context.Context, userID int6
EffectType: effectType, EffectType: effectType,
StackingStrategy: stacking, StackingStrategy: stacking,
Remark: remark, Remark: remark,
Count: 1, // Individual record
} }
} }
return items, total, nil return items, total, nil
@ -184,6 +283,7 @@ func (s *service) ListUserItemCardsWithTemplateUsable(ctx context.Context, userI
EffectType: effectType, EffectType: effectType,
StackingStrategy: stacking, StackingStrategy: stacking,
Remark: remark, Remark: remark,
Count: 1,
} }
} }
return items, total, nil return items, total, nil
@ -254,6 +354,7 @@ func (s *service) ListUserItemCardsWithTemplateByStatus(ctx context.Context, use
EffectType: effectType, EffectType: effectType,
StackingStrategy: stacking, StackingStrategy: stacking,
Remark: remark, Remark: remark,
Count: 1, // Individual record
} }
} }
return items, total, nil return items, total, nil

View File

@ -43,6 +43,21 @@ type OrderWithItems struct {
IsWinner bool `json:"is_winner"` IsWinner bool `json:"is_winner"`
RewardLevel int32 `json:"reward_level"` RewardLevel int32 `json:"reward_level"`
DrawReceipts []*DrawReceiptInfo `json:"draw_receipts"` DrawReceipts []*DrawReceiptInfo `json:"draw_receipts"`
CouponInfo *CouponSimpleInfo `json:"coupon_info,omitempty"`
ItemCardInfo *ItemCardSimpleInfo `json:"item_card_info,omitempty"`
}
type CouponSimpleInfo struct {
UserCouponID int64 `json:"user_coupon_id"`
Name string `json:"name"`
Type int32 `json:"type"` // 1:满减 2:折扣
Value int64 `json:"value"`
}
type ItemCardSimpleInfo struct {
UserCardID int64 `json:"user_card_id"`
Name string `json:"name"`
EffectType int32 `json:"effect_type"`
} }
func (s *service) ListOrders(ctx context.Context, userID int64, page, pageSize int) (items []*model.Orders, total int64, err error) { func (s *service) ListOrders(ctx context.Context, userID int64, page, pageSize int) (items []*model.Orders, total int64, err error) {
@ -123,6 +138,40 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i
res.Items = items res.Items = items
} }
// 补充优惠券和道具卡信息
if order.CouponID > 0 {
if uc, _ := s.readDB.UserCoupons.WithContext(ctx).ReadDB().Where(s.readDB.UserCoupons.ID.Eq(order.CouponID)).First(); uc != nil {
if sc, _ := s.readDB.SystemCoupons.WithContext(ctx).ReadDB().Where(s.readDB.SystemCoupons.ID.Eq(uc.CouponID)).First(); sc != nil {
val := sc.DiscountValue
// 尝试查询实际抵扣金额
if oc, _ := s.readDB.OrderCoupons.WithContext(ctx).ReadDB().Where(
s.readDB.OrderCoupons.OrderID.Eq(order.ID),
s.readDB.OrderCoupons.UserCouponID.Eq(order.CouponID),
).First(); oc != nil {
val = oc.AppliedAmount
}
res.CouponInfo = &CouponSimpleInfo{
UserCouponID: uc.ID,
Name: sc.Name,
Type: sc.DiscountType,
Value: val,
}
}
}
}
if order.ItemCardID > 0 {
if uc, _ := s.readDB.UserItemCards.WithContext(ctx).ReadDB().Where(s.readDB.UserItemCards.ID.Eq(order.ItemCardID)).First(); uc != nil {
if sc, _ := s.readDB.SystemItemCards.WithContext(ctx).ReadDB().Where(s.readDB.SystemItemCards.ID.Eq(uc.CardID)).First(); sc != nil {
res.ItemCardInfo = &ItemCardSimpleInfo{
UserCardID: uc.ID,
Name: sc.Name,
EffectType: sc.EffectType,
}
}
}
}
// 补充开奖信息 // 补充开奖信息
logs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).ReadDB().Where(s.readDB.ActivityDrawLogs.OrderID.Eq(order.ID)).Find() logs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).ReadDB().Where(s.readDB.ActivityDrawLogs.OrderID.Eq(order.ID)).Find()
if len(logs) > 0 { if len(logs) > 0 {
@ -179,7 +228,7 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i
ClientID: r.ClientID, ClientID: r.ClientID,
Timestamp: r.Timestamp, Timestamp: r.Timestamp,
ServerSeedHash: r.ServerSeedHash, ServerSeedHash: r.ServerSeedHash,
ServerSubSeed: r.ServerSubSeed, ServerSubSeed: "",
ClientSeed: r.ClientSeed, ClientSeed: r.ClientSeed,
Nonce: r.Nonce, Nonce: r.Nonce,
ItemsRoot: r.ItemsRoot, ItemsRoot: r.ItemsRoot,
@ -193,6 +242,11 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i
} }
} }
// Special handling for Ichiban Kuji: Do not show Item Card
if res.PlayType == "ichiban" {
res.ItemCardInfo = nil
}
return res, nil return res, nil
} }
@ -356,12 +410,116 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status
} }
} }
// 批量查询优惠券和道具卡信息
userCouponIDs := make([]int64, 0)
userItemCardIDs := make([]int64, 0)
for _, order := range orders {
if order.CouponID > 0 {
userCouponIDs = append(userCouponIDs, order.CouponID)
}
if order.ItemCardID > 0 {
userItemCardIDs = append(userItemCardIDs, order.ItemCardID)
}
}
couponMap := make(map[int64]*CouponSimpleInfo)
// orderID -> userCouponID -> appliedAmount
appliedAmountMap := make(map[int64]map[int64]int64)
if len(userCouponIDs) > 0 {
// 查询优惠券基本信息
userCoupons, _ := s.readDB.UserCoupons.WithContext(ctx).ReadDB().Where(s.readDB.UserCoupons.ID.In(userCouponIDs...)).Find()
var sysCouponIDs []int64
ucMap := make(map[int64]*model.UserCoupons)
for _, uc := range userCoupons {
sysCouponIDs = append(sysCouponIDs, uc.CouponID)
ucMap[uc.ID] = uc
}
if len(sysCouponIDs) > 0 {
sysCoupons, _ := s.readDB.SystemCoupons.WithContext(ctx).ReadDB().Where(s.readDB.SystemCoupons.ID.In(sysCouponIDs...)).Find()
scMap := make(map[int64]*model.SystemCoupons)
for _, sc := range sysCoupons {
scMap[sc.ID] = sc
}
for id, uc := range ucMap {
if sc, ok := scMap[uc.CouponID]; ok {
couponMap[id] = &CouponSimpleInfo{
UserCouponID: uc.ID,
Name: sc.Name,
Type: sc.DiscountType,
Value: sc.DiscountValue, // 默认使用面值,后面会尝试用实际抵扣金额覆盖
}
}
}
}
// 查询订单实际使用的优惠券金额
if len(orderIDs) > 0 {
ocs, _ := s.readDB.OrderCoupons.WithContext(ctx).ReadDB().Where(
s.readDB.OrderCoupons.OrderID.In(orderIDs...),
s.readDB.OrderCoupons.UserCouponID.In(userCouponIDs...),
).Find()
for _, oc := range ocs {
if _, ok := appliedAmountMap[oc.OrderID]; !ok {
appliedAmountMap[oc.OrderID] = make(map[int64]int64)
}
appliedAmountMap[oc.OrderID][oc.UserCouponID] = oc.AppliedAmount
}
}
}
itemCardMap := make(map[int64]*ItemCardSimpleInfo)
if len(userItemCardIDs) > 0 {
userCards, _ := s.readDB.UserItemCards.WithContext(ctx).ReadDB().Where(s.readDB.UserItemCards.ID.In(userItemCardIDs...)).Find()
var sysCardIDs []int64
ucMap := make(map[int64]*model.UserItemCards)
for _, uc := range userCards {
sysCardIDs = append(sysCardIDs, uc.CardID)
ucMap[uc.ID] = uc
}
if len(sysCardIDs) > 0 {
sysCards, _ := s.readDB.SystemItemCards.WithContext(ctx).ReadDB().Where(s.readDB.SystemItemCards.ID.In(sysCardIDs...)).Find()
scMap := make(map[int64]*model.SystemItemCards)
for _, sc := range sysCards {
scMap[sc.ID] = sc
}
for id, uc := range ucMap {
if sc, ok := scMap[uc.CardID]; ok {
itemCardMap[id] = &ItemCardSimpleInfo{
UserCardID: uc.ID,
Name: sc.Name,
EffectType: sc.EffectType,
}
}
}
}
}
// 构建返回结果 // 构建返回结果
items = make([]*OrderWithItems, len(orders)) items = make([]*OrderWithItems, len(orders))
for i, order := range orders { for i, order := range orders {
// 复制一份 CouponInfo因为同一个优惠券可能被多个订单使用但金额不同
var cInfo *CouponSimpleInfo
if baseInfo, ok := couponMap[order.CouponID]; ok {
cInfo = &CouponSimpleInfo{
UserCouponID: baseInfo.UserCouponID,
Name: baseInfo.Name,
Type: baseInfo.Type,
Value: baseInfo.Value,
}
// 尝试使用实际抵扣金额
if amMap, ok := appliedAmountMap[order.ID]; ok {
if amount, ok2 := amMap[order.CouponID]; ok2 {
cInfo.Value = amount
}
}
}
items[i] = &OrderWithItems{ items[i] = &OrderWithItems{
Orders: order, Orders: order,
Items: itemsMap[order.ID], Items: itemsMap[order.ID],
CouponInfo: cInfo,
ItemCardInfo: itemCardMap[order.ItemCardID],
} }
if logs, ok := drawLogsListMap[order.ID]; ok && len(logs) > 0 { if logs, ok := drawLogsListMap[order.ID]; ok && len(logs) > 0 {
@ -396,7 +554,7 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status
ClientID: r.ClientID, ClientID: r.ClientID,
Timestamp: r.Timestamp, Timestamp: r.Timestamp,
ServerSeedHash: r.ServerSeedHash, ServerSeedHash: r.ServerSeedHash,
ServerSubSeed: r.ServerSubSeed, ServerSubSeed: "",
ClientSeed: r.ClientSeed, ClientSeed: r.ClientSeed,
Nonce: r.Nonce, Nonce: r.Nonce,
ItemsRoot: r.ItemsRoot, ItemsRoot: r.ItemsRoot,
@ -423,6 +581,11 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status
} }
} }
} }
// Special handling for Ichiban Kuji: Do not show Item Card
if items[i].PlayType == "ichiban" {
items[i].ItemCardInfo = nil
}
} }
return items, total, nil return items, total, nil

View File

@ -20,4 +20,3 @@ func (s *service) CentsToPoints(ctx context.Context, cents int64) (int64, error)
} }
return cents * rate, nil return cents * rate, nil
} }

View File

@ -153,7 +153,12 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
} }
}(), }(),
Status: 1, // 持有状态 Status: 1, // 持有状态
Remark: product.Name, Remark: func() string {
if req.Remark != "" {
return req.Remark
}
return product.Name
}(),
} }
err = tx.UserInventory.WithContext(ctx).Create(inventory) err = tx.UserInventory.WithContext(ctx).Create(inventory)
@ -337,7 +342,12 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
return 0 return 0
}(), }(),
Status: 1, // 持有状态 Status: 1, // 持有状态
Remark: product.Name, Remark: func() string {
if req.Remark != "" {
return req.Remark
}
return product.Name
}(),
} }
err = tx.UserInventory.WithContext(ctx).Create(inventory) err = tx.UserInventory.WithContext(ctx).Create(inventory)

View File

@ -34,6 +34,7 @@ type Service interface {
ListUserItemCardsWithTemplate(ctx context.Context, userID int64, page, pageSize int) (items []*ItemCardWithTemplate, total int64, err error) ListUserItemCardsWithTemplate(ctx context.Context, userID int64, page, pageSize int) (items []*ItemCardWithTemplate, total int64, err error)
ListUserItemCardsWithTemplateUsable(ctx context.Context, userID int64, page, pageSize int) (items []*ItemCardWithTemplate, total int64, err error) ListUserItemCardsWithTemplateUsable(ctx context.Context, userID int64, page, pageSize int) (items []*ItemCardWithTemplate, total int64, err error)
ListUserItemCardsWithTemplateByStatus(ctx context.Context, userID int64, status int32, page, pageSize int) (items []*ItemCardWithTemplate, total int64, err error) ListUserItemCardsWithTemplateByStatus(ctx context.Context, userID int64, status int32, page, pageSize int) (items []*ItemCardWithTemplate, total int64, err error)
ListAggregatedUserItemCards(ctx context.Context, userID int64, status int32, page, pageSize int) (items []*ItemCardWithTemplate, total int64, err error)
ListUserItemCardUses(ctx context.Context, userID int64, page, pageSize int) (items []*model.ActivityDrawEffects, total int64, err error) ListUserItemCardUses(ctx context.Context, userID int64, page, pageSize int) (items []*model.ActivityDrawEffects, total int64, err error)
GetUserStats(ctx context.Context, userID int64) (*UserStats, error) GetUserStats(ctx context.Context, userID int64) (*UserStats, error)
AddAddress(ctx context.Context, userID int64, in AddAddressInput) (*model.UserAddresses, error) AddAddress(ctx context.Context, userID int64, in AddAddressInput) (*model.UserAddresses, error)
@ -49,6 +50,7 @@ type Service interface {
RevokeAddressShare(ctx context.Context, userID int64, inventoryID int64) error RevokeAddressShare(ctx context.Context, userID int64, inventoryID int64) error
SubmitAddressShare(ctx context.Context, shareToken string, name string, mobile string, province string, city string, district string, address string, submittedByUserID *int64, submittedIP *string) (int64, error) SubmitAddressShare(ctx context.Context, shareToken string, name string, mobile string, province string, city string, district string, address string, submittedByUserID *int64, submittedIP *string) (int64, error)
RequestShipping(ctx context.Context, userID int64, inventoryID int64) (int64, error) RequestShipping(ctx context.Context, userID int64, inventoryID int64) (int64, error)
CancelShipping(ctx context.Context, userID int64, inventoryID int64) error
RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (int64, []int64, []struct { RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (int64, []int64, []struct {
ID int64 ID int64
Reason string Reason string

File diff suppressed because one or more lines are too long

13
main.go
View File

@ -8,6 +8,7 @@ import (
"bindbox-game/configs" "bindbox-game/configs"
"bindbox-game/internal/pkg/env" "bindbox-game/internal/pkg/env"
"bindbox-game/internal/pkg/logger" "bindbox-game/internal/pkg/logger"
"bindbox-game/internal/pkg/redis"
"bindbox-game/internal/pkg/shutdown" "bindbox-game/internal/pkg/shutdown"
"bindbox-game/internal/pkg/timeutil" "bindbox-game/internal/pkg/timeutil"
"bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql"
@ -130,8 +131,13 @@ func main() {
_ = customLogger.Sync() _ = customLogger.Sync()
}() }()
// 初始化 Redis
if err := redis.Init(context.Background(), customLogger); err != nil {
customLogger.Warn("Redis init failed, some features may be disabled", zap.Error(err))
}
// 初始化 HTTP 服务 // 初始化 HTTP 服务
mux, err := router.NewHTTPMux(customLogger, dbRepo) mux, cleanup, err := router.NewHTTPMux(customLogger, dbRepo)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -152,6 +158,11 @@ func main() {
// 优雅关闭 // 优雅关闭
shutdown.Close( shutdown.Close(
func() { func() {
// 清理资源 (Worker)
if cleanup != nil {
cleanup()
}
// 关闭 http server // 关闭 http server
if err := server.Shutdown(context.TODO()); err != nil { if err := server.Shutdown(context.TODO()); err != nil {
customLogger.Error("server shutdown err", zap.Error(err)) customLogger.Error("server shutdown err", zap.Error(err))

View File

@ -0,0 +1,6 @@
-- 添加订单优惠券与道具卡字段
-- 2025-12-22
ALTER TABLE `orders`
ADD COLUMN `coupon_id` BIGINT NULL COMMENT '使用的优惠券ID' AFTER `points_ledger_id`,
ADD COLUMN `item_card_id` BIGINT NULL COMMENT '使用的道具卡ID' AFTER `coupon_id`;

View File

@ -0,0 +1,4 @@
ALTER TABLE user_invites ADD COLUMN is_effective TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为有效邀请';
ALTER TABLE user_invites ADD COLUMN accumulated_amount BIGINT NOT NULL DEFAULT 0 COMMENT '被邀请人累计消费金额(分)';
ALTER TABLE task_center_task_tiers ADD COLUMN extra_params JSON COMMENT '额外参数配置(如消费门槛)';
ALTER TABLE task_center_user_progress ADD COLUMN effective_invite_count BIGINT NOT NULL DEFAULT 0 COMMENT '有效邀请人数';

View File

@ -0,0 +1 @@
ALTER TABLE task_center_user_progress MODIFY COLUMN invite_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计邀请数(注册)';

12
说明文档.md Normal file
View File

@ -0,0 +1,12 @@
# 项目说明文档
## 一、项目规划
(待补充)
## 二、实施方案
(待补充)
## 三、进度记录
- **2025-12-23** [修复]
- 修复微信虚拟发货接口 `items_desc` 字段因字节截断导致的 UTF-8 编码错误 (errcode=47007)。
- 修复开奖通知接口 `phrase3` 字段内容过长导致的参数无效错误 (errcode=47003),调整为固定文案“恭喜中奖”,并将赏品名称移至 `thing4` 展示。