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
docker build -t zfc931912343/bindbox-game:v1.8 .
docker push zfc931912343/bindbox-game:v1.8
docker build -t zfc931912343/bindbox-game:v1.9 .
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 type="module" crossorigin src="/assets/index-iR6j7F-E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-q7XdNN2Z.css">
<script type="module" crossorigin src="/assets/index-D9UEFhei.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZQg_MtJ.css">
</head>
<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": {
"post": {
"security": [
@ -8286,6 +8338,17 @@ const docTemplate = `{
}
}
},
"app.cancelShippingRequest": {
"type": "object",
"properties": {
"inventory_id": {
"type": "integer"
}
}
},
"app.cancelShippingResponse": {
"type": "object"
},
"app.couponDetail": {
"type": "object",
"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": {
"post": {
"security": [
@ -8278,6 +8330,17 @@
}
}
},
"app.cancelShippingRequest": {
"type": "object",
"properties": {
"inventory_id": {
"type": "integer"
}
}
},
"app.cancelShippingResponse": {
"type": "object"
},
"app.couponDetail": {
"type": "object",
"properties": {

View File

@ -1393,6 +1393,13 @@ definitions:
status:
type: integer
type: object
app.cancelShippingRequest:
properties:
inventory_id:
type: integer
type: object
app.cancelShippingResponse:
type: object
app.couponDetail:
properties:
amount:
@ -6113,6 +6120,39 @@ paths:
summary: 撤销共享地址链接
tags:
- 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:
post:
consumes:

View File

@ -5,7 +5,6 @@ import (
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
activitysvc "bindbox-game/internal/service/activity"
syscfgsvc "bindbox-game/internal/service/sysconfig"
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
@ -17,13 +16,11 @@ type handler struct {
writeDB *dao.Query
readDB *dao.Query
activity activitysvc.Service
syscfg syscfgsvc.Service
title titlesvc.Service
repo mysql.Repo
user usersvc.Service
redis *redis.Client
activityOrder activitysvc.ActivityOrderService // 活动订单服务
rewardEffects activitysvc.RewardEffectsService // 奖励效果服务
}
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()),
readDB: dao.Use(db.GetDbR()),
activity: activitysvc.New(logger, db),
syscfg: syscfgsvc.New(logger, db),
title: titlesvc.New(logger, db),
repo: db,
user: usersvc.New(logger, db),
redis: rdb,
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()))
return
}
var total int64
for _, r := range rewards {
if r.OriginalQty > 0 {
total += r.OriginalQty
}
}
// 一番赏:每种奖品 = 1个格位
total := int64(len(rewards))
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 {
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}
if activityItem.ActivityCategoryID == 1 {
rewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.Eq(v.ID)).Find()
var totalQty int64
var remainQty int64
for _, r := range rewards {
if r.OriginalQty >= 0 {
totalQty += r.OriginalQty
}
if r.Quantity >= 0 {
remainQty += r.Quantity
}
// 一番赏:每种奖品 = 1个格位
totalQty := int64(len(rewards))
// 查询已占用格位数
var claimedCnt int64
_ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id = ?", v.ID).Scan(&claimedCnt).Error
remainQty := totalQty - claimedCnt
if remainQty < 0 {
remainQty = 0
}
it.TotalPrizeQuantity = totalQty
it.RemainingPrizeQuantity = remainQty

View File

@ -81,8 +81,6 @@ func (h *handler) JoinLottery() core.HandlerFunc {
if 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秒禁止下单防止订单抖动
if activity.PlayType == "ichiban" && cfgMode == "scheduled" && !activity.ScheduledTime.IsZero() {
now := time.Now()
@ -118,12 +116,9 @@ func (h *handler) JoinLottery() core.HandlerFunc {
order.PointsAmount = 0
order.PointsLedgerID = 0
order.ActualAmount = order.TotalAmount
fmt.Printf("[抽奖下单] 订单总额(分)=%d 初始实付(分)=%d 备注=%s\n", order.TotalAmount, order.ActualAmount, order.Remark)
applied := int64(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)
fmt.Printf("[抽奖下单] 优惠后 实付(分)=%d 累计优惠(分)=%d 备注=%s\n", order.ActualAmount, order.DiscountAmount, order.Remark)
}
// Title Discount Logic
// 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 {
order.ActualAmount -= discount
fmt.Printf("[抽奖下单] Title Discount Applied: -%d (EffectID: %d)\n", discount, ef.ID)
// Append to remark or separate logging?
if order.Remark == "" {
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.OrderNo = orderNo
rsp.DrawMode = cfgMode
fmt.Printf("[抽奖下单] 汇总 订单号=%s 总额(分)=%d 优惠(分)=%d 实付(分)=%d 队列=true 模式=%s\n", orderNo, order.TotalAmount, order.DiscountAmount, order.ActualAmount, cfgMode)
if order.ActualAmount == 0 {
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})
@ -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
} else {
rsp.Queued = true
@ -600,6 +389,8 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170006, "order not paid"))
return
}
// Daily Seed logic removed to ensure strict adherence to CommitmentSeedMaster
if actCommit.PlayType == "ichiban" {
slot := parseSlotFromRemark(ord.Remark)
if slot >= 0 {
@ -634,20 +425,14 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log)
_ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, issueID, userID, proof)
icID := parseItemCardIDFromRemark(ord.Remark)
fmt.Printf("[道具卡-GetLotteryResult] 从订单备注解析道具卡ID icID=%d 订单备注=%s\n", icID, ord.Remark)
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()
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()
now := time.Now()
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) {
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 {
eff := &model.ActivityDrawEffects{
DrawLogID: log.ID,
@ -665,13 +450,9 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
IssueID: issueID,
}
_ = h.writeDB.ActivityDrawEffects.WithContext(ctx.RequestContext()).Create(eff)
fmt.Printf("[道具卡-GetLotteryResult] 创建道具卡效果记录 EffectID=%d\n", eff.ID)
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 + "(倍数)"})
fmt.Printf("[道具卡-GetLotteryResult] ✅ 双倍奖励发放完成\n")
} 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()
var better *model.ActivityRewardSettings
for _, r := range uprw {
@ -683,41 +464,29 @@ func (h *handler) GetLotteryResult() core.HandlerFunc {
}
if better != nil && rand.Int31n(1000) < ic.BoostRateX1000 {
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 + "(升级)"})
// 创建升级后的抽奖日志并保存凭据
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 {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 创建升级抽奖日志失败 err=%v\n", err)
} else {
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 {
fmt.Printf("[道具卡-GetLotteryResult] ✅ 保存升级凭据成功 DrawLogID=%d IssueID=%d\n", drawLog2.ID, issueID)
}
}
} else {
fmt.Printf("[道具卡-GetLotteryResult] 概率提升未触发\n")
}
} 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()})
} else {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 范围检查失败\n")
}
} else {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 时间检查失败\n")
}
} else {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 未找到系统道具卡 CardID=%d\n", uic.CardID)
}
} else {
fmt.Printf("[道具卡-GetLotteryResult] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, icID)
}
} else {
fmt.Printf("[道具卡-GetLotteryResult] 订单备注中没有道具卡ID\n")
}
rsp.Result = map[string]any{"reward_id": rid, "reward_name": func() string {
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 一番赏格位校验
// 功能:校验请求中的格位选择是否有效(数量匹配、范围合法、未被占用)
// 参数:
@ -922,379 +554,3 @@ func (h *handler) validateIchibanSlots(ctx core.Context, req *joinLotteryRequest
}
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()
if act != nil {
// 一次补抽
rid := int64(0)
var e2 error
if act.PlayType == "ichiban" {
slot := parseSlotFromRemark(ord.Remark)
if slot >= 0 {
@ -129,8 +127,8 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc {
if cnt > 0 {
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 {
var proof map[string]any
rid, proof, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), act.ID, iss, slot)
// Daily Seed removed to enforce CommitmentSeedMaster
rid, proof, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), act.ID, iss, slot)
if e2 == nil && rid > 0 {
rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First()
if rw != nil {
@ -146,8 +144,8 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc {
}
} else {
sel := strat.NewDefault(h.readDB, h.writeDB)
var proof map[string]any
rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), act.ID, iss, userID)
// Daily Seed removed
rid, proof, e2 := sel.SelectItem(ctx.RequestContext(), act.ID, iss, userID)
if e2 == nil && rid > 0 {
rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First()
if rw != nil {
@ -257,78 +255,3 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc {
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
import (
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/model"
activitysvc "bindbox-game/internal/service/activity"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/redis/go-redis/v9"
"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 ==========
type matchingGamePreOrderRequest struct {
@ -321,178 +55,6 @@ type matchingGameCheckResponse struct {
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 下单并预生成对对碰游戏数据
// @Summary 下单并获取对对碰全量数据
// @Description 用户下单服务器扣费并返回全量99张乱序卡牌前端自行负责游戏流程
@ -588,7 +150,13 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
}
// 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.IssueID = req.IssueID
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 {
fmt.Printf("[对对碰Check] ❌ 校验失败: 提交数量超过理论最大值\n")
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, fmt.Sprintf("invalid total pairs: %d > %d", req.TotalPairs, game.MaxPossiblePairs)))
return
}
fmt.Printf("[对对碰Check] ✅ 校验通过\n")
game.TotalPairs = req.TotalPairs // 记录一下
var rewardInfo *MatchingRewardInfo
@ -742,112 +313,111 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
}
}
// 3. Grant Reward if found
if candidate != nil {
if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, candidate); err != nil {
h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err))
} else {
rewardInfo = &MatchingRewardInfo{
RewardID: candidate.ID,
Name: candidate.Name,
Level: candidate.Level,
}
// 3. Prepare Grant Params
// Fetch real product name for remark
productName := candidate.Name
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
productName = p.Name
}
// 4. Apply Item Card Effects (if any)
ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First()
if ord != nil {
icID := parseItemCardIDFromRemark(ord.Remark)
fmt.Printf("[道具卡-CheckMatchingGame] 从订单备注解析道具卡ID icID=%d 订单备注=%s\n", icID, ord.Remark)
if icID > 0 {
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.UserItemCards.ID.Eq(icID),
h.readDB.UserItemCards.UserID.Eq(game.UserID),
h.readDB.UserItemCards.Status.Eq(1),
finalReward := candidate
finalQuantity := 1
finalRemark := fmt.Sprintf("%s %s", order.OrderNo, productName)
var cardToVoid int64 = 0
// 4. Apply Item Card Effects (Determine final reward and quantity)
if order != nil {
icID := parseItemCardIDFromRemark(order.Remark)
if icID > 0 {
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.UserItemCards.ID.Eq(icID),
h.readDB.UserItemCards.UserID.Eq(game.UserID),
h.readDB.UserItemCards.Status.Eq(1),
).First()
if uic != nil {
ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.SystemItemCards.ID.Eq(uic.CardID),
h.readDB.SystemItemCards.Status.Eq(1),
).First()
if uic != nil {
ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.SystemItemCards.ID.Eq(uic.CardID),
h.readDB.SystemItemCards.Status.Eq(1),
).First()
now := time.Now()
if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID)
fmt.Printf("[道具卡-CheckMatchingGame] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, game.ActivityID, game.IssueID, scopeOK)
if scopeOK {
// Apply effect based on type
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// Double reward
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s\n", ic.RewardMultiplierX1000, candidate.ID, candidate.Name)
rid := candidate.ID
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), game.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: game.OrderID,
ProductID: candidate.ProductID,
Quantity: 1,
ActivityID: &game.ActivityID,
RewardID: &rid,
Remark: candidate.Name + "(倍数)",
})
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// Probability boost - try to upgrade to better reward
fmt.Printf("[道具卡-CheckMatchingGame] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID),
).Find()
var better *model.ActivityRewardSettings
for _, r := range allRewards {
if r.MinScore > candidate.MinScore && r.Quantity > 0 {
if better == nil || r.MinScore < better.MinScore {
better = r
}
}
}
if better != nil {
// Use crypto/rand for secure random
randBytes := make([]byte, 4)
rand.Read(randBytes)
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
if randVal < ic.BoostRateX1000 {
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name)
rid := better.ID
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), game.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: game.OrderID,
ProductID: better.ProductID,
Quantity: 1,
ActivityID: &game.ActivityID,
RewardID: &rid,
Remark: better.Name + "(升级)",
})
now := time.Now()
if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID)
fmt.Printf("[道具卡-CheckMatchingGame] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, game.ActivityID, game.IssueID, scopeOK)
if scopeOK {
cardToVoid = icID
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// Double reward
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 应用双倍奖励 倍数=%d\n", ic.RewardMultiplierX1000)
finalQuantity = 2
finalRemark += "(倍数)"
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// Probability boost - try to upgrade to better reward
fmt.Printf("[道具卡-CheckMatchingGame] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID),
).Find()
var better *model.ActivityRewardSettings
for _, r := range allRewards {
if r.MinScore > candidate.MinScore && r.Quantity > 0 {
if better == nil || r.MinScore < better.MinScore {
better = r
}
}
}
// Void the item card
fmt.Printf("[道具卡-CheckMatchingGame] 核销道具卡 用户道具卡ID=%d\n", icID)
// Get DrawLog ID for the order
drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID),
).First()
var drawLogID int64
if drawLog != nil {
drawLogID = drawLog.ID
if better != nil {
// Use crypto/rand for secure random
randBytes := make([]byte, 4)
rand.Read(randBytes)
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
if randVal < ic.BoostRateX1000 {
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name)
finalReward = better
finalRemark = better.Name + "(升级)"
}
}
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.writeDB.UserItemCards.ID.Eq(icID),
h.writeDB.UserItemCards.UserID.Eq(game.UserID),
h.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{
h.writeDB.UserItemCards.Status.ColumnName().String(): 2,
h.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): drawLogID,
h.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): game.ActivityID,
h.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): game.IssueID,
h.writeDB.UserItemCards.UsedAt.ColumnName().String(): now,
})
}
}
}
}
}
}
// 5. Grant Reward
if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, finalReward, finalQuantity, finalRemark); err != nil {
h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err))
} else {
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
drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID),
).First()
var drawLogID int64
if drawLog != nil {
drawLogID = drawLog.ID
}
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.writeDB.UserItemCards.ID.Eq(cardToVoid),
h.writeDB.UserItemCards.UserID.Eq(game.UserID),
h.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{
h.writeDB.UserItemCards.Status.ColumnName().String(): 2,
h.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): drawLogID,
h.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): game.ActivityID,
h.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): game.IssueID,
h.writeDB.UserItemCards.UsedAt.ColumnName().String(): now,
})
}
}
}
}
@ -858,6 +428,44 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
Reward: rewardInfo,
}
// 7. Virtual Shipping (Async)
// Upload shipping info to WeChat (similar to Ichiban Kuji) so user can see "Shipped" status and reward info.
rewardName := "无奖励"
if rewardInfo != nil {
rewardName = rewardInfo.Name
}
go func(orderID int64, orderNo string, userID int64, rName string) {
bgCtx := context.Background()
// 1. Get Payment Transaction
tx, _ := h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
if tx == nil || tx.TransactionID == "" {
h.logger.Warn("CheckMatchingGame: No payment transaction found for shipping", zap.String("order_no", orderNo))
return
}
// 2. Get User OpenID
u, _ := h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First()
payerOpenid := ""
if u != nil {
payerOpenid = u.Openid
}
// 3. Construct Item Desc
itemsDesc := fmt.Sprintf("对对碰 %s 赏品: %s", orderNo, rName)
if len(itemsDesc) > 120 {
itemsDesc = itemsDesc[:120]
}
// 4. Upload
c := configs.Get()
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil {
h.logger.Error("CheckMatchingGame: Failed to upload virtual shipping", zap.Error(err))
} else {
h.logger.Info("CheckMatchingGame: Virtual shipping uploaded", zap.String("order_no", orderNo), zap.String("items", itemsDesc))
}
}(game.OrderID, order.OrderNo, game.UserID, rewardName)
// 结算完成,清理会话 (Delete from Redis)
_ = h.redis.Del(ctx.RequestContext(), matchingGameKeyPrefix+req.GameID)
@ -922,81 +530,3 @@ func (h *handler) ListMatchingCardTypes() core.HandlerFunc {
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"`
Level int32 `json:"level"`
ProductImage string `json:"product_image"`
OriginalQty int64 `json:"original_qty"`
RemainingQty int64 `json:"remaining_qty"`
Claimed bool `json:"claimed"`
}
@ -72,7 +70,7 @@ func (h *handler) ListIchibanSlots() core.HandlerFunc {
}
out := make([]slotItem, len(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})
}
@ -105,8 +103,6 @@ func (h *handler) GetIchibanSlotDetail() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170203, err.Error()))
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"))
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{}
drawMode := "scheduled"
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))
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()
@ -144,6 +231,20 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
} else if pa > 0 {
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{
"id": o.ID,
"order_no": o.OrderNo,
@ -157,12 +258,14 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
}
return ""
}(),
"created_at": o.CreatedAt,
"is_consumed": o.IsConsumed,
"remark": o.Remark,
"points_amount": pa,
"points_used": pu,
"total_amount": o.TotalAmount,
"created_at": o.CreatedAt,
"is_consumed": o.IsConsumed,
"remark": o.Remark,
"points_amount": pa,
"points_used": pu,
"total_amount": o.TotalAmount,
"coupon_info": cInfo,
"item_card_info": itemCardMap[o.ItemCardID],
}
out = append(out, item)
}
@ -238,6 +341,8 @@ type getPayOrderResponse struct {
} `json:"reward_items"`
RewardShipments []*model.ShippingRecords `json:"reward_shipments"`
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 {
@ -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)
}
}

