650 lines
24 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/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请求上下文
// - 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, "参数错误")
}
// 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
}