邹方成 e2782a69d3 feat: 添加对对碰游戏功能与Redis支持
refactor: 重构抽奖逻辑以支持可验证凭据
feat(redis): 集成Redis客户端并添加配置支持
fix: 修复订单取消时的优惠券和库存处理逻辑
docs: 添加对对碰游戏前端对接指南和示例JSON
test: 添加对对碰游戏模拟测试和验证逻辑
2025-12-21 17:31:32 +08:00

1301 lines
59 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package app
import (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
strat "bindbox-game/internal/service/activity/strategy"
usersvc "bindbox-game/internal/service/user"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"time"
titlesvc "bindbox-game/internal/service/title"
)
type joinLotteryRequest struct {
ActivityID int64 `json:"activity_id"`
IssueID int64 `json:"issue_id"`
Count int64 `json:"count"`
Channel string `json:"channel"`
SlotIndex []int64 `json:"slot_index"`
CouponID *int64 `json:"coupon_id"`
ItemCardID *int64 `json:"item_card_id"`
UsePoints *int64 `json:"use_points"`
}
type joinLotteryResponse struct {
JoinID string `json:"join_id"`
OrderNo string `json:"order_no"`
Queued bool `json:"queued"`
DrawMode string `json:"draw_mode"`
RewardID int64 `json:"reward_id,omitempty"`
RewardName string `json:"reward_name,omitempty"`
}
// JoinLottery 用户参与抽奖
// @Summary 用户参与抽奖
// @Description 提交活动ID与期ID创建参与记录与订单支持积分抵扣返回参与ID、订单号、抽奖模式及是否进入队列
// @Tags APP端.抽奖
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body joinLotteryRequest true "请求参数"
// @Success 200 {object} joinLotteryResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/lottery/join [post]
func (h *handler) JoinLottery() core.HandlerFunc {
return func(ctx core.Context) {
req := new(joinLotteryRequest)
rsp := new(joinLotteryResponse)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID := int64(ctx.SessionUserInfo().Id)
activity, err := h.activity.GetActivity(ctx.RequestContext(), req.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "activity not found"))
return
}
if !activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170009, "本活动不支持优惠券"))
return
}
if !activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170010, "本活动不支持道具卡"))
return
}
// Ichiban Restriction: No Item Cards allowed (even if Activity logic might technically allow it, enforce strict rule here)
if activity.PlayType == "ichiban" && req.ItemCardID != nil && *req.ItemCardID > 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170010, "一番赏活动不支持道具卡"))
return
}
cfgMode := "scheduled"
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()
cutoff := activity.ScheduledTime.Add(-20 * time.Second)
if now.After(cutoff) && now.Before(activity.ScheduledTime.Add(30*time.Second)) {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "距离开奖时间不足20秒暂停下单"))
return
}
}
if activity.PlayType == "ichiban" {
if e := h.validateIchibanSlots(ctx, req); e != nil {
ctx.AbortWithError(e)
return
}
}
joinID := h.randomID("J")
orderNo := h.randomID("O")
c := req.Count
if c <= 0 {
c = 1
}
total := activity.PriceDraw * c
order := h.orderModel(userID, orderNo, total, req.ActivityID, req.IssueID, c)
if len(req.SlotIndex) > 0 {
order.Remark = fmt.Sprintf("lottery:activity:%d|issue:%d|count:%d|slots:%s", req.ActivityID, req.IssueID, c, buildSlotsRemarkWithScalarCount(req.SlotIndex))
}
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
if order.Remark == "" {
order.Remark = fmt.Sprintf("lottery:activity:%d|issue:%d|count:%d", req.ActivityID, req.IssueID, c)
}
order.Remark = order.Remark + fmt.Sprintf("|itemcard:%d", *req.ItemCardID)
}
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
// Note: Category ID is not readily available on Activity struct in this scope easily without join, skipping detailed scope for now or fetch if needed.
// Assuming scope by ActivityID and IssueID is enough.
titleEffects, _ := h.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{
ActivityID: &req.ActivityID,
IssueID: &req.IssueID,
})
// 2. Apply Type=2 (Discount) effects
for _, ef := range titleEffects {
if ef.EffectType == 2 {
// Parse ParamsJSON: {"discount_type":"percentage","value_x1000":...,"max_discount_x1000":...}
// Simple parsing here or helper
var p struct {
DiscountType string `json:"discount_type"`
ValueX1000 int64 `json:"value_x1000"`
MaxDiscountX1000 int64 `json:"max_discount_x1000"`
}
if jsonErr := json.Unmarshal([]byte(ef.ParamsJSON), &p); jsonErr == nil {
var discount int64
if p.DiscountType == "percentage" {
// e.g. 900 = 90% (10% off), or value_x1000 is the discount rate?
// Usually "value" is what you pay? Title "8折" -> value=800?
// Let's assume value_x1000 is the DISCOUNT amount (e.g. 200 = 20% off).
// Wait, standard is usually "multiplier". Title "Discount" usually means "Cut".
// Let's look at `ValidateEffectParams`: "percentage" or "fixed".
// Assume ValueX1000 is discount ratio. 200 = 20% off.
discount = order.ActualAmount * p.ValueX1000 / 1000
} else if p.DiscountType == "fixed" {
discount = p.ValueX1000 // In cents
}
if p.MaxDiscountX1000 > 0 && discount > p.MaxDiscountX1000 {
discount = p.MaxDiscountX1000
}
if discount > order.ActualAmount {
discount = order.ActualAmount
}
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)
} else {
order.Remark += fmt.Sprintf("|title_discount:%d:%d", ef.ID, discount)
}
}
}
}
}
if req.UsePoints != nil && *req.UsePoints > 0 {
bal, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID)
usePts := *req.UsePoints
if bal > 0 && usePts > bal {
usePts = bal
}
ratePtsPerCent, _ := h.user.CentsToPoints(ctx.RequestContext(), 1)
if ratePtsPerCent <= 0 {
ratePtsPerCent = 1
}
deductCents := usePts / ratePtsPerCent
if deductCents > order.ActualAmount {
deductCents = order.ActualAmount
}
if deductCents > 0 {
needPts := deductCents * ratePtsPerCent
ledgerID, errConsume := h.user.ConsumePointsFor(ctx.RequestContext(), userID, needPts, "orders", orderNo, "order points consume", "consume_order")
if errConsume != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170020, errConsume.Error()))
return
}
order.PointsAmount = deductCents
order.PointsLedgerID = ledgerID
order.ActualAmount = order.ActualAmount - deductCents
}
}
err = h.writeDB.Orders.WithContext(ctx.RequestContext()).Omit(h.writeDB.Orders.PaidAt, h.writeDB.Orders.CancelledAt).Create(order)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
return
}
// 写结构化优惠券使用明细(兼容保留 remark
if applied > 0 && req.CouponID != nil && *req.CouponID > 0 {
_ = h.user.RecordOrderCouponUsage(ctx.RequestContext(), order.ID, *req.CouponID, applied)
}
// 优惠券扣减与核销在支付回调中执行(避免未支付时扣减)
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})
// 0元订单统一在“已支付”后扣券解析 remark 中优惠券使用片段并扣减或核销)
{
ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.OrderNo.Eq(orderNo)).First()
if ord != nil {
parts := func(s string) []string {
p := make([]string, 0, 8)
b := 0
for i := 0; i < len(s); i++ {
if s[i] == '|' {
if i > b {
p = append(p, s[b:i])
}
b = i + 1
}
}
if b < len(s) {
p = append(p, s[b:])
}
return p
}(ord.Remark)
for _, seg := range parts {
if len(seg) > 2 && seg[:2] == "c:" {
// 解析 id 与 amount
j := 2
var cid int64
for j < len(seg) && seg[j] >= '0' && seg[j] <= '9' {
cid = cid*10 + int64(seg[j]-'0')
j++
}
var applied int64
if j < len(seg) && seg[j] == ':' {
j++
for j < len(seg) && seg[j] >= '0' && seg[j] <= '9' {
applied = applied*10 + int64(seg[j]-'0')
j++
}
}
uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(cid), h.readDB.UserCoupons.UserID.Eq(userID)).First()
if uc != nil {
if uc.Status == 2 && uc.UsedOrderID == ord.ID {
continue
}
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 {
if sc.DiscountType == 1 {
var bal int64
_ = h.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", uc.ID).Scan(&bal).Error
nb := bal - applied
if nb < 0 {
nb = 0
}
if nb == 0 {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(uc.ID)).Updates(map[string]any{"balance_amount": nb, h.readDB.UserCoupons.Status.ColumnName().String(): 2, h.readDB.UserCoupons.UsedOrderID.ColumnName().String(): ord.ID, h.readDB.UserCoupons.UsedAt.ColumnName().String(): now})
} else {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(uc.ID)).Updates(map[string]any{"balance_amount": nb, h.readDB.UserCoupons.UsedOrderID.ColumnName().String(): ord.ID, h.readDB.UserCoupons.UsedAt.ColumnName().String(): now})
}
} else {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(uc.ID)).Updates(map[string]any{h.readDB.UserCoupons.Status.ColumnName().String(): 2, h.readDB.UserCoupons.UsedOrderID.ColumnName().String(): ord.ID, h.readDB.UserCoupons.UsedAt.ColumnName().String(): now})
}
}
}
}
}
}
}
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
}
ctx.Payload(rsp)
}
}
type resultQueryRequest struct {
JoinID string `form:"join_id"`
OrderID int64 `form:"order_id"`
}
type resultResponse struct {
Result map[string]any `json:"result"`
Receipt map[string]any `json:"receipt"`
}
// GetLotteryResult 抽奖结果查询
// @Summary 抽奖结果查询
// @Description 根据参与ID与期ID查询抽奖结果即时模式会立即开奖并发奖同时返回服务端签名凭证用于校验
// @Tags APP端.抽奖
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param join_id query string true "参与ID"
// @Param order_id query int false "订单ID用于区分同一用户多次参与"
// @Param issue_id query int false "期ID"
// @Success 200 {object} resultResponse
// @Failure 400 {object} code.Failure
func (h *handler) GetLotteryResult() core.HandlerFunc {
return func(ctx core.Context) {
req := new(resultQueryRequest)
rsp := new(resultResponse)
if err := ctx.ShouldBindQuery(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
issueIDStr := ctx.RequestInputParams().Get("issue_id")
var issueID int64
if issueIDStr != "" {
for i := 0; i < len(issueIDStr); i++ {
c := issueIDStr[i]
if c < '0' || c > '9' {
issueID = 0
break
}
issueID = issueID*10 + int64(c-'0')
}
}
var orderID int64
var ord *model.Orders
if req.OrderID > 0 {
orderID = req.OrderID
ord, _ = h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(orderID)).First()
}
userID := int64(ctx.SessionUserInfo().Id)
issue, _ := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityIssues.ID.Eq(issueID)).First()
activityID := func() int64 {
if issue != nil {
return issue.ActivityID
}
return 0
}()
actCommit, err := func() (*model.Activities, error) {
if activityID <= 0 {
return nil, nil
}
return h.readDB.Activities.WithContext(ctx.RequestContext()).Where(h.readDB.Activities.ID.Eq(activityID)).First()
}()
if err != nil || actCommit == nil || len(actCommit.CommitmentSeedMaster) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170003, "commitment not found"))
return
}
cfgMode := "scheduled"
if act, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).Where(h.readDB.Activities.ID.Eq(activityID)).First(); act != nil && act.DrawMode != "" {
cfgMode = act.DrawMode
}
// 结果优先返回历史抽奖日志
var existed *model.ActivityDrawLogs
if orderID > 0 {
existed, _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).First()
} else {
existed, _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityDrawLogs.UserID.Eq(userID), h.readDB.ActivityDrawLogs.IssueID.Eq(issueID)).First()
}
if existed != nil && existed.RewardID > 0 {
rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(existed.RewardID)).First()
rsp.Result = map[string]any{"reward_id": existed.RewardID, "reward_name": func() string {
if rw != nil {
return rw.Name
}
return ""
}()}
} else if cfgMode == "instant" {
// 即时开奖必须绑定订单且校验归属与支付状态
if orderID == 0 || ord == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170005, "order required for instant draw"))
return
}
if ord.UserID != userID {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170005, "order not owned by user"))
return
}
if ord.Status != 2 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170006, "order not paid"))
return
}
if actCommit.PlayType == "ichiban" {
slot := parseSlotFromRemark(ord.Remark)
if slot >= 0 {
if err := 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, orderID).Error; err == nil {
var proof map[string]any
rid, proof, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), activityID, 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: issueID, OrderID: orderID, 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)
rsp.Result = map[string]any{"reward_id": rid, "reward_name": rw.Name}
}
}
}
}
} else {
sel := strat.NewDefault(h.readDB, h.writeDB)
var proof map[string]any
rid, proof, e2 := sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID)
if e2 == nil && rid > 0 {
rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First()
_ = sel.GrantReward(ctx.RequestContext(), userID, rid)
log := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: orderID, RewardID: rid, IsWinner: 1, Level: func() int32 {
if rw != nil {
return rw.Level
}
return 1
}(), CurrentLevel: 1}
_ = 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,
UserID: userID,
UserItemCardID: uic.ID,
SystemItemCardID: ic.ID,
Applied: 1,
CardType: ic.CardType,
EffectType: ic.EffectType,
RewardMultiplierX1000: ic.RewardMultiplierX1000,
ProbabilityDeltaX1000: ic.BoostRateX1000,
ScopeType: ic.ScopeType,
ActivityCategoryID: actCommit.ActivityCategoryID,
ActivityID: activityID,
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 {
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("[道具卡-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 {
return rw.Name
}
return ""
}()}
}
}
}
ts := time.Now().UnixMilli()
nonce := rand.Int63()
mac := hmac.New(sha256.New, actCommit.CommitmentSeedMaster)
mac.Write([]byte(h.joinSigPayload(userID, issueID, ts, nonce)))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if rsp.Result == nil {
rsp.Result = map[string]any{}
}
rsp.Receipt = map[string]any{
"issue_id": issueID,
"seed_version": actCommit.CommitmentStateVersion,
"timestamp": ts,
"nonce": nonce,
"signature": sig,
"algorithm": "HMAC-SHA256",
"inputs": map[string]any{"user_id": userID, "issue_id": issueID, "order_id": orderID, "timestamp": ts, "nonce": nonce},
}
ctx.Payload(rsp)
}
}
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 一番赏格位校验
// 功能:校验请求中的格位选择是否有效(数量匹配、范围合法、未被占用)
// 参数:
// - ctx请求上下文
// - reqJoinLottery 请求体,使用其中的 issue_id、count、slot_index
//
// 返回core.BusinessError 用于直接传递给 AbortWithError合法时返回 nil
func (h *handler) validateIchibanSlots(ctx core.Context, req *joinLotteryRequest) core.BusinessError {
var totalSlots int64
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(original_qty),0) FROM activity_reward_settings WHERE issue_id=?", req.IssueID).Scan(&totalSlots).Error
if totalSlots <= 0 {
return core.Error(http.StatusBadRequest, 170008, "no slots")
}
if len(req.SlotIndex) > 0 {
if req.Count <= 0 || req.Count != int64(len(req.SlotIndex)) {
return core.Error(http.StatusBadRequest, code.ParamBindError, "参数错误")
}
seen := make(map[int64]struct{})
for i := range req.SlotIndex {
si := req.SlotIndex[i]
if _, ok := seen[si]; ok {
return core.Error(http.StatusBadRequest, 170011, "duplicate slots not allowed")
}
seen[si] = struct{}{}
if si < 1 || si > totalSlots {
return core.Error(http.StatusBadRequest, 170008, "slot out of range")
}
var cnt int64
_ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id=? AND slot_index=?", req.IssueID, si-1).Scan(&cnt).Error
if cnt > 0 {
return core.Error(http.StatusBadRequest, 170007, "位置已被占用")
}
}
}
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")
}
}
}
}