2026-01-27 01:33:32 +08:00

668 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
}
// DEBUG LOG: Print request params to diagnose frontend issue
reqBytes, _ := json.Marshal(req)
h.logger.Info(fmt.Sprintf("JoinLottery Request Params: UserID=%d Payload=%s", ctx.SessionUserInfo().Id, string(reqBytes)))
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 {
applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID)
if applied > 0 {
order.CouponID = *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
// Record usage for remark (Format: gp_use:ID:Count)
if order.Remark == "" {
order.Remark = fmt.Sprintf("gp_use:%d:%d", p.ID, canDeduct)
} else {
order.Remark += fmt.Sprintf("|gp_use:%d:%d", p.ID, 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
// Legacy marker for backward compatibility or simple check
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)
// req.UsePoints 是前端传入的积分数,需要转换为分
usePtsCents, _ := h.user.PointsToCents(ctx.RequestContext(), *req.UsePoints)
// bal 已经是分单位
if bal > 0 && usePtsCents > bal {
usePtsCents = bal
}
// deductCents 是要从订单金额中抵扣的分数
deductCents := usePtsCents
if deductCents > order.ActualAmount {
deductCents = order.ActualAmount
}
if deductCents > 0 {
// needPts 是实际需要扣除的分数
needPts := deductCents
// 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
}
}
}
// 优惠券预扣:在事务中原子性扣减余额
// 如果余额不足(被其他并发订单消耗),事务回滚
if applied > 0 && order.CouponID > 0 {
// 原子更新优惠券余额和状态
now := time.Now()
res := tx.Orders.UnderlyingDB().Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount - ?,
status = CASE WHEN balance_amount - ? <= 0 THEN 4 ELSE 4 END,
used_order_id = ?,
used_at = ?
WHERE id = ? AND user_id = ? AND balance_amount >= ? AND status IN (1, 4)
`, applied, applied, order.ID, now, order.CouponID, userID, applied)
if res.Error != nil {
return fmt.Errorf("优惠券预扣失败: %w", res.Error)
}
if res.RowsAffected == 0 {
// 余额不足或状态不对,事务回滚
return errors.New("优惠券余额不足或已被使用")
}
// 记录使用关系
_ = tx.Orders.UnderlyingDB().Exec("INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", order.ID, order.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
// 即时开奖触发(已支付 + 即时开奖模式)
if shouldTriggerInstantDraw(order.Status, activity.DrawMode) {
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")
}
// 1. 强制校验:必须选择位置
if len(req.SlotIndex) == 0 {
return core.Error(http.StatusBadRequest, code.ParamBindError, "一番赏必须选择位置")
}
if req.Count <= 0 || req.Count != int64(len(req.SlotIndex)) {
return core.Error(http.StatusBadRequest, code.ParamBindError, "参数错误:数量与位置不匹配")
}
// 2. 内存中去重和范围检查
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)
}
// 3. 批量查询数据库检查格位是否已被占用
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 {
// 即使是并发场景,这里做一个 Pre-check 也能拦截大部分冲突
return core.Error(http.StatusBadRequest, 170007, "部分位置已被占用,请刷新重试")
}
return nil
}