650 lines
24 KiB
Go
650 lines
24 KiB
Go
package app
|
||
|
||
import (
|
||
"bindbox-game/internal/code"
|
||
"bindbox-game/internal/pkg/core"
|
||
"bindbox-game/internal/pkg/validation"
|
||
"bindbox-game/internal/repository/mysql/dao"
|
||
"bindbox-game/internal/repository/mysql/model"
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math/rand"
|
||
"net/http"
|
||
"time"
|
||
|
||
titlesvc "bindbox-game/internal/service/title"
|
||
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
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"`
|
||
UseGamePass *bool `json:"use_game_pass"`
|
||
}
|
||
|
||
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"`
|
||
ActualAmount int64 `json:"actual_amount"`
|
||
Status int32 `json:"status"`
|
||
}
|
||
|
||
// 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)
|
||
h.logger.Info(fmt.Sprintf("JoinLottery Start: UserID=%d ActivityID=%d IssueID=%d", userID, req.ActivityID, req.IssueID))
|
||
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
|
||
}
|
||
// 定时一番赏:开奖前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 {
|
||
order.ItemCardID = *req.ItemCardID
|
||
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
|
||
applied := int64(0)
|
||
// Game Pass Conflict Check: If using Game Pass, do NOT allow coupons.
|
||
isUsingGamePass := req.UseGamePass != nil && *req.UseGamePass
|
||
if isUsingGamePass {
|
||
req.CouponID = nil
|
||
}
|
||
|
||
if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
|
||
order.CouponID = *req.CouponID
|
||
applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID)
|
||
}
|
||
// Title Discount Logic
|
||
// 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
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Check Game Pass (Pre-check)
|
||
// We will do the actual deduction inside transaction, but we can fail fast here or setup variables.
|
||
useGamePass := false
|
||
if req.UseGamePass != nil && *req.UseGamePass {
|
||
// Check if user has enough valid passes
|
||
// Note: We need to find specific passes to deduct.
|
||
// Logic: Find all valid passes, sort by activity specific first, then expire soonest?
|
||
// Matching game logic: "ActivityID Desc" (Specific first)
|
||
count := int(c)
|
||
validPasses, err := h.writeDB.UserGamePasses.WithContext(ctx.RequestContext()).
|
||
Where(h.writeDB.UserGamePasses.UserID.Eq(userID)).
|
||
Where(h.writeDB.UserGamePasses.Remaining.Gt(0)).
|
||
Where(h.writeDB.UserGamePasses.ActivityID.In(0, req.ActivityID)).
|
||
Order(h.writeDB.UserGamePasses.ActivityID.Desc(), h.writeDB.UserGamePasses.ExpiredAt.Asc()). // 优先专用,然后优先过期
|
||
Find()
|
||
|
||
if err != nil {
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
|
||
return
|
||
}
|
||
|
||
totalAvailable := 0
|
||
now := time.Now()
|
||
for _, p := range validPasses {
|
||
if p.ExpiredAt.IsZero() || p.ExpiredAt.After(now) {
|
||
totalAvailable += int(p.Remaining)
|
||
}
|
||
}
|
||
|
||
if totalAvailable < count {
|
||
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170012, "次数卡余额不足"))
|
||
return
|
||
}
|
||
useGamePass = true
|
||
}
|
||
|
||
h.logger.Info(fmt.Sprintf("JoinLottery Tx Start: UserID=%d", userID))
|
||
err = h.writeDB.Transaction(func(tx *dao.Query) error {
|
||
// Handle Game Pass Deduction
|
||
if useGamePass {
|
||
count := int(c)
|
||
validPasses, _ := tx.UserGamePasses.WithContext(ctx.RequestContext()).
|
||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where(tx.UserGamePasses.UserID.Eq(userID)).
|
||
Where(tx.UserGamePasses.Remaining.Gt(0)).
|
||
Where(tx.UserGamePasses.ActivityID.In(0, req.ActivityID)).
|
||
Order(tx.UserGamePasses.ActivityID.Desc(), tx.UserGamePasses.ExpiredAt.Asc()).
|
||
Find()
|
||
|
||
now := time.Now()
|
||
deducted := 0
|
||
for _, p := range validPasses {
|
||
if deducted >= count {
|
||
break
|
||
}
|
||
if !p.ExpiredAt.IsZero() && p.ExpiredAt.Before(now) {
|
||
continue
|
||
}
|
||
|
||
canDeduct := int(p.Remaining)
|
||
if canDeduct > (count - deducted) {
|
||
canDeduct = count - deducted
|
||
}
|
||
|
||
// Update pass
|
||
if _, err := tx.UserGamePasses.WithContext(ctx.RequestContext()).
|
||
Where(tx.UserGamePasses.ID.Eq(p.ID)).
|
||
Updates(map[string]any{
|
||
"remaining": p.Remaining - int32(canDeduct),
|
||
"total_used": p.TotalUsed + int32(canDeduct),
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
deducted += canDeduct
|
||
}
|
||
|
||
if deducted < count {
|
||
return errors.New("次数卡余额不足")
|
||
}
|
||
|
||
// Set Order to be fully paid by Game Pass
|
||
order.ActualAmount = 0
|
||
order.SourceType = 4 // Cleanly mark as Game Pass source
|
||
|
||
// existing lottery logic sets SourceType based on "h.orderModel" which defaults to something?
|
||
// h.orderModel(..., c) implementation needs to be checked or inferred.
|
||
// Assuming orderModel sets SourceType based on activity or defaults.
|
||
// Let's explicitly mark it or rely on Remark.
|
||
if order.Remark == "" {
|
||
order.Remark = "use_game_pass"
|
||
} else {
|
||
order.Remark += "|use_game_pass"
|
||
}
|
||
// Note: If we change SourceType to 4, ProcessOrderLottery might skip it if checks SourceType.
|
||
// Lottery app usually expects SourceType=2 or similar.
|
||
// Let's KEEP SourceType as is (likely 2 for ichiban), but Amount=0 ensures it's treated as Paid.
|
||
}
|
||
|
||
if !useGamePass && 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
|
||
if needPts > usePts {
|
||
needPts = usePts
|
||
}
|
||
// Inline ConsumePointsFor logic using tx
|
||
// Lock rows
|
||
rows, errFind := tx.UserPoints.WithContext(ctx.RequestContext()).Clauses(clause.Locking{Strength: "UPDATE"}).Where(tx.UserPoints.UserID.Eq(userID)).Order(tx.UserPoints.ValidEnd.Asc()).Find()
|
||
if errFind != nil {
|
||
return errFind
|
||
}
|
||
remain := needPts
|
||
now := time.Now()
|
||
var fullConsumeIDs []int64
|
||
var lastConsumeID int64
|
||
var lastConsumePoints int64
|
||
|
||
for _, r := range rows {
|
||
if !r.ValidEnd.IsZero() && r.ValidEnd.Before(now) {
|
||
continue
|
||
}
|
||
if remain <= 0 {
|
||
break
|
||
}
|
||
use := r.Points
|
||
if use > remain {
|
||
use = remain
|
||
lastConsumeID = r.ID
|
||
lastConsumePoints = r.Points - use
|
||
} else {
|
||
fullConsumeIDs = append(fullConsumeIDs, r.ID)
|
||
}
|
||
remain -= use
|
||
}
|
||
|
||
// 批量更新完全扣除的记录
|
||
if len(fullConsumeIDs) > 0 {
|
||
if _, errUpd := tx.UserPoints.WithContext(ctx.RequestContext()).Where(tx.UserPoints.ID.In(fullConsumeIDs...)).Updates(map[string]any{"points": 0}); errUpd != nil {
|
||
return errUpd
|
||
}
|
||
}
|
||
// 更新最后一条记录(如果有部分剩余)
|
||
if lastConsumeID > 0 {
|
||
if _, errUpd := tx.UserPoints.WithContext(ctx.RequestContext()).Where(tx.UserPoints.ID.Eq(lastConsumeID)).Updates(map[string]any{"points": lastConsumePoints}); errUpd != nil {
|
||
return errUpd
|
||
}
|
||
}
|
||
if remain > 0 {
|
||
return errors.New("insufficient_points")
|
||
}
|
||
// Record Ledger
|
||
led := &model.UserPointsLedger{UserID: userID, Action: "consume_order", Points: -needPts, RefTable: "orders", RefID: orderNo, Remark: "consume by lottery"}
|
||
if errCreate := tx.UserPointsLedger.WithContext(ctx.RequestContext()).Create(led); errCreate != nil {
|
||
return errCreate
|
||
}
|
||
|
||
order.PointsAmount = deductCents
|
||
order.PointsLedgerID = led.ID
|
||
order.ActualAmount = order.ActualAmount - deductCents
|
||
}
|
||
}
|
||
|
||
// Check if fully paid (by discount, game pass, or points)
|
||
if order.ActualAmount <= 0 {
|
||
order.Status = 2 // Paid
|
||
order.PaidAt = time.Now()
|
||
}
|
||
|
||
err = tx.Orders.WithContext(ctx.RequestContext()).Omit(tx.Orders.PaidAt, tx.Orders.CancelledAt).Create(order)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 一番赏占位 (针对内抵扣/次数卡导致的 0 元支付成功的订单补偿占位逻辑)
|
||
if order.Status == 2 && activity.PlayType == "ichiban" {
|
||
for _, si := range req.SlotIndex {
|
||
slotIdx0 := si - 1 // 转换为 0-based 索引
|
||
// 再次检查占用情况 (事务内原子防并发)
|
||
cnt, _ := tx.IssuePositionClaims.WithContext(ctx.RequestContext()).Where(tx.IssuePositionClaims.IssueID.Eq(req.IssueID), tx.IssuePositionClaims.SlotIndex.Eq(slotIdx0)).Count()
|
||
if cnt > 0 {
|
||
return errors.New("slot_unavailable")
|
||
}
|
||
if err := tx.IssuePositionClaims.WithContext(ctx.RequestContext()).Create(&model.IssuePositionClaims{
|
||
IssueID: req.IssueID,
|
||
SlotIndex: slotIdx0,
|
||
UserID: userID,
|
||
OrderID: order.ID,
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
// Inline RecordOrderCouponUsage (no logging)
|
||
if applied > 0 && req.CouponID != nil && *req.CouponID > 0 {
|
||
_ = tx.Orders.UnderlyingDB().Exec("INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", order.ID, *req.CouponID, applied).Error
|
||
}
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
h.logger.Error(fmt.Sprintf("JoinLottery Tx Failed: %v", err))
|
||
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
|
||
return
|
||
}
|
||
|
||
rsp.JoinID = joinID
|
||
rsp.OrderNo = orderNo
|
||
rsp.ActualAmount = order.ActualAmount
|
||
rsp.Status = order.Status
|
||
|
||
// Immediate Draw Trigger if Paid (e.g. Game Pass or Free)
|
||
if order.Status == 2 && activity.DrawMode == "instant" {
|
||
go func() {
|
||
_ = h.activity.ProcessOrderLottery(context.Background(), order.ID)
|
||
}()
|
||
}
|
||
// Immediate Draw Trigger if Paid (e.g. Game Pass or Free)
|
||
if order.Status == 2 && activity.DrawMode == "instant" {
|
||
// Trigger process asynchronously or synchronously?
|
||
// Usually WechatNotify triggers it. Since we bypass WechatNotify, we must trigger it.
|
||
go func() {
|
||
_ = h.activity.ProcessOrderLottery(context.Background(), order.ID)
|
||
}()
|
||
}
|
||
rsp.DrawMode = 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元订单:统一由 Service 处理优惠券扣减与流水记录
|
||
_ = h.user.DeductCouponsForPaidOrder(ctx.RequestContext(), nil, userID, order.ID, now)
|
||
|
||
// 异步触发任务中心逻辑
|
||
go func() {
|
||
_ = h.task.OnOrderPaid(context.Background(), userID, order.ID)
|
||
}()
|
||
|
||
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"`
|
||
Results []map[string]any `json:"results"`
|
||
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), h.readDB.Orders.UserID.Eq(int64(ctx.SessionUserInfo().Id))).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
|
||
}
|
||
// 即时开奖/结果补齐:仅在订单已支付的情况下执行
|
||
if ord != nil && ord.Status == 2 && cfgMode == "instant" {
|
||
_ = h.activity.ProcessOrderLottery(ctx.RequestContext(), ord.ID)
|
||
}
|
||
|
||
// 获取最终的开奖记录
|
||
var logs []*model.ActivityDrawLogs
|
||
if orderID > 0 {
|
||
logs, _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Order(h.readDB.ActivityDrawLogs.DrawIndex).Find()
|
||
} else {
|
||
logs, _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityDrawLogs.UserID.Eq(userID), h.readDB.ActivityDrawLogs.IssueID.Eq(issueID)).Order(h.readDB.ActivityDrawLogs.DrawIndex).Find()
|
||
}
|
||
|
||
if len(logs) > 0 {
|
||
// 设置第一个为主结果 (兼容旧版单个结果显示)
|
||
lg0 := logs[0]
|
||
rw0, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(lg0.RewardID)).First()
|
||
// 获取第一个奖励的商品名称
|
||
firstProdName := ""
|
||
if rw0 != nil && rw0.ProductID > 0 {
|
||
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(rw0.ProductID)).First(); p != nil {
|
||
firstProdName = p.Name
|
||
}
|
||
}
|
||
rsp.Result = map[string]any{
|
||
"reward_id": lg0.RewardID,
|
||
"reward_name": firstProdName,
|
||
}
|
||
|
||
// 填充所有结果
|
||
rewardCache := make(map[int64]*model.ActivityRewardSettings)
|
||
productCache := make(map[int64]*model.Products)
|
||
|
||
for _, lg := range logs {
|
||
rw, ok := rewardCache[lg.RewardID]
|
||
if !ok {
|
||
rw, _ = h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(lg.RewardID)).First()
|
||
rewardCache[lg.RewardID] = rw
|
||
}
|
||
|
||
var img string
|
||
var prodName string
|
||
if rw != nil && rw.ProductID > 0 {
|
||
prod, ok := productCache[rw.ProductID]
|
||
if !ok {
|
||
prod, _ = h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(rw.ProductID)).First()
|
||
productCache[rw.ProductID] = prod
|
||
}
|
||
if prod != nil {
|
||
prodName = prod.Name
|
||
if prod.ImagesJSON != "" {
|
||
var imgs []string
|
||
if json.Unmarshal([]byte(prod.ImagesJSON), &imgs) == nil && len(imgs) > 0 {
|
||
img = imgs[0]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
rsp.Results = append(rsp.Results, map[string]any{
|
||
"reward_id": lg.RewardID,
|
||
"reward_name": prodName,
|
||
"image": img,
|
||
"draw_index": lg.DrawIndex + 1,
|
||
})
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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, "参数错误")
|
||
}
|
||
|
||
// 1. 内存中去重和范围检查
|
||
selectedSlots := make([]int64, 0, len(req.SlotIndex))
|
||
seen := make(map[int64]struct{}, len(req.SlotIndex))
|
||
for _, si := range req.SlotIndex {
|
||
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")
|
||
}
|
||
selectedSlots = append(selectedSlots, si-1)
|
||
}
|
||
|
||
// 2. 批量查询数据库检查格位是否已被占用
|
||
var occupiedCount int64
|
||
_ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id=? AND slot_index IN ?", req.IssueID, selectedSlots).Scan(&occupiedCount).Error
|
||
if occupiedCount > 0 {
|
||
// 如果有占用,为了告知具体是哪个位置,可以打个 log 或者简单的直接返回错误
|
||
return core.Error(http.StatusBadRequest, 170007, "部分位置已被占用,请刷新重试")
|
||
}
|
||
}
|
||
return nil
|
||
}
|