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:请求上下文 // - req:JoinLottery 请求体,使用其中的 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") } } } }