View File

@ -4,6 +4,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"bindbox-game/internal/code"
@ -11,6 +14,7 @@ import (
paypkg "bindbox-game/internal/pkg/pay"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
)
type createRefundRequest struct {
@ -42,6 +46,35 @@ func (h *handler) CreateRefund() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 160001, "order not found"))
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()
var refundedSumCents int64
@ -137,19 +170,35 @@ func (h *handler) CreateRefund() core.HandlerFunc {
}
}
// 全额退款:回收中奖资产与奖品库存
type invRow struct {
ID int64
RewardID int64
}
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
// 恢复奖品库存 (ActivityRewardSettings)
if inv.RewardID > 0 {
_ = h.repo.GetDbW().Exec("UPDATE activity_reward_settings SET quantity = quantity + 1 WHERE id=?", inv.RewardID).Error
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
svc := usersvc.New(h.logger, h.repo)
for _, inv := range allInvs {
if inv.Status == 1 {
// 状态1持有更新状态为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
// 恢复奖品库存
if inv.RewardID > 0 {
_ = 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

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

View File

@ -1,20 +1,23 @@
package pay
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
usersvc "bindbox-game/internal/service/user"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
tasksvc "bindbox-game/internal/service/task_center"
usersvc "bindbox-game/internal/service/user"
)
type handler struct {
logger logger.CustomLogger
writeDB *dao.Query
readDB *dao.Query
user usersvc.Service
repo mysql.Repo
logger logger.CustomLogger
writeDB *dao.Query
readDB *dao.Query
user usersvc.Service
task tasksvc.Service
repo mysql.Repo
}
func New(logger logger.CustomLogger, db mysql.Repo) *handler {
return &handler{logger: logger, writeDB: dao.Use(db.GetDbW()), readDB: dao.Use(db.GetDbR()), user: usersvc.New(logger, db), repo: db}
func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
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"
lotterynotify "bindbox-game/internal/pkg/notify"
pay "bindbox-game/internal/pkg/pay"
pkgutils "bindbox-game/internal/pkg/utils"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/model"
strat "bindbox-game/internal/service/activity/strategy"
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/downloader"
"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()))
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()
// 支付成功后扣减优惠券余额(优先使用结构化明细表),如无明细再降级解析备注
if ord != nil {
@ -345,6 +354,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
break
}
// 位置已在上面占用,这里直接选择奖品
// Use Commitment Seed (via SelectItemBySlot internal logic)
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)
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()
drawLogs, _ := h.readDB.ActivityDrawLogs.WithContext(bgCtx).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
if len(drawLogs) == 0 {
@ -486,9 +496,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
}
}
itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ")
if len(itemsDesc) > 120 {
itemsDesc = itemsDesc[:120]
}
itemsDesc = pkgutils.TruncateBytes(itemsDesc, 120)
// 获取支付交易信息
var tx *model.PaymentTransactions
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 {
fmt.Printf("[即时开奖-虚拟发货] 上传失败: %v\n", err)
}
// 【开奖后推送通知】
notifyCfg := &lotterynotify.WechatNotifyConfig{
AppID: c.Wechat.AppID,
AppSecret: c.Wechat.AppSecret,
LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID,
// 【开奖后推送通知】只有一番赏才发送
if playType == "ichiban" {
notifyCfg := &lotterynotify.WechatNotifyConfig{
AppID: c.Wechat.AppID,
AppSecret: c.Wechat.AppSecret,
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 {
@ -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))
}
s := strings.Join(parts, ", ")
if len(s) > 120 {
s = s[:120]
}
itemsDesc = s
itemsDesc = pkgutils.TruncateRunes(s, 120)
} else {
itemsDesc = "订单" + ord.OrderNo
}
@ -536,8 +543,8 @@ func (h *handler) WechatNotify() core.HandlerFunc {
if transaction.Payer != nil && transaction.Payer.Openid != nil {
payerOpenid = *transaction.Payer.Openid
}
// 抽奖订单在开奖后发货,非抽奖订单在支付后立即发货
if ord.SourceType != 2 {
// 抽奖订单(2)和对对碰订单(3)在开奖/结算后发货,非此类订单在支付后立即发货
if ord.SourceType != 2 && ord.SourceType != 3 {
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)
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 {
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"
"net/http"
"strconv"
"time"
"gorm.io/datatypes"
)
@ -30,7 +31,14 @@ func (h *handler) ListTasksForAdmin() core.HandlerFunc {
}
out := &rsp{Total: total, List: make([]map[string]any, len(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)
}
@ -41,6 +49,8 @@ type createTaskRequest struct {
Description string `json:"description"`
Status int32 `json:"status"`
Visibility int32 `json:"visibility"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
}
// @Summary 创建任务(Admin)
@ -58,7 +68,18 @@ func (h *handler) CreateTaskForAdmin() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
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 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return
@ -72,6 +93,8 @@ type modifyTaskRequest struct {
Description string `json:"description"`
Status int32 `json:"status"`
Visibility int32 `json:"visibility"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
}
// @Summary 修改任务(Admin)
@ -95,7 +118,18 @@ func (h *handler) ModifyTaskForAdmin() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
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()))
return
}
@ -128,12 +162,13 @@ func (h *handler) DeleteTaskForAdmin() core.HandlerFunc {
type upsertTiersRequest struct {
Tiers []struct {
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold int64 `json:"threshold"`
Window string `json:"window"`
Repeatable int32 `json:"repeatable"`
Priority int32 `json:"priority"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold int64 `json:"threshold"`
Window string `json:"window"`
Repeatable int32 `json:"repeatable"`
Priority int32 `json:"priority"`
ExtraParams datatypes.JSON `json:"extra_params"`
} `json:"tiers"`
}
@ -160,7 +195,7 @@ func (h *handler) UpsertTaskTiersForAdmin() core.HandlerFunc {
}
in := make([]tasksvc.TaskTierInput, len(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 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))

View File

@ -1,27 +1,30 @@
package taskcenter
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
tasksvc "bindbox-game/internal/service/task_center"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
tasksvc "bindbox-game/internal/service/task_center"
)
type handler struct {
logger logger.CustomLogger
writeDB *dao.Query
readDB *dao.Query
repo mysql.Repo
task tasksvc.Service
logger logger.CustomLogger
writeDB *dao.Query
readDB *dao.Query
repo mysql.Repo
task tasksvc.Service
}
func New(l logger.CustomLogger, db mysql.Repo) *handler {
return &handler{
logger: l,
writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()),
repo: db,
task: tasksvc.New(l, db),
}
func New(l logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
return &handler{
logger: l,
writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()),
repo: 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
}
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 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10008, err.Error()))
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.CostSeconds = time.Since(ts).Seconds()
logger.Info("trace-log",
zap.Any("method", ctx.Request.Method),
zap.Any("path", decodedURL),
zap.Any("http_code", ctx.Writer.Status()),
zap.Any("business_code", businessCode),
zap.Any("success", t.Success),
zap.Any("cost_seconds", t.CostSeconds),
zap.Any("trace_id", t.Identifier),
zap.Any("trace_info", t),
zap.Error(abortErr),
)
// logger.Info("trace-log",
// zap.Any("method", ctx.Request.Method),
// zap.Any("path", decodedURL),
// zap.Any("http_code", ctx.Writer.Status()),
// zap.Any("business_code", businessCode),
// zap.Any("success", t.Success),
// zap.Any("cost_seconds", t.CostSeconds),
// zap.Any("trace_id", t.Identifier),
// zap.Any("trace_info", t),
// zap.Error(abortErr),
// )
traceInfo := ""
if traceJsonData, err := json.Marshal(t); err == nil {

View File

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

View File

@ -18,8 +18,10 @@ type UserInvites struct {
InviterID int64 `gorm:"column:inviter_id;not null;comment:邀请人用户ID" json:"inviter_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"` // 邀请时使用的邀请码
RewardPoints int64 `gorm:"column:reward_points;not null;comment:发放的积分数量(用于审计)" json:"reward_points"` // 发放的积分数量(用于审计)
RewardedAt time.Time `gorm:"column:rewarded_at;comment:奖励发放时间" json:"rewarded_at"` // 奖励发放时间
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"` // 奖励发放时间
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"` // 更新时间
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;comment:删除时间(软删)" json:"deleted_at"` // 删除时间(软删)

View File

@ -1,85 +1,89 @@
package task_center
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type Task struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:64;not null"`
Description string `gorm:"type:text"`
Status int32 `gorm:"not null"`
StartTime *time.Time `gorm:"index"`
EndTime *time.Time `gorm:"index"`
Visibility int32 `gorm:"not null"`
ConditionsSchema datatypes.JSON `gorm:"type:json"`
CreatedAt time.Time
UpdatedAt time.Time
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:64;not null"`
Description string `gorm:"type:text"`
Status int32 `gorm:"not null;index"` // 增加索引,常用于过滤活跃任务
StartTime *time.Time `gorm:"index"`
EndTime *time.Time `gorm:"index"`
Visibility int32 `gorm:"not null"`
ConditionsSchema datatypes.JSON `gorm:"type:json"`
Tiers []TaskTier `gorm:"foreignKey:TaskID"`
Rewards []TaskReward `gorm:"foreignKey:TaskID"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (Task) TableName() string { return "task_center_tasks" }
type TaskTier struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TaskID int64 `gorm:"index;not null"`
Metric string `gorm:"size:32;not null"`
Operator string `gorm:"size:8;not null"`
Threshold int64 `gorm:"not null"`
Window string `gorm:"size:32;not null"`
Repeatable int32 `gorm:"not null"`
Priority int32 `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
ID int64 `gorm:"primaryKey;autoIncrement"`
TaskID int64 `gorm:"index;not null"`
Metric string `gorm:"size:32;not null"`
Operator string `gorm:"size:8;not null"`
Threshold int64 `gorm:"not null"`
Window string `gorm:"size:32;not null"`
Repeatable int32 `gorm:"not null"`
Priority int32 `gorm:"not null"`
ExtraParams datatypes.JSON `gorm:"type:json"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (TaskTier) TableName() string { return "task_center_task_tiers" }
type TaskReward struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TaskID int64 `gorm:"index;not null"`
TierID int64 `gorm:"index;not null"`
RewardType string `gorm:"size:32;not null"`
RewardPayload datatypes.JSON `gorm:"type:json"`
Quantity int64 `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
ID int64 `gorm:"primaryKey;autoIncrement"`
TaskID int64 `gorm:"index;not null"`
TierID int64 `gorm:"index;not null"`
RewardType string `gorm:"size:32;not null"`
RewardPayload datatypes.JSON `gorm:"type:json"`
Quantity int64 `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (TaskReward) TableName() string { return "task_center_task_rewards" }
type UserTaskProgress struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"index;not null"`
TaskID int64 `gorm:"index;not null"`
OrderCount int64 `gorm:"not null"`
InviteCount int64 `gorm:"not null"`
FirstOrder int32 `gorm:"not null"`
ClaimedTiers datatypes.JSON `gorm:"type:json"`
CreatedAt time.Time
UpdatedAt time.Time
ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"uniqueIndex:uk_user_task;not null"` // 联合唯一索引,防止并发重复创建
TaskID int64 `gorm:"uniqueIndex:uk_user_task;not null"` // 联合唯一索引
OrderCount int64 `gorm:"not null"`
InviteCount int64 `gorm:"not null"`
EffectiveInviteCount int64 `gorm:"not null;default:0"`
FirstOrder int32 `gorm:"not null"`
ClaimedTiers datatypes.JSON `gorm:"type:json"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (UserTaskProgress) TableName() string { return "task_center_user_progress" }
type TaskEventLog struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
EventID string `gorm:"size:64;index"`
SourceType string `gorm:"size:16"`
SourceID int64 `gorm:"index"`
UserID int64 `gorm:"index"`
TaskID int64 `gorm:"index"`
TierID int64 `gorm:"index"`
IdempotencyKey string `gorm:"size:128;uniqueIndex"`
Status string `gorm:"size:16"`
Result datatypes.JSON `gorm:"type:json"`
CreatedAt time.Time
ID int64 `gorm:"primaryKey;autoIncrement"`
EventID string `gorm:"size:64;index"`
SourceType string `gorm:"size:16"`
SourceID int64 `gorm:"index"`
UserID int64 `gorm:"index"`
TaskID int64 `gorm:"index"`
TierID int64 `gorm:"index"`
IdempotencyKey string `gorm:"size:128;uniqueIndex"`
Status string `gorm:"size:16"`
Result datatypes.JSON `gorm:"type:json"`
CreatedAt time.Time
}
func (TaskEventLog) TableName() string { return "task_center_event_logs" }
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/repository/mysql"
"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"
"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 {
return nil, errors.New("logger required")
return nil, nil, errors.New("logger required")
}
if db == nil {
return nil, errors.New("db required")
return nil, nil, errors.New("db required")
}
mux, err := core.New(logger,
@ -41,19 +44,27 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) {
panic(err)
}
// Init Redis
if err := redis.Init(context.Background(), logger); err != nil {
panic(err)
}
// Redis is initialized in main.go
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)
activityHandler := activityapi.New(logger, db, rdb)
taskCenterHandler := taskcenterapi.New(logger, db)
taskCenterHandler := taskcenterapi.New(logger, db, taskSvc)
userHandler := userapi.New(logger, db)
commonHandler := commonapi.New(logger, db)
payHandler := payapi.New(logger, db)
payHandler := payapi.New(logger, db, taskSvc)
// minesweeperHandler := minesweeperapi.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/revoke", userHandler.RevokeAddressShare())
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/points/redeem-coupon", userHandler.RedeemPointsToCoupon())
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())
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)
}
// 记录优惠券和道具卡信息(显式字段 + 备注追加)
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)
titleEffects, _ := s.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{
ActivityID: &req.ActivityID,

View File

@ -36,20 +36,14 @@ func (s *IchibanSlotsService) buildMapping(ctx context.Context, activityID int64
if err != nil || len(rewards) == 0 {
return nil, errors.New("no rewards")
}
var total int64
for _, r := range rewards {
if r.OriginalQty > 0 {
total += r.OriginalQty
}
}
// 一番赏:每种奖品 = 1个格位
total := int64(len(rewards))
if total <= 0 {
return nil, errors.New("no slots")
}
slots := make([]int64, 0, total)
for _, r := range rewards {
for i := int64(0); i < r.OriginalQty; i++ {
slots = append(slots, r.ID)
}
slots := make([]int64, total)
for i, r := range rewards {
slots[i] = r.ID
}
mac := hmac.New(sha256.New, seed)
for i := int(total - 1); i > 0; i-- {
@ -68,8 +62,6 @@ type SlotItem struct {
RewardName string
Level int32
ProductImage string
OriginalQty int64
RemainingQty int64
Claimed bool
}
@ -131,7 +123,7 @@ func (s *IchibanSlotsService) Page(ctx context.Context, activityID int64, issueI
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
}
@ -163,5 +155,5 @@ func (s *IchibanSlotsService) SlotDetail(ctx context.Context, activityID 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)
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())
w := dao.Use(repo.GetDbW())
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() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
@ -108,45 +126,77 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
r.Orders.CreatedAt.Gte(last),
).Find()
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 {
fmt.Printf("[定时开奖] 活动ID=%d ❌ 人数不足,进行退款处理\n", aid)
wc, err := paypkg.NewWechatPayClient(ctx)
if err == nil {
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)
_ = 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 a.RefundCouponID > 0 {
_ = us.AddCoupon(ctx, o.UserID, a.RefundCouponID)
// Initialize Wechat Client if needed
wc, _ := paypkg.NewWechatPayClient(ctx)
refundedIssues := make(map[int64]bool)
// 【优化】一番赏定时退款:检查是否售罄
if a.PlayType == "ichiban" {
issueIDs := make(map[int64]struct{})
for _, o := range orders {
iss := extractIssueID(o.Remark)
if iss > 0 {
issueIDs[iss] = struct{}{}
}
}
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 {
fmt.Printf("[定时开奖] 活动ID=%d ✅ 人数满足,开始开奖处理\n", aid)
fmt.Printf("[定时开奖] 活动ID=%d ✅ 人数满足(或一番赏模式),开始开奖处理\n", aid)
if a.PlayType == "ichiban" {
fmt.Printf("[定时开奖] 活动ID=%d 一番赏模式开奖,订单数=%d\n", aid, len(orders))
// 一番赏定时开奖逻辑
ichibanSel := strat.NewIchiban(r, w)
for _, o := range orders {
uid := o.UserID
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)
// 检查是否已经处理过
@ -177,6 +227,7 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
}
// 使用 claim 中的 slot_index 直接获取奖品
// Use Commitment (via SelectItemBySlot internal logic)
rid, proof, err := ichibanSel.SelectItemBySlot(ctx, aid, iss, claim.SlotIndex)
if err != nil || rid <= 0 {
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 "活动"
}())
}(), "ichiban")
}
} else {
// 默认玩法逻辑
sel := strat.NewDefault(r, w)
// Daily Seed removed
for _, o := range orders {
uid := o.UserID
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)
}
}
// 【开奖后虚拟发货】定时开奖后上传虚拟发货
// 【开奖后虚拟发货】定时开奖后上传虚拟发货(非一番赏不发通知)
uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string {
act, _ := r.Activities.WithContext(ctx).Where(r.Activities.ID.Eq(aid)).First()
if act != nil {
return act.Name
}
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)
if len(instantActs) > 0 {
sel2 := strat.NewDefault(r, w)
@ -309,6 +363,7 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) {
r.Orders.SourceType.Eq(2),
r.Orders.Remark.Like(fmt.Sprintf("lottery:activity:%d|%%", ia.ID)),
).Find()
// Daily Seed removed
for _, o2 := range orders2 {
uid := o2.UserID
iss := extractIssueID(o2.Remark)
@ -401,7 +456,8 @@ func extractCount(remark string) int64 {
// uploadVirtualShippingForScheduledDraw 定时开奖后上传虚拟发货
// 收集中奖产品名称并调用微信虚拟发货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()
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 {
fmt.Printf("[定时开奖-虚拟发货] 上传失败: %v\n", err)
}
// 【定时开奖后推送通知】
notifyCfg := &notify.WechatNotifyConfig{
AppID: c.Wechat.AppID,
AppSecret: c.Wechat.AppSecret,
LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID,
// 【定时开奖后推送通知】只有一番赏才发送
if playType == "ichiban" {
notifyCfg := &notify.WechatNotifyConfig{
AppID: c.Wechat.AppID,
AppSecret: c.Wechat.AppSecret,
LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID,
}
_ = 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)
}
_ = notify.SendLotteryResultNotification(ctx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now())
}

View File

@ -43,15 +43,30 @@ func (s *defaultStrategy) SelectItem(ctx context.Context, activityID int64, issu
return 0, nil, errors.New("no weight")
}
// 使用 crypto/rand 生成加密安全的随机种子
seed := make([]byte, 32)
if _, err := rand.Read(seed); err != nil {
return 0, nil, errors.New("crypto rand failed")
// Determine seed key: use Activity Commitment
var seedKey []byte
// 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")
}
}
// 使用 HMAC-SHA256 生成加密安全的随机数
mac := hmac.New(sha256.New, seed)
mac.Write([]byte(fmt.Sprintf("draw:issue:%d|user:%d", issueID, userID)))
// To ensure uniqueness per draw when using a fixed CommitmentSeedMaster, mix in a random salt
salt := make([]byte, 16)
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)
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 {
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
}

