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 } // 定时一番赏:开奖前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) 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) } } } } } 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 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}) } } } } } } } 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 } // Daily Seed logic removed to ensure strict adherence to CommitmentSeedMaster 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) if icID > 0 { 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 { 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 { 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) 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) if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: orderID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name + "(倍数)"}) } else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { 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 _, _ = 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 { } else { if err := strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog2.ID, issueID, userID, proof); err != nil { } else { } } } else { } } else { } _, _ = 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 { } } else { } } else { } } else { } } else { } 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) } } // 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 }