邹方成 a7a0f639e1 feat: 新增取消发货功能并优化任务中心
fix: 修复微信通知字段截断导致的编码错误
feat: 添加有效邀请相关字段和任务中心常量
refactor: 重构一番赏奖品格位逻辑
perf: 优化道具卡列表聚合显示
docs: 更新项目说明文档和API文档
test: 添加字符串截断工具测试
2025-12-23 22:26:07 +08:00

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