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 }