package admin import ( "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" paypkg "bindbox-game/internal/pkg/pay" "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/binary" "encoding/json" "fmt" "net/http" "time" ) type participantsResponse struct { IssueID int64 `json:"issue_id"` ActivityID int64 `json:"activity_id"` DrawMode string `json:"draw_mode"` ScheduledTime string `json:"scheduled_time"` MinParticipants int64 `json:"min_participants"` Participants int64 `json:"participants"` Reached bool `json:"reached"` } func (h *handler) GetIssueParticipants() core.HandlerFunc { return func(ctx core.Context) { if ctx.SessionUserInfo().IsSuper != 1 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "禁止操作")) return } issueIDStr := ctx.Param("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') } } iss, err := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityIssues.ID.Eq(issueID)).First() if err != nil || iss == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170101, "issue not found")) return } drawMode := "scheduled" minN := int64(0) scheduled := "" var st *time.Time var last *time.Time if act, e := h.readDB.Activities.WithContext(ctx.RequestContext()).Where(h.readDB.Activities.ID.Eq(iss.ActivityID)).First(); e == nil && act != nil { if act.DrawMode != "" { drawMode = act.DrawMode } minN = act.MinParticipants if !act.ScheduledTime.IsZero() { t := act.ScheduledTime st = &t scheduled = t.Format(time.RFC3339) } if !act.LastSettledAt.IsZero() { t := act.LastSettledAt last = &t } } remarkLike := fmt.Sprintf("lottery:activity:%d|issue:%d", iss.ActivityID, iss.ID) q := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().Where( h.readDB.Orders.Status.Eq(2), h.readDB.Orders.SourceType.Eq(2), h.readDB.Orders.Remark.Like("%"+remarkLike+"%"), ) if st != nil { if last != nil { q = q.Where(h.readDB.Orders.CreatedAt.Gte(*last)) } q = q.Where(h.readDB.Orders.CreatedAt.Lte(*st)) } cnt, _ := q.Count() reached := (minN == 0) || (cnt >= minN) ctx.Payload(&participantsResponse{IssueID: iss.ID, ActivityID: iss.ActivityID, DrawMode: drawMode, ScheduledTime: scheduled, MinParticipants: minN, Participants: cnt, Reached: reached}) } } type settleIssueRequest struct { Force bool `json:"force"` } type settleIssueResponse struct { IssueID int64 `json:"issue_id"` Status string `json:"status"` Refunded int `json:"refunded"` Granted int `json:"granted"` } func (h *handler) SettleIssue() core.HandlerFunc { return func(ctx core.Context) { if ctx.SessionUserInfo().IsSuper != 1 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "禁止操作")) return } req := new(settleIssueRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } issueIDStr := ctx.Param("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') } } iss, err := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityIssues.ID.Eq(issueID)).First() if err != nil || iss == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170201, "issue not found")) return } cfg := map[string]any{} drawMode := "scheduled" minN := int64(0) refundCouponType := "" refundCouponAmount := 0.0 refundCouponID := int64(0) if m, e := h.syscfg.GetByKey(ctx.RequestContext(), fmt.Sprintf("lottery:activity:%d:scheduled_config", iss.ActivityID)); e == nil && m != nil { _ = json.Unmarshal([]byte(m.ConfigValue), &cfg) if v, ok := cfg["draw_mode"].(string); ok { drawMode = v } if v, ok := cfg["min_participants"].(float64); ok { minN = int64(v) } if v, ok := cfg["refund_coupon_type"].(string); ok { refundCouponType = v } if v, ok := cfg["refund_coupon_amount"].(float64); ok { refundCouponAmount = v } if v, ok := cfg["refund_coupon_id"].(float64); ok { refundCouponID = int64(v) } } remarkLike := fmt.Sprintf("lottery:activity:%d|issue:%d", iss.ActivityID, iss.ID) orders, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().Where( h.readDB.Orders.Status.Eq(2), h.readDB.Orders.SourceType.Eq(2), h.readDB.Orders.Remark.Like("%"+remarkLike+"%"), ).Find() participants := len(orders) refunded := 0 granted := 0 if drawMode == "scheduled" && !req.Force { // 若到时未满员则退款 if minN > 0 && int64(participants) < minN { wc, e := paypkg.NewWechatPayClient(ctx.RequestContext()) if e == nil { for _, o := range orders { refundNo := fmt.Sprintf("R%s-%d", o.OrderNo, time.Now().Unix()) refundID, status, e2 := wc.RefundOrder(ctx.RequestContext(), o.OrderNo, refundNo, o.ActualAmount, o.ActualAmount, "admin_settle_not_enough") if e2 != nil { continue } _ = h.writeDB.PaymentRefunds.WithContext(ctx.RequestContext()).Create(&model.PaymentRefunds{OrderID: o.ID, OrderNo: o.OrderNo, RefundNo: refundNo, Channel: "wechat_jsapi", Status: status, AmountRefund: o.ActualAmount, Reason: "admin_settle_not_enough"}) _ = h.writeDB.UserPointsLedger.WithContext(ctx.RequestContext()).Create(&model.UserPointsLedger{UserID: o.UserID, Action: "refund_amount", Points: o.ActualAmount / 100, RefTable: "payment_refund", RefID: refundID}) _, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.ID.Eq(o.ID)).Updates(map[string]any{h.writeDB.Orders.Status.ColumnName().String(): 4}) // 退款日志与送券 _ = h.repo.GetDbW().Exec("INSERT INTO lottery_refund_logs(issue_id, order_id, user_id, amount, coupon_type, coupon_amount, reason, status) VALUES(?,?,?,?,?,?,?,?)", iss.ID, o.ID, o.UserID, o.ActualAmount, refundCouponType, refundCouponAmount, "admin_settle_not_enough", status).Error if refundCouponID > 0 { _ = usersvc.New(h.logger, h.repo).AddCoupon(ctx.RequestContext(), o.UserID, refundCouponID) } refunded++ } } } else { // 人数达标统一开奖 s := strat.NewDefault(h.readDB, h.writeDB) for _, o := range orders { rid, _, e2 := s.SelectItem(ctx.RequestContext(), iss.ActivityID, iss.ID, o.UserID) if e2 != nil || rid <= 0 { continue } rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() _ = s.GrantReward(ctx.RequestContext(), o.UserID, rid) _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: o.UserID, IssueID: iss.ID, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: func() int32 { if rw != nil { return rw.Level } return 1 }(), CurrentLevel: 1}) granted++ } } } else { // 即时或强制:统一开奖 s := strat.NewDefault(h.readDB, h.writeDB) for _, o := range orders { rid, _, e2 := s.SelectItem(ctx.RequestContext(), iss.ActivityID, iss.ID, o.UserID) if e2 != nil || rid <= 0 { continue } rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() _ = s.GrantReward(ctx.RequestContext(), o.UserID, rid) _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: o.UserID, IssueID: iss.ID, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: func() int32 { if rw != nil { return rw.Level } return 1 }(), CurrentLevel: 1}) granted++ } } ctx.Payload(&settleIssueResponse{IssueID: iss.ID, Status: "success", Refunded: refunded, Granted: granted}) } } type simulateIssueRequest struct { NumUsers int `json:"num_users"` DrawsPerUser int `json:"draws_per_user"` PriceDraw int64 `json:"price_draw"` // 模拟的单次价格(分) } type simulateRewardStat struct { RewardID int64 `json:"reward_id"` Name string `json:"name"` Level int32 `json:"level"` OriginalQty int64 `json:"original_qty"` WonCount int `json:"won_count"` RemainingQty int64 `json:"remaining_qty"` ActualProb float64 `json:"actual_prob"` TheoreticalProb float64 `json:"theoretical_prob"` EffectiveProb float64 `json:"effective_prob"` // 考虑库存后的有效理论概率 Cost int64 `json:"cost"` // 单价 TotalCost int64 `json:"total_cost"` // 总成本 } type simulateIssueResponse struct { TotalDraws int `json:"total_draws"` Rewards []simulateRewardStat `json:"rewards"` TotalSimulationCost int64 `json:"total_simulation_cost"` TotalSimulationRevenue int64 `json:"total_simulation_revenue"` GrossProfit int64 `json:"gross_profit"` GrossProfitRate float64 `json:"gross_profit_rate"` } func (h *handler) SimulateIssue() core.HandlerFunc { return func(ctx core.Context) { if ctx.SessionUserInfo().IsSuper != 1 { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "禁止操作")) return } req := new(simulateIssueRequest) if err := ctx.ShouldBindJSON(req); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } if req.NumUsers <= 0 { req.NumUsers = 1 } if req.DrawsPerUser <= 0 { req.DrawsPerUser = 1 } issueIDStr := ctx.Param("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') } } iss, err := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityIssues.ID.Eq(issueID)).First() if err != nil || iss == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170201, "issue not found")) return } act, err := h.readDB.Activities.WithContext(ctx.RequestContext()).Where(h.readDB.Activities.ID.Eq(iss.ActivityID)).First() if err != nil || act == nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170203, "activity not found")) return } rewards, err := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.IssueID.Eq(issueID)).Find() if err != nil { ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170202, "failed to load rewards")) return } // Load Products for Price productIDs := make([]int64, 0, len(rewards)) for _, r := range rewards { if r.ProductID > 0 { productIDs = append(productIDs, r.ProductID) } } priceMap := make(map[int64]int64) if len(productIDs) > 0 { products, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.In(productIDs...)).Find() for _, p := range products { priceMap[p.ID] = p.Price } } // Setup Simulation type simReward struct { *model.ActivityRewardSettings CurrentQty int64 Won int UnitCost int64 } simRewards := make([]*simReward, len(rewards)) var totalInitialWeight int64 for i, r := range rewards { cost := int64(0) if r.ProductID > 0 { cost = priceMap[r.ProductID] } simRewards[i] = &simReward{ ActivityRewardSettings: r, CurrentQty: r.Quantity, Won: 0, UnitCost: cost, } if r.Quantity > 0 { totalInitialWeight += int64(r.Weight) } } totalDraws := req.NumUsers * req.DrawsPerUser // Use a temporary seed for simulation (IssueID + Time) simSeed := []byte(fmt.Sprintf("%d-%d", issueID, time.Now().UnixNano())) isIchiban := act.PlayType == "ichiban" actualDraws := 0 for i := 0; i < totalDraws; i++ { if isIchiban { // Ichiban Simulation: Shuffle-based selection // Replicate strategy/ichiban.go logic locally // 1. Build available slots type slotItem struct { RewardID int64 Index int } var availableSlots []int64 for _, r := range simRewards { for k := 0; k < int(r.CurrentQty); k++ { availableSlots = append(availableSlots, r.ID) } } totalSlots := len(availableSlots) if totalSlots == 0 { break // Sold out } // 2. Pick a random slot using Hash // Note: Real Ichiban shuffles ALL slots at start. // Here we simulate picking one from remaining slots which is statistically equivalent // if we assume each draw is independent or we just want to simulate the outcome. // HOWEVER, to be "Exactly the same" as user requested, we should ideally simulate the full shuffle if possible. // But full shuffle is stateful per issue. // For simulation of "N draws", we can just pick N items from the available pool randomly. mac := hmac.New(sha256.New, simSeed) mac.Write([]byte(fmt.Sprintf("sim_ichiban:%d", i))) sum := mac.Sum(nil) // Pick index [0, totalSlots) randIdx := int(binary.BigEndian.Uint64(sum) % uint64(totalSlots)) pickedRewardID := availableSlots[randIdx] // 3. Update Inventory for _, r := range simRewards { if r.ID == pickedRewardID { r.CurrentQty-- r.Won++ break } } actualDraws++ } else { // Default Strategy: Weighted Random var currentTotalWeight int64 activeRewards := make([]*simReward, 0, len(simRewards)) for _, r := range simRewards { if r.CurrentQty > 0 { w := int64(r.Weight) currentTotalWeight += w activeRewards = append(activeRewards, r) } } if currentTotalWeight <= 0 { break // Sold out } // HMAC-SHA256 based random for Default Strategy mac := hmac.New(sha256.New, simSeed) mac.Write([]byte(fmt.Sprintf("sim_default:%d", i))) sum := mac.Sum(nil) randVal := binary.BigEndian.Uint64(sum) rVal := int64(randVal % uint64(currentTotalWeight)) var acc int64 for _, r := range activeRewards { w := int64(r.Weight) acc += w if rVal < acc { r.CurrentQty-- r.Won++ break } } actualDraws++ } } // Build Response resp := &simulateIssueResponse{ TotalDraws: actualDraws, Rewards: make([]simulateRewardStat, len(simRewards)), } var totalSimCost int64 var totalOriginalQty int64 if isIchiban { for _, r := range simRewards { totalOriginalQty += int64(r.OriginalQty) } } for i, r := range simRewards { actualProb := 0.0 if actualDraws > 0 { actualProb = float64(r.Won) / float64(actualDraws) } theoProb := 0.0 if isIchiban { if totalOriginalQty > 0 { theoProb = float64(r.OriginalQty) / float64(totalOriginalQty) } } else { // For Weighted Random, if we run until exhaustion (TotalDraws == TotalInventory), // the "Actual" distribution is forced to match Inventory distribution, not Weight distribution. // To avoid confusing users with "Deviation" in this specific scenario, // we can display Inventory-based Probability as Theoretical IF the inventory is fully exhausted. // However, strictly speaking, Theoretical IS Weight-based. // But let's check if TotalInitialWeight makes sense. if totalInitialWeight > 0 { theoProb = float64(r.Weight) / float64(totalInitialWeight) } } rewardTotalCost := int64(r.Won) * r.UnitCost totalSimCost += rewardTotalCost // Calculate Effective Probability: min(TheoreticalProb, InventoryLimitProb) effectiveProb := theoProb if actualDraws > 0 && r.OriginalQty < int64(actualDraws) { // Max possible win rate is OriginalQty / TotalDraws maxWinRate := float64(r.OriginalQty) / float64(actualDraws) // Special case: If inventory is exhausted (or near exhausted), // the effective probability IS the inventory ratio. // If we don't adjust this, users see large deviation. // Let's be smarter: If maxWinRate < effectiveProb, we cap it. // This handles the "Support" case: Theo=15%, Inventory=19% (28/145). // Wait, 19% > 15%. So maxWinRate (19%) is NOT < effectiveProb (15%). // So effectiveProb remains 15%. // But Actual is 19%. Deviation 4%. // Why is Actual > Theo? // Because other items ran out, forcing the algorithm to pick this one more often than its weight suggests. // This is the nature of "Weighted Random with Inventory Limit". // As high-weight items sell out, low-weight items become 100% probability. if maxWinRate < effectiveProb { effectiveProb = maxWinRate } } resp.Rewards[i] = simulateRewardStat{ RewardID: r.ID, Name: r.Name, Level: r.Level, OriginalQty: r.OriginalQty, // Fix: Use OriginalQty from simReward struct which is *ActivityRewardSettings WonCount: r.Won, RemainingQty: r.CurrentQty, ActualProb: actualProb, TheoreticalProb: theoProb, EffectiveProb: effectiveProb, Cost: r.UnitCost, TotalCost: rewardTotalCost, } } resp.TotalSimulationCost = totalSimCost // Use PriceDraw from Request if provided, otherwise use Activity Price simPrice := act.PriceDraw if req.PriceDraw > 0 { simPrice = req.PriceDraw } fmt.Printf("Debug: req.PriceDraw=%d, act.PriceDraw=%d, simPrice=%d, actualDraws=%d\n", req.PriceDraw, act.PriceDraw, simPrice, actualDraws) // Debug log resp.TotalSimulationRevenue = int64(actualDraws) * simPrice resp.GrossProfit = resp.TotalSimulationRevenue - resp.TotalSimulationCost if resp.TotalSimulationRevenue > 0 { resp.GrossProfitRate = float64(resp.GrossProfit) / float64(resp.TotalSimulationRevenue) } ctx.Payload(resp) } }