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" "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"` } 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) 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) 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) } } } } } h.logger.Info(fmt.Sprintf("JoinLottery Tx Start: UserID=%d", userID)) err = h.writeDB.Transaction(func(tx *dao.Query) error { 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 // 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() 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 } _, errUpd := tx.UserPoints.WithContext(ctx.RequestContext()).Where(tx.UserPoints.ID.Eq(r.ID)).Updates(map[string]any{"points": r.Points - use}) if errUpd != nil { return errUpd } remain -= use } 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 } } err = tx.Orders.WithContext(ctx.RequestContext()).Omit(tx.Orders.PaidAt, tx.Orders.CancelledAt).Create(order) if 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.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"` 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)).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() rsp.Result = map[string]any{ "reward_id": lg0.RewardID, "reward_name": func() string { if rw0 != nil { return rw0.Name } return "" }(), } // 填充所有结果 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 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 && 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": func() string { if rw != nil { return rw.Name } return "" }(), "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, "参数错误") } 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 }