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

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)
}
}