fix: 修复微信通知字段截断导致的编码错误 feat: 添加有效邀请相关字段和任务中心常量 refactor: 重构一番赏奖品格位逻辑 perf: 优化道具卡列表聚合显示 docs: 更新项目说明文档和API文档 test: 添加字符串截断工具测试
542 lines
18 KiB
Go
542 lines
18 KiB
Go
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
|
|
}
|
|
// Fetch Activity for Seed
|
|
// act, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).Where(h.readDB.Activities.ID.Eq(iss.ActivityID)).First()
|
|
|
|
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, proof, 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)
|
|
drawLog := &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}
|
|
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog)
|
|
// 保存可验证凭据
|
|
_ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog.ID, iss.ID, o.UserID, proof)
|
|
granted++
|
|
}
|
|
}
|
|
} else {
|
|
// 即时或强制:统一开奖
|
|
s := strat.NewDefault(h.readDB, h.writeDB)
|
|
for _, o := range orders {
|
|
rid, proof, 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)
|
|
drawLog := &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}
|
|
_ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog)
|
|
// 保存可验证凭据
|
|
_ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog.ID, iss.ID, o.UserID, proof)
|
|
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)
|
|
}
|
|
}
|