View File

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

View File

@ -2,6 +2,8 @@ package strategy
import (
"context"
"crypto/sha256"
"fmt"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@ -85,3 +87,56 @@ func TestIchibanGrantReward_Decrement(t *testing.T) {
got, _ := q.ActivityRewardSettings.Where(q.ActivityRewardSettings.ID.Eq(r.ID)).First()
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
import (
"bindbox-game/internal/pkg/async"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
@ -14,6 +15,8 @@ import (
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@ -32,6 +35,7 @@ type Service interface {
ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error
OnOrderPaid(ctx context.Context, userID int64, orderID int64) error
OnInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error
StartWorker(ctx context.Context)
}
type service struct {
@ -39,18 +43,26 @@ type service struct {
readDB *dao.Query
writeDB *dao.Query
repo mysql.Repo
redis *redis.Client
queue async.TaskQueue
userSvc usersvc.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{
logger: l,
readDB: dao.Use(db.GetDbR()),
writeDB: dao.Use(db.GetDbW()),
repo: db,
userSvc: usersvc.New(l, db),
titleSvc: titlesvc.New(l, db),
redis: rdb,
queue: q,
userSvc: userSvc,
titleSvc: titleSvc,
}
}
@ -99,22 +111,24 @@ type ModifyTaskInput struct {
}
type TaskTierInput struct {
Metric string
Operator string
Threshold int64
Window string
Repeatable int32
Priority int32
Metric string
Operator string
Threshold int64
Window string
Repeatable int32
Priority int32
ExtraParams datatypes.JSON
}
type TaskTierItem struct {
ID int64 `json:"id"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold int64 `json:"threshold"`
Window string `json:"window"`
Repeatable int32 `json:"repeatable"`
Priority int32 `json:"priority"`
ID int64 `json:"id"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold int64 `json:"threshold"`
Window string `json:"window"`
Repeatable int32 `json:"repeatable"`
Priority int32 `json:"priority"`
ExtraParams datatypes.JSON `json:"extra_params"`
}
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 {
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
}
items = make([]TaskItem, len(rows))
out := make([]TaskItem, len(rows))
for i, v := range rows {
var st, et int64
if v.StartTime != nil {
@ -157,17 +175,19 @@ func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []Tas
if v.EndTime != nil {
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
if tiers, err := s.ListTaskTiers(ctx, v.ID); err == nil {
items[i].Tiers = tiers
out[i].Tiers = make([]TaskTierItem, len(v.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
if rewards, err := s.ListTaskRewards(ctx, v.ID); err == nil {
items[i].Rewards = rewards
out[i].Rewards = make([]TaskRewardItem, len(v.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) {
@ -211,17 +231,23 @@ func (s *service) CreateTask(ctx context.Context, in CreateTaskInput) (int64, er
if err := db.Create(row).Error; err != nil {
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 {
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 {
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) {
@ -232,7 +258,7 @@ func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierIt
}
out := make([]TaskTierItem, len(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
}
@ -243,12 +269,12 @@ func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []Tas
return err
}
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 {
return err
}
}
return nil
return s.invalidateCache(ctx)
}
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 nil
return s.invalidateCache(ctx)
}
func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64) error {
var tasks []tcmodel.Task
if err := s.repo.GetDbR().Where("status=1").Find(&tasks).Error; err != nil {
if s.queue != 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
}
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
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 {
@ -293,7 +374,7 @@ func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64)
}
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
}
p.OrderCount++
@ -303,21 +384,95 @@ func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64)
return tx.Save(&p).Error
})
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 {
return err
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
}
// 有效邀请通常不重置(除非是每日任务?一般是长期任务)
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
}
func (s *service) OnInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error {
var tasks []tcmodel.Task
if err := s.repo.GetDbR().Where("status=1").Find(&tasks).Error; err != nil {
if s.queue != 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
}
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
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 {
@ -327,7 +482,7 @@ func (s *service) OnInviteSuccess(ctx context.Context, inviterID int64, inviteeI
}
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
}
p.InviteCount++
@ -336,19 +491,31 @@ func (s *service) OnInviteSuccess(ctx context.Context, inviterID int64, inviteeI
if err != nil {
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 nil
}
func (s *service) checkAndResetDailyProgress(ctx context.Context, tx *gorm.DB, taskID int64, p *tcmodel.UserTaskProgress) error {
var count int64
if err := tx.Model(&tcmodel.TaskTier{}).Where("task_id = ? AND window = ?", taskID, "daily").Count(&count).Error; err != nil {
return err
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
if err := tx.Model(&tcmodel.TaskTier{}).Where("task_id = ? AND window = ?", t.ID, WindowDaily).Count(&count).Error; err != nil {
return err
}
isDaily = count > 0
}
if count == 0 {
if !isDaily {
return nil
}
now := time.Now()
@ -368,9 +535,11 @@ 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 {
var tiers []tcmodel.TaskTier
if err := s.repo.GetDbR().Where("task_id=?", t.ID).Order("priority asc").Find(&tiers).Error; err != nil {
return err
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 {
return err
}
}
var claimed []int64
if len(p.ClaimedTiers) > 0 {
@ -386,20 +555,26 @@ func (s *service) matchAndGrant(ctx context.Context, t *tcmodel.Task, p *tcmodel
}
hit := false
switch tier.Metric {
case "first_order":
case MetricFirstOrder:
hit = p.FirstOrder == 1
case "order_count":
if tier.Operator == ">=" {
case MetricOrderCount:
if tier.Operator == OperatorGTE {
hit = p.OrderCount >= tier.Threshold
} else {
hit = p.OrderCount == tier.Threshold
}
case "invite_count":
if tier.Operator == ">=" {
case MetricInviteCount:
if tier.Operator == OperatorGTE {
hit = p.InviteCount >= tier.Threshold
} else {
hit = p.InviteCount == tier.Threshold
}
case MetricEffectiveInviteCount:
if tier.Operator == OperatorGTE {
hit = p.EffectiveInviteCount >= tier.Threshold
} else {
hit = p.EffectiveInviteCount == tier.Threshold
}
}
if !hit {
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"`
StackingStrategy int32 `json:"stacking_strategy"`
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 获取用户道具卡列表
@ -115,6 +213,7 @@ func (s *service) ListUserItemCardsWithTemplate(ctx context.Context, userID int6
EffectType: effectType,
StackingStrategy: stacking,
Remark: remark,
Count: 1, // Individual record
}
}
return items, total, nil
@ -184,6 +283,7 @@ func (s *service) ListUserItemCardsWithTemplateUsable(ctx context.Context, userI
EffectType: effectType,
StackingStrategy: stacking,
Remark: remark,
Count: 1,
}
}
return items, total, nil
@ -254,6 +354,7 @@ func (s *service) ListUserItemCardsWithTemplateByStatus(ctx context.Context, use
EffectType: effectType,
StackingStrategy: stacking,
Remark: remark,
Count: 1, // Individual record
}
}
return items, total, nil

View File

@ -43,6 +43,21 @@ type OrderWithItems struct {
IsWinner bool `json:"is_winner"`
RewardLevel int32 `json:"reward_level"`
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) {
@ -123,6 +138,40 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i
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()
if len(logs) > 0 {
@ -179,7 +228,7 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i
ClientID: r.ClientID,
Timestamp: r.Timestamp,
ServerSeedHash: r.ServerSeedHash,
ServerSubSeed: r.ServerSubSeed,
ServerSubSeed: "",
ClientSeed: r.ClientSeed,
Nonce: r.Nonce,
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
}
@ -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))
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{
Orders: order,
Items: itemsMap[order.ID],
Orders: order,
Items: itemsMap[order.ID],
CouponInfo: cInfo,
ItemCardInfo: itemCardMap[order.ItemCardID],
}
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,
Timestamp: r.Timestamp,
ServerSeedHash: r.ServerSeedHash,
ServerSubSeed: r.ServerSubSeed,
ServerSubSeed: "",
ClientSeed: r.ClientSeed,
Nonce: r.Nonce,
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

View File

@ -1,23 +1,22 @@
package user
import (
"context"
"fmt"
"context"
"fmt"
)
func (s *service) CentsToPoints(ctx context.Context, cents int64) (int64, error) {
if cents <= 0 {
return 0, nil
}
cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First()
rate := int64(1)
if cfg != nil {
var r int64
_, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &r)
if r > 0 {
rate = r
}
}
return cents * rate, nil
if cents <= 0 {
return 0, nil
}
cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First()
rate := int64(1)
if cfg != nil {
var r int64
_, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &r)
if r > 0 {
rate = r
}
}
return cents * rate, nil
}

View File

@ -153,7 +153,12 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward
}
}(),
Status: 1, // 持有状态
Remark: product.Name,
Remark: func() string {
if req.Remark != "" {
return req.Remark
}
return product.Name
}(),
}
err = tx.UserInventory.WithContext(ctx).Create(inventory)
@ -337,7 +342,12 @@ func (s *service) GrantRewardToOrder(ctx context.Context, userID int64, req Gran
return 0
}(),
Status: 1, // 持有状态
Remark: product.Name,
Remark: func() string {
if req.Remark != "" {
return req.Remark
}
return product.Name
}(),
}
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)
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)
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)
GetUserStats(ctx context.Context, userID int64) (*UserStats, 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
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)
CancelShipping(ctx context.Context, userID int64, inventoryID int64) error
RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (int64, []int64, []struct {
ID int64
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/internal/pkg/env"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/pkg/redis"
"bindbox-game/internal/pkg/shutdown"
"bindbox-game/internal/pkg/timeutil"
"bindbox-game/internal/repository/mysql"
@ -130,8 +131,13 @@ func main() {
_ = 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 服务
mux, err := router.NewHTTPMux(customLogger, dbRepo)
mux, cleanup, err := router.NewHTTPMux(customLogger, dbRepo)
if err != nil {
panic(err)
}
@ -152,6 +158,11 @@ func main() {
// 优雅关闭
shutdown.Close(
func() {
// 清理资源 (Worker)
if cleanup != nil {
cleanup()
}
// 关闭 http server
if err := server.Shutdown(context.TODO()); err != nil {
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` 展示。